Files

352 lines
13 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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])