995 lines
38 KiB
Python
995 lines
38 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
金融数据爬虫核心模块 v4.2
|
||
统一的爬取入口:Scrapling(高级反爬)→ Playwright(JS渲染)→ http_utils(标准请求)→ 本地缓存
|
||
|
||
v3.0 改进:
|
||
- 六级降级链:Scrapling(隐身) → Scrapling(移动) → Playwright(JS渲染) → http_utils(桌面) → http_utils(移动) → 过期缓存
|
||
- 多源数据降级:基金净值/股票行情/公告 支持自动切换备用数据源
|
||
- 域名健康检查与熔断:连续失败自动暂停,恢复后自动探测
|
||
- 整合 http_utils v4.0(UA 轮换、多策略重试、自适应限流、指纹随机化)
|
||
|
||
v2.1:
|
||
- 整合 http_utils 公共基础设施(统一限流/重试/会话复用)
|
||
- 三级降级链:Scrapling 隐身模式 → requests 标准请求 → 本地缓存
|
||
- 请求去重 + 缓存避免重复爬取
|
||
- 自动重试机制 + 断点续爬 + 失败记录
|
||
- 增强错误提示,提供详细排查步骤和解决方案
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import re
|
||
import time
|
||
import hashlib
|
||
import threading
|
||
from pathlib import Path
|
||
from urllib.parse import urlparse
|
||
from typing import Optional, Dict, Any, List, Tuple, TYPE_CHECKING
|
||
from datetime import datetime, timedelta
|
||
from enum import Enum
|
||
|
||
# Scrapling导入(可选增强)
|
||
try:
|
||
from scrapling.fetchers import StealthyFetcher
|
||
from scrapling.parser import Selector
|
||
SCRAPLING_AVAILABLE = True
|
||
except ImportError:
|
||
SCRAPLING_AVAILABLE = False
|
||
if TYPE_CHECKING:
|
||
Selector = Any # type: ignore
|
||
|
||
# Playwright 导入(可选增强)
|
||
try:
|
||
from playwright.sync_api import sync_playwright
|
||
PLAYWRIGHT_AVAILABLE = True
|
||
except ImportError:
|
||
PLAYWRIGHT_AVAILABLE = False
|
||
|
||
# http_utils 公共基础设施(v4.0)
|
||
try:
|
||
from .http_utils import (
|
||
http_get, http_get_json, get_session, rate_limit, download_file, sanitize_filename,
|
||
random_ua, get_best_ua_for_domain,
|
||
report_request_result, get_adaptive_delay,
|
||
clear_cache as clear_http_cache,
|
||
)
|
||
except ImportError:
|
||
from http_utils import (
|
||
http_get, http_get_json, get_session, rate_limit, download_file, sanitize_filename,
|
||
random_ua, get_best_ua_for_domain,
|
||
report_request_result, get_adaptive_delay,
|
||
clear_cache as clear_http_cache,
|
||
)
|
||
|
||
# 数据目录
|
||
SKILL_DATA_DIR = Path(__file__).parent.parent / "data"
|
||
CACHE_DIR = SKILL_DATA_DIR / "scrape_cache"
|
||
CACHE_DIR.mkdir(exist_ok=True, parents=True)
|
||
|
||
# 请求限流(由 http_utils 管理,此处保留常量供参考)
|
||
DELAY_BETWEEN_REQUESTS = 2 # 秒
|
||
MAX_REQUESTS_PER_MINUTE = 30
|
||
DEFAULT_TIMEOUT = 30 # 秒
|
||
|
||
# 缓存有效期(按域名区分)
|
||
CACHE_TTL_HOURS = 24 # 默认:24小时内不重复爬取同一URL
|
||
CACHE_TTL_BY_DOMAIN = {
|
||
"eastmoney.com": 2, # 行情数据2小时刷新
|
||
"10jqka.com.cn": 1, # 实时数据1小时
|
||
"sina.com.cn": 4, # 新浪财经4小时
|
||
"xueqiu.com": 6, # 雪球6小时
|
||
"default": CACHE_TTL_HOURS,
|
||
}
|
||
# 请求去重(防止同一 session 内重复请求同一 URL)
|
||
_requested_urls: set = set()
|
||
|
||
# ─── 降级策略枚举(v3.0) ──────────────────────────────────────────────────────
|
||
|
||
class FallbackLevel(Enum):
|
||
"""降级链级别"""
|
||
SCRAPLING_STEALTH = 0 # Scrapling 隐身模式(最优先)
|
||
SCRAPLING_MOBILE = 1 # Scrapling 移动端
|
||
PLAYWRIGHT = 2 # Playwright JS 渲染
|
||
HTTP_DESKTOP = 3 # http_utils 桌面 UA
|
||
HTTP_MOBILE = 4 # http_utils 移动 UA
|
||
EXPIRED_CACHE = 5 # 过期缓存(最后手段)
|
||
|
||
# ─── 多源数据降级映射(v3.0) ────────────────────────────────────────────────────
|
||
|
||
# 基金净值备用源
|
||
FUND_NAV_FALLBACKS: Dict[str, List[str]] = {
|
||
"eastmoney.com": [
|
||
# 天天基金 → 新浪基金 → 东方财富手机版 API → 蛋卷
|
||
"https://fundgz.1234567.com.cn/js/{code}.js", # 天天基金估算净值 API
|
||
"https://hq.sinajs.cn/list=f_{code}", # 新浪基金行情
|
||
"https://push2.eastmoney.com/api/qt/stock/get?secid=0.{code}&fields=f43,f44,f45,f46,f47,f48,f170",
|
||
],
|
||
}
|
||
|
||
# 股票行情备用源
|
||
STOCK_QUOTE_FALLBACKS: Dict[str, List[str]] = {
|
||
"eastmoney.com": [
|
||
"https://hq.sinajs.cn/list={market}{code}", # 新浪股票行情
|
||
"https://push2.eastmoney.com/api/qt/stock/get?secid={market_id}.{code}&fields=f43,f44,f45,f46,f47,f48,f57,f58,f170",
|
||
"https://qt.gtimg.cn/q={market}{code}", # 腾讯股票行情
|
||
],
|
||
"sina.com.cn": [
|
||
"https://push2.eastmoney.com/api/qt/stock/get?secid={market_id}.{code}&fields=f43,f44,f45,f46,f47,f48,f57,f58,f170",
|
||
"https://qt.gtimg.cn/q={market}{code}", # 腾讯股票行情
|
||
],
|
||
}
|
||
|
||
# 公告搜索备用源
|
||
ANNOUNCEMENT_FALLBACKS: Dict[str, str] = {
|
||
"eastmoney.com": "http://www.cninfo.com.cn/new/disclosure/stock?stockCode={code}&orgId={org_id}",
|
||
"cninfo.com.cn": "https://np-anotice-stock.eastmoney.com/api/security/ann?stockCode={code}&pageSize=30",
|
||
}
|
||
|
||
|
||
class ScrapingError(Exception):
|
||
"""爬取错误基类"""
|
||
def __init__(self, message: str, error_type: str = "unknown", hint: str = ""):
|
||
self.message = message
|
||
self.error_type = error_type
|
||
self.hint = hint
|
||
super().__init__(self.message)
|
||
|
||
|
||
class NetworkError(ScrapingError):
|
||
"""网络相关错误"""
|
||
def __init__(self, message: str, hint: str = "请检查网络连接,或稍后重试"):
|
||
super().__init__(message, "network", hint)
|
||
|
||
|
||
class ScrapingTimeoutError(ScrapingError):
|
||
"""超时错误"""
|
||
def __init__(self, message: str, hint: str = "目标网站响应较慢,可以尝试:1) 稍后重试 2) 使用动态渲染模式 3) 检查网络状况"):
|
||
super().__init__(message, "timeout", hint)
|
||
|
||
|
||
class ParseError(ScrapingError):
|
||
"""解析错误"""
|
||
def __init__(self, message: str, hint: str = "页面结构可能已变更,可以尝试更新选择器或使用动态渲染"):
|
||
super().__init__(message, "parse", hint)
|
||
|
||
|
||
class DependencyError(ScrapingError):
|
||
"""依赖缺失错误"""
|
||
def __init__(self, message: str, hint: str = "请运行: pip install scrapling playwright && playwright install chromium"):
|
||
super().__init__(message, "dependency", hint)
|
||
|
||
|
||
# ─── 域名健康追踪与熔断(v3.0) ──────────────────────────────────────────────────
|
||
|
||
class DomainHealthTracker:
|
||
"""域名健康检查与熔断器
|
||
|
||
追踪各域名的请求成功率,连续失败达到阈值后自动熔断,
|
||
熔断期间返回缓存数据,半开后探测恢复。
|
||
"""
|
||
|
||
def __init__(self, fail_threshold: int = 3, cooldown_seconds: float = 60.0,
|
||
half_open_probe_seconds: float = 30.0):
|
||
self.fail_threshold = fail_threshold
|
||
self.cooldown_seconds = cooldown_seconds
|
||
self.half_open_probe_seconds = half_open_probe_seconds
|
||
|
||
self._consecutive_fails: Dict[str, int] = {}
|
||
self._blocked_until: Dict[str, float] = {}
|
||
self._total_requests: Dict[str, int] = {}
|
||
self._total_failures: Dict[str, int] = {}
|
||
self._last_probe: Dict[str, float] = {}
|
||
self._lock = threading.Lock()
|
||
|
||
def record_success(self, domain: str):
|
||
"""记录一次成功"""
|
||
with self._lock:
|
||
self._consecutive_fails[domain] = 0
|
||
self._total_requests[domain] = self._total_requests.get(domain, 0) + 1
|
||
# 成功后清除熔断
|
||
self._blocked_until.pop(domain, None)
|
||
|
||
def record_failure(self, domain: str) -> bool:
|
||
"""记录一次失败
|
||
|
||
Returns:
|
||
是否触发熔断(调用方应暂停该域名请求)
|
||
"""
|
||
with self._lock:
|
||
fails = self._consecutive_fails.get(domain, 0) + 1
|
||
self._consecutive_fails[domain] = fails
|
||
self._total_requests[domain] = self._total_requests.get(domain, 0) + 1
|
||
self._total_failures[domain] = self._total_failures.get(domain, 0) + 1
|
||
|
||
if fails >= self.fail_threshold:
|
||
self._blocked_until[domain] = time.time() + self.cooldown_seconds
|
||
return True
|
||
return False
|
||
|
||
def is_blocked(self, domain: str) -> bool:
|
||
"""检查域名是否在熔断中"""
|
||
with self._lock:
|
||
blocked_until = self._blocked_until.get(domain, 0)
|
||
if blocked_until > time.time():
|
||
return True
|
||
# 熔断已过期,进入半开状态
|
||
if blocked_until > 0 and blocked_until <= time.time():
|
||
# 半开状态:允许探测,但记录探测时间
|
||
last_probe = self._last_probe.get(domain, 0)
|
||
if time.time() - last_probe < self.half_open_probe_seconds:
|
||
return True # 刚探测过不久,继续等待
|
||
self._last_probe[domain] = time.time()
|
||
self._blocked_until[domain] = 0
|
||
return False
|
||
return False
|
||
|
||
def get_health(self, domain: str) -> Dict[str, Any]:
|
||
"""获取域名健康状态"""
|
||
with self._lock:
|
||
total = self._total_requests.get(domain, 0)
|
||
failures = self._total_failures.get(domain, 0)
|
||
return {
|
||
"domain": domain,
|
||
"blocked": self._blocked_until.get(domain, 0) > time.time(),
|
||
"consecutive_fails": self._consecutive_fails.get(domain, 0),
|
||
"success_rate": (total - failures) / total if total > 0 else 1.0,
|
||
"total_requests": total,
|
||
}
|
||
|
||
def get_blocked_domains(self) -> List[str]:
|
||
"""获取当前熔断的域名列表"""
|
||
with self._lock:
|
||
now = time.time()
|
||
return [d for d, t in self._blocked_until.items() if t > now]
|
||
|
||
def reset(self, domain: Optional[str] = None):
|
||
"""重置健康状态(domain=None 则全部重置)"""
|
||
with self._lock:
|
||
if domain:
|
||
for d in [domain]:
|
||
self._consecutive_fails.pop(d, None)
|
||
self._blocked_until.pop(d, None)
|
||
else:
|
||
self._consecutive_fails.clear()
|
||
self._blocked_until.clear()
|
||
self._total_requests.clear()
|
||
self._total_failures.clear()
|
||
self._last_probe.clear()
|
||
|
||
|
||
# 全局域名健康追踪器
|
||
_health_tracker = DomainHealthTracker()
|
||
|
||
|
||
class _CachedPage:
|
||
"""缓存命中的轻量页面包装(替代每次动态 type() 重建对象)。
|
||
统一暴露 html / text / html_content 属性,与 Scrapling/requests 返回对象对齐。
|
||
"""
|
||
__slots__ = ('html', 'text', 'html_content')
|
||
|
||
def __init__(self, content: str):
|
||
self.html = content
|
||
self.text = content
|
||
self.html_content = content
|
||
|
||
def css(self, *args, **kwargs):
|
||
"""兼容调用:缓存命中时不支持 CSS 选择器,返回空列表"""
|
||
return []
|
||
|
||
def prettify(self):
|
||
return self.html
|
||
|
||
|
||
class ScraperHelpers:
|
||
"""爬虫辅助工具"""
|
||
|
||
@staticmethod
|
||
def classify_error(e: Exception, operation: str = "操作") -> tuple:
|
||
"""分类错误并返回用户友好的错误信息和诊断提示
|
||
|
||
Returns:
|
||
tuple: (user_message, diagnostic_hint, error_type, solution_steps)
|
||
"""
|
||
error_str = str(e).lower()
|
||
error_type = type(e).__name__
|
||
|
||
# 网络相关错误
|
||
network_keywords = ['timeout', 'connection', 'network', '网络', '连接', '超时', 'dns', 'socket',
|
||
'econnreset', 'etimedout', 'enotfound', 'eai_again']
|
||
if any(kw in error_str for kw in network_keywords) or 'httperror' in error_type.lower():
|
||
return (
|
||
f"{operation}失败,网络连接出现问题",
|
||
"请按以下步骤排查:\n1. 检查网络连接是否正常\n2. 尝试访问其他网站确认网络通畅\n3. 如果使用代理,请检查代理配置\n4. 稍后重试,可能是目标网站临时不可用",
|
||
"network",
|
||
["检查网络连接", "尝试其他网站", "检查代理配置", "稍后重试"]
|
||
)
|
||
|
||
# 超时错误
|
||
timeout_keywords = ['timed out', 'timeout', '超时']
|
||
if any(kw in error_str for kw in timeout_keywords):
|
||
return (
|
||
f"{operation}超时,目标网站响应太慢",
|
||
"可能原因:\n1. 目标网站服务器繁忙\n2. 网络延迟较高\n3. 页面内容过大\n\n建议操作:\n1. 稍后重试\n2. 使用缓存模式:`use_cache=True`\n3. 增加超时时间:`timeout=60`",
|
||
"timeout",
|
||
["稍后重试", "使用缓存模式", "增加超时时间"]
|
||
)
|
||
|
||
# Cloudflare/反爬
|
||
if 'cloudflare' in error_str or 'captcha' in error_str:
|
||
return (
|
||
f"{operation}失败,遇到网站防护机制",
|
||
"目标网站检测到自动化访问并进行了拦截。\n\n建议操作:\n1. 降低爬取频率\n2. 使用动态渲染模式:`mode='realtime'`\n3. 稍后重试\n4. 检查是否有验证码需要处理",
|
||
"anti_scraper",
|
||
["降低爬取频率", "使用动态渲染", "稍后重试", "检查验证码"]
|
||
)
|
||
|
||
# 依赖缺失
|
||
if 'import' in error_str or 'no module' in error_str or 'not found' in error_str:
|
||
module_match = re.search(r"module '(\w+)'", error_str) or re.search(r"'(\w+)'", error_str)
|
||
module_name = module_match.group(1) if module_match else "未知模块"
|
||
return (
|
||
f"{operation}失败,缺少必要的 Python 模块",
|
||
f"缺少模块:`{module_name}`\n\n请运行以下命令安装:\n```bash\npip install {module_name}\n```\n\n或者安装所有依赖:\n```bash\npip install -r requirements.txt\n```",
|
||
"dependency",
|
||
[f"pip install {module_name}", "pip install -r requirements.txt"]
|
||
)
|
||
|
||
# JSON/数据解析错误
|
||
if 'json' in error_str or 'decode' in error_str:
|
||
return (
|
||
f"{operation}失败,数据格式异常",
|
||
"目标网站返回的数据格式不符合预期。\n\n可能原因:\n1. 页面结构已变更\n2. 返回了错误页面(如登录页)\n3. 网络问题导致数据不完整\n\n建议:\n1. 检查 URL 是否正确\n2. 尝试使用动态渲染模式\n3. 查看缓存中是否有历史数据",
|
||
"parse",
|
||
["检查 URL", "使用动态渲染", "查看缓存数据"]
|
||
)
|
||
|
||
# 元素未找到
|
||
if 'no such' in error_str or 'not found' in error_str or 'none' in error_str:
|
||
return (
|
||
f"{operation}失败,页面中找不到目标内容",
|
||
"可能原因:\n1. 页面结构已更新\n2. 内容需要登录才能查看\n3. 使用了错误的选择器\n\n建议:\n1. 检查 URL 是否可直接访问\n2. 尝试使用动态渲染模式\n3. 更新选择器配置",
|
||
"parse",
|
||
["检查 URL", "使用动态渲染", "更新选择器"]
|
||
)
|
||
|
||
# 权限错误
|
||
if 'permission' in error_str or 'access denied' in error_str or '403' in error_str:
|
||
return (
|
||
f"{operation}失败,访问被拒绝",
|
||
"目标网站拒绝了访问请求。\n\n可能原因:\n1. 需要登录或授权\n2. IP 被临时封禁\n3. 请求频率过高\n\n建议:\n1. 检查是否需要登录\n2. 降低请求频率\n3. 稍后重试",
|
||
"permission",
|
||
["检查登录需求", "降低请求频率", "稍后重试"]
|
||
)
|
||
|
||
# 默认错误
|
||
return (
|
||
f"{operation}失败:{str(e)[:100]}",
|
||
"请按以下步骤排查:\n1. 检查网络连接\n2. 确认 URL 是否正确\n3. 稍后重试\n4. 如果问题持续,请查看详细日志",
|
||
"unknown",
|
||
["检查网络", "确认 URL", "稍后重试", "查看日志"]
|
||
)
|
||
|
||
|
||
class FinancialPageScraper:
|
||
"""金融页面爬取器 v3.0
|
||
|
||
六级降级链 + 自动重试 + 断点续爬 + 域名熔断:
|
||
1. Scrapling 隐身模式(反反爬)→
|
||
2. Scrapling 移动端模式 →
|
||
3. Playwright JS 渲染 →
|
||
4. http_utils 桌面 UA 请求 →
|
||
5. http_utils 移动 UA 请求 →
|
||
6. 本地过期缓存(离线可用)
|
||
|
||
域名健康检查:连续失败 >=3 次自动熔断 60s。
|
||
"""
|
||
|
||
def __init__(self, timeout: int = DEFAULT_TIMEOUT, use_cache: bool = True,
|
||
max_retries: int = 3, auto_recovery: bool = True,
|
||
enable_health_tracker: bool = True):
|
||
self.timeout = timeout
|
||
self.use_cache = use_cache
|
||
self.max_retries = max_retries
|
||
self.auto_recovery = auto_recovery
|
||
self.enable_health_tracker = enable_health_tracker
|
||
self._helpers = ScraperHelpers()
|
||
self._fetcher = None
|
||
self._failed_urls: List[Dict[str, str]] = []
|
||
self._progress_file = SKILL_DATA_DIR / "crawl_progress.json"
|
||
|
||
# ── 缓存 ──
|
||
|
||
def _cache_key(self, url: str) -> str:
|
||
return hashlib.md5(url.encode()).hexdigest()[:16]
|
||
|
||
def _cache_path(self, url: str) -> Path:
|
||
return CACHE_DIR / f"{self._cache_key(url)}.html"
|
||
|
||
def _cache_ttl(self, url: str) -> float:
|
||
"""根据域名获取缓存 TTL(秒)"""
|
||
try:
|
||
domain = urlparse(url).netloc.lower()
|
||
except Exception:
|
||
domain = ""
|
||
for pattern, ttl_hours in CACHE_TTL_BY_DOMAIN.items():
|
||
if pattern in domain:
|
||
return ttl_hours * 3600
|
||
return CACHE_TTL_HOURS * 3600
|
||
|
||
def _cache_get(self, url: str, allow_expired: bool = False) -> Optional[str]:
|
||
"""获取缓存内容
|
||
|
||
Args:
|
||
url: 目标 URL
|
||
allow_expired: 是否允许返回过期缓存(用于网络失败时降级)
|
||
|
||
Returns:
|
||
缓存的 HTML 内容,无缓存返回 None
|
||
"""
|
||
if not self.use_cache:
|
||
return None
|
||
p = self._cache_path(url)
|
||
if not p.exists():
|
||
return None
|
||
age = time.time() - p.stat().st_mtime
|
||
ttl = self._cache_ttl(url)
|
||
|
||
# 正常模式:过期则删除
|
||
if age > ttl and not allow_expired:
|
||
p.unlink(missing_ok=True)
|
||
return None
|
||
|
||
# 降级模式:过期但仍可用
|
||
if age > ttl and allow_expired:
|
||
# 过期超过 7 天则不使用
|
||
if age > ttl * 7:
|
||
return None
|
||
|
||
return p.read_text(encoding='utf-8')
|
||
|
||
def _cache_set(self, url: str, html: str):
|
||
if not self.use_cache or not html:
|
||
return
|
||
self._cache_path(url).write_text(html, encoding='utf-8')
|
||
|
||
def cleanup_cache(self, max_size_mb: int = 500) -> Dict[str, Any]:
|
||
"""清理过期和超大缓存
|
||
|
||
Args:
|
||
max_size_mb: 缓存目录最大大小(MB)
|
||
|
||
Returns:
|
||
清理结果统计
|
||
"""
|
||
cache_files = sorted(CACHE_DIR.glob("*.html"), key=lambda f: f.stat().st_mtime)
|
||
total_size = sum(f.stat().st_size for f in cache_files)
|
||
deleted_count = 0
|
||
deleted_size = 0
|
||
|
||
# 1. 删除过期文件(超过 2 倍 TTL)
|
||
for f in cache_files:
|
||
age = time.time() - f.stat().st_mtime
|
||
if age > CACHE_TTL_HOURS * 3600 * 2:
|
||
deleted_size += f.stat().st_size
|
||
f.unlink(missing_ok=True)
|
||
deleted_count += 1
|
||
|
||
# 2. 如果仍然超限,按 LRU 删除最旧的文件
|
||
remaining_files = sorted(CACHE_DIR.glob("*.html"), key=lambda f: f.stat().st_mtime)
|
||
remaining_size = sum(f.stat().st_size for f in remaining_files)
|
||
max_size_bytes = max_size_mb * 1024 * 1024
|
||
|
||
if remaining_size > max_size_bytes:
|
||
for f in remaining_files:
|
||
if remaining_size <= max_size_bytes:
|
||
break
|
||
file_size = f.stat().st_size
|
||
remaining_size -= file_size
|
||
deleted_size += file_size
|
||
f.unlink(missing_ok=True)
|
||
deleted_count += 1
|
||
|
||
result = {
|
||
"deleted_count": deleted_count,
|
||
"deleted_size_mb": round(deleted_size / 1024 / 1024, 2),
|
||
"remaining_count": len(list(CACHE_DIR.glob("*.html"))),
|
||
"remaining_size_mb": round(remaining_size / 1024 / 1024, 2)
|
||
}
|
||
|
||
if deleted_count > 0:
|
||
print(f"🧹 缓存清理完成: 删除 {deleted_count} 个文件,释放 {result['deleted_size_mb']} MB")
|
||
|
||
return result
|
||
|
||
# ── 爬取(v3.0 六级降级链) ──
|
||
|
||
def scrape_url(self, url: str, use_dynamic: bool = False,
|
||
fallback_chain: Optional[List[FallbackLevel]] = None) -> Optional[Any]:
|
||
"""
|
||
爬取URL页面(六级降级链)。
|
||
|
||
Args:
|
||
url: 目标 URL
|
||
use_dynamic: 是否强制使用动态渲染
|
||
fallback_chain: 自定义降级链,默认全部启用
|
||
|
||
Returns:
|
||
Scrapling Selector / StdlibResponse / _CachedPage / None
|
||
"""
|
||
domain = urlparse(url).netloc.lower() if url else ""
|
||
|
||
# 域名熔断检查
|
||
if self.enable_health_tracker and _health_tracker.is_blocked(domain):
|
||
cached = self._cache_get(url, allow_expired=True)
|
||
if cached:
|
||
return _CachedPage(cached)
|
||
return None
|
||
|
||
# 1. 查缓存
|
||
cached = self._cache_get(url)
|
||
if cached:
|
||
return _CachedPage(cached)
|
||
|
||
# 确定降级链
|
||
if fallback_chain is None:
|
||
if use_dynamic:
|
||
fallback_chain = [FallbackLevel.PLAYWRIGHT, FallbackLevel.SCRAPLING_STEALTH,
|
||
FallbackLevel.HTTP_DESKTOP, FallbackLevel.HTTP_MOBILE]
|
||
else:
|
||
fallback_chain = [FallbackLevel.SCRAPLING_STEALTH, FallbackLevel.SCRAPLING_MOBILE,
|
||
FallbackLevel.PLAYWRIGHT, FallbackLevel.HTTP_DESKTOP,
|
||
FallbackLevel.HTTP_MOBILE]
|
||
|
||
# 逐级尝试
|
||
for level in fallback_chain:
|
||
try:
|
||
result = self._try_fallback_level(url, level)
|
||
if result is not None:
|
||
html = getattr(result, 'html', '') or getattr(result, 'html_content', '') or ''
|
||
if not html and hasattr(result, 'text'):
|
||
html = result.text
|
||
if html:
|
||
self._cache_set(url, html)
|
||
if self.enable_health_tracker:
|
||
_health_tracker.record_success(domain)
|
||
return result
|
||
except Exception:
|
||
pass
|
||
|
||
# 全部失败 -> 尝试过期缓存
|
||
if self.enable_health_tracker:
|
||
_health_tracker.record_failure(domain)
|
||
|
||
expired = self._cache_get(url, allow_expired=True)
|
||
if expired:
|
||
return _CachedPage(expired)
|
||
|
||
return None
|
||
|
||
def _try_fallback_level(self, url: str, level: FallbackLevel) -> Optional[Any]:
|
||
"""尝试降级链中的某个级别"""
|
||
if level == FallbackLevel.SCRAPLING_STEALTH:
|
||
if SCRAPLING_AVAILABLE:
|
||
return self._scrape_scrapling(url, mobile=False)
|
||
|
||
elif level == FallbackLevel.SCRAPLING_MOBILE:
|
||
if SCRAPLING_AVAILABLE:
|
||
return self._scrape_scrapling(url, mobile=True)
|
||
|
||
elif level == FallbackLevel.PLAYWRIGHT:
|
||
if PLAYWRIGHT_AVAILABLE:
|
||
return self._scrape_playwright(url)
|
||
|
||
elif level == FallbackLevel.HTTP_DESKTOP:
|
||
resp = http_get(url, timeout=self.timeout, rate_limit_delay=1.0)
|
||
if resp is not None:
|
||
return resp
|
||
|
||
elif level == FallbackLevel.HTTP_MOBILE:
|
||
s = get_session(mobile=True, rotate_ua=True, randomize_fingerprint=True)
|
||
resp = http_get(url, timeout=self.timeout, rate_limit_delay=1.0, session=s)
|
||
if resp is not None:
|
||
return resp
|
||
|
||
elif level == FallbackLevel.EXPIRED_CACHE:
|
||
cached = self._cache_get(url, allow_expired=True)
|
||
if cached:
|
||
return _CachedPage(cached)
|
||
|
||
return None
|
||
|
||
def _scrape_scrapling(self, url: str, mobile: bool = False) -> Optional[Any]:
|
||
"""Scrapling 隐身爬取(v3.0: 支持移动端模式)"""
|
||
if self._fetcher is None:
|
||
try:
|
||
self._fetcher = StealthyFetcher()
|
||
except Exception:
|
||
return None
|
||
|
||
try:
|
||
if mobile:
|
||
try:
|
||
mobile_fetcher = StealthyFetcher()
|
||
page = mobile_fetcher.fetch(url, timeout=self.timeout)
|
||
except Exception:
|
||
page = self._fetcher.fetch(url, timeout=self.timeout)
|
||
else:
|
||
page = self._fetcher.fetch(url, timeout=self.timeout)
|
||
|
||
# scrapling 0.2.x compat: .html_content → .html
|
||
if hasattr(page, 'html_content') and not hasattr(page, 'html'):
|
||
page.html = page.html_content
|
||
elif hasattr(page, 'body') and isinstance(page.body, str) and not hasattr(page, 'html'):
|
||
page.html = page.body
|
||
return page
|
||
except Exception:
|
||
return None
|
||
|
||
def _scrape_playwright(self, url: str) -> Optional[Any]:
|
||
"""Playwright JS 渲染爬取"""
|
||
try:
|
||
with sync_playwright() as p:
|
||
browser = p.chromium.launch(headless=True)
|
||
context = browser.new_context(
|
||
user_agent=random_ua(mobile=False)
|
||
)
|
||
page = context.new_page()
|
||
page.goto(url, wait_until="domcontentloaded", timeout=self.timeout * 1000)
|
||
html = page.content()
|
||
browser.close()
|
||
if html:
|
||
return _CachedPage(html)
|
||
except Exception:
|
||
pass
|
||
return None
|
||
|
||
def scrape_url_with_retry(self, url: str, use_dynamic: bool = False) -> Optional[Any]:
|
||
"""
|
||
带自动重试的爬取方法(v3.0: 内部调用六级降级链)
|
||
|
||
Args:
|
||
url: 目标 URL
|
||
use_dynamic: 是否使用动态渲染
|
||
|
||
Returns:
|
||
爬取结果,失败返回 None
|
||
"""
|
||
last_error = None
|
||
|
||
for attempt in range(self.max_retries):
|
||
try:
|
||
result = self.scrape_url(url, use_dynamic=use_dynamic)
|
||
if result is not None:
|
||
# 成功,清除失败记录
|
||
self._remove_from_failed(url)
|
||
return result
|
||
except Exception as e:
|
||
last_error = e
|
||
user_msg, hint, error_type, solutions = self._helpers.classify_error(e, "爬取")
|
||
|
||
if attempt < self.max_retries - 1:
|
||
wait_time = (attempt + 1) * 2 # 递增等待:2s, 4s, 6s...
|
||
print(f"⚠️ 第 {attempt + 1} 次尝试失败: {user_msg}")
|
||
print(f" {hint}")
|
||
print(f" {wait_time} 秒后重试...")
|
||
time.sleep(wait_time)
|
||
else:
|
||
print(f"❌ {self.max_retries} 次尝试均失败: {user_msg}")
|
||
print(f" {hint}")
|
||
if solutions:
|
||
print(f" 建议操作: {', '.join(solutions[:3])}")
|
||
self._add_to_failed(url, str(e))
|
||
|
||
# 所有重试失败后,尝试使用过期缓存降级
|
||
expired = self._cache_get(url, allow_expired=True)
|
||
if expired:
|
||
print(f"⚠️ 网络请求失败,使用过期缓存数据: {url[:80]}")
|
||
return _CachedPage(expired)
|
||
|
||
return None
|
||
|
||
def _add_to_failed(self, url: str, error: str):
|
||
"""记录失败的 URL"""
|
||
self._failed_urls.append({
|
||
"url": url,
|
||
"error": error,
|
||
"time": time.strftime("%Y-%m-%d %H:%M:%S")
|
||
})
|
||
self._save_progress()
|
||
|
||
def _remove_from_failed(self, url: str):
|
||
"""从失败列表中移除"""
|
||
self._failed_urls = [f for f in self._failed_urls if f["url"] != url]
|
||
self._save_progress()
|
||
|
||
def _save_progress(self):
|
||
"""保存进度到文件"""
|
||
if self.auto_recovery and self._failed_urls:
|
||
try:
|
||
with open(self._progress_file, 'w', encoding='utf-8') as f:
|
||
json.dump({
|
||
"failed_urls": self._failed_urls,
|
||
"last_update": time.strftime("%Y-%m-%d %H:%M:%S")
|
||
}, f, ensure_ascii=False, indent=2)
|
||
except Exception as e:
|
||
print(f"⚠️ 保存进度失败: {e}")
|
||
|
||
def load_progress(self) -> List[str]:
|
||
"""加载未完成的 URL 列表"""
|
||
if self._progress_file.exists():
|
||
try:
|
||
with open(self._progress_file, 'r', encoding='utf-8') as f:
|
||
data = json.load(f)
|
||
return [item["url"] for item in data.get("failed_urls", [])]
|
||
except Exception:
|
||
pass
|
||
return []
|
||
|
||
def retry_failed(self) -> Dict[str, Any]:
|
||
"""
|
||
重试所有失败的 URL
|
||
|
||
Returns:
|
||
重试结果统计
|
||
"""
|
||
failed_urls = self.load_progress()
|
||
if not failed_urls:
|
||
print("✅ 没有需要重试的 URL")
|
||
return {"retried": 0, "success": 0, "failed": 0}
|
||
|
||
print(f"🔄 重试 {len(failed_urls)} 个失败的 URL...")
|
||
results = {"retried": len(failed_urls), "success": 0, "failed": 0}
|
||
|
||
for url in failed_urls:
|
||
result = self.scrape_url_with_retry(url)
|
||
if result:
|
||
results["success"] += 1
|
||
print(f" ✅ {url}")
|
||
else:
|
||
results["failed"] += 1
|
||
print(f" ❌ {url}")
|
||
|
||
print(f"\n📊 重试结果: {results['success']}/{results['retried']} 成功")
|
||
return results
|
||
|
||
def clear_progress(self):
|
||
"""清除进度文件"""
|
||
if self._progress_file.exists():
|
||
self._progress_file.unlink(missing_ok=True)
|
||
self._failed_urls.clear()
|
||
print("✅ 已清除进度文件")
|
||
|
||
def extract_fund_info(self, page: Selector, url: str) -> Dict[str, Any]:
|
||
"""
|
||
从天天基金页面提取基金信息
|
||
|
||
Args:
|
||
page: Selector对象
|
||
url: 原始URL
|
||
|
||
Returns:
|
||
基金信息字典
|
||
"""
|
||
result = {
|
||
"source_url": url,
|
||
"scrape_time": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||
"product_name": "",
|
||
"product_code": "",
|
||
"product_type": "",
|
||
"company": "",
|
||
"manager": "",
|
||
"risk_level": "",
|
||
"nav": {},
|
||
"historical_nav": {},
|
||
"holdings": {"stocks": [], "top_industry": ""},
|
||
"risk_metrics": {},
|
||
"fees": {}
|
||
}
|
||
|
||
try:
|
||
# 提取基金代码(从URL或页面)
|
||
code_match = re.search(r'fund\.eastmoney\.com/(\d{6})', url)
|
||
if code_match:
|
||
result["product_code"] = code_match.group(1)
|
||
|
||
# 尝试多种选择器
|
||
selectors_to_try = [
|
||
# 基金名称
|
||
['.fundName', '.fund-title', '.fundname', '[class*="fundName"]', 'h1'],
|
||
# 基金代码
|
||
['.fundCode', '.fundcode', '[class*="code"]', '.title .code'],
|
||
# 基金类型
|
||
['.fundType', '.type', '[class*="type"]'],
|
||
# 基金经理
|
||
['.manager a', '.基金经理', '[class*="manager"] a', '.fundManager a'],
|
||
]
|
||
|
||
# 提取文本内容
|
||
for sel_list in selectors_to_try:
|
||
for sel in sel_list:
|
||
try:
|
||
elements = page.css(sel)
|
||
if elements and len(elements) > 0:
|
||
text = elements[0].text().strip()
|
||
if text:
|
||
if 'name' in str(sel).lower() or 'title' in str(sel).lower():
|
||
result["product_name"] = text
|
||
elif 'code' in str(sel).lower():
|
||
result["product_code"] = text
|
||
elif 'type' in str(sel).lower():
|
||
result["product_type"] = text
|
||
elif 'manager' in str(sel).lower():
|
||
result["manager"] = text
|
||
break
|
||
except:
|
||
continue
|
||
|
||
# 提取净值数据
|
||
nav_selectors = ['.nav', '.NAV', '[class*="nav"]', '.data .nav']
|
||
for sel in nav_selectors:
|
||
try:
|
||
elements = page.css(sel)
|
||
if elements:
|
||
text = elements[0].text()
|
||
# 尝试匹配净值数字
|
||
nav_match = re.search(r'(\d+\.\d+)', text)
|
||
if nav_match:
|
||
result["nav"]["current"] = float(nav_match.group(1))
|
||
break
|
||
except:
|
||
continue
|
||
|
||
except Exception as e:
|
||
print(f"[警告] 解析过程出错: {e}")
|
||
|
||
return result
|
||
|
||
def extract_stock_info(self, page: Selector, url: str) -> Dict[str, Any]:
|
||
"""提取股票信息"""
|
||
result = {
|
||
"source_url": url,
|
||
"scrape_time": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||
"stock_name": "",
|
||
"stock_code": "",
|
||
"price": {},
|
||
"indicators": {},
|
||
"financials": {}
|
||
}
|
||
|
||
try:
|
||
# 股票代码
|
||
code_match = re.search(r'(?:s[hz]|sh|sz|szse)\.(\d{6})', url.lower())
|
||
if code_match:
|
||
result["stock_code"] = code_match.group(1)
|
||
|
||
# 股票名称
|
||
name_sel = page.css('h1, .stock-name, [class*="name"]')
|
||
if name_sel:
|
||
result["stock_name"] = name_sel[0].text().strip()
|
||
|
||
# 价格
|
||
price_sel = page.css('.price, .current, [class*="price"]')
|
||
if price_sel:
|
||
price_text = price_sel[0].text()
|
||
price_match = re.search(r'(\d+\.\d+)', price_text)
|
||
if price_match:
|
||
result["price"]["current"] = float(price_match.group(1))
|
||
|
||
except Exception as e:
|
||
print(f"[警告] 股票解析出错: {e}")
|
||
|
||
return result
|
||
|
||
|
||
def scrape_financial_product(url: str, product_type: str = "auto", use_retry: bool = True) -> Dict[str, Any]:
|
||
"""
|
||
爬取金融产品信息
|
||
|
||
Args:
|
||
url: 产品URL
|
||
product_type: 产品类型 ("fund", "stock", "auto")
|
||
use_retry: 是否使用自动重试(默认 True)
|
||
|
||
Returns:
|
||
产品信息字典
|
||
"""
|
||
scraper = FinancialPageScraper()
|
||
|
||
# 自动检测类型
|
||
if product_type == "auto":
|
||
url_lower = url.lower()
|
||
if 'eastmoney.com/fund' in url_lower or 'fund.eastmoney' in url_lower or 'fundf10' in url_lower:
|
||
product_type = "fund"
|
||
elif 'eastmoney.com/stock' in url_lower or 'stock.eastmoney' in url_lower:
|
||
product_type = "stock"
|
||
elif 'xueqiu.com' in url_lower:
|
||
product_type = "stock"
|
||
elif '10jqka.com.cn' in url_lower:
|
||
product_type = "stock"
|
||
elif 'danjuanapp' in url_lower or '蛋卷' in url:
|
||
product_type = "fund"
|
||
else:
|
||
product_type = "fund" # 默认
|
||
|
||
# 决定是否使用动态渲染
|
||
use_dynamic = '10jqka.com.cn' in url.lower() or 'tonghuashun' in url.lower() or 'xueqiu.com' in url.lower()
|
||
|
||
# 使用带重试的爬取方法
|
||
if use_retry:
|
||
page = scraper.scrape_url_with_retry(url, use_dynamic=use_dynamic)
|
||
else:
|
||
page = scraper.scrape_url(url, use_dynamic=use_dynamic)
|
||
|
||
if page is None:
|
||
return {
|
||
"error": "爬取失败",
|
||
"hint": "请检查:1) 网络连接 2) URL是否正确 3) 目标网站是否可访问",
|
||
"solutions": ["检查网络连接", "确认 URL 是否正确", "稍后重试", "使用动态渲染模式"]
|
||
}
|
||
|
||
# 根据类型提取
|
||
if product_type == "fund":
|
||
return scraper.extract_fund_info(page, url)
|
||
elif product_type == "stock":
|
||
return scraper.extract_stock_info(page, url)
|
||
else:
|
||
return {"error": f"未知产品类型: {product_type}", "hint": "支持类型: fund, stock"}
|
||
|
||
|
||
def save_to_cache(product_info: Dict[str, Any], cache_file: str = "scraped_products.json"):
|
||
"""保存到本地缓存"""
|
||
cache_path = SKILL_DATA_DIR / cache_file
|
||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||
|
||
# 读取现有缓存
|
||
existing = []
|
||
if cache_path.exists():
|
||
try:
|
||
with open(cache_path, 'r', encoding='utf-8') as f:
|
||
existing = json.load(f)
|
||
except:
|
||
existing = []
|
||
|
||
# 更新或添加
|
||
code = product_info.get("product_code") or product_info.get("stock_code", "")
|
||
if code:
|
||
# 去除已存在的同代码记录
|
||
existing = [x for x in existing if x.get("product_code") != code and x.get("stock_code") != code]
|
||
existing.insert(0, product_info)
|
||
|
||
# 写入
|
||
with open(cache_path, 'w', encoding='utf-8') as f:
|
||
json.dump(existing, f, ensure_ascii=False, indent=2)
|
||
|
||
return cache_path
|
||
|
||
|
||
# CLI入口
|
||
if __name__ == "__main__":
|
||
import sys
|
||
|
||
if len(sys.argv) < 2:
|
||
print("用法: python scraper.py <URL> [--retry-failed]")
|
||
print("示例: python scraper.py https://fund.eastmoney.com/000001.html")
|
||
print("重试失败: python scraper.py --retry-failed")
|
||
sys.exit(1)
|
||
|
||
# 重试失败的 URL
|
||
if sys.argv[1] == "--retry-failed":
|
||
scraper = FinancialPageScraper()
|
||
results = scraper.retry_failed()
|
||
sys.exit(0 if results["failed"] == 0 else 1)
|
||
|
||
url = sys.argv[1]
|
||
print(f"正在爬取: {url}")
|
||
|
||
result = scrape_financial_product(url)
|
||
print("\n结果:")
|
||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||
|
||
# 保存到缓存
|
||
if "error" not in result:
|
||
save_path = save_to_cache(result)
|
||
print(f"\n已缓存到: {save_path}") |