extract-webpage 1.2.35 → 1.2.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,1049 +1,1049 @@
1
- /**
2
- * @fileoverview Comprehensive date extraction orchestrator for HTML documents.
3
- * Integrates pattern searching, JSON-LD parsing, and image metadata analysis.
4
- */
5
- import {
6
- discard_unwanted,
7
- extract_url_date,
8
- idiosyncrasies_search,
9
- img_search,
10
- json_search,
11
- regex_parse,
12
- pattern_search,
13
- try_date_expr,
14
- DATE_EXPRESSIONS,
15
- FAST_PREPEND,
16
- SLOW_PREPEND,
17
- FREE_TEXT_EXPRESSIONS,
18
- MAX_SEGMENT_LEN,
19
- MIN_SEGMENT_LEN,
20
- YEAR_PATTERN,
21
- YMD_PATTERN,
22
- COPYRIGHT_PATTERN,
23
- TIMESTAMP_PATTERN,
24
- THREE_PATTERN,
25
- THREE_CATCH,
26
- THREE_LOOSE_PATTERN,
27
- THREE_LOOSE_CATCH,
28
- SELECT_YMD_PATTERN,
29
- SELECT_YMD_YEAR,
30
- YMD_YEAR,
31
- DATESTRINGS_PATTERN,
32
- DATESTRINGS_CATCH,
33
- SLASHES_PATTERN,
34
- SLASHES_YEAR,
35
- YYYYMM_PATTERN,
36
- YYYYMM_CATCH,
37
- MMYYYY_PATTERN,
38
- MMYYYY_YEAR,
39
- SIMPLE_PATTERN,
40
- THREE_COMP_REGEX_A,
41
- THREE_COMP_REGEX_B,
42
- TWO_COMP_REGEX,
43
- } from "./date-extractors";
44
-
45
- import {
46
- check_extracted_reference,
47
- compare_values,
48
- filter_ymd_candidate,
49
- get_min_date,
50
- get_max_date,
51
- is_valid_date,
52
- is_valid_format,
53
- plausible_year_filter,
54
- convert_date,
55
- } from "./date-validators";
56
-
57
- /**
58
- * Format a date object into the desired output format
59
- */
60
- function format_date(date: Date, format: string): string {
61
- return convert_date(date, "YYYY-MM-DD", format);
62
- }
63
-
64
- // Global debug flag
65
- let DEBUG = false;
66
- const LOGGER = {
67
- debug: (...args) => DEBUG && console.log(...args),
68
- warn: (...args) => DEBUG && console.warn(...args),
69
- error: (...args) => console.error(...args),
70
- };
71
-
72
- var MAX_POSSIBLE_CANDIDATES = 1000;
73
-
74
- var CLEANING_LIST = [
75
- "applet",
76
- "audio",
77
- "canvas",
78
- "datalist",
79
- "embed",
80
- "frame",
81
- "frameset",
82
- "iframe",
83
- "label",
84
- "map",
85
- "math",
86
- "noframes",
87
- "object",
88
- "picture",
89
- "rdf",
90
- "svg",
91
- "track",
92
- "video",
93
- ];
94
-
95
- // Date-related meta tag attributes
96
- const DATE_ATTRIBUTES = new Set([
97
- "analyticsattributes.articledate",
98
- "article.created",
99
- "article_date_original",
100
- "article:post_date",
101
- "article.published",
102
- "article:published",
103
- "article:published_date",
104
- "article:published_time",
105
- "article:publicationdate",
106
- "bt:pubdate",
107
- "citation_date",
108
- "citation_publication_date",
109
- "content_create_date",
110
- "created",
111
- "cxenseparse:recs:publishtime",
112
- "date",
113
- "date_created",
114
- "date_published",
115
- "datecreated",
116
- "dateposted",
117
- "datepublished",
118
- // Dublin Core
119
- "dc.date",
120
- "dc.created",
121
- "dc.date.created",
122
- "dc.date.issued",
123
- "dc.date.publication",
124
- "dcsext.articlefirstpublished",
125
- "dcterms.created",
126
- "dcterms.date",
127
- "dcterms.issued",
128
- "dc:created",
129
- "dc:date",
130
- "displaydate",
131
- "doc_date",
132
- "field-name-post-date",
133
- "gentime",
134
- "mediator_published_time",
135
- "meta",
136
- // Open Graph
137
- "og:article:published",
138
- "og:article:published_time",
139
- "og:datepublished",
140
- "og:pubdate",
141
- "og:publish_date",
142
- "og:published_time",
143
- "og:question:published_time",
144
- "og:regdate",
145
- "originalpublicationdate",
146
- "parsely-pub-date",
147
- "pdate",
148
- "ptime",
149
- "pubdate",
150
- "publishdate",
151
- "publish_date",
152
- "publish_time",
153
- "publish-date",
154
- "published-date",
155
- "published_date",
156
- "published_time",
157
- "publisheddate",
158
- "publication_date",
159
- "rbpubdate",
160
- "release_date",
161
- "rnews:datepublished",
162
- "sailthru.date",
163
- "shareaholic:article_published_time",
164
- "timestamp",
165
- "twt-published-at",
166
- "video:release_date",
167
- "vr:published_time",
168
- ]);
169
-
170
- // Modified date attributes
171
- const NAME_MODIFIED = new Set([
172
- "lastdate",
173
- "lastmod",
174
- "lastmodified",
175
- "last-modified",
176
- "modified",
177
- "utime",
178
- ]);
179
-
180
- // Property modified attributes
181
- const PROPERTY_MODIFIED = new Set([
182
- "article:modified",
183
- "article:modified_date",
184
- "article:modified_time",
185
- "article:post_modified",
186
- "bt:moddate",
187
- "datemodified",
188
- "dc.modified",
189
- "dcterms.modified",
190
- "lastmodified",
191
- "modified_time",
192
- "modificationdate",
193
- "og:article:modified_time",
194
- "og:modified_time",
195
- "og:updated_time",
196
- "release_date",
197
- "revision_date",
198
- "updated_time",
199
- ]);
200
-
201
- // ItemProp attributes
202
- const ITEMPROP_ATTRS_ORIGINAL = new Set([
203
- "datecreated",
204
- "datepublished",
205
- "pubyear",
206
- ]);
207
-
208
- const ITEMPROP_ATTRS_MODIFIED = new Set(["datemodified", "dateupdate"]);
209
-
210
- // Combine ItemProp sets
211
- const ITEMPROP_ATTRS = new Set([
212
- ...ITEMPROP_ATTRS_ORIGINAL,
213
- ...ITEMPROP_ATTRS_MODIFIED,
214
- ]);
215
-
216
- // Class attributes
217
- const CLASS_ATTRS = new Set(["date-published", "published", "time published"]);
218
-
219
- // Regular expressions
220
- const NON_DIGITS_REGEX = /\D+$/;
221
-
222
- // Note: THREE_PATTERN and THREE_CATCH would need to be defined
223
- const THREE_COMP_PATTERNS = [
224
- [THREE_PATTERN, THREE_CATCH],
225
- [THREE_LOOSE_PATTERN, THREE_LOOSE_CATCH],
226
- ];
227
-
228
- // Export all constants
229
- export {
230
- DATE_ATTRIBUTES,
231
- NAME_MODIFIED,
232
- PROPERTY_MODIFIED,
233
- ITEMPROP_ATTRS_ORIGINAL,
234
- ITEMPROP_ATTRS_MODIFIED,
235
- ITEMPROP_ATTRS,
236
- CLASS_ATTRS,
237
- NON_DIGITS_REGEX,
238
- // THREE_COMP_PATTERNS // Uncomment once patterns are defined
239
- };
240
-
241
- function examine_text(text, options) {
242
- if (text.length <= MIN_SEGMENT_LEN) {
243
- return null;
244
- }
245
-
246
- text = text.slice(0, MAX_SEGMENT_LEN).replace(NON_DIGITS_REGEX, "");
247
- return try_date_expr(
248
- text,
249
- options.format,
250
- options.extensive,
251
- options.min,
252
- options.max
253
- );
254
- }
255
-
256
- function examine_date_elements(tree, expression, options) {
257
- const elements = tree.querySelectorAll(expression);
258
- if (!elements || elements.length > MAX_POSSIBLE_CANDIDATES) {
259
- return null;
260
- }
261
-
262
- for (const elem of elements) {
263
- for (const text of [elem.textContent, elem.getAttribute("title") || ""]) {
264
- const attempt = examine_text(text, options);
265
- if (attempt) {
266
- return attempt;
267
- }
268
- }
269
- }
270
-
271
- return null;
272
- }
273
-
274
- function examine_header(tree, options) {
275
- let headerdate = null;
276
- let reserve = null;
277
- const tryfunc = (content) =>
278
- try_date_expr(
279
- content,
280
- options.format,
281
- options.extensive,
282
- options.min,
283
- options.max
284
- );
285
-
286
- for (const elem of tree.querySelectorAll("meta")) {
287
- if (
288
- !elem.attributes.length ||
289
- (!elem.getAttribute("content") && !elem.getAttribute("datetime"))
290
- ) {
291
- continue;
292
- }
293
-
294
- if (elem.hasAttribute("name")) {
295
- const attribute = elem.getAttribute("name")?.toLowerCase();
296
- if (attribute === "og:url") {
297
- reserve = extract_url_date(elem.getAttribute("content"), options);
298
- } else if (DATE_ATTRIBUTES.has(attribute)) {
299
- LOGGER.debug(`examining meta name: ${elem.outerHTML}`);
300
- headerdate = tryfunc(elem.getAttribute("content"));
301
- } else if (NAME_MODIFIED.has(attribute)) {
302
- LOGGER.debug(`examining meta name: ${elem.outerHTML}`);
303
- if (!options.original) {
304
- headerdate = tryfunc(elem.getAttribute("content"));
305
- } else {
306
- reserve = tryfunc(elem.getAttribute("content"));
307
- }
308
- }
309
- } else if (elem.hasAttribute("property")) {
310
- const attribute = elem.getAttribute("property").toLowerCase();
311
- if (DATE_ATTRIBUTES.has(attribute) || PROPERTY_MODIFIED.has(attribute)) {
312
- LOGGER.debug(`examining meta property: ${elem.outerHTML}`);
313
- const attempt = tryfunc(elem.getAttribute("content"));
314
- if (attempt !== null) {
315
- if (
316
- (DATE_ATTRIBUTES.has(attribute) && options.original) ||
317
- (PROPERTY_MODIFIED.has(attribute) && !options.original)
318
- ) {
319
- headerdate = attempt;
320
- } else {
321
- reserve = attempt;
322
- }
323
- }
324
- }
325
- } else if (elem.hasAttribute("itemprop")) {
326
- const attribute = elem.getAttribute("itemprop").toLowerCase();
327
- if (ITEMPROP_ATTRS.has(attribute)) {
328
- LOGGER.debug(`examining meta itemprop: ${elem.outerHTML}`);
329
- const attempt = tryfunc(
330
- elem.getAttribute("datetime") || elem.getAttribute("content")
331
- );
332
- if (attempt !== null) {
333
- if (
334
- (ITEMPROP_ATTRS_ORIGINAL.has(attribute) && options.original) ||
335
- (ITEMPROP_ATTRS_MODIFIED.has(attribute) && !options.original)
336
- ) {
337
- headerdate = attempt;
338
- }
339
- }
340
- } else if (attribute === "copyrightyear") {
341
- LOGGER.debug(`examining meta itemprop: ${elem.outerHTML}`);
342
- if (elem.hasAttribute("content")) {
343
- const attempt = `${elem.getAttribute("content")}-01-01`;
344
- if (is_valid_date(attempt, "%Y-%m-%d", options.min, options.max)) {
345
- reserve = attempt;
346
- }
347
- }
348
- }
349
- } else if (elem.hasAttribute("pubdate")) {
350
- if (elem.getAttribute("pubdate").toLowerCase() === "pubdate") {
351
- LOGGER.debug(`examining meta pubdate: ${elem.outerHTML}`);
352
- headerdate = tryfunc(elem.getAttribute("content"));
353
- }
354
- } else if (elem.hasAttribute("http-equiv")) {
355
- const attribute = elem.getAttribute("http-equiv").toLowerCase();
356
- if (attribute === "date") {
357
- LOGGER.debug(`examining meta http-equiv: ${elem.outerHTML}`);
358
- if (options.original) {
359
- headerdate = tryfunc(elem.getAttribute("content"));
360
- } else {
361
- reserve = tryfunc(elem.getAttribute("content"));
362
- }
363
- } else if (attribute === "last-modified") {
364
- LOGGER.debug(`examining meta http-equiv: ${elem.outerHTML}`);
365
- if (!options.original) {
366
- headerdate = tryfunc(elem.getAttribute("content"));
367
- } else {
368
- reserve = tryfunc(elem.getAttribute("content"));
369
- }
370
- }
371
- }
372
-
373
- if (headerdate !== null) {
374
- break;
375
- }
376
- }
377
-
378
- if (headerdate === null && reserve !== null) {
379
- LOGGER.debug("opting for reserve date with less granularity");
380
- headerdate = reserve;
381
- }
382
-
383
- return headerdate;
384
- }
385
-
386
- function select_candidate(occurrences, catch_regex, yearpat, options) {
387
- if (!occurrences || occurrences.size > MAX_POSSIBLE_CANDIDATES) {
388
- return null;
389
- }
390
-
391
- // console.log( JSON.stringify(occurrences) + " 444444444444" + occurrences + " 444444444444")
392
-
393
- if (occurrences.size === 1) {
394
- const pattern = occurrences.keys().next().value;
395
- return catch_regex.exec(pattern);
396
- }
397
- ///if null
398
- // if (occurrences.size === 0) {
399
- return occurrences;
400
- //todo fix
401
- // }
402
-
403
- const firstselect = Array.from(occurrences.entries())
404
- .sort((a, b) => b[1] - a[1])
405
- .slice(0, 10);
406
- LOGGER.debug(`firstselect: ${JSON.stringify(firstselect)}`);
407
-
408
- const bestones = firstselect
409
- .sort((a, b) => (options.original ? a[1] - b[1] : b[1] - a[1]))
410
- .slice(0, 2);
411
- LOGGER.debug(`bestones: ${JSON.stringify(bestones)}`);
412
-
413
- const patterns = bestones.map((item) => item[0]);
414
- const counts = bestones.map((item) => item[1]);
415
-
416
- const years = patterns
417
- .map((pattern) => {
418
- const year_match = yearpat.exec(pattern);
419
- return year_match ? year_match[1] : null;
420
- })
421
- .filter((year) => year !== null);
422
-
423
- const validation = years.map((year) =>
424
- is_valid_date(
425
- new Date(parseInt(year), 0, 1),
426
- "%Y",
427
- options.min,
428
- options.max
429
- )
430
- );
431
-
432
- let match = null;
433
- if (validation.every((v) => v)) {
434
- if (counts[0] === counts[1]) {
435
- match = catch_regex.exec(patterns[0]);
436
- } else if (years[1] !== years[0] && counts[1] / counts[0] > 0.5) {
437
- match = catch_regex.exec(patterns[1]);
438
- } else {
439
- match = catch_regex.exec(patterns[0]);
440
- }
441
- } else if (validation.some((v) => v)) {
442
- match = catch_regex.exec(patterns[validation.indexOf(true)]);
443
- } else {
444
- LOGGER.debug(`no suitable candidate: ${years[0]} ${years[1]}`);
445
- }
446
-
447
- return match;
448
- }
449
-
450
- function search_pattern(htmlstring, pattern, catch_regex, yearpat, options) {
451
- const candidates = plausible_year_filter(
452
- htmlstring,
453
- pattern,
454
- yearpat,
455
- options.min,
456
- options.max
457
- );
458
- return select_candidate(candidates, catch_regex, yearpat, options);
459
- }
460
-
461
- function compare_reference(reference, expression, options) {
462
- const attempt = try_date_expr(
463
- expression,
464
- options.format,
465
- options.extensive,
466
- options.min,
467
- options.max
468
- );
469
- if (attempt !== null) {
470
- return compare_values(reference, attempt, options);
471
- }
472
- return reference;
473
- }
474
-
475
- function examine_abbr_elements(tree, options) {
476
- const elements = tree.querySelectorAll("abbr");
477
- if (elements.length > 0 && elements.length < MAX_POSSIBLE_CANDIDATES) {
478
- let reference = 0;
479
- for (const elem of elements) {
480
- if (elem.hasAttribute("data-utime")) {
481
- try {
482
- const candidate = parseInt(elem.getAttribute("data-utime"));
483
- LOGGER.debug(`data-utime found: ${candidate}`);
484
- if (options.original && (reference === 0 || candidate < reference)) {
485
- reference = candidate;
486
- } else if (!options.original && candidate > reference) {
487
- reference = candidate;
488
- }
489
- } catch (err) {
490
- continue;
491
- }
492
- } else if (CLASS_ATTRS.has(elem.getAttribute("class"))) {
493
- if (elem.hasAttribute("title")) {
494
- const trytext = elem.getAttribute("title");
495
- LOGGER.debug(`abbr published-title found: ${trytext}`);
496
- if (options.original) {
497
- const attempt = try_date_expr(
498
- trytext,
499
- options.format,
500
- options.extensive,
501
- options.min,
502
- options.max
503
- );
504
- if (attempt !== null) {
505
- return attempt;
506
- }
507
- } else {
508
- reference = compare_reference(reference, trytext, options);
509
- if (reference > 0) {
510
- break;
511
- }
512
- }
513
- } else if (elem.textContent && elem.textContent.length > 10) {
514
- LOGGER.debug(`abbr published found: ${elem.textContent}`);
515
- reference = compare_reference(reference, elem.textContent, options);
516
- }
517
- }
518
- }
519
- return (
520
- check_extracted_reference(reference, options) ||
521
- examine_date_elements(tree, "abbr", options)
522
- );
523
- }
524
- return null;
525
- }
526
-
527
- function examine_time_elements(tree, options) {
528
- const elements = tree.querySelectorAll("time");
529
- if (elements.length > 0 && elements.length < MAX_POSSIBLE_CANDIDATES) {
530
- let reference = 0;
531
- for (const elem of elements) {
532
- let shortcut_flag = false;
533
- if (!elem || !elem.getAttribute) continue;
534
-
535
- const datetime_attr = elem.getAttribute("datetime") || "";
536
- if (datetime_attr.length > 6) {
537
- if (
538
- elem.hasAttribute("pubdate") &&
539
- elem.getAttribute("pubdate") === "pubdate" &&
540
- options.original
541
- ) {
542
- shortcut_flag = true;
543
- LOGGER.debug(`shortcut for time pubdate found: ${datetime_attr}`);
544
- } else if (elem.hasAttribute("class")) {
545
- if (
546
- options.original &&
547
- (elem.getAttribute("class").startsWith("entry-date") ||
548
- elem.getAttribute("class").startsWith("entry-time"))
549
- ) {
550
- shortcut_flag = true;
551
- LOGGER.debug(`shortcut for time/datetime found: ${datetime_attr}`);
552
- } else if (
553
- !options.original &&
554
- elem.getAttribute("class") === "updated"
555
- ) {
556
- shortcut_flag = true;
557
- LOGGER.debug(
558
- `shortcut for updated time/datetime found: ${datetime_attr}`
559
- );
560
- }
561
- } else {
562
- LOGGER.debug(`time/datetime found: ${datetime_attr}`);
563
- }
564
-
565
- if (shortcut_flag) {
566
- const attempt = try_date_expr(
567
- datetime_attr,
568
- options.format,
569
- options.extensive,
570
- options.min,
571
- options.max
572
- );
573
- if (attempt !== null) {
574
- return attempt;
575
- }
576
- } else {
577
- reference = compare_reference(reference, datetime_attr, options);
578
- }
579
- } else if (elem.textContent && elem.textContent.length > 6) {
580
- LOGGER.debug(`time/datetime found in text: ${elem.textContent}`);
581
- reference = compare_reference(reference, elem.textContent, options);
582
- }
583
- }
584
- return check_extracted_reference(reference, options);
585
- }
586
- return null;
587
- }
588
-
589
- function normalize_match(match) {
590
- if (!match) return null;
591
- const groups = match.slice(1).filter((g) => g);
592
- let [day, month, year] = groups.map((g) => g.padStart(2, "0"));
593
- if (year.length === 2) {
594
- year = year[0] === "9" ? `19${year}` : `20${year}`;
595
- }
596
- return `${year}-${month}-${day}`;
597
- }
598
-
599
- function search_page(htmlstring, options) {
600
- LOGGER.debug("looking for copyright/footer information");
601
- let copyear = 0;
602
-
603
- let bestmatch = search_pattern(
604
- htmlstring,
605
- COPYRIGHT_PATTERN,
606
- YEAR_PATTERN,
607
- YEAR_PATTERN,
608
- options
609
- );
610
- if (bestmatch) {
611
- const year = parseInt(bestmatch[0]);
612
- if (is_valid_date(new Date(year, 0, 1), "%Y", options.min, options.max)) {
613
- LOGGER.debug(`copyright year/footer pattern found: ${year}`);
614
- copyear = year;
615
- }
616
- }
617
-
618
- LOGGER.debug("3 components");
619
- for (const patterns of THREE_COMP_PATTERNS) {
620
- bestmatch = search_pattern(
621
- htmlstring,
622
- patterns[0],
623
- patterns[1],
624
- YEAR_PATTERN,
625
- options
626
- );
627
- let result = filter_ymd_candidate(
628
- bestmatch,
629
- patterns[0],
630
- options.original,
631
- copyear,
632
- options.format,
633
- options.min,
634
- options.max
635
- );
636
- if (result !== null) {
637
- return result;
638
- }
639
- }
640
-
641
- let candidates = plausible_year_filter(
642
- htmlstring,
643
- SELECT_YMD_PATTERN,
644
- SELECT_YMD_YEAR,
645
- options.min,
646
- options.max
647
- );
648
- let replacement = {};
649
- for (const [item, count] of Object.entries(candidates)) {
650
- const match = THREE_COMP_REGEX_A.exec(item);
651
- if (match) {
652
- const candidate = normalize_match(match);
653
- replacement[candidate] = count;
654
- }
655
- }
656
-
657
- bestmatch = select_candidate(
658
- new Counter(replacement),
659
- YMD_PATTERN,
660
- YMD_YEAR,
661
- options
662
- );
663
- let result = filter_ymd_candidate(
664
- bestmatch,
665
- SELECT_YMD_PATTERN,
666
- options.original,
667
- copyear,
668
- options.format,
669
- options.min,
670
- options.max
671
- );
672
- if (result !== null) {
673
- return result;
674
- }
675
-
676
- bestmatch = search_pattern(
677
- htmlstring,
678
- DATESTRINGS_PATTERN,
679
- DATESTRINGS_CATCH,
680
- YEAR_PATTERN,
681
- options
682
- );
683
- result = filter_ymd_candidate(
684
- bestmatch,
685
- DATESTRINGS_PATTERN,
686
- options.original,
687
- copyear,
688
- options.format,
689
- options.min,
690
- options.max
691
- );
692
- if (result !== null) {
693
- return result;
694
- }
695
-
696
- LOGGER.debug("switching to two components");
697
-
698
- // First option: YYYY-MM pattern
699
- bestmatch = search_pattern(
700
- htmlstring,
701
- YYYYMM_PATTERN,
702
- YYYYMM_CATCH,
703
- YEAR_PATTERN,
704
- options
705
- );
706
-
707
- if (bestmatch) {
708
- const dateObject = new Date(
709
- parseInt(bestmatch[1]),
710
- parseInt(bestmatch[2]) - 1,
711
- 1
712
- );
713
- if (
714
- is_valid_date(dateObject, "%Y-%m-%d", options.min, options.max) &&
715
- (copyear === 0 || dateObject.getFullYear() >= copyear)
716
- ) {
717
- LOGGER.debug(
718
- 'date found for pattern "%s": %s, %s',
719
- YYYYMM_PATTERN,
720
- bestmatch[1],
721
- bestmatch[2]
722
- );
723
- return format_date(dateObject, options.format);
724
- }
725
- }
726
-
727
- // 2 components, second option: MM-YYYY pattern
728
- candidates = plausible_year_filter(
729
- htmlstring,
730
- MMYYYY_PATTERN,
731
- MMYYYY_YEAR,
732
- options.min,
733
- options.max,
734
- options.original
735
- );
736
-
737
- // Revert DD-MM-YYYY patterns before sorting
738
- replacement = {};
739
- for (const [item, count] of Object.entries(candidates)) {
740
- const match = item?.match(TWO_COMP_REGEX);
741
- if (!match) continue;
742
-
743
- let month = match[1];
744
- if (month.length === 1) {
745
- month = `0${month}`;
746
- }
747
- const candidate = `${match[2]}-${month}-01`;
748
- replacement[candidate] = count;
749
- }
750
-
751
- candidates = new Counter(replacement);
752
-
753
- // Select best candidate
754
- bestmatch = select_candidate(candidates, YMD_PATTERN, YMD_YEAR, options);
755
- result = filter_ymd_candidate(
756
- bestmatch,
757
- MMYYYY_PATTERN,
758
- options.original,
759
- copyear,
760
- options.format,
761
- options.min,
762
- options.max
763
- );
764
-
765
- if (result !== null) {
766
- return result;
767
- }
768
-
769
- // Try full-blown text regex on all HTML
770
- const dateObject = regex_parse(htmlstring);
771
-
772
- // Find all candidates and disambiguate
773
- if (
774
- dateObject &&
775
- is_valid_date(dateObject, options.format, options.min, options.max) &&
776
- (copyear === 0 || dateObject.getFullYear() >= copyear)
777
- ) {
778
- try {
779
- LOGGER.debug("regex result on HTML: %s", dateObject);
780
- return format_date(dateObject, options.format);
781
- } catch (err) {
782
- LOGGER.error("value error during conversion: %s %s", dateObject, err);
783
- }
784
- }
785
-
786
- // Catchall: copyright mention
787
- if (copyear !== 0) {
788
- LOGGER.debug("using copyright year as default");
789
- const dateObject = new Date(copyear, 0, 1); // Month is 0-based in JavaScript
790
- return format_date(dateObject, options.format);
791
- }
792
-
793
- LOGGER.debug("switching to one component");
794
- bestmatch = search_pattern(
795
- htmlstring,
796
- SIMPLE_PATTERN,
797
- YEAR_PATTERN,
798
- YEAR_PATTERN,
799
- options
800
- );
801
- if (bestmatch) {
802
- const dateobject = new Date(parseInt(bestmatch[0]), 0, 1);
803
- if (
804
- is_valid_date(dateobject, "%Y-%m-%d", options.min, options.max) &&
805
- dateobject.getFullYear() >= copyear
806
- ) {
807
- LOGGER.debug(
808
- `date found for pattern "${SIMPLE_PATTERN}": ${bestmatch[0]}`
809
- );
810
- return dateobject.toISOString().slice(0, 10);
811
- }
812
- }
813
-
814
- return null;
815
- }
816
-
817
- class Extractor {
818
- extensive: any;
819
- format: any;
820
- max: any;
821
- min: any;
822
- original: any;
823
- constructor(
824
- extensive_search,
825
- max_date,
826
- min_date,
827
- original_date,
828
- outputformat
829
- ) {
830
- this.extensive = extensive_search;
831
- this.format = outputformat;
832
- this.max = max_date;
833
- this.min = min_date;
834
- this.original = original_date;
835
- }
836
- }
837
- class Counter extends Map {
838
- constructor(iterable = null) {
839
- super();
840
- if (iterable === null) {
841
- return;
842
- }
843
-
844
- // Handle string input
845
- if (typeof iterable === "string") {
846
- for (const char of iterable) {
847
- this.set(char, (this.get(char) || 0) + 1);
848
- }
849
- return;
850
- }
851
-
852
- // Handle array input
853
- if (Array.isArray(iterable)) {
854
- for (const item of iterable) {
855
- this.set(item, (this.get(item) || 0) + 1);
856
- }
857
- return;
858
- }
859
-
860
- // Handle object/mapping input
861
- if (typeof iterable === "object") {
862
- for (const [key, value] of Object.entries(iterable)) {
863
- if (value !== 0) {
864
- this.set(key, value);
865
- }
866
- }
867
- return;
868
- }
869
- }
870
-
871
- // Counter-specific methods
872
-
873
- // Return n most common elements and their counts
874
- most_common(n = null) {
875
- const items = Array.from(this.entries());
876
- items.sort((a, b) => b[1] - a[1]);
877
- return n ? items.slice(0, n) : items;
878
- }
879
-
880
- // Return the total of all counts
881
- total() {
882
- return Array.from(this.values()).reduce((a, b) => a + b, 0);
883
- }
884
-
885
- // Return an iterator over elements, repeating each as many times as its count
886
- *elements() {
887
- for (const [item, count] of this.entries()) {
888
- for (let i = 0; i < count; i++) {
889
- yield item;
890
- }
891
- }
892
- }
893
-
894
- // Update counts from an iterable
895
- update(iterable) {
896
- const other =
897
- iterable instanceof Counter ? iterable : new Counter(iterable);
898
- for (const [key, value] of other.entries()) {
899
- this.set(key, (this.get(key) || 0) + value);
900
- if (this.get(key) === 0) {
901
- this.delete(key);
902
- }
903
- }
904
- }
905
-
906
- // Subtract counts from an iterable
907
- subtract(iterable) {
908
- const other =
909
- iterable instanceof Counter ? iterable : new Counter(iterable);
910
- for (const [key, value] of other.entries()) {
911
- this.set(key, (this.get(key) || 0) - value);
912
- if (this.get(key) === 0) {
913
- this.delete(key);
914
- }
915
- }
916
- }
917
- }
918
-
919
- /**
920
- * Extract date from document using various methods
921
- *
922
- * @param {Document} htmlobject - DOM object with article content
923
- * @param {boolean} [extensive_search=true] - perform extensive search if true
924
- * @param {boolean} [original_date=false] - return original date if true
925
- * @param {string} [outputformat="%Y-%m-%d"] - output format
926
- * @param {string} [url=null] - URL of the page
927
- * @param {boolean} [verbose=false] - log debug messages if true
928
- * @param {Date} [min_date=null] - minimum date to consider
929
- * @param {Date} [max_date=null] - maximum date to consider
930
- * @param {boolean} [deferred_url_extractor=false] - if true, do not extract date from URL
931
- * @returns {string|null} Extracted date or null if not found
932
- * @author [vtempest (2025)](https://github.com/vtempest)
933
- * Based on [Barbaresi (2020)](https://github.com/adbar/htmldate/)
934
- */
935
- export function extractDate(
936
- htmlobject,
937
- extensive_search = true,
938
- original_date = false,
939
- outputformat = "%Y-%m-%d",
940
- url = null,
941
- verbose = false,
942
- min_date = null,
943
- max_date = null,
944
- deferred_url_extractor = false
945
- ) {
946
- if (verbose) {
947
- DEBUG = true;
948
- }
949
-
950
- const tree = htmlobject;
951
-
952
- if (!tree) {
953
- return null;
954
- }
955
- if (outputformat !== "%Y-%m-%d" && !is_valid_format(outputformat)) {
956
- return null;
957
- }
958
-
959
- const options = new Extractor(
960
- extensive_search,
961
- get_max_date(max_date),
962
- get_min_date(min_date),
963
- original_date,
964
- outputformat
965
- );
966
-
967
- let url_result = null;
968
- if (!url) {
969
- const urlelem = tree.querySelector('link[rel="canonical"]');
970
- if (urlelem) {
971
- url = urlelem.getAttribute("href");
972
- }
973
- }
974
-
975
- url_result = extract_url_date(url, options);
976
- if (url_result && !deferred_url_extractor) {
977
- return url_result;
978
- }
979
-
980
- let result = examine_header(tree, options) || json_search(tree, options);
981
- if (result) {
982
- return result;
983
- }
984
-
985
- if (deferred_url_extractor && url_result) {
986
- return url_result;
987
- }
988
-
989
- const abbr_result = examine_abbr_elements(tree, options);
990
- if (abbr_result) {
991
- return abbr_result;
992
- }
993
-
994
- let search_tree, discarded;
995
- try {
996
- [search_tree, discarded] = discard_unwanted(tree.cloneNode(true));
997
- } catch (error) {
998
- search_tree = tree;
999
- LOGGER.error("DOM cleaner error");
1000
- }
1001
-
1002
- const date_expr = extensive_search
1003
- ? SLOW_PREPEND + DATE_EXPRESSIONS
1004
- : FAST_PREPEND + DATE_EXPRESSIONS;
1005
-
1006
- result =
1007
- examine_date_elements(search_tree, date_expr, options) ||
1008
- examine_date_elements(search_tree, "title,h1", options) ||
1009
- examine_time_elements(search_tree, options);
1010
- if (result) {
1011
- return result;
1012
- }
1013
-
1014
- const htmlstring = search_tree.innerHTML;
1015
- // console.log(htmlstring)
1016
-
1017
- result =
1018
- pattern_search(htmlstring, TIMESTAMP_PATTERN, options) ||
1019
- img_search(search_tree, options) ||
1020
- idiosyncrasies_search(htmlstring, options);
1021
- if (result) {
1022
- return result;
1023
- }
1024
-
1025
- if (extensive_search) {
1026
- LOGGER.debug("extensive search started");
1027
- let reference = 0;
1028
-
1029
- const textNodes = Array.from(search_tree.childNodes).filter(
1030
- (node: any) => node.nodeType === 3
1031
- );
1032
-
1033
- // Now iterate through them
1034
- for (let i = 0; i < textNodes.length; i++) {
1035
- const segment = (textNodes[i] as any).textContent.trim();
1036
- if (
1037
- segment.length > MIN_SEGMENT_LEN &&
1038
- segment.length < MAX_SEGMENT_LEN
1039
- ) {
1040
- reference = compare_reference(reference, segment, options);
1041
- }
1042
- }
1043
-
1044
- const converted = check_extracted_reference(reference, options);
1045
- return converted || search_page(htmlstring, options);
1046
- }
1047
-
1048
- return null;
1049
- }
1
+ /**
2
+ * @fileoverview Comprehensive date extraction orchestrator for HTML documents.
3
+ * Integrates pattern searching, JSON-LD parsing, and image metadata analysis.
4
+ */
5
+ import {
6
+ discard_unwanted,
7
+ extract_url_date,
8
+ idiosyncrasies_search,
9
+ img_search,
10
+ json_search,
11
+ regex_parse,
12
+ pattern_search,
13
+ try_date_expr,
14
+ DATE_EXPRESSIONS,
15
+ FAST_PREPEND,
16
+ SLOW_PREPEND,
17
+ FREE_TEXT_EXPRESSIONS,
18
+ MAX_SEGMENT_LEN,
19
+ MIN_SEGMENT_LEN,
20
+ YEAR_PATTERN,
21
+ YMD_PATTERN,
22
+ COPYRIGHT_PATTERN,
23
+ TIMESTAMP_PATTERN,
24
+ THREE_PATTERN,
25
+ THREE_CATCH,
26
+ THREE_LOOSE_PATTERN,
27
+ THREE_LOOSE_CATCH,
28
+ SELECT_YMD_PATTERN,
29
+ SELECT_YMD_YEAR,
30
+ YMD_YEAR,
31
+ DATESTRINGS_PATTERN,
32
+ DATESTRINGS_CATCH,
33
+ SLASHES_PATTERN,
34
+ SLASHES_YEAR,
35
+ YYYYMM_PATTERN,
36
+ YYYYMM_CATCH,
37
+ MMYYYY_PATTERN,
38
+ MMYYYY_YEAR,
39
+ SIMPLE_PATTERN,
40
+ THREE_COMP_REGEX_A,
41
+ THREE_COMP_REGEX_B,
42
+ TWO_COMP_REGEX,
43
+ } from "./date-extractors";
44
+
45
+ import {
46
+ check_extracted_reference,
47
+ compare_values,
48
+ filter_ymd_candidate,
49
+ get_min_date,
50
+ get_max_date,
51
+ is_valid_date,
52
+ is_valid_format,
53
+ plausible_year_filter,
54
+ convert_date,
55
+ } from "./date-validators";
56
+
57
+ /**
58
+ * Format a date object into the desired output format
59
+ */
60
+ function format_date(date: Date, format: string): string {
61
+ return convert_date(date, "YYYY-MM-DD", format);
62
+ }
63
+
64
+ // Global debug flag
65
+ let DEBUG = false;
66
+ const LOGGER = {
67
+ debug: (...args) => DEBUG && console.log(...args),
68
+ warn: (...args) => DEBUG && console.warn(...args),
69
+ error: (...args) => console.error(...args),
70
+ };
71
+
72
+ var MAX_POSSIBLE_CANDIDATES = 1000;
73
+
74
+ var CLEANING_LIST = [
75
+ "applet",
76
+ "audio",
77
+ "canvas",
78
+ "datalist",
79
+ "embed",
80
+ "frame",
81
+ "frameset",
82
+ "iframe",
83
+ "label",
84
+ "map",
85
+ "math",
86
+ "noframes",
87
+ "object",
88
+ "picture",
89
+ "rdf",
90
+ "svg",
91
+ "track",
92
+ "video",
93
+ ];
94
+
95
+ // Date-related meta tag attributes
96
+ const DATE_ATTRIBUTES = new Set([
97
+ "analyticsattributes.articledate",
98
+ "article.created",
99
+ "article_date_original",
100
+ "article:post_date",
101
+ "article.published",
102
+ "article:published",
103
+ "article:published_date",
104
+ "article:published_time",
105
+ "article:publicationdate",
106
+ "bt:pubdate",
107
+ "citation_date",
108
+ "citation_publication_date",
109
+ "content_create_date",
110
+ "created",
111
+ "cxenseparse:recs:publishtime",
112
+ "date",
113
+ "date_created",
114
+ "date_published",
115
+ "datecreated",
116
+ "dateposted",
117
+ "datepublished",
118
+ // Dublin Core
119
+ "dc.date",
120
+ "dc.created",
121
+ "dc.date.created",
122
+ "dc.date.issued",
123
+ "dc.date.publication",
124
+ "dcsext.articlefirstpublished",
125
+ "dcterms.created",
126
+ "dcterms.date",
127
+ "dcterms.issued",
128
+ "dc:created",
129
+ "dc:date",
130
+ "displaydate",
131
+ "doc_date",
132
+ "field-name-post-date",
133
+ "gentime",
134
+ "mediator_published_time",
135
+ "meta",
136
+ // Open Graph
137
+ "og:article:published",
138
+ "og:article:published_time",
139
+ "og:datepublished",
140
+ "og:pubdate",
141
+ "og:publish_date",
142
+ "og:published_time",
143
+ "og:question:published_time",
144
+ "og:regdate",
145
+ "originalpublicationdate",
146
+ "parsely-pub-date",
147
+ "pdate",
148
+ "ptime",
149
+ "pubdate",
150
+ "publishdate",
151
+ "publish_date",
152
+ "publish_time",
153
+ "publish-date",
154
+ "published-date",
155
+ "published_date",
156
+ "published_time",
157
+ "publisheddate",
158
+ "publication_date",
159
+ "rbpubdate",
160
+ "release_date",
161
+ "rnews:datepublished",
162
+ "sailthru.date",
163
+ "shareaholic:article_published_time",
164
+ "timestamp",
165
+ "twt-published-at",
166
+ "video:release_date",
167
+ "vr:published_time",
168
+ ]);
169
+
170
+ // Modified date attributes
171
+ const NAME_MODIFIED = new Set([
172
+ "lastdate",
173
+ "lastmod",
174
+ "lastmodified",
175
+ "last-modified",
176
+ "modified",
177
+ "utime",
178
+ ]);
179
+
180
+ // Property modified attributes
181
+ const PROPERTY_MODIFIED = new Set([
182
+ "article:modified",
183
+ "article:modified_date",
184
+ "article:modified_time",
185
+ "article:post_modified",
186
+ "bt:moddate",
187
+ "datemodified",
188
+ "dc.modified",
189
+ "dcterms.modified",
190
+ "lastmodified",
191
+ "modified_time",
192
+ "modificationdate",
193
+ "og:article:modified_time",
194
+ "og:modified_time",
195
+ "og:updated_time",
196
+ "release_date",
197
+ "revision_date",
198
+ "updated_time",
199
+ ]);
200
+
201
+ // ItemProp attributes
202
+ const ITEMPROP_ATTRS_ORIGINAL = new Set([
203
+ "datecreated",
204
+ "datepublished",
205
+ "pubyear",
206
+ ]);
207
+
208
+ const ITEMPROP_ATTRS_MODIFIED = new Set(["datemodified", "dateupdate"]);
209
+
210
+ // Combine ItemProp sets
211
+ const ITEMPROP_ATTRS = new Set([
212
+ ...ITEMPROP_ATTRS_ORIGINAL,
213
+ ...ITEMPROP_ATTRS_MODIFIED,
214
+ ]);
215
+
216
+ // Class attributes
217
+ const CLASS_ATTRS = new Set(["date-published", "published", "time published"]);
218
+
219
+ // Regular expressions
220
+ const NON_DIGITS_REGEX = /\D+$/;
221
+
222
+ // Note: THREE_PATTERN and THREE_CATCH would need to be defined
223
+ const THREE_COMP_PATTERNS = [
224
+ [THREE_PATTERN, THREE_CATCH],
225
+ [THREE_LOOSE_PATTERN, THREE_LOOSE_CATCH],
226
+ ];
227
+
228
+ // Export all constants
229
+ export {
230
+ DATE_ATTRIBUTES,
231
+ NAME_MODIFIED,
232
+ PROPERTY_MODIFIED,
233
+ ITEMPROP_ATTRS_ORIGINAL,
234
+ ITEMPROP_ATTRS_MODIFIED,
235
+ ITEMPROP_ATTRS,
236
+ CLASS_ATTRS,
237
+ NON_DIGITS_REGEX,
238
+ // THREE_COMP_PATTERNS // Uncomment once patterns are defined
239
+ };
240
+
241
+ function examine_text(text, options) {
242
+ if (text.length <= MIN_SEGMENT_LEN) {
243
+ return null;
244
+ }
245
+
246
+ text = text.slice(0, MAX_SEGMENT_LEN).replace(NON_DIGITS_REGEX, "");
247
+ return try_date_expr(
248
+ text,
249
+ options.format,
250
+ options.extensive,
251
+ options.min,
252
+ options.max
253
+ );
254
+ }
255
+
256
+ function examine_date_elements(tree, expression, options) {
257
+ const elements = tree.querySelectorAll(expression);
258
+ if (!elements || elements.length > MAX_POSSIBLE_CANDIDATES) {
259
+ return null;
260
+ }
261
+
262
+ for (const elem of elements) {
263
+ for (const text of [elem.textContent, elem.getAttribute("title") || ""]) {
264
+ const attempt = examine_text(text, options);
265
+ if (attempt) {
266
+ return attempt;
267
+ }
268
+ }
269
+ }
270
+
271
+ return null;
272
+ }
273
+
274
+ function examine_header(tree, options) {
275
+ let headerdate = null;
276
+ let reserve = null;
277
+ const tryfunc = (content) =>
278
+ try_date_expr(
279
+ content,
280
+ options.format,
281
+ options.extensive,
282
+ options.min,
283
+ options.max
284
+ );
285
+
286
+ for (const elem of tree.querySelectorAll("meta")) {
287
+ if (
288
+ !elem.attributes.length ||
289
+ (!elem.getAttribute("content") && !elem.getAttribute("datetime"))
290
+ ) {
291
+ continue;
292
+ }
293
+
294
+ if (elem.hasAttribute("name")) {
295
+ const attribute = elem.getAttribute("name")?.toLowerCase();
296
+ if (attribute === "og:url") {
297
+ reserve = extract_url_date(elem.getAttribute("content"), options);
298
+ } else if (DATE_ATTRIBUTES.has(attribute)) {
299
+ LOGGER.debug(`examining meta name: ${elem.outerHTML}`);
300
+ headerdate = tryfunc(elem.getAttribute("content"));
301
+ } else if (NAME_MODIFIED.has(attribute)) {
302
+ LOGGER.debug(`examining meta name: ${elem.outerHTML}`);
303
+ if (!options.original) {
304
+ headerdate = tryfunc(elem.getAttribute("content"));
305
+ } else {
306
+ reserve = tryfunc(elem.getAttribute("content"));
307
+ }
308
+ }
309
+ } else if (elem.hasAttribute("property")) {
310
+ const attribute = elem.getAttribute("property").toLowerCase();
311
+ if (DATE_ATTRIBUTES.has(attribute) || PROPERTY_MODIFIED.has(attribute)) {
312
+ LOGGER.debug(`examining meta property: ${elem.outerHTML}`);
313
+ const attempt = tryfunc(elem.getAttribute("content"));
314
+ if (attempt !== null) {
315
+ if (
316
+ (DATE_ATTRIBUTES.has(attribute) && options.original) ||
317
+ (PROPERTY_MODIFIED.has(attribute) && !options.original)
318
+ ) {
319
+ headerdate = attempt;
320
+ } else {
321
+ reserve = attempt;
322
+ }
323
+ }
324
+ }
325
+ } else if (elem.hasAttribute("itemprop")) {
326
+ const attribute = elem.getAttribute("itemprop").toLowerCase();
327
+ if (ITEMPROP_ATTRS.has(attribute)) {
328
+ LOGGER.debug(`examining meta itemprop: ${elem.outerHTML}`);
329
+ const attempt = tryfunc(
330
+ elem.getAttribute("datetime") || elem.getAttribute("content")
331
+ );
332
+ if (attempt !== null) {
333
+ if (
334
+ (ITEMPROP_ATTRS_ORIGINAL.has(attribute) && options.original) ||
335
+ (ITEMPROP_ATTRS_MODIFIED.has(attribute) && !options.original)
336
+ ) {
337
+ headerdate = attempt;
338
+ }
339
+ }
340
+ } else if (attribute === "copyrightyear") {
341
+ LOGGER.debug(`examining meta itemprop: ${elem.outerHTML}`);
342
+ if (elem.hasAttribute("content")) {
343
+ const attempt = `${elem.getAttribute("content")}-01-01`;
344
+ if (is_valid_date(attempt, "%Y-%m-%d", options.min, options.max)) {
345
+ reserve = attempt;
346
+ }
347
+ }
348
+ }
349
+ } else if (elem.hasAttribute("pubdate")) {
350
+ if (elem.getAttribute("pubdate").toLowerCase() === "pubdate") {
351
+ LOGGER.debug(`examining meta pubdate: ${elem.outerHTML}`);
352
+ headerdate = tryfunc(elem.getAttribute("content"));
353
+ }
354
+ } else if (elem.hasAttribute("http-equiv")) {
355
+ const attribute = elem.getAttribute("http-equiv").toLowerCase();
356
+ if (attribute === "date") {
357
+ LOGGER.debug(`examining meta http-equiv: ${elem.outerHTML}`);
358
+ if (options.original) {
359
+ headerdate = tryfunc(elem.getAttribute("content"));
360
+ } else {
361
+ reserve = tryfunc(elem.getAttribute("content"));
362
+ }
363
+ } else if (attribute === "last-modified") {
364
+ LOGGER.debug(`examining meta http-equiv: ${elem.outerHTML}`);
365
+ if (!options.original) {
366
+ headerdate = tryfunc(elem.getAttribute("content"));
367
+ } else {
368
+ reserve = tryfunc(elem.getAttribute("content"));
369
+ }
370
+ }
371
+ }
372
+
373
+ if (headerdate !== null) {
374
+ break;
375
+ }
376
+ }
377
+
378
+ if (headerdate === null && reserve !== null) {
379
+ LOGGER.debug("opting for reserve date with less granularity");
380
+ headerdate = reserve;
381
+ }
382
+
383
+ return headerdate;
384
+ }
385
+
386
+ function select_candidate(occurrences, catch_regex, yearpat, options) {
387
+ if (!occurrences || occurrences.size > MAX_POSSIBLE_CANDIDATES) {
388
+ return null;
389
+ }
390
+
391
+ // console.log( JSON.stringify(occurrences) + " 444444444444" + occurrences + " 444444444444")
392
+
393
+ if (occurrences.size === 1) {
394
+ const pattern = occurrences.keys().next().value;
395
+ return catch_regex.exec(pattern);
396
+ }
397
+ ///if null
398
+ // if (occurrences.size === 0) {
399
+ return occurrences;
400
+ //todo fix
401
+ // }
402
+
403
+ const firstselect = Array.from(occurrences.entries())
404
+ .sort((a, b) => b[1] - a[1])
405
+ .slice(0, 10);
406
+ LOGGER.debug(`firstselect: ${JSON.stringify(firstselect)}`);
407
+
408
+ const bestones = firstselect
409
+ .sort((a, b) => (options.original ? a[1] - b[1] : b[1] - a[1]))
410
+ .slice(0, 2);
411
+ LOGGER.debug(`bestones: ${JSON.stringify(bestones)}`);
412
+
413
+ const patterns = bestones.map((item) => item[0]);
414
+ const counts = bestones.map((item) => item[1]);
415
+
416
+ const years = patterns
417
+ .map((pattern) => {
418
+ const year_match = yearpat.exec(pattern);
419
+ return year_match ? year_match[1] : null;
420
+ })
421
+ .filter((year) => year !== null);
422
+
423
+ const validation = years.map((year) =>
424
+ is_valid_date(
425
+ new Date(parseInt(year), 0, 1),
426
+ "%Y",
427
+ options.min,
428
+ options.max
429
+ )
430
+ );
431
+
432
+ let match = null;
433
+ if (validation.every((v) => v)) {
434
+ if (counts[0] === counts[1]) {
435
+ match = catch_regex.exec(patterns[0]);
436
+ } else if (years[1] !== years[0] && counts[1] / counts[0] > 0.5) {
437
+ match = catch_regex.exec(patterns[1]);
438
+ } else {
439
+ match = catch_regex.exec(patterns[0]);
440
+ }
441
+ } else if (validation.some((v) => v)) {
442
+ match = catch_regex.exec(patterns[validation.indexOf(true)]);
443
+ } else {
444
+ LOGGER.debug(`no suitable candidate: ${years[0]} ${years[1]}`);
445
+ }
446
+
447
+ return match;
448
+ }
449
+
450
+ function search_pattern(htmlstring, pattern, catch_regex, yearpat, options) {
451
+ const candidates = plausible_year_filter(
452
+ htmlstring,
453
+ pattern,
454
+ yearpat,
455
+ options.min,
456
+ options.max
457
+ );
458
+ return select_candidate(candidates, catch_regex, yearpat, options);
459
+ }
460
+
461
+ function compare_reference(reference, expression, options) {
462
+ const attempt = try_date_expr(
463
+ expression,
464
+ options.format,
465
+ options.extensive,
466
+ options.min,
467
+ options.max
468
+ );
469
+ if (attempt !== null) {
470
+ return compare_values(reference, attempt, options);
471
+ }
472
+ return reference;
473
+ }
474
+
475
+ function examine_abbr_elements(tree, options) {
476
+ const elements = tree.querySelectorAll("abbr");
477
+ if (elements.length > 0 && elements.length < MAX_POSSIBLE_CANDIDATES) {
478
+ let reference = 0;
479
+ for (const elem of elements) {
480
+ if (elem.hasAttribute("data-utime")) {
481
+ try {
482
+ const candidate = parseInt(elem.getAttribute("data-utime"));
483
+ LOGGER.debug(`data-utime found: ${candidate}`);
484
+ if (options.original && (reference === 0 || candidate < reference)) {
485
+ reference = candidate;
486
+ } else if (!options.original && candidate > reference) {
487
+ reference = candidate;
488
+ }
489
+ } catch (err) {
490
+ continue;
491
+ }
492
+ } else if (CLASS_ATTRS.has(elem.getAttribute("class"))) {
493
+ if (elem.hasAttribute("title")) {
494
+ const trytext = elem.getAttribute("title");
495
+ LOGGER.debug(`abbr published-title found: ${trytext}`);
496
+ if (options.original) {
497
+ const attempt = try_date_expr(
498
+ trytext,
499
+ options.format,
500
+ options.extensive,
501
+ options.min,
502
+ options.max
503
+ );
504
+ if (attempt !== null) {
505
+ return attempt;
506
+ }
507
+ } else {
508
+ reference = compare_reference(reference, trytext, options);
509
+ if (reference > 0) {
510
+ break;
511
+ }
512
+ }
513
+ } else if (elem.textContent && elem.textContent.length > 10) {
514
+ LOGGER.debug(`abbr published found: ${elem.textContent}`);
515
+ reference = compare_reference(reference, elem.textContent, options);
516
+ }
517
+ }
518
+ }
519
+ return (
520
+ check_extracted_reference(reference, options) ||
521
+ examine_date_elements(tree, "abbr", options)
522
+ );
523
+ }
524
+ return null;
525
+ }
526
+
527
+ function examine_time_elements(tree, options) {
528
+ const elements = tree.querySelectorAll("time");
529
+ if (elements.length > 0 && elements.length < MAX_POSSIBLE_CANDIDATES) {
530
+ let reference = 0;
531
+ for (const elem of elements) {
532
+ let shortcut_flag = false;
533
+ if (!elem || !elem.getAttribute) continue;
534
+
535
+ const datetime_attr = elem.getAttribute("datetime") || "";
536
+ if (datetime_attr.length > 6) {
537
+ if (
538
+ elem.hasAttribute("pubdate") &&
539
+ elem.getAttribute("pubdate") === "pubdate" &&
540
+ options.original
541
+ ) {
542
+ shortcut_flag = true;
543
+ LOGGER.debug(`shortcut for time pubdate found: ${datetime_attr}`);
544
+ } else if (elem.hasAttribute("class")) {
545
+ if (
546
+ options.original &&
547
+ (elem.getAttribute("class").startsWith("entry-date") ||
548
+ elem.getAttribute("class").startsWith("entry-time"))
549
+ ) {
550
+ shortcut_flag = true;
551
+ LOGGER.debug(`shortcut for time/datetime found: ${datetime_attr}`);
552
+ } else if (
553
+ !options.original &&
554
+ elem.getAttribute("class") === "updated"
555
+ ) {
556
+ shortcut_flag = true;
557
+ LOGGER.debug(
558
+ `shortcut for updated time/datetime found: ${datetime_attr}`
559
+ );
560
+ }
561
+ } else {
562
+ LOGGER.debug(`time/datetime found: ${datetime_attr}`);
563
+ }
564
+
565
+ if (shortcut_flag) {
566
+ const attempt = try_date_expr(
567
+ datetime_attr,
568
+ options.format,
569
+ options.extensive,
570
+ options.min,
571
+ options.max
572
+ );
573
+ if (attempt !== null) {
574
+ return attempt;
575
+ }
576
+ } else {
577
+ reference = compare_reference(reference, datetime_attr, options);
578
+ }
579
+ } else if (elem.textContent && elem.textContent.length > 6) {
580
+ LOGGER.debug(`time/datetime found in text: ${elem.textContent}`);
581
+ reference = compare_reference(reference, elem.textContent, options);
582
+ }
583
+ }
584
+ return check_extracted_reference(reference, options);
585
+ }
586
+ return null;
587
+ }
588
+
589
+ function normalize_match(match) {
590
+ if (!match) return null;
591
+ const groups = match.slice(1).filter((g) => g);
592
+ let [day, month, year] = groups.map((g) => g.padStart(2, "0"));
593
+ if (year.length === 2) {
594
+ year = year[0] === "9" ? `19${year}` : `20${year}`;
595
+ }
596
+ return `${year}-${month}-${day}`;
597
+ }
598
+
599
+ function search_page(htmlstring, options) {
600
+ LOGGER.debug("looking for copyright/footer information");
601
+ let copyear = 0;
602
+
603
+ let bestmatch = search_pattern(
604
+ htmlstring,
605
+ COPYRIGHT_PATTERN,
606
+ YEAR_PATTERN,
607
+ YEAR_PATTERN,
608
+ options
609
+ );
610
+ if (bestmatch) {
611
+ const year = parseInt(bestmatch[0]);
612
+ if (is_valid_date(new Date(year, 0, 1), "%Y", options.min, options.max)) {
613
+ LOGGER.debug(`copyright year/footer pattern found: ${year}`);
614
+ copyear = year;
615
+ }
616
+ }
617
+
618
+ LOGGER.debug("3 components");
619
+ for (const patterns of THREE_COMP_PATTERNS) {
620
+ bestmatch = search_pattern(
621
+ htmlstring,
622
+ patterns[0],
623
+ patterns[1],
624
+ YEAR_PATTERN,
625
+ options
626
+ );
627
+ let result = filter_ymd_candidate(
628
+ bestmatch,
629
+ patterns[0],
630
+ options.original,
631
+ copyear,
632
+ options.format,
633
+ options.min,
634
+ options.max
635
+ );
636
+ if (result !== null) {
637
+ return result;
638
+ }
639
+ }
640
+
641
+ let candidates = plausible_year_filter(
642
+ htmlstring,
643
+ SELECT_YMD_PATTERN,
644
+ SELECT_YMD_YEAR,
645
+ options.min,
646
+ options.max
647
+ );
648
+ let replacement = {};
649
+ for (const [item, count] of Object.entries(candidates)) {
650
+ const match = THREE_COMP_REGEX_A.exec(item);
651
+ if (match) {
652
+ const candidate = normalize_match(match);
653
+ replacement[candidate] = count;
654
+ }
655
+ }
656
+
657
+ bestmatch = select_candidate(
658
+ new Counter(replacement),
659
+ YMD_PATTERN,
660
+ YMD_YEAR,
661
+ options
662
+ );
663
+ let result = filter_ymd_candidate(
664
+ bestmatch,
665
+ SELECT_YMD_PATTERN,
666
+ options.original,
667
+ copyear,
668
+ options.format,
669
+ options.min,
670
+ options.max
671
+ );
672
+ if (result !== null) {
673
+ return result;
674
+ }
675
+
676
+ bestmatch = search_pattern(
677
+ htmlstring,
678
+ DATESTRINGS_PATTERN,
679
+ DATESTRINGS_CATCH,
680
+ YEAR_PATTERN,
681
+ options
682
+ );
683
+ result = filter_ymd_candidate(
684
+ bestmatch,
685
+ DATESTRINGS_PATTERN,
686
+ options.original,
687
+ copyear,
688
+ options.format,
689
+ options.min,
690
+ options.max
691
+ );
692
+ if (result !== null) {
693
+ return result;
694
+ }
695
+
696
+ LOGGER.debug("switching to two components");
697
+
698
+ // First option: YYYY-MM pattern
699
+ bestmatch = search_pattern(
700
+ htmlstring,
701
+ YYYYMM_PATTERN,
702
+ YYYYMM_CATCH,
703
+ YEAR_PATTERN,
704
+ options
705
+ );
706
+
707
+ if (bestmatch) {
708
+ const dateObject = new Date(
709
+ parseInt(bestmatch[1]),
710
+ parseInt(bestmatch[2]) - 1,
711
+ 1
712
+ );
713
+ if (
714
+ is_valid_date(dateObject, "%Y-%m-%d", options.min, options.max) &&
715
+ (copyear === 0 || dateObject.getFullYear() >= copyear)
716
+ ) {
717
+ LOGGER.debug(
718
+ 'date found for pattern "%s": %s, %s',
719
+ YYYYMM_PATTERN,
720
+ bestmatch[1],
721
+ bestmatch[2]
722
+ );
723
+ return format_date(dateObject, options.format);
724
+ }
725
+ }
726
+
727
+ // 2 components, second option: MM-YYYY pattern
728
+ candidates = plausible_year_filter(
729
+ htmlstring,
730
+ MMYYYY_PATTERN,
731
+ MMYYYY_YEAR,
732
+ options.min,
733
+ options.max,
734
+ options.original
735
+ );
736
+
737
+ // Revert DD-MM-YYYY patterns before sorting
738
+ replacement = {};
739
+ for (const [item, count] of Object.entries(candidates)) {
740
+ const match = item?.match(TWO_COMP_REGEX);
741
+ if (!match) continue;
742
+
743
+ let month = match[1];
744
+ if (month.length === 1) {
745
+ month = `0${month}`;
746
+ }
747
+ const candidate = `${match[2]}-${month}-01`;
748
+ replacement[candidate] = count;
749
+ }
750
+
751
+ candidates = new Counter(replacement);
752
+
753
+ // Select best candidate
754
+ bestmatch = select_candidate(candidates, YMD_PATTERN, YMD_YEAR, options);
755
+ result = filter_ymd_candidate(
756
+ bestmatch,
757
+ MMYYYY_PATTERN,
758
+ options.original,
759
+ copyear,
760
+ options.format,
761
+ options.min,
762
+ options.max
763
+ );
764
+
765
+ if (result !== null) {
766
+ return result;
767
+ }
768
+
769
+ // Try full-blown text regex on all HTML
770
+ const dateObject = regex_parse(htmlstring);
771
+
772
+ // Find all candidates and disambiguate
773
+ if (
774
+ dateObject &&
775
+ is_valid_date(dateObject, options.format, options.min, options.max) &&
776
+ (copyear === 0 || dateObject.getFullYear() >= copyear)
777
+ ) {
778
+ try {
779
+ LOGGER.debug("regex result on HTML: %s", dateObject);
780
+ return format_date(dateObject, options.format);
781
+ } catch (err) {
782
+ LOGGER.error("value error during conversion: %s %s", dateObject, err);
783
+ }
784
+ }
785
+
786
+ // Catchall: copyright mention
787
+ if (copyear !== 0) {
788
+ LOGGER.debug("using copyright year as default");
789
+ const dateObject = new Date(copyear, 0, 1); // Month is 0-based in JavaScript
790
+ return format_date(dateObject, options.format);
791
+ }
792
+
793
+ LOGGER.debug("switching to one component");
794
+ bestmatch = search_pattern(
795
+ htmlstring,
796
+ SIMPLE_PATTERN,
797
+ YEAR_PATTERN,
798
+ YEAR_PATTERN,
799
+ options
800
+ );
801
+ if (bestmatch) {
802
+ const dateobject = new Date(parseInt(bestmatch[0]), 0, 1);
803
+ if (
804
+ is_valid_date(dateobject, "%Y-%m-%d", options.min, options.max) &&
805
+ dateobject.getFullYear() >= copyear
806
+ ) {
807
+ LOGGER.debug(
808
+ `date found for pattern "${SIMPLE_PATTERN}": ${bestmatch[0]}`
809
+ );
810
+ return dateobject.toISOString().slice(0, 10);
811
+ }
812
+ }
813
+
814
+ return null;
815
+ }
816
+
817
+ class Extractor {
818
+ extensive: any;
819
+ format: any;
820
+ max: any;
821
+ min: any;
822
+ original: any;
823
+ constructor(
824
+ extensive_search,
825
+ max_date,
826
+ min_date,
827
+ original_date,
828
+ outputformat
829
+ ) {
830
+ this.extensive = extensive_search;
831
+ this.format = outputformat;
832
+ this.max = max_date;
833
+ this.min = min_date;
834
+ this.original = original_date;
835
+ }
836
+ }
837
+ class Counter extends Map {
838
+ constructor(iterable = null) {
839
+ super();
840
+ if (iterable === null) {
841
+ return;
842
+ }
843
+
844
+ // Handle string input
845
+ if (typeof iterable === "string") {
846
+ for (const char of iterable) {
847
+ this.set(char, (this.get(char) || 0) + 1);
848
+ }
849
+ return;
850
+ }
851
+
852
+ // Handle array input
853
+ if (Array.isArray(iterable)) {
854
+ for (const item of iterable) {
855
+ this.set(item, (this.get(item) || 0) + 1);
856
+ }
857
+ return;
858
+ }
859
+
860
+ // Handle object/mapping input
861
+ if (typeof iterable === "object") {
862
+ for (const [key, value] of Object.entries(iterable)) {
863
+ if (value !== 0) {
864
+ this.set(key, value);
865
+ }
866
+ }
867
+ return;
868
+ }
869
+ }
870
+
871
+ // Counter-specific methods
872
+
873
+ // Return n most common elements and their counts
874
+ most_common(n = null) {
875
+ const items = Array.from(this.entries());
876
+ items.sort((a, b) => b[1] - a[1]);
877
+ return n ? items.slice(0, n) : items;
878
+ }
879
+
880
+ // Return the total of all counts
881
+ total() {
882
+ return Array.from(this.values()).reduce((a, b) => a + b, 0);
883
+ }
884
+
885
+ // Return an iterator over elements, repeating each as many times as its count
886
+ *elements() {
887
+ for (const [item, count] of this.entries()) {
888
+ for (let i = 0; i < count; i++) {
889
+ yield item;
890
+ }
891
+ }
892
+ }
893
+
894
+ // Update counts from an iterable
895
+ update(iterable) {
896
+ const other =
897
+ iterable instanceof Counter ? iterable : new Counter(iterable);
898
+ for (const [key, value] of other.entries()) {
899
+ this.set(key, (this.get(key) || 0) + value);
900
+ if (this.get(key) === 0) {
901
+ this.delete(key);
902
+ }
903
+ }
904
+ }
905
+
906
+ // Subtract counts from an iterable
907
+ subtract(iterable) {
908
+ const other =
909
+ iterable instanceof Counter ? iterable : new Counter(iterable);
910
+ for (const [key, value] of other.entries()) {
911
+ this.set(key, (this.get(key) || 0) - value);
912
+ if (this.get(key) === 0) {
913
+ this.delete(key);
914
+ }
915
+ }
916
+ }
917
+ }
918
+
919
+ /**
920
+ * Extract date from document using various methods
921
+ *
922
+ * @param {Document} htmlobject - DOM object with article content
923
+ * @param {boolean} [extensive_search=true] - perform extensive search if true
924
+ * @param {boolean} [original_date=false] - return original date if true
925
+ * @param {string} [outputformat="%Y-%m-%d"] - output format
926
+ * @param {string} [url=null] - URL of the page
927
+ * @param {boolean} [verbose=false] - log debug messages if true
928
+ * @param {Date} [min_date=null] - minimum date to consider
929
+ * @param {Date} [max_date=null] - maximum date to consider
930
+ * @param {boolean} [deferred_url_extractor=false] - if true, do not extract date from URL
931
+ * @returns {string|null} Extracted date or null if not found
932
+ * @author [vtempest (2025)](https://github.com/vtempest)
933
+ * Based on [Barbaresi (2020)](https://github.com/adbar/htmldate/)
934
+ */
935
+ export function extractDate(
936
+ htmlobject,
937
+ extensive_search = true,
938
+ original_date = false,
939
+ outputformat = "%Y-%m-%d",
940
+ url = null,
941
+ verbose = false,
942
+ min_date = null,
943
+ max_date = null,
944
+ deferred_url_extractor = false
945
+ ) {
946
+ if (verbose) {
947
+ DEBUG = true;
948
+ }
949
+
950
+ const tree = htmlobject;
951
+
952
+ if (!tree) {
953
+ return null;
954
+ }
955
+ if (outputformat !== "%Y-%m-%d" && !is_valid_format(outputformat)) {
956
+ return null;
957
+ }
958
+
959
+ const options = new Extractor(
960
+ extensive_search,
961
+ get_max_date(max_date),
962
+ get_min_date(min_date),
963
+ original_date,
964
+ outputformat
965
+ );
966
+
967
+ let url_result = null;
968
+ if (!url) {
969
+ const urlelem = tree.querySelector('link[rel="canonical"]');
970
+ if (urlelem) {
971
+ url = urlelem.getAttribute("href");
972
+ }
973
+ }
974
+
975
+ url_result = extract_url_date(url, options);
976
+ if (url_result && !deferred_url_extractor) {
977
+ return url_result;
978
+ }
979
+
980
+ let result = examine_header(tree, options) || json_search(tree, options);
981
+ if (result) {
982
+ return result;
983
+ }
984
+
985
+ if (deferred_url_extractor && url_result) {
986
+ return url_result;
987
+ }
988
+
989
+ const abbr_result = examine_abbr_elements(tree, options);
990
+ if (abbr_result) {
991
+ return abbr_result;
992
+ }
993
+
994
+ let search_tree, discarded;
995
+ try {
996
+ [search_tree, discarded] = discard_unwanted(tree.cloneNode(true));
997
+ } catch (error) {
998
+ search_tree = tree;
999
+ LOGGER.error("DOM cleaner error");
1000
+ }
1001
+
1002
+ const date_expr = extensive_search
1003
+ ? SLOW_PREPEND + DATE_EXPRESSIONS
1004
+ : FAST_PREPEND + DATE_EXPRESSIONS;
1005
+
1006
+ result =
1007
+ examine_date_elements(search_tree, date_expr, options) ||
1008
+ examine_date_elements(search_tree, "title,h1", options) ||
1009
+ examine_time_elements(search_tree, options);
1010
+ if (result) {
1011
+ return result;
1012
+ }
1013
+
1014
+ const htmlstring = search_tree.innerHTML;
1015
+ // console.log(htmlstring)
1016
+
1017
+ result =
1018
+ pattern_search(htmlstring, TIMESTAMP_PATTERN, options) ||
1019
+ img_search(search_tree, options) ||
1020
+ idiosyncrasies_search(htmlstring, options);
1021
+ if (result) {
1022
+ return result;
1023
+ }
1024
+
1025
+ if (extensive_search) {
1026
+ LOGGER.debug("extensive search started");
1027
+ let reference = 0;
1028
+
1029
+ const textNodes = Array.from(search_tree.childNodes).filter(
1030
+ (node: any) => node.nodeType === 3
1031
+ );
1032
+
1033
+ // Now iterate through them
1034
+ for (let i = 0; i < textNodes.length; i++) {
1035
+ const segment = (textNodes[i] as any).textContent.trim();
1036
+ if (
1037
+ segment.length > MIN_SEGMENT_LEN &&
1038
+ segment.length < MAX_SEGMENT_LEN
1039
+ ) {
1040
+ reference = compare_reference(reference, segment, options);
1041
+ }
1042
+ }
1043
+
1044
+ const converted = check_extracted_reference(reference, options);
1045
+ return converted || search_page(htmlstring, options);
1046
+ }
1047
+
1048
+ return null;
1049
+ }