docs: site-import 计划评审收口(6 硬伤+8 建议)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-20 16:57:55 +08:00
parent 196df2dea7
commit 29953b9fc8
@@ -12,7 +12,7 @@
**执行环境注意(Windows):**
- 后端:`PYTHONUTF8=1 .venv/Scripts/python -m pytest ...`;前端:`cd admin && pnpm test` / `pnpm build`
- **实施首步(Task 1)**:核查配置模型 max_tokens 上限(spec §3.2——resolver 默认 glm-4-flash 旧接口封顶 4095,用 env ZHIPU_API_KEY 直接打一次 `chat.completions.create(max_tokens=8192)` 看是否报错;报错则本计划内 MAX_TOKENS_TARGET 取 4095 并在 task.py 记档)——结果写进 Task 3 的常量
- **实施首步(Task 1)**:核查配置模型 max_tokens 上限(spec §3.2——resolver 默认 glm-4-flash 旧接口封顶 4095,用 env ZHIPU_API_KEY 直接打一次 `chat.completions.create(max_tokens=8192)` 看是否报错;报错则本计划内 MAX_TOKENS_TARGET 取 4095)——结果写进 **Task 5** 的常量
- dts 行尾纪律照旧(`git diff --ignore-cr-at-eol` 为空才 checkout 还原)
- 分支:`feature/site-import`(已建)
@@ -31,7 +31,7 @@
# PYTHONUTF8=1 .venv/Scripts/python -c 方式,读 .env 的 ZHIPU_API_KEY
# 尝试 max_tokens=8192 一次调用 "say ok";报 InvalidParameter/超限错 → 上限 4095
```
结论(8192 可用 / 用 4095)记入本任务 commit message 与 Task 3 的 MAX_TOKENS_TARGET 注释。
结论(8192 可用 / 用 4095)记入本任务 commit message 与 **Task 5** 的 MAX_TOKENS_TARGET 注释。
- [ ] **Step 1: 失败测试**——tests/test_glm_provider.py:
```python
@@ -87,12 +87,13 @@ from app.platform.site_import import safe_fetch
class TestVersionPin:
def test_httpx私有口存在(self):
"""httpx/httpcore 升级破坏私有 API 时立即红(spec 要求的版本钉位)。"""
"""httpx/httpcore 升级破坏私有 API 时立即红(spec 要求的版本钉位)——真断言,禁永真。"""
import inspect
import httpcore
t = httpx.AsyncHTTPTransport()
assert hasattr(t, "_pool")
import httpcore
assert hasattr(httpcore, "AsyncConnectionPool")
assert hasattr(httpcore.AsyncConnectionPool, "network_backend") or True # 构造参数
assert "network_backend" in inspect.signature(
httpcore.AsyncConnectionPool.__init__).parameters
class TestIsBlockedIp:
@pytest.mark.parametrize("ip", [
@@ -112,8 +113,7 @@ class TestValidatingBackend:
backend = safe_fetch.ValidatingBackend()
with pytest.raises(safe_fetch.BlockedAddressError):
await backend.connect_tcp("127.0.0.1", 80)
with pytest.raises(safe_fetch.BlockedAddressError):
await backend.connect_tcp("some-host", 80, local_address="10.0.0.5") # 按实现签名适配
# (连接层校验逻辑用 IP 字面量直接钉,不做真实域名 DNS 测试)
class TestFetch:
async def test_非法端口拒绝(self):
@@ -125,6 +125,21 @@ class TestFetch:
await safe_fetch.fetch("http://127.0.0.1/x")
with pytest.raises(safe_fetch.FetchError):
await safe_fetch.fetch("http://10.0.0.1/x")
async def test_逐跳重定向每跳重校验(self, monkeypatch):
"""302 → Location 指向私网 IP → 必须以 FetchError 终结(安全关键路径)。"""
# monkeypatch 首跳响应 302 Location=http://10.0.0.2/evil;
# 断言重定向跳因私网被拒——每跳走完整校验
...
async def test_请求真实走校验后端(self, monkeypatch):
"""_make_client 的请求确实路由经 ValidatingBackend(连接层钩子被调用)。"""
# monkeypatch ValidatingBackend.connect_tcp 计数;mock 公网请求断言计数>=1
...
async def test_重定向上限5跳(self, monkeypatch):
""">5 跳 302 链 → FetchError。"""
...
```
(connect_tcp 的准确签名以 httpcore 1.0.9 `AsyncNetworkBackend` 为准——实施时先读 `httpcore/_backends/anyio.py`,照抄其 connect_tcp 签名与 anyio.getaddrinfo 用法,仅在校验点插桩。测试对签名的适配在这一步完成。)
@@ -177,22 +192,25 @@ class ValidatingBackend(httpcore.AsyncNetworkBackend):
async def connect_tcp(self, host, port, timeout=None, local_address=None,
socket_options=None): # 签名照 httpcore 1.0.9 对齐
# ⚠️ 评审折入两个实现陷阱:
# 1) anyio 不重导出 socket 常量——AF_UNSPEC/SOCK_STREAM 用 socket 模块;
# 2) 默认后端 anyio.connect_tcp(remote_host=host) 会内部重新解析 DNS——
# 会重新打开 rebinding TOCTOU!必须把「已验 IP 字面量」传给连接调用,
# 禁止把原 hostname 再递给任何会做 DNS 的连接函数。
import socket as _socket
if port not in ALLOWED_PORTS:
raise BlockedAddressError(f"port {port} not allowed")
# 照抄 httpcore 默认后端: anyio.getaddrinfo 解析
infos = await anyio.getaddrinfo(host, port, family=anyio.AF_UNSPEC,
type=anyio.SOCK_STREAM)
addr = None
infos = await anyio.getaddrinfo(host, port, family=_socket.AF_UNSPEC,
type=_socket.SOCK_STREAM)
candidates = []
for fam, _, _, sockaddr in infos:
ip = sockaddr[0]
if isinstance(ip, bytes): # ipv6 bytes 形态
if isinstance(ip, bytes):
ip = ip.decode("idna")
if is_blocked_ip(str(ip)):
raise BlockedAddressError(f"{host} resolves to blocked {ip}")
addr = (fam, sockaddr)
break
# 连已验 sockaddr——实现上委托 anyio.open_tcp_stream 或照默认后端
#(以 httpcore._backends.anyio 的 connect_tcp 实现为准照抄,替换解析源)
candidates.append((fam, str(ip), port))
# 只连已验 IP 字面量(逐候选尝试的简化 happy-eyeballs)
...
@@ -217,7 +235,7 @@ async def fetch(url: str, timeout: float = 10.0) -> httpx.Response:
- [ ] **Step 4: GREEN(含连接真实公网站点冒烟——如 https://example.com 200;离线环境跳过记档)**
- [ ] **Step 5: 全量回归 + Commit** `feat(site-import): SSRF 连接层校验 fetch 原语`
## Batch B:抓取器 + 骨架模板 + 生成器
## Batch B:清洗抽取 + 骨架模板 + 生成器(crawler 编排在 Batch C/Task 6)
### Task 3: HTML 清洗与抽取纯函数(TDD)
@@ -225,7 +243,7 @@ async def fetch(url: str, timeout: float = 10.0) -> httpx.Response:
- Create: `app/platform/site_import/extract.py`
- Test: `tests/test_site_import_extract.py`
- [ ] **Step 1: 失败测试**(fixture 内联三段 HTML:WP 形态含内联色/logo img/wa.me/mailto/模板残留 555 电话 + 一段外链 CSS 文本 + 一个 >500KB 模拟页)——覆盖:清洗去 script/style/nav/footer/header/comment、正文文本化、8K 截断、主色统计(排除白黑灰——饱和度阈值 + #fff/#000/#888 近邻跳过)、CSS 文本同样统计、logo URL 提取(多 logo 取路径日期新者)、wa.me 归一 E.164、mailto 提取、模板残留命中清单(555 电话/Zemez/Add Your Heading/(800) 123-0045/Glasgow D04 89GR)、选页链接发现(同源 + 关键词 en/es/zh + page/N 分页)、薄子页跳过阈值、入口页 SPA 判定(<200 字符)
- [ ] **Step 1: 失败测试**(fixture 内联三段 HTML:WP 形态含内联色/logo img/wa.me/mailto/模板残留 555 电话 + 一段外链 CSS 文本 + 一个 >500KB 模拟页)——覆盖:清洗去 script/style/nav/footer/header/comment、正文文本化、8K 截断、主色统计(排除白黑灰——饱和度阈值 + #fff/#000/#888 近邻跳过;**两处都拿不到 → 默认 `#066fc0`**)、CSS 文本同样统计、logo URL 提取(多 logo 取路径日期新者)、wa.me 归一 E.164、mailto 提取、模板残留命中清单(555 电话/Zemez/Add Your Heading/(800) 123-0045/Glasgow D04 89GR)、选页链接发现(同源 + 关键词 en/es/zh + page/N 分页)、薄子页跳过阈值、入口页 SPA 判定(<200 字符)
- [ ] **Step 2: RED** → **Step 3: 实现**(纯函数:`clean_html(html)->str`、`extract_color(html, css_text)->str|None`、`extract_logo_url(html)->str|None`、`extract_contacts(html)->{whatsapp,email}`、`find_residue(texts)->list[str]`、`discover_links(base_url, html)->list[str]`、`is_thin(text)->bool`;**禁第三方解析库**——项目零新依赖,用 stdlib `html.parser` 或正则实现,鲁棒性以 fixture 覆盖为准)
- [ ] **Step 4: GREEN + Commit** `feat(site-import): HTML 清洗与抽取纯函数`
@@ -236,7 +254,7 @@ async def fetch(url: str, timeout: float = 10.0) -> httpx.Response:
- Create: `app/platform/site_import/fewshot/`(`README.md` 注明来源两租户+数据治理记档;`fewshot_excerpts.md`)
- Test: `tests/test_site_import_template.py`
- [ ] **Step 1: 骨架抽取**——以 `seed_profiles/jiancai/prompts/system.md` 为底,参考生产 yotaienergys/televicsic 版(通过 GET 生产 API 或直接读两租户 DB 配置——**用本地 curl 拿一次存文件**,勿在测试里连生产)划分:通用纪律段(语言规则/职能边界/红线/磨价/联系方式策略/对话推进/上下文追踪/禁止说的话)原样保留;行业段(首段身份、§三类请求中产品举例、§说话风格的推荐句、固定追问句、好差示例组、关注信息清单)替换为占位符 `{{身份描述}}`、`{{产品追问路径}}`、`{{推荐建议}}`、`{{好差示例组}}`、`{{关注信息}}` 等;extractor/evaluator 同法(字段名与示例占位化)
- [ ] **Step 1: 骨架抽取**——以 `seed_profiles/jiancai/prompts/system.md` 为底,参考生产 yotaienergys/televicsic 版(**来源任选其一、一次性取回存本地文件,勿在测试里连生产**:① `ssh all-kefu-prod` + `docker exec all-kefu-app-1 python -c` 读 `/app/tenants/*.db` 最新版 config;② 生产 admin API `GET /api/platform/tenants/{slug}/config`):通用纪律段(语言规则/职能边界/红线/磨价/联系方式策略/对话推进/上下文追踪/禁止说的话)原样保留;行业段(首段身份、§三类请求中产品举例、§说话风格的推荐句、固定追问句、好差示例组、关注信息清单)替换为占位符 `{{身份描述}}`、`{{产品追问路径}}`、`{{推荐建议}}`、`{{好差示例组}}`、`{{关注信息}}` 等;extractor/evaluator 同法(字段名与示例占位化)
- [ ] **Step 2: 失败测试**:
```python
@@ -286,9 +304,11 @@ class TestBuild:
async def test_空占位符出厂兜底加warning(self): ...
async def test_生成物中文断言(self): ...
async def test_墙尾巴三语(self): ...
async def test_es生成失败降级为null(self): ...
def test_残留命中在build层从联系方式与事实卡剔除(self): ...
```
- [ ] **Step 2: RED** → **Step 3: 实现 generator.py**(`analyze(pages_text) -> Analysis`、`generate(analysis) -> GenOutput`、`validate_numbers(gen, source_texts)`、`build_draft(crawl_result, gen) -> draft包`;AI 调用经注入的 provider 接口——生产装配用 resolver 的 `create_provider(config_data)` + max_tokens 透传 + `asyncio.wait_for` 120s 包裹;**MAX_TOKENS_TARGET = Task 1 核查结论**;调用 prompt 顶层带数据边界声明与「严格只输出 JSON」指令)
- [ ] **Step 2: RED** → **Step 3: 实现 generator.py**(`analyze(pages_text) -> Analysis`(**入口截断总量 ≤24K 字符**——10 页 ×8K 不封顶会爆 token 预算,截断在 analyze 入口做,spec §3.2)、`generate(analysis) -> GenOutput`、`validate_numbers(gen, source_texts)`、`build_draft(crawl_result, gen) -> draft包`;AI 调用经注入的 provider 接口——生产装配用 resolver 的 `create_provider(config_data)` + max_tokens 透传 + `asyncio.wait_for` 120s 包裹;**MAX_TOKENS_TARGET = Task 1 核查结论**;调用 prompt 顶层带数据边界声明与「严格只输出 JSON」指令)
- [ ] **Step 4: GREEN + 全量回归 + Commit** `feat(site-import): 两段生成器与数字校验器`
## Batch C:任务表 + API
@@ -297,10 +317,10 @@ class TestBuild:
**Files:**
- Create: `app/platform/site_import/task.py`
- Create: `app/platform/site_import/crawler.py`(编排 safe_fetch + extract:BFS 选页≤10、逐页 fetch、CSS/logo 追抓 15s 超时、薄子页跳过、入口页 SPA 判定、内容不足判定)
- Create: `app/platform/site_import/crawler.py`(编排 safe_fetch + extract:BFS 选页≤10、逐页 fetch、CSS/logo 追抓 15s 超时、薄子页跳过、入口页 SPA 判定、内容不足判定、**logo 下载校验**——Content-Type+魔数双验只收 png/jpg/jpeg/webp、≤2MB、SVG/超限放弃头像项+warnings 注记,含枚举测试)
- Test: `tests/test_site_import_task.py`
- [ ] **Step 1: 失败测试**(crawler 打桩——monkeypatch safe_fetch.fetch 返回 fixture Response):状态机流转、(slug, task_id) 联合索引跨租户 404、并发 ≤2 第三个拒绝、**频控 3 次/15 分钟**(复用 config_routes.SlidingWindowCounter 或同款新实例)、7 分钟总超时强 failed、generate 失败可 retry 同 task 复活且不重抓、30 分钟惰性清理、强引用(任务对象在表内有引用)
- [ ] **Step 1: 失败测试**(crawler 打桩——monkeypatch safe_fetch.fetch 返回 fixture Response):状态机流转、(slug, task_id) 联合索引跨租户 404、**全平台**并发 ≤2 第三个拒绝、**频控 3 次/15 分钟(新建与 retry 同计,超限 429)**(复用 config_routes.SlidingWindowCounter 或同款新实例)、7 分钟总超时强 failed、generate 失败可 retry 同 task 复活且不重抓、30 分钟惰性清理、强引用(任务对象在表内有引用)、**内容不足三触发各自映射失败文案**(<1K 总字符 / 调用 1 产品线为空 / 入口页非 text/html 零页)、**logo 下载格式/大小过滤**(SVG 放弃+warning、>2MB 放弃+warning、png 通过)
- [ ] **Step 2: RED** → **Step 3: 实现**(模块级 `_tasks: dict[tuple[str, str], ImportTask]` + `asyncio.Lock`;`run_import(slug, task_id, url)` 为 `asyncio.wait_for(_pipeline(), 7*60)` 的包装;pipeline = crawl → analyze → generate → build → done)
- [ ] **Step 4: GREEN + Commit** `feat(site-import): 导入任务编排与内存任务表`
@@ -311,7 +331,7 @@ class TestBuild:
- Modify: `app/main.py`(include_router,照 config_routes 挂载位置)
- Test: `tests/test_site_import_api.py`
- [ ] **Step 1: 失败测试**(照 test_config_api.py 的 SETTINGS 夹具形态):
- [ ] **Step 1: 失败测试**(夹具用 conftest 的 `db_source_app` + `super_client`(超管)+ 手建租户管理员 client——注意 test_config_api.py 里的 SETTINGS 是 URL 常量不是夹具,勿按图索骥):
- POST 新建 `{url}` → 200 `{task_id}`;非 http(s) 400;停用租户 403;同租户进行中 409;**频控第 4 次 429**;retry 带不存在 task_id 404;retry 非 generate-failed 态 409
- GET 两角色 200(超管 + 租户管理员夹具);未认证 401;他人 task_id 404;过期后 404
- **跨租户:租户管理员打别的租户 slug → 403**(_tenant_guard,勿断言 404)
@@ -349,7 +369,7 @@ class TestBuild:
- [ ] **Step 1: 后端全量** `PYTHONUTF8=1 .venv/Scripts/python -m pytest -q`(672+新增全绿)
- [ ] **Step 2: 前端全量 + build**
- [ ] **Step 3: 本地真实走查**(uvicorn 8018 + admin dev):对 `https://televicsic.com` 走完整流程——POST → 轮询到 done → 草稿包完整(品牌四件/四件套/三语尾巴/warnings 有规格判定)→ 审核页改两处 → 保存全部 → GET config 验证版本递增与内容落位 → 新会话对话验证行业身份
- [ ] **Step 3: 本地真实走查**(uvicorn 8018 + admin dev):对 `https://televicsic.com` 走完整流程——POST → 轮询到 done → 草稿包完整(品牌四件/四件套/三语尾巴/warnings 有规格判定)→ 审核页改两处 → 保存全部 → GET config 验证版本递增与内容落位 → 新会话对话验证行业身份。**两种角色各走一遍**(超管 + 租户管理员——spec §9.4,租户管理员夹具同 Task 7 建法)
- [ ] **Step 4: SSRF 实测**——POST `http://127.0.0.1/x` 400;POST `http://10.0.0.1/x` 400
- [ ] **Step 5: 频控实测**——连打 4 次 POST 第 4 次 429
- [ ] **Step 6: 收尾**——临时产物清理、`git status` 干净、batch 记档 commit
@@ -358,5 +378,5 @@ class TestBuild:
## 依赖与顺序
- Task 1 → 5(max_tokens 扩展被生成器用);Task 2 → 3 → 6(fetch 原语 → 抽取 → 编排);Task 4 → 5;Task 5 → 6 → 7;Task 8 → 9;Task 10 收口
- Task 1 → 5(max_tokens 扩展被生成器用);Task 2 → 6(fetch 原语被编排用);Task 3、4 → 5;Task 5 → 6 → 7;Task 8 → 9;Task 10 收口
- 后端 Batch A-C 严格顺序;Batch D 依赖 C 的 API 形状(可先写 model 纯函数后写 UI)