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,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""
|
|
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("" % (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
|