"""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 备忘)。 """ 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"]*>", 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, }