webpage-parser 0.1.0__tar.gz → 0.2.0__tar.gz

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 (31) hide show
  1. webpage_parser-0.2.0/PKG-INFO +98 -0
  2. webpage_parser-0.2.0/README_PYPI.md +88 -0
  3. {webpage_parser-0.1.0 → webpage_parser-0.2.0}/__init__.py +2 -2
  4. webpage_parser-0.2.0/extract.py +42 -0
  5. {webpage_parser-0.1.0 → webpage_parser-0.2.0}/htmlprep.py +8 -1
  6. webpage_parser-0.2.0/pipeline.py +325 -0
  7. {webpage_parser-0.1.0 → webpage_parser-0.2.0}/pubtime.py +96 -24
  8. {webpage_parser-0.1.0 → webpage_parser-0.2.0}/pyproject.toml +1 -1
  9. webpage_parser-0.2.0/util.py +250 -0
  10. webpage_parser-0.2.0/webpage_parser.egg-info/PKG-INFO +98 -0
  11. webpage_parser-0.1.0/PKG-INFO +0 -93
  12. webpage_parser-0.1.0/README_PYPI.md +0 -83
  13. webpage_parser-0.1.0/extract.py +0 -63
  14. webpage_parser-0.1.0/pipeline.py +0 -196
  15. webpage_parser-0.1.0/util.py +0 -95
  16. webpage_parser-0.1.0/webpage_parser.egg-info/PKG-INFO +0 -93
  17. {webpage_parser-0.1.0 → webpage_parser-0.2.0}/MANIFEST.in +0 -0
  18. {webpage_parser-0.1.0 → webpage_parser-0.2.0}/author.py +0 -0
  19. {webpage_parser-0.1.0 → webpage_parser-0.2.0}/content.py +0 -0
  20. {webpage_parser-0.1.0 → webpage_parser-0.2.0}/pagegate.py +0 -0
  21. {webpage_parser-0.1.0 → webpage_parser-0.2.0}/requirements.txt +0 -0
  22. {webpage_parser-0.1.0 → webpage_parser-0.2.0}/setup.cfg +0 -0
  23. {webpage_parser-0.1.0 → webpage_parser-0.2.0}/siteconfig.py +0 -0
  24. {webpage_parser-0.1.0 → webpage_parser-0.2.0}/sitespecific.py +0 -0
  25. {webpage_parser-0.1.0 → webpage_parser-0.2.0}/timetext.py +0 -0
  26. {webpage_parser-0.1.0 → webpage_parser-0.2.0}/title.py +0 -0
  27. {webpage_parser-0.1.0 → webpage_parser-0.2.0}/webpage_parser.egg-info/SOURCES.txt +0 -0
  28. {webpage_parser-0.1.0 → webpage_parser-0.2.0}/webpage_parser.egg-info/dependency_links.txt +0 -0
  29. {webpage_parser-0.1.0 → webpage_parser-0.2.0}/webpage_parser.egg-info/entry_points.txt +0 -0
  30. {webpage_parser-0.1.0 → webpage_parser-0.2.0}/webpage_parser.egg-info/requires.txt +0 -0
  31. {webpage_parser-0.1.0 → webpage_parser-0.2.0}/webpage_parser.egg-info/top_level.txt +0 -0
