extract-cite 1.0.0

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.
Files changed (47) hide show
  1. package/README.md +56 -0
  2. package/dist/extract-cite.cjs.js +2 -0
  3. package/dist/extract-cite.cjs.js.map +1 -0
  4. package/dist/extract-cite.es.js +4 -0
  5. package/dist/extract-cite.es.js.map +1 -0
  6. package/dist/html-to-cite/extract-author.d.ts +11 -0
  7. package/dist/html-to-cite/extract-cite.d.ts +33 -0
  8. package/dist/html-to-cite/extract-date/date-extractors.d.ts +40 -0
  9. package/dist/html-to-cite/extract-date/date-validators.d.ts +15 -0
  10. package/dist/html-to-cite/extract-date/extract-date-quick.d.ts +8 -0
  11. package/dist/html-to-cite/extract-date/extract-date.d.ts +26 -0
  12. package/dist/html-to-cite/extract-source.d.ts +11 -0
  13. package/dist/html-to-cite/extract-title.d.ts +11 -0
  14. package/dist/html-to-cite/human-names-recognize.d.ts +16 -0
  15. package/dist/html-to-cite/metadata-to-cite.d.ts +16 -0
  16. package/dist/html-to-cite/url-to-domain.d.ts +20 -0
  17. package/dist/index.d.ts +14 -0
  18. package/dist/llm/call-llm.d.ts +33 -0
  19. package/dist/llm/extract-cite-llm.d.ts +19 -0
  20. package/dist/llm/format-citation.d.ts +22 -0
  21. package/dist/llm/index.d.ts +7 -0
  22. package/dist/llm/page-text.d.ts +21 -0
  23. package/dist/llm/parse-reply.d.ts +26 -0
  24. package/dist/llm/prompt.d.ts +14 -0
  25. package/dist/llm/types.d.ts +115 -0
  26. package/package.json +61 -0
  27. package/src/html-to-cite/extract-author.ts +125 -0
  28. package/src/html-to-cite/extract-cite.ts +97 -0
  29. package/src/html-to-cite/extract-date/date-extractors.ts +509 -0
  30. package/src/html-to-cite/extract-date/date-validators.ts +191 -0
  31. package/src/html-to-cite/extract-date/extract-date-quick.ts +184 -0
  32. package/src/html-to-cite/extract-date/extract-date.ts +1051 -0
  33. package/src/html-to-cite/extract-source.ts +35 -0
  34. package/src/html-to-cite/extract-title.ts +78 -0
  35. package/src/html-to-cite/human-names-92k.json +1 -0
  36. package/src/html-to-cite/human-names-recognize.ts +396 -0
  37. package/src/html-to-cite/metadata-to-cite.ts +77 -0
  38. package/src/html-to-cite/url-to-domain.ts +50 -0
  39. package/src/index.ts +14 -0
  40. package/src/llm/call-llm.ts +112 -0
  41. package/src/llm/extract-cite-llm.ts +100 -0
  42. package/src/llm/format-citation.ts +399 -0
  43. package/src/llm/index.ts +7 -0
  44. package/src/llm/page-text.ts +106 -0
  45. package/src/llm/parse-reply.ts +279 -0
  46. package/src/llm/prompt.ts +95 -0
  47. package/src/llm/types.ts +174 -0
