webpage-parser 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,760 @@
1
+ """HTML 信号采集层:把原始 HTML 解析成各字段抽取器共用的信号集合。
2
+
3
+ 只做解析与归集,不做字段级仲裁。所有解析均容错,任何子步骤失败不抛出。
4
+ """
5
+ import json
6
+ import re
7
+
8
+ from lxml import html as lxml_html
9
+
10
+ try:
11
+ from .util import clean_text, loop_unescape
12
+ except ImportError: # 直接运行 extract.py 时 system/ 在 sys.path,无包上下文
13
+ from util import clean_text, loop_unescape
14
+
15
+ ARTICLE_TYPES = {
16
+ "article", "newsarticle", "blogposting", "webpage", "report",
17
+ "qapage", "scholarlyarticle", "techarticle", "socialmediaposting",
18
+ "discussionforumposting", "liveblogposting", "analysisnewsarticle",
19
+ "reviewnewsarticle", "reportagenewsarticle", "opinionnewsarticle",
20
+ }
21
+
22
+ PUB_KEYS = {
23
+ "datepublished", "pubdate", "publishtime", "publish_time", "published_time",
24
+ "createtime", "create_time", "datecreate", "firstpublished", "issued",
25
+ "datecreated", "pub_time", "publishtime", "publishdate", "publish_date",
26
+ "releasedate", "release_time",
27
+ }
28
+ MOD_KEYS = {
29
+ "datemodified", "updatetime", "update_time", "dateupdate", "mtime",
30
+ "modified", "lastmodified", "last_modified", "lastupdated", "moddate",
31
+ }
32
+
33
+ def _cls_tokens(text):
34
+ """已弃用(被 region_token_hit 取代),保留仅防外部引用。"""
35
+ out = set()
36
+ for part in re.split(r"[^A-Za-z0-9]+", text or ""):
37
+ if not part:
38
+ continue
39
+ for tok in re.findall(r"[A-Z]+(?![a-z])|[A-Z][a-z0-9]*|[a-z0-9]+", part):
40
+ out.add(tok.lower())
41
+ return out
42
+
43
+
44
+ # 状态标记类名("has-sidebar"/"comment-open" 表示页面状态而非样板区块)
45
+ _FLAG_PREFIX = {"has", "with", "no", "is", "fixed", "show", "hide", "use",
46
+ "js", "enable", "enabled", "contains"}
47
+ _STATE_SUFFIX = {"open", "closed", "on", "off", "enabled", "active",
48
+ "visible", "shown", "hidden", "expanded", "collapsed"}
49
+
50
+
51
+ def region_token_hit(attr, tokens):
52
+ """判断 class/id 字符串是否语义上**就是** tokens 命名的区块(而非布局状态标记)。
53
+
54
+ 逐 class 名判定:含 Tailwind 任意值([/(/&)的名跳过;
55
+ token 前一段是 has/fixed/with… 或后一段是 open/on/active… 视为状态标记,不算命中。
56
+ 例:'site-sidebar' 命中 sidebar;'has-sidebar'/'comment-open'/
57
+ 'xs:[&.fixed-sidebar]:grid-cols-[...]' 不命中。
58
+ """
59
+ for name in (attr or "").lower().split():
60
+ if "[" in name or "(" in name or "&" in name:
61
+ continue
62
+ segs = [s for s in re.split(r"[^a-z0-9]+", name) if s]
63
+ for idx, seg in enumerate(segs):
64
+ if seg not in tokens:
65
+ continue
66
+ prev = segs[idx - 1] if idx > 0 else None
67
+ nxt = segs[idx + 1] if idx + 1 < len(segs) else None
68
+ if prev in _FLAG_PREFIX or nxt in _STATE_SUFFIX:
69
+ continue
70
+ return True
71
+ return False
72
+
73
+
74
+ # 强 badZone:语义确定的样板区,对所有可见时间候选生效
75
+ _STRONG_BAD_TOKENS = {
76
+ "comment", "comments", "reply", "replies", "respond", "discuss",
77
+ "discussion", "recommend", "recommended", "related", "footer",
78
+ "sidebar", "breadcrumb", "breadcrumbs", "copyright", "pager",
79
+ "pagination", "commentary",
80
+ }
81
+ # 弱 badZone:易误伤(module-nav / member-photo 这类复合 class),
82
+ # 只对“无标签”的裸日期候选生效;带明确 发布时间/更新时间 标签的候选豁免
83
+ # (调研07 §9.2:L5_visible_labeled 层不排除 badZone)
84
+ _WEAK_BAD_TOKENS = {
85
+ "hot", "rank", "ranking", "trending", "share", "sharing", "advert",
86
+ "ads", "banner", "popup", "pop", "modal", "cookie", "subscribe",
87
+ "newsletter", "promo", "sponsor", "widget", "nav", "menu",
88
+ }
89
+
90
+ BADZONE_RE = re.compile( # 保留导出兼容;新逻辑用 _in_badzone
91
+ r"comment|reply|respond|discuss|footer|recommend|related|sidebar|nav|menu|"
92
+ r"copyright|hot|rank|trending|breadcrumb|share|advert|banner|popup|modal|"
93
+ r"cookie|subscribe|newsletter|promo|sponsor|widget",
94
+ re.IGNORECASE,
95
+ )
96
+
97
+ DATE_LIKE_RE = re.compile(
98
+ r"(19|20)\d{2}[-/.\u5e74]\s*\d{1,2}[-/.\u6708]\s*\d{1,2}"
99
+ r"|\d{4}-\d{2}-\d{2}[Tt ]\d{2}:\d{2}"
100
+ r"|\d{1,2}/\d{1,2}/(?:19|20)\d{2}"
101
+ r"|\d{1,2}\s*(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)"
102
+ r"|(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\s+\d{1,2}"
103
+ r"|\d+\s*\u4e2a?\s*(\u79d2|\u5206\u949f|\u5c0f\u65f6|\u5929|\u65e5|\u5468|\u661f\u671f)\u524d"
104
+ r"|\u6628\u5929|\u4eca\u5929|\u524d\u5929|\u521a\u521a"
105
+ r"|\d+\s*(seconds?|minutes?|hours?|days?|weeks?)\s+ago",
106
+ re.IGNORECASE,
107
+ )
108
+
109
+ HINT_PUB_RE = re.compile(
110
+ r"\u53d1\u5e03\u65f6\u95f4|\u53d1\u5e03\u4e8e|\u53d1\u8868\u4e8e|\u53d1\u8868\u65f6\u95f4|\u6765\u6e90.{0,4}\u65f6\u95f4|"
111
+ r"\u516c\u544a\u65e5\u671f|\u53d1\u5e03\u65e5\u671f|\u521b\u5efa\u65f6\u95f4|\u51fa\u7248\u65e5\u671f|"
112
+ r"published|posted|pubdate|release date|date published",
113
+ re.IGNORECASE,
114
+ )
115
+ HINT_MOD_RE = re.compile(
116
+ r"\u66f4\u65b0\u65f6\u95f4|\u66f4\u65b0\u4e8e|\u6700\u540e\u66f4\u65b0|\u4fee\u8ba2|\u7f16\u8f91\u4e8e|"
117
+ r"updated|modified|last[ -]?modified|edited",
118
+ re.IGNORECASE,
119
+ )
120
+
121
+ _SKIP_TAGS = {"script", "style", "noscript", "template", "svg", "iframe",
122
+ "canvas", "form", "button", "input", "select", "textarea"}
123
+ _BLOCK_TAGS = {"p", "div", "span", "li", "td", "th", "time", "em", "strong",
124
+ "b", "i", "small", "label", "h1", "h2", "h3", "h4", "h5", "h6"}
125
+
126
+ # 可见文本是相对时间的元素(其 title/datetime 属性常带同刻高精度绝对时间,
127
+ # 如 gcores <span title="2026-08-28 04:51:19">31 分钟前</span>,验收反馈 case_02)
128
+ _REL_TEXT_RE = re.compile(
129
+ r"(\u79d2|\u5206\u949f|\u5c0f\u65f6|\u5929|\u65e5|\u5468|\u661f\u671f)\s*\u524d\s*$"
130
+ r"|\bago\s*$"
131
+ r"|^(?:\u6628\u5929|\u4eca\u5929|\u524d\u5929|\u521a\u521a|\u4eca\u65e5|\u6628\u65e5)"
132
+ r"|^(?:yesterday|today)\b",
133
+ re.IGNORECASE,
134
+ )
135
+
136
+
137
+ def _user_panel(el):
138
+ """是否位于"当前用户面板"(SMF <div class="user"> 里的当前时刻显示)。
139
+
140
+ 只认 class 恰为单词 'user' 或 id=='user' 的祖先:user-info/user-name 这类
141
+ byline 署名区不命中(它们的 <time> 是真实发布时间)。
142
+ """
143
+ n = 0
144
+ for anc in el.iterancestors():
145
+ if not isinstance(anc.tag, str) or anc.tag in ("body", "html"):
146
+ break
147
+ cls = (anc.get("class") or "").split()
148
+ aid = (anc.get("id") or "").strip().lower()
149
+ if cls == ["user"] or aid == "user":
150
+ return True
151
+ n += 1
152
+ if n >= 5:
153
+ break
154
+ return False
155
+
156
+
157
+ class PageSignals:
158
+ def __init__(self, url="", host=""):
159
+ self.url = url
160
+ self.host = host
161
+ self.html = ""
162
+ self.tree = None
163
+ self.meta = {} # key(lower, 保留空格) -> [value, ...]
164
+ self.jsonld_nodes = [] # 摊平后的节点 dict 列表
165
+ self.state_objs = [] # L1b 大 JSON 对象
166
+ self.time_elements = [] # (raw, strong_badzone, pos_ratio)
167
+ self.attr_times = [] # 元素属性里的时间 (raw, strong_badzone, pos_ratio)
168
+ self.comment_times = [] # CMS 注释里的原始时间串
169
+ self.itemprop_times = [] # microdata itemprop 时间 (raw, sem, strong_badzone)
170
+ self.visible_dates = [] # (txt, hint, labeled, strong_bz, weak_bz, pos_ratio)
171
+ self.author_nodes = [] # (txt, strong_bz, weak_bz, pos_ratio, tag)
172
+ self.visible_text_len = 0
173
+ self.title_tag = ""
174
+ self.h1_texts = []
175
+ self.forum = False
176
+ self.ok = False
177
+
178
+
179
+ def _tolerant_json(text):
180
+ if not text:
181
+ return None
182
+ text = text.strip().strip(";")
183
+ if not text or text[0] not in "[{":
184
+ return None
185
+ # 小红书 __INITIAL_STATE__ 等内嵌 JS 字面量 undefined,替换后再试
186
+ variants = [text, re.sub(r",(\s*[}\]])", r"\1", text)]
187
+ if ":undefined" in text:
188
+ fixed = text.replace(":undefined", ":null")
189
+ variants += [fixed, re.sub(r",(\s*[}\]])", r"\1", fixed)]
190
+ for candidate in variants:
191
+ try:
192
+ return json.loads(candidate, strict=False)
193
+ except Exception:
194
+ continue
195
+ return None
196
+
197
+
198
+ def _balance_extract(text, start):
199
+ """从 text[start](应为 '{' 或 '[')做括号配平截取,返回子串或 None。"""
200
+ if start >= len(text) or text[start] not in "{[":
201
+ return None
202
+ open_ch = text[start]
203
+ close_ch = "}" if open_ch == "{" else "]"
204
+ depth = 0
205
+ in_str = False
206
+ esc = False
207
+ i = start
208
+ end = min(len(text), start + 2_000_000)
209
+ while i < end:
210
+ ch = text[i]
211
+ if in_str:
212
+ if esc:
213
+ esc = False
214
+ elif ch == "\\":
215
+ esc = True
216
+ elif ch == '"':
217
+ in_str = False
218
+ else:
219
+ if ch == '"':
220
+ in_str = True
221
+ elif ch == open_ch:
222
+ depth += 1
223
+ elif ch == close_ch:
224
+ depth -= 1
225
+ if depth == 0:
226
+ return text[start:i + 1]
227
+ i += 1
228
+ return None
229
+
230
+
231
+ def _flatten_jsonld(obj, out, depth=0):
232
+ if depth > 6:
233
+ return
234
+ if isinstance(obj, dict):
235
+ if "@graph" in obj:
236
+ _flatten_jsonld(obj["@graph"], out, depth + 1)
237
+ node = {k: v for k, v in obj.items() if k != "@graph"}
238
+ if node:
239
+ out.append(node)
240
+ for v in obj.values():
241
+ if isinstance(v, (dict, list)) and v is not obj.get("@graph"):
242
+ pass # 嵌套实体保留在节点内,由取值方递归处理
243
+ elif isinstance(obj, list):
244
+ for item in obj:
245
+ _flatten_jsonld(item, out, depth + 1)
246
+
247
+
248
+ def walk_json_keys(obj, prefix="", depth=0):
249
+ """递归遍历 JSON,产出 (dotted_lower_key, value)。供 L1b 时间/作者搜键用。"""
250
+ if depth > 8:
251
+ return
252
+ if isinstance(obj, dict):
253
+ for k, v in obj.items():
254
+ key = f"{prefix}.{k}".lower() if prefix else str(k).lower()
255
+ yield key, v
256
+ if isinstance(v, (dict, list)):
257
+ yield from walk_json_keys(v, key, depth + 1)
258
+ elif isinstance(obj, list):
259
+ for item in obj[:50]:
260
+ if isinstance(item, (dict, list)):
261
+ yield from walk_json_keys(item, prefix, depth + 1)
262
+
263
+
264
+ def jsonld_regex_fallback(text):
265
+ """JSON 解析全失败时按键名正则直取(最后兜底,调研03 §2.4)。"""
266
+ node = {}
267
+ for key in ("datePublished", "dateModified", "headline"):
268
+ m = re.search(r'"' + key + r'"\s*:\s*"([^"{}]{4,64})"', text)
269
+ if m:
270
+ node[key] = m.group(1)
271
+ m = re.search(r'"author"\s*:\s*\{[^{}]{0,200}?"name"\s*:\s*"([^"{}]{1,80})"', text)
272
+ if m:
273
+ node["author"] = {"name": m.group(1)}
274
+ if node:
275
+ node["@type"] = "Article"
276
+ return node if node else None
277
+
278
+
279
+ def _collect_jsonld(tree, signals):
280
+ for sc in tree.iter("script"):
281
+ t = (sc.get("type") or "").lower().strip()
282
+ if "ld+json" not in t:
283
+ continue
284
+ text = sc.text_content() or ""
285
+ if len(text) > 500_000:
286
+ text = text[:500_000]
287
+ obj = _tolerant_json(text)
288
+ if obj is None:
289
+ node = jsonld_regex_fallback(text)
290
+ if node:
291
+ signals.jsonld_nodes.append(node)
292
+ continue
293
+ _flatten_jsonld(obj, signals.jsonld_nodes)
294
+
295
+
296
+ _STATE_HINT_RE = re.compile(
297
+ r"datePublished|publishTime|publish_time|pubDate|createTime|dateUpdate|"
298
+ r"updateTime|__NEXT_DATA__|__NUXT__|__INITIAL_STATE__|jsonData",
299
+ re.IGNORECASE,
300
+ )
301
+
302
+
303
+ def _collect_state_json(tree, signals):
304
+ """L1b:扫描 <script> 中的大 JSON 字面量(不硬编码变量名,调研03 §2.2)。"""
305
+ count = 0
306
+ for sc in tree.iter("script"):
307
+ if count >= 4:
308
+ break
309
+ t = (sc.get("type") or "").lower().strip()
310
+ if "ld+json" in t:
311
+ continue
312
+ text = sc.text_content() or ""
313
+ if len(text) < 100 or len(text) > 1_500_000:
314
+ continue
315
+ if not _STATE_HINT_RE.search(text):
316
+ continue
317
+ # 找变量赋值后的第一个 '{',做括号配平
318
+ eq = text.find("=")
319
+ start = text.find("{", eq if eq >= 0 else 0)
320
+ if start < 0:
321
+ start = text.find("{")
322
+ sub = _balance_extract(text, start) if start >= 0 else None
323
+ obj = _tolerant_json(sub) if sub else None
324
+ if obj is None:
325
+ obj = _tolerant_json(text)
326
+ if obj is not None:
327
+ signals.state_objs.append(obj)
328
+ count += 1
329
+
330
+
331
+ _FORUM_SOFT_TOKENS = {"comment", "comments", "reply", "replies", "discuss",
332
+ "discussion"}
333
+
334
+
335
+ def _in_badzone(el, forum=False):
336
+ """返回 (strong, weak)。标签/role 恒为强;class/id 按 token 分级。
337
+
338
+ - body/html 的类名是页面级布局声明(WP body class、Tailwind 布局容器),
339
+ 不是局部样板区,跳过(样本实证:m-sidebar/comment-open/fixed-sidebar);
340
+ - <article> 内部的 <header>/<footer> 是文章署名/元信息区(Nexstar、懂球帝
341
+ 的 byline 都在 article>header 里),不算 badZone;
342
+ - forum=True(论坛/评论页型)时 comment/reply 楼层 token 降级为弱:
343
+ 楼层即正文(V3 业务裁定:论坛保留讨论区)。
344
+ """
345
+ strong = False
346
+ weak = False
347
+ in_article = False
348
+ for anc in el.iterancestors():
349
+ if not isinstance(anc.tag, str):
350
+ continue
351
+ if anc.tag in ("body", "html"):
352
+ break
353
+ if anc.tag == "article":
354
+ in_article = True
355
+ continue
356
+ if anc.tag in ("nav", "header", "footer", "aside"):
357
+ anc_attr = (anc.get("class") or "") + " " + (anc.get("id") or "")
358
+ if in_article and anc.tag in ("header", "footer"):
359
+ continue
360
+ if anc.tag in ("header", "footer") and region_token_hit(
361
+ anc_attr, {"article", "post", "entry"}):
362
+ continue
363
+ # 含 h1 的 header/footer 是文章头(署名/日期区),不是页头样板
364
+ # (验收反馈 case_01:vietnam.vn ant-layout-header 内含 h1+byline+日期)
365
+ if anc.tag in ("header", "footer"):
366
+ try:
367
+ if any(clean_text(h.text_content()) for h in anc.iter("h1")):
368
+ continue
369
+ except Exception:
370
+ pass
371
+ strong = True
372
+ continue
373
+ attr = (anc.get("class") or "") + " " + (anc.get("id") or "")
374
+ strong_tokens = _STRONG_BAD_TOKENS
375
+ if forum:
376
+ strong_tokens = strong_tokens - _FORUM_SOFT_TOKENS
377
+ if region_token_hit(attr, _FORUM_SOFT_TOKENS):
378
+ weak = True
379
+ if region_token_hit(attr, strong_tokens):
380
+ strong = True
381
+ if region_token_hit(attr, _WEAK_BAD_TOKENS):
382
+ weak = True
383
+ role = anc.get("role") or ""
384
+ if role in ("navigation", "banner", "complementary", "search"):
385
+ strong = True
386
+ return strong, weak
387
+
388
+
389
+ def _neighbor_label(el):
390
+ """取相邻结构里的短标签文本:前驱兄弟元素,或所在表格行首格。
391
+
392
+ 覆盖 <td class="module-name">更新时间</td><td>2026-08-27…</td> 这类
393
+ 标签与值分离的中文 CMS 表格标注(验收 case_00)。
394
+ """
395
+ try:
396
+ sib = el.getprevious()
397
+ while sib is not None:
398
+ t = clean_text(sib.text_content())
399
+ if t:
400
+ if len(t) <= 16:
401
+ return t
402
+ return ""
403
+ sib = sib.getprevious()
404
+ if el.tag in ("td", "th"):
405
+ row = None
406
+ for anc in el.iterancestors():
407
+ if anc.tag == "tr":
408
+ row = anc
409
+ break
410
+ if row is not None:
411
+ for cell in row.iter("td", "th"):
412
+ if cell is not el:
413
+ t = clean_text(cell.text_content())
414
+ if t and len(t) <= 16:
415
+ return t
416
+ return ""
417
+ except Exception:
418
+ return ""
419
+ return ""
420
+
421
+
422
+ def _collect_comments(raw_html, signals):
423
+ """CMS 注释时间(enpproperty 等,毫秒冒号分隔,调研03 §3.5)。"""
424
+ for m in re.finditer(r"<!--(.{0,2000}?)-->", raw_html):
425
+ body = m.group(1)
426
+ dm = re.search(r"<date>([^<]{6,40})</date>", body)
427
+ if dm:
428
+ signals.comment_times.append(dm.group(1).strip())
429
+ continue
430
+ if re.search(r"publish|createdate|pubdate", body, re.IGNORECASE):
431
+ dm = re.search(r"((?:19|20)\d{2}[-/]\d{1,2}[-/]\d{1,2}[ T]?\d{0,2}:?\d{0,2}:?\d{0,2}:?\d{0,3})", body)
432
+ if dm:
433
+ signals.comment_times.append(dm.group(1).strip())
434
+
435
+
436
+ _PUB_LABEL_NEAR_DATE_RE = re.compile(
437
+ r"(?:\u53d1\u5e03\u65f6\u95f4|\u53d1\u8868\u65f6\u95f4|\u53d1\u5e03\u65e5\u671f|\u53d1\u8868\u65e5\u671f|"
438
+ r"\u53d1\u5e03\u4e8e|\u53d1\u8868\u4e8e|\u51fa\u7248\u65e5\u671f|\u516c\u544a\u65e5\u671f|"
439
+ r"\u521b\u5efa\u65f6\u95f4|\u521b\u5efa\u65e5\u671f|\u520a\u767b\u65e5\u671f)"
440
+ r"\s*[\uff08(:\uff1a]?\s*(?=(?:19|20)\d{2})"
441
+ )
442
+ _MOD_LABEL_NEAR_DATE_RE = re.compile(
443
+ r"(?:\u66f4\u65b0\u65f6\u95f4|\u66f4\u65b0\u65e5\u671f|\u6700\u540e\u66f4\u65b0|\u6700\u8fd1\u66f4\u65b0|"
444
+ r"\u66f4\u65b0\u4e8e|\u4fee\u8ba2\u65f6\u95f4)"
445
+ r"\s*[\uff08(:\uff1a]?\s*(?=(?:19|20)\d{2})"
446
+ )
447
+
448
+
449
+ def _collect_time_elements(tree, signals):
450
+ body = tree.find("body") if tree.tag != "body" else tree
451
+ if body is None:
452
+ body = tree
453
+ total = 0
454
+ for el in body.iter():
455
+ if el.tag in _SKIP_TAGS:
456
+ continue
457
+ t = el.text or ""
458
+ total += len(t)
459
+ total = max(total, 1)
460
+ signals.visible_text_len = total
461
+
462
+ run = 0
463
+ seen_text = set()
464
+ for el in body.iter():
465
+ if el.tag in _SKIP_TAGS:
466
+ continue
467
+ t = el.text or ""
468
+ run += len(t)
469
+ pos = min(run / total, 1.0)
470
+ strong_bz = None
471
+ if el.tag == "time":
472
+ dt_attr = el.get("datetime") or ""
473
+ txt = clean_text(el.text_content())[:60]
474
+ raw = dt_attr.strip() or txt
475
+ if raw and DATE_LIKE_RE.search(raw):
476
+ strong_bz, _w = _in_badzone(el, getattr(signals, "forum", False))
477
+ signals.time_elements.append((raw, strong_bz, pos, _user_panel(el)))
478
+ for attr in ("data-time", "data-date", "data-ts", "data-timestamp",
479
+ "data-pubtime", "data-publish-time", "data-createtime"):
480
+ v = el.get(attr)
481
+ if v and (DATE_LIKE_RE.search(v) or v.strip().isdigit()):
482
+ if strong_bz is None:
483
+ strong_bz, _w = _in_badzone(el, getattr(signals, "forum", False))
484
+ signals.attr_times.append((v.strip(), strong_bz, pos))
485
+ # 可见文本日期扫描:只看短文本块
486
+ if el.tag in _BLOCK_TAGS:
487
+ txt = clean_text(el.text_content())
488
+ if not txt or len(txt) > 300 or len(txt) < 6:
489
+ continue
490
+ if txt in seen_text:
491
+ continue
492
+ if not DATE_LIKE_RE.search(txt):
493
+ continue
494
+ seen_text.add(txt)
495
+ # 复合标注:标签紧邻日期("更新时间 2026-08-27(发布时间2026-08-27)")
496
+ # 发布标签优先于更新标签(同一文本块内两者同时出现时)
497
+ hint = ""
498
+ labeled = False
499
+ if _PUB_LABEL_NEAR_DATE_RE.search(txt):
500
+ hint, labeled = "pub", True
501
+ elif _MOD_LABEL_NEAR_DATE_RE.search(txt):
502
+ hint, labeled = "mod", True
503
+ elif HINT_PUB_RE.search(txt):
504
+ hint = "pub"
505
+ elif HINT_MOD_RE.search(txt):
506
+ hint = "mod"
507
+ if not hint:
508
+ nb = _neighbor_label(el)
509
+ if nb:
510
+ if HINT_PUB_RE.search(nb):
511
+ hint, labeled = "pub", True
512
+ elif HINT_MOD_RE.search(nb):
513
+ hint, labeled = "mod", True
514
+ strong, weak = _in_badzone(el, getattr(signals, "forum", False))
515
+ # 相对时间节点的高精度属性(title/datetime 里的绝对时间,与相对文本同刻)
516
+ if _REL_TEXT_RE.search(txt):
517
+ for a in ("title", "datetime"):
518
+ av = (el.get(a) or "").strip()
519
+ if av and DATE_LIKE_RE.search(av):
520
+ signals.attr_times.append((av, strong, pos))
521
+ break
522
+ signals.visible_dates.append((txt, hint, labeled, strong, weak, pos))
523
+ if len(signals.visible_dates) >= 40:
524
+ break
525
+
526
+
527
+ _AUTHOR_BYLINE_TOKENS = {"author", "byline", "writer"}
528
+ _AUTHOR_TESTID_RE = re.compile(r"author|byline", re.IGNORECASE)
529
+
530
+
531
+ def _collect_author_nodes(tree, signals):
532
+ """署名候选节点(调研03 §5.1 #3/#4):itemprop/rel/data-testid/class-id 四路。
533
+
534
+ 只取叶级命中(后代中无其他命中节点),文本截断 80 字符;
535
+ 后续清洗与强拒绝在 author.py 完成。
536
+ """
537
+ body = tree.find("body") if tree.tag != "body" else tree
538
+ if body is None:
539
+ body = tree
540
+ hits = []
541
+ for el in body.iter():
542
+ if not isinstance(el.tag, str) or el.tag in _SKIP_TAGS:
543
+ continue
544
+ kind = None
545
+ testid = el.get("data-testid") or ""
546
+ if testid and _AUTHOR_TESTID_RE.search(testid):
547
+ kind = "testid"
548
+ elif (el.get("itemprop") or "").strip().lower() == "author":
549
+ kind = "itemprop"
550
+ elif el.tag == "a" and (el.get("rel") or "").strip().lower() == "author":
551
+ kind = "rel"
552
+ else:
553
+ attr = (el.get("class") or "") + " " + (el.get("id") or "")
554
+ if region_token_hit(attr, _AUTHOR_BYLINE_TOKENS):
555
+ kind = "cls"
556
+ if not kind:
557
+ continue
558
+ hits.append(el)
559
+ seen = set()
560
+ total = max(signals.visible_text_len, 1)
561
+ run = 0
562
+ hit_set = set(id(h) for h in hits)
563
+ for el in body.iter():
564
+ if not isinstance(el.tag, str) or el.tag in _SKIP_TAGS:
565
+ continue
566
+ run += len(el.text or "")
567
+ if id(el) not in hit_set:
568
+ continue
569
+ # 叶级判定:后代里还有命中节点则跳过父容器
570
+ leaf = True
571
+ for ch in el.iter():
572
+ if ch is not el and id(ch) in hit_set:
573
+ leaf = False
574
+ break
575
+ if not leaf:
576
+ continue
577
+ txt = ""
578
+ if (el.get("itemprop") or "").strip().lower() == "author":
579
+ nm = (el.get("content") or "").strip()
580
+ if not nm:
581
+ for sub in el.iter():
582
+ if (sub.get("itemprop") or "").strip().lower() == "name":
583
+ nm = clean_text(sub.text_content())
584
+ break
585
+ txt = nm or clean_text(el.text_content())
586
+ else:
587
+ txt = clean_text(el.text_content())
588
+ if not txt or len(txt) > 80 or txt in seen:
589
+ continue
590
+ if txt.isascii() and len(txt) < 3:
591
+ continue
592
+ seen.add(txt)
593
+ strong, weak = _in_badzone(el, getattr(signals, "forum", False))
594
+ signals.author_nodes.append((txt, strong, weak, min(run / total, 1.0), el.tag))
595
+ if len(signals.author_nodes) >= 30:
596
+ break
597
+
598
+
599
+ _AVATAR_ALT_BLOCK = frozenset({
600
+ "微信小程序", "微信", "新浪微博", "微博", "小程序", "公众号", "抖音", "快手",
601
+ "今日头条", "朋友圈", "qq", "qq空间", "app", "wechat", "weibo", "facebook",
602
+ "twitter", "x", "instagram", "linkedin", "whatsapp", "telegram", "email",
603
+ "mail", "分享", "收藏", "点赞", "评论", "打印", "字体", "字号",
604
+ "微信公众号", "话题追踪", "官方微博", "搜索", "专业版功能", "微博二维码",
605
+ "在线客服",
606
+ })
607
+ _AVATAR_ANCESTOR_RE = re.compile(
608
+ r"tool|share|popover|sns|avatar|recommand|recommend", re.IGNORECASE)
609
+ _AVATAR_PUNCT = set(":,。!?!?.")
610
+
611
+
612
+ def _collect_avatar_byline(tree, signals):
613
+ """头像署名:img[alt] 与同一容器内兄弟节点的文本完全一致 → 作者名
614
+ (验收 case_01:越南站 byline 只有头像 alt + 紧邻 span 名字,无 class 特征)。"""
615
+ body = tree.find("body") if tree.tag != "body" else tree
616
+ if body is None:
617
+ body = tree
618
+ found = []
619
+ for img in body.iter("img"):
620
+ alt = clean_text(img.get("alt") or "")
621
+ if not (2 <= len(alt) <= 40) or any(ch.isdigit() for ch in alt):
622
+ continue
623
+ if any(c in _AVATAR_PUNCT for c in alt):
624
+ continue
625
+ if alt.isascii() and " " not in alt and (len(alt) < 4 or alt.isupper()):
626
+ continue
627
+ if "扫码" in alt or "下载" in alt or alt.endswith("公众号"):
628
+ continue
629
+ if alt.lower() in _AVATAR_ALT_BLOCK:
630
+ continue
631
+ parent = img.getparent()
632
+ if parent is None:
633
+ continue
634
+ # 工具栏/分享条里的 icon+label 组合不是署名(回归 s1#16-19 微信小程序)
635
+ chrome = False
636
+ for anc in img.iterancestors():
637
+ if anc.tag == "button":
638
+ chrome = True
639
+ break
640
+ aattr = ((anc.get("class") or "") + " " + (anc.get("id") or ""))
641
+ if _AVATAR_ANCESTOR_RE.search(aattr):
642
+ chrome = True
643
+ break
644
+ if anc.tag in ("body",):
645
+ break
646
+ if chrome:
647
+ continue
648
+ containers = [parent]
649
+ gp = parent.getparent()
650
+ if gp is not None:
651
+ containers.append(gp)
652
+ match = None
653
+ for box in containers:
654
+ for i, sib in enumerate(box.iter("span", "a", "p", "strong", "b", "em", "label")):
655
+ if i >= 60:
656
+ break
657
+ if clean_text(sib.text_content()) == alt:
658
+ match = sib
659
+ break
660
+ if match is not None:
661
+ break
662
+ if match is None:
663
+ continue
664
+ # 命中的是链接文本 → 导航/相关文章/产品网格,不是署名(回归 s1#58/86/179/199/290)
665
+ if match.tag == "a" or any(a.tag == "a" for a in match.iterancestors()):
666
+ continue
667
+ strong, weak = _in_badzone(match, getattr(signals, "forum", False))
668
+ found.append((alt, strong, weak, 0.0, "img_alt"))
669
+ # 一页命中过多 → 列表/网格页 alt 噪音,整批弃用(case_01 真值仅 1 个命中)
670
+ if 0 < len(found) <= 3:
671
+ signals.author_nodes.extend(found)
672
+
673
+
674
+ def _collect_meta(tree, signals):
675
+ for el in tree.iter("meta"):
676
+ key = el.get("name") or el.get("property") or el.get("itemprop") or ""
677
+ key = key.strip().lower() # 保留内部空格:weibo: article:create_at(调研05 坑)
678
+ if not key:
679
+ continue
680
+ val = el.get("content")
681
+ if val is None:
682
+ continue
683
+ val = clean_text(val)
684
+ if not val:
685
+ continue
686
+ signals.meta.setdefault(key, []).append(val)
687
+
688
+
689
+ _ITEMPROP_PUB = {"datepublished", "datecreated", "pubdate", "publishdate"}
690
+ _ITEMPROP_MOD = {"datemodified", "dateupdate"}
691
+
692
+
693
+ def _collect_itemprop(tree, signals):
694
+ """Microdata 时间:<span itemprop="datePublished" content="...">(gamespress 等)。"""
695
+ for el in tree.iter():
696
+ if not isinstance(el.tag, str) or el.tag in _SKIP_TAGS:
697
+ continue
698
+ ip = (el.get("itemprop") or "").strip().lower()
699
+ if ip not in _ITEMPROP_PUB and ip not in _ITEMPROP_MOD:
700
+ continue
701
+ raw = (el.get("content") or el.get("datetime") or "").strip()
702
+ attr_based = bool(raw)
703
+ if not raw:
704
+ raw = clean_text(el.text_content())[:60]
705
+ if not raw or not DATE_LIKE_RE.search(raw):
706
+ continue
707
+ strong, _w = _in_badzone(el, getattr(signals, "forum", False))
708
+ if attr_based:
709
+ strong = False
710
+ sem = "pub" if ip in _ITEMPROP_PUB else "mod"
711
+ signals.itemprop_times.append((raw, sem, strong))
712
+ if len(signals.itemprop_times) >= 20:
713
+ break
714
+
715
+
716
+ def _collect_titles(tree, signals):
717
+ for el in tree.iter("title"):
718
+ txt = clean_text(el.text_content())
719
+ if txt:
720
+ signals.title_tag = txt
721
+ break
722
+ for el in tree.iter("h1"):
723
+ txt = clean_text(el.text_content())
724
+ if txt:
725
+ signals.h1_texts.append(txt)
726
+ if len(signals.h1_texts) >= 3:
727
+ break
728
+
729
+
730
+ def parse_page(raw_html, url="", host="", collect_state=True, forum=False):
731
+ """主入口:原始 HTML -> PageSignals。任何失败返回 ok=False 的空信号。"""
732
+ signals = PageSignals(url=url, host=host)
733
+ signals.html = raw_html or ""
734
+ signals.forum = bool(forum)
735
+ if not raw_html:
736
+ return signals
737
+ try:
738
+ _collect_comments(signals.html, signals)
739
+ except Exception:
740
+ pass
741
+ try:
742
+ doc = lxml_html.fromstring(raw_html)
743
+ except Exception:
744
+ try:
745
+ doc = lxml_html.fromstring(loop_unescape(raw_html))
746
+ except Exception:
747
+ return signals
748
+ signals.tree = doc
749
+ signals.ok = True
750
+ fns = [_collect_meta, _collect_titles, _collect_jsonld,
751
+ _collect_time_elements, _collect_itemprop, _collect_author_nodes,
752
+ _collect_avatar_byline]
753
+ if collect_state:
754
+ fns.insert(3, _collect_state_json)
755
+ for fn in fns:
756
+ try:
757
+ fn(doc, signals)
758
+ except Exception:
759
+ continue
760
+ return signals