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.exec;
17  
18  import static org.codelibs.core.stream.StreamUtil.stream;
19  
20  import java.io.BufferedReader;
21  import java.io.File;
22  import java.io.IOException;
23  import java.io.InputStreamReader;
24  import java.lang.management.ManagementFactory;
25  import java.text.SimpleDateFormat;
26  import java.util.ArrayList;
27  import java.util.Collections;
28  import java.util.Date;
29  import java.util.HashMap;
30  import java.util.List;
31  import java.util.Map;
32  import java.util.Queue;
33  import java.util.concurrent.ConcurrentLinkedQueue;
34  import java.util.concurrent.atomic.AtomicBoolean;
35  import java.util.stream.Collectors;
36  
37  import org.apache.logging.log4j.LogManager;
38  import org.apache.logging.log4j.Logger;
39  import org.codelibs.core.CoreLibConstants;
40  import org.codelibs.core.exception.InterruptedRuntimeException;
41  import org.codelibs.core.lang.StringUtil;
42  import org.codelibs.core.lang.ThreadUtil;
43  import org.codelibs.core.misc.DynamicProperties;
44  import org.codelibs.core.timer.TimeoutManager;
45  import org.codelibs.core.timer.TimeoutTask;
46  import org.codelibs.fess.Constants;
47  import org.codelibs.fess.app.service.CrawlingInfoService;
48  import org.codelibs.fess.app.service.PathMappingService;
49  import org.codelibs.fess.crawler.client.FesenClient;
50  import org.codelibs.fess.exception.ContainerNotAvailableException;
51  import org.codelibs.fess.helper.CrawlingInfoHelper;
52  import org.codelibs.fess.helper.DataIndexHelper;
53  import org.codelibs.fess.helper.DuplicateHostHelper;
54  import org.codelibs.fess.helper.NotificationHelper;
55  import org.codelibs.fess.helper.PathMappingHelper;
56  import org.codelibs.fess.helper.SystemHelper;
57  import org.codelibs.fess.helper.WebFsIndexHelper;
58  import org.codelibs.fess.mylasta.direction.FessConfig;
59  import org.codelibs.fess.mylasta.mail.CrawlerPostcard;
60  import org.codelibs.fess.opensearch.client.SearchEngineClient;
61  import org.codelibs.fess.timer.HotThreadMonitorTarget;
62  import org.codelibs.fess.timer.LogNotificationTarget;
63  import org.codelibs.fess.timer.SystemMonitorTarget;
64  import org.codelibs.fess.util.ComponentUtil;
65  import org.codelibs.fess.util.SystemUtil;
66  import org.codelibs.fess.util.ThreadDumpUtil;
67  import org.dbflute.mail.send.hook.SMailCallbackContext;
68  import org.kohsuke.args4j.CmdLineException;
69  import org.kohsuke.args4j.CmdLineParser;
70  import org.kohsuke.args4j.Option;
71  import org.lastaflute.core.mail.Postbox;
72  import org.lastaflute.di.core.external.GenericExternalContext;
73  import org.lastaflute.di.core.external.GenericExternalContextComponentDefRegister;
74  import org.lastaflute.di.core.factory.SingletonLaContainerFactory;
75  import org.opensearch.monitor.jvm.JvmInfo;
76  import org.opensearch.monitor.os.OsProbe;
77  import org.opensearch.monitor.process.ProcessProbe;
78  
79  import jakarta.annotation.Resource;
80  
81  /**
82   * Main executable class for running crawling operations in the Fess search engine.
83   * This class serves as the entry point for crawling web content, file systems, and data stores.
84   * It manages the crawling lifecycle, including initialization, execution coordination,
85   * monitoring, and cleanup operations.
86   *
87   * <p>The crawler can operate in different modes based on command-line options:
88   * <ul>
89   * <li>Web crawling - crawls web sites and web content</li>
90   * <li>File system crawling - crawls file systems and documents</li>
91   * <li>Data store crawling - crawls databases and other data sources</li>
92   * <li>Combined crawling - runs multiple crawling types simultaneously</li>
93   * </ul>
94   *
95   * <p>Command line usage:
96   * <pre>
97   * java org.codelibs.fess.exec.Crawler [options...]
98   *   -s, --sessionId sessionId     : Session ID for the crawling session
99   *   -n, --name name               : Name for the crawling session
100  *   -w, --webConfigIds ids        : Comma-separated web config IDs
101  *   -f, --fileConfigIds ids       : Comma-separated file config IDs
102  *   -d, --dataConfigIds ids       : Comma-separated data config IDs
103  *   -p, --properties path         : Properties file path
104  *   -e, --expires days            : Expires for documents (in days)
105  *   -h, --hotThread interval      : Interval for hot thread logging
106  * </pre>
107  */
108 public class Crawler {
109 
110     /**
111      * Creates a new instance of Crawler.
112      */
113     public Crawler() {
114         // Default constructor
115     }
116 
117     /** Logger instance for this class. */
118     private static final Logger logger = LogManager.getLogger(Crawler.class);
119 
120     /** Thread name for web and file system crawling process. */
121     private static final String WEB_FS_CRAWLING_PROCESS = "WebFsCrawler";
122 
123     /** Thread name for data store crawling process. */
124     private static final String DATA_CRAWLING_PROCESS = "DataStoreCrawler";
125 
126     /** Atomic flag indicating whether the crawler is currently running. */
127     private static AtomicBoolean running = new AtomicBoolean(false);
128 
129     /** Thread-safe queue for collecting error messages during crawling operations. */
130     private static Queue<String> errors = new ConcurrentLinkedQueue<>();
131 
132     /** Injected search engine client for OpenSearch operations. */
133     @Resource
134     protected SearchEngineClient searchEngineClient;
135 
136     /** Injected helper for web and file system indexing operations. */
137     @Resource
138     protected WebFsIndexHelper webFsIndexHelper;
139 
140     /** Injected helper for data store indexing operations. */
141     @Resource
142     protected DataIndexHelper dataIndexHelper;
143 
144     /** Injected service for managing path mappings during crawling. */
145     @Resource
146     protected PathMappingService pathMappingService;
147 
148     /** Injected service for managing crawling session information. */
149     @Resource
150     protected CrawlingInfoService crawlingInfoService;
151 
152     /**
153      * Adds an error message to the error queue for later processing.
154      * This method is thread-safe and can be called from multiple crawler threads.
155      *
156      * @param msg the error message to add; ignored if null or blank
157      */
158     public static void addError(final String msg) {
159         if (StringUtil.isNotBlank(msg)) {
160             errors.offer(msg);
161         }
162     }
163 
164     /**
165      * Command-line options container for the crawler application.
166      * This class uses args4j annotations to define command-line arguments
167      * and provides methods to parse and access configuration values.
168      */
169     public static class Options {
170 
171         /** Session ID for the crawling session. If not provided, a timestamp-based ID will be generated. */
172         @Option(name = "-s", aliases = "--sessionId", metaVar = "sessionId", usage = "Session ID")
173         public String sessionId;
174 
175         /** Name for the crawling session for identification purposes. */
176         @Option(name = "-n", aliases = "--name", metaVar = "name", usage = "Name")
177         public String name;
178 
179         /** Comma-separated list of web configuration IDs to crawl. */
180         @Option(name = "-w", aliases = "--webConfigIds", metaVar = "webConfigIds", usage = "Web Config IDs")
181         public String webConfigIds;
182 
183         /** Comma-separated list of file system configuration IDs to crawl. */
184         @Option(name = "-f", aliases = "--fileConfigIds", metaVar = "fileConfigIds", usage = "File Config IDs")
185         public String fileConfigIds;
186 
187         /** Comma-separated list of data store configuration IDs to crawl. */
188         @Option(name = "-d", aliases = "--dataConfigIds", metaVar = "dataConfigIds", usage = "Data Config IDs")
189         public String dataConfigIds;
190 
191         /** Path to properties file for system configuration overrides. */
192         @Option(name = "-p", aliases = "--properties", metaVar = "properties", usage = "Properties File")
193         public String propertiesPath;
194 
195         /** Number of days after which documents should expire and be cleaned up. */
196         @Option(name = "-e", aliases = "--expires", metaVar = "expires", usage = "Expires for documents")
197         public String expires;
198 
199         /** Interval in milliseconds for hot thread monitoring and logging. */
200         @Option(name = "-h", aliases = "--hotThread", metaVar = "hotThread", usage = "Interval for Hot Thread logging")
201         public Integer hotThread;
202 
203         /**
204          * Default constructor for Options.
205          * Protected to allow subclassing while preventing direct instantiation.
206          */
207         protected Options() {
208             // nothing
209         }
210 
211         /**
212          * Parses the web configuration IDs string into a list.
213          *
214          * @return list of web configuration IDs, or null if none specified
215          */
216         protected List<String> getWebConfigIdList() {
217             if (StringUtil.isNotBlank(webConfigIds)) {
218                 final String[] values = webConfigIds.split(",");
219                 return createConfigIdList(values);
220             }
221             return null;
222         }
223 
224         /**
225          * Parses the file configuration IDs string into a list.
226          *
227          * @return list of file configuration IDs, or null if none specified
228          */
229         protected List<String> getFileConfigIdList() {
230             if (StringUtil.isNotBlank(fileConfigIds)) {
231                 final String[] values = fileConfigIds.split(",");
232                 return createConfigIdList(values);
233             }
234             return null;
235         }
236 
237         /**
238          * Parses the data configuration IDs string into a list.
239          *
240          * @return list of data configuration IDs, or null if none specified
241          */
242         protected List<String> getDataConfigIdList() {
243             if (StringUtil.isNotBlank(dataConfigIds)) {
244                 final String[] values = dataConfigIds.split(",");
245                 return createConfigIdList(values);
246             }
247             return null;
248         }
249 
250         /**
251          * Creates a list from an array of configuration ID values.
252          *
253          * @param values array of configuration ID strings
254          * @return list containing all values from the array
255          */
256         private static List<String> createConfigIdList(final String[] values) {
257             final List<String> idList = new ArrayList<>();
258             Collections.addAll(idList, values);
259             return idList;
260         }
261 
262         /**
263          * Returns a string representation of this Options object.
264          * Contains all option values for debugging and logging purposes.
265          *
266          * @return string representation containing all option values
267          */
268         @Override
269         public String toString() {
270             return "Options [sessionId=" + sessionId + ", name=" + name + ", webConfigIds=" + webConfigIds + ", fileConfigIds="
271                     + fileConfigIds + ", dataConfigIds=" + dataConfigIds + ", propertiesPath=" + propertiesPath + ", expires=" + expires
272                     + ", hotThread=" + hotThread + "]";
273         }
274 
275     }
276 
277     /**
278      * Initializes OpenSearch monitoring probes.
279      * Forces the loading of process, OS, and JVM monitoring probes
280      * to ensure they are available for system monitoring during crawling.
281      */
282     static void initializeProbes() {
283         // Force probes to be loaded
284         ProcessProbe.getInstance();
285         OsProbe.getInstance();
286         JvmInfo.jvmInfo();
287     }
288 
289     /**
290      * Main entry point for the crawler application.
291      * Parses command-line arguments, initializes the application container,
292      * sets up monitoring, and executes the crawling process.
293      *
294      * @param args command-line arguments as defined in the Options class
295      */
296     public static void main(final String[] args) {
297         final Options options = new Options();
298 
299         final CmdLineParser parser = new CmdLineParser(options);
300         try {
301             parser.parseArgument(args);
302         } catch (final CmdLineException e) {
303             System.err.println(e.getMessage());
304             System.err.println("java " + Crawler.class.getCanonicalName() + " [options...] arguments...");
305             parser.printUsage(System.err);
306             return;
307         }
308 
309         if (logger.isDebugEnabled()) {
310             try {
311                 ManagementFactory.getRuntimeMXBean().getInputArguments().stream().forEach(s -> logger.debug("Parameter: {}", s));
312                 System.getProperties()
313                         .entrySet()
314                         .stream()
315                         .forEach(e -> logger.debug("Property: {}={}", e.getKey(),
316                                 SystemUtil.maskSensitiveValue(String.valueOf(e.getKey()), String.valueOf(e.getValue()))));
317                 System.getenv()
318                         .entrySet()
319                         .forEach(e -> logger.debug("Env: {}={}", e.getKey(), SystemUtil.maskSensitiveValue(e.getKey(), e.getValue())));
320                 logger.debug("Options: options={}", options);
321             } catch (final Exception e) {
322                 // ignore
323             }
324         }
325 
326         initializeProbes();
327 
328         final String httpAddress = SystemUtil.getSearchEngineHttpAddress();
329         if (StringUtil.isNotBlank(httpAddress)) {
330             System.setProperty(FesenClient.HTTP_ADDRESS, httpAddress);
331         }
332 
333         TimeoutTask systemMonitorTask = null;
334         TimeoutTask hotThreadMonitorTask = null;
335         TimeoutTask logNotificationTask = null;
336         LogNotificationTarget logNotificationTarget = null;
337         Thread commandThread = null;
338         int exitCode;
339         try {
340             running.set(true);
341             SingletonLaContainerFactory.setConfigPath("app.xml");
342             SingletonLaContainerFactory.setExternalContext(new GenericExternalContext());
343             SingletonLaContainerFactory.setExternalContextComponentDefRegister(new GenericExternalContextComponentDefRegister());
344             SingletonLaContainerFactory.init();
345 
346             final Thread shutdownCallback = new Thread("ShutdownHook") {
347                 @Override
348                 public void run() {
349                     destroyContainer();
350                 }
351 
352             };
353             Runtime.getRuntime().addShutdownHook(shutdownCallback);
354 
355             commandThread = new Thread(() -> {
356                 try (BufferedReader reader = new BufferedReader(new InputStreamReader(System.in))) {
357                     String command;
358                     while (true) {
359                         try {
360                             while (!reader.ready()) {
361                                 ThreadUtil.sleep(1000L);
362                             }
363                             command = reader.readLine().trim();
364                             if (logger.isDebugEnabled()) {
365                                 logger.debug("Process command: command={}", command);
366                             }
367                             if (Constants.CRAWLER_PROCESS_COMMAND_THREAD_DUMP.equals(command)) {
368                                 ThreadDumpUtil.printThreadDump();
369                             } else {
370                                 logger.warn("Unknown process command: command={}", command);
371                             }
372                             if (Thread.interrupted()) {
373                                 return;
374                             }
375                         } catch (final InterruptedRuntimeException e) {
376                             return;
377                         }
378                     }
379                 } catch (final IOException e) {
380                     logger.debug("I/O exception while reading process command.", e);
381                 }
382             }, "ProcessCommand");
383             commandThread.start();
384 
385             systemMonitorTask = TimeoutManager.getInstance()
386                     .addTimeoutTarget(new SystemMonitorTarget(), ComponentUtil.getFessConfig().getCrawlerSystemMonitorIntervalAsInteger(),
387                             true);
388 
389             if (ComponentUtil.getFessConfig().isLogNotificationEnabled()) {
390                 logNotificationTarget = new LogNotificationTarget();
391                 logNotificationTask = TimeoutManager.getInstance()
392                         .addTimeoutTarget(logNotificationTarget, ComponentUtil.getFessConfig().getLogNotificationFlushIntervalAsInteger(),
393                                 true);
394             }
395 
396             if (options.hotThread != null) {
397                 hotThreadMonitorTask = TimeoutManager.getInstance().addTimeoutTarget(new HotThreadMonitorTarget(), options.hotThread, true);
398             }
399 
400             exitCode = process(options);
401         } catch (final ContainerNotAvailableException e) {
402             if (logger.isDebugEnabled()) {
403                 logger.debug("Crawler is stopped.", e);
404             } else if (logger.isInfoEnabled()) {
405                 logger.info("Crawler is stopped.");
406             }
407             exitCode = Constants.EXIT_FAIL;
408         } catch (final Throwable t) {
409             logger.error("Crawler terminated unexpectedly.", t);
410             exitCode = Constants.EXIT_FAIL;
411         } finally {
412             if (commandThread != null && commandThread.isAlive()) {
413                 commandThread.interrupt();
414             }
415             if (systemMonitorTask != null) {
416                 systemMonitorTask.cancel();
417             }
418             if (hotThreadMonitorTask != null) {
419                 hotThreadMonitorTask.cancel();
420             }
421             if (logNotificationTask != null) {
422                 logNotificationTask.cancel();
423             }
424             if (logNotificationTarget != null) {
425                 logNotificationTarget.flush();
426             }
427             destroyContainer();
428         }
429 
430         if (exitCode != Constants.EXIT_OK) {
431             System.exit(exitCode);
432         }
433     }
434 
435     /**
436      * Destroys the DI container and stops the timeout manager.
437      * This method ensures proper cleanup of resources when the crawler shuts down.
438      * It's called both during normal shutdown and in error conditions.
439      */
440     private static void destroyContainer() {
441         if (running.getAndSet(false)) {
442             TimeoutManager.getInstance().stop();
443             if (logger.isDebugEnabled()) {
444                 logger.debug("Destroying LaContainer...");
445             }
446             SingletonLaContainerFactory.destroy();
447             logger.info("Destroyed LaContainer.");
448         }
449     }
450 
451     /**
452      * Main processing method that coordinates the entire crawling workflow.
453      * This method handles session setup, crawler initialization, crawling execution,
454      * and cleanup operations.
455      *
456      * @param options parsed command-line options containing crawling configuration
457      * @return exit code (Constants.EXIT_OK for success, Constants.EXIT_FAIL for failure)
458      */
459     private static int process(final Options options) {
460         final Crawler crawler = ComponentUtil.getComponent(Crawler.class);
461 
462         if (StringUtil.isBlank(options.sessionId)) {
463             // use a default session id
464             final SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMddHHmmss");
465             options.sessionId = sdf.format(new Date());
466         } else {
467             options.sessionId = options.sessionId.replace('-', '_');
468         }
469 
470         final CrawlingInfoHelper crawlingInfoHelper = ComponentUtil.getCrawlingInfoHelper();
471         final DynamicProperties systemProperties = ComponentUtil.getSystemProperties();
472 
473         if (StringUtil.isNotBlank(options.propertiesPath)) {
474             systemProperties.reload(options.propertiesPath);
475         } else {
476             try {
477                 final File propFile = ComponentUtil.getSystemHelper().createTempFile("crawler_", ".properties");
478                 if (propFile.delete() && logger.isDebugEnabled()) {
479                     logger.debug("Deleted temp file: path={}", propFile.getAbsolutePath());
480                 }
481                 systemProperties.reload(propFile.getAbsolutePath());
482                 propFile.deleteOnExit();
483             } catch (final Exception e) {
484                 logger.warn("Failed to create system properties file.", e);
485             }
486         }
487 
488         try {
489             crawlingInfoHelper.store(options.sessionId, true);
490             final String dayForCleanupStr;
491             int dayForCleanup = -1;
492             if (StringUtil.isNotBlank(options.expires)) {
493                 dayForCleanupStr = options.expires;
494                 try {
495                     dayForCleanup = Integer.parseInt(dayForCleanupStr);
496                 } catch (final NumberFormatException e) {
497                     if (logger.isDebugEnabled()) {
498                         logger.debug("Invalid expires value, using default: value={}, error={}", dayForCleanupStr, e.getMessage());
499                     }
500                 }
501             } else {
502                 dayForCleanup = ComponentUtil.getFessConfig().getDayForCleanup();
503             }
504             crawlingInfoHelper.updateParams(options.sessionId, options.name, dayForCleanup);
505         } catch (final Exception e) {
506             logger.warn("Failed to store crawling information: sessionId={}", options.sessionId, e);
507         }
508 
509         try {
510             return crawler.doCrawl(options);
511         } finally {
512             try {
513                 crawlingInfoHelper.store(options.sessionId, false);
514             } catch (final Exception e) {
515                 logger.warn("Failed to store crawling information: sessionId={}", options.sessionId, e);
516             }
517 
518             final Map<String, String> infoMap = crawlingInfoHelper.getInfoMap(options.sessionId);
519 
520             final StringBuilder buf = new StringBuilder(500);
521             for (final Map.Entry<String, String> entry : infoMap.entrySet()) {
522                 if (buf.length() != 0) {
523                     buf.append(',');
524                 }
525                 buf.append(entry.getKey()).append('=').append(entry.getValue());
526             }
527             if (logger.isInfoEnabled()) {
528                 logger.info("[CRAWL INFO] {}", buf);
529             }
530 
531             // notification
532             try {
533                 crawler.sendMail(infoMap);
534             } catch (final Exception e) {
535                 logger.warn("Failed to send notification mail.", e);
536             }
537 
538         }
539     }
540 
541     /**
542      * Sends email notification with crawling results and statistics.
543      * The email contains detailed information about the crawling session including
544      * execution times, index sizes, and status information.
545      *
546      * @param infoMap map containing crawling session information and statistics
547      */
548     protected void sendMail(final Map<String, String> infoMap) {
549         final FessConfig fessConfig = ComponentUtil.getFessConfig();
550         if (fessConfig.hasNotification()) {
551             final Map<String, String> dataMap = new HashMap<>();
552             for (final Map.Entry<String, String> entry : infoMap.entrySet()) {
553                 dataMap.put(StringUtil.decapitalize(entry.getKey()), entry.getValue());
554             }
555 
556             String hostname = fessConfig.getMailHostname();
557             if (StringUtil.isBlank(hostname)) {
558                 hostname = ComponentUtil.getSystemHelper().getHostname();
559             }
560             dataMap.put("hostname", hostname);
561 
562             logger.debug("\ninfoMap: {}\ndataMap: {}", infoMap, dataMap);
563 
564             final DynamicProperties systemProperties = ComponentUtil.getSystemProperties();
565             final String toStrs = fessConfig.getNotificationTo();
566             final Postbox postbox = ComponentUtil.getComponent(Postbox.class);
567             try {
568                 final String[] toAddresses;
569                 if (StringUtil.isNotBlank(toStrs)) {
570                     toAddresses = toStrs.split(",");
571                 } else {
572                     toAddresses = StringUtil.EMPTY_STRINGS;
573                 }
574                 final NotificationHelper notificationHelper = ComponentUtil.getNotificationHelper();
575                 SMailCallbackContext.setPreparedMessageHookOnThread(notificationHelper::send);
576                 CrawlerPostcard.droppedInto(postbox, postcard -> {
577                     postcard.setFrom(fessConfig.getMailFromAddress(), fessConfig.getMailFromName());
578                     postcard.addReplyTo(fessConfig.getMailReturnPath());
579                     if (toAddresses.length > 0) {
580                         stream(toAddresses).of(stream -> stream.map(String::trim).forEach(address -> {
581                             postcard.addTo(address);
582                         }));
583                     } else {
584                         postcard.addTo(fessConfig.getMailFromAddress());
585                         postcard.dryrun();
586                     }
587                     postcard.setCrawlerEndTime(getValueFromMap(dataMap, "crawlerEndTime", StringUtil.EMPTY));
588                     postcard.setCrawlerExecTime(getValueFromMap(dataMap, "crawlerExecTime", "0"));
589                     postcard.setCrawlerStartTime(getValueFromMap(dataMap, "crawlerStartTime", StringUtil.EMPTY));
590                     postcard.setDataCrawlEndTime(getValueFromMap(dataMap, "dataCrawlEndTime", StringUtil.EMPTY));
591                     postcard.setDataCrawlExecTime(getValueFromMap(dataMap, "dataCrawlExecTime", "0"));
592                     postcard.setDataCrawlStartTime(getValueFromMap(dataMap, "dataCrawlStartTime", StringUtil.EMPTY));
593                     postcard.setDataIndexSize(getValueFromMap(dataMap, "dataIndexSize", "0"));
594                     postcard.setDataIndexExecTime(getValueFromMap(dataMap, "dataIndexExecTime", "0"));
595                     postcard.setHostname(getValueFromMap(dataMap, "hostname", StringUtil.EMPTY));
596                     postcard.setWebFsCrawlEndTime(getValueFromMap(dataMap, "webFsCrawlEndTime", StringUtil.EMPTY));
597                     postcard.setWebFsCrawlExecTime(getValueFromMap(dataMap, "webFsCrawlExecTime", "0"));
598                     postcard.setWebFsCrawlStartTime(getValueFromMap(dataMap, "webFsCrawlStartTime", StringUtil.EMPTY));
599                     postcard.setWebFsIndexExecTime(getValueFromMap(dataMap, "webFsIndexExecTime", "0"));
600                     postcard.setWebFsIndexSize(getValueFromMap(dataMap, "webFsIndexSize", "0"));
601                     if (Constants.TRUE.equalsIgnoreCase(infoMap.get(Constants.CRAWLER_STATUS))) {
602                         postcard.setStatus(Constants.OK);
603                     } else {
604                         postcard.setStatus(Constants.FAIL);
605                     }
606                     postcard.setJobname(systemProperties.getProperty("job.runtime.name", StringUtil.EMPTY));
607                 });
608             } finally {
609                 SMailCallbackContext.clearPreparedMessageHookOnThread();
610             }
611         }
612     }
613 
614     /**
615      * Retrieves a value from a map with a default fallback.
616      *
617      * @param dataMap the map to retrieve the value from
618      * @param key the key to look up
619      * @param defaultValue the default value to return if key is not found or value is blank
620      * @return the value from the map or the default value
621      */
622     private String getValueFromMap(final Map<String, String> dataMap, final String key, final String defaultValue) {
623         final String value = dataMap.get(key);
624         if (StringUtil.isBlank(value)) {
625             return defaultValue;
626         }
627         return value;
628     }
629 
630     /**
631      * Executes the actual crawling operations based on the provided options.
632      * This method coordinates web/file system crawling and data store crawling,
633      * running them in parallel threads when multiple types are requested.
634      *
635      * @param options crawling configuration options
636      * @return exit code (Constants.EXIT_OK for success, Constants.EXIT_FAIL for failure)
637      */
638     public int doCrawl(final Options options) {
639         if (logger.isInfoEnabled()) {
640             logger.info("Starting Crawler...");
641         }
642 
643         final PathMappingHelper pathMappingHelper = ComponentUtil.getPathMappingHelper();
644         final SystemHelper systemHelper = ComponentUtil.getSystemHelper();
645         final long totalTime = systemHelper.getCurrentTimeAsLong();
646 
647         final CrawlingInfoHelper crawlingInfoHelper = ComponentUtil.getCrawlingInfoHelper();
648 
649         try {
650             writeTimeToSessionInfo(crawlingInfoHelper, Constants.CRAWLER_START_TIME);
651 
652             // setup path mapping
653             final List<String> ptList = new ArrayList<>();
654             ptList.add(Constants.PROCESS_TYPE_CRAWLING);
655             ptList.add(Constants.PROCESS_TYPE_BOTH);
656             pathMappingHelper.setPathMappingList(options.sessionId, pathMappingService.getPathMappingList(ptList));
657 
658             // duplicate host
659             try {
660                 final DuplicateHostHelper duplicateHostHelper = ComponentUtil.getDuplicateHostHelper();
661                 duplicateHostHelper.init();
662             } catch (final Exception e) {
663                 logger.warn("Could not initialize duplicateHostHelper.", e);
664             }
665 
666             // delete expired sessions
667             crawlingInfoService.deleteSessionIdsBefore(options.sessionId, options.name, systemHelper.getCurrentTimeAsLong());
668 
669             final List<String> webConfigIdList = options.getWebConfigIdList();
670             final List<String> fileConfigIdList = options.getFileConfigIdList();
671             final List<String> dataConfigIdList = options.getDataConfigIdList();
672             final boolean runAll = webConfigIdList == null && fileConfigIdList == null && dataConfigIdList == null;
673 
674             Thread webFsCrawlerThread = null;
675             Thread dataCrawlerThread = null;
676 
677             if (runAll || webConfigIdList != null || fileConfigIdList != null) {
678                 webFsCrawlerThread = new Thread((Runnable) () -> {
679                     // crawl web
680                     writeTimeToSessionInfo(crawlingInfoHelper, Constants.WEB_FS_CRAWLER_START_TIME);
681                     webFsIndexHelper.crawl(options.sessionId, webConfigIdList, fileConfigIdList);
682                     writeTimeToSessionInfo(crawlingInfoHelper, Constants.WEB_FS_CRAWLER_END_TIME);
683                 }, WEB_FS_CRAWLING_PROCESS);
684                 webFsCrawlerThread.start();
685             }
686 
687             if (runAll || dataConfigIdList != null) {
688                 dataCrawlerThread = new Thread((Runnable) () -> {
689                     // crawl data system
690                     writeTimeToSessionInfo(crawlingInfoHelper, Constants.DATA_CRAWLER_START_TIME);
691                     dataIndexHelper.crawl(options.sessionId, dataConfigIdList);
692                     writeTimeToSessionInfo(crawlingInfoHelper, Constants.DATA_CRAWLER_END_TIME);
693                 }, DATA_CRAWLING_PROCESS);
694                 dataCrawlerThread.start();
695             }
696 
697             joinCrawlerThread(webFsCrawlerThread);
698             joinCrawlerThread(dataCrawlerThread);
699 
700             if (logger.isInfoEnabled()) {
701                 logger.info("Finished Crawler.");
702             }
703 
704             return Constants.EXIT_OK;
705         } catch (final Throwable t) {
706             logger.warn("Crawl task failed with an exception.", t);
707             return Constants.EXIT_FAIL;
708         } finally {
709             pathMappingHelper.removePathMappingList(options.sessionId);
710             crawlingInfoHelper.putToInfoMap(Constants.CRAWLER_STATUS, errors.isEmpty() ? Constants.T.toString() : Constants.F.toString());
711             if (!errors.isEmpty()) {
712                 crawlingInfoHelper.putToInfoMap(Constants.CRAWLER_ERRORS,
713                         errors.stream().map(s -> s.replace(" ", StringUtil.EMPTY)).collect(Collectors.joining(" ")));
714             }
715             writeTimeToSessionInfo(crawlingInfoHelper, Constants.CRAWLER_END_TIME);
716             crawlingInfoHelper.putToInfoMap(Constants.CRAWLER_EXEC_TIME, Long.toString(systemHelper.getCurrentTimeAsLong() - totalTime));
717 
718         }
719     }
720 
721     /**
722      * Writes the current timestamp to the crawling session information.
723      * The timestamp is formatted in ISO 8601 extended format.
724      *
725      * @param crawlingInfoHelper helper for managing crawling session information
726      * @param key the key under which to store the timestamp
727      */
728     protected void writeTimeToSessionInfo(final CrawlingInfoHelper crawlingInfoHelper, final String key) {
729         if (crawlingInfoHelper != null) {
730             final SimpleDateFormat dateFormat = new SimpleDateFormat(CoreLibConstants.DATE_FORMAT_ISO_8601_EXTEND);
731             crawlingInfoHelper.putToInfoMap(key, dateFormat.format(new Date()));
732         }
733     }
734 
735     /**
736      * Waits for a crawler thread to complete execution.
737      * This method handles interruptions gracefully and logs when a crawler process is interrupted.
738      *
739      * @param crawlerThread the thread to wait for; null threads are ignored
740      */
741     private void joinCrawlerThread(final Thread crawlerThread) {
742         if (crawlerThread != null) {
743             try {
744                 crawlerThread.join();
745             } catch (final Exception e) {
746                 logger.info("Interrupted crawling process: name={}", crawlerThread.getName());
747             }
748         }
749     }
750 }