Initial commit of Tmall new-product analysis loop v9.2.5.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+20
@@ -0,0 +1,20 @@
|
||||
# secrets
|
||||
server/config.json
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
# generated
|
||||
*.log
|
||||
server/reports/
|
||||
server/monitor_data/
|
||||
|
||||
# os
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,25 @@
|
||||
# V9.1.0 修复清单
|
||||
|
||||
## 报告逻辑
|
||||
- 评论由原文罗列改为消费者洞察优先,原文/买家秀进入折叠证据层。
|
||||
- 评论洞察强制进入新品方案 consumerInsightInputs。
|
||||
- 主图与详情共享核心表达链,新增“主图承诺 → 详情证明 → 评论验证 → 断层 → 下一款动作”连贯性检查。
|
||||
- 主图/详情先展示整组逻辑,再展开逐图证据。
|
||||
- 标题渲染器禁止 dict/list 字面量泄漏。
|
||||
- 空的设计资产卡不再显示占位内容。
|
||||
- AI视觉增加 reference / optimization / exploration 来源等级。
|
||||
|
||||
## 数据状态
|
||||
- SKU 未采集不再显示为 0。
|
||||
- 结构化 SKU 数量与 SKU 图片数量严格分开。
|
||||
|
||||
## 图片稳定性
|
||||
- 报告生成时自动缓存证据图片到 server/reports/assets/<task_id>/<group>/。
|
||||
- HTML 优先引用本地缓存,加载失败自动回退原始 URL。
|
||||
- 服务端按图片扩展名返回正确 MIME 类型。
|
||||
- main / detail / sku / buyerShow 保持独立目录和证据来源。
|
||||
|
||||
## 验证
|
||||
- Python 语法检查通过。
|
||||
- 自动测试 16/16 通过。
|
||||
- 使用历史真实分析 JSON 重建报告通过:V9.1.0、无 Dict 泄漏、无 SKU 0 误报、主图/详情折叠结构正常。
|
||||
@@ -0,0 +1,41 @@
|
||||
# V9.2.0 图片驱动专业报告版
|
||||
|
||||
## 核心目标
|
||||
|
||||
把报告从“模型分析很多、页面全部展示”升级为“分析可以深,报告必须轻”。最终阅读顺序统一为:图片 → 专业意见 → 专业建议 → 详细证据。
|
||||
|
||||
## 分析与 Prompt
|
||||
|
||||
- 删除用户可见的 `[数据事实] / [分析判断] / [设计建议] / [证据不足]` 拼接要求。
|
||||
- SYS 改为专业顾问语气:不做简单好坏评分,使用“当前表达 / 可强化 / 需要关注 / 专业建议”。
|
||||
- Task B 主图:输出 `role / message / professionalAdvice`,构图、色彩、OCR 等深度字段进入 `evidenceDetail`。
|
||||
- Task C 标题:输出 `professionalOpinion / currentExpression / reinforce / recommendedActions`。
|
||||
- Task D 详情:按 4–7 个信息任务聚合,选择代表图;不再默认渲染 20 张流水账。
|
||||
- Task F 新品:只要有商品基础证据且有主图或详情,至少输出 1 套证据可追溯方案;证据完整时输出 2–3 套。
|
||||
- Task G Composer:增加字段长度限制,直接输出 V9.2 图片驱动报告结构,同时保留原有 evidence-only 校验字段。
|
||||
|
||||
## Renderer
|
||||
|
||||
- 封面使用第一张商品主图,去掉边框、背景套卡和大阴影。
|
||||
- 总览只保留 3 个重点:商品定位、主要产品资产、下一款优先方向。
|
||||
- 标题改为轻量三栏:当前表达 / 可强化 / 专业建议。
|
||||
- 主图默认直接显示图片与短意见,不再先折叠图库。
|
||||
- 主图 × 详情 × 评论连贯性改为状态矩阵。
|
||||
- 详情页按信息任务展示代表图,其余图片只做证据,不重复显示“暂无语义结论”。
|
||||
- SKU 改为图片拼图 + 用户选择顺序,结构未采集只作为小状态提示。
|
||||
- 评论改为买家秀 + 消费者洞察并排。
|
||||
- 商品价值改为场景 / 产品 / 服务 / 可强化方向。
|
||||
- 新品方案引用当前商品图片,显示产品调整、SKU、主图、详情策略。
|
||||
- 图片加载失败时隐藏失败图并显示轻量 fallback,不再出现灰色故障框。
|
||||
|
||||
## 证据与稳定性
|
||||
|
||||
- 保留 V9.1 `localUrl → sourceUrl` 图片缓存与回退逻辑。
|
||||
- SKU 图片数量不会当作 SKU 数量。
|
||||
- 新增 evidence-backed launch fallback:AI 偶发漏方案时,只使用当前真实证据形成最小“现有基础优化”方案。
|
||||
- 不补充未采集市场规模、转化率、利润、趋势或竞品结论。
|
||||
|
||||
## 测试
|
||||
|
||||
- 19/19 tests passed。
|
||||
- 新增 V9.2 图片驱动渲染、无边框封面、必出新品方案测试。
|
||||
@@ -0,0 +1,14 @@
|
||||
# V9.2.1 图片加载修复版
|
||||
|
||||
## 修复
|
||||
- 修复采集完成后报告图片可能全部无法加载的问题。
|
||||
- 图片缓存改为多策略请求头重试:天猫 Referer、淘宝 Referer、淘宝首页 Referer、无 Referer。
|
||||
- 支持 protocol-relative (`//...`) 与 data:image 图片进入本地缓存。
|
||||
- 缓存结果增加图片文件签名校验,不再只依赖 Content-Type。
|
||||
- 本地缓存失败时保留原始 sourceUrl,浏览器继续尝试远程回退。
|
||||
- Renderer 取消 `referrerpolicy=no-referrer`,避免阻断部分电商 CDN 回退加载。
|
||||
- 图片本地资源仍通过 `/reports/assets/<task>/<group>/...` 提供。
|
||||
|
||||
## 回归验证
|
||||
- 21/21 tests passed.
|
||||
- SKU 缺失不等于 0、图片驱动报告、新品方案、图片缓存优先级等 V9.2.0 行为保持不变。
|
||||
@@ -0,0 +1,7 @@
|
||||
# V9.2.2
|
||||
|
||||
## P0 图片链路修复
|
||||
- 修复 standalone / legacy collector 已采集 `images.main/detail/sku`,但因缺少 strict-v1 provenance 在 `analysis._normalize_image_groups()` 被全部丢弃的问题。
|
||||
- legacy/standalone 模式现在保留采集器已经分组的 main/detail/sku,并生成兼容 provenance。
|
||||
- buyerShow 仍只从评论记录建立,避免页面图误归类为买家秀。
|
||||
- 保留 V9.2.1 本地图片缓存与远程 URL 回退。
|
||||
@@ -0,0 +1,6 @@
|
||||
# V9.2.3 版本一致性修复
|
||||
|
||||
- 修复主扩展 `manifest.json` 已是 9.2.2,但 `popup.js` 仍固定校验 9.2.1,导致错误提示“插件 9.2.1 / 后端 9.2.2”。
|
||||
- 统一主扩展、standalone 扩展、capture-extension、后端、AI client、analysis engine、renderer、启动脚本、VERSION 为 9.2.3。
|
||||
- 保留 V9.2.2 图片归一化与图片缓存修复。
|
||||
- 新增版本一致性自检。
|
||||
@@ -0,0 +1,22 @@
|
||||
# V9.2.5 Final — Owner + Professional User Report
|
||||
|
||||
## 内容工程
|
||||
- SYS Prompt 改为老板/专业用户双层阅读目标。
|
||||
- Task B:主图输出 imageRole + visualSignals + businessMeaning + nextAction。
|
||||
- Task C:标题压缩为 currentExpression + reinforce + recommendedActions。
|
||||
- Task D:详情按4-6个任务组输出 visualSignals + nextAction。
|
||||
- Task E:收敛成老板决策摘要,不再生产大量重复长文。
|
||||
- Task F:新品方案增加 sourceType(证据驱动优化 / 反馈驱动升级 / 探索性方向),默认只输出短决策字段。
|
||||
- Task G:最终 Schema 与 V9.2.5 报告布局完全对齐。
|
||||
|
||||
## 渲染工程
|
||||
- 报告顺序改为:老板速览 → 新品方向 → 标题 → 主图 → 详情 → SKU → 评论。
|
||||
- 去掉独立“商品核心竞争表达”章节,避免与总览重复。
|
||||
- 主图/详情以图片信息标签为主要文字层。
|
||||
- 主图承接矩阵默认 details 折叠。
|
||||
- 新品方案改成3列决策卡。
|
||||
- 图片全部 contain,取消 cover 裁切;封面无边框。
|
||||
|
||||
## 兼容
|
||||
- 保留旧字段回退:旧 message/professionalAdvice 仍可转换为单个短标签/动作。
|
||||
- 保留 V9.2.3 图片缓存、sourceUrl/localUrl 回退与 legacy 图片归一化。
|
||||
@@ -0,0 +1,11 @@
|
||||
# V9.2.5 Final — Big Visual / Big Type
|
||||
|
||||
- 报告从轻量Dashboard进一步升级为Editorial式大图大字商业报告。
|
||||
- 首屏、老板速览、主图、详情、SKU、买家秀全面放大。
|
||||
- 主图由5列小图改为2列大图,第5张可单独放大。
|
||||
- 详情由3列卡片改为交错大图模块。
|
||||
- SKU由4列缩略图改为2列大图;买家秀默认2x2。
|
||||
- 所有图片继续使用 contain,完整保留图片文字与商品主体。
|
||||
- visualSignals 从胶囊小标签改为大字信息行。
|
||||
- Prompt进一步压缩,避免Renderer硬截断。
|
||||
- 新品可见层只保留产品动作与页面动作。
|
||||
@@ -0,0 +1,84 @@
|
||||
# Prompt Engineering V9.2.5
|
||||
|
||||
## 总目标
|
||||
报告服务两类用户:
|
||||
- 老板/决策者:30秒内回答为什么能卖、什么值得继承、下一款先做什么。
|
||||
- 专业商品用户:从图片中快速提取卖点、规格、证明与可执行动作。
|
||||
|
||||
核心原则:分析可以深,默认报告必须轻;图片信息优先于AI解释。
|
||||
|
||||
## SYS Prompt
|
||||
新增硬规则:
|
||||
- 同一结论默认只展示一次。
|
||||
- 图片逐张提炼 `visualSignals`,禁止用长段 `message` 作为默认输出。
|
||||
- 探索性新品必须标记 `sourceType=探索性方向`。
|
||||
- 图片完整显示优先,Renderer 使用 contain。
|
||||
|
||||
## Task B — 主图商业信息提炼
|
||||
默认字段:
|
||||
- `imageRole`:图片承担的商业任务,<=10字
|
||||
- `visualSignals`:2-4个图片可核验信息,每项<=12字
|
||||
- `businessMeaning`:内部解释,<=28字
|
||||
- `nextAction`:下一款动作,<=22字
|
||||
- `evidenceDetail`:构图/OCR/颜色等深层分析,默认折叠
|
||||
|
||||
## Task C — 标题压缩
|
||||
默认字段:
|
||||
- `professionalOpinion` <=55字
|
||||
- `currentExpression`:短词组
|
||||
- `reinforce`:短词组
|
||||
- `recommendedActions`:<=28字
|
||||
|
||||
## Task D — 详情证明信息
|
||||
按4-6个任务组,不逐张流水账:
|
||||
- `type`
|
||||
- `representativeImageId`
|
||||
- `visualSignals`
|
||||
- `businessMeaning`
|
||||
- `nextAction`
|
||||
|
||||
## Task E — 老板决策摘要
|
||||
只输出可供最终报告压缩的:
|
||||
- 为什么能卖
|
||||
- 值得继承
|
||||
- 下一款优先
|
||||
不再要求大段 opportunity map / top drivers / top risks 默认展示。
|
||||
|
||||
## Task F — 新品方向
|
||||
每套方案默认只保留:
|
||||
- `sourceType`:证据驱动优化 / 反馈驱动升级 / 探索性方向
|
||||
- `name`
|
||||
- `whyThisPlan`
|
||||
- `productAction`
|
||||
- `pageAction`
|
||||
- `validation`
|
||||
- `visualReferenceImageIds`
|
||||
|
||||
## Task G — 最终编排
|
||||
顺序固定:
|
||||
1. 老板速览
|
||||
2. 下一款方向
|
||||
3. 标题
|
||||
4. 主图在卖什么
|
||||
5. 详情页证明什么
|
||||
6. SKU怎么选
|
||||
7. 消费者实际感受到什么
|
||||
|
||||
商品价值不再独立重复一章,合并进老板速览。
|
||||
|
||||
## 字数控制
|
||||
- professionalOpinion <=55字
|
||||
- ownerOverview headline/note <=28字
|
||||
- imageRole <=10字
|
||||
- visualSignal <=12字
|
||||
- nextAction <=22字
|
||||
- review insight <=35字
|
||||
- plan why <=45字
|
||||
- plan product/page action <=35字
|
||||
|
||||
## 证据规则
|
||||
所有结论继续绑定 `evidenceIds`。图片信息标签必须来源于:
|
||||
- 图片可读文字
|
||||
- 图片可见产品/场景
|
||||
- 与该图片明确关联的页面参数
|
||||
不得用市场常识补齐。
|
||||
@@ -0,0 +1,37 @@
|
||||
# Prompt Engineering V9.2.5
|
||||
|
||||
## 目标
|
||||
默认报告从“信息面板”升级为“大图大字决策报告”。模型不负责写长分析,而负责把真实证据压成短判断、图片信息标签和下一步动作。
|
||||
|
||||
## 总原则
|
||||
- 大图讲事实,大字讲判断,短句讲动作。
|
||||
- 老板先看:为什么能卖 / 值得继承 / 下一款优先。
|
||||
- 专业用户再看:标题 / 主图 / 详情 / SKU / 消费者。
|
||||
- 图片文字、规格、材质、权益、功能优先转成 visualSignals。
|
||||
- 同一结论默认只出现一次。
|
||||
- 未采集经营数据不推断市场规模、转化、利润。
|
||||
|
||||
## 可见字段硬限制
|
||||
- verdict/professionalOpinion <= 45字
|
||||
- owner headline <= 18字;note <= 22字
|
||||
- title keyword <= 10字
|
||||
- main imageRole <= 8字;visualSignals 2-3项,每项<=10字;nextAction<=18字
|
||||
- detail visualSignals 2-3项,每项<=10字;nextAction<=18字
|
||||
- SKU professionalOpinion <=35字;recommendedAction<=24字
|
||||
- review insight <=28字
|
||||
- plan productAction/pageAction <=28字
|
||||
|
||||
## Task B 主图
|
||||
输出 imageRole + visualSignals + nextAction。visualSignals 必须是图上真实信息,如“40支新疆棉”“免费仓储”“六件套”。禁止“高级感强”“表达清晰”这类抽象词充当图片信息。
|
||||
|
||||
## Task D 详情
|
||||
按4-6个信息任务组:品牌服务 / 套件组成 / 材质品质 / 产品功能 / 使用便利 / 花型选择 / 用户验证。每组只放代表图、2-3个 visualSignals、1个 nextAction。
|
||||
|
||||
## Task E 老板摘要
|
||||
只回答三个问题:为什么能卖、值得继承、下一款优先。
|
||||
|
||||
## Task F 新品
|
||||
默认最多3套,区分:证据驱动优化 / 反馈驱动升级 / 探索性方向。可见层只展示产品动作和页面动作。whyThisPlan/validation 可作为内部证据字段。
|
||||
|
||||
## Task G 编排
|
||||
顺序固定:老板速览 → 下一款先做什么 → 标题 → 主图 → 详情 → SKU → 消费者。商品价值不再独立成章。
|
||||
@@ -0,0 +1,32 @@
|
||||
# 三笙 · 商品开品分析报告引擎 V9.2.5
|
||||
|
||||
V9.2.5 是“大图大字决策报告”最终版:老板先看结论,专业用户再看图片证据。大图讲事实,大字讲判断,短句讲动作。
|
||||
|
||||
## 最终报告结构
|
||||
|
||||
1. 老板速览:为什么能卖 / 值得继承 / 下一款优先
|
||||
2. 下一款方向:证据驱动优化 / 反馈驱动升级 / 探索性方向
|
||||
3. 标题表达:已表达 / 建议强化
|
||||
4. 主图在卖什么:完整图片 + 图片信息标签 + 1条动作
|
||||
5. 详情页证明什么:代表图 + 图片信息标签 + 1条动作
|
||||
6. SKU怎么选:SKU图 + 选择维度与顺序
|
||||
7. 消费者实际感受到什么:买家秀 + 聚合体验信号
|
||||
|
||||
## V9.2.5 关键变化
|
||||
|
||||
- Prompt 从“解释图片”改为“提炼图片上的商业信息”。
|
||||
- 主图/详情新增 `visualSignals`,优先输出图片可核验的卖点与参数标签。
|
||||
- 同一结论默认只展示一次,详细推理留在 evidenceDetail。
|
||||
- 主图 × 详情 × 评论承接矩阵默认折叠。
|
||||
- 商品价值不再单独重复一章,吸收到老板速览。
|
||||
- 新品方案改为横向决策卡,只保留为什么做 / 产品动作 / 页面动作,并标记来源等级。
|
||||
- 图片统一 `object-fit: contain`,封面、主图、详情、SKU、买家秀、方案参考图均优先完整展示,不裁切商品信息。
|
||||
- SKU图片数量继续与SKU结构数量分离。
|
||||
- 保留 V9.2.3 已修复的图片 HTML 标签、图片缓存与旧采集格式兼容能力。
|
||||
|
||||
详细规则见 `REPORT_OUTPUT_STANDARD_V9.2.5.md`、`PROMPT_ENGINEERING_V9.2.5.md` 与 `CHANGELOG_V9.2.5.md`。
|
||||
|
||||
## 集成约定(V9.2.5)
|
||||
|
||||
- `server/prompt_templates.py` 提供提示词模板注册表与商品变量说明。业务代码传入结构化变量(商品、参数、图片、评论、问答、程序统计、证据账本),不再自行拼接上下文。
|
||||
- 报告数据保存为 `/reports/<taskId>.json`;页面使用独立的 `server/report.html` + `server/report.js` 通过 `data` 参数加载 JSON。可直接把这两个静态文件部署到其它服务端,UI 与后端数据接口解耦。
|
||||
@@ -0,0 +1,271 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>本屋创品大学生宿舍床品六件套|大图大字决策报告</title>
|
||||
<style>
|
||||
:root{--ink:#141b24;--text:#44505d;--muted:#8a95a3;--line:#e8edf2;--soft:#f6f8fa;--blue:#315fba;--blue-soft:#eef4ff}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif;color:var(--text);background:#fff}
|
||||
.wrap{width:min(1240px,calc(100% - 56px));margin:auto}
|
||||
nav{position:sticky;top:0;z-index:20;background:rgba(255,255,255,.96);backdrop-filter:blur(10px);border-bottom:1px solid var(--line)}
|
||||
nav .wrap{height:58px;display:flex;align-items:center;justify-content:space-between}
|
||||
.brand{font-weight:800;color:var(--ink);font-size:15px}
|
||||
.links{display:flex;gap:22px} .links a{text-decoration:none;color:var(--muted);font-size:13px}
|
||||
.hero{padding:58px 0 42px;border-bottom:1px solid var(--line)}
|
||||
.hero-grid{display:grid;grid-template-columns:.9fr 1.1fr;gap:54px;align-items:center}
|
||||
.kicker{font-size:12px;font-weight:800;color:var(--blue);letter-spacing:.08em}
|
||||
h1{font-size:48px;line-height:1.12;letter-spacing:-.035em;margin:10px 0 14px;color:var(--ink)}
|
||||
.meta{font-size:14px;color:var(--muted)}
|
||||
.hero-claim{margin-top:28px;font-size:26px;line-height:1.48;font-weight:800;color:var(--ink);max-width:620px}
|
||||
.hero-claim em{font-style:normal;color:var(--blue)}
|
||||
.pic{margin:0;background:var(--soft);display:flex;align-items:center;justify-content:center;overflow:hidden}
|
||||
.pic img{display:block;width:100%;height:100%;object-fit:contain}
|
||||
.pic .fallback{display:none;color:var(--muted);font-size:14px;padding:24px} .pic.failed .fallback{display:block}
|
||||
.cover{height:530px;background:transparent}
|
||||
.cover img{height:auto;max-height:530px}
|
||||
section{padding:70px 0} section+section{border-top:1px solid var(--line)}
|
||||
.section-kicker{font-size:12px;font-weight:800;color:var(--blue);margin-bottom:8px}
|
||||
h2{font-size:36px;line-height:1.2;margin:0 0 28px;color:var(--ink);letter-spacing:-.025em}
|
||||
.lead{font-size:22px;line-height:1.55;font-weight:760;color:var(--ink);max-width:940px;margin:0 0 34px}
|
||||
.big3{display:grid;grid-template-columns:repeat(3,1fr);gap:28px}
|
||||
.big3 article{padding-top:20px;border-top:2px solid var(--ink)} .big3 span{font-size:13px;color:var(--muted);font-weight:700}
|
||||
.big3 b{display:block;font-size:28px;line-height:1.25;color:var(--ink);margin-top:9px} .big3 p{font-size:15px;margin:8px 0 0}
|
||||
.plan-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:18px}
|
||||
.plan{border:1px solid var(--line);border-radius:16px;overflow:hidden} .plan .pic{height:300px}
|
||||
.plan-body{padding:22px} .plan-body small{font-size:12px;color:var(--blue);font-weight:800}
|
||||
.plan-body h3{font-size:25px;margin:6px 0 14px;color:var(--ink)} .plan-body dl{margin:0}
|
||||
.plan-body dl div{display:grid;grid-template-columns:52px 1fr;gap:10px;padding:12px 0;border-top:1px solid var(--line)}
|
||||
.plan-body dt{font-size:12px;color:var(--muted);font-weight:800} .plan-body dd{margin:0;font-size:15px;color:var(--ink);line-height:1.5}
|
||||
.title-now{padding:18px 20px;background:var(--soft);border-radius:12px;font-size:17px;color:var(--ink)}
|
||||
.title-now small{display:block;font-size:12px;color:var(--muted);margin-bottom:6px}
|
||||
.title-two{display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-top:18px}
|
||||
.title-two article{padding:22px;border:1px solid var(--line);border-radius:12px}
|
||||
.title-two span{font-size:12px;color:var(--muted);font-weight:800} .title-two b{display:block;font-size:22px;color:var(--ink);margin-top:7px}
|
||||
.main-grid{display:grid;grid-template-columns:1fr 1fr;gap:28px}
|
||||
.main-card{margin-bottom:18px} .main-card:nth-child(5){grid-column:1/-1;max-width:620px}
|
||||
.main-card .pic{height:560px;border-radius:14px} .main-card:nth-child(5) .pic{height:620px}
|
||||
.info{padding:16px 2px 0} .info h3{font-size:24px;margin:0 0 9px;color:var(--ink)}
|
||||
.signals{font-size:18px;font-weight:760;color:var(--ink);line-height:1.5} .action{font-size:14px;color:var(--blue);font-weight:800;margin-top:9px}
|
||||
.detail-list{display:grid;gap:54px}
|
||||
.detail-block{display:grid;grid-template-columns:1.2fr .8fr;gap:34px;align-items:center}
|
||||
.detail-block:nth-child(even){grid-template-columns:.8fr 1.2fr} .detail-block:nth-child(even) .pic{order:2}
|
||||
.detail-block .pic{min-height:520px;border-radius:14px} .detail-copy h3{font-size:30px;margin:0 0 14px;color:var(--ink)}
|
||||
.detail-copy .signals{font-size:20px} .detail-copy .action{font-size:15px}
|
||||
.sku-layout{display:grid;grid-template-columns:1.3fr .7fr;gap:36px}
|
||||
.sku-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:12px} .sku-grid .pic{height:300px;border-radius:12px}
|
||||
.steps{border-top:1px solid var(--line)} .step{padding:17px 0;border-bottom:1px solid var(--line)}
|
||||
.step small{font-size:11px;color:var(--blue);font-weight:800} .step b{display:block;font-size:21px;color:var(--ink);margin-top:4px}
|
||||
.choice-note{margin-top:22px;padding:18px;background:var(--blue-soft);border-radius:12px;font-size:17px;font-weight:760;color:#294a78}
|
||||
.review-layout{display:grid;grid-template-columns:1.15fr .85fr;gap:38px}
|
||||
.buyer-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px} .buyer-grid .pic{height:330px;border-radius:12px}
|
||||
.insight{padding:20px 0;border-top:1px solid var(--line)} .insight:first-child{border-top:0}
|
||||
.insight small{font-size:12px;color:var(--muted);font-weight:800} .insight b{display:block;font-size:24px;line-height:1.35;color:var(--ink);margin-top:6px}
|
||||
footer{padding:34px 0 54px;border-top:1px solid var(--line);color:var(--muted);font-size:12px}
|
||||
@media(max-width:900px){
|
||||
.hero-grid,.sku-layout,.review-layout,.detail-block,.detail-block:nth-child(even){grid-template-columns:1fr}
|
||||
h1{font-size:38px} .hero-claim{font-size:21px} .big3,.plan-grid,.title-two{grid-template-columns:1fr}
|
||||
.main-grid{grid-template-columns:1fr} .main-card:nth-child(5){grid-column:auto;max-width:none}
|
||||
.detail-block:nth-child(even) .pic{order:0} .links{display:none}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav><div class="wrap"><div class="brand">三笙 · 商品决策报告</div><div class="links">
|
||||
<a href="#boss">老板速览</a><a href="#plans">新品</a><a href="#main">主图</a><a href="#detail">详情</a><a href="#sku">SKU</a><a href="#review">评价</a>
|
||||
</div></div></nav>
|
||||
|
||||
<header class="hero"><div class="wrap hero-grid">
|
||||
<div>
|
||||
<div class="kicker">PRODUCT DECISION REPORT · BIG VISUAL</div>
|
||||
<h1>本屋创品大学生宿舍床品六件套</h1>
|
||||
<div class="meta">¥597.55 · 公开销量 2000+ · 评论 47 · SKU图 30 · 参数 24</div>
|
||||
<div class="hero-claim">宿舍一站式床品定位清晰。下一款优先解决 <em>规格选择、品质证明、花色分类</em>。</div>
|
||||
</div>
|
||||
<figure class="pic cover">
|
||||
<img src="./本屋创品大学生宿舍床品六件套商品分析报告_files/O1CN019ZVIm125bfTHPjOrH_!!4611686018427387817-0-item_pic.jpg_q50.jpg_.webp" alt="商品封面图" loading="lazy" decoding="async"
|
||||
onerror="this.style.display='none';this.parentElement.classList.add('failed')">
|
||||
<div class="fallback">图片暂不可用</div>
|
||||
</figure>
|
||||
</div></header>
|
||||
|
||||
<section id="boss"><div class="wrap">
|
||||
<div class="section-kicker">01 · BOSS VIEW</div><h2>老板 30 秒看懂</h2>
|
||||
<div class="big3">
|
||||
<article><span>为什么能卖</span><b>宿舍一站式买齐</b><p>全棉品质 + 记忆棉睡感 + 新生服务。</p></article>
|
||||
<article><span>值得继承</span><b>40支全棉 + 单品拆解</b><p>免费仓储与质保继续作为新生权益。</p></article>
|
||||
<article><span>下一款重点</span><b>更好选 · 更可证 · 更好看</b><p>降低选择成本,不继续堆卖点。</p></article>
|
||||
</div>
|
||||
</div></section>
|
||||
|
||||
<section id="plans"><div class="wrap">
|
||||
<div class="section-kicker">02 · NEXT PRODUCT</div><h2>下一款先做什么</h2>
|
||||
<p class="lead">优先做规格透明化;气候/睡感适配作为体验升级,花色风格化作为探索方向。</p>
|
||||
<div class="plan-grid">
|
||||
<article class="plan"><figure class="pic ">
|
||||
<img src="./本屋创品大学生宿舍床品六件套商品分析报告_files/O1CN01L0VeCn25bfTI4vMUz_!!2200693317545.jpg_q50.jpg_.webp" alt="方案A" loading="lazy" decoding="async"
|
||||
onerror="this.style.display='none';this.parentElement.classList.add('failed')">
|
||||
<div class="fallback">图片暂不可用</div>
|
||||
</figure><div class="plan-body"><small>方案 A · 证据驱动</small><h3>规格透明化升级</h3><dl><div><dt>产品</dt><dd>统一六件套,明确床型、厚度、克重。</dd></div><div><dt>页面</dt><dd>主图前置规格,详情增加床型对照。</dd></div></dl></div></article>
|
||||
<article class="plan"><figure class="pic ">
|
||||
<img src="./本屋创品大学生宿舍床品六件套商品分析报告_files/O1CN01r2XLQZ25bfTHPwIZR_!!2200693317545.jpg_q50.jpg_.webp" alt="方案B" loading="lazy" decoding="async"
|
||||
onerror="this.style.display='none';this.parentElement.classList.add('failed')">
|
||||
<div class="fallback">图片暂不可用</div>
|
||||
</figure><div class="plan-body"><small>方案 B · 反馈驱动</small><h3>气候与睡感适配</h3><dl><div><dt>产品</dt><dd>按季节区分被芯克重,明确支撑参数。</dd></div><div><dt>页面</dt><dd>用气候/睡感指引替代概念堆叠。</dd></div></dl></div></article>
|
||||
<article class="plan"><figure class="pic ">
|
||||
<img src="./本屋创品大学生宿舍床品六件套商品分析报告_files/O1CN01CiOps125bfTHSxRq6_!!2200693317545.jpg_q50.jpg_.webp" alt="方案C" loading="lazy" decoding="async"
|
||||
onerror="this.style.display='none';this.parentElement.classList.add('failed')">
|
||||
<div class="fallback">图片暂不可用</div>
|
||||
</figure><div class="plan-body"><small>方案 C · 探索方向</small><h3>花色风格化重组</h3><dl><div><dt>产品</dt><dd>29种花色先分风格,再选具体花型。</dd></div><div><dt>页面</dt><dd>先选风格,再进入SKU选择。</dd></div></dl></div></article>
|
||||
</div>
|
||||
</div></section>
|
||||
|
||||
<section><div class="wrap">
|
||||
<div class="section-kicker">03 · TITLE</div><h2>标题只解决一个问题:说清楚</h2>
|
||||
<p class="lead">“三件套 / 六件套”存在冲突;建议统一六件套,并突出 40支斜纹全棉。</p>
|
||||
<div class="title-now"><small>当前标题</small>本屋创品大学生宿舍床上三件套纯棉寝室床单人被套六件套被子全套</div>
|
||||
<div class="title-two"><article><span>已表达</span><b>大学生宿舍 · 纯棉 · 六件套</b></article><article><span>建议强化</span><b>40支斜纹 · 规格统一 · 风格词</b></article></div>
|
||||
</div></section>
|
||||
|
||||
<section id="main"><div class="wrap">
|
||||
<div class="section-kicker">04 · MAIN IMAGES</div><h2>主图到底在卖什么</h2>
|
||||
<p class="lead">图片本身就是成交语言。下面只保留图片里的核心商业信息,以及下一步动作。</p>
|
||||
<div class="main-grid">
|
||||
<article class="main-card"><figure class="pic ">
|
||||
<img src="./本屋创品大学生宿舍床品六件套商品分析报告_files/O1CN019ZVIm125bfTHPjOrH_!!4611686018427387817-0-item_pic.jpg_q50.jpg_.webp" alt="主图1" loading="lazy" decoding="async"
|
||||
onerror="this.style.display='none';this.parentElement.classList.add('failed')">
|
||||
<div class="fallback">图片暂不可用</div>
|
||||
</figure><div class="info"><h3>宿舍一站式</h3><div class="signals">学生床品TOP1 · 免费仓储 · 质保赠品</div><div class="action">专业动作 · 突出“免费仓储”</div></div></article><article class="main-card"><figure class="pic ">
|
||||
<img src="./本屋创品大学生宿舍床品六件套商品分析报告_files/O1CN01L0VeCn25bfTI4vMUz_!!2200693317545.jpg_q50.jpg_.webp" alt="主图2" loading="lazy" decoding="async"
|
||||
onerror="this.style.display='none';this.parentElement.classList.add('failed')">
|
||||
<div class="fallback">图片暂不可用</div>
|
||||
</figure><div class="info"><h3>六件套清单</h3><div class="signals">六件套 · 尺寸 · 一次买齐</div><div class="action">专业动作 · SKU同步编号</div></div></article><article class="main-card"><figure class="pic ">
|
||||
<img src="./本屋创品大学生宿舍床品六件套商品分析报告_files/O1CN01CiOps125bfTHSxRq6_!!2200693317545.jpg_q50.jpg_.webp" alt="主图3" loading="lazy" decoding="async"
|
||||
onerror="this.style.display='none';this.parentElement.classList.add('failed')">
|
||||
<div class="fallback">图片暂不可用</div>
|
||||
</figure><div class="info"><h3>全棉品质</h3><div class="signals">100%新疆棉 · 高支数 · 亲肤裸睡</div><div class="action">专业动作 · 直接标明40支</div></div></article><article class="main-card"><figure class="pic ">
|
||||
<img src="./本屋创品大学生宿舍床品六件套商品分析报告_files/O1CN01r2XLQZ25bfTHPwIZR_!!2200693317545.jpg_q50.jpg_.webp" alt="主图4" loading="lazy" decoding="async"
|
||||
onerror="this.style.display='none';this.parentElement.classList.add('failed')">
|
||||
<div class="fallback">图片暂不可用</div>
|
||||
</figure><div class="info"><h3>床垫支撑</h3><div class="signals">A2 Pro · 包裹感 · 支撑力</div><div class="action">专业动作 · 明确4cm / 6cm</div></div></article><article class="main-card"><figure class="pic ">
|
||||
<img src="./本屋创品大学生宿舍床品六件套商品分析报告_files/O1CN01hrplMs25bfTHFqSzR_!!2200693317545.jpg_q50.jpg_.webp" alt="主图5" loading="lazy" decoding="async"
|
||||
onerror="this.style.display='none';this.parentElement.classList.add('failed')">
|
||||
<div class="fallback">图片暂不可用</div>
|
||||
</figure><div class="info"><h3>枕被功能</h3><div class="signals">记忆枕 · 泡芙被 · A类/7A级</div><div class="action">专业动作 · 补检测依据</div></div></article>
|
||||
</div>
|
||||
</div></section>
|
||||
|
||||
<section id="detail"><div class="wrap">
|
||||
<div class="section-kicker">05 · DETAIL PAGE</div><h2>详情页证明了什么</h2>
|
||||
<p class="lead">不再逐张解释,只保留最关键的信息任务与证明内容。</p>
|
||||
<div class="detail-list">
|
||||
<article class="detail-block"><figure class="pic ">
|
||||
<img src="./本屋创品大学生宿舍床品六件套商品分析报告_files/O1CN01o4jxnH25bfTD68A5Q_!!2200693317545.jpg" alt="品牌与服务" loading="lazy" decoding="async"
|
||||
onerror="this.style.display='none';this.parentElement.classList.add('failed')">
|
||||
<div class="fallback">图片暂不可用</div>
|
||||
</figure><div class="detail-copy"><h3>品牌与服务</h3><div class="signals">轻装上阵 · 免费仓储</div><div class="action">专业动作 · 补仓储流程</div></div></article><article class="detail-block"><figure class="pic ">
|
||||
<img src="./本屋创品大学生宿舍床品六件套商品分析报告_files/O1CN01ne5twn25bfTHPTM86_!!2200693317545.jpg" alt="套件组成" loading="lazy" decoding="async"
|
||||
onerror="this.style.display='none';this.parentElement.classList.add('failed')">
|
||||
<div class="fallback">图片暂不可用</div>
|
||||
</figure><div class="detail-copy"><h3>套件组成</h3><div class="signals">六件套 · 尺寸清单</div><div class="action">专业动作 · 继续前置</div></div></article><article class="detail-block"><figure class="pic ">
|
||||
<img src="./本屋创品大学生宿舍床品六件套商品分析报告_files/O1CN01GlB5rj25bfTHgsvv5_!!2200693317545.jpg" alt="材质品质" loading="lazy" decoding="async"
|
||||
onerror="this.style.display='none';this.parentElement.classList.add('failed')">
|
||||
<div class="fallback">图片暂不可用</div>
|
||||
</figure><div class="detail-copy"><h3>材质品质</h3><div class="signals">新疆棉 · 40支斜纹 · 全棉</div><div class="action">专业动作 · 参数具体化</div></div></article><article class="detail-block"><figure class="pic ">
|
||||
<img src="./本屋创品大学生宿舍床品六件套商品分析报告_files/O1CN01Lza83v25bfTHE1OLp_!!2200693317545.jpg" alt="产品功能" loading="lazy" decoding="async"
|
||||
onerror="this.style.display='none';this.parentElement.classList.add('failed')">
|
||||
<div class="fallback">图片暂不可用</div>
|
||||
</figure><div class="detail-copy"><h3>产品功能</h3><div class="signals">A2 Pro记忆棉 · 支撑回弹</div><div class="action">专业动作 · 补厚度密度</div></div></article><article class="detail-block"><figure class="pic ">
|
||||
<img src="./本屋创品大学生宿舍床品六件套商品分析报告_files/O1CN01A6gpVO25bfTHbTlUj_!!2200693317545.jpg" alt="使用便利" loading="lazy" decoding="async"
|
||||
onerror="this.style.display='none';this.parentElement.classList.add('failed')">
|
||||
<div class="fallback">图片暂不可用</div>
|
||||
</figure><div class="detail-copy"><h3>使用便利</h3><div class="signals">床笠 · 宿舍床型</div><div class="action">专业动作 · 补适配范围</div></div></article><article class="detail-block"><figure class="pic ">
|
||||
<img src="./本屋创品大学生宿舍床品六件套商品分析报告_files/O1CN017XQuFJ25bfTHaDMw0_!!2200693317545.jpg" alt="花型选择" loading="lazy" decoding="async"
|
||||
onerror="this.style.display='none';this.parentElement.classList.add('failed')">
|
||||
<div class="fallback">图片暂不可用</div>
|
||||
</figure><div class="detail-copy"><h3>花型选择</h3><div class="signals">29种花色 · OOTD</div><div class="action">专业动作 · 先按风格分组</div></div></article>
|
||||
</div>
|
||||
</div></section>
|
||||
|
||||
<section id="sku"><div class="wrap">
|
||||
<div class="section-kicker">06 · SKU</div><h2>先解决适配,再解决审美</h2>
|
||||
<p class="lead">SKU不是“有多少图”,而是用户能不能快速选到适合自己的组合。</p>
|
||||
<div class="sku-layout">
|
||||
<div class="sku-grid"><figure class="pic ">
|
||||
<img src="./本屋创品大学生宿舍床品六件套商品分析报告_files/O1CN01lTaWVO25bfTJ4LM42_!!2200693317545.jpg_.webp" alt="SKU 1" loading="lazy" decoding="async"
|
||||
onerror="this.style.display='none';this.parentElement.classList.add('failed')">
|
||||
<div class="fallback">图片暂不可用</div>
|
||||
</figure><figure class="pic ">
|
||||
<img src="./本屋创品大学生宿舍床品六件套商品分析报告_files/O1CN01e3G6nj25bfTHRIHk4_!!2200693317545.jpg_.webp" alt="SKU 2" loading="lazy" decoding="async"
|
||||
onerror="this.style.display='none';this.parentElement.classList.add('failed')">
|
||||
<div class="fallback">图片暂不可用</div>
|
||||
</figure><figure class="pic ">
|
||||
<img src="./本屋创品大学生宿舍床品六件套商品分析报告_files/O1CN01y3Fy3l25bfTHDbXna_!!2200693317545.jpg_.webp" alt="SKU 3" loading="lazy" decoding="async"
|
||||
onerror="this.style.display='none';this.parentElement.classList.add('failed')">
|
||||
<div class="fallback">图片暂不可用</div>
|
||||
</figure><figure class="pic ">
|
||||
<img src="./本屋创品大学生宿舍床品六件套商品分析报告_files/O1CN01zifUgv25bfTHAdbit_!!2200693317545.jpg_.webp" alt="SKU 4" loading="lazy" decoding="async"
|
||||
onerror="this.style.display='none';this.parentElement.classList.add('failed')">
|
||||
<div class="fallback">图片暂不可用</div>
|
||||
</figure><figure class="pic ">
|
||||
<img src="./本屋创品大学生宿舍床品六件套商品分析报告_files/O1CN01QKYFxU25bfTHMyUrY_!!2200693317545.jpg_.webp" alt="SKU 5" loading="lazy" decoding="async"
|
||||
onerror="this.style.display='none';this.parentElement.classList.add('failed')">
|
||||
<div class="fallback">图片暂不可用</div>
|
||||
</figure><figure class="pic ">
|
||||
<img src="./本屋创品大学生宿舍床品六件套商品分析报告_files/O1CN01DwEGzR25bfTHAd0JD_!!2200693317545.jpg_.webp" alt="SKU 6" loading="lazy" decoding="async"
|
||||
onerror="this.style.display='none';this.parentElement.classList.add('failed')">
|
||||
<div class="fallback">图片暂不可用</div>
|
||||
</figure><figure class="pic ">
|
||||
<img src="./本屋创品大学生宿舍床品六件套商品分析报告_files/O1CN01lq06CQ25bfTHFUiH4_!!2200693317545.jpg_.webp" alt="SKU 7" loading="lazy" decoding="async"
|
||||
onerror="this.style.display='none';this.parentElement.classList.add('failed')">
|
||||
<div class="fallback">图片暂不可用</div>
|
||||
</figure><figure class="pic ">
|
||||
<img src="./本屋创品大学生宿舍床品六件套商品分析报告_files/O1CN01hK1u2r25bfTHOh79a_!!2200693317545.jpg_.webp" alt="SKU 8" loading="lazy" decoding="async"
|
||||
onerror="this.style.display='none';this.parentElement.classList.add('failed')">
|
||||
<div class="fallback">图片暂不可用</div>
|
||||
</figure></div>
|
||||
<div>
|
||||
<div class="steps">
|
||||
<div class="step"><small>01</small><b>套件:三 / 四 / 五件套</b></div>
|
||||
<div class="step"><small>02</small><b>床型:80×190 / 90×190 / 90×200</b></div>
|
||||
<div class="step"><small>03</small><b>床垫:4cm / 6cm</b></div>
|
||||
<div class="step"><small>04</small><b>被芯:4.5斤 / 6斤</b></div>
|
||||
<div class="step"><small>05</small><b>花色:按风格再选具体花型</b></div>
|
||||
</div>
|
||||
<div class="choice-note">建议固定选择路径:套件 → 床型 → 厚度 → 被芯 → 花色。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div></section>
|
||||
|
||||
<section id="review"><div class="wrap">
|
||||
<div class="section-kicker">07 · CONSUMER</div><h2>消费者真正感受到什么</h2>
|
||||
<p class="lead">买家秀是证据。结论只看三件事:稳定体验、体验摩擦、下一款动作。</p>
|
||||
<div class="review-layout">
|
||||
<div class="buyer-grid"><figure class="pic ">
|
||||
<img src="./本屋创品大学生宿舍床品六件套商品分析报告_files/O1CN019QvTfRQhjvE3thAe_!!4611686018427381919-0-rate.jpg_960x960.jpg_.webp" alt="买家秀 1" loading="lazy" decoding="async"
|
||||
onerror="this.style.display='none';this.parentElement.classList.add('failed')">
|
||||
<div class="fallback">图片暂不可用</div>
|
||||
</figure><figure class="pic ">
|
||||
<img src="./本屋创品大学生宿舍床品六件套商品分析报告_files/O1CN01zfrhh56y0yC5eT3Y_!!4611686018427381919-0-rate.jpg_960x960.jpg_.webp" alt="买家秀 2" loading="lazy" decoding="async"
|
||||
onerror="this.style.display='none';this.parentElement.classList.add('failed')">
|
||||
<div class="fallback">图片暂不可用</div>
|
||||
</figure><figure class="pic ">
|
||||
<img src="./本屋创品大学生宿舍床品六件套商品分析报告_files/O1CN0134Jp6sFQskK7XHVo_!!4611686018427381919-0-rate.jpg_960x960.jpg_.webp" alt="买家秀 3" loading="lazy" decoding="async"
|
||||
onerror="this.style.display='none';this.parentElement.classList.add('failed')">
|
||||
<div class="fallback">图片暂不可用</div>
|
||||
</figure><figure class="pic ">
|
||||
<img src="./本屋创品大学生宿舍床品六件套商品分析报告_files/O1CN016WzMAKlM79K7XHVo_!!4611686018427381919-0-rate.jpg_960x960.jpg_.webp" alt="买家秀 4" loading="lazy" decoding="async"
|
||||
onerror="this.style.display='none';this.parentElement.classList.add('failed')">
|
||||
<div class="fallback">图片暂不可用</div>
|
||||
</figure></div>
|
||||
<div>
|
||||
<div class="insight"><small>稳定体验</small><b>柔软亲肤 · 透气 · 记忆棉回弹</b></div>
|
||||
<div class="insight"><small>体验摩擦</small><b>色差 · 包装洁净 · 尺寸适配</b></div>
|
||||
<div class="insight"><small>下一款动作</small><b>规格透明 + 品控证明 + 花色分组</b></div>
|
||||
</div>
|
||||
</div>
|
||||
</div></section>
|
||||
|
||||
<footer><div class="wrap">三笙 · 大图大字商品决策报告 · 基于当前商品页面、图片、参数与评论证据。</div></footer>
|
||||
</body></html>
|
||||
@@ -0,0 +1,40 @@
|
||||
# V9.2.5 报告输出标准
|
||||
|
||||
## 1. 阅读优先级
|
||||
图片信息 > 核心判断 > 专业动作 > 详细证据。
|
||||
|
||||
老板默认只需前两章即可完成方向判断;专业用户继续阅读图片证据与执行细节。
|
||||
|
||||
## 2. 字数硬限制
|
||||
- 核心判断:55字以内
|
||||
- 老板速览 headline/note:各28字以内
|
||||
- 主图 imageRole:10字以内
|
||||
- 主图 visualSignals:2-4项,每项12字以内
|
||||
- 主图 nextAction:22字以内
|
||||
- 详情 visualSignals:2-4项,每项12字以内
|
||||
- 详情 nextAction:22字以内
|
||||
- 评论 insight:35字以内
|
||||
- 新品 whyThisPlan:45字以内
|
||||
- 新品 productAction/pageAction:各35字以内
|
||||
|
||||
## 3. 图片规则
|
||||
- 所有商品图默认完整展示:`object-fit: contain`。
|
||||
- 不允许为了卡片整齐裁掉图片文字、产品主体或规格信息。
|
||||
- 封面无边框、无卡片套、无大阴影。
|
||||
- 详情只选4-6张代表图默认展示,其余保留为证据。
|
||||
|
||||
## 4. 主图/详情内容规则
|
||||
默认展示:图片 + 2-4个 visualSignals + 1条 nextAction。
|
||||
不默认展示:构图、OCR、颜色、材质识别过程、长段 businessMeaning。
|
||||
|
||||
## 5. 新品方案规则
|
||||
- A:证据驱动优化
|
||||
- B:反馈驱动升级
|
||||
- C:探索性方向(如存在)
|
||||
探索性方向必须显式标记,不能伪装成当前商品已验证事实。
|
||||
|
||||
## 6. 禁止项
|
||||
- 禁止 `[数据事实][分析判断][设计建议][证据不足]`
|
||||
- 禁止同一结论在一个章节重复三次
|
||||
- 禁止通过 Renderer 用省略号硬截断长段落充当“精简”
|
||||
- 禁止 SKU 图片数当作 SKU 数量
|
||||
@@ -0,0 +1,12 @@
|
||||
# V9.2.5 大图大字报告输出标准
|
||||
|
||||
1. 首屏:商品图占主要视觉,标题 48px,核心判断 26px。
|
||||
2. 老板速览:3个大结论,不使用密集小卡。
|
||||
3. 新品:3张大图决策卡;只展示产品动作、页面动作。
|
||||
4. 主图:桌面端2列,每图约560px高,第5张可单独放大。
|
||||
5. 详情:单组大图+大标题,交错排版;图片完整 contain。
|
||||
6. SKU:2列大图,右侧用大字展示选择路径。
|
||||
7. 评论:默认4张大买家秀 + 3-4条大字体验结论。
|
||||
8. 禁止 `object-fit: cover` 裁掉图片文字或商品主体。
|
||||
9. 默认可见正文不靠省略号截断,Prompt阶段直接生成短文本。
|
||||
10. 视觉原则:大图讲事实,大字讲判断,小字只做补充。
|
||||
@@ -0,0 +1,32 @@
|
||||
# V9.2 报告输出标准
|
||||
|
||||
## 阅读层级
|
||||
|
||||
1. 商品封面图 + 一条专业意见
|
||||
2. 商品重点(最多 3 项)
|
||||
3. 标题表达(当前表达 / 可强化 / 专业建议)
|
||||
4. 主图表达(图片直接展示,每图只保留角色、信息、建议)
|
||||
5. 详情页逻辑(4–7 个信息任务组 + 代表图)
|
||||
6. SKU 选择逻辑(代表图 + 选择顺序)
|
||||
7. 消费者反馈(买家秀 + 聚合洞察)
|
||||
8. 商品核心竞争表达(场景 / 产品 / 服务 / 可强化)
|
||||
9. 新品建议(1–3 套证据可追溯方案)
|
||||
|
||||
## 用户可见语言
|
||||
|
||||
使用:专业意见、当前表达、可进一步强化、需要关注、专业建议、下一款建议。
|
||||
|
||||
不使用:做得好/差、主要问题、整体有效点、整体断层、严重错误,以及 `[数据事实][分析判断][设计建议]` 等模型内部标签。
|
||||
|
||||
## 图片规则
|
||||
|
||||
- cover:第一张主图,Renderer 不加边框与卡片底。
|
||||
- main:直接展示,建议最多 5–8 张。
|
||||
- detail:默认只展示 4–7 张代表图,其余为证据。
|
||||
- sku:默认 8 张代表图拼图;图片数不等于结构化 SKU 数。
|
||||
- buyerShow:默认 6 张买家秀。
|
||||
- 图片加载优先 `localUrl`,失败回退 `sourceUrl`。
|
||||
|
||||
## 新品规则
|
||||
|
||||
有基础商品证据且存在主图/详情时,至少形成 1 套方案。方案必须能回溯 evidenceIds;无法确定的规格写待验证,不得凭空补市场趋势、利润、转化或竞品结论。
|
||||
@@ -0,0 +1 @@
|
||||
9.2.5
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "sansong-tmall-product-report",
|
||||
"version": "9.0.0",
|
||||
"description": "三笙跨类目电商商品采集、分析、开品与AI视觉报告插件。",
|
||||
"author": {
|
||||
"name": "三笙市场洞察"
|
||||
},
|
||||
"skills": "./skills/",
|
||||
"interface": {
|
||||
"displayName": "三笙电商商品分析报告",
|
||||
"shortDescription": "跨类目采集商品证据并输出图文开品报告。",
|
||||
"longDescription": "根据类目、标题、主图、详情、SKU、参数、评论与问答动态生成有证据链的电商商品分析报告、新品设计方案和AI视觉参数。",
|
||||
"developerName": "三笙市场洞察",
|
||||
"category": "Productivity",
|
||||
"capabilities": ["Read", "Write", "Analysis", "Reporting"],
|
||||
"brandColor": "#00AFC5",
|
||||
"defaultPrompt": [
|
||||
"基于最新采集生成商品分析报告",
|
||||
"检查商品字段采集是否完整",
|
||||
"输出下一款开品卖点与落地方案"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
# 三笙电商商品分析报告插件 v9.2.5
|
||||
|
||||
用于把淘宝/天猫当前商品采集结果转成跨类目商品分析、新品设计和 AI 视觉报告。
|
||||
|
||||
- `skills/tmall-product-analysis-report/SKILL.md`:跨类目分析与报告规范。
|
||||
- `scripts/normalize_capture.mjs`:采集 JSON 标准化。
|
||||
- `assets/capture-extension/`:通用商品页采集扩展参考。
|
||||
- `assets/taobao_detail_fields_v8.3.6.xlsx`:保持原文件名的跨类目字段对照表。
|
||||
|
||||
报告明确展开标题、主图、详情、SKU、评论、商品价值结构、设计资产分级及 AI 生成规格;用户只负责采集并生成。
|
||||
@@ -0,0 +1,3 @@
|
||||
# 三笙电商商品详情采集扩展 v9.2.5
|
||||
|
||||
跨类目采集商品标题、类目候选、主图、详情、SKU、动态参数、评论和问答。家纺仅为分析模板分支,不限制采集字段。
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "三笙商品详情采集",
|
||||
"version": "9.2.5",
|
||||
"description": "跨类目解析商品标题、主图、详情、活动、SKU、动态参数、评论与问答。",
|
||||
"permissions": ["activeTab", "scripting", "tabs", "alarms", "cookies"],
|
||||
"host_permissions": [
|
||||
"http://127.0.0.1:3300/*",
|
||||
"http://localhost:3300/*",
|
||||
"http://127.0.0.1/*",
|
||||
"http://localhost/*",
|
||||
"https://*.taobao.com/*",
|
||||
"https://item.jd.com/*",
|
||||
"https://*.jd.com/*",
|
||||
"https://item.taobao.com/*",
|
||||
"https://detail.tmall.com/*",
|
||||
"https://*.tmall.com/*"
|
||||
],
|
||||
"content_scripts": [{
|
||||
"matches": ["http://127.0.0.1/*", "http://localhost/*"],
|
||||
"js": ["page_bridge.js"],
|
||||
"run_at": "document_start"
|
||||
}],
|
||||
"background": { "service_worker": "service_worker.js" },
|
||||
"action": { "default_title": "重新采集当前商品页" }
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
window.addEventListener('message',event=>{
|
||||
if(event.source!==window||event.data?.type!=='SANSONG_COLLECTOR_PING')return
|
||||
chrome.runtime.sendMessage({type:'SANSONG_COLLECTOR_WAKE'},response=>{
|
||||
window.postMessage({type:'SANSONG_COLLECTOR_PONG',requestId:event.data.requestId,installed:true,version:chrome.runtime.getManifest().version,connected:Boolean(response?.connected)},'*')
|
||||
})
|
||||
})
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
const API_BASE='http://127.0.0.1:3300/api/collector/product-page'
|
||||
|
||||
async function capture(tabId){
|
||||
const result=await chrome.scripting.executeScript({target:{tabId},func:()=>{
|
||||
const clean=value=>String(value||'').replace(/\s+/g,' ').trim()
|
||||
const uniq=rows=>[...new Set(rows.filter(Boolean))]
|
||||
const bodyText=document.body?.innerText||''
|
||||
const lines=bodyText.split(/\n+/).map(clean).filter(Boolean)
|
||||
const url=location.href
|
||||
const itemId=url.match(/[?&]id=(\d+)/)?.[1]||url.match(/\/(\d+)\.html/)?.[1]||''
|
||||
const skuId=url.match(/[?&]skuId=(\d+)/)?.[1]||''
|
||||
const platform=/tmall/i.test(location.hostname)?'tmall':/taobao/i.test(location.hostname)?'taobao':/jd\.com/i.test(location.hostname)?'jd':'unknown'
|
||||
const title=clean(document.querySelector('h1')?.textContent||document.querySelector('meta[property="og:title"]')?.content||document.title)
|
||||
const readAfter=patterns=>{const row=lines.find(line=>patterns.some(pattern=>pattern.test(line)));if(!row)return'';const parts=row.split(/[::]/);return clean(parts.length>1?parts.slice(1).join(':'):row)}
|
||||
const shop=clean(document.querySelector('[class*="shop-name"],[class*="shopName"],[class*="seller-name"],[class*="sellerName"]')?.textContent)||readAfter([/^店铺/,/^店名/,/^卖家/])
|
||||
const priceText=clean(document.querySelector('[class*="promotionPrice"],[class*="salePrice"],[class*="price"]')?.textContent||'')
|
||||
const currentPrice=clean(priceText.match(/[¥¥]?\s*(\d+(?:\.\d+)?)/)?.[1]||bodyText.match(/(?:券后|到手价|活动价|价格)\s*[¥¥]?\s*(\d+(?:\.\d+)?)/)?.[1]||'')
|
||||
const originalPrice=clean(bodyText.match(/(?:原价|划线价|优惠前)\s*[¥¥]?\s*(\d+(?:\.\d+)?)/)?.[1]||'')
|
||||
const sold=clean(bodyText.match(/(?:已售|付款|销量|累计销量)\s*([0-9.万wW+]+)/)?.[1]||'')
|
||||
const publicReviewCount=clean(bodyText.match(/(?:累计评价|全部评价|商品评价|评论)\s*([0-9.万wW+]+)/)?.[1]||'')
|
||||
const ranking=clean(bodyText.match(/(?:热销榜|排行榜|榜第)\s*第?\s*([0-9]+)\s*名/)?.[0]||'')
|
||||
|
||||
const meta={}
|
||||
document.querySelectorAll('meta').forEach(el=>{const key=el.getAttribute('property')||el.getAttribute('name');const value=el.getAttribute('content');if(key&&value&&!/cookie|authorization|token|password/i.test(key))meta[key]=value})
|
||||
const jsonld=[]
|
||||
document.querySelectorAll('script[type="application/ld+json"]').forEach(el=>{try{jsonld.push(JSON.parse(el.textContent||'null'))}catch{}})
|
||||
|
||||
const badImage=/avatar|icon|logo|sprite|loading|placeholder|qrcode|qr-code|emoji|badge/i
|
||||
const imageRows=[...document.images].map(img=>({
|
||||
url:img.currentSrc||img.src,
|
||||
width:img.naturalWidth||img.width||0,
|
||||
height:img.naturalHeight||img.height||0,
|
||||
alt:clean(img.alt),
|
||||
context:clean(`${img.alt||''} ${img.closest('[class],[id]')?.className||''} ${img.closest('[class],[id]')?.id||''}`)
|
||||
})).filter(row=>/^https?:\/\//.test(row.url)&&!badImage.test(`${row.url} ${row.context}`)&&(row.width>=120||row.height>=120))
|
||||
const uniqueImages=rows=>{const seen=new Set();return rows.filter(row=>{const key=row.url.replace(/_[0-9]+x[0-9]+[^/]*$/,'');if(seen.has(key))return false;seen.add(key);return true})}
|
||||
const reviewRows=uniqueImages(imageRows.filter(row=>/review|comment|rate|evaluate|buyer|show|评价|评论|买家秀|晒单/i.test(row.context)))
|
||||
const detailRows=uniqueImages(imageRows.filter(row=>/detail|description|desc|richtext|module|详情|图文|描述/i.test(row.context)&&!reviewRows.some(x=>x.url===row.url)))
|
||||
const skuRows=uniqueImages(imageRows.filter(row=>/sku|prop|variant|option|规格|颜色|尺码|型号|口味/i.test(row.context)&&!reviewRows.some(x=>x.url===row.url)&&!detailRows.some(x=>x.url===row.url)))
|
||||
const mainRows=uniqueImages(imageRows.filter(row=>/gallery|thumb|preview|main|swiper|carousel|主图|轮播/i.test(row.context)&&!reviewRows.some(x=>x.url===row.url)&&!detailRows.some(x=>x.url===row.url))).slice(0,30)
|
||||
const allImages=uniqueImages(imageRows)
|
||||
|
||||
const blockedAttribute=/优惠|券|立减|包邮|发货|送达|价保|退货|客服|店铺|销量|付款|评论|评价|问答/
|
||||
const attributes=[]
|
||||
const attributeSeen=new Set()
|
||||
const addAttribute=(name,value,source='dom')=>{name=clean(name).replace(/[::]$/,'');value=clean(value);if(!name||!value||name.length>36||value.length>600||blockedAttribute.test(name))return;const key=`${name}\u0000${value}`;if(attributeSeen.has(key))return;attributeSeen.add(key);attributes.push({name,value,source})}
|
||||
document.querySelectorAll('table tr,[class*="attribute"] li,[class*="Attribute"] li,[class*="parameter"] li,[class*="Parameter"] li,[class*="property"] li,[class*="Property"] li,[class*="attr"] li').forEach(el=>{
|
||||
const cells=[...el.querySelectorAll(':scope > th,:scope > td,:scope > span,:scope > div')].map(x=>clean(x.textContent)).filter(Boolean)
|
||||
if(cells.length>=2)addAttribute(cells[0],cells.slice(1).join(' '),'attribute_dom')
|
||||
else{const text=clean(el.textContent);const match=text.match(/^([^::]{1,36})[::]\s*(.{1,600})$/);if(match)addAttribute(match[1],match[2],'attribute_dom')}
|
||||
})
|
||||
lines.forEach(line=>{const match=line.match(/^([^::]{1,30})[::]\s*(.{1,400})$/);if(match)addAttribute(match[1],match[2],'page_text')})
|
||||
|
||||
const breadcrumb=uniq([...document.querySelectorAll('[class*="breadcrumb"] a,[class*="crumb"] a,[class*="category"] a')].map(el=>clean(el.textContent))).filter(x=>x.length<=40).slice(0,12)
|
||||
const categoryCandidates=uniq([...breadcrumb,...attributes.filter(x=>/类目|分类|品类|产品类型|商品类型/.test(x.name)).map(x=>x.value)]).slice(0,20)
|
||||
|
||||
const variants=[]
|
||||
const variantSeen=new Set()
|
||||
const addVariant=row=>{const name=clean(row.name);if(!name||name.length>180)return;const key=`${clean(row.group)}\u0000${name}`;if(variantSeen.has(key))return;variantSeen.add(key);variants.push({...row,name,group:clean(row.group),source:row.source||'sku_dom'})}
|
||||
document.querySelectorAll('[class*="sku"],[class*="Sku"],[class*="variant"],[class*="Variant"],[class*="prop"] ,[class*="Prop"]').forEach(container=>{
|
||||
const group=clean(container.querySelector('[class*="title"],[class*="label"],[class*="name"]')?.textContent||'')
|
||||
container.querySelectorAll('button,[role="option"],li,[class*="item"],[class*="Item"]').forEach(el=>{
|
||||
const name=clean(el.getAttribute('title')||el.getAttribute('aria-label')||el.textContent)
|
||||
const image=el.querySelector('img')?.currentSrc||el.querySelector('img')?.src||''
|
||||
addVariant({group,name,image,selected:el.getAttribute('aria-selected')==='true'||/selected|active|current/i.test(el.className),disabled:el.getAttribute('aria-disabled')==='true'||el.hasAttribute('disabled')})
|
||||
})
|
||||
})
|
||||
const variantKeywords=/(颜色|色号|尺码|尺寸|规格|型号|版本|配置|容量|净含量|口味|香型|套装|套餐|款式|适用|包装|重量|数量|码数|度数|功率|内存|存储)/
|
||||
lines.filter(line=>variantKeywords.test(line)&&line.length<=500).slice(0,180).forEach(line=>line.split(/[,,]/).map(clean).filter(Boolean).forEach(name=>addVariant({group:'',name,source:'page_text'})))
|
||||
|
||||
const promotionPattern=/(优惠|券|立减|满减|包邮|免运费|价保|退货|退款|七天无理由|预计.*发货|送达|大促|活动|限时|到手价|店铺优惠|赠品|买一送一)/i
|
||||
const promotions=uniq(lines.filter(line=>promotionPattern.test(line)&&line.length<=120)).slice(0,100).map(text=>({text,source:'page_text'}))
|
||||
|
||||
const questions=[]
|
||||
document.querySelectorAll('[class*="question"],[class*="Question"],[class*="ask"],[class*="Ask"]').forEach((el,index)=>{
|
||||
const text=clean(el.innerText);if(!text)return
|
||||
const question=clean(el.querySelector('[class*="question"],[class*="title"]')?.textContent||text.match(/(.{2,160}[??])/)?.[1])
|
||||
const answer=clean(el.querySelector('[class*="answer"]')?.textContent||'')
|
||||
if(question)questions.push({id:`question-${index+1}`,question,answer,source:'question_dom'})
|
||||
})
|
||||
|
||||
const reviewSelectors=['[class*="review-item"]','[class*="comment-item"]','[class*="rate-item"]','[class*="ReviewItem"]','[class*="CommentItem"]','[data-testid*="review"]']
|
||||
const reviews=[...document.querySelectorAll(reviewSelectors.join(','))].slice(0,500).map((el,index)=>{
|
||||
const content=clean(el.innerText);if(content.length<6||content.length>3000)return null
|
||||
const images=uniq([...el.querySelectorAll('img')].map(img=>img.currentSrc||img.src).filter(src=>/^https?:\/\//.test(src)&&!badImage.test(src)))
|
||||
const date=content.match(/20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?(?:\s+\d{1,2}:\d{2})?/)?.[0]||''
|
||||
const appendContent=clean(content.match(/(?:追评|追加评价)[::\s]*(.{2,800})/)?.[1]||'')
|
||||
const sku=clean(el.querySelector('[class*="sku"],[class*="spec"],[class*="attr"]')?.textContent||'')
|
||||
const merchantReply=clean(el.querySelector('[class*="reply"],[class*="seller"]')?.textContent||'')
|
||||
const rating=Number(el.getAttribute('data-score')||el.getAttribute('data-rating')||el.querySelector('[data-score]')?.getAttribute('data-score'))||null
|
||||
return{id:el.getAttribute('data-id')||el.id||`review-${index+1}`,content,appendContent,date,rating,sku,hasImage:images.length>0,images,merchantReply,source:'review_dom'}
|
||||
}).filter(Boolean)
|
||||
|
||||
const collection={
|
||||
publicReviewCount,
|
||||
reviewActual:reviews.length,
|
||||
questionActual:questions.length,
|
||||
reviewCompleteness:reviews.length?'visible_content_collected':'no_visible_review_nodes',
|
||||
reviewStopReason:reviews.length?'visible_dom_exhausted':'reviews_not_open_or_platform_folded',
|
||||
capturedAt:new Date().toISOString(),
|
||||
collectorVersion:chrome.runtime.getManifest().version,
|
||||
platform
|
||||
}
|
||||
const structured={
|
||||
meta:{...collection,url},
|
||||
category:{candidates:categoryCandidates,breadcrumb},
|
||||
product:{itemId,skuId,title,shop,brand:attributes.find(item=>/^品牌$/.test(item.name))?.value||'',url,platform},
|
||||
sales:{currentPrice,originalPrice,sold,ranking},
|
||||
promotions,attributes,sku:variants,questions,reviews,
|
||||
images:{all:allImages,main:mainRows,detail:detailRows.slice(0,120),sku:skuRows.slice(0,120),buyerShow:reviewRows.slice(0,160)},
|
||||
collection,pageText:bodyText
|
||||
}
|
||||
return{structured,...structured,jsonld,meta}
|
||||
}})
|
||||
return result?.[0]?.result
|
||||
}
|
||||
|
||||
const lastCaptured=new Map()
|
||||
function isProductUrl(url=''){return /^https:\/\/(?:item\.jd\.com\/\d+\.html|item\.taobao\.com\/item\.htm|detail\.tmall\.com\/item\.htm)/i.test(url)}
|
||||
|
||||
async function reportAuthState(){
|
||||
try{
|
||||
const [taobaoCookies,jdCookies]=await Promise.all([chrome.cookies.getAll({domain:'.taobao.com'}),chrome.cookies.getAll({domain:'.jd.com'})])
|
||||
const response=await fetch(`${API_BASE}/auth-heartbeat`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({version:chrome.runtime.getManifest().version,platforms:{taobao:{loggedIn:taobaoCookies.some(cookie=>/tracknick|cookie2|_tb_token_|sgcookie/i.test(cookie.name))},jd:{loggedIn:jdCookies.some(cookie=>/pin|thor|unick/i.test(cookie.name))}}})})
|
||||
return response.ok
|
||||
}catch(error){console.error('[三笙登录态上报]',error);return false}
|
||||
}
|
||||
|
||||
async function captureAndSend(tab,{force=false}={}){
|
||||
if(!tab?.id||!isProductUrl(tab.url))return
|
||||
const key=`${tab.id}:${tab.url}`
|
||||
if(!force&&Date.now()-(lastCaptured.get(key)||0)<30_000)return
|
||||
lastCaptured.set(key,Date.now())
|
||||
try{
|
||||
await chrome.action.setBadgeText({tabId:tab.id,text:'采'})
|
||||
await chrome.action.setBadgeBackgroundColor({tabId:tab.id,color:'#2563EB'})
|
||||
const payload=await capture(tab.id)
|
||||
const response=await fetch(`${API_BASE}/capture`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)})
|
||||
if(!response.ok)throw new Error(await response.text())
|
||||
await chrome.action.setBadgeText({tabId:tab.id,text:'OK'})
|
||||
await chrome.action.setBadgeBackgroundColor({tabId:tab.id,color:'#155C4E'})
|
||||
setTimeout(()=>chrome.action.setBadgeText({tabId:tab.id,text:''}),2500)
|
||||
}catch(error){
|
||||
lastCaptured.delete(key)
|
||||
await chrome.action.setBadgeText({tabId:tab.id,text:'!'})
|
||||
await chrome.action.setBadgeBackgroundColor({tabId:tab.id,color:'#A54F43'})
|
||||
console.error('[三笙商品采集]',error)
|
||||
}
|
||||
}
|
||||
|
||||
chrome.tabs.onUpdated.addListener((tabId,changeInfo,tab)=>{if(changeInfo.status==='complete'&&isProductUrl(tab.url))setTimeout(()=>captureAndSend({...tab,id:tabId}),2500)})
|
||||
chrome.tabs.onRemoved.addListener(tabId=>{for(const key of lastCaptured.keys())if(key.startsWith(`${tabId}:`))lastCaptured.delete(key)})
|
||||
chrome.action.onClicked.addListener(tab=>captureAndSend(tab,{force:true}))
|
||||
|
||||
let queueBusy=false
|
||||
let queueBackoffMs=1000
|
||||
async function processCaptureQueue(){
|
||||
if(queueBusy)return
|
||||
queueBusy=true
|
||||
let tab
|
||||
try{
|
||||
const response=await fetch(`${API_BASE}/next`)
|
||||
if(response.status===204){queueBackoffMs=Math.min(queueBackoffMs*1.5,15_000);return}
|
||||
if(!response.ok)throw new Error(`任务队列 HTTP ${response.status}`)
|
||||
const task=await response.json()
|
||||
tab=await chrome.tabs.create({url:task.url,active:false})
|
||||
await new Promise((resolve,reject)=>{
|
||||
const timeout=setTimeout(()=>{chrome.tabs.onUpdated.removeListener(listener);reject(new Error('商品页加载超时'))},45_000)
|
||||
const listener=(tabId,changeInfo,updated)=>{if(tabId===tab.id&&changeInfo.status==='complete'){clearTimeout(timeout);chrome.tabs.onUpdated.removeListener(listener);resolve(updated)}}
|
||||
chrome.tabs.onUpdated.addListener(listener)
|
||||
})
|
||||
await new Promise(resolve=>setTimeout(resolve,3500))
|
||||
await captureAndSend(await chrome.tabs.get(tab.id),{force:true})
|
||||
queueBackoffMs=1000
|
||||
}catch(error){console.error('[三笙采集队列]',error);queueBackoffMs=Math.min(queueBackoffMs*2,30_000)}
|
||||
finally{if(tab?.id)await chrome.tabs.remove(tab.id).catch(()=>{});queueBusy=false;setTimeout(processCaptureQueue,queueBackoffMs)}
|
||||
}
|
||||
|
||||
chrome.runtime.onInstalled.addListener(()=>{chrome.alarms.create('sansong-product-queue',{periodInMinutes:.5});processCaptureQueue()})
|
||||
chrome.runtime.onStartup.addListener(()=>{chrome.alarms.create('sansong-product-queue',{periodInMinutes:.5});processCaptureQueue()})
|
||||
chrome.alarms.onAlarm.addListener(alarm=>{if(alarm.name==='sansong-product-queue')processCaptureQueue();if(alarm.name==='sansong-auth-heartbeat')reportAuthState()})
|
||||
chrome.alarms.create('sansong-product-queue',{periodInMinutes:.5})
|
||||
chrome.alarms.create('sansong-auth-heartbeat',{periodInMinutes:1})
|
||||
processCaptureQueue()
|
||||
reportAuthState()
|
||||
chrome.runtime.onMessage.addListener((message,_sender,sendResponse)=>{if(message?.type!=='SANSONG_COLLECTOR_WAKE')return;reportAuthState().then(connected=>sendResponse({connected})).catch(()=>sendResponse({connected:false}));return true})
|
||||
chrome.cookies.onChanged.addListener(change=>{if(/(?:^|\.)(?:taobao|tmall|jd)\.com$/i.test(change.cookie.domain))setTimeout(reportAuthState,300)})
|
||||
BIN
Binary file not shown.
+52
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from 'node:fs'
|
||||
|
||||
const input = process.argv[2]
|
||||
if (!input) {
|
||||
console.error('Usage: node scripts/normalize_capture.mjs <capture.json>')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const payload = JSON.parse(fs.readFileSync(input, 'utf8'))
|
||||
const raw = payload.raw || payload.structured || payload
|
||||
const images = raw.images || raw.image_groups || {}
|
||||
const product = raw.product || {}
|
||||
const sales = raw.sales || {}
|
||||
|
||||
const out = {
|
||||
schemaVersion: '9.0.0',
|
||||
category: raw.category || {candidates: raw.categoryCandidates || [], breadcrumb: raw.breadcrumb || []},
|
||||
product: {
|
||||
itemId: product.itemId || raw.itemId || '',
|
||||
skuId: product.skuId || raw.skuId || '',
|
||||
title: product.title || raw.title || payload.title || '',
|
||||
shop: product.shop || raw.shopName || '',
|
||||
brand: product.brand || '',
|
||||
category: product.category || raw.categoryName || '',
|
||||
url: product.url || raw.url || payload.url || ''
|
||||
},
|
||||
sales: {
|
||||
currentPrice: sales.currentPrice || raw.price || '',
|
||||
sold: sales.sold || raw.sold || '',
|
||||
ranking: sales.ranking || ''
|
||||
},
|
||||
counts: {
|
||||
attributes: Array.isArray(raw.attributes) ? raw.attributes.length : 0,
|
||||
sku: Array.isArray(raw.sku) ? raw.sku.length : 0,
|
||||
promotions: Array.isArray(raw.promotions) ? raw.promotions.length : 0,
|
||||
reviews: Array.isArray(raw.reviews) ? raw.reviews.length : 0,
|
||||
questions: Array.isArray(raw.questions) ? raw.questions.length : 0,
|
||||
mainImages: Array.isArray(images.main || images.hero) ? (images.main || images.hero).length : 0,
|
||||
detailImages: Array.isArray(images.detail) ? images.detail.length : 0,
|
||||
skuImages: Array.isArray(images.sku) ? images.sku.length : 0,
|
||||
buyerShowImages: Array.isArray(images.buyerShow || images.review) ? (images.buyerShow || images.review).length : 0
|
||||
},
|
||||
attributes: raw.attributes || [],
|
||||
sku: raw.sku || [],
|
||||
promotions: raw.promotions || [],
|
||||
reviews: raw.reviews || [],
|
||||
questions: raw.questions || [],
|
||||
images
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(out, null, 2))
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
---
|
||||
name: tmall-product-analysis-report
|
||||
description: Generate evidence-based, cross-category ecommerce product analysis reports from captured title, hero images, detail images, promotions, SKU, attributes, reviews and questions.
|
||||
---
|
||||
|
||||
# 三笙电商商品分析报告引擎
|
||||
|
||||
本技能把真实电商商品数据转成完整阅读型报告和下一款 AI 视觉方案。用户只负责采集与生成,不要求用户在报告中操作流程。
|
||||
|
||||
## 工作顺序
|
||||
|
||||
1. 读取类目、标题、参数、SKU、主图、详情图、评论、买家图和问答。
|
||||
2. 自动识别类目及分析维度。家纺只是分支,不是默认模板。
|
||||
3. 建立证据编号,并把内容分为数据事实、分析判断、设计建议和证据不足。
|
||||
4. 输出商品总览、商品画像、标题、主图、详情、SKU、评价、商品价值、新品设计和 AI 生成企划。
|
||||
|
||||
## 类目分支
|
||||
|
||||
- 服饰:尺码、版型、面料、做工、色差、上身效果。
|
||||
- 食品:规格、口味、配料、保质期、复购、包装。
|
||||
- 美妆:功效宣称、肤质、成分、使用反馈、敏感风险。
|
||||
- 数码:配置、兼容性、性能、售后、使用问题。
|
||||
- 家纺:结构规格、材质触感、颜色花型、工艺、洗后表现、场景适配。
|
||||
- 其他:从已采标题、参数、SKU 和评论自动形成维度。
|
||||
|
||||
## 证据规则
|
||||
|
||||
- 主图、详情图、SKU 图和评论图严格分源,并绑定对应 imageEvidenceId。
|
||||
- 评论只验证购后体验,问答只表示购买前顾虑。
|
||||
- 单商品特征不等于竞争力;没有竞品数据时写“商品价值结构”。
|
||||
- “存在”不等于“必须继承”。设计结论分为已验证资产、候选方向和 AI 探索变量。
|
||||
- 缺少可靠数据时显示“未采集 / 当前不可分析”,不得用常识补齐。
|
||||
|
||||
## 报告结构
|
||||
|
||||
1. 商品总览与证据边界。
|
||||
2. 动态商品画像。
|
||||
3. 标题与营销表达拆解。
|
||||
4. 主图逐张深度分析。
|
||||
5. 详情页分段分析。
|
||||
6. SKU 与参数矩阵。
|
||||
7. 评论、问答和买家实拍。
|
||||
8. 商品价值结构。
|
||||
9. 新品方案与设计资产分级。
|
||||
10. AI 视觉生成企划。
|
||||
11. 样品和页面落地验证。
|
||||
|
||||
## AI 视觉输出
|
||||
|
||||
必须包含 Reference DNA、Design Delta、Must Keep、Must Change,以及 Product、Style、Scene、Photography、Material Rendering、Composition、Negative Rules。产品图和电商主图逐张写清生成目的、拍摄方式、构图和证明任务。
|
||||
|
||||
## 页面设计
|
||||
|
||||
使用白色背景和高对比深色文字,青绿到青蓝渐变只承担品牌、进度和重点强调。商品图保持原比例;大段内容用图文并排、卡片分层和表格呈现,桌面与移动端均需可读。
|
||||
@@ -0,0 +1,5 @@
|
||||
# 三笙电商商品详情采集扩展 v9.2.5
|
||||
|
||||
跨类目采集标题、类目候选、主图、详情图、SKU 图、评论图、动态参数、变体、促销、评论与问答。字段由页面结构和文本自动发现,不绑定家纺 BOM。
|
||||
|
||||
此目录是独立采集扩展参考实现;完整工具请使用根目录 `extension/` 与 `start-tmall-ai.command`。
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "三笙商品详情采集",
|
||||
"version": "9.2.5",
|
||||
"description": "跨类目解析商品标题、主图、详情、活动、SKU、动态参数、评论与问答。",
|
||||
"permissions": ["activeTab", "scripting", "tabs", "alarms", "cookies"],
|
||||
"host_permissions": [
|
||||
"http://127.0.0.1:3300/*",
|
||||
"http://localhost:3300/*",
|
||||
"http://127.0.0.1/*",
|
||||
"http://localhost/*",
|
||||
"https://*.taobao.com/*",
|
||||
"https://item.jd.com/*",
|
||||
"https://*.jd.com/*",
|
||||
"https://item.taobao.com/*",
|
||||
"https://detail.tmall.com/*",
|
||||
"https://*.tmall.com/*"
|
||||
],
|
||||
"content_scripts": [{
|
||||
"matches": ["http://127.0.0.1/*", "http://localhost/*"],
|
||||
"js": ["page_bridge.js"],
|
||||
"run_at": "document_start"
|
||||
}],
|
||||
"background": { "service_worker": "service_worker.js" },
|
||||
"action": { "default_title": "重新采集当前商品页" }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
window.addEventListener('message',event=>{
|
||||
if(event.source!==window||event.data?.type!=='SANSONG_COLLECTOR_PING')return
|
||||
chrome.runtime.sendMessage({type:'SANSONG_COLLECTOR_WAKE'},response=>{
|
||||
window.postMessage({type:'SANSONG_COLLECTOR_PONG',requestId:event.data.requestId,installed:true,version:chrome.runtime.getManifest().version,connected:Boolean(response?.connected)},'*')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,196 @@
|
||||
const API_BASE='http://127.0.0.1:3300/api/collector/product-page'
|
||||
|
||||
async function capture(tabId){
|
||||
const result=await chrome.scripting.executeScript({target:{tabId},func:()=>{
|
||||
const clean=value=>String(value||'').replace(/\s+/g,' ').trim()
|
||||
const uniq=rows=>[...new Set(rows.filter(Boolean))]
|
||||
const bodyText=document.body?.innerText||''
|
||||
const lines=bodyText.split(/\n+/).map(clean).filter(Boolean)
|
||||
const url=location.href
|
||||
const itemId=url.match(/[?&]id=(\d+)/)?.[1]||url.match(/\/(\d+)\.html/)?.[1]||''
|
||||
const skuId=url.match(/[?&]skuId=(\d+)/)?.[1]||''
|
||||
const platform=/tmall/i.test(location.hostname)?'tmall':/taobao/i.test(location.hostname)?'taobao':/jd\.com/i.test(location.hostname)?'jd':'unknown'
|
||||
const title=clean(document.querySelector('h1')?.textContent||document.querySelector('meta[property="og:title"]')?.content||document.title)
|
||||
const readAfter=patterns=>{const row=lines.find(line=>patterns.some(pattern=>pattern.test(line)));if(!row)return'';const parts=row.split(/[::]/);return clean(parts.length>1?parts.slice(1).join(':'):row)}
|
||||
const shop=clean(document.querySelector('[class*="shop-name"],[class*="shopName"],[class*="seller-name"],[class*="sellerName"]')?.textContent)||readAfter([/^店铺/,/^店名/,/^卖家/])
|
||||
const priceText=clean(document.querySelector('[class*="promotionPrice"],[class*="salePrice"],[class*="price"]')?.textContent||'')
|
||||
const currentPrice=clean(priceText.match(/[¥¥]?\s*(\d+(?:\.\d+)?)/)?.[1]||bodyText.match(/(?:券后|到手价|活动价|价格)\s*[¥¥]?\s*(\d+(?:\.\d+)?)/)?.[1]||'')
|
||||
const originalPrice=clean(bodyText.match(/(?:原价|划线价|优惠前)\s*[¥¥]?\s*(\d+(?:\.\d+)?)/)?.[1]||'')
|
||||
const sold=clean(bodyText.match(/(?:已售|付款|销量|累计销量)\s*([0-9.万wW+]+)/)?.[1]||'')
|
||||
const publicReviewCount=clean(bodyText.match(/(?:累计评价|全部评价|商品评价|评论)\s*([0-9.万wW+]+)/)?.[1]||'')
|
||||
const ranking=clean(bodyText.match(/(?:热销榜|排行榜|榜第)\s*第?\s*([0-9]+)\s*名/)?.[0]||'')
|
||||
|
||||
const meta={}
|
||||
document.querySelectorAll('meta').forEach(el=>{const key=el.getAttribute('property')||el.getAttribute('name');const value=el.getAttribute('content');if(key&&value&&!/cookie|authorization|token|password/i.test(key))meta[key]=value})
|
||||
const jsonld=[]
|
||||
document.querySelectorAll('script[type="application/ld+json"]').forEach(el=>{try{jsonld.push(JSON.parse(el.textContent||'null'))}catch{}})
|
||||
|
||||
const badImage=/avatar|icon|logo|sprite|loading|placeholder|qrcode|qr-code|emoji|badge/i
|
||||
const imageRows=[...document.images].map(img=>({
|
||||
url:img.currentSrc||img.src,
|
||||
width:img.naturalWidth||img.width||0,
|
||||
height:img.naturalHeight||img.height||0,
|
||||
alt:clean(img.alt),
|
||||
context:clean(`${img.alt||''} ${img.closest('[class],[id]')?.className||''} ${img.closest('[class],[id]')?.id||''}`)
|
||||
})).filter(row=>/^https?:\/\//.test(row.url)&&!badImage.test(`${row.url} ${row.context}`)&&(row.width>=120||row.height>=120))
|
||||
const uniqueImages=rows=>{const seen=new Set();return rows.filter(row=>{const key=row.url.replace(/_[0-9]+x[0-9]+[^/]*$/,'');if(seen.has(key))return false;seen.add(key);return true})}
|
||||
const reviewRows=uniqueImages(imageRows.filter(row=>/review|comment|rate|evaluate|buyer|show|评价|评论|买家秀|晒单/i.test(row.context)))
|
||||
const detailRows=uniqueImages(imageRows.filter(row=>/detail|description|desc|richtext|module|详情|图文|描述/i.test(row.context)&&!reviewRows.some(x=>x.url===row.url)))
|
||||
const skuRows=uniqueImages(imageRows.filter(row=>/sku|prop|variant|option|规格|颜色|尺码|型号|口味/i.test(row.context)&&!reviewRows.some(x=>x.url===row.url)&&!detailRows.some(x=>x.url===row.url)))
|
||||
const mainRows=uniqueImages(imageRows.filter(row=>/gallery|thumb|preview|main|swiper|carousel|主图|轮播/i.test(row.context)&&!reviewRows.some(x=>x.url===row.url)&&!detailRows.some(x=>x.url===row.url))).slice(0,30)
|
||||
const allImages=uniqueImages(imageRows)
|
||||
|
||||
const blockedAttribute=/优惠|券|立减|包邮|发货|送达|价保|退货|客服|店铺|销量|付款|评论|评价|问答/
|
||||
const attributes=[]
|
||||
const attributeSeen=new Set()
|
||||
const addAttribute=(name,value,source='dom')=>{name=clean(name).replace(/[::]$/,'');value=clean(value);if(!name||!value||name.length>36||value.length>600||blockedAttribute.test(name))return;const key=`${name}\u0000${value}`;if(attributeSeen.has(key))return;attributeSeen.add(key);attributes.push({name,value,source})}
|
||||
document.querySelectorAll('table tr,[class*="attribute"] li,[class*="Attribute"] li,[class*="parameter"] li,[class*="Parameter"] li,[class*="property"] li,[class*="Property"] li,[class*="attr"] li').forEach(el=>{
|
||||
const cells=[...el.querySelectorAll(':scope > th,:scope > td,:scope > span,:scope > div')].map(x=>clean(x.textContent)).filter(Boolean)
|
||||
if(cells.length>=2)addAttribute(cells[0],cells.slice(1).join(' '),'attribute_dom')
|
||||
else{const text=clean(el.textContent);const match=text.match(/^([^::]{1,36})[::]\s*(.{1,600})$/);if(match)addAttribute(match[1],match[2],'attribute_dom')}
|
||||
})
|
||||
lines.forEach(line=>{const match=line.match(/^([^::]{1,30})[::]\s*(.{1,400})$/);if(match)addAttribute(match[1],match[2],'page_text')})
|
||||
|
||||
const breadcrumb=uniq([...document.querySelectorAll('[class*="breadcrumb"] a,[class*="crumb"] a,[class*="category"] a')].map(el=>clean(el.textContent))).filter(x=>x.length<=40).slice(0,12)
|
||||
const categoryCandidates=uniq([...breadcrumb,...attributes.filter(x=>/类目|分类|品类|产品类型|商品类型/.test(x.name)).map(x=>x.value)]).slice(0,20)
|
||||
|
||||
const variants=[]
|
||||
const variantSeen=new Set()
|
||||
const addVariant=row=>{const name=clean(row.name);if(!name||name.length>180)return;const key=`${clean(row.group)}\u0000${name}`;if(variantSeen.has(key))return;variantSeen.add(key);variants.push({...row,name,group:clean(row.group),source:row.source||'sku_dom'})}
|
||||
document.querySelectorAll('[class*="sku"],[class*="Sku"],[class*="variant"],[class*="Variant"],[class*="prop"] ,[class*="Prop"]').forEach(container=>{
|
||||
const group=clean(container.querySelector('[class*="title"],[class*="label"],[class*="name"]')?.textContent||'')
|
||||
container.querySelectorAll('button,[role="option"],li,[class*="item"],[class*="Item"]').forEach(el=>{
|
||||
const name=clean(el.getAttribute('title')||el.getAttribute('aria-label')||el.textContent)
|
||||
const image=el.querySelector('img')?.currentSrc||el.querySelector('img')?.src||''
|
||||
addVariant({group,name,image,selected:el.getAttribute('aria-selected')==='true'||/selected|active|current/i.test(el.className),disabled:el.getAttribute('aria-disabled')==='true'||el.hasAttribute('disabled')})
|
||||
})
|
||||
})
|
||||
const variantKeywords=/(颜色|色号|尺码|尺寸|规格|型号|版本|配置|容量|净含量|口味|香型|套装|套餐|款式|适用|包装|重量|数量|码数|度数|功率|内存|存储)/
|
||||
lines.filter(line=>variantKeywords.test(line)&&line.length<=500).slice(0,180).forEach(line=>line.split(/[,,]/).map(clean).filter(Boolean).forEach(name=>addVariant({group:'',name,source:'page_text'})))
|
||||
|
||||
const promotionPattern=/(优惠|券|立减|满减|包邮|免运费|价保|退货|退款|七天无理由|预计.*发货|送达|大促|活动|限时|到手价|店铺优惠|赠品|买一送一)/i
|
||||
const promotions=uniq(lines.filter(line=>promotionPattern.test(line)&&line.length<=120)).slice(0,100).map(text=>({text,source:'page_text'}))
|
||||
|
||||
const questions=[]
|
||||
document.querySelectorAll('[class*="question"],[class*="Question"],[class*="ask"],[class*="Ask"]').forEach((el,index)=>{
|
||||
const text=clean(el.innerText);if(!text)return
|
||||
const question=clean(el.querySelector('[class*="question"],[class*="title"]')?.textContent||text.match(/(.{2,160}[??])/)?.[1])
|
||||
const answer=clean(el.querySelector('[class*="answer"]')?.textContent||'')
|
||||
if(question)questions.push({id:`question-${index+1}`,question,answer,source:'question_dom'})
|
||||
})
|
||||
|
||||
const reviewSelectors=['[class*="review-item"]','[class*="comment-item"]','[class*="rate-item"]','[class*="ReviewItem"]','[class*="CommentItem"]','[data-testid*="review"]']
|
||||
const reviews=[...document.querySelectorAll(reviewSelectors.join(','))].slice(0,500).map((el,index)=>{
|
||||
const content=clean(el.innerText);if(content.length<6||content.length>3000)return null
|
||||
const images=uniq([...el.querySelectorAll('img')].map(img=>img.currentSrc||img.src).filter(src=>/^https?:\/\//.test(src)&&!badImage.test(src)))
|
||||
const date=content.match(/20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?(?:\s+\d{1,2}:\d{2})?/)?.[0]||''
|
||||
const appendContent=clean(content.match(/(?:追评|追加评价)[::\s]*(.{2,800})/)?.[1]||'')
|
||||
const sku=clean(el.querySelector('[class*="sku"],[class*="spec"],[class*="attr"]')?.textContent||'')
|
||||
const merchantReply=clean(el.querySelector('[class*="reply"],[class*="seller"]')?.textContent||'')
|
||||
const rating=Number(el.getAttribute('data-score')||el.getAttribute('data-rating')||el.querySelector('[data-score]')?.getAttribute('data-score'))||null
|
||||
return{id:el.getAttribute('data-id')||el.id||`review-${index+1}`,content,appendContent,date,rating,sku,hasImage:images.length>0,images,merchantReply,source:'review_dom'}
|
||||
}).filter(Boolean)
|
||||
|
||||
const collection={
|
||||
publicReviewCount,
|
||||
reviewActual:reviews.length,
|
||||
questionActual:questions.length,
|
||||
reviewCompleteness:reviews.length?'visible_content_collected':'no_visible_review_nodes',
|
||||
reviewStopReason:reviews.length?'visible_dom_exhausted':'reviews_not_open_or_platform_folded',
|
||||
capturedAt:new Date().toISOString(),
|
||||
collectorVersion:chrome.runtime.getManifest().version,
|
||||
platform
|
||||
}
|
||||
const structured={
|
||||
meta:{...collection,url,imageClassificationVersion:'strict-v1',collectorVersion:'9.2.2'},
|
||||
category:{candidates:categoryCandidates,breadcrumb},
|
||||
product:{itemId,skuId,title,shop,brand:attributes.find(item=>/^品牌$/.test(item.name))?.value||'',url,platform},
|
||||
sales:{currentPrice,originalPrice,sold,ranking},
|
||||
promotions,attributes,sku:variants,questions,reviews,
|
||||
images:(()=>{
|
||||
const main=mainRows, detail=detailRows.slice(0,120), sku=skuRows.slice(0,120), buyerShow=reviewRows.slice(0,160);
|
||||
const provenance=[
|
||||
...main.map(url=>({url,group:'main',source:'dom_main_gallery'})),
|
||||
...detail.map(url=>({url,group:'detail',source:'dom_product_description'})),
|
||||
...sku.map(url=>({url,group:'sku',source:'dom_sku_selector'})),
|
||||
...buyerShow.map(url=>({url,group:'buyerShow',source:'review_record'}))
|
||||
];
|
||||
return {all:allImages,main,detail,sku,buyerShow,provenance,classification:{version:'strict-v1',classifiedCount:allImages.length,groupsAreDisjoint:true}};
|
||||
})(),
|
||||
collection,pageText:bodyText
|
||||
}
|
||||
return{structured,...structured,jsonld,meta}
|
||||
}})
|
||||
return result?.[0]?.result
|
||||
}
|
||||
|
||||
const lastCaptured=new Map()
|
||||
function isProductUrl(url=''){return /^https:\/\/(?:item\.jd\.com\/\d+\.html|item\.taobao\.com\/item\.htm|detail\.tmall\.com\/item\.htm)/i.test(url)}
|
||||
|
||||
async function reportAuthState(){
|
||||
try{
|
||||
const [taobaoCookies,jdCookies]=await Promise.all([chrome.cookies.getAll({domain:'.taobao.com'}),chrome.cookies.getAll({domain:'.jd.com'})])
|
||||
const response=await fetch(`${API_BASE}/auth-heartbeat`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({version:chrome.runtime.getManifest().version,platforms:{taobao:{loggedIn:taobaoCookies.some(cookie=>/tracknick|cookie2|_tb_token_|sgcookie/i.test(cookie.name))},jd:{loggedIn:jdCookies.some(cookie=>/pin|thor|unick/i.test(cookie.name))}}})})
|
||||
return response.ok
|
||||
}catch(error){console.error('[三笙登录态上报]',error);return false}
|
||||
}
|
||||
|
||||
async function captureAndSend(tab,{force=false}={}){
|
||||
if(!tab?.id||!isProductUrl(tab.url))return
|
||||
const key=`${tab.id}:${tab.url}`
|
||||
if(!force&&Date.now()-(lastCaptured.get(key)||0)<30_000)return
|
||||
lastCaptured.set(key,Date.now())
|
||||
try{
|
||||
await chrome.action.setBadgeText({tabId:tab.id,text:'采'})
|
||||
await chrome.action.setBadgeBackgroundColor({tabId:tab.id,color:'#2563EB'})
|
||||
const payload=await capture(tab.id)
|
||||
const response=await fetch(`${API_BASE}/capture`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)})
|
||||
if(!response.ok)throw new Error(await response.text())
|
||||
await chrome.action.setBadgeText({tabId:tab.id,text:'OK'})
|
||||
await chrome.action.setBadgeBackgroundColor({tabId:tab.id,color:'#155C4E'})
|
||||
setTimeout(()=>chrome.action.setBadgeText({tabId:tab.id,text:''}),2500)
|
||||
}catch(error){
|
||||
lastCaptured.delete(key)
|
||||
await chrome.action.setBadgeText({tabId:tab.id,text:'!'})
|
||||
await chrome.action.setBadgeBackgroundColor({tabId:tab.id,color:'#A54F43'})
|
||||
console.error('[三笙商品采集]',error)
|
||||
}
|
||||
}
|
||||
|
||||
chrome.tabs.onUpdated.addListener((tabId,changeInfo,tab)=>{if(changeInfo.status==='complete'&&isProductUrl(tab.url))setTimeout(()=>captureAndSend({...tab,id:tabId}),2500)})
|
||||
chrome.tabs.onRemoved.addListener(tabId=>{for(const key of lastCaptured.keys())if(key.startsWith(`${tabId}:`))lastCaptured.delete(key)})
|
||||
chrome.action.onClicked.addListener(tab=>captureAndSend(tab,{force:true}))
|
||||
|
||||
let queueBusy=false
|
||||
let queueBackoffMs=1000
|
||||
async function processCaptureQueue(){
|
||||
if(queueBusy)return
|
||||
queueBusy=true
|
||||
let tab
|
||||
try{
|
||||
const response=await fetch(`${API_BASE}/next`)
|
||||
if(response.status===204){queueBackoffMs=Math.min(queueBackoffMs*1.5,15_000);return}
|
||||
if(!response.ok)throw new Error(`任务队列 HTTP ${response.status}`)
|
||||
const task=await response.json()
|
||||
tab=await chrome.tabs.create({url:task.url,active:false})
|
||||
await new Promise((resolve,reject)=>{
|
||||
const timeout=setTimeout(()=>{chrome.tabs.onUpdated.removeListener(listener);reject(new Error('商品页加载超时'))},45_000)
|
||||
const listener=(tabId,changeInfo,updated)=>{if(tabId===tab.id&&changeInfo.status==='complete'){clearTimeout(timeout);chrome.tabs.onUpdated.removeListener(listener);resolve(updated)}}
|
||||
chrome.tabs.onUpdated.addListener(listener)
|
||||
})
|
||||
await new Promise(resolve=>setTimeout(resolve,3500))
|
||||
await captureAndSend(await chrome.tabs.get(tab.id),{force:true})
|
||||
queueBackoffMs=1000
|
||||
}catch(error){console.error('[三笙采集队列]',error);queueBackoffMs=Math.min(queueBackoffMs*2,30_000)}
|
||||
finally{if(tab?.id)await chrome.tabs.remove(tab.id).catch(()=>{});queueBusy=false;setTimeout(processCaptureQueue,queueBackoffMs)}
|
||||
}
|
||||
|
||||
chrome.runtime.onInstalled.addListener(()=>{chrome.alarms.create('sansong-product-queue',{periodInMinutes:.5});processCaptureQueue()})
|
||||
chrome.runtime.onStartup.addListener(()=>{chrome.alarms.create('sansong-product-queue',{periodInMinutes:.5});processCaptureQueue()})
|
||||
chrome.alarms.onAlarm.addListener(alarm=>{if(alarm.name==='sansong-product-queue')processCaptureQueue();if(alarm.name==='sansong-auth-heartbeat')reportAuthState()})
|
||||
chrome.alarms.create('sansong-product-queue',{periodInMinutes:.5})
|
||||
chrome.alarms.create('sansong-auth-heartbeat',{periodInMinutes:1})
|
||||
processCaptureQueue()
|
||||
reportAuthState()
|
||||
chrome.runtime.onMessage.addListener((message,_sender,sendResponse)=>{if(message?.type!=='SANSONG_COLLECTOR_WAKE')return;reportAuthState().then(connected=>sendResponse({connected})).catch(()=>sendResponse({connected:false}));return true})
|
||||
chrome.cookies.onChanged.addListener(change=>{if(/(?:^|\.)(?:taobao|tmall|jd)\.com$/i.test(change.cookie.domain))setTimeout(reportAuthState,300)})
|
||||
@@ -0,0 +1,576 @@
|
||||
const SERVER='http://127.0.0.1:17962';
|
||||
const RUN_GAP=8*60*1000;
|
||||
const sleep=ms=>new Promise(r=>setTimeout(r,ms));
|
||||
const isProduct=u=>/^https:\/\/detail\.tmall\.com\/item\.htm/i.test(u||'') || /item\.taobao\.com\/item\.htm/i.test(u||'');
|
||||
|
||||
async function setStatus(tabId,patch){
|
||||
const key='status_'+tabId; const old=(await chrome.storage.local.get(key))[key]||{};
|
||||
await chrome.storage.local.set({[key]:{...old,...patch,updatedAt:Date.now()}});
|
||||
}
|
||||
async function health(){ try{const r=await fetch(SERVER+'/health');return r.ok?await r.json():{ok:false}}catch(e){return {ok:false,error:String(e)}} }
|
||||
|
||||
chrome.runtime.onMessage.addListener((m,s,send)=>{
|
||||
if(m?.type==='ANALYZE_CURRENT_NOW'){
|
||||
(async()=>{
|
||||
try{
|
||||
const tabId=m.tabId;
|
||||
const [{result:pageInfo}]=await chrome.scripting.executeScript({
|
||||
target:{tabId},world:'MAIN',
|
||||
func:()=>({url:location.href,itemId:(location.href.match(/[?&]id=(\d+)/)||[])[1]||''})
|
||||
});
|
||||
const itemId=pageInfo?.itemId||'';
|
||||
if(!itemId){send({ok:false,error:'当前页面没有识别到商品ID'});return}
|
||||
let raw=null;
|
||||
try{
|
||||
const got=await chrome.storage.local.get('tmall_last_raw_'+itemId);
|
||||
raw=got['tmall_last_raw_'+itemId]||null;
|
||||
}catch(e){}
|
||||
if(raw){
|
||||
const hasFacts=!!(raw.product?.title||raw.product?.shop||raw.sales?.currentPrice||raw.sku?.length||raw.attributes?.length||raw.images?.main?.length);
|
||||
if(!hasFacts)raw=null;
|
||||
}
|
||||
if(!raw){
|
||||
// 没有历史raw时,做一次“轻采集”:复用 collector,但立即给 stop flag,
|
||||
// 只保留当前页面已经可见/已捕获的数据,不再长时间采评论。
|
||||
await chrome.scripting.executeScript({target:{tabId},world:'MAIN',func:()=>{window.__TMALL_AI_STOP_REVIEW__=true;}});
|
||||
const [{result:r}]=await chrome.scripting.executeScript({target:{tabId},world:'MAIN',func:collector,args:[true]});
|
||||
raw=r;
|
||||
}
|
||||
if(!raw){send({ok:false,error:'当前没有可用于分析的数据'});return}
|
||||
|
||||
raw.collection=raw.collection||{};
|
||||
raw.collection.analysisMode='current_snapshot';
|
||||
raw.collection.reviewCollectionComplete=!!raw.collection.reviewCollectionComplete;
|
||||
raw.collection.reviewCompleteness=raw.collection.reviewCollectionComplete?'confirmed':'partial';
|
||||
raw.collection.analysisStartedBeforeReviewComplete=!raw.collection.reviewCollectionComplete;
|
||||
raw.collection.analysisStartedAt=new Date().toISOString();
|
||||
|
||||
const resp=await fetch('http://127.0.0.1:17962/api/analyze-current',{
|
||||
method:'POST',headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({raw,allowPartialReviews:true})
|
||||
});
|
||||
const j=await resp.json().catch(()=>({ok:false,error:'后端返回无法解析'}));
|
||||
send({...j,reviewCount:raw.reviews?.length||0});
|
||||
}catch(e){send({ok:false,error:String(e)})}
|
||||
})();
|
||||
return true;
|
||||
}
|
||||
if(m?.type==='STOP_COLLECTION'){(async()=>{try{await chrome.scripting.executeScript({target:{tabId:m.tabId},world:'MAIN',func:()=>{window.__TMALL_AI_STOP_REVIEW__=true;}});send({ok:true})}catch(e){send({ok:false,error:String(e)})}})();return true;}
|
||||
if(m?.type==='RUN_CURRENT'){ run(m.tabId,true).then(x=>send(x)).catch(e=>send({ok:false,error:String(e)})); return true; }
|
||||
if(m?.type==='HEALTH'){ health().then(send); return true; }
|
||||
});
|
||||
|
||||
async function run(tabId,manual){
|
||||
const tab=await chrome.tabs.get(tabId);
|
||||
if(!isProduct(tab.url)) throw new Error('当前标签页不是天猫/淘宝商品详情页');
|
||||
const h=await health();
|
||||
if(!h.ok){ await setStatus(tabId,{state:'server_offline',message:'本地 AI 服务未启动'}); throw new Error('本地 AI 服务未启动,请先运行 根目录的「start-tmall-ai.command」'); }
|
||||
if(!h.modelConfigured){ await setStatus(tabId,{state:'model_unconfigured',message:'模型未配置:请填写 本地设置页'}); throw new Error('模型未配置。请先填写 本地设置页 的 api_key 与 model。'); }
|
||||
const [{result:pageState}]=await chrome.scripting.executeScript({
|
||||
target:{tabId},world:'MAIN',
|
||||
func:async()=>{
|
||||
if(document.readyState==='loading') await new Promise(resolve=>document.addEventListener('DOMContentLoaded',resolve,{once:true}));
|
||||
for(let i=0;i<8&&(document.body?.innerText||'').trim().length<120;i++) await new Promise(r=>setTimeout(r,350));
|
||||
const body=(document.body?.innerText||'').replace(/\s+/g,' ').slice(0,5000);
|
||||
const login=/login\.taobao\.com|login\.tmall\.com/i.test(location.hostname)||(/密码登录|短信登录|手机扫码登录/.test(body)&&!/商品评价|宝贝详情/.test(body));
|
||||
const verification=/验证码|滑块验证|安全验证|访问过于频繁|操作频繁/.test(body);
|
||||
return {url:location.href,login,verification,bodyLength:(document.body?.innerText||'').length};
|
||||
}
|
||||
});
|
||||
if(pageState?.login){await setStatus(tabId,{state:'login_required',message:'淘宝要求登录。请先在当前浏览器登录淘宝,再刷新商品页重试。',progress:0});throw new Error('淘宝商品页未登录:请登录后刷新当前商品页再采集')}
|
||||
if(pageState?.verification){await setStatus(tabId,{state:'verification_required',message:'页面出现淘宝安全验证,请手动完成后刷新重试。',progress:0});throw new Error('页面出现淘宝安全验证,请手动完成验证后重试')}
|
||||
if(!isProduct(pageState?.url)){throw new Error('商品页已跳转,当前不是可采集的淘宝/天猫详情页')}
|
||||
await setStatus(tabId,{state:'collecting',message:'正在采集当前商品:主图、SKU、详情、参数、问答与评论。不会采集推荐商品…',progress:10});
|
||||
await chrome.scripting.executeScript({target:{tabId},world:'MAIN',func:()=>{window.__TMALL_AI_STOP_REVIEW__=false;window.__TMALL_AI_REVIEW_PROGRESS__={count:0,round:0,stagnant:0,mode:'启动',elapsedMs:0,preview:[]};}});
|
||||
let collectorDone=false,collectorError=null;const startedAt=Date.now();
|
||||
const collectorPromise=chrome.scripting.executeScript({target:{tabId},world:'MAIN',func:collector,args:[manual]}).then(v=>v).catch(e=>{collectorError=e;return null}).finally(()=>{collectorDone=true});
|
||||
while(!collectorDone){try{const rr=await chrome.scripting.executeScript({target:{tabId},world:'MAIN',func:()=>window.__TMALL_AI_REVIEW_PROGRESS__||null});const live=rr?.[0]?.result;if(live)await setStatus(tabId,{state:'collecting_reviews',message:`评论采集中:${live.count||0} 条 · ${live.mode||'处理中'}${live.stagnant?` · ${live.stagnant}轮无新增`:''}`,progress:Math.min(67,18+(Number(live.round||0)*2)),reviewLive:{...live,elapsedMs:Date.now()-startedAt}})}catch(e){}await sleep(600)}
|
||||
const collectorResult=await collectorPromise;
|
||||
if(collectorError)throw new Error('页面采集脚本执行失败:'+String(collectorError?.message||collectorError));
|
||||
const raw=collectorResult?.[0]?.result;
|
||||
if(!raw?.product?.itemId) throw new Error('未识别到 itemId,请确认商品页已正常打开并刷新后重试');
|
||||
const hasProductFacts=!!(raw.product.title||raw.product.shop||raw.sales?.currentPrice||raw.sku?.length||raw.attributes?.length||raw.images?.main?.length);
|
||||
if(!hasProductFacts){
|
||||
await setStatus(tabId,{state:'product_empty',message:'已识别商品ID,但页面商品信息为空。请确认已登录、详情已加载后刷新重试。',progress:0});
|
||||
throw new Error('商品字段为空:请确认淘宝已登录且商品详情加载完成,然后刷新页面重试');
|
||||
}
|
||||
try{
|
||||
await chrome.storage.local.set({['tmall_last_raw_'+(raw?.product?.itemId||'unknown')]:raw});
|
||||
try{
|
||||
const p=(raw?.product?.parameterCollection)||{};
|
||||
await chrome.storage.local.set({['tmall_param_status_'+(raw?.product?.itemId||'unknown')]:p});
|
||||
}catch(e){}
|
||||
|
||||
}catch(e){}
|
||||
|
||||
await chrome.storage.local.set({['last_raw_'+raw.product.itemId]:raw});
|
||||
if(!raw.collection?.reviewCollectionComplete){
|
||||
const reason=raw.collection?.reviewStopReason||'unknown';
|
||||
const human=reason==='human_verification_required';const stopped=reason==='user_stopped';
|
||||
const noReviewData=!raw.reviews?.length&&!raw.collection?.networkReviewResponses;
|
||||
const folded=raw.collection?.foldedDefaultReviewCount?`;平台另折叠 ${raw.collection.foldedDefaultReviewCount} 条默认好评,未提供逐条内容`:'';
|
||||
const msg=stopped?`已终止采集,并保存当前 ${raw.reviews?.length||0} 条评论。可随时继续。`:human?`评论已保存 ${raw.reviews?.length||0} 条,页面出现验证。请手动完成验证后继续。`:noReviewData?'页面未释放可读取的评论数据。商品信息已保存;请在当前页打开“评价/评论”后再次点击继续采集。':`评论已保存 ${raw.reviews?.length||0} 条,尚未确认完整${folded}。已停止低效空转,可继续采集。`;
|
||||
await setStatus(tabId,{state:stopped?'reviews_stopped':'reviews_incomplete',message:msg,progress:68,itemId:raw.product.itemId,reviewStopReason:reason,reviewLive:raw.collection?.reviewLive||null,parameterStatus:raw.product?.parameterCollection||null});
|
||||
return {ok:false,needsReviewResume:true,message:msg,reviewStopReason:reason,rawStats:{title:raw.product?.title||'',reviews:raw.reviews?.length||0,questions:raw.questions?.length||0,sku:raw.sku?.length||0,attributes:raw.attributes?.length||0,promotions:raw.promotions?.length||0,images:raw.images?.all?.length||0}};
|
||||
}
|
||||
await setStatus(tabId,{state:'sending',message:`评论完整性已确认,共 ${raw.reviews?.length||0} 条。正在生成新品开品闭环…`,progress:78,itemId:raw.product.itemId});
|
||||
const r=await fetch(SERVER+'/api/analyze',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(raw)});
|
||||
const j=await r.json(); if(!r.ok||!j.ok) throw new Error(j.error||'提交分析失败');
|
||||
await setStatus(tabId,{state:'analyzing',message:'正在分析全方位产品体验并生成解决方案',progress:82,taskId:j.taskId,taskUrl:j.taskUrl});
|
||||
await chrome.tabs.create({url:j.taskUrl});
|
||||
return {ok:true,taskId:j.taskId,rawStats:{title:raw.product?.title||'',reviews:raw.reviews?.length||0,questions:raw.questions?.length||0,sku:raw.sku?.length||0,attributes:raw.attributes?.length||0,promotions:raw.promotions?.length||0,images:raw.images?.all?.length||0}};
|
||||
}
|
||||
|
||||
async function collector(manual=false){
|
||||
const delay=ms=>new Promise(r=>setTimeout(r,ms));
|
||||
const text=(el)=>el?.innerText?.trim()||el?.textContent?.trim()||'';
|
||||
const clean=s=>(s||'').replace(/\s+/g,' ').trim();
|
||||
const uniq=a=>[...new Set((a||[]).filter(Boolean))];
|
||||
let queryRoots=[]; let queryRootsAt=0;
|
||||
const refreshQueryRoots=()=>{
|
||||
if(Date.now()-queryRootsAt<1200&&queryRoots.length)return;
|
||||
const roots=[document];
|
||||
for(let i=0;i<roots.length;i++){
|
||||
try{for(const el of roots[i].querySelectorAll('*'))if(el.shadowRoot&&!roots.includes(el.shadowRoot))roots.push(el.shadowRoot)}catch(e){}
|
||||
}
|
||||
queryRoots=roots;queryRootsAt=Date.now();
|
||||
};
|
||||
const $all=s=>{refreshQueryRoots();const out=[];for(const root of queryRoots){try{out.push(...root.querySelectorAll(s))}catch(e){}}return [...new Set(out)]};
|
||||
const assetUrl=u=>{u=String(u||'').trim();if(u.startsWith('//'))u='https:'+u;return u.replace(/&/g,'&')};
|
||||
const srcOf=img=>assetUrl(img?.currentSrc||img?.src||img?.getAttribute?.('data-src')||img?.getAttribute?.('data-ks-lazyload')||img?.getAttribute?.('data-lazyload-src')||'');
|
||||
const url=new URL(location.href); const itemId=url.searchParams.get('id')||location.href.match(/[?&]id=(\d+)/)?.[1]||''; const skuId=url.searchParams.get('skuId')||'';
|
||||
const bodyClone=document.body?.cloneNode(true);
|
||||
try{bodyClone?.querySelectorAll?.('[class*=recommend],[class*=Recommend],[class*=guess],[class*=Guess],[class*=related],[class*=Related],[class*=similar],[class*=Similar],[class*=猜你喜欢],[id*=recommend],[id*=guess]').forEach(n=>n.remove())}catch(e){}
|
||||
const bodyText=clean(bodyClone?.innerText||document.body?.innerText||'');
|
||||
const captured=[];
|
||||
const reviews=[]; const questions=[];
|
||||
const reviewNetMeta={totals:[],hasMoreFalse:false,lastPageFlags:[],pageHints:[],networkReviewObjects:0,networkResponses:0};
|
||||
const reviewStartedAt=Date.now();
|
||||
const processedCaptureKeys=new Set();
|
||||
function dedupReviewCount(){const m=new Set();for(const r of reviews){const k=clean(r.content||'').replace(/\s+/g,'').slice(0,320);if(k)m.add(k)}return m.size}
|
||||
function publishReviewProgress(round=0,stagnant=0,mode='DOM',extra={}){const seen=new Set(),preview=[];for(let i=reviews.length-1;i>=0&&preview.length<3;i--){const c=clean(reviews[i]?.content||'');const k=c.replace(/\s+/g,'').slice(0,320);if(k&&!seen.has(k)){seen.add(k);preview.push(c.slice(0,70)+(c.length>70?'…':''))}}window.__TMALL_AI_REVIEW_PROGRESS__={
|
||||
count:dedupReviewCount(),
|
||||
rawCount:reviews.length,
|
||||
networkReviewObjects:reviewNetMeta.networkReviewObjects,
|
||||
networkResponses:reviewNetMeta.networkResponses,
|
||||
round,stagnant,mode,
|
||||
elapsedMs:Date.now()-reviewStartedAt,
|
||||
preview,
|
||||
capturePolicy:'passive_code_and_network',
|
||||
extraRequestsCreated:0,
|
||||
safety:'no_api_replay_no_auth_bypass_no_rate_limit_bypass',
|
||||
...extra
|
||||
}}
|
||||
|
||||
const looksQuestion=(c)=>{c=clean(c);return /问大家|更多回答|查看全部问答|大家问|^问[::\s]|请问|请教|会不会|有没有|能不能|是否|怎么|如何|多少|适合|兼容|保质期|[??]$/.test(c)};
|
||||
const addQuestion=(o,source='unknown')=>{ const q=clean(o?.question||o?.title||o?.ask||o?.content||o?.text||''); const a=clean(o?.answer||o?.reply||o?.answers?.[0]?.content||''); if(q.length>2)questions.push({question:q,answer:a,source}); };
|
||||
const reviewImageUrl=v=>assetUrl(typeof v==='string'?v:(v?.url||v?.picUrl||v?.imageUrl||v?.src||v?.bigUrl||v?.thumbnail||''));
|
||||
const validReviewImage=u=>/^https?:\/\//.test(u)&&!/(?:avatar|sns_logo|credit|logo|icon|defaultRate|sprite|\/tps\/|\/tfs\/TB1[^/]+-\d{1,4}-\d{1,4}\.png)/i.test(u);
|
||||
const addReview=(o,source='unknown')=>{ if(!o)return; const c=clean(o.content||o.rateContent||o.feedback||o.comment||o.text||o.contentText||''); if(c.length<3)return; if(looksQuestion(c)){addQuestion({question:c},source);return;} const media=uniq((o.images||o.pics||o.photos||[]).map(reviewImageUrl)).filter(validReviewImage).slice(0,24);reviews.push({content:c,appendContent:clean(o.appendContent||o.append||o.appendComment||''),rating:o.rating||o.rate||o.star||o.score||o.starLevel||'',sku:clean(o.sku||o.skuInfo||o.auctionSku||o.skuMap||''),date:clean(o.date||o.time||o.gmtCreate||o.rateDate||''),images:media,likes:o.likes||o.likeCount||0,source});};
|
||||
function scanPageMeta(obj){
|
||||
if(!obj||typeof obj!=='object'||Array.isArray(obj))return;
|
||||
for(const [k,v] of Object.entries(obj)){
|
||||
const key=String(k).toLowerCase();
|
||||
if(/^(total|totalcount|total_count|reviewcount|ratecount|commentcount|totalnum|totalnumber)$/.test(key)){
|
||||
const n=Number(String(v).replace(/,/g,'')); if(Number.isFinite(n)&&n>=0&&n<10000000)reviewNetMeta.totals.push(n);
|
||||
}
|
||||
if(/^(hasmore|has_more|hasnext|has_next|hasnextpage|has_next_page|more)$/.test(key) && (v===false||v===0||v==='false'||v==='0')) reviewNetMeta.hasMoreFalse=true;
|
||||
if(/^(lastpage|islast|is_last|islastpage|is_last_page|last|end|isend|is_end)$/.test(key) && (v===true||v===1||v==='true'||v==='1')) reviewNetMeta.lastPageFlags.push(true);
|
||||
if(/^(page|pageno|page_no|pagenum|page_num|currentpage|current_page)$/.test(key)){
|
||||
const n=Number(v); if(Number.isFinite(n)&&n>0)reviewNetMeta.pageHints.push(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
function walk(obj,depth=0,sourceUrl=''){
|
||||
if(!obj||depth>10)return;
|
||||
if(Array.isArray(obj)){for(const x of obj.slice(0,2500))walk(x,depth+1,sourceUrl);return;}
|
||||
if(typeof obj!=='object')return;
|
||||
scanPageMeta(obj);
|
||||
const ks=Object.keys(obj); const qaUrl=/ask|question|wdj|qa/i.test(sourceUrl||'');
|
||||
const hasQ=ks.some(k=>/question|ask/i.test(k)); const hasA=ks.some(k=>/answer|reply/i.test(k));
|
||||
if(qaUrl || (hasQ&&hasA)) addQuestion(obj,sourceUrl||'network');
|
||||
else if(ks.some(k=>/rateContent|feedback|comment|contentText|reviewContent|rateText/i.test(k)) || ((('content'in obj)||('text'in obj))&&(/review|rate|comment|feedback|evaluate|评价|评论/i.test(sourceUrl||'') || ks.some(k=>/star|rating|sku|rate|append|user|buyer|date|time|pic|photo/i.test(k))))){
|
||||
const before=reviews.length; addReview(obj,sourceUrl||'network'); if(reviews.length>before)reviewNetMeta.networkReviewObjects++;
|
||||
}
|
||||
for(const k of ks){ const v=obj[k]; if(v&&typeof v==='object')walk(v,depth+1,sourceUrl); }
|
||||
}
|
||||
// V8.1.5:评论网络监听由 page-review-hook.js 在 document_start 提前安装。
|
||||
// 这里禁止再次重置 __TMALL_AI_CAPTURE__,否则会丢失用户打开商品页后已经收到的评论响应。
|
||||
// 如果极端情况下 MAIN-world hook 未安装,只记录状态并继续使用 DOM / inline JSON 兜底,不主动重放接口。
|
||||
if(!Array.isArray(window.__TMALL_AI_CAPTURE__)) window.__TMALL_AI_CAPTURE__=[];
|
||||
if(!Array.isArray(window.__TMALL_AI_SOURCE_SNAPSHOTS__)) window.__TMALL_AI_SOURCE_SNAPSHOTS__=[];
|
||||
|
||||
const visible=(el)=>{try{const r=el.getBoundingClientRect?.();return !r||(r.width>0&&r.height>0)}catch(e){return true}};
|
||||
const textControl=(patterns)=>{
|
||||
const matches=$all('button,a,[role=tab],[role=button],li,div,span').filter(el=>{
|
||||
const t=clean(text(el));
|
||||
return visible(el)&&t&&t.length<=36&&patterns.some(pattern=>pattern.test(t));
|
||||
});
|
||||
return matches.sort((a,b)=>{
|
||||
const ai=/^(BUTTON|A)$/.test(a.tagName)||a.getAttribute('role')==='tab'||a.getAttribute('role')==='button';
|
||||
const bi=/^(BUTTON|A)$/.test(b.tagName)||b.getAttribute('role')==='tab'||b.getAttribute('role')==='button';
|
||||
return Number(bi)-Number(ai)||clean(text(a)).length-clean(text(b)).length;
|
||||
})[0];
|
||||
};
|
||||
const activatePanel=async(patterns)=>{
|
||||
const control=textControl(patterns);if(!control)return false;
|
||||
try{control.scrollIntoView({block:'center'});await delay(220);control.click();await delay(1000);queryRootsAt=0;return true}catch(e){return false}
|
||||
};
|
||||
const reviewDrawerOpen=()=>{
|
||||
const scrollBox=$all('[class*=comments],[class*=Comments],[class*=commentList],[class*=CommentList]').find(el=>{
|
||||
try{const style=getComputedStyle(el);return visible(el)&&/(auto|scroll)/.test(style.overflowY)&&el.clientHeight>180}
|
||||
catch(e){return false}
|
||||
});
|
||||
const drawer=$all('[class*=Drawer],[class*=drawer]').find(el=>visible(el)&&/用户评价/.test(clean(text(el))));
|
||||
return Boolean(scrollBox&&drawer);
|
||||
};
|
||||
const clickControl=async(el,waitMs=900)=>{
|
||||
if(!el)return false;
|
||||
try{
|
||||
el.scrollIntoView({block:'center',inline:'nearest'});await delay(220);
|
||||
el.click();await delay(waitMs);queryRootsAt=0;return true;
|
||||
}catch(e){return false}
|
||||
};
|
||||
const openReviewDrawer=async()=>{
|
||||
if(reviewDrawerOpen())return true;
|
||||
for(let attempt=0;attempt<4;attempt++){
|
||||
queryRootsAt=0;
|
||||
const showAll=$all('button,a,[role=button],div,span').filter(el=>{
|
||||
const t=clean(text(el));return visible(el)&&/^(查看全部评价|查看全部评论|全部评价|全部评论)$/.test(t);
|
||||
}).sort((a,b)=>{
|
||||
const score=el=>/ShowButton/i.test(String(el.className||''))?4:(/^(BUTTON|A)$/.test(el.tagName)||el.getAttribute('role')==='button'?3:/footer/i.test(String(el.className||''))?1:2);
|
||||
return score(b)-score(a)||clean(text(b)).length-clean(text(a)).length;
|
||||
})[0];
|
||||
if(showAll){publishReviewProgress(pagesVisited,0,'打开全部评价');await clickControl(showAll,1200);if(reviewDrawerOpen())return true;}
|
||||
const reviewTab=$all('button,a,[role=tab],[role=button],div,span').filter(el=>visible(el)&&clean(text(el))==='用户评价').sort((a,b)=>{
|
||||
const ac=/tabTitleItem/i.test(String(a.className||''));const bc=/tabTitleItem/i.test(String(b.className||''));return Number(bc)-Number(ac);
|
||||
})[0];
|
||||
if(reviewTab){publishReviewProgress(pagesVisited,0,'定位用户评价');await clickControl(/tabTitle/i.test(String(reviewTab.parentElement?.className||''))?reviewTab.parentElement:reviewTab,700);}
|
||||
else{window.scrollBy(0,700);await delay(500)}
|
||||
}
|
||||
return reviewDrawerOpen();
|
||||
};
|
||||
// V5: Collect all currently accessible effective reviews. Resume from page-local checkpoint when possible.
|
||||
const checkpointKey='__TMALL_AI_REVIEWS_V62__'+itemId;
|
||||
async function idbOpen(){return await new Promise((resolve,reject)=>{try{const req=indexedDB.open('TMALL_AI_V62',1);req.onupgradeneeded=()=>{const db=req.result;if(!db.objectStoreNames.contains('checkpoints'))db.createObjectStore('checkpoints')};req.onsuccess=()=>resolve(req.result);req.onerror=()=>reject(req.error)}catch(e){reject(e)}})}
|
||||
async function loadCheckpoint(){try{const db=await idbOpen();return await new Promise((resolve,reject)=>{const tx=db.transaction('checkpoints','readonly');const req=tx.objectStore('checkpoints').get(checkpointKey);req.onsuccess=()=>resolve(req.result||{});req.onerror=()=>reject(req.error)})}catch(e){try{return JSON.parse(localStorage.getItem(checkpointKey)||'{}')||{}}catch(_){return {}}}}
|
||||
async function saveCheckpoint(data){try{const db=await idbOpen();await new Promise((resolve,reject)=>{const tx=db.transaction('checkpoints','readwrite');tx.objectStore('checkpoints').put(data,checkpointKey);tx.oncomplete=resolve;tx.onerror=()=>reject(tx.error)})}catch(e){try{localStorage.setItem(checkpointKey,JSON.stringify({...data,reviews:(data.reviews||[]).slice(-1500),questions:(data.questions||[]).slice(-500)}))}catch(_){}}}
|
||||
let checkpoint=await loadCheckpoint(); checkpoint=checkpoint||{};
|
||||
for(const r of (checkpoint.reviews||[])) addReview(r); for(const q of (checkpoint.questions||[])) addQuestion(q,q.source||'checkpoint');
|
||||
let collectionComplete=false, stopReason='unknown', pagesVisited=Number(checkpoint.round||0), explicitEnd=false;
|
||||
const domReviewSeen=new Set();
|
||||
function collectVisibleReviews(){
|
||||
const selectors=[
|
||||
'[class^="Comment--"]','[class^="comment--"]','[class*=rate-item]','[class*=rateItem]','[class*=review-item]','[class*=reviewItem]',
|
||||
'[class*=comment-item]','[class*=commentItem]','[class*=feedback-item]','[class*=feedbackItem]',
|
||||
'[data-testid*=review-card]','[data-testid=review]','[class*=ReviewItem]','[class*=CommentItem]','[class*=RateItem]','[class*=reviewCard]','[class*=commentCard]','[class*=rateCard]'
|
||||
];
|
||||
const rawNodes=uniq(selectors.flatMap(s=>{try{return $all(s)}catch(e){return[]}}));
|
||||
const genericNodes=$all('article,[role=article],li,section,div').filter(el=>{
|
||||
const raw=String(el.innerText||'').replace(/\u00a0/g,' ').trim();
|
||||
return visible(el)&&raw.length>=10&&raw.length<=1500&&/20\d{2}[-/.年]\s*\d{1,2}[-/.月]\s*\d{1,2}/.test(raw)&&/[。!?!?,,]/.test(raw);
|
||||
});
|
||||
const genericAtomic=genericNodes.filter(n=>![...n.children].some(child=>genericNodes.includes(child)&&String(child.innerText||'').trim().length>=10));
|
||||
const nodes=uniq([...rawNodes,...genericAtomic]).filter(n=>!rawNodes.some(o=>o!==n&&o.contains?.(n))&&!genericAtomic.some(o=>o!==n&&o.contains?.(n))); // 只保留独立评论卡,避免父子容器重复/合并
|
||||
for(const b of nodes){
|
||||
const contentNode=b.querySelector?.('[class^="content--"],[class^="Content--"],[class*=comment-content],[class*=review-content],[class*=rate-content],[data-testid*=content]');
|
||||
const c=clean(text(contentNode)||text(b));
|
||||
if(c.length<8||c.length>1800) continue;
|
||||
const k=c.replace(/\s+/g,'').slice(0,240);
|
||||
if(domReviewSeen.has(k)) continue;
|
||||
domReviewSeen.add(k);
|
||||
const ratingText=clean(b.querySelector?.('[class*=star],[class*=score],[class*=rating]')?.getAttribute?.('aria-label')||text(b.querySelector?.('[class*=star],[class*=score],[class*=rating]')));
|
||||
const rm=ratingText.match(/([1-5](?:\.\d)?)/);
|
||||
const sku=clean(text(b.querySelector?.('[class*=sku],[class*=spec],[class*=auction]')));
|
||||
const metaText=clean(text(b.querySelector?.('time,[class*=date],[class*=time],[class^="meta--"]')));
|
||||
const date=metaText.match(/20\d{2}[^\d]?\d{1,2}[^\d]?\d{1,2}/)?.[0]||metaText;
|
||||
const purchased=metaText.match(/已购[::]?\s*(.+)$/)?.[1]||'';
|
||||
const appendContent=clean((clean(text(b)).match(/(?:追评|追加评价)[::]\s*(.{2,1200})/)||[])[1]||'');
|
||||
const images=uniq([...b.querySelectorAll?.('img')||[]].map(srcOf)).filter(validReviewImage).slice(0,16);
|
||||
addReview({content:c,appendContent,rating:rm?.[1]||'',sku:purchased||sku,date,images},'dom_visible');
|
||||
}
|
||||
}
|
||||
function scrollReviewContainers(){
|
||||
let moved=false;
|
||||
const boxes=$all('[class*=comments],[class*=Comments],[class*=commentList],[class*=CommentList],[class*=drawer],[class*=Drawer]').filter(el=>{
|
||||
try{return visible(el)&&el.scrollHeight>el.clientHeight+8}
|
||||
catch(e){return false}
|
||||
});
|
||||
for(const box of boxes){
|
||||
try{
|
||||
const old=box.scrollTop; const step=Math.max(420,Math.floor(box.clientHeight*.82));
|
||||
box.scrollTop=Math.min(box.scrollHeight-box.clientHeight,old+step);
|
||||
if(box.scrollTop>old+2)moved=true;
|
||||
box.dispatchEvent(new Event('scroll',{bubbles:true}));
|
||||
}catch(e){}
|
||||
}
|
||||
return moved;
|
||||
}
|
||||
function collectVisibleQuestions(){
|
||||
const selectors=['[class*=question]','[class*=Question]','[class*=ask]','[class*=Ask]','[class*=qa]','[class*=Qa]'];
|
||||
const nodes=uniq(selectors.flatMap(sel=>{try{return $all(sel)}catch(e){return[]}}));
|
||||
for(const b of nodes){const c=clean(text(b));if(c.length>=4&&c.length<=1800&&(/问大家|更多回答|查看全部问答|请问|请教|会不会|有没有|能不能|是否|怎么|如何|多少|适合|兼容|保质期|[??]/.test(c)))addQuestion({question:c},'dom_question');}
|
||||
}
|
||||
// Trigger review/question areas if clickable text exists.
|
||||
// 先按用户通常的顺序打开详情/规格区域;新版页面常把参数放在折叠面板内。
|
||||
await activatePanel([/^(?:宝贝)?详情$/,/商品详情/,/规格参数|商品参数|产品参数/]);
|
||||
// 必须确认“查看全部评价”抽屉已打开;只点“用户评价”页签不算成功。
|
||||
const reviewDrawerWasOpened=await openReviewDrawer();
|
||||
// V8.0.4:实时、可终止、快速停滞检测。优先分页/加载更多,不再长时间整页空转。
|
||||
let stagnant=0;const MAX_ROUNDS=40;publishReviewProgress(pagesVisited,0,reviewDrawerWasOpened?'评论抽屉已打开':'读取页面可见评论',{reviewDrawerOpened:reviewDrawerWasOpened});
|
||||
for(let attempt=0;attempt<MAX_ROUNDS;attempt++){
|
||||
const round=pagesVisited;pagesVisited+=1;if(window.__TMALL_AI_STOP_REVIEW__){stopReason='user_stopped';publishReviewProgress(round,stagnant,'用户终止');break}
|
||||
const before=dedupReviewCount();const capBefore=(window.__TMALL_AI_CAPTURE__||[]).length;collectVisibleReviews();collectVisibleQuestions();publishReviewProgress(round,stagnant,'读取当前评论');
|
||||
const navNodes=$all('button,a,span,div,[role=button]');
|
||||
const next=navNodes.find(el=>{const t=clean(text(el));const dis=el.getAttribute?.('disabled')!=null||el.getAttribute?.('aria-disabled')==='true'||/disabled|next-disabled/.test(String(el.className||''));const r=el.getBoundingClientRect?.();return !dis&&t&&t.length<20&&/下一页|下页|查看更多|加载更多|更多评价|展开更多|下一批/.test(t)&&(!r||(r.width>0&&r.height>0))});
|
||||
const disabledNext=navNodes.find(el=>{const t=clean(text(el));const dis=el.getAttribute?.('disabled')!=null||el.getAttribute?.('aria-disabled')==='true'||/disabled|next-disabled/.test(String(el.className||''));return dis&&/下一页|下页/.test(t)});
|
||||
if(next){try{next.scrollIntoView({block:'center'});await delay(180);next.click();publishReviewProgress(round,stagnant,'加载下一批');await delay(1100+Math.floor(Math.random()*500))}catch(e){}}
|
||||
else{const rev=document.querySelector('[class*=review],[class*=rate],[class*=comment],[data-testid*=review]');try{rev?.scrollIntoView({block:'center'})}catch(e){}for(let i=0;i<4;i++){if(window.__TMALL_AI_STOP_REVIEW__)break;window.scrollBy(0,650);scrollReviewContainers();await delay(240);collectVisibleReviews();collectVisibleQuestions()}await delay(550)}
|
||||
if(window.__TMALL_AI_STOP_REVIEW__){stopReason='user_stopped';publishReviewProgress(round,stagnant,'用户终止');break}
|
||||
// 每轮即时解析已捕获网络响应
|
||||
const capNow=(window.__TMALL_AI_CAPTURE__||[]).slice(-120);for(const c of capNow){try{const ck=String(c.__safeKey||[c.method||'',c.url||'',String(c.text||'').slice(0,240)].join('|'));if(processedCaptureKeys.has(ck))continue;processedCaptureKeys.add(ck);if(/review|rate|comment|feed/i.test(c.url||''))reviewNetMeta.networkResponses++;let z=(c.text||'').replace(/^\s*[\w$.]+\(/,'').replace(/\)\s*;?\s*$/,'');try{walk(JSON.parse(z),0,c.url||'')}catch(_){const m=z.match(/\{[\s\S]*\}/);if(m)try{walk(JSON.parse(m[0]),0,c.url||'')}catch(__){}}}catch(e){}}
|
||||
collectVisibleReviews();collectVisibleQuestions();const after=dedupReviewCount();stagnant=after<=before?stagnant+1:0;publishReviewProgress(round+1,stagnant,next?'翻页/加载更多':'短滚动',{lastDelta:after-before});
|
||||
const bodyNow=clean(document.body?.innerText||'');const noMoreText=/没有更多(?:评价|评论)|已显示全部(?:评价|评论)|到底了|暂无更多(?:评价|评论)/.test(bodyNow);const captcha=/验证码|滑块|安全验证|访问过于频繁|操作频繁/.test(bodyNow);
|
||||
if(captcha){stopReason='human_verification_required';publishReviewProgress(round+1,stagnant,'需要人工验证');break}
|
||||
if((disabledNext||noMoreText)&&stagnant>=1){explicitEnd=true;collectionComplete=true;stopReason='explicit_last_page';publishReviewProgress(round+1,stagnant,'页面明确结束');break}
|
||||
// 连续4轮没有新增才停止;给异步评论接口和虚拟列表更多响应时间,但仍避免长时间空转。
|
||||
if(stagnant>=4){collectionComplete=false;explicitEnd=false;stopReason='no_progress_after_4_rounds';publishReviewProgress(round+1,stagnant,'无新增,停止空转');break}
|
||||
await saveCheckpoint({reviews,questions,round:round+1,updatedAt:Date.now()});
|
||||
}
|
||||
await delay(300);
|
||||
// V8.1.5 安全模式:
|
||||
// 只消费页面在正常浏览过程中已经产生的真实 fetch/XHR 响应。
|
||||
// 不根据 performance resource URL 主动重放请求,避免额外接口调用、重复页和频控风险。
|
||||
captured.push(...(window.__TMALL_AI_CAPTURE__||[]).slice(-100));
|
||||
|
||||
// 同时读取页面已经内嵌的脚本/JSON快照,不产生任何网络请求。
|
||||
const sourceSnapshots=(window.__TMALL_AI_SOURCE_SNAPSHOTS__||[]).slice(-12);
|
||||
for(const snap of sourceSnapshots){
|
||||
captured.push({
|
||||
url:'page-source://inline-script',
|
||||
method:'SOURCE',
|
||||
status:200,
|
||||
text:String(snap?.text||'').slice(0,1200000),
|
||||
passive:true
|
||||
});
|
||||
}
|
||||
for(const c of captured){
|
||||
const ck=String(c.__safeKey||[c.method||'',c.url||'',String(c.text||'').slice(0,240)].join('|'));
|
||||
if(processedCaptureKeys.has(ck)) continue;
|
||||
processedCaptureKeys.add(ck);
|
||||
if(/review|rate|comment|feed/i.test(c.url||''))reviewNetMeta.networkResponses++;
|
||||
let s=c.text||''; s=s.replace(/^\s*[\w$.]+\(/,'').replace(/\)\s*;?\s*$/,'');
|
||||
try{walk(JSON.parse(s),0,c.url||'')}catch(e){ const m=s.match(/\{[\s\S]*\}/);if(m)try{walk(JSON.parse(m[0]),0,c.url||'')}catch(_){}}
|
||||
}
|
||||
// DOM fallback:仍按“单个评论卡”解析,不读取整块评价容器,避免把多条买家评论合并。
|
||||
collectVisibleReviews(); collectVisibleQuestions();
|
||||
// 评论采集完成后再切换问大家,读取页面正常加载出来的问答,不额外调用接口。
|
||||
const qaNow=$all('a,button,div,span,[role=tab]').find(el=>{const t=clean(text(el));return t&&t.length<32&&/问大家|大家问/.test(t)});
|
||||
if(qaNow){
|
||||
try{
|
||||
qaNow.scrollIntoView({block:'center'});await delay(220);qaNow.click();await delay(1000);
|
||||
for(let i=0;i<3;i++){collectVisibleQuestions();window.scrollBy(0,420);await delay(220)}
|
||||
collectVisibleQuestions();
|
||||
}catch(e){}
|
||||
}
|
||||
// 评论完成后重新打开详情/规格面板,避免问大家面板覆盖参数区域。
|
||||
await activatePanel([/^(?:宝贝)?详情$/,/商品详情/,/规格参数|商品参数|产品参数/]);
|
||||
// 像正常浏览一样有限滚动详情容器,触发页面自身的图片懒加载;不创建额外接口请求。
|
||||
const detailRootSelectors=['#J_DivItemDesc','#description','[id="description"]','[class*="detailContent"]','[class*="DetailContent"]','[class*="detail-content"]','[class*="descV8"]','[class^="desc--"]','[data-testid*="product-description"]','[class*="ProductDetail"]','[class*="productDetail"]','[class*="detailDesc"]','[class*="DetailDesc"]','[class*="moduleDesc"]','[class*="ModuleDesc"]','[class*="contentDetail"]','[class*="ContentDetail"]','[class*="aplus"]','[class*="Aplus"]'];
|
||||
const detailRoots=uniq(detailRootSelectors.flatMap(sel=>{try{return $all(sel)}catch(e){return[]}}));
|
||||
const detailScanUrls=[];
|
||||
const scanRootImages=root=>{try{for(const img of root.querySelectorAll('img')){const u=srcOf(img);if(u&&!detailScanUrls.includes(u))detailScanUrls.push(u)}}catch(e){}};
|
||||
for(const root of detailRoots.slice(0,4)){
|
||||
try{
|
||||
const box=root.getBoundingClientRect();const top=Math.max(0,window.scrollY+box.top-120);const height=Math.max(box.height,root.scrollHeight||0);const rounds=Math.min(14,Math.max(1,Math.ceil(height/700)));
|
||||
for(let i=0;i<rounds;i++){window.scrollTo({top:top+Math.min(height-1,i*700),behavior:'auto'});await delay(150);scanRootImages(root)}
|
||||
}catch(e){}
|
||||
}
|
||||
// Product facts: DOM first, then schema/meta and page-owned inline state for the 2025 Taobao/Tmall detail app.
|
||||
const freshClone=document.body?.cloneNode(true);
|
||||
try{freshClone?.querySelectorAll?.('[class*=recommend],[class*=Recommend],[class*=guess],[class*=Guess],[class*=related],[class*=Related],[class*=similar],[class*=Similar],[id*=recommend],[id*=guess]').forEach(n=>n.remove())}catch(e){}
|
||||
const liveBodyRaw=freshClone?.innerText||document.body?.innerText||'';
|
||||
const liveBodyText=clean(liveBodyRaw);
|
||||
const firstText=(selectors)=>{for(const sel of selectors){try{const v=clean(text(document.querySelector(sel)));if(v)return v}catch(e){}}return''};
|
||||
const meta=(selectors)=>{for(const sel of selectors){try{const v=clean(document.querySelector(sel)?.getAttribute?.('content')||'');if(v)return v}catch(e){}}return''};
|
||||
const scalar=v=>(typeof v==='string'||typeof v==='number')?clean(String(v)):'';
|
||||
const structured={titles:[],shops:[],brands:[],categories:[],prices:[],sold:[],sku:[],attrs:[],promos:[],images:[],imageRecords:[]};
|
||||
const addSignal=(group,value,max=300)=>{value=scalar(value);if(value&&value.length<=max&&!structured[group].includes(value))structured[group].push(value)};
|
||||
const roots=[];
|
||||
for(const script of $all('script[type="application/ld+json"],script#__NEXT_DATA__,script[type="application/json"]')){
|
||||
const raw=String(script.textContent||'').trim();if(!raw||raw.length>6000000)continue;
|
||||
try{roots.push(JSON.parse(raw))}catch(e){}
|
||||
}
|
||||
for(const key of ['__INITIAL_STATE__','__INIT_DATA__','__DATA__','__SSR_DATA__','__APOLLO_STATE__']){
|
||||
try{const value=window[key];if(value&&typeof value==='object')roots.push(value)}catch(e){}
|
||||
}
|
||||
const seenStructured=new WeakSet();let structuredNodes=0;
|
||||
function walkProduct(obj,path='',depth=0){
|
||||
if(!obj||typeof obj!=='object'||depth>11||structuredNodes>30000||seenStructured.has(obj))return;
|
||||
seenStructured.add(obj);structuredNodes++;
|
||||
if(Array.isArray(obj)){for(const x of obj.slice(0,2000))walkProduct(x,path,depth+1);return}
|
||||
const objName=scalar(obj.attrName||obj.propertyName||obj.propName||obj.name||obj.label||obj.key);
|
||||
const objValue=scalar(obj.attrValue||obj.propertyValue||obj.valueName||obj.value||obj.text);
|
||||
if(objName&&objValue&&/(?:attr|param|property|prop|feature|规格|参数|属性)/i.test(path))structured.attrs.push({name:objName,value:objValue});
|
||||
for(const [key,value] of Object.entries(obj)){
|
||||
const lower=String(key).toLowerCase(),next=path?path+'.'+lower:lower;
|
||||
if(value&&typeof value==='object'){walkProduct(value,next,depth+1);continue}
|
||||
const valueText=scalar(value);if(!valueText)continue;
|
||||
if(/^(title|itemtitle|item_title|auctiontitle|producttitle|subject|name)$/.test(lower)&&/(?:item|product|auction|detail|schema|offers?|^$)/i.test(path))addSignal('titles',valueText,260);
|
||||
if(/^(shopname|shop_name|shoptitle|sellername|seller_name|storename|store_name|nick)$/.test(lower))addSignal('shops',valueText,160);
|
||||
if(/^(brand|brandname|brand_name)$/.test(lower))addSignal('brands',valueText,120);
|
||||
if(/^(category|categoryname|category_name|catname)$/.test(lower))addSignal('categories',valueText,160);
|
||||
if(/^(price|pricetext|price_text|promotionprice|promotion_price|saleprice|sale_price|pricevalue)$/.test(lower))addSignal('prices',valueText,80);
|
||||
if(/^(sold|soldcount|sold_count|sales|salescount|sales_count|monthsold|monthlysales)$/.test(lower))addSignal('sold',valueText,80);
|
||||
if(/(?:sku|property|prop|spec)/i.test(path)&&/^(name|text|title|value|valuename|value_name)$/.test(lower))addSignal('sku',valueText,100);
|
||||
if(/(?:promo|promotion|activity|coupon|discount|benefit|service)/i.test(next))addSignal('promos',valueText,220);
|
||||
if(/(?:image|img|pic|picture|thumb)/i.test(lower)&&/^https?:\/\/|^\/\//i.test(valueText)){
|
||||
const imageUrl=assetUrl(valueText);addSignal('images',imageUrl,1200);
|
||||
if(imageUrl&&!structured.imageRecords.some(x=>x.url===imageUrl&&x.path===next))structured.imageRecords.push({url:imageUrl,path:next});
|
||||
}
|
||||
}
|
||||
}
|
||||
for(const root of roots)walkProduct(root);
|
||||
const relevantScripts=$all('script').map(s=>String(s.textContent||'')).filter(s=>s.length>20&&s.length<=6000000&&(s.includes(itemId)||/itemTitle|auctionTitle|shopName|skuBase|priceInfo|promotion/i.test(s))).slice(0,16);
|
||||
const scriptCorpus=relevantScripts.join('\n').slice(0,10000000);
|
||||
const scriptString=(keys,max=300)=>{
|
||||
for(const key of keys){
|
||||
const escaped=key.replace(/[.*+?^${}()|[\]\\]/g,'\\$&');
|
||||
const m=scriptCorpus.match(new RegExp('["\\\']'+escaped+'["\\\']\\s*:\\s*"((?:\\\\.|[^"\\\\]){1,'+max+'})"','i'));
|
||||
if(m){try{return clean(JSON.parse('"'+m[1]+'"'))}catch(e){return clean(m[1].replace(/\\u([0-9a-f]{4})/gi,(_,h)=>String.fromCharCode(parseInt(h,16))))}}
|
||||
}
|
||||
return'';
|
||||
};
|
||||
const validProductTitle=value=>{
|
||||
value=clean(value).replace(/[-_–—|]\s*(淘宝网|天猫|淘宝).*$/,'');
|
||||
if(value.length<4||value.length>260)return'';
|
||||
if(/用户评价|累计评价|商品评价|问大家|参数信息|规格参数|图文详情|商品详情|登录|验证码/.test(value))return'';
|
||||
return value;
|
||||
};
|
||||
const titleCandidates=[
|
||||
meta(['meta[property="og:title"]','meta[name="twitter:title"]']),
|
||||
...structured.titles,
|
||||
scriptString(['itemTitle','auctionTitle','productTitle']),
|
||||
firstText(['[data-testid="item-title"]','[data-testid*=item-title]','h1[class*="ItemTitle"]','h1[class*="itemTitle"]','[class*="ItemTitle--"]','[class*="itemTitle--"]','h1']),
|
||||
clean(document.title)
|
||||
];
|
||||
const title=titleCandidates.map(validProductTitle).find(Boolean)||'';
|
||||
const visiblePriceTexts=$all('[class*=price],[class*=Price],[data-testid*=price]').filter(el=>{const r=el.getBoundingClientRect?.();return !r||(r.width>0&&r.height>0&&r.top<1600)}).map(text).map(clean).filter(x=>/\d/.test(x)).slice(0,30);
|
||||
const metaPrice=meta(['meta[property="product:price:amount"]','meta[property="og:price:amount"]','meta[itemprop="price"]']);
|
||||
const visiblePrice=(visiblePriceTexts.join(' ')+' '+liveBodyText.slice(0,6000)).match(/(?:到手价|券后价|活动价|促销价|价格)?\s*[¥¥]\s*(\d{1,7}(?:\.\d{1,2})?)/)?.[1]||(liveBodyText.slice(0,6000).match(/(?:到手价|券后价|活动价|促销价|价格)\s*[::]?\s*(\d{1,7}(?:\.\d{1,2})?)/)||[])[1]||'';
|
||||
const fallbackPrice=structured.prices[0]||scriptString(['promotionPrice','salePrice','priceText','price'],80);
|
||||
const priceValue=(String(metaPrice).match(/\d{1,7}(?:\.\d{1,2})?/)||[])[0]||visiblePrice||(String(fallbackPrice).match(/\d{1,7}(?:\.\d{1,2})?/)||[])[0]||'';
|
||||
const soldMatch=liveBodyText.match(/(?:已售|销量|月销|付款|成交)\s*([\d,.万+]+)/);
|
||||
const soldValue=soldMatch?.[1]||structured.sold[0]||scriptString(['soldCount','salesCount','monthSold','monthlySales'],80);
|
||||
const rankMatch=liveBodyText.match(/(?:热销榜|榜单|排名)[^\d]{0,8}(?:第)?\s*(\d+)\s*名?/);
|
||||
const shop=firstText(['[data-testid*=shop]','[class*=shopName]','[class*=shop-name]','[class*=ShopName]','[class*=sellerName]','[class*=SellerName]','[class*=storeName]','[class*=StoreName]'])||meta(['meta[name="seller"]'])||structured.shops[0]||scriptString(['shopName','shopTitle','sellerName','storeName'],160);
|
||||
const domSkuTexts=$all('[class*=sku] button,[class*=sku] li,[class*=sku] label,[class*=sku] [role=button],[class*=Sku] button,[class*=Sku] li,[class*=prop] button,[class*=Prop] button,[data-testid*=sku] button').map(text).map(clean);
|
||||
const skuTexts=uniq([...domSkuTexts,...structured.sku]).filter(x=>x&&x.length<100&&!/加入购物车|立即购买|领券购买|购买|收藏|客服|分享|确定|取消|数量/.test(x)).slice(0,160);
|
||||
// 参数必采:同时读取属性列表、规格参数、表格、dt/dd 等常见结构;只保留短文本,避免把整块详情正文误当参数。
|
||||
const paramPairs=[];
|
||||
const validParam=(name,value)=>{
|
||||
if(!name||!value||name.length>60||value.length>220)return false;
|
||||
if(/^\d+$/.test(name)||/(?:window\.|console\.|function\b|frontend_data|performance\.|[{}\[\]"'=;<>])/i.test(name))return false;
|
||||
if(/(?:console\.log|window\.|performance\.now|frontend_data)/i.test(value))return false;
|
||||
return /[\u4e00-\u9fffA-Za-z]/.test(name);
|
||||
};
|
||||
const pushParam=(name,value)=>{name=clean(name);value=clean(value);if(!validParam(name,value))return;const k=name+'\u0000'+value;if(!paramPairs.some(x=>x._k===k))paramPairs.push({name,value,_k:k});};
|
||||
const parseParamText=(s)=>{s=clean(s);if(!s||s.length>260)return;const m=s.match(/^([^::]{1,60})[::]\s*(.{1,220})$/);if(m)pushParam(m[1],m[2]);};
|
||||
for(const el of $all('[class*=attr] li,[class*=Attr] li,[class*=parameter] li,[class*=Parameter] li,[class*=prop] li,[class*=Prop] li,[class*=specification] li,[class*=Specification] li,[class*=attribute] li,[class*=Attribute] li')) parseParamText(text(el));
|
||||
for(const dl of $all('dl')){const dts=[...dl.querySelectorAll('dt')],dds=[...dl.querySelectorAll('dd')];for(let i=0;i<Math.min(dts.length,dds.length);i++)pushParam(text(dts[i]),text(dds[i]));}
|
||||
for(const tr of $all('table tr')){const cells=[...tr.querySelectorAll('th,td')].map(text).map(clean).filter(Boolean);if(cells.length>=2)pushParam(cells[0],cells.slice(1).join(' '));}
|
||||
// 兜底读取页面中常见“键:值”短行。
|
||||
const paramTextNodes=$all('[class*=attr],[class*=parameter],[class*=specification],[class*=attribute]').slice(0,80);
|
||||
for(const el of paramTextNodes){for(const line of String(text(el)||'').split(/\n+/).slice(0,80))parseParamText(line);}
|
||||
// 淘宝新版经常移除参数容器 class,但可见页面仍保留“字段:值”短行。
|
||||
for(const line of String(liveBodyRaw||'').split(/\n+/).slice(0,5000))parseParamText(line);
|
||||
for(const el of $all('li,dt,dd,tr,td,th,[role=listitem]').slice(0,3000)){
|
||||
for(const line of String(el.innerText||'').split(/\n+/).slice(0,12))parseParamText(line);
|
||||
}
|
||||
// 当前淘宝新版参数是“值 + 字段名”或“字段名 + 值”两列,没有冒号。
|
||||
for(const el of $all('[class*="emphasisParamsInfoItem--"],[class*="generalParamsInfoItem--"]').slice(0,400)){
|
||||
const children=[...el.children].filter(child=>clean(child.getAttribute?.('title')||text(child)));
|
||||
if(children.length<2)continue;
|
||||
const values=children.map(child=>clean(child.getAttribute?.('title')||text(child)));
|
||||
const isGeneral=/generalParamsInfoItem/i.test(String(el.className||''));
|
||||
const label=isGeneral?values[0]:values[1];
|
||||
const value=isGeneral?values[1]:values[0];
|
||||
pushParam(label,value);
|
||||
}
|
||||
for(const pair of structured.attrs)pushParam(pair.name,pair.value);
|
||||
const attrs=paramPairs.slice(0,240).map(({name,value})=>({name,value}));
|
||||
const PARAM_GROUPS={
|
||||
'基础身份':['品牌','品类','型号','货号','名称'],
|
||||
'规格选择':['规格','尺寸','尺码','颜色','容量','净含量','口味','版本','数量','包装'],
|
||||
'材质/成分/配置':['材质','面料','成分','配料','填充','配置','处理器','内存','存储'],
|
||||
'功能与适用':['功能','功效','适用','肤质','兼容','性能','版型','风格','场景'],
|
||||
'工艺与使用':['工艺','做工','使用','洗护','洗涤','清洗','保养','食用方法'],
|
||||
'合规与服务':['产地','等级','执行标准','保质期','生产日期','认证','质保','售后']
|
||||
};
|
||||
function evaluateParameterCollectionJS(list){
|
||||
const found=[],missing=[];const keys=(list||[]).map(x=>clean(x.name)).filter(Boolean);
|
||||
for(const [canon,aliases] of Object.entries(PARAM_GROUPS)){
|
||||
const hit=(list||[]).some(x=>aliases.some(a=>clean(x.name).includes(a))&&clean(x.value));
|
||||
(hit?found:missing).push(canon);
|
||||
}
|
||||
const state=(list||[]).length>=3?'complete':((list||[]).length?'partial':'missing');
|
||||
return {state,stateText:state==='complete'?'动态参数已采集':state==='partial'?'参数样本较少':'未识别到有效参数',count:(list||[]).length,keys:keys.slice(0,50),found,missing:state==='missing'?['页面动态参数']:[]};
|
||||
}
|
||||
const parameterCollection=evaluateParameterCollectionJS(attrs);
|
||||
const promoDom=$all('[class*=promo],[class*=Promo],[class*=coupon],[class*=Coupon],[class*=activity],[class*=Activity],[class*=discount],[class*=Discount],[class*=service],[class*=Service]').map(text).map(clean);
|
||||
const promoLines=String(liveBodyRaw||'').split(/\n+/).map(clean).filter(x=>x.length>=2&&x.length<=180&&/(减|券|折|赠|包邮|价保|退|换新|补贴|立减|满|活动|优惠|会员|服务)/.test(x));
|
||||
const promoTexts=uniq([...promoDom,...promoLines,...structured.promos]).filter(x=>x&&x.length>=2&&x.length<=220&&/(减|券|折|赠|包邮|价保|退|换新|补贴|立减|满|活动|优惠|会员|服务)/.test(x)).slice(0,120);
|
||||
const isRecImg=img=>{try{return !!img.closest('[class*=recommend],[class*=Recommend],[class*=guess],[class*=Guess],[class*=related],[class*=Related],[class*=similar],[class*=Similar],[id*=recommend],[id*=guess]')}catch(e){return false}};
|
||||
const validImg=u=>/^https?:\/\//.test(u)&&!/icon|logo|avatar|sprite|shopmanager|\/tps\/|\/tfs\/TB1[^/]+-\d{1,4}-\d{1,4}\.png|(?:-|_)rate(?:\.|_|-)|rate_livephoto|tbbala|alicdn.*\.gif/i.test(u);
|
||||
const deReview=new Map();
|
||||
for(const r of reviews){
|
||||
const k=clean(r.content).replace(/\s+/g,'').slice(0,320);if(!k)continue;
|
||||
if(!deReview.has(k))deReview.set(k,{...r,images:uniq((r.images||[]).map(reviewImageUrl)).filter(validReviewImage)});
|
||||
else{const old=deReview.get(k);old.images=uniq([...(old.images||[]),...(r.images||[]).map(reviewImageUrl)]).filter(validReviewImage).slice(0,24)}
|
||||
}
|
||||
const reviewRows=[...deReview.values()];
|
||||
const buyerCandidates=uniq(reviewRows.flatMap(r=>r.images||[])).filter(validReviewImage);
|
||||
const metaImage=assetUrl(meta(['meta[property="og:image"]','meta[name="twitter:image"]','meta[itemprop="image"]']));
|
||||
const isMainRegionImg=img=>{try{if(isRecImg(img)||img.closest('[class*=review],[class*=Review],[class*=rate],[class*=Rate],[class*=comment],[class*=Comment],[class*=detail],[class*=Detail],[class*=desc],[class*=Desc]'))return false;const r=img.getBoundingClientRect();return r.top<1800&&r.bottom>-80&&r.width>=34&&r.height>=34&&r.left<innerWidth*.76}catch(e){return false}};
|
||||
const mainGallerySelector='[class*="PicGallery--"],[class*="picGallery--"],[class*="MainPic--"],[class*="mainPic--"],[class*="main-picture"],[class*="mainPicture"],[data-testid*="main-image"],[data-testid*="gallery"],#J_UlThumb,[class*="Gallery"],[class*="gallery"],[class*="Photo"],[class*="photo"],[class*="Thumb"],[class*="thumb"],[class*="PicList"],[class*="picList"]';
|
||||
const isMainGalleryImg=img=>{try{if(isRecImg(img)||img.closest('[class*=review],[class*=Review],[class*=rate],[class*=Rate],[class*=comment],[class*=Comment],[class*=detail],[class*=Detail],[class*=desc],[class*=Desc]'))return false;const inGallery=!!img.closest(mainGallerySelector);const w=img.naturalWidth||img.width||img.getBoundingClientRect().width;const h=img.naturalHeight||img.height||img.getBoundingClientRect().height;return inGallery?(w>=34&&h>=34):isMainRegionImg(img)}catch(e){return false}};
|
||||
const mainDom=$all(mainGallerySelector+' img').filter(isMainGalleryImg).map(srcOf);
|
||||
const skuDom=$all('[class*="SkuContent--"] img,[class*="skuContent--"] img,[class*="SkuItem--"] img,[class*="skuItem--"] img,[data-testid*="sku"] img,[class*="sku"] [role="button"] img,[class*="Sku"] [role="button"] img').filter(img=>!isRecImg(img)).map(srcOf);
|
||||
const detailDom=uniq([...detailScanUrls,...detailRoots.flatMap(root=>{try{return [...root.querySelectorAll('img')].filter(img=>!isRecImg(img)).map(srcOf)}catch(e){return[]}})]);
|
||||
const structuredMain=structured.imageRecords.filter(x=>/(?:main|gallery|itempic|auctionpic|primary|header)/i.test(x.path)&&!/(?:detail|desc|review|rate|comment|sku|prop|recommend|shop)/i.test(x.path)).map(x=>x.url);
|
||||
const structuredDetail=structured.imageRecords.filter(x=>/(?:detail|desc|module|content|aplus)/i.test(x.path)&&!/(?:review|rate|comment|recommend|shop)/i.test(x.path)).map(x=>x.url);
|
||||
const structuredSku=structured.imageRecords.filter(x=>/(?:sku|prop|spec)/i.test(x.path)&&!/(?:review|rate|comment)/i.test(x.path)).map(x=>x.url);
|
||||
const buyerSet=new Set(buyerCandidates);
|
||||
const skuImages=uniq([...skuDom,...structuredSku]).filter(validImg).filter(u=>!buyerSet.has(u)).slice(0,80);const skuSet=new Set(skuImages);
|
||||
const main=uniq([metaImage,...mainDom,...structuredMain]).filter(validImg).filter(u=>!buyerSet.has(u)&&!skuSet.has(u)).slice(0,24);const mainSet=new Set(main);
|
||||
const detail=uniq([...detailDom,...structuredDetail]).filter(validImg).filter(u=>!buyerSet.has(u)&&!skuSet.has(u)&&!mainSet.has(u)).slice(0,160);
|
||||
const buyer=buyerCandidates.slice(0,200);
|
||||
const allPageCandidates=uniq($all('img').filter(img=>!isRecImg(img)).map(srcOf)).filter(validImg);
|
||||
const allImgs=uniq([...main,...detail,...skuImages,...buyer]);
|
||||
const provenance=[];
|
||||
for(const u of main)provenance.push({url:u,group:'main',source:u===metaImage?'meta_og_image':structuredMain.includes(u)?'structured_main_gallery':'dom_main_gallery'});
|
||||
for(const u of detail)provenance.push({url:u,group:'detail',source:structuredDetail.includes(u)?'structured_product_description':'dom_product_description'});
|
||||
for(const u of skuImages)provenance.push({url:u,group:'sku',source:structuredSku.includes(u)?'structured_sku_selector':'dom_sku_selector'});
|
||||
reviewRows.forEach((r,reviewIndex)=>{for(const u of r.images||[])if(buyerSet.has(u))provenance.push({url:u,group:'buyerShow',source:'review_record',reviewIndex:reviewIndex+1,reviewSource:r.source||'unknown'})});
|
||||
const deQ=new Map(); for(const q of questions){const k=clean(q.question).replace(/\s+/g,'').slice(0,260);if(k&&!deQ.has(k))deQ.set(k,q)}
|
||||
const publicRaw=(liveBodyText.match(/(?:评价|评论)[^\d]{0,8}([\d,.]+\s*万?\+?)/)||[])[1]||'';
|
||||
const parseCount=v=>{v=String(v||'').replace(/,/g,'').trim();const m=v.match(/([\d.]+)\s*(万)?/);if(!m)return null;return Math.round(parseFloat(m[1])*(m[2]?10000:1))};
|
||||
const publicNumeric=parseCount(publicRaw);
|
||||
const foldedDefaultMatch=liveBodyText.match(/已折叠\s*([\d,.]+)\s*条[^\n]{0,30}评价/);
|
||||
const defaultPraiseMatch=liveBodyText.match(/近半年\s*([\d,.]+)\s*位买家默认好评/);
|
||||
const networkTotal=reviewNetMeta.totals.length?Math.max(...reviewNetMeta.totals.filter(n=>Number.isFinite(n))):null;
|
||||
if(publicNumeric&&deReview.size>=publicNumeric){collectionComplete=true;explicitEnd=true;stopReason='matched_public_review_count';}
|
||||
else if(networkTotal!==null&&networkTotal>0&&deReview.size>=networkTotal){collectionComplete=true;explicitEnd=true;stopReason='matched_network_review_total';}
|
||||
else if((reviewNetMeta.hasMoreFalse||reviewNetMeta.lastPageFlags.length>0) && deReview.size>0){collectionComplete=true;explicitEnd=true;stopReason='network_explicit_end';}
|
||||
// 严禁“无新增/无下一页控件”单独作为完整依据
|
||||
const completeness=collectionComplete?'confirmed':'unconfirmed';
|
||||
await saveCheckpoint({reviews:[...deReview.values()],questions:[...deQ.values()],round:pagesVisited,updatedAt:Date.now(),complete:collectionComplete,stopReason,completeness,networkTotal,publicNumeric,reviewNetMeta});
|
||||
return {
|
||||
meta:{platform:location.hostname.includes('tmall')?'tmall':'taobao',collectedAt:new Date().toISOString(),url:location.href,collectorVersion:'9.2.2',imageClassificationVersion:'strict-v1',networkCaptures:captured.length,structuredRoots:roots.length},
|
||||
product:{itemId,skuId,title,shop,brand:structured.brands[0]||scriptString(['brandName','brand'],120),category:structured.categories[0]||scriptString(['categoryName','catName'],160),url:location.href,parameterCollection},
|
||||
sales:{currentPrice:priceValue,sold:soldValue,ranking:rankMatch?.[1]||''},
|
||||
sku:skuTexts.map(name=>({name})),attributes:attrs,promotions:promoTexts.map(text=>({text})),
|
||||
images:{main,detail,sku:skuImages,buyerShow:buyer,all:allImgs,provenance,classification:{version:'strict-v1',classifiedCount:allImgs.length,unclassifiedCount:Math.max(0,allPageCandidates.length-allImgs.length),groupsAreDisjoint:true}},
|
||||
reviews:reviewRows,questions:[...deQ.values()],pageText:liveBodyText.slice(0,120000),collection:{reviewActual:deReview.size,questionActual:deQ.size,reviewDrawerOpened:reviewDrawerWasOpened,reviewCollectionComplete:collectionComplete,reviewCompleteness:collectionComplete?'confirmed':'unconfirmed',reviewStopReason:stopReason,reviewPagesVisited:pagesVisited,publicReviewCount:publicRaw,publicReviewCountNumeric:publicNumeric,foldedDefaultReviewCount:foldedDefaultMatch?parseCount(foldedDefaultMatch[1]):null,defaultPraiseBuyerCount:defaultPraiseMatch?parseCount(defaultPraiseMatch[1]):null,networkReviewTotal:networkTotal,networkReviewObjects:reviewNetMeta.networkReviewObjects,networkReviewResponses:reviewNetMeta.networkResponses,networkExplicitEnd:reviewNetMeta.hasMoreFalse||reviewNetMeta.lastPageFlags.length>0,explicitEnd,reviewLive:window.__TMALL_AI_REVIEW_PROGRESS__||null}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "三笙 · 电商商品分析闭环 V9.2.5",
|
||||
"version": "9.2.5",
|
||||
"description": "跨类目电商商品分析:动态参数与SKU采集、图文分源、评论证据、开品与AI视觉方案。",
|
||||
"permissions": [
|
||||
"scripting",
|
||||
"storage",
|
||||
"tabs"
|
||||
],
|
||||
"host_permissions": [
|
||||
"https://detail.tmall.com/*",
|
||||
"https://*.tmall.com/*",
|
||||
"https://*.taobao.com/*",
|
||||
"https://*.alicdn.com/*",
|
||||
"http://127.0.0.1:17962/*"
|
||||
],
|
||||
"background": {
|
||||
"service_worker": "background.js"
|
||||
},
|
||||
"action": {
|
||||
"default_popup": "popup.html",
|
||||
"default_title": "三笙 · 电商商品分析闭环 V9.2.5"
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": [
|
||||
"https://detail.tmall.com/*",
|
||||
"https://*.tmall.com/*",
|
||||
"https://item.taobao.com/*",
|
||||
"https://*.taobao.com/*"
|
||||
],
|
||||
"js": [
|
||||
"page-review-hook.js"
|
||||
],
|
||||
"run_at": "document_start",
|
||||
"world": "MAIN"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
(() => {
|
||||
if (window.__TMALL_AI_SAFE_REVIEW_HOOK__) return;
|
||||
window.__TMALL_AI_SAFE_REVIEW_HOOK__ = true;
|
||||
|
||||
const MAX_RECORDS = 100;
|
||||
const MAX_TEXT = 1200000;
|
||||
const MATCH = /review|rate|comment|feedback|evaluate|buyer|remark|ratedetail|ask|question|wdj|评价|评论|问大家/i;
|
||||
|
||||
window.__TMALL_AI_CAPTURE__ = Array.isArray(window.__TMALL_AI_CAPTURE__)
|
||||
? window.__TMALL_AI_CAPTURE__ : [];
|
||||
window.__TMALL_AI_SOURCE_SNAPSHOTS__ = Array.isArray(window.__TMALL_AI_SOURCE_SNAPSHOTS__)
|
||||
? window.__TMALL_AI_SOURCE_SNAPSHOTS__ : [];
|
||||
|
||||
function pushCapture(rec) {
|
||||
try {
|
||||
if (!rec || !MATCH.test(String(rec.url || ""))) return;
|
||||
const text = String(rec.text || "").slice(0, MAX_TEXT);
|
||||
const key = [rec.method || "", rec.url || "", text.slice(0, 240)].join("|");
|
||||
const list = window.__TMALL_AI_CAPTURE__;
|
||||
if (list.some(x => x && x.__safeKey === key)) return;
|
||||
list.push({...rec, text, __safeKey:key, capturedAt:Date.now(), captureMode:"passive_network"});
|
||||
while (list.length > MAX_RECORDS) list.shift();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// Passive fetch interception: clones only the response the page already requested.
|
||||
try {
|
||||
const originalFetch = window.fetch;
|
||||
if (typeof originalFetch === "function") {
|
||||
window.fetch = async function(...args) {
|
||||
const response = await originalFetch.apply(this, args);
|
||||
try {
|
||||
const req = args[0];
|
||||
const opt = args[1] || {};
|
||||
const url = String(req?.url || req || "");
|
||||
if (MATCH.test(url)) {
|
||||
const clone = response.clone();
|
||||
clone.text().then(text => {
|
||||
pushCapture({
|
||||
url,
|
||||
method:String(opt.method || req?.method || "GET"),
|
||||
status:response.status,
|
||||
text
|
||||
});
|
||||
}).catch(() => {});
|
||||
}
|
||||
} catch (_) {}
|
||||
return response;
|
||||
};
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
// Passive XHR interception: reads response after normal page request completes.
|
||||
try {
|
||||
const open = XMLHttpRequest.prototype.open;
|
||||
const send = XMLHttpRequest.prototype.send;
|
||||
XMLHttpRequest.prototype.open = function(method, url, ...rest) {
|
||||
this.__tmall_ai_url = String(url || "");
|
||||
this.__tmall_ai_method = String(method || "GET");
|
||||
return open.call(this, method, url, ...rest);
|
||||
};
|
||||
XMLHttpRequest.prototype.send = function(...args) {
|
||||
if (MATCH.test(this.__tmall_ai_url || "")) {
|
||||
this.addEventListener("load", () => {
|
||||
try {
|
||||
const txt = typeof this.responseText === "string" ? this.responseText : "";
|
||||
pushCapture({
|
||||
url:this.__tmall_ai_url,
|
||||
method:this.__tmall_ai_method || "XHR",
|
||||
status:this.status,
|
||||
text:txt
|
||||
});
|
||||
} catch (_) {}
|
||||
}, {once:true});
|
||||
}
|
||||
return send.apply(this, args);
|
||||
};
|
||||
} catch (_) {}
|
||||
|
||||
// Page-code snapshots. Only read script text already present in the DOM.
|
||||
function snapshotScripts() {
|
||||
try {
|
||||
const out = [];
|
||||
const scripts = document.scripts ? Array.from(document.scripts) : [];
|
||||
for (const s of scripts) {
|
||||
const txt = String(s.textContent || "");
|
||||
if (txt.length < 20) continue;
|
||||
if (!/review|rate|comment|evaluate|feedback|question|ask|评价|评论|问大家/i.test(txt)) continue;
|
||||
out.push({
|
||||
source:"inline_script",
|
||||
text:txt.slice(0, MAX_TEXT),
|
||||
capturedAt:Date.now()
|
||||
});
|
||||
if (out.length >= 12) break;
|
||||
}
|
||||
window.__TMALL_AI_SOURCE_SNAPSHOTS__ = out;
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", snapshotScripts, {once:true});
|
||||
} else {
|
||||
snapshotScripts();
|
||||
}
|
||||
|
||||
// Mutation is only used to refresh already-present inline JSON snapshots.
|
||||
try {
|
||||
let timer = 0;
|
||||
const mo = new MutationObserver(() => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(snapshotScripts, 1200);
|
||||
});
|
||||
mo.observe(document.documentElement, {childList:true, subtree:true});
|
||||
} catch (_) {}
|
||||
|
||||
window.__TMALL_AI_SAFE_CAPTURE_STATUS__ = () => ({
|
||||
mode:"passive_code_and_network",
|
||||
networkRecords:(window.__TMALL_AI_CAPTURE__ || []).length,
|
||||
sourceSnapshots:(window.__TMALL_AI_SOURCE_SNAPSHOTS__ || []).length,
|
||||
extraRequestsCreated:0,
|
||||
installed:true
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,43 @@
|
||||
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><style>
|
||||
body{width:360px;margin:0;padding:18px;font-family:-apple-system,BlinkMacSystemFont,"PingFang SC",sans-serif;background:#f5faf9;color:#10191d}h1{font-size:18px;margin:0 0 6px;background:linear-gradient(110deg,#00d9b6,#008bd8);-webkit-background-clip:text;color:transparent}.sub{font-size:12px;color:#60757c;line-height:1.6}.card{margin-top:14px;padding:14px;background:#fff;border:1px solid #dbe8e8;border-radius:14px}.state{font-weight:800;margin-bottom:8px}.bar{height:6px;background:#e6f0ef;border-radius:8px;overflow:hidden}.bar i{display:block;height:100%;background:linear-gradient(110deg,#00d9b6,#008bd8);width:0}.btn{width:100%;margin-top:12px;border:0;background:linear-gradient(110deg,#00cdb0,#008fd8);color:#fff;padding:11px;border-radius:999px;font-weight:800;cursor:pointer}.secondary{background:#fff;color:#17434b;border:1px solid #a9cfcc}.tiny{font-size:11px;color:#71858a;margin-top:9px}.monitor{display:flex;gap:8px}.monitor .btn{flex:1}.ok{color:#008b78}.warn{color:#a66d18}.file{display:none}.live{margin-top:10px;padding:10px;background:#edf9f7;border-radius:10px;font-size:11px;line-height:1.6;color:#526a70}.live strong{font-size:12px;color:#10191d}.live-grid{display:grid;grid-template-columns:1fr 1fr;gap:4px 10px;margin-top:6px}.live-preview{margin-top:7px;padding-top:7px;border-top:1px solid #d6e8e5;max-height:78px;overflow:auto}.stop{background:#a83f49!important;color:#fff!important;border:0!important}
|
||||
|
||||
.analysis-now{background:#fff;color:#1f1d1a;border:1px solid #1f1d1a}
|
||||
.mode-row{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:10px}
|
||||
.mode{padding:10px;border:1px solid #ded7ce;border-radius:12px;background:#fff;cursor:pointer;font-size:12px}
|
||||
.mode.active{background:#1f1d1a;color:#fff;border-color:#1f1d1a}
|
||||
.mode small{display:block;opacity:.72;margin-top:4px;line-height:1.45}
|
||||
|
||||
.param-live{margin-top:10px;padding:10px;background:#f6f3ed;border-radius:10px;font-size:11px;line-height:1.6;color:#5f5a54}
|
||||
.param-live strong{font-size:12px;color:#181716}
|
||||
.param-tags{display:flex;flex-wrap:wrap;gap:6px;margin-top:7px}
|
||||
.param-tag{padding:4px 8px;border-radius:999px;background:#fff;border:1px solid #e0d8cf;font-size:11px}
|
||||
.param-miss{margin-top:8px;padding-top:8px;border-top:1px solid #e5dfd6}
|
||||
</style></head><body>
|
||||
<h1>三笙 · 电商商品分析引擎</h1>
|
||||
<div class="sub">采集当前商品后自动识别类目和分析维度,生成标题、主图、详情、SKU、评论、新品设计与 AI 视觉报告。</div>
|
||||
<div class="card"><div id="server" class="state">检查本地服务…</div><div id="status" class="sub">等待当前商品页</div><div class="bar"><i id="bar"></i></div></div>
|
||||
<div class="card"><div class="state">当前商品监测数据</div><div id="monitorStatus" class="sub">未检测</div><div class="monitor"><button class="btn secondary" id="importMonitor">添加监测数据</button><button class="btn secondary" id="clearMonitor">清除</button></div><input class="file" id="monitorFile" type="file" accept=".xlsx,.csv,.json"></div>
|
||||
<button class="btn" id="run">开始深度采集</button>
|
||||
<button class="btn stop" id="stop" style="display:none">终止采集并保存进度</button>
|
||||
|
||||
<div id="paramLive" class="param-live" style="display:none">
|
||||
<strong>参数采集</strong>
|
||||
<div>状态:<b id="paramState">等待采集</b></div>
|
||||
<div>已识别:<b id="paramCount">0</b> 项</div>
|
||||
<div class="param-tags" id="paramTags"></div>
|
||||
<div class="param-miss" id="paramMissing">缺失项:—</div>
|
||||
</div>
|
||||
<div id="reviewLive" class="live" style="display:none"><strong>评论实时采集</strong><div class="live-grid"><span>去重:<b id="liveCount">0</b></span><span>网络对象:<b id="liveNet">0</b></span><span>当前轮次:<b id="liveRound">0</b></span><span>无新增:<b id="liveStagnant">0</b></span><span>模式:<b id="liveMode">—</b></span><span>耗时:<b id="liveElapsed">0s</b></span></div><div id="livePreview" class="live-preview">等待评论数据…</div></div>
|
||||
<button class="btn secondary" id="open">打开本地服务</button>
|
||||
<div class="tiny">不会自动采集;不会采集推荐商品。完整采集模式会继续采到可确认末页;快速分析可随时使用当前已采数据生成报告。</div>
|
||||
<div class="card">
|
||||
<div class="state">分析方式</div>
|
||||
<div class="mode-row">
|
||||
<button id="modeQuick" class="mode active">快速分析<small>当前已采数据即可开始</small></button>
|
||||
<button id="modeFull" class="mode">完整采集<small>继续采到可确认末页</small></button>
|
||||
</div>
|
||||
<button id="analyzeNow" class="btn analysis-now">基于当前数据开始分析</button>
|
||||
<div class="tiny">分析不会清空采集进度。评论可继续采;报告会明确标注当前评论数量与完整性。</div>
|
||||
</div>
|
||||
<script src="popup.js"></script>
|
||||
</body></html>
|
||||
@@ -0,0 +1,124 @@
|
||||
const SERVER='http://127.0.0.1:17962';
|
||||
const EXPECTED_SERVER_VERSION='9.2.5';
|
||||
let backendCompatible=false;
|
||||
const server=document.getElementById('server'),status=document.getElementById('status'),bar=document.getElementById('bar'),monitorStatus=document.getElementById('monitorStatus'),runBtn=document.getElementById('run'),stopBtn=document.getElementById('stop'),reviewLive=document.getElementById('reviewLive'),liveCount=document.getElementById('liveCount'),liveNet=document.getElementById('liveNet'),liveRound=document.getElementById('liveRound'),liveStagnant=document.getElementById('liveStagnant'),liveMode=document.getElementById('liveMode'),liveElapsed=document.getElementById('liveElapsed'),livePreview=document.getElementById('livePreview');
|
||||
const paramLive=document.getElementById('paramLive'),paramState=document.getElementById('paramState'),paramCount=document.getElementById('paramCount'),paramTags=document.getElementById('paramTags'),paramMissing=document.getElementById('paramMissing');
|
||||
async function active(){return (await chrome.tabs.query({active:true,currentWindow:true}))[0]}
|
||||
function itemIdFromUrl(u){try{return new URL(u).searchParams.get('id')||((u||'').match(/[?&]id=(\d+)/)||[])[1]||''}catch(e){return''}}
|
||||
async function refresh(){
|
||||
const h=await chrome.runtime.sendMessage({type:'HEALTH'});
|
||||
backendCompatible=!!(h?.ok && h.serverVersion===EXPECTED_SERVER_VERSION);
|
||||
if(!h?.ok){
|
||||
server.textContent='本地服务未启动 ✕';
|
||||
server.className='state warn';
|
||||
}else if(!backendCompatible){
|
||||
server.textContent=`版本不一致 ✕ 插件 ${EXPECTED_SERVER_VERSION} / 后端 ${h.serverVersion||'未知'},请启动本包最新服务`;
|
||||
server.className='state warn';
|
||||
}else{
|
||||
server.textContent=`本地服务 ✓ V${h.serverVersion} · ${h.modelConfigured?'模型已配置':'模型未配置'}`;
|
||||
server.className='state ok';
|
||||
}
|
||||
const t=await active(); if(!t)return; const id=itemIdFromUrl(t.url||'');
|
||||
if(!id){status.textContent='请打开天猫/淘宝商品详情页';monitorStatus.textContent='当前页面未识别商品ID';return}
|
||||
const k='status_'+t.id; const d=(await chrome.storage.local.get(k))[k];
|
||||
if(d){status.textContent=d.message||d.state;bar.style.width=(d.progress||0)+'%';runBtn.textContent=(d.state==='reviews_incomplete'||d.state==='reviews_stopped')?'继续采集评论':'开始深度采集';const lp=d.reviewLive;const activeCollect=d.state==='collecting'||d.state==='collecting_reviews';stopBtn.style.display=activeCollect?'block':'none';reviewLive.style.display=(lp||activeCollect||d.state==='reviews_incomplete'||d.state==='reviews_stopped')?'block':'none';const ps=d.parameterStatus;
|
||||
paramLive.style.display=ps?'block':'none';
|
||||
if(ps){
|
||||
paramState.textContent=ps.stateText||'已采集';
|
||||
paramCount.textContent=ps.count??0;
|
||||
paramTags.innerHTML=(ps.keys||[]).slice(0,10).map(k=>`<span class="param-tag">${String(k).replace(/[<>&]/g,s=>({'<':'<','>':'>','&':'&'}[s]))}</span>`).join('')||'<span class="param-tag">暂无</span>';
|
||||
paramMissing.textContent='缺失项:'+((ps.missing||[]).length?(ps.missing.join('、')):'无');
|
||||
}
|
||||
if(lp){liveCount.textContent=lp.count??0;liveNet.textContent=lp.networkReviewObjects??0;liveRound.textContent=lp.round??0;liveStagnant.textContent=lp.stagnant??0;liveMode.textContent=lp.mode||'—';liveElapsed.textContent=Math.round((lp.elapsedMs||0)/1000)+'s';livePreview.innerHTML=(lp.preview||[]).map((x,i)=>`<div>${i+1}. ${String(x).replace(/[<>&]/g,c=>({'<':'<','>':'>','&':'&'}[c]))}</div>`).join('')||'等待评论数据…'}}else{status.textContent='已识别当前商品,等待手动开始。';bar.style.width='0%';runBtn.textContent='开始深度采集';stopBtn.style.display='none';reviewLive.style.display='none'}
|
||||
try{const r=await fetch(`${SERVER}/api/monitor/${encodeURIComponent(id)}`);const j=await r.json();monitorStatus.textContent=j.ok&&j.days?`已导入 ${j.days} 天 · ${j.firstSales||'—'} → ${j.lastSales||'—'}`:'未导入(可选)'}catch(e){monitorStatus.textContent='本地服务启动后可导入'}
|
||||
}
|
||||
runBtn.onclick=async()=>{
|
||||
if(!backendCompatible){status.textContent='插件与本地后端版本不一致,请先双击本包“start-tmall-ai.command”';return}
|
||||
const t=await active();status.textContent='开始采集当前商品…';try{const r=await chrome.runtime.sendMessage({type:'RUN_CURRENT',tabId:t.id});if(r?.needsReviewResume){status.textContent=r.message||'评论还没到末页,请继续采集';runBtn.textContent='继续采集评论'}else if(!r?.ok){status.textContent='采集失败:'+(r?.error||'未知错误')}}catch(e){status.textContent='采集失败:'+String(e)}setTimeout(refresh,500)};
|
||||
document.getElementById('open').onclick=()=>chrome.tabs.create({url:SERVER+'/'});
|
||||
document.getElementById('importMonitor').onclick=()=>document.getElementById('monitorFile').click();
|
||||
document.getElementById('monitorFile').onchange=async e=>{
|
||||
const f=e.target.files?.[0];if(!f)return;
|
||||
if(!backendCompatible){monitorStatus.textContent='导入失败:插件与后端版本不一致,请启动本包最新服务';e.target.value='';return}
|
||||
const t=await active();const itemId=itemIdFromUrl(t?.url||'');
|
||||
if(!itemId){monitorStatus.textContent='请先打开目标商品页';e.target.value='';return}
|
||||
monitorStatus.textContent='正在解析并导入…';
|
||||
try{
|
||||
const buf=await f.arrayBuffer();
|
||||
let binary='';const bytes=new Uint8Array(buf);
|
||||
for(let i=0;i<bytes.length;i+=0x8000)binary+=String.fromCharCode(...bytes.subarray(i,i+0x8000));
|
||||
const base64=btoa(binary);
|
||||
const send=async(forceBind=false)=>{
|
||||
const r=await fetch(SERVER+'/api/monitor-import',{
|
||||
method:'POST',headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({itemId,filename:f.name,dataBase64:base64,forceBind})
|
||||
});
|
||||
const j=await r.json().catch(()=>({ok:false,error:'服务返回内容无法解析'}));
|
||||
return {r,j};
|
||||
};
|
||||
let {r,j}=await send(false);
|
||||
if(!j.ok && j.code==='ITEM_ID_MISMATCH' && j.canForce){
|
||||
const fileId=(j.fileItemIds||[])[0]||'未知';
|
||||
const yes=confirm(`监测文件中的商品ID为 ${fileId},当前页面商品ID为 ${itemId}。
|
||||
|
||||
如果这份文件确实属于当前商品,点击“确定”后按当前商品绑定;否则点击“取消”。`);
|
||||
if(yes)({r,j}=await send(true));
|
||||
}
|
||||
if(j.ok){
|
||||
monitorStatus.textContent=`已导入 ${j.days} 天 · ${j.firstSales||'—'} → ${j.lastSales||'—'}`;
|
||||
}else{
|
||||
const extra=j.fileItemIds?.length?`(文件商品ID:${j.fileItemIds.slice(0,5).join(', ')})`:'';
|
||||
monitorStatus.textContent=`导入失败:${j.error||'未知错误'}${extra}`;
|
||||
}
|
||||
}catch(err){
|
||||
monitorStatus.textContent='导入失败:'+String(err);
|
||||
}
|
||||
e.target.value='';
|
||||
};
|
||||
document.getElementById('clearMonitor').onclick=async()=>{const t=await active();const itemId=itemIdFromUrl(t?.url||'');if(!itemId)return;try{await fetch(SERVER+'/api/monitor-clear',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({itemId})});monitorStatus.textContent='已清除'}catch(e){monitorStatus.textContent='清除失败'}};
|
||||
refresh();setInterval(refresh,1300);
|
||||
|
||||
stopBtn.onclick=async()=>{const t=await active();stopBtn.disabled=true;stopBtn.textContent='正在终止…';try{const r=await chrome.runtime.sendMessage({type:'STOP_COLLECTION',tabId:t?.id});status.textContent=r?.ok?'已发送终止指令,正在保存当前进度…':'终止失败,请重试'}catch(e){status.textContent='终止失败:'+String(e)}setTimeout(()=>{stopBtn.disabled=false;stopBtn.textContent='终止采集并保存进度'},1000)};
|
||||
|
||||
const analyzeNow=document.getElementById('analyzeNow');
|
||||
const modeQuick=document.getElementById('modeQuick');
|
||||
const modeFull=document.getElementById('modeFull');
|
||||
let analysisMode='quick';
|
||||
|
||||
function setMode(mode){
|
||||
analysisMode=mode;
|
||||
modeQuick?.classList.toggle('active',mode==='quick');
|
||||
modeFull?.classList.toggle('active',mode==='full');
|
||||
if(analyzeNow){
|
||||
analyzeNow.textContent=mode==='quick'?'基于当前数据开始分析':'完整采集后自动分析';
|
||||
analyzeNow.disabled=mode==='full';
|
||||
}
|
||||
}
|
||||
modeQuick && (modeQuick.onclick=()=>setMode('quick'));
|
||||
modeFull && (modeFull.onclick=()=>setMode('full'));
|
||||
setMode('quick');
|
||||
|
||||
analyzeNow && (analyzeNow.onclick=async()=>{
|
||||
if(!backendCompatible){
|
||||
status.textContent='插件与本地后端版本不一致,请先启动本包最新服务';
|
||||
return;
|
||||
}
|
||||
const t=await active();
|
||||
if(!t?.id){status.textContent='请先打开目标商品页';return}
|
||||
analyzeNow.disabled=true;
|
||||
analyzeNow.textContent='正在提交当前数据…';
|
||||
try{
|
||||
const r=await chrome.runtime.sendMessage({type:'ANALYZE_CURRENT_NOW',tabId:t.id});
|
||||
if(r?.ok){
|
||||
status.textContent=`已开始分析当前数据${r.reviewCount!=null?` · 当前评论 ${r.reviewCount} 条`:''}`;
|
||||
if(r.taskUrl) chrome.tabs.create({url:/^https?:\/\//i.test(r.taskUrl)?r.taskUrl:SERVER+r.taskUrl});
|
||||
}else{
|
||||
status.textContent='无法开始分析:'+(r?.error||'未知错误');
|
||||
}
|
||||
}catch(e){
|
||||
status.textContent='无法开始分析:'+String(e);
|
||||
}finally{
|
||||
analyzeNow.disabled=false;
|
||||
analyzeNow.textContent='基于当前数据开始分析';
|
||||
}
|
||||
});
|
||||
Binary file not shown.
@@ -0,0 +1,8 @@
|
||||
日期,itemId,商品标题,销量展示值,榜单排名,价格,活动
|
||||
2026-08-21,,当前商品,5000+,22,899,
|
||||
2026-08-22,,当前商品,5000+,20,899,
|
||||
2026-08-23,,当前商品,1万+,17,899,直降
|
||||
2026-08-24,,当前商品,1万+,15,899,直降
|
||||
2026-08-25,,当前商品,2万+,12,891,满减+直降
|
||||
2026-08-26,,当前商品,2万+,8,891,满减+直降
|
||||
2026-08-27,,当前商品,2万+,6,891,满减+直降
|
||||
|
@@ -0,0 +1,148 @@
|
||||
import json, os, re, ssl, time, urllib.request, urllib.error
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
SERVER_VERSION = '9.2.5'
|
||||
|
||||
def load_config():
|
||||
cfg = {}
|
||||
p = Path(os.getenv('AI_CONFIG_PATH') or ROOT/'config.json').expanduser()
|
||||
cfg['_config_path'] = str(p)
|
||||
cfg['_config_error'] = ''
|
||||
if p.exists():
|
||||
try:
|
||||
raw = p.read_text('utf-8').strip()
|
||||
data = json.loads(raw)
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError('config.json 顶层必须是 JSON 对象')
|
||||
cfg.update(data)
|
||||
except json.JSONDecodeError as e:
|
||||
cfg['_config_error'] = f'config.json JSON 格式错误:第 {e.lineno} 行第 {e.colno} 列:{e.msg}'
|
||||
except Exception as e:
|
||||
cfg['_config_error'] = f'{type(e).__name__}: {e}'
|
||||
else:
|
||||
cfg['_config_error'] = 'config.json 不存在'
|
||||
cfg['api_base'] = os.getenv('AI_API_BASE', cfg.get('api_base','https://api.bananarouter.com/v1')).rstrip('/')
|
||||
cfg['api_key'] = os.getenv('AI_API_KEY', cfg.get('api_key',''))
|
||||
cfg['model'] = os.getenv('AI_MODEL', cfg.get('model',''))
|
||||
cfg['chat_path'] = str(cfg.get('chat_path','/chat/completions'))
|
||||
cfg['models_path'] = str(cfg.get('models_path','/models'))
|
||||
try: cfg['temperature'] = float(os.getenv('AI_TEMPERATURE', cfg.get('temperature',0.2)))
|
||||
except Exception: cfg['temperature'] = 0.2
|
||||
try: cfg['timeout'] = int(os.getenv('AI_TIMEOUT', cfg.get('timeout',120)))
|
||||
except Exception: cfg['timeout'] = 120
|
||||
try: cfg['retries'] = max(0, int(cfg.get('retries',2)))
|
||||
except Exception: cfg['retries'] = 2
|
||||
cfg['extra_headers'] = cfg.get('extra_headers',{}) or {}
|
||||
cfg['ca_bundle'] = str(cfg.get('ca_bundle','') or '')
|
||||
cfg['ssl_verify'] = bool(cfg.get('ssl_verify', True))
|
||||
return cfg
|
||||
|
||||
def configured():
|
||||
c=load_config()
|
||||
key=str(c.get('api_key') or '').strip(); model=str(c.get('model') or '').strip()
|
||||
return bool(not c.get('_config_error') and key and model and not key.startswith('YOUR_') and not model.startswith('YOUR_'))
|
||||
|
||||
def _ssl_context(c):
|
||||
if not c.get('ssl_verify', True):
|
||||
raise RuntimeError('ssl_verify=false 被禁止。V6.2 不允许关闭 HTTPS 证书校验。')
|
||||
cafile = c.get('ca_bundle') or ''
|
||||
bundled = ROOT/'cacert.pem'
|
||||
if not cafile and bundled.exists(): cafile=str(bundled)
|
||||
if cafile:
|
||||
if not Path(cafile).exists():
|
||||
raise RuntimeError(f'ca_bundle 文件不存在:{cafile}')
|
||||
ctx=ssl.create_default_context(cafile=cafile)
|
||||
else:
|
||||
try:
|
||||
import certifi
|
||||
ctx=ssl.create_default_context(cafile=certifi.where())
|
||||
except Exception:
|
||||
ctx=ssl.create_default_context()
|
||||
try: ctx.minimum_version=ssl.TLSVersion.TLSv1_2
|
||||
except Exception: pass
|
||||
return ctx
|
||||
|
||||
def _headers(c):
|
||||
h={'Content-Type':'application/json','Accept':'application/json','Authorization':'Bearer '+c['api_key'],'User-Agent':'TmallAIProductAnalysis/9.2.5'}
|
||||
h.update(c.get('extra_headers',{}))
|
||||
return h
|
||||
|
||||
def _join(base,path):
|
||||
path='/' + str(path or '').lstrip('/')
|
||||
return base.rstrip('/') + path
|
||||
|
||||
def probe_model_api(timeout=15):
|
||||
c=load_config()
|
||||
if c.get('_config_error'):
|
||||
return {'ok':False,'stage':'config','error':c['_config_error']}
|
||||
if not c.get('api_key') or not c.get('model'):
|
||||
return {'ok':False,'stage':'config','error':'api_key 或 model 为空'}
|
||||
try:
|
||||
req=urllib.request.Request(_join(c['api_base'],c['models_path']),headers=_headers(c),method='GET')
|
||||
with urllib.request.urlopen(req,timeout=min(timeout,c['timeout']),context=_ssl_context(c)) as r:
|
||||
obj=json.loads(r.read().decode('utf-8','ignore') or '{}')
|
||||
ids=[x.get('id') for x in (obj.get('data') or []) if isinstance(x,dict)]
|
||||
return {'ok':True,'stage':'models','http':200,'modelFound':(c['model'] in ids) if ids else None,'model':c['model'],'modelsCount':len(ids)}
|
||||
except urllib.error.HTTPError as e:
|
||||
body=e.read().decode('utf-8','ignore')
|
||||
return {'ok':False,'stage':'models','http':e.code,'error':body[:500] or str(e)}
|
||||
except ssl.SSLCertVerificationError as e:
|
||||
return {'ok':False,'stage':'ssl','error':f'SSL证书验证失败:{e}. V6.2 已优先使用 certifi;如仍失败,请确认 Python/代理没有替换证书链。'}
|
||||
except Exception as e:
|
||||
return {'ok':False,'stage':'network','error':str(e)}
|
||||
|
||||
def _parse_json(s):
|
||||
if isinstance(s, dict): return s
|
||||
s = (s or '').strip()
|
||||
s = re.sub(r'^```(?:json)?\s*|\s*```$', '', s, flags=re.I|re.S)
|
||||
try: return json.loads(s)
|
||||
except Exception:
|
||||
a=s.find('{'); b=s.rfind('}')
|
||||
if a>=0 and b>a: return json.loads(s[a:b+1])
|
||||
raise
|
||||
|
||||
def _do_post(c,payload):
|
||||
req=urllib.request.Request(_join(c['api_base'],c['chat_path']),data=json.dumps(payload,ensure_ascii=False).encode('utf-8'),headers=_headers(c),method='POST')
|
||||
with urllib.request.urlopen(req,timeout=c['timeout'],context=_ssl_context(c)) as r:
|
||||
return json.loads(r.read().decode('utf-8','ignore'))
|
||||
|
||||
def chat_json(system, user, images=None, max_tokens=5000):
|
||||
c=load_config()
|
||||
if c.get('_config_error'):
|
||||
raise RuntimeError(c['_config_error'])
|
||||
if not c.get('api_key') or not c.get('model'):
|
||||
raise RuntimeError('未配置模型。请复制 config.example.json 为 config.json,并填写 api_key 与 model。')
|
||||
content=[{'type':'text','text':user}]
|
||||
for url in (images or [])[:12]:
|
||||
if isinstance(url,str) and url.startswith(('http://','https://','data:image/')):
|
||||
content.append({'type':'image_url','image_url':{'url':url}})
|
||||
base_payload={
|
||||
'model':c['model'],
|
||||
'messages':[{'role':'system','content':system},{'role':'user','content':content if len(content)>1 else user}],
|
||||
'temperature':c['temperature'],
|
||||
'response_format':{'type':'json_object'},
|
||||
'max_tokens':max_tokens
|
||||
}
|
||||
variants=[base_payload]
|
||||
# Some OpenAI-compatible gateways/models reject response_format, temperature, or max_tokens.
|
||||
v2=dict(base_payload); v2.pop('response_format',None); variants.append(v2)
|
||||
v3=dict(v2); v3.pop('temperature',None); v3['max_completion_tokens']=v3.pop('max_tokens',max_tokens); variants.append(v3)
|
||||
last=None
|
||||
for attempt in range(c['retries']+1):
|
||||
for payload in variants:
|
||||
try:
|
||||
obj=_do_post(c,payload)
|
||||
try: return _parse_json(obj['choices'][0]['message']['content'])
|
||||
except Exception as e: raise RuntimeError(f'模型返回无法解析为 JSON: {e}')
|
||||
except urllib.error.HTTPError as e:
|
||||
body=e.read().decode('utf-8','ignore')
|
||||
last=RuntimeError(f'模型接口 HTTP {e.code}: {body[:900]}')
|
||||
if e.code not in (400,408,409,429,500,502,503,504): raise last
|
||||
except ssl.SSLCertVerificationError as e:
|
||||
raise RuntimeError(f'SSL证书验证失败:{e}。V6.2 已使用 certifi CA;请检查本机 Python/代理证书链。')
|
||||
except Exception as e:
|
||||
last=RuntimeError(f'模型接口调用失败: {e}')
|
||||
if attempt<c['retries']:
|
||||
time.sleep(min(1.5*(attempt+1),4))
|
||||
raise last or RuntimeError('模型接口调用失败')
|
||||
+1362
File diff suppressed because it is too large
Load Diff
+3892
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"api_base": "https://api.bananarouter.com/v1",
|
||||
"api_key": "",
|
||||
"model": "gpt-5.5",
|
||||
"chat_path": "/chat/completions",
|
||||
"models_path": "/models",
|
||||
"temperature": 0.2,
|
||||
"timeout": 120,
|
||||
"retries": 2,
|
||||
"ssl_verify": true,
|
||||
"ca_bundle": "",
|
||||
"extra_headers": {}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
"""可复用的提示词模板注册表。
|
||||
|
||||
后端只负责准备 evidence/context,模板负责描述任务;接入其它项目时可直接
|
||||
替换模板或覆盖变量,不需要在业务代码里拼接提示词。
|
||||
"""
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
PRODUCT_VARIABLES = {
|
||||
"product": "商品基础信息:标题、商品ID、类目、品牌、价格、销量等页面字段",
|
||||
"attributes": "商品参数/规格:仅包含页面实际采集到的名称和值",
|
||||
"images": "图片证据:主图、详情图、SKU图、评论原图及其 evidenceId",
|
||||
"reviews": "消费者评论:原文、评分、SKU、日期和评论 evidenceId",
|
||||
"questions": "问大家/购买问答:问题、回答及 QA evidenceId",
|
||||
"programStats": "程序统计:评论数量、图片数量、参数数量等可计算指标",
|
||||
"evidence": "统一证据账本;模型只能引用其中的 evidenceId",
|
||||
}
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PromptTemplate:
|
||||
name: str
|
||||
body: str
|
||||
variables: tuple = ()
|
||||
|
||||
def render(self, **values):
|
||||
missing = [v for v in self.variables if v not in values]
|
||||
if missing:
|
||||
raise ValueError(f"模板 {self.name} 缺少变量: {', '.join(missing)}")
|
||||
return self.body.format(**values)
|
||||
|
||||
TEMPLATES = {
|
||||
"system": PromptTemplate("system", "{system}\n\n可用商品变量说明:\n{variable_schema}", ("system", "variable_schema")),
|
||||
"task": PromptTemplate("task", "{task}\n\n输入变量:{input_variables}", ("task", "input_variables")),
|
||||
}
|
||||
|
||||
def register_template(name, body, variables=()):
|
||||
"""覆盖或新增模板,供宿主项目在启动时注入行业提示词。"""
|
||||
TEMPLATES[name] = PromptTemplate(name, body, tuple(variables))
|
||||
|
||||
def render_template(name, **values):
|
||||
if name not in TEMPLATES:
|
||||
raise KeyError(f"未知提示词模板: {name}")
|
||||
return TEMPLATES[name].render(**values)
|
||||
|
||||
def variable_schema():
|
||||
return json.dumps(PRODUCT_VARIABLES, ensure_ascii=False, indent=2)
|
||||
|
||||
def build_system_prompt(system_text):
|
||||
override=Path(__file__).with_name('prompts.json')
|
||||
if override.exists():
|
||||
try:
|
||||
data=json.loads(override.read_text('utf-8'))
|
||||
if data.get('system',{}).get('body'): return data['system']['body'].format(system=system_text, variable_schema=variable_schema())
|
||||
except Exception: pass
|
||||
return TEMPLATES["system"].render(system=system_text, variable_schema=variable_schema())
|
||||
|
||||
def build_task_prompt(task_text, variables=("evidence",)):
|
||||
names = "、".join(f"{name}({PRODUCT_VARIABLES.get(name, '任务上下文变量')})" for name in variables)
|
||||
return TEMPLATES["task"].render(task=task_text, input_variables=names)
|
||||
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from analysis import ensure_experience_solution
|
||||
from renderer import render
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='从已有分析 JSON 重建完整事实报告,不调用模型 API。')
|
||||
parser.add_argument('input', type=Path)
|
||||
parser.add_argument('--output', type=Path)
|
||||
args = parser.parse_args()
|
||||
|
||||
source = args.input.resolve()
|
||||
analysis = json.loads(source.read_text('utf-8'))
|
||||
result = ensure_experience_solution(analysis)
|
||||
output = (args.output or source.with_name(source.stem + '_fixed.html')).resolve()
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.write_text(render(result, source.stem), 'utf-8')
|
||||
output.with_suffix('.json').write_text(json.dumps(result, ensure_ascii=False, indent=2), 'utf-8')
|
||||
print(output)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,686 @@
|
||||
import html
|
||||
import re
|
||||
from analysis import ensure_experience_solution
|
||||
|
||||
UNCERTAIN = ('可能', '或许', '推测', '无法判断', '不能判断', '证据不足', '尚不能', '不足以', '未知', '未识别', '不代表', '待验证')
|
||||
|
||||
def H(v): return html.escape('' if v is None else str(v))
|
||||
def arr(v): return v if isinstance(v, list) else []
|
||||
def safe(v): return '' if v in (None, '', [], {}) else (str(v) if not isinstance(v,(dict,list,tuple)) else '')
|
||||
|
||||
def text_of(v, keys=('text','point','statement','label','value','driver','problem','concern','signal','item')):
|
||||
if v in (None,'',[],{}): return ''
|
||||
if isinstance(v,str): return v
|
||||
if isinstance(v,(int,float)): return str(v)
|
||||
if isinstance(v,dict):
|
||||
for k in keys:
|
||||
if v.get(k) not in (None,'',[],{}): return text_of(v.get(k),keys)
|
||||
return ''
|
||||
return ''
|
||||
|
||||
def concise(v, limit=90, verified=True):
|
||||
text = re.sub(r'\s+', ' ', safe(v)).strip()
|
||||
if not text: return ''
|
||||
parts = [x.strip() for x in re.split(r'(?<=[。!?;])', text) if x.strip()]
|
||||
if verified:
|
||||
text = ''.join(x for x in parts if not any(word in x for word in UNCERTAIN))
|
||||
if not text: return ''
|
||||
if len(text) <= limit: return text
|
||||
cut = max(text.rfind(mark, 0, limit + 1) for mark in ',。;:')
|
||||
if cut < limit // 2: cut = limit
|
||||
return text[:cut].rstrip(',。;: ') + '…'
|
||||
|
||||
def evidence_index(a): return {x.get('id'): x for x in arr(a.get('evidenceLedger')) if isinstance(x, dict) and x.get('id')}
|
||||
def image_entry(eid, idx, allowed_groups=None):
|
||||
e = idx.get(eid) or {}; value = e.get('value')
|
||||
group=(e.get('meta') or {}).get('group')
|
||||
if e.get('type')!='image' or not isinstance(value,str) or not value.startswith(('http://','https://','data:image/')):return None
|
||||
if allowed_groups and group not in allowed_groups:return None
|
||||
return e
|
||||
def image_url(eid, idx, allowed_groups=None):
|
||||
e=image_entry(eid,idx,allowed_groups)
|
||||
if not e:return ''
|
||||
return (e.get('meta') or {}).get('localUrl') or e.get('value')
|
||||
|
||||
def image_source(e):
|
||||
if not isinstance(e,dict):return ('','')
|
||||
meta=e.get('meta') or {}; return (meta.get('localUrl') or e.get('value') or '', meta.get('sourceUrl') or e.get('value') or '')
|
||||
|
||||
def img_tag(url, alt='商品图片', fallback=''):
|
||||
if not url:return ''
|
||||
fb=fallback if fallback and fallback!=url else ''
|
||||
attrs=f' data-fallback="{H(fb)}"' if fb else ''
|
||||
return f'<img loading="eager" decoding="async" src="{H(url)}"{attrs} alt="{H(alt)}" onerror="if(this.dataset.fallback){{this.src=this.dataset.fallback;this.dataset.fallback=\'\';return;}}this.classList.add(\'image-error\');">'
|
||||
|
||||
def product_hero(visual, idx):
|
||||
for item in arr(visual.get('items')):
|
||||
if isinstance(item, dict):
|
||||
url = image_url(safe(item.get('imageEvidenceId')), idx, {'main'})
|
||||
if url: return url
|
||||
return ''
|
||||
|
||||
def action_list(text, limit=5):
|
||||
items = []
|
||||
for value in re.split(r'[;。]\s*', safe(text)):
|
||||
value = concise(value, 64)
|
||||
if value and value not in items: items.append(value)
|
||||
return '<ul class="action-list">' + ''.join(f'<li>{H(x)}</li>' for x in items[:limit]) + '</ul>' if items else ''
|
||||
|
||||
def owner_cards(cards):
|
||||
out = []
|
||||
for x in arr(cards)[:6]:
|
||||
if not isinstance(x, dict): continue
|
||||
headline = concise(x.get('headline'), 46)
|
||||
bullets = [concise(v, 42) for v in arr(x.get('bullets'))]
|
||||
bullets = [v for v in bullets if v][:2]
|
||||
if not headline: continue
|
||||
detail = '<ul>' + ''.join(f'<li>{H(v)}</li>' for v in bullets) + '</ul>' if bullets else ''
|
||||
out.append(f'<article class="owner-card {H(safe(x.get("tone")) or "normal")}"><span>{H(x.get("label"))}</span><strong>{H(headline)}</strong>{detail}</article>')
|
||||
return ''.join(out)
|
||||
|
||||
def overview_rows(items, kind):
|
||||
out = []
|
||||
for x in arr(items)[:6]:
|
||||
if not isinstance(x, dict): continue
|
||||
label = concise(x.get('label'), 24, False)
|
||||
value = concise(x.get('value'), 84, False)
|
||||
note = concise(x.get('note'), 42, False)
|
||||
priority = concise(x.get('priority'), 14, False)
|
||||
if not label or not value: continue
|
||||
meta = priority or note
|
||||
out.append(
|
||||
f'<article class="shopper-row {H(kind)}">'
|
||||
+ (f'<span class="shopper-meta">{H(meta)}</span>' if meta else '')
|
||||
+ f'<strong>{H(label)}</strong><p>{H(value)}</p></article>'
|
||||
)
|
||||
return ''.join(out)
|
||||
|
||||
def platform_overview_html(overview):
|
||||
facts = overview_rows(overview.get('facts'), 'fact')
|
||||
outcomes = overview_rows(arr(overview.get('outcomes'))[:3], 'reason')
|
||||
actions = overview_rows(overview.get('actions'), 'check')
|
||||
groups = ''.join([
|
||||
f'<section class="shopper-group"><h3>交易与产品事实</h3><div class="shopper-grid">{facts}</div></section>' if facts else '',
|
||||
f'<section class="shopper-group"><h3>购后体验验证</h3><div class="shopper-grid">{outcomes}</div></section>' if outcomes else '',
|
||||
f'<section class="shopper-group full"><h3>平台经营建议</h3><div class="shopper-grid">{actions}</div></section>' if actions else '',
|
||||
])
|
||||
verdict = concise(overview.get('verdict'), 70, False)
|
||||
sample = concise(overview.get('sampleNote'), 64, False)
|
||||
return '<div class="shopper-decision">' + (
|
||||
f'<header class="shopper-verdict"><span>产品分析结论</span><strong>{H(verdict)}</strong>'
|
||||
+ (f'<small>{H(sample)}</small>' if sample else '') + '</header>' if verdict else ''
|
||||
) + f'<div class="shopper-groups">{groups}</div></div>'
|
||||
|
||||
def decision_boundary_html(base):
|
||||
reviews = base.get('reviewCount', 0)
|
||||
public = safe(base.get('publicReviewCount'))
|
||||
coverage = f'已采有效评论 {reviews}' + (f' / 页面公开 {public}' if public else '')
|
||||
status = '评论采集已确认完整' if base.get('reviewCollectionComplete') else '评论采集未确认完整'
|
||||
items = [
|
||||
('证据覆盖', coverage, status),
|
||||
('本报告支持', '商品拆解、样品验证、规格校准、页面信息修正', '基于当前页面参数与已采评论'),
|
||||
('适用范围', '不输出生产规模、利润、转化或市场容量', '当前数据不含对应经营指标'),
|
||||
('补充数据', '完整评论、流量转化、退款售后、成本毛利', '补齐后再进入经营测算'),
|
||||
]
|
||||
return '<section class="decision-boundary">' + ''.join(
|
||||
f'<article><span>{H(label)}</span><strong>{H(value)}</strong><small>{H(note)}</small></article>'
|
||||
for label, value, note in items
|
||||
) + '</section>'
|
||||
|
||||
def review_results_html(overview):
|
||||
def rows(items, tone):
|
||||
out=[]
|
||||
for x in arr(items)[:6]:
|
||||
if not isinstance(x,dict):continue
|
||||
label=concise(x.get('label'),24,False);value=concise(x.get('value'),100,False);note=concise(x.get('note') or x.get('priority'),26,False)
|
||||
if not label or not value:continue
|
||||
out.append(f'<article class="result-row {H(tone)}">'+(f'<span>{H(note)}</span>' if note else '')+f'<strong>{H(label)}</strong><p>{H(value)}</p></article>')
|
||||
return ''.join(out)
|
||||
outcomes=rows(overview.get('outcomes'),'positive')
|
||||
risks=rows([x for x in arr(overview.get('checks')) if isinstance(x,dict) and x.get('priority')=='明确反馈'],'risk')
|
||||
questions=rows(overview.get('questions'),'question')
|
||||
return ''.join([
|
||||
f'<section class="result-block"><h3>评论确认的结果</h3><div class="result-grid">{outcomes}</div></section>' if outcomes else '',
|
||||
f'<section class="result-block"><h3>明确问题</h3><div class="result-grid">{risks}</div></section>' if risks else '',
|
||||
f'<section class="result-block"><h3>购买问答反馈</h3><div class="result-grid">{questions}</div></section>' if questions else '',
|
||||
])
|
||||
|
||||
def visual_items(items, idx):
|
||||
out = []
|
||||
for x in arr(items):
|
||||
if not isinstance(x, dict): continue
|
||||
eid=safe(x.get('imageEvidenceId'));entry=image_entry(eid,idx,{'main','detail'})
|
||||
if not entry: continue
|
||||
url=entry.get('value');group=(entry.get('meta') or {}).get('group');source=(entry.get('meta') or {}).get('source')
|
||||
source_label='商品主图画廊' if group=='main' else '商品详情描述区域'
|
||||
if source not in ('meta_og_image','dom_main_gallery','structured_main_gallery','dom_product_description','structured_product_description'):continue
|
||||
headline = concise(x.get('headline'), 42)
|
||||
frame = concise(x.get('frameInfo'), 90)
|
||||
subject = concise(x.get('productSubject'), 90)
|
||||
pattern = concise(x.get('patternColor'), 90)
|
||||
text_info = concise(x.get('textInfo'), 90)
|
||||
hierarchy = concise(x.get('informationHierarchy'), 90)
|
||||
task = concise(x.get('contentTask') or x.get('persuasionTask'), 72)
|
||||
weakness = concise(x.get('weakness'), 72)
|
||||
action = concise(x.get('borrowableAction') or x.get('action') or x.get('nextProductAction'), 82)
|
||||
facts = ''.join([
|
||||
f'<div><span>画面内容</span><b>{H(frame)}</b></div>' if frame else '',
|
||||
f'<div><span>商品主体</span><b>{H(subject)}</b></div>' if subject else '',
|
||||
f'<div><span>花型配色</span><b>{H(pattern)}</b></div>' if pattern else '',
|
||||
f'<div><span>文案信息</span><b>{H(text_info)}</b></div>' if text_info else '',
|
||||
f'<div><span>层级关系</span><b>{H(hierarchy)}</b></div>' if hierarchy else '',
|
||||
f'<div><span>表达任务</span><b>{H(task)}</b></div>' if task else '',
|
||||
f'<div><span>明确问题</span><b>{H(weakness)}</b></div>' if weakness else '',
|
||||
f'<div class="action"><span>可借鉴动作</span><b>{H(action)}</b></div>' if action else ''
|
||||
])
|
||||
out.append('<article class="story-item"><figure class="story-media">' + img_tag(url, '商品主图' if group=='main' else '商品详情图') + '</figure><div class="story-copy"><span class="source-badge">来源:' + H(source_label) + '</span><h3>' + H(headline or ('已核验主图' if group=='main' else '已核验详情图')) + '</h3><div class="evidence">' + facts + '</div></div></article>')
|
||||
return ''.join(out)
|
||||
|
||||
def buyer_evidence_html(block, idx):
|
||||
groups={}
|
||||
for item in arr((block or {}).get('items'))[:24]:
|
||||
if not isinstance(item,dict):continue
|
||||
image_id=safe(item.get('imageEvidenceId'));review_id=safe(item.get('reviewEvidenceId'))
|
||||
image=image_entry(image_id,idx,{'buyerShow'});review=idx.get(review_id) or {};meta=(image or {}).get('meta') or {}
|
||||
if not image or review.get('type')!='review' or review_id not in arr(meta.get('reviewEvidenceIds')):continue
|
||||
value=review.get('value') if isinstance(review.get('value'),dict) else {};quote=concise(value.get('content'),90,False)
|
||||
if not quote:continue
|
||||
details=' · '.join(x for x in (concise(value.get('sku'),42,False),concise(value.get('date'),18,False)) if x)
|
||||
group=groups.setdefault(review_id,{'quote':quote,'details':details,'images':[]})
|
||||
group['images'].append(image.get('value'))
|
||||
if not groups:return ''
|
||||
title=H((block or {}).get('title') or '评论原图与对应评价')
|
||||
image_count=sum(len(x['images']) for x in groups.values())
|
||||
out=[]
|
||||
for review_id,group in groups.items():
|
||||
gallery=''.join(
|
||||
'<figure>'+img_tag(url,f'{review_id} 评论原图 {i}')+f'<figcaption>原图 {i:02d}</figcaption></figure>'
|
||||
for i,url in enumerate(group['images'],1)
|
||||
)
|
||||
out.append('<article class="buyer-review-group"><div class="buyer-review-copy"><span>评论证据 · '+H(review_id)+'</span><blockquote>'+H(group['quote'])+'</blockquote>'+('<small>'+H(group['details'])+'</small>' if group['details'] else '')+'</div><div class="buyer-gallery">'+gallery+'</div></article>')
|
||||
return f'<section class="buyer-evidence"><header><span>评论图片证据</span><h3>{title} · {len(groups)} 条评价 / {image_count} 张原图</h3><p>每组图片只关联同一条评论,按原比例完整展示;不作为商品主图、详情图或 SKU 图使用。</p></header><div class="buyer-groups">'+''.join(out)+'</div></section>'
|
||||
|
||||
def stats_html(stats):
|
||||
out = []
|
||||
for x in arr(stats)[:6]:
|
||||
if isinstance(x, dict) and safe(x.get('value')):
|
||||
out.append(f'<div class="voice-stat"><span>{H(concise(x.get("label"), 16, False))}</span><strong>{H(concise(x.get("value"), 18, False))}</strong></div>')
|
||||
return ''.join(out)
|
||||
|
||||
def journey_html(stages):
|
||||
xs = [x for x in arr(stages) if isinstance(x, dict)]
|
||||
tabs, panels = [], []
|
||||
for i, x in enumerate(xs):
|
||||
positive = concise(x.get('positiveConfirmation'), 70); friction = concise(x.get('friction'), 70)
|
||||
if not positive and not friction: continue
|
||||
active = ' active' if not tabs else ''
|
||||
tabs.append(f'<button class="jtab{active}" data-i="{len(tabs)}"><small>{H(x.get("stageNo") or str(i + 1).zfill(2))}</small><b>{H(x.get("label"))}</b></button>')
|
||||
facts = (f'<section><span>已确认</span><p>{H(positive)}</p></section>' if positive else '') + (f'<section><span>明确问题</span><p>{H(friction)}</p></section>' if friction else '')
|
||||
quotes = []
|
||||
for q in arr(x.get('quotes'))[:2]:
|
||||
if isinstance(q, dict) and safe(q.get('text')): quotes.append(f'<li><span>{H(q.get("type"))}</span>{H(concise(q.get("text"), 90, False))}</li>')
|
||||
panels.append(f'<div class="jpanel{active}"><header><span>第 {H(x.get("stageNo") or i + 1)} 阶段</span><h4>{H(x.get("label"))}</h4><p>{H(concise(x.get("headline"), 54))}</p></header><div class="jgrid">{facts}</div>' + ('<ul class="quotes">' + ''.join(quotes) + '</ul>' if quotes else '') + '</div>')
|
||||
return '<div class="journey-tabs">' + ''.join(tabs) + '</div>' + ''.join(panels) if tabs else ''
|
||||
|
||||
def signals_html(signals):
|
||||
if not signals: return ''
|
||||
values = []
|
||||
for key, tone in [('positive', 'good'), ('questions', 'ask'), ('risks', 'risk')]:
|
||||
for x in arr(signals.get(key))[:5]:
|
||||
label = concise(x.get('label'), 42) if isinstance(x, dict) else ''
|
||||
if label: values.append(f'<span class="signal {tone}">{H(label)}</span>')
|
||||
return '<section class="signal-atlas"><span class="label">已确认体验信号</span><div class="signals">' + ''.join(values) + '</div></section>' if values else ''
|
||||
|
||||
def param_html(xs):
|
||||
out = []
|
||||
for x in arr(xs)[:8]:
|
||||
value = concise(x.get('value'), 82) if isinstance(x, dict) else ''
|
||||
if value: out.append(f'<div class="param"><span>{H(x.get("label"))}</span><b>{H(value)}</b></div>')
|
||||
return ''.join(out)
|
||||
|
||||
def gates_html(xs):
|
||||
out = []
|
||||
for x in arr(xs)[:6]:
|
||||
action = concise(x.get('action'), 92) if isinstance(x, dict) else ''
|
||||
if action: out.append(f'<div class="gate"><b>{H(x.get("priority"))} · {H(concise(x.get("label"), 24, False))}</b><span>{H(action)}</span></div>')
|
||||
return ''.join(out)
|
||||
|
||||
def plans_html(xs):
|
||||
out = []
|
||||
for x in arr(xs)[:3]:
|
||||
if not isinstance(x, dict): continue
|
||||
selling = selling_points_html(x.get('sellingPoints'))
|
||||
specs = []
|
||||
for spec in arr(x.get('specs')):
|
||||
value = concise(spec.get('value'), 82) if isinstance(spec, dict) else ''
|
||||
if value: specs.append(f'<div><span>{H(spec.get("label"))}</span><b>{H(value)}</b></div>')
|
||||
mg = x.get('manufacturingGate') or {}; vc = x.get('visualCommerce') or {}
|
||||
out.append('<article class="plan"><span>' + H(x.get('type')) + '</span><h3>' + H(concise(x.get('name'), 30, False)) + '</h3><p>' + H(concise(x.get('positioning'), 110)) + '</p>' + selling + '<div class="plan-specs">' + ''.join(specs) + '</div><div class="plan-actions"><section><h4>' + H(mg.get('title') or '制造闸门') + '</h4>' + action_list(mg.get('content')) + '</section><section><h4>' + H(vc.get('title') or '首图与商详') + '</h4>' + action_list(vc.get('content')) + '</section></div></article>')
|
||||
return ''.join(out)
|
||||
|
||||
def selling_points_html(xs):
|
||||
out=[]
|
||||
for x in arr(xs)[:5]:
|
||||
if not isinstance(x,dict):continue
|
||||
slogan=concise(x.get('slogan'),34,False)
|
||||
value=concise(x.get('consumerValue'),54,False)
|
||||
source=concise(x.get('evidenceSource'),32,False)
|
||||
action=concise(x.get('productAction'),64,False)
|
||||
if not slogan:continue
|
||||
out.append(f'<article><strong>{H(slogan)}</strong>'+(f'<p>{H(value)}</p>' if value else '')+(f'<small>{H(source)}</small>' if source else '')+(f'<b>{H(action)}</b>' if action else '')+'</article>')
|
||||
return '<section class="selling-points"><header><span>NEXT PRODUCT SELLING POINTS</span><h4>下一款明确卖点</h4></header><div>'+''.join(out)+'</div></section>' if out else ''
|
||||
|
||||
def governance_html(gov):
|
||||
if not isinstance(gov,dict):return ''
|
||||
items=[('报告角色',gov.get('role')),('结构标准',gov.get('structure')),('内容来源',gov.get('sourceRule')),('证据规则',gov.get('evidenceRule'))]
|
||||
rows=''.join(f'<article><span>{H(label)}</span><b>{H(concise(value,64,False))}</b></article>' for label,value in items if safe(value))
|
||||
return '<section class="governance"><div class="wrap"><div class="governance-grid">'+rows+'</div></div></section>' if rows else ''
|
||||
|
||||
def attr_lookup(raw, names):
|
||||
for x in arr(raw.get('attributes')):
|
||||
if not isinstance(x,dict):continue
|
||||
name=safe(x.get('name'))
|
||||
if any(k in name for k in names) and safe(x.get('value')):return safe(x.get('value'))
|
||||
return ''
|
||||
|
||||
def product_profile_html(raw, sales, base):
|
||||
rows=[
|
||||
('一级品类', safe((raw.get('product') or {}).get('category')) or '床上用品'),
|
||||
('商品标题', safe((raw.get('product') or {}).get('title'))),
|
||||
('价格定位', ('¥'+safe(sales.get('currentPrice'))) if safe(sales.get('currentPrice')) else ''),
|
||||
('公开销量', safe(sales.get('sold')) or safe(sales.get('cumulativeSales'))),
|
||||
('目标人群', attr_lookup(raw, ['适用人群','适用对象'])),
|
||||
('核心场景', attr_lookup(raw, ['适用场景','适用床尺寸','床品尺寸'])),
|
||||
('产品结构', attr_lookup(raw, ['件数','套件组成','组合形式','款式'])),
|
||||
('材质成分', attr_lookup(raw, ['成分含量','床单面料材质','被面材质','材质','棉种类'])),
|
||||
('支数密度', ' / '.join(x for x in [attr_lookup(raw,['面料支数','纱线支数']), attr_lookup(raw,['面料密度','织物密度'])] if x)),
|
||||
('工艺结构', attr_lookup(raw, ['床品工艺','织造工艺','印染工艺','工艺'])),
|
||||
('风格花型', ' / '.join(x for x in [attr_lookup(raw,['风格']), attr_lookup(raw,['主图案类型','颜色分类'])] if x)),
|
||||
('评论样本', f'{base.get("reviewCount",0)} 条有效评论'),
|
||||
]
|
||||
cells=''.join(f'<div class="profile-cell"><span>{H(k)}</span><b>{H(concise(v,78,False))}</b></div>' for k,v in rows if safe(v))
|
||||
return '<div class="profile-board">'+cells+'</div>' if cells else ''
|
||||
|
||||
def deal_logic_html(plans):
|
||||
points=[]
|
||||
for plan in arr(plans.get('plans'))[:1]:
|
||||
if isinstance(plan,dict):points=arr(plan.get('sellingPoints'))
|
||||
rows=[]
|
||||
for i,x in enumerate(points[:5],1):
|
||||
if not isinstance(x,dict):continue
|
||||
rows.append(f'<article class="logic-step"><span>{i:02d}</span><strong>{H(concise(x.get("slogan"),34,False))}</strong><p>{H(concise(x.get("consumerValue"),62,False))}</p><small>{H(concise(x.get("evidenceSource"),36,False))}</small></article>')
|
||||
return '<div class="logic-chain">'+''.join(rows)+'</div>' if rows else ''
|
||||
|
||||
def feature_power_html(overview, product_exp):
|
||||
cards=[]
|
||||
for x in arr(overview.get('cards')):
|
||||
if not isinstance(x,dict):continue
|
||||
label=safe(x.get('label')); headline=concise(x.get('headline'),42,False)
|
||||
if not headline or label in ('销售事实','页面活动'):continue
|
||||
tone=safe(x.get('tone')) or 'normal'
|
||||
cards.append(f'<article class="feature-card {H(tone)}"><span>{H(label)}</span><strong>{H(headline)}</strong></article>')
|
||||
for x in arr(product_exp.get('gates'))[:3]:
|
||||
if not isinstance(x,dict):continue
|
||||
cards.append(f'<article class="feature-card risk"><span>{H(x.get("priority"))}</span><strong>{H(concise(x.get("label"),34,False))}</strong><p>{H(concise(x.get("action"),64,False))}</p></article>')
|
||||
return '<div class="feature-grid">'+''.join(cards[:8])+'</div>' if cards else ''
|
||||
|
||||
def marketing_html(raw):
|
||||
product=raw.get('product') or {}; title=safe(product.get('title'))
|
||||
slots=[
|
||||
('品类词', '床品 / 三件套' if re.search(r'床品|三件套|四件套|被套|床单|床笠', title) else ''),
|
||||
('人群词', attr_lookup(raw,['适用人群']) or ('学生' if '学生' in title else '')),
|
||||
('场景词', attr_lookup(raw,['适用床尺寸','适用场景'])),
|
||||
('材质词', attr_lookup(raw,['床单面料材质','被面材质','成分含量','材质'])),
|
||||
('工艺词', attr_lookup(raw,['面料支数','面料密度','床品工艺','织造工艺'])),
|
||||
('风格词', attr_lookup(raw,['风格','主图案类型'])),
|
||||
]
|
||||
slot_html=''.join(f'<div class="market-slot"><span>{H(k)}</span><b>{H(concise(v,62,False))}</b></div>' for k,v in slots if safe(v))
|
||||
promos=''.join(f'<li>{H(concise(x.get("text"),58,False))}</li>' for x in arr(raw.get('promotions'))[:4] if isinstance(x,dict) and safe(x.get('text')))
|
||||
return '<div class="marketing-grid"><article><span>商品标题</span><strong>'+H(concise(title,90,False))+'</strong><div class="market-slots">'+slot_html+'</div></article><article><span>页面营销表达</span><ul>'+promos+'</ul></article></div>'
|
||||
|
||||
def visual_status_html(images, verified_visual):
|
||||
rows=[('主图',len(arr(images.get('main'))),bool(arr(images.get('main')))),('详情图',len(arr(images.get('detail'))),bool(arr(images.get('detail')))),('SKU 图',len(arr(images.get('sku'))),bool(arr(images.get('sku')))),('评论实拍',len(arr(images.get('buyerShow'))),bool(arr(images.get('buyerShow'))))]
|
||||
cards=''.join(f'<article class="visual-state {"ready" if ok else "missing"}"><span>{H(k)}</span><strong>{n}</strong><small>{"已采集" if ok else "未采集"}</small></article>' for k,n,ok in rows)
|
||||
body=verified_visual or '<p class="evidence-note">主图/详情图未形成可核验图片条目时,本章只展示来源状态,不用评论图片替代主图或详情图。</p>'
|
||||
return '<div class="visual-states">'+cards+'</div><div class="story">'+body+'</div>'
|
||||
|
||||
def competition_html(plans, product_exp, overview):
|
||||
point=''
|
||||
for plan in arr(plans.get('plans'))[:1]:
|
||||
if isinstance(plan,dict) and arr(plan.get('sellingPoints')):
|
||||
point=concise(plan['sellingPoints'][0].get('slogan'),42,False)
|
||||
risks=[concise(x.get('label'),34,False) for x in arr(product_exp.get('gates'))[:3] if isinstance(x,dict)]
|
||||
return '<div class="competition-grid"><article class="strong"><span>核心价值</span><strong>'+H(point or '已确认体验可复用')+'</strong><p>来自页面参数与评论聚合,不写市场推断。</p></article><article class="mid"><span>基础能力</span><strong>类目关键参数、规格与页面需要和真实商品一致</strong><p>基础能力必须被主图、详情和参数共同证明。</p></article><article class="weak"><span>风险点</span><strong>'+H(' / '.join(x for x in risks if x) or '按评论明确问题处理')+'</strong><p>风险只按真实评论和参数校验项呈现。</p></article></div>'
|
||||
|
||||
def validation_rows(xs):
|
||||
out = []
|
||||
for x in arr(xs)[:6]:
|
||||
if isinstance(x, dict) and any(safe(x.get(k)) for k in ('stage','metrics','successMeaning','nextAction')):
|
||||
stage=concise(x.get('stage'),18,False);owner=concise(x.get('owner'),24,False)
|
||||
if not owner:continue
|
||||
out.append(f'<tr><td>{H(stage)}</td><td>{H(owner)}</td><td>{H(concise(x.get("metrics"), 62))}</td><td>{H(concise(x.get("successMeaning"), 58))}</td><td>{H(concise(x.get("nextAction"), 62))}</td></tr>')
|
||||
return ''.join(out)
|
||||
|
||||
|
||||
# V9.2 图片驱动专业报告。分析可以深,默认报告必须轻。
|
||||
DISPLAY_MARKERS = ('[数据事实]','[分析判断]','[设计建议]','[证据不足]','数据事实:','分析判断:','设计建议:')
|
||||
|
||||
def clean_display(v, limit=120):
|
||||
t=text_of(v) if not isinstance(v,str) else v
|
||||
if not t:return ''
|
||||
t=re.sub(r'\*\*|__','',t)
|
||||
for m in DISPLAY_MARKERS:t=t.replace(m,'')
|
||||
t=re.sub(r'\s*/\s*',';',t)
|
||||
t=re.sub(r'\s+',' ',t).strip(' ;/')
|
||||
return concise(t,limit,False)
|
||||
|
||||
def first_text(*values, limit=100):
|
||||
for v in values:
|
||||
t=clean_display(v,limit)
|
||||
if t:return t
|
||||
return ''
|
||||
|
||||
def _entries(idx, group, limit=200):
|
||||
return [(eid,e) for eid,e in idx.items() if e.get('type')=='image' and (e.get('meta') or {}).get('group')==group][:limit]
|
||||
|
||||
def _visual_figure(e, alt, css=''):
|
||||
src,fb=image_source(e)
|
||||
if not src:return '<figure class="visual '+H(css)+' failed"><div class="image-fallback">图片未采集</div></figure>'
|
||||
return '<figure class="visual '+H(css)+'">'+img_tag(src,alt,fb)+'<div class="image-fallback">图片暂不可用</div></figure>'
|
||||
|
||||
def _section(no,title,deck,body,sid):
|
||||
return f'<section class="v92-section" id="{H(sid)}"><div class="wrap"><header class="v92-head"><span>{H(no)}</span><h2>{H(title)}</h2><p>{H(deck)}</p></header>{body}</div></section>'
|
||||
|
||||
def _opinion(text):
|
||||
text=clean_display(text,45)
|
||||
return f'<div class="v92-opinion v925-opinion"><strong>{H(text)}</strong></div>' if text else ''
|
||||
|
||||
def _chips(values, limit=3):
|
||||
vals=[]
|
||||
for v in arr(values)[:limit]:
|
||||
t=clean_display(v.get('value') if isinstance(v,dict) else v,10)
|
||||
if t and t not in vals: vals.append(t)
|
||||
return '<div class="v925-signals">'+H(' · '.join(vals))+'</div>' if vals else ''
|
||||
|
||||
def _owner_overview(exp, raw, base, sales):
|
||||
src=arr((exp.get('ownerOverview') or {}).get('cards'))
|
||||
wanted=['为什么能卖','值得继承','下一款优先']
|
||||
mapped={k:None for k in wanted}
|
||||
for x in src:
|
||||
if not isinstance(x,dict): continue
|
||||
label=clean_display(x.get('label'),16); headline=clean_display(x.get('headline'),20); note=clean_display(x.get('note'),22)
|
||||
if not headline: continue
|
||||
for k in wanted:
|
||||
if k in label and mapped[k] is None: mapped[k]=(headline,note)
|
||||
# Compatibility fallback from old labels.
|
||||
legacy={'为什么能卖':['商品定位','场景价值'],'值得继承':['主要产品资产','产品价值'],'下一款优先':['下一款优先方向','可强化方向']}
|
||||
for x in src:
|
||||
if not isinstance(x,dict): continue
|
||||
label=clean_display(x.get('label'),18); headline=clean_display(x.get('headline'),20)
|
||||
for target,keys in legacy.items():
|
||||
if mapped[target] is None and any(k in label for k in keys) and headline: mapped[target]=(headline,'')
|
||||
pd=arr((exp.get('productValue') or {}).get('items'))
|
||||
for x in pd:
|
||||
if not isinstance(x,dict): continue
|
||||
typ=clean_display(x.get('type'),16); val=first_text(x.get('value'),limit=30)
|
||||
if typ=='场景价值' and mapped['为什么能卖'] is None: mapped['为什么能卖']=(val,'')
|
||||
elif typ=='产品价值' and mapped['值得继承'] is None: mapped['值得继承']=(val,'')
|
||||
elif typ=='可强化方向' and mapped['下一款优先'] is None: mapped['下一款优先']=(val,'')
|
||||
title=clean_display((raw.get('product') or {}).get('title'),36)
|
||||
mapped['为什么能卖']=mapped['为什么能卖'] or (title or '当前商品场景与产品组合', '')
|
||||
mapped['值得继承']=mapped['值得继承'] or (f'已确认 {base.get("attributeCount",0)} 项产品参数', '')
|
||||
mapped['下一款优先']=mapped['下一款优先'] or ('规格更清楚 · 品质更可证 · 图片更好选', '')
|
||||
cards=[]
|
||||
for k in wanted:
|
||||
headline,note=mapped[k]
|
||||
cards.append(f'<article><span>{H(k)}</span><b>{H(headline)}</b>'+(f'<p>{H(note)}</p>' if note else '')+'</article>')
|
||||
return '<div class="v92-summary3 v924-decision3">'+''.join(cards)+'</div>'
|
||||
|
||||
def _overview(exp, raw, base, sales):
|
||||
cards=[]
|
||||
source=arr((exp.get('ownerOverview') or {}).get('cards'))
|
||||
preferred={'商品定位':None,'主要产品资产':None,'下一款优先方向':None}
|
||||
for x in source:
|
||||
if not isinstance(x,dict):continue
|
||||
label=clean_display(x.get('label'),18);headline=clean_display(x.get('headline'),42)
|
||||
if not headline:continue
|
||||
low=label+headline
|
||||
if preferred['商品定位'] is None and any(k in low for k in ('定位','场景','人群','商品定义')):preferred['商品定位']=headline
|
||||
elif preferred['主要产品资产'] is None and any(k in low for k in ('产品','材质','资产','体验','参数')):preferred['主要产品资产']=headline
|
||||
elif preferred['下一款优先方向'] is None and any(k in low for k in ('下一款','动作','优化','调整')):preferred['下一款优先方向']=headline
|
||||
pd=(exp.get('productValue') or {}).get('items') or []
|
||||
for x in pd:
|
||||
if not isinstance(x,dict):continue
|
||||
typ=clean_display(x.get('type'),16);val=clean_display(x.get('value'),42)
|
||||
if typ=='场景价值' and not preferred['商品定位']:preferred['商品定位']=val
|
||||
if typ=='产品价值' and not preferred['主要产品资产']:preferred['主要产品资产']=val
|
||||
if typ=='可强化方向' and not preferred['下一款优先方向']:preferred['下一款优先方向']=val
|
||||
title=clean_display((raw.get('product') or {}).get('title'),60)
|
||||
attrs=arr(raw.get('attributes'))
|
||||
attr_text=' / '.join(clean_display(x.get('value'),30) for x in attrs[:6] if isinstance(x,dict) and clean_display(x.get('value'),30))
|
||||
preferred['商品定位']=preferred['商品定位'] or title or '当前商品定位以页面事实为准'
|
||||
preferred['主要产品资产']=preferred['主要产品资产'] or clean_display(attr_text,70) or f'已采 {base.get("attributeCount",0)} 项商品参数'
|
||||
preferred['下一款优先方向']=preferred['下一款优先方向'] or '优先把规格、图片表达和商品页面口径统一'
|
||||
labels=[('商品定位',preferred['商品定位'],'先看这款商品解决什么场景'),('主要产品资产',preferred['主要产品资产'],'只保留当前证据可以确认的资产'),('下一款优先方向',preferred['下一款优先方向'],'把分析转成下一款动作')]
|
||||
return '<div class="v92-summary3">'+''.join(f'<article><span>{H(l)}</span><b>{H(v)}</b><p>{H(n)}</p></article>' for l,v,n in labels)+'</div>'
|
||||
|
||||
def _title_v92(raw, exp, a):
|
||||
block=exp.get('titleAnalysis') or (a.get('merchandisingDecision') or {}).get('titleAnalysis') or {}
|
||||
title=clean_display(block.get('originalTitle') or (raw.get('product') or {}).get('title'),130)
|
||||
opinion=first_text(block.get('professionalOpinion'),block.get('thesis'),limit=58) or '标题优先统一场景、规格与关键品质参数。'
|
||||
current=[]
|
||||
for x in arr(block.get('currentExpression'))[:6]:
|
||||
if isinstance(x,dict): t=first_text(x.get('value'),x.get('text'),limit=10)
|
||||
else: t=clean_display(x,14)
|
||||
if t and t not in current: current.append(t)
|
||||
if not current:
|
||||
for kw in ('大学生宿舍','学生宿舍','六件套','七件套','全棉','纯棉','40支','四季'):
|
||||
if kw in title: current.append(kw)
|
||||
reinforce=[]
|
||||
for x in arr(block.get('reinforce'))[:5]:
|
||||
t=first_text(x.get('topic') if isinstance(x,dict) else x,limit=14)
|
||||
if t and t not in reinforce: reinforce.append(t)
|
||||
actions=[first_text(x.get('action') if isinstance(x,dict) else x,limit=28) for x in arr(block.get('recommendedActions'))[:2]]
|
||||
if actions and not reinforce: reinforce=actions
|
||||
return _opinion(opinion)+f'<div class="v92-title-current"><small>当前标题</small>{H(title)}</div><div class="v924-title2"><article><span>已表达</span><b>{H(" · ".join(current[:5]) or "按当前标题识别")}</b></article><article><span>建议强化</span><b>{H(" · ".join(reinforce[:5]) or (actions[0] if actions else "规格统一 · 品质参数"))}</b></article></div>'
|
||||
|
||||
def _main_v92(exp,a,idx):
|
||||
block=exp.get('visualCommerce') or {}; vd=a.get('visualDecision') or {}
|
||||
opinion=first_text(block.get('professionalOpinion'),(vd.get('visualThesis') or {}).get('professionalOpinion'),limit=58) or '主图优先让用户看懂商品信息,再决定是否展开详细分析。'
|
||||
items=arr(block.get('items')) or arr(vd.get('imageRoles')) or arr(vd.get('mainImageContent'))
|
||||
byid={safe(x.get('imageEvidenceId')):x for x in items if isinstance(x,dict)}
|
||||
cards=[]
|
||||
for n,(eid,e) in enumerate(_entries(idx,'main',5),1):
|
||||
x=byid.get(eid,{})
|
||||
role=first_text(x.get('imageRole'),x.get('role'),x.get('contentTask'),limit=8) or f'主图{n}'
|
||||
signals=arr(x.get('visualSignals'))
|
||||
if not signals:
|
||||
# Compatibility: old message becomes one compact signal, never a paragraph.
|
||||
msg=first_text(x.get('message'),x.get('observation'),x.get('productSubject'),limit=14)
|
||||
if msg: signals=[msg]
|
||||
action=first_text(x.get('nextAction'),x.get('professionalAdvice'),x.get('nextProductAction'),x.get('action'),limit=18)
|
||||
copy=f'<div class="v925-image-info"><div class="v925-row-title"><h3>{H(role)}</h3><span>主图 {n:02d}</span></div>{_chips(signals)}'+(f'<p class="v925-action">专业动作 · {H(action)}</p>' if action else '')+'</div>'
|
||||
cards.append('<article class="v92-main-card v925-main-card">'+_visual_figure(e,'商品主图 '+str(n),'main-ratio')+copy+'</article>')
|
||||
continuity=_continuity_v92(exp,a)
|
||||
folded=f'<details class="v924-details"><summary>查看主图 × 详情 × 评论承接关系</summary>{continuity}</details>' if continuity else ''
|
||||
return _opinion(opinion)+'<div class="v92-main-grid">'+''.join(cards)+'</div>'+folded
|
||||
|
||||
def _status_label(kind,value):
|
||||
v=safe(value)
|
||||
maps={
|
||||
'main':{'expressed':('已表达','ok'),'partial':('部分表达','mid'),'missing':('未覆盖','none')},
|
||||
'detail':{'proven':('已证明','ok'),'partial':('部分证明','mid'),'missing':('未覆盖','none')},
|
||||
'review':{'validated':('有验证','ok'),'partial':('部分验证','mid'),'missing':('暂无验证','none')},
|
||||
}
|
||||
if v in maps.get(kind,{}):return maps[kind][v]
|
||||
low=v.lower()
|
||||
if any(k in low for k in ('closed','已表达','已证明','有反馈','已解释','有验证')):return ('已覆盖','ok')
|
||||
if any(k in low for k in ('partial','有限','部分','pending','待')):return ('部分','mid')
|
||||
if not v:return ('—','none')
|
||||
return (clean_display(v,18),'mid')
|
||||
|
||||
def _continuity_v92(exp,a):
|
||||
block=exp.get('expressionContinuity') or {};checks=arr(block.get('coreClaims')) or arr((a.get('detailDecision') or {}).get('continuityChecks'))
|
||||
rows=[]
|
||||
for x in checks[:6]:
|
||||
if not isinstance(x,dict):continue
|
||||
claim=clean_display(x.get('claim'),30)
|
||||
if not claim:continue
|
||||
ml,mc=_status_label('main',x.get('mainStatus') or x.get('mainImageRole') or ('expressed' if x.get('mainImageEvidenceIds') else ''))
|
||||
dl,dc=_status_label('detail',x.get('detailStatus') or x.get('detailProof') or ('proven' if x.get('detailEvidenceIds') else ''))
|
||||
rl,rc=_status_label('review',x.get('reviewStatus') or x.get('reviewValidation'))
|
||||
adv=first_text(x.get('professionalAdvice'),x.get('nextProductAction'),x.get('gap'),limit=45)
|
||||
rows.append(f'<tr><td><b>{H(claim)}</b></td><td class="{mc}">{H(ml)}</td><td class="{dc}">{H(dl)}</td><td class="{rc}">{H(rl)}</td><td>{H(adv or "继续保持页面口径一致")}</td></tr>')
|
||||
if not rows:return ''
|
||||
return '<div class="v92-table-wrap"><table class="v92-matrix"><thead><tr><th>核心表达</th><th>主图</th><th>详情</th><th>评论</th><th>动作</th></tr></thead><tbody>'+''.join(rows)+'</tbody></table></div>'
|
||||
|
||||
def _detail_v92(exp,a,idx):
|
||||
block=exp.get('detailCommerce') or {}; dd=a.get('detailDecision') or {}
|
||||
opinion=first_text(block.get('professionalOpinion'),(dd.get('detailThesis') or {}).get('professionalOpinion'),limit=58) or '详情页优先展示能证明商品价值的代表图。'
|
||||
groups=arr(block.get('contentGroups')) or arr(dd.get('contentGroups'))
|
||||
entries=dict(_entries(idx,'detail',30)); cards=[]
|
||||
for i,x in enumerate(groups[:6],1):
|
||||
if not isinstance(x,dict): continue
|
||||
eid=safe(x.get('representativeImageId') or x.get('imageEvidenceId')); e=entries.get(eid)
|
||||
if not e and entries: e=list(entries.values())[min(i-1,len(entries)-1)]
|
||||
if not e: continue
|
||||
typ=first_text(x.get('type'),x.get('stage'),limit=10) or '详情信息'
|
||||
signals=arr(x.get('visualSignals'))
|
||||
if not signals:
|
||||
msg=first_text(x.get('message'),x.get('businessMeaning'),limit=14)
|
||||
if msg: signals=[msg]
|
||||
action=first_text(x.get('nextAction'),x.get('professionalAdvice'),x.get('nextPageAction'),limit=24)
|
||||
cards.append('<article class="v925-detail-block">'+_visual_figure(e,typ,'wide-ratio')+f'<div class="v925-detail-copy"><h3>{H(typ)}</h3>{_chips(signals)}'+(f'<p class="v925-action">专业动作 · {H(action)}</p>' if action else '')+'</div></article>')
|
||||
collected=len(entries)
|
||||
note=f'<div class="v92-evidence-note">详情图 {collected} 张 · 默认展示 {len(cards)} 个信息任务代表图;其余图片保留为证据。</div>'
|
||||
return _opinion(opinion)+('<div class="v925-detail-list">'+''.join(cards)+'</div>' if cards else '<div class="v92-empty">详情图片已采集,当前未形成可靠分组。</div>')+note
|
||||
|
||||
def _sku_dimensions(raw):
|
||||
product=raw.get('product') or {};title=safe(product.get('title'));attrs=arr(raw.get('attributes'));sku=arr(raw.get('sku'))
|
||||
dims=[]
|
||||
def add(name,vals,source=''):
|
||||
vals=[clean_display(x,24) for x in vals if clean_display(x,24)]
|
||||
if vals and name not in [d[0] for d in dims]:dims.append((name,vals[:5]))
|
||||
if '床单' in title or '床笠' in title:add('款式',[x for x in ('床单','床笠') if x in title])
|
||||
for x in attrs:
|
||||
if not isinstance(x,dict):continue
|
||||
n=safe(x.get('name'));v=safe(x.get('value'))
|
||||
if any(k in n for k in ('尺寸','床尺寸','规格')) and v:add('尺寸',[v])
|
||||
elif any(k in n for k in ('颜色','花色','图案','款式')) and v:add('花色/款式',[v])
|
||||
elif any(k in n for k in ('件数','套件','组合')) and v:add('套件',[v])
|
||||
if sku:
|
||||
names=[]
|
||||
for x in sku[:20]:
|
||||
if isinstance(x,dict):
|
||||
val=first_text(x.get('name'),x.get('title'),x.get('value'),x.get('properties'),limit=40)
|
||||
if val:names.append(val)
|
||||
if names:add('SKU规格',names)
|
||||
return dims[:4]
|
||||
|
||||
def _sku_v92(exp,a,raw,idx):
|
||||
block=exp.get('skuAnalysis') or a.get('skuVisualAnalysis') or {}
|
||||
entries=_entries(idx,'sku',30); structured=arr(raw.get('sku'))
|
||||
opinion=first_text(block.get('professionalOpinion'),limit=90)
|
||||
dims=[]
|
||||
for x in arr(block.get('selectionDimensions')):
|
||||
if not isinstance(x,dict):continue
|
||||
name=clean_display(x.get('name'),18);values=[clean_display(v,24) for v in arr(x.get('values')) if clean_display(v,24)]
|
||||
if name:dims.append((name,values))
|
||||
if not dims:dims=_sku_dimensions(raw)
|
||||
order=[clean_display(x,18) for x in arr(block.get('recommendedOrder')) if clean_display(x,18)] or [x[0] for x in dims]
|
||||
if not opinion:
|
||||
opinion='当前SKU更适合按清晰选择层级组织:先解决规格是否适配,再处理组合和外观偏好。'
|
||||
mosaic=''.join(_visual_figure(e,'SKU '+str(i+1),'square-ratio') for i,(eid,e) in enumerate(entries[:8]))
|
||||
steps=''.join(f'<div class="v92-choice-row"><span>{i:02d}</span><b>{H(name)}</b>'+(f'<small>{H(" / ".join(vals[:3]))}</small>' if vals else '')+'</div>' for i,(name,vals) in enumerate(dims or [(x,[]) for x in order],1))
|
||||
advice=first_text(block.get('recommendedAction'),limit=55) or ('建议按“'+' → '.join(order)+'”分层选择。' if order else '建议先建立规格层级,再展示外观选择。')
|
||||
status='结构化SKU '+(str(len(structured))+' 个' if structured else '未完整采集')+f' · SKU图片 {len(entries)} 张'
|
||||
return _opinion(opinion)+f'<div class="v92-sku-layout"><div class="v92-sku-mosaic">{mosaic}</div><div class="v92-choice">{steps}<div class="v92-choice-note">{H(advice)}</div><small class="v92-status">{H(status)};SKU图片数量不等于SKU数量。</small></div></div>'
|
||||
|
||||
def _consumer_v92(exp,a,idx):
|
||||
block=exp.get('customerExperience') or {}; cr=a.get('consumerResearch') or {}; ci=block.get('consumerInsights') or {}
|
||||
opinion=first_text(block.get('professionalOpinion'),(cr.get('consumerThesis') or {}).get('statement'),limit=58) or '消费者反馈只保留可指导下一款的真实体验信号。'
|
||||
signals=arr(ci.get('experienceSignals'))
|
||||
if not signals:
|
||||
mapping=[('稳定体验',cr.get('satisfactionDrivers')),('体验差异',cr.get('expectationGaps')),('购前关注',cr.get('prePurchaseConcerns'))]
|
||||
for typ,values in mapping:
|
||||
for x in arr(values)[:1]: signals.append({'type':typ,'insight':text_of(x)})
|
||||
buyer=_entries(idx,'buyerShow',12)
|
||||
gallery=''.join(_visual_figure(e,'买家秀 '+str(i+1),'square-ratio') for i,(eid,e) in enumerate(buyer[:4]))
|
||||
rows=[]
|
||||
for x in signals[:4]:
|
||||
if not isinstance(x,dict): continue
|
||||
typ=clean_display(x.get('type'),16) or '消费者信号'; ins=first_text(x.get('insight'),x.get('statement'),x.get('driver'),x.get('problem'),limit=38)
|
||||
if ins: rows.append(f'<article><span>{H(typ)}</span><b>{H(ins)}</b></article>')
|
||||
gallery_html=gallery or '<div class="v92-empty">未采集买家秀图片</div>'
|
||||
rows_html=''.join(rows) or '<div class="v92-empty">当前评论尚未形成可靠聚合洞察</div>'
|
||||
return _opinion(opinion)+f'<div class="v92-review-layout"><div class="v92-buyer-grid">{gallery_html}</div><div class="v92-insights">{rows_html}</div></div>'
|
||||
|
||||
def _value_v92(exp,a):
|
||||
block=exp.get('productValue') or {};items=arr(block.get('items'))
|
||||
if not items:
|
||||
pd=a.get('productDecision') or {};pillars=arr(pd.get('valuePillars'))
|
||||
for x in pillars[:3]:
|
||||
if isinstance(x,dict):items.append({'type':clean_display(x.get('name'),18) or '产品价值','value':first_text(x.get('customerValue'),x.get('businessMeaning'),limit=40),'explanation':first_text(x.get('mustKeep'),limit=55)})
|
||||
if not items:
|
||||
items=[{'type':'场景价值','value':'当前商品使用场景','explanation':'以标题、参数与图片证据为准'},{'type':'产品价值','value':'当前商品核心配置','explanation':'只保留页面已确认参数'},{'type':'服务价值','value':'页面已展示服务','explanation':'不推断未采集服务'},{'type':'可强化方向','value':'规格与证明表达','explanation':'优先保证图片、参数、SKU口径一致'}]
|
||||
cards=[]
|
||||
for x in items[:4]:
|
||||
if not isinstance(x,dict):continue
|
||||
typ=clean_display(x.get('type'),18);val=first_text(x.get('value'),x.get('customerValue'),limit=42);desc=first_text(x.get('explanation'),x.get('commercialValue'),x.get('mustKeep'),limit=58)
|
||||
if val:cards.append(f'<article><span>{H(typ)}</span><b>{H(val)}</b>'+(f'<p>{H(desc)}</p>' if desc else '')+'</article>')
|
||||
return _opinion(block.get('professionalOpinion'))+'<div class="v92-value-grid">'+''.join(cards)+'</div>'
|
||||
|
||||
def _plan_action_list(values, limit=3):
|
||||
out=[]
|
||||
for x in arr(values)[:limit]:
|
||||
t=first_text(x.get('action') if isinstance(x,dict) else x,x.get('item') if isinstance(x,dict) else '',limit=55)
|
||||
if t:out.append(t)
|
||||
return out
|
||||
|
||||
def _plans_v92(exp,a,idx):
|
||||
block=exp.get('newProductPlans') or {}; source=arr(block.get('plans')) or arr((a.get('launchPlans') or {}).get('plans'))
|
||||
opinion=first_text(block.get('professionalOpinion'),(a.get('launchPlans') or {}).get('professionalOpinion'),limit=58) or '优先从证据最强的优化方向开始,再看反馈升级与探索方向。'
|
||||
cards=[]
|
||||
for i,x in enumerate(source[:3],1):
|
||||
if not isinstance(x,dict): continue
|
||||
source_type=first_text(x.get('sourceType'),limit=10)
|
||||
typ=first_text(x.get('type'),limit=14)
|
||||
if not source_type:
|
||||
source_type='证据驱动优化' if i==1 else ('反馈驱动升级' if i==2 else '探索性方向')
|
||||
name=first_text(x.get('name'),limit=22) or ('新品方向 '+str(i))
|
||||
why=first_text(x.get('whyThisPlan'),x.get('positioning'),limit=48)
|
||||
product=first_text(x.get('productAction'),limit=38)
|
||||
if not product:
|
||||
acts=_plan_action_list(x.get('productChanges'),1) or _plan_action_list(x.get('mustChange'),1); product=acts[0] if acts else ''
|
||||
page=first_text(x.get('pageAction'),limit=38)
|
||||
if not page:
|
||||
main=_plan_action_list(x.get('mainImagePlan'),1); detail=_plan_action_list(x.get('detailPagePlan'),1); page=';'.join((main+detail)[:2])
|
||||
refs=arr(x.get('visualReferenceImageIds')); ref_e=None
|
||||
for eid in refs:
|
||||
if eid in idx and (idx[eid].get('meta') or {}).get('group') in ('main','detail','sku'): ref_e=idx[eid]; break
|
||||
if not ref_e:
|
||||
mains=_entries(idx,'main',5); ref_e=mains[min(i-1,len(mains)-1)][1] if mains else None
|
||||
visual=_visual_figure(ref_e,'方案参考图','plan-ratio') if ref_e else '<div class="v92-plan-placeholder">当前商品视觉参考</div>'
|
||||
body=f'<div class="v925-plan-body"><div class="v925-plan-eyebrow">方案 {chr(64+i)} · {H(source_type)}</div><h3>{H(name)}</h3><dl>'+(f'<div><dt>产品</dt><dd>{H(product)}</dd></div>' if product else '')+(f'<div><dt>页面</dt><dd>{H(page)}</dd></div>' if page else '')+'</dl></div>'
|
||||
cards.append(f'<article class="v925-plan-card">{visual}{body}</article>')
|
||||
if not cards: return _opinion(opinion)+'<div class="v92-empty">当前证据未形成可追溯新品方案。</div>'
|
||||
return _opinion(opinion)+'<div class="v925-plan-grid">'+''.join(cards)+'</div>'
|
||||
|
||||
def render(a, task_id):
|
||||
a=ensure_experience_solution(a)
|
||||
if not (a.get('meta') or {}).get('reportReady'): raise ValueError((a.get('meta') or {}).get('reportBlockedReason') or '本次没有可输出的证据结论')
|
||||
raw=a.get('facts') or {}; product=raw.get('product') or {}; sales=raw.get('sales') or {}; base=a.get('baseline') or {}; exp=a.get('experienceSolution') or {}; idx=evidence_index(a)
|
||||
summary=exp.get('reportSummary') or {}
|
||||
title=clean_display(summary.get('title') or product.get('title') or '商品分析报告',60)
|
||||
verdict=first_text(summary.get('verdict'),limit=58) or '基于当前商品页面、图片、参数与评论证据形成开品建议。'
|
||||
cover_id=safe(summary.get('coverImageId')); cover_e=idx.get(cover_id) if cover_id else None
|
||||
if not cover_e:
|
||||
mains=_entries(idx,'main',1); cover_e=mains[0][1] if mains else None
|
||||
price='¥'+safe(sales.get('currentPrice')) if safe(sales.get('currentPrice')) else '价格未采集'
|
||||
sold=safe(sales.get('sold') or sales.get('cumulativeSales')) or '销量未采集'
|
||||
meta_line=f'{price} · 公开销量 {sold} · 评论 {base.get("reviewCount",0)} · SKU图 {len(_entries(idx,"sku",999))} · 参数 {base.get("attributeCount",0)}'
|
||||
nav='<nav class="v92-nav"><div class="wrap"><div class="brand">三笙 · 商品开品分析</div><div class="v92-links"><a href="#decision">老板速览</a><a href="#plan">新品方向</a><a href="#title">标题</a><a href="#main">主图</a><a href="#detail">详情</a><a href="#sku">SKU</a><a href="#review">评价</a></div></div></nav>'
|
||||
cover=_visual_figure(cover_e,'商品封面图','cover-ratio') if cover_e else '<div class="v92-cover-empty">主图未采集</div>'
|
||||
hero=f'<header class="v92-hero"><div class="wrap v92-hero-grid"><div><div class="v92-kicker">PRODUCT DECISION REPORT · BIG VISUAL · V9.2.5</div><h1>{H(title)}</h1><p class="v92-meta">{H(meta_line)}</p><div class="v925-hero-claim">{H(clean_display(verdict,45))}</div></div><div class="v92-cover">{cover}</div></div></header>'
|
||||
doc='<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>'+H(title)+'</title><link rel="stylesheet" href="/report.css"></head><body>'+nav+hero+_section('01','老板 30 秒看懂','只看结果,不看分析过程',_owner_overview(exp,raw,base,sales),'decision')+_section('02','下一款先做什么','先给决策,再看证据',_plans_v92(exp,a,idx),'plan')+_section('03','标题只解决一个问题:说清楚','保留表达与动作',_title_v92(raw,exp,a),'title')+_section('04','主图到底在卖什么','图片本身就是成交语言',_main_v92(exp,a,idx),'main')+_section('05','详情页证明了什么','大图展示证明内容',_detail_v92(exp,a,idx),'detail')+_section('06','先解决适配,再解决审美','SKU看选择路径',_sku_v92(exp,a,raw,idx),'sku')+_section('07','消费者真正感受到什么','买家秀是证据',_consumer_v92(exp,a,idx),'review')+f'<footer class="v92-footer"><div class="wrap">Task {H(task_id)} · 三笙商品开品分析报告引擎 V9.2.5 · 未采集经营指标不作推断</div></footer><script>document.addEventListener("error",e=>{{if(e.target&&e.target.tagName==="IMG"){{const f=e.target.closest("figure.visual");if(f)f.classList.add("failed");e.target.style.display="none";}}}},true);</script></body></html>'
|
||||
return doc
|
||||
@@ -0,0 +1,303 @@
|
||||
|
||||
:root{
|
||||
--paper:#fff;--paper2:#f5f2ee;--ink:#171614;--muted:#6b6760;
|
||||
--line:#ddd8d1;--dark:#181715;--dark2:#24211d;--cream:#ebe4db;
|
||||
--good:#3f6f58;--risk:#9a4d43;--ask:#9a7a4f;--accent:#b88b5b
|
||||
}
|
||||
*{box-sizing:border-box}html{scroll-behavior:smooth}
|
||||
body{margin:0;background:var(--paper2);color:var(--ink);font-family:-apple-system,BlinkMacSystemFont,"SF Pro Display","PingFang SC","Helvetica Neue",Arial,sans-serif}
|
||||
.wrap{width:min(1380px,calc(100% - 72px));margin:auto}
|
||||
nav{height:64px;position:sticky;top:0;z-index:40;background:rgba(255,255,255,.95);backdrop-filter:blur(14px);border-bottom:1px solid var(--line)}
|
||||
.navin{height:100%;display:flex;align-items:center;justify-content:space-between}.brand{font-size:13px;font-weight:800}
|
||||
.navlinks{display:flex;gap:5px}.navlinks a{text-decoration:none;font-size:11px;color:#6f685f;padding:8px 10px;border-radius:999px}.navlinks a:hover{background:#201e1b;color:#fff}
|
||||
.hero{min-height:0;background:linear-gradient(180deg,#fff 0%,#f8f4ef 100%);padding:72px 0 60px}
|
||||
.hero-grid{display:grid;grid-template-columns:1fr .72fr;gap:58px;align-items:center}
|
||||
.hero-grid.text-only{grid-template-columns:minmax(0,1100px)}
|
||||
.kicker{font-size:11px;letter-spacing:0;color:#938980;margin-bottom:16px}
|
||||
h1{margin:0;font-size:56px;line-height:1.06;letter-spacing:0;font-weight:720}
|
||||
.hero-copy{font-size:19px;line-height:1.78;color:#625c55;max-width:900px;margin:28px 0 0}
|
||||
.hero-copy-block h1{max-width:840px}.fact-line{font-size:13px;line-height:1.6;color:#6b645d;margin:24px 0 0}.hero-verdict{margin-top:34px;padding:20px 0;border-top:1px solid var(--line);border-bottom:1px solid var(--line)}.hero-verdict span,.hero-actions>span{display:block;font-size:10px;color:#918981;margin-bottom:8px}.hero-verdict strong{display:block;font-size:25px;line-height:1.35}.hero-actions{margin-top:22px}.hero-actions .gate{padding:9px 0;grid-template-columns:155px 1fr}.hero-actions .gate b{font-size:12px}.hero-actions .gate span{font-size:13px;line-height:1.5}
|
||||
.hero-card{background:#f5f7f6;border:1px solid var(--line);padding:27px}
|
||||
.hero-card span{font-size:10px;color:#8e857c}.hero-card strong{display:block;font-size:35px;line-height:1.22;margin:10px 0}
|
||||
.hero-card p{font-size:14px;line-height:1.72;color:#68615a;margin:0}
|
||||
.hero-kpis{display:grid;grid-template-columns:repeat(2,1fr);border-left:1px solid var(--line);border-top:1px solid var(--line);margin-top:22px}
|
||||
.hero-kpi{background:#fff;padding:15px;border-right:1px solid var(--line);border-bottom:1px solid var(--line)}
|
||||
.hero-kpi small{font-size:10px;color:#918981}.hero-kpi b{display:block;font-size:23px;margin-top:5px}
|
||||
|
||||
.chapter{padding:68px 0}.white{background:#fff}.soft{background:#f4f6f5}.ink{background:var(--dark);color:#fff}
|
||||
.section-head{display:grid;grid-template-columns:1fr .47fr;gap:44px;align-items:end;margin-bottom:40px}
|
||||
.section-index{display:flex;gap:19px}.section-index>span{font-size:12px;color:#958c83;margin-top:9px}
|
||||
.section-kicker{display:block;font-size:10px;letter-spacing:0;color:#948a81;margin-bottom:9px}
|
||||
h2{font-size:44px;line-height:1.16;letter-spacing:0;margin:0}
|
||||
.section-head.compact{display:block;margin-bottom:24px}.section-head.compact h2{font-size:48px;max-width:1050px}.evidence-note{font-size:12px;color:#8a8178;margin:0 0 24px}
|
||||
.section-head>p{font-size:15px;line-height:1.72;color:#6b645d;margin:0}.ink .section-head>p{color:#c5bdb5}
|
||||
|
||||
.shopper-decision{border-top:1px solid var(--line);border-bottom:1px solid var(--line)}
|
||||
.decision-boundary{display:grid;grid-template-columns:repeat(4,1fr);border-top:1px solid var(--line);border-left:1px solid var(--line);margin-bottom:24px}
|
||||
.decision-boundary article{padding:17px;background:#f7f8f7;border-right:1px solid var(--line);border-bottom:1px solid var(--line);min-width:0}
|
||||
.decision-boundary span{display:block;font-size:10px;color:#817970}.decision-boundary strong{display:block;font-size:15px;line-height:1.48;margin:7px 0;overflow-wrap:anywhere}.decision-boundary small{display:block;font-size:10px;line-height:1.5;color:#777f7a}
|
||||
.shopper-verdict{display:grid;grid-template-columns:120px 1fr;gap:18px;padding:24px 0;border-bottom:1px solid var(--line);align-items:start}
|
||||
.shopper-verdict>span{font-size:10px;color:#7f776f;margin-top:6px}.shopper-verdict strong{font-size:24px;line-height:1.4}.shopper-verdict small{grid-column:2;font-size:11px;color:#77706a;margin-top:-8px}
|
||||
.shopper-groups{display:grid;grid-template-columns:1fr 1fr}.shopper-group{padding:24px 26px 26px 0;border-bottom:1px solid var(--line)}.shopper-group:nth-child(even){padding-left:26px;padding-right:0;border-left:1px solid var(--line)}.shopper-group.full{grid-column:1/-1;padding-left:0;border-left:0}.shopper-group.full .shopper-grid{grid-template-columns:repeat(3,1fr)}
|
||||
.shopper-group h3{font-size:16px;margin:0 0 14px}.shopper-grid{display:grid;grid-template-columns:1fr 1fr;border-top:1px solid var(--line);border-left:1px solid var(--line)}
|
||||
.shopper-row{padding:14px;border-right:1px solid var(--line);border-bottom:1px solid var(--line);min-width:0}.shopper-row strong{display:block;font-size:12px;line-height:1.4}.shopper-row p{font-size:14px;line-height:1.55;margin:6px 0 0;overflow-wrap:anywhere}.shopper-meta{display:block;font-size:9px;color:#817970;margin-bottom:7px}.shopper-row.check{background:#f3f6f4}.shopper-row.question{background:#faf8f4}
|
||||
.owner-thesis{font-size:22px;line-height:1.45;margin:-8px 0 12px;max-width:980px}
|
||||
|
||||
.owner{border:1px solid var(--line);background:#faf8f5}
|
||||
.owner>header{padding:28px 30px;border-bottom:1px solid var(--line)}
|
||||
.owner>header>span{font-size:10px;color:#8f867d}.owner>header h3{font-size:34px;line-height:1.3;letter-spacing:0;margin:9px 0}
|
||||
.owner>header p{font-size:13px;color:#736b63;margin:0}
|
||||
.owner-grid{display:grid;grid-template-columns:repeat(3,1fr)}
|
||||
.owner-card{background:#fff;padding:21px;border-right:1px solid var(--line);border-bottom:1px solid var(--line);min-height:0}.owner-card strong{font-size:21px;line-height:1.38}.owner-card ul{padding-left:16px;margin:12px 0 0}.owner-card li{font-size:12px;line-height:1.5;margin:4px 0}
|
||||
.owner-card:nth-child(3n){border-right:0}.owner-card.wide{grid-column:span 2}
|
||||
.owner-card span{font-size:10px;color:#918981}.owner-card strong{display:block;font-size:27px;line-height:1.3;margin:10px 0}
|
||||
.owner-card p{font-size:14px;line-height:1.72;color:#68625b;margin:0}.owner-card ul{padding-left:18px;margin:13px 0 0}
|
||||
.owner-card li{font-size:13px;line-height:1.58;margin:5px 0;color:#5e5852}.owner-card.good strong{color:var(--good)}.owner-card.risk strong{color:var(--risk)}
|
||||
|
||||
.story{margin-top:28px}.story-item{display:grid;grid-template-columns:.9fr 1.1fr;border-top:1px solid var(--line);padding:25px 0;gap:38px;align-items:start}.story-item:last-child{border-bottom:1px solid var(--line)}
|
||||
.story-media{margin:0;background:#fff;border:1px solid var(--line);display:flex;justify-content:center;align-items:flex-start;padding:10px}.story-media img{width:100%;height:auto;object-fit:contain;display:block}
|
||||
.story-copy>span{font-size:10px;color:#918981}.story-copy h3{font-size:25px;line-height:1.3;margin:9px 0 18px}.story-copy>p{font-size:15px;line-height:1.75;color:#68625b}
|
||||
.story-copy .source-badge{display:inline-block;color:#405e4b;border:1px solid #b9c8bf;padding:6px 8px;background:#f3f7f4}
|
||||
.evidence{display:grid;grid-template-columns:1fr;border-left:1px solid var(--line);border-top:1px solid var(--line);margin-top:18px}
|
||||
.evidence div{padding:13px;border-right:1px solid var(--line);border-bottom:1px solid var(--line)}
|
||||
.evidence span{display:block;font-size:10px;color:#918981}.evidence b{display:block;font-size:13px;line-height:1.55;margin-top:4px}
|
||||
.evidence .action{background:#eef2f0}
|
||||
|
||||
.param-strip{display:grid;grid-template-columns:repeat(4,1fr);border-left:1px solid var(--line);border-top:1px solid var(--line)}
|
||||
.param{background:#fff;padding:16px;border-right:1px solid var(--line);border-bottom:1px solid var(--line)}.param span{font-size:10px;color:#918981}.param b{display:block;font-size:13px;line-height:1.55;margin-top:6px}
|
||||
|
||||
.voice-stats{display:grid;grid-template-columns:repeat(6,1fr);border:1px solid #4a443e}
|
||||
.voice-stat{padding:17px;border-right:1px solid #4a443e}.voice-stat:last-child{border-right:0}
|
||||
.voice-stat span{display:block;font-size:10px;color:#aaa199}.voice-stat strong{display:block;font-size:28px;margin:6px 0}.voice-stat small{font-size:10px;color:#bbb2aa}
|
||||
.result-block{margin-top:28px}.result-block h3{font-size:17px;margin:0 0 12px}.result-grid{display:grid;grid-template-columns:repeat(3,1fr);border-top:1px solid #4a443e;border-left:1px solid #4a443e}
|
||||
.result-row{padding:16px;border-right:1px solid #4a443e;border-bottom:1px solid #4a443e;min-width:0}.result-row>span{display:block;font-size:9px;color:#aaa199;margin-bottom:7px}.result-row strong{display:block;font-size:15px;line-height:1.4}.result-row p{font-size:13px;line-height:1.6;color:#d1c9c1;margin:7px 0 0;overflow-wrap:anywhere}.result-row.positive{background:#29312c}.result-row.risk{background:#342724}.result-row.question{background:#302d27}
|
||||
.journey-wrap{margin-top:28px;border:1px solid #4a443e;background:#282520}
|
||||
.journey-head{padding:22px;border-bottom:1px solid #4a443e}.journey-head span{font-size:10px;color:#aaa199}.journey-head h3{font-size:25px;line-height:1.28;margin:8px 0}.journey-head p{font-size:14px;line-height:1.7;color:#c4bcb4;margin:0}
|
||||
.journey-tabs{display:grid;grid-template-columns:repeat(7,1fr);border-bottom:1px solid #4a443e}
|
||||
.jtab{padding:15px;border-right:1px solid #4a443e;background:#24211e;color:#d2cac2;cursor:pointer}.jtab:last-child{border-right:0}.jtab.active{background:#fff;color:#1a1815}
|
||||
.jtab small{font-size:9px;display:block;opacity:.65}.jtab b{font-size:13px;display:block;margin-top:5px}
|
||||
.jpanel{display:none;padding:22px}.jpanel.active{display:block}
|
||||
.jpanel>header span{font-size:10px;color:#aaa199}.jpanel>header h4{font-size:28px;margin:8px 0}.jpanel>header p{font-size:14px;color:#c5bdb5}
|
||||
.jgrid{display:grid;grid-template-columns:repeat(4,1fr);border-left:1px solid #4a443e;border-top:1px solid #4a443e;margin-top:20px}
|
||||
.jgrid section{padding:15px;border-right:1px solid #4a443e;border-bottom:1px solid #4a443e}.jgrid span{font-size:10px;color:#aaa199}.jgrid p{font-size:13px;line-height:1.6;color:#d1c9c1;margin:5px 0 0}
|
||||
.jmeta{display:flex;gap:9px;margin-top:16px}.jmeta span{font-size:10px;border:1px solid #514a43;padding:6px 9px;border-radius:999px;color:#c7bfb7}
|
||||
.quotes{margin:16px 0 0;padding-left:20px}.quotes li{font-size:13px;color:#cfc7bf;line-height:1.65;margin:7px 0}
|
||||
.buyer-evidence{margin-top:24px;border:1px solid #4a514d;background:#252a27;padding:22px}.buyer-evidence>header{padding:0 0 18px;border-bottom:1px solid #4a514d}.buyer-evidence>header span{font-size:10px;color:#aeb8b2}.buyer-evidence>header h3{font-size:24px;margin:7px 0}.buyer-evidence>header p{font-size:12px;line-height:1.55;color:#aeb8b2;margin:0}.buyer-groups{display:grid;gap:18px;padding-top:18px}.buyer-review-group{display:grid;grid-template-columns:minmax(220px,300px) minmax(0,1fr);gap:18px;border:1px solid #48504b;background:#202522;padding:16px;min-width:0}.buyer-review-copy{padding:4px}.buyer-review-copy span{font-size:10px;color:#aeb8b2}.buyer-review-copy blockquote{font-size:14px;line-height:1.65;margin:10px 0;color:#f0f3f1;overflow-wrap:anywhere}.buyer-review-copy small{display:block;font-size:10px;line-height:1.5;color:#aeb8b2}.buyer-gallery{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;align-items:start}.buyer-gallery figure{margin:0;background:#fff;min-width:0}.buyer-gallery img{width:100%;height:auto;object-fit:contain;display:block}.buyer-gallery figcaption{padding:7px 8px;color:#68716c;background:#eef1ef;font-size:9px;border-top:1px solid #d8dedb}
|
||||
|
||||
.signal-atlas{margin-top:24px;border:1px solid #4a443e;padding:22px;background:#26231f}
|
||||
.signal-atlas span.label{font-size:10px;color:#aaa199}.signal-atlas h3{font-size:30px;margin:8px 0}.signal-atlas p{font-size:14px;color:#c5bdb5}
|
||||
.signals{display:flex;flex-wrap:wrap;gap:8px;margin-top:18px}.signal{font-size:12px;border:1px solid #575049;border-radius:999px;padding:8px 11px}.signal.good{color:#bfd4bd}.signal.ask{color:#ddc9a9}.signal.risk{color:#e2b1a9}
|
||||
|
||||
.product-box{display:grid;grid-template-columns:.82fr 1.18fr;gap:25px}
|
||||
.param-card,.product-conclusion{background:#fff;border:1px solid var(--line);padding:25px}
|
||||
.param-card h3,.product-conclusion h3{font-size:31px;line-height:1.28;margin:0 0 17px}
|
||||
.prow{display:grid;grid-template-columns:135px 1fr;gap:18px;padding:13px 0;border-top:1px solid var(--line)}.prow:first-of-type{border-top:0}
|
||||
.prow span{font-size:10px;color:#918981}.prow b{font-size:14px;line-height:1.6}
|
||||
.product-conclusion>p{font-size:15px;line-height:1.76;color:#68625b}
|
||||
.gate{padding:14px 0;border-top:1px solid var(--line);display:grid;grid-template-columns:145px 1fr;gap:20px}.gate b{font-size:13px}.gate span{font-size:14px;line-height:1.65;color:#67615a}
|
||||
|
||||
.plans{display:grid;grid-template-columns:1fr 1fr;gap:20px}
|
||||
.plan{border:1px solid #4a443e;background:#282520;padding:24px}.plan>span{font-size:10px;color:#aaa199}.plan h3{font-size:27px;line-height:1.25;margin:9px 0}.plan>p{font-size:13px;line-height:1.65;color:#c9c1b9}
|
||||
.plan-specs{display:grid;grid-template-columns:1fr 1fr;border-left:1px solid #4a443e;border-top:1px solid #4a443e;margin-top:19px}
|
||||
.plan-specs div{padding:13px;border-right:1px solid #4a443e;border-bottom:1px solid #4a443e}.plan-specs span{font-size:10px;color:#aaa199;display:block}.plan-specs b{font-size:13px;line-height:1.55;display:block;margin-top:4px}
|
||||
.plan-section{padding:15px 0;border-top:1px solid #4a443e}.plan-section h4{font-size:17px;margin:0 0 6px}.plan-section p{font-size:13px;line-height:1.65;color:#c6beb6;margin:0}
|
||||
.plan-actions{display:grid;grid-template-columns:1fr 1fr;gap:24px;margin-top:18px}.plan-actions h4{font-size:15px;margin:0 0 7px}.action-list{padding-left:17px;margin:0}.action-list li{font-size:12px;line-height:1.55;color:#c6beb6;margin:5px 0}
|
||||
.commercial{margin-top:26px;border:1px solid #4a443e}.commercial header{padding:21px;border-bottom:1px solid #4a443e}
|
||||
.commercial header span{font-size:10px;color:#aaa199}.commercial header strong{font-size:27px;display:block;margin-top:6px}
|
||||
.commercial-grid{display:grid;grid-template-columns:repeat(3,1fr)}.commercial-grid p{padding:20px;margin:0;border-right:1px solid #4a443e}.commercial-grid p:last-child{border-right:0}
|
||||
.commercial-grid span{font-size:10px;color:#aaa199;display:block}.commercial-grid b{font-size:14px;line-height:1.62;display:block;margin-top:5px}
|
||||
.governance{background:#181715;color:#fff;border-top:1px solid #2f2b26;border-bottom:1px solid #2f2b26}.governance-grid{display:grid;grid-template-columns:repeat(4,1fr);border-left:1px solid #3b352e}.governance article{padding:16px 18px;border-right:1px solid #3b352e}.governance span{display:block;font-size:9px;color:#bfb4a8;margin-bottom:7px}.governance b{font-size:12px;line-height:1.55;color:#f5efe8}
|
||||
.selling-points{margin-top:22px;border:1px solid #4d443a;background:#211e1a}.selling-points header{padding:18px 18px 0}.selling-points header span{font-size:9px;color:#bfb4a8}.selling-points h4{font-size:22px;margin:6px 0 14px}.selling-points>div{display:grid;grid-template-columns:repeat(2,1fr);border-top:1px solid #4d443a;border-left:1px solid #4d443a}.selling-points article{padding:16px;border-right:1px solid #4d443a;border-bottom:1px solid #4d443a;min-width:0}.selling-points article strong{display:block;font-size:18px;line-height:1.35;color:#fff}.selling-points article p{font-size:12px;line-height:1.55;color:#d5ccc2;margin:8px 0}.selling-points article small{display:block;font-size:10px;color:#b8ab9c}.selling-points article b{display:block;font-size:12px;line-height:1.55;color:#d8bea1;margin-top:8px}
|
||||
.profile-board{display:grid;grid-template-columns:repeat(4,1fr);border-left:1px solid var(--line);border-top:1px solid var(--line);background:#fff}.profile-cell{padding:16px;border-right:1px solid var(--line);border-bottom:1px solid var(--line);min-width:0}.profile-cell span,.market-slot span{display:block;font-size:10px;color:#918981}.profile-cell b,.market-slot b{display:block;font-size:14px;line-height:1.55;margin-top:6px;overflow-wrap:anywhere}
|
||||
.logic-chain{display:grid;grid-template-columns:repeat(5,1fr);gap:12px}.logic-step{background:#fff;border:1px solid var(--line);padding:20px;min-height:180px}.logic-step span{font-size:11px;color:#918981}.logic-step strong{display:block;font-size:22px;line-height:1.28;margin:10px 0}.logic-step p{font-size:13px;line-height:1.6;color:#67615a;margin:0 0 12px}.logic-step small{font-size:10px;color:#8c847b}
|
||||
.feature-grid{display:grid;grid-template-columns:repeat(4,1fr);border-left:1px solid #4a443e;border-top:1px solid #4a443e}.feature-card{padding:20px;border-right:1px solid #4a443e;border-bottom:1px solid #4a443e;min-height:150px;background:#24211e}.feature-card span{font-size:10px;color:#aaa199}.feature-card strong{display:block;font-size:23px;line-height:1.32;margin:9px 0;color:#fff}.feature-card p{font-size:12px;line-height:1.58;color:#cbc3bb;margin:0}.feature-card.good strong{color:#c6dcc4}.feature-card.risk strong{color:#e0b1a9}
|
||||
.marketing-grid{display:grid;grid-template-columns:1.05fr .95fr;gap:18px}.marketing-grid>article{background:#fff;border:1px solid var(--line);padding:22px;min-width:0}.marketing-grid>article>span{font-size:10px;color:#918981}.marketing-grid strong{display:block;font-size:26px;line-height:1.35;margin:9px 0 18px}.marketing-grid ul{margin:14px 0 0;padding-left:18px}.marketing-grid li{font-size:14px;line-height:1.65;margin:7px 0}.market-slots{display:grid;grid-template-columns:repeat(2,1fr);border-left:1px solid var(--line);border-top:1px solid var(--line)}.market-slot{padding:14px;border-right:1px solid var(--line);border-bottom:1px solid var(--line)}
|
||||
.visual-states{display:grid;grid-template-columns:repeat(4,1fr);border-left:1px solid var(--line);border-top:1px solid var(--line);margin-bottom:18px}.visual-state{background:#fff;padding:17px;border-right:1px solid var(--line);border-bottom:1px solid var(--line)}.visual-state span{font-size:10px;color:#918981}.visual-state strong{display:block;font-size:28px;margin:5px 0}.visual-state small{font-size:10px;color:#77706a}.visual-state.ready strong{color:var(--good)}.visual-state.missing strong{color:var(--risk)}
|
||||
.competition-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:16px}.competition-grid article{border:1px solid var(--line);background:#fff;padding:24px;min-height:190px}.competition-grid span{font-size:10px;color:#918981}.competition-grid strong{display:block;font-size:28px;line-height:1.28;margin:9px 0}.competition-grid p{font-size:13px;line-height:1.65;color:#67615a;margin:0}.competition-grid .strong strong{color:var(--good)}.competition-grid .mid strong{color:var(--ask)}.competition-grid .weak strong{color:var(--risk)}
|
||||
.validation-wrap{margin-top:28px;border:1px solid #4a443e;background:#221f1b;padding:22px;overflow-x:auto}.validation-wrap header{margin-bottom:14px}.validation-wrap span{font-size:10px;color:#aaa199}.validation-wrap h3{font-size:26px;margin:7px 0;color:#fff}.validation-wrap .validation{min-width:980px}
|
||||
|
||||
.validation{width:100%;border-collapse:collapse;background:#fff;border:1px solid var(--line)}
|
||||
.validation th,.validation td{text-align:left;vertical-align:top;padding:13px;border-bottom:1px solid var(--line)}.validation th{font-size:10px;color:#8e857c}.validation td{font-size:12px;line-height:1.55}.validation tr:last-child td{border-bottom:0}
|
||||
.footer{padding:38px 0 52px;font-size:11px;color:#8c847b}
|
||||
.reveal{opacity:1;transform:none}
|
||||
@media(max-width:1000px){.wrap{width:min(1380px,calc(100% - 28px))}.hero-grid,.section-head,.story-item,.product-box,.marketing-grid{grid-template-columns:1fr}.decision-boundary{grid-template-columns:1fr 1fr}.owner-grid,.voice-stats,.journey-tabs,.jgrid,.param-strip,.result-grid,.governance-grid,.profile-board,.feature-grid,.visual-states{grid-template-columns:1fr 1fr}.logic-chain,.competition-grid{grid-template-columns:1fr}.owner-card.wide{grid-column:auto}.plans{grid-template-columns:1fr}.commercial-grid{grid-template-columns:1fr}#validate .wrap{overflow-x:auto}.validation{min-width:980px}.shopper-group.full .shopper-grid{grid-template-columns:1fr 1fr}.buyer-review-group{grid-template-columns:240px minmax(0,1fr)}.buyer-gallery{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
||||
@media(max-width:680px){nav{height:auto}.navin{min-height:56px}.navlinks{display:none}.hero{padding:42px 0 34px}h1{font-size:38px}.hero-shot{order:-1;padding:8px}.hero-verdict strong{font-size:20px}.hero-actions .gate{grid-template-columns:1fr;gap:4px}.chapter{padding:46px 0}.section-head.compact h2{font-size:34px}.decision-boundary{grid-template-columns:1fr}.owner-grid,.voice-stats,.journey-tabs,.jgrid,.param-strip,.hero-kpis,.plan-specs,.plan-actions,.shopper-groups,.shopper-grid,.shopper-group.full .shopper-grid,.result-grid,.governance-grid,.selling-points>div,.profile-board,.feature-grid,.visual-states,.market-slots{grid-template-columns:1fr}.shopper-verdict{grid-template-columns:1fr;gap:7px}.shopper-verdict small{grid-column:1;margin-top:0}.shopper-group,.shopper-group:nth-child(even),.shopper-group.full{grid-column:1;padding:20px 0;border-left:0}.buyer-evidence{padding:16px}.buyer-review-group{grid-template-columns:1fr;padding:12px}.buyer-gallery{grid-template-columns:repeat(2,minmax(0,1fr))}.owner-card{min-height:auto}.section-index{gap:12px}.story-media{height:auto}.story-copy h3{font-size:23px}.owner-thesis{font-size:19px}.logic-step,.feature-card,.competition-grid article{min-height:0}.marketing-grid strong{font-size:22px}}
|
||||
|
||||
.hero-shot{min-height:360px;background:#f8f5f1;border:1px solid var(--line);display:flex;align-items:center;justify-content:center;padding:14px;box-shadow:0 24px 80px rgba(39,31,24,.08)}.hero-shot img,.story-media img{width:100%;height:auto;object-fit:contain;display:block}.scroll-progress{position:fixed;left:0;top:0;width:100%;height:2px;z-index:90}.scroll-progress span{display:block;height:100%;background:#1d211f;transform-origin:left;transform:scaleX(0)}
|
||||
.loading-page{min-height:100vh;background:#eef1ef;padding:34px;color:#171a18}.loading-shell{width:min(1180px,100%);margin:auto;background:#fff;border:1px solid #d8dedb}.loading-head{display:flex;justify-content:space-between;gap:30px;padding:34px 36px 28px;border-bottom:1px solid var(--line)}.loading-head h1{font-size:42px;line-height:1.12;margin:9px 0 12px}.loading-head p{font-size:14px;line-height:1.6;color:#646b67;margin:0;max-width:720px}.loading-head>strong{font-size:42px;line-height:1;color:#556d5a}.loading-evidence,.loading-work{padding:25px 36px;border-bottom:1px solid var(--line)}.loading-section-title{display:flex;justify-content:space-between;align-items:baseline;gap:20px;margin-bottom:15px}.loading-section-title>span{font-size:16px;font-weight:750}.loading-section-title>small{font-size:11px;color:#717873}.loading-stats{display:grid;grid-template-columns:repeat(7,1fr);border-top:1px solid var(--line);border-left:1px solid var(--line)}.loading-stats>div{padding:13px;border-right:1px solid var(--line);border-bottom:1px solid var(--line)}.loading-stats span{display:block;font-size:10px;color:#727a75}.loading-stats b{display:block;font-size:23px;margin-top:6px}.loading-images{display:grid;grid-template-columns:repeat(8,minmax(0,1fr));gap:8px;margin-top:14px}.loading-images figure{margin:0;border:1px solid var(--line);background:#f5f7f6}.loading-images img{display:block;width:100%;aspect-ratio:3/4;object-fit:contain;background:#fff}.loading-images figcaption{font-size:9px;color:#69706c;padding:6px 7px;border-top:1px solid var(--line)}.loading-steps{display:grid;grid-template-columns:repeat(2,1fr);border-top:1px solid var(--line);border-left:1px solid var(--line)}.loading-step{display:grid;grid-template-columns:10px 1fr auto;align-items:center;gap:10px;padding:11px 13px;border-right:1px solid var(--line);border-bottom:1px solid var(--line)}.loading-step i{width:8px;height:8px;border:1px solid #929a95;border-radius:50%}.loading-step span{font-size:12px}.loading-step b{font-size:10px;color:#7b837e;font-weight:600}.loading-step.done i{background:#556d5a;border-color:#556d5a}.loading-step.done b{color:#556d5a}.loading-step.processing{background:#f1f5f2}.loading-step.processing i{background:#171a18;border-color:#171a18}.loading-step.not_collected{background:#faf8f5}.loading-step.not_collected i{border-color:#a88a64}.loading-step.not_collected b{color:#8d6f4c}.loading-step.failed{background:#fff2f0}.loading-step.failed i{background:#a2463d;border-color:#a2463d}.loading-step.failed b{color:#a2463d}.loading-page .topic-bar{height:5px;background:#e5e9e6;margin:0 36px 28px}.loading-page .topic-bar i{display:block;height:100%;background:#556d5a;transition:width .35s ease}.loading-page .error{color:#a2463d;white-space:pre-wrap;margin:0 36px 28px}.loading-page .eyebrow{font-size:10px;color:#7c847f}
|
||||
@media(max-width:680px){h1{font-size:38px}}
|
||||
@media(max-width:800px){.loading-page{padding:0}.loading-shell{border-left:0;border-right:0}.loading-head{padding:25px 18px 22px}.loading-head h1{font-size:32px}.loading-head>strong{font-size:28px}.loading-evidence,.loading-work{padding:21px 18px}.loading-section-title{display:block}.loading-section-title small{display:block;margin-top:6px}.loading-stats{grid-template-columns:repeat(2,1fr)}.loading-images{grid-template-columns:repeat(4,1fr)}.loading-steps{grid-template-columns:1fr}.loading-page .topic-bar{margin:0 18px 24px}}
|
||||
|
||||
/* V9:白色阅读背景,青绿到青蓝为品牌强调色。 */
|
||||
:root{--paper:#fff;--paper2:#f5faf9;--ink:#10191d;--muted:#60757c;--line:#dbe8e8;--dark:#071114;--dark2:#0d2025;--good:#00a98f;--risk:#d34f5f;--ask:#167ca7;--accent:#00cdb0;--accent2:#008fd8;--gradient:linear-gradient(110deg,#00d9b6 0%,#00acd0 56%,#008bd8 100%)}
|
||||
body{background:#fff;color:var(--ink)}nav{background:rgba(255,255,255,.96);border-color:#dce9e9;box-shadow:0 8px 26px rgba(8,54,63,.06)}
|
||||
.brand{background:var(--gradient);-webkit-background-clip:text;background-clip:text;color:transparent;font-weight:900}.navlinks a{color:#526d74}.navlinks a:hover{background:#071114;color:#fff}
|
||||
.hero{background:linear-gradient(180deg,#fff 0%,#f1fbf9 100%);position:relative;overflow:hidden}.hero:before{content:"";position:absolute;inset:0 0 auto auto;width:46%;height:5px;background:var(--gradient)}
|
||||
.kicker,.section-kicker{color:#008fae;font-weight:800;letter-spacing:.08em}.hero-verdict{border-color:#bfe6e1}.hero-shot{background:#fff;border-color:#cfe4e4;box-shadow:0 28px 70px rgba(0,105,128,.13)}
|
||||
.scroll-progress span{background:var(--gradient)}.soft{background:#f5faf9}.white{background:#fff}.ink{background:#071114;color:#f2fbfc}.ink .section-kicker{color:#12d8bb}.ink .section-head h2{color:#fff}
|
||||
.evidence-tags{display:flex;flex-wrap:wrap;gap:8px;margin-top:22px}.evidence-tags b{font-size:10px;padding:7px 10px;border:1px solid #b9e2df;border-radius:999px;background:#fff;color:#24717b}.evidence-tags b:nth-child(2){border-color:#9dd5ef;color:#117ba7}.evidence-tags b:nth-child(3){background:var(--gradient);color:#fff;border:0}.evidence-tags b:nth-child(4){color:#708187}
|
||||
.title-original{padding:26px 28px;border:1px solid var(--line);background:#071114;color:#fff}.title-original span{display:block;font-size:10px;color:#7fb9c1;margin-bottom:9px}.title-original strong{font-size:25px;line-height:1.5}
|
||||
.title-segments{display:grid;grid-template-columns:repeat(4,1fr);border-left:1px solid var(--line);border-top:1px solid var(--line)}.title-segments article{padding:18px;background:#fff;border-right:1px solid var(--line);border-bottom:1px solid var(--line)}.title-segments span,.analysis-strip span{display:block;font-size:10px;color:#698087}.title-segments strong{display:block;font-size:17px;line-height:1.4;margin:8px 0}.title-segments p{font-size:12px;line-height:1.6;color:#5c7076;margin:0}
|
||||
.analysis-strip{display:grid;grid-template-columns:repeat(3,1fr);margin-top:18px;border:1px solid #cde4e3;background:#effaf8}.analysis-strip article{padding:18px;border-right:1px solid #cde4e3}.analysis-strip article:last-child{border:0}.analysis-strip b{display:block;margin-top:8px;font-size:13px;line-height:1.6}
|
||||
.image-analysis-list{display:grid;gap:24px}.image-analysis{display:grid;grid-template-columns:minmax(300px,.9fr) minmax(0,1.1fr);border:1px solid var(--line);background:#fff;box-shadow:0 16px 42px rgba(15,63,72,.07)}.image-analysis figure{margin:0;padding:12px;background:#f7fbfa;border-right:1px solid var(--line)}.image-analysis img{display:block;width:100%;height:auto;max-height:720px;object-fit:contain;background:#fff}.image-analysis figcaption{padding:10px 4px 0;font-size:10px;color:#668087}.image-copy{display:grid;grid-template-columns:1fr 1fr;align-content:start}.image-copy>div{padding:17px;border-right:1px solid var(--line);border-bottom:1px solid var(--line)}.image-copy span{display:block;font-size:10px;color:#668087}.image-copy b{display:block;font-size:13px;line-height:1.62;margin-top:6px;overflow-wrap:anywhere}
|
||||
.empty-state{padding:30px;border:1px dashed #a8cdca;background:#f3fbf9;color:#61777d;text-align:center;font-size:13px}.table-scroll{overflow:auto}.table-scroll .validation{min-width:840px}
|
||||
.asset-levels,.dna-grid,.parameter-grid,.shot-grid{display:grid;grid-template-columns:repeat(4,1fr);border-left:1px solid var(--line);border-top:1px solid var(--line);margin-top:24px}.asset-levels article,.dna-grid article,.parameter-grid article,.shot-grid article{padding:20px;border-right:1px solid var(--line);border-bottom:1px solid var(--line);background:#fff;color:var(--ink)}.asset-levels span,.dna-grid span,.parameter-grid span,.shot-grid span{font-size:10px;color:#008ca7}.asset-levels li,.asset-levels p,.dna-grid p,.parameter-grid p,.shot-grid p{font-size:13px;line-height:1.62;color:#526a70}.shot-grid strong{display:block;font-size:18px;line-height:1.4;margin:9px 0}.shot-grid small{font-size:10px;color:#75898e}.subhead{font-size:22px;margin:34px 0 0}
|
||||
.ink .plans{grid-template-columns:repeat(3,1fr)}.ink .plan{border-color:#24444a;background:#0d2025}.ink .asset-levels article{background:#0d2025;color:#fff;border-color:#31565a}.ink .asset-levels li,.ink .asset-levels p{color:#bad0d3}
|
||||
@media(max-width:1000px){.title-segments,.analysis-strip,.asset-levels,.dna-grid,.parameter-grid,.shot-grid{grid-template-columns:1fr 1fr}.image-analysis{grid-template-columns:1fr}.image-analysis figure{border-right:0;border-bottom:1px solid var(--line)}.ink .plans{grid-template-columns:1fr}}
|
||||
@media(max-width:620px){.title-segments,.analysis-strip,.asset-levels,.dna-grid,.parameter-grid,.shot-grid,.image-copy{grid-template-columns:1fr}}
|
||||
|
||||
|
||||
/* V9.1 information hierarchy additions — preserves existing palette */
|
||||
.evidence-fold{margin-top:18px;border-top:1px solid var(--line);padding-top:14px}.evidence-fold>summary{cursor:pointer;font-size:13px;font-weight:700;padding:10px 0;list-style:none}.evidence-fold>summary::-webkit-details-marker{display:none}.evidence-fold>summary:after{content:'+';float:right}.evidence-fold[open]>summary:after{content:'-'}
|
||||
.insight-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;margin:18px 0}.insight-card{padding:18px;border:1px solid var(--line);background:var(--card,#fff)}.insight-card span,.consumer-thesis span,.continuity header span{display:block;font-size:11px;color:var(--muted);margin-bottom:7px}.insight-card strong,.consumer-thesis strong{display:block;font-size:18px;line-height:1.45}.insight-card p{margin:8px 0 0;font-size:13px;line-height:1.55;color:var(--muted)}
|
||||
.consumer-thesis{padding:20px 0;border-bottom:1px solid var(--line);margin-bottom:14px}.continuity{margin:22px 0}.continuity header h3{margin:0 0 12px}.sku-status{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;margin-bottom:16px}.sku-status article{padding:16px;border:1px solid var(--line);background:var(--card,#fff)}.sku-status span{display:block;font-size:11px;color:var(--muted);margin-bottom:6px}.sku-status strong{font-size:15px;line-height:1.4}.recommended-titles{margin-top:14px;padding:16px;border:1px solid var(--line)}.recommended-titles>span{font-size:11px;color:var(--muted)}.recommended-titles ul{margin:8px 0 0;padding-left:18px}.image-error{opacity:.18;min-height:120px;background:repeating-linear-gradient(45deg,transparent,transparent 8px,rgba(127,127,127,.08) 8px,rgba(127,127,127,.08) 16px)}
|
||||
@media(max-width:900px){.insight-grid{grid-template-columns:1fr 1fr}.sku-status{grid-template-columns:1fr 1fr}}@media(max-width:560px){.insight-grid,.sku-status{grid-template-columns:1fr}}
|
||||
|
||||
/* =========================================================
|
||||
V9.2.0 IMAGE-DRIVEN PROFESSIONAL REPORT
|
||||
图片 > 专业意见 > 专业建议 > 证据
|
||||
========================================================= */
|
||||
:root{
|
||||
--v92-ink:#17202a;--v92-text:#465364;--v92-muted:#8a95a3;--v92-line:#e8edf2;
|
||||
--v92-soft:#f7f9fb;--v92-blue:#396bc8;--v92-blue-soft:#f2f6fd;--v92-white:#fff;
|
||||
}
|
||||
body{background:#fff;color:var(--v92-text);font-size:14px;line-height:1.55}
|
||||
.wrap{width:min(1180px,calc(100% - 40px));margin:auto}
|
||||
.v92-nav{height:52px;position:sticky;top:0;z-index:50;background:rgba(255,255,255,.96);backdrop-filter:blur(10px);border-bottom:1px solid var(--v92-line);box-shadow:none}
|
||||
.v92-nav .wrap{height:100%;display:flex;align-items:center;justify-content:space-between;gap:18px}
|
||||
.v92-nav .brand{font-size:13px;color:var(--v92-ink);font-weight:760;background:none;-webkit-background-clip:initial;background-clip:initial}
|
||||
.v92-links{display:flex;gap:18px;overflow:auto}.v92-links a{font-size:12px;color:var(--v92-muted);text-decoration:none;white-space:nowrap;padding:0}.v92-links a:hover{background:none;color:var(--v92-blue)}
|
||||
.v92-hero{padding:42px 0 30px;border-bottom:1px solid var(--v92-line);background:#fff;min-height:0}
|
||||
.v92-hero:before{display:none}.v92-hero-grid{display:grid;grid-template-columns:1.08fr .92fr;gap:42px;align-items:center}
|
||||
.v92-kicker{font-size:10px;font-weight:800;color:var(--v92-blue);letter-spacing:.08em;margin-bottom:8px}
|
||||
.v92-hero h1{margin:0;color:var(--v92-ink);font-size:32px;line-height:1.2;letter-spacing:-.02em;font-weight:720}
|
||||
.v92-meta{font-size:12px;color:var(--v92-muted);margin:10px 0 0}
|
||||
.v92-cover{min-width:0}.v92-cover .visual{background:transparent;border:0;border-radius:0;box-shadow:none}.v92-cover img{display:block;width:100%;max-height:390px;object-fit:contain;background:transparent}
|
||||
.cover-ratio{aspect-ratio:auto!important}.v92-cover-empty{min-height:280px;display:grid;place-items:center;background:var(--v92-soft);color:var(--v92-muted)}
|
||||
.v92-opinion{margin:20px 0 0;padding:14px 16px;background:var(--v92-blue-soft);border-left:3px solid var(--v92-blue);border-radius:8px;display:grid;grid-template-columns:78px 1fr;gap:14px}
|
||||
.v92-opinion span{font-size:10px;color:var(--v92-blue);font-weight:800}.v92-opinion strong{font-size:15px;line-height:1.55;color:var(--v92-ink);font-weight:700}
|
||||
.v92-section{padding:42px 0;background:#fff}.v92-section+.v92-section{border-top:1px solid var(--v92-line)}
|
||||
.v92-head{display:flex;align-items:baseline;gap:10px;margin-bottom:18px}.v92-head>span{font-size:10px;color:var(--v92-blue);font-weight:800}.v92-head h2{margin:0;font-size:21px;color:var(--v92-ink);line-height:1.25}.v92-head p{margin:0 0 0 auto;font-size:11px;color:var(--v92-muted)}
|
||||
.v92-section>.wrap>.v92-opinion{margin-top:0;margin-bottom:18px}
|
||||
.v92-summary3{display:grid;grid-template-columns:repeat(3,1fr);border-top:1px solid var(--v92-line);border-bottom:1px solid var(--v92-line)}
|
||||
.v92-summary3 article{padding:15px 17px;border-right:1px solid var(--v92-line)}.v92-summary3 article:last-child{border-right:0}
|
||||
.v92-summary3 span,.v92-title3 span{display:block;font-size:10px;color:var(--v92-muted);font-weight:700}.v92-summary3 b,.v92-title3 b{display:block;margin-top:5px;color:var(--v92-ink);font-size:13px;line-height:1.5}.v92-summary3 p{margin:4px 0 0;color:var(--v92-muted);font-size:11px}
|
||||
.v92-title-current{padding:14px 16px;background:var(--v92-soft);border-radius:8px;color:var(--v92-ink);margin-bottom:13px}.v92-title-current small{display:block;font-size:10px;color:var(--v92-muted);margin-bottom:4px}
|
||||
.v92-title3{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}.v92-title3 article{padding:15px;border:1px solid var(--v92-line);border-radius:9px;background:#fff}
|
||||
.v92-flow{display:flex;gap:7px;align-items:center;flex-wrap:wrap;margin:7px 0 17px}.v92-flow .node{font-size:11px;color:var(--v92-ink);font-weight:700;padding:6px 9px;background:var(--v92-soft);border-radius:99px}.v92-flow i{font-style:normal;color:#b7c0ca}
|
||||
.v92-main-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:10px}.v92-main-card{min-width:0}.visual{margin:0;position:relative;background:var(--v92-soft);border-radius:9px;overflow:hidden}.visual img{width:100%;height:100%;display:block;object-fit:cover}.main-ratio{aspect-ratio:4/5}.wide-ratio{aspect-ratio:16/10}.square-ratio{aspect-ratio:1/1}.plan-ratio{aspect-ratio:4/3}
|
||||
.image-fallback{display:none;width:100%;height:100%;min-height:120px;align-items:center;justify-content:center;color:var(--v92-muted);font-size:10px;text-align:center;padding:16px}.visual.failed .image-fallback{display:flex}.visual.failed{background:var(--v92-soft)}
|
||||
.v92-main-copy{padding:8px 1px 0}.v92-main-copy span{display:block;color:var(--v92-ink);font-size:12px;font-weight:760}.v92-main-copy b{display:block;margin-top:2px;color:var(--v92-text);font-size:11px;line-height:1.45}.v92-main-copy p{margin:3px 0 0;color:var(--v92-muted);font-size:10px;line-height:1.45}
|
||||
.v92-table-wrap{overflow:auto;margin-top:18px}.v92-matrix{width:100%;border-collapse:collapse;min-width:720px}.v92-matrix th,.v92-matrix td{padding:10px 11px;border-bottom:1px solid var(--v92-line);text-align:left;font-size:11px;vertical-align:top}.v92-matrix th{font-size:9px;color:var(--v92-muted);background:var(--v92-soft)}.v92-matrix .ok{color:#3c765e;font-weight:800}.v92-matrix .mid{color:#a06d2f;font-weight:800}.v92-matrix .none{color:#9aa3ae}
|
||||
.v92-detail-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}.v92-detail-card{border:1px solid var(--v92-line);border-radius:10px;overflow:hidden;background:#fff}.v92-detail-card>div:last-child{padding:12px}.v92-detail-card span{font-size:9px;color:var(--v92-blue);font-weight:800}.v92-detail-card b{display:block;margin-top:3px;color:var(--v92-ink);font-size:13px}.v92-detail-card p{margin:4px 0 0;color:var(--v92-muted);font-size:10.5px}.v92-evidence-note{margin-top:12px;font-size:10px;color:var(--v92-muted)}
|
||||
.v92-sku-layout{display:grid;grid-template-columns:1.15fr .85fr;gap:24px;align-items:start}.v92-sku-mosaic{display:grid;grid-template-columns:repeat(4,1fr);gap:8px}.v92-choice{border-left:1px solid var(--v92-line);padding-left:20px}.v92-choice-row{display:grid;grid-template-columns:36px 1fr;gap:10px;padding:9px 0;border-bottom:1px solid var(--v92-line)}.v92-choice-row span{font-size:9px;color:var(--v92-blue);font-weight:800}.v92-choice-row b{font-size:12px;color:var(--v92-ink)}.v92-choice-row small{grid-column:2;font-size:9px;color:var(--v92-muted)}.v92-choice-note{margin-top:13px;padding:12px;background:var(--v92-blue-soft);border-radius:8px;font-size:11px;color:#345077}.v92-status{display:block;margin-top:9px;font-size:9px;color:var(--v92-muted)}
|
||||
.v92-review-layout{display:grid;grid-template-columns:1.15fr .85fr;gap:24px;align-items:start}.v92-buyer-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}.v92-insights{border-top:1px solid var(--v92-line)}.v92-insights article{padding:12px 0;border-bottom:1px solid var(--v92-line)}.v92-insights span{display:block;font-size:9px;color:var(--v92-muted);font-weight:750}.v92-insights b{display:block;margin-top:3px;color:var(--v92-ink);font-size:12.5px}.v92-insights p{margin:4px 0 0;color:var(--v92-muted);font-size:10.5px}
|
||||
.v92-value-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:10px}.v92-value-grid article{padding:15px;border-top:2px solid var(--v92-line);background:var(--v92-soft);border-radius:0 0 8px 8px}.v92-value-grid span{font-size:9px;color:var(--v92-muted);font-weight:800}.v92-value-grid b{display:block;margin-top:5px;color:var(--v92-ink);font-size:13px}.v92-value-grid p{margin:4px 0 0;color:var(--v92-muted);font-size:10.5px}
|
||||
.v92-plan-stack{display:grid;gap:16px}.v92-plan{display:grid;grid-template-columns:190px 1fr 1fr;gap:20px;padding-top:16px;border-top:1px solid var(--v92-line)}.v92-plan:first-child{border-top:0;padding-top:0}.v92-plan-copy small{font-size:9px;color:var(--v92-blue);font-weight:800}.v92-plan-copy h3{margin:3px 0 7px;color:var(--v92-ink);font-size:17px}.v92-plan-copy p{margin:0;color:var(--v92-muted);font-size:11px}.v92-plan-points{display:grid;gap:8px}.v92-plan-points>div{padding:10px 12px;background:var(--v92-soft);border-radius:8px}.v92-plan-points span{display:block;font-size:9px;color:var(--v92-muted);font-weight:750}.v92-plan-points b{display:block;margin-top:3px;color:var(--v92-ink);font-size:11px}.v92-plan-placeholder{aspect-ratio:4/3;background:var(--v92-soft);display:grid;place-items:center;color:var(--v92-muted);font-size:10px;border-radius:9px}
|
||||
.v92-empty{padding:18px;background:var(--v92-soft);color:var(--v92-muted);font-size:11px;border-radius:8px}.v92-footer{padding:28px 0 40px;border-top:1px solid var(--v92-line);font-size:10px;color:var(--v92-muted);background:#fff}
|
||||
.image-error{display:none!important}
|
||||
@media(max-width:900px){.v92-hero-grid{grid-template-columns:1fr}.v92-summary3,.v92-title3,.v92-detail-grid,.v92-value-grid{grid-template-columns:1fr}.v92-main-grid{grid-template-columns:repeat(2,1fr)}.v92-sku-layout,.v92-review-layout{grid-template-columns:1fr}.v92-choice{border-left:0;border-top:1px solid var(--v92-line);padding:16px 0 0}.v92-plan{grid-template-columns:1fr}.v92-head p{display:none}}
|
||||
@media(max-width:560px){.wrap{width:min(100% - 24px,1180px)}.v92-hero h1{font-size:27px}.v92-opinion{grid-template-columns:1fr;gap:5px}.v92-main-grid{grid-template-columns:1fr 1fr}.v92-sku-mosaic,.v92-buyer-grid{grid-template-columns:repeat(2,1fr)}}
|
||||
|
||||
/* =========================================================
|
||||
V9.2.5 OWNER + PROFESSIONAL USER FINAL REPORT
|
||||
老板先决策;专业用户再看图片证据
|
||||
图片完整展示,不裁切
|
||||
========================================================= */
|
||||
.visual img{object-fit:contain!important;background:var(--v92-soft)}
|
||||
.v92-cover .visual,.v92-cover img{background:transparent!important}
|
||||
.main-ratio{aspect-ratio:auto!important;height:310px}
|
||||
.wide-ratio{aspect-ratio:auto!important;height:260px}
|
||||
.square-ratio{aspect-ratio:auto!important;height:145px}
|
||||
.plan-ratio{aspect-ratio:auto!important;height:190px}
|
||||
.v92-buyer-grid .square-ratio{height:160px}
|
||||
.v92-cover .cover-ratio{height:auto;min-height:300px;max-height:420px}
|
||||
.v92-cover .cover-ratio img{height:auto!important;max-height:420px;width:100%;object-fit:contain!important}
|
||||
|
||||
.v924-decision3 article{padding:15px 16px}
|
||||
.v924-decision3 b{font-size:14px}
|
||||
.v924-decision3 p{font-size:10.5px;margin-top:4px}
|
||||
|
||||
.v924-title2{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:12px}
|
||||
.v924-title2 article{padding:13px 14px;border:1px solid var(--v92-line);border-radius:8px;background:#fff}
|
||||
.v924-title2 span{display:block;font-size:9px;color:var(--v92-muted);font-weight:760}
|
||||
.v924-title2 b{display:block;margin-top:5px;color:var(--v92-ink);font-size:12.5px;line-height:1.45}
|
||||
|
||||
.v924-image-info{padding-top:8px}
|
||||
.v924-row-title{display:flex;align-items:baseline;justify-content:space-between;gap:8px}
|
||||
.v924-row-title strong{font-size:12px;color:var(--v92-ink)}
|
||||
.v924-row-title span{font-size:8px;color:var(--v92-muted)}
|
||||
.v924-chips{display:flex;gap:5px;flex-wrap:wrap;margin-top:6px}
|
||||
.v924-chips i{font-style:normal;padding:3px 6px;border-radius:99px;background:var(--v92-soft);color:#586675;font-size:9px;line-height:1.35}
|
||||
.v924-image-info p,.v924-detail-info p{margin:7px 0 0;font-size:10px;color:var(--v92-text);line-height:1.4}
|
||||
.v924-image-info p b{font-size:8px;color:var(--v92-blue);margin-right:5px}
|
||||
|
||||
.v924-details{margin-top:15px;border-top:1px solid var(--v92-line)}
|
||||
.v924-details summary{padding:12px 0;cursor:pointer;color:var(--v92-blue);font-size:11px;font-weight:700;list-style:none}
|
||||
.v924-details summary::-webkit-details-marker{display:none}
|
||||
.v924-details .v92-table-wrap{margin-top:0}
|
||||
|
||||
.v924-detail-info{padding:10px 11px!important}
|
||||
.v924-detail-info strong{font-size:12px!important;color:var(--v92-ink)}
|
||||
|
||||
.v924-plan-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}
|
||||
.v924-plan-card{border:1px solid var(--v92-line);border-radius:9px;overflow:hidden;background:#fff}
|
||||
.v924-plan-body{padding:12px}
|
||||
.v924-plan-eyebrow{font-size:8px;color:var(--v92-blue);font-weight:800}
|
||||
.v924-plan-body h3{margin:4px 0 8px;color:var(--v92-ink);font-size:15px;line-height:1.3}
|
||||
.v924-plan-body>p{margin:0 0 8px;color:var(--v92-muted);font-size:10.5px;line-height:1.45}
|
||||
.v924-plan-body dl{margin:0}
|
||||
.v924-plan-body dl div{display:grid;grid-template-columns:36px 1fr;gap:6px;padding:7px 0;border-top:1px solid var(--v92-line)}
|
||||
.v924-plan-body dt{font-size:8px;color:var(--v92-muted);font-weight:760}
|
||||
.v924-plan-body dd{margin:0;font-size:10.5px;color:var(--v92-text);line-height:1.45}
|
||||
|
||||
/* V9.2.5 removes repeated explanatory subcopy from default reading layer. */
|
||||
.v92-evidence-note{font-size:9px}
|
||||
.v92-insights article p{display:none}
|
||||
.v92-summary3 p:empty{display:none}
|
||||
|
||||
@media(max-width:900px){
|
||||
.v924-title2,.v924-plan-grid{grid-template-columns:1fr}
|
||||
.main-ratio{height:340px}.wide-ratio{height:300px}.plan-ratio{height:240px}
|
||||
}
|
||||
@media(max-width:560px){
|
||||
.main-ratio{height:280px}.wide-ratio{height:240px}.square-ratio{height:150px}.plan-ratio{height:220px}
|
||||
}
|
||||
|
||||
|
||||
/* =========================================================
|
||||
V9.2.5 BIG VISUAL / BIG TYPE FINAL REPORT
|
||||
Big images tell facts; big type tells judgement; short lines tell actions.
|
||||
========================================================= */
|
||||
:root{--v925-ink:#141b24;--v925-text:#44505d;--v925-muted:#8a95a3;--v925-line:#e8edf2;--v925-soft:#f6f8fa;--v925-blue:#315fba;--v925-blue-soft:#eef4ff}
|
||||
.wrap{width:min(1240px,calc(100% - 56px))}
|
||||
.v92-nav .wrap{height:58px}.v92-nav .brand{font-size:15px}.v92-links{gap:22px}.v92-links a{font-size:13px}
|
||||
.v92-hero{padding:58px 0 42px}.v92-hero-grid{grid-template-columns:.9fr 1.1fr;gap:54px;align-items:center}
|
||||
.v92-kicker{font-size:12px;letter-spacing:.08em}.v92-hero h1{font-size:48px;line-height:1.12;letter-spacing:-.035em;margin:10px 0 14px}.v92-meta{font-size:14px}
|
||||
.v925-hero-claim{margin-top:28px;font-size:26px;line-height:1.48;font-weight:800;color:var(--v925-ink);max-width:620px}
|
||||
.v92-cover .cover-ratio{min-height:420px;max-height:530px}.v92-cover .cover-ratio img{max-height:530px!important}
|
||||
.v92-section{padding:70px 0}.v92-head{margin-bottom:28px}.v92-head>span{font-size:12px}.v92-head h2{font-size:36px;line-height:1.2;letter-spacing:-.025em}.v92-head p{font-size:12px}
|
||||
.v925-opinion{display:block!important;padding:0!important;background:transparent!important;border:0!important;margin:0 0 34px!important;max-width:940px}.v925-opinion strong{font-size:22px!important;line-height:1.55!important;color:var(--v925-ink)!important}
|
||||
.v92-summary3{gap:28px;border:0}.v92-summary3 article{padding:20px 0 0;border-right:0!important;border-top:2px solid var(--v925-ink)}.v92-summary3 span{font-size:13px}.v92-summary3 b{font-size:28px;line-height:1.25;margin-top:9px}.v92-summary3 p{font-size:15px;line-height:1.5;margin-top:8px}
|
||||
.v92-title-current{padding:18px 20px;border-radius:12px;font-size:17px;margin-bottom:18px}.v92-title-current small{font-size:12px}.v924-title2{gap:18px}.v924-title2 article{padding:22px;border-radius:12px}.v924-title2 span{font-size:12px}.v924-title2 b{font-size:22px;line-height:1.35;margin-top:7px}
|
||||
.visual{background:var(--v925-soft);border-radius:14px}.visual img{object-fit:contain!important}.main-ratio,.wide-ratio,.square-ratio,.plan-ratio{aspect-ratio:auto}
|
||||
.v92-main-grid{grid-template-columns:1fr 1fr;gap:28px}.v925-main-card:nth-child(5){grid-column:1/-1;max-width:620px}.v925-main-card .main-ratio{height:560px}.v925-main-card:nth-child(5) .main-ratio{height:620px}
|
||||
.v925-image-info{padding:16px 2px 0}.v925-row-title{display:flex;justify-content:space-between;align-items:baseline;gap:12px}.v925-row-title h3{font-size:24px;margin:0;color:var(--v925-ink)}.v925-row-title span{font-size:12px;color:var(--v925-muted)}
|
||||
.v925-signals{margin-top:9px;font-size:18px;line-height:1.5;font-weight:760;color:var(--v925-ink)}.v925-action{margin:9px 0 0!important;font-size:14px!important;line-height:1.45!important;color:var(--v925-blue)!important;font-weight:800!important}
|
||||
.v924-details{margin-top:26px}.v924-details summary{font-size:14px;padding:16px 0}
|
||||
.v925-detail-list{display:grid;gap:54px}.v925-detail-block{display:grid;grid-template-columns:1.2fr .8fr;gap:34px;align-items:center}.v925-detail-block:nth-child(even){grid-template-columns:.8fr 1.2fr}.v925-detail-block:nth-child(even) .visual{order:2}.v925-detail-block .wide-ratio{min-height:520px}.v925-detail-copy h3{font-size:30px;line-height:1.2;margin:0 0 14px;color:var(--v925-ink)}.v925-detail-copy .v925-signals{font-size:20px}.v925-detail-copy .v925-action{font-size:15px!important}.v92-evidence-note{font-size:12px;margin-top:24px}
|
||||
.v92-sku-layout{grid-template-columns:1.3fr .7fr;gap:36px}.v92-sku-mosaic{grid-template-columns:repeat(2,1fr);gap:12px}.v92-sku-mosaic .square-ratio{height:300px}.v92-choice{padding-left:24px}.v92-choice-row{padding:17px 0;grid-template-columns:42px 1fr}.v92-choice-row span{font-size:11px}.v92-choice-row b{font-size:21px}.v92-choice-row small{font-size:13px;line-height:1.45}.v92-choice-note{margin-top:22px;padding:18px;border-radius:12px;font-size:17px;font-weight:760}.v92-status{font-size:11px;margin-top:12px}
|
||||
.v92-review-layout{grid-template-columns:1.15fr .85fr;gap:38px}.v92-buyer-grid{grid-template-columns:1fr 1fr;gap:12px}.v92-buyer-grid .square-ratio{height:330px}.v92-insights article{padding:20px 0}.v92-insights span{font-size:12px}.v92-insights b{font-size:24px;line-height:1.35;margin-top:6px}
|
||||
.v925-plan-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:18px}.v925-plan-card{border:1px solid var(--v925-line);border-radius:16px;overflow:hidden;background:#fff}.v925-plan-card .plan-ratio{height:300px}.v925-plan-body{padding:22px}.v925-plan-eyebrow{font-size:12px;color:var(--v925-blue);font-weight:800}.v925-plan-body h3{font-size:25px;line-height:1.25;margin:6px 0 14px;color:var(--v925-ink)}.v925-plan-body dl{margin:0}.v925-plan-body dl div{display:grid;grid-template-columns:52px 1fr;gap:10px;padding:12px 0;border-top:1px solid var(--v925-line)}.v925-plan-body dt{font-size:12px;color:var(--v925-muted);font-weight:800}.v925-plan-body dd{margin:0;font-size:15px;color:var(--v925-ink);line-height:1.5}
|
||||
.v92-footer{font-size:12px;padding:34px 0 54px}
|
||||
@media(max-width:900px){.wrap{width:min(100% - 28px,1240px)}.v92-hero-grid,.v92-sku-layout,.v92-review-layout,.v925-detail-block,.v925-detail-block:nth-child(even){grid-template-columns:1fr}.v92-hero h1{font-size:38px}.v925-hero-claim{font-size:21px}.v92-summary3,.v924-title2,.v925-plan-grid{grid-template-columns:1fr}.v92-main-grid{grid-template-columns:1fr}.v925-main-card:nth-child(5){grid-column:auto;max-width:none}.v925-detail-block:nth-child(even) .visual{order:0}.v92-head h2{font-size:30px}.v92-head p{display:none}.v92-sku-mosaic{grid-template-columns:1fr 1fr}.v92-buyer-grid{grid-template-columns:1fr 1fr}}
|
||||
@media(max-width:560px){.v92-hero h1{font-size:32px}.v925-hero-claim{font-size:19px}.v925-main-card .main-ratio,.v925-main-card:nth-child(5) .main-ratio{height:440px}.v925-detail-block .wide-ratio{min-height:400px}.v92-sku-mosaic .square-ratio,.v92-buyer-grid .square-ratio{height:220px}}
|
||||
/* JSON report view */
|
||||
.report-app .wrap{max-width:1180px;margin:0 auto;padding:0 24px}
|
||||
.report-section{padding:42px 0;border-bottom:1px solid #e8ece7}
|
||||
.report-section h2{margin:0 0 22px;font-size:28px}
|
||||
.report-app .grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:16px}
|
||||
.report-app .card{background:#fff;border:1px solid #e1e7e0;border-radius:14px;padding:18px;box-shadow:0 5px 18px #172b1a0b}
|
||||
.report-app .card h3{margin:0 0 10px;font-size:17px}.report-app .card p{margin:7px 0;line-height:1.6}
|
||||
.report-app .gallery{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:14px;margin-bottom:18px}
|
||||
.report-app figure{margin:0;background:#f4f7f3;border-radius:12px;overflow:hidden}.report-app figure img{width:100%;height:220px;object-fit:contain;display:block}.report-app figcaption{padding:7px 10px;font-size:12px;color:#647064}
|
||||
@@ -0,0 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>商品开品分析报告</title><link rel="stylesheet" href="/report.css"></head>
|
||||
<body><main id="app" class="report-app"><p>正在加载报告数据…</p></main>
|
||||
<script src="/report.js"></script></body></html>
|
||||
@@ -0,0 +1,26 @@
|
||||
/* 独立报告视图:页面只负责渲染 JSON,不在 Python 中拼接 HTML。 */
|
||||
const esc = v => String(v ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
const arr = v => Array.isArray(v) ? v : [];
|
||||
const text = (v, keys=['value','label','headline','note','insight','action','name']) => {
|
||||
if (Array.isArray(v)) return v.map(x => text(x, keys)).filter(Boolean).join(' · ');
|
||||
if (typeof v === 'string' || typeof v === 'number') return String(v);
|
||||
if (v && typeof v === 'object') for (const k of keys) if (v[k] != null && v[k] !== '') return text(v[k], keys);
|
||||
return '';
|
||||
};
|
||||
function cards(items, fields=['headline','note']) {
|
||||
return arr(items).map(x => `<article class="card"><h3>${esc(text(x,['label','name','type']))}</h3>${fields.map(k=>text(x,[k])).filter(Boolean).map(v=>`<p>${esc(v)}</p>`).join('')}</article>`).join('');
|
||||
}
|
||||
function images(data, group, limit=8) {
|
||||
const list = arr(data.evidenceLedger).filter(e => e?.type === 'image' && e?.meta?.group === group).slice(0,limit);
|
||||
return list.map(e => `<figure><img loading="lazy" src="${esc(e.meta.localUrl || e.value)}" alt="商品${esc(group)}"><figcaption>${esc(e.id)}</figcaption></figure>`).join('') || '<p>未采集</p>';
|
||||
}
|
||||
function section(id, title, body) { return `<section id="${id}" class="report-section"><div class="wrap"><h2>${esc(title)}</h2>${body}</div></section>`; }
|
||||
async function main() {
|
||||
const q = new URLSearchParams(location.search); const source = window.REPORT_DATA || q.get('data');
|
||||
if (!source) throw new Error('缺少 data 参数');
|
||||
const data = await fetch(source).then(r => r.json()); const exp = data.experienceSolution || {}; const raw = data.facts || {}; const p = raw.product || {}; const s = raw.sales || {};
|
||||
const summary = exp.reportSummary || {}; document.title = summary.title || p.title || '商品开品分析报告';
|
||||
const owner = exp.ownerOverview?.cards || []; const plans = exp.newProductPlans?.plans || []; const title = exp.titleAnalysis || {}; const visual = exp.visualCommerce || {}; const detail = exp.detailCommerce || {};
|
||||
document.getElementById('app').innerHTML = `<nav class="v92-nav"><div class="wrap"><div class="brand">三笙 · 商品开品分析</div><a href="#decision">老板速览</a> <a href="#plan">新品方向</a> <a href="#main">主图</a> <a href="#review">评价</a></div></nav><header class="v92-hero"><div class="wrap"><div class="v92-kicker">PRODUCT DECISION REPORT · JSON VIEW</div><h1>${esc(summary.title || p.title || '商品分析报告')}</h1><p>${esc(summary.verdict || '')}</p><small>价格 ${esc(s.currentPrice || '未采集')} · 评论 ${esc(data.baseline?.reviewCount || 0)}</small></div></header>${section('decision','老板速览',`<div class="grid">${cards(owner)}</div>`)}${section('plan','下一款方向',`<div class="grid">${cards(plans,['sourceType','whyThisPlan','productAction','pageAction'])}</div>`)}${section('title','标题表达',`<p>${esc(title.professionalOpinion || '')}</p>`)}${section('main','主图在卖什么',`<div class="gallery">${images(data,'main')}</div><div class="grid">${cards(visual.items,['imageRole','visualSignals','nextAction'])}</div>`)}${section('detail','详情页证明什么',`<div class="gallery">${images(data,'detail')}</div><div class="grid">${cards(detail.contentGroups,['type','visualSignals','nextAction'])}</div>`)}${section('sku','SKU怎么选',`<p>${esc(exp.skuAnalysis?.professionalOpinion || '')}</p>${cards(exp.skuAnalysis?.selectionDimensions,['name','values','evidenceIds'])}`)}${section('review','消费者实际感受到什么',`<div class="gallery">${images(data,'buyerShow',6)}</div>${cards(exp.customerExperience?.consumerInsights?.experienceSignals,['type','insight'])}`)}<footer class="v92-footer"><div class="wrap">数据来自本次采集证据;页面由独立 HTML + JSON 渲染</div></footer>`;
|
||||
}
|
||||
main().catch(e => { document.getElementById('app').innerHTML = `<p class="error">报告加载失败:${esc(e.message)}</p>`; });
|
||||
@@ -0,0 +1,531 @@
|
||||
import json, os, threading, time, uuid, traceback, html, base64, csv, io, zipfile, re, mimetypes, hashlib, urllib.request, urllib.error
|
||||
import xml.etree.ElementTree as ET
|
||||
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
|
||||
from urllib.parse import urlparse, unquote_to_bytes
|
||||
from pathlib import Path
|
||||
from analysis import analyze, normalize
|
||||
from renderer import render
|
||||
from ai_client import configured, load_config, probe_model_api
|
||||
from prompt_templates import TEMPLATES, PRODUCT_VARIABLES
|
||||
|
||||
ROOT=Path(__file__).resolve().parent
|
||||
PROMPT_CONFIG=ROOT/'prompts.json'
|
||||
REPORTS=ROOT/'reports'; REPORTS.mkdir(exist_ok=True)
|
||||
MONITOR_DIR=ROOT/'monitor_data'; MONITOR_DIR.mkdir(exist_ok=True)
|
||||
PORT=int(os.getenv('TMALL_AI_PORT','17962'))
|
||||
SERVER_VERSION='9.2.5'
|
||||
MODEL_PROBE={'ok':False,'stage':'startup','error':'尚未检测'}
|
||||
MODEL_PROBE_AT=0
|
||||
TASKS={}
|
||||
LOCK=threading.Lock()
|
||||
|
||||
|
||||
IMAGE_ASSET_ROOT=REPORTS/'assets'; IMAGE_ASSET_ROOT.mkdir(exist_ok=True)
|
||||
|
||||
def _asset_ext(url, content_type=''):
|
||||
c=(content_type or '').split(';',1)[0].strip().lower()
|
||||
mapping={'image/jpeg':'.jpg','image/png':'.png','image/webp':'.webp','image/gif':'.gif','image/avif':'.avif'}
|
||||
if c in mapping:return mapping[c]
|
||||
path=urlparse(str(url or '')).path.lower()
|
||||
for ext in ('.webp','.jpg','.jpeg','.png','.gif','.avif'):
|
||||
if ext in path:return '.jpg' if ext=='.jpeg' else ext
|
||||
return '.img'
|
||||
|
||||
def _normalize_asset_url(url):
|
||||
url=str(url or '').strip().replace('&','&')
|
||||
if url.startswith('//'):url='https:'+url
|
||||
return url
|
||||
|
||||
def _looks_like_image(data, content_type=''):
|
||||
if not data:return False
|
||||
c=(content_type or '').split(';',1)[0].strip().lower()
|
||||
if c.startswith('image/'):return True
|
||||
sig=data[:16]
|
||||
return (sig.startswith(b'\xff\xd8\xff') or sig.startswith(b'\x89PNG\r\n\x1a\n') or sig[:6] in (b'GIF87a',b'GIF89a') or (len(sig)>=12 and sig[:4]==b'RIFF' and sig[8:12]==b'WEBP') or b'ftypavif' in data[:32])
|
||||
|
||||
def _download_image(url, timeout=18):
|
||||
"""Download a commerce image with several anti-hotlink compatible header strategies."""
|
||||
url=_normalize_asset_url(url)
|
||||
strategies=[
|
||||
{'Referer':'https://detail.tmall.com/','Origin':'https://detail.tmall.com'},
|
||||
{'Referer':'https://item.taobao.com/','Origin':'https://item.taobao.com'},
|
||||
{'Referer':'https://www.taobao.com/'},
|
||||
{},
|
||||
]
|
||||
last=None
|
||||
for extra in strategies:
|
||||
try:
|
||||
headers={
|
||||
'User-Agent':'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/151 Safari/537.36',
|
||||
'Accept':'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
|
||||
'Accept-Language':'zh-CN,zh;q=0.9,en;q=0.8',
|
||||
'Cache-Control':'no-cache',
|
||||
}
|
||||
headers.update(extra)
|
||||
req=urllib.request.Request(url,headers=headers)
|
||||
with urllib.request.urlopen(req,timeout=timeout) as r:
|
||||
data=r.read(16*1024*1024+1)
|
||||
if len(data)>16*1024*1024:raise ValueError('image too large')
|
||||
ctype=r.headers.get('Content-Type','')
|
||||
if not _looks_like_image(data,ctype):raise ValueError('not image response')
|
||||
return data,ctype
|
||||
except Exception as ex:last=ex
|
||||
raise last or RuntimeError('image download failed')
|
||||
|
||||
def cache_report_images(result, task_id):
|
||||
"""Persist report images under /reports/assets. Keep the source URL as a browser fallback."""
|
||||
root=IMAGE_ASSET_ROOT/task_id; root.mkdir(parents=True,exist_ok=True)
|
||||
ok=failed=skipped=0
|
||||
for e in result.get('evidenceLedger') or []:
|
||||
if not isinstance(e,dict) or e.get('type')!='image':continue
|
||||
url=_normalize_asset_url(e.get('value'))
|
||||
meta=e.setdefault('meta',{})
|
||||
if not url:
|
||||
skipped+=1;continue
|
||||
# Keep source URL even when caching fails so the renderer can still try the browser.
|
||||
meta['sourceUrl']=url
|
||||
group=re.sub(r'[^a-zA-Z0-9_-]+','_',str(meta.get('group') or 'other'))
|
||||
eid=re.sub(r'[^a-zA-Z0-9_-]+','_',str(e.get('id') or hashlib.sha1(url.encode()).hexdigest()[:12]))
|
||||
group_dir=root/group; group_dir.mkdir(parents=True,exist_ok=True)
|
||||
try:
|
||||
if url.startswith('data:image/'):
|
||||
head,payload=url.split(',',1)
|
||||
data=base64.b64decode(payload) if ';base64' in head else unquote_to_bytes(payload)
|
||||
ctype=head[5:].split(';',1)[0]
|
||||
elif url.startswith(('http://','https://')):
|
||||
data,ctype=_download_image(url)
|
||||
else:
|
||||
raise ValueError('unsupported image url')
|
||||
ext=_asset_ext(url,ctype); target=group_dir/(eid+ext); target.write_bytes(data)
|
||||
# Browser URL is absolute-from-server-root and therefore works from every report route.
|
||||
meta['localUrl']=f'/reports/assets/{task_id}/{group}/{target.name}'
|
||||
meta['cacheStatus']='success'; meta.pop('cacheError',None); ok+=1
|
||||
except Exception as ex:
|
||||
meta.pop('localUrl',None); meta['cacheStatus']='failed'; meta['cacheError']=str(ex)[:220]; failed+=1
|
||||
result.setdefault('meta',{})['imageCache']={'success':ok,'failed':failed,'skipped':skipped,'mode':'local-first-multi-retry-remote-fallback'}
|
||||
return result
|
||||
|
||||
def jdump(o): return json.dumps(o,ensure_ascii=False).encode('utf-8')
|
||||
|
||||
|
||||
def _norm_key(k): return re.sub(r'\s+','',str(k or '')).lower()
|
||||
MONITOR_ALIASES={
|
||||
'date':['日期','采集日期','监测日期','统计日期','date','day','capturedate','capturedat','capturedAt'],
|
||||
'itemId':['商品id','itemid','item_id','id','商品链接','链接','url','商品url'],
|
||||
'sales':['销量展示值','销量展示','销量','销售量','salesdisplay','sales','sold','销量档位','已售'],
|
||||
'rank':['榜单排名','排名','rank','ranking','榜单','类目排名'],
|
||||
'price':['价格','当前价','当前价格','到手价','price','currentprice'],
|
||||
'promotion':['活动','促销','promotion','promotions','活动信息','优惠'],
|
||||
'title':['标题','商品标题','title','name','商品名称']
|
||||
}
|
||||
def _pick(row,name):
|
||||
m={_norm_key(k):v for k,v in (row or {}).items()}
|
||||
for a in MONITOR_ALIASES[name]:
|
||||
if _norm_key(a) in m:return m[_norm_key(a)]
|
||||
return ''
|
||||
def _xlsx_rows(data):
|
||||
# 标准 xlsx 第一张工作表;纯标准库解析,用户本机无需安装 openpyxl。
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as z:
|
||||
shared=[]
|
||||
if 'xl/sharedStrings.xml' in z.namelist():
|
||||
root=ET.fromstring(z.read('xl/sharedStrings.xml'))
|
||||
ns={'a':'http://schemas.openxmlformats.org/spreadsheetml/2006/main'}
|
||||
for si in root.findall('a:si',ns): shared.append(''.join(t.text or '' for t in si.findall('.//a:t',ns)))
|
||||
wb=ET.fromstring(z.read('xl/workbook.xml'))
|
||||
rel=ET.fromstring(z.read('xl/_rels/workbook.xml.rels'))
|
||||
relmap={x.attrib.get('Id'):x.attrib.get('Target') for x in rel}
|
||||
ns={'a':'http://schemas.openxmlformats.org/spreadsheetml/2006/main','r':'http://schemas.openxmlformats.org/officeDocument/2006/relationships'}
|
||||
sh=wb.find('a:sheets/a:sheet',ns); rid=sh.attrib.get('{http://schemas.openxmlformats.org/officeDocument/2006/relationships}id')
|
||||
target=relmap.get(rid,'worksheets/sheet1.xml').lstrip('/')
|
||||
if not target.startswith('xl/'): target='xl/'+target
|
||||
root=ET.fromstring(z.read(target))
|
||||
rows=[]
|
||||
for rr in root.findall('.//a:sheetData/a:row',ns):
|
||||
vals=[]
|
||||
for c in rr.findall('a:c',ns):
|
||||
t=c.attrib.get('t'); v=c.find('a:v',ns); val='' if v is None else (v.text or '')
|
||||
if t=='s' and val!='':
|
||||
try: val=shared[int(val)]
|
||||
except: pass
|
||||
elif t=='inlineStr':
|
||||
val=''.join(x.text or '' for x in c.findall('.//a:t',ns))
|
||||
vals.append(val)
|
||||
rows.append(vals)
|
||||
if not rows:return []
|
||||
head=[str(x).strip() for x in rows[0]]
|
||||
return [{head[i]:(r[i] if i<len(r) else '') for i in range(len(head))} for r in rows[1:] if any(str(x).strip() for x in r)]
|
||||
def parse_monitor_file(filename,data):
|
||||
ext=Path(filename).suffix.lower()
|
||||
if ext=='.json':
|
||||
obj=json.loads(data.decode('utf-8-sig')); rows=obj if isinstance(obj,list) else obj.get('data',[]) if isinstance(obj,dict) else []
|
||||
elif ext=='.csv':
|
||||
text=data.decode('utf-8-sig',errors='replace'); rows=list(csv.DictReader(io.StringIO(text)))
|
||||
elif ext in ('.xlsx','.xls'):
|
||||
if ext=='.xls': raise ValueError('旧版 .xls 暂不支持,请另存为 .xlsx 或 CSV')
|
||||
rows=_xlsx_rows(data)
|
||||
else: raise ValueError('仅支持 .xlsx / .csv / .json')
|
||||
return rows
|
||||
|
||||
def _norm_item_id(v):
|
||||
s=str(v or '').strip()
|
||||
if not s:return ''
|
||||
# URL / 普通数字 / Excel 科学计数法都尽量恢复为商品ID
|
||||
m=re.search(r'[?&]id=(\d+)',s)
|
||||
if m:return m.group(1)
|
||||
if re.fullmatch(r'\d+(?:\.0+)?',s): return s.split('.')[0]
|
||||
if re.fullmatch(r'\d+(?:\.\d+)?[eE][+-]?\d+',s):
|
||||
try:return str(int(float(s)))
|
||||
except:return s
|
||||
m=re.search(r'(?<!\d)(\d{8,})(?!\d)',s)
|
||||
return m.group(1) if m else s
|
||||
|
||||
def _norm_monitor_date(v):
|
||||
s=str(v or '').strip()
|
||||
if not s:return ''
|
||||
# Excel 日期序列号
|
||||
try:
|
||||
f=float(s)
|
||||
if 20000 <= f <= 80000:
|
||||
import datetime
|
||||
d=datetime.datetime(1899,12,30)+datetime.timedelta(days=f)
|
||||
return d.strftime('%Y-%m-%d')
|
||||
except: pass
|
||||
s=s.replace('/','-').replace('.','-')
|
||||
m=re.match(r'(\d{4})-(\d{1,2})-(\d{1,2})',s)
|
||||
if m:return f"{m.group(1)}-{int(m.group(2)):02d}-{int(m.group(3)):02d}"
|
||||
return s
|
||||
|
||||
def normalize_monitor_rows(rows,item_id,force_bind=False):
|
||||
out=[]
|
||||
current=_norm_item_id(item_id)
|
||||
for r in rows:
|
||||
rid=_norm_item_id(_pick(r,'itemId'))
|
||||
if rid and current and rid!=current and not force_bind: continue
|
||||
date=_norm_monitor_date(_pick(r,'date'))
|
||||
sales=str(_pick(r,'sales') or '').strip()
|
||||
if not date or not sales: continue
|
||||
out.append({
|
||||
'date':date,
|
||||
'itemId':current or rid,
|
||||
'salesDisplay':sales,
|
||||
'ranking':str(_pick(r,'rank') or '').strip(),
|
||||
'price':str(_pick(r,'price') or '').strip(),
|
||||
'promotion':str(_pick(r,'promotion') or '').strip(),
|
||||
'title':str(_pick(r,'title') or '').strip()
|
||||
})
|
||||
by={}
|
||||
for x in out: by[x['date']]=x
|
||||
return sorted(by.values(),key=lambda x:x['date'])[-7:]
|
||||
def monitor_path(item_id): return MONITOR_DIR/(re.sub(r'\D','',str(item_id)) or 'unknown')
|
||||
def set_task(tid, **kw):
|
||||
with LOCK: TASKS.setdefault(tid,{}).update(kw)
|
||||
|
||||
def task_evidence_summary(raw):
|
||||
clean=normalize(raw);images=clean.get('images') or {}; collection=clean.get('collection') or {}
|
||||
groups=(('main','商品主图'),('detail','详情图'),('sku','SKU 图'),('buyerShow','评论原图'))
|
||||
preview=[];seen=set()
|
||||
for key,label in groups:
|
||||
for url in (images.get(key) or [])[:4]:
|
||||
if not isinstance(url,str) or not url.startswith(('http://','https://','data:image/')) or url in seen:continue
|
||||
seen.add(url);preview.append({'url':url,'group':key,'label':label})
|
||||
if len(preview)>=8:break
|
||||
if len(preview)>=8:break
|
||||
return {
|
||||
'counts':{
|
||||
'parameters':len(clean.get('attributes') or []),'reviews':len(clean.get('reviews') or []),
|
||||
'questions':len(clean.get('questions') or []),'main':len(images.get('main') or []),
|
||||
'detail':len(images.get('detail') or []),'sku':len(images.get('sku') or []),
|
||||
'buyerShow':len(images.get('buyerShow') or []),
|
||||
},
|
||||
'publicReviewCount':collection.get('publicReviewCount') or '',
|
||||
'reviewComplete':bool(collection.get('reviewCollectionComplete')),
|
||||
'previewImages':preview,
|
||||
}
|
||||
|
||||
def worker(tid, raw):
|
||||
try:
|
||||
evidence_summary=task_evidence_summary(raw);counts=evidence_summary['counts']
|
||||
set_task(tid,status='analyzing',progress=28,evidenceSummary=evidence_summary,steps={'collect':'done','evidence':'waiting','planner':'waiting','product':'waiting','fashion':'waiting','visual':'not_collected' if not counts['main'] else 'waiting','merchandising':'waiting','detail':'not_collected' if not counts['detail'] else 'waiting','review':'waiting','qa':'waiting','competition':'waiting','strategy':'waiting','plans':'waiting','editor':'waiting','report':'waiting'})
|
||||
def progress(step,state,pct):
|
||||
with LOCK:
|
||||
t=TASKS[tid]; t['progress']=pct
|
||||
if step=='visual' and not counts['main']:t['steps'][step]='not_collected'
|
||||
elif step=='detail' and not counts['detail']:t['steps'][step]='not_collected'
|
||||
else:t['steps'][step]=state
|
||||
result=analyze(raw,progress)
|
||||
if not (result.get('meta') or {}).get('reportReady'):
|
||||
(REPORTS/f'{tid}.failed.json').write_text(json.dumps(result,ensure_ascii=False,indent=2),'utf-8')
|
||||
raise RuntimeError((result.get('meta') or {}).get('reportBlockedReason') or '模型未生成可输出报告,本次不使用兜底内容。')
|
||||
set_task(tid,status='rendering',progress=95)
|
||||
TASKS[tid]['steps']['report']='processing'
|
||||
result=cache_report_images(result,tid)
|
||||
(REPORTS/f'{tid}.json').write_text(json.dumps(result,ensure_ascii=False,indent=2),'utf-8')
|
||||
# 报告页面是静态 HTML;数据单独由 JSON 接口提供,便于部署和独立迭代 UI。
|
||||
set_task(tid,status='complete',progress=100,reportUrl=f'/report.html?data=/reports/{tid}.json',analysisUrl=f'/reports/{tid}.json')
|
||||
TASKS[tid]['steps']['report']='done'
|
||||
except Exception as e:
|
||||
with LOCK:
|
||||
task=TASKS.setdefault(tid,{})
|
||||
for key,state in (task.get('steps') or {}).items():
|
||||
if str(state).startswith('processing'):task['steps'][key]='failed'
|
||||
set_task(tid,status='error',error=str(e),trace=traceback.format_exc(),progress=100)
|
||||
|
||||
def task_page(tid):
|
||||
return f'''<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>行业平台报告生成中</title><link rel="stylesheet" href="/report.css"></head><body class="loading-page"><main class="loading-shell"><header class="loading-head"><div><div class="eyebrow">SANXIAN · PRODUCT INTELLIGENCE</div><h1 id="title">正在生成商品产品分析报告</h1><p id="desc">按真实采集证据完成核验、评论聚合、主图分析、详情参数解读、活动分析、产品定义、推荐方案与落地方式。</p></div><strong id="percent">8%</strong></header><section class="loading-evidence"><div class="loading-section-title"><span>本次证据</span><small id="reviewState"></small></div><div id="evidenceStats" class="loading-stats"></div><div id="imagePreview" class="loading-images"></div></section><section class="loading-work"><div class="loading-section-title"><span>生成进度</span><small>缺失资料会明确标记,不会补写</small></div><div id="steps" class="loading-steps"></div></section><div class="topic-bar"><i id="bar" style="width:8%"></i></div><p id="err" class="error"></p></main><script>window.TASK_ID={json.dumps(tid)}</script><script src="/task.js"></script></body></html>'''
|
||||
|
||||
TASK_JS='''
|
||||
const labels={collect:'商品页面采集',evidence:'证据清洗与建库',planner:'报告结构编排',product:'商品与参数核验',fashion:'设计资产梳理',visual:'商品主图核验',merchandising:'标题与活动核验',detail:'商品详情图核验',review:'评论体验聚合',qa:'购买问答整理',competition:'竞品数据边界',strategy:'产品结论生成',plans:'开品建议生成',editor:'最终报告汇总与校验',report:'图文报告渲染'};
|
||||
const stateText={done:'已完成',processing:'处理中',waiting:'待处理',not_collected:'未采到',failed:'失败'};
|
||||
const statLabels={parameters:'有效参数',reviews:'有效评论',questions:'去重问答',main:'商品主图',detail:'详情图',sku:'SKU 图',buyerShow:'评论原图'};
|
||||
function fillEvidence(summary){
|
||||
const counts=summary?.counts||{}; const stats=document.getElementById('evidenceStats'); stats.replaceChildren();
|
||||
Object.entries(statLabels).forEach(([key,label])=>{const card=document.createElement('div');const s=document.createElement('span');s.textContent=label;const b=document.createElement('b');b.textContent=String(counts[key]||0);card.append(s,b);stats.append(card)});
|
||||
const state=document.getElementById('reviewState'); const publicCount=summary?.publicReviewCount; state.textContent=(publicCount?`已采 ${counts.reviews||0} / 页面公开 ${publicCount} · `:`已采 ${counts.reviews||0} 条 · `)+(summary?.reviewComplete?'评论采集已确认完整':'评论采集未确认完整');
|
||||
const preview=document.getElementById('imagePreview'); preview.replaceChildren();
|
||||
(summary?.previewImages||[]).forEach((item,i)=>{const fig=document.createElement('figure');const img=document.createElement('img');img.src=item.url;img.alt=item.label+' '+(i+1);img.referrerPolicy='no-referrer';const cap=document.createElement('figcaption');cap.textContent=item.label;fig.append(img,cap);preview.append(fig)});
|
||||
preview.hidden=!preview.children.length;
|
||||
}
|
||||
function fillSteps(steps){const box=document.getElementById('steps');box.replaceChildren();Object.entries(steps||{}).forEach(([key,state])=>{const row=document.createElement('div');row.className='loading-step '+String(state).split(' ')[0];const mark=document.createElement('i');const name=document.createElement('span');name.textContent=labels[key]||key;const value=document.createElement('b');value.textContent=String(state).startsWith('processing')?'处理中':(stateText[state]||state);row.append(mark,name,value);box.append(row)})}
|
||||
async function tick(){
|
||||
const r=await fetch('/api/task/'+encodeURIComponent(window.TASK_ID)); const d=await r.json();
|
||||
document.getElementById('bar').style.width=(d.progress||0)+'%';
|
||||
document.getElementById('percent').textContent=(d.progress||0)+'%'; fillEvidence(d.evidenceSummary); fillSteps(d.steps);
|
||||
if(d.status==='complete'){ location.href=d.reportUrl; return; }
|
||||
if(d.status==='error'){ document.getElementById('title').textContent='本次未生成报告'; document.getElementById('desc').textContent='模型结论未达到完整性与证据引用要求,不显示兜底内容。'; document.getElementById('err').textContent=d.error||'未生成可输出结论'; return; }
|
||||
setTimeout(tick,900);
|
||||
} tick();
|
||||
'''
|
||||
|
||||
def save_config(data):
|
||||
p=ROOT/'config.json'
|
||||
old={}
|
||||
if p.exists():
|
||||
try: old=json.loads(p.read_text('utf-8'))
|
||||
except Exception: old={}
|
||||
key=str(data.get('api_key','')).strip()
|
||||
if not key: key=str(old.get('api_key','')).strip()
|
||||
cfg={
|
||||
'api_base':str(data.get('api_base') or old.get('api_base') or 'https://api.bananarouter.com/v1').strip().rstrip('/'),
|
||||
'api_key':key,
|
||||
'model':str(data.get('model') or old.get('model') or 'gpt-5.5').strip(),
|
||||
'chat_path':'/chat/completions','models_path':'/models','temperature':0.2,'timeout':120,'retries':2,
|
||||
'ssl_verify':True,'ca_bundle':'','extra_headers':{}
|
||||
}
|
||||
tmp=p.with_suffix('.json.tmp')
|
||||
tmp.write_text(json.dumps(cfg,ensure_ascii=False,indent=2),'utf-8')
|
||||
tmp.replace(p)
|
||||
return cfg
|
||||
|
||||
def control_page():
|
||||
global MODEL_PROBE, MODEL_PROBE_AT
|
||||
c=load_config(); is_cfg=configured()
|
||||
if is_cfg and (time.time()-MODEL_PROBE_AT>30):
|
||||
MODEL_PROBE=probe_model_api(timeout=12); MODEL_PROBE_AT=time.time()
|
||||
api_ok=bool(MODEL_PROBE.get('ok'))
|
||||
base=html.escape(str(c.get('api_base') or 'https://api.bananarouter.com/v1'))
|
||||
model=html.escape(str(c.get('model') or 'gpt-5.5'))
|
||||
cfg_err=html.escape(str(c.get('_config_error') or ''))
|
||||
probe=html.escape(str(MODEL_PROBE.get('error') or ''))
|
||||
key_state='已保存' if str(c.get('api_key') or '').strip() and not str(c.get('api_key')).startswith('YOUR_') else '未填写'
|
||||
return f'''<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>三笙 · 电商商品分析闭环 V{SERVER_VERSION}</title><style>
|
||||
*{{box-sizing:border-box}}body{{font-family:-apple-system,BlinkMacSystemFont,"PingFang SC",sans-serif;background:#f5faf9;color:#10191d;margin:0}}.wrap{{max-width:900px;margin:54px auto;padding:0 24px}}.hero{{background:#071114;color:#fff;padding:34px;border-radius:22px;border-top:5px solid #00cdb0}}h1{{font-size:38px;margin:0 0 10px}}.sub{{color:#b9d0d4}}.card{{margin-top:18px;background:#fff;border:1px solid #dbe8e8;border-radius:18px;padding:24px}}.status{{display:grid;grid-template-columns:repeat(3,1fr);gap:10px}}.pill{{padding:14px;border-radius:12px;background:#eef9f7;font-weight:700}}.ok{{color:#008c78}}.bad{{color:#c54552}}label{{display:block;font-size:13px;font-weight:700;margin:16px 0 7px}}input{{width:100%;padding:13px 14px;border:1px solid #bcd8d6;border-radius:10px;font-size:15px}}button{{margin-top:18px;border:0;border-radius:10px;padding:13px 18px;background:linear-gradient(110deg,#00cdb0,#008fd8);color:white;font-weight:800;font-size:15px;cursor:pointer}}button.secondary{{background:#071114;margin-left:8px}}#msg{{margin-top:12px;white-space:pre-wrap}}code{{background:#eaf7f5;padding:3px 6px;border-radius:5px}}@media(max-width:700px){{.status{{grid-template-columns:1fr}}}}
|
||||
</style></head><body><div class="wrap"><section class="hero"><div style="font-size:12px;letter-spacing:.16em;color:#12d8bb;font-weight:800">ONE-CLICK LOCAL SERVICE</div><h1>三笙 · 电商商品分析引擎 V{SERVER_VERSION}</h1><div class="sub">单商品手动深采;类目不限,参数与SKU动态识别,评论可持续采集,也可随时基于当前数据开始分析。</div></section>
|
||||
<section class="card"><div class="status"><div class="pill">本地服务<br><span class="ok">运行正常 · V{SERVER_VERSION}</span></div><div class="pill">模型配置<br><span class="{'ok' if is_cfg else 'bad'}">{'已配置 '+model if is_cfg else '待配置'}</span></div><div class="pill">API连接<br><span class="{'ok' if api_ok else 'bad'}">{'连通 ✓' if api_ok else '待检测/失败'}</span></div></div></section>
|
||||
<section class="card"><h2 style="margin-top:0">首次只需要填一次 API Key</h2><p style="color:#667067">默认已为 BananaRouter + gpt-5.5 配好。API Key 只保存在本机 <code>server/config.json</code>,不会写入 Chrome 扩展。</p>
|
||||
<label>API Base</label><input id="base" value="{base}"><label>API Key({key_state})</label><input id="key" type="password" placeholder="粘贴新的 sk-...;已保存时可留空"><label>模型</label><input id="model" value="{model}">
|
||||
<button onclick="save()">保存并测试连接</button><button class="secondary" onclick="test()">仅重新测试</button><div id="msg">{'配置错误:'+cfg_err if cfg_err else ('API错误:'+probe if (is_cfg and not api_ok and probe) else '')}</div></section>
|
||||
<section class="card"><h2 style="margin-top:0">下一步</h2><p>Chrome 只需加载一次 <code>extension</code> 文件夹。之后每次使用:双击启动 → 打开已登录的天猫商品页 → 可选导入监测数据 → 点击扩展“开始深度采集”。</p></section></div>
|
||||
<script>
|
||||
async function save(){{const msg=document.getElementById('msg');msg.textContent='正在保存并测试…';const r=await fetch('/api/config',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{api_base:base.value,api_key:key.value,model:model.value}})}});const d=await r.json();msg.textContent=d.ok?'✓ 配置已保存,API连接正常。现在可以去天猫商品页运行扩展。':'✕ '+(d.error||'配置失败');if(d.ok)setTimeout(()=>location.reload(),600)}}
|
||||
async function test(){{const msg=document.getElementById('msg');msg.textContent='正在测试…';const r=await fetch('/api/model-test');const d=await r.json();msg.textContent=d.ok?'✓ API连接正常,模型 '+(d.model||'')+' 可用。':'✕ '+(d.error||'连接失败')}}
|
||||
</script></body></html>'''
|
||||
|
||||
def prompt_page():
|
||||
saved={}
|
||||
if PROMPT_CONFIG.exists():
|
||||
try: saved=json.loads(PROMPT_CONFIG.read_text('utf-8'))
|
||||
except Exception: saved={}
|
||||
items={k:{'name':v.name,'body':saved.get(k,{}).get('body',v.body),'variables':list(v.variables)} for k,v in TEMPLATES.items()}
|
||||
return '<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>提示词配置</title><style>body{font-family:-apple-system,BlinkMacSystemFont,PingFang SC,sans-serif;background:#f5faf9;margin:0}.wrap{max-width:1100px;margin:30px auto;padding:0 20px}.card{background:#fff;border:1px solid #dbe8e8;border-radius:16px;padding:22px;margin:16px 0}textarea{width:100%;min-height:260px;font:14px monospace;padding:12px;border:1px solid #bcd8d6;border-radius:10px}button{background:#008f7a;color:#fff;border:0;border-radius:9px;padding:12px 18px;font-weight:700;cursor:pointer}.vars{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:8px}.var{background:#eef9f7;padding:10px;border-radius:8px;font-size:13px}</style><div class="wrap"><h1>提示词可视化配置</h1><p>编辑后保存,下一次分析任务生效。</p><section class="card"><h2>商品变量说明</h2><div class="vars">'+Object.entries(PRODUCT_VARIABLES).map(([k,v])=>`<div class="var"><b>${html.escape(k)}</b><br>${html.escape(v)}</div>`).join('')+'</div></section>'+Object.entries(items).map(([k,v])=>`<section class="card"><h2>${html.escape(k)}</h2><p>变量:${html.escape(v.variables.join('、')||'自定义')}</p><textarea data-key="${html.escape(k)}">${html.escape(v.body)}</textarea></section>`).join('')+'<button onclick="save()">保存全部模板</button><span id="msg" style="margin-left:12px"></span></div><script>async function save(){const templates={};document.querySelectorAll("textarea[data-key]").forEach(x=>templates[x.dataset.key]={body:x.value});const r=await fetch("/api/prompts",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({templates})});const d=await r.json();document.getElementById("msg").textContent=d.ok?"已保存":"保存失败:"+(d.error||"")}</script></html>'
|
||||
|
||||
class H(BaseHTTPRequestHandler):
|
||||
def log_message(self, fmt, *args): print('[HTTP]',fmt%args)
|
||||
def cors(self):
|
||||
self.send_header('Access-Control-Allow-Origin','*');self.send_header('Access-Control-Allow-Headers','Content-Type');self.send_header('Access-Control-Allow-Methods','GET,POST,OPTIONS')
|
||||
def send_json(self,obj,code=200):
|
||||
b=jdump(obj);self.send_response(code);self.cors();self.send_header('Content-Type','application/json; charset=utf-8');self.send_header('Content-Length',str(len(b)));self.end_headers();self.wfile.write(b)
|
||||
def send_file(self,p,ctype):
|
||||
p=Path(p)
|
||||
if not p.exists(): self.send_error(404);return
|
||||
b=p.read_bytes();self.send_response(200);self.cors();self.send_header('Content-Type',ctype);self.send_header('Content-Length',str(len(b)));self.end_headers();self.wfile.write(b)
|
||||
def do_OPTIONS(self): self.send_response(204);self.cors();self.end_headers()
|
||||
def do_POST(self):
|
||||
global MODEL_PROBE, MODEL_PROBE_AT
|
||||
if self.path=='/api/prompts':
|
||||
try:
|
||||
n=int(self.headers.get('Content-Length','0')); data=json.loads(self.rfile.read(n).decode('utf-8') or '{}')
|
||||
templates=data.get('templates') if isinstance(data.get('templates'),dict) else {}
|
||||
clean={k:{'body':str(v.get('body',''))[:100000]} for k,v in templates.items() if k in TEMPLATES and isinstance(v,dict)}
|
||||
PROMPT_CONFIG.write_text(json.dumps(clean,ensure_ascii=False,indent=2),'utf-8'); self.send_json({'ok':True})
|
||||
except Exception as e: self.send_json({'ok':False,'error':str(e)},400)
|
||||
return
|
||||
if self.path=='/api/monitor-import':
|
||||
try:
|
||||
n=int(self.headers.get('Content-Length','0'))
|
||||
data=json.loads(self.rfile.read(n).decode('utf-8') or '{}')
|
||||
item_id=_norm_item_id(data.get('itemId'))
|
||||
filename=str(data.get('filename') or '')
|
||||
force_bind=bool(data.get('forceBind'))
|
||||
if not item_id:
|
||||
self.send_json({'ok':False,'error':'缺少当前商品 itemId','code':'MISSING_ITEM_ID'},400); return
|
||||
blob=base64.b64decode(data.get('dataBase64') or '')
|
||||
parsed=parse_monitor_file(filename,blob)
|
||||
file_ids=sorted({ _norm_item_id(_pick(r,'itemId')) for r in parsed if _norm_item_id(_pick(r,'itemId')) })
|
||||
if file_ids and item_id not in file_ids and not force_bind:
|
||||
self.send_json({
|
||||
'ok':False,
|
||||
'code':'ITEM_ID_MISMATCH',
|
||||
'error':'监测文件中的商品ID与当前商品不一致',
|
||||
'currentItemId':item_id,
|
||||
'fileItemIds':file_ids[:20],
|
||||
'canForce':len(file_ids)==1
|
||||
},409); return
|
||||
rows=normalize_monitor_rows(parsed,item_id,force_bind=force_bind)
|
||||
if not rows:
|
||||
self.send_json({
|
||||
'ok':False,
|
||||
'code':'NO_VALID_ROWS',
|
||||
'error':'没有识别到当前商品的有效“日期 + 销量”数据',
|
||||
'fileItemIds':file_ids[:20]
|
||||
},400); return
|
||||
monitor_path(item_id).with_suffix('.json').write_text(json.dumps(rows,ensure_ascii=False,indent=2),'utf-8')
|
||||
self.send_json({
|
||||
'ok':True,'days':len(rows),
|
||||
'firstSales':rows[0].get('salesDisplay'),
|
||||
'lastSales':rows[-1].get('salesDisplay'),
|
||||
'itemId':item_id
|
||||
}); return
|
||||
except Exception as e:
|
||||
self.send_json({'ok':False,'code':'IMPORT_EXCEPTION','error':str(e)},400); return
|
||||
if self.path=='/api/monitor-clear':
|
||||
try:
|
||||
n=int(self.headers.get('Content-Length','0')); data=json.loads(self.rfile.read(n).decode('utf-8') or '{}'); p=monitor_path(data.get('itemId')).with_suffix('.json')
|
||||
if p.exists(): p.unlink()
|
||||
self.send_json({'ok':True}); return
|
||||
except Exception as e: self.send_json({'ok':False,'error':str(e)},400); return
|
||||
if self.path=='/api/config':
|
||||
try:
|
||||
n=int(self.headers.get('Content-Length','0')); data=json.loads(self.rfile.read(n).decode('utf-8') or '{}')
|
||||
save_config(data)
|
||||
MODEL_PROBE=probe_model_api(timeout=18); MODEL_PROBE_AT=time.time()
|
||||
if not MODEL_PROBE.get('ok'): self.send_json({'ok':False,'error':MODEL_PROBE.get('error') or 'API连接失败','probe':MODEL_PROBE},400); return
|
||||
self.send_json({'ok':True,'model':load_config().get('model'),'probe':MODEL_PROBE}); return
|
||||
except Exception as e: self.send_json({'ok':False,'error':str(e)},400); return
|
||||
if self.path not in ('/api/analyze','/api/analyze-current'): self.send_error(404);return
|
||||
try:
|
||||
c=load_config(); model_ready=configured()
|
||||
MODEL_PROBE=probe_model_api(timeout=15) if model_ready else {'ok':False,'stage':'config','error':'模型未配置,使用事实报告'}
|
||||
MODEL_PROBE_AT=time.time()
|
||||
n=int(self.headers.get('Content-Length','0'))
|
||||
payload=json.loads(self.rfile.read(n).decode('utf-8') or '{}')
|
||||
if self.path=='/api/analyze-current':
|
||||
raw=payload.get('raw') if isinstance(payload,dict) else None
|
||||
allow_partial=True
|
||||
else:
|
||||
raw=payload
|
||||
allow_partial=False
|
||||
if not isinstance(raw,dict):
|
||||
self.send_json({'ok':False,'error':'分析数据格式错误:缺少 raw 商品数据'},400); return
|
||||
raw['_model_available']=bool(MODEL_PROBE.get('ok'))
|
||||
item_id=str(raw.get('product',{}).get('itemId') or '')
|
||||
if not item_id:
|
||||
self.send_json({'ok':False,'error':'采集数据缺少商品 itemId,请刷新商品页后重新采集'},400); return
|
||||
has_facts=bool(raw.get('product',{}).get('title') or raw.get('product',{}).get('shop') or raw.get('sales',{}).get('currentPrice') or raw.get('sku') or raw.get('attributes') or raw.get('images',{}).get('main'))
|
||||
if not has_facts:
|
||||
self.send_json({'ok':False,'error':'采集数据中商品字段为空,请确认淘宝已登录且详情加载完成'},400); return
|
||||
if not bool((raw.get('collection') or {}).get('reviewCollectionComplete')) and not allow_partial:
|
||||
self.send_json({'ok':False,'error':'评论尚未确认完整。如需立即分析,请使用“基于当前数据开始分析”。'},409); return
|
||||
mp=monitor_path(item_id).with_suffix('.json')
|
||||
if mp.exists():
|
||||
try: raw['monitoring']=json.loads(mp.read_text('utf-8'))
|
||||
except Exception: raw['monitoring']=[]
|
||||
tid='tmall_'+str(item_id or 'item')+'_'+time.strftime('%Y%m%d_%H%M%S')+'_'+uuid.uuid4().hex[:5]
|
||||
summary=task_evidence_summary(raw);counts=summary['counts']
|
||||
TASKS[tid]={'status':'queued','progress':20,'evidenceSummary':summary,'steps':{'collect':'done','evidence':'waiting','planner':'waiting','product':'waiting','fashion':'waiting','visual':'not_collected' if not counts['main'] else 'waiting','merchandising':'waiting','detail':'not_collected' if not counts['detail'] else 'waiting','review':'waiting','qa':'waiting','competition':'waiting','strategy':'waiting','plans':'waiting','editor':'waiting','report':'waiting'},'createdAt':time.time()}
|
||||
threading.Thread(target=worker,args=(tid,raw),daemon=True).start()
|
||||
self.send_json({'ok':True,'taskId':tid,'taskUrl':f'http://127.0.0.1:{PORT}/task/{tid}'})
|
||||
except Exception as e: self.send_json({'ok':False,'error':str(e)},400)
|
||||
def do_GET(self):
|
||||
global MODEL_PROBE, MODEL_PROBE_AT
|
||||
p=urlparse(self.path).path
|
||||
if p=='/health':
|
||||
c=load_config(); self.send_json({'ok':True,'serverVersion':SERVER_VERSION,'modelConfigured':configured(),'model':c.get('model') or None,'apiBase':c.get('api_base') or None,'configPath':c.get('_config_path'),'configError':c.get('_config_error') or None,'modelProbe':MODEL_PROBE,'port':PORT}); return
|
||||
if p=='/api/prompts':
|
||||
saved={}
|
||||
if PROMPT_CONFIG.exists():
|
||||
try: saved=json.loads(PROMPT_CONFIG.read_text('utf-8'))
|
||||
except Exception: pass
|
||||
self.send_json({'ok':True,'variables':PRODUCT_VARIABLES,'templates':{k:{'body':saved.get(k,{}).get('body',v.body),'variables':list(v.variables)} for k,v in TEMPLATES.items()}}); return
|
||||
if p=='/prompts':
|
||||
b=prompt_page().encode('utf-8'); self.send_response(200); self.send_header('Content-Type','text/html; charset=utf-8'); self.send_header('Content-Length',str(len(b))); self.end_headers(); self.wfile.write(b); return
|
||||
if p=='/version':
|
||||
self.send_json({'ok':True,'serverVersion':SERVER_VERSION,'port':PORT}); return
|
||||
if p=='/api/model-test':
|
||||
MODEL_PROBE=probe_model_api(timeout=18); MODEL_PROBE_AT=time.time(); self.send_json(MODEL_PROBE,200 if MODEL_PROBE.get('ok') else 502); return
|
||||
if p.startswith('/api/monitor/'):
|
||||
item_id=p.rsplit('/',1)[-1]; mp=monitor_path(item_id).with_suffix('.json')
|
||||
if not mp.exists(): self.send_json({'ok':True,'days':0}); return
|
||||
try:
|
||||
rows=json.loads(mp.read_text('utf-8')); self.send_json({'ok':True,'days':len(rows),'firstSales':rows[0].get('salesDisplay') if rows else None,'lastSales':rows[-1].get('salesDisplay') if rows else None,'rows':rows}); return
|
||||
except Exception as e: self.send_json({'ok':False,'error':str(e)},500); return
|
||||
if p.startswith('/api/task/'):
|
||||
tid=p.rsplit('/',1)[-1]; t=TASKS.get(tid); self.send_json(t if t else {'error':'task not found'},200 if t else 404); return
|
||||
if p.startswith('/task/'):
|
||||
tid=p.rsplit('/',1)[-1]; b=task_page(tid).encode();self.send_response(200);self.send_header('Content-Type','text/html; charset=utf-8');self.send_header('Content-Length',str(len(b)));self.end_headers();self.wfile.write(b);return
|
||||
if p=='/task.js':
|
||||
b=TASK_JS.encode();self.send_response(200);self.send_header('Content-Type','text/javascript; charset=utf-8');self.send_header('Content-Length',str(len(b)));self.end_headers();self.wfile.write(b);return
|
||||
if p=='/report.css': self.send_file(ROOT/'report.css','text/css; charset=utf-8'); return
|
||||
if p=='/report.js': self.send_file(ROOT/'report.js','text/javascript; charset=utf-8'); return
|
||||
if p=='/report.html': self.send_file(ROOT/'report.html','text/html; charset=utf-8'); return
|
||||
if p.startswith('/reports/'):
|
||||
f=(REPORTS/p.split('/reports/',1)[1]).resolve()
|
||||
if REPORTS.resolve() not in f.parents:self.send_error(403);return
|
||||
ctype=mimetypes.guess_type(str(f))[0] or 'application/octet-stream'
|
||||
if f.suffix=='.json':ctype='application/json; charset=utf-8'
|
||||
elif f.suffix=='.html':ctype='text/html; charset=utf-8'
|
||||
self.send_file(f,ctype);return
|
||||
if p=='/':
|
||||
b=control_page().encode('utf-8');self.send_response(200);self.send_header('Content-Type','text/html; charset=utf-8');self.send_header('Content-Length',str(len(b)));self.end_headers();self.wfile.write(b);return
|
||||
self.send_error(404)
|
||||
|
||||
def main():
|
||||
print(f'Sansong Ecommerce Product Loop V{SERVER_VERSION} running: http://127.0.0.1:{PORT}',flush=True)
|
||||
print('Dedicated port 17962. Manual single-product collection; no recommendation scraping.',flush=True)
|
||||
ThreadingHTTPServer(('127.0.0.1',PORT),H).serve_forever()
|
||||
if __name__=='__main__': main()
|
||||
DYNAMIC_PARAM_FAMILIES = {
|
||||
'身份与合规':['品牌','产地','型号','执行标准','备案','许可证','等级'],
|
||||
'规格与选择':['尺寸','尺码','规格','颜色','口味','容量','重量','净含量','版本','配置'],
|
||||
'材质与成分':['材质','面料','成分','配料','配方','填充物'],
|
||||
'功能与适用':['功能','功效','适用','肤质','人群','场景','兼容'],
|
||||
'工艺与使用':['工艺','做工','版型','洗护','使用方法','保质期','储存','续航','性能'],
|
||||
}
|
||||
def evaluate_parameter_collection(attrs):
|
||||
keys=list((attrs or {}).keys())
|
||||
norm = {str(k).strip():str(v).strip() for k,v in (attrs or {}).items() if str(v).strip()}
|
||||
found=[]
|
||||
for canon, aliases in DYNAMIC_PARAM_FAMILIES.items():
|
||||
hit=False
|
||||
for k,v in norm.items():
|
||||
if any(a in k for a in aliases) and v:
|
||||
hit=True; break
|
||||
if hit: found.append(canon)
|
||||
status = 'collected' if norm else 'missing'
|
||||
return {
|
||||
'state':status,
|
||||
'stateText': {'collected':'参数已采集并等待类目分析','missing':'未识别到有效参数'}.get(status,'参数已采集'),
|
||||
'count': len(norm),
|
||||
'keys': keys[:50],
|
||||
'found': found,
|
||||
'missing': []
|
||||
}
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
cd "$(dirname "$0")/.."
|
||||
exec ./start-tmall-ai.command
|
||||
@@ -0,0 +1,5 @@
|
||||
@echo off
|
||||
cd /d %~dp0
|
||||
if not exist config.json copy config.example.json config.json
|
||||
python server.py
|
||||
pause
|
||||
Executable
+50
@@ -0,0 +1,50 @@
|
||||
#!/bin/bash
|
||||
ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||
PORT=17962
|
||||
VERSION="9.2.5"
|
||||
SERVER="$ROOT/server"
|
||||
LOG="$SERVER/v9_1_0.log"
|
||||
|
||||
health_version() {
|
||||
curl -fsS "http://127.0.0.1:$PORT/health" 2>/dev/null | python3 -c 'import sys,json
|
||||
try: print(json.load(sys.stdin).get("serverVersion",""))
|
||||
except: print("")' 2>/dev/null
|
||||
}
|
||||
|
||||
RUNNING="$(health_version || true)"
|
||||
if [ "$RUNNING" = "$VERSION" ]; then
|
||||
open "http://127.0.0.1:$PORT/"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -n "$RUNNING" ]; then
|
||||
PID="$(lsof -tiTCP:$PORT -sTCP:LISTEN 2>/dev/null | head -1 || true)"
|
||||
[ -n "$PID" ] && kill "$PID" 2>/dev/null || true
|
||||
sleep 1
|
||||
fi
|
||||
|
||||
if lsof -nP -iTCP:$PORT -sTCP:LISTEN >/dev/null 2>&1; then
|
||||
osascript -e 'display alert "启动失败" message "端口 17962 被其他程序占用,请先关闭占用进程。" as critical' 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$SERVER"
|
||||
[ -f config.json ] || cp config.example.json config.json
|
||||
|
||||
osascript <<APPLESCRIPT
|
||||
tell application "Terminal"
|
||||
do script "cd '$SERVER'; python3 server.py >> '$LOG' 2>&1"
|
||||
end tell
|
||||
APPLESCRIPT
|
||||
|
||||
for i in {1..40}; do
|
||||
V="$(health_version || true)"
|
||||
if [ "$V" = "$VERSION" ]; then
|
||||
open "http://127.0.0.1:$PORT/"
|
||||
exit 0
|
||||
fi
|
||||
sleep 0.3
|
||||
done
|
||||
|
||||
open -a TextEdit "$LOG" 2>/dev/null || true
|
||||
exit 1
|
||||
Executable
+10
@@ -0,0 +1,10 @@
|
||||
#!/bin/bash
|
||||
PORT=17962
|
||||
V=$(curl -fsS "http://127.0.0.1:$PORT/health" 2>/dev/null | python3 -c 'import sys,json
|
||||
try: print(json.load(sys.stdin).get("serverVersion",""))
|
||||
except: print("")' 2>/dev/null || true)
|
||||
if [ -n "$V" ]; then
|
||||
PID="$(lsof -tiTCP:$PORT -sTCP:LISTEN 2>/dev/null | head -1 || true)"
|
||||
[ -n "$PID" ] && kill "$PID" 2>/dev/null || true
|
||||
fi
|
||||
osascript -e 'display notification "本地服务已停止" with title "三笙电商商品分析"' 2>/dev/null || true
|
||||
@@ -0,0 +1,320 @@
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'server'))
|
||||
|
||||
from analysis import analyze, ensure_experience_solution, normalize, _review_issue, _buyer_evidence, detect_category, _category_parameter_facts, core_solution_diagnostics, repair_core_solution_with_evidence
|
||||
from renderer import render
|
||||
from server import TASK_JS, task_evidence_summary, task_page
|
||||
|
||||
|
||||
class EvidenceOnlyReportTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.analysis = {
|
||||
'facts': {
|
||||
'product': {'title': '用户评价·51', 'itemId': '900653753390'},
|
||||
'sales': {'sold': '700+', 'currentPrice': '395.25'},
|
||||
'attributes': [
|
||||
{'name': '1024', 'value': '"lg",'},
|
||||
{'name': 'console.log("onload]', 'value': 'performance.now()'},
|
||||
{'name': '面料支数', 'value': '60支'},
|
||||
{'name': '床单面料材质', 'value': '全棉'},
|
||||
{'name': '适用床尺寸', 'value': '1.2m床三件套'},
|
||||
],
|
||||
'reviews': [
|
||||
{'content': '2025年4月18日已购:【四件套】测试规格'},
|
||||
{'content': '花色好看,摸起来很舒服', 'images': ['https://img.alicdn.com/review-rate.jpg'], 'source': 'dom_visible'},
|
||||
{'content': '1米2床单尺寸小,床边不够'},
|
||||
],
|
||||
'questions': [
|
||||
{'question': '问大家·5 问 有味道吗 更多回答 问 盖上舒服吗 更多回答'},
|
||||
{'question': '问 有味道吗 更多回答'},
|
||||
],
|
||||
'collection': {
|
||||
'reviewCollectionComplete': False,
|
||||
'publicReviewCount': '51',
|
||||
'reviewStopReason': 'no_progress_after_4_rounds',
|
||||
'reviewPagesVisited': 35,
|
||||
},
|
||||
'images': {'main': [
|
||||
'https://img.alicdn.com/tps-120-60.png',
|
||||
'https://img.alicdn.com/item_pic.jpg',
|
||||
], 'detail': [], 'sku': [], 'buyerShow': [], 'all': []},
|
||||
},
|
||||
'experienceSolution': {},
|
||||
'meta': {},
|
||||
}
|
||||
|
||||
def test_empty_model_result_does_not_generate_fallback_report(self):
|
||||
result = ensure_experience_solution(self.analysis)
|
||||
self.assertFalse(result['meta']['fallbackReport'])
|
||||
self.assertFalse(result['meta']['reportReady'])
|
||||
self.assertIn('不输出兜底报告', result['meta']['reportBlockedReason'])
|
||||
self.assertEqual('', result['facts']['product']['title'])
|
||||
self.assertEqual(['面料支数', '床单面料材质', '适用床尺寸'], [x['name'] for x in result['facts']['attributes']])
|
||||
self.assertEqual(2, result['baseline']['reviewCount'])
|
||||
self.assertEqual(2, result['baseline']['questionCount'])
|
||||
cards = result['experienceSolution']['ownerOverview']['cards']
|
||||
self.assertTrue(cards)
|
||||
self.assertEqual('下一款明确卖点', cards[0]['label'])
|
||||
self.assertIn('ATTR_0003', cards[0]['evidenceIds'])
|
||||
self.assertEqual([], result['experienceSolution']['productExperience']['gates'])
|
||||
with self.assertRaisesRegex(ValueError, '不输出兜底报告'):
|
||||
render(result, 'fixture')
|
||||
|
||||
def test_analysis_runs_without_model_channel(self):
|
||||
raw = dict(self.analysis['facts'])
|
||||
raw['_model_available'] = False
|
||||
result = analyze(raw)
|
||||
self.assertFalse(result['meta']['modelUsed'])
|
||||
self.assertEqual('9.2.5', result['meta']['engineVersion'])
|
||||
self.assertEqual('home_textile', result['meta']['industryTemplate'])
|
||||
self.assertFalse(result['meta']['fallbackReport'])
|
||||
self.assertFalse(result['meta']['reportReady'])
|
||||
|
||||
def test_historical_fallback_json_is_rejected(self):
|
||||
result = ensure_experience_solution({
|
||||
'facts': self.analysis['facts'],
|
||||
'experienceSolution': {'reportSummary': {'title': '旧报告', 'verdict': '旧结论'}},
|
||||
'meta': {'fallbackReport': True},
|
||||
})
|
||||
self.assertFalse(result['meta']['reportReady'])
|
||||
self.assertIn('历史分析标记为兜底结果', result['meta']['reportBlockedReason'])
|
||||
|
||||
def test_dormitory_spec_never_invents_12m_issue_and_title_is_clean(self):
|
||||
facts = {
|
||||
'product': {'title': '宿舍七件套新生床品-tmall.com天猫', 'itemId': '2'},
|
||||
'sales': {'sold': '1万+', 'currentPrice': '637.5'},
|
||||
'attributes': [
|
||||
{'name': '套件组成', 'value': '被芯、床垫、床单、被套、枕芯、枕套、床帘'},
|
||||
{'name': '适用床尺寸', 'value': '80×190cm、90×190cm、90×200cm;床垫厚4cm/6cm'},
|
||||
{'name': '面料材质', 'value': '聚酯纤维'},
|
||||
{'name': '适用人群', 'value': '学生'},
|
||||
],
|
||||
'reviews': [
|
||||
{'content': '七件配齐,开学直接用,尺寸合适'},
|
||||
{'content': '枕套实物和页面有色差', 'sku': '90×190cm / 4cm床垫'},
|
||||
],
|
||||
'questions': [], 'images': {},
|
||||
}
|
||||
clean = normalize(facts);issue = _review_issue(clean)
|
||||
self.assertEqual('宿舍七件套新生床品', clean['product']['title'])
|
||||
self.assertEqual('实物色差', issue['label'])
|
||||
self.assertNotIn('1.2m', str(issue))
|
||||
self.assertNotIn('1米2', str(issue))
|
||||
|
||||
def test_real_12m_comment_keeps_12m_evidence(self):
|
||||
clean = normalize(self.analysis['facts']);issue = _review_issue(clean)
|
||||
self.assertIn('1米2床单尺寸小,床边不够', issue['text'])
|
||||
self.assertEqual('尺寸与床型适配', issue['label'])
|
||||
|
||||
def test_apparel_uses_dynamic_category_dimensions(self):
|
||||
raw=normalize({'product':{'title':'女士宽松连衣裙'},'attributes':[{'name':'尺码','value':'S M L XL'},{'name':'面料成分','value':'棉 95% 氨纶 5%'},{'name':'版型','value':'宽松'}],'reviews':[{'content':'尺码合身,版型显瘦'}],'images':{}})
|
||||
route=detect_category(raw)
|
||||
facts=_category_parameter_facts(raw)
|
||||
self.assertEqual('apparel',route['key'])
|
||||
self.assertIn('尺码与适配',[x['label'] for x in facts])
|
||||
self.assertIn('面料与成分',[x['label'] for x in facts])
|
||||
self.assertNotIn('套件 BOM',[x['label'] for x in facts])
|
||||
|
||||
def test_food_beauty_and_digital_route_to_their_own_dimensions(self):
|
||||
fixtures=[
|
||||
('food',{'product':{'title':'坚果零食礼盒'},'attributes':[{'name':'净含量','value':'750g'},{'name':'配料表','value':'坚果'}]}),
|
||||
('beauty',{'product':{'title':'敏感肌保湿精华'},'attributes':[{'name':'主要成分','value':'透明质酸'},{'name':'适用肤质','value':'敏感肌'}]}),
|
||||
('digital',{'product':{'title':'无线蓝牙耳机'},'attributes':[{'name':'兼容系统','value':'iOS/Android'},{'name':'续航时间','value':'30小时'}]}),
|
||||
]
|
||||
for expected,data in fixtures:
|
||||
data.update({'reviews':[],'images':{}})
|
||||
clean=normalize(data); route=detect_category(clean)
|
||||
self.assertEqual(expected,route['key'])
|
||||
self.assertTrue(_category_parameter_facts(clean))
|
||||
|
||||
def test_complete_evidence_backed_ai_output_is_rendered_and_sanitized(self):
|
||||
facts = {
|
||||
'product': {'title': '全棉三件套-taobao.com淘宝网', 'itemId': '3'},
|
||||
'sales': {},
|
||||
'attributes': [
|
||||
{'name': '床单面料材质', 'value': '全棉'},
|
||||
{'name': '套件组成', 'value': '床单、被套、枕套'},
|
||||
{'name': '适用床尺寸', 'value': '0.9m床三件套'},
|
||||
],
|
||||
'reviews': [{'content': '摸起来柔软舒服'}],
|
||||
'questions': [{'question': '会不会起球?'}],
|
||||
'images': {},
|
||||
}
|
||||
exp = {
|
||||
'reportSummary': {'title': '省心型大众爆款,提升转化', 'verdict': '先完成样品验证'},
|
||||
'ownerOverview': {'chapterTitle': '页面事实与评论结果已形成', 'cards': [
|
||||
{'label': '客户为什么购买', 'headline': '决定成交', 'evidenceIds': ['REV_000001']},
|
||||
{'label': '页面产品定义', 'headline': '全棉三件套', 'evidenceIds': ['ATTR_0001', 'ATTR_0002']},
|
||||
{'label': '下一款怎么开', 'headline': '先校准规格', 'evidenceIds': ['ATTR_0003']},
|
||||
]},
|
||||
'customerExperience': {
|
||||
'chapterTitle': '1 条评论确认柔软触感', 'journeyTitle': '只显示评论已确认结果',
|
||||
'stats': [{'label': '有效评论', 'value': '1'}],
|
||||
'stages': [
|
||||
{'stageNo': '01', 'label': '实际使用', 'positiveConfirmation': '1 条确认柔软舒服', 'evidenceIds': ['REV_000001']},
|
||||
{'stageNo': '02', 'label': '选择规格', 'positiveConfirmation': '页面标注0.9m床三件套', 'evidenceIds': ['ATTR_0003']},
|
||||
],
|
||||
'signals': {'positive': [], 'questions': [], 'risks': [
|
||||
{'label': '起球产品缺陷', 'evidenceIds': ['QA_00001']},
|
||||
]},
|
||||
},
|
||||
'productExperience': {'chapterTitle': '材质、BOM与床型必须一致', 'parameterFacts': [
|
||||
{'label': '材质 / 成分', 'value': '全棉', 'evidenceIds': ['ATTR_0001']},
|
||||
{'label': '套件 BOM', 'value': '床单、被套、枕套', 'evidenceIds': ['ATTR_0002']},
|
||||
], 'gates': [
|
||||
{'priority': 'P0', 'label': '材质核对', 'action': '核对标签与样品', 'evidenceIds': ['ATTR_0001']},
|
||||
{'priority': 'P0', 'label': '规格实测', 'action': '按0.9m床铺装', 'evidenceIds': ['ATTR_0003']},
|
||||
]},
|
||||
'newProductPlans': {'plans': [
|
||||
{'type': '主销款', 'name': '主销', 'positioning': '保持全棉三件套定义', 'evidenceIds': ['ATTR_0001']},
|
||||
{'name': '无证据升级', 'evidenceIds': []},
|
||||
{'type': '升级款', 'name': '第三套', 'positioning': '按BOM校准', 'evidenceIds': ['ATTR_0002']},
|
||||
], 'chapterTitle': '只输出有证据的开品方向'},
|
||||
'validationLoop': {'chapterTitle': '三道验证门决定是否上架', 'rows': [
|
||||
{'stage': '商品定义', 'owner': '商品企划 / 商品运营', 'metrics': '核对定义', 'successMeaning': '参数一致', 'nextAction': '进入打样', 'evidenceIds': ['ATTR_0001']},
|
||||
{'stage': '规格实测', 'owner': '供应链 / 品控', 'metrics': '实床铺装', 'successMeaning': '规格适配', 'nextAction': '锁定SKU', 'evidenceIds': ['ATTR_0003']},
|
||||
{'stage': '页面验收', 'owner': '视觉与内容', 'metrics': '核对页面', 'successMeaning': '页面一致', 'nextAction': '上架', 'evidenceIds': ['ATTR_0002']},
|
||||
]},
|
||||
}
|
||||
result = ensure_experience_solution({'facts': facts, 'experienceSolution': exp, 'meta': {}})
|
||||
output = str(result['experienceSolution'])
|
||||
labels = [x['label'] for x in result['experienceSolution']['productExperience']['parameterFacts']]
|
||||
signals = result['experienceSolution']['customerExperience']['signals']
|
||||
self.assertTrue(result['meta']['reportReady'])
|
||||
self.assertEqual('全棉三件套', result['facts']['product']['title'])
|
||||
self.assertIn('套件 BOM', labels)
|
||||
self.assertIn('材质 / 成分', labels)
|
||||
self.assertEqual([], signals['risks'])
|
||||
self.assertTrue(signals['questions'][0]['label'].startswith('购买前关注:'))
|
||||
self.assertEqual(2, len(result['experienceSolution']['newProductPlans']['plans']))
|
||||
self.assertNotIn('无证据升级', output)
|
||||
self.assertNotIn('项目负责人', output)
|
||||
self.assertNotIn('客户为什么购买', output)
|
||||
self.assertNotIn('提升转化', output)
|
||||
self.assertNotIn('决定成交', output)
|
||||
self.assertNotIn('爆款', output)
|
||||
doc = render(result, 'evidence-only')
|
||||
self.assertIn('老板速览', doc)
|
||||
self.assertIn('下一款先做什么', doc)
|
||||
self.assertIn('V9.2.5', doc)
|
||||
self.assertNotIn('项目负责人', doc)
|
||||
|
||||
def test_incomplete_final_editor_output_is_repaired_from_real_evidence(self):
|
||||
facts = {
|
||||
'product': {'title': '学生宿舍全棉三件套', 'itemId': 'repair-1'},
|
||||
'sales': {'sold': '1000+', 'currentPrice': '129'},
|
||||
'attributes': [
|
||||
{'name': '床单面料材质', 'value': '全棉'},
|
||||
{'name': '套件组成', 'value': '床单、被套、枕套'},
|
||||
{'name': '适用床尺寸', 'value': '0.9m床三件套'},
|
||||
{'name': '产品等级', 'value': '合格品'},
|
||||
],
|
||||
'reviews': [
|
||||
{'content': '面料柔软,尺寸合适'},
|
||||
{'content': '花色好看,宿舍床铺上正好'},
|
||||
],
|
||||
'questions': [{'question': '洗后会不会缩水?'}],
|
||||
'images': {},
|
||||
}
|
||||
repaired = repair_core_solution_with_evidence({}, facts)
|
||||
result = ensure_experience_solution({'facts': facts, 'experienceSolution': repaired, 'meta': {}})
|
||||
self.assertTrue(result['meta']['reportReady'])
|
||||
self.assertTrue(result['meta']['reportValidation']['ok'])
|
||||
self.assertEqual([], result['meta']['reportValidation']['missing'])
|
||||
self.assertFalse(result['meta']['fallbackReport'])
|
||||
self.assertIn('evidenceIds', result['experienceSolution']['validationLoop']['rows'][0])
|
||||
|
||||
|
||||
def test_legacy_grouped_images_are_preserved_without_strict_provenance(self):
|
||||
raw={
|
||||
'product':{'title':'测试商品','itemId':'1'},
|
||||
'reviews':[],
|
||||
'images':{
|
||||
'main':['https://img.alicdn.com/item_pic.jpg'],
|
||||
'detail':['https://img.alicdn.com/detail_01.jpg'],
|
||||
'sku':['https://img.alicdn.com/sku_01.jpg'],
|
||||
'buyerShow':[]
|
||||
}
|
||||
}
|
||||
clean=normalize(raw)
|
||||
self.assertEqual(['https://img.alicdn.com/item_pic.jpg'], clean['images']['main'])
|
||||
self.assertEqual(['https://img.alicdn.com/detail_01.jpg'], clean['images']['detail'])
|
||||
self.assertEqual(['https://img.alicdn.com/sku_01.jpg'], clean['images']['sku'])
|
||||
self.assertEqual('legacy-review-only', clean['images']['classification']['version'])
|
||||
sources={x['group']:x['source'] for x in clean['images']['provenance'] if x['group'] in ('main','detail','sku')}
|
||||
self.assertEqual('legacy_group_main', sources['main'])
|
||||
self.assertEqual('legacy_group_detail', sources['detail'])
|
||||
self.assertEqual('legacy_group_sku', sources['sku'])
|
||||
|
||||
def test_validation_reports_exact_missing_fields(self):
|
||||
validation = core_solution_diagnostics({'reportSummary': {'title': '有标题'}})
|
||||
self.assertFalse(validation['ok'])
|
||||
self.assertNotIn('reportSummary.title', validation['missing'])
|
||||
self.assertIn('reportSummary.verdict', validation['missing'])
|
||||
self.assertIn('validationLoop.rows', validation['missing'])
|
||||
|
||||
def test_task_progress_exposes_real_evidence_counts(self):
|
||||
raw = self.analysis['facts']
|
||||
summary = task_evidence_summary(raw)
|
||||
self.assertEqual(3, summary['counts']['parameters'])
|
||||
self.assertEqual(2, summary['counts']['reviews'])
|
||||
self.assertEqual(1, summary['counts']['main'])
|
||||
self.assertEqual(0, summary['counts']['detail'])
|
||||
self.assertEqual(1, summary['counts']['buyerShow'])
|
||||
self.assertEqual('51', summary['publicReviewCount'])
|
||||
self.assertFalse(summary['reviewComplete'])
|
||||
page = task_page('fixture')
|
||||
self.assertIn('本次证据', page)
|
||||
self.assertIn('生成进度', page)
|
||||
self.assertIn('evidenceStats', page)
|
||||
self.assertIn("not_collected:'未采到'", TASK_JS)
|
||||
self.assertIn("visual:'商品主图核验'", TASK_JS)
|
||||
self.assertIn("detail:'商品详情图核验'", TASK_JS)
|
||||
self.assertIn("editor:'最终报告汇总与校验'", TASK_JS)
|
||||
self.assertIn("failed:'失败'", TASK_JS)
|
||||
self.assertNotIn("visual:'主图成交'", TASK_JS)
|
||||
|
||||
def test_strict_image_groups_are_disjoint_and_traceable(self):
|
||||
shared = 'https://img.alicdn.com/shared-review.jpg'
|
||||
buyer_two = 'https://img.alicdn.com/second-review.jpg'
|
||||
main = 'https://img.alicdn.com/verified-main.jpg'
|
||||
structured_main = 'https://img.alicdn.com/structured-main.jpg'
|
||||
detail = 'https://img.alicdn.com/verified-detail.jpg'
|
||||
structured_detail = 'https://img.alicdn.com/structured-detail.jpg'
|
||||
facts = {
|
||||
'meta': {'imageClassificationVersion': 'strict-v1'},
|
||||
'product': {'title': '真实商品标题', 'itemId': '1'},
|
||||
'sales': {}, 'attributes': [], 'questions': [],
|
||||
'reviews': [{'content': '评论原文', 'images': [shared, buyer_two], 'source': 'dom_visible'}],
|
||||
'images': {
|
||||
'main': [shared, main, structured_main], 'detail': [shared, detail, structured_detail], 'sku': [], 'buyerShow': [shared, buyer_two],
|
||||
'provenance': [
|
||||
{'url': shared, 'group': 'buyerShow', 'source': 'review_record'},
|
||||
{'url': buyer_two, 'group': 'buyerShow', 'source': 'review_record'},
|
||||
{'url': shared, 'group': 'main', 'source': 'dom_main_gallery'},
|
||||
{'url': main, 'group': 'main', 'source': 'dom_main_gallery'},
|
||||
{'url': structured_main, 'group': 'main', 'source': 'structured_main_gallery'},
|
||||
{'url': detail, 'group': 'detail', 'source': 'dom_product_description'},
|
||||
{'url': structured_detail, 'group': 'detail', 'source': 'structured_product_description'},
|
||||
],
|
||||
},
|
||||
}
|
||||
clean = normalize(facts)
|
||||
groups = clean['images']
|
||||
self.assertEqual([shared, buyer_two], groups['buyerShow'])
|
||||
self.assertEqual([main, structured_main], groups['main'])
|
||||
self.assertEqual([detail, structured_detail], groups['detail'])
|
||||
self.assertTrue(groups['classification']['groupsAreDisjoint'])
|
||||
self.assertEqual(6, len(set(groups['buyerShow'] + groups['main'] + groups['detail'])))
|
||||
buyer_items = _buyer_evidence(clean)
|
||||
buyer = buyer_items[0]
|
||||
self.assertEqual('REV_000001', buyer['reviewEvidenceId'])
|
||||
self.assertEqual(2, len(buyer_items))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,60 @@
|
||||
import sys, unittest
|
||||
from pathlib import Path
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0,str(ROOT/'server'))
|
||||
from renderer import render, image_source, img_tag
|
||||
from unittest.mock import patch
|
||||
import tempfile
|
||||
import server as server_module
|
||||
from analysis import ensure_experience_solution
|
||||
|
||||
class V910RegressionTest(unittest.TestCase):
|
||||
def base_result(self):
|
||||
facts={'product':{'title':'测试商品'},'sales':{},'attributes':[{'name':'材质','value':'全棉'},{'name':'规格','value':'700g'}], 'reviews':[{'content':'使用后很顺滑'}], 'questions':[], 'sku':[], 'images':{'main':['https://img.example.com/a.jpg'],'sku':['https://img.example.com/s.jpg']}}
|
||||
exp={'reportSummary':{'title':'测试报告','verdict':'先核对商品结构'},'ownerOverview':{'chapterTitle':'页面事实与评论结果已形成','cards':[{'label':'结论','headline':'先核对规格','evidenceIds':['ATTR_0001']},{'label':'产品','headline':'材质明确','evidenceIds':['ATTR_0001']},{'label':'动作','headline':'统一页面口径','evidenceIds':['ATTR_0002']}]},'customerExperience':{'stages':[{'label':'使用','positiveConfirmation':'顺滑','evidenceIds':['REV_000001']},{'label':'规格','positiveConfirmation':'700g','evidenceIds':['ATTR_0002']}], 'consumerInsights':{'coreSatisfaction':[{'driver':'顺滑体验','evidenceIds':['REV_000001']}]}},'productExperience':{'parameterFacts':[{'label':'材质','value':'全棉','evidenceIds':['ATTR_0001']},{'label':'规格','value':'700g','evidenceIds':['ATTR_0002']}], 'gates':[{'label':'材质','action':'核对','evidenceIds':['ATTR_0001']},{'label':'规格','action':'核对','evidenceIds':['ATTR_0002']}]},'newProductPlans':{'plans':[{'type':'稳健优化','name':'规格清晰化','whyThisPlan':'减少理解成本','evidenceIds':['ATTR_0002']}]},'validationLoop':{'rows':[{'stage':'定义','owner':'商品运营','metrics':'核对','successMeaning':'一致','nextAction':'进入下一步','evidenceIds':['ATTR_0001']},{'stage':'规格','owner':'供应链','metrics':'核对','successMeaning':'一致','nextAction':'锁定','evidenceIds':['ATTR_0002']},{'stage':'页面','owner':'视觉','metrics':'核对','successMeaning':'一致','nextAction':'发布','evidenceIds':['ATTR_0002']} ]}}
|
||||
r=ensure_experience_solution({'facts':facts,'experienceSolution':exp,'meta':{}})
|
||||
for e in r['evidenceLedger']:
|
||||
if e.get('id')=='IMG_MAIN_0001':
|
||||
e.setdefault('meta',{})['localUrl']='/reports/assets/test/main/IMG_MAIN_0001.jpg';e['meta']['sourceUrl']=e['value']
|
||||
return r
|
||||
|
||||
def test_missing_sku_not_rendered_as_zero(self):
|
||||
doc=render(self.base_result(),'test')
|
||||
self.assertNotIn('SKU 0',doc)
|
||||
self.assertIn('结构化SKU 未完整采集',doc)
|
||||
self.assertIn('SKU图片数量不等于SKU数量',doc)
|
||||
|
||||
def test_renderer_prefers_local_image_and_keeps_remote_fallback(self):
|
||||
entry={'value':'https://img.example.com/a.jpg','meta':{'localUrl':'/reports/assets/test/main/IMG_MAIN_0001.jpg','sourceUrl':'https://img.example.com/a.jpg'}}
|
||||
local,remote=image_source(entry)
|
||||
html=img_tag(local,'商品主图',remote)
|
||||
self.assertIn('/reports/assets/test/main/IMG_MAIN_0001.jpg',html)
|
||||
self.assertIn('https://img.example.com/a.jpg',html)
|
||||
self.assertIn('data-fallback',html)
|
||||
|
||||
def test_renderer_never_prints_python_dict_literal(self):
|
||||
r=self.base_result()
|
||||
r['experienceSolution']['titleAnalysis']={
|
||||
'currentExpression':[{'value':'信息明确','evidenceIds':['ATTR_0001']}],
|
||||
'reinforce':[{'topic':'规格需拆清','evidenceIds':['ATTR_0002']}]
|
||||
}
|
||||
doc=render(r,'test')
|
||||
self.assertNotIn("{'point':",doc)
|
||||
self.assertIn('信息明确',doc)
|
||||
|
||||
def test_cache_report_images_writes_local_url_and_preserves_source(self):
|
||||
class FakeResponse:
|
||||
headers={'Content-Type':'image/jpeg'}
|
||||
def __enter__(self):return self
|
||||
def __exit__(self,*a):return False
|
||||
def read(self,n=-1):return b'\xff\xd8\xffFAKEJPEG'
|
||||
result={'evidenceLedger':[{'id':'IMG_MAIN_0001','type':'image','value':'https://img.example.com/a.jpg','meta':{'group':'main'}}]}
|
||||
with tempfile.TemporaryDirectory() as td, patch.object(server_module,'IMAGE_ASSET_ROOT',Path(td)), patch('server.urllib.request.urlopen',return_value=FakeResponse()):
|
||||
out=server_module.cache_report_images(result,'task1')
|
||||
meta=out['evidenceLedger'][0]['meta']
|
||||
self.assertEqual('success',meta['cacheStatus'])
|
||||
self.assertEqual('https://img.example.com/a.jpg',meta['sourceUrl'])
|
||||
self.assertTrue(meta['localUrl'].startswith('/reports/assets/task1/main/'))
|
||||
self.assertTrue(list((Path(td)/'task1'/'main').glob('IMG_MAIN_0001.*')))
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
@@ -0,0 +1,75 @@
|
||||
import sys, unittest
|
||||
from pathlib import Path
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0,str(ROOT/'server'))
|
||||
from analysis import ensure_experience_solution, _evidence_backed_launch_fallback, evidence_ledger, normalize
|
||||
from renderer import render, img_tag, image_source
|
||||
|
||||
class V920ImageDrivenReportTest(unittest.TestCase):
|
||||
def fixture(self):
|
||||
facts={
|
||||
'meta':{'imageClassificationVersion':'strict-v1'},
|
||||
'product':{'title':'学生宿舍七件套纯棉床笠床单四季子母被'},
|
||||
'sales':{'currentPrice':'637.5','sold':'1万+'},
|
||||
'attributes':[{'name':'面料支数','value':'40支'},{'name':'床单面料材质','value':'全棉'},{'name':'适用床尺寸','value':'90×190cm'},{'name':'套件组成','value':'七件套'}],
|
||||
'sku':[],
|
||||
'reviews':[{'content':'面料柔软透气,宿舍用很方便'},{'content':'枕头有点偏软'}],
|
||||
'questions':[],
|
||||
'images':{'main':['https://img.example.com/m1.jpg','https://img.example.com/m2.jpg'],'detail':['https://img.example.com/d1.jpg','https://img.example.com/d2.jpg'],'sku':['https://img.example.com/s1.jpg'],'buyerShow':['https://img.example.com/b1.jpg'],'provenance':[{'url':'https://img.example.com/m1.jpg','group':'main','source':'dom_main_gallery'},{'url':'https://img.example.com/m2.jpg','group':'main','source':'dom_main_gallery'},{'url':'https://img.example.com/d1.jpg','group':'detail','source':'dom_product_description'},{'url':'https://img.example.com/d2.jpg','group':'detail','source':'dom_product_description'},{'url':'https://img.example.com/s1.jpg','group':'sku','source':'dom_sku'},{'url':'https://img.example.com/b1.jpg','group':'buyerShow','source':'review_record'}]}
|
||||
}
|
||||
led=evidence_ledger(normalize(facts)); ids={x['id'] for x in led}
|
||||
exp={
|
||||
'reportSummary':{'title':'宿舍七件套商品分析报告','verdict':'当前围绕宿舍一站式买齐形成商品逻辑,下一款优先把规格与睡感选择说清。','coverImageId':'IMG_MAIN_0001'},
|
||||
'ownerOverview':{'cards':[{'label':'商品定位','headline':'宿舍一站式七件套','evidenceIds':['P_TITLE']},{'label':'主要产品资产','headline':'40支全棉 + 七件套','evidenceIds':['ATTR_0001','ATTR_0002']},{'label':'下一款优先方向','headline':'规格与睡感更易选','evidenceIds':['ATTR_0003','REV_000002']}]},
|
||||
'titleAnalysis':{'professionalOpinion':'场景与套件范围清楚,建议进一步强化40支品质参数。','currentExpression':[{'label':'场景','value':'学生宿舍','evidenceIds':['P_TITLE']}],'reinforce':[{'topic':'40支全棉','reason':'品质参数可前置','evidenceIds':['ATTR_0001','ATTR_0002']}],'recommendedActions':[{'action':'统一标题与SKU口径','evidenceIds':['P_TITLE']}]},
|
||||
'visualCommerce':{'professionalOpinion':'主图先建立套装认知,再解释具体产品。','sequence':['一站式','套件清单'], 'items':[{'imageEvidenceId':'IMG_MAIN_0001','role':'一站式套装','message':'建立宿舍整套购买认知','professionalAdvice':'场景更贴近真实宿舍','evidenceIds':['IMG_MAIN_0001']},{'imageEvidenceId':'IMG_MAIN_0002','role':'套件清单','message':'解释具体包含内容','professionalAdvice':'补尺寸差异','evidenceIds':['IMG_MAIN_0002']}]},
|
||||
'detailCommerce':{'professionalOpinion':'详情负责承接材质和规格证明。','contentGroups':[{'type':'材质品质','representativeImageId':'IMG_DETAIL_0001','message':'解释40支全棉','professionalAdvice':'前置品质证明','evidenceIds':['IMG_DETAIL_0001','ATTR_0001']}]},
|
||||
'customerExperience':{'professionalOpinion':'评论验证了柔软透气,同时出现睡感差异。','consumerInsights':{'experienceSignals':[{'type':'稳定体验','insight':'柔软透气','implication':'继续保留全棉','evidenceIds':['REV_000001']},{'type':'体验差异','insight':'枕头偏软','implication':'增加睡感说明','evidenceIds':['REV_000002']}]},'stages':[{'label':'使用','positiveConfirmation':'柔软透气','evidenceIds':['REV_000001']},{'label':'睡感','positiveConfirmation':'软硬有差异','evidenceIds':['REV_000002']}]},
|
||||
'productExperience':{'parameterFacts':[{'label':'支数','value':'40支','evidenceIds':['ATTR_0001']},{'label':'材质','value':'全棉','evidenceIds':['ATTR_0002']}], 'gates':[{'label':'规格','action':'核对','evidenceIds':['ATTR_0003']},{'label':'套件','action':'核对','evidenceIds':['ATTR_0004']}]},
|
||||
'newProductPlans':{'professionalOpinion':'下一款围绕规格更清楚与睡感区分优化。','plans':[{'type':'现有基础优化','name':'宿舍七件套2.0','whyThisPlan':'保留全棉与七件套逻辑,强化规格选择。','visualReferenceImageIds':['IMG_MAIN_0001'],'productChanges':[{'action':'明确床型尺寸','evidenceIds':['ATTR_0003']}],'skuStrategy':'款式→尺寸→花色','mainImagePlan':['首图保留一站式'],'detailPagePlan':['紧接材质证明'],'evidenceIds':['P_TITLE','ATTR_0001','ATTR_0003','IMG_MAIN_0001']}]},
|
||||
'validationLoop':{'rows':[{'stage':'定义','owner':'商品运营','metrics':'核对','successMeaning':'一致','nextAction':'打样','evidenceIds':['P_TITLE']},{'stage':'规格','owner':'供应链','metrics':'实测','successMeaning':'一致','nextAction':'锁定','evidenceIds':['ATTR_0003']},{'stage':'页面','owner':'视觉','metrics':'核对','successMeaning':'一致','nextAction':'发布','evidenceIds':['IMG_MAIN_0001']}]}
|
||||
}
|
||||
return ensure_experience_solution({'facts':facts,'experienceSolution':exp,'meta':{}})
|
||||
|
||||
def test_renderer_is_image_driven_and_professional(self):
|
||||
doc=render(self.fixture(),'v920')
|
||||
for text in ('老板速览','下一款先做什么','主图到底在卖什么','详情页证明了什么','先解决适配,再解决审美','消费者真正感受到什么'):
|
||||
self.assertIn(text,doc)
|
||||
self.assertIn('v92-main-grid',doc)
|
||||
self.assertIn('v925-detail-list',doc)
|
||||
self.assertIn('v92-sku-mosaic',doc)
|
||||
self.assertIn('v92-buyer-grid',doc)
|
||||
self.assertIn('v925-plan-grid',doc)
|
||||
self.assertNotIn('[数据事实]',doc)
|
||||
self.assertNotIn('[分析判断]',doc)
|
||||
self.assertNotIn('[设计建议]',doc)
|
||||
self.assertNotIn('整体有效点',doc)
|
||||
self.assertNotIn('主要问题',doc)
|
||||
|
||||
def test_cover_has_no_card_border_wrapper(self):
|
||||
doc=render(self.fixture(),'v920')
|
||||
self.assertIn('class="v92-cover"',doc)
|
||||
self.assertIn('cover-ratio',doc)
|
||||
self.assertNotIn('hero-shot',doc)
|
||||
|
||||
def test_launch_fallback_never_empty_with_real_evidence(self):
|
||||
facts=normalize(self.fixture()['facts'])
|
||||
ids={x['id'] for x in evidence_ledger(facts)}
|
||||
plans=_evidence_backed_launch_fallback(facts,ids)
|
||||
self.assertGreaterEqual(len(plans.get('plans') or []),1)
|
||||
self.assertTrue(plans['plans'][0]['evidenceIds'])
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
|
||||
|
||||
def test_img_tag_remote_fallback_does_not_force_no_referrer():
|
||||
html = img_tag('https://img.example.com/a.jpg', '图', 'https://img.example.com/b.jpg')
|
||||
assert 'referrerpolicy="no-referrer"' not in html
|
||||
assert 'data-fallback="https://img.example.com/b.jpg"' in html
|
||||
|
||||
|
||||
def test_image_source_keeps_remote_when_cache_failed():
|
||||
e={'type':'image','value':'https://img.example.com/a.jpg','meta':{'sourceUrl':'https://img.example.com/a.jpg','cacheStatus':'failed'}}
|
||||
src,fb=image_source(e)
|
||||
assert src=='https://img.example.com/a.jpg'
|
||||
assert fb=='https://img.example.com/a.jpg'
|
||||
@@ -0,0 +1,21 @@
|
||||
import json, re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
EXPECTED='9.2.5'
|
||||
|
||||
def test_version_consistency():
|
||||
assert (ROOT/'VERSION.txt').read_text().strip()==EXPECTED
|
||||
for rel in ['extension/manifest.json','collector-extension-standalone/manifest.json','codex-plugin-toolkit/sansong-tmall-product-report/assets/capture-extension/manifest.json']:
|
||||
data=json.loads((ROOT/rel).read_text(encoding='utf-8'))
|
||||
assert data['version']==EXPECTED, rel
|
||||
popup=(ROOT/'extension/popup.js').read_text(encoding='utf-8')
|
||||
assert f"EXPECTED_SERVER_VERSION='{EXPECTED}'" in popup
|
||||
server=(ROOT/'server/server.py').read_text(encoding='utf-8')
|
||||
assert f"SERVER_VERSION='{EXPECTED}'" in server or f'SERVER_VERSION = \'{EXPECTED}\'' in server
|
||||
analysis=(ROOT/'server/analysis.py').read_text(encoding='utf-8')
|
||||
assert f"ENGINE_VERSION='{EXPECTED}'" in analysis
|
||||
ai=(ROOT/'server/ai_client.py').read_text(encoding='utf-8')
|
||||
assert f"SERVER_VERSION = '{EXPECTED}'" in ai
|
||||
start=(ROOT/'start-tmall-ai.command').read_text(encoding='utf-8')
|
||||
assert f'VERSION="{EXPECTED}"' in start
|
||||
Reference in New Issue
Block a user