352 lines
13 KiB
Python
352 lines
13 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
网页内容识别引擎 v7.3.0 (web_recognizer.py)
|
||
|
||
增强"网页上相关内容的识别和爬取":零依赖(正则 + html.parser)识别金融网页的
|
||
结构化内容,输出可直接入库/分析的 dict。
|
||
|
||
能力:
|
||
- extract_tables : 识别全部 <table>(含表头/行数据/合并单元格摊平)
|
||
- extract_kv_blocks : 识别键值数据块(dl/dt/dd、label:value 模式,如净值/涨跌幅)
|
||
- extract_article_items: 列表页识别文章条目(标题/链接/日期),供后续批量爬取
|
||
- extract_numbers_with_context: 正文中金额/百分比数字 + 上下文抽取
|
||
- detect_pagination : 分页识别(下一页链接 + 页码规律)
|
||
- recognize_page_type : 页面类型判别(list/article/data/home)
|
||
- smart_extract : 一站式识别(自动按页面类型组合上述能力)
|
||
|
||
用法:
|
||
from scripts.web_recognizer import smart_extract
|
||
result = smart_extract(html, url="https://example.com/news")
|
||
# {"page_type": "list", "articles": [...], "tables": [...], ...}
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
from html.parser import HTMLParser
|
||
from typing import Any, Dict, List, Optional, Tuple
|
||
from urllib.parse import urljoin, urlparse
|
||
|
||
# ── 通用小工具 ─────────────────────────────────────────────────
|
||
|
||
_TAG_RE = re.compile(r"<[^>]+>")
|
||
_WS_RE = re.compile(r"\s+")
|
||
_SCRIPT_RE = re.compile(r"<(script|style|noscript)[^>]*>.*?</\1>",
|
||
re.DOTALL | re.IGNORECASE)
|
||
|
||
|
||
def strip_tags(html: str) -> str:
|
||
"""去标签 + 压缩空白(纯文本化)。"""
|
||
text = _SCRIPT_RE.sub(" ", html or "")
|
||
text = _TAG_RE.sub(" ", text)
|
||
return _WS_RE.sub(" ", text).strip()
|
||
|
||
|
||
def _clean(s: str) -> str:
|
||
return _WS_RE.sub(" ", (s or "")).strip()
|
||
|
||
|
||
class _TableCollector(HTMLParser):
|
||
"""收集页面内全部 table 的原始单元格文本。"""
|
||
|
||
def __init__(self):
|
||
super().__init__(convert_charrefs=True)
|
||
self.tables: List[List[List[str]]] = [] # [table][row][cell]
|
||
self._rows: Optional[List[List[str]]] = None
|
||
self._cell: Optional[List[str]] = None
|
||
self._depth = 0
|
||
self._skip = 0
|
||
|
||
def handle_starttag(self, tag, attrs):
|
||
if tag in ("script", "style"):
|
||
self._skip += 1
|
||
return
|
||
if self._skip:
|
||
return
|
||
if tag == "table":
|
||
self._depth += 1
|
||
if self._depth == 1:
|
||
self._rows = []
|
||
elif self._depth == 1 and tag == "tr" and self._rows is not None:
|
||
self._rows.append([])
|
||
elif self._depth == 1 and tag in ("td", "th"):
|
||
self._cell = []
|
||
|
||
def handle_endtag(self, tag):
|
||
if tag in ("script", "style"):
|
||
self._skip = max(0, self._skip - 1)
|
||
return
|
||
if self._skip:
|
||
return
|
||
if tag in ("td", "th") and self._cell is not None:
|
||
if self._rows and self._rows[-1] is not None:
|
||
self._rows[-1].append(_clean("".join(self._cell)))
|
||
self._cell = None
|
||
elif tag == "table":
|
||
self._depth -= 1
|
||
if self._depth == 0 and self._rows is not None:
|
||
rows = [r for r in self._rows if r]
|
||
if rows:
|
||
self.tables.append(rows)
|
||
self._rows = None
|
||
|
||
def handle_data(self, data):
|
||
if self._skip:
|
||
return
|
||
if self._cell is not None:
|
||
self._cell.append(data)
|
||
|
||
|
||
def extract_tables(html: str, min_rows: int = 2,
|
||
max_tables: int = 20) -> List[Dict[str, Any]]:
|
||
"""识别页面中的全部表格。
|
||
|
||
Returns:
|
||
[{"headers": [...], "rows": [[...]], "n_rows": n, "n_cols": n}, ...]
|
||
表头启发式:首行无数字且列数一致时视为表头。
|
||
"""
|
||
if not html:
|
||
return []
|
||
collector = _TableCollector()
|
||
try:
|
||
collector.feed(html)
|
||
except Exception:
|
||
return []
|
||
out = []
|
||
for raw in collector.tables[:max_tables]:
|
||
if len(raw) < min_rows:
|
||
continue
|
||
n_cols = max(len(r) for r in raw)
|
||
if n_cols < 2:
|
||
continue
|
||
headers: List[str] = []
|
||
rows = raw
|
||
first = raw[0]
|
||
has_digit = any(re.search(r"\d", c) for c in first)
|
||
if not has_digit and len(raw) > 1:
|
||
headers = first
|
||
rows = raw[1:]
|
||
out.append({"headers": headers, "rows": rows,
|
||
"n_rows": len(rows), "n_cols": n_cols})
|
||
return out
|
||
|
||
|
||
# ── 键值数据块识别 ─────────────────────────────────────────────
|
||
|
||
_DL_RE = re.compile(r"<dl[^>]*>(.*?)</dl>", re.DOTALL | re.IGNORECASE)
|
||
_DT_RE = re.compile(r"<dt[^>]*>(.*?)</dt>", re.DOTALL | re.IGNORECASE)
|
||
_DD_RE = re.compile(r"<dd[^>]*>(.*?)</dd>", re.DOTALL | re.IGNORECASE)
|
||
|
||
# 金融常见键名(用于散落在文本中的 label:value 识别)
|
||
_FIN_LABELS = (
|
||
"最新净值|累计净值|单位净值|日增长率|涨跌幅|涨跌|今开|昨收|最高|最低|"
|
||
"成交量|成交额|换手率|市盈率|市净率|总市值|流通市值|每股收益|每股净资产|"
|
||
"净资产收益率|营业收入|净利润|总资产|净资产|负债|收益率|年化收益|"
|
||
"最大回撤|夏普比率|成立日期|基金规模|基金经理|管理费率|托管费率|"
|
||
"申购状态|赎回状态|风险等级|发行价|现价|利息|利率|余额|规模"
|
||
)
|
||
_KV_TEXT_RE = re.compile(
|
||
r"(" + _FIN_LABELS + r")\s*[::]\s*([^\s<>]{1,40})")
|
||
|
||
|
||
def extract_kv_blocks(html: str, max_items: int = 200) -> Dict[str, str]:
|
||
"""识别键值型金融数据块(净值/涨跌幅/财务指标等)。
|
||
|
||
来源:1) <dl>/<dt>/<dd> 结构;2) 文本中「标签:值」模式。
|
||
键冲突时保留先出现的值。
|
||
"""
|
||
kv: Dict[str, str] = {}
|
||
if not html:
|
||
return kv
|
||
# 1) dl 结构
|
||
for dl in _DL_RE.findall(html):
|
||
dts = _DT_RE.findall(dl)
|
||
dds = _DD_RE.findall(dl)
|
||
for k, v in zip(dts, dds):
|
||
k, v = _clean(strip_tags(k)), _clean(strip_tags(v))
|
||
if k and v and k not in kv and len(kv) < max_items:
|
||
kv[k] = v
|
||
# 2) 文本 label:value
|
||
text = strip_tags(html)
|
||
for m in _KV_TEXT_RE.finditer(text):
|
||
k, v = _clean(m.group(1)), _clean(m.group(2))
|
||
if k and v and k not in kv and len(kv) < max_items:
|
||
kv[k] = v
|
||
return kv
|
||
|
||
|
||
# ── 列表页文章条目识别 ─────────────────────────────────────────
|
||
|
||
_A_RE = re.compile(r"<a\s+([^>]*)>(.*?)</a>", re.DOTALL | re.IGNORECASE)
|
||
_HREF_RE = re.compile(r"""href\s*=\s*["']([^"']+)["']""", re.IGNORECASE)
|
||
_DATE_RE = re.compile(
|
||
r"(20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}|(\d+)[天日小时分钟]+前|"
|
||
r"今天|昨天|前天|\d{1,2}:\d{2})")
|
||
_SKIP_LINK_RE = re.compile(
|
||
r"(javascript:|^#$|^mailto:|login|register|app下载)", re.IGNORECASE)
|
||
|
||
|
||
def extract_article_items(html: str, base_url: str = "",
|
||
min_title_len: int = 6,
|
||
max_items: int = 100) -> List[Dict[str, str]]:
|
||
"""列表页识别:提取文章条目(标题/链接/日期)。
|
||
|
||
启发式:链接文本 ≥ min_title_len 个字符、非导航词、
|
||
相邻上下文含日期则一并提取。
|
||
"""
|
||
if not html:
|
||
return []
|
||
items: List[Dict[str, str]] = []
|
||
seen_titles = set()
|
||
for m in _A_RE.finditer(html):
|
||
attrs, inner = m.group(1), m.group(2)
|
||
hm = _HREF_RE.search(attrs)
|
||
if not hm:
|
||
continue
|
||
href = hm.group(1).strip()
|
||
if not href or _SKIP_LINK_RE.search(href):
|
||
continue
|
||
title = _clean(strip_tags(inner))
|
||
if len(title) < min_title_len:
|
||
continue
|
||
if title in seen_titles:
|
||
continue
|
||
# 上下文 200 字符内找日期
|
||
ctx = html[m.end():m.end() + 200]
|
||
dm = _DATE_RE.search(strip_tags(ctx))
|
||
item = {"title": title, "url": urljoin(base_url, href) if base_url else href}
|
||
if dm:
|
||
item["date"] = dm.group(1)
|
||
seen_titles.add(title)
|
||
items.append(item)
|
||
if len(items) >= max_items:
|
||
break
|
||
return items
|
||
|
||
|
||
# ── 分页识别 ───────────────────────────────────────────────────
|
||
|
||
_NEXT_TEXT_RE = re.compile(r"(下一页|下页|更多|加载更多|next)", re.IGNORECASE)
|
||
_PAGE_URL_RE = re.compile(r"[?&](page|p|pn|pageNum|pageNo)=\d+", re.IGNORECASE)
|
||
|
||
|
||
def detect_pagination(html: str, base_url: str = "") -> Dict[str, Any]:
|
||
"""识别分页:返回下一页链接、是否存在页码参数、总页数估计。"""
|
||
result: Dict[str, Any] = {"next_url": None, "has_page_param": False,
|
||
"max_page": 0}
|
||
if not html:
|
||
return result
|
||
for m in _A_RE.finditer(html):
|
||
attrs, inner = m.group(1), strip_tags(m.group(2))
|
||
hm = _HREF_RE.search(attrs)
|
||
if not hm:
|
||
continue
|
||
href = hm.group(1).strip()
|
||
if _PAGE_URL_RE.search(href):
|
||
result["has_page_param"] = True
|
||
pm = re.search(r"[?&](?:page|p|pn|pageNum|pageNo)=(\d+)", href,
|
||
re.IGNORECASE)
|
||
if pm:
|
||
result["max_page"] = max(result["max_page"], int(pm.group(1)))
|
||
if result["next_url"] is None and _NEXT_TEXT_RE.search(inner):
|
||
result["next_url"] = urljoin(base_url, href) if base_url else href
|
||
return result
|
||
|
||
|
||
# ── 数字与上下文抽取 ───────────────────────────────────────────
|
||
|
||
_NUM_RE = re.compile(
|
||
r"(-?\d+(?:,\d{3})*(?:\.\d+)?)\s*(万亿|亿元|万元|亿|万|元|%|个百分点)?")
|
||
|
||
|
||
def extract_numbers_with_context(text: str, window: int = 20,
|
||
max_items: int = 100) -> List[Dict[str, Any]]:
|
||
"""从正文抽取金融数字(金额/百分比)+ 前后上下文。"""
|
||
out = []
|
||
if not text:
|
||
return out
|
||
for m in _NUM_RE.finditer(text):
|
||
raw, unit = m.group(1), m.group(2) or ""
|
||
try:
|
||
value = float(raw.replace(",", ""))
|
||
except ValueError:
|
||
continue
|
||
lo = max(0, m.start() - window)
|
||
hi = min(len(text), m.end() + window)
|
||
ctx = _clean(text[lo:hi])
|
||
out.append({"raw": raw + unit, "value": value, "unit": unit,
|
||
"context": ctx})
|
||
if len(out) >= max_items:
|
||
break
|
||
return out
|
||
|
||
|
||
# ── 页面类型判别 ───────────────────────────────────────────────
|
||
|
||
def recognize_page_type(html: str, url: str = "") -> str:
|
||
"""判别页面类型:list / article / data / home。
|
||
|
||
依据:文章条目数、表格数、键值块数、路径特征(纯启发式零依赖)。
|
||
"""
|
||
if not html:
|
||
return "unknown"
|
||
path = urlparse(url).path.lower() if url else ""
|
||
articles = extract_article_items(html, max_items=30)
|
||
tables = extract_tables(html, max_tables=5)
|
||
kv = extract_kv_blocks(html, max_items=50)
|
||
|
||
if len(articles) >= 5:
|
||
return "list"
|
||
if len(tables) >= 2 or len(kv) >= 8 or re.search(
|
||
r"(fund|stock|quote|data|行情)", path):
|
||
return "data"
|
||
text = strip_tags(html)
|
||
if len(text) >= 300 and len(articles) < 5:
|
||
return "article"
|
||
if path in ("", "/") or re.search(r"(index|home|main)", path):
|
||
return "home"
|
||
return "home" if len(articles) <= 1 else "list"
|
||
|
||
|
||
# ── 一站式识别 ─────────────────────────────────────────────────
|
||
|
||
def smart_extract(html: str, url: str = "") -> Dict[str, Any]:
|
||
"""按页面类型自动组合识别能力,输出结构化结果。"""
|
||
page_type = recognize_page_type(html, url)
|
||
result: Dict[str, Any] = {
|
||
"url": url, "page_type": page_type,
|
||
"text": strip_tags(html)[:2000],
|
||
}
|
||
if page_type == "list":
|
||
result["articles"] = extract_article_items(html, base_url=url)
|
||
result["pagination"] = detect_pagination(html, base_url=url)
|
||
elif page_type == "article":
|
||
result["numbers"] = extract_numbers_with_context(result["text"])
|
||
elif page_type == "data":
|
||
result["tables"] = extract_tables(html)
|
||
result["kv"] = extract_kv_blocks(html)
|
||
else:
|
||
result["articles"] = extract_article_items(html, base_url=url)
|
||
return result
|
||
|
||
|
||
if __name__ == "__main__":
|
||
import sys
|
||
if len(sys.argv) < 2:
|
||
print("用法: python scripts/web_recognizer.py <url>")
|
||
sys.exit(1)
|
||
try:
|
||
from .http_utils import http_get
|
||
except ImportError:
|
||
from http_utils import http_get # type: ignore
|
||
resp = http_get(sys.argv[1])
|
||
if resp is None:
|
||
print("抓取失败")
|
||
sys.exit(1)
|
||
import json as _json
|
||
out = smart_extract(resp.text, url=sys.argv[1])
|
||
out.pop("text", None)
|
||
for k in ("articles",):
|
||
if k in out:
|
||
out[k] = out[k][:10]
|
||
print(_json.dumps(out, ensure_ascii=False, indent=1)[:3000])
|