# -*- coding: utf-8 -*-
"""
网页操作与金融产品解析模块 v3.0
支持动态网页交互、元素操作、金融产品深度解析
v3.0 增强:
- 统一入口六级降级链:Playwright → Scrapling(隐身) → Scrapling(移动) → http_utils(桌面) → http_utils(移动) → API 备用端点
- JSON-LD 结构化数据提取作为解析 fallback
- 自适应选择器匹配:自动学习每个网站的有效选择器,缓存至本地
- 页面对比与差异检测
"""
from __future__ import annotations
import json
import re
import time
from pathlib import Path
from typing import Dict, Any, List, Optional, Union, Callable, TYPE_CHECKING
from dataclasses import dataclass, field
# Scrapling导入
try:
from scrapling.fetchers import StealthyFetcher, DynamicFetcher
from scrapling.parser import Selector, Element
SCRAPLING_AVAILABLE = True
except ImportError:
SCRAPLING_AVAILABLE = False
if TYPE_CHECKING:
Selector = Any # type: ignore
Element = Any # type: ignore
SCRAPLING_AVAILABLE = False
# Playwright导入(用于复杂交互)
try:
from playwright.sync_api import sync_playwright, Page, Locator
PLAYWRIGHT_AVAILABLE = True
except ImportError:
PLAYWRIGHT_AVAILABLE = False
SKILL_DATA_DIR = Path(__file__).parent.parent / "data"
# http_utils 公共基础设施(v4.0)
try:
from .http_utils import http_get, fetch_text, get_session, random_ua
from .http_utils import report_request_result
HAS_HTTP_UTILS = True
except ImportError:
HAS_HTTP_UTILS = False
# ==================== JSON-LD 结构化数据提取(v3.0) ====================
_RE_JSONLD = re.compile(
r'',
re.DOTALL | re.IGNORECASE
)
def extract_jsonld(html: str) -> List[Dict[str, Any]]:
"""从 HTML 提取 JSON-LD 结构化数据(所有金融网站的通用 fallback)"""
results = []
for match in _RE_JSONLD.finditer(html):
try:
data = json.loads(match.group(1))
if isinstance(data, list):
results.extend(data)
else:
results.append(data)
except (json.JSONDecodeError, ValueError):
pass
return results
def extract_product_from_jsonld(jsonld_list: List[Dict]) -> Dict[str, Any]:
"""从 JSON-LD 数据提取金融产品 / 文章信息"""
result = {}
for item in jsonld_list:
item_type = item.get("@type", "")
if isinstance(item_type, list):
item_type = item_type[0] if item_type else ""
item_type = str(item_type)
# 通用 Product 类型
if "Product" in item_type or "FinancialProduct" in item_type:
result["product_name"] = item.get("name", "")
result["product_code"] = item.get("sku", "") or item.get("productID", "")
offers = item.get("offers", {})
if isinstance(offers, dict):
result["price"] = offers.get("price", "")
result["description"] = item.get("description", "")[:500]
# Organization(公司信息)
elif "Organization" in item_type or "Corporation" in item_type:
result["company"] = item.get("name", "")
result["company_url"] = item.get("url", "")
result["legal_name"] = item.get("legalName", "")
# WebPage(页面元数据)
elif "WebPage" in item_type:
result["page_name"] = item.get("name", "")
result["page_description"] = item.get("description", "")[:300]
# NewsArticle / Article / BlogPosting(v6.0.0 新增:新闻正文结构化提取)
elif any(t in item_type for t in ("NewsArticle", "Article", "BlogPosting")):
result["article_title"] = item.get("headline", "") or item.get("name", "")
result["article_date"] = (item.get("datePublished", "") or "")[:19]
result["article_author"] = ""
author = item.get("author", {})
if isinstance(author, dict):
result["article_author"] = author.get("name", "")
elif isinstance(author, list) and author and isinstance(author[0], dict):
result["article_author"] = author[0].get("name", "")
result["article_image"] = ""
image = item.get("image", {})
if isinstance(image, dict):
result["article_image"] = image.get("url", "")
elif isinstance(image, str):
result["article_image"] = image
result["article_body"] = (item.get("articleBody", "") or "")[:2000]
return result
def extract_main_content(html: str, min_text_len: int = 60) -> Dict[str, str]:
"""readability 风格正文抽取(v6.0.0 新增,零依赖文本密度打分)。
通过遍历候选块级容器,按「正文文本密度 = 文本长度 / (文本+链接文本)」打分,
选出最可能的正文节点。适合新闻/公告/研报类页面的正文提取。
Args:
html: 页面 HTML
min_text_len: 候选正文最小文本长度
Returns:
{"title": str, "content": str, "text_len": int}
抽取失败时 content 为空字符串。
"""
result = {"title": "", "content": "", "text_len": 0}
if not html:
return result
# 标题优先从
/ 提取
m = re.search(r"]*>(.*?)", html, re.DOTALL | re.IGNORECASE)
if m:
result["title"] = m.group(1).strip()
if not result["title"]:
m = re.search(r"]*>(.*?)
", html, re.DOTALL | re.IGNORECASE)
if m:
result["title"] = re.sub(r"<[^>]+>", "", m.group(1)).strip()
# 候选正文容器(按常见语义标签优先排序)
candidates = []
try:
from bs4 import BeautifulSoup as _BS4
_has_bs4 = True
except ImportError:
_has_bs4 = False
try:
if _has_bs4:
soup = _BS4(html, "html.parser")
for tag in soup(["script", "style", "nav", "footer", "header", "aside",
"form", "iframe", "noscript"]):
tag.decompose()
for node in soup.find_all(["article", "main", "section", "div"]):
text = node.get_text(" ", strip=True)
if len(text) < min_text_len:
continue
link_text = sum(len(a.get_text(" ", strip=True)) for a in node.find_all("a"))
total = len(text)
density = (total - link_text) / max(total, 1)
# 正文节点特征:文本密度高 + 段落数量多
para_count = len(node.find_all("p"))
score = density * (1 + min(para_count, 10) * 0.15)
candidates.append((score, text, node.name, para_count))
else:
# 无 bs4 时的正则 fallback:按标签剥文本
m = re.search(r"]*>(.*?)", html, re.DOTALL | re.IGNORECASE)
if m:
content = re.sub(r"<[^>]+>", " ", m.group(1))
candidates.append((1.0, re.sub(r"\s{2,}", " ", content).strip(), "article", 1))
except Exception:
return result
if not candidates:
return result
# 按分数排序取最优
candidates.sort(key=lambda c: c[0], reverse=True)
best_score, best_text, best_tag, best_paras = candidates[0]
best_text = re.sub(r"\s{2,}", " ", best_text).strip()
result["content"] = best_text[:20000]
result["text_len"] = len(best_text)
return result
# ==================== 自适应选择器匹配(v3.0) ====================
_SELECTOR_CACHE_DIR = SKILL_DATA_DIR / "selector_cache"
_SELECTOR_CACHE_DIR.mkdir(exist_ok=True, parents=True)
_SELECTOR_CACHE_TTL = 7 * 86400 # 7 天过期
def _load_selector_cache(domain: str) -> Dict[str, List[str]]:
"""加载某域名的选择器缓存"""
cache_file = _SELECTOR_CACHE_DIR / f"{domain.replace('.', '_')}.json"
if cache_file.exists():
try:
data = json.loads(cache_file.read_text(encoding="utf-8"))
age = time.time() - data.get("updated", 0)
if age < _SELECTOR_CACHE_TTL:
return data.get("selectors", {})
except Exception:
pass
return {}
def _save_selector_cache(domain: str, selectors: Dict[str, List[str]]):
"""保存选择器缓存(v7.0.0: 目录不存在时自动创建)"""
cache_file = _SELECTOR_CACHE_DIR / f"{domain.replace('.', '_')}.json"
try:
_SELECTOR_CACHE_DIR.mkdir(parents=True, exist_ok=True)
data = {
"domain": domain,
"selectors": selectors,
"updated": time.time()
}
cache_file.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
except Exception:
pass
class AdaptiveSelectorMatcher:
"""自适应选择器匹配器
对每个目标字段维护候选选择器列表 + 最近成功的选择器。
自动学习每个网站当前有效的选择器,缓存至本地 JSON。
"""
def __init__(self, domain: str, selectors: Optional[Dict[str, List[str]]] = None):
self.domain = domain
# 优先用缓存,其次用传入的选择器
cached = _load_selector_cache(domain)
self._selectors: Dict[str, List[str]] = {}
if selectors:
self._selectors.update(selectors)
if cached:
# 缓存的选择器放在前面优先尝试
for field, sels in cached.items():
existing = self._selectors.get(field, [])
self._selectors[field] = sels + [s for s in existing if s not in sels]
self._successful: Dict[str, str] = {} # field → 最近成功的 selector
self._failures: Dict[str, Dict[str, int]] = {} # field → {selector: 连续失败次数}
def css_first(self, page: Any, field: str,
selectors: Optional[List[str]] = None) -> Optional[Any]:
"""尝试多个选择器,返回第一个匹配的元素
Args:
page: Selector 对象
field: 字段名
selectors: 覆盖的选择器列表
Returns:
匹配的 Element 或 None
"""
sels = selectors or self._selectors.get(field, [])
if not sels:
return None
# 优先尝试上次成功的
last_success = self._successful.get(field)
if last_success:
sels = [last_success] + [s for s in sels if s != last_success]
field_failures = self._failures.setdefault(field, {})
for sel in sels:
try:
el = page.css_first(sel)
if el:
self._successful[field] = sel
field_failures.pop(sel, None)
return el
else:
# v6.0.0:缓存选择器连续失败 → 置信度衰减,重学新选择器
field_failures[sel] = field_failures.get(sel, 0) + 1
except Exception:
field_failures[sel] = field_failures.get(sel, 0) + 1
continue
return None
def css_first_text(self, page: Any, field: str,
selectors: Optional[List[str]] = None,
default: str = "") -> str:
"""安全获取第一个匹配的文本"""
el = self.css_first(page, field, selectors)
if el:
try:
text = el.text()
if text:
return text.strip()
except Exception:
pass
return default
def flush(self):
"""持久化当前成功的选择器到缓存(v6.0.0:连续失败 ≥3 次的选择器淘汰重学)"""
if self._successful:
to_save = {}
for field, sel in self._successful.items():
all_sels = self._selectors.get(field, [])
field_failures = self._failures.get(field, {})
# 把成功的放在第一位;淘汰连续失败 ≥3 次的过时选择器
fresh = [s for s in all_sels
if s != sel and field_failures.get(s, 0) < 3]
to_save[field] = [sel] + fresh[:4]
_save_selector_cache(self.domain, to_save)
# ==================== 预编译正则(性能优化,避免每次解析重复编译) ====================
# 金融数字提取
_RE_NUMBER = re.compile(r'(\d+\.?\d*)')
_RE_SIGNED_NUMBER = re.compile(r'([+-]?\d+\.?\d*)')
_RE_PERCENTAGE = re.compile(r'(\d+\.?\d*)\s*%')
_RE_FUND_CODE = re.compile(r'(\d{6})')
_RE_STOCK_CODE = re.compile(r'(?:s[hz]|sh|sz)\s*\.?\s*(\d{6})', re.IGNORECASE)
_RE_DATE = re.compile(r'(\d{4}-\d{2}-\d{2})')
# 金融指标提取
_RE_PE = re.compile(r'市盈率[((]?PE[))]?\s*[::]\s*(\d+\.?\d*)')
_RE_PB = re.compile(r'市净率[((]?PB[))]?\s*[::]\s*(\d+\.?\d*)')
_RE_SHARPE = re.compile(r'夏普比率[((]?Sharpe[))]?\s*[::]\s*([+-]?\d+\.?\d*)')
_RE_MAXDD = re.compile(r'最大回撤\s*[::]\s*([+-]?\d+\.?\d*)\s*%?')
_RE_VOLATILITY = re.compile(r'波动率\s*[::]\s*(\d+\.?\d*)\s*%?')
# 基金指标
_RE_FUND_NAV = re.compile(r'(?:单位)?净值[::]\s*(\d+\.\d+)')
_RE_FUND_ACCU_NAV = re.compile(r'累计净值[::]\s*(\d+\.\d+)')
_RE_FUND_SCALE = re.compile(r'基金规模[::]\s*([\d.]+)\s*亿')
_RE_FUND_MANAGER = re.compile(r'基金经理[::]\s*([^\s,,]+)')
_RE_FUND_COMPANY = re.compile(r'基金公司[::]\s*([^\s,,]+)')
_RE_FUND_ESTABLISH = re.compile(r'成立日期[::]\s*(\d{4}-\d{2}-\d{2})')
# 收益提取
_RE_RETURN_PATTERNS = [
(re.compile(r'近1月[收益收益率]*[::]\s*([+-]?\d+\.?\d*)\s*%?'), 'return_1m'),
(re.compile(r'近3月[收益收益率]*[::]\s*([+-]?\d+\.?\d*)\s*%?'), 'return_3m'),
(re.compile(r'近6月[收益收益率]*[::]\s*([+-]?\d+\.?\d*)\s*%?'), 'return_6m'),
(re.compile(r'近1年[收益收益率]*[::]\s*([+-]?\d+\.?\d*)\s*%?'), 'return_1y'),
(re.compile(r'近3年[收益收益率]*[::]\s*([+-]?\d+\.?\d*)\s*%?'), 'return_3y'),
(re.compile(r'今年来[收益收益率]*[::]\s*([+-]?\d+\.?\d*)\s*%?'), 'return_ytd'),
(re.compile(r'成立来[收益收益率]*[::]\s*([+-]?\d+\.?\d*)\s*%?'), 'return_since_inception'),
]
# HTML 标签清理
_RE_HTML_TAG = re.compile(r'<[^>]+>')
_RE_HTML_SCRIPT = re.compile(r'', re.DOTALL | re.IGNORECASE)
_RE_HTML_STYLE = re.compile(r'', re.DOTALL | re.IGNORECASE)
_RE_MULTI_SPACE = re.compile(r'\s+')
# ==================== 解析工具函数 ====================
def _safe_css_first(page, selectors, default=""):
"""安全获取第一个匹配的 CSS 选择器文本"""
if not hasattr(page, 'css_first'):
return default
for sel in ([selectors] if isinstance(selectors, str) else selectors):
try:
el = page.css_first(sel)
if el and el.text():
return el.text().strip()
except Exception:
continue
return default
def _safe_re_search(page, pattern, group=1, default=None):
"""安全执行正则搜索,提取分组"""
try:
if hasattr(page, 're_search'):
match = page.re_search(pattern)
else:
match = re.search(pattern, str(page))
if match:
if group == 0:
return match.group(0)
return match.group(group) if match.groups() else match.group(0)
except Exception:
pass
return default
def _clean_html(html: str) -> str:
"""清理 HTML 标签,提取纯文本"""
text = _RE_HTML_SCRIPT.sub(' ', html)
text = _RE_HTML_STYLE.sub(' ', text)
text = _RE_HTML_TAG.sub(' ', text)
text = _RE_MULTI_SPACE.sub(' ', text)
return text.strip()
def _parse_percentage(text: str) -> float:
"""从文本解析百分比值"""
match = _RE_SIGNED_NUMBER.search(text)
return float(match.group(1)) if match else 0.0
def _match_first(text: str, patterns: list) -> str:
"""用多个正则模式匹配文本,返回第一个成功的"""
for pattern in patterns:
match = re.search(pattern, text) if isinstance(pattern, str) else pattern.search(text)
if match:
return match.group(1) if match.groups() else match.group(0)
return ""
@dataclass
class WebOperation:
"""网页操作描述"""
op_type: str # click, scroll, wait, fill, select, hover
selector: str
value: Optional[str] = None
timeout: int = 10000
wait_for: Optional[str] = None # networkidle, domcontentloaded, load
class PageOperator:
"""页面操作器 - 支持复杂网页交互"""
def __init__(self, headless: bool = True):
self.headless = headless
self.playwright = None
self.browser = None
self.context = None
self.page = None
self._initialized = False
def _ensure_playwright(self):
"""确保Playwright可用"""
if not PLAYWRIGHT_AVAILABLE:
raise ImportError("Playwright未安装,请运行: pip install playwright && playwright install")
def start(self):
"""启动浏览器。
v5.0.0: 优先复用 browser_scraper 进程级单例的浏览器/上下文(避免每请求冷启动);
复用失败时回退到独立启动。"""
self._ensure_playwright()
if self.browser is None:
# 尝试复用单例(共享模式下 self.playwright 保持 None,stop 不关闭共享资源)
try:
from browser_scraper import _get_browser_scraper
bs = _get_browser_scraper(headless=self.headless)
if bs is not None and bs._browser is not None:
self.browser = bs._browser
self.context = bs._context
self.page = self.context.new_page()
self._initialized = True
return self
except Exception:
pass
# 回退:独立启动
self.playwright = sync_playwright().start()
self.browser = self.playwright.chromium.launch(headless=self.headless)
self.context = self.browser.new_context(
user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
)
self.page = self.context.new_page()
self._initialized = True
return self
def stop(self):
"""关闭页面。v5.0.0: 共享单例时只关自己的 page,不关闭共享浏览器/上下文。"""
if self.page:
try:
self.page.close()
except Exception:
pass
self.page = None
if self.playwright is not None:
# 独立启动的才真正关闭整链
if self.context:
try:
self.context.close()
except Exception:
pass
if self.browser:
try:
self.browser.close()
except Exception:
pass
try:
self.playwright.stop()
except Exception:
pass
self.playwright = None
self.browser = None
self.context = None
self._initialized = False
def __enter__(self):
return self.start()
def __exit__(self, *args):
self.stop()
def goto(self, url: str, wait: str = "networkidle") -> 'PageOperator':
"""导航到URL"""
if not self._initialized:
self.start()
self.page.goto(url, wait_until=wait, timeout=30000)
return self
def click(self, selector: str, timeout: int = 10000) -> 'PageOperator':
"""点击元素"""
self.page.click(selector, timeout=timeout)
time.sleep(0.5)
return self
def fill(self, selector: str, value: str) -> 'PageOperator':
"""填写表单"""
self.page.fill(selector, value)
return self
def select(self, selector: str, value: str) -> 'PageOperator':
"""选择下拉选项"""
self.page.select_option(selector, value)
return self
def hover(self, selector: str) -> 'PageOperator':
"""悬停"""
self.page.hover(selector)
return self
def scroll(self, direction: str = "down", amount: int = 500) -> 'PageOperator':
"""滚动页面"""
if direction == "down":
self.page.evaluate(f"window.scrollBy(0, {amount})")
else:
self.page.evaluate(f"window.scrollBy(0, -{amount})")
time.sleep(0.3)
return self
def wait(self, seconds: float = 1) -> 'PageOperator':
"""等待"""
time.sleep(seconds)
return self
def wait_for_selector(self, selector: str, timeout: int = 10000) -> 'PageOperator':
"""等待元素出现"""
self.page.wait_for_selector(selector, timeout=timeout)
return self
def wait_for_navigation(self, timeout: int = 30000) -> 'PageOperator':
"""等待导航完成"""
self.page.wait_for_load_state("networkidle", timeout=timeout)
return self
def execute_script(self, script: str) -> Any:
"""执行JavaScript"""
return self.page.evaluate(script)
def get_html(self) -> str:
"""获取页面HTML"""
return self.page.content()
def get_text(self, selector: str) -> str:
"""获取元素文本"""
return self.page.locator(selector).text_content() or ""
def get_attribute(self, selector: str, attr: str) -> str:
"""获取元素属性"""
return self.page.locator(selector).get_attribute(attr) or ""
def screenshot(self, path: str):
"""截图"""
self.page.screenshot(path=path)
# ── v4.5 类人翻页 / 链接提取 / 链接跟随 ─────────────────
def paginate(self, next_selector: str, max_pages: int = 10,
wait_after_click: float = 1.0,
total_timeout: float = 60.0,
until_selector: str = None) -> List[Dict[str, Any]]:
"""类人翻页:点 next 翻页,累积每页 HTML。
停止条件:
1. 连续 2 轮新页面相同(去时间戳后 hash 比较)
2. max_pages 到顶
3. next_selector 不存在
4. until_selector 出现
5. 累计超过 total_timeout
Returns:
[{"url", "html", "page_num"}]
"""
import hashlib as _hashlib
import time as _time
start = _time.time()
pages: List[Dict[str, Any]] = []
last_hash: Optional[str] = None
no_change_count = 0
for page_num in range(1, max_pages + 1):
if (_time.time() - start) > total_timeout:
break
if until_selector:
try:
if self.page.query_selector(until_selector):
break
except Exception:
pass
try:
self.wait(0.5)
html = self.get_html()
# 简单 hash
stripped = _strip_dynamic(html)
h = _hashlib.md5(stripped.encode("utf-8", "ignore")).hexdigest()
pages.append({"url": self.page.url, "html": html, "page_num": page_num})
if h == last_hash:
no_change_count += 1
if no_change_count >= 2:
break
else:
no_change_count = 0
last_hash = h
except Exception:
break
if page_num >= max_pages:
break
# 找 next
try:
next_btn = self.page.query_selector(next_selector)
except Exception:
next_btn = None
if not next_btn:
break
try:
next_btn.click(timeout=5000)
self.wait(wait_after_click)
except Exception:
break
return pages
def extract_all_links(self, selector: str = "a[href]",
skip_javascript: bool = True) -> List[Dict[str, str]]:
"""提取所有链接。
Returns:
[{"href", "text", "type": "internal"|"external", "domain"}]
"""
from urllib.parse import urljoin, urlparse
base = self.page.url
base_domain = urlparse(base).netloc
results: List[Dict[str, str]] = []
# Playwright 直接用 query_selector_all 更稳定
try:
els = self.page.query_selector_all(selector)
for el in els:
try:
href = el.get_attribute("href") or ""
text = (el.text_content() or "").strip()[:100]
if not href:
continue
if skip_javascript and href.startswith("javascript:"):
continue
if href.startswith("mailto:") or href.startswith("#"):
continue
full = urljoin(base, href)
dom = urlparse(full).netloc
link_type = "internal" if dom == base_domain else "external"
results.append({"href": full, "text": text,
"type": link_type, "domain": dom})
except Exception:
continue
except Exception:
pass
return results
def follow_links(self, link_selector: str,
content_selector: Optional[str] = None,
max_links: int = 20,
wait_after_goto: float = 1.0) -> List[Dict[str, Any]]:
"""列表页 → 逐条进入详情页 → 返回正文列表。"""
results: List[Dict[str, Any]] = []
try:
hrefs = self.page.eval_on_selector_all(
link_selector,
"els => els.map(e => e.href).filter(h => h && !h.startsWith('javascript:'))"
)
except Exception:
return results
for href in hrefs[:max_links]:
try:
self.goto(href)
self.wait(wait_after_goto)
html = self.get_html()
text = ""
title = ""
if content_selector:
el = self.page.query_selector(content_selector)
if el:
text = (el.text_content() or "").strip()
else:
try:
text = self.get_text("body") or ""
except Exception:
text = ""
try:
title_el = self.page.query_selector("title")
if title_el:
title = (title_el.text_content() or "").strip()[:200]
except Exception:
pass
results.append({"url": href, "html": html,
"text": text, "title": title})
except Exception:
continue
return results
def run_operations(self, operations: List[WebOperation]) -> 'PageOperator':
"""批量执行操作"""
for op in operations:
if op.op_type == "click":
self.click(op.selector, op.timeout)
elif op.op_type == "fill":
self.fill(op.selector, op.value or "")
elif op.op_type == "scroll":
self.scroll("down", int(op.value or 500))
elif op.op_type == "wait":
self.wait(float(op.value or 1))
elif op.op_type == "hover":
self.hover(op.selector)
elif op.op_type == "select":
self.select(op.selector, op.value or "")
elif op.op_type == "goto":
self.goto(op.value or "")
elif op.op_type == "wait_for":
self.wait_for_selector(op.selector, op.timeout)
elif op.op_type == "paginate":
# 新增:批量翻页
self.paginate(op.selector, max_pages=int(op.value or 5))
if op.wait_for == "navigation":
self.wait_for_navigation()
return self
def _strip_dynamic(html: str) -> str:
"""去掉动态时间戳(辅助 paginate 停止判定)。"""
import re as _re
html = _re.sub(r"