@@ -0,0 +1,1051 @@
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
+ } else {
339
+ reserve = attempt;
340
+ }
341
+ }
342
+ } else if (attribute === "copyrightyear") {
343
+ LOGGER.debug(`examining meta itemprop: ${elem.outerHTML}`);
344
+ if (elem.hasAttribute("content")) {
345
+ const attempt = `${elem.getAttribute("content")}-01-01`;
346
+ if (is_valid_date(attempt, "%Y-%m-%d", options.min, options.max)) {
347
+ reserve = attempt;
348
+ }
349
+ }
350
+ }
351
+ } else if (elem.hasAttribute("pubdate")) {
352
+ if (elem.getAttribute("pubdate").toLowerCase() === "pubdate") {
353
+ LOGGER.debug(`examining meta pubdate: ${elem.outerHTML}`);
354
+ headerdate = tryfunc(elem.getAttribute("content"));
355
+ }
356
+ } else if (elem.hasAttribute("http-equiv")) {
357
+ const attribute = elem.getAttribute("http-equiv").toLowerCase();
358
+ if (attribute === "date") {
359
+ LOGGER.debug(`examining meta http-equiv: ${elem.outerHTML}`);
360
+ if (options.original) {
361
+ headerdate = tryfunc(elem.getAttribute("content"));
362
+ } else {
363
+ reserve = tryfunc(elem.getAttribute("content"));
364
+ }
365
+ } else if (attribute === "last-modified") {
366
+ LOGGER.debug(`examining meta http-equiv: ${elem.outerHTML}`);
367
+ if (!options.original) {
368
+ headerdate = tryfunc(elem.getAttribute("content"));
369
+ } else {
370
+ reserve = tryfunc(elem.getAttribute("content"));
371
+ }
372
+ }
373
+ }
374
+
375
+ if (headerdate !== null) {
376
+ break;
377
+ }
378
+ }
379
+
380
+ if (headerdate === null && reserve !== null) {
381
+ LOGGER.debug("opting for reserve date with less granularity");
382
+ headerdate = reserve;
383
+ }
384
+
385
+ return headerdate;
386
+ }
387
+
388
+ function select_candidate(occurrences, catch_regex, yearpat, options) {
389
+ if (!occurrences || occurrences.size > MAX_POSSIBLE_CANDIDATES) {
390
+ return null;
391
+ }
392
+
393
+ // console.log( JSON.stringify(occurrences) + " 444444444444" + occurrences + " 444444444444")
394
+
395
+ if (occurrences.size === 1) {
396
+ const pattern = occurrences.keys().next().value;
397
+ return catch_regex.exec(pattern);
398
+ }
399
+ ///if null
400
+ // if (occurrences.size === 0) {
401
+ return occurrences;
402
+ //todo fix
403
+ // }
404
+
405
+ const firstselect = Array.from(occurrences.entries())
406
+ .sort((a, b) => b[1] - a[1])
407
+ .slice(0, 10);
408
+ LOGGER.debug(`firstselect: ${JSON.stringify(firstselect)}`);
409
+
410
+ const bestones = firstselect
411
+ .sort((a, b) => (options.original ? a[1] - b[1] : b[1] - a[1]))
412
+ .slice(0, 2);
413
+ LOGGER.debug(`bestones: ${JSON.stringify(bestones)}`);
414
+
415
+ const patterns = bestones.map((item) => item[0]);
416
+ const counts = bestones.map((item) => item[1]);
417
+
418
+ const years = patterns
419
+ .map((pattern) => {
420
+ const year_match = yearpat.exec(pattern);
421
+ return year_match ? year_match[1] : null;
422
+ })
423
+ .filter((year) => year !== null);
424
+
425
+ const validation = years.map((year) =>
426
+ is_valid_date(
427
+ new Date(parseInt(year), 0, 1),
428
+ "%Y",
429
+ options.min,
430
+ options.max
431
+ )
432
+ );
433
+
434
+ let match = null;
435
+ if (validation.every((v) => v)) {
436
+ if (counts[0] === counts[1]) {
437
+ match = catch_regex.exec(patterns[0]);
438
+ } else if (years[1] !== years[0] && counts[1] / counts[0] > 0.5) {
439
+ match = catch_regex.exec(patterns[1]);
440
+ } else {
441
+ match = catch_regex.exec(patterns[0]);
442
+ }
443
+ } else if (validation.some((v) => v)) {
444
+ match = catch_regex.exec(patterns[validation.indexOf(true)]);
445
+ } else {
446
+ LOGGER.debug(`no suitable candidate: ${years[0]} ${years[1]}`);
447
+ }
448
+
449
+ return match;
450
+ }
451
+
452
+ function search_pattern(htmlstring, pattern, catch_regex, yearpat, options) {
453
+ const candidates = plausible_year_filter(
454
+ htmlstring,
455
+ pattern,
456
+ yearpat,
457
+ options.min,
458
+ options.max
459
+ );
460
+ return select_candidate(candidates, catch_regex, yearpat, options);
461
+ }
462
+
463
+ function compare_reference(reference, expression, options) {
464
+ const attempt = try_date_expr(
465
+ expression,
466
+ options.format,
467
+ options.extensive,
468
+ options.min,
469
+ options.max
470
+ );
471
+ if (attempt !== null) {
472
+ return compare_values(reference, attempt, options);
473
+ }
474
+ return reference;
475
+ }
476
+
477
+ function examine_abbr_elements(tree, options) {
478
+ const elements = tree.querySelectorAll("abbr");
479
+ if (elements.length > 0 && elements.length < MAX_POSSIBLE_CANDIDATES) {
480
+ let reference = 0;
481
+ for (const elem of elements) {
482
+ if (elem.hasAttribute("data-utime")) {
483
+ try {
484
+ const candidate = parseInt(elem.getAttribute("data-utime"));
485
+ LOGGER.debug(`data-utime found: ${candidate}`);
486
+ if (options.original && (reference === 0 || candidate < reference)) {
487
+ reference = candidate;
488
+ } else if (!options.original && candidate > reference) {
489
+ reference = candidate;
490
+ }
491
+ } catch (err) {
492
+ continue;
493
+ }
494
+ } else if (CLASS_ATTRS.has(elem.getAttribute("class"))) {
495
+ if (elem.hasAttribute("title")) {
496
+ const trytext = elem.getAttribute("title");
497
+ LOGGER.debug(`abbr published-title found: ${trytext}`);
498
+ if (options.original) {
499
+ const attempt = try_date_expr(
500
+ trytext,
501
+ options.format,
502
+ options.extensive,
503
+ options.min,
504
+ options.max
505
+ );
506
+ if (attempt !== null) {
507
+ return attempt;
508
+ }
509
+ } else {
510
+ reference = compare_reference(reference, trytext, options);
511
+ if (reference > 0) {
512
+ break;
513
+ }
514
+ }
515
+ } else if (elem.textContent && elem.textContent.length > 10) {
516
+ LOGGER.debug(`abbr published found: ${elem.textContent}`);
517
+ reference = compare_reference(reference, elem.textContent, options);
518
+ }
519
+ }
520
+ }
521
+ return (
522
+ check_extracted_reference(reference, options) ||
523
+ examine_date_elements(tree, "abbr", options)
524
+ );
525
+ }
526
+ return null;
527
+ }
528
+
529
+ function examine_time_elements(tree, options) {
530
+ const elements = tree.querySelectorAll("time");
531
+ if (elements.length > 0 && elements.length < MAX_POSSIBLE_CANDIDATES) {
532
+ let reference = 0;
533
+ for (const elem of elements) {
534
+ let shortcut_flag = false;
535
+ if (!elem || !elem.getAttribute) continue;
536
+
537
+ const datetime_attr = elem.getAttribute("datetime") || "";
538
+ if (datetime_attr.length > 6) {
539
+ if (
540
+ elem.hasAttribute("pubdate") &&
541
+ elem.getAttribute("pubdate") === "pubdate" &&
542
+ options.original
543
+ ) {
544
+ shortcut_flag = true;
545
+ LOGGER.debug(`shortcut for time pubdate found: ${datetime_attr}`);
546
+ } else if (elem.hasAttribute("class")) {
547
+ if (
548
+ options.original &&
549
+ (elem.getAttribute("class").startsWith("entry-date") ||
550
+ elem.getAttribute("class").startsWith("entry-time"))
551
+ ) {
552
+ shortcut_flag = true;
553
+ LOGGER.debug(`shortcut for time/datetime found: ${datetime_attr}`);
554
+ } else if (
555
+ !options.original &&
556
+ elem.getAttribute("class") === "updated"
557
+ ) {
558
+ shortcut_flag = true;
559
+ LOGGER.debug(
560
+ `shortcut for updated time/datetime found: ${datetime_attr}`
561
+ );
562
+ }
563
+ } else {
564
+ LOGGER.debug(`time/datetime found: ${datetime_attr}`);
565
+ }
566
+
567
+ if (shortcut_flag) {
568
+ const attempt = try_date_expr(
569
+ datetime_attr,
570
+ options.format,
571
+ options.extensive,
572
+ options.min,
573
+ options.max
574
+ );
575
+ if (attempt !== null) {
576
+ return attempt;
577
+ }
578
+ } else {
579
+ reference = compare_reference(reference, datetime_attr, options);
580
+ }
581
+ } else if (elem.textContent && elem.textContent.length > 6) {
582
+ LOGGER.debug(`time/datetime found in text: ${elem.textContent}`);
583
+ reference = compare_reference(reference, elem.textContent, options);
584
+ }
585
+ }
586
+ return check_extracted_reference(reference, options);
587
+ }
588
+ return null;
589
+ }
590
+
591
+ function normalize_match(match) {
592
+ if (!match) return null;
593
+ const groups = match.slice(1).filter((g) => g);
594
+ let [day, month, year] = groups.map((g) => g.padStart(2, "0"));
595
+ if (year.length === 2) {
596
+ year = year[0] === "9" ? `19${year}` : `20${year}`;
597
+ }
598
+ return `${year}-${month}-${day}`;
599
+ }
600
+
601
+ function search_page(htmlstring, options) {
602
+ LOGGER.debug("looking for copyright/footer information");
603
+ let copyear = 0;
604
+
605
+ let bestmatch = search_pattern(
606
+ htmlstring,
607
+ COPYRIGHT_PATTERN,
608
+ YEAR_PATTERN,
609
+ YEAR_PATTERN,
610
+ options
611
+ );
612
+ if (bestmatch) {
613
+ const year = parseInt(bestmatch[0]);
614
+ if (is_valid_date(new Date(year, 0, 1), "%Y", options.min, options.max)) {
615
+ LOGGER.debug(`copyright year/footer pattern found: ${year}`);
616
+ copyear = year;
617
+ }
618
+ }
619
+
620
+ LOGGER.debug("3 components");
621
+ for (const patterns of THREE_COMP_PATTERNS) {
622
+ bestmatch = search_pattern(
623
+ htmlstring,
624
+ patterns[0],
625
+ patterns[1],
626
+ YEAR_PATTERN,
627
+ options
628
+ );
629
+ let result = filter_ymd_candidate(
630
+ bestmatch,
631
+ patterns[0],
632
+ options.original,
633
+ copyear,
634
+ options.format,
635
+ options.min,
636
+ options.max
637
+ );
638
+ if (result !== null) {
639
+ return result;
640
+ }
641
+ }
642
+
643
+ let candidates = plausible_year_filter(
644
+ htmlstring,
645
+ SELECT_YMD_PATTERN,
646
+ SELECT_YMD_YEAR,
647
+ options.min,
648
+ options.max
649
+ );
650
+ let replacement = {};
651
+ for (const [item, count] of Object.entries(candidates)) {
652
+ const match = THREE_COMP_REGEX_A.exec(item);
653
+ if (match) {
654
+ const candidate = normalize_match(match);
655
+ replacement[candidate] = count;
656
+ }
657
+ }
658
+
659
+ bestmatch = select_candidate(
660
+ new Counter(replacement),
661
+ YMD_PATTERN,
662
+ YMD_YEAR,
663
+ options
664
+ );
665
+ let result = filter_ymd_candidate(
666
+ bestmatch,
667
+ SELECT_YMD_PATTERN,
668
+ options.original,
669
+ copyear,
670
+ options.format,
671
+ options.min,
672
+ options.max
673
+ );
674
+ if (result !== null) {
675
+ return result;
676
+ }
677
+
678
+ bestmatch = search_pattern(
679
+ htmlstring,
680
+ DATESTRINGS_PATTERN,
681
+ DATESTRINGS_CATCH,
682
+ YEAR_PATTERN,
683
+ options
684
+ );
685
+ result = filter_ymd_candidate(
686
+ bestmatch,
687
+ DATESTRINGS_PATTERN,
688
+ options.original,
689
+ copyear,
690
+ options.format,
691
+ options.min,
692
+ options.max
693
+ );
694
+ if (result !== null) {
695
+ return result;
696
+ }
697
+
698
+ LOGGER.debug("switching to two components");
699
+
700
+ // First option: YYYY-MM pattern
701
+ bestmatch = search_pattern(
702
+ htmlstring,
703
+ YYYYMM_PATTERN,
704
+ YYYYMM_CATCH,
705
+ YEAR_PATTERN,
706
+ options
707
+ );
708
+
709
+ if (bestmatch) {
710
+ const dateObject = new Date(
711
+ parseInt(bestmatch[1]),
712
+ parseInt(bestmatch[2]) - 1,
713
+ 1
714
+ );
715
+ if (
716
+ is_valid_date(dateObject, "%Y-%m-%d", options.min, options.max) &&
717
+ (copyear === 0 || dateObject.getFullYear() >= copyear)
718
+ ) {
719
+ LOGGER.debug(
720
+ 'date found for pattern "%s": %s, %s',
721
+ YYYYMM_PATTERN,
722
+ bestmatch[1],
723
+ bestmatch[2]
724
+ );
725
+ return format_date(dateObject, options.format);
726
+ }
727
+ }
728
+
729
+ // 2 components, second option: MM-YYYY pattern
730
+ candidates = plausible_year_filter(
731
+ htmlstring,
732
+ MMYYYY_PATTERN,
733
+ MMYYYY_YEAR,
734
+ options.min,
735
+ options.max,
736
+ options.original
737
+ );
738
+
739
+ // Revert DD-MM-YYYY patterns before sorting
740
+ replacement = {};
741
+ for (const [item, count] of Object.entries(candidates)) {
742
+ const match = item?.match(TWO_COMP_REGEX);
743
+ if (!match) continue;
744
+
745
+ let month = match[1];
746
+ if (month.length === 1) {
747
+ month = `0${month}`;
748
+ }
749
+ const candidate = `${match[2]}-${month}-01`;
750
+ replacement[candidate] = count;
751
+ }
752
+
753
+ candidates = new Counter(replacement);
754
+
755
+ // Select best candidate
756
+ bestmatch = select_candidate(candidates, YMD_PATTERN, YMD_YEAR, options);
757
+ result = filter_ymd_candidate(
758
+ bestmatch,
759
+ MMYYYY_PATTERN,
760
+ options.original,
761
+ copyear,
762
+ options.format,
763
+ options.min,
764
+ options.max
765
+ );
766
+
767
+ if (result !== null) {
768
+ return result;
769
+ }
770
+
771
+ // Try full-blown text regex on all HTML
772
+ const dateObject = regex_parse(htmlstring);
773
+
774
+ // Find all candidates and disambiguate
775
+ if (
776
+ dateObject &&
777
+ is_valid_date(dateObject, options.format, options.min, options.max) &&
778
+ (copyear === 0 || dateObject.getFullYear() >= copyear)
779
+ ) {
780
+ try {
781
+ LOGGER.debug("regex result on HTML: %s", dateObject);
782
+ return format_date(dateObject, options.format);
783
+ } catch (err) {
784
+ LOGGER.error("value error during conversion: %s %s", dateObject, err);
785
+ }
786
+ }
787
+
788
+ // Catchall: copyright mention
789
+ if (copyear !== 0) {
790
+ LOGGER.debug("using copyright year as default");
791
+ const dateObject = new Date(copyear, 0, 1); // Month is 0-based in JavaScript
792
+ return format_date(dateObject, options.format);
793
+ }
794
+
795
+ LOGGER.debug("switching to one component");
796
+ bestmatch = search_pattern(
797
+ htmlstring,
798
+ SIMPLE_PATTERN,
799
+ YEAR_PATTERN,
800
+ YEAR_PATTERN,
801
+ options
802
+ );
803
+ if (bestmatch) {
804
+ const dateobject = new Date(parseInt(bestmatch[0]), 0, 1);
805
+ if (
806
+ is_valid_date(dateobject, "%Y-%m-%d", options.min, options.max) &&
807
+ dateobject.getFullYear() >= copyear
808
+ ) {
809
+ LOGGER.debug(
810
+ `date found for pattern "${SIMPLE_PATTERN}": ${bestmatch[0]}`
811
+ );
812
+ return format_date(dateobject, options.format);
813
+ }
814
+ }
815
+
816
+ return null;
817
+ }
818
+
819
+ class Extractor {
820
+ extensive: any;
821
+ format: any;
822
+ max: any;
823
+ min: any;
824
+ original: any;
825
+ constructor(
826
+ extensive_search,
827
+ max_date,
828
+ min_date,
829
+ original_date,
830
+ outputformat
831
+ ) {
832
+ this.extensive = extensive_search;
833
+ this.format = outputformat;
834
+ this.max = max_date;
835
+ this.min = min_date;
836
+ this.original = original_date;
837
+ }
838
+ }
839
+ class Counter extends Map {
840
+ constructor(iterable = null) {
841
+ super();
842
+ if (iterable === null) {
843
+ return;
844
+ }
845
+
846
+ // Handle string input
847
+ if (typeof iterable === "string") {
848
+ for (const char of iterable) {
849
+ this.set(char, (this.get(char) || 0) + 1);
850
+ }
851
+ return;
852
+ }
853
+
854
+ // Handle array input
855
+ if (Array.isArray(iterable)) {
856
+ for (const item of iterable) {
857
+ this.set(item, (this.get(item) || 0) + 1);
858
+ }
859
+ return;
860
+ }
861
+
862
+ // Handle object/mapping input
863
+ if (typeof iterable === "object") {
864
+ for (const [key, value] of Object.entries(iterable)) {
865
+ if (value !== 0) {
866
+ this.set(key, value);
867
+ }
868
+ }
869
+ return;
870
+ }
871
+ }
872
+
873
+ // Counter-specific methods
874
+
875
+ // Return n most common elements and their counts
876
+ most_common(n = null) {
877
+ const items = Array.from(this.entries());
878
+ items.sort((a, b) => b[1] - a[1]);
879
+ return n ? items.slice(0, n) : items;
880
+ }
881
+
882
+ // Return the total of all counts
883
+ total() {
884
+ return Array.from(this.values()).reduce((a, b) => a + b, 0);
885
+ }
886
+
887
+ // Return an iterator over elements, repeating each as many times as its count
888
+ *elements() {
889
+ for (const [item, count] of this.entries()) {
890
+ for (let i = 0; i < count; i++) {
891
+ yield item;
892
+ }
893
+ }
894
+ }
895
+
896
+ // Update counts from an iterable
897
+ update(iterable) {
898
+ const other =
899
+ iterable instanceof Counter ? iterable : new Counter(iterable);
900
+ for (const [key, value] of other.entries()) {
901
+ this.set(key, (this.get(key) || 0) + value);
902
+ if (this.get(key) === 0) {
903
+ this.delete(key);
904
+ }
905
+ }
906
+ }
907
+
908
+ // Subtract counts from an iterable
909
+ subtract(iterable) {
910
+ const other =
911
+ iterable instanceof Counter ? iterable : new Counter(iterable);
912
+ for (const [key, value] of other.entries()) {
913
+ this.set(key, (this.get(key) || 0) - value);
914
+ if (this.get(key) === 0) {
915
+ this.delete(key);
916
+ }
917
+ }
918
+ }
919
+ }
920
+
921
+ /**
922
+ * Extract date from document using various methods
923
+ *
924
+ * @param {Document} htmlobject - DOM object with article content
925
+ * @param {boolean} [extensive_search=true] - perform extensive search if true
926
+ * @param {boolean} [original_date=false] - return original date if true
927
+ * @param {string} [outputformat="%Y-%m-%d"] - output format
928
+ * @param {string} [url=null] - URL of the page
929
+ * @param {boolean} [verbose=false] - log debug messages if true
930
+ * @param {Date} [min_date=null] - minimum date to consider
931
+ * @param {Date} [max_date=null] - maximum date to consider
932
+ * @param {boolean} [deferred_url_extractor=false] - if true, do not extract date from URL
933
+ * @returns {string|null} Extracted date or null if not found
934
+ * @author [vtempest (2025)](https://github.com/vtempest)
935
+ * Based on [Barbaresi (2020)](https://github.com/adbar/htmldate/)
936
+ */
937
+ export function extractDate(
938
+ htmlobject,
939
+ extensive_search = true,
940
+ original_date = false,
941
+ outputformat = "%Y-%m-%d",
942
+ url = null,
943
+ verbose = false,
944
+ min_date = null,
945
+ max_date = null,
946
+ deferred_url_extractor = false
947
+ ) {
948
+ if (verbose) {
949
+ DEBUG = true;
950
+ }
951
+
952
+ const tree = htmlobject;
953
+
954
+ if (!tree) {
955
+ return null;
956
+ }
957
+ if (outputformat !== "%Y-%m-%d" && !is_valid_format(outputformat)) {
958
+ return null;
959
+ }
960
+
961
+ const options = new Extractor(
962
+ extensive_search,
963
+ get_max_date(max_date),
964
+ get_min_date(min_date),
965
+ original_date,
966
+ outputformat
967
+ );
968
+
969
+ let url_result = null;
970
+ if (!url) {
971
+ const urlelem = tree.querySelector('link[rel="canonical"]');
972
+ if (urlelem) {
973
+ url = urlelem.getAttribute("href");
974
+ }
975
+ }
976
+
977
+ url_result = extract_url_date(url, options);
978
+ if (url_result && !deferred_url_extractor) {
979
+ return url_result;
980
+ }
981
+
982
+ let result = examine_header(tree, options) || json_search(tree, options);
983
+ if (result) {
984
+ return result;
985
+ }
986
+
987
+ if (deferred_url_extractor && url_result) {
988
+ return url_result;
989
+ }
990
+
991
+ const abbr_result = examine_abbr_elements(tree, options);
992
+ if (abbr_result) {
993
+ return abbr_result;
994
+ }
995
+
996
+ let search_tree, discarded;
997
+ try {
998
+ [search_tree, discarded] = discard_unwanted(tree.cloneNode(true));
999
+ } catch (error) {
1000
+ search_tree = tree;
1001
+ LOGGER.error("DOM cleaner error");
1002
+ }
1003
+
1004
+ const date_expr = extensive_search
1005
+ ? SLOW_PREPEND + DATE_EXPRESSIONS
1006
+ : FAST_PREPEND + DATE_EXPRESSIONS;
1007
+
1008
+ result =
1009
+ examine_date_elements(search_tree, date_expr, options) ||
1010
+ examine_date_elements(search_tree, "title,h1", options) ||
1011
+ examine_time_elements(search_tree, options);
1012
+ if (result) {
1013
+ return result;
1014
+ }
1015
+
1016
+ const htmlstring = search_tree.innerHTML;
1017
+ // console.log(htmlstring)
1018
+
1019
+ result =
1020
+ pattern_search(htmlstring, TIMESTAMP_PATTERN, options) ||
1021
+ img_search(search_tree, options) ||
1022
+ idiosyncrasies_search(htmlstring, options);
1023
+ if (result) {
1024
+ return result;
1025
+ }
1026
+
1027
+ if (extensive_search) {
1028
+ LOGGER.debug("extensive search started");
1029
+ let reference = 0;
1030
+
1031
+ const textNodes = Array.from(search_tree.childNodes).filter(
1032
+ (node: any) => node.nodeType === 3
1033
+ );
1034
+
1035
+ // Now iterate through them
1036
+ for (let i = 0; i < textNodes.length; i++) {
1037
+ const segment = (textNodes[i] as any).textContent.trim();
1038
+ if (
1039
+ segment.length > MIN_SEGMENT_LEN &&
1040
+ segment.length < MAX_SEGMENT_LEN
1041
+ ) {
1042
+ reference = compare_reference(reference, segment, options);
1043
+ }
1044
+ }
1045
+
1046
+ const converted = check_extracted_reference(reference, options);
1047
+ return converted || search_page(htmlstring, options);
1048
+ }
1049
+
1050
+ return null;
1051
+ }