Files
gungun88 ca102a3398 feat: VPS 优惠聚合雷达(LET + LowEndBox + 回帖库存判定)
数据源
- LowEndTalk Offers RSS(含完整正文)
- LowEndBox RSS(按 category + 正文规格密度过滤非优惠文章)
- 跨源去重(厂商+价格+配置指纹;数据不足时拒绝合并)

抽取内核(确定性正则,无 LLM)
- 规格/价格/折扣/活动性质/地点/CPU 型号
- 拍平 HTML 表格重建(按列序位置推断,解决裸容量无语义问题)
- 库存判定:商家回帖表态 > 帖文关键词 > 用户提及(弱证据)
- 语境校验:疑问句/否定句/非库存对象/促销资格四类误判防线
- 末页优先抓取回帖(实测商家表态集中在回帖流 75%-100% 位置)

工程化
- 包结构分工明确,配置支持环境变量(可绑自有域名)
- 124 个 pytest 用例;BERO 十周年帖为黄金样本
- GitHub Actions 每 6 小时抓取 + 定时提交 + 可选 rsync 部署
- 两种部署方案(Actions+自有服务器 / 纯服务器 cron),均含
  「记录数为 0 则拒绝发布」安全网
- legacy/ 保留重构前脚本,供回归比对(19 字段零回归差异)

零运行时依赖(仅 Python 标准库);产物为纯静态文件。
2026-10-16 06:10:15 +08:00

91 lines
2.9 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""带重试与退避的 HTTP 抓取。
比原型的裸 urlopen 多了:
· 自动重试(网络抖动 / 5xx / Cloudflare 偶发 403)
· 指数退避,避免被当成攻击
· gzip 解压
· 明确区分「永久失败(404)」和「可重试失败」
"""
from __future__ import annotations
import gzip
import time
import urllib.error
import urllib.request
from . import config
# 这些状态码重试无意义
PERMANENT = {400, 401, 404, 410, 451}
class FetchError(Exception):
"""抓取失败(已耗尽重试)。"""
def __init__(self, url: str, reason: str, status: int | None = None):
self.url = url
self.reason = reason
self.status = status
super().__init__(f"{url}: {reason}" + (f" (HTTP {status})" if status else ""))
def _decode(raw: bytes, encoding_header: str | None) -> str:
if encoding_header == "gzip":
try:
raw = gzip.decompress(raw)
except Exception:
pass
return raw.decode("utf-8", errors="replace")
def fetch(
url: str,
*,
accept: str = "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
timeout: int | None = None,
retries: int | None = None,
verbose: bool = False,
) -> str | None:
"""抓取 URL 并返回文本。
返回 None 表示**永久失败**(如 404)——调用方应据此跳过而非重试。
抛 FetchError 表示**可重试失败已耗尽**。
"""
timeout = timeout or config.HTTP_TIMEOUT
retries = config.HTTP_RETRIES if retries is None else retries
last_reason = "unknown"
last_status: int | None = None
for attempt in range(retries + 1):
req = urllib.request.Request(url, headers={
"User-Agent": config.USER_AGENT,
"Accept": accept,
"Accept-Language": "en-US,en;q=0.9",
"Accept-Encoding": "gzip, deflate",
"Connection": "keep-alive",
})
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read()
return _decode(raw, resp.headers.get("Content-Encoding"))
except urllib.error.HTTPError as e:
last_status = e.code
last_reason = f"HTTP {e.code} {e.reason}"
if e.code in PERMANENT:
return None # 永久失败,不重试
except urllib.error.URLError as e:
last_reason = f"URLError {e.reason}"
except Exception as e: # noqa: BLE001 - 网络层兜底
last_reason = f"{type(e).__name__} {e}"
if attempt < retries:
delay = config.HTTP_BACKOFF ** attempt
if verbose:
print(f" [retry {attempt + 1}/{retries}] {last_reason} -> "
f"{delay:.1f}s 后重试")
time.sleep(delay)
raise FetchError(url, last_reason, last_status)