feat(site-import): 抓取编排与内存任务表

crawler:BFS ≤10 页(深度 ≤2)+ 入口双重门(非 html 零页/SPA 空壳)+
薄子页跳过 + CSS/logo 15s 追抓(MIME+魔数双验 ≤2MB → base64 data URL)
+ 总文本 <1K 内容不足 + 主色默认 #066fc0 回退;logo 相对路径对入口绝对化。
task:(slug, task_id) 联合索引内存表 + 30 分钟惰性清理 + 强引用防 GC +
全平台并发 ≤2(复活同占名额)+ 频控 3 次/15min(新建与 retry 同计,
SlidingWindowCounter 同款独立实例)+ 7 分钟总超时兜底(生成段超时标
generate 可 retry=generate 复活不重抓)+ 内容不足三触发失败文案映射。
conftest 增 autouse 复位夹具(任务表/频控单例跨测试隔离)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-20 20:57:50 +08:00
parent 2e40901d26
commit 8f2a73c5d4
4 changed files with 926 additions and 0 deletions
+191
View File
@@ -0,0 +1,191 @@
"""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"<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,
}
+243
View File
@@ -0,0 +1,243 @@
"""site_import 内存任务表(spec §3.3)——单进程 asyncio,(slug, task_id) 索引。
内存铁律:30 分钟惰性清理(查询/新建时顺带扫);强引用防 GC(裸
create_task 会被回收——asyncio 官方坑,模块级 dict 存 task 对象);
全平台并发 ≤2(复活任务占用同一名额);频控 3 次/15 分钟(新建与
retry 同计)。异常类型:ImportRateLimited→429 / ConcurrentLimit→409 /
LookupError→404 / ValueError→409——API 层(Task 7)映射。
7 分钟总超时兜底:超时落在生成段(抓取产物已在)标 failed_phase=
"generate"——retry=generate 是 spec §3.3 的预期恢复路径(不重抓);
落在抓取段则无产物,重开任务。
"""
from __future__ import annotations
import asyncio
import time
import uuid
from collections import defaultdict
from app.platform.site_import import crawler, generator
from app.platform.site_import.crawler import CrawlError, INSUFFICIENT_MESSAGE
from app.platform.site_import.generator import GenerateError
TASK_TTL = 30 * 60
TOTAL_TIMEOUT = 7 * 60
MAX_CONCURRENT = 2
IMPORT_WINDOW = 15 * 60
IMPORT_MAX = 3
class ImportRateLimited(Exception):
"""频控超限(15 分钟 3 次)——API 层映射 429。"""
class ConcurrentLimit(Exception):
"""全平台并发上限(≤2,复活任务同占名额)——API 层映射 409。"""
class _SlidingWindow:
"""(kind, slug) 键滑动窗计数——config_routes.SlidingWindowCounter 同款
风格的独立实例(勿共用 email/push 测试端点的预算)。同款语义:超限后
allow 守卫先于一切预检;计数只落在真正发起的这次(校验失败不消耗)。"""
def __init__(self, window_seconds: float = IMPORT_WINDOW,
max_calls: int = IMPORT_MAX) -> None:
self.window_seconds = window_seconds
self.max_calls = max_calls
self._calls: dict[tuple[str, str], list[float]] = defaultdict(list)
def allow(self, key: tuple[str, str]) -> bool:
self._prune(key, time.time())
return len(self._calls.get(key, ())) < self.max_calls
def record(self, key: tuple[str, str]) -> None:
now = time.time()
self._prune(key, now)
self._calls[key].append(now)
def _prune(self, key: tuple[str, str], now: float) -> None:
calls = self._calls.get(key)
if calls is None:
return
kept = [t for t in calls if t > now - self.window_seconds]
if kept:
self._calls[key] = kept
else:
self._calls.pop(key, None)
# 模块级单例:导入频控(键 ("import", slug),全平台各租户独立预算)
_import_limiter = _SlidingWindow()
# 内存任务表:(slug, task_id) 联合索引——跨租户持他人 id 查询即 miss
_tasks: dict[tuple[str, str], "ImportTask"] = {}
_lock = asyncio.Lock()
class TaskState:
PENDING = "pending"
CRAWLING = "crawling"
GENERATING = "generating"
DONE = "done"
FAILED = "failed"
ACTIVE = (PENDING, CRAWLING, GENERATING)
class ImportTask:
"""一条导入任务:状态机 + 阶段产物 + 强引用的后台 aio task。
failed_phase:"generate" 可 retry=generate 复活(复用 crawl_result,
不重抓);"crawl"(SPA/内容不足/抓取失败)与 None(超时落在抓取段/
未知异常)不可复活——重开任务。
"""
def __init__(self, slug: str, task_id: str, url: str | None) -> None:
self.slug = slug
self.task_id = task_id
self.url = url
self.state = TaskState.PENDING
self.phase: str | None = None # 运行中阶段(crawling/generating)
self.pages_fetched = 0
self.draft: dict | None = None
self.error: str | None = None
self.created_at = time.time()
self.finished_at: float | None = None
self.failed_phase: str | None = None
self.crawl_result: dict | None = None # retry=generate 复用
self.need_crawl = True
self._aio_task: asyncio.Task | None = None # 强引用防 GC
def fail(self, message: str, *, phase: str | None) -> None:
self.state = TaskState.FAILED
self.error = message
self.failed_phase = phase
self.phase = None
self.finished_at = time.time()
def _finish(self, draft: dict) -> None:
self.draft = draft
self.state = TaskState.DONE
self.phase = None
self.finished_at = time.time()
def _prune_expired(now: float | None = None) -> None:
"""30 分钟惰性清理(查询/新建顺带扫)。运行中任务防御性取消——
7 分钟总超时下正常不会发生(30 >> 7),仅兜异常态。"""
now = time.time() if now is None else now
deadline = now - TASK_TTL
for key in [k for k, t in _tasks.items() if t.created_at < deadline]:
stale = _tasks.pop(key)
if stale._aio_task is not None and not stale._aio_task.done():
stale._aio_task.cancel()
def _count_active() -> int:
return sum(1 for t in _tasks.values() if t.state in TaskState.ACTIVE)
def active_count() -> int:
"""全平台进行中任务数(pending/crawling/generating)。"""
_prune_expired()
return _count_active()
def get(slug: str, task_id: str) -> ImportTask | None:
"""(slug, task_id) 联合索引查询 + 惰性清理(跨租户 → None → 404)。"""
_prune_expired()
return _tasks.get((slug, task_id))
async def start(slug: str, url: str | None, *,
retry_task_id: str | None = None) -> str:
"""新建或复活(retry=generate)导入任务,返回 task_id。
频控守卫最先(超限后 429 优先于一切预检);record 只落在真正发起的
这次——LookupError/ValueError/ConcurrentLimit 不消耗预算。
"""
_prune_expired()
key = ("import", slug)
if not _import_limiter.allow(key):
raise ImportRateLimited(
f"导入太频繁,{IMPORT_WINDOW // 60} 分钟内最多 {IMPORT_MAX} 次,"
"稍后再试")
async with _lock:
if retry_task_id is not None:
target = _tasks.get((slug, retry_task_id))
if target is None:
raise LookupError(
f"task {retry_task_id!r} not found for tenant {slug!r}")
if (target.state != TaskState.FAILED
or target.failed_phase != "generate"):
raise ValueError("任务当前状态不可重试"
"(仅 generate 阶段失败可复活)")
# 并发名额对新建与 retry 复活同样生效(spec §3.3)
if _count_active() >= MAX_CONCURRENT:
raise ConcurrentLimit("已有导入任务进行中,稍后再试")
if retry_task_id is not None:
# 复活同 task_id:复用已抓产物,不重抓
target.state = TaskState.PENDING
target.error = None
target.failed_phase = None
target.finished_at = None
target.need_crawl = False
else:
target = ImportTask(slug, uuid.uuid4().hex, url)
_tasks[(slug, target.task_id)] = target
_import_limiter.record(key)
target._aio_task = asyncio.create_task(_pipeline(target))
return target.task_id
async def _pipeline(task_obj: ImportTask) -> None:
"""后台管线:7 分钟总超时兜底 + 异常归档为白话失败。"""
try:
await asyncio.wait_for(_run(task_obj), TOTAL_TIMEOUT)
except asyncio.TimeoutError:
task_obj.fail(
"导入超时,请重试",
phase="generate" if task_obj.crawl_result is not None else None)
except CrawlError as exc:
task_obj.fail(str(exc), phase="crawl") # SPA/内容不足——不可 retry
except GenerateError as exc:
task_obj.fail(str(exc), phase="generate") # retry=generate 复活
except Exception as exc: # 防御:未知异常不裸抛(任务表对象完整性优先)
task_obj.fail(f"导入失败:{exc}", phase=None)
async def _run(task_obj: ImportTask) -> None:
if task_obj.need_crawl:
task_obj.state = TaskState.CRAWLING
task_obj.phase = "crawling"
task_obj.crawl_result = await crawler.crawl(task_obj.url)
task_obj.pages_fetched = int(
task_obj.crawl_result.get("pages_fetched") or 0)
task_obj.state = TaskState.GENERATING
task_obj.phase = "generating"
provider = generator.make_provider(None) # resolver 三级链(平台设置/env)
# source_text 传 analyze 实际消费的截断后文本(24K 同款——Task 5 备忘:
# 否则截断段数字会被 build 的数字校验误剔除)
source = ((task_obj.crawl_result or {}).get("pages_text")
or "")[:generator.TOTAL_INPUT_CAP]
analysis = await generator.analyze(provider, source)
if not (analysis.get("product_lines") or []):
# 内容不足三触发之一(调用 1 产品线为空)——同 crawl 类不可复活
task_obj.fail(INSUFFICIENT_MESSAGE, phase="crawl")
return
gen = await generator.generate(provider, analysis)
task_obj._finish(
generator.build(gen, analysis, task_obj.crawl_result, source))
def _reset_for_test() -> None:
"""测试隔离(conftest autouse 前后各跑):任务表清空(运行中 aio
任务防御性取消)+ 频控窗计数/参数复位。"""
for t in _tasks.values():
if t._aio_task is not None and not t._aio_task.done():
t._aio_task.cancel()
_tasks.clear()
_import_limiter._calls.clear()
_import_limiter.window_seconds = IMPORT_WINDOW
_import_limiter.max_calls = IMPORT_MAX