View Javadoc
1   /*
2    * Copyright 2012-2025 CodeLibs Project and the Others.
3    *
4    * Licensed under the Apache License, Version 2.0 (the "License");
5    * you may not use this file except in compliance with the License.
6    * You may obtain a copy of the License at
7    *
8    *     http://www.apache.org/licenses/LICENSE-2.0
9    *
10   * Unless required by applicable law or agreed to in writing, software
11   * distributed under the License is distributed on an "AS IS" BASIS,
12   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
13   * either express or implied. See the License for the specific language
14   * governing permissions and limitations under the License.
15   */
16  package org.codelibs.fess.helper;
17  
18  import java.io.BufferedReader;
19  import java.io.ByteArrayInputStream;
20  import java.io.ByteArrayOutputStream;
21  import java.io.IOException;
22  import java.io.InputStreamReader;
23  import java.io.Reader;
24  import java.io.StringReader;
25  import java.util.Base64;
26  import java.util.HashSet;
27  import java.util.Map;
28  import java.util.Set;
29  import java.util.zip.GZIPInputStream;
30  import java.util.zip.GZIPOutputStream;
31  
32  import org.apache.commons.lang3.StringUtils;
33  import org.apache.logging.log4j.LogManager;
34  import org.apache.logging.log4j.Logger;
35  import org.codelibs.core.io.ReaderUtil;
36  import org.codelibs.core.lang.StringUtil;
37  import org.codelibs.fess.Constants;
38  import org.codelibs.fess.crawler.builder.RequestDataBuilder;
39  import org.codelibs.fess.crawler.client.CrawlerClient;
40  import org.codelibs.fess.crawler.client.CrawlerClientFactory;
41  import org.codelibs.fess.crawler.entity.RequestData;
42  import org.codelibs.fess.crawler.entity.ResponseData;
43  import org.codelibs.fess.crawler.entity.ResultData;
44  import org.codelibs.fess.crawler.exception.ChildUrlsException;
45  import org.codelibs.fess.crawler.exception.CrawlerSystemException;
46  import org.codelibs.fess.crawler.exception.CrawlingAccessException;
47  import org.codelibs.fess.crawler.extractor.Extractor;
48  import org.codelibs.fess.crawler.extractor.impl.TikaExtractor;
49  import org.codelibs.fess.crawler.processor.ResponseProcessor;
50  import org.codelibs.fess.crawler.processor.impl.DefaultResponseProcessor;
51  import org.codelibs.fess.crawler.rule.Rule;
52  import org.codelibs.fess.crawler.rule.RuleManager;
53  import org.codelibs.fess.crawler.serializer.DataSerializer;
54  import org.codelibs.fess.crawler.transformer.Transformer;
55  import org.codelibs.fess.crawler.util.TextUtil;
56  import org.codelibs.fess.mylasta.direction.FessConfig;
57  import org.codelibs.fess.opensearch.config.exentity.CrawlingConfig;
58  import org.codelibs.fess.opensearch.config.exentity.CrawlingConfig.ConfigName;
59  import org.codelibs.fess.opensearch.config.exentity.CrawlingConfig.Param;
60  import org.codelibs.fess.util.ComponentUtil;
61  import org.lastaflute.di.core.SingletonLaContainer;
62  import org.lastaflute.di.core.exception.ComponentNotFoundException;
63  
64  import jakarta.annotation.PostConstruct;
65  
66  /**
67   * Helper class for document processing and manipulation in the Fess search system.
68   * This class provides utilities for processing document content, titles, and digests,
69   * handling text normalization, content extraction, and similar document hash encoding/decoding.
70   * It also manages document processing requests and integrates with the crawler system.
71   *
72   */
73  public class DocumentHelper {
74      private static final Logger logger = LogManager.getLogger(DocumentHelper.class);
75  
76      /** Prefix used for encoded similar document hashes */
77      protected static final String SIMILAR_DOC_HASH_PREFIX = "$";
78  
79      /**
80       * Default constructor for DocumentHelper.
81       * Creates a new document helper instance.
82       */
83      public DocumentHelper() {
84          // Default constructor
85      }
86  
87      /**
88       * Initializes the document helper after construction.
89       * Sets up the TikaExtractor with configuration parameters for text processing.
90       */
91      @PostConstruct
92      public void init() {
93          if (logger.isDebugEnabled()) {
94              logger.debug("Initializing {}", this.getClass().getSimpleName());
95          }
96          try {
97              final TikaExtractor tikaExtractor = ComponentUtil.getComponent("tikaExtractor");
98              if (tikaExtractor != null) {
99                  tikaExtractor.setMaxAlphanumTermSize(getMaxAlphanumTermSize());
100                 tikaExtractor.setMaxSymbolTermSize(getMaxSymbolTermSize());
101                 tikaExtractor.setReplaceDuplication(isDuplicateTermRemoved());
102                 tikaExtractor.setSpaceChars(getSpaceChars());
103             }
104         } catch (final ComponentNotFoundException e) {
105             if (logger.isDebugEnabled()) {
106                 logger.debug("tikaExtractor is not found: {}", e.getMessage().replace('\n', ' '));
107             }
108         } catch (final Exception e) {
109             logger.warn("Failed to initialize TikaExtractor.", e);
110         }
111     }
112 
113     /**
114      * Processes and normalizes a document title.
115      * Applies text normalization using configured space characters and returns
116      * a clean title suitable for indexing.
117      *
118      * @param responseData the response data from crawling (not currently used)
119      * @param title the raw title text to process
120      * @param dataMap additional data map (not currently used)
121      * @return the normalized title, or empty string if title is null
122      */
123     public String getTitle(final ResponseData responseData, final String title, final Map<String, Object> dataMap) {
124         if (title == null) {
125             return StringUtil.EMPTY; // empty
126         }
127 
128         final int[] spaceChars = getSpaceChars();
129         try (final Reader reader = new StringReader(title)) {
130             return TextUtil.normalizeText(reader).initialCapacity(title.length()).spaceChars(spaceChars).execute();
131         } catch (final IOException e) {
132             return StringUtil.EMPTY; // empty
133         }
134     }
135 
136     /**
137      * Processes and normalizes document content.
138      * Applies text normalization including duplicate term removal, size limits,
139      * and space character handling. May preserve original content based on configuration.
140      *
141      * @param crawlingConfig the crawling configuration containing processing parameters
142      * @param responseData the response data from crawling
143      * @param content the raw content text to process
144      * @param dataMap additional data map
145      * @return the normalized content, or empty string if content is null
146      */
147     public String getContent(final CrawlingConfig crawlingConfig, final ResponseData responseData, final String content,
148             final Map<String, Object> dataMap) {
149         if (content == null) {
150             return StringUtil.EMPTY; // empty
151         }
152 
153         if (crawlingConfig != null) {
154             final Map<String, String> configParam = crawlingConfig.getConfigParameterMap(ConfigName.CONFIG);
155             if (configParam != null && Constants.TRUE.equalsIgnoreCase(configParam.get(Param.Config.KEEP_ORIGINAL_BODY))) {
156                 return content;
157             }
158         }
159 
160         if (responseData.getMetaDataMap().get(Extractor.class.getSimpleName()) instanceof TikaExtractor) {
161             return content;
162         }
163 
164         final int maxAlphanumTermSize = getMaxAlphanumTermSize();
165         final int maxSymbolTermSize = getMaxSymbolTermSize();
166         final boolean duplicateTermRemoved = isDuplicateTermRemoved();
167         final int[] spaceChars = getSpaceChars();
168         try (final Reader reader = new StringReader(content)) {
169             return TextUtil.normalizeText(reader)
170                     .initialCapacity(content.length())
171                     .maxAlphanumTermSize(maxAlphanumTermSize)
172                     .maxSymbolTermSize(maxSymbolTermSize)
173                     .duplicateTermRemoved(duplicateTermRemoved)
174                     .spaceChars(spaceChars)
175                     .execute();
176         } catch (final IOException e) {
177             return StringUtil.EMPTY; // empty
178         }
179     }
180 
181     /**
182      * Gets the maximum size for alphanumeric terms from configuration.
183      *
184      * @return the maximum alphanumeric term size
185      */
186     protected int getMaxAlphanumTermSize() {
187         final FessConfig fessConfig = ComponentUtil.getFessConfig();
188         return fessConfig.getCrawlerDocumentMaxAlphanumTermSizeAsInteger();
189     }
190 
191     /**
192      * Gets the maximum size for symbol terms from configuration.
193      *
194      * @return the maximum symbol term size
195      */
196     protected int getMaxSymbolTermSize() {
197         final FessConfig fessConfig = ComponentUtil.getFessConfig();
198         return fessConfig.getCrawlerDocumentMaxSymbolTermSizeAsInteger();
199     }
200 
201     /**
202      * Checks if duplicate term removal is enabled in configuration.
203      *
204      * @return true if duplicate terms should be removed, false otherwise
205      */
206     protected boolean isDuplicateTermRemoved() {
207         final FessConfig fessConfig = ComponentUtil.getFessConfig();
208         return fessConfig.isCrawlerDocumentDuplicateTermRemoved();
209     }
210 
211     /**
212      * Gets the array of space character codes from configuration.
213      *
214      * @return array of character codes to be treated as spaces
215      */
216     protected int[] getSpaceChars() {
217         final FessConfig fessConfig = ComponentUtil.getFessConfig();
218         return fessConfig.getCrawlerDocumentSpaceCharsAsArray();
219     }
220 
221     /**
222      * Creates a digest (abbreviated summary) of document content.
223      * Truncates and normalizes content to create a summary suitable for display.
224      *
225      * @param responseData the response data from crawling (not currently used)
226      * @param content the content to create a digest from
227      * @param dataMap additional data map (not currently used)
228      * @param maxWidth the maximum width of the digest
229      * @return the abbreviated and normalized digest, or empty string if content is null
230      */
231     public String getDigest(final ResponseData responseData, final String content, final Map<String, Object> dataMap, final int maxWidth) {
232         if (content == null) {
233             return StringUtil.EMPTY; // empty
234         }
235 
236         String subContent;
237         if (content.length() < maxWidth * 2) {
238             subContent = content;
239         } else {
240             subContent = content.substring(0, maxWidth * 2);
241         }
242 
243         final int[] spaceChars = getSpaceChars();
244         try (final Reader reader = new StringReader(subContent)) {
245             final String originalStr = TextUtil.normalizeText(reader).initialCapacity(content.length()).spaceChars(spaceChars).execute();
246             return StringUtils.abbreviate(originalStr, maxWidth);
247         } catch (final IOException e) {
248             return StringUtil.EMPTY; // empty
249         }
250     }
251 
252     /**
253      * Processes a crawling request for a specific URL.
254      * Executes the full crawling pipeline including client execution, rule processing,
255      * transformation, and data extraction.
256      *
257      * @param crawlingConfig the crawling configuration to use
258      * @param crawlingInfoId the crawling session ID
259      * @param url the URL to process
260      * @return a map containing the processed document data
261      * @throws CrawlingAccessException if crawling fails or configuration is invalid
262      * @throws ChildUrlsException if the URL redirects to another location
263      * @throws CrawlerSystemException if data deserialization fails
264      */
265     public Map<String, Object> processRequest(final CrawlingConfig crawlingConfig, final String crawlingInfoId, final String url) {
266         if (StringUtil.isBlank(crawlingInfoId)) {
267             throw new CrawlingAccessException("sessionId is null. Cannot access document without a valid session ID.");
268         }
269 
270         final CrawlerClientFactory crawlerClientFactory = crawlingConfig.initializeClientFactory(ComponentUtil::getCrawlerClientFactory);
271         final CrawlerClient client = crawlerClientFactory.getClient(url);
272         if (client == null) {
273             throw new CrawlingAccessException(
274                     "CrawlerClient is null for URL: " + url + ". Unable to access the document without a crawler client.");
275         }
276 
277         final SystemHelper systemHelper = ComponentUtil.getSystemHelper();
278         final long startTime = systemHelper.getCurrentTimeAsLong();
279         try (final ResponseData responseData = client.execute(RequestDataBuilder.newRequestData().get().url(url).build())) {
280             if (responseData.getRedirectLocation() != null) {
281                 final Set<RequestData> childUrlList = new HashSet<>();
282                 childUrlList.add(RequestDataBuilder.newRequestData().get().url(responseData.getRedirectLocation()).build());
283                 throw new ChildUrlsException(childUrlList, this.getClass().getName() + "#RedirectedFrom:" + url);
284             }
285             responseData.setExecutionTime(systemHelper.getCurrentTimeAsLong() - startTime);
286             responseData.setSessionId(crawlingInfoId);
287 
288             final RuleManager ruleManager = SingletonLaContainer.getComponent(RuleManager.class);
289             final Rule rule = ruleManager.getRule(responseData);
290             if (rule == null) {
291                 throw new CrawlingAccessException("No url rule for " + url);
292             }
293             responseData.setRuleId(rule.getRuleId());
294             final ResponseProcessor responseProcessor = rule.getResponseProcessor();
295             if (!(responseProcessor instanceof DefaultResponseProcessor)) {
296                 throw new CrawlingAccessException("The response processor is not DefaultResponseProcessor. responseProcessor: "
297                         + responseProcessor + ", url: " + url);
298             }
299             final Transformer transformer = ((DefaultResponseProcessor) responseProcessor).getTransformer();
300             final ResultData resultData = transformer.transform(responseData);
301             final Object rawData = resultData.getRawData();
302             if (rawData != null) {
303                 @SuppressWarnings("unchecked")
304                 final Map<String, Object> responseDataMap = (Map<String, Object>) rawData;
305                 return responseDataMap;
306             } else {
307                 final byte[] data = resultData.getData();
308                 if (data != null) {
309                     try {
310                         final DataSerializer dataSerializer = ComponentUtil.getComponent("dataSerializer");
311                         @SuppressWarnings("unchecked")
312                         final Map<String, Object> responseDataMap = (Map<String, Object>) dataSerializer.fromBinaryToObject(data);
313                         return responseDataMap;
314                     } catch (final Exception e) {
315                         throw new CrawlerSystemException("Could not create an instance from bytes.", e);
316                     }
317                 }
318             }
319             return null;
320         } catch (final Exception e) {
321             throw new CrawlingAccessException("Failed to parse " + url, e);
322         }
323     }
324 
325     /**
326      * Decodes a similar document hash from its compressed and encoded form.
327      * Reverses the encoding process applied by encodeSimilarDocHash.
328      *
329      * @param hash the encoded hash string to decode
330      * @return the decoded hash string, or the original hash if decoding fails
331      */
332     public String decodeSimilarDocHash(final String hash) {
333         if (hash != null && hash.startsWith(SIMILAR_DOC_HASH_PREFIX) && hash.length() > SIMILAR_DOC_HASH_PREFIX.length()) {
334             try (BufferedReader reader = new BufferedReader(new InputStreamReader(
335                     new GZIPInputStream(
336                             new ByteArrayInputStream(Base64.getUrlDecoder().decode(hash.substring(SIMILAR_DOC_HASH_PREFIX.length())))),
337                     Constants.UTF_8))) {
338                 return ReaderUtil.readText(reader);
339             } catch (final Exception e) {
340                 if (logger.isDebugEnabled()) {
341                     logger.debug("Failed to decode similar document hash: hash={}", hash, e);
342                 }
343             }
344         }
345         return hash;
346     }
347 
348     /**
349      * Encodes a similar document hash using GZIP compression and Base64 encoding.
350      * This reduces storage space for hash values while maintaining uniqueness.
351      *
352      * @param hash the hash string to encode
353      * @return the encoded hash string with prefix, or the original hash if encoding fails
354      */
355     public String encodeSimilarDocHash(final String hash) {
356         if (hash != null && !hash.startsWith(SIMILAR_DOC_HASH_PREFIX)) {
357             try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
358                 try (GZIPOutputStream gos = new GZIPOutputStream(baos)) {
359                     gos.write(hash.getBytes(Constants.UTF_8));
360                 }
361                 return SIMILAR_DOC_HASH_PREFIX + Base64.getUrlEncoder().withoutPadding().encodeToString(baos.toByteArray());
362             } catch (final IOException e) {
363                 logger.warn("Failed to encode similar document hash: hash={}", hash, e);
364             }
365         }
366         return hash;
367     }
368 
369     /**
370      * Appends line numbers to each line of content with a given prefix.
371      * Useful for debugging and displaying content with line references.
372      *
373      * @param prefix the prefix to add before each line number
374      * @param content the content to add line numbers to
375      * @return the content with line numbers prepended, or empty string if content is blank
376      */
377     public String appendLineNumber(final String prefix, final String content) {
378         if (StringUtil.isBlank(content)) {
379             return StringUtil.EMPTY;
380         }
381         final String[] values = content.split("\n");
382         final StringBuilder buf = new StringBuilder((int) (content.length() * 1.3));
383         buf.append(prefix).append(1).append(':').append(values[0]);
384         for (int i = 1; i < values.length; i++) {
385             buf.append('\n').append(prefix).append(i + 1).append(':').append(values[i]);
386         }
387         return buf.toString();
388     }
389 }