Repository navigation
Expand file tree
/
Copy pathstable_tasks.py
More file actions
2144 lines (2026 loc) · 117 KB
/
Copy pathstable_tasks.py
File metadata and controls
2144 lines (2026 loc) · 117 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""Pure acceptance contracts for changing, structured page content.
Values are parsed, never compared to a snapshot. Supported Markdown/plain-text
layouts preserve record boundaries; fields are never searched across cards.
Passing establishes the declared output structure, not factual source fidelity.
"""
from __future__ import annotations
import hashlib
import math
import re
import soupsieve
import unicodedata
from datetime import date, datetime
from html import unescape
from urllib.parse import urlsplit
from .dynamic_lists import _label_key, extract_named_entries, named_entry_identity, named_entry_source_pair_hash
from .reference_quality import (
clean_reference_text, is_markdown_image_start, markdown_delimiter_pairs,
visible_markdown_links, without_markdown_images,
)
ACCEPTANCE_CONTRACT_FIELDS = {
'version', 'schema', 'minimum_records',
'maximum_records', 'page_patterns', 'stable_text_anchors',
'stable_text_role', 'hosts', 'entry_path',
'entry_identity_pattern', 'entry_context', 'entry_scope',
'name_validation', 'identity_query_keys', 'identity_query_paths',
'field_minimum_records', 'record_name_exclusions', 'title_exclusion_patterns',
'price_patterns', 'price_exclusion_patterns', 'price_unavailable_patterns',
'record_count_policy', 'source_record_pair_hashes', 'source_record_pairs_sha256',
'table_columns', 'record_identity_fields', 'table_scope',
'duplicate_policy', 'link_base_url', 'text_exclusions',
'content_scope', 'source_membership',
}
SCHEMA_FIELDS = {
"table_row": (),
"earthquake": ("magnitude", "location", "time", "depth"),
"body_text": (),
"quote": ("quote", "author", "tags"),
"product": ("name", "price"),
"catalog_product": ("title", "price", "description", "reviews"),
"review": ("date", "text"),
"name_description": ("name", "description"),
"api_operation": ("method", "path", "summary"),
"taxonomy_family": ("family",),
"named_link": ("title", "url"),
"linked_product": ("title", "url", "price"),
"page_identity": (),
}
_NATIVE_CARD_SCHEMAS = frozenset({
'product', 'catalog_product', 'earthquake', 'quote', 'api_operation',
})
UNIQUE_FIELDS = {
"table_row": (),
"earthquake": ("time", "location"),
"body_text": (),
"quote": ("quote",), "product": ("name",),
"catalog_product": ("title", "description"), "review": ("date", "text"),
"name_description": ("name",), "api_operation": ("method", "path"),
"taxonomy_family": ("family",), "named_link": ("url",),
"linked_product": ("url",), "page_identity": (),
}
_AMOUNT = r"(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d{1,2})?"
_MONEY = rf"(?:[$£€¥]\s*{_AMOUNT}|(?:USD|EUR|GBP|CNY)\s+{_AMOUNT}|{_AMOUNT}\s+(?:USD|EUR|GBP|CNY))"
_METHOD = r"(?:GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS|TRACE|CONNECT)"
_REVIEW_COUNT = r"\d+\s+reviews?"
_PLACEHOLDER = re.compile(r"(?:n/?a|none|null|undefined|unknown|tbd|loading[. …]*|[-—]+|add to cart|read more|view details|description|name|title)", re.I)
_LABELS = {"quote": "quote", "author": "(?:author|by)", "tags": "tags",
"magnitude": "magnitude", "location": "location", "time": "time", "depth": "depth",
"name": "name", "title": "title", "price": "price", "description": "description",
"reviews": "reviews", "date": "date", "text": "(?:text|review)", "method": "method",
"path": "path", "summary": "summary", "family": "family", "financing": "financing", "shipping": "shipping"}
def _key(text):
return re.sub(r"\s+", " ", unicodedata.normalize("NFKC", text).casefold()).strip()
_CARD_BOUNDARY = "\ue000"
_MEDIA_BOUNDARY = "\ue001"
_HTML_HEADING = "\ue002"
def _unwrap_links(text, schema):
"""Unwrap balanced inline links, including multiline card labels.
Only explicit markup creates boundaries; text from separate cards is never
flattened together. Image alt text is not a substitute for a visible title.
"""
pairs = markdown_delimiter_pairs(text)
output, i = [], 0
while i < len(text):
image = is_markdown_image_start(text, i)
start = i + 1 if image else i
if text[start:start + 1] != "[":
output.append(text[i])
i += 1
continue
end = pairs.get(start)
destination = end + 1 if end is not None else len(text)
close = pairs.get(destination) if text[destination:destination + 1] == "(" else None
if close is None:
output.append(text[i])
i += 1
continue
raw_label = text[start + 1:end]
label = "" if image else _unwrap_links(raw_label, None).strip()
image_only = image or (not label and raw_label.lstrip().startswith("!["))
if image_only and schema == "name_description":
prefix = "".join(output).rsplit("\n", 1)[-1]
if re.fullmatch(r"\s*(?:[-*+]|\d+[.)])\s*", prefix):
output.append(_CARD_BOUNDARY)
elif image and not prefix.strip():
line_end = text.find("\n", close + 1)
suffix = text[close + 1:line_end if line_end >= 0 else len(text)]
if not suffix.strip():
# A standalone image is an explicit local card boundary.
# Keep it as ownership evidence without treating alt text as
# the record name or joining prose across adjacent cards.
output.append(_MEDIA_BOUNDARY)
if label:
rendered = "".join(output)
prefix = rendered.rsplit("\n", 1)[-1]
if schema in {"product", "catalog_product", "earthquake"} and "\n" in text[start + 1:end]:
label = f"\n{_CARD_BOUNDARY}\n{label}\n{_CARD_BOUNDARY}\n"
output.append(label)
elif schema == "name_description" and (re.fullmatch(r"\s*(?:[-*+]|\d+[.)])\s*", prefix)
or re.fullmatch(rf"\s*(?:[-*+]|\d+[.)])\s*{_CARD_BOUNDARY}\s*", prefix)
or re.search(rf"(?m)^\s*(?:[-*+]|\d+[.)])\s*(?:{_CARD_BOUNDARY})?\s*\n\s*$", rendered)):
# A bullet's linked title owns the following prose, even when
# both share one physical line. Some converters put an
# image-only bullet on the previous line; that orphan bullet
# still marks the following visible title as the card owner.
suffix = re.match(r'[\w-]+', text[close + 1:])
if suffix:
joined = visible_markdown_links(text[i:close + 1] + suffix[0])
if joined == label + suffix[0]:
label = joined
close += len(suffix[0])
output.append(label + "\n")
else:
# The shared visible-link owner preserves word fragments,
# adjacent labels and native path/multi-word boundaries.
output.append(text[i:close + 1])
i = close + 1
return visible_markdown_links("".join(output))
def _html_record_owners(soup):
"""Keep semantic cards; nested feature lists remain presentation content."""
return [node for node in soup.select('article, li, [role="listitem"]')
if node.name == 'article' or node.get('role') == 'listitem' or not node.find_parent(
lambda parent: parent.name in {'article', 'li'}
or parent.get('role') == 'listitem')]
def _mark_html_headings(soup, *, include=None):
"""Keep real heading levels available to the shared record parsers."""
for heading in soup.select('h1,h2,h3,h4,h5,h6'):
if include is None or include(heading):
heading.insert(0, _HTML_HEADING + heading.name[1] + _HTML_HEADING + ' ')
def _visible(text, schema=None, *, normalized_html=False, representation=None):
if representation == 'plain_text':
return clean_reference_text(text).replace('\r\n', '\n')
from .reference_quality import is_html_document_or_fragment
projected_html = (
not normalized_html and schema in SCHEMA_FIELDS
and schema not in {"named_link", "linked_product", "table_row"}
and (representation == 'html' or is_html_document_or_fragment(text))
)
if projected_html:
from .dynamic_lists import visible_html_text
if schema == "review":
from .dynamic_lists import scoped_html
soup = scoped_html(text)
# Nested presentation blocks stay inside their single-review owner.
containers = 'section,article,li,div'
candidates = [node for node in soup.select(containers)
if len(re.findall(r"(?m)^\s*(?:date\s*:\s*)?\d{4}-\d{2}-\d{2}(?=\s|$)",
visible_html_text(str(node)), re.I)) == 1]
dated = {id(node) for node in candidates}
owners = {id(node) for node in candidates
if not any(id(child) in dated for child in node.select(containers))}
# Presentation wrappers do not own records. Independent articles and
# auxiliary blocks cannot supply an enclosing review's missing prose.
for node in candidates:
if (id(node) not in owners or node.name == 'article'
or not re.fullmatch(r"(?:date\s*:\s*)?\d{4}-\d{2}-\d{2}",
visible_html_text(str(node)), re.I)):
continue
for parent in node.parents:
if id(parent) not in dated:
continue
own_text = ' '.join(str(text) for text in parent.strings
if next((p for p in text.parents if p is parent or p is node
or p.name in {'article', 'footer', 'nav', 'aside'}), None) is parent)
if own_text.strip():
owners.remove(id(node))
owners.add(id(parent))
break
if parent.name == 'article':
break
def inside_record(node):
return any(id(parent) in owners for parent in node.parents)
for node in list(soup.select(containers + ',main,header,footer,nav,aside,hr')):
if inside_record(node) and node.name not in {'article', 'footer', 'nav', 'aside'}:
continue
node.insert_before("\n" + _CARD_BOUNDARY + "\n")
node.insert_after("\n" + _CARD_BOUNDARY + "\n")
_mark_html_headings(soup, include=lambda node: not inside_record(node))
text = str(soup)
elif schema == 'name_description':
from .dynamic_lists import scoped_html
soup = scoped_html(text)
_mark_html_headings(soup)
text = str(soup)
elif schema in _NATIVE_CARD_SCHEMAS:
from .dynamic_lists import scoped_html
soup = scoped_html(text)
if schema in {'catalog_product', 'api_operation'}:
_mark_html_headings(soup)
owners = _html_record_owners(soup)
identities = {id(node) for node in owners}
groups = {}
for node in owners:
ancestors = [parent for parent in node.parents if id(parent) in identities]
root = ancestors[-1] if ancestors else node
groups.setdefault(id(root), (root, []))[1].append(node)
for root, cards in groups.values():
chunks = []
for card in cards:
copy = scoped_html(str(card))
owner = copy.find()
for nested in _html_record_owners(copy):
if nested is not owner and nested.parent is not None:
nested.decompose()
chunks.append(visible_html_text(str(copy)))
# Preformatted projection keeps field lines intact through the
# shared HTML reader; each card still uses the existing parser.
projected = soup.new_tag('pre')
projected.string = ('\n' + _CARD_BOUNDARY + '\n'
+ ('\n' + _CARD_BOUNDARY + '\n').join(chunks)
+ '\n' + _CARD_BOUNDARY + '\n')
root.replace_with(projected)
text = str(soup)
text = visible_html_text(text)
if schema == "name_description" and not normalized_html and not projected_html and re.search(r"<(?:div|section|article|span|script|template)\b", text, re.I):
from .dynamic_lists import scoped_html
text = scoped_html(text).get_text(separator="\n")
projected_html = True
text = clean_reference_text(text)
if projected_html or representation == 'html':
return text.replace('\r\n', '\n')
literals = {}
marker = '\ue026stable-code'
while marker in text or marker in unescape(text):
marker += '-'
body_view = schema in {None, 'body_text', 'page_identity'}
spans = _markdown_code_payloads(text, preserve_delimiters=not body_view)
for start, end, payload in reversed(spans):
token = marker + str(len(literals)) + '\ue027'
literals[token] = payload
text = text[:start] + token + text[end:]
from .reference_quality import _markdown_unescape
def rendered_literal(match):
raw = match[0]
punctuation = match[1]
if punctuation is not None:
if schema == 'table_row' and punctuation in {'|', '\\'}:
return raw
if punctuation == '\\' and re.match(r'[ \t]*\n', text[match.end():]):
return raw
elif normalized_html:
return raw
value = _markdown_unescape(raw)
if value == raw:
return raw
token = marker + str(len(literals)) + '\ue027'
# Keep emitted delimiters opaque until an owned record scalar is read.
literals[token] = '\\' + value if not body_view and value in {'`', '\\'} else value
return token
text = re.sub(r'\\([!"#$%&\'()*+,\-./:;<=>?@\[\]\\^_`{|}~])'
r'|&(?:#[xX][0-9a-fA-F]+|#\d+|[A-Za-z][A-Za-z0-9]*);',
rendered_literal, text)
# An actual inline HTML break in Markdown is layout. Preserve escaped
# markup and code spans, and decode character references only afterward.
text = re.sub(r'(`+|~{3,}).*?\1|(?<!\\)<br\s*/?>' if schema != "table_row" and not normalized_html else r'(`+|~{3,}).*?\1',
lambda m: m.group(0) if m.group(1) else " ", text,
flags=re.I | re.S)
# Escaped punctuation and references above are decoded once and restored
# after formatting; their emitted characters cannot introduce Markdown.
# Extractors represent Markdown hard breaks using one or two backslashes.
text = re.sub(r"\\{1,2}[ \t]*(?=\n)", "", text)
text = (visible_markdown_links(without_markdown_images(text))
if schema in {None, "body_text", "page_identity"} else _unwrap_links(text, schema))
# CommonMark punctuation escapes preserve the same visible characters.
# Remove them before date/field parsing (for example ``2023\-05\-18``).
text = re.sub(r'\\([!"#$%&\'()*+,\-./:;<=>?@\[\]\\^_`{|}~])', lambda m: m[0] if schema == 'table_row' and m[1] in {'|', '\\'} else m[1], text)
from .metrics import native_punctuation_boundaries
text = native_punctuation_boundaries(text)
if schema == "name_description":
# Image-only card links intentionally contribute no visible title.
# Remove their now-empty bullet line so blank-delimited card parsing
# sees the following visible title and description as one record.
text = re.sub(r"(?m)^\s*(?:[-*+]|\d+[.)])\s*$", "", text)
text = re.sub(rf"(?m)^\s*(?:[-*+]|\d+[.)])\s*{_CARD_BOUNDARY}\s*$",
f"\n{_CARD_BOUNDARY}\n", text)
boundaries = {_CARD_BOUNDARY, _MEDIA_BOUNDARY}
text = "\n".join(line if line.strip() in boundaries
else line.replace(_CARD_BOUNDARY, "").replace(_MEDIA_BOUNDARY, "")
for line in text.splitlines())
text = re.sub(r"\*\*|__" if schema in {None, "body_text", "page_identity"} else r"\*\*|__|`", "", text)
# Link unwrapping may insert boundary spaces inside surrounding emphasis
# (``*[label](url)*`` becomes ``* label *``). Treat that as the same visible
# text as ordinary emphasis, without flattening other punctuation.
text = re.sub(r"(?<!\w)([*_])\s*([^\n]*?\S)\s*\1(?!\w)", r"\2", text)
# Preserve newlines: flattening them would let one card borrow another's fields.
for token, literal in literals.items():
text = text.replace(token, literal)
return text.replace("\r\n", "\n")
def _line(text, *, literal=False):
if literal:
return re.sub(r'^' + _HTML_HEADING + r'[1-6]' + _HTML_HEADING + r'\s*', '', text).strip()
text = re.sub(r"^(?:\s*[#>]+\s*|\s*[-*+]\s+|\s*\d+[.)]\s+)", "", text)
return _render_inline_scalar(text)
def _render_inline_scalar(text, *, pipe_cell=False):
"""Render an owned value after layout, without reinterpreting code payloads."""
from .article_text import mask_markdown_inline_code
text, restore_code = mask_markdown_inline_code(text)
text = re.sub(r"</?(?:strong|b|em|i)>", "", text, flags=re.I)
text = re.sub(r'\\(`)' if pipe_cell else r'\\([`\\])', r'\1', text)
return restore_code(text).strip()
def _field(text, name):
return re.sub(rf"^(?:{_LABELS.get(name, name)})\s*:\s*", "", text, flags=re.I).strip()
def _text(value, minimum=2):
return (len(value.strip()) >= minimum and any(c.isalpha() for c in value)
and not _PLACEHOLDER.fullmatch(value.strip()) and not re.match(r"https?://", value)
and not re.fullmatch(_MONEY, value, re.I))
def _valid_field(name, value):
if not isinstance(value, str) or not value.strip():
return False
if name == "price":
return bool(re.fullmatch(_MONEY, value, re.I))
if name == "magnitude":
return bool(re.fullmatch(r"-?\d{1,2}(?:\.\d+)?", value))
if name == "depth":
return bool(re.fullmatch(r"-?\d+(?:\.\d+)?\s*km", value, re.I))
if name == "time":
# Accept source-local UTC offsets and ISO timestamps without freezing
# a date. A date alone is not an event timestamp.
normalized = re.sub(r"\s*\(UTC([+-]\d{2}:\d{2})\)$", r"\1", value)
normalized = re.sub(r"\s*\(UTC\)$", "+00:00", normalized)
if not re.fullmatch(r"\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?", normalized):
return False
try:
datetime.fromisoformat(normalized.replace("Z", "+00:00"))
return True
except ValueError:
return False
if name == "reviews":
return bool(re.fullmatch(r"\d+(?:\s*reviews?)?", value, re.I))
if name == "date":
try:
return bool(re.fullmatch(r"\d{4}-\d{2}-\d{2}", value) and date.fromisoformat(value))
except ValueError:
return False
if name == "method":
return bool(re.fullmatch(_METHOD, value))
if name == "path":
return bool(re.fullmatch(r"/(?:[A-Za-z0-9_{}./~%:@+!$&'()*,;=\-]*)", value)) and not value.startswith("//")
if name == "family":
return bool(re.fullmatch(r"[A-Za-z]+idae", value))
if name == "financing":
return bool(re.fullmatch(rf"(?:or\s+)?[1-9]\d*\s*[x×]\s*{_MONEY}", value, re.I))
if name == "shipping":
return bool(re.fullmatch(rf"(?:free shipping|shipping\s*:?\s*(?:free|{_MONEY}))", value, re.I))
return _text(value, 3 if name in {"description", "text", "summary", "quote"} else 2)
def _record(fields, schema):
errors = [f"invalid_field:{field}" for field in SCHEMA_FIELDS[schema]
if not _valid_field(field, fields.get(field))]
for field in ("financing", "shipping"):
if field in fields and not _valid_field(field, fields[field]):
errors.append(f"invalid_field:{field}")
if schema == "name_description" and _key(fields.get("name", "")) == _key(fields.get("description", "")):
errors.append("description_repeats_name")
return {"fields": fields, "valid": not errors, "errors": errors}
def _column_record(headers, values, schema):
"""Apply the same field and column rules to owned Markdown or HTML rows."""
record = _record(dict(zip(headers, values)), schema)
if len(values) != len(headers) or len(headers) != len(set(headers)):
record["valid"] = False
record["errors"].append("invalid_table_columns")
return record
def _html_record_tables(content, schema, *, allow_closed_title_cards=False):
"""Read native cell ownership before any line/card projection."""
from .dynamic_lists import scoped_html
from .structured_tables import _native_cell_text, _spanning_rows, _visible_markdown_scalar
soup = scoped_html(content)
tables = list(soup.find_all('table'))
if not tables:
return None
required = set(SCHEMA_FIELDS[schema])
blocked = _spanning_rows(soup)
records = []
for table in tables:
headers = None
rows = [(row, row.find_all(['th', 'td'], recursive=False))
for row in table.find_all('tr') if row.find_parent('table') is table]
def header_row(row, cells):
return bool(cells) and (all(cell.name == 'th' for cell in cells)
or (row.find_parent(['thead', 'table']) or table).name == 'thead')
has_header = any(header_row(row, cells) for row, cells in rows)
for row, cells in rows:
if not cells:
continue
values = [_visible_markdown_scalar(_native_cell_text(cell, table)) for cell in cells]
if header_row(row, cells):
labels = [_key(value).replace('review count', 'reviews') for value in values]
headers = labels if required <= set(labels) and id(row) not in blocked else None
continue
if headers is not None:
record = _column_record(headers, values, schema)
if id(row) in blocked:
record['valid'] = False
record['errors'].append('invalid_table_columns')
records.append(record)
elif not has_header and id(row) not in blocked:
# Headerless layout tables can contain existing native cards.
# Keep each cell isolated and let the established literal card
# parser recognize it without interpreting cell text as markup.
for cell in cells:
card = scoped_html(str(cell))
for nested in card.find_all('table'):
nested.decompose()
descriptions = (_html_list_descriptions(str(card),
allow_closed_title_cards=allow_closed_title_cards)
if schema == 'name_description' else None)
records.extend(descriptions if descriptions is not None else _parse_records(
_visible(str(card), schema, representation='html'), schema, literal=True,
allow_closed_title_cards=allow_closed_title_cards))
# Tables own their rows, including incomplete or unqualified rows. Their
# flattened text cannot supply an independent card or another table's data.
for table in reversed(tables):
if table.parent is not None:
table.decompose()
return records, str(soup)
def _table_records(lines, schema):
"""Explicit Markdown table headers own their columns, including empty cells."""
from .structured_tables import _pipe_cells, _table_header_at
from .article_text import mask_markdown_inline_code
masked = [mask_markdown_inline_code(line) for line in lines]
records, occupied = [], set()
required = set(SCHEMA_FIELDS[schema])
for i in range(len(lines) - 1):
if i in occupied or "|" not in lines[i]:
continue
headers = [_key(masked[i][1](_render_inline_scalar(x, pipe_cell=True))).replace("review count", "reviews")
for x in _pipe_cells(masked[i][0])]
separator = _pipe_cells(masked[i + 1][0])
if (not required <= set(headers) or len(separator) != len(headers)
or not all(re.fullmatch(r"\s*:?-{3,}:?\s*", x) for x in separator)):
continue
occupied.update((i, i + 1))
j = i + 2
while j < len(lines) and "|" in lines[j] and lines[j].strip():
if _table_header_at(lines, j):
break
values = [masked[j][1](_render_inline_scalar(x, pipe_cell=True)).strip()
for x in _pipe_cells(masked[j][0])]
records.append(_column_record(headers, values, schema))
occupied.add(j)
j += 1
return records, occupied
def _product_start(lines, index, schema, levels):
"""Recognize the same supported title/price layouts at every card boundary."""
if re.match(r"description\s*:", lines[index], re.I):
return None
current = _field(lines[index], "price")
price_first = bool(re.fullmatch(_MONEY, current, re.I))
inline = None if price_first else re.fullmatch(rf"(.+?)\s*\|?\s+({_MONEY})", lines[index], re.I)
# In a catalog, heading price then a title heading no deeper than it is its own card;
# a plain line above is not its title. A deeper heading is the description.
next_price = (index + 1 < len(lines)
and not (schema == "catalog_product"
and levels[index] is None and levels[index + 1] is not None
and index + 2 < len(lines) and levels[index + 2] is not None
and levels[index + 2] <= levels[index + 1])
and re.fullmatch(_MONEY, _field(lines[index + 1], "price"), re.I))
if not price_first and not inline and not next_price:
return None
title_field = "title" if schema == "catalog_product" else "name"
if inline:
return {title_field: _field(inline[1].rstrip(" |"), title_field), "price": inline[2]}, index + 1
if price_first:
fields = {"price": current}
if schema == "catalog_product" and index + 1 < len(lines):
following = lines[index + 1]
if (not re.fullmatch(_MONEY, _field(following, 'price'), re.I)
and not re.fullmatch(rf"(.+?)\s*\|?\s+({_MONEY})", following, re.I)):
fields[title_field] = _field(following, title_field)
return fields, index + 2
return fields, index + 1
return {title_field: _field(lines[index], title_field),
"price": _field(lines[index + 1], "price")}, index + 2
def _products(lines, schema, *, literal=False):
raw_lines = [line for line in lines if line.strip()]
heading_pattern = (r'^' + _HTML_HEADING + r'([1-6])' + _HTML_HEADING + r'\s+'
if literal else r'^ {0,3}(#{1,6})[ \t]+')
headings = [re.match(heading_pattern, line) for line in raw_lines]
levels = [None if heading is None else int(heading[1]) if literal else len(heading[1])
for heading in headings]
lines = [_line(line, literal=literal) for line in raw_lines]
records = []
claimed_shipping = set()
i = 0
while i < len(lines):
start = _product_start(lines, i, schema, levels)
if start is None:
i += 1
continue
fields, end = start
if schema == "catalog_product":
level = next((value for value in levels[i:end] if value is not None), 6)
description = []
while end < len(lines):
if levels[end] is not None and levels[end] <= level:
break
line = lines[end]
count = _field(line, 'reviews')
labeled = re.match(rf"^(?:{_LABELS['reviews']})\s*:", line, re.I)
if (_valid_field('reviews', count)
and (labeled or re.fullmatch(r"\d+\s*reviews?", line, re.I))):
fields["reviews"] = re.match(r"\d+", count)[0]
end += 1
break
# Stop before any subsequent card, rather than borrowing its
# description or review count to repair this card.
if _product_start(lines, end, schema, levels) is not None:
break
description.append(_field(line, "description"))
end += 1
fields["description"] = " ".join(description)
else:
if i and i-1 not in claimed_shipping and re.fullmatch(r'free shipping|shipping\s*:\s*free', lines[i-1], re.I):
fields['shipping'] = lines[i-1]
claimed_shipping.add(i-1)
while end < len(lines) and re.match(r"(?:or(?=\s|\d)|financing\s*:|free shipping|shipping\s*:)", lines[end], re.I):
field = "shipping" if "shipping" in lines[end].casefold() else "financing"
fields[field] = re.sub(r'^or(?=\d)', 'or ', _field(lines[end], field)) if field == "financing" else lines[end]
if field == 'shipping':
claimed_shipping.add(end)
end += 1
records.append(_record(fields, schema))
i = end
return records
def _product_records(text, schema, *, literal=False):
"""Parse product cards without erasing explicit record ownership.
A blank-line block is a record boundary only when every nonempty block is
independently a single complete card. This preserves extractors that put
an optional badge before the product name, while retaining the permissive
line-layout parser for prose/headings that use blank lines decoratively.
"""
records = []
for marked_block in text.split(_CARD_BOUNDARY):
blocks = [block for block in re.split(r"\n\s*\n", marked_block)
if block.strip()]
# A separate prose preface cannot own a price-first catalog card.
# Require price and title in the same following block before trimming.
if schema == "catalog_product":
while (len(blocks) > 1 and not _products(blocks[0].splitlines(), schema, literal=literal)
and re.match(rf"^{_MONEY}\n[^\n]+", blocks[1], re.I)):
blocks.pop(0)
marked_block = "\n\n".join(blocks)
parsed = [
_products(block.splitlines(), schema, literal=literal)
for block in blocks
]
if (len(parsed) > 1
and all(len(block_records) == 1 and block_records[0]["valid"]
for block_records in parsed)):
for block_records in parsed:
records.extend(block_records)
else:
records.extend(_products(
marked_block.splitlines(), schema, literal=literal
))
return records
def _quotes(lines):
records, i = [], 0
while i < len(lines):
line = lines[i]
if not re.match(r'[“"‘]|quote\s*:', line, re.I):
i += 1
continue
parts = [line]
j = i + 1
while j < len(lines) and not re.match(r'[“"‘]|quote\s*:', lines[j], re.I):
if re.match(r'(?:#{1,6}\s*)?(?:loading[. …]*|quotes by\s*:|made with\b|'
r'(?:next|previous)(?:\s+(?:page\s*)?\d+)?\s*[→←»«]?\s*$|'
r'page\s+\d+(?:\s+of\s+\d+)?\s*$)',
lines[j], re.I):
break
parts.append(lines[j])
j += 1
text = " ".join(parts)
quote_text = re.sub(r'^quote\s*:\s*', '', text, flags=re.I)
closing = {'“': '”', '"': '"', '‘': '’'}.get(quote_text[:1])
suffix = r'\s*(?:[-–—]\s*)?(?:by\s*|author\s*:\s*)(.+?)\s*tags\s*:\s*(.+)'
match = (re.fullmatch(re.escape(quote_text[0]) + r'(.*)' + re.escape(closing) + suffix, quote_text, re.I)
if closing else re.fullmatch(r'quote\s*:\s*(.+)\s+' + suffix, text, re.I))
fields = {"quote": match[1].strip(), "author": match[2], "tags": match[3]} if match else {}
if fields:
fields["author"] = re.sub(r"\s*\(about\)\s*$", "", fields["author"], flags=re.I)
records.append(_record(fields, "quote"))
i = j
return records
def _reviews(lines, *, literal=False):
lines = [line.strip() for line in lines if line.strip()]
records, i, context_level = [], 0, 6
while i < len(lines):
pattern = (_HTML_HEADING + r'([1-6])' + _HTML_HEADING + r'\s+'
if literal else r'(#{1,6})\s+')
heading = re.match(pattern, lines[i])
if heading:
context_level = int(heading[1]) if literal else len(heading[1])
match = re.match(r"(?:date\s*:\s*)?(\d{4}-\d{2}-\d{2})(?:\s+(.+))?$", _line(lines[i], literal=literal), re.I)
if not match:
i += 1
continue
fields = {"date": match[1]}
text = [match[2]] if match[2] else []
j = i + 1
while j < len(lines):
following = _line(lines[j], literal=literal)
heading = re.match(pattern, lines[j])
if (re.match(r"(?:date\s*:\s*)?\d{4}-\d{2}-\d{2}", following, re.I)
or (heading and (int(heading[1]) if literal else len(heading[1])) <= context_level)):
break
text.append(_field(following, "text"))
j += 1
fields["text"] = " ".join(text)
records.append(_record(fields, "review"))
i = j
return records
def _operations(lines):
records, i = [], 0
while i < len(lines):
# Some native Markdown serializers emit METHOD[/path](target)Summary;
# visible-link normalization leaves METHOD/path Summary without a gap.
match = re.match(rf"(?:method\s*:\s*)?({_METHOD})(?:(?:\s+|(?=/))(.+))?$", lines[i])
if not match:
i += 1
continue
fields = {"method": match[1]}
rest = [match[2]] if match[2] else []
j = i + 1
while j < len(lines) and not re.match(rf"(?:method\s*:\s*)?{_METHOD}\b", lines[j]):
rest.append(lines[j])
j += 1
if rest:
path = re.fullmatch(r"(?:path\s*:\s*)?(/\S*)(?:\s+(.+))?", rest[0], re.I)
if path:
fields["path"] = path[1]
summary = ([path[2]] if path[2] else []) + rest[1:]
fields["summary"] = " ".join(_field(s, "summary") for s in summary)
records.append(_record(fields, "api_operation"))
i = j
return records
def _descriptions(text, *, literal=False):
records = []
if _CARD_BOUNDARY in text:
# A marker comes from an image-only bullet immediately before the
# visible linked title. It is a stronger ownership boundary than blank
# lines, which may legitimately occur inside a card description.
prefix, *marked = text.split(_CARD_BOUNDARY)
records.extend(_descriptions(prefix, literal=literal))
for block in marked:
following = None if literal else re.search(r"(?m)^\s*(?:[-*+]|\d+[.)])[ \t]+\S.*$", block)
end = following.start() if following else len(block)
lines = [_line(line, literal=literal) for line in block[:end].splitlines() if line.strip()]
if len(lines) >= 2:
records.append(_record({"name": _field(lines[0], "name"),
"description": " ".join(_field(line, "description") for line in lines[1:])},
"name_description"))
if following:
records.extend(_descriptions(block[end:], literal=literal))
return records
# A whitespace-only line is a blank line for the setext check below.
text = re.sub(r'(?m)^[ \t]+$', '', text)
bullets = [] if literal else list(re.finditer(r"(?m)^\s*(?:[-*+]|\d+[.)])[ \t]+\S.*$", text))
if bullets:
# Fences are consumed so their code lines cannot declare a section. A
# setext title needs a blank line before it; otherwise it is a lazy
# continuation of the item and the underline is not a heading.
sections = [match for match in re.finditer(
r'(?ms)^ {0,3}(`{3,}(?=[^`\n]*$)|~{3,}).*?(?:^ {0,3}\1[`~]*[ \t]*$|\Z)'
r'|^(?:(?= {0,3}#{1,6}(?:[ \t]+\S|[ \t]*$))'
r'|(?<![^\n]\n)(?= {0,3}\S[^\n]*\n {0,3}(?:=+|-+)[ \t]*$))( {0,3})', text)
if match[2] is not None]
for index, bullet in enumerate(bullets):
end = bullets[index + 1].start() if index + 1 < len(bullets) else len(text)
# A heading left of the item's content column may be a later section or
# the card's own title. It ends the description when nothing follows it
# before the next bullet, or after the last bullet unless it repeats the
# bullet's title. A heading indented into the item remains a continuation.
column = re.match(r'\s*(?:[-*+]|\d+[.)])[ \t]+', bullet[0].rsplit('\n', 1)[-1].expandtabs(4)).end()
section = next((section.start() for section in sections
if bullet.end() <= section.start() < end and len(section[2]) < column), None)
if section is not None:
heading, _, body = text[section:end].partition('\n')
title = _line(heading)
if not body.strip() or (index + 1 == len(bullets) and not (title and title in bullet[0])):
end = section
lines = [_line(line, literal=literal) for line in text[bullet.start():end].splitlines() if line.strip()]
records.append(_record({"name": _field(lines[0], "name"),
"description": " ".join(_field(line, "description") for line in lines[1:])}, "name_description"))
return records
# Both Markdown headings and blank-delimited plain cards express ownership.
pattern = (r'(?m)^' + _HTML_HEADING + r'([1-6])' + _HTML_HEADING + r'\s+(.+)$'
if literal else r'(?m)^(#{1,6})\s+(.+)$')
headings = list(re.finditer(pattern, text))
if headings:
# Repeated deepest-level headings are cards; enclosing page/section
# headings are not records. Blank lines within a card do not erase its
# ownership boundary.
depth = max(int(h[1]) if literal else len(h[1]) for h in headings)
for index, heading in enumerate(headings):
if (int(heading[1]) if literal else len(heading[1])) != depth:
continue
end = headings[index + 1].start() if index + 1 < len(headings) else len(text)
body = [_line(line, literal=literal) for line in text[heading.end():end].splitlines() if line.strip()]
records.append(_record({"name": _field(_line(heading[2], literal=literal), "name"),
"description": " ".join(_field(line, "description") for line in body)}, "name_description"))
return records
blocks = re.split(r"\n\s*\n", text)
for block in blocks:
lines = [_line(line, literal=literal) for line in block.splitlines() if line.strip()]
if len(lines) < 2:
continue
fields = {"name": _field(lines[0], "name"),
"description": " ".join(_field(line, "description") for line in lines[1:])}
records.append(_record(fields, "name_description"))
return records
def _html_list_descriptions(content, *, allow_closed_title_cards=False):
"""Preserve native leaf-list ownership before flattening visible HTML."""
from .dynamic_lists import visible_html_text
from .document_structure import _content_root
from .reference_quality import is_html_document_or_fragment
if not is_html_document_or_fragment(content):
return None
from .dynamic_lists import scoped_html
soup = scoped_html(content)
# Keep record-local titles before the document helper removes page chrome.
for header in soup.select('article header,section header,li header'):
header.unwrap()
root = _content_root(str(soup), single_article=False)
items = [node for node in root.select('li') if not node.select('li')]
records = []
for item in items:
lines = visible_html_text(str(item)).splitlines()
if len(lines) >= 2:
records.append(_record({'name': _field(lines[0], 'name'),
'description': ' '.join(_field(line, 'description') for line in lines[1:])},
'name_description'))
# List ownership includes incomplete items and enclosing list labels.
# Remove those subtrees before parsing independently owned non-list cards.
for item in list(root.select('li')):
if item.parent is not None:
item.decompose()
def bounded_records(container):
original = str(container)
children = [child for child in container.select('section')
if child.find_parent(['section', 'article']) is container]
for child in children:
child.extract()
# An empty wrapper or a heading over sibling sections is a group.
# A record title with one presentation section retains its own prose.
own_text = visible_html_text(str(container)).strip()
headings = '\n'.join(visible_html_text(str(h)) for h in container.select('h1,h2,h3,h4,h5,h6'))
if children and (not own_text or own_text == headings):
child_records = [record for child in children for record in bounded_records(child)]
if not own_text or len(children) > 1 or len(child_records) > 1:
return child_records
return _parse_records(_visible(original, 'name_description', representation='html'), 'name_description',
allow_closed_title_cards=allow_closed_title_cards, literal=True)
# Consume nested articles first; retain presentation sections with their title.
for article in reversed(list(root.select('article'))):
if article.parent is not None:
records.extend(bounded_records(article))
article.decompose()
for section in list(root.select('section')):
if section.parent is not None:
records.extend(bounded_records(section))
section.decompose()
return records + _parse_records(_visible(str(root), 'name_description', representation='html'), 'name_description',
allow_closed_title_cards=allow_closed_title_cards, literal=True)
def _closed_title_descriptions(text, *, line_delimited=False, literal=False):
"""Treat title-description-title triples as explicitly closed cards."""
blocks = text.splitlines() if line_delimited else re.split(r"\n\s*\n", text)
paragraphs = [block.strip() for block in blocks if block.strip()]
records = []
for first, description, repeated in zip(
paragraphs, paragraphs[1:], paragraphs[2:], strict=False
):
if any("\n" in value for value in (first, description, repeated)):
continue
name = _line(first, literal=literal)
if _key(name) != _key(_line(repeated, literal=literal)):
continue
record = _record(
{"name": name, "description": _line(description, literal=literal)},
"name_description",
)
if record["valid"]:
records.append(record)
return records
def _media_closed_title_descriptions(text, *, literal=False):
"""Read title-description-title closures only inside image-owned blocks."""
if _MEDIA_BOUNDARY not in text:
return []
return [
record
for block in text.split(_MEDIA_BOUNDARY)[1:]
for record in _closed_title_descriptions(block, line_delimited=True, literal=literal)
]
def _earthquakes(text, *, literal=False):
"""Read contiguous magnitude/location/time/depth cards, never bag-of-fields.
Four-line ordering is independently observed on the source list. Explicit
Markdown card/link boundaries cannot be crossed to repair a missing field.
Tables are handled separately with column ownership.
"""
records = []
for block in text.split(_CARD_BOUNDARY):
lines = [_line(line, literal=literal) for line in block.splitlines() if line.strip()]
# Inline custom elements can serialize location and timestamp on one
# line. Split only a complete trailing timestamp, within this card.
separated = []
for line in lines:
compact = re.fullmatch(
r"(.+?)(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}"
r"(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2}|\s*\(UTC(?:[+-]\d{2}:\d{2})?\))?)"
r"\s+(-?\d+(?:\.\d+)?\s*km)", line, re.I,
)
if (compact and _text(compact[1])
and _valid_field("time", compact[2])
and _valid_field("depth", compact[3])):
separated.extend((compact[1].strip(), compact[2], compact[3]))
continue
joined = re.fullmatch(
r"(.+?)(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}"
r"(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2}|\s*\(UTC(?:[+-]\d{2}:\d{2})?\))?)",
line,
)
if joined and _valid_field("time", joined[2]) and _text(joined[1]):
separated.extend((joined[1].strip(), joined[2]))
else:
separated.append(line)
lines = separated
for i, line in enumerate(lines):
magnitude = _field(line, "magnitude")
if not _valid_field("magnitude", magnitude):
continue
fields = {"magnitude": magnitude}
for offset, name in enumerate(("location", "time", "depth"), 1):
if i + offset < len(lines):
fields[name] = _field(lines[i + offset], name)
records.append(_record(fields, "earthquake"))
return records
def _parse_records(visible, schema, *, allow_closed_title_cards=False, literal=False):
raw_lines = visible.splitlines()
records, occupied = ([], set()) if literal else _table_records(raw_lines, schema)
remainder = "\n".join(line if i not in occupied else _CARD_BOUNDARY for i, line in enumerate(raw_lines))
lines = [_line(line, literal=literal) for line in remainder.splitlines() if line.strip()]
if schema == "earthquake":
records.extend(_earthquakes(remainder, literal=literal))
elif schema in {"product", "catalog_product"}:
records.extend(_product_records(remainder, schema, literal=literal))
elif schema == "quote":
for block in remainder.split(_CARD_BOUNDARY):
records.extend(_quotes([_line(line, literal=literal)
for line in block.splitlines() if line.strip()]))
elif schema == "review":
for block in remainder.split(_CARD_BOUNDARY):
records.extend(_reviews(block.splitlines(), literal=literal))
elif schema == "api_operation":
# Keep section boundaries until after parsing ownership. Otherwise the
# last operation in each group absorbs the next heading, its overview,
# or the models/footer section into its summary. A heading may itself
# contain METHOD /path, so retain its text within the new section.
boundary = (r'(?m)(?=^' + _HTML_HEADING + r'[1-6]' + _HTML_HEADING + r'\s+)'
if literal else r'(?m)(?=^[ \t]{0,3}#{1,6}[ \t]+)')
sections = [section for block in remainder.split(_CARD_BOUNDARY)
for section in re.split(boundary, block)]
for section in sections:
records.extend(_operations([_line(line, literal=literal) for line in section.splitlines() if line.strip()]))
elif schema == "name_description":
# Scoped title-description-title cards and image-owned cards are two
# ways to establish the same ownership boundary. When the caller has
# already bounded the target section, parsing both routes would emit
# every image-backed card twice and reject an otherwise valid answer as
# a duplicate-name list.
if _MEDIA_BOUNDARY in remainder:
closed = _media_closed_title_descriptions(remainder, literal=literal)
if allow_closed_title_cards:
closed += _closed_title_descriptions(remainder.split(_MEDIA_BOUNDARY, 1)[0], literal=literal)
else:
closed = _closed_title_descriptions(remainder, literal=literal) if allow_closed_title_cards else []
# A page heading cannot own descriptions across separate media cards.
weak = [record for record in _descriptions(remainder, literal=literal)
if not any(_MEDIA_BOUNDARY in value
for value in record['fields'].values())]
for record in weak:
description = _key(record["fields"].get("description", ""))
name = _key(record["fields"].get("name", ""))
if any(
name != _key(owner["fields"]["name"])
and _key(owner["fields"]["name"]) in description
and _key(owner["fields"]["description"]) in description
for owner in closed
):
record["valid"] = False
record["errors"].append("description_contains_separate_owned_record")
records.extend(weak)
records.extend(closed)
elif schema == "taxonomy_family":
records.extend(_record({"family": _field(line, "family")}, schema) for line in lines
if re.fullmatch(r"(?:family\s*:\s*)?[A-Za-z]+idae", line, re.I))
return records
def _non_record_visible(content, contract, records, base_url, *, representation=None):
"""Remove owned record links, without erasing identical navigation text."""
if representation == 'plain_text':
return None
from .response_input import join_answer_url
from .dynamic_lists import scoped_html, named_entry_identity, canonical_entry_path, visible_html_text
from .reference_quality import markdown_links, visible_markdown_links, visible_mixed_html_text
identities = {named_entry_identity(r['fields']['url'], contract)
for r in records if r.get('valid') and r.get('fields', {}).get('url')}
def is_record(url):
try:
absolute = join_answer_url(base_url, url)