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;
17  
18  import static org.codelibs.core.stream.StreamUtil.split;
19  import static org.codelibs.core.stream.StreamUtil.stream;
20  
21  import java.util.ArrayList;
22  import java.util.Collections;
23  import java.util.Date;
24  import java.util.HashMap;
25  import java.util.HashSet;
26  import java.util.LinkedHashSet;
27  import java.util.List;
28  import java.util.Map;
29  import java.util.Set;
30  import java.util.concurrent.ConcurrentHashMap;
31  import java.util.regex.Pattern;
32  import java.util.stream.Collectors;
33  
34  import org.apache.logging.log4j.LogManager;
35  import org.apache.logging.log4j.Logger;
36  import org.codelibs.core.io.CloseableUtil;
37  import org.codelibs.core.lang.StringUtil;
38  import org.codelibs.core.misc.Pair;
39  import org.codelibs.fess.app.service.FailureUrlService;
40  import org.codelibs.fess.crawler.builder.RequestDataBuilder;
41  import org.codelibs.fess.crawler.client.CrawlerClient;
42  import org.codelibs.fess.crawler.entity.RequestData;
43  import org.codelibs.fess.crawler.entity.ResponseData;
44  import org.codelibs.fess.crawler.entity.UrlQueue;
45  import org.codelibs.fess.crawler.log.LogType;
46  import org.codelibs.fess.exception.ContainerNotAvailableException;
47  import org.codelibs.fess.exception.ContentNotFoundException;
48  import org.codelibs.fess.helper.CrawlingConfigHelper;
49  import org.codelibs.fess.helper.CrawlingInfoHelper;
50  import org.codelibs.fess.helper.DuplicateHostHelper;
51  import org.codelibs.fess.helper.IndexingHelper;
52  import org.codelibs.fess.helper.PermissionHelper;
53  import org.codelibs.fess.helper.SystemHelper;
54  import org.codelibs.fess.mylasta.direction.FessConfig;
55  import org.codelibs.fess.opensearch.client.SearchEngineClient;
56  import org.codelibs.fess.opensearch.config.exentity.CrawlingConfig;
57  import org.codelibs.fess.opensearch.config.exentity.CrawlingConfig.ConfigName;
58  import org.codelibs.fess.util.ComponentUtil;
59  import org.codelibs.fess.util.DocumentUtil;
60  
61  /**
62   * FessCrawlerThread is a specialized crawler thread implementation for the Fess search engine.
63   * This class extends the base CrawlerThread and provides Fess-specific functionality for
64   * crawling and indexing documents, including incremental crawling capabilities, content
65   * modification checking, and integration with the Fess search engine backend.
66   *
67   * <p>Key features include:</p>
68   * <ul>
69   * <li>Incremental crawling support with last-modified timestamp checking</li>
70   * <li>Document expiration handling</li>
71   * <li>Child URL extraction and queueing</li>
72   * <li>Integration with Fess configuration and permission systems</li>
73   * <li>Client selection based on URL patterns</li>
74   * </ul>
75   *
76   * @see CrawlerThread
77   * @see org.codelibs.fess.crawler.client.CrawlerClient
78   */
79  public class FessCrawlerThread extends CrawlerThread {
80  
81      /**
82       * Default constructor.
83       */
84      public FessCrawlerThread() {
85          super();
86      }
87  
88      private static final Logger logger = LogManager.getLogger(FessCrawlerThread.class);
89  
90      /** Configuration key for crawler clients used in parameter maps */
91      protected static final String CRAWLER_CLIENTS = "crawlerClients";
92  
93      /** HTTP status code for Not Found */
94      private static final int HTTP_STATUS_NOT_FOUND = 404;
95  
96      /** HTTP status code for OK */
97      private static final int HTTP_STATUS_OK = 200;
98  
99      /**
100      * Cache for client rules mapping client names to their corresponding URL patterns.
101      * This cache improves performance by avoiding repeated parsing of client configuration rules.
102      * The key is the rule string, and the value is a pair containing the client name and compiled pattern.
103      */
104     protected ConcurrentHashMap<String, Pair<String, Pattern>> clientRuleCache = new ConcurrentHashMap<>();
105 
106     /**
107      * Determines whether the content at the given URL has been updated since the last crawl.
108      * This method implements incremental crawling by comparing timestamps and checking document
109      * expiration. It also handles special cases for different URL schemes (SMB, file, FTP).
110      *
111      * @param client the crawler client to use for accessing the URL
112      * @param urlQueue the URL queue item containing the URL to check
113      * @return true if the content has been updated and should be crawled, false otherwise
114      */
115     @Override
116     protected boolean isContentUpdated(final CrawlerClient client, final UrlQueue<?> urlQueue) {
117         if (ComponentUtil.getFessConfig().isIncrementalCrawling()) {
118 
119             final SystemHelper systemHelper = ComponentUtil.getSystemHelper();
120             final long startTime = systemHelper.getCurrentTimeAsLong();
121 
122             final FessConfig fessConfig = ComponentUtil.getFessConfig();
123             final CrawlingConfigHelper crawlingConfigHelper = ComponentUtil.getCrawlingConfigHelper();
124             final CrawlingInfoHelper crawlingInfoHelper = ComponentUtil.getCrawlingInfoHelper();
125             final IndexingHelper indexingHelper = ComponentUtil.getIndexingHelper();
126             final SearchEngineClient searchEngineClient = ComponentUtil.getSearchEngineClient();
127 
128             final String url = urlQueue.getUrl();
129             ResponseData responseData = null;
130             try {
131                 final CrawlingConfig crawlingConfig = crawlingConfigHelper.get(crawlerContext.getSessionId());
132                 final Map<String, Object> dataMap = new HashMap<>();
133                 dataMap.put(fessConfig.getIndexFieldUrl(), url);
134                 final List<String> roleTypeList = new ArrayList<>();
135                 stream(crawlingConfig.getPermissions()).of(stream -> stream.forEach(p -> roleTypeList.add(p)));
136                 if (ComponentUtil.getProtocolHelper().isFilePathProtocol(url)) {
137                     if (url.endsWith("/")) {
138                         // directory
139                         return true;
140                     }
141                     final PermissionHelper permissionHelper = ComponentUtil.getPermissionHelper();
142                     if (fessConfig.isSmbRoleFromFile() || fessConfig.isFileRoleFromFile() || fessConfig.isFtpRoleFromFile()) {
143                         // head method
144                         responseData =
145                                 client.execute(RequestDataBuilder.newRequestData().head().url(url).weight(urlQueue.getWeight()).build());
146                         if (responseData == null) {
147                             return true;
148                         }
149 
150                         roleTypeList.addAll(permissionHelper.getSmbRoleTypeList(responseData));
151                         roleTypeList.addAll(permissionHelper.getFileRoleTypeList(responseData));
152                         roleTypeList.addAll(permissionHelper.getFtpRoleTypeList(responseData));
153                     }
154                 }
155                 dataMap.put(fessConfig.getIndexFieldRole(), roleTypeList);
156                 final String id = crawlingInfoHelper.generateId(dataMap);
157 
158                 if (logger.isDebugEnabled()) {
159                     logger.debug("Searching indexed document: {}", id);
160                 }
161                 final Map<String, Object> document = indexingHelper.getDocument(searchEngineClient, id,
162                         new String[] { fessConfig.getIndexFieldId(), fessConfig.getIndexFieldLastModified(),
163                                 fessConfig.getIndexFieldAnchor(), fessConfig.getIndexFieldSegment(), fessConfig.getIndexFieldExpires(),
164                                 fessConfig.getIndexFieldClickCount(), fessConfig.getIndexFieldFavoriteCount() });
165                 if (document == null) {
166                     storeChildUrlsToQueue(urlQueue, getChildUrlSet(searchEngineClient, id));
167                     return true;
168                 }
169 
170                 final Date expires = DocumentUtil.getValue(document, fessConfig.getIndexFieldExpires(), Date.class);
171                 if (expires != null && expires.getTime() < systemHelper.getCurrentTimeAsLong()) {
172                     final Object idValue = document.get(fessConfig.getIndexFieldId());
173                     if (idValue != null && !indexingHelper.deleteDocument(searchEngineClient, idValue.toString())) {
174                         logger.debug("Failed to delete expired document: {}", url);
175                     }
176                     return true;
177                 }
178 
179                 final Date lastModified = DocumentUtil.getValue(document, fessConfig.getIndexFieldLastModified(), Date.class);
180                 if (lastModified == null) {
181                     return true;
182                 }
183                 urlQueue.setLastModified(lastModified.getTime());
184                 log(logHelper, LogType.CHECK_LAST_MODIFIED, crawlerContext, urlQueue);
185 
186                 if (responseData == null) {
187                     // head method
188                     responseData = client.execute(RequestDataBuilder.newRequestData().head().url(url).build());
189                     if (responseData == null) {
190                         return true;
191                     }
192                 }
193 
194                 final int httpStatusCode = responseData.getHttpStatusCode();
195                 if (logger.isDebugEnabled()) {
196                     logger.debug("Accessing document: url={}, status={}", url, httpStatusCode);
197                 }
198                 if (httpStatusCode == HTTP_STATUS_NOT_FOUND) {
199                     storeChildUrlsToQueue(urlQueue, getAnchorSet(document.get(fessConfig.getIndexFieldAnchor())));
200                     if (!indexingHelper.deleteDocument(searchEngineClient, id)) {
201                         logger.debug("Failed to delete document: status={}, url={}", HTTP_STATUS_NOT_FOUND, url);
202                     }
203                     return false;
204                 }
205                 if (responseData.getLastModified() == null) {
206                     return true;
207                 }
208                 if (responseData.getLastModified().getTime() <= lastModified.getTime() && httpStatusCode == HTTP_STATUS_OK) {
209 
210                     log(logHelper, LogType.NOT_MODIFIED, crawlerContext, urlQueue);
211 
212                     responseData.setExecutionTime(systemHelper.getCurrentTimeAsLong() - startTime);
213                     responseData.setParentUrl(urlQueue.getParentUrl());
214                     responseData.setSessionId(crawlerContext.getSessionId());
215                     responseData.setHttpStatusCode(org.codelibs.fess.crawler.Constants.NOT_MODIFIED_STATUS);
216                     processResponse(urlQueue, responseData);
217 
218                     storeChildUrlsToQueue(urlQueue, getAnchorSet(document.get(fessConfig.getIndexFieldAnchor())));
219 
220                     final Date documentExpires = crawlingInfoHelper.getDocumentExpires(crawlingConfig);
221                     if (documentExpires != null
222                             && !indexingHelper.updateDocument(searchEngineClient, id, fessConfig.getIndexFieldExpires(), documentExpires)) {
223                         logger.debug("Failed to update field: field={}, url={}", fessConfig.getIndexFieldExpires(), url);
224                     }
225 
226                     return false;
227                 }
228             } finally {
229                 if (responseData != null) {
230                     CloseableUtil.closeQuietly(responseData);
231                 }
232             }
233         }
234         return true;
235     }
236 
237     /**
238      * Stores child URLs from the given set into the crawling queue for future processing.
239      * This method filters out blank URLs and increments the depth for child URLs.
240      *
241      * @param urlQueue the parent URL queue item
242      * @param childUrlSet the set of child URLs to be queued for crawling
243      */
244     protected void storeChildUrlsToQueue(final UrlQueue<?> urlQueue, final Set<RequestData> childUrlSet) {
245         if (childUrlSet != null) {
246             // add an url
247             try {
248                 storeChildUrls(childUrlSet.stream().filter(rd -> StringUtil.isNotBlank(rd.getUrl())).collect(Collectors.toSet()),
249                         urlQueue.getUrl(), urlQueue.getDepth() != null ? urlQueue.getDepth() + 1 : 1);
250             } catch (final Throwable t) {
251                 if (!ComponentUtil.available()) {
252                     throw new ContainerNotAvailableException(t);
253                 }
254                 throw t;
255             }
256         }
257     }
258 
259     /**
260      * Extracts anchor URLs from the given object and converts them to RequestData objects.
261      * The input object can be either a single string or a list of strings representing URLs.
262      *
263      * @param obj the object containing anchor URLs (String or List of Strings)
264      * @return a set of RequestData objects for the anchor URLs, or null if no valid URLs found
265      */
266     protected Set<RequestData> getAnchorSet(final Object obj) {
267         if (obj == null) {
268             return null;
269         }
270 
271         List<String> anchorList;
272         if (obj instanceof final String s) {
273             anchorList = List.of(s);
274         } else if (obj instanceof final List<?> l) {
275             anchorList = l.stream().filter(item -> item != null).map(String::valueOf).toList();
276         } else {
277             return null;
278         }
279 
280         if (anchorList.isEmpty()) {
281             return null;
282         }
283 
284         final Set<RequestData> childUrlSet = new LinkedHashSet<>();
285         for (final String anchor : anchorList) {
286             if (StringUtil.isNotBlank(anchor)) {
287                 childUrlSet.add(RequestDataBuilder.newRequestData().get().url(anchor).build());
288             }
289         }
290         return childUrlSet.isEmpty() ? null : childUrlSet;
291     }
292 
293     /**
294      * Retrieves child URLs for a given document ID from the search engine index.
295      * This method queries the search engine for child documents and extracts their URLs.
296      *
297      * @param searchEngineClient the search engine client to query
298      * @param id the parent document ID to find children for
299      * @return a set of RequestData objects for the child URLs, or null if no children found
300      */
301     protected Set<RequestData> getChildUrlSet(final SearchEngineClient searchEngineClient, final String id) {
302         final FessConfig fessConfig = ComponentUtil.getFessConfig();
303         final IndexingHelper indexingHelper = ComponentUtil.getIndexingHelper();
304         final List<Map<String, Object>> docList =
305                 indexingHelper.getChildDocumentList(searchEngineClient, id, new String[] { fessConfig.getIndexFieldUrl() });
306         if (docList.isEmpty()) {
307             return null;
308         }
309         if (logger.isDebugEnabled()) {
310             logger.debug("Found documents: {}", docList);
311         }
312         final Set<RequestData> urlSet = new HashSet<>(docList.size());
313         for (final Map<String, Object> doc : docList) {
314             final String url = DocumentUtil.getValue(doc, fessConfig.getIndexFieldUrl(), String.class);
315             if (StringUtil.isNotBlank(url)) {
316                 urlSet.add(RequestDataBuilder.newRequestData().get().url(url).build());
317             }
318         }
319         return urlSet;
320     }
321 
322     /**
323      * Processes the response data from a crawled URL, including failure handling.
324      * This method extends the base response processing to handle Fess-specific failure
325      * URL tracking when certain HTTP status codes are encountered.
326      *
327      * @param urlQueue the URL queue item that was processed
328      * @param responseData the response data from the crawl operation
329      */
330     @Override
331     protected void processResponse(final UrlQueue<?> urlQueue, final ResponseData responseData) {
332         super.processResponse(urlQueue, responseData);
333 
334         final FessConfig fessConfig = ComponentUtil.getFessConfig();
335         if (fessConfig.isCrawlerFailureUrlStatusCodes(responseData.getHttpStatusCode())) {
336             final String sessionId = crawlerContext.getSessionId();
337             final CrawlingConfig crawlingConfig = ComponentUtil.getCrawlingConfigHelper().get(sessionId);
338             final String url = urlQueue.getUrl();
339 
340             final FailureUrlService failureUrlService = ComponentUtil.getComponent(FailureUrlService.class);
341             failureUrlService.store(crawlingConfig, ContentNotFoundException.class.getCanonicalName(), url,
342                     new ContentNotFoundException(urlQueue.getParentUrl(), url));
343         }
344     }
345 
346     /**
347      * Stores a child URL in the crawling queue with duplicate host handling.
348      * This method applies duplicate host conversion before storing the URL.
349      *
350      * @param childUrl the child URL to store
351      * @param parentUrl the parent URL that referenced this child URL
352      * @param weight the weight/priority of the child URL
353      * @param depth the crawling depth of the child URL
354      */
355     @Override
356     protected void storeChildUrl(final String childUrl, final String parentUrl, final float weight, final int depth) {
357         if (StringUtil.isNotBlank(childUrl)) {
358             final DuplicateHostHelper duplicateHostHelper = ComponentUtil.getDuplicateHostHelper();
359             final String url = duplicateHostHelper.convert(childUrl);
360             super.storeChildUrl(url, parentUrl, weight, depth);
361         }
362     }
363 
364     /**
365      * Retrieves the appropriate crawler client for the given URL based on configured rules.
366      * This method uses client rules to determine which specific client implementation
367      * should be used for crawling the URL, falling back to the default client if no
368      * specific rule matches.
369      *
370      * @param url the URL to get a client for
371      * @return the crawler client instance to use for the URL
372      */
373     @Override
374     protected CrawlerClient getClient(final String url) {
375         final CrawlingConfigHelper crawlingConfigHelper = ComponentUtil.getCrawlingConfigHelper();
376         final CrawlingConfig crawlingConfig = crawlingConfigHelper.get(crawlerContext.getSessionId());
377         final Map<String, String> clientConfigMap = crawlingConfig.getConfigParameterMap(ConfigName.CLIENT);
378         final String value = clientConfigMap.get(CRAWLER_CLIENTS);
379         final CrawlerClient client = getClientRuleList(value).stream().map(e -> {
380             if (e.getSecond().matcher(url).matches()) {
381                 return e.getFirst();
382             }
383             return null;
384         })
385                 .filter(StringUtil::isNotBlank)
386                 .findFirst()//
387                 .map(s -> clientFactory.getClient(s + ":" + url))//
388                 .orElseGet(() -> clientFactory.getClient(url));
389         if (logger.isDebugEnabled()) {
390             logger.debug("CrawlerClient: class={}", client.getClass().getCanonicalName());
391         }
392         return client;
393     }
394 
395     /**
396      * Parses client rule configuration string into a list of client name and pattern pairs.
397      * The configuration string format is "clientName:pattern,clientName:pattern,..."
398      * Results are cached to improve performance on subsequent calls.
399      *
400      * @param value the client rule configuration string
401      * @return a list of pairs containing client names and their corresponding compiled patterns
402      */
403     protected List<Pair<String, Pattern>> getClientRuleList(final String value) {
404         if (StringUtil.isBlank(value)) {
405             return Collections.emptyList();
406         }
407         return split(value, ",").get(stream -> stream.map(String::trim)//
408                 .map(s -> clientRuleCache.computeIfAbsent(s, t -> {
409                     final String[] values = t.split(":", 2);
410                     if (values.length != 2) {
411                         return null;
412                     }
413                     return new Pair<>(values[0], Pattern.compile(values[1]));
414                 }))
415                 .toList());
416     }
417 }