Files
cnfinancialscraper/scripts/http_utils.py
T

1873 lines
74 KiB
Python
Raw 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.
# -*- coding: utf-8 -*-
"""
HTTP 公共基础设施 v7.7 — 零外部依赖纯标准库实现
所有 scraper 模块共用,消除重复代码。
v7.7.0: 下载优化 — 断点续传(.part + Range 206)/ sha256 完整性校验 /
已存在跳过 / .part 原子落盘(失败不残留半成品)/ batch_download 并发批量。
v7.5.0: 编码修复与爬取加固 —
charset 归一化/任意合法编码识别(gb2312/gbk 统一 gb18030)、
声明编码与正文不符时启发式纠错、GBK 系下载文件名修复、
download_file 走重试链+Referer 注入+反爬页校验+文本转码、
SSL 证书失败协议降级重试、空响应自动重试、域名编码记忆。
v7.2.0: 软拦截检测接线(200 反爬页面自动识别并重试)、Retry-After 遵从、
修复空 POST 退化为 GET / download_file 保存错误页 / 熔断 sleep 持锁等 bug。
v4.0: 增强反爬能力 — UA 轮换池、多策略重试、自适应限流、指纹随机化、
编码检测增强、代理支持。全部向后兼容,零破坏性变更。
v3.0: requests → urllib 标准库,零 pip 依赖即可运行核心爬虫功能。
optional: scrapling/playwright 用户可选安装用于动态渲染。
兼容接口(与旧 requests 版完全一致):
http_get(), http_post(), fetch_text(), http_get_json(),
download_file(), get_session(), rate_limit(), clear_cache()
v2.2 遗留 API(保留):
DEFAULT_UA, DEFAULT_HEADERS, DEFAULT_TIMEOUT, DOWNLOAD_TIMEOUT,
sanitize_filename(), LRUCache
"""
from __future__ import annotations
import codecs
import os
import re
import ssl
import time
import json
import random
import logging
import hashlib
import threading
import urllib.request
import urllib.error
import urllib.parse
from enum import Enum, auto
from collections import OrderedDict
import socket
from pathlib import Path
from typing import Optional, Dict, Any, Union, List, Tuple, Callable
from http.cookiejar import CookieJar
# ─── 可选依赖 ──────────────────────────────────────────────────────────────────
try:
import chardet as _chardet
HAS_CHARDET = True
except ImportError:
_chardet = None # type: ignore
HAS_CHARDET = False
# v7.2: 软拦截检测(anti_block_utils 纯函数,导入失败时静默降级关闭)
try:
from anti_block_utils import detect_block as _detect_block
except ImportError:
try:
from scripts.anti_block_utils import detect_block as _detect_block
except ImportError:
_detect_block = None # type: ignore
# ─── 日志 ────────────────────────────────────────────────────────────────────
log = logging.getLogger("http_utils")
# ─── 常量 ────────────────────────────────────────────────────────────────────
DEFAULT_UA = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/120.0.0.0 Safari/537.36"
)
MOBILE_UA = (
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) "
"AppleWebKit/605.1.15 (KHTML, like Gecko) "
"Version/17.0 Mobile/15E148 Safari/604.1"
)
# ─── UA 轮换池(v4.0 — 10+ 种真实浏览器 UA) ─────────────────────────────────────
UA_POOL_DESKTOP: List[str] = [
# Chrome 133 Win
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36",
# Chrome 132 Win
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36",
# Chrome 131 Win
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
# Chrome 130 Win
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
# Chrome 129 Mac
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36",
# Edge 133 Win
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36 Edg/133.0.0.0",
# Edge 131 Win
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0",
# Edge 130 Win
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 Edg/130.0.0.0",
# Firefox 134 Win
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:134.0) Gecko/20100101 Firefox/134.0",
# Firefox 133 Win
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0",
# Firefox 132 Mac
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:132.0) Gecko/20100101 Firefox/132.0",
# Chrome 126 Linux
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
]
UA_POOL_MOBILE: List[str] = [
# iPhone Safari 17
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1",
# iPhone Safari 18
"Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1",
# Android Chrome
"Mozilla/5.0 (Linux; Android 14; Pixel 8 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.135 Mobile Safari/537.36",
# Android Chrome (Samsung)
"Mozilla/5.0 (Linux; Android 14; SM-S928B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.107 Mobile Safari/537.36",
# iPad Safari
"Mozilla/5.0 (iPad; CPU OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1",
]
# 合并池
UA_POOL_ALL: List[str] = UA_POOL_DESKTOP + UA_POOL_MOBILE
# ─── 请求指纹池(v4.0 — 每次请求随机组合) ──────────────────────────────────────────
ACCEPT_LANGUAGE_POOL: List[str] = [
"zh-CN,zh;q=0.9",
"zh-CN,zh;q=0.9,en;q=0.8",
"zh-CN,zh;q=0.9,en;q=0.8,en-US;q=0.7",
"zh-CN,zh;q=0.8,en;q=0.6",
]
ACCEPT_ENCODING_POOL: List[str] = [
"gzip, deflate",
"gzip, deflate, br",
]
SEC_CH_UA_POOL: List[str] = [
'"Google Chrome";v="133", "Chromium";v="133", "Not=A?Brand";v="24"',
'"Google Chrome";v="132", "Chromium";v="132", "Not=A?Brand";v="24"',
'"Google Chrome";v="131", "Chromium";v="131", "Not=A?Brand";v="24"',
'"Google Chrome";v="130", "Chromium";v="130", "Not=A?Brand";v="24"',
'"Chromium";v="133", "Not A(Brand";v="24", "Microsoft Edge";v="133"',
'"Chromium";v="131", "Not A(Brand";v="24", "Microsoft Edge";v="131"',
'"Chromium";v="130", "Not A(Brand";v="24", "Microsoft Edge";v="130"',
]
SEC_CH_UA_PLATFORM_POOL: List[str] = [
'"Windows"',
'"macOS"',
]
DEFAULT_HEADERS: Dict[str, str] = {
"User-Agent": DEFAULT_UA,
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
"Accept-Encoding": "gzip, deflate",
"Connection": "keep-alive",
}
DEFAULT_TIMEOUT = 30 # HTTP 请求超时(DNS 解析另由 socket.setdefaulttimeout 控制)
DOWNLOAD_TIMEOUT = 60
MAX_RETRIES = 3
RETRY_BACKOFF = 1.5
RATE_LIMIT_DELAY = 2.0
JITTER_FACTOR = 0.3
# ─── 重试策略枚举(v4.0) ──────────────────────────────────────────────────────
class RetryStrategy(Enum):
"""多策略重试 — 每次重试采用不同反爬策略"""
UA_ROTATE = auto() # 换一个 UA
MOBILE_UA = auto() # 模拟移动端
FINGERPRINT = auto() # 换 Accept-Language / Sec-Ch-Ua 指纹
ADD_AJAX_HEADER = auto() # 添加 X-Requested-With: XMLHttpRequest
REFERRER_CHANGE = auto() # 更换 Referer
NO_CACHE = auto() # 绕过缓存直接请求
DELAY_INCREASE = auto() # 增加延迟再试
# 默认重试策略序列
DEFAULT_RETRY_STRATEGIES: List[RetryStrategy] = [
RetryStrategy.UA_ROTATE,
RetryStrategy.FINGERPRINT,
RetryStrategy.ADD_AJAX_HEADER,
RetryStrategy.MOBILE_UA,
RetryStrategy.DELAY_INCREASE,
]
# ─── UA 轮换与指纹随机化工具(v4.0) ────────────────────────────────────────────
def random_ua(mobile: bool = False) -> str:
"""从 UA 池随机选择一个"""
pool = UA_POOL_MOBILE if mobile else UA_POOL_DESKTOP
return random.choice(pool)
def random_fingerprint_headers(base_ua: Optional[str] = None) -> Dict[str, str]:
"""生成随机指纹 headers 集合(Accept-Language, Accept-Encoding, Sec-Ch-Ua 等)"""
headers: Dict[str, str] = {
"Accept-Language": random.choice(ACCEPT_LANGUAGE_POOL),
"Accept-Encoding": random.choice(ACCEPT_ENCODING_POOL),
}
if base_ua:
headers["User-Agent"] = base_ua
# 对 Chrome/Edge UA 添加 Sec-Ch-Ua 系列 header
ua = base_ua or ""
if "Chrome" in ua or "Edg" in ua:
headers["Sec-Ch-Ua"] = random.choice(SEC_CH_UA_POOL)
headers["Sec-Ch-Ua-Platform"] = random.choice(SEC_CH_UA_PLATFORM_POOL)
headers["Sec-Ch-Ua-Mobile"] = "?0"
return headers
# 按域名记住最近成功的 UA(部分网站对特定 UA 更友好)
_successful_ua_by_domain: Dict[str, str] = {}
_ua_domain_lock = threading.Lock()
def record_successful_ua(domain: str, ua: str):
"""记录某域名下最近成功的 UA"""
with _ua_domain_lock:
_successful_ua_by_domain[domain] = ua
# 限制字典大小
if len(_successful_ua_by_domain) > 200:
# 清理一半
keys = list(_successful_ua_by_domain.keys())
for k in keys[:100]:
del _successful_ua_by_domain[k]
def get_best_ua_for_domain(domain: str, mobile: bool = False) -> str:
"""获取某域名下最佳 UA(有历史成功记录则用历史的,否则随机)"""
with _ua_domain_lock:
if domain in _successful_ua_by_domain:
cached = _successful_ua_by_domain[domain]
# 如果请求的是 mobile 但缓存的是 desktop,重新随机
if mobile and "Mobile" not in cached and "Android" not in cached:
return random_ua(mobile=True)
return cached
return random_ua(mobile=mobile)
# ─── StdlibResponse ───────────────────────────────────────────────────────────
class HTTPError(urllib.error.HTTPError):
"""requests 风格的 HTTPError(兼容 raise_for_status 用法)
v7.2 修复:此前定义的 url property 与父类 addinfourl 的
self.url 赋值冲突,导致 raise_for_status 抛 AttributeError。
"""
def __init__(self, code: int, msg: str, hdrs: Dict, fp: Any, url: str = ""):
super().__init__(url or "?", code, msg, hdrs, fp)
self.code = code # urllib.error.HTTPError uses .code internally
@property
def status_code(self) -> int:
return self.code
def _auto_decompress(data: bytes, headers: Dict[str, str]) -> bytes:
"""v7.0.0: 自动解压 gzip/deflate 响应体(urllib 不解压,指纹头发了
Accept-Encoding: gzip 时服务器会返回压缩流,导致 json()/text() 乱码)。"""
if not data:
return data
ce = (headers.get("Content-Encoding")
or headers.get("content-encoding") or "").lower()
try:
if "gzip" in ce or data[:2] == b"\x1f\x8b":
import gzip
return gzip.decompress(data)
if "deflate" in ce:
import zlib
try:
return zlib.decompress(data)
except zlib.error:
return zlib.decompress(data, -zlib.MAX_WBITS)
if "br" in ce:
try:
import brotli
return brotli.decompress(data)
except ImportError:
pass
except Exception:
return data
return data
class StdlibResponse:
"""
urllib.response 包装器,对标 requests.Response 接口。
让调用方无感切换到 urllib。
"""
__slots__ = ('_url', '_code', '_headers', '_data', '_encoding')
def __init__(self, url: str, code: int, headers: Dict[str, str], data: bytes, encoding: str = "utf-8"):
self._url = url
self._code = code
self._headers = headers
self._data = _auto_decompress(data, headers)
self._encoding = encoding
@property
def url(self) -> str:
return self._url
@property
def code(self) -> int:
return self._code
@property
def status_code(self) -> int:
return self._code
@property
def headers(self) -> Dict[str, str]:
return self._headers
@property
def content(self) -> bytes:
return self._data
@property
def text(self) -> str:
return self._data.decode(self._encoding, errors="replace")
def json(self) -> Any:
# v7.5: GBK/GB2312 JSON API 兼容(json.loads 对 bytes 仅支持 utf-8/16/32)
try:
return json.loads(self._data)
except (UnicodeDecodeError, ValueError):
return json.loads(self._data.decode(self._encoding, errors="replace"))
def raise_for_status(self):
if self._code >= 400:
raise HTTPError(
self._code,
f"HTTP {self._code} error",
self._headers,
None,
self._url
)
def __repr__(self):
return f"<StdlibResponse [{self._code}]>"
# ─── StdlibSession ───────────────────────────────────────────────────────────
class StdlibSession:
"""
对标 requests.Session 的标准库实现。
内部使用 urllib.request.OpenerDirector + CookieJar。
v4.0: 支持代理、UA 轮换、指纹随机化。
"""
def __init__(self, headers: Optional[Dict[str, str]] = None,
proxy: Optional[str] = None,
rotate_ua: bool = False,
randomize_fingerprint: bool = False,
mobile: bool = False):
self.headers = {**DEFAULT_HEADERS, **(headers or {})}
self.cookies = CookieJar()
self.proxy = proxy
self.rotate_ua = rotate_ua
self.randomize_fingerprint = randomize_fingerprint
self.mobile = mobile
# 如果启用 UA 轮换,随机选取初始 UA
if rotate_ua and "User-Agent" not in (headers or {}):
self.headers["User-Agent"] = random_ua(mobile=mobile)
# 构建 opener(含代理)
handlers: List[Any] = [urllib.request.HTTPCookieProcessor(self.cookies)]
if proxy:
proxy_handler = urllib.request.ProxyHandler({
"http": proxy,
"https": proxy,
})
handlers.insert(0, proxy_handler)
self._opener = urllib.request.build_opener(*handlers)
def _apply_fingerprint(self, headers: Dict[str, str]) -> Dict[str, str]:
"""如果启用指纹随机化,为每次请求注入变化的指纹 headers"""
if not self.randomize_fingerprint:
return headers
fp = random_fingerprint_headers()
# 不覆盖调用方显式设置的 headers
for k, v in fp.items():
if k not in headers:
headers[k] = v
return headers
def _rotate_ua_if_needed(self, headers: Dict[str, str]) -> Dict[str, str]:
"""如果启用 UA 轮换且调用方没显式设 UA,则随机换一个"""
if not self.rotate_ua:
return headers
if "User-Agent" in headers:
return headers
base_ua = self.headers.get("User-Agent", "")
# 从 URL host 获取域名以使用最佳 UA
# (在 _build_request 中无法预知 URL,这里用默认随机)
headers["User-Agent"] = random_ua(mobile=self.mobile)
return headers
def _build_request(self, url: str, headers: Dict,
data: Optional[bytes],
method: Optional[str] = None) -> urllib.request.Request:
merged = {**self.headers, **headers}
# v4.0: 指纹随机化
merged = self._apply_fingerprint(merged)
# v4.0: UA 轮换 + 域名最佳 UA
if self.rotate_ua and "User-Agent" not in headers:
domain = _extract_domain(url)
merged["User-Agent"] = get_best_ua_for_domain(domain, mobile=self.mobile)
# v7.2: 显式 method 优先(修复空 body POST 被误判为 GET)
if method is None:
method = "GET" if data is None else "POST"
return urllib.request.Request(url, data=data, headers=merged, method=method)
def _do_open(self, request: urllib.request.Request,
timeout: int,
response_cls: type = StdlibResponse) -> StdlibResponse:
"""执行请求并返回 StdlibResponse"""
try:
raw = self._opener.open(request, timeout=timeout)
code = raw.getcode()
# urllib 返回的是 email.message.Message 或类似对象
hdrs = dict(raw.headers) if hasattr(raw.headers, '__iter__') else {}
data = raw.read()
url = request.full_url
raw.close()
# 编码检测(v7.5: 传入 url 以启用域名编码记忆)
encoding = self._detect_encoding(code, hdrs, data, url)
return StdlibResponse(url, code, hdrs, data, encoding)
except urllib.error.HTTPError as e:
# 读取错误响应体(某些服务器在 4xx/5xx 时仍返回内容)
code = e.code
hdrs = dict(e.headers) if hasattr(e.headers, '__iter__') else {}
data = b""
try:
if e.fp:
data = e.fp.read()
e.fp.close()
except Exception:
pass
url = e.url or request.full_url
encoding = self._detect_encoding(code, hdrs, data, url)
return StdlibResponse(url, code, hdrs, data, encoding)
def _detect_encoding(self, code: int, hdrs: Dict, data: bytes, url: str = "") -> str:
"""推断响应编码(v7.5 重写:BOM → 域名记忆 → Content-Type(严格解码验证)
→ HTML meta charset → chardet → 中文启发式)。
修复:声明编码与正文不符时不再盲信(如声称 utf-8 实为 GBK),
gb2312/gbk 统一归一化为 gb18030 避免超纲字符变替换符。"""
if not data:
return "utf-8"
# 0. BOM 探测(最高优先)
if data.startswith(b"\xef\xbb\xbf"):
return "utf-8"
if data.startswith(b"\xff\xfe") or data.startswith(b"\xfe\xff"):
return "utf-16"
# 0.5 域名编码记忆(同一站点首次探测成功后直接复用)
if url:
hinted = get_domain_encoding(_extract_domain(url))
if hinted:
try:
data[:4096].decode(hinted)
return hinted
except (UnicodeDecodeError, LookupError):
pass
# 1. Content-Type header 的 charset=(归一化 + 严格解码验证)
ct = hdrs.get("Content-Type", "") or hdrs.get("content-type", "")
m = re.search(r'charset=([^\s;]+)', ct, re.IGNORECASE)
declared = normalize_charset(m.group(1)) if m else None
if declared:
try:
data.decode(declared)
return declared
except (UnicodeDecodeError, LookupError):
log.debug(f"declared charset {declared} 无法解码正文,启发式纠错")
# 2. HTML <meta charset> / http-equiv(字节级探测,避免先用错编码解析头部)
meta_enc = _detect_meta_charset_bytes(data)
if meta_enc:
try:
data[:4096].decode(meta_enc)
return meta_enc
except (UnicodeDecodeError, LookupError):
pass
# 3. 可选:chardet 高精度检测
if HAS_CHARDET and len(data) > 20:
try:
detected = _chardet.detect(data[:16384]) # type: ignore[union-attr]
if detected and detected.get("encoding") and detected.get("confidence", 0) > 0.7:
enc = normalize_charset(detected["encoding"])
if enc:
return enc
except Exception:
pass
# 4. 中文编码启发式(中国金融网站主流编码)
guess = looks_like_chinese_binary(data)
if guess:
return guess
# 5. 最后兜底:latin-1 保证任何字节流都可解码(不再伪造 utf-8)
return "latin-1"
def get(self, url: str,
headers: Optional[Dict[str, str]] = None,
timeout: int = DEFAULT_TIMEOUT,
**kwargs) -> StdlibResponse:
"""HTTP GET"""
req = self._build_request(url, headers or {}, None)
return self._do_open(req, timeout)
def post(self, url: str,
data: Optional[Any] = None,
json_body: Optional[Dict] = None,
headers: Optional[Dict[str, str]] = None,
timeout: int = DEFAULT_TIMEOUT,
**kwargs) -> StdlibResponse:
"""HTTP POST — data 或 json_body 二选一"""
if json_body is not None:
body_bytes = json.dumps(json_body).encode("utf-8")
h = {"Content-Type": "application/json; charset=utf-8"}
if headers:
h = {**headers, **h}
headers = h
elif data is not None:
if isinstance(data, str):
body_bytes = data.encode("utf-8")
elif isinstance(data, dict):
body_bytes = urllib.parse.urlencode(data).encode("utf-8")
else:
body_bytes = data
else:
body_bytes = None
req = self._build_request(url, headers or {}, body_bytes, method="POST")
return self._do_open(req, timeout)
def request(self, method: str, url: str,
headers: Optional[Dict[str, str]] = None,
data: Optional[Any] = None,
timeout: int = DEFAULT_TIMEOUT,
**kwargs) -> StdlibResponse:
"""通用 request"""
if method.upper() == "POST":
return self.post(url, data=data, headers=headers, timeout=timeout, **kwargs)
return self.get(url, headers=headers, timeout=timeout, **kwargs)
def _extract_meta_charset(html_head: str) -> Optional[str]:
"""从 HTML 头部提取 charset 声明"""
# <meta charset="gbk">
m = re.search(r'<meta[^>]+charset=["\']?\s*([^\s"\'/>]+)', html_head, re.IGNORECASE)
if m:
enc = m.group(1).strip().lower()
if enc in ("gbk", "gb2312", "gb18030", "utf-8", "utf8", "big5"):
if enc in ("gb2312", "gbk"):
return "gb18030"
return enc
# <meta http-equiv="Content-Type" content="text/html; charset=gbk">
m = re.search(r'http-equiv=["\']?Content-Type["\']?[^>]+charset=["\']?\s*([^\s"\'/>]+)',
html_head, re.IGNORECASE)
if m:
enc = m.group(1).strip().lower()
if enc in ("gbk", "gb2312", "gb18030", "utf-8", "utf8", "big5"):
if enc in ("gb2312", "gbk"):
return "gb18030"
return enc
return None
# ─── v7.5 编码辅助 ──────────────────────────────────────
_CHARSET_ALIASES: Dict[str, str] = {
"utf8": "utf-8", "utf-16le": "utf-16", "utf-16be": "utf-16",
"gb2312": "gb18030", "gbk": "gb18030", "x-gbk": "gb18030",
"chinese": "gb18030", "cp936": "gb18030",
"iso-8859-1": "latin-1", "latin1": "latin-1", "iso8859-1": "latin-1",
"windows-1252": "cp1252", "ascii": "utf-8", "us-ascii": "utf-8",
}
def normalize_charset(enc: Optional[str]) -> Optional[str]:
"""归一化 charset 声明:别名映射 + GB 系统一为 gb18030(超集,避免超纲字符变替换符),
用 codecs 验证合法性。非法/无法识别返回 None。"""
if not enc:
return None
e = str(enc).strip().lower().strip('"\'')
e = _CHARSET_ALIASES.get(e, e)
try:
codecs.lookup(e)
except (LookupError, TypeError):
return None
return e
# 域名编码记忆:首次探测成功后复用,避免同站反复误判
domain_encoding_hints: Dict[str, str] = {}
_enc_hint_lock = threading.Lock()
def remember_domain_encoding(domain: str, enc: str):
"""记录域名下成功解码的编码(仅记非平凡编码)"""
if not domain or not enc or enc.lower() in ("ascii", "latin-1"):
return
with _enc_hint_lock:
domain_encoding_hints[domain] = enc.lower()
if len(domain_encoding_hints) > 300:
for k in list(domain_encoding_hints.keys())[:150]:
del domain_encoding_hints[k]
def get_domain_encoding(domain: str) -> Optional[str]:
with _enc_hint_lock:
return domain_encoding_hints.get(domain)
def _detect_meta_charset_bytes(data: bytes) -> Optional[str]:
"""字节级 meta charset 探测:依次用常见编码解码头部再提取,
避免旧版先用 errors=replace 解码导致 GBK 页头部解析失真。"""
if not data:
return None
head = data[:4096]
for enc_try in ("utf-8", "gb18030", "latin-1"):
try:
head_text = head.decode(enc_try)
except (UnicodeDecodeError, LookupError):
continue
meta_enc = _extract_meta_charset(head_text)
if meta_enc:
return normalize_charset(meta_enc) or meta_enc.lower()
return None
def looks_like_chinese_binary(data: bytes) -> Optional[str]:
"""无 charset 声明时的启发式编码判定:
utf-8 严格解码通过 → utf-8;失败则统计 GBK 双字节对比例,
显著时判为 gb18030;均不满足返回 None。"""
if not data:
return None
sample = data[:16384]
if all(b < 0x80 for b in sample):
return "utf-8" # 纯 ASCII,任何超集等价
try:
sample.decode("utf-8")
return "utf-8"
except UnicodeDecodeError:
pass
# GBK 双字节对统计(首字节 0x81-0xFE + 尾字节 0x40-0xFE)
gbk_pairs = 0
i = 0
n = len(sample)
while i < n - 1:
b = sample[i]
if 0x81 <= b <= 0xFE and 0x40 <= sample[i + 1] <= 0xFE:
gbk_pairs += 1
i += 2
else:
i += 1
if gbk_pairs >= max(2, n // 64):
return "gb18030"
return None
# ─── 代理支持(v4.0) ──────────────────────────────────────────────────────────
_proxy_url: Optional[str] = None
_proxy_lock = threading.Lock()
def set_proxy(proxy_url: Optional[str]):
"""设置全局代理 URL(http://user:pass@host:port 或 socks5://host:port)
Args:
proxy_url: 代理 URL,传 None 清除代理设置
"""
global _proxy_url
with _proxy_lock:
_proxy_url = proxy_url
# 清除共享 session 以使用新代理重建
clear_session_cache()
if proxy_url:
log.info(f"代理已设置: {proxy_url[:50]}...")
else:
log.info("代理已清除")
def get_proxy() -> Optional[str]:
"""获取当前全局代理 URL"""
with _proxy_lock:
return _proxy_url
def check_proxy(proxy_url: Optional[str] = None, test_url: str = "https://www.baidu.com",
timeout: int = 10) -> Tuple[bool, str]:
"""检测代理是否可用
Returns:
(是否可用, 响应信息或错误描述)
"""
proxy = proxy_url or _proxy_url
if not proxy:
return False, "未设置代理"
try:
proxy_handler = urllib.request.ProxyHandler({"http": proxy, "https": proxy})
opener = urllib.request.build_opener(proxy_handler)
req = urllib.request.Request(test_url, headers={"User-Agent": DEFAULT_UA})
resp = opener.open(req, timeout=timeout)
code = resp.getcode()
resp.close()
if 200 <= code < 400:
return True, f"代理可用 (HTTP {code})"
return False, f"代理返回 HTTP {code}"
except Exception as e:
return False, f"代理检测失败: {str(e)[:100]}"
# ─── SSL 验证管理(v5.0) ─────────────────────────────────────────────────────
_ssl_verify_enabled: bool = True
_ssl_lock = threading.Lock()
def disable_ssl_verify():
"""⚠️ 仅调试用 — 关闭 SSL 证书验证(生产环境请安装代理 CA 证书)
调用此函数会记录 WARNING 日志(含调用栈),便于安全审计。
推荐替代方案:
1. 安装代理 CA 证书到系统信任库
2. 使用 requests 的 verify 参数指定 PEM 文件路径
"""
global _ssl_verify_enabled
import traceback
stack = "".join(traceback.format_stack()[-4:-1])
log.warning(
"SSL 证书验证已关闭(仅调试用,生产环境请安装代理 CA 证书)\n"
f"调用栈:\n{stack}"
)
with _ssl_lock:
_ssl_verify_enabled = False
def enable_ssl_verify():
"""重新启用 SSL 证书验证(默认状态)"""
global _ssl_verify_enabled
with _ssl_lock:
_ssl_verify_enabled = True
log.info("SSL 证书验证已启用")
def get_ssl_status() -> Dict[str, Any]:
"""查询当前 SSL 验证状态
Returns:
{"enabled": bool, "status": str}
"""
with _ssl_lock:
enabled = _ssl_verify_enabled
return {
"enabled": enabled,
"status": "正常(证书验证开启)" if enabled else "⚠️ 已关闭(仅调试模式)",
}
def is_ssl_verify_enabled() -> bool:
"""返回当前 SSL 验证是否启用"""
with _ssl_lock:
return _ssl_verify_enabled
def clear_session_cache():
"""清除共享 session 缓存(代理变更后需要重建 session)"""
global _shared_session, _shared_mobile_session
_shared_session = None
_shared_mobile_session = None
# ─── 全局会话(v4.0 增强) ────────────────────────────────────────────────────
_shared_session: Optional[StdlibSession] = None
_shared_mobile_session: Optional[StdlibSession] = None
def get_session(headers: Optional[Dict[str, str]] = None,
reuse: bool = True,
mobile: bool = False,
rotate_ua: bool = True,
randomize_fingerprint: bool = True) -> StdlibSession:
"""获取或创建 HTTP 会话
Args:
headers: 额外的请求 headers
reuse: 是否复用共享 session
mobile: 是否使用移动端 UA
rotate_ua: 是否启用 UA 轮换(每次请求随机选 UA)
randomize_fingerprint: 是否启用请求指纹随机化
v4.0: 新增 rotate_ua 和 randomize_fingerprint 参数,
默认开启以增强反爬能力。
"""
global _shared_session, _shared_mobile_session
proxy = get_proxy()
if mobile:
if reuse and _shared_mobile_session is not None and headers is None and not proxy:
return _shared_mobile_session
session = StdlibSession(
{**DEFAULT_HEADERS, "User-Agent": MOBILE_UA, **(headers or {})},
proxy=proxy, rotate_ua=rotate_ua,
randomize_fingerprint=randomize_fingerprint, mobile=True
)
if reuse and headers is None and not proxy:
_shared_mobile_session = session
return session
if reuse and _shared_session is not None and headers is None and not proxy:
return _shared_session
session = StdlibSession(
{**DEFAULT_HEADERS, **(headers or {})},
proxy=proxy, rotate_ua=rotate_ua,
randomize_fingerprint=randomize_fingerprint, mobile=False
)
if reuse and headers is None and not proxy:
_shared_session = session
return session
# ─── 自适应限流(v4.0) ──────────────────────────────────────────────────────────
class AdaptiveRateLimiter:
"""根据服务器响应动态调整域名限流延迟
- HTTP 429 / 503 → 加大该域名延迟(指数增长,最大 120 秒)
- 连续成功 → 逐步恢复至基础延迟
- 触发限流后短暂暂停该域名所有请求
"""
def __init__(self, base_delays: Optional[Dict[str, float]] = None):
# 延迟引用 _DOMAIN_RATE_LIMITS(在模块底部定义),避免循环依赖
defaults = base_delays if base_delays else _DOMAIN_RATE_LIMITS
self._base_delays: Dict[str, float] = dict(defaults)
self._current_delays: Dict[str, float] = {}
self._consecutive_ok: Dict[str, int] = {}
self._consecutive_fail: Dict[str, int] = {}
self._blocked_until: Dict[str, float] = {}
self._lock = threading.Lock()
# 配置
self.max_delay = 120.0 # 最大延迟(秒)
self.fail_threshold = 3 # 连续失败几次触发熔断
self.ok_recovery_threshold = 5 # 连续成功几次开始恢复
self.recovery_factor = 0.8 # 恢复因子(每次乘 0.8)
def _get_base_delay(self, domain: str) -> float:
"""获取域名基础延迟"""
for d, v in self._base_delays.items():
if d in domain and d != "default":
return v
return self._base_delays.get("default", RATE_LIMIT_DELAY)
def get_delay(self, domain: str) -> float:
"""获取当前域名的限流延迟"""
with self._lock:
# 检查是否在熔断期
blocked = self._blocked_until.get(domain, 0)
if blocked > time.time():
remaining = blocked - time.time()
return max(remaining, 5.0)
return self._current_delays.get(domain, self._get_base_delay(domain))
def report_success(self, domain: str):
"""报告一次成功的请求"""
with self._lock:
self._consecutive_fail[domain] = 0
ok_count = self._consecutive_ok.get(domain, 0) + 1
self._consecutive_ok[domain] = ok_count
# 连续成功 N 次后逐步恢复延迟
if ok_count >= self.ok_recovery_threshold:
current = self._current_delays.get(domain, self._get_base_delay(domain))
base = self._get_base_delay(domain)
if current > base:
new_delay = max(base, current * self.recovery_factor)
self._current_delays[domain] = new_delay
log.debug(f"域名 {domain} 延迟恢复: {current:.1f}s → {new_delay:.1f}s")
def report_failure(self, domain: str, status_code: int = 0) -> bool:
"""报告一次失败的请求
Returns:
是否触发熔断(该域名应暂时停止请求)
"""
with self._lock:
self._consecutive_ok[domain] = 0
fail_count = self._consecutive_fail.get(domain, 0) + 1
self._consecutive_fail[domain] = fail_count
# HTTP 429 (Rate Limited) 或 503 (Service Unavailable) → 加大延迟
if status_code in (429, 503):
current = self._current_delays.get(domain, self._get_base_delay(domain))
new_delay = min(current * 2.5, self.max_delay)
self._current_delays[domain] = new_delay
log.warning(f"域名 {domain} HTTP {status_code},延迟增至 {new_delay:.1f}s")
# 连续失败 → 熔断
if fail_count >= self.fail_threshold:
block_seconds = min(10 * (2 ** (fail_count - self.fail_threshold)), 300)
self._blocked_until[domain] = time.time() + block_seconds
log.warning(f"域名 {domain} 连续失败 {fail_count} 次,熔断 {block_seconds:.0f}s")
return True
return False
def is_blocked(self, domain: str) -> bool:
"""检查域名是否在熔断期"""
with self._lock:
blocked = self._blocked_until.get(domain, 0)
if blocked > time.time():
return True
# 熔断过期,半开恢复
if blocked > 0 and blocked <= time.time():
self._blocked_until[domain] = 0
return False
def reset_domain(self, domain: str):
"""重置某个域名的限流状态"""
with self._lock:
self._current_delays.pop(domain, None)
self._consecutive_ok.pop(domain, None)
self._consecutive_fail.pop(domain, None)
self._blocked_until.pop(domain, None)
def get_stats(self) -> Dict[str, Any]:
"""获取限流统计信息"""
with self._lock:
return {
"blocked_domains": [
d for d, t in self._blocked_until.items() if t > time.time()
],
"delays": dict(self._current_delays),
"consecutive_fails": dict(self._consecutive_fail),
}
# ── 全局自适应限流器(延迟创建,在 _DOMAIN_RATE_LIMITS 之后) ──
_adaptive_limiter: Optional[AdaptiveRateLimiter] = None
def _get_adaptive_limiter() -> AdaptiveRateLimiter:
"""延迟获取全局自适应限流器(确保 _DOMAIN_RATE_LIMITS 已定义)"""
global _adaptive_limiter
if _adaptive_limiter is None:
_adaptive_limiter = AdaptiveRateLimiter()
return _adaptive_limiter
def get_adaptive_delay(domain: str) -> float:
"""便捷函数:获取域名自适应延迟"""
return _get_adaptive_limiter().get_delay(domain)
def report_request_result(url: str, success: bool, status_code: int = 0):
"""便捷函数:报告单次请求结果给自适应限流器"""
domain = _extract_domain(url)
if success:
_get_adaptive_limiter().report_success(domain)
else:
_get_adaptive_limiter().report_failure(domain, status_code)
# ─── LRU 缓存 ────────────────────────────────────────────────────────────────
class LRUCache:
def __init__(self, max_size: int = 128, ttl: float = 3600):
self.max_size = max_size
self.ttl = ttl
self._cache: OrderedDict = OrderedDict()
self._lock = threading.Lock()
def _key(self, url: str) -> str:
return hashlib.md5(url.encode()).hexdigest()[:16]
def get(self, url: str) -> Optional[StdlibResponse]:
key = self._key(url)
with self._lock:
if key in self._cache:
entry_time, resp = self._cache[key]
if time.time() - entry_time < self.ttl:
self._cache.move_to_end(key)
return resp
del self._cache[key]
return None
def set(self, url: str, resp: StdlibResponse):
key = self._key(url)
with self._lock:
if key in self._cache:
self._cache.move_to_end(key)
else:
if len(self._cache) >= self.max_size:
self._cache.popitem(last=False)
self._cache[key] = (time.time(), resp)
def clear(self):
with self._lock:
self._cache.clear()
def __len__(self):
return len(self._cache)
_response_cache = LRUCache(max_size=256, ttl=1800)
# ─── 按域名限流 ──────────────────────────────────────────────────────────────
_DOMAIN_RATE_LIMITS: Dict[str, float] = {
"eastmoney.com": 1.0,
"10jqka.com.cn": 2.0,
"sina.com.cn": 1.5,
"163.com": 1.5,
"xueqiu.com": 3.0,
"cls.cn": 2.0,
"jisilu.cn": 3.0,
"wallstreetcn.com": 2.0,
"sse.com.cn": 3.0,
"szse.cn": 3.0,
# v6.0.0 新增媒体源
"hexun.com": 2.0,
"jrj.com.cn": 2.0,
"ifeng.com": 1.5,
"fx678.com": 2.0,
"cngold.org": 2.0,
"investing.com": 2.0,
"zhitongcaijing.com": 2.0,
"jiemian.com": 1.5,
"lanjinger.com": 2.0,
"futunn.com": 2.0,
"aastocks.com": 2.0,
"smm.cn": 2.0,
"default": RATE_LIMIT_DELAY,
}
_domain_last_request: Dict[str, float] = {}
_domain_lock = threading.Lock()
def _extract_domain(url: str) -> str:
try:
return urllib.parse.urlparse(url).netloc.lower()
except Exception:
return "unknown"
def rate_limit(delay: float = RATE_LIMIT_DELAY, url: str = ""):
"""按域名限流(v4.0 增强:合并自适应限流器延迟;v7.2: sleep 移出锁外)"""
global _domain_last_request
wait_time = 0.0
block_wait = 0.0
blocked_domain = ""
with _domain_lock:
now = time.time()
if url:
domain = _extract_domain(url)
# v4.0: 优先使用自适应限流延迟
limiter = _get_adaptive_limiter()
adaptive_delay = limiter.get_delay(domain)
# 查找匹配域名配置(取自适应与静态配置的最大值)
domain_delay = adaptive_delay
for d, v in _DOMAIN_RATE_LIMITS.items():
if d in domain and d != "default":
domain_delay = max(adaptive_delay, v)
break
else:
domain_delay = max(adaptive_delay, _DOMAIN_RATE_LIMITS.get("default", RATE_LIMIT_DELAY))
# 检查是否熔断(v7.2: 只计算等待时长,sleep 在锁外执行,
# 避免一个域名熔断阻塞所有域名的限流判断)
if limiter.is_blocked(domain):
block_wait = max(domain_delay, 5.0)
blocked_domain = domain
else:
last_time = _domain_last_request.get(domain, 0)
elapsed = now - last_time
if elapsed < domain_delay:
wait_time = domain_delay - elapsed
_domain_last_request[domain] = now + wait_time
if len(_domain_last_request) > 100:
expired = [d for d, t in _domain_last_request.items() if now - t > 3600]
for d in expired:
del _domain_last_request[d]
if block_wait > 0:
log.warning(f"域名 {blocked_domain} 已熔断,等待 {block_wait:.1f}s")
time.sleep(block_wait)
# 不更新 last_request,让下次请求也等待
return
if wait_time > 0:
time.sleep(wait_time)
class RatelimitedSession:
"""v5.0.0: requests.Session 的自适应限流代理。
包装 requests.Session,使 self.session.get(...) 自动按域名限流 + 熔断,
供 company_report / research_report / news 等以 requests 直连的 scraper 使用,
使其与 http_utils 的限流策略保持一致(此前 requests 直连完全绕过 rate_limit)。
属性访问透明代理到原 Session(如 headers.update / close)。
"""
def __init__(self, session):
self._session = session
def get(self, url, **kwargs):
rate_limit(url=url)
return self._session.get(url, **kwargs)
def post(self, url, **kwargs):
rate_limit(url=url)
return self._session.post(url, **kwargs)
def __getattr__(self, name):
return getattr(self._session, name)
def close(self):
return self._session.close()
# ─── 重试抖动 ────────────────────────────────────────────────────────────────
def _jitter(base: float, factor: float = JITTER_FACTOR) -> float:
return base * (1 + random.uniform(-factor, factor))
def _apply_retry_strategy(strategy: RetryStrategy, headers: Dict[str, str],
session: Optional[StdlibSession], mobile: bool,
url: str) -> Tuple[Dict[str, str], bool]:
"""对单次重试应用指定策略,返回 (新headers, 是否切换为移动端session)"""
h = {**headers}
new_mobile = mobile
if strategy == RetryStrategy.UA_ROTATE:
h["User-Agent"] = random_ua(mobile=mobile)
elif strategy == RetryStrategy.MOBILE_UA:
h["User-Agent"] = random_ua(mobile=True)
new_mobile = True
elif strategy == RetryStrategy.FINGERPRINT:
fp = random_fingerprint_headers(h.get("User-Agent"))
for k, v in fp.items():
if k not in h:
h[k] = v
elif strategy == RetryStrategy.ADD_AJAX_HEADER:
h["X-Requested-With"] = "XMLHttpRequest"
elif strategy == RetryStrategy.REFERRER_CHANGE:
# 尝试设为同域根路径
try:
parsed = urllib.parse.urlparse(url)
h["Referer"] = f"{parsed.scheme}://{parsed.netloc}/"
except Exception:
h["Referer"] = "https://www.google.com/"
elif strategy == RetryStrategy.DELAY_INCREASE:
# 增加静态延迟,在调用方处理
pass
elif strategy == RetryStrategy.NO_CACHE:
# 在调用方处理
pass
return h, new_mobile
# ─── v7.2 重试/软拦截辅助 ────────────────────────────────────────────
def _header_ci(headers: Dict[str, str], name: str) -> str:
"""大小写不敏感取响应头"""
if not headers:
return ""
lname = name.lower()
for k, v in headers.items():
if k.lower() == lname:
return v or ""
return ""
def _retry_after_seconds(resp: StdlibResponse, cap: float = 60.0) -> float:
"""解析 Retry-After 响应头(仅支持秒数格式),限幅 cap"""
ra = _header_ci(resp.headers, "Retry-After").strip()
if not ra:
return 0.0
try:
return max(0.0, min(float(int(ra)), cap))
except (ValueError, TypeError):
return 0.0
def _is_ssl_error(e: Exception) -> bool:
"""v7.5: 判断异常是否为 SSL 证书/握手类错误(银行等站点证书链不全常见)"""
reason = getattr(e, "reason", None)
if isinstance(e, ssl.SSLError) or isinstance(reason, ssl.SSLError):
return True
msg = f"{type(e).__name__}: {e}".lower()
if not isinstance(e, (urllib.error.URLError, OSError, ConnectionError)):
return False
return any(k in msg for k in ("certificate", "ssl", "handshake", "tlsv1"))
def _scheme_downgrade(url: str) -> Optional[str]:
"""v7.5: https → http 协议降级(仅证书失败时兜底,返回新 URL 或 None)"""
if url.startswith("https://"):
return "http://" + url[len("https://"):]
return None
def _is_soft_blocked(resp: StdlibResponse):
"""v7.2: 检测伪装成 200 的软拦截页面。
保守策略:仅对短小(<8KB)的 HTML 响应做文本特征检测,
避免误伤 JSON API 与正常长文章页。返回 BlockSignal 或 None。
"""
if _detect_block is None or resp.code >= 300 or len(resp.content) > 8192:
return None
ct = _header_ci(resp.headers, "Content-Type").lower()
body_start = resp.content[:64].lstrip().lower()
if "html" not in ct and not body_start.startswith(b"<"):
return None
try:
return _detect_block(resp.code, resp.headers, resp.text[:2000])
except Exception:
return None
def _prepare_next_attempt(strategies: List[RetryStrategy], attempt: int,
merged: Dict[str, str], s: Any, current_mobile: bool,
url: str, wait: float, use_cache: bool
) -> Tuple[Dict[str, str], Any, bool, float, bool]:
"""对第 attempt 次失败应用重试策略。
返回 (新headers, session, 是否移动端, 调整后wait, 调整后use_cache)。
"""
if attempt >= len(strategies):
return merged, s, current_mobile, wait, use_cache
strat = strategies[attempt]
if strat == RetryStrategy.DELAY_INCREASE:
wait *= 2
elif strat == RetryStrategy.NO_CACHE:
use_cache = False
merged, current_mobile = _apply_retry_strategy(strat, merged, s, current_mobile, url)
if current_mobile and not getattr(s, 'mobile', False):
s = get_session(mobile=True, rotate_ua=True, randomize_fingerprint=True)
return merged, s, current_mobile, wait, use_cache
# ─── HTTP GET(v4.0: 多策略重试 + 自适应限流) ──────────────────────────────────
def http_get(url: str,
headers: Optional[Dict[str, str]] = None,
timeout: int = DEFAULT_TIMEOUT,
retries: int = MAX_RETRIES,
rate_limit_delay: float = 0,
session: Optional[StdlibSession] = None,
use_cache: bool = True,
retry_strategies: Optional[List[RetryStrategy]] = None,
detect_soft_block: bool = True,
scheme_fallback: bool = True,
retry_empty: bool = True,
**kwargs) -> Optional[StdlibResponse]:
"""HTTP GET 请求(v4.0 多策略重试 + 自适应限流;v7.2 软拦截检测;
v7.5 SSL 协议降级 + 空响应重试)
Args:
url: 目标 URL
headers: 额外 headers
timeout: 超时秒数
retries: 最大重试次数
rate_limit_delay: 最低限流延迟
session: 复用已有 session
use_cache: 是否使用 LRU 缓存
retry_strategies: 重试策略序列,默认使用 DEFAULT_RETRY_STRATEGIES
detect_soft_block: v7.2 是否检测 200 软拦截页面(短小反爬 HTML)
scheme_fallback: v7.5 SSL 证书失败时自动 https→http 降级重试一次(仅公开数据)
retry_empty: v7.5 200 空响应自动重试(部分站点首包空体)
Returns:
StdlibResponse 或 None
"""
# 缓存检查
if use_cache:
cached = _response_cache.get(url)
if cached is not None:
log.debug(f"Cache hit: {url[:80]}")
return cached
domain = _extract_domain(url)
# 自适应限流 + 熔断检查
rate_limit(rate_limit_delay, url)
s = session or get_session()
merged = {**DEFAULT_HEADERS, **(headers or {})}
current_mobile = False
# 策略列表
strategies = retry_strategies if retry_strategies is not None else DEFAULT_RETRY_STRATEGIES
downgraded = False # v7.5: SSL 协议降级只做一次
for attempt in range(retries):
try:
resp = s.get(url, headers=merged, timeout=timeout, **kwargs)
if resp.code >= 400:
# 报告失败给自适应限流器
report_request_result(url, False, resp.code)
wait = _jitter(RETRY_BACKOFF ** (attempt + 1))
# v7.2: 遵从服务端 Retry-After 声明(429/503)
if resp.code in (429, 503):
wait = max(wait, _retry_after_seconds(resp))
# v7.2: 应用重试策略(统一辅助函数)
merged, s, current_mobile, wait, use_cache = _prepare_next_attempt(
strategies, attempt, merged, s, current_mobile, url, wait, use_cache)
log.warning(f"HTTP {resp.code} on {url[:80]}, retry {attempt+1}/{retries} "
f"in {wait:.1f}s")
time.sleep(wait)
continue
# v7.5: 200 空响应自动重试(部分站点首包空体,二次请求即正常)
if retry_empty and resp.code < 300 and not resp.content \
and attempt < retries - 1:
log.warning(f"Empty body on {url[:80]}, retry {attempt+1}/{retries}")
time.sleep(_jitter(1.0))
continue
# v7.2: 软拦截检测(伪装成 200 的反爬页面视为失败并重试)
if detect_soft_block:
sig = _is_soft_blocked(resp)
if sig is not None:
report_request_result(url, False, 429)
wait = _jitter(RETRY_BACKOFF ** (attempt + 1))
merged, s, current_mobile, wait, use_cache = _prepare_next_attempt(
strategies, attempt, merged, s, current_mobile, url, wait, use_cache)
log.warning(f"Soft-block on {url[:80]} ({sig.reason}), "
f"retry {attempt+1}/{retries} in {wait:.1f}s")
time.sleep(wait)
continue
# 成功:缓存、记录成功 UA、报告成功
if use_cache:
_response_cache.set(url, resp)
report_request_result(url, True)
# 记录成功 UA 供后续使用
ua = merged.get("User-Agent", "")
if ua:
record_successful_ua(domain, ua)
# v7.5: 域名编码记忆(下次同域请求直接复用)
remember_domain_encoding(domain, getattr(resp, "_encoding", ""))
return resp
except Exception as e:
# v7.5: SSL 证书失败 → 协议降级重试一次(公开数据场景,警告日志留痕)
if scheme_fallback and not downgraded and _is_ssl_error(e):
alt = _scheme_downgrade(url)
if alt:
log.warning(f"SSL error on {url[:80]} ({e}),降级 http 重试")
url = alt
domain = _extract_domain(url)
downgraded = True
continue
report_request_result(url, False)
wait = _jitter(RETRY_BACKOFF ** (attempt + 1))
merged, s, current_mobile, wait, use_cache = _prepare_next_attempt(
strategies, attempt, merged, s, current_mobile, url, wait, use_cache)
log.warning(f"Request failed on {url[:80]}: {e}, retry {attempt+1}/{retries} "
f"in {wait:.1f}s")
time.sleep(wait)
log.error(f"All {retries} retries exhausted for {url[:80]}")
return None
def http_get_json(url: str,
timeout: int = DEFAULT_TIMEOUT,
session: Optional[StdlibSession] = None,
retry_strategies: Optional[List[RetryStrategy]] = None,
**kwargs) -> Optional[Dict]:
resp = http_get(url, timeout=timeout, session=session,
headers={"Accept": "application/json, */*"},
retry_strategies=retry_strategies, **kwargs)
if resp is not None:
try:
return resp.json()
except Exception as e:
log.warning(f"JSON parse failed for {url[:80]}: {e}")
return None
# ─── HTTP POST(v4.0: 多策略重试 + 自适应限流) ──────────────────────────────────
def http_post(url: str,
data: Optional[Any] = None,
json_body: Optional[Dict] = None,
headers: Optional[Dict[str, str]] = None,
timeout: int = DEFAULT_TIMEOUT,
retries: int = MAX_RETRIES,
rate_limit_delay: float = 0,
session: Optional[StdlibSession] = None,
use_cache: bool = False,
retry_strategies: Optional[List[RetryStrategy]] = None,
scheme_fallback: bool = True,
**kwargs) -> Optional[StdlibResponse]:
"""HTTP POST 请求(v4.0 多策略重试 + 自适应限流)
参数与 http_get 一致,额外支持 data/json_body。
"""
cache_key = url if use_cache else None
domain = _extract_domain(url)
rate_limit(rate_limit_delay, url)
s = session or get_session()
merged = {**DEFAULT_HEADERS, **(headers or {})}
current_mobile = False
strategies = retry_strategies if retry_strategies is not None else DEFAULT_RETRY_STRATEGIES
downgraded = False # v7.5: SSL 协议降级只做一次
for attempt in range(retries):
try:
resp = s.post(url, data=data, json_body=json_body,
headers=merged, timeout=timeout, **kwargs)
if resp.code >= 400:
report_request_result(url, False, resp.code)
wait = _jitter(RETRY_BACKOFF ** (attempt + 1))
# v7.2: 遵从服务端 Retry-After 声明(429/503)
if resp.code in (429, 503):
wait = max(wait, _retry_after_seconds(resp))
merged, s, current_mobile, wait, use_cache = _prepare_next_attempt(
strategies, attempt, merged, s, current_mobile, url, wait, use_cache)
log.warning(f"HTTP {resp.code} on POST {url[:80]}, retry {attempt+1}/{retries} "
f"in {wait:.1f}s")
time.sleep(wait)
continue
if use_cache and cache_key:
_response_cache.set(cache_key, resp)
report_request_result(url, True)
ua = merged.get("User-Agent", "")
if ua:
record_successful_ua(domain, ua)
return resp
except Exception as e:
# v7.5: SSL 证书失败 → 协议降级重试一次(与 http_get 一致)
if scheme_fallback and not downgraded and _is_ssl_error(e):
alt = _scheme_downgrade(url)
if alt:
log.warning(f"SSL error on POST {url[:80]} ({e}),降级 http 重试")
url = alt
domain = _extract_domain(url)
downgraded = True
continue
report_request_result(url, False)
wait = _jitter(RETRY_BACKOFF ** (attempt + 1))
merged, s, current_mobile, wait, use_cache = _prepare_next_attempt(
strategies, attempt, merged, s, current_mobile, url, wait, use_cache)
log.warning(f"POST failed on {url[:80]}: {e}, retry {attempt+1}/{retries} "
f"in {wait:.1f}s")
time.sleep(wait)
log.error(f"All {retries} retries exhausted for POST {url[:80]}")
return None
# ─── 便捷函数 ───────────────────────────────────────────────────────────────
def fetch_text(url: str,
headers: Optional[Dict[str, str]] = None,
timeout: int = DEFAULT_TIMEOUT,
retries: int = MAX_RETRIES,
use_cache: bool = True,
**kwargs) -> Optional[str]:
resp = http_get(url, headers=headers, timeout=timeout,
retries=retries, use_cache=use_cache, **kwargs)
return resp.text if resp is not None else None
def clear_cache() -> int:
_response_cache.clear()
return len(_response_cache)
# ─── 文件下载 ────────────────────────────────────────────────────────────────
def download_file(url: str,
save_dir: str = ".",
filename: Optional[str] = None,
headers: Optional[Dict[str, str]] = None,
timeout: int = DOWNLOAD_TIMEOUT,
session: Optional[StdlibSession] = None,
auto_referer: bool = True,
transcode_text: bool = True,
verify_content: bool = True,
resume: bool = True,
overwrite: bool = False,
expect_sha256: str = "",
min_bytes: int = 0,
**kwargs) -> Optional[str]:
"""下载文件(v7.5 加固 + v7.7 续传/校验)
相比旧版:
- 走 http_get 重试链(多策略重试/限流/软拦截检测,此前裸 session 无重试)
- 自动注入同源 Referer(部分金融站点校验 Referer,缺失直接拒载)
- GBK Content-Disposition 中文文件名乱码修复(v7.5)
- 反爬页校验:期望下载文档却收到 HTML 壳页时不落盘(v7.5)
- 文本文件自动转码为 UTF-8,修复 GB2312/GBK 源页下载后打开乱码(v7.5)
v7.7 新增:
- 已存在跳过:目标文件非空且非 overwrite → 直接返回(批量重跑省时)
- 断点续传:文件名已知且存在 .part 残文件时附 Range 头,
服务端支持则 206 只拉剩余部分;不支持则自动全量重下(仅缓存未命中时生效)
- 完整性:expect_sha256 校验(不匹配则删文件返回 None);min_bytes 拒收过小文件
- .part 原子落盘:先写临时文件再 os.replace,失败不残留半成品;下载不进 LRU 缓存
"""
merged = {**DEFAULT_HEADERS, **(headers or {})}
# v7.5: 同源 Referer 注入(不少公告/研报下载接口校验)
if auto_referer and not any(k.lower() == "referer" for k in merged):
try:
p = urllib.parse.urlparse(url)
merged["Referer"] = f"{p.scheme}://{p.netloc}/"
except Exception:
pass
# v7.7 已存在跳过(仅文件名已知时可用)
pre_path = None
if filename:
pre_path = os.path.join(save_dir, sanitize_filename(filename))
if not overwrite and os.path.isfile(pre_path) \
and os.path.getsize(pre_path) > 0:
log.info(f"Download skip existing: {pre_path}")
return pre_path
# v7.7 断点续传:存在 .part 残文件 → 附 Range 头只拉剩余(需服务端支持 206)
part_path = (pre_path + ".part") if pre_path else None
part_size = 0
if resume and part_path and os.path.isfile(part_path):
part_size = os.path.getsize(part_path)
if part_size > 0:
merged["Range"] = f"bytes={part_size}-"
kwargs.setdefault("use_cache", False) # v7.7: 下载不走 LRU 缓存(大体积且需新鲜)
resp = http_get(url, headers=merged, timeout=timeout,
session=session, **kwargs)
# v7.2: 错误响应不落盘(此前 404 页面会被当文件保存)
if resp is None or resp.code >= 400:
code = resp.code if resp is not None else 0
log.error(f"Download got HTTP {code} from {url[:80]}, skip saving")
return None
if not resp.content:
log.error(f"Download got empty body from {url[:80]}, skip saving")
return None
# v7.7: 206 = 服务端确认续传;200 = 不支持 Range,残文件作废重下
resumed = part_size > 0 and resp.code == 206
if part_size > 0 and not resumed:
part_size = 0
# 解析文件名(含 GBK 乱码修复)
if not filename:
filename = _extract_filename(resp, url)
filename = sanitize_filename(filename)
if not filename or filename == "unnamed":
filename = f"download_{int(time.time())}"
data = resp.content
# v7.5: 反爬页校验 — 期望二进制文档却收到 HTML,多为反爬/登录壳页,不落盘
if verify_content and _looks_html_magic(data):
ext = os.path.splitext(filename)[1].lower()
if ext in _BINARY_DOC_EXTS:
log.warning(f"Download {url[:80]} 返回 HTML(疑似反爬页),不保存为 {ext}")
return None
# v7.5: 文本文件自动转码 UTF-8(修复下载乱码)
if transcode_text and _is_text_download(filename, resp):
data = resp.text.encode("utf-8")
if not filename.lower().endswith((".html", ".htm", ".txt", ".csv",
".json", ".xml", ".js")):
filename += ".txt"
os.makedirs(save_dir, exist_ok=True)
save_path = os.path.join(save_dir, filename)
# v7.7 .part 原子落盘:续传追加 / 全量先写临时再 replace
if resumed and part_path and \
os.path.abspath(part_path) == os.path.abspath(save_path + ".part"):
with open(part_path, "ab") as f:
f.write(data)
os.replace(part_path, save_path)
else:
if part_path and os.path.isfile(part_path) and \
os.path.abspath(part_path) != os.path.abspath(save_path + ".part"):
try: # 最终文件名由服务端决定且与预设不一致 → 清旧残文件
os.remove(part_path)
except OSError:
pass
tmp_path = save_path + ".part"
with open(tmp_path, "wb") as f:
f.write(data)
os.replace(tmp_path, save_path)
# v7.7 完整性校验
if expect_sha256:
actual = file_sha256(save_path)
if actual != expect_sha256.strip().lower():
log.error(f"Download sha256 mismatch: {url[:80]} "
f"expect {expect_sha256[:12]}… got {actual[:12]}…")
try:
os.remove(save_path)
except OSError:
pass
return None
if min_bytes and os.path.getsize(save_path) < min_bytes:
log.error(f"Download too small (<{min_bytes}B): {url[:80]}")
try:
os.remove(save_path)
except OSError:
pass
return None
log.info(f"Downloaded: {save_path}")
return save_path
_BINARY_DOC_EXTS = {".pdf", ".doc", ".docx", ".xls", ".xlsx",
".ppt", ".pptx", ".zip", ".rar", ".7z"}
def _looks_html_magic(data: bytes) -> bool:
"""魔数判断响应体是否为 HTML(而非声明的文档类型)"""
head = (data or b"")[:64].lstrip().lower()
return head.startswith(b"<!doctype html") or head.startswith(b"<html")
def _is_text_download(filename: str, resp: StdlibResponse) -> bool:
"""判断下载目标是否为文本类文件(可安全转码为 UTF-8)"""
ct = _header_ci(resp.headers, "Content-Type").lower()
if ct.startswith(("text/", "application/json", "application/xml")):
return True
return filename.lower().endswith((".html", ".htm", ".txt", ".csv"))
def _fix_mojibake_filename(name: str) -> str:
"""v7.5: 修复 Content-Disposition 中文文件名乱码。
urllib 按 latin-1 解码响应头,中文文件名(实为 GBK 字节)会呈现乱码;
回环 latin-1 编码后用 gb18030 重解,若结果含 CJK 则采用。"""
if not name or name.isascii():
return name
try:
fixed = name.encode("latin-1").decode("gb18030")
except (UnicodeEncodeError, UnicodeDecodeError):
return name
if any('\u4e00' <= ch <= '\u9fff' for ch in fixed):
return fixed
return name
def _extract_filename(resp: StdlibResponse, url: str) -> str:
cd = resp.headers.get("Content-Disposition", "") or _header_ci(
resp.headers, "Content-Disposition")
if cd:
m = re.search(r"filename\*=UTF-8''(.+?)(?:;|$)", cd, re.IGNORECASE)
if m:
return _fix_mojibake_filename(urllib.parse.unquote(m.group(1).strip()))
m = re.search(r'filename=["\']?([^"\';]+)', cd)
if m:
return _fix_mojibake_filename(m.group(1).strip())
path = urllib.parse.urlparse(url).path
name = os.path.basename(path)
if name and '.' in name:
return _fix_mojibake_filename(urllib.parse.unquote(name))
return f"download_{int(time.time())}"
def sanitize_filename(name: str, max_len: int = 100) -> str:
name = re.sub(r'[<>:"/\\|?*\x00-\x1f]', '_', name)
name = re.sub(r'\s+', ' ', name).strip()
if len(name) > max_len:
name = name[:max_len]
# If the name is empty or only underscores after sanitization, use a default
if not name or not name.strip('_'):
return "unnamed"
return name
def file_sha256(path: str) -> str:
"""v7.7: 文件 sha256(分块读取,支持大文件)。"""
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(65536), b""):
h.update(chunk)
return h.hexdigest()
def batch_download(urls: List[str], save_dir: str = ".",
concurrency: int = 3,
session: Optional[StdlibSession] = None,
progress: Optional[Callable[[int, int, str, Optional[str]], None]] = None,
**dl_kwargs) -> Dict[str, Any]:
"""v7.7 并发批量下载(单个失败不阻断其余)。
Args:
urls: 下载链接列表
save_dir: 保存目录(共用)
concurrency: 并发数(建议 ≤4,防风控)
session: 复用会话(共享 Cookie/指纹)
progress: 进度回调 (done, total, url, path_or_None)
**dl_kwargs: 透传 download_file(resume/overwrite/expect_sha256 等)
Returns:
{"total", "ok", "failed": [url...], "files": {url: path/None}, "elapsed"}
"""
from concurrent.futures import ThreadPoolExecutor, as_completed
start = time.monotonic()
files: Dict[str, Optional[str]] = {}
done = 0
dl_kwargs.pop("filename", None) # 各文件名自动解析,不接受统一指定
with ThreadPoolExecutor(max_workers=max(1, min(concurrency, 8))) as pool:
futures = {pool.submit(download_file, u, save_dir=save_dir,
session=session, **dl_kwargs): u for u in urls}
for fut in as_completed(futures):
u = futures[fut]
try:
files[u] = fut.result()
except Exception as e:
log.error(f"batch_download {u[:80]} error: {e}")
files[u] = None
done += 1
if progress:
try:
progress(done, len(urls), u, files[u])
except Exception:
pass
ok_paths = [p for p in files.values() if p]
return {"total": len(urls), "ok": len(ok_paths),
"failed": [u for u, p in files.items() if not p],
"files": files,
"elapsed": round(time.monotonic() - start, 2)}
# ─── 错误诊断 (v4.3.1 新增,针对 R 维度优化) ──────────────────────────────────
# 错误码 → 用户可读解释 + 建议(与 TROUBLESHOOTING.md §8 对齐)
_HTTP_DIAGNOSTIC_TABLE = {
400: ("请求格式错误", "检查 URL 参数和请求头"),
401: ("需要鉴权", "当前为公开接口无需登录;如错误持续,可能源站策略变更"),
403: ("访问被拒(IP/UA/反爬触发)", "切换代理 IP 或降低频率,详见 TROUBLESHOOTING.md §3.1"),
404: ("资源不存在", "检查 URL 是否拼写正确,或更新到机构列表最新版"),
418: ("被反爬系统识别", "启用 UA 轮换 + 降低频率,详见 TROUBLESHOOTING.md §3.4"),
429: ("请求过于频繁", "降低并发到 max_workers=3 或加长 request_delay"),
500: ("源站内部错误", "稍后重试,源站可能临时异常"),
502: ("网关错误(CDN 故障)", "切换代理或等待 5-15 分钟"),
503: ("服务暂不可用", "稍后重试;可能是源站维护中"),
521: ("CDN Web 服务器宕机", "切换代理或等待"),
522: ("CDN 连接超时", "切换代理或降低并发"),
523: ("CDN 不可达", "源站 CDN 故障,等待 1-30 分钟后重试"),
}
def get_diagnostic(code_or_exc, url: str = "") -> dict:
"""根据 HTTP 状态码或异常返回结构化诊断信息。
用于标准化网络错误的展示,方便用户在 [TROUBLESHOOTING.md](../TROUBLESHOOTING.md) 中查询。
Args:
code_or_exc: HTTP 状态码(int)/ 异常对象(urllib.error.HTTPError, ConnectionError 等)
url: 触发该错误的 URL(用于日志)
Returns:
dict: {
"code": int 或 None,
"title": str, # 简短说明
"suggestion": str, # 可执行建议
"doc_link": str, # TROUBLESHOOTING 锚点
"url": str, # 触发 URL
}
Examples:
>>> get_diagnostic(403, "https://example.com")
{'code': 403, 'title': '访问被拒(IP/UA/反爬触发)',
'suggestion': '切换代理 IP 或降低频率,详见 TROUBLESHOOTING.md §3.1',
'doc_link': '#31-ip-被封--一直-403',
'url': 'https://example.com'}
>>> try:
... urllib.request.urlopen(url)
... except urllib.error.HTTPError as e:
... print(get_diagnostic(e, url))
"""
# 提取 code(兼容多种输入)
if isinstance(code_or_exc, int):
code = code_or_exc
elif hasattr(code_or_exc, "code"):
code = int(code_or_exc.code)
elif hasattr(code_or_exc, "status_code"):
code = int(code_or_exc.status_code)
else:
code = None
if code is not None and code in _HTTP_DIAGNOSTIC_TABLE:
title, suggestion = _HTTP_DIAGNOSTIC_TABLE[code]
# 根据 code 选择对应的 §X.Y 锚点
anchors = {
400: "#81-错误码速查表", # 占位,实际查看速查表
401: "#81-错误码速查表",
403: "#31-ip-被封--一直-403",
404: "#81-错误码速查表",
418: "#34-user-agent-失效--抓到的是请升级浏览器",
429: "#81-错误码速查表",
500: "#82-反复-504-502-网关错误",
502: "#82-反复-504-502-网关错误",
503: "#82-反复-504-502-网关错误",
521: "#82-反复-504-502-网关错误",
522: "#82-反复-504-502-网关错误",
523: "#82-反复-504-502-网关错误",
}
doc_link = anchors.get(code, "#81-错误码速查表")
else:
title = "未知错误"
suggestion = "查看完整堆栈后查阅 TROUBLESHOOTING.md 或提 issue"
doc_link = "#9-如何提-issue"
return {
"code": code,
"title": title,
"suggestion": suggestion,
"doc_link": doc_link,
"url": url,
}
def format_diagnostic(code_or_exc, url: str = "") -> str:
"""便捷函数:把 get_diagnostic() 格式化成可粘贴的多行字符串。"""
d = get_diagnostic(code_or_exc, url)
lines = []
if d["code"] is not None:
lines.append(f"❌ HTTP {d['code']}: {d['title']}")
else:
lines.append(f"❌ {d['title']}")
if d["url"]:
lines.append(f" URL: {d['url']}")
lines.append(f" 建议: {d['suggestion']}")
lines.append(f" 文档: TROUBLESHOOTING.md{d['doc_link']}")
return "\n".join(lines)