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.
- webpage_parser/__init__.py +11 -0
- webpage_parser/author.py +503 -0
- webpage_parser/content.py +915 -0
- webpage_parser/extract.py +63 -0
- webpage_parser/htmlprep.py +760 -0
- webpage_parser/pagegate.py +177 -0
- webpage_parser/pipeline.py +196 -0
- webpage_parser/pubtime.py +490 -0
- webpage_parser/siteconfig.py +110 -0
- webpage_parser/sitespecific.py +170 -0
- webpage_parser/timetext.py +365 -0
- webpage_parser/title.py +222 -0
- webpage_parser/util.py +95 -0
- webpage_parser-0.1.0.dist-info/METADATA +93 -0
- webpage_parser-0.1.0.dist-info/RECORD +18 -0
- webpage_parser-0.1.0.dist-info/WHEEL +5 -0
- webpage_parser-0.1.0.dist-info/entry_points.txt +2 -0
- webpage_parser-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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
|