docs: site-import spec 新视角评审收口(7 硬伤+9 建议:品牌四件生成路径/审核页墙尾巴区/POST 限流/超时对齐/anyio/模型上限/空值兜底等)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-20 16:33:53 +08:00
parent 0fe12b261c
commit 2942a8d511
@@ -29,33 +29,36 @@
### 3.1 抓取器 `crawler.py`
- **起点与选页**:从给定 URL(须为首页或任意站内页)出发;同源链接发现关于/产品/联系页(URL 与锚文本关键词匹配:about/company/product/solution/contact + 中文「关于/产品/联系」+ 首页产品列表链接 + 分页形态 `page/N`);**深度 ≤2、总页数 ≤10、单页 ≤500KB、每页超时 10s**。
- **起点与选页**:从给定 URL(须为首页或任意站内页)出发;同源链接发现关于/产品/联系页(URL 与锚文本关键词匹配:about/company/product/solution/contact + 西语 nosotros/empresa/productos/contacto + 中文「关于/产品/联系」+ 首页产品列表链接 + 分页形态 `page/N`);**深度 ≤2、总页数 ≤10、单页 ≤500KB、每页超时 10s**。
- **HTML 清洗**:去 script/style/nav/footer/comment;正文文本化;每页截断 8K 字符。
- **顺带抽取**:主色(内联十六进制频率统计 + **抓取第一个外链 CSS 文件**一并统计,样式表同样受 500KB 限制;**排除白/黑/灰系**——饱和度低于阈值或接近 #fff/#000/#888 的通用色跳过(实测站白色出现 131 次排第一,机器按频率会误选;跳过后取剩余最高频色);两处都拿不到→默认 `#066fc0`);logo URL(`logo` 命中的 `<img>`,取最新日期路径者优先);WhatsApp(`wa.me/` 链接归一为 `+E.164` 形态);邮箱(`mailto:`)。
- **logo 下载**:仅接受 png/jpg/jpeg/webp(Content-Type + 魔数双验;SVG 等头像端点不支持的格式直接放弃头像项,warnings 注记「logo 为不支持的格式,已跳过」);通过格式检查的下载入任务结果(base64 data URL,≤2MB——超限同样放弃并注记);审核页 `<img>` 直接预览,保存时前端解码为 Blob 走既有 `POST .../brand/avatar` multipart——**规避浏览器 CORS 限制,头像链路零新后端端点**。
- **SSRF 防护(连接层,防 DNS rebinding——硬性要求)**:httpx 0.28 未公开 network_backend 注入口——自定义 `AsyncHTTPTransport` 子类在 `__init__` 后替换私有 `self._pool` 为 `httpcore.AsyncConnectionPool(network_backend=<校验后端>)`,该后端的 `connect_tcp` 即真正连接层钩子(getaddrinfo 一次 → 校验 → 连接已验 IP,闭合 TOCTOU;httpcore 以原 hostname 包 TLS,SNI 与证书校验保持正确)。**禁止改写 URL host 为 IP**(会破坏 HTTPS 证书校验)。私有 API 依赖加版本钉位测试(探测 `_pool`/`network_backend` 存在,httpx/httpcore 升级即红)。拒绝段:127/8、10/8、172.16/12、192.168/16、169.254/16、0.0.0.0/8、100.64.0.0/10、::1、fc00::/7、fe80::/10、::ffff:0:0/96(IPv4 映射绕过);只允许 80/443;**重定向禁自动跟随,逐跳手动校验**。主机名技巧(十进制/八进制 IP、userinfo)由 getaddrinfo 后的连接层校验天然覆盖。**logo 下载与外链 CSS 抓取走同一个校验 client**(统一出口)。
- **SSRF 防护(连接层,防 DNS rebinding——硬性要求)**:httpx 0.28 未公开 network_backend 注入口——自定义 `AsyncHTTPTransport` 子类在 `__init__` 后替换私有 `self._pool` 为 `httpcore.AsyncConnectionPool(network_backend=<校验后端>)`,该后端的 `connect_tcp` 即真正连接层钩子(**DNS 解析必须走 `anyio.getaddrinfo` 异步**——同步 `socket.getaddrinfo` 会阻塞事件循环,单进程下拖死所有租户的在线聊天,零同步 IO 纪律硬约束,照抄 httpcore 默认后端的 anyio 用法;解析一次 → 校验 → 连接已验 IP,闭合 TOCTOU;httpcore 以原 hostname 包 TLS,SNI 与证书校验保持正确)。**禁止改写 URL host 为 IP**(会破坏 HTTPS 证书校验)。私有 API 依赖加版本钉位测试(探测 `_pool`/`network_backend` 存在,httpx/httpcore 升级即红)。拒绝段:127/8、10/8、172.16/12、192.168/16、169.254/16、0.0.0.0/8、100.64.0.0/10、::1、fc00::/7、fe80::/10、::ffff:0:0/96(IPv4 映射绕过);只允许 80/443;**重定向禁自动跟随,逐跳手动校验**。主机名技巧(十进制/八进制 IP、userinfo)由 getaddrinfo 后的连接层校验天然覆盖。**logo 下载与外链 CSS 抓取走同一个校验 client**(统一出口)。
- **UA**:设置常规浏览器 UA,降低被拒概率。
- **SPA 空壳检测**:清洗后正文 <200 字符视为空壳 → 任务 failed,文案「该网站需要浏览器渲染才能看到内容,暂不支持自动导入——请在配置中心手动配置」。
- **SPA 空壳检测(仅限入口页)**:入口页清洗后正文 <200 字符视为空壳 → 任务 failed(文案同下);发现的**子页**过薄则跳过该页不失败(JS 渲染的产品子页常见,整站不该陪葬)。整体失败文案:「该网站需要浏览器渲染才能看到内容,暂不支持自动导入——请在配置中心手动配置」。
### 3.2 生成器 `generator.py`
- **行为骨架模板**(实施时从 jiancai `system.md` 抽取通用纪律制成):语言规则/职能边界/红线/磨价应对/联系方式策略/对话推进/上下文追踪等段落为固定文本;行业占位符:`{{身份描述}}`、`{{产品线总述}}`、{{规格追问路径}}、`{{好差示例组}}`、`{{推荐建议}}` 等(占位符清单一并定义在模板文件中)。**红线/联系方式策略零 AI 参与。**
- **AI 调用**(走平台 resolver 的 AI 配置——租户 ai 组→平台设置→env;**超时 120s 经 `asyncio.wait_for` 包裹(wait_for 是放弃非取消,to_thread 卸载的调用不中断——记档)、max_tokens 8K 需 GLMProvider 小扩展(参数透传)**;两次调用):
- **AI 调用**(走平台 resolver 的 AI 配置——租户 ai 组→平台设置→env;**超时 120s 经 `asyncio.wait_for` 包裹(wait_for 是放弃非取消,to_thread 卸载的调用不中断——记档)、max_tokens 需 GLMProvider 小扩展(参数透传)且实施时核查配置模型的上限**(resolver 默认 glm-4-flash 的旧接口 max_tokens 封顶 4095——传 8K 会报错;实施首步实测当前配置模型可接受的 max_tokens,取 min(目标, 模型上限),记入实施记档)**;**两次调用):
- **调用 1「行业分析」**:输入=各页清洗文本(截断后总量 ≤24K 字符)→ 输出=结构化 JSON:公司概况/产品线(名称+要点)/规格公开与否(bool+依据)/应用领域/联系方式/目标客户/行业关键词/西语尾巴产品词(es tail 用)。
- **调用 2「内容生成」**:输入=调用 1 的 JSON + 占位符清单 + **few-shot 范例**(落位 `app/platform/site_import/fewshot/` 包内数据文件,从生产两租户 yotaienergys/televicsic 的实际配置摘录——身份段/追问路径/好差示例对/事实卡骨架/提取器字段/评估器示例各取范式,总量 ≤6K tokens)→ 输出=各占位符内容(JSON 键值)。
- **调用 2「内容生成」**:输入=调用 1 的 JSON + 占位符清单 + **few-shot 范例**(落位 `app/platform/site_import/fewshot/` 包内数据文件,从生产两租户 yotaienergys/televicsic 的实际配置摘录——身份段/追问路径/好差示例对/事实卡骨架/提取器字段/评估器示例各取范式,总量 ≤6K tokens;**数据治理注记**:摘录内容为提示词/品牌文案非密钥,属用户自有内容入库本仓——客户自有产品的公开话术,风险接受,记档)→ 输出=各占位符内容 + **品牌文案四件**(JSON 键值):`brand_title`(公司名/品牌名+Support 形态)、`welcome`(一句开放式提问,范式照 few-shot)、`topics`(3-5 个产品线短标签)、`placeholder`(输入框占位一句)。品牌四件与占位符同源生成——否则草稿包品牌字段无生成路径(评审抓漏)。
- **生成物语言写死中文**(示例对为英文),与既有提示词体系一致——提示词中明确指令,抓取文本为西语/英语不改变产出语言。
- **AI 输出解析容错**:JSON 剥离 markdown 代码栅栏与前后杂文后解析;解析失败或 schema 缺键 → **自动重试该调用一次**(换「严格只输出 JSON」强化指令),再失败才任务 failed——AI 返回带栅栏是常态,一次重试覆盖绝大多数。
- **占位符空值处理**:生成「成功」但个别占位符为空 → 不失败;空值以出厂对应段落兜底填充 + warnings 注记「以下内容未生成,已用默认,请审核页确认」(审核层可改——比整任务失败更符合审核兜底定位)。
- **行业风格分支**(两次实操教训):调用 1 判定「规格公开」(产品页含参数表/数字规格)→ 人设允许聊参数(炉辊范式);不公开 → 参数一律转客户经理(储能范式,事实卡加「参数未公布,问参数走兜底」使用规则)。判断依据写入 warnings 供审核参考。
- **数字纪律**:事实卡与示例中出现的数字必须来自抓取源文本;生成后**归一化校验器**(全半角/单位符号/波浪号连字符归一后比对),违例数字剔除并在 warnings 注记「以下数字未在源站出现已移除:…」。AI 提示词明令禁止推算/编造。
- **模板残留甄别**:常见建站模板残留清单(555 电话、Zemez/footer © 占位、`Add Your Heading Text Here`、(800) 123-0045 类占位电话、知名假地址如 Glasgow D04 89GR)→ 命中项从联系方式/事实卡剔除,warnings 列出「已剔除可疑模板内容」。
- **wall 尾巴**:替换 `deflections_contact` 的 **en/zh/es 三语**产品指向句(en/zh 与两次实操同款:`...Anything else about our {产品}?` / `...还想了解什么{产品}?`;es 由调用 2 一并生成——出厂墙的 es 池带着管道指向句,只换 en/zh 会给西语客户漏出管道身份)。es 生成失败时降级为整键丢弃(wall_schema 只必留 en/zh,合法)。
- **wall 尾巴**:替换 `deflections_contact` 的 **en/zh/es 三语**产品指向句。**替换语义按池形态分派**:池为出厂单句形态(新租户恒如此)→ 整句替换;池被人工改过多句 → 追加新尾巴句为池末项(不删人工句子)+ warnings 注记「原池含人工编辑句,已追加而非替换」。es 由调用 2 一并生成——出厂墙的 es 池带着管道指向句,只换 en/zh 会给西语客户漏出管道身份;es 生成失败时降级为整键丢弃(wall_schema 只必留 en/zh,合法)。
### 3.3 任务管理 `task.py`
- POST 启动 → 立即返回 `task_id`(uuid);`asyncio.create_task` 后台执行(单进程内存任务表,**以 `(slug, task_id)` 联合索引**——跨租户持他人 task_id 查询即 404;模块级 dict + 锁,风格对齐限流器)。
- 状态机:`pending → crawling → generating → done(草稿包) | failed(白话原因)`。**任务总超时 6 分钟**(`asyncio.wait_for` 包全流程;抓 10 页最坏 100s + AI 两段最坏 240s,6 分钟封顶强 failed「导入超时,请重试」);前端轮询 5 分钟自动停止并提示可重试。
- 状态机:`pending → crawling → generating → done(草稿包) | failed(白话原因)`。
- **时间预算(含重试的最坏情形)**:抓取 10 页 ×10s = 100s;外链 CSS 与 logo 下载各 15s 超时(>500KB 页**截断解析**——解析器容忍截断的 HTML;非 text/html 的 Content-Type 直接跳过该页);AI 两段 ×各一次自动重试 = 最坏 4×120s = 480s。最坏总和超 6 分钟——**总超时即兜底**:`asyncio.wait_for` 7 分钟封顶强 failed「导入超时」,`retry=generate` 是超时的预期恢复路径(不重抓);**前端轮询 7.5 分钟**(>服务端 7 分钟封顶)自动停止并提示可重试——两端窗口对齐,5:00-6:00 之间完成的结果不会漏展示(评审修正:原 5 分钟前端 < 6 分钟服务端的错位)。
- **过期清理**:任务结果保留 30 分钟,惰性清理(每次查询/新建时顺带扫过期项——内存铁律)。
- **并发上限**:全平台同时进行 ≤2(第三个 POST → 409「已有导入任务进行中,稍后再试」)。
- **并发上限**:全平台同时进行 ≤2(第三个 POST → 409「已有导入任务进行中,稍后再试」);**复活任务占用同一名额**。
- **频率限流**(对齐 email/push test 的 SlidingWindowCounter 先例——本端点烧平台 AI 配额+对外抓取,必须有频控):**每租户 15 分钟 3 次** POST(新建与 retry 同计),超限 429「导入太频繁,15 分钟内最多 3 次,稍后再试」。
- 失败恢复:**generate 阶段 failed 的任务在 30 分钟保留期内可 `POST ?retry=generate` 以同 task_id 复活**(只重跑生成步,复用已抓文本);抓取失败无重试价值(直接 failed)。任务实现须持强引用(模块级 dict 存 task 对象——裸 create_task 无引用会被 GC,asyncio 官方坑)。
### 3.4 Prompt injection 注记
@@ -65,7 +68,7 @@
## 4. API(2 个端点,挂 platform 认证 + 既有 `_tenant_guard`,超管+租户管理员可用;路由文件 `app/platform/routes/site_import_routes.py` 照 config_routes 组织,挂 `/api/platform/tenants` 前缀)
- `POST /api/platform/tenants/{slug}/site-import` body `{url}`(新建任务)或 `{retry: "generate", task_id: "..."}`(复活该租户下 failed-at-generate 的指定任务,语义见 §3.3)→ `{task_id}`。校验:新建时 URL 格式 http(s);retry 时 task 存在/属于该租户/处于 generate-failed 态(否则 404/409);目标租户非停用;无同租户进行中任务(409)。
- `GET /api/platform/tenants/{slug}/site-import/{task_id}` → `{status, phase?, pages?, draft?, warnings?, error?}`(done 时含完整草稿包)。
- `GET /api/platform/tenants/{slug}/site-import/{task_id}` → `{status, phase?, pages?, draft?, error?}`(done 时含完整草稿包;**warnings 只在草稿包内**——单一权威位置,前端不合并两处;GET 404=任务已过期,前端按终态「任务已过期,请重新导入」处理)。
草稿包结构:
```json
@@ -86,8 +89,8 @@
## 5. 前端(admin)
- **入口**:租户管理详情抽屉 + 配置中心页头,两处「从网站导入」按钮(两种角色均可见)。
- **导入对话框**:URL 输入 + 开始按钮;进行中显示阶段(「抓取中·第 N/M 页」→「AI 生成中」);3s 轮询;关闭对话框弹确认「放弃本次导入?任务将在 30 分钟后自动清理」。
- **审核页**(全屏对话框,复用 ConfigCard 视觉基线):左列品牌表单(草稿预填可改;头像 base64 预览,可不上传);右列四件套 tab 编辑器(等宽,字节计数);顶部黄条区显示 warnings;页头明示「保存将覆盖当前配置(产生新版本,可随时回滚)」。底部「保存全部」(依次调上述端点,进度条显示第几组;**部分失败时重试跳过已完成组**)+「放弃」。保存成功 toast + 提示「配置已热生效,可到嵌入代码页取挂件代码」。
- **导入对话框**:URL 输入 + 开始按钮;进行中显示阶段(「抓取中·第 N 页」——总数在 BFS 中不可知,不显示 M;→「AI 生成中」);3s 轮询;关闭对话框弹确认「放弃本次导入?任务将在 30 分钟后自动清理」。
- **审核页**(全屏对话框,复用 ConfigCard 视觉基线):左列品牌表单(草稿预填可改;头像 base64 预览,可不上传);右列四件套 tab 编辑器(等宽,字节计数);**墙尾巴区**(en/zh/es 三语单行编辑——es 是将展示给真实客户的 AI 生成文本,必须可见可改,不得盲存)+ **联系方式开关区**(两个 ElSwitch 预览);顶部黄条区显示 warnings;页头明示「保存将覆盖当前配置(产生新版本,可随时回滚)」。底部「保存全部」(依次调上述端点,进度条显示第几组;**部分失败时重试跳过已完成组**)+「放弃」。保存成功 toast + 提示「配置已热生效,可到嵌入代码页取挂件代码」。
- 视图逻辑进 `model.ts` 纯函数:草稿包→表单模型、base64→Blob、保存序列构造(**brand 组的 GET 在头像上传之后取**——上传会写新版本含 avatar 文件名,读-改-写才不丢)、warnings 分级;vitest 覆盖。
## 6. 错误与边界