@@ -0,0 +1,98 @@
1
+ Metadata-Version: 2.4
2
+ Name: webpage-parser
3
+ Version: 0.2.0
4
+ Summary: Extract structured fields (title, body, publication time, authors, language and more) from raw HTML pages
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: lxml==6.1.2
8
+ Requires-Dist: trafilatura==2.2.0
9
+ Requires-Dist: dateparser==1.4.2
10
+
11
+ # webpage-parser
12
+
13
+ Extract structured fields from a raw HTML page. Given one crawled web record,
14
+ `process_row` returns a fixed set of **35 fields** covering document identity,
15
+ URLs, the main article (title, body, headings, description, authors),
16
+ timestamps, language and content type.
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ pip install webpage-parser
22
+ ```
23
+
24
+ Python 3.10+ is required (`trafilatura` and `dateparser` need it).
25
+
26
+ ## Python API
27
+
28
+ ```python
29
+ from webpage_parser import process_row
30
+
31
+ row = {
32
+ "html": "<html>…</html>", # the main input
33
+ "url": "https://example.com/a", # everything except html is optional
34
+ }
35
+ result = process_row(row)
36
+ print(result["title"], result["published_at"], result["language"])
37
+ ```
38
+
39
+ `process_row(row)` reads the following keys from the input dict. Only `html`
40
+ really matters; without usable HTML most output fields stay empty.
41
+
42
+ | key | meaning |
43
+ | --- | --- |
44
+ | `html` | raw HTML, the main input |
45
+ | `url`, `durl`, `redirect_url` | candidate page URLs; first non-empty of `redirect_url` > `durl` > `url` wins |
46
+ | `purl` | passed through unchanged |
47
+ | `domain` | registrable-domain hint |
48
+ | `host` | full host-name hint |
49
+ | `headers` | HTTP response headers, a dict or a JSON string |
50
+ | `crawled_time`, `first_found_time`, `last_found_time` | epoch seconds (or milliseconds) |
51
+ | `anchor` | inbound anchor text |
52
+ | `in_domain_links`, `out_domain_links` | link counts; `(0, 0)` is treated as unknown |
53
+ | `raw_id`, `raw_id_version` | opaque document id / version |
54
+ | `extra` | optional dict; recognised sub-keys `time_zone` (offset in hours), `anchor`, `parser_name` |
55
+ | `img_ref` | optional dict of image references |
56
+
57
+ ## Output
58
+
59
+ `process_row` returns a dict with exactly these 35 keys:
60
+
61
+ `document_id`, `document_version`, `url`, `url_hash`, `canonical_url`,
62
+ `canonical_url_hash`, `domain`, `registrable_domain`, `purl`, `site_name`,
63
+ `favicon_url`, `host_logo_url`, `image_url`, `title`, `headings`, `body`,
64
+ `description`, `authors`, `published_at`, `published_at_confidence`,
65
+ `published_at_source`, `updated_at`, `language`, `language_confidence`,
66
+ `country_codes`, `content_type`, `mime_type`, `content_return_policy`,
67
+ `parser_version`, `crawled_at`, `first_seen_at`, `last_seen_at`,
68
+ `anchor_text`, `in_domain_links`, `out_domain_links`.
69
+
70
+ Notes on selected fields:
71
+
72
+ - `title`, `body`, `description` are strings; `headings` and `authors` are lists.
73
+ - `domain` is the full host name; `registrable_domain` is the eTLD+1.
74
+ - `favicon_url`, `host_logo_url`, `image_url`, `country_codes` and
75
+ `content_return_policy` are placeholders that are currently always empty.
76
+ - `parser_version` is a constant string.
77
+
78
+ `empty_result(row)` is also exported and returns the same 35-key dict with the
79
+ HTML-derived fields left empty. `process_row` degrades to it whenever a page
80
+ cannot be parsed, so callers always receive the complete key set.
81
+
82
+ ## Time zone
83
+
84
+ - `published_at` and `updated_at` are wall-clock strings `YYYY-MM-DD HH:MM:SS`
85
+ normalised to **UTC+8** (the default assumed site time zone). A value without
86
+ an explicit offset is interpreted using `extra["time_zone"]`, an offset in
87
+ hours, falling back to UTC+8 when absent.
88
+ - `crawled_at`, `first_seen_at` and `last_seen_at` are RFC 3339 timestamps in
89
+ **UTC** with a trailing `Z`, derived from the epoch inputs.
90
+
91
+ ## Command line
92
+
93
+ ```bash
94
+ webpage-parser --input rows.jsonl --output parsed.jsonl
95
+ ```
96
+
97
+ Each input line is a JSON object shaped like the `process_row` argument; each
98
+ output line is the 35-field JSON result.
@@ -0,0 +1,88 @@
1
+ # webpage-parser
2
+
3
+ Extract structured fields from a raw HTML page. Given one crawled web record,
4
+ `process_row` returns a fixed set of **35 fields** covering document identity,
5
+ URLs, the main article (title, body, headings, description, authors),
6
+ timestamps, language and content type.
7
+
8
+ ## Install
9
+
10
+ ```bash
11
+ pip install webpage-parser
12
+ ```
13
+
14
+ Python 3.10+ is required (`trafilatura` and `dateparser` need it).
15
+
16
+ ## Python API
17
+
18
+ ```python
19
+ from webpage_parser import process_row
20
+
21
+ row = {
22
+ "html": "<html>…</html>", # the main input
23
+ "url": "https://example.com/a", # everything except html is optional
24
+ }
25
+ result = process_row(row)
26
+ print(result["title"], result["published_at"], result["language"])
27
+ ```
28
+
29
+ `process_row(row)` reads the following keys from the input dict. Only `html`
30
+ really matters; without usable HTML most output fields stay empty.
31
+
32
+ | key | meaning |
33
+ | --- | --- |
34
+ | `html` | raw HTML, the main input |
35
+ | `url`, `durl`, `redirect_url` | candidate page URLs; first non-empty of `redirect_url` > `durl` > `url` wins |
36
+ | `purl` | passed through unchanged |
37
+ | `domain` | registrable-domain hint |
38
+ | `host` | full host-name hint |
39
+ | `headers` | HTTP response headers, a dict or a JSON string |
40
+ | `crawled_time`, `first_found_time`, `last_found_time` | epoch seconds (or milliseconds) |
41
+ | `anchor` | inbound anchor text |
42
+ | `in_domain_links`, `out_domain_links` | link counts; `(0, 0)` is treated as unknown |
43
+ | `raw_id`, `raw_id_version` | opaque document id / version |
44
+ | `extra` | optional dict; recognised sub-keys `time_zone` (offset in hours), `anchor`, `parser_name` |
45
+ | `img_ref` | optional dict of image references |
46
+
47
+ ## Output
48
+
49
+ `process_row` returns a dict with exactly these 35 keys:
50
+
51
+ `document_id`, `document_version`, `url`, `url_hash`, `canonical_url`,
52
+ `canonical_url_hash`, `domain`, `registrable_domain`, `purl`, `site_name`,
53
+ `favicon_url`, `host_logo_url`, `image_url`, `title`, `headings`, `body`,
54
+ `description`, `authors`, `published_at`, `published_at_confidence`,
55
+ `published_at_source`, `updated_at`, `language`, `language_confidence`,
56
+ `country_codes`, `content_type`, `mime_type`, `content_return_policy`,
57
+ `parser_version`, `crawled_at`, `first_seen_at`, `last_seen_at`,
58
+ `anchor_text`, `in_domain_links`, `out_domain_links`.
59
+
60
+ Notes on selected fields:
61
+
62
+ - `title`, `body`, `description` are strings; `headings` and `authors` are lists.
63
+ - `domain` is the full host name; `registrable_domain` is the eTLD+1.
64
+ - `favicon_url`, `host_logo_url`, `image_url`, `country_codes` and
65
+ `content_return_policy` are placeholders that are currently always empty.
66
+ - `parser_version` is a constant string.
67
+
68
+ `empty_result(row)` is also exported and returns the same 35-key dict with the
69
+ HTML-derived fields left empty. `process_row` degrades to it whenever a page
70
+ cannot be parsed, so callers always receive the complete key set.
71
+
72
+ ## Time zone
73
+
74
+ - `published_at` and `updated_at` are wall-clock strings `YYYY-MM-DD HH:MM:SS`
75
+ normalised to **UTC+8** (the default assumed site time zone). A value without
76
+ an explicit offset is interpreted using `extra["time_zone"]`, an offset in
77
+ hours, falling back to UTC+8 when absent.
78
+ - `crawled_at`, `first_seen_at` and `last_seen_at` are RFC 3339 timestamps in
79
+ **UTC** with a trailing `Z`, derived from the epoch inputs.
80
+
81
+ ## Command line
82
+
83
+ ```bash
84
+ webpage-parser --input rows.jsonl --output parsed.jsonl
85
+ ```
86
+
87
+ Each input line is a JSON object shaped like the `process_row` argument; each
88
+ output line is the 35-field JSON result.
@@ -1,10 +1,10 @@
1
- """网页解析算子:从爬取到的 HTML 抽取 title/content/pub_time/author。
1
+ """网页解析算子:从爬取到的 HTML 原始行抽取结构化字段。
2
2
 
3
3
  包内模块沿用「相对导入优先、扁平导入兜底」的双模式写法,因此既可以按包导入
4
4
  (`from webpage_parser import process_row`),也可以把目录直接摊平当脚本跑
5
5
  (`python3 extract.py`)。推荐按包导入。
6
6
  """
7
- __version__ = "0.1.0"
7
+ __version__ = "0.2.0"
8
8
 
9
9
  from .pipeline import empty_result, process_row
10
10
 
@@ -0,0 +1,42 @@
1
+ """CLI:逐行将上游网页 JSONL 转换为固定 35 字段结果。"""
2
+ import argparse
3
+ import json
4
+ import sys
5
+
6
+ try:
7
+ from .pipeline import process_row, empty_result
8
+ except ImportError:
9
+ sys.path.insert(0, __file__.rsplit("/", 1)[0])
10
+ from pipeline import process_row, empty_result
11
+
12
+
13
+ def _safe_row(line):
14
+ try:
15
+ obj = json.loads(line)
16
+ return obj if isinstance(obj, dict) else None
17
+ except Exception:
18
+ return None
19
+
20
+
21
+ def main():
22
+ parser = argparse.ArgumentParser()
23
+ parser.add_argument("--input", required=True)
24
+ parser.add_argument("--output", required=True)
25
+ args = parser.parse_args()
26
+
27
+ with open(args.input, "r", encoding="utf-8", errors="replace") as fin, \
28
+ open(args.output, "w", encoding="utf-8") as fout:
29
+ for line in fin:
30
+ if not line.strip():
31
+ continue
32
+ row = _safe_row(line)
33
+ try:
34
+ result = process_row(row) if row is not None else empty_result()
35
+ except Exception:
36
+ result = empty_result(row)
37
+ fout.write(json.dumps(result, ensure_ascii=False) + "\n")
38
+ return 0
39
+
40
+
41
+ if __name__ == "__main__":
42
+ sys.exit(main())
@@ -172,6 +172,7 @@ class PageSignals:
172
172
  self.visible_text_len = 0
173
173
  self.title_tag = ""
174
174
  self.h1_texts = []
175
+ self.canonical_urls = []
175
176
  self.forum = False
176
177
  self.ok = False
177
178
 
@@ -672,7 +673,13 @@ def _collect_avatar_byline(tree, signals):
672
673
 
673
674
 
674
675
  def _collect_meta(tree, signals):
675
- for el in tree.iter("meta"):
676
+ for el in tree.iter("meta", "link"):
677
+ if el.tag == "link":
678
+ rel = (el.get("rel") or "").lower().split()
679
+ href = clean_text(el.get("href") or "")
680
+ if "canonical" in rel and href:
681
+ signals.canonical_urls.append(href)
682
+ continue
676
683
  key = el.get("name") or el.get("property") or el.get("itemprop") or ""
677
684
  key = key.strip().lower() # 保留内部空格:weibo: article:create_at(调研05 坑)
678
685
  if not key:
@@ -0,0 +1,325 @@
1
+ """行级编排:一条上游网页记录转换为固定 35 字段输出。"""
2
+ import re
3
+ from datetime import datetime, timedelta
4
+ from urllib.parse import urljoin, urlparse
5
+
6
+ try:
7
+ from .util import (clean_text, norm_key, get_host, reg_domain, loop_unescape,
8
+ parse_headers, select_url, hash_url, epoch_to_rfc3339,
9
+ detect_content_type, detect_language)
10
+ from .htmlprep import parse_page
11
+ from .siteconfig import site_config
12
+ from .pagegate import gate
13
+ from .title import extract_title
14
+ from .author import extract_author
15
+ from .pubtime import extract_pubtime, pubtime_confidence
16
+ from .content import extract_content
17
+ from .timetext import parse_absolute
18
+ from .sitespecific import site_override
19
+ except ImportError:
20
+ from util import (clean_text, norm_key, get_host, reg_domain, loop_unescape,
21
+ parse_headers, select_url, hash_url, epoch_to_rfc3339,
22
+ detect_content_type, detect_language)
23
+ from htmlprep import parse_page
24
+ from siteconfig import site_config
25
+ from pagegate import gate
26
+ from title import extract_title
27
+ from author import extract_author
28
+ from pubtime import extract_pubtime, pubtime_confidence
29
+ from content import extract_content
30
+ from timetext import parse_absolute
31
+ from sitespecific import site_override
32
+
33
+ PARSER_VERSION = "1.0.0"
34
+ OUTPUT_FIELDS = (
35
+ "document_id", "document_version", "url", "url_hash", "canonical_url",
36
+ "canonical_url_hash", "domain", "registrable_domain", "purl", "site_name",
37
+ "favicon_url", "host_logo_url", "image_url", "title", "headings", "body",
38
+ "description", "authors", "published_at", "published_at_confidence",
39
+ "published_at_source", "updated_at", "language", "language_confidence",
40
+ "country_codes", "content_type", "mime_type", "content_return_policy",
41
+ "parser_version", "crawled_at", "first_seen_at", "last_seen_at",
42
+ "anchor_text", "in_domain_links", "out_domain_links",
43
+ )
44
+ _SITE_TZ_SKEW = timedelta(minutes=10)
45
+ _HEADING_RE = re.compile(r"^\s{0,3}#{1,6}\s+(.+?)\s*#*\s*$", re.M)
46
+ _SITE_SUFFIX_RE = re.compile(r"^\s*[-–—_|·•::]+\s*(.+?)\s*$")
47
+
48
+
49
+ def _string_or_none(value):
50
+ if value is None:
51
+ return None
52
+ text = str(value).strip()
53
+ return text or None
54
+
55
+
56
+ def _is_zero(value):
57
+ try:
58
+ return float(value) == 0
59
+ except (TypeError, ValueError):
60
+ return False
61
+
62
+
63
+ def empty_result(row=None):
64
+ row = row if isinstance(row, dict) else {}
65
+ url = select_url(row)
66
+ domain = _string_or_none(row.get("host")) or _string_or_none(get_host(url or ""))
67
+ registrable = _string_or_none(row.get("domain")) or \
68
+ _string_or_none(reg_domain(domain or ""))
69
+ in_links = row.get("in_domain_links")
70
+ out_links = row.get("out_domain_links")
71
+ if _is_zero(in_links) and _is_zero(out_links):
72
+ in_links = None
73
+ out_links = None
74
+ return {
75
+ "document_id": _string_or_none(row.get("raw_id")),
76
+ "document_version": _string_or_none(row.get("raw_id_version")),
77
+ "url": url,
78
+ "url_hash": hash_url(url),
79
+ "canonical_url": None,
80
+ "canonical_url_hash": None,
81
+ "domain": domain,
82
+ "registrable_domain": registrable,
83
+ "purl": row.get("purl"),
84
+ "site_name": None,
85
+ "favicon_url": None,
86
+ "host_logo_url": None,
87
+ "image_url": None,
88
+ "title": None,
89
+ "headings": [],
90
+ "body": None,
91
+ "description": None,
92
+ "authors": [],
93
+ "published_at": None,
94
+ "published_at_confidence": None,
95
+ "published_at_source": None,
96
+ "updated_at": None,
97
+ "language": "und",
98
+ "language_confidence": 0.0,
99
+ "country_codes": None,
100
+ "content_type": None,
101
+ "mime_type": None,
102
+ "content_return_policy": None,
103
+ "parser_version": PARSER_VERSION,
104
+ "crawled_at": epoch_to_rfc3339(row.get("crawled_time")),
105
+ "first_seen_at": epoch_to_rfc3339(row.get("first_found_time"), reject_zero=True),
106
+ "last_seen_at": epoch_to_rfc3339(row.get("last_found_time")),
107
+ "anchor_text": _string_or_none(row.get("anchor")),
108
+ "in_domain_links": in_links,
109
+ "out_domain_links": out_links,
110
+ }
111
+
112
+
113
+ def _visible_text(tree):
114
+ try:
115
+ body = tree.find("body")
116
+ root = body if body is not None else tree
117
+ for bad in root.iter("script", "style", "noscript", "template"):
118
+ parent = bad.getparent()
119
+ if parent is not None:
120
+ parent.remove(bad)
121
+ return root.text_content() or ""
122
+ except Exception:
123
+ return ""
124
+
125
+
126
+ def _parse_crawl_time(row):
127
+ value = row.get("crawled_time")
128
+ if value is None:
129
+ return None
130
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
131
+ try:
132
+ seconds = float(value)
133
+ if abs(seconds) >= 100_000_000_000:
134
+ seconds /= 1000.0
135
+ return datetime.utcfromtimestamp(seconds) + timedelta(hours=8)
136
+ except (TypeError, ValueError, OverflowError, OSError):
137
+ return None
138
+ text = str(value).strip()
139
+ parsed = parse_absolute(text)
140
+ return parsed.dt if parsed else None
141
+
142
+
143
+ def _first_meta(signals, *keys):
144
+ for key in keys:
145
+ for value in signals.meta.get(key, []):
146
+ text = clean_text(value)
147
+ if text:
148
+ return text
149
+ return None
150
+
151
+
152
+ def _canonical_url(signals, page_url):
153
+ base_url = page_url or ""
154
+ try:
155
+ bases = signals.tree.xpath("//base[@href][1]")
156
+ if bases:
157
+ candidate_base = urljoin(base_url, clean_text(bases[0].get("href") or ""))
158
+ parsed_base = urlparse(candidate_base)
159
+ if parsed_base.scheme.lower() in ("http", "https") and parsed_base.hostname:
160
+ base_url = candidate_base
161
+ except Exception:
162
+ pass
163
+ candidates = list(signals.canonical_urls)
164
+ candidates.extend(signals.meta.get("og:url", []))
165
+ for candidate in candidates:
166
+ try:
167
+ resolved = urljoin(base_url, clean_text(candidate))
168
+ parsed = urlparse(resolved)
169
+ if parsed.scheme.lower() in ("http", "https") and parsed.hostname:
170
+ return resolved
171
+ except Exception:
172
+ continue
173
+ return None
174
+
175
+
176
+ def _site_name(signals, title, suffix_stripped):
177
+ explicit = _first_meta(signals, "og:site_name")
178
+ if explicit:
179
+ return explicit
180
+ if not title or not suffix_stripped:
181
+ return None
182
+ raw_titles = list(signals.meta.get("og:title", []))
183
+ if signals.title_tag:
184
+ raw_titles.append(signals.title_tag)
185
+ for raw in raw_titles:
186
+ raw = clean_text(raw)
187
+ if not raw.startswith(title):
188
+ continue
189
+ match = _SITE_SUFFIX_RE.match(raw[len(title):])
190
+ if match:
191
+ value = clean_text(match.group(1))
192
+ if 1 < len(value) <= 80:
193
+ return value
194
+ return None
195
+
196
+
197
+ def _authors(value):
198
+ if not value:
199
+ return []
200
+ result = []
201
+ for item in str(value).split("、"):
202
+ item = clean_text(item)
203
+ if item and item not in result:
204
+ result.append(item)
205
+ return result
206
+
207
+
208
+ def _headings(markdown):
209
+ result = []
210
+ seen = set()
211
+ for match in _HEADING_RE.finditer(markdown or ""):
212
+ value = clean_text(match.group(1))
213
+ if value and value not in seen:
214
+ seen.add(value)
215
+ result.append(value)
216
+ return result
217
+
218
+
219
+ def _declared_language(signals):
220
+ try:
221
+ roots = signals.tree.xpath("//html[@lang][1]")
222
+ if roots:
223
+ return roots[0].get("lang") or ""
224
+ return signals.tree.get("lang") or ""
225
+ except Exception:
226
+ return ""
227
+
228
+
229
+ def process_row(row):
230
+ out = empty_result(row)
231
+ raw_html = row.get("html")
232
+ if not isinstance(raw_html, str):
233
+ raw_html = ""
234
+ headers = parse_headers(row.get("headers"))
235
+ content_type, mime_type = detect_content_type(headers, raw_html)
236
+ out["content_type"] = content_type
237
+ out["mime_type"] = mime_type
238
+ if not raw_html.strip():
239
+ return out
240
+ if len(raw_html) > 8_000_000:
241
+ raw_html = raw_html[:8_000_000]
242
+
243
+ url = out["url"] or ""
244
+ host = get_host(url) or out["domain"] or ""
245
+ extra = row.get("extra") if isinstance(row.get("extra"), dict) else {}
246
+ if out["anchor_text"] and not extra.get("anchor"):
247
+ extra = dict(extra)
248
+ extra["anchor"] = out["anchor_text"]
249
+ parser_name = extra.get("parser_name")
250
+ images = row.get("_IMAGES") if isinstance(row.get("_IMAGES"), list) else []
251
+ img_ref = row.get("img_ref") if isinstance(row.get("img_ref"), dict) else {}
252
+ crawl_dt = _parse_crawl_time(row)
253
+ cfg = site_config(host, parser_name)
254
+ signals = parse_page(raw_html, url=url, host=host,
255
+ collect_state=cfg.allow_l1b,
256
+ forum=cfg.page_type in ("forum_page", "comment_page"))
257
+ if not signals.ok:
258
+ return out
259
+
260
+ canonical = _canonical_url(signals, url)
261
+ out["canonical_url"] = canonical
262
+ out["canonical_url_hash"] = hash_url(canonical)
263
+ out["description"] = _first_meta(signals, "description", "og:description")
264
+ out["site_name"] = _first_meta(signals, "og:site_name")
265
+
266
+ page_status, _ = gate(signals, cfg, headers)
267
+ visible_text = _visible_text(signals.tree)
268
+ if page_status == "list_page":
269
+ language, confidence = detect_language(visible_text, _declared_language(signals))
270
+ out["language"] = language
271
+ out["language_confidence"] = confidence
272
+ return out
273
+ visible_norm = norm_key(visible_text)
274
+ html_norm = norm_key(loop_unescape(raw_html[:1_500_000]))
275
+
276
+ title, _, suffix_stripped = extract_title(signals, cfg, html_norm)
277
+ author, _, _ = extract_author(
278
+ signals, cfg, extra, visible_norm, html_norm, title=title)
279
+
280
+ anchor = ""
281
+ if cfg.allow_anchor_author:
282
+ anchor = clean_text(str(extra.get("anchor") or ""))
283
+ pubtime = extract_pubtime(signals, cfg, crawl_dt, extra, headers, url=url,
284
+ anchor=anchor)
285
+
286
+ if page_status == "spa_empty":
287
+ body = ""
288
+ else:
289
+ body, _, _, _ = extract_content(
290
+ signals, cfg, page_status, images, img_ref, visible_norm,
291
+ title=title, url=url, pub_time=pubtime["pub_time"])
292
+
293
+ override = site_override(host, signals, extra)
294
+ if override:
295
+ if override.get("content"):
296
+ body = override["content"]
297
+ if override.get("title"):
298
+ title = override["title"]
299
+ if override.get("author") and not author:
300
+ author = override["author"]
301
+ if override.get("pub_time_raw") and not pubtime["pub_time"]:
302
+ parsed = parse_absolute(override["pub_time_raw"])
303
+ if parsed and (not crawl_dt or parsed.dt <= crawl_dt + _SITE_TZ_SKEW):
304
+ pubtime["pub_time"] = parsed.fmt()
305
+ pubtime["pub_time_source"] = "site_rule"
306
+ pubtime["pub_time_precision"] = parsed.precision
307
+ pubtime["pub_time_tz_offset"] = parsed.tz_offset
308
+ pubtime["pub_time_raw"] = parsed.raw[:120]
309
+
310
+ out["title"] = title or None
311
+ out["body"] = body or None
312
+ out["authors"] = _authors(author)
313
+ out["headings"] = _headings(body)
314
+ if not out["site_name"]:
315
+ out["site_name"] = _site_name(signals, title, suffix_stripped)
316
+ out["published_at"] = pubtime["pub_time"] or None
317
+ out["published_at_source"] = pubtime["pub_time_source"] \
318
+ if pubtime["pub_time_source"] != "none" else None
319
+ out["published_at_confidence"] = pubtime_confidence(pubtime)
320
+ out["updated_at"] = pubtime["updated_at"]
321
+ language, confidence = detect_language(body or visible_text or title,
322
+ _declared_language(signals))
323
+ out["language"] = language
324
+ out["language_confidence"] = confidence
325
+ return out