# -*- coding: utf-8 -*-
"""
网页内容识别引擎 v7.3.0 (web_recognizer.py)
增强"网页上相关内容的识别和爬取":零依赖(正则 + html.parser)识别金融网页的
结构化内容,输出可直接入库/分析的 dict。
能力:
- extract_tables : 识别全部
(含表头/行数据/合并单元格摊平)
- 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"]*>(.*?)
", re.DOTALL | re.IGNORECASE)
_DT_RE = re.compile(r"]*>(.*?)", re.DOTALL | re.IGNORECASE)
_DD_RE = re.compile(r"]*>(.*?)", 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) /- /
- 结构;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"]*)>(.*?)", 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 ")
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])