Files
ai-kefu-admin/app/platform/site_import/crawler.py
T
浅念 e1e97dac2c fix(site-import): FetchError 白话化映射(spec §6 行1)+ 预算语义注记
_pipeline 增 FetchError 分支(先于通用异常兜底):入口页不可达/超时——
最高频真实失败路径——映射白话文案「网站打不开或响应太慢——检查 URL」
+ phase=crawl(不可 retry);ImportTask docstring 的抓取失败归档口径
同步对齐。crawler 模块 docstring 注记 MAX_PAGES 预算语义(只计成功入编
页,链接农场广度消耗由总超时/并发/频控三层兜底)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-20 21:16:46 +08:00

194 lines
7.5 KiB
Python
Raw 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.
"""site_import 抓取编排(spec §3.1/§3.3 的 crawl 段)。
BFS 选页 ≤10(深度 ≤2)→ safe_fetch 逐页 → extract 清洗抽取 →
CSS/logo 追抓(15s 超时)→ 组装 crawl_result dict 供生成器。
入口页双重门(spec §3.1/§6):非 text/html → 零页内容不足;清洗后
<200 字符 → SPA 空壳(子页过薄只跳过不失败——JS 渲染的产品子页常见,
整站不该陪葬)。单页 >500KB 截断解析(clean_html 未闭合块二遍清洗兜底)。
discover_links 会回吐入口自身(href="#"/"/")——visited 预置 start URL
去重,防浪费 ≤10 页名额(Task 3 备忘)。MAX_PAGES 只计成功入编的页
(薄页/非 html/失败页不占名额)——链接农场的广度消耗由 7 分钟总超时/
并发 ≤2/频控三层兜底(预算语义记档)。
"""
from __future__ import annotations
import base64
import re
from collections import deque
from urllib.parse import urljoin, urlsplit, urlunsplit
from app.platform.site_import import safe_fetch
from app.platform.site_import.extract import (
clean_html, discover_links, extract_color, extract_contacts,
extract_logo_url, is_thin,
)
from app.platform.site_import.safe_fetch import FetchError
MAX_PAGES = 10
MAX_DEPTH = 2
DEFAULT_COLOR = "#066fc0" # extract_color None 的函数层不兜底原则(Task 3 备忘)
PAGE_TIMEOUT = 10.0
ASSET_TIMEOUT = 15.0
MAX_PAGE_CHARS = 500 * 1024 # 单页 500KB 上限(截断解析)
LOGO_MAX_BYTES = 2 * 1024 * 1024
LOGO_OK_MIME = {"image/png", "image/jpeg", "image/webp"}
INSUFFICIENT_TOTAL_CHARS = 1000
SPA_MESSAGE = "该网站需要浏览器渲染才能看到内容,暂不支持自动导入——请在配置中心手动配置"
INSUFFICIENT_MESSAGE = "网站内容不足以生成配置,请在配置中心手动配置"
# 首个外链样式表发现(link 标签内 rel 含 stylesheet → 提取 href)
_RE_LINK_TAG = re.compile(r"<link\b[^>]*>", re.IGNORECASE)
_RE_REL_STYLESHEET = re.compile(r"""rel\s*=\s*["']?\s*stylesheet\b""",
re.IGNORECASE)
_RE_HREF_IN_TAG = re.compile(r"""href\s*=\s*["']([^"']+)["']""",
re.IGNORECASE)
class CrawlError(Exception):
"""抓取阶段不可恢复失败(SPA 空壳/内容不足)——phase=crawl,不可 retry。"""
def _sniff_image(data: bytes) -> str | None:
"""魔数指纹(与 Content-Type 双验——防伪造头)。"""
if data.startswith(b"\x89PNG\r\n\x1a\n"):
return "image/png"
if data.startswith(b"\xff\xd8\xff"):
return "image/jpeg"
if len(data) >= 12 and data[:4] == b"RIFF" and data[8:12] == b"WEBP":
return "image/webp"
return None
def _normalize(url: str) -> str:
"""同源同径去重键(与 discover_links 的清洗口径对齐:去查询去锚点)。"""
parts = urlsplit(url)
return urlunsplit((parts.scheme.lower(), parts.netloc.lower(),
parts.path, "", ""))
def _html_text(resp) -> str | None:
"""Content-Type 非 text/html → None(调用方跳页/判零页);500KB 截断。"""
ct = resp.headers.get("content-type", "").lower()
if "text/html" not in ct:
return None
return resp.text[:MAX_PAGE_CHARS]
def _first_css_url(base_url: str, html_text: str) -> str | None:
for m in _RE_LINK_TAG.finditer(html_text):
tag = m.group(0)
if _RE_REL_STYLESHEET.search(tag):
hm = _RE_HREF_IN_TAG.search(tag)
if hm:
return urljoin(base_url, hm.group(1).strip())
return None
async def _fetch_logo(url: str, warnings: list[str]) -> str | None:
"""logo 下载校验(spec §3.1):MIME 白名单 + 魔数双验 + ≤2MB →
base64 data URL;不合格 → None + warning 注记(审核页无头像预览,
保存链路头像项空置——零新后端端点)。"""
try:
resp = await safe_fetch.fetch(url, ASSET_TIMEOUT)
except FetchError:
warnings.append("logo 下载失败,已跳过头像")
return None
data = resp.content
if len(data) > LOGO_MAX_BYTES:
warnings.append("logo 超过 2MB 上限,已跳过头像")
return None
mime = resp.headers.get("content-type", "").split(";", 1)[0].strip().lower()
sniffed = _sniff_image(data)
if mime not in LOGO_OK_MIME or sniffed is None or sniffed != mime:
warnings.append("logo 为不支持的格式,已跳过头像")
return None
return f"data:{sniffed};base64," + base64.b64encode(data).decode("ascii")
async def crawl(start_url: str) -> dict:
"""整站抓取编排,返回 crawl_result dict。
FetchError 不在此包装——交任务层 fail(白话化 + phase 归档)。
"""
warnings: list[str] = []
# 1) 入口页:非 text/html → 零页(内容不足三触发之一)
entry = await safe_fetch.fetch(start_url, PAGE_TIMEOUT)
entry_html = _html_text(entry)
if entry_html is None:
raise CrawlError(INSUFFICIENT_MESSAGE)
entry_clean = clean_html(entry_html)
if is_thin(entry_clean):
raise CrawlError(SPA_MESSAGE)
# 2) BFS:入口 depth 0 → 子页 ≤MAX_DEPTH;关键词优先序即抓取优先级
pages_text = entry_clean
htmls = [entry_html]
fetched = 1
visited = {_normalize(start_url)} # discover_links 含入口自身——预置去重
queue: deque[tuple[str, int]] = deque(
(u, 1) for u in discover_links(start_url, entry_html))
while queue and fetched < MAX_PAGES:
url, depth = queue.popleft()
norm = _normalize(url)
if norm in visited:
continue
visited.add(norm)
try:
resp = await safe_fetch.fetch(url, PAGE_TIMEOUT)
except FetchError:
warnings.append(f"子页抓取失败已跳过:{url}")
continue
html = _html_text(resp)
if html is None:
warnings.append(f"子页非 HTML 已跳过:{url}")
continue
clean = clean_html(html)
if is_thin(clean):
warnings.append(f"子页内容过薄已跳过:{url}")
continue
pages_text += "\n" + clean
htmls.append(html)
fetched += 1
if depth < MAX_DEPTH:
queue.extend((u, depth + 1) for u in discover_links(url, html))
# 3) 总文本 <1K(内容不足三触发之一)
if len(pages_text) < INSUFFICIENT_TOTAL_CHARS:
raise CrawlError(INSUFFICIENT_MESSAGE)
# 4) 主色:内联 style(全页 html 集)+ 首个外链 CSS(15s 追抓,失败
# 静默回退内联/默认色——样式表属增益项不陪葬)
css_text: str | None = None
css_url = _first_css_url(start_url, entry_html)
if css_url:
try:
css_resp = await safe_fetch.fetch(css_url, ASSET_TIMEOUT)
css_text = css_resp.text[:MAX_PAGE_CHARS]
except FetchError:
pass
combined_html = "\n".join(htmls)
color = extract_color(combined_html, css_text) or DEFAULT_COLOR
contacts = extract_contacts(combined_html)
logo_data_url = None
logo_url = extract_logo_url(combined_html)
if logo_url:
# src 可能是相对路径(WordPress uploads 常见)——对入口页绝对化
logo_data_url = await _fetch_logo(urljoin(start_url, logo_url),
warnings)
return {
"pages_text": pages_text,
"color": color,
"whatsapp": contacts["whatsapp"],
"email": contacts["email"],
"logo_data_url": logo_data_url,
"warnings": warnings,
"pages_fetched": fetched,
}