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,915 @@
1
+ """content 抽取:两段式(调研03 §3.2)。
2
+
3
+ 阶段 A:DOM 预清理 + 图片白名单改写(_IMAGES 校验 -> 原站 URL -> 后续 img_ref 映射)
4
+ 阶段 B:trafilatura 2.2.0 fast markdown 抽取 + Markdown 后处理
5
+ (强/弱噪声双表 + 长度闸门、独立来源标注首段删除、空白归一)
6
+
7
+ 任何子步骤失败向外抛出由 pipeline 兜底为空串。
8
+ """
9
+ import re
10
+ from copy import deepcopy
11
+ from urllib.parse import urlparse
12
+
13
+ from lxml import html as lxml_html
14
+ from lxml import etree
15
+
16
+ try:
17
+ from .util import clean_text, norm_key, has_cjk
18
+ from .htmlprep import region_token_hit
19
+ from .timetext import parse_absolute
20
+ except ImportError:
21
+ from util import clean_text, norm_key, has_cjk
22
+ from htmlprep import region_token_hit
23
+ from timetext import parse_absolute
24
+
25
+ _REMOVE_TAGS = ("script", "style", "noscript", "template", "svg", "iframe",
26
+ "canvas", "form", "button", "input", "select", "textarea")
27
+ _BOILER_TOKENS = {
28
+ "sidebar", "breadcrumb", "breadcrumbs", "related", "recommend", "recommended",
29
+ "footer", "copyright", "promo", "advert", "banner", "cookie", "subscribe",
30
+ "newsletter", "hot", "rank", "ranking", "location", "qrcode", "share",
31
+ # statement:网易系 post_statement/creative_statement 平台免责声明容器
32
+ #(与 post_body 并列、非正文;验收反馈 case_01/06/08/09)。token 精确匹配,
33
+ # "statements" 不命中。
34
+ "statement",
35
+ # nav/menu 误伤率高(如 module-nav 是数据表命名),单独做密度/长度门控
36
+ }
37
+ _BOILER_NAVY_TOKENS = {"nav", "menu"}
38
+ _COMMENT_TOKENS = {"comment", "comments", "discuss", "reply"}
39
+ _MODULE_TITLE_RE = re.compile(
40
+ r"^(.{0,10}(大家都在|也看过|猜你喜欢|相关推荐|延伸阅读|友情链接|合作伙伴).{0,4}|"
41
+ r"(相关|推荐|最新|热门)(文章|新闻|阅读|资讯|内容)|"
42
+ r".{1,10}分站|热门.{0,8}(推荐|排行|榜))$")
43
+ _INVISIBLE_RE = re.compile(r"display\s*:\s*none|visibility\s*:\s*hidden|opacity\s*:\s*0",
44
+ re.IGNORECASE)
45
+
46
+ _IMG_ATTRS = ("src", "data-src", "data-original", "data-lazy-src", "data-actualsrc",
47
+ "data-url", "data-echo", "data-img")
48
+ _QUERY_IGNORE = {"auth", "sig", "sign", "width", "height", "w", "h", "quality",
49
+ "strip", "format", "smart", "fit", "auto", "token", "t", "size"}
50
+
51
+ _STRONG_NOISE = {
52
+ "\u5c55\u5f00\u5168\u6587", "\u67e5\u770b\u539f\u6587", "\u9605\u8bfb\u5168\u6587",
53
+ "\u6536\u8d77", "\u76f8\u5173\u9605\u8bfb", "\u731c\u4f60\u559c\u6b22",
54
+ "\u70ed\u95e8\u63a8\u8350", "\u4e3a\u4f60\u63a8\u8350", "\u5ef6\u4f38\u9605\u8bfb",
55
+ "\u5927\u5bb6\u90fd\u5728\u770b", "\u4e0a\u4e00\u9875", "\u4e0b\u4e00\u9875",
56
+ "\u5206\u4eab\u5230", "\u6536\u85cf", "\u70b9\u8d5e", "\u8f6c\u53d1", "\u4e3e\u62a5",
57
+ "\u6211\u6765\u8bf4\u4e24\u53e5", "\u53d1\u8868\u4f60\u7684\u770b\u6cd5",
58
+ "\u67e5\u770b\u5168\u90e8\u8bc4\u8bba", "\u6682\u65e0\u8bc4\u8bba",
59
+ "\u9996\u9875", "\u767b\u5f55", "\u6ce8\u518c", "\u66f4\u591a", "\u641c\u7d22",
60
+ "\u8fd4\u56de\u9876\u90e8", "\u70b9\u51fb\u67e5\u770b", "\u67e5\u770b\u66f4\u591a",
61
+ "\u6211\u8981\u8bc4\u8bba", "\u4e3e\u62a5/\u53cd\u9988", "\u5206\u4eab",
62
+ "\u624b\u673a\u770b", "Contact Us",
63
+ }
64
+ _WEAK_NOISE = (
65
+ "\u767b\u5f55\u540e\u624d\u80fd", "\u4e0b\u8f7d\u5ba2\u6237\u7aef",
66
+ "\u6253\u5f00\u5c0f\u7a0b\u5e8f", "\u626b\u7801\u67e5\u770b", "\u7248\u6743\u6240\u6709",
67
+ "\u672a\u7ecf\u6388\u6743", "\u4e0d\u5f97\u8f6c\u8f7d", "all rights reserved",
68
+ "icp\u5907\u6848", "\u4eac\u516c\u7f51\u5b89\u5907", "cookie", "\u9690\u79c1",
69
+ "\u9a8c\u8bc1\u7801", "\u8bbf\u95ee\u8fc7\u4e8e\u9891\u7e41", "\u5173\u6ce8\u516c\u4f17\u53f7",
70
+ "\u6253\u5f00app", "\u5728app\u5185\u6253\u5f00", "\u4e00\u952e\u767b\u5f55",
71
+ "\u672c\u7ad9\u4f7f\u7528cookie", "skip to content", "skip to main",
72
+ "preferred source",
73
+ )
74
+ _TECH_RE = re.compile(r"^(function\s*\(|var\s+\w+\s*=|@media|\./tpl_|const\s+\w+\s*=)")
75
+ _SRC_LABEL_RE = re.compile(
76
+ r"^\s*(\u6765\u6e90|\u6765\u81ea|\u51fa\u5904|\u6587\u7ae0\u6765\u6e90|\u7a3f\u6e90|"
77
+ r"\u7f16\u8f91|\u8d23\u7f16|\u8d23\u4efb\u7f16\u8f91|\u4f5c\u8005)\s*[::]")
78
+
79
+ _MD_IMG_RE = re.compile(r"!\[([^\]]*)\]\(([^)\s]+)[^)]*\)")
80
+ # 关注引导短行("点击上方 蓝字 关注,获取更多资讯"类;验收反馈 case_01)
81
+ _FOLLOW_GUIDE_RE = re.compile(
82
+ r"^(?:\u70b9\u51fb|\u957f\u6309|\u626b\u63cf|\u626b\u7801).{0,12}?"
83
+ r"(?:\u5173\u6ce8|\u8ba2\u9605)")
84
+ # App 导流短行("更多热点速报…尽在北京日报App",验收反馈 case_03)
85
+ _APP_GUIDE_RE = re.compile(
86
+ r"(?:\u5c3d\u5728|\u5c31\u5728|\u626b\u7801|\u70b9\u51fb|\u4e0b\u8f7d|\u6253\u5f00)"
87
+ r".{0,24}(?:App|APP|app)\s*$")
88
+ # 英文付费墙/订阅 CTA 短行(Bloomberg 等,验收反馈 case_04)
89
+ _EN_CTA_RE = re.compile(
90
+ r"already a subscriber|log\s*in to (?:keep reading|continue)|"
91
+ r"(?:subscribe|sign up) to (?:continue|keep)|to continue reading",
92
+ re.IGNORECASE)
93
+ # 仅有标签词 + 冒号的悬挂行("Chủ đề:" / "话题:" / "Tags:",验收反馈 case_01)
94
+ _LABEL_ONLY_RE = re.compile(
95
+ r"^(?:\u8bdd\u9898|\u6807\u7b7e|\u5206\u7c7b|\u4e3b\u9898|"
96
+ r"ch\u1ee7\s*\u0111\u1ec1|tags?|categor(?:y|ies)|topics?|keywords?)"
97
+ r"\s*[:\uff1a]\s*$",
98
+ re.IGNORECASE)
99
+
100
+ # ---- GBK/UTF-8 双重编码乱码检测(验收反馈:乱码块必须丢弃,不得原样倒出)----
101
+ _PUA_RE = re.compile(r"[\ue000-\uf8ff]")
102
+ _MOJI_EURO_RE = re.compile(r"[\u4e00-\u9fff]\u20ac|\u20ac[\u4e00-\u9fff]")
103
+ # GBK->UTF-8 乱码中的高频罕见字(从真实乱码块统计字符频率,再减去同页正常
104
+ # 正文区出现的字与 plausible 用字后得到;正常中文不会成簇出现这些字)
105
+ _MOJI_CHARS = ("\u6924\u572d\u6d30\u6d7c\u95ab\u934f\u90f4\u5f37\u9352\u9354\u93c8\u59df"
106
+ "\u8bf2\u5d07\u53e7\u7eef\u7d1d\u59e9\u9429\u4f78\u6c2c\u52eb\u61b3\u9365"
107
+ "\u69e6\u5bee\u6e47\u7b1f\u6b91\u4f77\u951b\u93ac\u93c3\u74ba\u6d93\u93bb"
108
+ "\u935b\u6a3b\u5d85\u5a23\u59de\u6769\u752f\u59ea\u93ae\u9473\u9286\u74d1"
109
+ "\u4f79\u95b2\u9366\u677f\u7aa1\u9471\u6c2b\u7d30\u93c2\u4f34\u6427\u9368"
110
+ "\u5b2c\u7ecb\u5b2a\u7c2d\u6a3a\u4f10\u8702\u7d12\u6c31\u608a\u7490\u5f7f"
111
+ "\u546d\u5978\u6336\u9356\u5474\u95c8\u6540\u8930\u66df\u9367\u6943\u6ae5"
112
+ "\u655e\u9350")
113
+ _MOJI_SET = frozenset(_MOJI_CHARS)
114
+
115
+
116
+ def mojibake_score(text):
117
+ if not text:
118
+ return 0
119
+ s = len(_PUA_RE.findall(text)) * 2
120
+ s += text.count("\ufffd")
121
+ s += len(_MOJI_EURO_RE.findall(text)) * 2
122
+ s += sum(1 for c in text if c in _MOJI_SET)
123
+ return s
124
+
125
+
126
+ # ---- 推广/营销区块(验收反馈 b:含推广特征词的区块做负样本过滤)----
127
+ _PROMO_STRONG_RE = re.compile(
128
+ r"\u7b7e\u5355\u603b\u989d|\u53ef\u80fd\u8ba4\u8bc6\u7684\u597d\u53cb|"
129
+ r"\u626b\u7801\u5b89\u88c5|\u5f00\u901a\u4f1a\u5458\u670d\u52a1|"
130
+ r"\u4eba\u8109\u5708|\u50ac\u4fc3\u66f4\u65b0|\u52a0\u5165\u5929\u5de5"
131
+ )
132
+ _PROMO_WEAK_RE = re.compile(
133
+ r"\u5f00\u901a\u4f1a\u5458|\u4e0b\u8f7d\s*[Aa][Pp][Pp]|\u6253\u5f00\s*[Aa][Pp][Pp]|"
134
+ r"\u514d\u8d39\u9886\u53d6|\u4f18\u60e0\u5238|\u4f1a\u5458\u4e13\u4eab|"
135
+ r"\u5c0a\u4eab|VIP\u4f1a\u5458|\u7acb\u5373\u5f00\u901a|\u7acb\u5373\u4e0b\u8f7d|"
136
+ r"\u70b9\u51fb\u4e0b\u8f7d|\u626b\u7801\u4e0b\u8f7d|\u626b\u7801\u5173\u6ce8|"
137
+ r"\u5173\u6ce8\u516c\u4f17\u53f7|\u9650\u65f6\u4f18\u60e0|\u7acb\u51cf"
138
+ )
139
+ _POPUP_TOKENS = {"pop", "popup", "modal", "dialog", "mask", "layer", "float",
140
+ "fixed", "suspension", "hoverbox"}
141
+
142
+ # data-test/testid 的 CTA 容器(Bloomberg article-cta 等,验收反馈 case_04)
143
+ _CTA_TESTID_RE = re.compile(r"cta|paywall|subscribe|login|signup|sign-up")
144
+ # 版权/免责声明块(验收反馈 case_02 新浪红字声明)
145
+ _DECLARATION_RE = re.compile(r"^(特别声明|免责声明)\s*[::]?")
146
+ # AI 生成声明(验收反馈 case_02 "本文由AI生成")
147
+ _AI_GEN_RE = re.compile(r"^本文由.{0,4}AI.{0,4}生成")
148
+
149
+
150
+ def _promo_hits(text):
151
+ return len(set(_PROMO_WEAK_RE.findall(text)))
152
+
153
+
154
+ _traf = None
155
+ _traf_cfg = None
156
+
157
+
158
+ def _get_trafilatura():
159
+ global _traf, _traf_cfg
160
+ if _traf is None:
161
+ import trafilatura
162
+ from trafilatura.settings import use_config
163
+ cfg = use_config()
164
+ cfg.set("DEFAULT", "MIN_EXTRACTED_SIZE", "80")
165
+ cfg.set("DEFAULT", "MIN_OUTPUT_SIZE", "1")
166
+ cfg.set("DEFAULT", "MIN_OUTPUT_COMM", "1")
167
+ _traf = trafilatura
168
+ _traf_cfg = cfg
169
+ return _traf, _traf_cfg
170
+
171
+
172
+ def _norm_img_url(u):
173
+ """归一化图片 URL:去 scheme、去签名/尺寸类 query。"""
174
+ try:
175
+ p = urlparse(u.strip())
176
+ except Exception:
177
+ return ""
178
+ host = (p.hostname or "").lower()
179
+ path = p.path or ""
180
+ if not host or not path:
181
+ return ""
182
+ from urllib.parse import parse_qsl
183
+ kept = [(k, v) for k, v in parse_qsl(p.query) if k.lower() not in _QUERY_IGNORE]
184
+ kept.sort()
185
+ q = "&".join(f"{k}={v}" for k, v in kept)
186
+ return host + path + ("?" + q if q else "")
187
+
188
+
189
+ def _img_basename(u):
190
+ try:
191
+ path = urlparse(u.strip()).path
192
+ except Exception:
193
+ return ""
194
+ return path.rsplit("/", 1)[-1].lower()
195
+
196
+
197
+ _SIZE_SUFFIX_RE = re.compile(r"-(?:\d{2,4}x\d{2,4}|scaled|large|medium|small|thumbnail)(?=\.[a-z]{3,4}$)")
198
+
199
+
200
+ def _img_base_stripped(u):
201
+ """去掉 WP 式尺寸后缀后的 basename:LAKEVIEW-2-634x312.jpg -> lakeview-2.jpg。"""
202
+ b = _img_basename(u)
203
+ return _SIZE_SUFFIX_RE.sub("", b)
204
+
205
+
206
+ class _ImageMap:
207
+ def __init__(self, images, img_ref):
208
+ self.exact = {}
209
+ self.norm = {}
210
+ self.path = {}
211
+ self.base = {}
212
+ self.base_stripped = {}
213
+ self.allowed = bool(images)
214
+ for u in images or []:
215
+ if not isinstance(u, str) or not u:
216
+ continue
217
+ self.exact[u] = u
218
+ self.norm.setdefault(_norm_img_url(u), u)
219
+ nk = _norm_img_url(u)
220
+ if nk:
221
+ self.path.setdefault(nk.split("?", 1)[0], u)
222
+ self.base.setdefault(_img_basename(u), u)
223
+ self.base_stripped.setdefault(_img_base_stripped(u), u)
224
+ self.ref = img_ref if isinstance(img_ref, dict) else {}
225
+
226
+ def match(self, raw):
227
+ """返回 (白名单原站 URL, OSS URL) 或 None。raw 可为候选 list。"""
228
+ if not self.allowed or not raw:
229
+ return None
230
+ cands = [raw] if isinstance(raw, str) else [c for c in raw if c]
231
+ for cand in cands:
232
+ cand = cand.strip()
233
+ if not cand or cand.startswith("data:"):
234
+ continue
235
+ key = self.exact.get(cand)
236
+ if key is None:
237
+ key = self.norm.get(_norm_img_url(cand))
238
+ if key is None:
239
+ nk = _norm_img_url(cand)
240
+ if nk:
241
+ key = self.path.get(nk.split("?", 1)[0])
242
+ if key is None:
243
+ key = self.base.get(_img_basename(cand))
244
+ if key is None:
245
+ key = self.base_stripped.get(_img_base_stripped(cand))
246
+ if key is None:
247
+ continue
248
+ oss = self.ref.get(key)
249
+ if oss:
250
+ return key, oss
251
+ return None
252
+
253
+
254
+ def _preclean_tree(root, keep_comments):
255
+ for tag in _REMOVE_TAGS:
256
+ for el in root.iter(tag):
257
+ parent = el.getparent()
258
+ if parent is not None:
259
+ parent.remove(el)
260
+ for el in list(root.iter()):
261
+ if not isinstance(el.tag, str):
262
+ continue
263
+ style = el.get("style") or ""
264
+ if _INVISIBLE_RE.search(style) or el.get("hidden") is not None:
265
+ parent = el.getparent()
266
+ if parent is not None:
267
+ parent.remove(el)
268
+ continue
269
+ if (el.get("aria-hidden") or "").lower() == "true":
270
+ parent = el.getparent()
271
+ if parent is not None:
272
+ parent.remove(el)
273
+ continue
274
+ for el in list(root.iter()):
275
+ if not isinstance(el.tag, str):
276
+ continue
277
+ if el.tag in ("nav", "header", "footer", "aside"):
278
+ parent = el.getparent()
279
+ if parent is not None:
280
+ parent.remove(el)
281
+ continue
282
+ role = el.get("role") or ""
283
+ if role in ("navigation", "banner", "complementary", "search"):
284
+ parent = el.getparent()
285
+ if parent is not None:
286
+ parent.remove(el)
287
+ continue
288
+ if el.tag in ("div", "section", "ul", "span"):
289
+ attr = ((el.get("class") or "") + " " + (el.get("id") or "")).lower()
290
+ hit_boiler = region_token_hit(attr, _BOILER_TOKENS)
291
+ hit_navy = region_token_hit(attr, _BOILER_NAVY_TOKENS)
292
+ if (hit_boiler or hit_navy) and not (
293
+ keep_comments and region_token_hit(attr, _COMMENT_TOKENS)):
294
+ # 大容器豁免:页面级布局包装器常带 banner/hot 等 token
295
+ # (dailymail class="page-banner articlepage" 包着全部正文),
296
+ # 含 h1 或 >4000 字的容器删了就是灾难,跳过
297
+ if el.findall(".//h1"):
298
+ continue
299
+ try:
300
+ _el_txt = clean_text(el.text_content())
301
+ except Exception:
302
+ continue
303
+ if len(_el_txt) > 4000:
304
+ continue
305
+ remove = True
306
+ if hit_navy and not hit_boiler:
307
+ # 纯 nav/menu token:只对链接密集或短文本块下手
308
+ try:
309
+ txt = _el_txt
310
+ atxt = "".join(clean_text(a.text_content())
311
+ for a in el.iter("a"))
312
+ remove = len(txt) < 200 or len(atxt) > 0.5 * max(len(txt), 1)
313
+ except Exception:
314
+ remove = False
315
+ if remove:
316
+ parent = el.getparent()
317
+ if parent is not None:
318
+ parent.remove(el)
319
+ # CTA / 声明 / 平台引导区块(验收反馈 case_02/03/04)
320
+ for el in list(root.iter()):
321
+ if not isinstance(el.tag, str):
322
+ continue
323
+ parent = el.getparent()
324
+ if parent is None:
325
+ continue
326
+ datatest = ((el.get("data-test") or "") + " " +
327
+ (el.get("data-testid") or "")).lower()
328
+ if datatest and _CTA_TESTID_RE.search(datatest):
329
+ parent.remove(el)
330
+ continue
331
+ cls_id = ((el.get("class") or "") + " " + (el.get("id") or "")).lower()
332
+ if el.get("data-bjh-src-hide") is not None or "bjh-a-hide" in cls_id:
333
+ parent.remove(el)
334
+ continue
335
+ if el.tag not in ("div", "p", "section", "aside", "span"):
336
+ continue
337
+ if el.findall(".//h1"):
338
+ continue
339
+ try:
340
+ t = clean_text(el.text_content())
341
+ except Exception:
342
+ continue
343
+ if not t or len(t) > 400:
344
+ continue
345
+ if _DECLARATION_RE.match(t):
346
+ parent.remove(el)
347
+ continue
348
+ if len(t) <= 60 and _AI_GEN_RE.search(t):
349
+ parent.remove(el)
350
+ continue
351
+ # 模块标题板:短标题命中站点样板词 → 删所属模块(大家都在问/天工网分站/猜你喜欢…)
352
+ for el in list(root.iter()):
353
+ if el.tag not in ("h2", "h3", "h4", "div", "p", "span"):
354
+ continue
355
+ parent = el.getparent()
356
+ if parent is None:
357
+ continue
358
+ try:
359
+ t = clean_text(el.text_content())
360
+ except Exception:
361
+ continue
362
+ if not t or len(t) > 14 or not _MODULE_TITLE_RE.match(t):
363
+ continue
364
+ try:
365
+ ptxt = clean_text(parent.text_content())
366
+ except Exception:
367
+ continue
368
+ if len(ptxt) > 4000 or parent.findall(".//h1"):
369
+ continue
370
+ gp = parent.getparent()
371
+ if gp is not None and parent.getparent() is not None:
372
+ gp.remove(parent)
373
+ # 弹窗 / 推广 / 乱码块:最深优先,含 h1 或超长文本的容器不删(验收反馈 b/c/d)
374
+ for el in reversed(list(root.iter())):
375
+ if not isinstance(el.tag, str):
376
+ continue
377
+ if el.tag not in ("div", "section", "aside", "ul", "table"):
378
+ continue
379
+ parent = el.getparent()
380
+ if parent is None:
381
+ continue
382
+ try:
383
+ text = clean_text(el.text_content())
384
+ except Exception:
385
+ continue
386
+ tl = len(text)
387
+ if tl < 2 or tl > 4000:
388
+ continue
389
+ if el.findall(".//h1"):
390
+ continue
391
+ cid = ((el.get("class") or "") + " " + (el.get("id") or "")).lower()
392
+ if region_token_hit(cid, _POPUP_TOKENS) and tl < 2000:
393
+ parent.remove(el)
394
+ continue
395
+ if _PROMO_STRONG_RE.search(text):
396
+ parent.remove(el)
397
+ continue
398
+ if _promo_hits(text) >= 2 and tl < 1500:
399
+ parent.remove(el)
400
+ continue
401
+ if _mojibake_heavy(text):
402
+ parent.remove(el)
403
+ continue
404
+ return root
405
+
406
+
407
+ def _mojibake_heavy(text):
408
+ """局部乱码块判定:只看密度(PUA 图标字体/长文罕见字积累不触发)。"""
409
+ sc = mojibake_score(text)
410
+ if sc < 3:
411
+ return False
412
+ cjk = sum(1 for c in text if "一" <= c <= "鿿")
413
+ if cjk == 0:
414
+ return sc >= 6
415
+ return sc * 2 >= cjk
416
+
417
+
418
+ _IMG_HREF_RE = re.compile(r"\.(?:jpe?g|png|gif|webp|bmp)(?:[?#]|$)", re.IGNORECASE)
419
+
420
+
421
+ def _img_candidates(img):
422
+ cands = []
423
+ for attr in _IMG_ATTRS:
424
+ v = img.get(attr)
425
+ if v and v.strip():
426
+ cands.append(v.strip())
427
+ srcset = img.get("srcset") or ""
428
+ for part in srcset.split(","):
429
+ u = part.strip().split(" ")[0]
430
+ if u:
431
+ cands.append(u)
432
+ parent = img.getparent()
433
+ if parent is not None and parent.tag == "a":
434
+ href = (parent.get("href") or "").strip()
435
+ if href and _IMG_HREF_RE.search(href):
436
+ cands.append(href)
437
+ return cands
438
+
439
+
440
+ def _rewrite_images(root, imap):
441
+ """正文区外的 <img> 已由预清理删除大半;此处做白名单校验与 URL 改写。"""
442
+ for img in list(root.iter("img")):
443
+ cands = _img_candidates(img)
444
+ m = imap.match(cands) if cands else None
445
+ parent = img.getparent()
446
+ if m is None:
447
+ if parent is not None:
448
+ parent.remove(img)
449
+ continue
450
+ key, _oss = m
451
+ img.set("src", key)
452
+ for attr in _IMG_ATTRS:
453
+ if attr != "src" and img.get(attr) is not None:
454
+ del img.attrib[attr]
455
+ if img.get("srcset") is not None:
456
+ del img.attrib["srcset"]
457
+ if not (img.get("alt") or "").strip():
458
+ fig = parent
459
+ cap = None
460
+ if fig is not None and fig.tag == "figure":
461
+ for fc in fig.iter("figcaption"):
462
+ cap = clean_text(fc.text_content())
463
+ break
464
+ if cap:
465
+ img.set("alt", cap[:120])
466
+ # 裸 div 下的 img 会被 trafilatura 的通配正文路径丢弃(BODY_XPATH 不命中
467
+ # 哈希类名容器时 recover_wild_text 只收 p/code/quote/table 等,graphic 全灭;
468
+ # 实测百家号 0→19 张)。把白名单图片(或其 <a> 包装)包进 <p> 保底。
469
+ target = img if parent is None or parent.tag != "a" else parent
470
+ holder = target.getparent()
471
+ if holder is not None and holder.tag not in ("p", "figure", "td", "th", "li"):
472
+ new_p = root.makeelement("p", {})
473
+ target.addprevious(new_p)
474
+ new_p.append(target)
475
+
476
+
477
+ def _post_md(md, imap):
478
+ """Markdown 后处理:图片映射 OSS、噪声行、来源标注首段、空白归一。"""
479
+ noise = {"strong": 0, "weak": 0, "source_label": 0}
480
+
481
+ def _img_sub(m):
482
+ alt, url = m.group(1), m.group(2)
483
+ mm = imap.match(url)
484
+ if mm is None:
485
+ return ""
486
+ return f"![{alt}]({mm[1]})"
487
+
488
+ md = _MD_IMG_RE.sub(_img_sub, md)
489
+
490
+ lines = md.split("\n")
491
+ out_lines = []
492
+ first_para_done = False
493
+ for ln in lines:
494
+ stripped = ln.strip()
495
+ if not stripped:
496
+ out_lines.append("")
497
+ continue
498
+ if not first_para_done:
499
+ first_para_done = True
500
+ if len(stripped) <= 40 and _SRC_LABEL_RE.match(stripped):
501
+ noise["source_label"] += len(stripped)
502
+ continue
503
+ if _DECLARATION_RE.match(stripped):
504
+ noise["declaration"] = noise.get("declaration", 0) + len(stripped)
505
+ continue
506
+ if len(stripped) <= 60 and _AI_GEN_RE.search(stripped):
507
+ noise["ai_gen"] = noise.get("ai_gen", 0) + len(stripped)
508
+ continue
509
+ if len(stripped) <= 50 and _APP_GUIDE_RE.search(stripped):
510
+ noise["app_guide"] = noise.get("app_guide", 0) + len(stripped)
511
+ continue
512
+ if len(stripped) <= 80 and _EN_CTA_RE.search(stripped):
513
+ noise["en_cta"] = noise.get("en_cta", 0) + len(stripped)
514
+ continue
515
+ if len(stripped) <= 30 and _LABEL_ONLY_RE.match(stripped):
516
+ noise["label_only"] = noise.get("label_only", 0) + len(stripped)
517
+ continue
518
+ if stripped in _STRONG_NOISE:
519
+ noise["strong"] += len(stripped)
520
+ continue
521
+ if mojibake_score(stripped) >= 2 and _mojibake_heavy(stripped):
522
+ noise["mojibake"] = noise.get("mojibake", 0) + len(stripped)
523
+ continue
524
+ if _PROMO_STRONG_RE.search(stripped):
525
+ noise["promo"] = noise.get("promo", 0) + len(stripped)
526
+ continue
527
+ if len(stripped) <= 60 and _PROMO_WEAK_RE.search(stripped):
528
+ noise["promo"] = noise.get("promo", 0) + len(stripped)
529
+ continue
530
+ if len(stripped) <= 40:
531
+ low = stripped.lower()
532
+ if any(w in low or w in stripped for w in _WEAK_NOISE):
533
+ noise["weak"] += len(stripped)
534
+ continue
535
+ if _FOLLOW_GUIDE_RE.match(stripped):
536
+ noise["weak"] += len(stripped)
537
+ continue
538
+ if _TECH_RE.match(stripped):
539
+ noise["weak"] += len(stripped)
540
+ continue
541
+ out_lines.append(ln.rstrip())
542
+
543
+ # 图片去重:同一 OSS 链接在正文中重复出现只保留第一次(验收反馈 case_03)
544
+ seen_img = set()
545
+ deduped = []
546
+ for ln in out_lines:
547
+ m = _MD_IMG_RE.fullmatch(ln.strip())
548
+ if m:
549
+ u = m.group(2)
550
+ if u in seen_img:
551
+ noise["dup_img"] = noise.get("dup_img", 0) + 1
552
+ continue
553
+ seen_img.add(u)
554
+ deduped.append(ln)
555
+ out_lines = deduped
556
+
557
+ # 尾部样板窗:最后 5 个非空行里的来源标注 / 纯数字计数 / UI 按钮行
558
+ # ("来源:京报体育 举报/反馈 0 分享" 常挂在文末,验收反馈 case_03)
559
+ nonempty_idx = [i for i, ln in enumerate(out_lines) if ln.strip()]
560
+ for i in nonempty_idx[-5:]:
561
+ s = out_lines[i].strip()
562
+ if (len(s) <= 40 and _SRC_LABEL_RE.match(s)) or \
563
+ (len(s) <= 6 and s.isdigit()) or s in _STRONG_NOISE:
564
+ noise["tail_boiler"] = noise.get("tail_boiler", 0) + len(s)
565
+ out_lines[i] = ""
566
+
567
+ # 相关文章区块删除:DOM 预清理对 >4000 字模块豁免,MD 层再兜一次
568
+ # (zhifang "## 相关文章" + 推荐列表整块漏出,验收口径:与正文无关内容须删)
569
+ cleaned = []
570
+ i = 0
571
+ n = len(out_lines)
572
+ while i < n:
573
+ ln = out_lines[i]
574
+ s = ln.strip()
575
+ hm = re.match(r"^(#{1,6})\s*(.+?)\s*$", s)
576
+ if hm and len(hm.group(2)) <= 14 and _MODULE_TITLE_RE.match(hm.group(2)):
577
+ j = i + 1
578
+ blk = []
579
+ while j < n and not out_lines[j].strip().startswith("#"):
580
+ blk.append(out_lines[j])
581
+ j += 1
582
+ nonempty = [b for b in blk if b.strip()]
583
+ if nonempty:
584
+ listy = sum(1 for b in nonempty
585
+ if b.strip().startswith(("- ", "![", "* ")))
586
+ if listy * 2 >= len(nonempty):
587
+ noise["related_block"] = noise.get("related_block", 0) + sum(
588
+ len(b) for b in nonempty)
589
+ i = j
590
+ continue
591
+ cleaned.append(ln)
592
+ i += 1
593
+ out_lines = cleaned
594
+
595
+ text = "\n".join(out_lines)
596
+ text = re.sub(r"\n{3,}", "\n\n", text)
597
+ return text.strip(), noise
598
+
599
+
600
+ def _h1_tokens(h1):
601
+ h1 = h1.lower()
602
+ if has_cjk(h1):
603
+ s = re.sub(r"[^一-鿿]", "", h1)
604
+ return {s[i:i + 2] for i in range(len(s) - 1)}
605
+ return set(re.findall(r"[^\W\d_]{3,}", h1))
606
+
607
+
608
+ def _h1_relevance(h1_texts, content):
609
+ """h1 与正文的词元重合率:低于阈值说明抽出来的是模板/推广区而非文章。"""
610
+ toks = set()
611
+ for h in h1_texts or []:
612
+ toks |= _h1_tokens(h)
613
+ if not toks:
614
+ return 1.0
615
+ c = (content or "").lower()
616
+ c_cjk = re.sub(r"[^一-鿿]", "", c)
617
+ hit = 0
618
+ for t in toks:
619
+ if has_cjk(t):
620
+ if t in c_cjk:
621
+ hit += 1
622
+ elif t in c:
623
+ hit += 1
624
+ return hit / len(toks)
625
+
626
+
627
+ def _h1_region_md(root):
628
+ """h1 祖先链中第一个文本量 >=400 的容器,序列化其段落/列表/表格/图片。"""
629
+ h1 = None
630
+ for el in root.iter("h1"):
631
+ if clean_text(el.text_content()):
632
+ h1 = el
633
+ break
634
+ if h1 is None:
635
+ return ""
636
+ node = h1.getparent()
637
+ best = None
638
+ while node is not None:
639
+ if len(clean_text(node.text_content())) >= 400:
640
+ best = node
641
+ break
642
+ node = node.getparent()
643
+ if best is None:
644
+ return ""
645
+ parts = []
646
+ for el in best.iter():
647
+ if el.tag in ("p", "li"):
648
+ t = clean_text(el.text_content())
649
+ if len(t) >= 8:
650
+ parts.append(t)
651
+ elif el.tag in ("h2", "h3"):
652
+ t = clean_text(el.text_content())
653
+ if t:
654
+ parts.append("## " + t)
655
+ elif el.tag == "tr":
656
+ cells = [clean_text(td.text_content()) for td in el.iter("td", "th")]
657
+ cells = [c for c in cells if c]
658
+ if cells:
659
+ parts.append("| " + " | ".join(cells) + " |")
660
+ elif el.tag == "img":
661
+ src = el.get("src") or ""
662
+ if src:
663
+ parts.append("![%s](%s)" % (clean_text(el.get("alt") or ""), src))
664
+ out = []
665
+ for p in parts:
666
+ if not out or out[-1] != p:
667
+ out.append(p)
668
+ return "\n\n".join(out)
669
+
670
+
671
+ def _title_guard_ok(text, title):
672
+ """标题-正文一致性守卫(只读版):抽出文本与标题是否属同一篇。"""
673
+ if not text or not title:
674
+ return True
675
+ rel = _h1_relevance([title], text)
676
+ if rel >= 0.15:
677
+ return True
678
+ cov = _cjk_char_coverage(title, text)
679
+ return cov is not None and cov >= 0.5
680
+
681
+
682
+ def _cjk_char_coverage(title, content):
683
+ """标题 CJK 字符在正文中的覆盖率;长标题复述型正文 bigram 低但字符覆盖率高。"""
684
+ t = re.sub(r"[^一-鿿]", "", title or "")
685
+ if not t:
686
+ return None
687
+ c = set(re.sub(r"[^一-鿿]", "", (content or "").lower()))
688
+ chars = set(t)
689
+ if not chars:
690
+ return None
691
+ return sum(1 for ch in chars if ch in c) / len(chars)
692
+
693
+
694
+ # 非文章页 URL 模式:视频页 / 作者页 / 栏目列表页(调研05 §5:≥11% 非文章页)
695
+ _NONARTICLE_URL_RE = re.compile(
696
+ r"/(video|videos|videonews|watch)(/|\.|\?|$)"
697
+ r"|/(author|authors|category|categories|tag|tags)/"
698
+ r"|[?&]a=lists\b|dtlandingsuper",
699
+ re.IGNORECASE,
700
+ )
701
+
702
+
703
+ def _drop_title_lead(content, title, pub_time=""):
704
+ """开头去重:首个文本行 == 标题则剔除;其后紧跟的短日期行 == pub_time 同日也剔除
705
+ (V3:不应把标题/发布时间作为正文首段重复输出;验收反馈 case_03/case_06)。
706
+ 开头的图片行跳过不处理。"""
707
+ if not content:
708
+ return content
709
+ nt = norm_key(title) if title else ""
710
+ pub_day = (pub_time or "")[:10]
711
+ lines = content.split("\n")
712
+ i = 0
713
+ n = len(lines)
714
+ # 跳过开头空行与图片行
715
+ while i < n and (not lines[i].strip() or lines[i].strip().startswith("![")):
716
+ i += 1
717
+ dropped = False
718
+ if i < n and nt:
719
+ s2 = lines[i].strip().lstrip("#").strip()
720
+ if s2 and norm_key(s2) == nt:
721
+ del lines[i]
722
+ dropped = True
723
+ n -= 1
724
+ # 标题行之后(或开头)的短日期行:与 pub_time 同日即重复信息
725
+ j = 0
726
+ n = len(lines)
727
+ while j < n and (not lines[j].strip() or lines[j].strip().startswith("![")):
728
+ j += 1
729
+ date_dropped = False
730
+ if j < n and pub_day and (dropped or i == j):
731
+ s = lines[j].strip()
732
+ if len(s) <= 60:
733
+ tv = parse_absolute(s.lstrip("#").strip())
734
+ if tv and tv.dt.strftime("%Y-%m-%d") == pub_day:
735
+ del lines[j]
736
+ date_dropped = True
737
+ if dropped or date_dropped:
738
+ # 标题/日期是页面唯一文字时保留原文(zhibo8 视频页 caption==title,删了就空)
739
+ if any(ln.strip() and not ln.strip().startswith("![") for ln in lines):
740
+ return "\n".join(lines).strip()
741
+ return content
742
+
743
+
744
+ def _trace_ratio(content, visible_norm):
745
+ if not content or not visible_norm:
746
+ return 0.0
747
+ units = []
748
+ for para in content.split("\n"):
749
+ para = para.strip().strip("|").strip()
750
+ if len(para) < 8:
751
+ continue
752
+ if para.startswith("!["):
753
+ continue
754
+ if "|" in para:
755
+ units.extend(c.strip() for c in para.split("|") if len(c.strip()) >= 8)
756
+ else:
757
+ units.append(para)
758
+ if len(units) >= 60:
759
+ break
760
+ if not units:
761
+ return 1.0
762
+ # 两侧必须同一套归一:unit 剥 markdown 符号(含连字符),visible 侧也要剥,
763
+ # 否则 'pick-up' vs 'pickup' 恒不匹配(回归实测英文正文假 0)
764
+ vn = re.sub(r"[*`#>\-]", "", visible_norm)
765
+ hit = 0
766
+ for u in units:
767
+ u = re.sub(r"[*`#>\-]", "", u)
768
+ if norm_key(u) and norm_key(u) in vn:
769
+ hit += 1
770
+ return hit / len(units)
771
+
772
+
773
+ def extract_content(signals, site_cfg, page_status, images, img_ref, visible_norm,
774
+ title="", url="", pub_time=""):
775
+ """返回 (content, content_path, content_noise_by_class, traceable_ratio)。"""
776
+ keep_comments = page_status in ("forum_page", "comment_page")
777
+ imap = _ImageMap(images, img_ref)
778
+
779
+ try:
780
+ root = deepcopy(signals.tree)
781
+ except Exception:
782
+ try:
783
+ root = lxml_html.fromstring(signals.html)
784
+ except Exception:
785
+ return "", "unparseable", {}, 0.0
786
+
787
+ try:
788
+ root = _preclean_tree(root, keep_comments)
789
+ except Exception:
790
+ pass
791
+ try:
792
+ from .sitespecific import site_preclean
793
+ except ImportError:
794
+ try:
795
+ from sitespecific import site_preclean
796
+ except ImportError:
797
+ site_preclean = None
798
+ if site_preclean is not None:
799
+ try:
800
+ site_preclean(getattr(signals, "host", "") or "", root)
801
+ except Exception:
802
+ pass
803
+ try:
804
+ _rewrite_images(root, imap)
805
+ except Exception:
806
+ pass
807
+
808
+ try:
809
+ html_str = etree.tostring(root, encoding="unicode", method="html")
810
+ except Exception:
811
+ return "", "serialize_fail", {}, 0.0
812
+
813
+ traf, cfg = _get_trafilatura()
814
+ md = None
815
+ md_rescue = None
816
+ path = "trafilatura_fast"
817
+ try:
818
+ md = traf.extract(
819
+ html_str,
820
+ output_format="markdown",
821
+ include_comments=keep_comments,
822
+ include_images=imap.allowed,
823
+ include_tables=True,
824
+ include_links=False,
825
+ favor_precision=False,
826
+ favor_recall=False,
827
+ config=cfg,
828
+ no_fallback=False,
829
+ )
830
+ except Exception:
831
+ md = None
832
+ if imap.allowed:
833
+ # trafilatura 与 readability/justext 的对比换体可能把 graphic 丢掉
834
+ # (百家号单图文章实测:正文 664 字时 readability 体胜出,图全灭)。
835
+ # 图数变少时用 recall 模式重抽一次保底,是否采用在 _post_md 之后裁决。
836
+ try:
837
+ n_tree_img = sum(1 for _ in root.iter("img"))
838
+ n_md_img = len(_MD_IMG_RE.findall(md or ""))
839
+ if n_tree_img > n_md_img:
840
+ md_rescue = traf.extract(
841
+ html_str,
842
+ output_format="markdown",
843
+ include_comments=keep_comments,
844
+ include_images=imap.allowed,
845
+ include_tables=True,
846
+ include_links=False,
847
+ favor_recall=True,
848
+ config=cfg,
849
+ no_fallback=True,
850
+ )
851
+ except Exception:
852
+ md_rescue = None
853
+ if not md:
854
+ try:
855
+ md = traf.extract(
856
+ signals.html,
857
+ output_format="markdown",
858
+ include_comments=keep_comments,
859
+ include_images=imap.allowed,
860
+ include_tables=True,
861
+ include_links=False,
862
+ favor_recall=True,
863
+ config=cfg,
864
+ )
865
+ path = "trafilatura_recall"
866
+ except Exception:
867
+ md = None
868
+ if not md:
869
+ return "", "extract_fail", {}, 0.0
870
+
871
+ content, noise = _post_md(md, imap)
872
+ if md_rescue:
873
+ content2, noise2 = _post_md(md_rescue, imap)
874
+ n1 = len(_MD_IMG_RE.findall(content or ""))
875
+ n2 = len(_MD_IMG_RE.findall(content2 or ""))
876
+ l1 = len(re.sub(r"\s", "", content or ""))
877
+ l2 = len(re.sub(r"\s", "", content2 or ""))
878
+ # 换上 recall 体必须:图更多、文本量不崩、且不把标题一致性从及格打成不及格
879
+ # (s1#204 fstoppers:recall 体稀释相关性触发 title_mismatch 误杀)
880
+ if n2 > n1 and l2 >= 0.8 * max(l1, 1) and (
881
+ not _title_guard_ok(content, title) or _title_guard_ok(content2, title)):
882
+ content, noise, path = content2, noise2, path + "|img_rescue"
883
+ h1s = getattr(signals, "h1_texts", None) or []
884
+ if h1s and _h1_relevance(h1s, content) < 0.15:
885
+ fb = _h1_region_md(root)
886
+ if fb:
887
+ fb_content, fb_noise = _post_md(fb, imap)
888
+ if len(fb_content) >= 200 and _h1_relevance(h1s, fb_content) >= 0.15:
889
+ content, noise, path = fb_content, fb_noise, "h1_region"
890
+ content_pre = content
891
+ content = _drop_title_lead(content, title, pub_time)
892
+ # 标题-正文一致性守卫(V3:标题与正文必须属同一篇;错配内容不如留空)
893
+ # 只在文章页生效:product_page 等非文章页型的正文与标题天然弱相关(回归 s1#16-18)
894
+ # 判定用去标题首段之前的 content_pre:首段标题被 _drop_title_lead 移除后
895
+ # 重合率会假性归零(回归 s1#308)
896
+ if content and title and page_status in ("ok", ""):
897
+ # 图+短说明型页面(摄影投稿/图说)豁免:尾部样板清洗后标题重合会假性归零
898
+ # (回归 s1#204 fstoppers:314 字符图说被误杀;正文确为本页内容)
899
+ nonimg = re.sub(r"!\[[^\]]*\]\([^)]*\)", "", content_pre)
900
+ img_caption = "![" in content_pre and len(re.sub(r"\s", "", nonimg)) < 400
901
+ if not img_caption:
902
+ rel = _h1_relevance([title], content_pre)
903
+ if rel < 0.15:
904
+ cov = _cjk_char_coverage(title, content_pre)
905
+ if cov is None or cov < 0.5:
906
+ content, path = "", path + "|title_mismatch"
907
+ elif rel < 0.5 and _NONARTICLE_URL_RE.search(url or ""):
908
+ content, path = "", path + "|nonarticle_url"
909
+ ratio = _trace_ratio(content, visible_norm)
910
+ # 低溯源率守卫:抽出内容大部分无法在页面可见文本中定位,说明抽错区域
911
+ # (验收反馈 case_04 错位输出 ratio=0.18),错位输出不如留空
912
+ if content and ratio < 0.2 and not keep_comments:
913
+ content, path = "", path + "|low_trace"
914
+ ratio = _trace_ratio(content, visible_norm)
915
+ return content, path, noise, ratio