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.crawler.transformer;
17  
18  import java.lang.reflect.Array;
19  import java.net.URLDecoder;
20  import java.util.Arrays;
21  import java.util.Collection;
22  import java.util.Collections;
23  import java.util.HashMap;
24  import java.util.LinkedHashMap;
25  import java.util.Map;
26  
27  import org.apache.commons.lang3.StringUtils;
28  import org.apache.logging.log4j.Logger;
29  import org.codelibs.core.collection.LruHashMap;
30  import org.codelibs.core.lang.StringUtil;
31  import org.codelibs.fess.Constants;
32  import org.codelibs.fess.crawler.entity.AccessResult;
33  import org.codelibs.fess.crawler.entity.AccessResultData;
34  import org.codelibs.fess.crawler.entity.UrlQueue;
35  import org.codelibs.fess.crawler.util.CrawlingParameterUtil;
36  import org.codelibs.fess.crawler.util.FieldConfigs;
37  import org.codelibs.fess.mylasta.direction.FessConfig;
38  import org.codelibs.fess.util.ComponentUtil;
39  
40  /**
41   * Interface for transforming and processing crawled documents in Fess.
42   * Provides utility methods for URL processing, site extraction, data mapping,
43   * and field configuration handling during the document transformation process.
44   */
45  public interface FessTransformer {
46  
47      /**
48       * Synchronized LRU cache for storing parent URL encodings.
49       * Maps session+parent URL keys to their corresponding character encodings.
50       */
51      Map<String, String> parentEncodingMap = Collections.synchronizedMap(new LruHashMap<>(1000));
52  
53      /**
54       * Gets the Fess configuration instance.
55       *
56       * @return the Fess configuration object
57       */
58      FessConfig getFessConfig();
59  
60      /**
61       * Gets the logger instance for this transformer.
62       *
63       * @return the logger instance
64       */
65      Logger getLogger();
66  
67      /**
68       * Extracts the host name from a URL string.
69       * Removes protocol and path components to return just the hostname.
70       *
71       * @param u the URL string to extract host from
72       * @return the host name, or empty string if URL is blank, or unknown hostname if parsing fails
73       */
74      default String getHost(final String u) {
75          if (StringUtil.isBlank(u)) {
76              return StringUtil.EMPTY; // empty
77          }
78  
79          String url = u;
80          final String originalUrl = url;
81  
82          int idx = url.indexOf("://");
83          if (idx >= 0) {
84              url = url.substring(idx + 3);
85          }
86  
87          idx = url.indexOf('/');
88          if (idx >= 0) {
89              url = url.substring(0, idx);
90          }
91  
92          if (url.equals(originalUrl)) {
93              return getFessConfig().getCrawlerDocumentUnknownHostname();
94          }
95  
96          return url;
97      }
98  
99      /**
100      * Extracts and processes the site path from a URL with proper encoding handling.
101      * Removes protocol, query parameters, and applies URL decoding based on encoding settings.
102      *
103      * @param u the URL string to process
104      * @param encoding the character encoding to use for URL decoding
105      * @return the processed site path, abbreviated if necessary
106      */
107     default String getSite(final String u, final String encoding) {
108         if (StringUtil.isBlank(u)) {
109             return StringUtil.EMPTY; // empty
110         }
111 
112         String url = u;
113         int idx = url.indexOf("://");
114         if (idx >= 0) {
115             url = url.substring(idx + 3);
116         }
117 
118         idx = url.indexOf('?');
119         if (idx >= 0) {
120             url = url.substring(0, idx);
121         }
122 
123         if (encoding != null) {
124             String enc;
125             if (StringUtil.isNotBlank(getFessConfig().getCrawlerDocumentSiteEncoding())
126                     && (!getFessConfig().isCrawlerDocumentUseSiteEncodingOnEnglish() || "ISO-8859-1".equalsIgnoreCase(encoding)
127                             || "US-ASCII".equalsIgnoreCase(encoding))) {
128                 enc = getFessConfig().getCrawlerDocumentSiteEncoding();
129             } else {
130                 enc = encoding;
131             }
132 
133             try {
134                 url = URLDecoder.decode(url, enc);
135             } catch (final Exception e) {
136                 // Failed to decode URL, using original URL as-is
137                 if (getLogger().isDebugEnabled()) {
138                     getLogger().debug("Failed to decode URL with encoding {}: {}", enc, url, e);
139                 }
140             }
141         }
142 
143         return abbreviateSite(url);
144     }
145 
146     /**
147      * Puts data into the result data map, handling value appending if configured.
148      * If data appending is enabled and the key already exists, values are combined into arrays.
149      *
150      * @param dataMap the data map to modify
151      * @param key the key to store the value under
152      * @param value the value to store
153      */
154     default void putResultDataBody(final Map<String, Object> dataMap, final String key, final Object value) {
155         final FessConfig fessConfig = ComponentUtil.getFessConfig();
156         if (fessConfig.getIndexFieldUrl().equals(key) || !dataMap.containsKey(key) || !getFessConfig().isCrawlerDocumentAppendData()) {
157             dataMap.put(key, value);
158         } else {
159             final Object oldValue = dataMap.get(key);
160             final Object[] oldValues;
161             if (oldValue instanceof Object[]) {
162                 oldValues = (Object[]) oldValue;
163             } else if (oldValue instanceof Collection<?>) {
164                 oldValues = ((Collection<?>) oldValue).toArray();
165             } else {
166                 oldValues = new Object[] { oldValue };
167             }
168             if (value.getClass().isArray()) {
169                 // Handle both Object[] and primitive arrays safely
170                 final int newLength = Array.getLength(value);
171                 final Object[] values = Arrays.copyOf(oldValues, oldValues.length + newLength);
172                 for (int i = 0; i < newLength; i++) {
173                     values[oldValues.length + i] = Array.get(value, i);
174                 }
175                 dataMap.put(key, values);
176             } else {
177                 final Object[] values = Arrays.copyOf(oldValues, oldValues.length + 1);
178                 values[values.length - 1] = value;
179                 dataMap.put(key, values);
180             }
181         }
182     }
183 
184     /**
185      * Puts data into the result data map after processing it through a template script.
186      * The template is evaluated using the specified script engine with the value and context.
187      *
188      * @param dataMap the data map to modify
189      * @param key the key to store the processed value under
190      * @param value the original value to process
191      * @param template the template script to evaluate
192      * @param scriptType the type of script engine to use
193      */
194     default void putResultDataWithTemplate(final Map<String, Object> dataMap, final String key, final Object value, final String template,
195             final String scriptType) {
196         Object target = value;
197         if (template != null) {
198             final Map<String, Object> contextMap = new HashMap<>();
199             contextMap.put("doc", dataMap);
200             final Map<String, Object> paramMap = new HashMap<>(dataMap.size() + 2);
201             paramMap.putAll(dataMap);
202             paramMap.put("value", target);
203             paramMap.put("context", contextMap);
204             target = evaluateValue(scriptType, template, paramMap);
205         }
206         if (key != null && target != null) {
207             putResultDataBody(dataMap, key, target);
208         }
209     }
210 
211     /**
212      * Evaluates a template script using the specified script engine and parameters.
213      *
214      * @param scriptType the type of script engine to use
215      * @param template the template script to evaluate
216      * @param paramMap the parameters to pass to the script
217      * @return the result of script evaluation, or empty string if template is empty
218      */
219     default Object evaluateValue(final String scriptType, final String template, final Map<String, Object> paramMap) {
220         if (StringUtil.isEmpty(template)) {
221             return StringUtil.EMPTY;
222         }
223 
224         return ComponentUtil.getScriptEngineFactory().getScriptEngine(scriptType).evaluate(template, paramMap);
225     }
226 
227     /**
228      * Gets the maximum allowed length for site strings from configuration.
229      *
230      * @return the maximum site length as configured
231      */
232     default int getMaxSiteLength() {
233         return getFessConfig().getCrawlerDocumentMaxSiteLengthAsInteger();
234     }
235 
236     /**
237      * Abbreviates a site string to the maximum allowed length if configured.
238      *
239      * @param value the site string to abbreviate
240      * @return the abbreviated string, or original if no length limit is set
241      */
242     default String abbreviateSite(final String value) {
243         final int maxSiteLength = getMaxSiteLength();
244         if (maxSiteLength > -1) {
245             return StringUtils.abbreviate(value, maxSiteLength);
246         }
247         return value;
248     }
249 
250     /**
251      * Extracts the filename from a URL, handling various protocols and URL decoding.
252      * Processes HTTP, HTTPS, file, SMB, and FTP URLs appropriately.
253      *
254      * @param url the URL to extract filename from
255      * @param encoding the character encoding (currently unused in this method)
256      * @return the extracted filename, or empty string if none found
257      */
258     default String getFileName(final String url, final String encoding) {
259         if (StringUtil.isBlank(url)) {
260             return StringUtil.EMPTY;
261         }
262 
263         int idx = 0;
264         String u = url;
265         if (u.startsWith("https:") || u.startsWith("http:")) {
266             idx = u.lastIndexOf('?');
267             if (idx >= 0) {
268                 u = u.substring(0, idx);
269             }
270 
271             idx = u.lastIndexOf('#');
272             if (idx >= 0) {
273                 u = u.substring(0, idx);
274             }
275         }
276         if (!ComponentUtil.getProtocolHelper().shouldSkipUrlDecode(u)) {
277             u = decodeUrlAsName(u, u.startsWith("file:"));
278         }
279         idx = u.lastIndexOf('/');
280         if (idx >= 0) {
281             if (u.length() > idx + 1) {
282                 u = u.substring(idx + 1);
283             } else {
284                 u = StringUtil.EMPTY;
285             }
286         }
287         return u;
288     }
289 
290     /**
291      * Decodes a URL as a name using appropriate character encoding.
292      * Handles encoding detection from parent URLs and configuration settings.
293      *
294      * @param url the URL to decode
295      * @param escapePlus whether to escape plus signs before decoding
296      * @return the decoded URL name, or original URL if decoding fails
297      */
298     default String decodeUrlAsName(final String url, final boolean escapePlus) {
299         if (url == null) {
300             return null;
301         }
302 
303         final FessConfig fessConfig = getFessConfig();
304         String enc = Constants.UTF_8;
305         if (StringUtil.isBlank(fessConfig.getCrawlerDocumentFileNameEncoding())) {
306             final UrlQueue<?> urlQueue = CrawlingParameterUtil.getUrlQueue();
307             if (urlQueue != null) {
308                 final String parentUrl = urlQueue.getParentUrl();
309                 if (StringUtil.isNotEmpty(parentUrl)) {
310                     final String sessionId = urlQueue.getSessionId();
311                     final String pageEnc = getParentEncoding(parentUrl, sessionId);
312                     if (pageEnc != null) {
313                         enc = pageEnc;
314                     } else if (urlQueue.getEncoding() != null) {
315                         enc = urlQueue.getEncoding();
316                     }
317                 }
318             }
319         } else {
320             enc = fessConfig.getCrawlerDocumentFileNameEncoding();
321         }
322 
323         final String escapedUrl = escapePlus ? url.replace("+", "%2B") : url;
324         try {
325             return URLDecoder.decode(escapedUrl, enc);
326         } catch (final Exception e) {
327             return url;
328         }
329     }
330 
331     /**
332      * Gets the character encoding for a parent URL from cache or data service.
333      * Caches encoding information to improve performance on subsequent requests.
334      *
335      * @param parentUrl the parent URL to get encoding for
336      * @param sessionId the session ID for the crawling session
337      * @return the character encoding, or null if not found
338      */
339     default String getParentEncoding(final String parentUrl, final String sessionId) {
340         final String key = sessionId + ":" + parentUrl;
341         String enc = parentEncodingMap.get(key);
342         if (enc != null) {
343             return enc;
344         }
345 
346         final AccessResult<?> accessResult = ComponentUtil.getDataService().getAccessResult(sessionId, parentUrl);
347         if (accessResult != null) {
348             final AccessResultData<?> accessResultData = accessResult.getAccessResultData();
349             if (accessResultData != null && accessResultData.getEncoding() != null) {
350                 enc = accessResultData.getEncoding();
351                 parentEncodingMap.put(key, enc);
352                 return enc;
353             }
354         }
355         return null;
356     }
357 
358     /**
359      * Processes field configurations to handle field overwriting rules.
360      * Creates a new data map with fields processed according to their configuration.
361      *
362      * @param dataMap the original data map to process
363      * @param fieldConfigs the field configurations to apply
364      * @return a new data map with configurations applied
365      */
366     default Map<String, Object> processFieldConfigs(final Map<String, Object> dataMap, final FieldConfigs fieldConfigs) {
367         final Map<String, Object> newDataMap = new LinkedHashMap<>();
368         for (final Map.Entry<String, Object> e : dataMap.entrySet()) {
369             if (fieldConfigs.getConfig(e.getKey()).map(FieldConfigs.Config::isOverwrite).orElse(false)
370                     && e.getValue() instanceof final Object[] values && values.length > 0) {
371                 newDataMap.put(e.getKey(), values[values.length - 1]);
372             } else {
373                 newDataMap.put(e.getKey(), e.getValue());
374             }
375         }
376         return newDataMap;
377     }
378 
379 }