# -*- coding: utf-8 -*- """ 新闻智能跟踪系统 - 真实网页采集层 职责: 1. 抓取数据源页面(真实 URL),用 readability 风格启发式清洗出「干净可读正文」 2. 从页面提取候选资讯链接(标题 + 绝对 URL),按文章相似度排序 3. 对前 N 条候选链接抓取全文,一并入库(存 articles.full_text 便于后期查看) 4. example.com 等模拟源 / 抓取失败源,回退到 simulate 模拟数据,保证全链路不空转 数据结构与 simulate 保持一致:fetch_source(source) -> list[dict] (title/url/content/summary/published_at/domain/entities/source_id/full_text) """ import json import re import time from datetime import datetime from urllib.parse import urljoin, urlparse import requests from bs4 import BeautifulSoup import config import db import simulate # 直接丢弃的标签(导航/脚本/广告/评论等) _BAD_TAGS = [ "script", "style", "noscript", "iframe", "form", "button", "select", "option", "input", "svg", "canvas", "video", "audio", "nav", "footer", "header", "aside", ] # 出现在 class/id 中即视为噪音块的片段 _BAD_HINTS = [ "comment", "share", "social", "advert", "ads", "menu", "sidebar", "footer", "nav", "related", "recommend", "promo", "newsletter", "subscribe", "cookie", "banner", "breadcrumb", "pagination", "toolbar", "login", "signup", "tag-list", "author", "meta", "bottom", "widget", "popup", "overlay", ] # 不是资讯链接的后缀/前缀 _BAD_HREFS = ("mailto:", "tel:", "javascript:", "#", "?login", "?signup") _BAD_EXTS = (".css", ".js", ".ico", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".pdf", ".zip", ".xml", ".rss", ".atom", ".json") # 常见导航词(短标题/纯导航链接,不当作资讯) _NAV_WORDS = { "research", "business", "developers", "about", "careers", "blog", "contact", "sign in", "sign up", "login", "register", "privacy", "terms", "legal", "trust", "customer stories", "partners", "docs", "api log in", "skip to main content", "home", "news", "newsroom", "press", "company", "safety", "product", "products", "pricing", "solutions", "learn more", "read more", "view all", "see all", "all news", "all stories", "open a new window", "instagram", "twitter", "facebook", "linkedin", "youtube", "github", "login", "log in", "get started", "try now", } # 标题尾部常见的“分类 + 日期”后缀,清洗掉(如 "Company Aug 27, 2026") _TRAIL_DATE_RE = re.compile(r"\s+\S+\s+[A-Z][a-z]{2}\s+\d{1,2},?\s+\d{4}$") def fetch_page(url, timeout=None, retries=2): """抓取页面 HTML,带浏览器 UA 与语言头;瞬断自动重试""" hdr = { "User-Agent": config.CRAWL_DEFAULTS["user_agent"], "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", } last = None for i in range(retries + 1): try: r = requests.get(url, headers=hdr, timeout=timeout or config.CRAWL_DEFAULTS["crawl_timeout"]) r.raise_for_status() return r.text except Exception as e: last = e if i < retries: time.sleep(1.5 * (i + 1)) raise last # ---------------- 正文清洗(readability 风格) ---------------- def _is_noise(tag): if tag is None or not hasattr(tag, "name") or tag.name is None: return True if tag.name in _BAD_TAGS: return True try: tokens = [] for t in tag.get("class") or []: # Tailwind 任意值类(如 [--header-h:...])只是 CSS 变量,语义噪音,跳过 if t.startswith("[") and t.endswith("]"): continue tokens.append(t.lower()) tokens.append((tag.get("id") or "").lower()) cls = " ".join(tokens) except Exception: cls = "" return any(h in cls for h in _BAD_HINTS) def _strip_noise(soup): """删除噪音节点,返回清洗后的 body。先收集再统一删除,避免迭代中改树。""" noise = [] for tag in list(soup.find_all(True)): try: if tag is None: continue if _is_noise(tag): noise.append(tag) continue style = (tag.get("style") or "").lower() if "display:none" in style or "visibility:hidden" in style: noise.append(tag) except Exception: continue for tag in noise: try: tag.decompose() except Exception: pass return soup.body or soup def _text_density(tag): """正文块得分:有效文本长度 + 段落/标题数量""" text = tag.get_text(" ", strip=True) if not text: return 0 paras = len(tag.find_all(["p", "h1", "h2", "h3", "h4", "li", "pre", "blockquote"])) links = len(tag.find_all("a")) # 链接占比例过高多半是导航/聚合页,降权 link_penalty = min(1.0, links / max(1, paras) * 0.5) return len(text) * (1 - link_penalty) + paras * 30 def _best_content_node(root): """从清洗后的文档里挑选正文块(按文本密度打分)""" if root is None: return None candidates = root.find_all(["article", "main", "div", "section"]) if not candidates: return root best, best_score = root, 0 for c in candidates: score = _text_density(c) if score > best_score: best, best_score = c, score return best def _node_to_lines(node): """把正文块转成干净的按行文本(保留段落结构)""" if node is None: return [] lines = [] for el in node.find_all(["h1", "h2", "h3", "h4", "p", "li", "pre", "blockquote", "td", "th"]): if el.find_parent("pre") is not None and el.name != "pre": continue t = el.get_text(" ", strip=True) t = re.sub(r"\s+", " ", t) if len(t) >= 2: lines.append(t) if not lines: t = node.get_text(" ", strip=True) t = re.sub(r"\s+", " ", t) lines = [t] if t else [] return lines def clean_html(html, url=""): """抓到的原始 HTML -> (页面标题, 干净可读正文纯文本)""" soup = BeautifulSoup(html, "lxml") title = (soup.title.get_text(strip=True) if soup.title else "") or url body = _strip_noise(soup) node = _best_content_node(body) text = "\n".join(_node_to_lines(node)) text = re.sub(r"\n{3,}", "\n\n", text).strip() return title, text # ---------------- 链接提取 ---------------- def _is_plausible_article(a, base_url): href = (a.get("href") or "").strip() if not href or href.startswith(_BAD_HREFS): return False low = href.lower() if any(low.endswith(e) for e in _BAD_EXTS): return False if urlparse(urljoin(base_url, href)).fragment: return False text = re.sub(r"\s+", " ", a.get_text(" ", strip=True)).strip() if len(text) < 10: return False if text.lower().strip() in _NAV_WORDS: return False if re.fullmatch(r"[\d\s·|/\\\-_]+", text): return False return True def extract_links(html, base_url, max_n=10): """从页面提取候选资讯链接 -> [{'title','url','summary'}](按文章相似度排序)""" soup = BeautifulSoup(html, "lxml") cands, seen = [], set() for a in soup.find_all("a", href=True): href = urljoin(base_url, a["href"].strip()) if href in seen or not _is_plausible_article(a, base_url): continue seen.add(href) text = re.sub(r"\s+", " ", a.get_text(" ", strip=True)).strip() text = _TRAIL_DATE_RE.sub("", text).strip() if len(text) < 10: continue # 标题在标题标签/文章块内 → 更可能是资讯 in_head = 1 if a.find_parent(["h1", "h2", "h3", "h4", "article", "main"]) else 0 parent = a.find_parent(["li", "p", "h1", "h2", "h3", "h4", "article"]) or a.parent summary = "" if parent is not None and getattr(parent, "get_text", None): summary = re.sub(r"\s+", " ", parent.get_text(" ", strip=True))[:200] cands.append({"title": text, "url": href, "summary": summary, "score": len(text) + in_head * 200}) cands.sort(key=lambda x: x["score"], reverse=True) return [{"title": c["title"], "url": c["url"], "summary": c["summary"]} for c in cands[:max_n]] # ---------------- web-capture-api 集成(数据源网页抓取) ---------------- def _webcapture_cfg(): cfg = dict(config.WEBCAPTURE_DEFAULTS) cfg.update(db.get_all_settings().get("webcapture", {})) return cfg def _source_capture_params(source): """解析数据源的抓取参数(capture_params JSON -> dict)""" raw = source.get("capture_params") or "{}" try: p = json.loads(raw) except Exception: p = {} return p if isinstance(p, dict) else {} def _call_webcapture(url, action, params=None): """调用 web-capture-api 抓取网页(html/text)。失败抛异常。 返回: {"success": True, "title": ..., "html"|"text": ...} """ cfg = _webcapture_cfg() base = (cfg.get("api_url") or "").rstrip("/") if not base: raise RuntimeError("web-capture-api 地址未配置(设置页可修改)") payload = {"url": url, "action": action} p = params or {} for k in ("wait_time", "scroll_times", "scroll_delay", "full_page", "backend", "viewport"): if k in p and p[k] not in (None, ""): payload[k] = p[k] r = requests.post(f"{base}/api/capture", json=payload, timeout=int(cfg.get("timeout", 60))) r.raise_for_status() data = r.json() if not data.get("success"): raise RuntimeError(data.get("error", "web-capture-api 返回失败")) return data # ---------------- 按源采集 ---------------- def _now(): return datetime.now().strftime("%Y-%m-%d %H:%M:%S") def _fetch_list_via_direct(url): """直接抓取(requests + bs4):返回 (title, page_text, links)""" per = config.CRAWL_DEFAULTS["per_source_links"] html = fetch_page(url) page_title, page_text = clean_html(html, url) links = extract_links(html, url, max_n=per) return page_title, page_text, links def _fetch_list_via_webcapture(url, params): """走 web-capture-api 抓取列表页:返回 (title, page_text, links)""" per = config.CRAWL_DEFAULTS["per_source_links"] action = (params.get("action") or "html").strip() or "html" data = _call_webcapture(url, action, params) title = data.get("title") or url if action == "text": # 只取正文,无法提取子链接 → 整页作为一条 return title, (data.get("text") or ""), [] html = data.get("html") or "" page_text = (data.get("text") or "").strip() if not page_text and html: page_text = clean_html(html, url)[1] links = extract_links(html, url, max_n=per) if html else [] return title, page_text, links def _fetch_full_via_direct(url): return clean_html(fetch_page(url), url)[1] def _fetch_full_via_webcapture(url, params): data = _call_webcapture(url, "text", params) return data.get("text") or "" def fetch_source(source): """采集单个数据源 -> list[item]。 example.com 占位源返回模拟数据;真实源抓取失败抛异常(由调用方捕获并标记 error)。 fetch_method:auto=优先 web-capture-api、失败回退直接抓取;webcapture=仅 web-capture-api; direct=直接抓取(requests+bs4)。各源可单独配置 capture_params 抓取参数。 """ url = (source.get("url") or "").strip() # 模拟源(example.com 占位)→ 用仿真数据填充(补齐 source_id,保证定制监控识别正确) if not url or "example.com" in url: items = simulate.items_for_source(source["id"], source.get("type", "")) for it in items: it["source_id"] = source["id"] return items method = (source.get("fetch_method") or "auto").strip() or "auto" per = config.CRAWL_DEFAULTS["per_source_links"] full = config.CRAWL_DEFAULTS["full_fetch_links"] params = _source_capture_params(source) # 抓取列表页(标题 + 正文 + 候选链接) if method == "direct": page_title, page_text, links = _fetch_list_via_direct(url) else: try: page_title, page_text, links = _fetch_list_via_webcapture(url, params) except Exception: if method == "webcapture": raise # auto:回退直接抓取 page_title, page_text, links = _fetch_list_via_direct(url) items = [] for i, lk in enumerate(links[:per]): full_text = "" content = lk["summary"] if i < full and lk["url"]: try: if method == "direct": full_text = _fetch_full_via_direct(lk["url"]) else: try: full_text = _fetch_full_via_webcapture(lk["url"], params) except Exception: if method == "webcapture": raise full_text = _fetch_full_via_direct(lk["url"]) if not content or len(content) < len(full_text): content = full_text except Exception: full_text = "" items.append({ "title": lk["title"], "url": lk["url"], "author": source.get("name", ""), "content": (content or page_text)[:4000], "summary": (lk["summary"] or content or page_text)[:220], "domain": source.get("type") or "", "entities": [], "source_id": source["id"], "published_at": _now(), "full_text": full_text or "", }) if not items and page_text: # 页面本身即正文(如单篇/无链接页/action=text)→ 整页作为一条 items.append({ "title": page_title, "url": url, "author": source.get("name", ""), "content": page_text[:4000], "summary": page_text[:220], "domain": source.get("type") or "", "entities": [], "source_id": source["id"], "published_at": _now(), "full_text": page_text, }) return items def fetch_all(): """采集全部启用数据源 -> (items, {source_id: 条数}) 真实源失败:跳过并标记 sources.status=error(网页可见),不塞模拟数据。""" sources = db.list_sources(only_enabled=True) items, per, failed = [], {}, [] for s in sources: try: got = fetch_source(s) per[s["id"]] = len(got) items.extend(got) except Exception: failed.append(s["id"]) per[s["id"]] = 0 # 标记各源采集状态 for sid in failed: db.update_source_fetch(sid, status="error", count=0) for sid in set(per.keys()) - set(failed): db.update_source_fetch(sid, status="ok", count=per.get(sid, 0)) return items, per