2532 lines
93 KiB
Python
2532 lines
93 KiB
Python
# -*- 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'<script[^>]+type=["\']application/ld\+json["\'][^>]*>(.*?)</script>',
|
||
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
|
||
|
||
# 标题优先从 <title> / <h1> 提取
|
||
m = re.search(r"<title[^>]*>(.*?)</title>", html, re.DOTALL | re.IGNORECASE)
|
||
if m:
|
||
result["title"] = m.group(1).strip()
|
||
if not result["title"]:
|
||
m = re.search(r"<h1[^>]*>(.*?)</h1>", 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"<article[^>]*>(.*?)</article>", 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'<script[^>]*>.*?</script>', re.DOTALL | re.IGNORECASE)
|
||
_RE_HTML_STYLE = re.compile(r'<style[^>]*>.*?</style>', 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"<script[\s\S]*?</script>", "", html, flags=_re.IGNORECASE)
|
||
html = _re.sub(r"\b\d{10,13}\b", "TS", html)
|
||
html = _re.sub(r"\b\d{4}-\d{2}-\d{2}[T\s]\d{2}:\d{2}:\d{2}", "DT", html)
|
||
return html
|
||
|
||
|
||
class FundParser:
|
||
"""基金产品解析器"""
|
||
|
||
# 天天基金页面选择器
|
||
TiantianSelectors = {
|
||
"name": [".fundName", ".fund-title h1", "h1.fundName", "[class*='fund-name']"],
|
||
"code": [".fundCode", ".fundcode", "#fundCode", "[class*='fund-code']"],
|
||
"type": [".fundType", ".type", "#fundType", "[class*='fund-type']"],
|
||
"company": [".company", ".management", "[class*='company']"],
|
||
"manager": [".manager a", ".基金经理 a", "#manager a", "[class*='manager'] a"],
|
||
"nav": [".nav", "#nav", "[class*='nav']"],
|
||
"nav_change": [".navChange", ".nav_change", "[class*='change']"],
|
||
"accu_nav": [".accuNav", "#accuNav", "[class*='accu']"],
|
||
"establish_date": [".establishDate", "#establishDate", "[class*='establish']"],
|
||
"scale": [".scale", "#scale", "[class*='scale']"],
|
||
"risk_level": [".risk", "#risk", "[class*='risk']"],
|
||
}
|
||
|
||
# 东方财富选择器
|
||
EastmoneySelectors = {
|
||
"name": [".title", ".fund-name", "h1", "[class*='name']"],
|
||
"code": [".code", "#fundCode", "[class*='code']"],
|
||
"type": [".type", "[class*='type']"],
|
||
"nav": ["#nav", ".nav", "[class*='nav']"],
|
||
"features": ["[class*='feature']", ".tags", ".label"],
|
||
}
|
||
|
||
@staticmethod
|
||
def parse_tiantian_fund(page: Union[Selector, str], url: str = "") -> Dict[str, Any]:
|
||
"""解析天天基金页面"""
|
||
result = {
|
||
"source": "tiantian",
|
||
"product_name": "",
|
||
"product_code": "",
|
||
"product_type": "",
|
||
"company": "",
|
||
"manager": "",
|
||
"risk_level": "",
|
||
"nav": {},
|
||
"fees": {},
|
||
"holdings": {"stocks": [], "bonds": [], "top_industry": ""},
|
||
"historical_nav": {},
|
||
"risk_metrics": {},
|
||
"basic_info": {},
|
||
"raw_data": {}
|
||
}
|
||
|
||
try:
|
||
if isinstance(page, str):
|
||
# 如果是HTML字符串,用正则解析
|
||
return FundParser._parse_fund_html(page, result)
|
||
else:
|
||
# 使用Scrapling Selector解析
|
||
return FundParser._parse_fund_selector(page, result)
|
||
except Exception as e:
|
||
result["error"] = str(e)
|
||
|
||
return result
|
||
|
||
@staticmethod
|
||
def _parse_fund_selector(page: Selector, result: Dict) -> Dict[str, Any]:
|
||
"""使用Selector解析基金"""
|
||
# 尝试多个选择器
|
||
for sel in FundParser.TiantianSelectors.get("name", []):
|
||
try:
|
||
el = page.css_first(sel)
|
||
if el:
|
||
result["product_name"] = el.text().strip()
|
||
break
|
||
except:
|
||
continue
|
||
|
||
# 基金代码
|
||
for sel in FundParser.TiantianSelectors.get("code", []):
|
||
try:
|
||
el = page.css_first(sel)
|
||
if el:
|
||
result["product_code"] = el.text().strip()
|
||
break
|
||
except:
|
||
continue
|
||
|
||
# 净值信息
|
||
for sel in FundParser.TiantianSelectors.get("nav", []):
|
||
try:
|
||
el = page.css_first(sel)
|
||
if el:
|
||
text = el.text()
|
||
nav_match = re.search(r'(\d+\.\d+)', text)
|
||
if nav_match:
|
||
result["nav"]["current"] = float(nav_match.group(1))
|
||
break
|
||
except:
|
||
continue
|
||
|
||
# 提取基本信息块
|
||
info_patterns = [
|
||
(r'基金规模[::]\s*([\d.]+\s*亿元)', 'scale'),
|
||
(r'成立日期[::]\s*(\d{4}-\d{2}-\d{2})', 'establish_date'),
|
||
(r'基金经理[::]\s*([^\s,,]+)', 'manager'),
|
||
(r'基金公司[::]\s*([^\s,,]+)', 'company'),
|
||
]
|
||
|
||
full_text = page.re_search(r'[一-龥]+[::][^<>\n]+')
|
||
if full_text:
|
||
for pattern, key in info_patterns:
|
||
match = re.search(pattern, full_text)
|
||
if match:
|
||
result["basic_info"][key] = match.group(1).strip()
|
||
|
||
return result
|
||
|
||
@staticmethod
|
||
def _parse_fund_html(html: str, result: Dict) -> Dict[str, Any]:
|
||
"""使用正则解析HTML(v5.0.0 选择器容错:类名容忍大小写/前后缀/连字符)"""
|
||
# 基金名称:容忍 class="fundName" / "fund-name" / "xxx fundName xxx" 等
|
||
name_match = re.search(r'class="[^"]*fund[-_ ]?name[^"]*"[^>]*>([^<]+)', html, re.IGNORECASE)
|
||
if not name_match:
|
||
name_match = re.search(r'<h1[^>]*>([^<]+)', html)
|
||
if name_match:
|
||
result["product_name"] = name_match.group(1).strip()
|
||
|
||
# 基金代码
|
||
code_match = re.search(r'class="[^"]*fund[-_ ]?code[^"]*"[^>]*>([^<]+)', html, re.IGNORECASE)
|
||
if not code_match:
|
||
code_match = re.search(r'class="fundCode"[^>]*>([^<]+)', html)
|
||
if code_match:
|
||
result["product_code"] = code_match.group(1).strip()
|
||
|
||
# 净值(容忍类名前后缀)
|
||
nav_match = re.search(r'<td[^>]*class="[^"]*nav[^"]*"[^>]*>.*?(\d+\.\d+)', html, re.DOTALL)
|
||
if nav_match:
|
||
result["nav"]["current"] = float(nav_match.group(1))
|
||
|
||
return result
|
||
|
||
|
||
class ETFParser:
|
||
"""ETF产品解析器"""
|
||
|
||
@staticmethod
|
||
def parse_etf_page(page: Union[Selector, str], url: str = "") -> Dict[str, Any]:
|
||
"""解析ETF详情页面"""
|
||
result = {
|
||
"source": "etf",
|
||
"source_url": url,
|
||
"scrape_time": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||
"product_name": "",
|
||
"product_code": "",
|
||
"product_type": "ETF",
|
||
"exchange": "",
|
||
"跟踪指数": "",
|
||
"nav": {},
|
||
"premium": {}, # 溢价率、折价率
|
||
"holdings": {
|
||
"stocks": [],
|
||
"top_10_weight": 0
|
||
},
|
||
"tracking_index": "",
|
||
"fund_attributes": {},
|
||
"risk_metrics": {}
|
||
}
|
||
|
||
try:
|
||
if isinstance(page, Selector):
|
||
result = ETFParser._parse_etf_selector(page, result)
|
||
else:
|
||
result = ETFParser._parse_etf_html(page, result)
|
||
except Exception as e:
|
||
result["error"] = str(e)
|
||
|
||
return result
|
||
|
||
@staticmethod
|
||
def _parse_etf_selector(page: Selector, result: Dict) -> Dict[str, Any]:
|
||
"""Selector解析ETF"""
|
||
# ETF名称
|
||
for sel in ["h1", ".fund-name", "[class*='name']", ".title"]:
|
||
try:
|
||
el = page.css_first(sel)
|
||
if el:
|
||
result["product_name"] = el.text().strip()
|
||
break
|
||
except:
|
||
continue
|
||
|
||
# ETF代码
|
||
code_patterns = [
|
||
r'(\d{6})',
|
||
r'代码[::]\s*(\d{6})',
|
||
r'512[0-9]{3}', # 上交所ETF
|
||
r'159[0-9]{3}', # 深交所ETF
|
||
]
|
||
for pat in code_patterns:
|
||
match = page.re_search(pat)
|
||
if match:
|
||
result["product_code"] = match.group(1) if match.groups() else match.group(0)
|
||
break
|
||
|
||
# 交易所
|
||
if result["product_code"]:
|
||
if result["product_code"].startswith("5"):
|
||
result["exchange"] = "上交所"
|
||
elif result["product_code"].startswith("1") or result["product_code"].startswith("15"):
|
||
result["exchange"] = "深交所"
|
||
|
||
# 净值数据
|
||
nav_sel = page.re_search(r'单位净值[::]\s*(\d+\.\d+)')
|
||
if nav_sel:
|
||
result["nav"]["unit"] = float(nav_sel.group(1))
|
||
|
||
accu_nav_sel = page.re_search(r'累计净值[::]\s*(\d+\.\d+)')
|
||
if accu_nav_sel:
|
||
result["nav"]["accumulated"] = float(accu_nav_sel.group(1))
|
||
|
||
# 溢价率/折价率
|
||
premium_sel = page.re_search(r'溢价率[::]\s*([+-]?\d+\.?\d*)%')
|
||
if premium_sel:
|
||
result["premium"]["premium_rate"] = float(premium_sel.group(1))
|
||
|
||
discount_sel = page.re_search(r'折价率[::]\s*([+-]?\d+\.?\d*)%')
|
||
if discount_sel:
|
||
result["premium"]["discount_rate"] = float(discount_sel.group(1))
|
||
|
||
# 跟踪指数
|
||
index_sel = page.re_search(r'跟踪指数[::]\s*([^\s\n,,]+[^\n,,]*)')
|
||
if index_sel:
|
||
result["tracking_index"] = index_sel.group(1).strip()
|
||
|
||
return result
|
||
|
||
@staticmethod
|
||
def _parse_etf_html(html: str, result: Dict) -> Dict[str, Any]:
|
||
"""正则解析ETF HTML"""
|
||
# 名称
|
||
name_match = re.search(r'<h1[^>]*>([^<]+)', html)
|
||
if name_match:
|
||
result["product_name"] = name_match.group(1).strip()
|
||
|
||
# 代码
|
||
code_match = re.search(r'(\d{6})', html)
|
||
if code_match:
|
||
result["product_code"] = code_match.group(1)
|
||
|
||
# 净值
|
||
nav_match = re.search(r'单位净值[^\d]*(\d+\.\d+)', html)
|
||
if nav_match:
|
||
result["nav"]["unit"] = float(nav_match.group(1))
|
||
|
||
return result
|
||
|
||
|
||
class StockParser:
|
||
"""股票产品解析器"""
|
||
|
||
@staticmethod
|
||
def parse_stock_page(page: Union[Selector, str], url: str = "") -> Dict[str, Any]:
|
||
"""解析股票详情页面"""
|
||
result = {
|
||
"source": "stock",
|
||
"source_url": url,
|
||
"scrape_time": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||
"stock_name": "",
|
||
"stock_code": "",
|
||
"exchange": "",
|
||
"price": {},
|
||
"indicators": {},
|
||
"financials": {},
|
||
"main_indicators": {}, # 主要财务指标
|
||
"dividend": {}, # 分红配送
|
||
"holders": {} # 股东情况
|
||
}
|
||
|
||
try:
|
||
if isinstance(page, Selector):
|
||
result = StockParser._parse_stock_selector(page, result)
|
||
else:
|
||
result = StockParser._parse_stock_html(page, result)
|
||
except Exception as e:
|
||
result["error"] = str(e)
|
||
|
||
return result
|
||
|
||
@staticmethod
|
||
def _parse_stock_selector(page: Selector, result: Dict) -> Dict[str, Any]:
|
||
"""Selector解析股票"""
|
||
# 股票名称
|
||
for sel in ["h1", ".stock-name", "[class*='name']", ".title"]:
|
||
try:
|
||
el = page.css_first(sel)
|
||
if el:
|
||
result["stock_name"] = el.text().strip()
|
||
break
|
||
except:
|
||
continue
|
||
|
||
# 价格信息
|
||
price_patterns = [
|
||
(r'现价[::]\s*(\d+\.?\d*)', 'current'),
|
||
(r'今开[::]\s*(\d+\.?\d*)', 'open'),
|
||
(r'最高[::]\s*(\d+\.?\d*)', 'high'),
|
||
(r'最低[::]\s*(\d+\.?\d*)', 'low'),
|
||
(r'成交量[::]\s*([\d.]+\s*[万千手]+)', 'volume'),
|
||
(r'成交额[::]\s*([\d.]+\s*[万元]+)', 'amount'),
|
||
]
|
||
|
||
text_content = page.re_search(r'[\d.]+')
|
||
if text_content:
|
||
for pattern, key in price_patterns:
|
||
match = page.re_search(pattern)
|
||
if match:
|
||
try:
|
||
result["price"][key] = float(re.search(r'[\d.]+', match.group(0)).group())
|
||
except:
|
||
pass
|
||
|
||
# 市盈率、市净率
|
||
pe_match = page.re_search(r'市盈率[((]PE[))][::]\s*(\d+\.?\d*)')
|
||
if pe_match:
|
||
result["indicators"]["pe"] = float(pe_match.group(1))
|
||
|
||
pb_match = page.re_search(r'市净率[((]PB[))][::]\s*(\d+\.?\d*)')
|
||
if pb_match:
|
||
result["indicators"]["pb"] = float(pb_match.group(1))
|
||
|
||
# 总市值、流通市值
|
||
mktcap_match = page.re_search(r'总市值[::]\s*([\d.]+\s*[万亿亿]?)')
|
||
if mktcap_match:
|
||
result["financials"]["market_cap"] = mktcap_match.group(1).strip()
|
||
|
||
return result
|
||
|
||
@staticmethod
|
||
def _parse_stock_html(html: str, result: Dict) -> Dict[str, Any]:
|
||
"""正则解析股票HTML"""
|
||
# 名称
|
||
name_match = re.search(r'<h1[^>]*>([^<]+)', html)
|
||
if name_match:
|
||
result["stock_name"] = name_match.group(1).strip()
|
||
|
||
# 价格
|
||
price_match = re.search(r'"price"\s*:\s*"?(\d+\.?\d*)"?', html)
|
||
if price_match:
|
||
result["price"]["current"] = float(price_match.group(1))
|
||
|
||
return result
|
||
|
||
|
||
class InsurerParser:
|
||
"""保险公司解析器(万能险、年金险等)"""
|
||
|
||
@staticmethod
|
||
def parse_insurance_page(page: Union[Selector, str], url: str = "") -> Dict[str, Any]:
|
||
"""解析保险产品页面"""
|
||
result = {
|
||
"source": "insurance",
|
||
"source_url": url,
|
||
"scrape_time": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||
"product_name": "",
|
||
"product_code": "",
|
||
"insurance_type": "", # 万能险、年金险、健康险
|
||
"company": "",
|
||
"coverage_years": 0,
|
||
"payment_years": [],
|
||
"min_premium": 0,
|
||
"Guaranteed_rate": 0, # 保证利率
|
||
"historical_rate": 0, # 历史利率/结算利率
|
||
"protection": {}, # 保障内容
|
||
"cash_value": {}, # 现金价值
|
||
"surrender_period": 0, # 犹豫期/退保期间
|
||
"risk_warnings": []
|
||
}
|
||
|
||
try:
|
||
if isinstance(page, Selector):
|
||
result = InsurerParser._parse_insurance_selector(page, result)
|
||
else:
|
||
result = InsurerParser._parse_insurance_html(page, result)
|
||
except Exception as e:
|
||
result["error"] = str(e)
|
||
|
||
return result
|
||
|
||
@staticmethod
|
||
def _parse_insurance_selector(page: Selector, result: Dict) -> Dict[str, Any]:
|
||
"""Selector解析保险"""
|
||
# 产品名称
|
||
for sel in ["h1", ".product-name", "[class*='name']", ".title"]:
|
||
try:
|
||
el = page.css_first(sel)
|
||
if el:
|
||
result["product_name"] = el.text().strip()
|
||
break
|
||
except:
|
||
continue
|
||
|
||
# 保证利率
|
||
rate_patterns = [
|
||
(r'保证利率[::]\s*(\d+\.?\d*)%?', 'Guaranteed_rate'),
|
||
(r'历史结算利率[::]\s*(\d+\.?\d*)%?', 'historical_rate'),
|
||
(r'预期收益率[::]\s*(\d+\.?\d*)%?', 'expected_return'),
|
||
]
|
||
for pat, key in rate_patterns:
|
||
match = page.re_search(pat)
|
||
if match:
|
||
try:
|
||
result[key] = float(re.search(r'\d+\.?\d*', match.group(0)).group())
|
||
except:
|
||
pass
|
||
|
||
# 保障期限
|
||
coverage_match = page.re_search(r'保障期限[::]\s*(\d+)\s*年')
|
||
if coverage_match:
|
||
result["coverage_years"] = int(coverage_match.group(1))
|
||
|
||
# 保费
|
||
premium_match = page.re_search(r'最低保费[::]\s*([\d,]+)\s*元')
|
||
if premium_match:
|
||
result["min_premium"] = int(premium_match.group(1).replace(',', ''))
|
||
|
||
return result
|
||
|
||
@staticmethod
|
||
def _parse_insurance_html(html: str, result: Dict) -> Dict[str, Any]:
|
||
"""正则解析保险HTML"""
|
||
name_match = re.search(r'<h1[^>]*>([^<]+)', html)
|
||
if name_match:
|
||
result["product_name"] = name_match.group(1).strip()
|
||
|
||
return result
|
||
|
||
|
||
class AdvisorPortfolioParser:
|
||
"""投资顾问组合方案解析器(基金组合、主理人组合)"""
|
||
|
||
@staticmethod
|
||
def parse_advisor_portfolio(url: str, page: Union[Selector, str] = None,
|
||
platform: str = "auto") -> Dict[str, Any]:
|
||
"""
|
||
解析投资顾问组合页面
|
||
|
||
支持平台:
|
||
- 天天基金"基金组合"(顾工组合)
|
||
- 且慢"长期主义"等组合
|
||
- 蛋卷基金组合
|
||
- 支付宝"投顾管家"
|
||
- 微信"基金组合"
|
||
"""
|
||
result = {
|
||
"source": "advisor_portfolio",
|
||
"source_url": url,
|
||
"platform": platform,
|
||
"scrape_time": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||
|
||
# 组合基本信息
|
||
"portfolio_name": "",
|
||
"portfolio_id": "",
|
||
"advisor_name": "", # 主理人/顾问
|
||
"advisor_title": "", # 顾问头衔
|
||
"company": "", # 所属机构
|
||
"creation_date": "",
|
||
"track_record_years": 0,
|
||
|
||
# 组合特征
|
||
"strategy_type": "", # 稳健、平衡、进取
|
||
"suitable_investor": "", # 适合投资者类型
|
||
"investment_horizon": "", # 建议投资期限
|
||
"risk_level": "", # 风险等级
|
||
"max_drawdown_control": "", # 最大回撤控制目标
|
||
|
||
# 业绩指标
|
||
"cumulative_return": 0, # 累计收益率
|
||
"annualized_return": 0, # 年化收益率
|
||
"ytd_return": 0, # 今年来收益
|
||
"performance": {}, # 各周期收益
|
||
|
||
# 组合配置 - 核心
|
||
"allocations": [], # 配置列表
|
||
"allocation_summary": { # 配置汇总
|
||
"stocks": 0,
|
||
"bonds": 0,
|
||
"funds": 0,
|
||
"cash": 0,
|
||
"other": 0
|
||
},
|
||
"holdings": {
|
||
"stocks": [], # 穿透后股票持仓
|
||
"funds": [], # 基金持仓
|
||
"bonds": [] # 债券持仓
|
||
},
|
||
"positions": [], # 持仓明细(含权重)
|
||
|
||
# 调仓记录
|
||
"rebalance_records": [],
|
||
"last_rebalance_date": "",
|
||
|
||
# 跟投信息
|
||
"follow_amount": 0, # 最低跟投金额
|
||
"follow_count": 0, # 跟投人数
|
||
"management_fee": 0, # 打理费
|
||
|
||
# 风险指标
|
||
"risk_metrics": {
|
||
"sharpe_ratio": 0,
|
||
"max_drawdown": 0,
|
||
"volatility": 0,
|
||
"calmar_ratio": 0
|
||
},
|
||
|
||
# 分析建议
|
||
"style_analysis": "", # 风格分析
|
||
"recommendation": "" # 配置建议
|
||
}
|
||
|
||
try:
|
||
if page is None and url:
|
||
# 需要先爬取
|
||
if SCRAPLING_AVAILABLE:
|
||
fetcher = StealthyFetcher()
|
||
page = fetcher.fetch(url, headless=True)
|
||
else:
|
||
result["error"] = "需要安装scrapling才能爬取"
|
||
return result
|
||
|
||
if isinstance(page, Selector):
|
||
result = AdvisorPortfolioParser._parse_portfolio_selector(page, result)
|
||
else:
|
||
result = AdvisorPortfolioParser._parse_portfolio_html(page, result)
|
||
|
||
# 自动检测平台
|
||
if platform == "auto":
|
||
url_lower = url.lower()
|
||
if 'easmart' in url_lower or '1234567' in url_lower:
|
||
result["platform"] = "天天基金组合"
|
||
elif 'qieman' in url_lower:
|
||
result["platform"] = "且慢"
|
||
elif 'danjuan' in url_lower:
|
||
result["platform"] = "蛋卷基金"
|
||
elif 'zhipu' in url_lower:
|
||
result["platform"] = "支付宝投顾"
|
||
|
||
except Exception as e:
|
||
result["error"] = str(e)
|
||
|
||
return result
|
||
|
||
@staticmethod
|
||
def _parse_portfolio_selector(page: Selector, result: Dict) -> Dict[str, Any]:
|
||
"""Selector解析投资顾问组合"""
|
||
|
||
# 组合名称
|
||
for sel in ["h1", ".portfolio-name", "[class*='name']", ".title",
|
||
".strategy-name", "[class*='portfolio']"]:
|
||
try:
|
||
el = page.css_first(sel)
|
||
if el:
|
||
result["portfolio_name"] = el.text().strip()
|
||
break
|
||
except:
|
||
continue
|
||
|
||
# 主理人/顾问
|
||
for sel in [".advisor", ".manager", "[class*='advisor']", ". Strategist",
|
||
"[class*='manager']", ".owner"]:
|
||
try:
|
||
el = page.css_first(sel)
|
||
if el:
|
||
result["advisor_name"] = el.text().strip()
|
||
break
|
||
except:
|
||
continue
|
||
|
||
# 策略类型(稳健/平衡/进取)
|
||
for sel in [".strategy", "[class*='strategy']", ".type", ".level"]:
|
||
try:
|
||
el = page.css_first(sel)
|
||
if el:
|
||
text = el.text().strip()
|
||
if text:
|
||
result["strategy_type"] = text
|
||
break
|
||
except:
|
||
continue
|
||
|
||
# 业绩数据提取
|
||
# 累计收益
|
||
cum_match = page.re_search(r'累计收益[::]*\s*([+-]?\d+\.?\d*)%?')
|
||
if cum_match:
|
||
result["cumulative_return"] = float(cum_match.group(1))
|
||
|
||
# 年化收益
|
||
annual_match = page.re_search(r'年化收益[::]*\s*([+-]?\d+\.?\d*)%?')
|
||
if annual_match:
|
||
result["annualized_return"] = float(annual_match.group(1))
|
||
|
||
# 今年来
|
||
ytd_match = page.re_search(r'今年来[::]*\s*([+-]?\d+\.?\d*)%?')
|
||
if ytd_match:
|
||
result["ytd_return"] = float(ytd_match.group(1))
|
||
|
||
# 各周期收益
|
||
period_patterns = [
|
||
(r'近1月[::]*\s*([+-]?\d+\.?\d*)%?', '1month'),
|
||
(r'近3月[::]*\s*([+-]?\d+\.?\d*)%?', '3month'),
|
||
(r'近6月[::]*\s*([+-]?\d+\.?\d*)%?', '6month'),
|
||
(r'近1年[::]*\s*([+-]?\d+\.?\d*)%?', '1year'),
|
||
(r'近3年[::]*\s*([+-]?\d+\.?\d*)%?', '3year'),
|
||
]
|
||
for pattern, key in period_patterns:
|
||
match = page.re_search(pattern)
|
||
if match:
|
||
result["performance"][key] = float(match.group(1))
|
||
|
||
# ============ 持仓配置解析(核心) ============
|
||
positions = []
|
||
|
||
# 尝试提取持仓表格
|
||
table_selectors = [
|
||
".positions-table tr",
|
||
".holdings-table tr",
|
||
".allocation-table tr",
|
||
".portfolio-holdings tr",
|
||
"[class*='position'] tr",
|
||
"table[class*='position'] tr"
|
||
]
|
||
|
||
for sel in table_selectors:
|
||
try:
|
||
rows = page.css(sel)
|
||
if rows and len(rows) > 1:
|
||
for row in rows[1:]: # 跳过表头
|
||
cells = row.css("td, th")
|
||
if len(cells) >= 2:
|
||
item = {
|
||
"name": "",
|
||
"code": "",
|
||
"weight": 0,
|
||
"change": "", # 调仓变化
|
||
"type": "" # stock/fund/bond
|
||
}
|
||
|
||
for i, cell in enumerate(cells):
|
||
cell_text = cell.text().strip()
|
||
if i == 0:
|
||
# 名称列
|
||
item["name"] = cell_text
|
||
# 尝试提取代码
|
||
code_match = re.search(r'(\d{6})', cell_text)
|
||
if code_match:
|
||
item["code"] = code_match.group(1)
|
||
elif i == 1:
|
||
# 权重列
|
||
w_match = re.search(r'(\d+\.?\d*)%?', cell_text)
|
||
if w_match:
|
||
item["weight"] = float(w_match.group(1))
|
||
# 判断类型
|
||
if 'ETF' in cell_text or '指数' in cell_text:
|
||
item["type"] = "etf"
|
||
elif re.search(r'\d{6}', cell_text):
|
||
item["type"] = "stock"
|
||
else:
|
||
item["type"] = "fund"
|
||
|
||
if item["name"]:
|
||
positions.append(item)
|
||
break
|
||
except:
|
||
continue
|
||
|
||
result["positions"] = positions
|
||
|
||
# 计算配置汇总
|
||
stocks_weight = 0
|
||
bonds_weight = 0
|
||
funds_weight = 0
|
||
|
||
for p in positions:
|
||
w = p.get("weight", 0)
|
||
t = p.get("type", "")
|
||
if t == "stock":
|
||
stocks_weight += w
|
||
elif t in ("fund", "etf"):
|
||
funds_weight += w
|
||
else:
|
||
funds_weight += w # 默认归为基金
|
||
|
||
result["allocation_summary"] = {
|
||
"stocks": stocks_weight,
|
||
"bonds": bonds_weight,
|
||
"funds": funds_weight,
|
||
"cash": 100 - stocks_weight - bonds_weight - funds_weight,
|
||
"other": 0
|
||
}
|
||
|
||
# 穿透后股票持仓(如果是基金组合,需要穿透到股票)
|
||
# 简化处理:根据基金类型估算
|
||
if funds_weight > 50:
|
||
result["holdings"]["funds"] = positions
|
||
# 估算穿透股票
|
||
# 股票型基金按95%计, 混合型按70%计, 债券型按5%计
|
||
estimated_stock = funds_weight * 0.5 # 简化估算
|
||
stocks_weight += estimated_stock
|
||
result["holdings"]["stocks"] = [{
|
||
"name": "穿透估算股票持仓",
|
||
"estimated_weight": estimated_stock,
|
||
"note": "由基金持仓穿透估算"
|
||
}]
|
||
else:
|
||
result["holdings"]["funds"] = positions
|
||
|
||
# 风险指标
|
||
sharpe_match = page.re_search(r'夏普比率[::]*\s*([+-]?\d+\.?\d*)')
|
||
if sharpe_match:
|
||
result["risk_metrics"]["sharpe_ratio"] = float(sharpe_match.group(1))
|
||
|
||
maxdd_match = page.re_search(r'最大回撤[::]*\s*([+-]?\d+\.?\d*)%?')
|
||
if maxdd_match:
|
||
result["risk_metrics"]["max_drawdown"] = float(maxdd_match.group(1))
|
||
|
||
# 适合投资者
|
||
investor_match = page.re_search(r'适合[人群类型]*[::]*\s*([^\n,,]+)')
|
||
if investor_match:
|
||
result["suitable_investor"] = investor_match.group(1).strip()
|
||
|
||
# 建议持有期
|
||
horizon_match = page.re_search(r'建议持有[期]*[::]*\s*(\d+)\s*[年个月天]+')
|
||
if horizon_match:
|
||
result["investment_horizon"] = horizon_match.group(0)
|
||
|
||
# 跟投信息
|
||
follow_match = page.re_search(r'跟投[人数金额]*[::]*\s*([\d,]+)')
|
||
if follow_match:
|
||
result["follow_count"] = int(follow_match.group(1).replace(',', ''))
|
||
|
||
return result
|
||
|
||
@staticmethod
|
||
def _parse_portfolio_html(html: str, result: Dict) -> Dict[str, Any]:
|
||
"""正则解析组合HTML"""
|
||
# 组合名称
|
||
name_match = re.search(r'<h1[^>]*>([^<]+)', html)
|
||
if name_match:
|
||
result["portfolio_name"] = name_match.group(1).strip()
|
||
|
||
# 主理人
|
||
advisor_match = re.search(r'主理人[::]*\s*([^\s<]+)', html)
|
||
if advisor_match:
|
||
result["advisor_name"] = advisor_match.group(1).strip()
|
||
|
||
# 累计收益
|
||
cum_match = re.search(r'累计收益[::]*\s*([+-]?\d+\.?\d*)%?', html)
|
||
if cum_match:
|
||
result["cumulative_return"] = float(cum_match.group(1))
|
||
|
||
# 持仓表格
|
||
rows = re.findall(r'<tr[^>]*>(.*?)</tr>', html, re.DOTALL)
|
||
positions = []
|
||
for row in rows[1:]: # 跳过表头
|
||
cells = re.findall(r'<td[^>]*>(.*?)</td>', row, re.DOTALL)
|
||
if len(cells) >= 2:
|
||
name_cell = re.sub(r'<[^>]+>', '', cells[0]).strip()
|
||
weight_cell = re.sub(r'<[^>]+>', '', cells[1]).strip()
|
||
|
||
weight_match = re.search(r'(\d+\.?\d*)%?', weight_cell)
|
||
weight = float(weight_match.group(1)) if weight_match else 0
|
||
|
||
code_match = re.search(r'(\d{6})', name_cell)
|
||
|
||
positions.append({
|
||
"name": re.sub(r'\d{6}', '', name_cell).strip(),
|
||
"code": code_match.group(1) if code_match else "",
|
||
"weight": weight
|
||
})
|
||
|
||
result["positions"] = positions
|
||
|
||
return result
|
||
|
||
@staticmethod
|
||
def format_portfolio_analysis(portfolio: Dict[str, Any]) -> str:
|
||
"""格式化投资顾问组合分析报告"""
|
||
if "error" in portfolio:
|
||
return f"解析错误: {portfolio['error']}"
|
||
|
||
name = portfolio.get("portfolio_name", "未知组合")
|
||
advisor = portfolio.get("advisor_name", "未知主理人")
|
||
platform = portfolio.get("platform", "")
|
||
strategy = portfolio.get("strategy_type", "")
|
||
|
||
report = f"""
|
||
【投资顾问组合分析】
|
||
|
||
■ 基本信息
|
||
组合名称: {name}
|
||
主理人: {advisor}
|
||
平台: {platform}
|
||
策略类型: {strategy if strategy else '未识别'}
|
||
"""
|
||
|
||
# 业绩表现
|
||
perf = portfolio.get("performance", {})
|
||
cum_return = portfolio.get("cumulative_return", 0)
|
||
annual_return = portfolio.get("annualized_return", 0)
|
||
|
||
if cum_return or annual_return:
|
||
report += "\n■ 业绩表现\n"
|
||
if cum_return:
|
||
report += f"累计收益: {cum_return:+.2f}%\n"
|
||
if annual_return:
|
||
report += f"年化收益: {annual_return:+.2f}%\n"
|
||
|
||
if perf:
|
||
report += "各周期表现:\n"
|
||
for period, value in list(perf.items())[:6]:
|
||
report += f" 近{period}: {value:+.2f}%\n"
|
||
|
||
# 资产配置
|
||
report += "\n■ 资产配置\n"
|
||
summary = portfolio.get("allocation_summary", {})
|
||
if summary:
|
||
stocks = summary.get("stocks", 0)
|
||
bonds = summary.get("bonds", 0)
|
||
funds = summary.get("funds", 0)
|
||
cash = summary.get("cash", 0)
|
||
|
||
bar_len = 25
|
||
total = stocks + bonds + funds + cash
|
||
if total > 0:
|
||
stocks_bar = int(stocks / 100 * bar_len)
|
||
bonds_bar = int(bonds / 100 * bar_len)
|
||
funds_bar = int(funds / 100 * bar_len)
|
||
cash_bar = int(cash / 100 * bar_len)
|
||
|
||
report += f"股票: {stocks:5.1f}% | {'▓' * stocks_bar}{'░' * (bar_len - stocks_bar)}\n"
|
||
report += f"债券: {bonds:5.1f}% | {'▓' * bonds_bar}{'░' * (bar_len - bonds_bar)}\n"
|
||
report += f"基金: {funds:5.1f}% | {'▓' * funds_bar}{'░' * (bar_len - funds_bar)}\n"
|
||
report += f"现金: {cash:5.1f}% | {'▓' * cash_bar}{'░' * (bar_len - cash_bar)}\n"
|
||
|
||
# 持仓明细
|
||
positions = portfolio.get("positions", [])
|
||
if positions:
|
||
report += f"\n■ 持仓明细 (共{len(positions)}只)\n"
|
||
report += f"{'名称':<20} {'代码':<8} {'权重':>8}\n"
|
||
report += "-" * 40 + "\n"
|
||
for p in positions[:15]:
|
||
name = (p.get("name", "") or "")[:18]
|
||
code = p.get("code", "") or "-"
|
||
weight = p.get("weight", 0)
|
||
report += f"{name:<20} {code:<8} {weight:>7.2f}%\n"
|
||
|
||
if len(positions) > 15:
|
||
report += f"... 还有{len(positions) - 15}只\n"
|
||
|
||
# 风险指标
|
||
risk = portfolio.get("risk_metrics", {})
|
||
if risk:
|
||
report += "\n■ 风险指标\n"
|
||
if risk.get("sharpe_ratio"):
|
||
report += f"夏普比率: {risk['sharpe_ratio']:.2f}\n"
|
||
if risk.get("max_drawdown"):
|
||
report += f"最大回撤: {risk['max_drawdown']:.2f}%\n"
|
||
if risk.get("volatility"):
|
||
report += f"波动率: {risk['volatility']:.2f}%\n"
|
||
|
||
# 适合投资者
|
||
suitable = portfolio.get("suitable_investor", "")
|
||
horizon = portfolio.get("investment_horizon", "")
|
||
if suitable or horizon:
|
||
report += "\n■ 适合人群\n"
|
||
if suitable:
|
||
report += f"{suitable}\n"
|
||
if horizon:
|
||
report += f"建议持有: {horizon}\n"
|
||
|
||
# 投资建议
|
||
report += "\n■ 配置建议\n"
|
||
if strategy:
|
||
if "稳健" in strategy or "保守" in strategy:
|
||
report += "该组合为稳健型,适合风险偏好较低的投资者,可作为养老或教育金规划。\n"
|
||
elif "进取" in strategy or "积极" in strategy:
|
||
report += "该组合为进取型,适合风险承受能力强的投资者,建议定投并长期持有。\n"
|
||
else:
|
||
report += "该组合为均衡型,适合大多数投资者,可作为核心配置。\n"
|
||
|
||
return report
|
||
|
||
|
||
class FOFParser:
|
||
"""FOF(基金中基金)产品解析器"""
|
||
|
||
@staticmethod
|
||
def parse_fof_page(page: Union[Selector, str], url: str = "") -> Dict[str, Any]:
|
||
"""解析FOF产品页面 - 提取底层基金配置"""
|
||
result = {
|
||
"source": "fof",
|
||
"source_url": url,
|
||
"scrape_time": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||
"product_name": "",
|
||
"product_code": "",
|
||
"product_type": "FOF",
|
||
"company": "",
|
||
"manager": "",
|
||
"fof_category": "", # 养老FOF、普通FOF
|
||
"investment_style": "", # 目标日期、目标风险、均衡型
|
||
|
||
# FOF特有字段
|
||
"asset_allocation": { # 资产配置比例
|
||
"stocks": 0, # 股票比例
|
||
"bonds": 0, # 债券比例
|
||
"funds": 0, # 基金比例(自家产品)
|
||
"cash": 0, # 现金
|
||
"other": 0 # 其他
|
||
},
|
||
"underlying_funds": [], # 底层基金持仓
|
||
"external_funds": [], # 外部基金(非自家)
|
||
"internal_funds": [], # 内部基金(自家产品)
|
||
"holdings": {
|
||
"stocks": [], # 直接股票持仓(如果有)
|
||
"bonds": [], # 直接债券持仓(如果有)
|
||
"funds": [] # 基金持仓
|
||
},
|
||
"top_10_holdings_weight": 0, # 前十持仓集中度
|
||
"rebalance_history": [], # 调仓历史
|
||
"fee_structure": {}, # FOF费率(双层收费)
|
||
"risk_metrics": {},
|
||
"performance": {}
|
||
}
|
||
|
||
try:
|
||
if isinstance(page, Selector):
|
||
result = FOFParser._parse_fof_selector(page, result)
|
||
else:
|
||
result = FOFParser._parse_fof_html(page, result)
|
||
except Exception as e:
|
||
result["error"] = str(e)
|
||
|
||
return result
|
||
|
||
@staticmethod
|
||
def _parse_fof_selector(page: Selector, result: Dict) -> Dict[str, Any]:
|
||
"""使用Selector解析FOF"""
|
||
# 产品名称
|
||
for sel in ["h1", ".fund-name", "[class*='name']", ".title", ".fof-name"]:
|
||
try:
|
||
el = page.css_first(sel)
|
||
if el:
|
||
result["product_name"] = el.text().strip()
|
||
break
|
||
except:
|
||
continue
|
||
|
||
# 基金代码
|
||
code_match = page.re_search(r'(\d{6})')
|
||
if code_match:
|
||
result["product_code"] = code_match.group(1)
|
||
|
||
# 资产配置比例 - 关键解析
|
||
# 格式1: 股票 20%、债券 60%、基金 15%、现金 5%
|
||
# 格式2: 权益类 20%、固收类 75%、其他 5%
|
||
allocation_patterns = [
|
||
r'股票[仓位]*[::]*\s*(\d+\.?\d*)\s*%',
|
||
r'权益类[::]*\s*(\d+\.?\d*)\s*%',
|
||
r'股票仓位[::]*\s*(\d+\.?\d*)\s*%',
|
||
r'投资于基金[::]*\s*(\d+\.?\d*)\s*%',
|
||
r'基金投资比例[::]*\s*(\d+\.?\d*)\s*%',
|
||
]
|
||
for pat in allocation_patterns:
|
||
match = page.re_search(pat)
|
||
if match:
|
||
result["asset_allocation"]["stocks"] = float(match.group(1))
|
||
break
|
||
|
||
# 债券配置
|
||
bond_patterns = [
|
||
r'债券[仓位]*[::]*\s*(\d+\.?\d*)\s*%',
|
||
r'固收类[::]*\s*(\d+\.?\d*)\s*%',
|
||
]
|
||
for pat in bond_patterns:
|
||
match = page.re_search(pat)
|
||
if match:
|
||
result["asset_allocation"]["bonds"] = float(match.group(1))
|
||
break
|
||
|
||
# 现金配置
|
||
cash_match = page.re_search(r'现金[::]*\s*(\d+\.?\d*)\s*%')
|
||
if cash_match:
|
||
result["asset_allocation"]["cash"] = float(cash_match.group(1))
|
||
|
||
# ============ 底层基金持仓解析(核心功能) ============
|
||
# FOF的精髓是提取其持有的底层基金
|
||
|
||
# 尝试提取基金持仓表格
|
||
# 天天基金的FOF持仓通常在"基金持仓"或"持仓明细"tab下
|
||
fund_holdings = []
|
||
|
||
# 尝试CSS选择器
|
||
holding_selectors = [
|
||
".holdings-table tr", # 持仓表格行
|
||
".fund-holdings tr",
|
||
"[class*='holding'] tr",
|
||
".positions tr",
|
||
"table[class*='fund'] tr",
|
||
".fof-holdings tr"
|
||
]
|
||
|
||
for sel in holding_selectors:
|
||
try:
|
||
rows = page.css(sel)
|
||
if rows and len(rows) > 1:
|
||
# 解析持仓行
|
||
for row in rows[1:]: # 跳过表头
|
||
cells = row.css("td, th")
|
||
if len(cells) >= 2:
|
||
fund_name = ""
|
||
fund_code = ""
|
||
weight = 0
|
||
change = ""
|
||
|
||
for i, cell in enumerate(cells):
|
||
cell_text = cell.text().strip()
|
||
# 第一列通常是基金名称或代码
|
||
if i == 0:
|
||
# 尝试提取基金代码 (6位数字)
|
||
code_in_cell = re.search(r'(\d{6})', cell_text)
|
||
if code_in_cell:
|
||
fund_code = code_in_cell.group(1)
|
||
fund_name = re.sub(r'\d{6}', '', cell_text).strip()
|
||
elif i == 1:
|
||
# 第二列通常是持仓比例
|
||
w_match = re.search(r'(\d+\.?\d*)%?', cell_text)
|
||
if w_match:
|
||
weight = float(w_match.group(1))
|
||
elif re.search(r'\d', cell_text):
|
||
try:
|
||
weight = float(re.search(r'[\d.]+', cell_text).group())
|
||
except:
|
||
pass
|
||
|
||
if fund_name or fund_code:
|
||
fund_holdings.append({
|
||
"name": fund_name,
|
||
"code": fund_code,
|
||
"weight": weight,
|
||
"change": change
|
||
})
|
||
break
|
||
except:
|
||
continue
|
||
|
||
result["underlying_funds"] = fund_holdings
|
||
|
||
# 区分内部基金和外部基金(通过名称匹配或平台判断)
|
||
internal_companies = ["易方达", "华夏", "嘉实", "南方", "广发", "博时",
|
||
"招商", "工银", "建信", "富国", "鹏华", "汇添富",
|
||
"中欧", "兴全", "景林", "高毅", "淡水泉", "明汯"]
|
||
|
||
internal_funds = []
|
||
external_funds = []
|
||
|
||
for f in fund_holdings:
|
||
is_internal = any(co in f.get("name", "") for co in internal_companies)
|
||
if is_internal:
|
||
internal_funds.append(f)
|
||
else:
|
||
external_funds.append(f)
|
||
|
||
result["internal_funds"] = internal_funds
|
||
result["external_funds"] = external_funds
|
||
|
||
# 计算前十持仓集中度
|
||
if fund_holdings:
|
||
top_10 = sum(f.get("weight", 0) for f in fund_holdings[:10])
|
||
result["top_10_holdings_weight"] = top_10
|
||
|
||
# FOF分类
|
||
name_text = result.get("product_name", "")
|
||
if "养老" in name_text or "目标日期" in name_text:
|
||
result["fof_category"] = "养老FOF"
|
||
result["investment_style"] = "目标日期"
|
||
elif "目标风险" in name_text:
|
||
result["fof_category"] = "普通FOF"
|
||
result["investment_style"] = "目标风险"
|
||
else:
|
||
result["fof_category"] = "普通FOF"
|
||
|
||
# FOF特有费率(通常有双重收费)
|
||
fee_match = page.re_search(r'基金管理费[::]\s*(\d+\.?\d*)%?')
|
||
if fee_match:
|
||
result["fee_structure"]["management_fee"] = float(fee_match.group(1))
|
||
|
||
return result
|
||
|
||
@staticmethod
|
||
def _parse_fof_html(html: str, result: Dict) -> Dict[str, Any]:
|
||
"""正则解析FOF HTML"""
|
||
# 名称
|
||
name_match = re.search(r'<h1[^>]*>([^<]+)', html)
|
||
if name_match:
|
||
result["product_name"] = name_match.group(1).strip()
|
||
|
||
# 代码
|
||
code_match = re.search(r'(\d{6})', html)
|
||
if code_match:
|
||
result["product_code"] = code_match.group(1)
|
||
|
||
# 资产配置比例
|
||
alloc_match = re.search(r'股票[::]*\s*(\d+\.?\d*)%?', html)
|
||
if alloc_match:
|
||
result["asset_allocation"]["stocks"] = float(alloc_match.group(1))
|
||
|
||
# 底层基金持仓 - 通过正则提取表格
|
||
# 匹配基金持仓行: 基金名称 持仓比例
|
||
fund_rows = re.findall(r'<tr[^>]*>.*?<td[^>]*>([^<]*(?:\d{6})?[^<]*)</td>.*?<td[^>]*>(\d+\.?\d*)%?</td>', html, re.DOTALL)
|
||
funds = []
|
||
for name_cell, weight_cell in fund_rows:
|
||
fund_name = re.sub(r'\d{6}', '', name_cell).strip()
|
||
fund_code = re.search(r'(\d{6})', name_cell)
|
||
try:
|
||
weight = float(re.search(r'(\d+\.?\d*)', weight_cell).group(1))
|
||
except:
|
||
weight = 0
|
||
|
||
if fund_name:
|
||
funds.append({
|
||
"name": fund_name,
|
||
"code": fund_code.group(1) if fund_code else "",
|
||
"weight": weight
|
||
})
|
||
|
||
result["underlying_funds"] = funds
|
||
|
||
return result
|
||
|
||
@staticmethod
|
||
def extract_asset_allocation(text: str) -> Dict[str, float]:
|
||
"""从文本提取资产配置比例"""
|
||
allocation = {"stocks": 0, "bonds": 0, "funds": 0, "cash": 0, "other": 0}
|
||
|
||
# 股票/权益类
|
||
stock_match = re.search(r'股票[仓位]*[::]*\s*(\d+\.?\d*)\s*%', text)
|
||
if stock_match:
|
||
allocation["stocks"] = float(stock_match.group(1))
|
||
else:
|
||
equity_match = re.search(r'权益类[::]*\s*(\d+\.?\d*)\s*%', text)
|
||
if equity_match:
|
||
allocation["stocks"] = float(equity_match.group(1))
|
||
|
||
# 债券/固收类
|
||
bond_match = re.search(r'债券[仓位]*[::]*\s*(\d+\.?\d*)\s*%', text)
|
||
if bond_match:
|
||
allocation["bonds"] = float(bond_match.group(1))
|
||
else:
|
||
fixed_match = re.search(r'固收类[::]*\s*(\d+\.?\d*)\s*%', text)
|
||
if fixed_match:
|
||
allocation["bonds"] = float(fixed_match.group(1))
|
||
|
||
# 现金
|
||
cash_match = re.search(r'现金[::]*\s*(\d+\.?\d*)\s*%', text)
|
||
if cash_match:
|
||
allocation["cash"] = float(cash_match.group(1))
|
||
|
||
# 基金
|
||
fund_match = re.search(r'基金[::]*\s*(\d+\.?\d*)\s*%', text)
|
||
if fund_match:
|
||
allocation["funds"] = float(fund_match.group(1))
|
||
|
||
return allocation
|
||
|
||
@staticmethod
|
||
def format_fof_analysis(fof_info: Dict[str, Any]) -> str:
|
||
"""格式化FOF分析报告"""
|
||
if "error" in fof_info:
|
||
return f"解析错误: {fof_info['error']}"
|
||
|
||
name = fof_info.get("product_name", "未知")
|
||
code = fof_info.get("product_code", "")
|
||
category = fof_info.get("fof_category", "FOF")
|
||
style = fof_info.get("investment_style", "")
|
||
|
||
report = f"""
|
||
【{category}产品分析】
|
||
|
||
■ 基本信息
|
||
名称: {name}
|
||
代码: {code}
|
||
类型: {category}
|
||
风格: {style if style else '未识别'}
|
||
|
||
■ 资产配置比例
|
||
"""
|
||
alloc = fof_info.get("asset_allocation", {})
|
||
if alloc:
|
||
stocks = alloc.get("stocks", 0)
|
||
bonds = alloc.get("bonds", 0)
|
||
funds = alloc.get("funds", 0)
|
||
cash = alloc.get("cash", 0)
|
||
|
||
# 可视化
|
||
total = stocks + bonds + funds + cash
|
||
if total > 0:
|
||
bar_len = 30
|
||
stocks_bar = int(stocks / 100 * bar_len)
|
||
bonds_bar = int(bonds / 100 * bar_len)
|
||
funds_bar = int(funds / 100 * bar_len)
|
||
cash_bar = int(cash / 100 * bar_len)
|
||
|
||
report += f"股票/权益: {stocks:5.1f}% | {'█' * stocks_bar}{'░' * (bar_len - stocks_bar)}\n"
|
||
report += f"债券/固收: {bonds:5.1f}% | {'█' * bonds_bar}{'░' * (bar_len - bonds_bar)}\n"
|
||
report += f"基金: {funds:5.1f}% | {'█' * funds_bar}{'░' * (bar_len - funds_bar)}\n"
|
||
report += f"现金: {cash:5.1f}% | {'█' * cash_bar}{'░' * (bar_len - cash_bar)}\n"
|
||
|
||
report += f"""
|
||
■ 底层基金持仓 (共{len(fof_info.get('underlying_funds', []))}只)
|
||
"""
|
||
underlying = fof_info.get("underlying_funds", [])
|
||
if underlying:
|
||
# 内部vs外部分类
|
||
internal = fof_info.get("internal_funds", [])
|
||
external = fof_info.get("external_funds", [])
|
||
|
||
if internal:
|
||
report += f"\n自家产品 ({len(internal)}只):\n"
|
||
for f in internal[:5]:
|
||
report += f" {f.get('name', '')}({f.get('code', '')}) - {f.get('weight', 0):.2f}%\n"
|
||
|
||
if external:
|
||
report += f"\n外部基金 ({len(external)}只):\n"
|
||
for f in external[:10]:
|
||
report += f" {f.get('name', '')}({f.get('code', '')}) - {f.get('weight', 0):.2f}%\n"
|
||
|
||
top10_weight = fof_info.get("top_10_holdings_weight", 0)
|
||
report += f"\n前十持仓集中度: {top10_weight:.1f}%\n"
|
||
else:
|
||
report += "暂无可用持仓数据\n"
|
||
|
||
# 收益表现
|
||
perf = fof_info.get("performance", {})
|
||
if perf:
|
||
report += "\n■ 收益表现\n"
|
||
for period, value in list(perf.items())[:5]:
|
||
report += f" {period}: {value}%\n"
|
||
|
||
return report
|
||
|
||
|
||
class BondParser:
|
||
"""债券产品解析器"""
|
||
|
||
@staticmethod
|
||
def parse_bond_page(page: Union[Selector, str], url: str = "") -> Dict[str, Any]:
|
||
"""解析债券产品页面"""
|
||
result = {
|
||
"source": "bond",
|
||
"source_url": url,
|
||
"scrape_time": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||
"product_name": "",
|
||
"product_code": "",
|
||
"bond_type": "", # 国债、企业债、可转债
|
||
"par_value": 100,
|
||
"coupon_rate": 0, # 票面利率
|
||
"issue_price": 0,
|
||
"maturity_date": "",
|
||
"term_to_maturity": 0, # 剩余期限
|
||
"rating": "", # 信用评级
|
||
"yield_to_maturity": 0, # 到期收益率
|
||
"clean_price": 0, # 净价
|
||
"dirty_price": 0, # 全价
|
||
"accrued_interest": 0, # 应计利息
|
||
"conversion_price": 0, # 转股价(可转债)
|
||
"conversion_ratio": 0, # 转股比例(可转债)
|
||
}
|
||
|
||
try:
|
||
if isinstance(page, Selector):
|
||
result = BondParser._parse_bond_selector(page, result)
|
||
else:
|
||
result = BondParser._parse_bond_html(page, result)
|
||
except Exception as e:
|
||
result["error"] = str(e)
|
||
|
||
return result
|
||
|
||
@staticmethod
|
||
def _parse_bond_selector(page: Selector, result: Dict) -> Dict[str, Any]:
|
||
"""Selector解析债券"""
|
||
for sel in ["h1", ".bond-name", "[class*='name']", ".title"]:
|
||
try:
|
||
el = page.css_first(sel)
|
||
if el:
|
||
result["product_name"] = el.text().strip()
|
||
break
|
||
except:
|
||
continue
|
||
|
||
# 票面利率
|
||
coupon_match = page.re_search(r'票面利率[::]\s*(\d+\.?\d*)%?')
|
||
if coupon_match:
|
||
result["coupon_rate"] = float(coupon_match.group(1))
|
||
|
||
# 到期收益率
|
||
ytm_match = page.re_search(r'到期收益率[::]\s*(\d+\.?\d*)%?')
|
||
if ytm_match:
|
||
result["yield_to_maturity"] = float(ytm_match.group(1))
|
||
|
||
return result
|
||
|
||
@staticmethod
|
||
def _parse_bond_html(html: str, result: Dict) -> Dict[str, Any]:
|
||
"""正则解析债券HTML"""
|
||
name_match = re.search(r'<h1[^>]*>([^<]+)', html)
|
||
if name_match:
|
||
result["product_name"] = name_match.group(1).strip()
|
||
|
||
return result
|
||
|
||
|
||
# ============ 统一解析入口(v3.0 增强降级链) ============
|
||
|
||
def parse_financial_product(url: str, product_type: str = "auto",
|
||
use_dynamic: bool = False,
|
||
operations: List[WebOperation] = None) -> Dict[str, Any]:
|
||
"""
|
||
统一金融产品解析入口(v3.0 增强)
|
||
|
||
降级链:
|
||
1. Playwright(复杂交互)→
|
||
2. Scrapling 隐身模式 →
|
||
3. Scrapling 移动端 →
|
||
4. http_utils 桌面 UA →
|
||
5. http_utils 移动 UA →
|
||
6. http_utils 直接 fetch(作为最后手段)→
|
||
7. JSON-LD 提取 + URL 代码提取(始终返回部分数据)
|
||
|
||
Args:
|
||
url: 产品URL
|
||
product_type: 产品类型 ("fund", "etf", "stock", "bond", "insurance", "p2p", "fof", "advisor", "auto")
|
||
use_dynamic: 是否使用动态渲染
|
||
operations: 页面操作列表(用于登录、点击等)
|
||
|
||
Returns:
|
||
产品信息字典(即使所有爬取失败也会包含 url 和从 URL 提取的信息)
|
||
"""
|
||
result = {"source_url": url, "scrape_time": time.strftime("%Y-%m-%d %H:%M:%S")}
|
||
|
||
# 自动检测类型
|
||
if product_type == "auto":
|
||
product_type = _detect_product_type(url)
|
||
|
||
# 从 URL 提取基本代码(最后手段)
|
||
_extract_code_from_url(url, result, product_type)
|
||
|
||
# ── 降级链 ──
|
||
html_content = None
|
||
page_obj = None
|
||
|
||
# 级别 1: Playwright(复杂交互操作)
|
||
if operations and PLAYWRIGHT_AVAILABLE:
|
||
try:
|
||
with PageOperator() as op:
|
||
op.goto(url)
|
||
op.run_operations(operations)
|
||
html_content = op.get_html()
|
||
except Exception:
|
||
pass
|
||
|
||
# 级别 2-3: Scrapling
|
||
if html_content is None and SCRAPLING_AVAILABLE:
|
||
for mode_desc, fetcher_cls, use_mobile in [
|
||
("隐身模式", StealthyFetcher, False),
|
||
("移动端模式", StealthyFetcher, True),
|
||
]:
|
||
try:
|
||
fetcher = fetcher_cls()
|
||
page = fetcher.fetch(url, headless=True,
|
||
solve_cloudflare=True if not use_mobile else False)
|
||
if page:
|
||
page_obj = page
|
||
if hasattr(page, 'html'):
|
||
html_content = page.html
|
||
elif hasattr(page, 'html_content'):
|
||
html_content = page.html_content
|
||
elif hasattr(page, 'body') and isinstance(page.body, str):
|
||
html_content = page.body
|
||
if html_content:
|
||
break
|
||
except Exception:
|
||
continue
|
||
|
||
# 级别 4-5: http_utils(标准库 HTTP)
|
||
if html_content is None and HAS_HTTP_UTILS:
|
||
for mobile in (False, True):
|
||
try:
|
||
s = get_session(mobile=mobile, rotate_ua=True, randomize_fingerprint=True)
|
||
resp = http_get(url, timeout=30, session=s)
|
||
if resp is not None:
|
||
html_content = resp.text
|
||
if html_content:
|
||
break
|
||
except Exception:
|
||
continue
|
||
|
||
# 级别 6: http_utils fetch_text(最后同步手段)
|
||
if html_content is None and HAS_HTTP_UTILS:
|
||
try:
|
||
html_content = fetch_text(url, timeout=30)
|
||
except Exception:
|
||
pass
|
||
|
||
# ── 解析阶段 ──
|
||
if page_obj is not None:
|
||
# 优先用 Scrapling Selector
|
||
result = _dispatch_parse(page_obj, product_type, url, result)
|
||
elif html_content:
|
||
# 用 HTML 字符串解析
|
||
result = _dispatch_parse(html_content, product_type, url, result)
|
||
# v3.0: JSON-LD fallback — 如果 CSS/正则解析结果不足,尝试 JSON-LD
|
||
if _is_result_sparse(result):
|
||
jsonld_data = extract_jsonld(html_content)
|
||
if jsonld_data:
|
||
product_info = extract_product_from_jsonld(jsonld_data)
|
||
_merge_sparse_result(result, product_info)
|
||
else:
|
||
result["warning"] = "所有爬取引擎均失败,仅返回 URL 提取信息"
|
||
result["solutions"] = [
|
||
"检查网络连接", "确认 URL 可访问",
|
||
"安装 scrapling: pip install scrapling",
|
||
"安装 playwright: pip install playwright && playwright install chromium"
|
||
]
|
||
|
||
return result
|
||
|
||
|
||
def _detect_product_type(url: str) -> str:
|
||
"""从 URL 自动检测产品类型"""
|
||
url_lower = url.lower()
|
||
if 'fof' in url_lower or 'FOF' in url:
|
||
return "fof"
|
||
elif 'portfolio' in url_lower or '组合' in url:
|
||
return "advisor"
|
||
elif 'fund.eastmoney' in url_lower or 'tiantian' in url_lower or '/fund/' in url_lower:
|
||
return "fund"
|
||
elif 'etf.' in url_lower or 'quotes.etf' in url_lower:
|
||
return "etf"
|
||
elif 'stock.eastmoney' in url_lower or 'quotes.stock' in url_lower or '10jqka' in url_lower:
|
||
return "stock"
|
||
elif 'bond.' in url_lower or 'cbond' in url_lower:
|
||
return "bond"
|
||
elif 'insurance' in url_lower or 'baodan' in url_lower:
|
||
return "insurance"
|
||
return "fund"
|
||
|
||
|
||
def _extract_code_from_url(url: str, result: Dict, product_type: str):
|
||
"""从 URL 中提取产品代码(即使爬取失败也能拿到基本信息)"""
|
||
# 基金代码:6位数字
|
||
fund_match = re.search(r'(?:fund|f|jj)[=/]*(\d{6})', url, re.IGNORECASE)
|
||
if not fund_match:
|
||
fund_match = re.search(r'/(\d{6})\.(?:html|shtml)', url)
|
||
if fund_match:
|
||
code = fund_match.group(1)
|
||
if product_type in ("fund", "etf", "fof", "auto"):
|
||
result["product_code"] = code
|
||
elif product_type == "stock":
|
||
result["stock_code"] = code
|
||
|
||
# 股票代码:sh/sz + 6位数字
|
||
stock_match = re.search(r'(?:sh|sz|SH|SZ)(\d{6})', url)
|
||
if stock_match:
|
||
result["stock_code"] = stock_match.group(1)
|
||
|
||
|
||
def _dispatch_parse(page_or_html, product_type: str, url: str,
|
||
result: Dict) -> Dict[str, Any]:
|
||
"""根据产品类型分派解析器"""
|
||
try:
|
||
if product_type == "fund":
|
||
parsed = FundParser.parse_tiantian_fund(page_or_html, url)
|
||
elif product_type == "etf":
|
||
parsed = ETFParser.parse_etf_page(page_or_html, url)
|
||
elif product_type == "stock":
|
||
parsed = StockParser.parse_stock_page(page_or_html, url)
|
||
elif product_type == "bond":
|
||
parsed = BondParser.parse_bond_page(page_or_html, url)
|
||
elif product_type == "fof":
|
||
parsed = FOFParser.parse_fof_page(page_or_html, url)
|
||
elif product_type == "advisor":
|
||
parsed = AdvisorPortfolioParser.parse_advisor_portfolio(url, page_or_html)
|
||
elif product_type == "insurance":
|
||
parsed = InsurerParser.parse_insurance_page(page_or_html, url)
|
||
else:
|
||
parsed = FundParser.parse_tiantian_fund(page_or_html, url)
|
||
result.update(parsed)
|
||
except Exception as e:
|
||
result["parse_error"] = str(e)[:200]
|
||
return result
|
||
|
||
|
||
def _is_result_sparse(result: Dict) -> bool:
|
||
"""判断解析结果是否稀疏(缺少关键字段)"""
|
||
name = result.get("product_name") or result.get("stock_name") or result.get("portfolio_name") or ""
|
||
code = result.get("product_code") or result.get("stock_code") or ""
|
||
nav = result.get("nav", {})
|
||
price = result.get("price", {})
|
||
return not name and not code and not nav and not price
|
||
|
||
|
||
def _merge_sparse_result(result: Dict, jsonld_info: Dict):
|
||
"""将 JSON-LD 提取的信息合并到稀疏结果中"""
|
||
if not result.get("product_name") and jsonld_info.get("product_name"):
|
||
result["product_name"] = jsonld_info["product_name"]
|
||
if not result.get("product_code") and jsonld_info.get("product_code"):
|
||
result["product_code"] = jsonld_info["product_code"]
|
||
if not result.get("company") and jsonld_info.get("company"):
|
||
result["company"] = jsonld_info["company"]
|
||
if jsonld_info.get("description"):
|
||
result["description"] = jsonld_info["description"]
|
||
if jsonld_info.get("price"):
|
||
if "price" not in result:
|
||
result["price"] = {}
|
||
result["price"]["jsonld_value"] = jsonld_info["price"]
|
||
result["_jsonld_fallback"] = True
|
||
|
||
|
||
def parse_product_from_html(html: str, product_type: str, url: str = "") -> Dict[str, Any]:
|
||
"""
|
||
从HTML内容解析金融产品(v3.0: 增加 JSON-LD fallback)
|
||
|
||
Args:
|
||
html: HTML内容
|
||
product_type: 产品类型
|
||
url: 原始URL(可选)
|
||
|
||
Returns:
|
||
产品信息
|
||
"""
|
||
result = {"source_url": url, "scrape_time": time.strftime("%Y-%m-%d %H:%M:%S")}
|
||
|
||
# 主解析
|
||
parsed = {}
|
||
try:
|
||
if product_type == "fund":
|
||
parsed = FundParser.parse_tiantian_fund(html, url)
|
||
elif product_type == "etf":
|
||
parsed = ETFParser.parse_etf_page(html, url)
|
||
elif product_type == "stock":
|
||
parsed = StockParser.parse_stock_page(html, url)
|
||
elif product_type == "bond":
|
||
parsed = BondParser.parse_bond_page(html, url)
|
||
elif product_type == "fof":
|
||
parsed = FOFParser.parse_fof_page(html, url)
|
||
elif product_type == "advisor":
|
||
parsed = AdvisorPortfolioParser.parse_advisor_portfolio(url, html)
|
||
else:
|
||
return {"error": f"不支持的产品类型: {product_type}"}
|
||
except Exception as e:
|
||
parsed = {"parse_error": str(e)[:200]}
|
||
|
||
result.update(parsed)
|
||
|
||
# v3.0: JSON-LD fallback
|
||
if _is_result_sparse(result):
|
||
jsonld_data = extract_jsonld(html)
|
||
if jsonld_data:
|
||
product_info = extract_product_from_jsonld(jsonld_data)
|
||
_merge_sparse_result(result, product_info)
|
||
|
||
# 从 URL 提取代码
|
||
if url:
|
||
_extract_code_from_url(url, result, product_type)
|
||
|
||
return result
|
||
|
||
|
||
def extract_financial_metrics(text: str) -> Dict[str, Any]:
|
||
"""
|
||
从文本中提取金融指标
|
||
|
||
Args:
|
||
text: 文本内容
|
||
|
||
Returns:
|
||
金融指标字典
|
||
"""
|
||
metrics = {}
|
||
|
||
# 收益率
|
||
return_patterns = [
|
||
(r'近1月[收益收益率]*[::]*\s*([+-]?\d+\.?\d*)%?', 'return_1m'),
|
||
(r'近3月[收益收益率]*[::]*\s*([+-]?\d+\.?\d*)%?', 'return_3m'),
|
||
(r'近6月[收益收益率]*[::]*\s*([+-]?\d+\.?\d*)%?', 'return_6m'),
|
||
(r'近1年[收益收益率]*[::]*\s*([+-]?\d+\.?\d*)%?', 'return_1y'),
|
||
(r'近3年[收益收益率]*[::]*\s*([+-]?\d+\.?\d*)%?', 'return_3y'),
|
||
(r'今年来[收益收益率]*[::]*\s*([+-]?\d+\.?\d*)%?', 'return_ytd'),
|
||
]
|
||
|
||
for pattern, key in return_patterns:
|
||
match = re.search(pattern, text)
|
||
if match:
|
||
try:
|
||
metrics[key] = float(match.group(1))
|
||
except (ValueError, TypeError):
|
||
pass
|
||
|
||
# 风险指标
|
||
risk_patterns = [
|
||
(r'夏普比率[((]Sharpe[))][::]*\s*([+-]?\d+\.?\d*)', 'sharpe_ratio'),
|
||
(r'最大回撤[::]*\s*([+-]?\d+\.?\d*)%?', 'max_drawdown'),
|
||
(r'波动率[::]*\s*(\d+\.?\d*)%?', 'volatility'),
|
||
(r'卡玛比率[::]*\s*([+-]?\d+\.?\d*)', 'calmar_ratio'),
|
||
]
|
||
|
||
for pattern, key in risk_patterns:
|
||
match = re.search(pattern, text)
|
||
if match:
|
||
try:
|
||
metrics[key] = float(re.search(r'[+-]?\d+\.?\d*', match.group(0)).group())
|
||
except:
|
||
pass
|
||
|
||
# 估值指标
|
||
val_patterns = [
|
||
(r'市盈率[((]PE[))][::]*\s*(\d+\.?\d*)', 'pe'),
|
||
(r'市净率[((]PB[))][::]*\s*(\d+\.?\d*)', 'pb'),
|
||
(r'市销率[((]PS[))][::]*\s*(\d+\.?\d*)', 'ps'),
|
||
(r'股息率[::]*\s*(\d+\.?\d*)%?', 'dividend_yield'),
|
||
]
|
||
|
||
for pattern, key in val_patterns:
|
||
match = re.search(pattern, text)
|
||
if match:
|
||
try:
|
||
metrics[key] = float(re.search(r'\d+\.?\d*', match.group(0)).group())
|
||
except:
|
||
pass
|
||
|
||
return metrics
|
||
|
||
|
||
def format_product_summary(product_info: Dict[str, Any]) -> str:
|
||
"""
|
||
格式化产品信息为可读摘要
|
||
|
||
Args:
|
||
product_info: 产品信息字典
|
||
|
||
Returns:
|
||
格式化摘要文本
|
||
"""
|
||
if "error" in product_info:
|
||
return f"解析错误: {product_info['error']}"
|
||
|
||
source = product_info.get("source", "unknown")
|
||
summary = f"【{source.upper()} 产品解析结果】\n\n"
|
||
|
||
# 基本信息
|
||
name = product_info.get("product_name") or product_info.get("stock_name", "") or \
|
||
product_info.get("portfolio_name", "未知")
|
||
code = product_info.get("product_code") or product_info.get("stock_code", "")
|
||
|
||
summary += f"名称: {name}\n"
|
||
if code:
|
||
summary += f"代码: {code}\n"
|
||
|
||
ptype = product_info.get("product_type") or product_info.get("insurance_type", "") or \
|
||
product_info.get("fof_category", "")
|
||
if ptype:
|
||
summary += f"类型: {ptype}\n"
|
||
|
||
company = product_info.get("company") or product_info.get("platform", "")
|
||
if company:
|
||
summary += f"机构: {company}\n"
|
||
|
||
# 净值/价格
|
||
nav = product_info.get("nav", {})
|
||
price = product_info.get("price", {})
|
||
if nav:
|
||
if "current" in nav:
|
||
summary += f"\n单位净值: {nav['current']}\n"
|
||
elif "unit" in nav:
|
||
summary += f"\n单位净值: {nav['unit']}\n"
|
||
if "accumulated" in nav:
|
||
summary += f"累计净值: {nav['accumulated']}\n"
|
||
|
||
if price:
|
||
if "current" in price:
|
||
summary += f"\n现价: {price['current']}\n"
|
||
|
||
# 收益率
|
||
hist = product_info.get("historical_nav", {})
|
||
perf = product_info.get("performance", {})
|
||
cum_return = product_info.get("cumulative_return", 0)
|
||
|
||
if hist:
|
||
summary += "\n历史收益:\n"
|
||
for period, value in list(hist.items())[:5]:
|
||
summary += f" 近{period}: {value}%\n"
|
||
elif perf:
|
||
summary += "\n收益表现:\n"
|
||
for period, value in list(perf.items())[:5]:
|
||
summary += f" {period}: {value}%\n"
|
||
elif cum_return:
|
||
summary += f"\n累计收益: {cum_return:+.2f}%\n"
|
||
|
||
# FOF特有 - 资产配置
|
||
asset_alloc = product_info.get("asset_allocation", {})
|
||
if asset_alloc:
|
||
summary += "\n资产配置:\n"
|
||
for k, v in asset_alloc.items():
|
||
if v > 0:
|
||
summary += f" {k}: {v}%\n"
|
||
|
||
# 投资顾问组合 - 配置汇总
|
||
alloc_summary = product_info.get("allocation_summary", {})
|
||
if alloc_summary:
|
||
summary += "\n配置比例:\n"
|
||
for k, v in alloc_summary.items():
|
||
if v > 0:
|
||
summary += f" {k}: {v}%\n"
|
||
|
||
# 风险指标
|
||
metrics = product_info.get("risk_metrics", {})
|
||
if metrics:
|
||
summary += "\n风险指标:\n"
|
||
for k, v in list(metrics.items())[:5]:
|
||
summary += f" {k}: {v}\n"
|
||
|
||
# 持仓
|
||
holdings = product_info.get("holdings", {})
|
||
positions = product_info.get("positions", [])
|
||
underlying = product_info.get("underlying_funds", [])
|
||
|
||
if holdings.get("stocks"):
|
||
summary += "\n重仓股:\n"
|
||
for s in holdings["stocks"][:5]:
|
||
summary += f" {s.get('name', '')}({s.get('code', '')}) - {s.get('weight', 0)}%\n"
|
||
elif underlying:
|
||
summary += "\n底层基金持仓:\n"
|
||
for f in underlying[:10]:
|
||
summary += f" {f.get('name', '')}({f.get('code', '')}) - {f.get('weight', 0):.2f}%\n"
|
||
elif positions:
|
||
summary += f"\n持仓明细 (共{len(positions)}只):\n"
|
||
for p in positions[:10]:
|
||
summary += f" {p.get('name', '')}({p.get('code', '')}) - {p.get('weight', 0):.2f}%\n"
|
||
|
||
return summary
|
||
|
||
|
||
# ============ CLI入口 ============
|
||
|
||
if __name__ == "__main__":
|
||
import sys
|
||
|
||
if len(sys.argv) < 2:
|
||
print("用法:")
|
||
print(" python web_parser.py <URL> [产品类型]")
|
||
print(" python web_parser.py --html <HTML文件> [产品类型]")
|
||
print("\n产品类型: fund, etf, stock, bond, insurance, auto")
|
||
print("示例:")
|
||
print(" python web_parser.py https://fund.eastmoney.com/000001.html fund")
|
||
print(" python web_parser.py --html page.html fund")
|
||
sys.exit(1)
|
||
|
||
if sys.argv[1] == "--html":
|
||
# 从HTML文件解析
|
||
html_file = sys.argv[2]
|
||
product_type = sys.argv[3] if len(sys.argv) > 3 else "fund"
|
||
|
||
with open(html_file, 'r', encoding='utf-8') as f:
|
||
html = f.read()
|
||
|
||
result = parse_product_from_html(html, product_type)
|
||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||
|
||
else:
|
||
url = sys.argv[1]
|
||
product_type = sys.argv[2] if len(sys.argv) > 2 else "auto"
|
||
|
||
print(f"正在解析: {url}")
|
||
print(f"产品类型: {product_type}")
|
||
|
||
result = parse_financial_product(url, product_type)
|
||
print("\n" + format_product_summary(result)) |