feat(v2.5.0): Sec-Ch-Ua 头修复 + 结构化提取 + token 预算 + 正文去重 + 连接复用

正确性修复:
- 修复 Sec-Ch-Ua 构造 bug: 原实现产出 ""Not_A Brand";v="99"" 双重引号
  畸形头(Chrome/Edge 两路径), 严格校验的 WAF 会忽略; 改为品牌数组拼接
- search 403 快速失败: 实例级 403 不再退避重试(~10.5s 空等), 立即 failover
- AdaptiveThrottle: --throttle-failure-threshold 0 现为真正禁用语义
- number_of_results 缺失时用 len(results) 兜底(JSON/HTML 路径契约对齐)
- 版本对齐: pyproject.toml 与 _config.py 同步 2.5.0

AI 代理体验:
- fetch.py --extract json: 结构化骨架(title/meta/headings/links/images)
- fetch.py --max-chars N: 提取后语义级截断(区别于 --max-size 字节截断)
- search --fetch-total-chars N: --fetch 全局字符预算, 耗尽后 status=skipped
- --dedup-fetched-content: 抓取正文 SimHash 去重, status=duplicate
- --progress 新增 angle_start/ok/fail + fetch_skip/fetch_duplicate 事件
- fetch.py 补齐 --log-format json + --dump-schema
- CSV 媒体列自适应(images/videos 类别自动追加媒体字段列)
- --dry-run 批量模式打印实际查询列表
- search 连接复用: requests 可用时走模块级 Session(连接池)

工程治理:
- 新增 scripts/release_check.py 发布一致性检查(版本/错误码表漂移)
- 新增 tests/test_v250_features.py 46+4 个回归测试(全量 622 通过)
- tests/conftest.py: autouse fixture 强制 stdlib 路径(本机有 requests 时
  既有 urllib mock 测试不失效)
This commit is contained in:
2026-08-07 15:19:17 +08:00
parent 471818074d
commit 10c01a3abd
10 changed files with 1746 additions and 54 deletions
+38 -5
View File
@@ -329,14 +329,25 @@ def build_browser_headers(user_agent: str, referer: str = None,
# Sec-Ch-Ua 系列仅 Chrome/Edge 发送,Firefox 不发
if not is_firefox:
# 从 UA 提取主版本号,构造 Sec-Ch-Ua
# 从 UA 提取主版本号,构造 Sec-Ch-Ua
# v2.5.0 修复:原实现把已含引号的 not_a_brand 再包进 f-string 引号,
# 生成 ""Not_A Brand";v="99"" 的畸形头——Chrome/Edge 两条路径都中招,
# 严格校验 Sec-CH-UA 的 WAFCloudflare 等会校验与 UA 一致性)会直接
# 忽略或判定不一致。现改为品牌数组统一拼接,产出合法格式:
# Chrome: "Not_A Brand";v="99", "Chromium";v="138", "Google Chrome";v="138"
# Edge: 上述 + , "Microsoft Edge";v="138"
m = re.search(r"Chrome/(\d+)", user_agent)
ver = m.group(1) if m else "131"
not_a_brand = '"Not_A Brand";v="99"' if ver != "99" else '"Not/A)Brand";v="99"'
headers["Sec-Ch-Ua"] = f'"{not_a_brand}", "Chromium";v="{ver}", "Google Chrome";v="{ver}"'
not_a_brand = "Not_A Brand" if ver != "99" else "Not/A)Brand"
brands = [
f'"{not_a_brand}";v="99"',
f'"Chromium";v="{ver}"',
f'"Google Chrome";v="{ver}"',
]
if is_edge:
# Edge 的品牌标识
headers["Sec-Ch-Ua"] = headers["Sec-Ch-Ua"].rstrip('"') + f'", "Microsoft Edge";v="{ver}"'
# Edge 额外声明 Microsoft Edge 品牌
brands.append(f'"Microsoft Edge";v="{ver}"')
headers["Sec-Ch-Ua"] = ", ".join(brands)
headers["Sec-Ch-Ua-Mobile"] = '"?1"' if "Mobile" in user_agent else '"?0"'
# 平台标识
if "Windows" in user_agent:
@@ -1054,3 +1065,25 @@ def is_similar(result_a: dict, result_b: dict, threshold: float = 0.85) -> bool:
max_distance = max(0, min(3, int(round((1.0 - threshold) / 0.05))))
return _hamming_distance(hash_a, hash_b) <= max_distance
def texts_are_similar(text_a: str, text_b: str, threshold: float = 0.85) -> bool:
"""判断两段正文是否近似重复(v2.5.0,供抓取正文去重用)。
镜像/转载站点的正文与原文高度相似但 URL 不同——URL 级去重无法合并,
需要按正文指纹判定。复用与 :func:`is_similar` 相同的
SimHash + 汉明距离逻辑,但针对**正文**而非标题:
* 归一化(小写/去标点/CJK 单字分词)后取前 1000 字符做指纹窗口——
镜像页前 1000 字符通常一致,长文全量计算只会稀释指纹且增加开销
* 空文本(len<窗口)返回 False,避免短错误文本误判为重复
* threshold → 汉明距离阈值映射与 is_similar 一致(0.85→3 ... 1.0→0
"""
norm_a = _normalize_title(text_a)[:1000]
norm_b = _normalize_title(text_b)[:1000]
if len(norm_a) < 5 or len(norm_b) < 5:
return False
hash_a = _simhash(norm_a)
hash_b = _simhash(norm_b)
max_distance = max(0, min(3, int(round((1.0 - threshold) / 0.05))))
return _hamming_distance(hash_a, hash_b) <= max_distance