Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d931871baa | ||
|
|
a7f496e635 | ||
|
|
2c8e7a5435 |
@@ -0,0 +1,87 @@
|
||||
# Chrome Web Store 上架申报与资料说明
|
||||
|
||||
> 适用版本:V9.3.0
|
||||
> 打包文件:`release/sansheng-product-analysis-extension-v9.3.0-webstore.zip`
|
||||
> 开发者平台:[Chrome 网上应用店信息中心 (Developer Dashboard)](https://chrome.google.com/webstore/devconsole/)
|
||||
|
||||
---
|
||||
|
||||
## 1. 商店基本信息 (Store Listing)
|
||||
|
||||
### 扩展名称 (Name)
|
||||
```text
|
||||
三笙 · 电商商品分析闭环
|
||||
```
|
||||
|
||||
### 简短摘要 (Summary, 最多 132 字符)
|
||||
```text
|
||||
跨类目电商商品智能分析:支持动态参数与SKU采集、主图详情图文分源、真实评价归纳,生成专业开品与AI视觉分析报告。
|
||||
```
|
||||
|
||||
### 详细描述 (Detailed Description)
|
||||
```text
|
||||
「三笙 · 电商商品分析闭环」是一款面向电商运营、商品策划、设计师及商业决策者的专业商品洞察与开品辅助扩展。
|
||||
|
||||
【核心功能】
|
||||
1. 详情深度解析:自动识别并结构化提取商品参数、SKU规格、主图、详情图、真实买家评价与问大家内容。
|
||||
2. 图文分源分析:对主图视觉信号、详情承接证据进行逐层拆解,避免图片裁切与失真。
|
||||
3. 真实口碑洞察:聚合多轮买家评价,提炼好评卖点、负评痛点及高频反馈,帮助定位产品改良空间。
|
||||
4. 七角色智能报告:联动三笙云端大模型,从商品策略、电商转化、消费者洞察、产品供应链、视觉设计、商业决策等维度输出闭环开品建议。
|
||||
|
||||
【使用说明】
|
||||
1. 在 Chrome 中打开淘宝(taobao.com)或天猫(tmall.com)的商品详情页面。
|
||||
2. 点击浏览器右上角插件图标,选择需要分析的评价样本量。
|
||||
3. 点击“开始采集”,完成后即可一键跳转查看完整的结构化开品分析报告。
|
||||
|
||||
【数据安全与隐私】
|
||||
本扩展仅在用户明确点击操作时分析当前浏览的商品公开信息,绝不收集用户个人隐私账户信息或浏览历史。
|
||||
```
|
||||
|
||||
### 类别 (Category)
|
||||
- **生产力工具 (Productivity)** 或 **购物与分析 (Shopping / Business)**
|
||||
|
||||
### 默认语言 (Default Language)
|
||||
- **中文(简体) (Chinese Simplified)**
|
||||
|
||||
---
|
||||
|
||||
## 2. 权限使用理由申诉 (Permissions Justifications)
|
||||
|
||||
在提交审核时,Google 审核人员会要求逐项填写权限的必要性理由,请直接复制以下内容:
|
||||
|
||||
| 权限 / 域名 | 审核用途说明(中文/英文) |
|
||||
| :--- | :--- |
|
||||
| **`tabs`** | 用于检测当前激活标签页是否属于支持的电商商品页面(淘宝/天猫),并在分析任务完成后打开报告页面展示结果。<br>*(Used to detect if the active tab is an e-commerce product page and open the generated report in a new tab upon completion.)* |
|
||||
| **`scripting`** | 用于在用户主动点击采集时,向商品页安全注入提取脚本,读取商品公开的规格参数及评论结构。<br>*(Used to inject content scripts to extract public product specifications and customer reviews when triggered by user.)* |
|
||||
| **`storage`** | 用于在本地临时保存采集状态、进度以及身份授权凭证,保证用户刷新或切换标签时不丢失进度。<br>*(Used to store temporary collection progress, state, and authentication tokens locally.)* |
|
||||
| **`alarms`** | 用于在长任务采集期间保持服务状态心跳,避免浏览器后台 Service Worker 意外失活导致任务中断。<br>*(Used to maintain scheduled background heartbeats to prevent service worker idling during long collection tasks.)* |
|
||||
| **`notifications`** | 用于在云端七角色分析报告生成完成时,向用户发送桌面完成提醒。<br>*(Used to notify the user when the AI product analysis report is ready.)* |
|
||||
| **`https://*.tmall.com/*` / `https://*.taobao.com/*`** | 核心业务目标域名,用于合法读取用户当前正在浏览的目标商品公开详情与评价。<br>*(Host permissions required to parse public product details and user reviews on Tmall/Taobao.)* |
|
||||
| **`https://*.alicdn.com/*`** | 用于获取商品原图的高清资源链接与缩略图预览。<br>*(Required to fetch high-resolution product image assets hosted on Alibaba CDN.)* |
|
||||
| **`https://api.sanshengai.net/*` / `https://www.sanshengai.net/*`** | 三笙官方生产服务端接口,用于传输清洗后的商品脱敏数据并接收云端大模型开品分析结果。<br>*(Official backend API endpoints to submit analysis payloads and retrieve report data securely via HTTPS.)* |
|
||||
|
||||
---
|
||||
|
||||
## 3. 单一用途说明 (Single Purpose)
|
||||
```text
|
||||
本扩展的单一用途是为电商从业者在浏览淘宝/天猫商品详情页时,提供商品公开信息结构化分析与云端 AI 开品辅助决策。
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 隐私权政策 (Privacy Policy)
|
||||
|
||||
- **隐私政策声明网址**:`https://www.sanshengai.net/privacy`(请确保公司官网该页面可访问)
|
||||
- **数据使用申明**:
|
||||
- [x] 不出售任何用户数据
|
||||
- [x] 不用于非关联目的或信用评估
|
||||
- [x] 仅用于支持本扩展的核心商品分析功能
|
||||
|
||||
---
|
||||
|
||||
## 5. 准备材料清单
|
||||
|
||||
- [x] **扩展 ZIP 压缩包**:`release/sansheng-product-analysis-extension-v9.3.0-webstore.zip`(已打包完成)
|
||||
- [x] **应用图标**:16x16, 48x48, 128x128(已全部内置于 ZIP)
|
||||
- [ ] **商店宣传截图 (Screenshots)**:准备 1~5 张 1280×800 或 640×400 的商品页操作与报告截图。
|
||||
- [ ] **小型宣传图 (Small Promo Tile)**:440×280 像素 PNG(可选)。
|
||||
@@ -22,7 +22,50 @@
|
||||
- evidenceIds 只用于机器追溯,不作为用户可见说明。
|
||||
- 未采集经营数据时,不推断市场规模、转化率、利润或竞品表现。
|
||||
|
||||
## 文本长度
|
||||
## 参考图拍摄一致策略(`matchReferenceShooting`)
|
||||
|
||||
该字段是一个显式的视觉策略开关,默认 `false`。它解决“产品要不要换”和“拍摄方式要不要跟参考图”被模型混为一谈的问题:
|
||||
|
||||
- `false`:参考图只作为商品身份、结构、材质、颜色和比例锚点;机位、景别、背景、光线、具体动作和摆放状态按当前图片的业务目标与产品目标重新设计。
|
||||
- `true`:同时迁移参考图的拍摄语法与产品展示状态。除机位/视角、景别、透视、构图、背景、光线和留白外,还必须具体复现平铺/悬挂/折叠/展开/堆叠/铺设/穿着/手持/使用中等状态,以及朝向、折叠程度、垂落褶皱、落点、支撑/接触点、遮挡、部件关系和人物动作阶段;不复制品牌、文字、无关商品或未经证实的卖点。
|
||||
|
||||
提示词优先级为:平台合规与安全 > 用户明确商品设定 / `identityLock` > 方案产品目标与批准的产品变化 > 当前槽位业务目标和证据任务 > 勾选时的参考图拍摄语言 > 默认视觉风格。首张 `main:1` 以第一张参考图作为商品外观母版;只有产品目标或用户最终设定明确列出的字段可以改变,其余轮廓、比例、材质、颜色、件数、结构和配件必须冻结。中文约束和 English constraints 同时写入最终 prompt,预览与正式生成共享同一策略。
|
||||
|
||||
## 对应参考图文案生成补充
|
||||
|
||||
开启参考一致时,对应采集图提供表达主题、产品展示状态和文字版式;上传产品图提供当前产品身份。参考图原文和参数不能直接作为当前产品事实。
|
||||
|
||||
每张生图任务携带对应图片的主题、视觉信息与商业含义分析。用户未指定文字策略时,按方案与参考图表达生成当前产品文案;用户明确要求决定文字是否出现、具体内容及排版变化。文字识别缺失不等于参考图无字,须查看实际参考图。
|
||||
|
||||
展示状态和文字版式作为固定视觉约束,方案与默认槽位任务在其中表达,不得改换参考图主题。保留文字位置、大小占比、对齐、层级、换行、行距和留白;文案过长时精简内容,不能移动或缩小商品、改变动作或折叠状态腾出文字区域。不复制参考商品原文、品牌或未经确认的宣称。
|
||||
|
||||
本模式的执行顺序为:先确认当前产品身份与事实,锁定对应参考图展示状态和文字版式,再将方案与业务文案适配进去;默认风格不得改变固定视觉约束。上述补充取代旧版“不复制文字”的笼统限制及允许业务任务改动参考状态的排序:禁止照抄原文,不禁止生成当前产品文案。预览与正式生成共享相同规则。
|
||||
|
||||
## 参考数据实际传输
|
||||
|
||||
开启参考一致时,按对应图片证据 ID 读取原始图片角色分析和构图观察,不用页面固定槽位名称改写主题,也不套用页面标签长度限制截断原始文字、构图或分析字段。完整原始分析随该图提示词传递;其中采集商品参数仅是参考表达,不是当前产品事实。
|
||||
|
||||
当前 API易 GPT Image 渠道携带参考图时使用 `/images/edits` 的 multipart 请求:第一个 `image[]` 文件为上传产品身份图,第二个为本槽位对应采集图。发送真实图片字节,不以 URL 别名字段或图片数量记录代替图片输入。读取失败、参考绑定不准确或图片数据无效时终止该槽位,不降级为无参考生图或其他采集图。
|
||||
|
||||
## 用户输入、方案与对应参考图的统一判断
|
||||
|
||||
没有全局或单图输入时,沿用所选方案的默认生图流程,不增加意图分析调用。参考一致开关继续决定展示与排版策略。
|
||||
|
||||
有输入时,使用完整用户内容、所选方案、产品与品类背景以及逐图原始参考分析进行统一语义判断,不以固定槽位关键词决定是否丢弃输入。方案始终是底座;用户明确要求及必然关联内容覆盖对应默认字段,其他适用方向继续保留。换品类不自动清空方案、重做报告或要求补齐所有新品资料;文案与展示按新品适配,不把旧品专属参数冒充新品事实。
|
||||
|
||||
判断结果包含明确产品更新、整套执行要求和每张图的主题/展示/文案关系。单图视觉要求只作用对应图片,单图明确的产品更新作用整套商品。参考一致开启时保持对应图适用的状态及文字位置、大小、层级和排版;用户明确改变的项可覆盖,跨品类不强迫新品执行不适用的旧品动作。
|
||||
|
||||
预览与正式生成调用同一解析函数,对相同上下文在当前服务进程的有界缓存内复用结果;输入、方案、产品图片或参考分析变化后重新判断。解析格式或接口异常保留完整输入与上下文交由生图模型理解,不终止整套任务;没有原文依据的解析更新不当成确认事实。单图原始编辑不再额外追加“无条件最高优先”提示,避免覆盖已统一的关系。
|
||||
|
||||
## 生图有效指令组装
|
||||
|
||||
参考观察增加通用品类无关的displayRelations:可见局部、局部间连接/接触/覆盖/遮挡/方向/相对位置/形变、视角及不可确认区域。主图和详情分析按原图证据ID记录,逐图完整传递,不猜隐藏结构,不将层数当件数。参考一致开启时,有输入和无输入共用空间关系执行约束;产品固有组成与展示形变分离。旧报告没有该字段时,生成模型直接核对实际对应参考图,不因字段缺失阻断生成,也不填固定折叠模板。输入检查不等于生成结果已精确复现,结果仍需按可见关系对照。
|
||||
|
||||
用户输入只授权其涉及的变化,不能由辅助理解将局部调整扩大为重拍或品类重判。开启参考一致时,对应图实际展示状态及原始构图观察是未被覆盖的执行依据,不仅是低优先级资料;产品图只确定身份,不能覆盖参考姿态或场景。辅助解析位于有效约束之后,仅解释授权变化。商品解析更新被拒绝时,同批可能受其污染的逐图描述也退出生图输入;原始返回仍保存用于排查,完整用户输入继续执行,不终止整套生图。
|
||||
|
||||
当前产品身份不可擅自变化,用户明确要求其次,适用参考关系再次,方案补充未指定内容。参考排版只在仍适用时保持。统一判断结果为辅助理解,完整原文保留在最终请求;普通解析格式或接口异常不终止生图,未能验证的商品解析更新不提升为已确认事实。最终有效指令重新组装,参考观察和方案以资料上下文传递,不重复追加旧强制指令。原始解析返回及异常随任务用户上下文保存用于排查。
|
||||
|
||||
## 文本长度限制
|
||||
|
||||
- 核心判断与专业意见不超过55字。
|
||||
- 老板结论不超过28字,补充说明不超过28字。
|
||||
|
||||
@@ -43,6 +43,20 @@ V9.3.0 将采集、七角色分析、证据追溯和最终报告统一为一条
|
||||
|
||||
## 本地运行
|
||||
|
||||
### 浏览器扩展环境
|
||||
|
||||
在 `extension/auth.js` 中修改 `SANSHENG_ENV` 选择环境,默认为 `dev`。
|
||||
|
||||
| 环境 | SANSHENG_ENV | 接口前缀 | PC 端地址 |
|
||||
| --- | --- | --- | --- |
|
||||
| 开发 | `dev` | `http://localhost:19174` | `http://localhost:5173` |
|
||||
| 测试 | `test` | `https://qa-api.sanshengai.net` | `https://qa.sanshengai.net` |
|
||||
| 生产 | `prod` | `https://api.sanshengai.net` | `https://www.sanshengai.net` |
|
||||
|
||||
切换后,在浏览器扩展管理页重新加载 `extension` 目录的扩展,并退出登录后重新登录。`extension/manifest.json` 已包含上述环境的域名权限。已有 `release` 发布包使用其自身配置。
|
||||
|
||||
### 本地分析服务
|
||||
|
||||
双击 `start-tmall-ai.command`,或在 `server` 目录运行:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1 +1 @@
|
||||
9.3.0
|
||||
1.0.0
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
// 切换环境后,在浏览器扩展管理页重新加载扩展并重新登录。
|
||||
const SANSHENG_ENV='test'; // dev | test | prod
|
||||
const SANSHENG_ENVIRONMENTS={
|
||||
dev:{apiBase:'http://localhost:19174',pcBase:'http://localhost:5173'},
|
||||
test:{apiBase:'https://qa-api.sanshengai.net',pcBase:'https://qa.sanshengai.net'},
|
||||
prod:{apiBase:'https://api.sanshengai.net',pcBase:'https://www.sanshengai.net'}
|
||||
};
|
||||
if(!Object.prototype.hasOwnProperty.call(SANSHENG_ENVIRONMENTS,SANSHENG_ENV))throw new Error(`未知扩展环境:${SANSHENG_ENV}`);
|
||||
const SANSHENG_AUTH_CONFIG={
|
||||
apiBase:'http://localhost:19174',
|
||||
pcBase:'http://localhost:5173',
|
||||
...SANSHENG_ENVIRONMENTS[SANSHENG_ENV],
|
||||
clientId:'sansheng-product-analysis-extension',
|
||||
scope:'product.analysis',
|
||||
callbackPath:'/extension/authorize'
|
||||
|
||||
@@ -21,8 +21,8 @@ async function submitProductAnalysis(raw){
|
||||
try{
|
||||
response=await sanshengExtensionAuth.fetch('/v1/product-analysis/tasks',{method:'POST',headers:{'Content-Type':'application/json','Idempotency-Key':raw.collection.clientRequestId},body:JSON.stringify({platform:raw?.meta?.platform||'taobao',productId:String(raw?.product?.itemId||''),analysisMode:raw?.collection?.analysisMode==='current_snapshot'?'quick':(raw?.collection?.reviewCollectionMode||'standard'),userInstruction:'',clientRequestId:raw.collection.clientRequestId,snapshot:raw,source:'chrome-extension'}),signal:controller.signal});
|
||||
}catch(error){
|
||||
if(error?.name==='AbortError')throw new Error('Web API 响应超时,请确认开发 API 已启动后重试');
|
||||
throw new Error('无法连接 Web API,请确认 http://localhost:19174 已启动');
|
||||
if(error?.name==='AbortError')throw new Error('Web API 响应超时,请确认当前环境 API 可用后重试');
|
||||
throw new Error(`无法连接 Web API,请确认 ${sanshengExtensionAuth.config.apiBase} 可用`);
|
||||
}finally{clearTimeout(timeout)}
|
||||
const result=await response.json().catch(()=>null);
|
||||
const code=result?.Code??result?.code;
|
||||
@@ -317,7 +317,7 @@ async function run(tabId,manual,collectionMode='standard'){
|
||||
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,collectionMode]}).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)}
|
||||
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){const target=live.reviewCap||(collectionMode==='quick'?50:collectionMode==='full'?200:100);const reviewRatio=Math.min(1,(live.count||0)/target);const displayProg=live.progress||Math.min(75,Math.round(20+reviewRatio*55));await setStatus(tabId,{state:'collecting_reviews',message:`评论采集中:${live.count||0} / ${target} 条 (${Math.round(reviewRatio*100)}%) · ${live.mode||'处理中'}${live.stagnant?` · ${live.stagnant}轮无新增`:''}`,progress:displayProg,reviewLive:{...live,elapsedMs:Date.now()-startedAt,reviewCap:target}})}}catch(e){}await sleep(500)}
|
||||
const collectorResult=await collectorPromise;
|
||||
if(collectorError)throw new Error('页面采集脚本执行失败:'+String(collectorError?.message||collectorError));
|
||||
const raw=collectorResult?.[0]?.result;
|
||||
@@ -376,12 +376,74 @@ async function collector(manual=false,collectionMode='standard'){
|
||||
const bodyText=clean(bodyClone?.innerText||document.body?.innerText||'');
|
||||
const captured=[];
|
||||
const reviews=[]; const questions=[];
|
||||
const reviewCap=collectionMode==='quick'?300:(collectionMode==='full'?1500:800);
|
||||
const reviewCap=collectionMode==='quick'?50:(collectionMode==='full'?200:100);
|
||||
const reviewNetMeta={totals:[],hasMoreFalse:false,lastPageFlags:[],pageHints:[],networkReviewObjects:0,networkResponses:0};
|
||||
const reviewStartedAt=Date.now();
|
||||
const processedCaptureKeys=new Set();
|
||||
function ensureInPageHud(){
|
||||
let hud=document.getElementById('__sansheng_hud__');
|
||||
if(!hud){
|
||||
hud=document.createElement('div');
|
||||
hud.id='__sansheng_hud__';
|
||||
hud.innerHTML=`<style>
|
||||
#__sansheng_hud__{position:fixed!important;top:20px!important;right:20px!important;z-index:2147483647!important;width:320px!important;background:rgba(18,28,33,.94)!important;backdrop-filter:blur(16px)!important;-webkit-backdrop-filter:blur(16px)!important;border:1px solid rgba(255,255,255,.16)!important;border-radius:12px!important;box-shadow:0 16px 36px rgba(0,0,0,.4)!important;color:#fff!important;font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif!important;padding:14px 16px!important;font-size:12px!important;line-height:1.5!important;box-sizing:border-box!important;pointer-events:auto!important;transition:opacity .3s,transform .3s!important}
|
||||
#__sansheng_hud__ *{box-sizing:border-box!important;margin:0!important}
|
||||
.__ss_header{display:flex!important;align-items:center!important;justify-content:space-between!important;margin-bottom:10px!important;padding-bottom:8px!important;border-bottom:1px solid rgba(255,255,255,.1)!important}
|
||||
.__ss_brand{display:flex!important;align-items:center!important;gap:8px!important;font-weight:600!important;font-size:13px!important;color:#fff!important}
|
||||
.__ss_pulse{width:8px!important;height:8px!important;border-radius:50%!important;background:#00a98f!important;box-shadow:0 0 0 3px rgba(0,169,143,.3)!important;animation:__ss_p 1.4s infinite!important}
|
||||
@keyframes __ss_p{50%{opacity:.35!important;transform:scale(.85)!important}}
|
||||
.__ss_stop_btn{background:rgba(255,255,255,.1)!important;border:1px solid rgba(255,255,255,.2)!important;color:#ff7875!important;border-radius:5px!important;font-size:11px!important;padding:3px 8px!important;cursor:pointer!important}
|
||||
.__ss_stop_btn:hover{background:rgba(255,78,78,.25)!important}
|
||||
.__ss_status_text{display:flex!important;justify-content:space-between!important;font-size:12px!important;color:#d1dedc!important;margin-bottom:6px!important}
|
||||
.__ss_status_text b{color:#2dd4bf!important}
|
||||
.__ss_prog_track{height:6px!important;background:rgba(255,255,255,.15)!important;border-radius:3px!important;overflow:hidden!important;margin-bottom:10px!important}
|
||||
.__ss_prog_bar{height:100%!important;width:0%!important;background:linear-gradient(90deg,#00a98f,#1675d1)!important;border-radius:3px!important;transition:width .3s ease!important}
|
||||
.__ss_stats{display:grid!important;grid-template-columns:repeat(3,1fr)!important;gap:6px!important;background:rgba(0,0,0,.25)!important;border-radius:6px!important;padding:6px 8px!important;text-align:center!important;margin-bottom:8px!important}
|
||||
.__ss_stats span{display:block!important;font-size:10px!important;color:#8da4a8!important}
|
||||
.__ss_stats b{font-size:12px!important;color:#fff!important}
|
||||
.__ss_preview{font-size:11px!important;color:#9cb3b6!important;white-space:nowrap!important;overflow:hidden!important;text-overflow:ellipsis!important;background:rgba(255,255,255,.06)!important;border-radius:4px!important;padding:4px 8px!important}
|
||||
</style>
|
||||
<div class="__ss_header"><div class="__ss_brand"><span class="__ss_pulse" id="__ss_pulse__"></span><span>三笙 · 商品采集 V1.0</span></div><button class="__ss_stop_btn" id="__ss_stop__">终止采集</button></div>
|
||||
<div class="__ss_status_text"><span id="__ss_step_label__">准备开始采集...</span><b id="__ss_pct__">0%</b></div>
|
||||
<div class="__ss_prog_track"><div class="__ss_prog_bar" id="__ss_bar__"></div></div>
|
||||
<div class="__ss_stats"><div><span>有效评价</span><b id="__ss_stat_reviews__">0 / ${reviewCap}</b></div><div><span>翻页轮次</span><b id="__ss_stat_rounds__">第 0 轮</b></div><div><span>已用耗时</span><b id="__ss_stat_time__">0s</b></div></div>
|
||||
<div class="__ss_preview" id="__ss_preview_box__">等待读取商品评价...</div>`;
|
||||
document.body.appendChild(hud);
|
||||
document.getElementById('__ss_stop__')?.addEventListener('click',()=>{window.__TMALL_AI_STOP_REVIEW__=true;const b=document.getElementById('__ss_stop__');if(b)b.textContent='终止中…'});
|
||||
}
|
||||
return hud;
|
||||
}
|
||||
function updateInPageHud(data){
|
||||
try{
|
||||
ensureInPageHud();
|
||||
const count=data.count||0;const cap=reviewCap;const pct=Math.min(100,Math.max(0,data.progress!=null?data.progress:Math.round((count/cap)*100)));
|
||||
const bar=document.getElementById('__ss_bar__');if(bar)bar.style.width=pct+'%';
|
||||
const pctEl=document.getElementById('__ss_pct__');if(pctEl)pctEl.textContent=pct+'%';
|
||||
const labelEl=document.getElementById('__ss_step_label__');if(labelEl)labelEl.textContent=data.message||`评论采集中 (${count}/${cap}条)`;
|
||||
const statReviews=document.getElementById('__ss_stat_reviews__');if(statReviews)statReviews.textContent=`${count} / ${cap}`;
|
||||
const statRounds=document.getElementById('__ss_stat_rounds__');if(statRounds)statRounds.textContent=`第 ${data.round||0} 轮`;
|
||||
const statTime=document.getElementById('__ss_stat_time__');if(statTime)statTime.textContent=Math.round((data.elapsedMs||0)/1000)+'s';
|
||||
const previewBox=document.getElementById('__ss_preview_box__');if(previewBox&&data.preview&&data.preview.length)previewBox.textContent='最新:'+data.preview[0];
|
||||
}catch(e){}
|
||||
}
|
||||
function finishInPageHud(success=true,message=''){
|
||||
try{
|
||||
const hud=document.getElementById('__sansheng_hud__');
|
||||
if(hud){
|
||||
const pulse=document.getElementById('__ss_pulse__');if(pulse){pulse.style.background=success?'#00a98f':'#ff7875';pulse.style.animation='none'}
|
||||
const label=document.getElementById('__ss_step_label__');if(label)label.textContent=message||(success?`✓ 采集完成(共 ${reviews.length} 条评论),正在提交...`:'采集已终止');
|
||||
const bar=document.getElementById('__ss_bar__');if(bar&&success)bar.style.width='100%';
|
||||
const pct=document.getElementById('__ss_pct__');if(pct&&success)pct.textContent='100%';
|
||||
setTimeout(()=>{if(hud&&hud.parentNode){hud.style.opacity='0';hud.style.transform='translateY(-12px)';setTimeout(()=>{if(hud.parentNode)hud.remove()},400)}},3500);
|
||||
}
|
||||
}catch(e){}
|
||||
}
|
||||
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__={
|
||||
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?'…':''))}}
|
||||
const currCount=dedupReviewCount();const reviewRatio=Math.min(1,currCount/reviewCap);const overallProg=Math.min(78,Math.round(20+reviewRatio*58));
|
||||
updateInPageHud({count:currCount,round,progress:overallProg,message:mode||`评论采集中:${currCount}/${reviewCap}条`,elapsedMs:Date.now()-reviewStartedAt,preview});
|
||||
window.__TMALL_AI_REVIEW_PROGRESS__={progress:overallProg,reviewCap,
|
||||
count:dedupReviewCount(),
|
||||
rawCount:reviews.length,
|
||||
networkReviewObjects:reviewNetMeta.networkReviewObjects,
|
||||
@@ -563,7 +625,7 @@ async function collector(manual=false,collectionMode='standard'){
|
||||
// 每轮即时解析已捕获网络响应
|
||||
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});
|
||||
if(collectionMode!=='full' && after>=reviewCap){collectionComplete=true;explicitEnd=true;stopReason='sample_cap';publishReviewProgress(round+1,stagnant,collectionMode==='quick'?'已采够快速样本':'已采够标准样本');break}
|
||||
if(after>=reviewCap){collectionComplete=true;explicitEnd=true;stopReason='sample_cap';publishReviewProgress(round+1,stagnant,collectionMode==='quick'?'已采够快速样本':collectionMode==='full'?'已采够深度样本':'已采够标准样本');break}
|
||||
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}
|
||||
@@ -807,6 +869,7 @@ async function collector(manual=false,collectionMode='standard'){
|
||||
// 严禁“无新增/无下一页控件”单独作为完整依据
|
||||
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});
|
||||
finishInPageHud(collectionComplete, collectionComplete ? ('✓ 采集完成(已获取 ' + deReview.size + ' 条评论),正在提交分析...') : ('采集结束:已保存 ' + deReview.size + ' 条评论'));
|
||||
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},
|
||||
|
||||
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 627 B |
|
After Width: | Height: | Size: 2.4 KiB |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "三笙 · 电商商品分析闭环 V9.2.5",
|
||||
"version": "9.2.5",
|
||||
"name": "三笙 · 电商商品分析闭环 V1.0",
|
||||
"version": "1.0.0",
|
||||
"description": "跨类目电商商品分析:动态参数与SKU采集、图文分源、评论证据、开品与AI视觉方案。",
|
||||
"permissions": [
|
||||
"scripting",
|
||||
@@ -17,6 +17,8 @@
|
||||
"https://*.alicdn.com/*",
|
||||
"https://api.sanshengai.net/*",
|
||||
"https://www.sanshengai.net/*",
|
||||
"https://qa-api.sanshengai.net/*",
|
||||
"https://qa.sanshengai.net/*",
|
||||
"http://localhost:19174/*",
|
||||
"http://localhost:5173/*"
|
||||
],
|
||||
@@ -24,12 +26,18 @@
|
||||
"service_worker": "background.js"
|
||||
},
|
||||
"icons": {
|
||||
"16": "icon-16.png",
|
||||
"48": "icon-48.png",
|
||||
"128": "icon-128.png"
|
||||
},
|
||||
"action": {
|
||||
"default_popup": "popup.html",
|
||||
"default_title": "三笙 · 电商商品分析闭环 V9.2.5",
|
||||
"default_icon": "icon-128.png"
|
||||
"default_title": "三笙 · 电商商品分析闭环 V1.0",
|
||||
"default_icon": {
|
||||
"16": "icon-16.png",
|
||||
"48": "icon-48.png",
|
||||
"128": "icon-128.png"
|
||||
}
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
|
||||
@@ -182,3 +182,68 @@ main { padding: 14px 16px 18px; }
|
||||
.mode.active { border-color: var(--ink); background: var(--ink); color: #fff; }
|
||||
.mode.active small { color: #c6d1d3; }
|
||||
.option-note { margin-top: 8px; color: var(--muted); font-size: 10px; text-align: center; }
|
||||
|
||||
/* 采集流程四步可视化指示条 */
|
||||
.steps-tracker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 14px;
|
||||
padding: 10px 12px;
|
||||
background: var(--soft);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
.tracker-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
.tracker-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #b5c3c6;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
.tracker-label {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tracker-label small {
|
||||
display: inline-block;
|
||||
font-size: 9px;
|
||||
background: rgba(0, 169, 143, 0.15);
|
||||
color: var(--green-dark);
|
||||
padding: 1px 4px;
|
||||
border-radius: 4px;
|
||||
font-weight: 700;
|
||||
margin-left: 2px;
|
||||
}
|
||||
.tracker-line {
|
||||
flex: 1;
|
||||
height: 2px;
|
||||
background: var(--line);
|
||||
margin: 0 4px 14px 4px;
|
||||
}
|
||||
.tracker-item.active .tracker-dot {
|
||||
background: var(--blue);
|
||||
box-shadow: 0 0 0 4px rgb(22 117 209 / 18%);
|
||||
transform: scale(1.15);
|
||||
}
|
||||
.tracker-item.active .tracker-label {
|
||||
color: var(--blue);
|
||||
font-weight: 700;
|
||||
}
|
||||
.tracker-item.done .tracker-dot {
|
||||
background: var(--green);
|
||||
box-shadow: 0 0 0 3px rgb(0 169 143 / 15%);
|
||||
}
|
||||
.tracker-item.done .tracker-label {
|
||||
color: var(--green-dark);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<img src="icon-128.png" alt="">
|
||||
<div>
|
||||
<h1>三笙商品分析</h1>
|
||||
<span>电商单品洞察</span>
|
||||
<span>电商单品洞察 V1.0</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="text-action" id="openReports" style="display:none">报告记录</button>
|
||||
@@ -43,7 +43,29 @@
|
||||
<b id="progressValue">0%</b>
|
||||
</div>
|
||||
<div id="status" class="status-message">请打开天猫或淘宝商品详情页</div>
|
||||
<div class="progress-track"><i id="bar"></i></div>
|
||||
<div class="progress-track"><i id="bar"></i></div>
|
||||
<!-- 采集阶段可视化跟踪 -->
|
||||
<div class="steps-tracker" id="stepsTracker">
|
||||
<div class="tracker-item" id="stepInfo">
|
||||
<span class="tracker-dot"></span>
|
||||
<span class="tracker-label">基本信息</span>
|
||||
</div>
|
||||
<div class="tracker-line"></div>
|
||||
<div class="tracker-item" id="stepParams">
|
||||
<span class="tracker-dot"></span>
|
||||
<span class="tracker-label">规格参数</span>
|
||||
</div>
|
||||
<div class="tracker-line"></div>
|
||||
<div class="tracker-item" id="stepReviews">
|
||||
<span class="tracker-dot"></span>
|
||||
<span class="tracker-label">买家评价 <small id="liveReviewBadge" style="display:none"></small></span>
|
||||
</div>
|
||||
<div class="tracker-line"></div>
|
||||
<div class="tracker-item" id="stepReport">
|
||||
<span class="tracker-dot"></span>
|
||||
<span class="tracker-label">云端报告</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="analysisMeta" class="task-meta" style="display:none"></div>
|
||||
<div id="waitHint" class="wait-hint" style="display:none">
|
||||
分析会在后台继续,可以关闭扩展或继续浏览商品。
|
||||
@@ -104,13 +126,13 @@
|
||||
</div>
|
||||
<div class="mode-row" role="group" aria-label="采集深度">
|
||||
<button id="modeQuick" class="mode">
|
||||
<strong>快速</strong><small>最多 300 条</small>
|
||||
<strong>快速</strong><small>最多 50 条</small>
|
||||
</button>
|
||||
<button id="modeStandard" class="mode active">
|
||||
<strong>标准</strong><small>最多 800 条</small>
|
||||
<strong>标准</strong><small>最多 100 条</small>
|
||||
</button>
|
||||
<button id="modeFull" class="mode">
|
||||
<strong>深度</strong><small>最多 1500 条</small>
|
||||
<strong>深度</strong><small>最多 200 条</small>
|
||||
</button>
|
||||
</div>
|
||||
<button id="analyzeNow" class="secondary-button">
|
||||
|
||||
@@ -49,6 +49,42 @@ function renderReviewStatus(lp,show){
|
||||
livePreview.innerHTML=(lp.preview||[]).map((value,index)=>`<div>${index+1}. ${escapeHtml(value)}</div>`).join('')||'等待评论数据…';
|
||||
}
|
||||
|
||||
|
||||
function updateCollectSteps(state, progress, reviewLive) {
|
||||
const stepInfo = byId('stepInfo');
|
||||
const stepParams = byId('stepParams');
|
||||
const stepReviews = byId('stepReviews');
|
||||
const stepReport = byId('stepReport');
|
||||
if (!stepInfo) return;
|
||||
|
||||
const isCollecting = state === 'collecting' || state === 'collecting_reviews';
|
||||
const isAnalyzing = state === 'sending' || state === 'analyzing';
|
||||
const isCompleted = state === 'analysis_completed';
|
||||
|
||||
stepInfo.classList.toggle('done', progress >= 15 || isAnalyzing || isCompleted);
|
||||
stepInfo.classList.toggle('active', progress < 15 && isCollecting);
|
||||
|
||||
stepParams.classList.toggle('done', progress >= 25 || isAnalyzing || isCompleted);
|
||||
stepParams.classList.toggle('active', progress >= 15 && progress < 25 && isCollecting);
|
||||
|
||||
stepReviews.classList.toggle('done', progress >= 75 || isAnalyzing || isCompleted);
|
||||
stepReviews.classList.toggle('active', state === 'collecting_reviews' || (progress >= 25 && progress < 75 && isCollecting));
|
||||
|
||||
stepReport.classList.toggle('done', isCompleted);
|
||||
stepReport.classList.toggle('active', isAnalyzing);
|
||||
|
||||
const liveBadge = byId('liveReviewBadge');
|
||||
if (liveBadge) {
|
||||
if (state === 'collecting_reviews' && reviewLive) {
|
||||
liveBadge.style.display = 'inline-block';
|
||||
const cap = reviewLive.reviewCap || (collectionMode==='quick'?50:collectionMode==='full'?200:100);
|
||||
liveBadge.textContent = String(reviewLive.count || 0) + " / " + String(cap) + " 条";
|
||||
} else {
|
||||
liveBadge.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderTaskStatus(data){
|
||||
const d=data||{};
|
||||
const state=d.state||'idle';
|
||||
@@ -82,6 +118,7 @@ function renderTaskStatus(data){
|
||||
phaseLabel.textContent=phaseMap[state]||'任务处理中';
|
||||
statusCard.dataset.state=reportReady?'completed':(analysisFailed||analysisBlocked)?'failed':analysisBusy?'analyzing':activeCollect?'collecting':'idle';
|
||||
isAnalysisRunning=analysisBusy;
|
||||
updateCollectSteps(state, progress, d.reviewLive);
|
||||
|
||||
updateRunButtonText(waitingForReviews,false);
|
||||
collectionControls.style.display=analysisBusy||reportReady?'none':'grid';
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
# Chrome Web Store 上架申报与资料说明
|
||||
|
||||
> 适用版本:V9.3.0
|
||||
> 打包文件:`release/sansheng-product-analysis-extension-v9.3.0-webstore.zip`
|
||||
> 开发者平台:[Chrome 网上应用店信息中心 (Developer Dashboard)](https://chrome.google.com/webstore/devconsole/)
|
||||
|
||||
---
|
||||
|
||||
## 1. 商店基本信息 (Store Listing)
|
||||
|
||||
### 扩展名称 (Name)
|
||||
```text
|
||||
三笙 · 电商商品分析闭环
|
||||
```
|
||||
|
||||
### 简短摘要 (Summary, 最多 132 字符)
|
||||
```text
|
||||
跨类目电商商品智能分析:支持动态参数与SKU采集、主图详情图文分源、真实评价归纳,生成专业开品与AI视觉分析报告。
|
||||
```
|
||||
|
||||
### 详细描述 (Detailed Description)
|
||||
```text
|
||||
「三笙 · 电商商品分析闭环」是一款面向电商运营、商品策划、设计师及商业决策者的专业商品洞察与开品辅助扩展。
|
||||
|
||||
【核心功能】
|
||||
1. 详情深度解析:自动识别并结构化提取商品参数、SKU规格、主图、详情图、真实买家评价与问大家内容。
|
||||
2. 图文分源分析:对主图视觉信号、详情承接证据进行逐层拆解,避免图片裁切与失真。
|
||||
3. 真实口碑洞察:聚合多轮买家评价,提炼好评卖点、负评痛点及高频反馈,帮助定位产品改良空间。
|
||||
4. 七角色智能报告:联动三笙云端大模型,从商品策略、电商转化、消费者洞察、产品供应链、视觉设计、商业决策等维度输出闭环开品建议。
|
||||
|
||||
【使用说明】
|
||||
1. 在 Chrome 中打开淘宝(taobao.com)或天猫(tmall.com)的商品详情页面。
|
||||
2. 点击浏览器右上角插件图标,选择需要分析的评价样本量。
|
||||
3. 点击“开始采集”,完成后即可一键跳转查看完整的结构化开品分析报告。
|
||||
|
||||
【数据安全与隐私】
|
||||
本扩展仅在用户明确点击操作时分析当前浏览的商品公开信息,绝不收集用户个人隐私账户信息或浏览历史。
|
||||
```
|
||||
|
||||
### 类别 (Category)
|
||||
- **生产力工具 (Productivity)** 或 **购物与分析 (Shopping / Business)**
|
||||
|
||||
### 默认语言 (Default Language)
|
||||
- **中文(简体) (Chinese Simplified)**
|
||||
|
||||
---
|
||||
|
||||
## 2. 权限使用理由申诉 (Permissions Justifications)
|
||||
|
||||
在提交审核时,Google 审核人员会要求逐项填写权限的必要性理由,请直接复制以下内容:
|
||||
|
||||
| 权限 / 域名 | 审核用途说明(中文/英文) |
|
||||
| :--- | :--- |
|
||||
| **`tabs`** | 用于检测当前激活标签页是否属于支持的电商商品页面(淘宝/天猫),并在分析任务完成后打开报告页面展示结果。<br>*(Used to detect if the active tab is an e-commerce product page and open the generated report in a new tab upon completion.)* |
|
||||
| **`scripting`** | 用于在用户主动点击采集时,向商品页安全注入提取脚本,读取商品公开的规格参数及评论结构。<br>*(Used to inject content scripts to extract public product specifications and customer reviews when triggered by user.)* |
|
||||
| **`storage`** | 用于在本地临时保存采集状态、进度以及身份授权凭证,保证用户刷新或切换标签时不丢失进度。<br>*(Used to store temporary collection progress, state, and authentication tokens locally.)* |
|
||||
| **`alarms`** | 用于在长任务采集期间保持服务状态心跳,避免浏览器后台 Service Worker 意外失活导致任务中断。<br>*(Used to maintain scheduled background heartbeats to prevent service worker idling during long collection tasks.)* |
|
||||
| **`notifications`** | 用于在云端七角色分析报告生成完成时,向用户发送桌面完成提醒。<br>*(Used to notify the user when the AI product analysis report is ready.)* |
|
||||
| **`https://*.tmall.com/*` / `https://*.taobao.com/*`** | 核心业务目标域名,用于合法读取用户当前正在浏览的目标商品公开详情与评价。<br>*(Host permissions required to parse public product details and user reviews on Tmall/Taobao.)* |
|
||||
| **`https://*.alicdn.com/*`** | 用于获取商品原图的高清资源链接与缩略图预览。<br>*(Required to fetch high-resolution product image assets hosted on Alibaba CDN.)* |
|
||||
| **`https://api.sanshengai.net/*` / `https://www.sanshengai.net/*`** | 三笙官方生产服务端接口,用于传输清洗后的商品脱敏数据并接收云端大模型开品分析结果。<br>*(Official backend API endpoints to submit analysis payloads and retrieve report data securely via HTTPS.)* |
|
||||
|
||||
---
|
||||
|
||||
## 3. 单一用途说明 (Single Purpose)
|
||||
```text
|
||||
本扩展的单一用途是为电商从业者在浏览淘宝/天猫商品详情页时,提供商品公开信息结构化分析与云端 AI 开品辅助决策。
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 隐私权政策 (Privacy Policy)
|
||||
|
||||
- **隐私政策声明网址**:`https://www.sanshengai.net/privacy`(请确保公司官网该页面可访问)
|
||||
- **数据使用申明**:
|
||||
- [x] 不出售任何用户数据
|
||||
- [x] 不用于非关联目的或信用评估
|
||||
- [x] 仅用于支持本扩展的核心商品分析功能
|
||||
|
||||
---
|
||||
|
||||
## 5. 准备材料清单
|
||||
|
||||
- [x] **扩展 ZIP 压缩包**:`release/sansheng-product-analysis-extension-v9.3.0-webstore.zip`(已打包完成)
|
||||
- [x] **应用图标**:16x16, 48x48, 128x128(已全部内置于 ZIP)
|
||||
- [ ] **商店宣传截图 (Screenshots)**:准备 1~5 张 1280×800 或 640×400 的商品页操作与报告截图。
|
||||
- [ ] **小型宣传图 (Small Promo Tile)**:440×280 像素 PNG(可选)。
|
||||
@@ -0,0 +1,87 @@
|
||||
// 切换环境后,在浏览器扩展管理页重新加载扩展并重新登录。
|
||||
const SANSHENG_ENV='test'; // dev | test | prod
|
||||
const SANSHENG_ENVIRONMENTS={
|
||||
dev:{apiBase:'http://localhost:19174',pcBase:'http://localhost:5173'},
|
||||
test:{apiBase:'https://qa-api.sanshengai.net',pcBase:'https://qa.sanshengai.net'},
|
||||
prod:{apiBase:'https://api.sanshengai.net',pcBase:'https://www.sanshengai.net'}
|
||||
};
|
||||
if(!Object.prototype.hasOwnProperty.call(SANSHENG_ENVIRONMENTS,SANSHENG_ENV))throw new Error(`未知扩展环境:${SANSHENG_ENV}`);
|
||||
const SANSHENG_AUTH_CONFIG={
|
||||
...SANSHENG_ENVIRONMENTS[SANSHENG_ENV],
|
||||
clientId:'sansheng-product-analysis-extension',
|
||||
scope:'product.analysis',
|
||||
callbackPath:'/extension/authorize'
|
||||
};
|
||||
|
||||
const AUTH_ACCESS_KEY='sansheng_extension_access';
|
||||
const AUTH_REFRESH_KEY='sansheng_extension_refresh';
|
||||
const AUTH_PENDING_KEY='sansheng_extension_auth_pending';
|
||||
const AUTH_LAST_CONTEXT_KEY='sansheng_extension_auth_last_context';
|
||||
|
||||
function authBase64Url(bytes){return btoa(String.fromCharCode(...bytes)).replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,'')}
|
||||
function authRandom(size=32){const bytes=new Uint8Array(size);crypto.getRandomValues(bytes);return authBase64Url(bytes)}
|
||||
async function authChallenge(verifier){const digest=await crypto.subtle.digest('SHA-256',new TextEncoder().encode(verifier));return authBase64Url(new Uint8Array(digest))}
|
||||
function authCallbackUrl(){return SANSHENG_AUTH_CONFIG.pcBase+SANSHENG_AUTH_CONFIG.callbackPath}
|
||||
|
||||
async function getExtensionAuth(){
|
||||
const session=await chrome.storage.session.get(AUTH_ACCESS_KEY);
|
||||
const local=await chrome.storage.local.get(AUTH_REFRESH_KEY);
|
||||
return {access:session[AUTH_ACCESS_KEY]||null,refresh:local[AUTH_REFRESH_KEY]||null};
|
||||
}
|
||||
|
||||
async function startExtensionLogin(){
|
||||
const verifier=authRandom(48),state=authRandom(24),challenge=await authChallenge(verifier);
|
||||
const tabs=await chrome.tabs.query({active:true,currentWindow:true});
|
||||
await chrome.storage.local.set({[AUTH_PENDING_KEY]:{verifier,state,createdAt:Date.now(),tabId:tabs?.[0]?.id||0,windowId:tabs?.[0]?.windowId||0}});
|
||||
const query=new URLSearchParams({client_id:SANSHENG_AUTH_CONFIG.clientId,redirect_uri:authCallbackUrl(),scope:SANSHENG_AUTH_CONFIG.scope,state,code_challenge:challenge,code_challenge_method:'S256'});
|
||||
return chrome.tabs.create({url:`${SANSHENG_AUTH_CONFIG.pcBase}${SANSHENG_AUTH_CONFIG.callbackPath}?${query}`,active:true});
|
||||
}
|
||||
|
||||
async function exchangeExtensionCode(callbackUrl){
|
||||
const url=new URL(callbackUrl),code=url.searchParams.get('code'),state=url.searchParams.get('state');
|
||||
if(!code||!state)return false;
|
||||
const pending=(await chrome.storage.local.get(AUTH_PENDING_KEY))[AUTH_PENDING_KEY];
|
||||
if(!pending||pending.state!==state||Date.now()-pending.createdAt>10*60*1000)throw new Error('扩展授权请求已过期,请重新登录');
|
||||
const body=new URLSearchParams({grant_type:'authorization_code',client_id:SANSHENG_AUTH_CONFIG.clientId,code,redirect_uri:authCallbackUrl(),code_verifier:pending.verifier});
|
||||
const response=await fetch(`${SANSHENG_AUTH_CONFIG.apiBase}/V1/OAuth/Token`,{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body});
|
||||
const token=await response.json();
|
||||
if(!response.ok||!token.access_token)throw new Error(token.error_description||'扩展登录失败');
|
||||
await chrome.storage.session.set({[AUTH_ACCESS_KEY]:{token:token.access_token,type:token.token_type||'Sansheng',expiresAt:Date.now()+(token.expires_in||7200)*1000}});
|
||||
await chrome.storage.local.set({[AUTH_REFRESH_KEY]:{token:token.refresh_token,expiresAt:Date.now()+(token.refresh_expires_in||2592000)*1000}});
|
||||
await chrome.storage.local.set({[AUTH_LAST_CONTEXT_KEY]:{tabId:pending.tabId||0,windowId:pending.windowId||0,updatedAt:Date.now()}});
|
||||
await chrome.storage.local.remove(AUTH_PENDING_KEY);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function refreshExtensionToken(){
|
||||
const auth=await getExtensionAuth();
|
||||
if(!auth.refresh?.token)throw new Error('扩展登录已过期');
|
||||
const body=new URLSearchParams({grant_type:'refresh_token',client_id:SANSHENG_AUTH_CONFIG.clientId,refresh_token:auth.refresh.token});
|
||||
const response=await fetch(`${SANSHENG_AUTH_CONFIG.apiBase}/V1/OAuth/Token`,{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body});
|
||||
const token=await response.json();
|
||||
if(!response.ok||!token.access_token)throw new Error(token.error_description||'扩展登录已过期');
|
||||
await chrome.storage.session.set({[AUTH_ACCESS_KEY]:{token:token.access_token,type:token.token_type||'Sansheng',expiresAt:Date.now()+(token.expires_in||7200)*1000}});
|
||||
await chrome.storage.local.set({[AUTH_REFRESH_KEY]:{token:token.refresh_token,expiresAt:Date.now()+(token.refresh_expires_in||2592000)*1000}});
|
||||
return token.access_token;
|
||||
}
|
||||
|
||||
async function extensionApiFetch(path,options={}){
|
||||
let auth=await getExtensionAuth();
|
||||
if(!auth.access?.token||auth.access.expiresAt-Date.now()<60_000){await refreshExtensionToken();auth=await getExtensionAuth()}
|
||||
const send=()=>fetch(SANSHENG_AUTH_CONFIG.apiBase+path,{...options,headers:{...(options.headers||{}),Authorization:`Sansheng ${auth.access.token}`,'Sansheng-Source':'chrome-extension'}});
|
||||
let response=await send();
|
||||
if(response.status===401){await refreshExtensionToken();auth=await getExtensionAuth();response=await send()}
|
||||
return response;
|
||||
}
|
||||
|
||||
async function logoutExtension(){
|
||||
const auth=await getExtensionAuth();
|
||||
if(auth.refresh?.token){
|
||||
const body=new URLSearchParams({client_id:SANSHENG_AUTH_CONFIG.clientId,refresh_token:auth.refresh.token,access_token:auth.access?.token||''});
|
||||
await fetch(`${SANSHENG_AUTH_CONFIG.apiBase}/V1/OAuth/Revoke`,{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body}).catch(()=>{});
|
||||
}
|
||||
await chrome.storage.session.remove(AUTH_ACCESS_KEY);
|
||||
await chrome.storage.local.remove([AUTH_REFRESH_KEY,AUTH_PENDING_KEY,AUTH_LAST_CONTEXT_KEY]);
|
||||
}
|
||||
|
||||
self.sanshengExtensionAuth={config:SANSHENG_AUTH_CONFIG,get:getExtensionAuth,start:startExtensionLogin,exchange:exchangeExtensionCode,fetch:extensionApiFetch,logout:logoutExtension};
|
||||
@@ -0,0 +1,881 @@
|
||||
importScripts('auth.js');
|
||||
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||'');
|
||||
const ANALYSIS_TASKS_KEY='sansheng_product_analysis_tasks';
|
||||
const LAST_ANALYSIS_TASK_KEY='sansheng_product_analysis_last_task';
|
||||
const ANALYSIS_ALARM='sansheng_product_analysis_poll';
|
||||
const ANALYSIS_NOTIFICATION_PREFIX='sansheng-product-analysis-';
|
||||
let serviceHealthCache={checkedAt:0,ready:false,error:''};
|
||||
|
||||
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 submitProductAnalysis(raw){
|
||||
raw.collection=raw.collection||{};
|
||||
raw.collection.clientRequestId=raw.collection.clientRequestId||crypto.randomUUID();
|
||||
const controller=new AbortController();
|
||||
const timeout=setTimeout(()=>controller.abort(),15000);
|
||||
let response;
|
||||
try{
|
||||
response=await sanshengExtensionAuth.fetch('/v1/product-analysis/tasks',{method:'POST',headers:{'Content-Type':'application/json','Idempotency-Key':raw.collection.clientRequestId},body:JSON.stringify({platform:raw?.meta?.platform||'taobao',productId:String(raw?.product?.itemId||''),analysisMode:raw?.collection?.analysisMode==='current_snapshot'?'quick':(raw?.collection?.reviewCollectionMode||'standard'),userInstruction:'',clientRequestId:raw.collection.clientRequestId,snapshot:raw,source:'chrome-extension'}),signal:controller.signal});
|
||||
}catch(error){
|
||||
if(error?.name==='AbortError')throw new Error('Web API 响应超时,请确认当前环境 API 可用后重试');
|
||||
throw new Error(`无法连接 Web API,请确认 ${sanshengExtensionAuth.config.apiBase} 可用`);
|
||||
}finally{clearTimeout(timeout)}
|
||||
const result=await response.json().catch(()=>null);
|
||||
const code=result?.Code??result?.code;
|
||||
if(!response.ok||!result||code!==0)throw new Error(result?.Message||result?.message||`商品分析提交失败 HTTP ${response.status}`);
|
||||
const data=result.Data||result.data||{};const taskId=String(data.TaskId||data.taskId||'');
|
||||
return {ok:true,taskId,taskUrl:sanshengExtensionAuth.config.pcBase+(data.ReportUrl||data.reportUrl||`/product-analysis/reports/${taskId}`)};
|
||||
}
|
||||
async function clearTabStatus(tabId){
|
||||
await chrome.storage.local.remove('status_'+tabId).catch(()=>{});
|
||||
}
|
||||
|
||||
async function checkAnalysisService(force=false){
|
||||
if(!force&&Date.now()-serviceHealthCache.checkedAt<8000)return serviceHealthCache;
|
||||
const controller=new AbortController();
|
||||
const timeout=setTimeout(()=>controller.abort(),5000);
|
||||
try{
|
||||
const response=await sanshengExtensionAuth.fetch('/v1/product-analysis/tasks?pageIndex=1&pageSize=1',{signal:controller.signal});
|
||||
const result=await response.json().catch(()=>null);
|
||||
const code=result?.Code??result?.code;
|
||||
const ready=response.ok&&result&&code===0;
|
||||
serviceHealthCache={checkedAt:Date.now(),ready,error:ready?'':(result?.Message||result?.message||`HTTP ${response.status}`)};
|
||||
}catch(error){
|
||||
serviceHealthCache={checkedAt:Date.now(),ready:false,error:error?.name==='AbortError'?'Web API 响应超时':'无法连接 Web API'};
|
||||
}finally{clearTimeout(timeout)}
|
||||
return serviceHealthCache;
|
||||
}
|
||||
|
||||
function analysisStageMessage(stage,progress){
|
||||
const labels={queued:'任务已进入分析队列',product:'正在分析商品定位与标题',visual:'正在分析主图与视觉表达',detail:'正在分析详情页内容',consumer:'正在汇总消费者体验',strategy:'正在生成经营策略与新品方案',assembling:'正在组装分析报告',completed:'报告已经生成'};
|
||||
const suffix=progress!=null?` · 分析进度 ${progress}%`:'';
|
||||
if(String(stage||'').startsWith('review'))return `正在分析评论(${String(stage).replace('review','').trim()||'处理中'})${suffix}`;
|
||||
return (labels[stage]||'正在生成报告')+suffix;
|
||||
}
|
||||
|
||||
async function readTrackedAnalysisTasks(){
|
||||
const stored=await chrome.storage.local.get(ANALYSIS_TASKS_KEY);
|
||||
return stored[ANALYSIS_TASKS_KEY]||{};
|
||||
}
|
||||
|
||||
async function saveTrackedAnalysisTasks(tasks){
|
||||
await chrome.storage.local.set({[ANALYSIS_TASKS_KEY]:tasks});
|
||||
if(Object.keys(tasks).length){
|
||||
await chrome.alarms.create(ANALYSIS_ALARM,{periodInMinutes:.5});
|
||||
}else{
|
||||
await chrome.alarms.clear(ANALYSIS_ALARM);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateAnalysisBadge(state,progress){
|
||||
const text=state==='analysis_completed'?'✓':state==='analysis_failed'?'!':String(Math.max(1,Math.min(99,Math.round(progress||1))));
|
||||
const color=state==='analysis_completed'?'#138a70':state==='analysis_failed'?'#b83b4b':'#315fba';
|
||||
await chrome.action.setBadgeBackgroundColor({color}).catch(()=>{});
|
||||
await chrome.action.setBadgeText({text}).catch(()=>{});
|
||||
}
|
||||
|
||||
async function showAnalysisNotification(task,state){
|
||||
const completed=state==='analysis_completed';
|
||||
await chrome.notifications.create(ANALYSIS_NOTIFICATION_PREFIX+task.taskId,{
|
||||
type:'basic',
|
||||
iconUrl:chrome.runtime.getURL('icon-128.png'),
|
||||
title:completed?'商品分析报告已生成':'商品分析未完成',
|
||||
message:completed?'点击通知即可查看完整分析报告。':(task.errorMessage||'请打开扩展查看失败原因。'),
|
||||
priority:completed?2:1
|
||||
}).catch(()=>{});
|
||||
}
|
||||
|
||||
async function writeAnalysisPresentation(task,state,message,progress,extra={}){
|
||||
const effectiveProgress=progress??task.progress??10;
|
||||
task.progress=effectiveProgress;
|
||||
const presentation={state,message,progress:effectiveProgress,taskId:task.taskId,taskUrl:task.taskUrl,productId:task.productId||'',stage:task.stage||'',productTitle:task.productTitle||'',...extra};
|
||||
await setStatus(task.tabId,presentation);
|
||||
await chrome.storage.local.set({[LAST_ANALYSIS_TASK_KEY]:{...presentation,tabId:task.tabId,updatedAt:Date.now()}});
|
||||
await updateAnalysisBadge(state,progress);
|
||||
}
|
||||
|
||||
async function trackProductAnalysis(tabId,submitted,productTitle='',productId=''){
|
||||
const tasks=await readTrackedAnalysisTasks();
|
||||
const task={taskId:submitted.taskId,taskUrl:submitted.taskUrl,tabId,productId:String(productId||''),productTitle,submittedAt:Date.now(),nextPollAt:0,pollErrors:0,stage:'queued'};
|
||||
tasks[task.taskId]=task;
|
||||
await saveTrackedAnalysisTasks(tasks);
|
||||
await writeAnalysisPresentation(task,'analyzing','任务已提交,正在排队分析',80,{analysisProgress:10});
|
||||
await pollTrackedAnalysisTasks(true);
|
||||
}
|
||||
|
||||
async function pollTrackedAnalysisTask(task,force=false){
|
||||
if(!force&&Number(task.nextPollAt||0)>Date.now())return {keep:true,task};
|
||||
try{
|
||||
const response=await sanshengExtensionAuth.fetch(`/v1/product-analysis/tasks/${encodeURIComponent(task.taskId)}`);
|
||||
const result=await response.json().catch(()=>null);
|
||||
const code=result?.Code??result?.code;
|
||||
if(!response.ok||!result||code!==0)throw new Error(result?.Message||result?.message||`任务状态查询失败 HTTP ${response.status}`);
|
||||
const data=result.Data||result.data||{};
|
||||
const status=Number(data.Status??data.status??0),progress=Number(data.Progress??data.progress??10),stage=String(data.Stage??data.stage??'queued');
|
||||
const displayProgress=Math.min(99,80+Math.round(progress*.2));
|
||||
task={...task,stage,progress:displayProgress,analysisProgress:progress,nextPollAt:Date.now()+3000,pollErrors:0};
|
||||
if(status===3){
|
||||
await writeAnalysisPresentation(task,'analysis_completed','报告已生成,点击下方按钮查看',100,{stage:'completed'});
|
||||
await showAnalysisNotification(task,'analysis_completed');
|
||||
return {keep:false,task};
|
||||
}
|
||||
if(status===4){
|
||||
task.errorMessage=String(data.ErrorMessage??data.errorMessage??'分析任务执行失败');
|
||||
await writeAnalysisPresentation(task,'analysis_failed',task.errorMessage,displayProgress,{stage,errorMessage:task.errorMessage,analysisProgress:progress});
|
||||
await showAnalysisNotification(task,'analysis_failed');
|
||||
return {keep:false,task};
|
||||
}
|
||||
const queuedTooLong=stage==='queued'&&Date.now()-Number(task.submittedAt||Date.now())>90000;
|
||||
const message=queuedTooLong?'任务排队时间较长,请确认分析 Worker 已启动;扩展仍会继续等待':analysisStageMessage(stage,progress);
|
||||
await writeAnalysisPresentation(task,'analyzing',message,displayProgress,{stage,analysisProgress:progress});
|
||||
return {keep:true,task};
|
||||
}catch(error){
|
||||
task={...task,nextPollAt:Date.now()+15000,pollErrors:Number(task.pollErrors||0)+1};
|
||||
await writeAnalysisPresentation(task,'analyzing',task.pollErrors>=3?'云端状态暂时不可用,后台稍后继续查询':'正在等待云端分析状态',undefined,{stage:task.stage||'queued'});
|
||||
return {keep:true,task};
|
||||
}
|
||||
}
|
||||
|
||||
async function pollTrackedAnalysisTasks(force=false){
|
||||
const tasks=await readTrackedAnalysisTasks();
|
||||
for(const [taskId,task] of Object.entries(tasks)){
|
||||
const result=await pollTrackedAnalysisTask(task,force);
|
||||
if(result.keep)tasks[taskId]=result.task;else delete tasks[taskId];
|
||||
}
|
||||
await saveTrackedAnalysisTasks(tasks);
|
||||
}
|
||||
|
||||
async function bootstrapLatestAnalysisTask(tabId){
|
||||
try{
|
||||
const response=await sanshengExtensionAuth.fetch('/v1/product-analysis/tasks?pageIndex=1&pageSize=1');
|
||||
const result=await response.json().catch(()=>null);
|
||||
const code=result?.Code??result?.code;
|
||||
if(!response.ok||!result||code!==0)return null;
|
||||
const page=result.Data||result.data||{};
|
||||
const item=(page.List||page.list||[])[0];
|
||||
if(!item)return null;
|
||||
const taskId=String(item.TaskId||item.taskId||'');
|
||||
if(!taskId)return null;
|
||||
const reportPath=item.ReportUrl||item.reportUrl||`/product-analysis/reports/${taskId}`;
|
||||
const taskUrl=/^https?:\/\//i.test(reportPath)?reportPath:sanshengExtensionAuth.config.pcBase+reportPath;
|
||||
const task={taskId,taskUrl,tabId,productId:String(item.ProductId||item.productId||''),productTitle:item.ProductTitle||item.productTitle||'',stage:item.Stage||item.stage||'queued',progress:Number(item.Progress??item.progress??10),nextPollAt:0,pollErrors:0};
|
||||
const status=Number(item.Status??item.status??0);
|
||||
if(status===1||status===2){
|
||||
const tasks=await readTrackedAnalysisTasks();
|
||||
tasks[taskId]=task;
|
||||
await saveTrackedAnalysisTasks(tasks);
|
||||
const polled=await pollTrackedAnalysisTask(task,true);
|
||||
return (await chrome.storage.local.get(LAST_ANALYSIS_TASK_KEY))[LAST_ANALYSIS_TASK_KEY]||polled.task;
|
||||
}
|
||||
if(status===3){
|
||||
await writeAnalysisPresentation(task,'analysis_completed','最近一份报告已生成,点击下方按钮查看',100,{stage:'completed',analysisProgress:100});
|
||||
return (await chrome.storage.local.get(LAST_ANALYSIS_TASK_KEY))[LAST_ANALYSIS_TASK_KEY];
|
||||
}
|
||||
if(status===4){
|
||||
await writeAnalysisPresentation(task,'analysis_failed','最近一份商品分析未完成',task.progress,{stage:task.stage});
|
||||
return (await chrome.storage.local.get(LAST_ANALYSIS_TASK_KEY))[LAST_ANALYSIS_TASK_KEY];
|
||||
}
|
||||
}catch(error){return null}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function refreshAnalysisForTab(tabId){
|
||||
const tasks=await readTrackedAnalysisTasks();
|
||||
const task=Object.values(tasks).find(item=>Number(item.tabId)===Number(tabId));
|
||||
if(task){
|
||||
const result=await pollTrackedAnalysisTask(task,false);
|
||||
if(result.keep)tasks[task.taskId]=result.task;else delete tasks[task.taskId];
|
||||
await saveTrackedAnalysisTasks(tasks);
|
||||
}
|
||||
const stored=await chrome.storage.local.get(['status_'+tabId,LAST_ANALYSIS_TASK_KEY]);
|
||||
const current=stored['status_'+tabId];
|
||||
const last=stored[LAST_ANALYSIS_TASK_KEY];
|
||||
if(current?.taskId)return current;
|
||||
if(last?.taskId)return last;
|
||||
return await bootstrapLatestAnalysisTask(tabId);
|
||||
}
|
||||
|
||||
chrome.alarms.onAlarm.addListener(alarm=>{if(alarm.name===ANALYSIS_ALARM)pollTrackedAnalysisTasks(false).catch(()=>{})});
|
||||
chrome.notifications.onClicked.addListener(notificationId=>{
|
||||
if(!notificationId.startsWith(ANALYSIS_NOTIFICATION_PREFIX))return;
|
||||
const taskId=notificationId.slice(ANALYSIS_NOTIFICATION_PREFIX.length);
|
||||
chrome.tabs.create({url:sanshengExtensionAuth.config.pcBase+`/product-analysis/reports/${encodeURIComponent(taskId)}`});
|
||||
chrome.notifications.clear(notificationId).catch(()=>{});
|
||||
});
|
||||
pollTrackedAnalysisTasks(false).catch(()=>{});
|
||||
|
||||
chrome.runtime.onMessage.addListener((m,s,send)=>{
|
||||
if(m?.type==='SANSHENG_AUTH_START'){sanshengExtensionAuth.start().then(()=>send({ok:true})).catch(e=>send({ok:false,error:String(e)}));return true;}
|
||||
if(m?.type==='SANSHENG_AUTH_STATE'){sanshengExtensionAuth.get().then(async a=>{const loggedIn=!!(a.access?.token||a.refresh?.token);const health=loggedIn?await checkAnalysisService(false):{ready:false,error:''};send({ok:true,loggedIn,serviceReady:health.ready,serviceError:health.error})}).catch(e=>send({ok:false,loggedIn:false,serviceReady:false,error:String(e)}));return true;}
|
||||
if(m?.type==='SANSHENG_AUTH_LOGOUT'){sanshengExtensionAuth.logout().then(async()=>{await saveTrackedAnalysisTasks({});await chrome.storage.local.remove(LAST_ANALYSIS_TASK_KEY);await chrome.action.setBadgeText({text:''}).catch(()=>{});send({ok:true})}).catch(e=>send({ok:false,error:String(e)}));return true;}
|
||||
if(m?.type==='PRODUCT_ANALYSIS_STATUS'){refreshAnalysisForTab(m.tabId).then(status=>send({ok:true,status})).catch(e=>send({ok:false,error:String(e)}));return true;}
|
||||
if(m?.type==='PRODUCT_ANALYSIS_HISTORY'){
|
||||
(async()=>{try{
|
||||
const response=await sanshengExtensionAuth.fetch('/v1/product-analysis/tasks?pageIndex=1&pageSize=8',{signal:AbortSignal.timeout(15000)});
|
||||
const result=await response.json().catch(()=>null); const code=result?.Code??result?.code;
|
||||
if(!response.ok||!result||code!==0)throw new Error(result?.Message||result?.message||'报告记录加载失败');
|
||||
send({ok:true,items:result.Data?.List||result.data?.list||[]});
|
||||
}catch(error){send({ok:false,error:String(error)})}})();
|
||||
return true;
|
||||
}
|
||||
if(m?.type==='PRODUCT_ANALYSIS_NOTICE'){
|
||||
(async()=>{try{
|
||||
await setStatus(m.tabId,{state:m.state||'analysis_blocked',message:String(m.message||'当前暂时无法提交分析'),progress:0,productId:String(m.productId||'')});
|
||||
send({ok:true});
|
||||
}catch(error){send({ok:false,error:String(error)})}})();
|
||||
return true;
|
||||
}
|
||||
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 j=await submitProductAnalysis(raw);
|
||||
await trackProductAnalysis(tabId,j,raw.product?.title||'',raw.product?.itemId||'');
|
||||
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,m.collectionMode||'standard').then(x=>send(x)).catch(e=>send({ok:false,error:String(e)})); return true; }
|
||||
});
|
||||
|
||||
chrome.tabs.onUpdated.addListener((tabId,changeInfo)=>{
|
||||
const url=changeInfo.url;
|
||||
if(url&&isProduct(url))clearTabStatus(tabId).catch(()=>{});
|
||||
if(!url||!url.startsWith(sanshengExtensionAuth.config.pcBase+sanshengExtensionAuth.config.callbackPath))return;
|
||||
const parsed=new URL(url);
|
||||
if(!parsed.searchParams.get('code'))return;
|
||||
sanshengExtensionAuth.exchange(url).then(async ok=>{
|
||||
if(!ok)return;
|
||||
const pending=(await chrome.storage.local.get('sansheng_extension_auth_last_context')).sansheng_extension_auth_last_context;
|
||||
await chrome.tabs.remove(tabId).catch(()=>{});
|
||||
if(pending?.tabId){
|
||||
await chrome.tabs.update(pending.tabId,{active:true}).catch(()=>{});
|
||||
if(pending.windowId)await chrome.windows.update(pending.windowId,{focused:true}).catch(()=>{});
|
||||
await setStatus(pending.tabId,{state:'idle',message:'登录成功,请在当前商品页打开扩展开始采集。',progress:0});
|
||||
await chrome.action.openPopup({windowId:pending.windowId}).catch(()=>{});
|
||||
}
|
||||
}).catch(error=>console.error('[三笙扩展登录]',error));
|
||||
});
|
||||
|
||||
async function run(tabId,manual,collectionMode='standard'){
|
||||
const tab=await chrome.tabs.get(tabId);
|
||||
if(!isProduct(tab.url)) throw new Error('当前标签页不是天猫/淘宝商品详情页');
|
||||
const auth=await sanshengExtensionAuth.get();
|
||||
if(!auth.access?.token&&!auth.refresh?.token){await setStatus(tabId,{state:'login_required',message:'请先登录三笙账号'});throw new Error('请先登录三笙账号')}
|
||||
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,collectionMode]}).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){const target=live.reviewCap||(collectionMode==='quick'?50:collectionMode==='full'?200:100);const reviewRatio=Math.min(1,(live.count||0)/target);const displayProg=live.progress||Math.min(75,Math.round(20+reviewRatio*55));await setStatus(tabId,{state:'collecting_reviews',message:`评论采集中:${live.count||0} / ${target} 条 (${Math.round(reviewRatio*100)}%) · ${live.mode||'处理中'}${live.stagnant?` · ${live.stagnant}轮无新增`:''}`,progress:displayProg,reviewLive:{...live,elapsedMs:Date.now()-startedAt,reviewCap:target}})}}catch(e){}await sleep(500)}
|
||||
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 j=await submitProductAnalysis(raw);
|
||||
await setStatus(tabId,{state:'analyzing',message:'正在分析全方位产品体验并生成解决方案',progress:82,taskId:j.taskId,taskUrl:j.taskUrl});
|
||||
await trackProductAnalysis(tabId,j,raw.product?.title||'',raw.product?.itemId||'');
|
||||
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,collectionMode='standard'){
|
||||
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 reviewCap=collectionMode==='quick'?50:(collectionMode==='full'?200:100);
|
||||
const reviewNetMeta={totals:[],hasMoreFalse:false,lastPageFlags:[],pageHints:[],networkReviewObjects:0,networkResponses:0};
|
||||
const reviewStartedAt=Date.now();
|
||||
const processedCaptureKeys=new Set();
|
||||
function ensureInPageHud(){
|
||||
let hud=document.getElementById('__sansheng_hud__');
|
||||
if(!hud){
|
||||
hud=document.createElement('div');
|
||||
hud.id='__sansheng_hud__';
|
||||
hud.innerHTML=`<style>
|
||||
#__sansheng_hud__{position:fixed!important;top:20px!important;right:20px!important;z-index:2147483647!important;width:320px!important;background:rgba(18,28,33,.94)!important;backdrop-filter:blur(16px)!important;-webkit-backdrop-filter:blur(16px)!important;border:1px solid rgba(255,255,255,.16)!important;border-radius:12px!important;box-shadow:0 16px 36px rgba(0,0,0,.4)!important;color:#fff!important;font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif!important;padding:14px 16px!important;font-size:12px!important;line-height:1.5!important;box-sizing:border-box!important;pointer-events:auto!important;transition:opacity .3s,transform .3s!important}
|
||||
#__sansheng_hud__ *{box-sizing:border-box!important;margin:0!important}
|
||||
.__ss_header{display:flex!important;align-items:center!important;justify-content:space-between!important;margin-bottom:10px!important;padding-bottom:8px!important;border-bottom:1px solid rgba(255,255,255,.1)!important}
|
||||
.__ss_brand{display:flex!important;align-items:center!important;gap:8px!important;font-weight:600!important;font-size:13px!important;color:#fff!important}
|
||||
.__ss_pulse{width:8px!important;height:8px!important;border-radius:50%!important;background:#00a98f!important;box-shadow:0 0 0 3px rgba(0,169,143,.3)!important;animation:__ss_p 1.4s infinite!important}
|
||||
@keyframes __ss_p{50%{opacity:.35!important;transform:scale(.85)!important}}
|
||||
.__ss_stop_btn{background:rgba(255,255,255,.1)!important;border:1px solid rgba(255,255,255,.2)!important;color:#ff7875!important;border-radius:5px!important;font-size:11px!important;padding:3px 8px!important;cursor:pointer!important}
|
||||
.__ss_stop_btn:hover{background:rgba(255,78,78,.25)!important}
|
||||
.__ss_status_text{display:flex!important;justify-content:space-between!important;font-size:12px!important;color:#d1dedc!important;margin-bottom:6px!important}
|
||||
.__ss_status_text b{color:#2dd4bf!important}
|
||||
.__ss_prog_track{height:6px!important;background:rgba(255,255,255,.15)!important;border-radius:3px!important;overflow:hidden!important;margin-bottom:10px!important}
|
||||
.__ss_prog_bar{height:100%!important;width:0%!important;background:linear-gradient(90deg,#00a98f,#1675d1)!important;border-radius:3px!important;transition:width .3s ease!important}
|
||||
.__ss_stats{display:grid!important;grid-template-columns:repeat(3,1fr)!important;gap:6px!important;background:rgba(0,0,0,.25)!important;border-radius:6px!important;padding:6px 8px!important;text-align:center!important;margin-bottom:8px!important}
|
||||
.__ss_stats span{display:block!important;font-size:10px!important;color:#8da4a8!important}
|
||||
.__ss_stats b{font-size:12px!important;color:#fff!important}
|
||||
.__ss_preview{font-size:11px!important;color:#9cb3b6!important;white-space:nowrap!important;overflow:hidden!important;text-overflow:ellipsis!important;background:rgba(255,255,255,.06)!important;border-radius:4px!important;padding:4px 8px!important}
|
||||
</style>
|
||||
<div class="__ss_header"><div class="__ss_brand"><span class="__ss_pulse" id="__ss_pulse__"></span><span>三笙 · 商品采集 V1.0</span></div><button class="__ss_stop_btn" id="__ss_stop__">终止采集</button></div>
|
||||
<div class="__ss_status_text"><span id="__ss_step_label__">准备开始采集...</span><b id="__ss_pct__">0%</b></div>
|
||||
<div class="__ss_prog_track"><div class="__ss_prog_bar" id="__ss_bar__"></div></div>
|
||||
<div class="__ss_stats"><div><span>有效评价</span><b id="__ss_stat_reviews__">0 / ${reviewCap}</b></div><div><span>翻页轮次</span><b id="__ss_stat_rounds__">第 0 轮</b></div><div><span>已用耗时</span><b id="__ss_stat_time__">0s</b></div></div>
|
||||
<div class="__ss_preview" id="__ss_preview_box__">等待读取商品评价...</div>`;
|
||||
document.body.appendChild(hud);
|
||||
document.getElementById('__ss_stop__')?.addEventListener('click',()=>{window.__TMALL_AI_STOP_REVIEW__=true;const b=document.getElementById('__ss_stop__');if(b)b.textContent='终止中…'});
|
||||
}
|
||||
return hud;
|
||||
}
|
||||
function updateInPageHud(data){
|
||||
try{
|
||||
ensureInPageHud();
|
||||
const count=data.count||0;const cap=reviewCap;const pct=Math.min(100,Math.max(0,data.progress!=null?data.progress:Math.round((count/cap)*100)));
|
||||
const bar=document.getElementById('__ss_bar__');if(bar)bar.style.width=pct+'%';
|
||||
const pctEl=document.getElementById('__ss_pct__');if(pctEl)pctEl.textContent=pct+'%';
|
||||
const labelEl=document.getElementById('__ss_step_label__');if(labelEl)labelEl.textContent=data.message||`评论采集中 (${count}/${cap}条)`;
|
||||
const statReviews=document.getElementById('__ss_stat_reviews__');if(statReviews)statReviews.textContent=`${count} / ${cap}`;
|
||||
const statRounds=document.getElementById('__ss_stat_rounds__');if(statRounds)statRounds.textContent=`第 ${data.round||0} 轮`;
|
||||
const statTime=document.getElementById('__ss_stat_time__');if(statTime)statTime.textContent=Math.round((data.elapsedMs||0)/1000)+'s';
|
||||
const previewBox=document.getElementById('__ss_preview_box__');if(previewBox&&data.preview&&data.preview.length)previewBox.textContent='最新:'+data.preview[0];
|
||||
}catch(e){}
|
||||
}
|
||||
function finishInPageHud(success=true,message=''){
|
||||
try{
|
||||
const hud=document.getElementById('__sansheng_hud__');
|
||||
if(hud){
|
||||
const pulse=document.getElementById('__ss_pulse__');if(pulse){pulse.style.background=success?'#00a98f':'#ff7875';pulse.style.animation='none'}
|
||||
const label=document.getElementById('__ss_step_label__');if(label)label.textContent=message||(success?`✓ 采集完成(共 ${reviews.length} 条评论),正在提交...`:'采集已终止');
|
||||
const bar=document.getElementById('__ss_bar__');if(bar&&success)bar.style.width='100%';
|
||||
const pct=document.getElementById('__ss_pct__');if(pct&&success)pct.textContent='100%';
|
||||
setTimeout(()=>{if(hud&&hud.parentNode){hud.style.opacity='0';hud.style.transform='translateY(-12px)';setTimeout(()=>{if(hud.parentNode)hud.remove()},400)}},3500);
|
||||
}
|
||||
}catch(e){}
|
||||
}
|
||||
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?'…':''))}}
|
||||
const currCount=dedupReviewCount();const reviewRatio=Math.min(1,currCount/reviewCap);const overallProg=Math.min(78,Math.round(20+reviewRatio*58));
|
||||
updateInPageHud({count:currCount,round,progress:overallProg,message:mode||`评论采集中:${currCount}/${reviewCap}条`,elapsedMs:Date.now()-reviewStartedAt,preview});
|
||||
window.__TMALL_AI_REVIEW_PROGRESS__={progress:overallProg,reviewCap,
|
||||
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});
|
||||
if(after>=reviewCap){collectionComplete=true;explicitEnd=true;stopReason='sample_cap';publishReviewProgress(round+1,stagnant,collectionMode==='quick'?'已采够快速样本':collectionMode==='full'?'已采够深度样本':'已采够标准样本');break}
|
||||
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)}
|
||||
}
|
||||
let reviewRows=[...deReview.values()];
|
||||
// 评论过多时保留最有决策价值的样本:差评、带图、最新评论优先,其余按原顺序补齐。
|
||||
let reviewSampled=false;
|
||||
if(reviewRows.length>reviewCap){
|
||||
const scored=reviewRows.map((r,i)=>{const rating=Number(String(r.rating||'').match(/[1-5]/)?.[0]||5);const bad=rating<=3?5:0;const media=(r.images||[]).length?3:0;const date=Date.parse(String(r.date||''));const rec=Number.isFinite(date)?Math.max(0,Math.min(2,(date-Date.now()+31536000000)/31536000000)):0;return {r,i,score:bad+media+rec}});
|
||||
scored.sort((a,b)=>b.score-a.score||a.i-b.i);
|
||||
const keep=new Set(scored.slice(0,reviewCap).map(x=>x.i));
|
||||
reviewRows=reviewRows.filter((_,i)=>keep.has(i)); reviewSampled=true;
|
||||
}
|
||||
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});
|
||||
finishInPageHud(collectionComplete, collectionComplete ? ('✓ 采集完成(已获取 ' + deReview.size + ' 条评论),正在提交分析...') : ('采集结束:已保存 ' + deReview.size + ' 条评论'));
|
||||
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,reviewSampled,reviewSampleCap:reviewCap,reviewCollectionMode:collectionMode,questionActual:deQ.size,reviewDrawerOpened:reviewDrawerWasOpened,reviewCollectionComplete:collectionComplete,reviewCompleteness:reviewSampled?'sampled':(collectionComplete?'confirmed':'unconfirmed'),reviewStopReason:reviewSampled?'sample_cap':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}
|
||||
};
|
||||
}
|
||||
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 627 B |
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "三笙 · 电商商品分析闭环 V1.0",
|
||||
"version": "1.0.0",
|
||||
"description": "跨类目电商商品分析:动态参数与SKU采集、图文分源、评论证据、开品与AI视觉方案。",
|
||||
"permissions": [
|
||||
"scripting",
|
||||
"storage",
|
||||
"tabs",
|
||||
"alarms",
|
||||
"notifications"
|
||||
],
|
||||
"host_permissions": [
|
||||
"https://detail.tmall.com/*",
|
||||
"https://*.tmall.com/*",
|
||||
"https://*.taobao.com/*",
|
||||
"https://*.alicdn.com/*",
|
||||
"https://api.sanshengai.net/*",
|
||||
"https://www.sanshengai.net/*",
|
||||
"https://qa-api.sanshengai.net/*",
|
||||
"https://qa.sanshengai.net/*",
|
||||
"http://localhost:19174/*",
|
||||
"http://localhost:5173/*"
|
||||
],
|
||||
"background": {
|
||||
"service_worker": "background.js"
|
||||
},
|
||||
"icons": {
|
||||
"16": "icon-16.png",
|
||||
"48": "icon-48.png",
|
||||
"128": "icon-128.png"
|
||||
},
|
||||
"action": {
|
||||
"default_popup": "popup.html",
|
||||
"default_title": "三笙 · 电商商品分析闭环 V1.0",
|
||||
"default_icon": {
|
||||
"16": "icon-16.png",
|
||||
"48": "icon-48.png",
|
||||
"128": "icon-128.png"
|
||||
}
|
||||
},
|
||||
"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,249 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--ink: #142126;
|
||||
--muted: #687a80;
|
||||
--line: #dbe6e5;
|
||||
--soft: #f3f8f7;
|
||||
--green: #00a98f;
|
||||
--green-dark: #087565;
|
||||
--blue: #1675d1;
|
||||
--danger: #b63f4b;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
/* 扩展窗口按内容自动定尺寸:根节点必须有独立宽度,不能用 vw 反向依赖窗口。
|
||||
只由根节点滚动,始终预留滚动条位置,历史展开和悬停时不挤压内容。 */
|
||||
html {
|
||||
width: 460px;
|
||||
min-width: 460px;
|
||||
max-width: 460px;
|
||||
max-height: 600px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
body {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
background: #f6faf9;
|
||||
color: var(--ink);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
}
|
||||
|
||||
button { font: inherit; }
|
||||
.brand-row > .text-action { flex: 0 0 auto; white-space: nowrap; }
|
||||
|
||||
.app-header {
|
||||
padding: 18px 18px 15px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.brand-row,
|
||||
.account-row,
|
||||
.status-head,
|
||||
.detail-title,
|
||||
.metric-row,
|
||||
.section-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.brand { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
||||
.brand img { width: 34px; height: 34px; border-radius: 8px; }
|
||||
.brand h1 { margin: 0; font-size: 17px; line-height: 1.2; letter-spacing: 0; }
|
||||
.brand span { display: block; margin-top: 2px; color: var(--muted); font-size: 11px; }
|
||||
.app-header p { margin: 12px 0 0; color: var(--muted); font-size: 12px; line-height: 1.55; }
|
||||
|
||||
main { padding: 14px 16px 18px; }
|
||||
|
||||
.text-action,
|
||||
.compact-button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--green-dark);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.compact-button {
|
||||
min-width: 52px;
|
||||
height: 30px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 7px;
|
||||
background: #fff;
|
||||
}
|
||||
.compact-button.primary { border-color: var(--green); background: var(--green); color: #fff; }
|
||||
|
||||
.account-row { margin-bottom: 12px; }
|
||||
.account-copy { display: flex; align-items: center; gap: 9px; min-width: 0; }
|
||||
.account-copy i { width: 8px; height: 8px; flex: 0 0 auto; border-radius: 50%; background: #a9b6b8; }
|
||||
.account-copy i.online { background: var(--green); box-shadow: 0 0 0 4px rgb(0 169 143 / 12%); }
|
||||
.account-copy strong { display: block; font-size: 12px; }
|
||||
.account-copy span { display: block; max-width: 230px; margin-top: 2px; overflow: hidden; color: var(--muted); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.status-card,
|
||||
.detail-panel,
|
||||
.mode-panel {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.status-card { padding: 15px; }
|
||||
.phase { display: flex; align-items: center; gap: 8px; }
|
||||
.phase strong { font-size: 14px; }
|
||||
.phase-indicator { width: 9px; height: 9px; border-radius: 50%; background: #9aa9ac; }
|
||||
.status-head > b { color: var(--green-dark); font-size: 13px; }
|
||||
.status-message { min-height: 38px; margin-top: 10px; color: #53676d; font-size: 12px; line-height: 1.55; }
|
||||
.progress-track { height: 7px; margin-top: 12px; overflow: hidden; border-radius: 6px; background: #e9f0ef; }
|
||||
.progress-track i { display: block; width: 0; height: 100%; border-radius: inherit; background: linear-gradient(90deg, var(--green), var(--blue)); transition: width .25s ease; }
|
||||
.task-meta { margin-top: 10px; overflow: hidden; color: var(--muted); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.wait-hint { margin-top: 10px; padding: 9px 10px; border-radius: 7px; background: #eef7ff; color: #43616e; font-size: 11px; line-height: 1.45; }
|
||||
|
||||
.status-card[data-state="collecting"] .phase-indicator,
|
||||
.status-card[data-state="analyzing"] .phase-indicator {
|
||||
background: var(--blue);
|
||||
box-shadow: 0 0 0 4px rgb(22 117 209 / 12%);
|
||||
animation: pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
.status-card[data-state="completed"] .phase-indicator { background: var(--green); box-shadow: 0 0 0 4px rgb(0 169 143 / 12%); }
|
||||
.status-card[data-state="failed"] .phase-indicator { background: var(--danger); box-shadow: 0 0 0 4px rgb(182 63 75 / 12%); }
|
||||
|
||||
@keyframes pulse {
|
||||
50% { opacity: .35; transform: scale(.8); }
|
||||
}
|
||||
|
||||
.service-line { display: flex; justify-content: flex-end; min-height: 25px; padding: 7px 2px 4px; }
|
||||
.service-line span { color: var(--muted); font-size: 10px; }
|
||||
.service-line span.ok { color: var(--green-dark); }
|
||||
.service-line span.warn { color: #a56a19; }
|
||||
|
||||
.action-section { display: grid; gap: 8px; }
|
||||
.main-button,
|
||||
.secondary-button {
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.main-button { background: linear-gradient(105deg, var(--green), var(--blue)); color: #fff; }
|
||||
.main-button:hover { filter: brightness(.97); }
|
||||
.main-button:disabled,
|
||||
.secondary-button:disabled { cursor: not-allowed; opacity: .55; }
|
||||
.main-button.danger { background: var(--danger); }
|
||||
.report-button { margin-top: 12px; background: var(--ink); }
|
||||
.secondary-button { margin-top: 12px; border: 1px solid #a8c8c4; background: #fff; color: #245d58; }
|
||||
/* 网格列必须允许收缩,否则长商品标题的最小内容宽度会撑出扩展弹窗。 */
|
||||
.history-panel { margin-top: 12px; min-width: 0; }
|
||||
.history-list { display: grid; grid-template-columns: minmax(0, 1fr); gap: 8px; margin-top: 10px; min-width: 0; }
|
||||
.history-item { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 10px; min-width: 0; max-width: 100%; padding: 10px; border: 1px solid var(--line); border-radius: 7px; background: var(--soft); }
|
||||
.history-item > div { min-width: 0; }
|
||||
.history-item strong { display: -webkit-box; overflow: hidden; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow-wrap: anywhere; font-size: 12px; line-height: 1.5; }
|
||||
.history-item span { display: block; margin-top: 5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); font-size: 11px; line-height: 1.4; }
|
||||
.history-item[data-status="3"] span { color: var(--green-dark); }
|
||||
.history-item[data-status="4"] span { color: var(--danger); }
|
||||
.history-item button { min-width: 42px; min-height: 32px; padding: 6px; border: 1px solid var(--line); border-radius: 6px; background: #fff; color: var(--green-dark); cursor: pointer; font-size: 11px; font-weight: 700; }
|
||||
.history-item button:hover { border-color: var(--green); }
|
||||
.history-item button:focus-visible { outline: 2px solid var(--green-dark); outline-offset: 2px; }
|
||||
.history-list .detail-note { overflow-wrap: anywhere; line-height: 1.5; }
|
||||
.detail-panel { margin-top: 12px; padding: 13px; }
|
||||
.detail-title strong,
|
||||
.section-heading strong { font-size: 13px; }
|
||||
.detail-title span,
|
||||
.section-heading span { color: var(--muted); font-size: 11px; }
|
||||
.metric-row { margin-top: 10px; font-size: 11px; }
|
||||
.metric-row span { color: var(--muted); }
|
||||
.metric-row b { font-size: 13px; }
|
||||
.metric-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 11px; }
|
||||
.metric-grid div { padding: 9px; border-radius: 7px; background: var(--soft); }
|
||||
.metric-grid span { display: block; color: var(--muted); font-size: 10px; }
|
||||
.metric-grid b { display: block; margin-top: 3px; font-size: 14px; }
|
||||
.detail-note { margin-top: 9px; color: var(--muted); font-size: 10px; }
|
||||
.param-tags { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 9px; }
|
||||
.param-tag { padding: 3px 7px; border: 1px solid var(--line); border-radius: 6px; background: var(--soft); color: #4c6267; font-size: 10px; }
|
||||
.live-preview { max-height: 68px; margin-top: 9px; overflow: auto; border-top: 1px solid var(--line); padding-top: 8px; color: #53686d; font-size: 10px; line-height: 1.55; }
|
||||
|
||||
.mode-panel { margin-top: 12px; padding: 14px; }
|
||||
.section-heading span { display: block; margin-top: 2px; }
|
||||
.mode-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 7px; margin-top: 11px; }
|
||||
.mode { min-height: 58px; border: 1px solid var(--line); border-radius: 8px; background: #fff; color: var(--ink); cursor: pointer; }
|
||||
.mode strong { display: block; font-size: 12px; }
|
||||
.mode small { display: block; margin-top: 4px; color: var(--muted); font-size: 9px; }
|
||||
.mode.active { border-color: var(--ink); background: var(--ink); color: #fff; }
|
||||
.mode.active small { color: #c6d1d3; }
|
||||
.option-note { margin-top: 8px; color: var(--muted); font-size: 10px; text-align: center; }
|
||||
|
||||
/* 采集流程四步可视化指示条 */
|
||||
.steps-tracker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 14px;
|
||||
padding: 10px 12px;
|
||||
background: var(--soft);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
.tracker-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
.tracker-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #b5c3c6;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
.tracker-label {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tracker-label small {
|
||||
display: inline-block;
|
||||
font-size: 9px;
|
||||
background: rgba(0, 169, 143, 0.15);
|
||||
color: var(--green-dark);
|
||||
padding: 1px 4px;
|
||||
border-radius: 4px;
|
||||
font-weight: 700;
|
||||
margin-left: 2px;
|
||||
}
|
||||
.tracker-line {
|
||||
flex: 1;
|
||||
height: 2px;
|
||||
background: var(--line);
|
||||
margin: 0 4px 14px 4px;
|
||||
}
|
||||
.tracker-item.active .tracker-dot {
|
||||
background: var(--blue);
|
||||
box-shadow: 0 0 0 4px rgb(22 117 209 / 18%);
|
||||
transform: scale(1.15);
|
||||
}
|
||||
.tracker-item.active .tracker-label {
|
||||
color: var(--blue);
|
||||
font-weight: 700;
|
||||
}
|
||||
.tracker-item.done .tracker-dot {
|
||||
background: var(--green);
|
||||
box-shadow: 0 0 0 3px rgb(0 169 143 / 15%);
|
||||
}
|
||||
.tracker-item.done .tracker-label {
|
||||
color: var(--green-dark);
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<link rel="stylesheet" href="popup.css">
|
||||
<title>三笙商品分析</title>
|
||||
</head>
|
||||
<body>
|
||||
<header class="app-header">
|
||||
<div class="brand-row">
|
||||
<div class="brand">
|
||||
<img src="icon-128.png" alt="">
|
||||
<div>
|
||||
<h1>三笙商品分析</h1>
|
||||
<span>电商单品洞察 V1.0</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="text-action" id="openReports" style="display:none">报告记录</button>
|
||||
</div>
|
||||
<p>采集商品、图片、参数和评论,生成可追溯的产品分析报告。</p>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="account-row">
|
||||
<div class="account-copy">
|
||||
<i id="accountDot"></i>
|
||||
<div>
|
||||
<strong>三笙账号</strong>
|
||||
<span id="accountStatus">正在检查登录状态</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="compact-button primary" id="loginAccount">登录</button>
|
||||
<button class="compact-button" id="logoutAccount" style="display:none">退出</button>
|
||||
</section>
|
||||
|
||||
<section class="status-card" id="statusCard" data-state="idle">
|
||||
<div class="status-head">
|
||||
<div class="phase">
|
||||
<span class="phase-indicator"></span>
|
||||
<strong id="phaseLabel">等待开始</strong>
|
||||
</div>
|
||||
<b id="progressValue">0%</b>
|
||||
</div>
|
||||
<div id="status" class="status-message">请打开天猫或淘宝商品详情页</div>
|
||||
<div class="progress-track"><i id="bar"></i></div>
|
||||
<!-- 采集阶段可视化跟踪 -->
|
||||
<div class="steps-tracker" id="stepsTracker">
|
||||
<div class="tracker-item" id="stepInfo">
|
||||
<span class="tracker-dot"></span>
|
||||
<span class="tracker-label">基本信息</span>
|
||||
</div>
|
||||
<div class="tracker-line"></div>
|
||||
<div class="tracker-item" id="stepParams">
|
||||
<span class="tracker-dot"></span>
|
||||
<span class="tracker-label">规格参数</span>
|
||||
</div>
|
||||
<div class="tracker-line"></div>
|
||||
<div class="tracker-item" id="stepReviews">
|
||||
<span class="tracker-dot"></span>
|
||||
<span class="tracker-label">买家评价 <small id="liveReviewBadge" style="display:none"></small></span>
|
||||
</div>
|
||||
<div class="tracker-line"></div>
|
||||
<div class="tracker-item" id="stepReport">
|
||||
<span class="tracker-dot"></span>
|
||||
<span class="tracker-label">云端报告</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="analysisMeta" class="task-meta" style="display:none"></div>
|
||||
<div id="waitHint" class="wait-hint" style="display:none">
|
||||
分析会在后台继续,可以关闭扩展或继续浏览商品。
|
||||
</div>
|
||||
<button class="main-button report-button" id="viewCurrentReport" style="display:none">
|
||||
查看分析报告
|
||||
</button>
|
||||
<button class="secondary-button" id="analyzeAgain" style="display:none">
|
||||
再次分析当前商品
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<div class="service-line">
|
||||
<span id="server">正在检查云端服务</span>
|
||||
</div>
|
||||
|
||||
<section id="collectionControls" class="action-section">
|
||||
<button class="main-button" id="run">标准采集并生成</button>
|
||||
<button class="main-button danger" id="stop" style="display:none">
|
||||
终止采集并保存进度
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section id="paramLive" class="detail-panel" style="display:none">
|
||||
<div class="detail-title">
|
||||
<strong>商品参数</strong>
|
||||
<span id="paramState">等待采集</span>
|
||||
</div>
|
||||
<div class="metric-row">
|
||||
<span>已识别</span>
|
||||
<b><span id="paramCount">0</span> 项</b>
|
||||
</div>
|
||||
<div class="param-tags" id="paramTags"></div>
|
||||
<div class="detail-note" id="paramMissing">缺失项:无</div>
|
||||
</section>
|
||||
|
||||
<section id="reviewLive" class="detail-panel" style="display:none">
|
||||
<div class="detail-title">
|
||||
<strong>评论采集</strong>
|
||||
<span id="liveMode">等待</span>
|
||||
</div>
|
||||
<div class="metric-grid">
|
||||
<div><span>有效评论</span><b id="liveCount">0</b></div>
|
||||
<div><span>网络对象</span><b id="liveNet">0</b></div>
|
||||
<div><span>当前轮次</span><b id="liveRound">0</b></div>
|
||||
<div><span>无新增轮次</span><b id="liveStagnant">0</b></div>
|
||||
</div>
|
||||
<div class="detail-note">采集耗时 <b id="liveElapsed">0s</b></div>
|
||||
<div id="livePreview" class="live-preview">等待评论数据</div>
|
||||
</section>
|
||||
|
||||
<section id="analysisOptions" class="mode-panel">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<strong>采集深度</strong>
|
||||
<span>决定最多分析的评论数量</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mode-row" role="group" aria-label="采集深度">
|
||||
<button id="modeQuick" class="mode">
|
||||
<strong>快速</strong><small>最多 50 条</small>
|
||||
</button>
|
||||
<button id="modeStandard" class="mode active">
|
||||
<strong>标准</strong><small>最多 100 条</small>
|
||||
</button>
|
||||
<button id="modeFull" class="mode">
|
||||
<strong>深度</strong><small>最多 200 条</small>
|
||||
</button>
|
||||
</div>
|
||||
<button id="analyzeNow" class="secondary-button">
|
||||
基于当前已有数据生成报告
|
||||
</button>
|
||||
<div class="option-note">当前数据可能尚未覆盖全部公开评论。</div>
|
||||
</section>
|
||||
|
||||
<section id="reportHistory" class="detail-panel history-panel" style="display:none">
|
||||
<div class="detail-title"><strong>最近报告</strong><button class="text-action" id="closeHistory">收起</button></div>
|
||||
<div id="historyList" class="history-list"></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="auth.js"></script>
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,326 @@
|
||||
let backendCompatible=false;
|
||||
let collectionMode='standard';
|
||||
let analysisMode='standard';
|
||||
let currentReportUrl='';
|
||||
let isAnalysisRunning=false;
|
||||
let refreshRunning=false;
|
||||
|
||||
const byId=id=>document.getElementById(id);
|
||||
const server=byId('server'),status=byId('status'),bar=byId('bar'),runBtn=byId('run'),stopBtn=byId('stop');
|
||||
const reviewLive=byId('reviewLive'),liveCount=byId('liveCount'),liveNet=byId('liveNet'),liveRound=byId('liveRound'),liveStagnant=byId('liveStagnant'),liveMode=byId('liveMode'),liveElapsed=byId('liveElapsed'),livePreview=byId('livePreview');
|
||||
const paramLive=byId('paramLive'),paramState=byId('paramState'),paramCount=byId('paramCount'),paramTags=byId('paramTags'),paramMissing=byId('paramMissing');
|
||||
const accountStatus=byId('accountStatus'),loginAccount=byId('loginAccount'),logoutAccount=byId('logoutAccount'),openReports=byId('openReports');
|
||||
const analyzeNow=byId('analyzeNow'),modeQuick=byId('modeQuick'),modeStandard=byId('modeStandard'),modeFull=byId('modeFull');
|
||||
const viewCurrentReport=byId('viewCurrentReport'),analysisMeta=byId('analysisMeta');
|
||||
const accountDot=byId('accountDot'),statusCard=byId('statusCard'),phaseLabel=byId('phaseLabel'),progressValue=byId('progressValue'),waitHint=byId('waitHint');
|
||||
const collectionControls=byId('collectionControls'),analysisOptions=byId('analysisOptions');
|
||||
const analyzeAgain=byId('analyzeAgain'),reportHistory=byId('reportHistory'),historyList=byId('historyList'),closeHistory=byId('closeHistory');
|
||||
|
||||
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''}}
|
||||
function pageKind(u){
|
||||
try{
|
||||
const url=new URL(u||'');
|
||||
if(!/taobao\.com$|tmall\.com$/.test(url.hostname))return 'unsupported';
|
||||
return /(^|\.)detail\.tmall\.com$/.test(url.hostname)&&url.pathname==='/item.htm'||/^item\.taobao\.com$/.test(url.hostname)&&url.pathname==='/item.htm'?'product':'taobao_other';
|
||||
}catch(e){return 'unsupported'}
|
||||
}
|
||||
/** 转义正文及属性中的特殊字符,长标题可安全放入 title 提示。 */
|
||||
function escapeHtml(value){return String(value||'').replace(/[<>&"']/g,c=>({'<':'<','>':'>','&':'&','"':'"',"'":'''}[c]))}
|
||||
|
||||
function renderParameterStatus(ps){
|
||||
paramLive.style.display=ps?'block':'none';
|
||||
if(!ps)return;
|
||||
paramState.textContent=ps.stateText||'已采集';
|
||||
paramCount.textContent=ps.count??0;
|
||||
paramTags.innerHTML=(ps.keys||[]).slice(0,10).map(key=>`<span class="param-tag">${escapeHtml(key)}</span>`).join('')||'<span class="param-tag">暂无</span>';
|
||||
paramMissing.textContent='缺失项:'+((ps.missing||[]).length?ps.missing.join('、'):'无');
|
||||
}
|
||||
|
||||
function renderReviewStatus(lp,show){
|
||||
reviewLive.style.display=show?'block':'none';
|
||||
if(!lp)return;
|
||||
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((value,index)=>`<div>${index+1}. ${escapeHtml(value)}</div>`).join('')||'等待评论数据…';
|
||||
}
|
||||
|
||||
|
||||
function updateCollectSteps(state, progress, reviewLive) {
|
||||
const stepInfo = byId('stepInfo');
|
||||
const stepParams = byId('stepParams');
|
||||
const stepReviews = byId('stepReviews');
|
||||
const stepReport = byId('stepReport');
|
||||
if (!stepInfo) return;
|
||||
|
||||
const isCollecting = state === 'collecting' || state === 'collecting_reviews';
|
||||
const isAnalyzing = state === 'sending' || state === 'analyzing';
|
||||
const isCompleted = state === 'analysis_completed';
|
||||
|
||||
stepInfo.classList.toggle('done', progress >= 15 || isAnalyzing || isCompleted);
|
||||
stepInfo.classList.toggle('active', progress < 15 && isCollecting);
|
||||
|
||||
stepParams.classList.toggle('done', progress >= 25 || isAnalyzing || isCompleted);
|
||||
stepParams.classList.toggle('active', progress >= 15 && progress < 25 && isCollecting);
|
||||
|
||||
stepReviews.classList.toggle('done', progress >= 75 || isAnalyzing || isCompleted);
|
||||
stepReviews.classList.toggle('active', state === 'collecting_reviews' || (progress >= 25 && progress < 75 && isCollecting));
|
||||
|
||||
stepReport.classList.toggle('done', isCompleted);
|
||||
stepReport.classList.toggle('active', isAnalyzing);
|
||||
|
||||
const liveBadge = byId('liveReviewBadge');
|
||||
if (liveBadge) {
|
||||
if (state === 'collecting_reviews' && reviewLive) {
|
||||
liveBadge.style.display = 'inline-block';
|
||||
const cap = reviewLive.reviewCap || (collectionMode==='quick'?50:collectionMode==='full'?200:100);
|
||||
liveBadge.textContent = String(reviewLive.count || 0) + " / " + String(cap) + " 条";
|
||||
} else {
|
||||
liveBadge.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderTaskStatus(data){
|
||||
const d=data||{};
|
||||
const state=d.state||'idle';
|
||||
const currentTabProductId=d.currentProductId||'';
|
||||
const activeCollect=state==='collecting'||state==='collecting_reviews';
|
||||
const analysisBusy=state==='sending'||state==='analyzing';
|
||||
const reportReady=state==='analysis_completed'&&!!d.taskUrl&&(!currentTabProductId||!d.productId||currentTabProductId===d.productId);
|
||||
const analysisFailed=state==='analysis_failed';
|
||||
const analysisBlocked=state==='analysis_blocked';
|
||||
const waitingForReviews=state==='reviews_incomplete'||state==='reviews_stopped';
|
||||
const progress=Math.max(0,Math.min(100,Number(d.progress||0)));
|
||||
const phaseMap={
|
||||
idle:'等待开始',
|
||||
login_required:'需要登录',
|
||||
verification_required:'需要完成验证',
|
||||
product_empty:'商品数据未就绪',
|
||||
collecting:'采集商品数据',
|
||||
collecting_reviews:'采集商品评论',
|
||||
reviews_incomplete:'数据等待补充',
|
||||
reviews_stopped:'采集已暂停',
|
||||
sending:'正在提交分析',
|
||||
analyzing:'报告生成中',
|
||||
analysis_completed:'报告已完成',
|
||||
analysis_failed:'分析未完成',
|
||||
analysis_blocked:'暂时无法提交'
|
||||
};
|
||||
|
||||
status.textContent=d.message||d.state||'已识别当前商品,等待手动开始。';
|
||||
bar.style.width=progress+'%';
|
||||
progressValue.textContent=progress+'%';
|
||||
phaseLabel.textContent=phaseMap[state]||'任务处理中';
|
||||
statusCard.dataset.state=reportReady?'completed':(analysisFailed||analysisBlocked)?'failed':analysisBusy?'analyzing':activeCollect?'collecting':'idle';
|
||||
isAnalysisRunning=analysisBusy;
|
||||
updateCollectSteps(state, progress, d.reviewLive);
|
||||
|
||||
updateRunButtonText(waitingForReviews,false);
|
||||
collectionControls.style.display=analysisBusy||reportReady?'none':'grid';
|
||||
runBtn.style.display=activeCollect?'none':'block';
|
||||
stopBtn.style.display=activeCollect?'block':'none';
|
||||
analysisOptions.style.display=(analysisBusy||activeCollect||reportReady)?'none':'block';
|
||||
waitHint.style.display=analysisBusy?'block':'none';
|
||||
|
||||
const showCollectionDetails=!analysisBusy&&!reportReady&&(activeCollect||waitingForReviews);
|
||||
renderParameterStatus(showCollectionDetails?d.parameterStatus:null);
|
||||
renderReviewStatus(d.reviewLive,showCollectionDetails&&!!(d.reviewLive||activeCollect||waitingForReviews));
|
||||
|
||||
currentReportUrl=d.taskUrl||'';
|
||||
viewCurrentReport.style.display=reportReady?'block':'none';
|
||||
analyzeAgain.style.display=reportReady?'block':'none';
|
||||
analysisMeta.style.display=d.taskId?'block':'none';
|
||||
if(d.taskId){
|
||||
const title=d.productTitle?String(d.productTitle).slice(0,32):'当前商品';
|
||||
analysisMeta.textContent=reportReady?`${title} · 报告已就绪`:`${title} · ${d.stage||'处理中'}`;
|
||||
}
|
||||
analyzeNow.disabled=analysisBusy;
|
||||
analyzeNow.textContent='基于当前已有数据生成报告';
|
||||
}
|
||||
|
||||
async function refresh(){
|
||||
if(refreshRunning)return;
|
||||
refreshRunning=true;
|
||||
try{
|
||||
const auth=await chrome.runtime.sendMessage({type:'SANSHENG_AUTH_STATE'}).catch(()=>({loggedIn:false}));
|
||||
accountStatus.textContent=auth?.loggedIn?'已登录,可提交市场洞察分析任务':'未登录,请先使用 PC 账号授权';
|
||||
accountDot.classList.toggle('online',!!auth?.loggedIn);
|
||||
loginAccount.style.display=auth?.loggedIn?'none':'block';
|
||||
logoutAccount.style.display=auth?.loggedIn?'block':'none';
|
||||
openReports.style.display=auth?.loggedIn?'block':'none';
|
||||
backendCompatible=!!auth?.loggedIn&&!!auth?.serviceReady;
|
||||
server.textContent=backendCompatible?'三笙云端分析服务已就绪 ✓':auth?.loggedIn?`云端服务不可用:${auth?.serviceError||'请确认 Web API 已启动'}`:'登录后可使用云端分析服务';
|
||||
server.className=backendCompatible?'state ok':'state warn';
|
||||
|
||||
const tab=await active();
|
||||
if(!tab?.id){renderTaskStatus(null);return}
|
||||
const kind=pageKind(tab.url||'');
|
||||
if(kind!=='product'){
|
||||
const message='请打开淘宝/天猫商品详情页';
|
||||
renderTaskStatus({state:'idle',message,progress:0,currentProductId:''});
|
||||
collectionControls.style.display='none';
|
||||
analysisOptions.style.display='none';
|
||||
return;
|
||||
}
|
||||
const currentProductId=itemIdFromUrl(tab.url||'');
|
||||
const result=backendCompatible?await chrome.runtime.sendMessage({type:'PRODUCT_ANALYSIS_STATUS',tabId:tab.id}).catch(()=>null):null;
|
||||
let data=result?.status||null;
|
||||
if(!data){
|
||||
const stored=await chrome.storage.local.get(['status_'+tab.id,'sansheng_product_analysis_last_task']);
|
||||
data=stored['status_'+tab.id]||stored.sansheng_product_analysis_last_task||null;
|
||||
}
|
||||
if(data){
|
||||
const dataProductId=String(data.productId||data.itemId||'');
|
||||
if(dataProductId&&dataProductId!==currentProductId)data=null;
|
||||
}
|
||||
if(data)renderTaskStatus({...data,currentProductId});
|
||||
else renderTaskStatus({message:'已打开商品详情页,选择采集深度后开始。',progress:0,currentProductId});
|
||||
}finally{
|
||||
refreshRunning=false;
|
||||
}
|
||||
}
|
||||
|
||||
function updateRunButtonText(continueReviews=false){
|
||||
if(continueReviews){runBtn.textContent='继续采集评论并生成';return}
|
||||
runBtn.textContent=collectionMode==='quick'?'快速采集并生成':collectionMode==='full'?'深度采集并生成':'标准采集并生成';
|
||||
}
|
||||
|
||||
function setMode(mode){
|
||||
analysisMode=mode;
|
||||
collectionMode=mode;
|
||||
modeQuick?.classList.toggle('active',mode==='quick');
|
||||
modeStandard?.classList.toggle('active',mode==='standard');
|
||||
modeFull?.classList.toggle('active',mode==='full');
|
||||
analyzeNow.disabled=isAnalysisRunning;
|
||||
updateRunButtonText(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将接口异常转换成用户可读提示,避免暴露 Error 包装文本。
|
||||
* 入参为未知异常,出参为稳定中文提示。
|
||||
*/
|
||||
function userErrorMessage(error){
|
||||
const message=error instanceof Error?error.message:String(error||'无法开始分析');
|
||||
return message.replace(/^Error:\s*/i,'').trim()||'无法开始分析,请稍后重试';
|
||||
}
|
||||
|
||||
/**
|
||||
* 持久化分析提交失败状态,避免自动刷新立刻覆盖错误提示。
|
||||
* 入参为当前标签页和错误文字,无返回值。
|
||||
*/
|
||||
async function showAnalysisSubmitError(tab,message){
|
||||
const currentProductId=itemIdFromUrl(tab?.url||'');
|
||||
await chrome.runtime.sendMessage({type:'PRODUCT_ANALYSIS_NOTICE',tabId:tab?.id,state:'analysis_blocked',productId:currentProductId,message}).catch(()=>{});
|
||||
renderTaskStatus({state:'analysis_blocked',message,progress:0,currentProductId,productId:currentProductId});
|
||||
}
|
||||
|
||||
loginAccount.onclick=()=>chrome.runtime.sendMessage({type:'SANSHENG_AUTH_START'});
|
||||
logoutAccount.onclick=async()=>{await chrome.runtime.sendMessage({type:'SANSHENG_AUTH_LOGOUT'});await refresh()};
|
||||
viewCurrentReport.onclick=()=>{if(currentReportUrl)chrome.tabs.create({url:currentReportUrl})};
|
||||
analyzeAgain.onclick=()=>{analyzeNow.click()};
|
||||
closeHistory.onclick=()=>{reportHistory.style.display='none'};
|
||||
modeQuick.onclick=()=>setMode('quick');
|
||||
modeStandard.onclick=()=>setMode('standard');
|
||||
modeFull.onclick=()=>setMode('full');
|
||||
|
||||
runBtn.onclick=async()=>{
|
||||
if(!backendCompatible){status.textContent='云端服务不可用,请确认 Web API 已启动后重试';return}
|
||||
const tab=await active();
|
||||
renderTaskStatus({state:'collecting',message:'正在准备采集当前商品数据',progress:5});
|
||||
try{
|
||||
const result=await chrome.runtime.sendMessage({type:'RUN_CURRENT',tabId:tab.id,collectionMode});
|
||||
if(result?.needsReviewResume){
|
||||
status.textContent=result.message||'评论还没到末页,请继续采集';
|
||||
runBtn.textContent='继续采集评论';
|
||||
}else if(result?.ok){
|
||||
status.textContent='采集已提交,云端正在生成报告。你可以继续浏览商品页。';
|
||||
}else{
|
||||
status.textContent='采集失败:'+(result?.error||'未知错误');
|
||||
}
|
||||
}catch(error){
|
||||
status.textContent='采集失败:'+String(error);
|
||||
}
|
||||
setTimeout(refresh,500);
|
||||
};
|
||||
|
||||
stopBtn.onclick=async()=>{
|
||||
const tab=await active();
|
||||
stopBtn.disabled=true;
|
||||
stopBtn.textContent='正在终止…';
|
||||
try{
|
||||
const result=await chrome.runtime.sendMessage({type:'STOP_COLLECTION',tabId:tab?.id});
|
||||
status.textContent=result?.ok?'已发送终止指令,正在保存当前进度…':'终止失败,请重试';
|
||||
}catch(error){
|
||||
status.textContent='终止失败:'+String(error);
|
||||
}
|
||||
setTimeout(()=>{stopBtn.disabled=false;stopBtn.textContent='终止采集并保存进度'},1000);
|
||||
};
|
||||
|
||||
analyzeNow.onclick=async()=>{
|
||||
if(!backendCompatible){status.textContent='云端服务不可用,请确认 Web API 已启动后重试';return}
|
||||
const tab=await active();
|
||||
if(!tab?.id){status.textContent='请先打开目标商品页';return}
|
||||
renderTaskStatus({state:'sending',message:'正在提交当前已有数据,请稍候',progress:78});
|
||||
try{
|
||||
const result=await chrome.runtime.sendMessage({type:'ANALYZE_CURRENT_NOW',tabId:tab.id});
|
||||
if(result?.ok){
|
||||
status.textContent=`分析任务已提交${result.reviewCount!=null?` · 当前评论 ${result.reviewCount} 条`:''},你可以继续浏览商品页。`;
|
||||
}else{
|
||||
await showAnalysisSubmitError(tab,userErrorMessage(result?.error||'无法开始分析,请稍后重试'));
|
||||
}
|
||||
}catch(error){
|
||||
await showAnalysisSubmitError(tab,userErrorMessage(error));
|
||||
}finally{
|
||||
setTimeout(refresh,400);
|
||||
}
|
||||
};
|
||||
|
||||
setMode('standard');
|
||||
void refresh();
|
||||
setInterval(refresh,2000);
|
||||
|
||||
let historyLoading=false;
|
||||
|
||||
/** 将任务状态转成中文;原始阶段未知时使用通用状态,不直接展示内部英文。 */
|
||||
function historyState(item){
|
||||
const status=Number(item.Status??item.status);
|
||||
if(status===3)return '报告已完成';
|
||||
if(status===4)return '分析未完成';
|
||||
const progress=Math.max(0,Math.min(100,Number(item.Progress??item.progress??0)||0));
|
||||
const stages={queued:'排队中',analyzing:'分析中',product:'商品定位分析',visual:'主图分析',detail:'详情分析',consumer:'消费者反馈分析',strategy:'生成开品方案',assembling:'整理报告'};
|
||||
const stage=String(item.Stage??item.stage??'');
|
||||
return `${stages[stage]||(stage.startsWith('review')?'评论分析':status===1?'排队中':'分析中')} · ${progress}%`;
|
||||
}
|
||||
|
||||
/** 读取最近报告;防重复点击,最多等待 16 秒,异常后可再次点击重试。 */
|
||||
openReports.onclick=async()=>{
|
||||
reportHistory.style.display='block';
|
||||
if(historyLoading)return;
|
||||
historyLoading=true;
|
||||
historyList.setAttribute('aria-busy','true');
|
||||
historyList.innerHTML='<div class="detail-note" role="status">正在加载报告记录…</div>';
|
||||
let timer;
|
||||
try{
|
||||
const result=await Promise.race([
|
||||
chrome.runtime.sendMessage({type:'PRODUCT_ANALYSIS_HISTORY'}),
|
||||
new Promise((_,reject)=>{timer=setTimeout(()=>reject(new Error('报告记录加载超时,请点击“报告记录”重试。')),16000)})
|
||||
]);
|
||||
if(!result?.ok)throw new Error(result?.error||'报告记录加载失败');
|
||||
const items=result.items||[];
|
||||
historyList.innerHTML=items.length?items.map(item=>{
|
||||
const id=String(item.TaskId||item.taskId||'');
|
||||
const title=escapeHtml(item.ProductTitle||item.productTitle||`商品 ${id}`);
|
||||
const status=Number(item.Status??item.status);
|
||||
const path=item.ReportUrl||item.reportUrl||`/product-analysis/reports/${encodeURIComponent(id)}`;
|
||||
const url=/^https?:\/\//i.test(path)?path:sanshengExtensionAuth.config.pcBase+path;
|
||||
return `<div class="history-item" data-status="${status}"><div><strong title="${title}">${title}</strong><span>${escapeHtml(historyState(item))}</span></div><button data-report-url="${escapeHtml(url)}" title="查看分析报告">查看</button></div>`;
|
||||
}).join(''):'<div class="detail-note">暂无分析报告</div>';
|
||||
historyList.querySelectorAll('button[data-report-url]').forEach(button=>button.onclick=()=>chrome.tabs.create({url:button.dataset.reportUrl}));
|
||||
}catch(error){historyList.innerHTML=`<div class="detail-note" role="alert">${escapeHtml(error instanceof Error?error.message:'报告记录加载失败')}</div>`}
|
||||
finally{clearTimeout(timer);historyLoading=false;historyList.setAttribute('aria-busy','false')}
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
// 切换环境后,在浏览器扩展管理页重新加载扩展并重新登录。
|
||||
const SANSHENG_ENV='test'; // dev | test | prod
|
||||
const SANSHENG_ENVIRONMENTS={
|
||||
dev:{apiBase:'http://localhost:19174',pcBase:'http://localhost:5173'},
|
||||
test:{apiBase:'https://qa-api.sanshengai.net',pcBase:'https://qa.sanshengai.net'},
|
||||
prod:{apiBase:'https://api.sanshengai.net',pcBase:'https://www.sanshengai.net'}
|
||||
};
|
||||
if(!Object.prototype.hasOwnProperty.call(SANSHENG_ENVIRONMENTS,SANSHENG_ENV))throw new Error(`未知扩展环境:${SANSHENG_ENV}`);
|
||||
const SANSHENG_AUTH_CONFIG={
|
||||
...SANSHENG_ENVIRONMENTS[SANSHENG_ENV],
|
||||
clientId:'sansheng-product-analysis-extension',
|
||||
scope:'product.analysis',
|
||||
callbackPath:'/extension/authorize'
|
||||
};
|
||||
|
||||
const AUTH_ACCESS_KEY='sansheng_extension_access';
|
||||
const AUTH_REFRESH_KEY='sansheng_extension_refresh';
|
||||
const AUTH_PENDING_KEY='sansheng_extension_auth_pending';
|
||||
const AUTH_LAST_CONTEXT_KEY='sansheng_extension_auth_last_context';
|
||||
|
||||
function authBase64Url(bytes){return btoa(String.fromCharCode(...bytes)).replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,'')}
|
||||
function authRandom(size=32){const bytes=new Uint8Array(size);crypto.getRandomValues(bytes);return authBase64Url(bytes)}
|
||||
async function authChallenge(verifier){const digest=await crypto.subtle.digest('SHA-256',new TextEncoder().encode(verifier));return authBase64Url(new Uint8Array(digest))}
|
||||
function authCallbackUrl(){return SANSHENG_AUTH_CONFIG.pcBase+SANSHENG_AUTH_CONFIG.callbackPath}
|
||||
|
||||
async function getExtensionAuth(){
|
||||
const session=await chrome.storage.session.get(AUTH_ACCESS_KEY);
|
||||
const local=await chrome.storage.local.get(AUTH_REFRESH_KEY);
|
||||
return {access:session[AUTH_ACCESS_KEY]||null,refresh:local[AUTH_REFRESH_KEY]||null};
|
||||
}
|
||||
|
||||
async function startExtensionLogin(){
|
||||
const verifier=authRandom(48),state=authRandom(24),challenge=await authChallenge(verifier);
|
||||
const tabs=await chrome.tabs.query({active:true,currentWindow:true});
|
||||
await chrome.storage.local.set({[AUTH_PENDING_KEY]:{verifier,state,createdAt:Date.now(),tabId:tabs?.[0]?.id||0,windowId:tabs?.[0]?.windowId||0}});
|
||||
const query=new URLSearchParams({client_id:SANSHENG_AUTH_CONFIG.clientId,redirect_uri:authCallbackUrl(),scope:SANSHENG_AUTH_CONFIG.scope,state,code_challenge:challenge,code_challenge_method:'S256'});
|
||||
return chrome.tabs.create({url:`${SANSHENG_AUTH_CONFIG.pcBase}${SANSHENG_AUTH_CONFIG.callbackPath}?${query}`,active:true});
|
||||
}
|
||||
|
||||
async function exchangeExtensionCode(callbackUrl){
|
||||
const url=new URL(callbackUrl),code=url.searchParams.get('code'),state=url.searchParams.get('state');
|
||||
if(!code||!state)return false;
|
||||
const pending=(await chrome.storage.local.get(AUTH_PENDING_KEY))[AUTH_PENDING_KEY];
|
||||
if(!pending||pending.state!==state||Date.now()-pending.createdAt>10*60*1000)throw new Error('扩展授权请求已过期,请重新登录');
|
||||
const body=new URLSearchParams({grant_type:'authorization_code',client_id:SANSHENG_AUTH_CONFIG.clientId,code,redirect_uri:authCallbackUrl(),code_verifier:pending.verifier});
|
||||
const response=await fetch(`${SANSHENG_AUTH_CONFIG.apiBase}/V1/OAuth/Token`,{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body});
|
||||
const token=await response.json();
|
||||
if(!response.ok||!token.access_token)throw new Error(token.error_description||'扩展登录失败');
|
||||
await chrome.storage.session.set({[AUTH_ACCESS_KEY]:{token:token.access_token,type:token.token_type||'Sansheng',expiresAt:Date.now()+(token.expires_in||7200)*1000}});
|
||||
await chrome.storage.local.set({[AUTH_REFRESH_KEY]:{token:token.refresh_token,expiresAt:Date.now()+(token.refresh_expires_in||2592000)*1000}});
|
||||
await chrome.storage.local.set({[AUTH_LAST_CONTEXT_KEY]:{tabId:pending.tabId||0,windowId:pending.windowId||0,updatedAt:Date.now()}});
|
||||
await chrome.storage.local.remove(AUTH_PENDING_KEY);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function refreshExtensionToken(){
|
||||
const auth=await getExtensionAuth();
|
||||
if(!auth.refresh?.token)throw new Error('扩展登录已过期');
|
||||
const body=new URLSearchParams({grant_type:'refresh_token',client_id:SANSHENG_AUTH_CONFIG.clientId,refresh_token:auth.refresh.token});
|
||||
const response=await fetch(`${SANSHENG_AUTH_CONFIG.apiBase}/V1/OAuth/Token`,{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body});
|
||||
const token=await response.json();
|
||||
if(!response.ok||!token.access_token)throw new Error(token.error_description||'扩展登录已过期');
|
||||
await chrome.storage.session.set({[AUTH_ACCESS_KEY]:{token:token.access_token,type:token.token_type||'Sansheng',expiresAt:Date.now()+(token.expires_in||7200)*1000}});
|
||||
await chrome.storage.local.set({[AUTH_REFRESH_KEY]:{token:token.refresh_token,expiresAt:Date.now()+(token.refresh_expires_in||2592000)*1000}});
|
||||
return token.access_token;
|
||||
}
|
||||
|
||||
async function extensionApiFetch(path,options={}){
|
||||
let auth=await getExtensionAuth();
|
||||
if(!auth.access?.token||auth.access.expiresAt-Date.now()<60_000){await refreshExtensionToken();auth=await getExtensionAuth()}
|
||||
const send=()=>fetch(SANSHENG_AUTH_CONFIG.apiBase+path,{...options,headers:{...(options.headers||{}),Authorization:`Sansheng ${auth.access.token}`,'Sansheng-Source':'chrome-extension'}});
|
||||
let response=await send();
|
||||
if(response.status===401){await refreshExtensionToken();auth=await getExtensionAuth();response=await send()}
|
||||
return response;
|
||||
}
|
||||
|
||||
async function logoutExtension(){
|
||||
const auth=await getExtensionAuth();
|
||||
if(auth.refresh?.token){
|
||||
const body=new URLSearchParams({client_id:SANSHENG_AUTH_CONFIG.clientId,refresh_token:auth.refresh.token,access_token:auth.access?.token||''});
|
||||
await fetch(`${SANSHENG_AUTH_CONFIG.apiBase}/V1/OAuth/Revoke`,{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body}).catch(()=>{});
|
||||
}
|
||||
await chrome.storage.session.remove(AUTH_ACCESS_KEY);
|
||||
await chrome.storage.local.remove([AUTH_REFRESH_KEY,AUTH_PENDING_KEY,AUTH_LAST_CONTEXT_KEY]);
|
||||
}
|
||||
|
||||
self.sanshengExtensionAuth={config:SANSHENG_AUTH_CONFIG,get:getExtensionAuth,start:startExtensionLogin,exchange:exchangeExtensionCode,fetch:extensionApiFetch,logout:logoutExtension};
|
||||
@@ -0,0 +1,881 @@
|
||||
importScripts('auth.js');
|
||||
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||'');
|
||||
const ANALYSIS_TASKS_KEY='sansheng_product_analysis_tasks';
|
||||
const LAST_ANALYSIS_TASK_KEY='sansheng_product_analysis_last_task';
|
||||
const ANALYSIS_ALARM='sansheng_product_analysis_poll';
|
||||
const ANALYSIS_NOTIFICATION_PREFIX='sansheng-product-analysis-';
|
||||
let serviceHealthCache={checkedAt:0,ready:false,error:''};
|
||||
|
||||
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 submitProductAnalysis(raw){
|
||||
raw.collection=raw.collection||{};
|
||||
raw.collection.clientRequestId=raw.collection.clientRequestId||crypto.randomUUID();
|
||||
const controller=new AbortController();
|
||||
const timeout=setTimeout(()=>controller.abort(),15000);
|
||||
let response;
|
||||
try{
|
||||
response=await sanshengExtensionAuth.fetch('/v1/product-analysis/tasks',{method:'POST',headers:{'Content-Type':'application/json','Idempotency-Key':raw.collection.clientRequestId},body:JSON.stringify({platform:raw?.meta?.platform||'taobao',productId:String(raw?.product?.itemId||''),analysisMode:raw?.collection?.analysisMode==='current_snapshot'?'quick':(raw?.collection?.reviewCollectionMode||'standard'),userInstruction:'',clientRequestId:raw.collection.clientRequestId,snapshot:raw,source:'chrome-extension'}),signal:controller.signal});
|
||||
}catch(error){
|
||||
if(error?.name==='AbortError')throw new Error('Web API 响应超时,请确认当前环境 API 可用后重试');
|
||||
throw new Error(`无法连接 Web API,请确认 ${sanshengExtensionAuth.config.apiBase} 可用`);
|
||||
}finally{clearTimeout(timeout)}
|
||||
const result=await response.json().catch(()=>null);
|
||||
const code=result?.Code??result?.code;
|
||||
if(!response.ok||!result||code!==0)throw new Error(result?.Message||result?.message||`商品分析提交失败 HTTP ${response.status}`);
|
||||
const data=result.Data||result.data||{};const taskId=String(data.TaskId||data.taskId||'');
|
||||
return {ok:true,taskId,taskUrl:sanshengExtensionAuth.config.pcBase+(data.ReportUrl||data.reportUrl||`/product-analysis/reports/${taskId}`)};
|
||||
}
|
||||
async function clearTabStatus(tabId){
|
||||
await chrome.storage.local.remove('status_'+tabId).catch(()=>{});
|
||||
}
|
||||
|
||||
async function checkAnalysisService(force=false){
|
||||
if(!force&&Date.now()-serviceHealthCache.checkedAt<8000)return serviceHealthCache;
|
||||
const controller=new AbortController();
|
||||
const timeout=setTimeout(()=>controller.abort(),5000);
|
||||
try{
|
||||
const response=await sanshengExtensionAuth.fetch('/v1/product-analysis/tasks?pageIndex=1&pageSize=1',{signal:controller.signal});
|
||||
const result=await response.json().catch(()=>null);
|
||||
const code=result?.Code??result?.code;
|
||||
const ready=response.ok&&result&&code===0;
|
||||
serviceHealthCache={checkedAt:Date.now(),ready,error:ready?'':(result?.Message||result?.message||`HTTP ${response.status}`)};
|
||||
}catch(error){
|
||||
serviceHealthCache={checkedAt:Date.now(),ready:false,error:error?.name==='AbortError'?'Web API 响应超时':'无法连接 Web API'};
|
||||
}finally{clearTimeout(timeout)}
|
||||
return serviceHealthCache;
|
||||
}
|
||||
|
||||
function analysisStageMessage(stage,progress){
|
||||
const labels={queued:'任务已进入分析队列',product:'正在分析商品定位与标题',visual:'正在分析主图与视觉表达',detail:'正在分析详情页内容',consumer:'正在汇总消费者体验',strategy:'正在生成经营策略与新品方案',assembling:'正在组装分析报告',completed:'报告已经生成'};
|
||||
const suffix=progress!=null?` · 分析进度 ${progress}%`:'';
|
||||
if(String(stage||'').startsWith('review'))return `正在分析评论(${String(stage).replace('review','').trim()||'处理中'})${suffix}`;
|
||||
return (labels[stage]||'正在生成报告')+suffix;
|
||||
}
|
||||
|
||||
async function readTrackedAnalysisTasks(){
|
||||
const stored=await chrome.storage.local.get(ANALYSIS_TASKS_KEY);
|
||||
return stored[ANALYSIS_TASKS_KEY]||{};
|
||||
}
|
||||
|
||||
async function saveTrackedAnalysisTasks(tasks){
|
||||
await chrome.storage.local.set({[ANALYSIS_TASKS_KEY]:tasks});
|
||||
if(Object.keys(tasks).length){
|
||||
await chrome.alarms.create(ANALYSIS_ALARM,{periodInMinutes:.5});
|
||||
}else{
|
||||
await chrome.alarms.clear(ANALYSIS_ALARM);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateAnalysisBadge(state,progress){
|
||||
const text=state==='analysis_completed'?'✓':state==='analysis_failed'?'!':String(Math.max(1,Math.min(99,Math.round(progress||1))));
|
||||
const color=state==='analysis_completed'?'#138a70':state==='analysis_failed'?'#b83b4b':'#315fba';
|
||||
await chrome.action.setBadgeBackgroundColor({color}).catch(()=>{});
|
||||
await chrome.action.setBadgeText({text}).catch(()=>{});
|
||||
}
|
||||
|
||||
async function showAnalysisNotification(task,state){
|
||||
const completed=state==='analysis_completed';
|
||||
await chrome.notifications.create(ANALYSIS_NOTIFICATION_PREFIX+task.taskId,{
|
||||
type:'basic',
|
||||
iconUrl:chrome.runtime.getURL('icon-128.png'),
|
||||
title:completed?'商品分析报告已生成':'商品分析未完成',
|
||||
message:completed?'点击通知即可查看完整分析报告。':(task.errorMessage||'请打开扩展查看失败原因。'),
|
||||
priority:completed?2:1
|
||||
}).catch(()=>{});
|
||||
}
|
||||
|
||||
async function writeAnalysisPresentation(task,state,message,progress,extra={}){
|
||||
const effectiveProgress=progress??task.progress??10;
|
||||
task.progress=effectiveProgress;
|
||||
const presentation={state,message,progress:effectiveProgress,taskId:task.taskId,taskUrl:task.taskUrl,productId:task.productId||'',stage:task.stage||'',productTitle:task.productTitle||'',...extra};
|
||||
await setStatus(task.tabId,presentation);
|
||||
await chrome.storage.local.set({[LAST_ANALYSIS_TASK_KEY]:{...presentation,tabId:task.tabId,updatedAt:Date.now()}});
|
||||
await updateAnalysisBadge(state,progress);
|
||||
}
|
||||
|
||||
async function trackProductAnalysis(tabId,submitted,productTitle='',productId=''){
|
||||
const tasks=await readTrackedAnalysisTasks();
|
||||
const task={taskId:submitted.taskId,taskUrl:submitted.taskUrl,tabId,productId:String(productId||''),productTitle,submittedAt:Date.now(),nextPollAt:0,pollErrors:0,stage:'queued'};
|
||||
tasks[task.taskId]=task;
|
||||
await saveTrackedAnalysisTasks(tasks);
|
||||
await writeAnalysisPresentation(task,'analyzing','任务已提交,正在排队分析',80,{analysisProgress:10});
|
||||
await pollTrackedAnalysisTasks(true);
|
||||
}
|
||||
|
||||
async function pollTrackedAnalysisTask(task,force=false){
|
||||
if(!force&&Number(task.nextPollAt||0)>Date.now())return {keep:true,task};
|
||||
try{
|
||||
const response=await sanshengExtensionAuth.fetch(`/v1/product-analysis/tasks/${encodeURIComponent(task.taskId)}`);
|
||||
const result=await response.json().catch(()=>null);
|
||||
const code=result?.Code??result?.code;
|
||||
if(!response.ok||!result||code!==0)throw new Error(result?.Message||result?.message||`任务状态查询失败 HTTP ${response.status}`);
|
||||
const data=result.Data||result.data||{};
|
||||
const status=Number(data.Status??data.status??0),progress=Number(data.Progress??data.progress??10),stage=String(data.Stage??data.stage??'queued');
|
||||
const displayProgress=Math.min(99,80+Math.round(progress*.2));
|
||||
task={...task,stage,progress:displayProgress,analysisProgress:progress,nextPollAt:Date.now()+3000,pollErrors:0};
|
||||
if(status===3){
|
||||
await writeAnalysisPresentation(task,'analysis_completed','报告已生成,点击下方按钮查看',100,{stage:'completed'});
|
||||
await showAnalysisNotification(task,'analysis_completed');
|
||||
return {keep:false,task};
|
||||
}
|
||||
if(status===4){
|
||||
task.errorMessage=String(data.ErrorMessage??data.errorMessage??'分析任务执行失败');
|
||||
await writeAnalysisPresentation(task,'analysis_failed',task.errorMessage,displayProgress,{stage,errorMessage:task.errorMessage,analysisProgress:progress});
|
||||
await showAnalysisNotification(task,'analysis_failed');
|
||||
return {keep:false,task};
|
||||
}
|
||||
const queuedTooLong=stage==='queued'&&Date.now()-Number(task.submittedAt||Date.now())>90000;
|
||||
const message=queuedTooLong?'任务排队时间较长,请确认分析 Worker 已启动;扩展仍会继续等待':analysisStageMessage(stage,progress);
|
||||
await writeAnalysisPresentation(task,'analyzing',message,displayProgress,{stage,analysisProgress:progress});
|
||||
return {keep:true,task};
|
||||
}catch(error){
|
||||
task={...task,nextPollAt:Date.now()+15000,pollErrors:Number(task.pollErrors||0)+1};
|
||||
await writeAnalysisPresentation(task,'analyzing',task.pollErrors>=3?'云端状态暂时不可用,后台稍后继续查询':'正在等待云端分析状态',undefined,{stage:task.stage||'queued'});
|
||||
return {keep:true,task};
|
||||
}
|
||||
}
|
||||
|
||||
async function pollTrackedAnalysisTasks(force=false){
|
||||
const tasks=await readTrackedAnalysisTasks();
|
||||
for(const [taskId,task] of Object.entries(tasks)){
|
||||
const result=await pollTrackedAnalysisTask(task,force);
|
||||
if(result.keep)tasks[taskId]=result.task;else delete tasks[taskId];
|
||||
}
|
||||
await saveTrackedAnalysisTasks(tasks);
|
||||
}
|
||||
|
||||
async function bootstrapLatestAnalysisTask(tabId){
|
||||
try{
|
||||
const response=await sanshengExtensionAuth.fetch('/v1/product-analysis/tasks?pageIndex=1&pageSize=1');
|
||||
const result=await response.json().catch(()=>null);
|
||||
const code=result?.Code??result?.code;
|
||||
if(!response.ok||!result||code!==0)return null;
|
||||
const page=result.Data||result.data||{};
|
||||
const item=(page.List||page.list||[])[0];
|
||||
if(!item)return null;
|
||||
const taskId=String(item.TaskId||item.taskId||'');
|
||||
if(!taskId)return null;
|
||||
const reportPath=item.ReportUrl||item.reportUrl||`/product-analysis/reports/${taskId}`;
|
||||
const taskUrl=/^https?:\/\//i.test(reportPath)?reportPath:sanshengExtensionAuth.config.pcBase+reportPath;
|
||||
const task={taskId,taskUrl,tabId,productId:String(item.ProductId||item.productId||''),productTitle:item.ProductTitle||item.productTitle||'',stage:item.Stage||item.stage||'queued',progress:Number(item.Progress??item.progress??10),nextPollAt:0,pollErrors:0};
|
||||
const status=Number(item.Status??item.status??0);
|
||||
if(status===1||status===2){
|
||||
const tasks=await readTrackedAnalysisTasks();
|
||||
tasks[taskId]=task;
|
||||
await saveTrackedAnalysisTasks(tasks);
|
||||
const polled=await pollTrackedAnalysisTask(task,true);
|
||||
return (await chrome.storage.local.get(LAST_ANALYSIS_TASK_KEY))[LAST_ANALYSIS_TASK_KEY]||polled.task;
|
||||
}
|
||||
if(status===3){
|
||||
await writeAnalysisPresentation(task,'analysis_completed','最近一份报告已生成,点击下方按钮查看',100,{stage:'completed',analysisProgress:100});
|
||||
return (await chrome.storage.local.get(LAST_ANALYSIS_TASK_KEY))[LAST_ANALYSIS_TASK_KEY];
|
||||
}
|
||||
if(status===4){
|
||||
await writeAnalysisPresentation(task,'analysis_failed','最近一份商品分析未完成',task.progress,{stage:task.stage});
|
||||
return (await chrome.storage.local.get(LAST_ANALYSIS_TASK_KEY))[LAST_ANALYSIS_TASK_KEY];
|
||||
}
|
||||
}catch(error){return null}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function refreshAnalysisForTab(tabId){
|
||||
const tasks=await readTrackedAnalysisTasks();
|
||||
const task=Object.values(tasks).find(item=>Number(item.tabId)===Number(tabId));
|
||||
if(task){
|
||||
const result=await pollTrackedAnalysisTask(task,false);
|
||||
if(result.keep)tasks[task.taskId]=result.task;else delete tasks[task.taskId];
|
||||
await saveTrackedAnalysisTasks(tasks);
|
||||
}
|
||||
const stored=await chrome.storage.local.get(['status_'+tabId,LAST_ANALYSIS_TASK_KEY]);
|
||||
const current=stored['status_'+tabId];
|
||||
const last=stored[LAST_ANALYSIS_TASK_KEY];
|
||||
if(current?.taskId)return current;
|
||||
if(last?.taskId)return last;
|
||||
return await bootstrapLatestAnalysisTask(tabId);
|
||||
}
|
||||
|
||||
chrome.alarms.onAlarm.addListener(alarm=>{if(alarm.name===ANALYSIS_ALARM)pollTrackedAnalysisTasks(false).catch(()=>{})});
|
||||
chrome.notifications.onClicked.addListener(notificationId=>{
|
||||
if(!notificationId.startsWith(ANALYSIS_NOTIFICATION_PREFIX))return;
|
||||
const taskId=notificationId.slice(ANALYSIS_NOTIFICATION_PREFIX.length);
|
||||
chrome.tabs.create({url:sanshengExtensionAuth.config.pcBase+`/product-analysis/reports/${encodeURIComponent(taskId)}`});
|
||||
chrome.notifications.clear(notificationId).catch(()=>{});
|
||||
});
|
||||
pollTrackedAnalysisTasks(false).catch(()=>{});
|
||||
|
||||
chrome.runtime.onMessage.addListener((m,s,send)=>{
|
||||
if(m?.type==='SANSHENG_AUTH_START'){sanshengExtensionAuth.start().then(()=>send({ok:true})).catch(e=>send({ok:false,error:String(e)}));return true;}
|
||||
if(m?.type==='SANSHENG_AUTH_STATE'){sanshengExtensionAuth.get().then(async a=>{const loggedIn=!!(a.access?.token||a.refresh?.token);const health=loggedIn?await checkAnalysisService(false):{ready:false,error:''};send({ok:true,loggedIn,serviceReady:health.ready,serviceError:health.error})}).catch(e=>send({ok:false,loggedIn:false,serviceReady:false,error:String(e)}));return true;}
|
||||
if(m?.type==='SANSHENG_AUTH_LOGOUT'){sanshengExtensionAuth.logout().then(async()=>{await saveTrackedAnalysisTasks({});await chrome.storage.local.remove(LAST_ANALYSIS_TASK_KEY);await chrome.action.setBadgeText({text:''}).catch(()=>{});send({ok:true})}).catch(e=>send({ok:false,error:String(e)}));return true;}
|
||||
if(m?.type==='PRODUCT_ANALYSIS_STATUS'){refreshAnalysisForTab(m.tabId).then(status=>send({ok:true,status})).catch(e=>send({ok:false,error:String(e)}));return true;}
|
||||
if(m?.type==='PRODUCT_ANALYSIS_HISTORY'){
|
||||
(async()=>{try{
|
||||
const response=await sanshengExtensionAuth.fetch('/v1/product-analysis/tasks?pageIndex=1&pageSize=8',{signal:AbortSignal.timeout(15000)});
|
||||
const result=await response.json().catch(()=>null); const code=result?.Code??result?.code;
|
||||
if(!response.ok||!result||code!==0)throw new Error(result?.Message||result?.message||'报告记录加载失败');
|
||||
send({ok:true,items:result.Data?.List||result.data?.list||[]});
|
||||
}catch(error){send({ok:false,error:String(error)})}})();
|
||||
return true;
|
||||
}
|
||||
if(m?.type==='PRODUCT_ANALYSIS_NOTICE'){
|
||||
(async()=>{try{
|
||||
await setStatus(m.tabId,{state:m.state||'analysis_blocked',message:String(m.message||'当前暂时无法提交分析'),progress:0,productId:String(m.productId||'')});
|
||||
send({ok:true});
|
||||
}catch(error){send({ok:false,error:String(error)})}})();
|
||||
return true;
|
||||
}
|
||||
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 j=await submitProductAnalysis(raw);
|
||||
await trackProductAnalysis(tabId,j,raw.product?.title||'',raw.product?.itemId||'');
|
||||
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,m.collectionMode||'standard').then(x=>send(x)).catch(e=>send({ok:false,error:String(e)})); return true; }
|
||||
});
|
||||
|
||||
chrome.tabs.onUpdated.addListener((tabId,changeInfo)=>{
|
||||
const url=changeInfo.url;
|
||||
if(url&&isProduct(url))clearTabStatus(tabId).catch(()=>{});
|
||||
if(!url||!url.startsWith(sanshengExtensionAuth.config.pcBase+sanshengExtensionAuth.config.callbackPath))return;
|
||||
const parsed=new URL(url);
|
||||
if(!parsed.searchParams.get('code'))return;
|
||||
sanshengExtensionAuth.exchange(url).then(async ok=>{
|
||||
if(!ok)return;
|
||||
const pending=(await chrome.storage.local.get('sansheng_extension_auth_last_context')).sansheng_extension_auth_last_context;
|
||||
await chrome.tabs.remove(tabId).catch(()=>{});
|
||||
if(pending?.tabId){
|
||||
await chrome.tabs.update(pending.tabId,{active:true}).catch(()=>{});
|
||||
if(pending.windowId)await chrome.windows.update(pending.windowId,{focused:true}).catch(()=>{});
|
||||
await setStatus(pending.tabId,{state:'idle',message:'登录成功,请在当前商品页打开扩展开始采集。',progress:0});
|
||||
await chrome.action.openPopup({windowId:pending.windowId}).catch(()=>{});
|
||||
}
|
||||
}).catch(error=>console.error('[三笙扩展登录]',error));
|
||||
});
|
||||
|
||||
async function run(tabId,manual,collectionMode='standard'){
|
||||
const tab=await chrome.tabs.get(tabId);
|
||||
if(!isProduct(tab.url)) throw new Error('当前标签页不是天猫/淘宝商品详情页');
|
||||
const auth=await sanshengExtensionAuth.get();
|
||||
if(!auth.access?.token&&!auth.refresh?.token){await setStatus(tabId,{state:'login_required',message:'请先登录三笙账号'});throw new Error('请先登录三笙账号')}
|
||||
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,collectionMode]}).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){const target=live.reviewCap||(collectionMode==='quick'?50:collectionMode==='full'?200:100);const reviewRatio=Math.min(1,(live.count||0)/target);const displayProg=live.progress||Math.min(75,Math.round(20+reviewRatio*55));await setStatus(tabId,{state:'collecting_reviews',message:`评论采集中:${live.count||0} / ${target} 条 (${Math.round(reviewRatio*100)}%) · ${live.mode||'处理中'}${live.stagnant?` · ${live.stagnant}轮无新增`:''}`,progress:displayProg,reviewLive:{...live,elapsedMs:Date.now()-startedAt,reviewCap:target}})}}catch(e){}await sleep(500)}
|
||||
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 j=await submitProductAnalysis(raw);
|
||||
await setStatus(tabId,{state:'analyzing',message:'正在分析全方位产品体验并生成解决方案',progress:82,taskId:j.taskId,taskUrl:j.taskUrl});
|
||||
await trackProductAnalysis(tabId,j,raw.product?.title||'',raw.product?.itemId||'');
|
||||
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,collectionMode='standard'){
|
||||
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 reviewCap=collectionMode==='quick'?50:(collectionMode==='full'?200:100);
|
||||
const reviewNetMeta={totals:[],hasMoreFalse:false,lastPageFlags:[],pageHints:[],networkReviewObjects:0,networkResponses:0};
|
||||
const reviewStartedAt=Date.now();
|
||||
const processedCaptureKeys=new Set();
|
||||
function ensureInPageHud(){
|
||||
let hud=document.getElementById('__sansheng_hud__');
|
||||
if(!hud){
|
||||
hud=document.createElement('div');
|
||||
hud.id='__sansheng_hud__';
|
||||
hud.innerHTML=`<style>
|
||||
#__sansheng_hud__{position:fixed!important;top:20px!important;right:20px!important;z-index:2147483647!important;width:320px!important;background:rgba(18,28,33,.94)!important;backdrop-filter:blur(16px)!important;-webkit-backdrop-filter:blur(16px)!important;border:1px solid rgba(255,255,255,.16)!important;border-radius:12px!important;box-shadow:0 16px 36px rgba(0,0,0,.4)!important;color:#fff!important;font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif!important;padding:14px 16px!important;font-size:12px!important;line-height:1.5!important;box-sizing:border-box!important;pointer-events:auto!important;transition:opacity .3s,transform .3s!important}
|
||||
#__sansheng_hud__ *{box-sizing:border-box!important;margin:0!important}
|
||||
.__ss_header{display:flex!important;align-items:center!important;justify-content:space-between!important;margin-bottom:10px!important;padding-bottom:8px!important;border-bottom:1px solid rgba(255,255,255,.1)!important}
|
||||
.__ss_brand{display:flex!important;align-items:center!important;gap:8px!important;font-weight:600!important;font-size:13px!important;color:#fff!important}
|
||||
.__ss_pulse{width:8px!important;height:8px!important;border-radius:50%!important;background:#00a98f!important;box-shadow:0 0 0 3px rgba(0,169,143,.3)!important;animation:__ss_p 1.4s infinite!important}
|
||||
@keyframes __ss_p{50%{opacity:.35!important;transform:scale(.85)!important}}
|
||||
.__ss_stop_btn{background:rgba(255,255,255,.1)!important;border:1px solid rgba(255,255,255,.2)!important;color:#ff7875!important;border-radius:5px!important;font-size:11px!important;padding:3px 8px!important;cursor:pointer!important}
|
||||
.__ss_stop_btn:hover{background:rgba(255,78,78,.25)!important}
|
||||
.__ss_status_text{display:flex!important;justify-content:space-between!important;font-size:12px!important;color:#d1dedc!important;margin-bottom:6px!important}
|
||||
.__ss_status_text b{color:#2dd4bf!important}
|
||||
.__ss_prog_track{height:6px!important;background:rgba(255,255,255,.15)!important;border-radius:3px!important;overflow:hidden!important;margin-bottom:10px!important}
|
||||
.__ss_prog_bar{height:100%!important;width:0%!important;background:linear-gradient(90deg,#00a98f,#1675d1)!important;border-radius:3px!important;transition:width .3s ease!important}
|
||||
.__ss_stats{display:grid!important;grid-template-columns:repeat(3,1fr)!important;gap:6px!important;background:rgba(0,0,0,.25)!important;border-radius:6px!important;padding:6px 8px!important;text-align:center!important;margin-bottom:8px!important}
|
||||
.__ss_stats span{display:block!important;font-size:10px!important;color:#8da4a8!important}
|
||||
.__ss_stats b{font-size:12px!important;color:#fff!important}
|
||||
.__ss_preview{font-size:11px!important;color:#9cb3b6!important;white-space:nowrap!important;overflow:hidden!important;text-overflow:ellipsis!important;background:rgba(255,255,255,.06)!important;border-radius:4px!important;padding:4px 8px!important}
|
||||
</style>
|
||||
<div class="__ss_header"><div class="__ss_brand"><span class="__ss_pulse" id="__ss_pulse__"></span><span>三笙 · 商品采集 V1.0</span></div><button class="__ss_stop_btn" id="__ss_stop__">终止采集</button></div>
|
||||
<div class="__ss_status_text"><span id="__ss_step_label__">准备开始采集...</span><b id="__ss_pct__">0%</b></div>
|
||||
<div class="__ss_prog_track"><div class="__ss_prog_bar" id="__ss_bar__"></div></div>
|
||||
<div class="__ss_stats"><div><span>有效评价</span><b id="__ss_stat_reviews__">0 / ${reviewCap}</b></div><div><span>翻页轮次</span><b id="__ss_stat_rounds__">第 0 轮</b></div><div><span>已用耗时</span><b id="__ss_stat_time__">0s</b></div></div>
|
||||
<div class="__ss_preview" id="__ss_preview_box__">等待读取商品评价...</div>`;
|
||||
document.body.appendChild(hud);
|
||||
document.getElementById('__ss_stop__')?.addEventListener('click',()=>{window.__TMALL_AI_STOP_REVIEW__=true;const b=document.getElementById('__ss_stop__');if(b)b.textContent='终止中…'});
|
||||
}
|
||||
return hud;
|
||||
}
|
||||
function updateInPageHud(data){
|
||||
try{
|
||||
ensureInPageHud();
|
||||
const count=data.count||0;const cap=reviewCap;const pct=Math.min(100,Math.max(0,data.progress!=null?data.progress:Math.round((count/cap)*100)));
|
||||
const bar=document.getElementById('__ss_bar__');if(bar)bar.style.width=pct+'%';
|
||||
const pctEl=document.getElementById('__ss_pct__');if(pctEl)pctEl.textContent=pct+'%';
|
||||
const labelEl=document.getElementById('__ss_step_label__');if(labelEl)labelEl.textContent=data.message||`评论采集中 (${count}/${cap}条)`;
|
||||
const statReviews=document.getElementById('__ss_stat_reviews__');if(statReviews)statReviews.textContent=`${count} / ${cap}`;
|
||||
const statRounds=document.getElementById('__ss_stat_rounds__');if(statRounds)statRounds.textContent=`第 ${data.round||0} 轮`;
|
||||
const statTime=document.getElementById('__ss_stat_time__');if(statTime)statTime.textContent=Math.round((data.elapsedMs||0)/1000)+'s';
|
||||
const previewBox=document.getElementById('__ss_preview_box__');if(previewBox&&data.preview&&data.preview.length)previewBox.textContent='最新:'+data.preview[0];
|
||||
}catch(e){}
|
||||
}
|
||||
function finishInPageHud(success=true,message=''){
|
||||
try{
|
||||
const hud=document.getElementById('__sansheng_hud__');
|
||||
if(hud){
|
||||
const pulse=document.getElementById('__ss_pulse__');if(pulse){pulse.style.background=success?'#00a98f':'#ff7875';pulse.style.animation='none'}
|
||||
const label=document.getElementById('__ss_step_label__');if(label)label.textContent=message||(success?`✓ 采集完成(共 ${reviews.length} 条评论),正在提交...`:'采集已终止');
|
||||
const bar=document.getElementById('__ss_bar__');if(bar&&success)bar.style.width='100%';
|
||||
const pct=document.getElementById('__ss_pct__');if(pct&&success)pct.textContent='100%';
|
||||
setTimeout(()=>{if(hud&&hud.parentNode){hud.style.opacity='0';hud.style.transform='translateY(-12px)';setTimeout(()=>{if(hud.parentNode)hud.remove()},400)}},3500);
|
||||
}
|
||||
}catch(e){}
|
||||
}
|
||||
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?'…':''))}}
|
||||
const currCount=dedupReviewCount();const reviewRatio=Math.min(1,currCount/reviewCap);const overallProg=Math.min(78,Math.round(20+reviewRatio*58));
|
||||
updateInPageHud({count:currCount,round,progress:overallProg,message:mode||`评论采集中:${currCount}/${reviewCap}条`,elapsedMs:Date.now()-reviewStartedAt,preview});
|
||||
window.__TMALL_AI_REVIEW_PROGRESS__={progress:overallProg,reviewCap,
|
||||
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});
|
||||
if(after>=reviewCap){collectionComplete=true;explicitEnd=true;stopReason='sample_cap';publishReviewProgress(round+1,stagnant,collectionMode==='quick'?'已采够快速样本':collectionMode==='full'?'已采够深度样本':'已采够标准样本');break}
|
||||
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)}
|
||||
}
|
||||
let reviewRows=[...deReview.values()];
|
||||
// 评论过多时保留最有决策价值的样本:差评、带图、最新评论优先,其余按原顺序补齐。
|
||||
let reviewSampled=false;
|
||||
if(reviewRows.length>reviewCap){
|
||||
const scored=reviewRows.map((r,i)=>{const rating=Number(String(r.rating||'').match(/[1-5]/)?.[0]||5);const bad=rating<=3?5:0;const media=(r.images||[]).length?3:0;const date=Date.parse(String(r.date||''));const rec=Number.isFinite(date)?Math.max(0,Math.min(2,(date-Date.now()+31536000000)/31536000000)):0;return {r,i,score:bad+media+rec}});
|
||||
scored.sort((a,b)=>b.score-a.score||a.i-b.i);
|
||||
const keep=new Set(scored.slice(0,reviewCap).map(x=>x.i));
|
||||
reviewRows=reviewRows.filter((_,i)=>keep.has(i)); reviewSampled=true;
|
||||
}
|
||||
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});
|
||||
finishInPageHud(collectionComplete, collectionComplete ? ('✓ 采集完成(已获取 ' + deReview.size + ' 条评论),正在提交分析...') : ('采集结束:已保存 ' + deReview.size + ' 条评论'));
|
||||
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,reviewSampled,reviewSampleCap:reviewCap,reviewCollectionMode:collectionMode,questionActual:deQ.size,reviewDrawerOpened:reviewDrawerWasOpened,reviewCollectionComplete:collectionComplete,reviewCompleteness:reviewSampled?'sampled':(collectionComplete?'confirmed':'unconfirmed'),reviewStopReason:reviewSampled?'sample_cap':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}
|
||||
};
|
||||
}
|
||||
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 627 B |
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "三笙 · 电商商品分析闭环 V1.0",
|
||||
"version": "1.0.0",
|
||||
"description": "跨类目电商商品分析:动态参数与SKU采集、图文分源、评论证据、开品与AI视觉方案。",
|
||||
"permissions": [
|
||||
"scripting",
|
||||
"storage",
|
||||
"tabs",
|
||||
"alarms",
|
||||
"notifications"
|
||||
],
|
||||
"host_permissions": [
|
||||
"https://detail.tmall.com/*",
|
||||
"https://*.tmall.com/*",
|
||||
"https://*.taobao.com/*",
|
||||
"https://*.alicdn.com/*",
|
||||
"https://api.sanshengai.net/*",
|
||||
"https://www.sanshengai.net/*",
|
||||
"https://qa-api.sanshengai.net/*",
|
||||
"https://qa.sanshengai.net/*",
|
||||
"http://localhost:19174/*",
|
||||
"http://localhost:5173/*"
|
||||
],
|
||||
"background": {
|
||||
"service_worker": "background.js"
|
||||
},
|
||||
"icons": {
|
||||
"16": "icon-16.png",
|
||||
"48": "icon-48.png",
|
||||
"128": "icon-128.png"
|
||||
},
|
||||
"action": {
|
||||
"default_popup": "popup.html",
|
||||
"default_title": "三笙 · 电商商品分析闭环 V1.0",
|
||||
"default_icon": {
|
||||
"16": "icon-16.png",
|
||||
"48": "icon-48.png",
|
||||
"128": "icon-128.png"
|
||||
}
|
||||
},
|
||||
"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,249 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--ink: #142126;
|
||||
--muted: #687a80;
|
||||
--line: #dbe6e5;
|
||||
--soft: #f3f8f7;
|
||||
--green: #00a98f;
|
||||
--green-dark: #087565;
|
||||
--blue: #1675d1;
|
||||
--danger: #b63f4b;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
/* 扩展窗口按内容自动定尺寸:根节点必须有独立宽度,不能用 vw 反向依赖窗口。
|
||||
只由根节点滚动,始终预留滚动条位置,历史展开和悬停时不挤压内容。 */
|
||||
html {
|
||||
width: 460px;
|
||||
min-width: 460px;
|
||||
max-width: 460px;
|
||||
max-height: 600px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
body {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
background: #f6faf9;
|
||||
color: var(--ink);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
}
|
||||
|
||||
button { font: inherit; }
|
||||
.brand-row > .text-action { flex: 0 0 auto; white-space: nowrap; }
|
||||
|
||||
.app-header {
|
||||
padding: 18px 18px 15px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.brand-row,
|
||||
.account-row,
|
||||
.status-head,
|
||||
.detail-title,
|
||||
.metric-row,
|
||||
.section-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.brand { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
||||
.brand img { width: 34px; height: 34px; border-radius: 8px; }
|
||||
.brand h1 { margin: 0; font-size: 17px; line-height: 1.2; letter-spacing: 0; }
|
||||
.brand span { display: block; margin-top: 2px; color: var(--muted); font-size: 11px; }
|
||||
.app-header p { margin: 12px 0 0; color: var(--muted); font-size: 12px; line-height: 1.55; }
|
||||
|
||||
main { padding: 14px 16px 18px; }
|
||||
|
||||
.text-action,
|
||||
.compact-button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--green-dark);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.compact-button {
|
||||
min-width: 52px;
|
||||
height: 30px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 7px;
|
||||
background: #fff;
|
||||
}
|
||||
.compact-button.primary { border-color: var(--green); background: var(--green); color: #fff; }
|
||||
|
||||
.account-row { margin-bottom: 12px; }
|
||||
.account-copy { display: flex; align-items: center; gap: 9px; min-width: 0; }
|
||||
.account-copy i { width: 8px; height: 8px; flex: 0 0 auto; border-radius: 50%; background: #a9b6b8; }
|
||||
.account-copy i.online { background: var(--green); box-shadow: 0 0 0 4px rgb(0 169 143 / 12%); }
|
||||
.account-copy strong { display: block; font-size: 12px; }
|
||||
.account-copy span { display: block; max-width: 230px; margin-top: 2px; overflow: hidden; color: var(--muted); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.status-card,
|
||||
.detail-panel,
|
||||
.mode-panel {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.status-card { padding: 15px; }
|
||||
.phase { display: flex; align-items: center; gap: 8px; }
|
||||
.phase strong { font-size: 14px; }
|
||||
.phase-indicator { width: 9px; height: 9px; border-radius: 50%; background: #9aa9ac; }
|
||||
.status-head > b { color: var(--green-dark); font-size: 13px; }
|
||||
.status-message { min-height: 38px; margin-top: 10px; color: #53676d; font-size: 12px; line-height: 1.55; }
|
||||
.progress-track { height: 7px; margin-top: 12px; overflow: hidden; border-radius: 6px; background: #e9f0ef; }
|
||||
.progress-track i { display: block; width: 0; height: 100%; border-radius: inherit; background: linear-gradient(90deg, var(--green), var(--blue)); transition: width .25s ease; }
|
||||
.task-meta { margin-top: 10px; overflow: hidden; color: var(--muted); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.wait-hint { margin-top: 10px; padding: 9px 10px; border-radius: 7px; background: #eef7ff; color: #43616e; font-size: 11px; line-height: 1.45; }
|
||||
|
||||
.status-card[data-state="collecting"] .phase-indicator,
|
||||
.status-card[data-state="analyzing"] .phase-indicator {
|
||||
background: var(--blue);
|
||||
box-shadow: 0 0 0 4px rgb(22 117 209 / 12%);
|
||||
animation: pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
.status-card[data-state="completed"] .phase-indicator { background: var(--green); box-shadow: 0 0 0 4px rgb(0 169 143 / 12%); }
|
||||
.status-card[data-state="failed"] .phase-indicator { background: var(--danger); box-shadow: 0 0 0 4px rgb(182 63 75 / 12%); }
|
||||
|
||||
@keyframes pulse {
|
||||
50% { opacity: .35; transform: scale(.8); }
|
||||
}
|
||||
|
||||
.service-line { display: flex; justify-content: flex-end; min-height: 25px; padding: 7px 2px 4px; }
|
||||
.service-line span { color: var(--muted); font-size: 10px; }
|
||||
.service-line span.ok { color: var(--green-dark); }
|
||||
.service-line span.warn { color: #a56a19; }
|
||||
|
||||
.action-section { display: grid; gap: 8px; }
|
||||
.main-button,
|
||||
.secondary-button {
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.main-button { background: linear-gradient(105deg, var(--green), var(--blue)); color: #fff; }
|
||||
.main-button:hover { filter: brightness(.97); }
|
||||
.main-button:disabled,
|
||||
.secondary-button:disabled { cursor: not-allowed; opacity: .55; }
|
||||
.main-button.danger { background: var(--danger); }
|
||||
.report-button { margin-top: 12px; background: var(--ink); }
|
||||
.secondary-button { margin-top: 12px; border: 1px solid #a8c8c4; background: #fff; color: #245d58; }
|
||||
/* 网格列必须允许收缩,否则长商品标题的最小内容宽度会撑出扩展弹窗。 */
|
||||
.history-panel { margin-top: 12px; min-width: 0; }
|
||||
.history-list { display: grid; grid-template-columns: minmax(0, 1fr); gap: 8px; margin-top: 10px; min-width: 0; }
|
||||
.history-item { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 10px; min-width: 0; max-width: 100%; padding: 10px; border: 1px solid var(--line); border-radius: 7px; background: var(--soft); }
|
||||
.history-item > div { min-width: 0; }
|
||||
.history-item strong { display: -webkit-box; overflow: hidden; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow-wrap: anywhere; font-size: 12px; line-height: 1.5; }
|
||||
.history-item span { display: block; margin-top: 5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); font-size: 11px; line-height: 1.4; }
|
||||
.history-item[data-status="3"] span { color: var(--green-dark); }
|
||||
.history-item[data-status="4"] span { color: var(--danger); }
|
||||
.history-item button { min-width: 42px; min-height: 32px; padding: 6px; border: 1px solid var(--line); border-radius: 6px; background: #fff; color: var(--green-dark); cursor: pointer; font-size: 11px; font-weight: 700; }
|
||||
.history-item button:hover { border-color: var(--green); }
|
||||
.history-item button:focus-visible { outline: 2px solid var(--green-dark); outline-offset: 2px; }
|
||||
.history-list .detail-note { overflow-wrap: anywhere; line-height: 1.5; }
|
||||
.detail-panel { margin-top: 12px; padding: 13px; }
|
||||
.detail-title strong,
|
||||
.section-heading strong { font-size: 13px; }
|
||||
.detail-title span,
|
||||
.section-heading span { color: var(--muted); font-size: 11px; }
|
||||
.metric-row { margin-top: 10px; font-size: 11px; }
|
||||
.metric-row span { color: var(--muted); }
|
||||
.metric-row b { font-size: 13px; }
|
||||
.metric-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 11px; }
|
||||
.metric-grid div { padding: 9px; border-radius: 7px; background: var(--soft); }
|
||||
.metric-grid span { display: block; color: var(--muted); font-size: 10px; }
|
||||
.metric-grid b { display: block; margin-top: 3px; font-size: 14px; }
|
||||
.detail-note { margin-top: 9px; color: var(--muted); font-size: 10px; }
|
||||
.param-tags { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 9px; }
|
||||
.param-tag { padding: 3px 7px; border: 1px solid var(--line); border-radius: 6px; background: var(--soft); color: #4c6267; font-size: 10px; }
|
||||
.live-preview { max-height: 68px; margin-top: 9px; overflow: auto; border-top: 1px solid var(--line); padding-top: 8px; color: #53686d; font-size: 10px; line-height: 1.55; }
|
||||
|
||||
.mode-panel { margin-top: 12px; padding: 14px; }
|
||||
.section-heading span { display: block; margin-top: 2px; }
|
||||
.mode-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 7px; margin-top: 11px; }
|
||||
.mode { min-height: 58px; border: 1px solid var(--line); border-radius: 8px; background: #fff; color: var(--ink); cursor: pointer; }
|
||||
.mode strong { display: block; font-size: 12px; }
|
||||
.mode small { display: block; margin-top: 4px; color: var(--muted); font-size: 9px; }
|
||||
.mode.active { border-color: var(--ink); background: var(--ink); color: #fff; }
|
||||
.mode.active small { color: #c6d1d3; }
|
||||
.option-note { margin-top: 8px; color: var(--muted); font-size: 10px; text-align: center; }
|
||||
|
||||
/* 采集流程四步可视化指示条 */
|
||||
.steps-tracker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 14px;
|
||||
padding: 10px 12px;
|
||||
background: var(--soft);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
.tracker-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
.tracker-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #b5c3c6;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
.tracker-label {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tracker-label small {
|
||||
display: inline-block;
|
||||
font-size: 9px;
|
||||
background: rgba(0, 169, 143, 0.15);
|
||||
color: var(--green-dark);
|
||||
padding: 1px 4px;
|
||||
border-radius: 4px;
|
||||
font-weight: 700;
|
||||
margin-left: 2px;
|
||||
}
|
||||
.tracker-line {
|
||||
flex: 1;
|
||||
height: 2px;
|
||||
background: var(--line);
|
||||
margin: 0 4px 14px 4px;
|
||||
}
|
||||
.tracker-item.active .tracker-dot {
|
||||
background: var(--blue);
|
||||
box-shadow: 0 0 0 4px rgb(22 117 209 / 18%);
|
||||
transform: scale(1.15);
|
||||
}
|
||||
.tracker-item.active .tracker-label {
|
||||
color: var(--blue);
|
||||
font-weight: 700;
|
||||
}
|
||||
.tracker-item.done .tracker-dot {
|
||||
background: var(--green);
|
||||
box-shadow: 0 0 0 3px rgb(0 169 143 / 15%);
|
||||
}
|
||||
.tracker-item.done .tracker-label {
|
||||
color: var(--green-dark);
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<link rel="stylesheet" href="popup.css">
|
||||
<title>三笙商品分析</title>
|
||||
</head>
|
||||
<body>
|
||||
<header class="app-header">
|
||||
<div class="brand-row">
|
||||
<div class="brand">
|
||||
<img src="icon-128.png" alt="">
|
||||
<div>
|
||||
<h1>三笙商品分析</h1>
|
||||
<span>电商单品洞察 V1.0</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="text-action" id="openReports" style="display:none">报告记录</button>
|
||||
</div>
|
||||
<p>采集商品、图片、参数和评论,生成可追溯的产品分析报告。</p>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="account-row">
|
||||
<div class="account-copy">
|
||||
<i id="accountDot"></i>
|
||||
<div>
|
||||
<strong>三笙账号</strong>
|
||||
<span id="accountStatus">正在检查登录状态</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="compact-button primary" id="loginAccount">登录</button>
|
||||
<button class="compact-button" id="logoutAccount" style="display:none">退出</button>
|
||||
</section>
|
||||
|
||||
<section class="status-card" id="statusCard" data-state="idle">
|
||||
<div class="status-head">
|
||||
<div class="phase">
|
||||
<span class="phase-indicator"></span>
|
||||
<strong id="phaseLabel">等待开始</strong>
|
||||
</div>
|
||||
<b id="progressValue">0%</b>
|
||||
</div>
|
||||
<div id="status" class="status-message">请打开天猫或淘宝商品详情页</div>
|
||||
<div class="progress-track"><i id="bar"></i></div>
|
||||
<!-- 采集阶段可视化跟踪 -->
|
||||
<div class="steps-tracker" id="stepsTracker">
|
||||
<div class="tracker-item" id="stepInfo">
|
||||
<span class="tracker-dot"></span>
|
||||
<span class="tracker-label">基本信息</span>
|
||||
</div>
|
||||
<div class="tracker-line"></div>
|
||||
<div class="tracker-item" id="stepParams">
|
||||
<span class="tracker-dot"></span>
|
||||
<span class="tracker-label">规格参数</span>
|
||||
</div>
|
||||
<div class="tracker-line"></div>
|
||||
<div class="tracker-item" id="stepReviews">
|
||||
<span class="tracker-dot"></span>
|
||||
<span class="tracker-label">买家评价 <small id="liveReviewBadge" style="display:none"></small></span>
|
||||
</div>
|
||||
<div class="tracker-line"></div>
|
||||
<div class="tracker-item" id="stepReport">
|
||||
<span class="tracker-dot"></span>
|
||||
<span class="tracker-label">云端报告</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="analysisMeta" class="task-meta" style="display:none"></div>
|
||||
<div id="waitHint" class="wait-hint" style="display:none">
|
||||
分析会在后台继续,可以关闭扩展或继续浏览商品。
|
||||
</div>
|
||||
<button class="main-button report-button" id="viewCurrentReport" style="display:none">
|
||||
查看分析报告
|
||||
</button>
|
||||
<button class="secondary-button" id="analyzeAgain" style="display:none">
|
||||
再次分析当前商品
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<div class="service-line">
|
||||
<span id="server">正在检查云端服务</span>
|
||||
</div>
|
||||
|
||||
<section id="collectionControls" class="action-section">
|
||||
<button class="main-button" id="run">标准采集并生成</button>
|
||||
<button class="main-button danger" id="stop" style="display:none">
|
||||
终止采集并保存进度
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section id="paramLive" class="detail-panel" style="display:none">
|
||||
<div class="detail-title">
|
||||
<strong>商品参数</strong>
|
||||
<span id="paramState">等待采集</span>
|
||||
</div>
|
||||
<div class="metric-row">
|
||||
<span>已识别</span>
|
||||
<b><span id="paramCount">0</span> 项</b>
|
||||
</div>
|
||||
<div class="param-tags" id="paramTags"></div>
|
||||
<div class="detail-note" id="paramMissing">缺失项:无</div>
|
||||
</section>
|
||||
|
||||
<section id="reviewLive" class="detail-panel" style="display:none">
|
||||
<div class="detail-title">
|
||||
<strong>评论采集</strong>
|
||||
<span id="liveMode">等待</span>
|
||||
</div>
|
||||
<div class="metric-grid">
|
||||
<div><span>有效评论</span><b id="liveCount">0</b></div>
|
||||
<div><span>网络对象</span><b id="liveNet">0</b></div>
|
||||
<div><span>当前轮次</span><b id="liveRound">0</b></div>
|
||||
<div><span>无新增轮次</span><b id="liveStagnant">0</b></div>
|
||||
</div>
|
||||
<div class="detail-note">采集耗时 <b id="liveElapsed">0s</b></div>
|
||||
<div id="livePreview" class="live-preview">等待评论数据</div>
|
||||
</section>
|
||||
|
||||
<section id="analysisOptions" class="mode-panel">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<strong>采集深度</strong>
|
||||
<span>决定最多分析的评论数量</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mode-row" role="group" aria-label="采集深度">
|
||||
<button id="modeQuick" class="mode">
|
||||
<strong>快速</strong><small>最多 50 条</small>
|
||||
</button>
|
||||
<button id="modeStandard" class="mode active">
|
||||
<strong>标准</strong><small>最多 100 条</small>
|
||||
</button>
|
||||
<button id="modeFull" class="mode">
|
||||
<strong>深度</strong><small>最多 200 条</small>
|
||||
</button>
|
||||
</div>
|
||||
<button id="analyzeNow" class="secondary-button">
|
||||
基于当前已有数据生成报告
|
||||
</button>
|
||||
<div class="option-note">当前数据可能尚未覆盖全部公开评论。</div>
|
||||
</section>
|
||||
|
||||
<section id="reportHistory" class="detail-panel history-panel" style="display:none">
|
||||
<div class="detail-title"><strong>最近报告</strong><button class="text-action" id="closeHistory">收起</button></div>
|
||||
<div id="historyList" class="history-list"></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="auth.js"></script>
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,326 @@
|
||||
let backendCompatible=false;
|
||||
let collectionMode='standard';
|
||||
let analysisMode='standard';
|
||||
let currentReportUrl='';
|
||||
let isAnalysisRunning=false;
|
||||
let refreshRunning=false;
|
||||
|
||||
const byId=id=>document.getElementById(id);
|
||||
const server=byId('server'),status=byId('status'),bar=byId('bar'),runBtn=byId('run'),stopBtn=byId('stop');
|
||||
const reviewLive=byId('reviewLive'),liveCount=byId('liveCount'),liveNet=byId('liveNet'),liveRound=byId('liveRound'),liveStagnant=byId('liveStagnant'),liveMode=byId('liveMode'),liveElapsed=byId('liveElapsed'),livePreview=byId('livePreview');
|
||||
const paramLive=byId('paramLive'),paramState=byId('paramState'),paramCount=byId('paramCount'),paramTags=byId('paramTags'),paramMissing=byId('paramMissing');
|
||||
const accountStatus=byId('accountStatus'),loginAccount=byId('loginAccount'),logoutAccount=byId('logoutAccount'),openReports=byId('openReports');
|
||||
const analyzeNow=byId('analyzeNow'),modeQuick=byId('modeQuick'),modeStandard=byId('modeStandard'),modeFull=byId('modeFull');
|
||||
const viewCurrentReport=byId('viewCurrentReport'),analysisMeta=byId('analysisMeta');
|
||||
const accountDot=byId('accountDot'),statusCard=byId('statusCard'),phaseLabel=byId('phaseLabel'),progressValue=byId('progressValue'),waitHint=byId('waitHint');
|
||||
const collectionControls=byId('collectionControls'),analysisOptions=byId('analysisOptions');
|
||||
const analyzeAgain=byId('analyzeAgain'),reportHistory=byId('reportHistory'),historyList=byId('historyList'),closeHistory=byId('closeHistory');
|
||||
|
||||
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''}}
|
||||
function pageKind(u){
|
||||
try{
|
||||
const url=new URL(u||'');
|
||||
if(!/taobao\.com$|tmall\.com$/.test(url.hostname))return 'unsupported';
|
||||
return /(^|\.)detail\.tmall\.com$/.test(url.hostname)&&url.pathname==='/item.htm'||/^item\.taobao\.com$/.test(url.hostname)&&url.pathname==='/item.htm'?'product':'taobao_other';
|
||||
}catch(e){return 'unsupported'}
|
||||
}
|
||||
/** 转义正文及属性中的特殊字符,长标题可安全放入 title 提示。 */
|
||||
function escapeHtml(value){return String(value||'').replace(/[<>&"']/g,c=>({'<':'<','>':'>','&':'&','"':'"',"'":'''}[c]))}
|
||||
|
||||
function renderParameterStatus(ps){
|
||||
paramLive.style.display=ps?'block':'none';
|
||||
if(!ps)return;
|
||||
paramState.textContent=ps.stateText||'已采集';
|
||||
paramCount.textContent=ps.count??0;
|
||||
paramTags.innerHTML=(ps.keys||[]).slice(0,10).map(key=>`<span class="param-tag">${escapeHtml(key)}</span>`).join('')||'<span class="param-tag">暂无</span>';
|
||||
paramMissing.textContent='缺失项:'+((ps.missing||[]).length?ps.missing.join('、'):'无');
|
||||
}
|
||||
|
||||
function renderReviewStatus(lp,show){
|
||||
reviewLive.style.display=show?'block':'none';
|
||||
if(!lp)return;
|
||||
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((value,index)=>`<div>${index+1}. ${escapeHtml(value)}</div>`).join('')||'等待评论数据…';
|
||||
}
|
||||
|
||||
|
||||
function updateCollectSteps(state, progress, reviewLive) {
|
||||
const stepInfo = byId('stepInfo');
|
||||
const stepParams = byId('stepParams');
|
||||
const stepReviews = byId('stepReviews');
|
||||
const stepReport = byId('stepReport');
|
||||
if (!stepInfo) return;
|
||||
|
||||
const isCollecting = state === 'collecting' || state === 'collecting_reviews';
|
||||
const isAnalyzing = state === 'sending' || state === 'analyzing';
|
||||
const isCompleted = state === 'analysis_completed';
|
||||
|
||||
stepInfo.classList.toggle('done', progress >= 15 || isAnalyzing || isCompleted);
|
||||
stepInfo.classList.toggle('active', progress < 15 && isCollecting);
|
||||
|
||||
stepParams.classList.toggle('done', progress >= 25 || isAnalyzing || isCompleted);
|
||||
stepParams.classList.toggle('active', progress >= 15 && progress < 25 && isCollecting);
|
||||
|
||||
stepReviews.classList.toggle('done', progress >= 75 || isAnalyzing || isCompleted);
|
||||
stepReviews.classList.toggle('active', state === 'collecting_reviews' || (progress >= 25 && progress < 75 && isCollecting));
|
||||
|
||||
stepReport.classList.toggle('done', isCompleted);
|
||||
stepReport.classList.toggle('active', isAnalyzing);
|
||||
|
||||
const liveBadge = byId('liveReviewBadge');
|
||||
if (liveBadge) {
|
||||
if (state === 'collecting_reviews' && reviewLive) {
|
||||
liveBadge.style.display = 'inline-block';
|
||||
const cap = reviewLive.reviewCap || (collectionMode==='quick'?50:collectionMode==='full'?200:100);
|
||||
liveBadge.textContent = String(reviewLive.count || 0) + " / " + String(cap) + " 条";
|
||||
} else {
|
||||
liveBadge.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderTaskStatus(data){
|
||||
const d=data||{};
|
||||
const state=d.state||'idle';
|
||||
const currentTabProductId=d.currentProductId||'';
|
||||
const activeCollect=state==='collecting'||state==='collecting_reviews';
|
||||
const analysisBusy=state==='sending'||state==='analyzing';
|
||||
const reportReady=state==='analysis_completed'&&!!d.taskUrl&&(!currentTabProductId||!d.productId||currentTabProductId===d.productId);
|
||||
const analysisFailed=state==='analysis_failed';
|
||||
const analysisBlocked=state==='analysis_blocked';
|
||||
const waitingForReviews=state==='reviews_incomplete'||state==='reviews_stopped';
|
||||
const progress=Math.max(0,Math.min(100,Number(d.progress||0)));
|
||||
const phaseMap={
|
||||
idle:'等待开始',
|
||||
login_required:'需要登录',
|
||||
verification_required:'需要完成验证',
|
||||
product_empty:'商品数据未就绪',
|
||||
collecting:'采集商品数据',
|
||||
collecting_reviews:'采集商品评论',
|
||||
reviews_incomplete:'数据等待补充',
|
||||
reviews_stopped:'采集已暂停',
|
||||
sending:'正在提交分析',
|
||||
analyzing:'报告生成中',
|
||||
analysis_completed:'报告已完成',
|
||||
analysis_failed:'分析未完成',
|
||||
analysis_blocked:'暂时无法提交'
|
||||
};
|
||||
|
||||
status.textContent=d.message||d.state||'已识别当前商品,等待手动开始。';
|
||||
bar.style.width=progress+'%';
|
||||
progressValue.textContent=progress+'%';
|
||||
phaseLabel.textContent=phaseMap[state]||'任务处理中';
|
||||
statusCard.dataset.state=reportReady?'completed':(analysisFailed||analysisBlocked)?'failed':analysisBusy?'analyzing':activeCollect?'collecting':'idle';
|
||||
isAnalysisRunning=analysisBusy;
|
||||
updateCollectSteps(state, progress, d.reviewLive);
|
||||
|
||||
updateRunButtonText(waitingForReviews,false);
|
||||
collectionControls.style.display=analysisBusy||reportReady?'none':'grid';
|
||||
runBtn.style.display=activeCollect?'none':'block';
|
||||
stopBtn.style.display=activeCollect?'block':'none';
|
||||
analysisOptions.style.display=(analysisBusy||activeCollect||reportReady)?'none':'block';
|
||||
waitHint.style.display=analysisBusy?'block':'none';
|
||||
|
||||
const showCollectionDetails=!analysisBusy&&!reportReady&&(activeCollect||waitingForReviews);
|
||||
renderParameterStatus(showCollectionDetails?d.parameterStatus:null);
|
||||
renderReviewStatus(d.reviewLive,showCollectionDetails&&!!(d.reviewLive||activeCollect||waitingForReviews));
|
||||
|
||||
currentReportUrl=d.taskUrl||'';
|
||||
viewCurrentReport.style.display=reportReady?'block':'none';
|
||||
analyzeAgain.style.display=reportReady?'block':'none';
|
||||
analysisMeta.style.display=d.taskId?'block':'none';
|
||||
if(d.taskId){
|
||||
const title=d.productTitle?String(d.productTitle).slice(0,32):'当前商品';
|
||||
analysisMeta.textContent=reportReady?`${title} · 报告已就绪`:`${title} · ${d.stage||'处理中'}`;
|
||||
}
|
||||
analyzeNow.disabled=analysisBusy;
|
||||
analyzeNow.textContent='基于当前已有数据生成报告';
|
||||
}
|
||||
|
||||
async function refresh(){
|
||||
if(refreshRunning)return;
|
||||
refreshRunning=true;
|
||||
try{
|
||||
const auth=await chrome.runtime.sendMessage({type:'SANSHENG_AUTH_STATE'}).catch(()=>({loggedIn:false}));
|
||||
accountStatus.textContent=auth?.loggedIn?'已登录,可提交市场洞察分析任务':'未登录,请先使用 PC 账号授权';
|
||||
accountDot.classList.toggle('online',!!auth?.loggedIn);
|
||||
loginAccount.style.display=auth?.loggedIn?'none':'block';
|
||||
logoutAccount.style.display=auth?.loggedIn?'block':'none';
|
||||
openReports.style.display=auth?.loggedIn?'block':'none';
|
||||
backendCompatible=!!auth?.loggedIn&&!!auth?.serviceReady;
|
||||
server.textContent=backendCompatible?'三笙云端分析服务已就绪 ✓':auth?.loggedIn?`云端服务不可用:${auth?.serviceError||'请确认 Web API 已启动'}`:'登录后可使用云端分析服务';
|
||||
server.className=backendCompatible?'state ok':'state warn';
|
||||
|
||||
const tab=await active();
|
||||
if(!tab?.id){renderTaskStatus(null);return}
|
||||
const kind=pageKind(tab.url||'');
|
||||
if(kind!=='product'){
|
||||
const message='请打开淘宝/天猫商品详情页';
|
||||
renderTaskStatus({state:'idle',message,progress:0,currentProductId:''});
|
||||
collectionControls.style.display='none';
|
||||
analysisOptions.style.display='none';
|
||||
return;
|
||||
}
|
||||
const currentProductId=itemIdFromUrl(tab.url||'');
|
||||
const result=backendCompatible?await chrome.runtime.sendMessage({type:'PRODUCT_ANALYSIS_STATUS',tabId:tab.id}).catch(()=>null):null;
|
||||
let data=result?.status||null;
|
||||
if(!data){
|
||||
const stored=await chrome.storage.local.get(['status_'+tab.id,'sansheng_product_analysis_last_task']);
|
||||
data=stored['status_'+tab.id]||stored.sansheng_product_analysis_last_task||null;
|
||||
}
|
||||
if(data){
|
||||
const dataProductId=String(data.productId||data.itemId||'');
|
||||
if(dataProductId&&dataProductId!==currentProductId)data=null;
|
||||
}
|
||||
if(data)renderTaskStatus({...data,currentProductId});
|
||||
else renderTaskStatus({message:'已打开商品详情页,选择采集深度后开始。',progress:0,currentProductId});
|
||||
}finally{
|
||||
refreshRunning=false;
|
||||
}
|
||||
}
|
||||
|
||||
function updateRunButtonText(continueReviews=false){
|
||||
if(continueReviews){runBtn.textContent='继续采集评论并生成';return}
|
||||
runBtn.textContent=collectionMode==='quick'?'快速采集并生成':collectionMode==='full'?'深度采集并生成':'标准采集并生成';
|
||||
}
|
||||
|
||||
function setMode(mode){
|
||||
analysisMode=mode;
|
||||
collectionMode=mode;
|
||||
modeQuick?.classList.toggle('active',mode==='quick');
|
||||
modeStandard?.classList.toggle('active',mode==='standard');
|
||||
modeFull?.classList.toggle('active',mode==='full');
|
||||
analyzeNow.disabled=isAnalysisRunning;
|
||||
updateRunButtonText(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将接口异常转换成用户可读提示,避免暴露 Error 包装文本。
|
||||
* 入参为未知异常,出参为稳定中文提示。
|
||||
*/
|
||||
function userErrorMessage(error){
|
||||
const message=error instanceof Error?error.message:String(error||'无法开始分析');
|
||||
return message.replace(/^Error:\s*/i,'').trim()||'无法开始分析,请稍后重试';
|
||||
}
|
||||
|
||||
/**
|
||||
* 持久化分析提交失败状态,避免自动刷新立刻覆盖错误提示。
|
||||
* 入参为当前标签页和错误文字,无返回值。
|
||||
*/
|
||||
async function showAnalysisSubmitError(tab,message){
|
||||
const currentProductId=itemIdFromUrl(tab?.url||'');
|
||||
await chrome.runtime.sendMessage({type:'PRODUCT_ANALYSIS_NOTICE',tabId:tab?.id,state:'analysis_blocked',productId:currentProductId,message}).catch(()=>{});
|
||||
renderTaskStatus({state:'analysis_blocked',message,progress:0,currentProductId,productId:currentProductId});
|
||||
}
|
||||
|
||||
loginAccount.onclick=()=>chrome.runtime.sendMessage({type:'SANSHENG_AUTH_START'});
|
||||
logoutAccount.onclick=async()=>{await chrome.runtime.sendMessage({type:'SANSHENG_AUTH_LOGOUT'});await refresh()};
|
||||
viewCurrentReport.onclick=()=>{if(currentReportUrl)chrome.tabs.create({url:currentReportUrl})};
|
||||
analyzeAgain.onclick=()=>{analyzeNow.click()};
|
||||
closeHistory.onclick=()=>{reportHistory.style.display='none'};
|
||||
modeQuick.onclick=()=>setMode('quick');
|
||||
modeStandard.onclick=()=>setMode('standard');
|
||||
modeFull.onclick=()=>setMode('full');
|
||||
|
||||
runBtn.onclick=async()=>{
|
||||
if(!backendCompatible){status.textContent='云端服务不可用,请确认 Web API 已启动后重试';return}
|
||||
const tab=await active();
|
||||
renderTaskStatus({state:'collecting',message:'正在准备采集当前商品数据',progress:5});
|
||||
try{
|
||||
const result=await chrome.runtime.sendMessage({type:'RUN_CURRENT',tabId:tab.id,collectionMode});
|
||||
if(result?.needsReviewResume){
|
||||
status.textContent=result.message||'评论还没到末页,请继续采集';
|
||||
runBtn.textContent='继续采集评论';
|
||||
}else if(result?.ok){
|
||||
status.textContent='采集已提交,云端正在生成报告。你可以继续浏览商品页。';
|
||||
}else{
|
||||
status.textContent='采集失败:'+(result?.error||'未知错误');
|
||||
}
|
||||
}catch(error){
|
||||
status.textContent='采集失败:'+String(error);
|
||||
}
|
||||
setTimeout(refresh,500);
|
||||
};
|
||||
|
||||
stopBtn.onclick=async()=>{
|
||||
const tab=await active();
|
||||
stopBtn.disabled=true;
|
||||
stopBtn.textContent='正在终止…';
|
||||
try{
|
||||
const result=await chrome.runtime.sendMessage({type:'STOP_COLLECTION',tabId:tab?.id});
|
||||
status.textContent=result?.ok?'已发送终止指令,正在保存当前进度…':'终止失败,请重试';
|
||||
}catch(error){
|
||||
status.textContent='终止失败:'+String(error);
|
||||
}
|
||||
setTimeout(()=>{stopBtn.disabled=false;stopBtn.textContent='终止采集并保存进度'},1000);
|
||||
};
|
||||
|
||||
analyzeNow.onclick=async()=>{
|
||||
if(!backendCompatible){status.textContent='云端服务不可用,请确认 Web API 已启动后重试';return}
|
||||
const tab=await active();
|
||||
if(!tab?.id){status.textContent='请先打开目标商品页';return}
|
||||
renderTaskStatus({state:'sending',message:'正在提交当前已有数据,请稍候',progress:78});
|
||||
try{
|
||||
const result=await chrome.runtime.sendMessage({type:'ANALYZE_CURRENT_NOW',tabId:tab.id});
|
||||
if(result?.ok){
|
||||
status.textContent=`分析任务已提交${result.reviewCount!=null?` · 当前评论 ${result.reviewCount} 条`:''},你可以继续浏览商品页。`;
|
||||
}else{
|
||||
await showAnalysisSubmitError(tab,userErrorMessage(result?.error||'无法开始分析,请稍后重试'));
|
||||
}
|
||||
}catch(error){
|
||||
await showAnalysisSubmitError(tab,userErrorMessage(error));
|
||||
}finally{
|
||||
setTimeout(refresh,400);
|
||||
}
|
||||
};
|
||||
|
||||
setMode('standard');
|
||||
void refresh();
|
||||
setInterval(refresh,2000);
|
||||
|
||||
let historyLoading=false;
|
||||
|
||||
/** 将任务状态转成中文;原始阶段未知时使用通用状态,不直接展示内部英文。 */
|
||||
function historyState(item){
|
||||
const status=Number(item.Status??item.status);
|
||||
if(status===3)return '报告已完成';
|
||||
if(status===4)return '分析未完成';
|
||||
const progress=Math.max(0,Math.min(100,Number(item.Progress??item.progress??0)||0));
|
||||
const stages={queued:'排队中',analyzing:'分析中',product:'商品定位分析',visual:'主图分析',detail:'详情分析',consumer:'消费者反馈分析',strategy:'生成开品方案',assembling:'整理报告'};
|
||||
const stage=String(item.Stage??item.stage??'');
|
||||
return `${stages[stage]||(stage.startsWith('review')?'评论分析':status===1?'排队中':'分析中')} · ${progress}%`;
|
||||
}
|
||||
|
||||
/** 读取最近报告;防重复点击,最多等待 16 秒,异常后可再次点击重试。 */
|
||||
openReports.onclick=async()=>{
|
||||
reportHistory.style.display='block';
|
||||
if(historyLoading)return;
|
||||
historyLoading=true;
|
||||
historyList.setAttribute('aria-busy','true');
|
||||
historyList.innerHTML='<div class="detail-note" role="status">正在加载报告记录…</div>';
|
||||
let timer;
|
||||
try{
|
||||
const result=await Promise.race([
|
||||
chrome.runtime.sendMessage({type:'PRODUCT_ANALYSIS_HISTORY'}),
|
||||
new Promise((_,reject)=>{timer=setTimeout(()=>reject(new Error('报告记录加载超时,请点击“报告记录”重试。')),16000)})
|
||||
]);
|
||||
if(!result?.ok)throw new Error(result?.error||'报告记录加载失败');
|
||||
const items=result.items||[];
|
||||
historyList.innerHTML=items.length?items.map(item=>{
|
||||
const id=String(item.TaskId||item.taskId||'');
|
||||
const title=escapeHtml(item.ProductTitle||item.productTitle||`商品 ${id}`);
|
||||
const status=Number(item.Status??item.status);
|
||||
const path=item.ReportUrl||item.reportUrl||`/product-analysis/reports/${encodeURIComponent(id)}`;
|
||||
const url=/^https?:\/\//i.test(path)?path:sanshengExtensionAuth.config.pcBase+path;
|
||||
return `<div class="history-item" data-status="${status}"><div><strong title="${title}">${title}</strong><span>${escapeHtml(historyState(item))}</span></div><button data-report-url="${escapeHtml(url)}" title="查看分析报告">查看</button></div>`;
|
||||
}).join(''):'<div class="detail-note">暂无分析报告</div>';
|
||||
historyList.querySelectorAll('button[data-report-url]').forEach(button=>button.onclick=()=>chrome.tabs.create({url:button.dataset.reportUrl}));
|
||||
}catch(error){historyList.innerHTML=`<div class="detail-note" role="alert">${escapeHtml(error instanceof Error?error.message:'报告记录加载失败')}</div>`}
|
||||
finally{clearTimeout(timer);historyLoading=false;historyList.setAttribute('aria-busy','false')}
|
||||
};
|
||||
@@ -1,8 +1,14 @@
|
||||
import json, os, re, ssl, time, urllib.request, urllib.error
|
||||
import base64, json, os, re, ssl, time, uuid, urllib.request, urllib.error
|
||||
from urllib.parse import urlparse
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
SERVER_VERSION = '9.3.0'
|
||||
IMAGE_QUALITY_OPTIONS = ('auto', 'low', 'medium', 'high', 'xhigh', 'max')
|
||||
|
||||
def normalize_image_quality(value):
|
||||
value = str(value or '').strip().lower()
|
||||
return value if value in IMAGE_QUALITY_OPTIONS else ''
|
||||
|
||||
def load_config():
|
||||
cfg = {}
|
||||
@@ -25,14 +31,45 @@ def load_config():
|
||||
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['image_api_base'] = os.getenv('IMAGE_API_BASE', cfg.get('image_api_base','')).rstrip('/')
|
||||
cfg['image_api_key'] = os.getenv('IMAGE_API_KEY', cfg.get('image_api_key',''))
|
||||
cfg['image_model'] = os.getenv('IMAGE_MODEL', cfg.get('image_model',''))
|
||||
cfg['image_quality'] = normalize_image_quality(os.getenv('IMAGE_QUALITY', cfg.get('image_quality','')))
|
||||
raw_image_models = os.getenv('IMAGE_MODELS', cfg.get('image_models', []))
|
||||
if isinstance(raw_image_models, str):
|
||||
raw_image_models = [x.strip() for x in raw_image_models.replace('\n', ',').split(',') if x.strip()]
|
||||
if not isinstance(raw_image_models, list):
|
||||
raw_image_models = []
|
||||
image_models = []
|
||||
for item in raw_image_models:
|
||||
if isinstance(item, dict):
|
||||
model_id = str(item.get('id') or item.get('model') or '').strip()
|
||||
label = str(item.get('label') or model_id).strip()
|
||||
quality = normalize_image_quality(item.get('quality'))
|
||||
else:
|
||||
model_id = str(item or '').strip()
|
||||
label = model_id
|
||||
quality = ''
|
||||
if model_id and not any(x['id'] == model_id for x in image_models):
|
||||
image_models.append({'id': model_id, 'label': label or model_id, 'quality': quality})
|
||||
default_image_model = str(cfg['image_model'] or '').strip()
|
||||
if default_image_model and not any(x['id'] == default_image_model for x in image_models):
|
||||
image_models.insert(0, {'id': default_image_model, 'label': default_image_model, 'quality': cfg['image_quality']})
|
||||
for item in image_models:
|
||||
if not item.get('quality'):
|
||||
item['quality'] = cfg['image_quality']
|
||||
cfg['image_models'] = image_models
|
||||
cfg['chat_path'] = str(cfg.get('chat_path','/chat/completions'))
|
||||
cfg['models_path'] = str(cfg.get('models_path','/models'))
|
||||
cfg['image_models_path'] = str(cfg.get('image_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
|
||||
try: cfg['image_concurrency'] = max(1, min(5, int(os.getenv('IMAGE_CONCURRENCY', cfg.get('image_concurrency',5)))))
|
||||
except Exception: cfg['image_concurrency'] = 5
|
||||
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))
|
||||
@@ -68,6 +105,63 @@ def _headers(c):
|
||||
h.update(c.get('extra_headers',{}))
|
||||
return h
|
||||
|
||||
def image_channel(c=None):
|
||||
"""Return an image-provider config, falling back to the analysis channel for compatibility."""
|
||||
source=dict(c or load_config())
|
||||
source['api_base']=str(source.get('image_api_base') or source.get('api_base') or '').rstrip('/')
|
||||
source['api_key']=str(source.get('image_api_key') or source.get('api_key') or '')
|
||||
source['models_path']=str(source.get('image_models_path') or '/models')
|
||||
return source
|
||||
|
||||
def image_model_options(c=None):
|
||||
"""Return configured image models while keeping the legacy single-model field."""
|
||||
source = c if isinstance(c, dict) else load_config()
|
||||
options = source.get('image_models') or []
|
||||
result = []
|
||||
for item in options:
|
||||
if isinstance(item, dict):
|
||||
model_id = str(item.get('id') or item.get('model') or '').strip()
|
||||
label = str(item.get('label') or model_id).strip()
|
||||
quality = normalize_image_quality(item.get('quality'))
|
||||
else:
|
||||
model_id = str(item or '').strip()
|
||||
label = model_id
|
||||
quality = ''
|
||||
if model_id and not any(x['id'] == model_id for x in result):
|
||||
result.append({'id': model_id, 'label': label or model_id, 'quality': quality})
|
||||
legacy = str(source.get('image_model') or '').strip()
|
||||
if legacy and not any(x['id'] == legacy for x in result):
|
||||
result.insert(0, {'id': legacy, 'label': legacy, 'quality': normalize_image_quality(source.get('image_quality'))})
|
||||
default_quality = normalize_image_quality(source.get('image_quality'))
|
||||
for item in result:
|
||||
if not item.get('quality'):
|
||||
item['quality'] = default_quality
|
||||
return result
|
||||
|
||||
def probe_image_api(timeout=15):
|
||||
c=image_channel()
|
||||
options=image_model_options(c)
|
||||
model=str(c.get('image_model') or (options[0]['id'] if options else '')).strip()
|
||||
if not c.get('api_base') or not c.get('api_key') or not options:
|
||||
return {'ok':False,'stage':'image_config','error':'生图渠道 API Base、API Key 或生图模型未配置'}
|
||||
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)]
|
||||
model_checks=[{'id':item['id'],'label':item['label'],'quality':item.get('quality') or '',
|
||||
'found':(item['id'] in ids) if ids else None} for item in options]
|
||||
return {'ok':True,'stage':'image_models','http':200,'imageModel':model,
|
||||
'imageModelFound':(model in ids) if ids else None,'modelsCount':len(ids),
|
||||
'imageModels':model_checks,
|
||||
'allImageModelsFound':all(item['found'] is not False for item in model_checks),
|
||||
'separateChannel':bool(c.get('image_api_base') or c.get('image_api_key'))}
|
||||
except urllib.error.HTTPError as e:
|
||||
body=e.read().decode('utf-8','ignore')
|
||||
return {'ok':False,'stage':'image_models','http':e.code,'error':body[:500] or str(e),'imageModel':model}
|
||||
except Exception as e:
|
||||
return {'ok':False,'stage':'image_network','error':str(e),'imageModel':model}
|
||||
|
||||
def _join(base,path):
|
||||
path='/' + str(path or '').lstrip('/')
|
||||
return base.rstrip('/') + path
|
||||
@@ -83,7 +177,10 @@ def probe_model_api(timeout=15):
|
||||
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)}
|
||||
image_model=str(c.get('image_model') or '').strip()
|
||||
return {'ok':True,'stage':'models','http':200,'modelFound':(c['model'] in ids) if ids else None,'model':c['model'],
|
||||
'imageModelConfigured':bool(image_model),'imageModel':image_model or None,
|
||||
'imageModelFound':(image_model in ids) if ids and image_model else None,'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)}
|
||||
@@ -107,6 +204,125 @@ def _do_post(c,payload):
|
||||
with urllib.request.urlopen(req,timeout=c['timeout'],context=_ssl_context(c)) as r:
|
||||
return json.loads(r.read().decode('utf-8','ignore'))
|
||||
|
||||
def image_generate(prompt, size='1024x1024', n=1, model=None, reference_images=None, quality=None, style=None):
|
||||
"""Generate commerce images through an OpenAI-compatible images endpoint.
|
||||
|
||||
Image generation is deliberately separate from chat generation: operators can
|
||||
keep a text model for analysis and configure a dedicated image model/path in
|
||||
config.json. The return value is the provider response so the server can
|
||||
persist both URL and base64 responses locally.
|
||||
"""
|
||||
c=image_channel()
|
||||
if c.get('_config_error'):
|
||||
raise RuntimeError(c['_config_error'])
|
||||
if not c.get('api_key'):
|
||||
raise RuntimeError('未配置 API Key,无法生成图片。')
|
||||
image_model=str(model or c.get('image_model') or '').strip()
|
||||
if not image_model:
|
||||
raise RuntimeError('未配置独立的生图模型 image_model。请在本地服务配置页与分析模型一起填写。')
|
||||
try: count=max(1,min(8,int(n)))
|
||||
except Exception: count=1
|
||||
payload={
|
||||
'model':image_model,
|
||||
'prompt':str(prompt or '').strip(),
|
||||
'size':str(size or c.get('image_size') or '1024x1024'),
|
||||
'n':count,
|
||||
}
|
||||
quality_value=normalize_image_quality(c.get('image_quality') if quality is None else quality)
|
||||
if quality_value: payload['quality']=quality_value
|
||||
style_value=c.get('image_style') if style is None else style
|
||||
if style_value: payload['style']=style_value
|
||||
path=str(c.get('image_path') or '/images/generations')
|
||||
last=None
|
||||
refs=[str(x).strip() for x in (reference_images or []) if str(x).strip()]
|
||||
if len(refs)>4:
|
||||
raise ValueError('单次生图最多四张参考图,不能静默丢弃输入')
|
||||
edit_request=bool(refs and image_model.startswith('gpt-image-') and (
|
||||
urlparse(c['api_base']).hostname in ('api.apiyi.com','api.openai.com') or path.rstrip('/').endswith('/images/edits')
|
||||
))
|
||||
if edit_request and path.rstrip('/').endswith('/images/generations'):
|
||||
path=path.rstrip('/')[:-len('generations')]+'edits'
|
||||
variants=[]
|
||||
if edit_request:
|
||||
variants=[payload]
|
||||
elif refs:
|
||||
# OpenAI-compatible image gateways use different names for image guidance.
|
||||
# Try the common JSON shapes, but never silently drop a requested reference.
|
||||
for key in ('image','reference_images','images'):
|
||||
v=dict(payload); v[key]=refs[0] if key=='image' and len(refs)==1 else refs; variants.append(v)
|
||||
else:
|
||||
variants=[payload]
|
||||
# Gateways differ: retry without optional fields before failing the job.
|
||||
if not edit_request and ('quality' in payload or 'style' in payload):
|
||||
clean=[]
|
||||
for item in variants:
|
||||
v=dict(item); v.pop('quality',None); v.pop('style',None); clean.append(v)
|
||||
variants.extend(clean)
|
||||
retryable_http=(408,409,429,500,502,503,504)
|
||||
# Try payload aliases only when the provider rejects a shape. A transport
|
||||
# failure must retry the same request after backoff; cycling every alias on
|
||||
# a TLS EOF/reset creates a burst of duplicate generation requests.
|
||||
for variant in variants:
|
||||
shape_rejected=False
|
||||
for attempt in range(c['retries']+1):
|
||||
try:
|
||||
return _do_image_edit(c,path,variant,refs) if edit_request else _do_post_path(c,path,variant)
|
||||
except urllib.error.HTTPError as e:
|
||||
body=e.read().decode('utf-8','ignore')
|
||||
if e.code in (502,503,504):
|
||||
last=RuntimeError(f'生图网关暂时不可用(HTTP {e.code}),系统已自动重试;请稍后仅重试失败图片')
|
||||
elif body.lstrip().lower().startswith(('<!doctype html','<html')):
|
||||
last=RuntimeError(f'图片接口 HTTP {e.code}:上游返回了网页错误页')
|
||||
else:
|
||||
last=RuntimeError(f'图片接口 HTTP {e.code}: {body[:900]}')
|
||||
if e.code==400:
|
||||
if edit_request:
|
||||
raise last
|
||||
shape_rejected=True
|
||||
break
|
||||
if e.code not in retryable_http:
|
||||
raise last
|
||||
except Exception as e:
|
||||
last=RuntimeError(f'图片接口调用失败: {e}')
|
||||
if attempt<c['retries']:
|
||||
time.sleep(min(2 ** attempt * 2,8))
|
||||
if not shape_rejected:
|
||||
raise last or RuntimeError('图片接口调用失败')
|
||||
raise last or RuntimeError('图片接口调用失败')
|
||||
|
||||
def _do_image_edit(c,path,payload,refs):
|
||||
"""Send ordered, actual image files using the provider's edit contract."""
|
||||
boundary='tmall_image_'+uuid.uuid4().hex
|
||||
chunks=[]
|
||||
for key,value in payload.items():
|
||||
chunks.append((f'--{boundary}\r\nContent-Disposition: form-data; name="{key}"\r\n\r\n{value}\r\n').encode('utf-8'))
|
||||
for index,ref in enumerate(refs,1):
|
||||
if not ref.startswith('data:image/') or ',' not in ref:
|
||||
raise ValueError('编辑接口必须接收已读取的图片数据,不能传远程链接')
|
||||
head,encoded=ref.split(',',1)
|
||||
if ';base64' not in head:
|
||||
raise ValueError('参考图必须使用 Base64 图片数据')
|
||||
data=base64.b64decode(encoded,validate=True)
|
||||
mime=head[5:].split(';',1)[0].lower()
|
||||
formats={'image/png':('png',data.startswith(b'\x89PNG\r\n\x1a\n')),
|
||||
'image/jpeg':('jpg',data.startswith(b'\xff\xd8\xff')),
|
||||
'image/webp':('webp',data[:4]==b'RIFF' and data[8:12]==b'WEBP')}
|
||||
if mime not in formats or not formats[mime][1] or len(data)>16*1024*1024:
|
||||
raise ValueError('参考图类型、数据签名或大小不符合编辑接口要求')
|
||||
ext=formats[mime][0]
|
||||
chunks.append((f'--{boundary}\r\nContent-Disposition: form-data; name="image[]"; filename="reference-{index}.{ext}"\r\nContent-Type: {mime}\r\n\r\n').encode('ascii'))
|
||||
chunks.extend((data,b'\r\n'))
|
||||
chunks.append(f'--{boundary}--\r\n'.encode('ascii'))
|
||||
headers=_headers(c);headers['Content-Type']='multipart/form-data; boundary='+boundary
|
||||
req=urllib.request.Request(_join(c['api_base'],path),data=b''.join(chunks),headers=headers,method='POST')
|
||||
with urllib.request.urlopen(req,timeout=c['timeout'],context=_ssl_context(c)) as response:
|
||||
return json.loads(response.read().decode('utf-8','ignore'))
|
||||
|
||||
def _do_post_path(c,path,payload):
|
||||
req=urllib.request.Request(_join(c['api_base'],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'):
|
||||
|
||||
@@ -79,12 +79,12 @@ FINAL_REPORT_PROMPT='''任务G|V9.3.0 七角色总编报告编排器。你只
|
||||
{"label":"下一款优先","headline":"<=18字","note":"<=22字","evidenceIds":[]}
|
||||
]},
|
||||
"titleAnalysis":{"professionalOpinion":"<=45字","originalTitle":"","currentExpression":[{"value":"<=10字","evidenceIds":[]}],"reinforce":[{"topic":"<=10字","evidenceIds":[]}],"recommendedActions":[{"action":"<=22字","evidenceIds":[]}],"recommendedTitles":[]},
|
||||
"visualCommerce":{"professionalOpinion":"<=45字","items":[{"imageEvidenceId":"IMG_MAIN_0001","imageRole":"<=8字","visualSignals":["<=10字"],"businessMeaning":"<=22字","nextAction":"<=18字","evidenceIds":[]}],"evidenceDetail":[]},
|
||||
"detailCommerce":{"professionalOpinion":"<=45字","contentGroups":[{"type":"品牌服务|套件组成|材质品质|产品功能|使用便利|花型选择|用户验证|其他","representativeImageId":"IMG_DETAIL_0001","visualSignals":["<=10字"],"businessMeaning":"<=22字","nextAction":"<=18字","imageEvidenceIds":[],"evidenceIds":[]}],"pageSequence":[]},
|
||||
"visualCommerce":{"professionalOpinion":"<=45字","items":[{"imageEvidenceId":"IMG_MAIN_0001","imageRole":"<=8字","visualSignals":["<=10字"],"businessMeaning":"<=22字","nextAction":"<=18字","contentKey":"product_overview|usage_scene|material_touch|structure_function|spec_choice","evidenceIds":[]}],"evidenceDetail":[]},
|
||||
"detailCommerce":{"professionalOpinion":"<=45字","contentGroups":[{"type":"场景证明|材质证明|结构工艺|功能表现|规格适配|使用维护","representativeImageId":"IMG_DETAIL_0001","visualSignals":["<=10字"],"businessMeaning":"<=22字","nextAction":"<=18字","contentKey":"scene_problem|material_proof|structure_proof|function_proof|spec_adaptation|care_durability","imageEvidenceIds":[],"evidenceIds":[]}],"pageSequence":[]},
|
||||
"skuAnalysis":{"professionalOpinion":"<=35字","selectionDimensions":[{"name":"<=8字","values":[],"evidenceIds":[]}],"recommendedOrder":[],"representativeImageIds":[],"recommendedAction":"<=24字","structuredStatus":"confirmed|missing","structuredCount":null,"imageCount":0},
|
||||
"customerExperience":{"professionalOpinion":"<=45字","consumerInsights":{"experienceSignals":[{"type":"稳定体验|体验差异|购前关注|对新品的启示","insight":"<=28字","evidenceIds":[]}],"productImplications":[]},"buyerEvidence":{}},
|
||||
"expressionContinuity":{"coreClaims":[{"claim":"<=24字","mainStatus":"expressed|partial|missing","detailStatus":"proven|partial|missing","reviewStatus":"validated|partial|missing","nextAction":"<=24字","evidenceIds":[]}]},
|
||||
"newProductPlans":{"professionalOpinion":"<=45字","plans":[{"sourceType":"证据驱动优化|反馈驱动升级|探索性方向","type":"现有基础优化|强化体验区分|强化设计选择|其他","name":"<=18字","whyThisPlan":"<=32字","visualReferenceImageIds":[],"productAction":"<=28字","pageAction":"<=28字","validation":"<=20字","evidenceIds":[]}]},
|
||||
"newProductPlans":{"professionalOpinion":"<=45字","plans":[{"sourceType":"证据驱动优化|反馈驱动升级|探索性方向","name":"<=18字","productAction":"<=45字","pageAction":"<=36字"}]},
|
||||
"productExperience":{"parameterFacts":[],"gates":[]},
|
||||
"validationLoop":{"rows":[]}
|
||||
}
|
||||
@@ -92,8 +92,10 @@ FINAL_REPORT_PROMPT='''任务G|V9.3.0 七角色总编报告编排器。你只
|
||||
硬规则:
|
||||
- ownerOverview.cards 必须正好3项,且分别回答“为什么能卖 / 值得继承 / 下一款优先”。
|
||||
- visualCommerce.items 中每张图必须优先给 visualSignals;不要写长 message。
|
||||
- detailCommerce.contentGroups 证据允许时输出4-6组。
|
||||
- newProductPlans.plans>=1;第三类创新若不是直接证据结论必须标 sourceType=探索性方向。
|
||||
- detailCommerce.contentGroups 证据允许时输出4-6组,顺序必须是场景 → 材质 → 结构/工艺 → 功能 → 规格 → 使用维护。
|
||||
- 主图与详情必须形成连续页面:主图先让用户看懂商品和使用入口,详情逐项证明;同一卖点只能在一个信息任务中承担主责。
|
||||
- 主图和详情只表达商品本身的结构、材质、成分、尺寸、件数、功能、工艺、触感、使用场景和洗护等内容。品牌、商标、专利、授权、认证、奖项、包邮、物流、价格、销量、促销、客服、售后、二维码、防伪和店铺信息不得进入 visualCommerce 或 detailCommerce。
|
||||
- newProductPlans.plans 必须正好3项,依次为证据驱动优化、反馈驱动升级、探索性方向;探索项必须明确为待验证方向,不能当作已验证结论。
|
||||
- productExperience.parameterFacts>=2、productExperience.gates>=2、validationLoop.rows>=3,继续用于工程证据校验,但默认报告不展示。
|
||||
- 禁止用户可见字段出现[数据事实][分析判断][设计建议][证据不足]。
|
||||
- 只返回有真实内容的可选字段;不得用空字符串、空数组、空对象或“暂无/未采集/待补充”填充版面。
|
||||
@@ -102,7 +104,7 @@ FINAL_REPORT_PROMPT='''任务G|V9.3.0 七角色总编报告编排器。你只
|
||||
|
||||
FINAL_REPORT_REPAIR_PROMPT='''任务G-R|修复 V9.3.0 最终报告 JSON。只修复结构缺失和证据引用,不扩写正文。
|
||||
必须保留短文本与图片信息优先规则。
|
||||
强制最小结构:reportSummary.title/verdict非空;ownerOverview.cards>=3;productExperience.parameterFacts>=2;productExperience.gates>=2;newProductPlans.plans>=1;validationLoop.rows>=3。
|
||||
强制最小结构:reportSummary.title/verdict非空;ownerOverview.cards>=3;productExperience.parameterFacts>=2;productExperience.gates>=2;newProductPlans.plans=3;validationLoop.rows>=3。
|
||||
如果 customerExperience 有评论证据,优先保留 experienceSignals;无评论时允许为空。
|
||||
所有 evidenceIds 只能来自 ACTUAL_EVIDENCE。不要输出 Markdown、解释或包装层。
|
||||
可选字段没有真实内容时直接省略,不得填入“暂无、未采集、待补充”或空容器。
|
||||
@@ -680,6 +682,69 @@ def _apply_selling_points(exp, raw, base=None):
|
||||
}
|
||||
return exp
|
||||
|
||||
def _ensure_three_product_plans(exp, raw, base=None):
|
||||
"""Keep one evidence-backed plan for each decision horizon, even when the model returns fewer."""
|
||||
if not isinstance(exp,dict):return exp
|
||||
base=base or baseline(raw); block=exp.setdefault('newProductPlans',{})
|
||||
existing=[x for x in block.get('plans',[]) or [] if isinstance(x,dict)]
|
||||
valid_types=('证据驱动优化','反馈驱动升级','探索性方向')
|
||||
by_type={}
|
||||
untyped=[]
|
||||
for plan in existing:
|
||||
source=_txt(plan.get('sourceType')).strip()
|
||||
if source in valid_types and source not in by_type:by_type[source]=dict(plan)
|
||||
else:untyped.append(dict(plan))
|
||||
for source in valid_types:
|
||||
if source not in by_type and untyped:
|
||||
plan=untyped.pop(0);plan['sourceType']=source;by_type[source]=plan
|
||||
|
||||
attrs=_attribute_rows(raw);issue=_review_issue(raw);points=_derive_next_selling_points(raw,base,issue)
|
||||
attr_refs=[f'ATTR_{i:04d}' for i in range(1,min(len(attrs),6)+1)]
|
||||
review_refs=[]
|
||||
if issue and issue.get('evidenceId'):review_refs.append(issue['evidenceId'])
|
||||
for point in points:
|
||||
review_refs.extend(x for x in point.get('evidenceIds',[]) or [] if _txt(x).startswith('REV_'))
|
||||
review_refs=_uniq(review_refs)[:6]
|
||||
shared_refs=_uniq((attr_refs or ['P_TITLE'])+review_refs)
|
||||
strongest=points[0] if points else {}
|
||||
|
||||
fallbacks={
|
||||
'证据驱动优化':{
|
||||
'sourceType':'证据驱动优化','name':'规格校准款',
|
||||
'productAction':_txt(strongest.get('productAction')).strip() or '统一现有规格、材质、结构与套件口径',
|
||||
'pageAction':'主图讲清商品与场景,详情逐项证明材质、结构和规格',
|
||||
'evidenceIds':shared_refs,
|
||||
},
|
||||
'反馈驱动升级':{
|
||||
'sourceType':'反馈驱动升级','name':'体验升级款',
|
||||
'productAction':(_txt(issue.get('action')).strip() if issue else '') or '围绕已采消费者反馈优化使用体验与规格适配',
|
||||
'pageAction':'用实拍动作、细节近景和对照信息回应购买顾虑',
|
||||
'evidenceIds':_uniq(review_refs+attr_refs) or shared_refs,
|
||||
},
|
||||
'探索性方向':{
|
||||
'sourceType':'探索性方向','name':'差异探索款',
|
||||
'productAction':'基于现有结构开发差异化配色或规格小样,先验证再定款',
|
||||
'pageAction':'独立展示探索设计与适用场景,不宣称已获市场验证',
|
||||
'evidenceIds':shared_refs,
|
||||
},
|
||||
}
|
||||
plans=[]
|
||||
for source in valid_types:
|
||||
plan=by_type.get(source) or fallbacks[source]
|
||||
plan['sourceType']=source
|
||||
if not _txt(plan.get('name')).strip():plan['name']=fallbacks[source]['name']
|
||||
name=_txt(plan.get('name')).strip()
|
||||
if len(name)>14:name=fallbacks[source]['name']
|
||||
if not name.endswith('款'):name=name[:17].rstrip(' ,。-')+'款'
|
||||
plan['name']=name[:17].rstrip(' ,。-')+'款' if len(name)>18 else name
|
||||
if not _txt(plan.get('productAction')).strip():plan['productAction']=fallbacks[source]['productAction']
|
||||
if not _txt(plan.get('pageAction')).strip():plan['pageAction']=fallbacks[source]['pageAction']
|
||||
if not plan.get('evidenceIds'):plan['evidenceIds']=fallbacks[source]['evidenceIds']
|
||||
plans.append(plan)
|
||||
block['plans']=plans
|
||||
block['professionalOpinion']=block.get('professionalOpinion') or '三条路径并行对照:先优化现有商品,再回应反馈,最后小样验证差异方向。'
|
||||
return exp
|
||||
|
||||
def _generic_platform_overview(raw, base=None):
|
||||
base=base or baseline(raw); route=detect_category(raw); attrs=_attribute_rows(raw); sales=raw.get('sales') or {}; issue=_review_issue(raw)
|
||||
price=_txt(sales.get('currentPrice')).strip(); sold=_txt(sales.get('sold') or sales.get('cumulativeSales')).strip(); facts=[]
|
||||
@@ -746,7 +811,7 @@ def _core_solution_ok(exp):
|
||||
isinstance((exp.get('ownerOverview') or {}).get('cards'),list) and len((exp.get('ownerOverview') or {}).get('cards'))>=3,
|
||||
isinstance((exp.get('productExperience') or {}).get('parameterFacts'),list) and len((exp.get('productExperience') or {}).get('parameterFacts'))>=2,
|
||||
isinstance((exp.get('productExperience') or {}).get('gates'),list) and len((exp.get('productExperience') or {}).get('gates'))>=2,
|
||||
isinstance((exp.get('newProductPlans') or {}).get('plans'),list) and len((exp.get('newProductPlans') or {}).get('plans'))>=1,
|
||||
isinstance((exp.get('newProductPlans') or {}).get('plans'),list) and len((exp.get('newProductPlans') or {}).get('plans'))>=3,
|
||||
isinstance((exp.get('validationLoop') or {}).get('rows'),list) and len((exp.get('validationLoop') or {}).get('rows'))>=3,
|
||||
)
|
||||
return all(checks)
|
||||
@@ -759,7 +824,7 @@ def core_solution_diagnostics(exp):
|
||||
('ownerOverview.cards',isinstance((exp.get('ownerOverview') or {}).get('cards'),list),len((exp.get('ownerOverview') or {}).get('cards') or []),3),
|
||||
('productExperience.parameterFacts',isinstance((exp.get('productExperience') or {}).get('parameterFacts'),list),len((exp.get('productExperience') or {}).get('parameterFacts') or []),2),
|
||||
('productExperience.gates',isinstance((exp.get('productExperience') or {}).get('gates'),list),len((exp.get('productExperience') or {}).get('gates') or []),2),
|
||||
('newProductPlans.plans',isinstance((exp.get('newProductPlans') or {}).get('plans'),list),len((exp.get('newProductPlans') or {}).get('plans') or []),1),
|
||||
('newProductPlans.plans',isinstance((exp.get('newProductPlans') or {}).get('plans'),list),len((exp.get('newProductPlans') or {}).get('plans') or []),3),
|
||||
('validationLoop.rows',isinstance((exp.get('validationLoop') or {}).get('rows'),list),len((exp.get('validationLoop') or {}).get('rows') or []),3),
|
||||
)
|
||||
checks=[]
|
||||
@@ -777,7 +842,7 @@ def repair_core_solution_with_evidence(draft, raw, base=None):
|
||||
list_rules=(
|
||||
('ownerOverview','cards',3),
|
||||
('productExperience','parameterFacts',2),('productExperience','gates',2),
|
||||
('newProductPlans','plans',1),('validationLoop','rows',3),
|
||||
('newProductPlans','plans',3),('validationLoop','rows',3),
|
||||
)
|
||||
for section,key in scalar_rules:
|
||||
current=dict(draft.get(section) or {})
|
||||
@@ -980,10 +1045,194 @@ def _filter_evidence_items(values, valid_ids, image=False):
|
||||
clean=dict(item);clean['evidenceIds']=refs;out.append(clean)
|
||||
return out
|
||||
|
||||
VISUAL_EXCLUDED_TERMS=(
|
||||
'品牌','商标','专利','授权','认证','证书','奖项','背书',
|
||||
'包邮','物流','快递','运费','价格','销量','优惠','折扣','促销','红包',
|
||||
'二维码','防伪','官方','店铺','客服','售后','信任服务','品牌服务',
|
||||
'条形码','商品编码','货号','发明人','正宗','产品等级','执行标准','安全类别'
|
||||
)
|
||||
|
||||
MAIN_VISUAL_TASKS=(
|
||||
{'contentKey':'product_overview','role':'商品全貌','signals':('主体完整','整体组成'),'keywords':('件数','套件','组合','商品','产品'),'nextAction':'先让用户看清商品主体与整体组成'},
|
||||
{'contentKey':'usage_scene','role':'使用场景','signals':('真实使用状态','空间适配'),'keywords':('场景','适用','人群','床型','季节','用途'),'nextAction':'承接使用场景,说明产品适合怎么用'},
|
||||
{'contentKey':'material_touch','role':'材质触感','signals':('材质近景','触感细节'),'keywords':('材质','面料','成分','支数','密度','克重','填充'),'nextAction':'用近景和参数把材质触感讲具体'},
|
||||
{'contentKey':'structure_function','role':'结构功能','signals':('结构细节','使用动作'),'keywords':('结构','工艺','功能','做工','细节','拉链','接口'),'nextAction':'把结构、工艺或功能的实现方式拍清楚'},
|
||||
{'contentKey':'spec_choice','role':'规格选择','signals':('尺寸清楚','选择路径'),'keywords':('尺寸','规格','件数','颜色','款式','SKU','适配'),'nextAction':'最后承接尺寸、件数和选择方式'},
|
||||
)
|
||||
|
||||
DETAIL_VISUAL_TASKS=(
|
||||
{'contentKey':'scene_problem','role':'场景证明','signals':('使用问题','真实场景'),'keywords':('场景','适用','人群','用途','问题'),'nextAction':'先用真实场景回答产品解决什么使用问题'},
|
||||
{'contentKey':'material_proof','role':'材质证明','signals':('材质参数','表面细节'),'keywords':('材质','面料','成分','支数','密度','克重','填充'),'nextAction':'接着证明材质、成分或填充信息'},
|
||||
{'contentKey':'structure_proof','role':'结构工艺','signals':('结构拆解','工艺细节'),'keywords':('结构','工艺','做工','细节','缝制','接口','拉链'),'nextAction':'继续证明结构、工艺和关键细节'},
|
||||
{'contentKey':'function_proof','role':'功能表现','signals':('使用动作','效果边界'),'keywords':('功能','性能','使用','透气','防护','收纳','连接'),'nextAction':'用使用动作说明功能表现,不扩大功效'},
|
||||
{'contentKey':'spec_adaptation','role':'规格适配','signals':('尺寸对照','套件清单'),'keywords':('规格','尺寸','件数','套件','SKU','适配','颜色','款式'),'nextAction':'单独列清尺寸、件数、款式与适配关系'},
|
||||
{'contentKey':'care_durability','role':'使用维护','signals':('洗护方式','耐用检查'),'keywords':('洗护','洗涤','护理','耐用','首洗','清洁','保存'),'nextAction':'最后说明洗护、耐用或长期使用注意事项'},
|
||||
{'contentKey':'material_closeup','role':'材质近观','signals':('纤维纹理','触感近景'),'keywords':('材质','面料','成分','纤维','触感'),'nextAction':'补充面料纹理与触感的微观证明'},
|
||||
{'contentKey':'craft_closeup','role':'工艺近观','signals':('走线细节','工艺局部'),'keywords':('工艺','缝制','绗缝','做工','细节'),'nextAction':'补充走线、接口或工艺局部的清晰证明'},
|
||||
{'contentKey':'function_scenario','role':'功能场景','signals':('功能动作','使用结果'),'keywords':('功能','使用','性能','保暖','透气','抗菌'),'nextAction':'在真实使用动作中补充功能表现'},
|
||||
{'contentKey':'size_compare','role':'尺寸对照','signals':('尺寸标注','实物对照'),'keywords':('尺寸','规格','床型','长度','宽度'),'nextAction':'用实物与标注对照减少规格误解'},
|
||||
{'contentKey':'set_inventory','role':'套件清单','signals':('部件清楚','组合关系'),'keywords':('件数','套件','组合','配件','被套'),'nextAction':'单独说清套件包含什么及组合关系'},
|
||||
{'contentKey':'color_selection','role':'款式选择','signals':('颜色对比','款式区分'),'keywords':('颜色','配色','花型','款式','SKU'),'nextAction':'用同一版式清楚区分可选颜色或款式'},
|
||||
{'contentKey':'care_steps','role':'洗护步骤','signals':('洗护提示','操作步骤'),'keywords':('洗护','洗涤','护理','晾晒','保存'),'nextAction':'把洗护步骤和注意事项拆成可执行说明'},
|
||||
{'contentKey':'quality_check','role':'耐用核验','signals':('长期使用','品质检查'),'keywords':('耐用','首洗','回弹','起球','褪色'),'nextAction':'补充长期使用和样品核验的观察点'},
|
||||
{'contentKey':'purchase_summary','role':'选择总结','signals':('适用边界','购买确认'),'keywords':('适用','人群','场景','规格','选择'),'nextAction':'最后汇总适用人群、场景和选择边界'},
|
||||
)
|
||||
|
||||
def _visual_product_text(value, limit=18, blocked_terms=()):
|
||||
"""Keep only product-facing phrases; trust and transaction metadata stay outside the visual chain."""
|
||||
if isinstance(value,list):
|
||||
parts=[]
|
||||
for item in value:
|
||||
parts.extend(_visual_product_text(item,limit,blocked_terms))
|
||||
return _uniq(parts)
|
||||
if isinstance(value,dict):
|
||||
for key in ('visualSignals','signal','statement','headline','value','label','topic','action','consumerValue','businessMeaning'):
|
||||
if value.get(key):
|
||||
return _visual_product_text(value.get(key),limit,blocked_terms)
|
||||
return []
|
||||
text=re.sub(r'\s+',' ',_txt(value)).strip(' ,,。;;')
|
||||
if not text:return []
|
||||
pieces=re.split(r'[;;。!?!?,,、|/]+',text)
|
||||
out=[]
|
||||
for piece in pieces:
|
||||
piece=re.sub(r'\s+',' ',piece).strip(' ::')
|
||||
if not piece or any(term in piece for term in VISUAL_EXCLUDED_TERMS) or any(term and term in piece for term in blocked_terms):continue
|
||||
if piece not in out:out.append(piece[:limit])
|
||||
return out
|
||||
|
||||
def _visual_refs(item, valid_ids):
|
||||
refs=[]
|
||||
if isinstance(item,dict):
|
||||
refs.extend(x for x in item.get('evidenceIds',[]) or [] if x in valid_ids)
|
||||
for key in ('imageEvidenceId','representativeImageId'):
|
||||
if item.get(key) in valid_ids:refs.append(item.get(key))
|
||||
refs.extend(x for x in item.get('imageEvidenceIds',[]) or [] if x in valid_ids)
|
||||
return _uniq(refs)
|
||||
|
||||
def _visual_fact_signals(raw, keywords, limit=2, blocked_terms=()):
|
||||
signals=[]
|
||||
for item in _attribute_rows(raw):
|
||||
name=_txt(item.get('name')); value=_txt(item.get('value'))
|
||||
if any(word in name for word in keywords):
|
||||
signals.extend(_visual_product_text(f'{name}:{value}',18,blocked_terms))
|
||||
if len(signals)>=limit:break
|
||||
return _uniq(signals)[:limit]
|
||||
|
||||
def _visual_image_ids(raw, group):
|
||||
return [f'IMG_{group.upper()}_{i:04d}' for i,_ in enumerate((raw.get('images') or {}).get(group,[]) or [],1)]
|
||||
|
||||
def _visual_identity_terms(raw):
|
||||
terms=[]
|
||||
product=raw.get('product') or {}
|
||||
for value in (_txt(product.get('brand')),):
|
||||
if len(value.strip())>=2:terms.append(value.strip())
|
||||
for item in _attribute_rows(raw):
|
||||
name=_txt(item.get('name'))
|
||||
value=_txt(item.get('value')).strip()
|
||||
if any(key in name for key in ('品牌','商标')) and len(value)>=2:
|
||||
terms.append(value)
|
||||
return _uniq(terms)
|
||||
|
||||
def _visual_signal_matches(signal, keywords):
|
||||
return any(keyword in signal for keyword in keywords)
|
||||
|
||||
def _canonical_visual_items(raw, items, tasks, valid_ids, plan_points=None, blocked_terms=()):
|
||||
"""Give every visual asset one job and a stable handoff to the next job."""
|
||||
source=[x for x in items or [] if isinstance(x,dict)]
|
||||
image_ids=_visual_image_ids(raw,'main' if tasks is MAIN_VISUAL_TASKS else 'detail')
|
||||
count=min(len(tasks),max(len(source),len(image_ids)))
|
||||
if not count:return []
|
||||
plan_signals=[]
|
||||
for point in plan_points or []:
|
||||
if isinstance(point,dict):
|
||||
plan_signals.extend(_visual_product_text(point.get('slogan') or point.get('consumerValue')))
|
||||
result=[]
|
||||
used_sources=set()
|
||||
for index in range(count):
|
||||
task=tasks[index]
|
||||
eid=image_ids[index] if index<len(image_ids) else ''
|
||||
src=None
|
||||
if eid:
|
||||
src=next((x for x in source if x.get('imageEvidenceId')==eid or x.get('representativeImageId')==eid),None)
|
||||
if src is None:
|
||||
src=next((x for i,x in enumerate(source) if i not in used_sources),None)
|
||||
if src is not None:used_sources.add(source.index(src))
|
||||
refs=_visual_refs(src,valid_ids) if src else []
|
||||
if eid in valid_ids and eid not in refs:refs.append(eid)
|
||||
if not refs:continue
|
||||
signals=_visual_product_text(src.get('visualSignals') if src else [],18,blocked_terms)
|
||||
signals=[x for x in signals if _visual_signal_matches(x,task['keywords'])]
|
||||
if not signals and src:
|
||||
signals=_visual_product_text(src.get('message') or src.get('observation') or src.get('businessMeaning'),18,blocked_terms)
|
||||
signals=[x for x in signals if _visual_signal_matches(x,task['keywords'])]
|
||||
fact_signals=_visual_fact_signals(raw,task['keywords'],2,blocked_terms)
|
||||
task_plan_signals=[x for x in plan_signals if _visual_signal_matches(x,task['keywords'])]
|
||||
signals=_uniq(signals+fact_signals+task_plan_signals)[:4]
|
||||
if not signals:signals=list(task['signals'])
|
||||
result.append({
|
||||
'contentKey':task['contentKey'],'imageEvidenceId':eid or next((x for x in refs if x.startswith('IMG_')),refs[0]),
|
||||
'imageRole':task['role'],'visualSignals':signals[:4],
|
||||
'businessMeaning':f"本图只承担{task['role']}信息,不与其他图片重复",
|
||||
'nextAction':task['nextAction'],'evidenceIds':_uniq(refs)
|
||||
})
|
||||
return result
|
||||
|
||||
def _normalize_visual_continuity(exp, raw, valid_ids):
|
||||
"""Normalize main/detail responsibilities after model output and build the page handoff."""
|
||||
exp=dict(exp or {})
|
||||
plans=((exp.get('newProductPlans') or {}).get('plans') or [])
|
||||
plan_points=plans[0].get('sellingPoints') if plans and isinstance(plans[0],dict) else []
|
||||
blocked_terms=_visual_identity_terms(raw)
|
||||
visual=exp.get('visualCommerce') or {}
|
||||
detail=exp.get('detailCommerce') or {}
|
||||
main=_canonical_visual_items(raw,visual.get('items'),MAIN_VISUAL_TASKS,valid_ids,plan_points,blocked_terms)
|
||||
groups=_canonical_visual_items(raw,detail.get('contentGroups'),DETAIL_VISUAL_TASKS,valid_ids,plan_points,blocked_terms)
|
||||
visual['items']=main
|
||||
visual['professionalOpinion']='主图先让用户看懂商品、场景和选择入口,每张图只承担一个购买确认任务。'
|
||||
detail['contentGroups']=[{
|
||||
'contentKey':item.get('contentKey'),'type':item.get('role'),'representativeImageId':item.get('imageEvidenceId'),
|
||||
'visualSignals':item.get('visualSignals'),'businessMeaning':item.get('businessMeaning'),
|
||||
'nextAction':item.get('nextAction'),'evidenceIds':item.get('evidenceIds')
|
||||
} for item in groups]
|
||||
detail['professionalOpinion']='详情按场景、材质、结构、功能、规格和维护顺序连续证明产品,不重复主图已经讲清的内容。'
|
||||
detail['pageSequence']=[{
|
||||
'order':index+1,'contentKey':item.get('contentKey'),'mainImageRole':main[min(index,len(main)-1)].get('imageRole') if main else '主图信息',
|
||||
'detailRole':item.get('role'),'handoff':f"主图先提出{item.get('role')},详情继续给出可核对证明"
|
||||
} for index,item in enumerate(DETAIL_VISUAL_TASKS[:len(groups)])]
|
||||
claims=[]
|
||||
for index,item in enumerate(DETAIL_VISUAL_TASKS[:min(len(main),len(groups))]):
|
||||
main_item=main[index];detail_item=groups[index]
|
||||
claims.append({
|
||||
'claim':item['role'],'mainStatus':'expressed','detailStatus':'proven',
|
||||
'reviewStatus':'partial','nextAction':item['nextAction'],
|
||||
'evidenceIds':_uniq((main_item.get('evidenceIds') or [])+(detail_item.get('evidenceIds') or []))
|
||||
})
|
||||
exp['visualCommerce']=visual;exp['detailCommerce']=detail
|
||||
if claims:exp['expressionContinuity']={'coreClaims':claims}
|
||||
for plan in plans:
|
||||
if not isinstance(plan,dict):continue
|
||||
page_action=_visual_product_text(plan.get('pageAction'),18,blocked_terms)
|
||||
if not page_action or any(term in _txt(plan.get('pageAction')) for term in VISUAL_EXCLUDED_TERMS):
|
||||
plan['pageAction']='主图展示商品全貌与场景,详情依次证明材质、结构、功能和规格'
|
||||
else:
|
||||
plan['pageAction']=';'.join(page_action[:2])
|
||||
points=plan.get('sellingPoints')
|
||||
if isinstance(points,list):
|
||||
plan['sellingPoints']=[p for p in points if isinstance(p,dict) and _visual_product_text(p.get('slogan') or p.get('consumerValue'),18,blocked_terms)]
|
||||
return exp
|
||||
|
||||
def _evidence_backed_solution(exp, valid_ids):
|
||||
exp=dict(exp or {})
|
||||
owner=exp.get('ownerOverview') or {};owner['cards']=_filter_evidence_items(owner.get('cards'),valid_ids);exp['ownerOverview']=owner
|
||||
visual=exp.get('visualCommerce') or {};visual['items']=_filter_evidence_items(visual.get('items'),valid_ids,True);exp['visualCommerce']=visual
|
||||
detail=exp.get('detailCommerce') or {}
|
||||
groups=[]
|
||||
for item in detail.get('contentGroups') or []:
|
||||
if not isinstance(item,dict):continue
|
||||
clean=dict(item);refs=_visual_refs(item,valid_ids)
|
||||
if refs:
|
||||
clean['evidenceIds']=refs;groups.append(clean)
|
||||
detail['contentGroups']=groups;exp['detailCommerce']=detail
|
||||
customer=exp.get('customerExperience') or {};customer['stages']=_filter_evidence_items(customer.get('stages'),valid_ids)
|
||||
signals=customer.get('signals') or {}
|
||||
for key in ('positive','questions','risks'):signals[key]=_filter_evidence_items(signals.get(key),valid_ids)
|
||||
@@ -1021,6 +1270,13 @@ def ensure_experience_solution(analysis):
|
||||
exp['newProductPlans']['professionalOpinion']=(analysis.get('launchPlans') or {}).get('professionalOpinion')
|
||||
exp=_separate_qa_signals(exp)
|
||||
exp=_sanitize_report_language(_apply_selling_points(exp,raw,analysis['baseline']))
|
||||
exp=_ensure_three_product_plans(exp,raw,analysis['baseline'])
|
||||
# 核心工程模块缺失时,先使用同一批真实证据确定性补齐;不得带着失败审核输出正式报告。
|
||||
exp=repair_core_solution_with_evidence(exp,raw,analysis['baseline'])
|
||||
exp=_evidence_backed_solution(exp,valid_ids)
|
||||
# 最后一轮把主图与详情收敛为连续的信息任务,避免模型把同一卖点复制到多张图。
|
||||
exp=_normalize_visual_continuity(exp,raw,valid_ids)
|
||||
exp=_evidence_backed_solution(exp,valid_ids)
|
||||
analysis['experienceSolution']=exp
|
||||
analysis['meta'].pop('fallbackReport',None)
|
||||
analysis['meta']['fallbackReport']=False
|
||||
@@ -1031,11 +1287,11 @@ def ensure_experience_solution(analysis):
|
||||
validation=core_solution_diagnostics(exp)
|
||||
analysis['meta']['reportValidation']=validation
|
||||
if not validation['ok']:
|
||||
# 审核是质量标记,不再阻断报告输出;缺失项在报告中显式提示,供人工复核。
|
||||
analysis['meta']['reportReady']=True
|
||||
analysis['meta']['reportReady']=False
|
||||
analysis['meta']['reportAuditPassed']=False
|
||||
analysis['meta']['reportQuality']='needs_review'
|
||||
analysis['meta']['reportNotice']='审核未通过,报告仍已生成。请优先补齐:'+', '.join(validation['missing'])
|
||||
analysis['meta']['reportQuality']='blocked'
|
||||
analysis['meta']['reportBlockedReason']='报告审核未通过,已停止生成。缺失:'+', '.join(validation['missing'])
|
||||
analysis['meta'].pop('reportNotice',None)
|
||||
return analysis
|
||||
analysis['meta']['reportReady']=True
|
||||
analysis['meta']['reportAuditPassed']=True
|
||||
@@ -1262,9 +1518,11 @@ def analyze(raw, progress=lambda *a:None):
|
||||
main_imgs=raw.get('images',{}).get('main',[])[:8]
|
||||
if use_ai and main_imgs:
|
||||
prompt='''任务B|主图商业信息提炼。目标不是解释图片,而是让专业用户一眼看到“这张图在卖什么”。逐张提炼图片中可核验的商品信息标签,再给一个短动作。
|
||||
同时逐图记录可见展示空间关系,放入evidenceDetail.displayRelations:{"visibleParts":[{"id":"局部1","description":"可见区域,不把折叠层误认为独立件数"}],"relations":[{"type":"连接|接触|覆盖|遮挡|方向|相对位置|形变","from":"局部1","to":"局部2","observation":"看得见的关系"}],"view":"观察视角","uncertainAreas":["不可见且不能确认的部分"]}。只记录实际图中的关系;折叠、悬挂、手持、铺展等共用此描述,不套固定状态模板,不猜隐藏结构、件数或操作过程。产品固有组成与摆放形成的折边、接触、覆盖必须区分;详细关系不适用页面标签字数限制。
|
||||
输出:
|
||||
{"visualThesis":{"professionalOpinion":"<=45字","evidenceIds":[]},"coreClaims":[],"imageRoles":[{"imageEvidenceId":"IMG_MAIN_0001","imageRole":"<=8字","visualSignals":["2-3个,每项<=10字"],"businessMeaning":"<=22字","nextAction":"<=18字","evidenceIds":[],"confidence":0}],"evidenceDetail":[{"imageEvidenceId":"IMG_MAIN_0001","composition":"","textInfo":"","productSubject":"","patternColor":"","evidenceIds":[]}]}
|
||||
visualSignals必须来自图片可见文字、商品、场景或已确认参数,例如“40支新疆棉/六件套/免费仓储”,不能写抽象评价。
|
||||
{"visualThesis":{"professionalOpinion":"<=45字","evidenceIds":[]},"coreClaims":[],"imageRoles":[{"imageEvidenceId":"IMG_MAIN_0001","imageRole":"<=8字","visualSignals":["2-3个,每项<=10字"],"businessMeaning":"<=22字","nextAction":"<=18字","contentKey":"product_overview|usage_scene|material_touch|structure_function|spec_choice","evidenceIds":[],"confidence":0}],"evidenceDetail":[{"imageEvidenceId":"IMG_MAIN_0001","composition":"","textInfo":"","productSubject":"","patternColor":"","evidenceIds":[]}]}
|
||||
visualSignals必须来自图片可见文字、商品、场景或已确认参数,例如“40支全棉/六件套/90×190cm”,不能写抽象评价。
|
||||
主图按商品全貌、使用场景、材质触感、结构功能、规格选择分工;每张图只承担一个主任务,不把品牌、专利、授权、价格、销量、物流或服务当作视觉卖点。
|
||||
'''
|
||||
z,e=call(prompt,{'evidence':compact_evidence(ledger,{'product','sales','image','attribute'},220)},6800,main_imgs)
|
||||
if e:out['modelErrors']['visual']=e
|
||||
@@ -1288,8 +1546,10 @@ visualSignals必须来自图片可见文字、商品、场景或已确认参数
|
||||
detail_imgs=raw.get('images',{}).get('detail',[])[:20]
|
||||
if use_ai and detail_imgs:
|
||||
prompt='''任务D|详情页证明信息提炼。按4-6个信息任务组选代表图,不按图片流水账。每组只提炼2-4个图片信息标签和1条专业动作。
|
||||
对实际输入图片在evidenceDetail中按imageEvidenceId记录composition与displayRelations:{"visibleParts":[{"id":"局部1","description":"可见区域"}],"relations":[{"type":"连接|接触|覆盖|遮挡|方向|相对位置|形变","from":"局部1","to":"局部2","observation":"实际可见关系"}],"view":"观察视角","uncertainAreas":["不能确认的部分"]}。区分产品固有结构与展示空间关系,不将可见层数当产品件数,不猜隐藏连接或操作过程;详细观察不按标签长度压缩,保持证据ID对应原图。
|
||||
输出:
|
||||
{"detailThesis":{"professionalOpinion":"<=45字","evidenceIds":[]},"contentGroups":[{"type":"品牌服务|套件组成|材质品质|产品功能|使用便利|花型选择|用户验证|其他","representativeImageId":"IMG_DETAIL_0001","visualSignals":["2-3项,每项<=10字"],"businessMeaning":"<=22字","nextAction":"<=18字","imageEvidenceIds":[],"evidenceIds":[]}],"continuityChecks":[{"claim":"<=24字","mainImageEvidenceIds":[],"detailEvidenceIds":[],"status":"closed_loop|detail_missing|main_missing|conflict|review_pending","nextAction":"<=24字","evidenceIds":[]}],"pageSequence":[],"evidenceDetail":[]}
|
||||
{"detailThesis":{"professionalOpinion":"<=45字","evidenceIds":[]},"contentGroups":[{"type":"场景证明|材质证明|结构工艺|功能表现|规格适配|使用维护","representativeImageId":"IMG_DETAIL_0001","visualSignals":["2-3项,每项<=10字"],"businessMeaning":"<=22字","nextAction":"<=18字","contentKey":"scene_problem|material_proof|structure_proof|function_proof|spec_adaptation|care_durability","imageEvidenceIds":[],"evidenceIds":[]}],"continuityChecks":[{"claim":"<=24字","mainImageEvidenceIds":[],"detailEvidenceIds":[],"status":"closed_loop|detail_missing|main_missing|conflict|review_pending","nextAction":"<=24字","evidenceIds":[]}],"pageSequence":[],"evidenceDetail":[]}
|
||||
详情顺序必须接续主图:场景证明 → 材质证明 → 结构工艺 → 功能表现 → 规格适配 → 使用维护;每组只负责一个购买疑问,不重复其他组。不要输出品牌、专利、授权、认证、奖项、包邮、物流、价格、销量、促销、客服或售后等唯一性/交易信息。
|
||||
'''
|
||||
z,e=call(prompt,{'visualDecision':out.get('visualDecision',{}),'evidence':compact_evidence(ledger,{'product','attribute','promotion','image'},360)},8200,detail_imgs)
|
||||
if e:out['modelErrors']['detail']=e
|
||||
@@ -1330,14 +1590,14 @@ visualSignals必须来自图片可见文字、商品、场景或已确认参数
|
||||
progress('plans','processing',95)
|
||||
if use_ai:
|
||||
context={'reportPlan':out.get('reportPlan',{}),'commercialDecision':out.get('commercialDecision',{}),'consumerResearch':out.get('consumerResearch',{}),'questionResearch':out.get('questionResearch',{}),'fashionDecision':out.get('fashionDecision',{}),'competitionDecision':out.get('competitionDecision',{}),'productDecision':out.get('productDecision',{}),'visualDecision':out.get('visualDecision',{}),'merchandisingDecision':out.get('merchandisingDecision',{}),'detailDecision':out.get('detailDecision',{}),'baseline':base}
|
||||
prompt='''任务F|下一款方案。面向老板先做决策卡,再让专业用户展开细节。每套默认只保留:来源等级、为什么做、产品动作、页面动作、验证项。
|
||||
至少1套,证据充分2-3套。
|
||||
prompt='''任务F|下一款产品开品方案。必须输出3套不同的产品新方案;每套只保留四个核心字段:来源等级、名称、产品动作、页面动作。产品动作是核心,必须具体说明下一款产品本身怎么改;页面动作必须说明该方案自己的完整主图证据链与详情图证据链如何承接。不要扩展字段。产品参考图由生成环节处理,不要求模型输出。
|
||||
必须正好3套,顺序固定为:证据驱动优化、反馈驱动升级、探索性方向。每套名称必须是简洁的“XX款”,不能直接复用商品长标题;三个方案不得只是换名字,产品结构、材质、规格、工艺、配色或使用体验上必须有可辨认差异。
|
||||
来源等级:
|
||||
- 证据驱动优化:直接来自当前商品页面/参数/评论;
|
||||
- 反馈驱动升级:直接来自消费者体验差异/购前关注;
|
||||
- 探索性方向:设计创新或扩展想法,必须明确标记,不能当作已验证结论。
|
||||
- 探索性方向:设计创新或扩展想法,必须明确标记为待小样验证,不能当作已验证结论。
|
||||
输出:
|
||||
{"professionalOpinion":"<=45字","plans":[{"sourceType":"证据驱动优化|反馈驱动升级|探索性方向","type":"现有基础优化|强化体验区分|强化设计选择|其他","name":"<=18字","whyThisPlan":"<=32字","visualReferenceImageIds":[],"productAction":"<=28字","pageAction":"<=28字","validation":"<=20字","evidenceIds":[],"confidence":0}],"nextValidation":[]}
|
||||
{"professionalOpinion":"<=45字","plans":[{"sourceType":"证据驱动优化|反馈驱动升级|探索性方向","name":"<=18字且以款结尾","productAction":"<=45字,明确结构/材质/规格/工艺/配色/包装中实际要改的内容","pageAction":"<=36字,说明该方案完整主图与详情证据链如何表达产品改进"}]}
|
||||
'''
|
||||
z,e=call(prompt,context,9800)
|
||||
if e:out['modelErrors']['plans']=e
|
||||
|
||||
@@ -2,11 +2,26 @@
|
||||
"api_base": "https://api.bananarouter.com/v1",
|
||||
"api_key": "",
|
||||
"model": "gpt-5.5",
|
||||
"image_api_base": "",
|
||||
"image_api_key": "",
|
||||
"image_model": "",
|
||||
"image_models": [
|
||||
{
|
||||
"id": "gpt-image-2.5-flare",
|
||||
"label": "GPT Image 2.5 Flare",
|
||||
"quality": "medium"
|
||||
}
|
||||
],
|
||||
"image_path": "/images/generations",
|
||||
"image_models_path": "/models",
|
||||
"image_size": "1024x1024",
|
||||
"image_quality": "",
|
||||
"chat_path": "/chat/completions",
|
||||
"models_path": "/models",
|
||||
"temperature": 0.2,
|
||||
"timeout": 120,
|
||||
"retries": 2,
|
||||
"image_concurrency": 5,
|
||||
"ssl_verify": true,
|
||||
"ca_bundle": "",
|
||||
"extra_headers": {}
|
||||
|
||||
@@ -17,6 +17,8 @@ PRODUCT_VARIABLES = {
|
||||
"evidence": "统一证据账本;模型只能引用其中的 evidenceId",
|
||||
"roleOutputs": "七角色中间结论:商品策略、电商转化、消费者洞察、产品/供应链、视觉设计、老板决策与总编审核;只能交叉校验和编排,不得当作无证据事实",
|
||||
"renderingPolicy": "最终可见性规则:只展示真实非空内容;证据以来源类型和内容摘要呈现,evidenceId仅用于追溯",
|
||||
"prompt": "系统根据真实商品事实、具体开品方案、套图槽位和平台约束生成的完整生图提示词",
|
||||
"matchReferenceShooting": "是否锁定参考图的拍摄语言与产品展示状态(具体动作、姿态、朝向、展开/折叠、摆放和物体关系),仅影响视觉表达,不改变未经用户确认的商品事实",
|
||||
}
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -34,6 +36,7 @@ class PromptTemplate:
|
||||
TEMPLATES = {
|
||||
"system": PromptTemplate("system", "{system}\n\n可用商品变量说明:\n{variable_schema}", ("system", "variable_schema")),
|
||||
"task": PromptTemplate("task", "{task}\n\n输入变量:{input_variables}", ("task", "input_variables")),
|
||||
"image_generation": PromptTemplate("image_generation", "{prompt}", ("prompt",)),
|
||||
}
|
||||
|
||||
def register_template(name, body, variables=()):
|
||||
@@ -61,3 +64,147 @@ def build_system_prompt(system_text):
|
||||
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)
|
||||
|
||||
def build_image_generation_prompt(prompt_text):
|
||||
"""Apply the operator-configured image prompt wrapper to the real generation prompt."""
|
||||
body=TEMPLATES["image_generation"].body
|
||||
override=Path(__file__).with_name('prompts.json')
|
||||
if override.exists():
|
||||
try:
|
||||
data=json.loads(override.read_text('utf-8'))
|
||||
configured_body=(data.get('image_generation') or {}).get('body') if isinstance(data,dict) else None
|
||||
if configured_body: body=str(configured_body)
|
||||
except Exception: pass
|
||||
if '{prompt}' not in body:
|
||||
raise ValueError('生图提示词模板必须包含 {prompt},否则无法与真实商品方案联动')
|
||||
return body.replace('{prompt}', str(prompt_text or ''))
|
||||
|
||||
|
||||
def build_display_relationship_guidance(analysis, enabled=False):
|
||||
"""One category-independent relationship contract for both prompt paths."""
|
||||
if not enabled:return ''
|
||||
analysis=analysis if isinstance(analysis,dict) else {}
|
||||
relations=analysis.get('displayRelations') or (analysis.get('imageObservation') or {}).get('displayRelations') or {}
|
||||
return ('【可见展示空间关系|对应原图约束】'+json.dumps(relations,ensure_ascii=False)
|
||||
+'。使用实际对应参考图核对可见局部之间的连续连接、接触、覆盖顺序、遮挡、方向、相对位置和形变。'
|
||||
'记录缺失时直接观察实际参考图,不能只按状态名称生成。保留未被用户修改的这些关系,不要求照搬参考商品固有材质或制造结构。'
|
||||
'产品组成、缝制和配件属于固有身份;折回、垂落、抓握和摆放形成的连接/覆盖属于展示关系,两者不能混淆。'
|
||||
'连续形变不能重构成独立物体叠放,可见层数不等于商品件数;不能根据模板补造隐藏局部或操作过程。'
|
||||
'按参考可见关系迁移当前产品,仅允许用户明确授权及实现要求不可避免的最小调整。'
|
||||
' English constraints: Preserve the visible connectivity, contact, overlap order, occlusion, orientation and relative placement from the corresponding reference. Distinguish intrinsic product construction from display-induced deformation. Do not replace a continuous folded surface with separate stacked objects or infer item count from visible layers. Inspect the actual reference when recorded observations are incomplete; never invent hidden geometry.')
|
||||
|
||||
|
||||
def build_reference_shooting_guidance(
|
||||
enabled=False,
|
||||
asset_type='main',
|
||||
reference_mode='collected_reference',
|
||||
business_goal='',
|
||||
product_goal='',
|
||||
slot_index=None,
|
||||
):
|
||||
"""Return the explicit reference-shooting policy for an image prompt.
|
||||
|
||||
``matchReferenceShooting`` is deliberately a visual-policy switch rather
|
||||
than an identity switch. When enabled, the provider is asked to inspect
|
||||
the collected slot image for shooting grammar and display state, while a
|
||||
separately uploaded image (when present) supplies only product identity.
|
||||
When disabled, the reference remains an identity/structure anchor and the
|
||||
slot's own e-commerce composition is authoritative. Keeping this policy
|
||||
in the shared template makes preview and execution produce identical
|
||||
instructions and gives downstream providers a deterministic contract.
|
||||
"""
|
||||
kind = '主图' if str(asset_type or '').lower() == 'main' else '详情图'
|
||||
is_first_main = str(asset_type or '').lower() == 'main' and str(slot_index or '') == '1'
|
||||
source_labels = {
|
||||
'uploaded_reference': '用户上传参考图',
|
||||
'uploaded_identity_collected_reference': '用户上传图(商品身份)+ 采集商品对应槽位图(展示状态)',
|
||||
'fission_base': '采集商品主图(裂变基准图)',
|
||||
'fission_followup': '上一张已生成基准图',
|
||||
'collected_reference': '采集到的商品主图',
|
||||
}
|
||||
source = source_labels.get(str(reference_mode or ''), '随请求提供的参考图')
|
||||
uploaded = str(reference_mode or '') in ('uploaded_reference', 'uploaded_identity_collected_reference')
|
||||
hybrid = str(reference_mode or '') == 'uploaded_identity_collected_reference'
|
||||
business = str(business_goal or '').strip() or (
|
||||
'让用户快速识别商品并理解本图承担的购买确认任务'
|
||||
)
|
||||
product = str(product_goal or '').strip() or (
|
||||
'保持用户最终确认的商品品类、结构、材质、颜色、件数和规格'
|
||||
)
|
||||
if hybrid:
|
||||
first_main_rule = (
|
||||
'第1张用户上传产品图是唯一商品身份母版,第2张采集商品对应槽位图是拍摄与展示状态母版;'
|
||||
'首张主图(main:1)也必须按这两个职责分别读取,不能把产品图的机位、构图或摆放状态带入结果。'
|
||||
'采集参考图中的商品颜色、花型、材质、纹理、品牌、结构和件数不是当前商品事实,全部不得覆盖第1张产品图。'
|
||||
'只有“产品目标”或用户最终设定明确列出的字段可以改变,其他字段冻结。'
|
||||
)
|
||||
elif uploaded and is_first_main:
|
||||
first_main_rule = (
|
||||
'首张主图(main:1)必须使用第一张参考图作为商品外观母版;第一张用户上传产品图是唯一商品身份母版,整套主图与详情图都必须继承它的轮廓、比例、件数、颜色/花型、材质纹理、结构和配件位置。'
|
||||
'采集商品的标题、参数、图片、品牌、颜色、材质和视觉分析不属于当前商品身份,不能改变上传产品图。'
|
||||
'只有“产品目标”或用户最终设定明确列出的字段可以改变,其他字段冻结。'
|
||||
)
|
||||
elif uploaded:
|
||||
first_main_rule = (
|
||||
'当前槽位继续继承同一产品身份;第一张用户上传产品图仍是唯一商品身份母版。'
|
||||
'采集商品的标题、参数、图片、品牌、颜色、材质和视觉分析不属于当前商品身份,不能改变上传产品图。'
|
||||
'只有用户最终设定明确列出的字段可以改变,其他字段冻结。'
|
||||
)
|
||||
elif is_first_main:
|
||||
first_main_rule = (
|
||||
'首张主图(main:1)优先使用第一张参考图作为商品外观母版;视觉上严格保持轮廓、比例、件数、颜色/花型、材质纹理、结构和配件位置。'
|
||||
'只有“产品目标”或用户最终设定明确列出的字段可以改变,其他字段冻结。'
|
||||
)
|
||||
else:
|
||||
first_main_rule = '当前槽位继续继承同一产品身份;只有“产品目标”或用户最终设定明确列出的字段可以改变,其他字段冻结。'
|
||||
reference_role_rule = (
|
||||
'【双参考图职责分离】第1张输入图是用户上传产品图,只允许提取商品身份、轮廓、比例、材质、颜色/花型、结构、件数和配件;'
|
||||
'第2张输入图是采集商品当前槽位图,提供表达主题、文字版式以及机位、视角、景别、透视、构图、背景、光线、具体动作、朝向、展开/折叠、摆放、支撑/接触点、遮挡和部件关系;其原文参数不能当作当前产品事实。'
|
||||
'结果必须使用第1张图的商品放入第2张图的展示状态;第2张图中的商品外观、颜色、花型、材质和结构不得覆盖第1张图。不得交换两张图的职责。'
|
||||
if hybrid else
|
||||
'【单一产品身份参考】第1张输入图是用户上传产品图,也是整套图片唯一的商品身份来源;只允许提取商品品类、轮廓、比例、材质、颜色/花型、结构、件数和配件。采集商品的文字、参数和图片只用于业务目标与证据任务,不得改变当前商品外观。'
|
||||
if uploaded else
|
||||
'【参考图职责】当前参考图用于确定商品身份与本策略指定的视觉表达;不得把未授权的其他商品、品牌或文字带入结果。'
|
||||
)
|
||||
reference_role_rule_en = (
|
||||
'【Two-reference role split】Input image 1 is the uploaded product image and is the only source for product identity, silhouette, proportions, material, color/pattern, construction, quantity and accessories. Input image 2 is the collected slot image and may provide only camera/view/display state. Place the product from image 1 into the display state of image 2; image 2 product appearance, color, pattern, material and construction must never override image 1. Never swap these roles.'
|
||||
if hybrid else
|
||||
'Input image 1 is the only product identity reference for the entire image set. Use it for the product category, silhouette, proportions, material, color/pattern, construction, quantity and accessories. Collected product text, parameters and images are business context only and must never change the uploaded product appearance.'
|
||||
if uploaded else
|
||||
'Use the current reference only for the product identity and the visual policy stated here; never import another product, brand or text.'
|
||||
)
|
||||
copy_policy = (
|
||||
'【参考图文案跟随规则】必须依据当前产品事实和对应图片分析任务生成简短中文文案并渲染到图中。文字识别缺失不代表参考图无字,必须查看实际参考图。参考图有文字时严格保持文字相对位置、大小占比、对齐、标题层级、换行、行距、留白和阅读顺序,仅替换为当前产品对应文案。不得照抄品牌、价格、促销、认证或虚构产品参数,不得默认留白取消文案。'
|
||||
)
|
||||
copy_policy_en = (
|
||||
'Render short Chinese copy derived from the current product and the corresponding slot analysis. Missing OCR is not evidence that the reference is text-free. Inspect the actual collected slot reference. Preserve its text positions, relative font sizes, alignment, title hierarchy, line breaks, line spacing and margins when text is present. Never copy brands or unsupported claims. Display state and text layout take precedence over default creative styling.'
|
||||
)
|
||||
if enabled:
|
||||
return f'''【参考图拍摄与产品展示状态一致|已勾选|matchReferenceShooting=true】
|
||||
策略定义:锁定对应参考图的拍摄语言、产品展示状态、表达主题和文字版式;替换为当前产品及基于当前产品分析的文案,不照抄参考商品原文。拍摄与摆放母版:{source};本策略适用于当前{kind}。
|
||||
【防止商品串图|最高优先级】任何采集参考图都不是当前商品外观参考。严禁复制采集图中的颜色、花型、材质、纹理、轮廓、结构、件数、配件、包装或品牌;当前商品只能来自第1张上传产品图和用户最终明确设定。若视觉状态与商品外观发生冲突,保留第1张产品图外观,放弃采集图外观。
|
||||
{reference_role_rule}
|
||||
优先级(高→低):平台合规与安全 > 当前产品身份与用户明确商品设定 > 用户明确表达要求 > 对应参考图的产品展示状态与文案排版 > 产品方案与业务任务 > 默认构图与风格。产品不能擅自改变;用户明确表达要求覆盖参考对应项,未涉及的参考约束保持有效。
|
||||
业务目标(不可丢失):{business}。
|
||||
产品目标(不可丢失):{product}。
|
||||
拍摄语言必须尽量一致:机位与视角、景别与主体占比、镜头透视/焦段观感、构图与裁切、背景/场景层级、光线方向与软硬度、色温与阴影、道具、视觉重心和留白节奏。
|
||||
产品展示状态必须具体复现:先识别商品是平铺、悬挂、折叠、展开、堆叠、卷起、铺设、穿着、手持、使用中还是局部掀开;再保持正反面与朝向、旋转/倾斜角度、展开或折叠程度、弯曲/垂落/褶皱状态、落点与画面坐标、接触面和支撑点、遮挡关系、部件相对位置、与道具/家具/人体的距离和前后层级。
|
||||
动作状态必须具体复现:若有人手或模特,保持谁在用什么部位、抓握/按压/提拉/铺开/穿戴等动作、手指或身体接触点、发力方向、动作阶段和姿态;不得把“正在动作”改成静态陈列,也不得把静态陈列擅自改成动作场景。先逐项分析实际参考图,再迁移到当前商品。
|
||||
商品保真:{first_main_rule}
|
||||
适配规则:在对应参考图既有展示状态与文字版式中表达当前产品的可核验信息;不引入其他商品、品牌Logo或未证实卖点。先锁定产品摆放、动作、朝向、主体占比和部件关系,再精简文案适配原有文字区域,不能为文案重新构图或移动商品。
|
||||
{reference_role_rule_en}
|
||||
{copy_policy}
|
||||
{copy_policy_en}
|
||||
【English constraints】Input image 1 supplies current product identity only. Input image 2 supplies the corresponding expression theme, shooting grammar, exact display/action state and text layout. Preserve its camera angle, shot scale, perspective, framing, background, lighting, shadows, props, negative space, orientation, fold/unfold amount, drape, placement, support/contact points, occlusion, part relationships, human touch points and action phase. Preserve the current product appearance from image 1. Render new Chinese copy supported by current product facts in the corresponding text positions, sizes, alignment, hierarchy and line spacing of image 2. Shorten copy to fit; never move or resize the product or change its display state to make room for text. Never copy another product identity, logo, original wording, watermark or unsupported claim.
|
||||
'''
|
||||
return f'''【参考图拍摄与产品展示状态一致|未勾选|matchReferenceShooting=false】
|
||||
策略定义:不继承参考图的拍摄语言或产品展示状态。参考来源:{source};参考图只用于核对当前商品身份、结构、材质、颜色和比例。
|
||||
优先级(高→低):平台合规与安全 > 用户明确商品设定与统一 identityLock > 产品目标/方案允许的明确变化 > 当前槽位业务目标与证据任务 > 适合本图的电商拍摄方案。不要复制其机位、景别、镜头透视、构图、背景、光线、道具、姿态、具体动作、朝向、展开/折叠、摆放位置、支撑/接触点、遮挡或部件关系;不得把参考图的视觉语言或展示状态当成默认要求。
|
||||
业务目标(不可丢失):{business}。
|
||||
产品目标(不可丢失):{product}。
|
||||
商品保真:{first_main_rule}
|
||||
{reference_role_rule_en}
|
||||
{copy_policy}
|
||||
{copy_policy_en}
|
||||
【English constraints】Use the reference only as a product-identity anchor. Do not copy its camera angle, shot scale, perspective, framing, background, lighting, props, display/action state, orientation, folding, placement, support/contact points, occlusion or part relationships. Design a clear ecommerce shot for the current business goal while preserving the current product identity and approved product changes.
|
||||
'''
|
||||
|
||||
@@ -348,8 +348,16 @@ def visual_status_html(images, verified_visual):
|
||||
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)
|
||||
if not isinstance(plan, dict):
|
||||
continue
|
||||
selling_points = arr(plan.get('sellingPoints'))
|
||||
first_point = selling_points[0] if selling_points else None
|
||||
if isinstance(first_point, dict):
|
||||
point = concise(first_point.get('slogan'), 42, False)
|
||||
elif isinstance(first_point, list) and first_point:
|
||||
nested_first = first_point[0]
|
||||
if isinstance(nested_first, dict):
|
||||
point = concise(nested_first.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>'
|
||||
|
||||
@@ -673,4 +681,4 @@ def render(a, task_id):
|
||||
raw=a.get('facts') or {}; product=raw.get('product') or {}; exp=a.get('experienceSolution') or {}; summary=exp.get('reportSummary') or {}
|
||||
title=clean_display(summary.get('title') or product.get('title') or '商品分析报告',60)
|
||||
payload=json.dumps(a,ensure_ascii=False,separators=(',',':')).replace('</','<\\/').replace('\u2028','\\u2028').replace('\u2029','\\u2029')
|
||||
return '<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="generator" content="Sansong Product Intelligence V9.3.0"><title>'+H(title)+'</title><link rel="stylesheet" href="/report.css"></head><body><main id="app" class="report-app"></main><script>window.REPORT_DATA='+payload+';</script><script src="/report.js"></script></body></html>'
|
||||
return '<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="generator" content="Sansong Product Intelligence V9.3.0"><title>'+H(title)+'</title><link rel="stylesheet" href="/report.css?v=20260911-modal-spec4"></head><body><main id="app" class="report-app"></main><script>window.REPORT_DATA='+payload+';</script><script src="/report.js?v=20260911-modal-spec4"></script></body></html>'
|
||||
|
||||
@@ -1,5 +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>
|
||||
<title>商品开品分析报告</title><link rel="stylesheet" href="/report.css?v=20260914-modal-spec5"></head>
|
||||
<body><main id="app" class="report-app"><p>正在加载报告数据…</p></main>
|
||||
<script src="/report.js"></script></body></html>
|
||||
<script src="/report.js?v=20260914-modal-spec5"></script></body></html>
|
||||
|
||||
@@ -3,6 +3,27 @@ const esc = value => String(value ?? '').replace(/[&<>"']/g, char => ({'&':'&
|
||||
const arr = value => Array.isArray(value) ? value : [];
|
||||
const compact = value => arr(value).map(item => typeof item === 'object' ? (item.value || item.topic || item.name || item.label || item.action || item.insight || '') : item).filter(Boolean).join(' · ');
|
||||
const percent = value => `${(Number(value || 0) * 100).toFixed(1).replace('.0','')}%`;
|
||||
const modelLabel = item => item?.modelLabel || item?.model || '默认生图模型';
|
||||
const modelQuality = item => item?.quality || '服务商默认';
|
||||
const IMAGE_QUALITY_OPTIONS = ['auto','low','medium','high','xhigh','max'];
|
||||
const resultSortKey = item => {
|
||||
const type = item?.assetType === 'main' ? 0 : 1;
|
||||
const index = Number(item?.slotIndex || 0);
|
||||
return [type, index, String(item?.modelLabel || item?.model || ''), String(item?.url || '')];
|
||||
};
|
||||
const orderedResults = items => arr(items).slice().sort((a,b) => {
|
||||
const aa = resultSortKey(a), bb = resultSortKey(b);
|
||||
return aa[0] - bb[0] || aa[1] - bb[1] || aa[2].localeCompare(bb[2]);
|
||||
});
|
||||
async function readJsonResponse(response, endpoint='接口') {
|
||||
const body=await response.text();
|
||||
try { return JSON.parse(body); }
|
||||
catch (_) {
|
||||
const returnedHtml=/^\s*</.test(body);
|
||||
const detail=returnedHtml?'返回了网页内容':'返回内容不是有效 JSON';
|
||||
throw new Error(`${endpoint} ${detail}(HTTP ${response.status})。请关闭旧版服务,并从 http://127.0.0.1:17962 重新打开当前报告。`);
|
||||
}
|
||||
}
|
||||
let evidenceIndex = new Map();
|
||||
const EMPTY_COPY = /^(暂无|无|未采集|未评级|尚未结构化|图片证据未采集|评论原文未采集|日期未采集)$/;
|
||||
function hasContent(value) {
|
||||
@@ -37,6 +58,19 @@ const FIELD_LABELS = {
|
||||
fashionRisks:'视觉风险', designAssets:'设计资产', styleSignals:'风格信号', validation:'审核结果', scope:'审核范围',
|
||||
structuredStatus:'结构化状态', status:'状态', gate:'验证门', examples:'评论原话', mentions:'提及次数', sampleRate:'样本占比'
|
||||
};
|
||||
Object.assign(FIELD_LABELS,{
|
||||
all:'全部图片',batchFindings:'批次发现',buyerShow:'买家实拍',compareDimensions:'对比维度',composition:'构图信息',concern:'购前顾虑',concerns:'购前顾虑',
|
||||
consumerThesis:'消费者结论',conversionImpact:'转化影响',coreClaims:'核心卖点',coreStory:'核心表达链',decision:'决策',decisionGates:'决策门槛',decisionQuestion:'决策问题',deemphasize:'降低强调',
|
||||
detailStatus:'详情证明状态',driver:'驱动因素',evidenceId:'证据编号',executiveDecision:'经营决策',expectationGaps:'预期差异',expected:'用户预期',finding:'分析发现',fixAction:'修正动作',
|
||||
grade:'数据等级',hypothesis:'验证假设',ifOnlyOneAction:'唯一优先动作',imageEvidenceId:'图片证据编号',imageRole:'图片任务',imageRoles:'图片任务分工',images:'图片统计',
|
||||
isProductRiskOrInfoGap:'问题性质',item:'项目',keepAction:'保留动作',knownLimitations:'已知限制',mainStatus:'主图表达状态',mechanism:'作用机制',metrics:'验收指标',module:'分析模块',
|
||||
mustFix:'必须修正',oneSentence:'一句话结论',owner:'负责人',pageFix:'页面修正',painPoints:'用户痛点',patternColor:'花型与颜色',prePurchaseConcerns:'购前关注',prePurchaseThesis:'购前判断',
|
||||
primaryQuestions:'核心问题',priority:'优先级',problem:'问题',productOpportunity:'产品机会',productSubject:'商品主体',promotionAnalysis:'促销分析',purchaseDrivers:'购买驱动',question:'用户问题',
|
||||
questionConcern:'问答关注点',questionSample:'问答样本',reason:'原因',representativeQuotes:'代表评论',requiredCompetitorSet:'所需竞品样本',reviewReality:'评论实际反馈',reviewSample:'评论样本',
|
||||
reviewStatus:'评论验证状态',role:'作用',rows:'验证步骤',satisfactionDrivers:'满意驱动',sectionPlan:'章节计划',severity:'严重程度',skuSignals:'规格反馈',stage:'验证阶段',successMeaning:'通过标准',
|
||||
text:'原文',textInfo:'图中文字',titleAnalysis:'标题分析',unansweredQuestions:'未解决问题',validatedByReviews:'评论验证结果',valuePillars:'价值支柱',verbatimQuotes:'评论原话',verdict:'最终判断',
|
||||
visualSignals:'视觉信号',visualThesis:'视觉结论',whatUserIsBuying:'用户实际购买价值',whyItBlocksPurchase:'阻碍购买原因',whyItSells:'购买理由',main:'主图',detail:'详情图',sku:'规格结构'
|
||||
});
|
||||
|
||||
const ROLE_META = [
|
||||
['productStrategy','01','商品策略','卖什么、为什么值得参考、下一款继承什么'],
|
||||
@@ -67,8 +101,14 @@ function decisionCards(items) {
|
||||
}
|
||||
function planCards(items) {
|
||||
const tone = item => String(item?.sourceType || '').includes('探索') ? 'explore' : String(item?.sourceType || '').includes('反馈') ? 'upgrade' : 'evidence';
|
||||
const themeCopy = item => {
|
||||
const value=String(item?.sourceType||item?.type||'');
|
||||
if (value.includes('反馈')) return [['升级动作',item.productAction],['体验证明',item.pageAction]];
|
||||
if (value.includes('探索')) return [['探索假设',item.productAction],['验证表达',item.pageAction]];
|
||||
return [['证据动作',item.productAction],['证据表达',item.pageAction]];
|
||||
};
|
||||
const valid=arr(items).map((item,index)=>({item,index})).filter(({item})=>hasContent(item));
|
||||
return valid.length ? `<div class="plan-grid">${valid.map(({item,index}) => {const rows=[['产品',item.productAction],['页面',item.pageAction],['验证',item.validation]].filter(([,value])=>hasContent(value));return `<article class="plan-card plan-${tone(item)}">${hasContent(item.sourceType||item.type)?`<span>${esc(item.sourceType||item.type)}</span>`:''}${hasContent(item.name)?`<h3>${esc(item.name)}</h3>`:''}${hasContent(item.whyThisPlan)?`<p>${esc(item.whyThisPlan)}</p>`:''}${rows.length?`<dl>${rows.map(([label,value])=>`<div><dt>${label}</dt><dd>${esc(value)}</dd></div>`).join('')}</dl>`:''}<button class="generate-plan-btn" type="button" data-plan-index="${index}">立即生成 <span>→</span></button></article>`}).join('')}</div>` : '';
|
||||
return valid.length ? `<div class="plan-grid">${valid.map(({item,index}) => {const rows=themeCopy(item).filter(([,value])=>hasContent(value));return `<article class="plan-card plan-${tone(item)}">${hasContent(item.sourceType||item.type)?`<span>${esc(item.sourceType||item.type)}</span>`:''}${rows.length?`<dl>${rows.map(([label,value])=>`<div><dt>${label}</dt><dd>${esc(value)}</dd></div>`).join('')}</dl>`:''}<button class="generate-plan-btn" type="button" data-plan-index="${index}">按此方向开品 <span>→</span></button></article>`}).join('')}</div>` : '';
|
||||
}
|
||||
function titleAnalysis(block) {
|
||||
const expressed = compact(block.currentExpression);
|
||||
@@ -82,6 +122,18 @@ function mainImages(data, block) {
|
||||
return `${hasContent(block.professionalOpinion)?`<p class="lead-opinion">${esc(block.professionalOpinion)}</p>`:''}${items.length?`<div class="main-analysis-grid">${items.map(item=>`<article>${figure(entries.get(item.imageEvidenceId), item.imageRole || '商品主图')}<div class="image-copy"><span>主图分析</span>${hasContent(item.imageRole)?`<h3>${esc(item.imageRole)}</h3>`:''}${hasContent(item.visualSignals)?`<b>${esc(compact(item.visualSignals))}</b>`:''}${hasContent(item.businessMeaning)?`<p>${esc(item.businessMeaning)}</p>`:''}${hasContent(item.nextAction)?`<em>${esc(item.nextAction)}</em>`:''}</div></article>`).join('')}</div>`:''}`;
|
||||
}
|
||||
function classifyDetailGroup(group) {
|
||||
const keyed = {
|
||||
scene_problem: [10, '场景证明', '先回答为什么需要'],
|
||||
material_proof: [20, '材质证明', '证明产品基础'],
|
||||
structure_proof: [30, '结构工艺', '证明结构如何实现'],
|
||||
function_proof: [40, '功能表现', '证明使用表现'],
|
||||
spec_adaptation: [50, '规格适配', '降低选择门槛'],
|
||||
care_durability: [60, '使用维护', '说明洗护与长期使用']
|
||||
};
|
||||
if (keyed[group?.contentKey]) {
|
||||
const [sequencePriority, sequenceLabel, sequenceHint] = keyed[group.contentKey];
|
||||
return {...group, sequencePriority, sequenceLabel, sequenceHint};
|
||||
}
|
||||
const source = `${group?.type || ''} ${group?.stage || ''}`;
|
||||
const rules = [
|
||||
[/场景|人群|体验|需求/,10,'使用场景','先回答为什么需要'],
|
||||
@@ -90,7 +142,7 @@ function classifyDetailGroup(group) {
|
||||
[/工艺|细节|结构|做工/,30,'工艺细节','证明品质如何实现'],
|
||||
[/耐用|性能|功能|洗涤|防护|透气/,40,'性能验证','证明长期使用表现'],
|
||||
[/规格|尺寸|SKU|选择|适配/,50,'规格选择','降低选择门槛'],
|
||||
[/品牌|服务|质检|证书|信任/,60,'信任背书','最后消除决策顾虑']
|
||||
[/洗护|护理|清洁|耐用/,60,'使用维护','说明洗护与长期使用']
|
||||
];
|
||||
const found = rules.find(([pattern])=>pattern.test(source));
|
||||
return found ? {...group,sequencePriority:found[1],sequenceLabel:found[2],sequenceHint:found[3]} : {...group,sequencePriority:70,sequenceLabel:'补充证据',sequenceHint:'补充未覆盖的信息'};
|
||||
@@ -125,14 +177,14 @@ function buyerGallery(data, block) {
|
||||
const grouped = new Map();
|
||||
items.forEach(item=>{
|
||||
const key = item.reviewEvidenceId || item.imageEvidenceId;
|
||||
if (!grouped.has(key)) grouped.set(key,{reviewId:item.reviewEvidenceId,quote:item.quote,date:item.date,sku:item.sku,images:[]});
|
||||
if (!grouped.has(key)) grouped.set(key,{reviewId:item.reviewEvidenceId,quote:item.quote,date:item.date,sku:item.sku,scenario:item.scenario || item.context,insight:item.insight || item.conclusion || item.businessMeaning,images:[]});
|
||||
grouped.get(key).images.push(byId.get(item.imageEvidenceId));
|
||||
});
|
||||
if (!grouped.size) {
|
||||
const loose = imageEntries(data,'buyerShow');
|
||||
return loose.length ? `<div class="buyer-story"><div class="buyer-photo-grid">${loose.map(item=>figure(item,item.id)).join('')}</div><p>买家秀已采集,尚未关联到具体评论。</p></div>` : '';
|
||||
}
|
||||
return [...grouped.values()].filter(group=>group.images.some(Boolean)||hasContent(group.quote)||hasContent(group.sku)).map(group=>`<article class="buyer-story"><div class="buyer-story-head">${hasContent(group.reviewId)?`<span>${esc(group.reviewId)}</span>`:''}<small>${group.images.filter(Boolean).length} 张实拍图${hasContent(group.date)?` · ${esc(group.date)}`:''}</small></div>${group.images.some(Boolean)?`<div class="buyer-photo-grid">${group.images.filter(Boolean).map(item=>figure(item,'')).join('')}</div>`:''}${hasContent(group.quote)?`<blockquote>“${esc(group.quote)}”</blockquote>`:''}${hasContent(group.sku)?`<p>${esc(group.sku)}</p>`:''}</article>`).join('');
|
||||
return [...grouped.values()].filter(group=>group.images.some(Boolean)||hasContent(group.quote)||hasContent(group.sku)).map((group,index)=>`<article class="buyer-story"><div class="buyer-story-head"><span>案例 ${String(index+1).padStart(2,'0')}${hasContent(group.reviewId)?` · ${esc(group.reviewId)}`:''}</span><small>${group.images.filter(Boolean).length} 张实拍图${hasContent(group.date)?` · ${esc(group.date)}`:''}</small></div>${group.images.some(Boolean)?`<div class="buyer-photo-grid">${group.images.filter(Boolean).map(item=>figure(item,'')).join('')}</div>`:''}${hasContent(group.scenario)?`<p class="case-scenario"><b>使用场景</b>${esc(group.scenario)}</p>`:''}${hasContent(group.quote)?`<blockquote>“${esc(group.quote)}”</blockquote>`:''}${hasContent(group.insight)?`<p class="case-insight"><b>案例结论</b>${esc(group.insight)}</p>`:''}${hasContent(group.sku)?`<p><b>SKU</b> ${esc(group.sku)}</p>`:''}</article>`).join('');
|
||||
}
|
||||
function reviews(data, block) {
|
||||
const base = data.baseline || {};
|
||||
@@ -160,7 +212,13 @@ function reviews(data, block) {
|
||||
const ROLE_DENSE_FIELDS = new Set(['baseline','buyerEvidence','signals','stages','evidenceDetail']);
|
||||
function displayValue(value) {
|
||||
if (typeof value === 'boolean') return value ? '是' : '否';
|
||||
const status = {passed:'已通过',confirmed:'已确认',high:'高风险',medium:'中风险',low:'低风险',consumer_supported:'评论支持',conflicted:'存在冲突',page_claim_only:'仅页面宣称',user_stopped:'用户主动停止'};
|
||||
const status = {
|
||||
passed:'已通过',confirmed:'已确认',missing:'缺失',partial:'部分满足',unknown:'待确认',high:'高风险',medium:'中风险',low:'低风险',
|
||||
consumer_supported:'评论支持',conflicted:'存在冲突',page_claim_only:'仅页面宣称',not_enough_evidence:'证据不足',user_stopped:'用户主动停止',
|
||||
expressed:'主图已表达',proven:'详情已证明',validated:'评论已验证',main_missing:'主图未表达',detail_missing:'详情未证明',review_pending:'等待评论验证',closed_loop:'证据闭环',conflict:'存在冲突',
|
||||
product_risk:'产品风险',information_gap:'信息缺口',purchase_driver:'购买驱动',satisfaction:'满意因素',pain:'用户痛点',expectation_gap:'预期差异',prepurchase_concern:'购前顾虑',sku_signal:'规格反馈',usage_scene:'使用场景',
|
||||
complete:'采集完成',limited:'样本有限',collected:'已采集',ready:'已就绪',normal:'一般',good:'良好',risk:'风险',true:'是',false:'否'
|
||||
};
|
||||
return status[value] || value;
|
||||
}
|
||||
function itemTitle(item) {
|
||||
@@ -201,7 +259,7 @@ function renderStructured(value, depth=0) {
|
||||
const valid=value.filter(hasContent);
|
||||
if (!valid.length) return '';
|
||||
if (valid.every(item=>typeof item !== 'object')) return `<div class="role-chip-list">${valid.map(item=>`<span>${esc(displayValue(item))}</span>`).join('')}</div>`;
|
||||
return `<div class="structured-list">${valid.map(item=>{const title=itemTitle(item);const inner=renderStructured(Object.fromEntries(Object.entries(item || {}).filter(([key])=>!title || !['headline','name','type','asset','signal','claim','risk','gate','topic','field'].includes(key))),depth+1);return title||inner?`<article class="structured-card">${title?`<header><span>${esc(title)}</span></header>`:''}${inner}</article>`:''}).join('')}</div>`;
|
||||
return `<div class="structured-list">${valid.map(item=>{const title=itemTitle(item);const inner=renderStructured(Object.fromEntries(Object.entries(item || {}).filter(([key])=>!title || !['headline','name','type','asset','signal','claim','risk','gate','topic','field'].includes(key))),depth+1);return title||inner?`<article class="structured-card">${title?`<header><span>${esc(displayValue(title))}</span></header>`:''}${inner}</article>`:''}).join('')}</div>`;
|
||||
}
|
||||
return `<div class="structured-object">${Object.entries(value).map(([key,item])=>{
|
||||
if (!hasContent(item)) return '';
|
||||
@@ -276,26 +334,616 @@ function initHeroCarousel() {
|
||||
window.addEventListener('resize',()=>show(current),{passive:true});
|
||||
show(0); start();
|
||||
}
|
||||
function initPlanActions(plans) {
|
||||
function initPlanActions(plans, context={}) {
|
||||
const dialog = document.querySelector('#quick-plan-dialog');
|
||||
if (!dialog) return;
|
||||
const title = dialog.querySelector('[data-plan-title]'), reason = dialog.querySelector('[data-plan-reason]');
|
||||
const product = dialog.querySelector('[data-plan-product]'), page = dialog.querySelector('[data-plan-page]'), validation = dialog.querySelector('[data-plan-validation]');
|
||||
let currentText = '';
|
||||
const generateButton=dialog.querySelector('[data-generate-images]');
|
||||
const generateStatus=dialog.querySelector('[data-generate-status]');
|
||||
const generateResults=dialog.querySelector('[data-generate-results]');
|
||||
const generationControls=dialog.querySelector('[data-generation-controls]');
|
||||
const userDirectionInput=dialog.querySelector('[data-user-direction]');
|
||||
const fissionWrap=dialog.querySelector('[data-fission-wrap]');
|
||||
const fissionInput=dialog.querySelector('[data-fission-pattern]');
|
||||
const matchReferenceWrap=dialog.querySelector('[data-match-reference-wrap]');
|
||||
const matchReferenceInput=dialog.querySelector('[data-match-reference-shooting]');
|
||||
const matchReferenceNote=dialog.querySelector('[data-match-reference-note]');
|
||||
const referenceInput=dialog.querySelector('[data-reference-input]');
|
||||
const referenceList=dialog.querySelector('[data-reference-list]');
|
||||
const referenceHint=dialog.querySelector('[data-reference-hint]');
|
||||
let uploadedReferences=[];
|
||||
if (generateButton) { generateButton.textContent='正在计算生成数量…'; generateButton.disabled=true; }
|
||||
let activePlanIndex=0, previewSlots=[], selectedSlotKeys=new Set(), promptOverrides={};
|
||||
let retainedResults=[], generatedTaskSlots=[];
|
||||
let imageModelOptions=[], selectedImageModels=new Set(), selectedImageQualities=new Map(), imageModelPromise=null;
|
||||
let submittedModelSpecs=[];
|
||||
const slotKey=slot=>`${slot.assetType}:${slot.index}`;
|
||||
const mergeResults=(older,newer)=>{
|
||||
const resultKey=item=>`${item.modelKey||item.model||'default'}:${item.assetType}:${item.slotIndex}`;
|
||||
const merged=new Map(arr(older).map(item=>[resultKey(item),item]));
|
||||
arr(newer).forEach(item=>merged.set(resultKey(item),item));
|
||||
return orderedResults([...merged.values()]);
|
||||
};
|
||||
const selectedTypes=()=>[...new Set(previewSlots.filter(slot=>selectedSlotKeys.has(slotKey(slot))).map(slot=>slot.assetType))];
|
||||
const generationCounts=()=>({
|
||||
main:previewSlots.filter(x=>x.assetType==='main'&&selectedSlotKeys.has(slotKey(x))).length,
|
||||
detail:previewSlots.filter(x=>x.assetType==='detail'&&selectedSlotKeys.has(slotKey(x))).length
|
||||
});
|
||||
const countText=counts=>`${counts.main} 张主图 + ${counts.detail} 张详情图`;
|
||||
const selectedModelOptions=()=>imageModelOptions
|
||||
.filter(item=>selectedImageModels.has(item.id))
|
||||
.map(item=>({...item,quality:selectedImageQualities.get(item.id) ?? item.quality ?? ''}));
|
||||
const comparisonMode=()=>selectedImageModels.size>1;
|
||||
const modelKey=item=>item?.modelKey||item?.model||'default';
|
||||
const imageModelControls=()=>dialog.querySelector('[data-image-model-controls]');
|
||||
const generationRunSummary=()=>dialog.querySelector('[data-generation-run-summary]');
|
||||
const renderGenerationRunSummary=(status='queued',specs=submittedModelSpecs)=>{
|
||||
const summary=generationRunSummary();
|
||||
if (!summary) return;
|
||||
const items=arr(specs);
|
||||
if (!items.length) {
|
||||
summary.hidden=true;
|
||||
summary.innerHTML='';
|
||||
return;
|
||||
}
|
||||
const stateLabel=status==='complete'?'已完成':status==='error'?'生成失败':status==='running'?'生成中':'已提交';
|
||||
summary.hidden=false;
|
||||
summary.innerHTML=`<b>本次生成参数 · ${stateLabel}</b>${items.map(item=>`<span><strong>${esc(item.label||item.id||'默认生图模型')}</strong><small>模型:${esc(item.id||'默认')} · 请求质量:${esc(modelQuality(item))}${item.quality?'':'(实际档位由服务商决定)'}</small></span>`).join('')}`;
|
||||
};
|
||||
const renderImageModelControls=()=>{
|
||||
const controls=imageModelControls();
|
||||
if (!controls) return;
|
||||
if (!imageModelOptions.length) {
|
||||
controls.innerHTML='<span class="image-model-loading">未读取到模型列表,请先在本地配置页填写生图测试模型。</span>';
|
||||
return;
|
||||
}
|
||||
controls.innerHTML=`<b>模型对比</b><span class="image-model-hint">同一图片任务会按模型并排生成,最多建议选择 3 个;模型对比建议统一使用 medium。</span>${imageModelOptions.map(item=>`<label class="image-model-option"><input type="checkbox" data-image-model="${esc(item.id)}" ${selectedImageModels.has(item.id)?'checked':''}><span class="image-model-option-name"><b>${esc(item.label||item.id)}</b><small>${esc(item.id)}</small></span><select data-image-quality-for="${esc(item.id)}" aria-label="${esc(`${item.label||item.id} 图片质量`)}">${['',...IMAGE_QUALITY_OPTIONS].map(value=>`<option value="${esc(value)}" ${((selectedImageQualities.get(item.id) ?? item.quality ?? '')===value)?'selected':''}>${esc(value||'服务商默认')}</option>`).join('')}</select></label>`).join('')}`;
|
||||
};
|
||||
const loadImageModels=()=>{
|
||||
if (imageModelPromise) return imageModelPromise;
|
||||
imageModelPromise=fetch('/health',{cache:'no-store'}).then(response=>readJsonResponse(response,'/health')).then(payload=>{
|
||||
imageModelOptions=arr(payload.imageModels).map(item=>typeof item==='string'?{id:item,label:item}:item).filter(item=>item?.id);
|
||||
imageModelOptions.forEach(item=>{
|
||||
if (!selectedImageQualities.has(item.id)) selectedImageQualities.set(item.id,item.quality||'');
|
||||
});
|
||||
if (!imageModelOptions.length&&payload.imageModel) imageModelOptions=[{id:payload.imageModel,label:payload.imageModel}];
|
||||
if (!selectedImageModels.size&&imageModelOptions[0]) selectedImageModels.add(imageModelOptions[0].id);
|
||||
selectedImageModels=new Set([...selectedImageModels].filter(id=>imageModelOptions.some(item=>item.id===id)));
|
||||
if (!selectedImageModels.size&&imageModelOptions[0]) selectedImageModels.add(imageModelOptions[0].id);
|
||||
renderImageModelControls();
|
||||
syncFissionControl();
|
||||
refreshGenerationConfirmation();
|
||||
}).catch(()=>{
|
||||
imageModelOptions=[{id:'',label:'默认生图模型'}];
|
||||
selectedImageQualities.set('','');
|
||||
selectedImageModels=new Set(['']);
|
||||
renderImageModelControls();
|
||||
refreshGenerationConfirmation();
|
||||
});
|
||||
return imageModelPromise;
|
||||
};
|
||||
const userDirection=()=>String(userDirectionInput?.value||'').trim();
|
||||
const fissionEnabled=()=>!!fissionInput?.checked&&!uploadedReferences.length&&!matchReferenceShooting()&&!comparisonMode();
|
||||
const matchReferenceShooting=()=>!!matchReferenceInput?.checked;
|
||||
const matchReferenceDescription=()=>matchReferenceShooting()
|
||||
? '已开启:第1张上传产品图只锁定商品本身;第2张采集参考图只提供展示状态;视角、动作、朝向、展开/折叠、摆放、支撑和部件关系跟随采集商品对应图片;不复制采集图中的其他商品、颜色、花型、品牌或文字。'
|
||||
: '未开启:产品图只用于商品身份,采集图的拍摄方式、具体动作和摆放状态不继承;按每张图任务重新设计。';
|
||||
const syncMatchReferenceControl=plan=>{
|
||||
if (!matchReferenceInput) return;
|
||||
const matchReferenceLabel=matchReferenceWrap?.querySelector('b');
|
||||
if (matchReferenceLabel) {
|
||||
if (matchReferenceShooting()) matchReferenceLabel.textContent='产品展示状态跟随采集商品对应图片';
|
||||
else matchReferenceLabel.textContent='产品图身份 + 采集参考图展示状态';
|
||||
}
|
||||
const referenceHeaderLabel=dialog.querySelector('.reference-upload header b');
|
||||
if (referenceHeaderLabel) referenceHeaderLabel.textContent=uploadedReferences.length?'产品图(商品身份)':'采集参考图(默认身份参考)';
|
||||
const fallback=fallbackReferences(plan||{});
|
||||
const hasReference=uploadedReferences.length>0&&fallback.length>0;
|
||||
matchReferenceInput.disabled=!hasReference;
|
||||
if (!hasReference) matchReferenceInput.checked=false;
|
||||
if (matchReferenceNote) matchReferenceNote.textContent=hasReference?matchReferenceDescription():'请同时准备产品图和采集商品对应参考图,才能启用展示状态一致。';
|
||||
if (matchReferenceWrap) matchReferenceWrap.title=hasReference?matchReferenceDescription():'需要产品图和采集参考图';
|
||||
};
|
||||
const ensureFissionBaseSelected=()=>{
|
||||
if (!fissionEnabled()) return;
|
||||
const hasAnySelection=previewSlots.some(slot=>selectedSlotKeys.has(slotKey(slot)));
|
||||
const base=previewSlots.find(slot=>slot.assetType==='main'&&slot.index===1);
|
||||
if (hasAnySelection&&base) selectedSlotKeys.add(slotKey(base));
|
||||
};
|
||||
const syncFissionControl=()=>{
|
||||
if (!fissionWrap||!fissionInput) return;
|
||||
const lockedByReferenceMatch=matchReferenceShooting();
|
||||
const lockedByComparison=comparisonMode();
|
||||
fissionWrap.hidden=!!uploadedReferences.length||lockedByReferenceMatch;
|
||||
fissionInput.disabled=!!uploadedReferences.length||lockedByReferenceMatch||lockedByComparison;
|
||||
if (uploadedReferences.length||lockedByReferenceMatch||lockedByComparison) fissionInput.checked=false;
|
||||
if (lockedByComparison&&fissionWrap) fissionWrap.title='多模型对比使用同一参考图,暂不启用裂变基准。';
|
||||
};
|
||||
const directionNote=direction=>{
|
||||
if (!direction?.raw) return '';
|
||||
const accepted=arr(direction.accepted), rejected=arr(direction.rejected);
|
||||
const ok=accepted.length?`已采纳:${accepted.join(' / ')}`:'未采纳可执行补充';
|
||||
const no=rejected.length?`;已忽略:${rejected.join(' / ')}`:'';
|
||||
return `${ok}${no}`;
|
||||
};
|
||||
const syncTypeSwitches=()=>{
|
||||
if (!generationControls) return;
|
||||
generationControls.querySelectorAll('[data-select-type]').forEach(input=>{
|
||||
const slots=previewSlots.filter(slot=>slot.assetType===input.dataset.selectType);
|
||||
const selected=slots.filter(slot=>selectedSlotKeys.has(slotKey(slot))).length;
|
||||
input.checked=!!slots.length&&selected===slots.length;
|
||||
input.indeterminate=selected>0&&selected<slots.length;
|
||||
input.disabled=!slots.length;
|
||||
const count=input.closest('label')?.querySelector('span');
|
||||
if (count) count.textContent=`${selected}/${slots.length}`;
|
||||
});
|
||||
};
|
||||
const fallbackReferences=plan=>{
|
||||
const firstMain=[...evidenceIndex.values()].find(item=>item?.type==='image'&&item?.meta?.group==='main');
|
||||
return [firstMain].filter(item=>Boolean(item&&imageUrl(item)));
|
||||
};
|
||||
const renderReferences=plan=>{
|
||||
const fallback=fallbackReferences(plan), urls=uploadedReferences.length?uploadedReferences:fallback.map(imageUrl).filter(Boolean);
|
||||
if (referenceList) {
|
||||
const uploadRoot=referenceList.closest('.reference-upload');
|
||||
uploadRoot?.classList.toggle('has-reference', urls.length>0);
|
||||
referenceList.innerHTML=urls.map((url,index)=>`<figure><img src="${esc(url)}" alt="${uploadedReferences.length?'产品图':'采集参考图'} ${index+1}">${uploadedReferences.length?`<button type="button" data-remove-reference="${index}" aria-label="删除产品图">×</button>`:''}</figure>`).join('');
|
||||
}
|
||||
syncFissionControl();
|
||||
syncMatchReferenceControl(plan);
|
||||
if (referenceHint) referenceHint.textContent=uploadedReferences.length?`已上传 ${uploadedReferences.length} 张产品图;第一张是整套图片唯一商品身份基准,其他上传图不会混入生图请求。开启展示状态一致后,采集商品对应图片只负责视角和摆放。`:(fallback.length?(fissionEnabled()?'默认先裂变生成相近花型基准图,后续主图/详情围绕该基准图生成。':'当前将直接参考采集商品主图生成。'):'当前没有商品主图,请上传产品图后生成。');
|
||||
};
|
||||
const taskName=slot=>slot.role||`${slot.assetType==='detail'?'详情图':'主图'}任务 ${slot.index||''}`;
|
||||
const taskCard=(slot,result,status='queued')=>{
|
||||
const label=status==='complete'?'已完成':status==='generating'?'生成中':status==='error'?'生成失败':'等待生成';
|
||||
const key=slotKey(slot), hasUserEdit=Object.prototype.hasOwnProperty.call(promptOverrides,key);
|
||||
const promptText=hasUserEdit?promptOverrides[key]:'';
|
||||
const mergeMode=slot.promptMerge?.mode;
|
||||
const globalDirection=slot.userDirection||{};
|
||||
const hasGlobalAccepted=arr(globalDirection.accepted).length>0;
|
||||
const hasGlobalIgnored=arr(globalDirection.ignored).length>0;
|
||||
const hasGlobalRejected=arr(globalDirection.rejected).length>0;
|
||||
const mergeNote=mergeMode==='applied'?'本图已关联并优先融合本图编辑':
|
||||
hasGlobalAccepted?'本图已关联并优先融合全局用户要求':
|
||||
mergeMode==='ignored_unrelated'||hasGlobalIgnored||hasGlobalRejected?'本图未关联或存在不可执行要求,继续执行内置策划':
|
||||
'本图执行内置策划';
|
||||
const planContext=compact([slot.planProductAction,slot.planPageAction]);
|
||||
const planContextHtml=planContext?`<p class="generation-plan-context"><b>${esc(slot.planName||'当前开品方案')}</b> · ${esc(planContext)}</p>`:'';
|
||||
const media=result?.url?`<button class="generation-preview-btn" type="button" data-preview-url="${esc(result.url)}" data-preview-title="${esc(taskName(slot))}"><img loading="lazy" src="${esc(result.url)}" alt="${esc(taskName(slot))}"></button>`:`<div class="generation-task-placeholder"><span>${slot.assetType==='detail'?'DETAIL':'MAIN'}</span><b>${String(slot.index||1).padStart(2,'0')}</b></div>`;
|
||||
const referencePreview=slot.displayReferenceUrl||slot.identityReferenceUrl;
|
||||
const referenceLabel=slot.displayReferenceUrl?'展示状态参考图':'产品身份参考图';
|
||||
const referenceThumb=referencePreview?`<div class="generation-reference-preview"><img loading="lazy" src="${esc(referencePreview)}" alt="${esc(referenceLabel)}"><span>${esc(referenceLabel)}</span></div>`:'';
|
||||
const resultSpec=result?`<small class="generation-task-spec">${esc(modelLabel(result))} · 质量 ${esc(modelQuality(result))}</small>`:'';
|
||||
const visibleResultSpec=result?`<div class="generation-task-result-meta"><b>本次实际请求</b>${resultSpec}</div>`:'';
|
||||
const choose=status==='queued'?`<label class="generation-slot-select"><input type="checkbox" data-select-slot="${esc(key)}" ${selectedSlotKeys.has(key)?'checked':''}><span>生成此图</span></label>`:'';
|
||||
return `<article class="generation-task-card generation-task-${status} ${selectedSlotKeys.has(key)?'is-selected':''}" data-task-card="${esc(key)}"><div class="generation-task-media">${media}<i>${esc(label)}</i><div class="generation-task-overlay"><small>${slot.assetType==='detail'?'详情图':'主图'} ${String(slot.index||1).padStart(2,'0')}</small><h4>${esc(taskName(slot))}</h4>${planContextHtml}${resultSpec}${arr(slot.task).length?`<p>${arr(slot.task).map(esc).join(' · ')}</p>`:''}<p class="generation-prompt-status">${esc(mergeNote)}</p>${choose}<details class="task-prompt"><summary>编辑本张提示词</summary><em>商品材质、颜色、结构等修改会同步整套图片;视觉要求仅在关联本图时融合。</em><textarea data-prompt-slot="${esc(key)}" placeholder="例如:改成天丝棉材质;或背景留白更大、改为面料微距特写">${esc(promptText)}</textarea><details class="task-final-prompt"><summary>查看最终提示词</summary><pre>${esc(slot.prompt||'')}</pre></details></details></div></div>${referenceThumb}${visibleResultSpec}</article>`;
|
||||
};
|
||||
const openPreview=button=>{
|
||||
const buttons=[...generateResults.querySelectorAll('[data-preview-url]')];
|
||||
openImagePreview(buttons.map(node=>({url:node.dataset.previewUrl,title:node.dataset.previewTitle||'生成图片'})),buttons.indexOf(button));
|
||||
};
|
||||
const comparisonCell=(slot,result,status,model)=>{
|
||||
const media=result?.url
|
||||
? `<button class="generation-preview-btn" type="button" data-preview-url="${esc(result.url)}" data-preview-title="${esc(`${modelLabel(model)} · ${taskName(slot)}`)}"><img loading="lazy" src="${esc(result.url)}" alt="${esc(`${modelLabel(model)} · ${taskName(slot)}`)}"></button>`
|
||||
: `<div class="generation-task-placeholder"><span>${status==='error'?'FAILED':status==='generating'?'RUNNING':slot.assetType==='detail'?'DETAIL':'MAIN'}</span><b>${String(slot.index||1).padStart(2,'0')}</b></div>`;
|
||||
const error=result?.error||'';
|
||||
return `<article class="generation-comparison-cell generation-task-${status}"><header><b>${esc(modelLabel(model))}<small>${esc(modelQuality(model))}</small></b><i>${status==='complete'?'已完成':status==='generating'?'生成中':status==='error'?'失败':'等待生成'}</i></header><div class="generation-comparison-media">${media}</div>${error?`<p class="generation-error">${esc(error)}</p>`:''}</article>`;
|
||||
};
|
||||
const renderComparisonBoard=(slots,results=[],jobStatus='queued',failedSlots=[],models=selectedModelOptions())=>{
|
||||
const modelList=models.length?models:imageModelOptions;
|
||||
const resultKey=(model,slot)=>`${modelKey(model)}:${slot.assetType}:${slot.index}`;
|
||||
const resultMap=new Map(arr(results).map(item=>[`${modelKey(item)}:${item.assetType}:${item.slotIndex}`,item]));
|
||||
const failedMap=new Map(arr(failedSlots).map(item=>[`${modelKey(item)}:${item.assetType}:${item.slotIndex}`,item]));
|
||||
const row=(type,title)=>{
|
||||
const items=arr(slots).filter(slot=>slot.assetType===type);
|
||||
if (!items.length) return '';
|
||||
const completed=items.reduce((sum,slot)=>sum+modelList.filter(model=>resultMap.has(resultKey(model,slot))).length,0);
|
||||
const modelHeaders=modelList.map(model=>`<div class="generation-comparison-model"><b>${esc(modelLabel(model))}</b><small>${esc(modelQuality(model))}</small></div>`).join('');
|
||||
const taskRows=items.map(slot=>{
|
||||
const slotKeyValue=slotKey(slot);
|
||||
const choose=jobStatus==='queued'
|
||||
? `<label class="generation-slot-select"><input type="checkbox" data-select-slot="${esc(slotKeyValue)}" ${selectedSlotKeys.has(slotKeyValue)?'checked':''}><span>生成此图</span></label>`
|
||||
: '';
|
||||
const ref=slot.displayReferenceUrl||slot.identityReferenceUrl;
|
||||
const refLabel=slot.displayReferenceUrl?'展示状态参考图':'产品身份参考图';
|
||||
const refThumb=ref?`<div class="generation-reference-preview"><img loading="lazy" src="${esc(ref)}" alt="${esc(refLabel)}"><span>${esc(refLabel)}</span></div>`:'';
|
||||
const planContext=compact([slot.planProductAction,slot.planPageAction]);
|
||||
const planContextHtml=planContext?`<span class="generation-plan-context"><b>${esc(slot.planName||'当前开品方案')}</b> · ${esc(planContext)}</span>`:'';
|
||||
const taskCell=`<div class="generation-comparison-task"><small>${slot.assetType==='detail'?'详情图':'主图'} ${String(slot.index||1).padStart(2,'0')}</small><strong>${esc(taskName(slot))}</strong>${planContextHtml}${refThumb}${choose}</div>`;
|
||||
const modelCells=modelList.map(model=>{
|
||||
const key=resultKey(model,slot);
|
||||
const result=resultMap.get(key);
|
||||
const failure=failedMap.get(key);
|
||||
let status=result?'complete':'queued';
|
||||
if (!result&&jobStatus==='running') status='generating';
|
||||
if (!result&&(jobStatus==='error'||failure)) status='error';
|
||||
return comparisonCell(slot,result||failure,status,model);
|
||||
}).join('');
|
||||
return taskCell+modelCells;
|
||||
}).join('');
|
||||
return `<section class="generation-comparison-row"><header><div><span>${type==='main'?'MAIN IMAGE TASKS':'DETAIL IMAGE TASKS'}</span><h3>${title}</h3></div><b>${completed} / ${items.length*modelList.length}</b></header><div class="generation-comparison-table" style="--comparison-columns:${Math.max(1,modelList.length)}"><div class="generation-comparison-corner">图片任务</div>${modelHeaders}${taskRows}</div></section>`;
|
||||
};
|
||||
generateResults.innerHTML=row('main','主图模型对比')+row('detail','详情图模型对比');
|
||||
};
|
||||
const renderTaskBoard=(slots,results=[],jobStatus='queued',failedSlots=[])=>{
|
||||
if (!generateResults) return;
|
||||
if (selectedImageModels.size>1) {
|
||||
renderComparisonBoard(slots,results,jobStatus,failedSlots,selectedModelOptions());
|
||||
return;
|
||||
}
|
||||
const selected=arr(slots);
|
||||
const resultMap=new Map(arr(results).map(item=>[`${item.assetType}:${item.slotIndex}`,item]));
|
||||
const failedKeys=new Set(arr(failedSlots).map(item=>`${item.assetType}:${item.slotIndex}`));
|
||||
const row=(type,title)=>{
|
||||
const items=selected.filter(slot=>slot.assetType===type);
|
||||
if (!items.length) return '';
|
||||
const heading=type==='detail'?`${title}(${items.length})`:`${title}(${items.length})`;
|
||||
const right=type==='detail'?`<span class="generation-task-hint">左右滑动查看全部 ${items.length} 张</span>`:`<b>${items.filter(slot=>resultMap.has(`${slot.assetType}:${slot.index}`)).length} / ${items.length}</b>`;
|
||||
return `<section class="generation-task-row generation-task-row-${type}"><header><div><span>${type==='main'?'MAIN IMAGE TASKS':'DETAIL IMAGE TASKS'}</span><h3>${heading}</h3></div>${right}</header><div class="generation-task-track" style="--task-count:${items.length}">${items.map(slot=>{const key=`${slot.assetType}:${slot.index}`;const result=resultMap.get(key);let status=result?'complete':'queued';if(!result&&jobStatus==='running')status='generating';if(!result&&(jobStatus==='error'||failedKeys.has(key)))status='error';return taskCard(slot,result,status)}).join('')}</div></section>`;
|
||||
};
|
||||
generateResults.innerHTML=row('main','主图生成任务')+row('detail','详情图生成任务');
|
||||
};
|
||||
const refreshGenerationConfirmation=()=>{
|
||||
ensureFissionBaseSelected();
|
||||
const counts=generationCounts(), total=counts.main+counts.detail;
|
||||
const modelCount=Math.max(1,selectedImageModels.size);
|
||||
const requestTotal=total*modelCount;
|
||||
// Keep the count calculation in one place; the single-model label follows the modal spec.
|
||||
// 兼容旧版校验:确认生成:${countText(counts)}
|
||||
if (generateButton) { generateButton.disabled=!total||!selectedImageModels.size; generateButton.textContent=requestTotal?(modelCount>1?`${modelCount} 个模型 × ${countText(counts)} = ${requestTotal} 张`:`确认生成:${countText(counts)}`):'请选择要生成的图片类型'; }
|
||||
if (generateStatus && previewSlots.length) generateStatus.textContent=requestTotal?`本次计划生成 ${countText(counts)};${modelCount>1?`将按 ${modelCount} 个模型并排对比,共 `:''} ${requestTotal} 张。${fissionEnabled()&&counts.main?'裂变模式已计入 main:1 基准图。':''}${modelCount>1?'多模型对比使用同一参考图和同一份提示词。':''}点击按钮后还需确认。`:'请至少选择一种图片类型和一个生图模型。';
|
||||
syncTypeSwitches();
|
||||
if (previewSlots.length) renderTaskBoard(previewSlots);
|
||||
};
|
||||
dialog.addEventListener('change',event=>{
|
||||
const input=event.target.closest('[data-image-model]');
|
||||
const qualityInput=event.target.closest('[data-image-quality-for]');
|
||||
if (qualityInput) {
|
||||
selectedImageQualities.set(qualityInput.dataset.imageQualityFor,qualityInput.value);
|
||||
refreshGenerationConfirmation();
|
||||
return;
|
||||
}
|
||||
if (!input) return;
|
||||
if (input.checked) selectedImageModels.add(input.dataset.imageModel);
|
||||
else selectedImageModels.delete(input.dataset.imageModel);
|
||||
if (!selectedImageModels.size) input.checked=true,selectedImageModels.add(input.dataset.imageModel);
|
||||
syncFissionControl();
|
||||
refreshGenerationConfirmation();
|
||||
});
|
||||
generateResults?.addEventListener('click',event=>{
|
||||
const button=event.target.closest('[data-preview-url]');if(button){openPreview(button);return;}
|
||||
if (event.target.closest('input,textarea,summary,details,label,button')) return;
|
||||
const card=event.target.closest('[data-task-card]');
|
||||
if (!card||!card.classList.contains('generation-task-queued')) return;
|
||||
if (selectedSlotKeys.has(card.dataset.taskCard)) selectedSlotKeys.delete(card.dataset.taskCard);
|
||||
else selectedSlotKeys.add(card.dataset.taskCard);
|
||||
refreshGenerationConfirmation();
|
||||
});
|
||||
const refreshPromptPreview=()=>{
|
||||
if (!previewSlots.length) return Promise.resolve();
|
||||
const previewBody={planIndex:activePlanIndex,assetTypes:['main','detail'],referenceImages:uploadedReferences,userDirection:userDirection(),fissionPattern:fissionEnabled(),matchReferenceShooting:matchReferenceShooting(),promptOverrides};
|
||||
if (context.source) previewBody.source=context.source; else if (context.data) previewBody.reportData=context.data;
|
||||
return fetch('/api/image-prompt-preview',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(previewBody)}).then(response=>readJsonResponse(response,'/api/image-prompt-preview')).then(payload=>{
|
||||
if (!payload.ok) throw new Error(payload.error||'提示词融合失败');
|
||||
const promptIndexes={main:0,detail:0};
|
||||
previewSlots=arr(payload.slots).map(slot=>{
|
||||
const index=promptIndexes[slot.assetType]++;
|
||||
const key=`${slot.assetType}:${slot.index}`;
|
||||
return {...slot,prompt:arr(payload.prompts?.[slot.assetType])[index]||'',promptMerge:payload.promptMerges?.[key],userDirection:payload.userDirectionBySlot?.[key]};
|
||||
});
|
||||
if (generateStatus && payload.userDirection?.raw) generateStatus.textContent=directionNote(payload.userDirection);
|
||||
});
|
||||
};
|
||||
generateResults?.addEventListener('change',event=>{
|
||||
const textarea=event.target.closest('[data-prompt-slot]');
|
||||
if (textarea) {
|
||||
promptOverrides[textarea.dataset.promptSlot]=textarea.value;
|
||||
refreshPromptPreview().then(refreshGenerationConfirmation).catch(error=>{if(generateStatus) generateStatus.textContent=error.message||'提示词融合失败';});
|
||||
return;
|
||||
}
|
||||
const input=event.target.closest('[data-select-slot]');
|
||||
if(!input)return;if(input.checked)selectedSlotKeys.add(input.dataset.selectSlot);else selectedSlotKeys.delete(input.dataset.selectSlot);refreshGenerationConfirmation();
|
||||
});
|
||||
generateResults?.addEventListener('input',event=>{const textarea=event.target.closest('[data-prompt-slot]');if(!textarea)return;promptOverrides[textarea.dataset.promptSlot]=textarea.value;});
|
||||
generationControls?.addEventListener('change',event=>{
|
||||
const input=event.target.closest('[data-select-type]');
|
||||
if (!input) return;
|
||||
previewSlots.filter(slot=>slot.assetType===input.dataset.selectType).forEach(slot=>{
|
||||
if (input.checked) selectedSlotKeys.add(slotKey(slot));
|
||||
else selectedSlotKeys.delete(slotKey(slot));
|
||||
});
|
||||
refreshGenerationConfirmation();
|
||||
});
|
||||
const pollJob = async jobId => {
|
||||
let networkErrors=0;
|
||||
for (let i=0;i<240;i++) {
|
||||
const endpoint='/api/image-job/'+encodeURIComponent(jobId);
|
||||
let state;
|
||||
try {
|
||||
const r=await fetch(endpoint,{cache:'no-store'});
|
||||
state=await readJsonResponse(r,endpoint);
|
||||
networkErrors=0;
|
||||
} catch (error) {
|
||||
networkErrors++;
|
||||
if (generateStatus) generateStatus.textContent=`本地服务短暂断开,正在自动重连(${networkErrors}/5)…`;
|
||||
if (networkErrors>=5) throw new Error('无法连接本地生图服务,请确认服务仍在运行后重试。');
|
||||
await new Promise(resolve=>setTimeout(resolve,Math.min(2000*networkErrors,6000)));
|
||||
continue;
|
||||
}
|
||||
// A fission run may inject main:1 as the shared product baseline even
|
||||
// when the user selected detail-only slots. Keep that implicit asset in
|
||||
// the board so the UI mirrors the manifest and does not hide the image
|
||||
// that all follow-up slots used as their reference.
|
||||
const expected=arr(state.expectedSlots);
|
||||
if (arr(state.comparisonModels).length) {
|
||||
const runModels=arr(state.comparisonModels).map(item=>({id:item.id||'',label:item.label||item.id||'默认生图模型',quality:item.quality||'',modelKey:item.modelKey}));
|
||||
imageModelOptions=runModels;
|
||||
selectedImageModels=new Set(imageModelOptions.map(item=>item.id));
|
||||
const preservedQualities=new Map(selectedImageQualities);
|
||||
runModels.forEach(item=>{
|
||||
if (item.quality || !preservedQualities.has(item.id)) preservedQualities.set(item.id,item.quality||'');
|
||||
});
|
||||
selectedImageQualities=preservedQualities;
|
||||
renderImageModelControls();
|
||||
submittedModelSpecs=runModels.map(item=>({...item,quality:selectedImageQualities.get(item.id) ?? item.quality ?? ''}));
|
||||
renderGenerationRunSummary(state.status,submittedModelSpecs);
|
||||
}
|
||||
if (expected.length) {
|
||||
const known=new Set(previewSlots.map(slotKey));
|
||||
const generatedKnown=new Set(generatedTaskSlots.map(slotKey));
|
||||
expected.forEach(slot=>{
|
||||
const key=slotKey(slot);
|
||||
if (!known.has(key)) { previewSlots.push({...slot}); known.add(key); }
|
||||
if (!generatedKnown.has(key)) { generatedTaskSlots.push({...slot}); generatedKnown.add(key); }
|
||||
if (state.fissionPattern && slot.assetType==='main' && slot.index===1) selectedSlotKeys.add(key);
|
||||
});
|
||||
}
|
||||
const displayResults=mergeResults(retainedResults,state.results);
|
||||
const boardSlots=generatedTaskSlots.length?generatedTaskSlots:expected.length?expected:previewSlots;
|
||||
if (state.status==='complete') { const gate=state.consistencyGate||{}; const shootPolicy=state.matchReferenceShooting?'已按参考图拍摄与产品展示状态生成。':'按每张图片任务重新设计拍摄和展示状态。'; const failedSlots=arr(state.failedSlots), failed=failedSlots.length; retainedResults=displayResults; if(failed){selectedSlotKeys=new Set(failedSlots.map(item=>`${item.assetType}:${item.slotIndex}`));const counts=generationCounts();if(generateButton){generateButton.disabled=false;generateButton.textContent=`仅重试失败:${countText(counts)}`;}syncTypeSwitches();} if(generateStatus) generateStatus.textContent=failed?`部分完成:累计成功 ${displayResults.length} 张,失败 ${failed} 张。点击按钮仅重试失败槽位。`:`已生成 ${displayResults.length} 张图片。${shootPolicy}${gate.status==='passed'?'一致性校验通过。':'已共享产品身份锁,发布前仍需完成一致性复核。'}`; renderTaskBoard(boardSlots,displayResults,'complete',failedSlots); return; }
|
||||
if (state.status==='error') { renderTaskBoard(boardSlots,displayResults,'error',state.failedSlots); throw new Error(state.error||'生图失败'); }
|
||||
renderTaskBoard(boardSlots,displayResults,'running',state.failedSlots);
|
||||
const counts=generationCounts(); const expectedTotal=(counts.main+counts.detail)*Math.max(1,selectedImageModels.size); if (generateStatus) generateStatus.textContent=`正在并发生成:已完成 ${arr(state.results).length} / ${expectedTotal} 张 · ${state.progress||0}%`;
|
||||
await new Promise(resolve=>setTimeout(resolve,1200));
|
||||
}
|
||||
throw new Error('生图任务等待超时,请稍后刷新报告查看结果。');
|
||||
};
|
||||
generateButton?.addEventListener('click',async event=>{
|
||||
ensureFissionBaseSelected();
|
||||
const types=selectedTypes(), counts=generationCounts(), total=counts.main+counts.detail;
|
||||
if (!types.length) { if(generateStatus) generateStatus.textContent='至少选择主图或详情图一种类型。'; return; }
|
||||
if (!total) { if(generateStatus) generateStatus.textContent='尚未完成生成数量计算,请稍后再试。'; return; }
|
||||
const modelSpecs=selectedModelOptions(), requestTotal=total*Math.max(1,modelSpecs.length);
|
||||
submittedModelSpecs=modelSpecs.map(item=>({...item}));
|
||||
renderGenerationRunSummary('running',submittedModelSpecs);
|
||||
const modelSummary=modelSpecs.map(item=>`${item.label||item.id}(${modelQuality(item)})`).join('、');
|
||||
if (!window.confirm(`确认开始生成?\n\n模型:${modelSummary}\n主图:${counts.main} 张\n详情图:${counts.detail} 张\n合计:${requestTotal} 张`)) {
|
||||
renderGenerationRunSummary('queued',[]);
|
||||
if (generateStatus) generateStatus.textContent=`已取消。本次原计划生成 ${modelSpecs.length} 个模型 × ${countText(counts)}。`;
|
||||
return;
|
||||
}
|
||||
const submittedSlots=previewSlots.filter(slot=>selectedSlotKeys.has(slotKey(slot)));
|
||||
event.currentTarget.disabled=true; if(generateStatus) generateStatus.textContent='正在提交生图任务…'; renderTaskBoard(submittedSlots,retainedResults,'running');
|
||||
try {
|
||||
const body={planIndex:activePlanIndex,assetTypes:types,selectedSlots:[...selectedSlotKeys],completeSet:true,imageModels:modelSpecs.map(item=>({id:item.id,quality:item.quality||''})),referenceImages:uploadedReferences,userDirection:userDirection(),fissionPattern:fissionEnabled(),matchReferenceShooting:matchReferenceShooting(),promptOverrides};
|
||||
if (context.source) body.source=context.source; else if (context.data) body.reportData=context.data;
|
||||
const response=await fetch('/api/generate-images',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}), payload=await readJsonResponse(response,'/api/generate-images');
|
||||
if (!response.ok || !payload.ok) throw new Error(payload.error||'无法提交生图任务');
|
||||
window.dispatchEvent(new CustomEvent('image-job-submitted',{detail:{jobId:payload.jobId}}));
|
||||
await pollJob(payload.jobId);
|
||||
window.dispatchEvent(new CustomEvent('image-job-finished',{detail:{jobId:payload.jobId}}));
|
||||
} catch (error) { renderGenerationRunSummary('error',submittedModelSpecs); if(generateStatus) generateStatus.textContent=error.message||'生图失败'; }
|
||||
finally { event.currentTarget.disabled=false; }
|
||||
});
|
||||
const referenceUpload=dialog.querySelector('.reference-upload');
|
||||
const referenceUploadButton=dialog.querySelector('.reference-upload-btn');
|
||||
const referenceEmpty=dialog.querySelector('.reference-empty');
|
||||
const addReferenceFiles=async fileList=>{
|
||||
const files=[...fileList].slice(0,Math.max(0,4-uploadedReferences.length));
|
||||
for (const file of files) {
|
||||
if (!file.type.startsWith('image/')) continue;
|
||||
if (file.size>9*1024*1024) { if(generateStatus) generateStatus.textContent='单张参考图不能超过 9MB。'; continue; }
|
||||
try {
|
||||
const dataUrl=await new Promise((resolve,reject)=>{const reader=new FileReader();reader.onload=()=>resolve(reader.result);reader.onerror=reject;reader.readAsDataURL(file)});
|
||||
uploadedReferences.push(dataUrl);
|
||||
} catch (_) {
|
||||
if (generateStatus) generateStatus.textContent='参考图读取失败,请重新选择图片。';
|
||||
}
|
||||
}
|
||||
renderReferences(arr(plans)[activePlanIndex]||{});
|
||||
if (previewSlots.length) await refreshPromptPreview().then(refreshGenerationConfirmation).catch(error=>{if(generateStatus) generateStatus.textContent=error.message||'参考图提示词刷新失败';});
|
||||
if(generateStatus) generateStatus.textContent=uploadedReferences.length?`已使用上传产品图;生成时会保持产品身份。${matchReferenceShooting()?' '+matchReferenceDescription():''}`:`将使用默认采集参考图。${matchReferenceShooting()?' '+matchReferenceDescription():''}`;
|
||||
};
|
||||
referenceInput?.addEventListener('change',async event=>{
|
||||
await addReferenceFiles(event.target.files);
|
||||
event.target.value='';
|
||||
});
|
||||
// Explicitly proxy the visible button to the file input. This remains
|
||||
// reliable when modal CSS or browser label activation suppresses the native
|
||||
// label-to-input click behavior.
|
||||
referenceUploadButton?.addEventListener('click',event=>{
|
||||
if (event.target===referenceInput) return;
|
||||
event.preventDefault();
|
||||
referenceInput?.click();
|
||||
});
|
||||
referenceEmpty?.addEventListener('click',()=>referenceInput?.click());
|
||||
referenceUpload?.addEventListener('dragenter',event=>{
|
||||
event.preventDefault();
|
||||
referenceUpload.classList.add('is-dragging');
|
||||
});
|
||||
referenceUpload?.addEventListener('dragover',event=>event.preventDefault());
|
||||
referenceUpload?.addEventListener('dragleave',event=>{
|
||||
if (!referenceUpload.contains(event.relatedTarget)) referenceUpload.classList.remove('is-dragging');
|
||||
});
|
||||
referenceUpload?.addEventListener('drop',async event=>{
|
||||
event.preventDefault();
|
||||
referenceUpload.classList.remove('is-dragging');
|
||||
await addReferenceFiles(event.dataTransfer?.files||[]);
|
||||
});
|
||||
referenceList?.addEventListener('click',event=>{
|
||||
const button=event.target.closest('[data-remove-reference]'); if(!button)return;
|
||||
uploadedReferences.splice(Number(button.dataset.removeReference),1);
|
||||
if (!uploadedReferences.length&&fissionInput) fissionInput.checked=true;
|
||||
renderReferences(arr(plans)[activePlanIndex]||{});
|
||||
if (previewSlots.length) refreshPromptPreview().then(refreshGenerationConfirmation).catch(error=>{if(generateStatus) generateStatus.textContent=error.message||'参考图提示词刷新失败';});
|
||||
});
|
||||
fissionInput?.addEventListener('change',()=>{
|
||||
renderReferences(arr(plans)[activePlanIndex]||{});
|
||||
if (previewSlots.length) refreshPromptPreview().then(refreshGenerationConfirmation).catch(error=>{if(generateStatus) generateStatus.textContent=error.message||'裂变提示词刷新失败';});
|
||||
});
|
||||
matchReferenceInput?.addEventListener('change',async()=>{
|
||||
syncMatchReferenceControl(arr(plans)[activePlanIndex]||{});
|
||||
syncFissionControl();
|
||||
if (!previewSlots.length) {
|
||||
if (generateStatus) generateStatus.textContent=matchReferenceDescription();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await refreshPromptPreview();
|
||||
refreshGenerationConfirmation();
|
||||
if (generateStatus) generateStatus.textContent=matchReferenceDescription();
|
||||
} catch(error) {
|
||||
if (generateStatus) generateStatus.textContent=error.message||'参考图拍摄方式提示词刷新失败';
|
||||
}
|
||||
});
|
||||
document.querySelectorAll('.generate-plan-btn').forEach(button=>button.addEventListener('click',()=>{
|
||||
loadImageModels();
|
||||
const plan = arr(plans)[Number(button.dataset.planIndex)] || {};
|
||||
activePlanIndex=Number(button.dataset.planIndex)||0;
|
||||
if (userDirectionInput) userDirectionInput.value='';
|
||||
if (fissionInput) fissionInput.checked=true;
|
||||
if (matchReferenceInput) matchReferenceInput.checked=false;
|
||||
uploadedReferences=[]; renderReferences(plan);
|
||||
previewSlots=[]; selectedSlotKeys=new Set(); promptOverrides={}; retainedResults=[]; generatedTaskSlots=[];
|
||||
if (generateButton) { generateButton.disabled=true; generateButton.textContent='正在计算生成数量…'; }
|
||||
submittedModelSpecs=[];
|
||||
renderGenerationRunSummary('queued',submittedModelSpecs);
|
||||
title.textContent = plan.name || '快速开品方案';
|
||||
reason.textContent = plan.whyThisPlan || ''; reason.hidden = !hasContent(plan.whyThisPlan);
|
||||
[[product,plan.productAction],[page,plan.pageAction],[validation,plan.validation]].forEach(([node,value])=>{node.textContent=value||'';node.closest('div').hidden=!hasContent(value)});
|
||||
currentText = [[`快速开品任务|${plan.name||''}`,plan.name],['依据:'+(plan.whyThisPlan||''),plan.whyThisPlan],['产品动作:'+(plan.productAction||''),plan.productAction],['页面动作:'+(plan.pageAction||''),plan.pageAction],['验证指标:'+(plan.validation||''),plan.validation]].filter(([,value])=>hasContent(value)).map(([line])=>line).join('\n');
|
||||
if (reason) { reason.textContent = plan.whyThisPlan || ''; reason.hidden = !hasContent(plan.whyThisPlan); }
|
||||
[[product,plan.productAction],[page,plan.pageAction],[validation,plan.validation]].forEach(([node,value])=>{if(!node)return;node.textContent=value||'';node.closest('div').hidden=!hasContent(value)});
|
||||
if (generateStatus) generateStatus.textContent='已按此方案准备生图提示词。请先选择要生成的图片类型。';
|
||||
if (generateResults) generateResults.replaceChildren();
|
||||
const previewBody={planIndex:activePlanIndex,assetTypes:['main','detail'],referenceImages:uploadedReferences,userDirection:userDirection(),fissionPattern:fissionEnabled(),matchReferenceShooting:matchReferenceShooting(),promptOverrides};
|
||||
if (context.source) previewBody.source=context.source; else if (context.data) previewBody.reportData=context.data;
|
||||
fetch('/api/image-prompt-preview',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(previewBody)}).then(response=>readJsonResponse(response,'/api/image-prompt-preview')).then(payload=>{
|
||||
if (!payload.ok) throw new Error(payload.error||'提示词预览失败');
|
||||
const promptIndexes={main:0,detail:0};
|
||||
previewSlots=arr(payload.slots).map(slot=>{
|
||||
const index=promptIndexes[slot.assetType]++;
|
||||
const key=`${slot.assetType}:${slot.index}`;
|
||||
return {...slot,prompt:arr(payload.prompts?.[slot.assetType])[index]||'',promptMerge:payload.promptMerges?.[key],userDirection:payload.userDirectionBySlot?.[key]};
|
||||
});
|
||||
// 默认保留完整主图链和前六个详情证明位;详情 7-15 可按需勾选。
|
||||
selectedSlotKeys=new Set(previewSlots.filter(slot=>slot.assetType==='main'||slot.index<=6).map(slotKey));
|
||||
refreshGenerationConfirmation();
|
||||
}).catch(error=>{if(generateStatus) generateStatus.textContent=error.message||'生成数量计算失败,请重新打开方案。'});
|
||||
if (typeof dialog.showModal === 'function') dialog.showModal(); else dialog.setAttribute('open','');
|
||||
}));
|
||||
dialog.querySelector('[data-close-plan]')?.addEventListener('click',()=>dialog.close ? dialog.close() : dialog.removeAttribute('open'));
|
||||
dialog.querySelector('[data-copy-plan]')?.addEventListener('click',async event=>{
|
||||
try { await navigator.clipboard.writeText(currentText); event.currentTarget.textContent='已复制任务'; }
|
||||
catch { event.currentTarget.textContent='复制失败,请手动复制'; }
|
||||
setTimeout(()=>event.currentTarget.textContent='复制开品任务',1600);
|
||||
userDirectionInput?.addEventListener('change',()=>{
|
||||
if (!previewSlots.length) return;
|
||||
const previewBody={planIndex:activePlanIndex,assetTypes:['main','detail'],referenceImages:uploadedReferences,userDirection:userDirection(),fissionPattern:fissionEnabled(),matchReferenceShooting:matchReferenceShooting(),promptOverrides};
|
||||
if (context.source) previewBody.source=context.source; else if (context.data) previewBody.reportData=context.data;
|
||||
fetch('/api/image-prompt-preview',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(previewBody)}).then(response=>readJsonResponse(response,'/api/image-prompt-preview')).then(payload=>{
|
||||
if (!payload.ok) throw new Error(payload.error||'用户补充判断失败');
|
||||
const promptIndexes={main:0,detail:0};
|
||||
previewSlots=arr(payload.slots).map(slot=>{
|
||||
const index=promptIndexes[slot.assetType]++;
|
||||
const key=`${slot.assetType}:${slot.index}`;
|
||||
return {...slot,prompt:arr(payload.prompts?.[slot.assetType])[index]||'',promptMerge:payload.promptMerges?.[key],userDirection:payload.userDirectionBySlot?.[key]};
|
||||
});
|
||||
refreshGenerationConfirmation();
|
||||
if (generateStatus && payload.userDirection?.raw) generateStatus.textContent=directionNote(payload.userDirection);
|
||||
}).catch(error=>{if(generateStatus) generateStatus.textContent=error.message||'用户补充判断失败';});
|
||||
});
|
||||
dialog.querySelector('[data-close-plan]')?.addEventListener('click',()=>dialog.close ? dialog.close() : dialog.removeAttribute('open'));
|
||||
loadImageModels();
|
||||
}
|
||||
function openImagePreview(items,startIndex=0) {
|
||||
const list=arr(items).filter(item=>item?.url);
|
||||
if (!list.length) return;
|
||||
let index=Math.max(0,Math.min(startIndex,list.length-1));
|
||||
let dialog=document.querySelector('[data-image-preview-dialog]');
|
||||
if (!dialog) {
|
||||
dialog=document.createElement('dialog');
|
||||
dialog.className='image-preview-dialog';
|
||||
dialog.setAttribute('data-image-preview-dialog','');
|
||||
dialog.innerHTML='<button class="image-preview-close" type="button" data-preview-close aria-label="关闭">×</button><button class="image-preview-nav image-preview-prev" type="button" data-preview-prev aria-label="上一张">‹</button><figure><img data-preview-image alt=""><figcaption><b data-preview-title></b><span data-preview-count></span></figcaption></figure><button class="image-preview-nav image-preview-next" type="button" data-preview-next aria-label="下一张">›</button>';
|
||||
document.body.append(dialog);
|
||||
dialog.addEventListener('click',event=>{if(event.target===dialog||event.target.closest('[data-preview-close]')) dialog.close();});
|
||||
dialog.addEventListener('keydown',event=>{if(event.key==='ArrowLeft'){event.preventDefault();dialog.querySelector('[data-preview-prev]')?.click();}if(event.key==='ArrowRight'){event.preventDefault();dialog.querySelector('[data-preview-next]')?.click();}});
|
||||
}
|
||||
const render=()=>{
|
||||
const item=list[index]||list[0], img=dialog.querySelector('[data-preview-image]');
|
||||
img.src=item.url; img.alt=item.title||'生成图片';
|
||||
dialog.querySelector('[data-preview-title]').textContent=item.title||'生成图片';
|
||||
dialog.querySelector('[data-preview-count]').textContent=`${index+1} / ${list.length}`;
|
||||
};
|
||||
dialog.querySelector('[data-preview-prev]').onclick=()=>{index=(index-1+list.length)%list.length;render();};
|
||||
dialog.querySelector('[data-preview-next]').onclick=()=>{index=(index+1)%list.length;render();};
|
||||
render();
|
||||
if (typeof dialog.showModal === 'function') dialog.showModal(); else dialog.setAttribute('open','');
|
||||
}
|
||||
function initGenerationHistory(context={}) {
|
||||
const root=document.querySelector('[data-generation-history]');
|
||||
if (!root) return;
|
||||
const endpoint='/api/image-jobs'+(context.source?'?source='+encodeURIComponent(context.source):'');
|
||||
const dialog=document.querySelector('[data-generation-history-dialog]');
|
||||
const trigger=document.querySelector('[data-generation-history-open]');
|
||||
const countNode=document.querySelector('[data-generation-history-count]');
|
||||
let timer=null;
|
||||
const openHistory=()=>{
|
||||
if (dialog && typeof dialog.showModal === 'function') dialog.showModal();
|
||||
else if (dialog) dialog.setAttribute('open','');
|
||||
};
|
||||
trigger?.addEventListener('click',openHistory);
|
||||
dialog?.addEventListener('click',event=>{
|
||||
if (event.target===dialog || event.target.closest('[data-generation-history-close]')) {
|
||||
if (typeof dialog.close === 'function') dialog.close();
|
||||
else dialog.removeAttribute('open');
|
||||
}
|
||||
});
|
||||
const render=records=>{
|
||||
const items=arr(records);
|
||||
if (countNode) {
|
||||
countNode.textContent=items.length>99?'99+':String(items.length);
|
||||
countNode.hidden=!items.length;
|
||||
}
|
||||
if (!items.length) { root.innerHTML='<p class="generation-history-empty">还没有生成记录。选择具体开品方案后,可异步生成主图与详情图。</p>'; return; }
|
||||
root.innerHTML=items.map(job=>{
|
||||
const failedCount=arr(job.failedSlots).length;
|
||||
const status=job.status==='complete'?(failedCount?`部分完成 · 失败 ${failedCount} 张`:'已完成'):job.status==='error'?'失败':`生成中 ${Number(job.progress||0)}%`;
|
||||
const thumbs=orderedResults(job.results).slice(0,15).map(item=>`<button type="button" data-history-preview-url="${esc(item.url)}" data-history-preview-title="${esc(`${modelLabel(item)} · ${item.assetType==='detail'?'详情图':'主图'} ${String(item.slotIndex||'').padStart(2,'0')}`)}"><img loading="lazy" src="${esc(item.url)}" alt="${esc(`${modelLabel(item)} · ${job.planName||'开品方案'}生成图`)}"></button>`).join('');
|
||||
const date=job.createdAt?new Date(Number(job.createdAt)*1000).toLocaleString('zh-CN',{hour12:false}):'';
|
||||
const shootPolicy=job.matchReferenceShooting?'已按参考图拍摄与展示状态一致':'按任务重新设计拍摄与展示';
|
||||
const download=job.jobId && arr(job.results).length ? `<a class="generation-download" href="/api/image-job/${encodeURIComponent(job.jobId)}/download" download>打包下载</a>` : '';
|
||||
const resultCount=arr(job.results).length;
|
||||
const modelSummary=arr(job.comparisonModels).map(item=>`${item.label||item.id}(质量:${modelQuality(item)})`).filter(Boolean).join('、');
|
||||
return `<article class="generation-record"><header><div><span>${job.comparisonMode?'模型对比任务':'开品方案'}</span><h3>${esc(job.planName||'未命名开品方案')}</h3><p>${esc(job.productName||'')} ${date?'· '+esc(date):''}</p></div><div class="generation-record-actions"><b class="generation-status generation-status-${esc(job.status||'queued')}">${esc(status)}</b>${download}</div></header><p class="generation-record-policy">${modelSummary?`对比模型:${esc(modelSummary)} · `:''}${shootPolicy} · ${job.referenceShootingPolicy==='match_reference'?'锁定机位/构图/光线/动作/摆放':'仅锁定产品身份'}</p>${job.error?`<p class="generation-error">${esc(job.error)}</p>`:''}${thumbs?`<div class="generation-thumbs">${thumbs}</div>`:''}<div class="generation-record-meta"><small>记录编号:${esc(job.jobId||'')}</small>${resultCount?`<small>已保存 ${resultCount} 张图片</small>`:''}</div></article>`;
|
||||
}).join('');
|
||||
};
|
||||
root.addEventListener('click',event=>{
|
||||
const button=event.target.closest('[data-history-preview-url]');
|
||||
if (!button) return;
|
||||
const record=button.closest('.generation-record')||root;
|
||||
const buttons=[...record.querySelectorAll('[data-history-preview-url]')];
|
||||
openImagePreview(buttons.map(node=>({url:node.dataset.historyPreviewUrl,title:node.dataset.historyPreviewTitle||'生成图片'})),buttons.indexOf(button));
|
||||
});
|
||||
const load=async()=>{
|
||||
clearTimeout(timer);
|
||||
try {
|
||||
const response=await fetch(endpoint,{cache:'no-store'}), payload=await readJsonResponse(response,endpoint);
|
||||
if (!response.ok||!payload.ok) throw new Error(payload.error||'生成记录读取失败');
|
||||
render(payload.records);
|
||||
if (arr(payload.records).some(x=>!['complete','error'].includes(x.status))) timer=setTimeout(load,1800);
|
||||
} catch(error) {
|
||||
if (countNode) countNode.hidden=true;
|
||||
root.innerHTML=`<p class="generation-history-empty">${esc(error.message||'生成记录读取失败')}</p>`;
|
||||
}
|
||||
};
|
||||
load();
|
||||
window.addEventListener('image-job-submitted',load);
|
||||
window.addEventListener('image-job-finished',load);
|
||||
}
|
||||
async function main() {
|
||||
const query = new URLSearchParams(location.search);
|
||||
@@ -306,7 +954,7 @@ async function main() {
|
||||
else {
|
||||
const response = await fetch(source,{cache:'no-store'});
|
||||
if (!response.ok) throw new Error(`报告数据读取失败(${response.status})`);
|
||||
data = await response.json();
|
||||
data = await readJsonResponse(response,String(source));
|
||||
}
|
||||
evidenceIndex = new Map(arr(data.evidenceLedger).map(item=>[item.id,item]));
|
||||
const exp = data.experienceSolution || {}, roles = data.roleOutputs || {}, facts = data.facts || {};
|
||||
@@ -342,10 +990,48 @@ async function main() {
|
||||
const navNames = {decision:'老板速览',detail:'详情',review:'评论',verification:'验证闭环',roles:'完整分析'};
|
||||
const navLinks = bodies.filter(([id])=>navNames[id]).map(([id])=>`<a href="#${id}">${navNames[id]}</a>`).join('');
|
||||
const hasPlans = bodies.some(([id])=>id==='plan');
|
||||
const heroStats = [['当前价格',sales.currentPrice,'price'],['评论样本',data.baseline?.reviewCount,'sample'],['数据质量',data.dataQuality?.grade,'status']].filter(([,value])=>hasContent(value));
|
||||
const heroStats = [['当前价格',sales.currentPrice,'price'],['评论样本',data.baseline?.reviewCount,'sample'],['状态',data.dataQuality?.grade,'status']].filter(([,value])=>hasContent(value));
|
||||
document.title = summary.title || product.title || '商品开品分析报告';
|
||||
document.getElementById('app').innerHTML = `<div class="report-top"><nav class="report-nav"><div class="wrap"><a class="brand" href="#top">三笙 · 商品开品分析</a><div class="report-nav-actions">${navLinks}${hasPlans?'<a class="quick-product-btn" href="#plan">快速开品 <span>→</span></a>':''}</div></div></nav><header id="top" class="report-hero hero-carousel" tabindex="0">${heroSlides?`<div class="hero-slides">${heroSlides}</div><div class="hero-shade"></div>`:''}<div class="wrap hero-overlay"><div class="hero-copy"><div class="report-kicker">PRODUCT DECISION REPORT · EVIDENCE BASED</div><h1>${esc(summary.title || product.title || '商品分析报告')}</h1>${hasContent(summary.verdict)?`<p>${esc(summary.verdict)}</p>`:''}${auditNotice?`<p class="report-audit-notice" role="status">${esc(auditNotice)}</p>`:''}${heroStats.length?`<div class="hero-meta">${heroStats.map(([label,value,tone])=>`<span class="hero-stat hero-stat-${tone}"><small>${label}</small><b>${esc(value)}</b></span>`).join('')}</div>`:''}</div>${heroDeck?`<div class="hero-deck" aria-label="商品主图牌组">${heroDeck}</div>`:''}${orderedMain.length>1?`<div class="hero-carousel-ui"><button type="button" data-carousel-prev aria-label="上一张主图">←</button><div class="hero-dots">${heroDots}</div><button type="button" data-carousel-next aria-label="下一张主图">→</button></div>`:''}</div></header></div>${sectionHtml}${hasPlans?'<dialog id="quick-plan-dialog" class="quick-plan-dialog"><button class="dialog-close" type="button" data-close-plan aria-label="关闭">×</button><span>QUICK PRODUCT BRIEF</span><h2 data-plan-title>快速开品方案</h2><p data-plan-reason></p><dl><div><dt>产品动作</dt><dd data-plan-product></dd></div><div><dt>页面动作</dt><dd data-plan-page></dd></div><div><dt>验证指标</dt><dd data-plan-validation></dd></div></dl><button class="copy-plan-btn" type="button" data-copy-plan>复制开品任务</button></dialog>':''}<footer class="report-footer"><div class="wrap"><b>数据边界</b> 本报告仅使用本次采集证据;评论主题可以重叠,未完成采集时不宣称平台全量。</div></footer>`;
|
||||
document.getElementById('app').innerHTML = `<div class="report-top"><nav class="report-nav"><div class="wrap"><a class="brand" href="#top">三笙 · 商品开品分析</a><div class="report-nav-actions">${navLinks}${hasPlans?'<a class="quick-product-btn" href="#plan">快速开品 <span>→</span></a>':''}</div></div></nav><header id="top" class="report-hero hero-carousel" tabindex="0">${heroSlides?`<div class="hero-slides">${heroSlides}</div><div class="hero-shade"></div>`:''}<div class="wrap hero-overlay"><div class="hero-copy"><div class="report-kicker">PRODUCT DECISION REPORT · EVIDENCE BASED</div><h1>${esc(summary.title || product.title || '商品分析报告')}</h1>${hasContent(summary.verdict)?`<p>${esc(summary.verdict)}</p>`:''}${auditNotice?`<p class="report-audit-notice" role="status">${esc(auditNotice)}</p>`:''}${heroStats.length?`<div class="hero-meta">${heroStats.map(([label,value,tone])=>`<span class="hero-stat hero-stat-${tone}"><small>${label}</small><b>${esc(value)}</b></span>`).join('')}</div>`:''}</div>${heroDeck?`<div class="hero-deck" aria-label="商品主图牌组">${heroDeck}</div>`:''}${orderedMain.length>1?`<div class="hero-carousel-ui"><button type="button" data-carousel-prev aria-label="上一张主图">←</button><div class="hero-dots">${heroDots}</div><button type="button" data-carousel-next aria-label="下一张主图">→</button></div>`:''}</div></header></div>${sectionHtml}${hasPlans?'<dialog id="quick-plan-dialog" class="quick-plan-dialog"><button class="dialog-close" type="button" data-close-plan aria-label="关闭弹窗">×</button><div class="quick-plan-top"><section class="product-brief"><span>PRODUCT LAUNCH BRIEF · 产品开品说明</span><h2 data-plan-title>快速开品方案</h2><dl><div><dt>产品开品</dt><dd data-plan-product></dd></div><div><dt>主图 / 详情打造</dt><dd data-plan-page></dd></div></dl><div class="brief-controls"><label class="fission-pattern-toggle match-reference-shooting-toggle" data-match-reference-wrap><input type="checkbox" data-match-reference-shooting><span><b>产品图身份 + 采集参考图展示状态(产品展示与参考图拍摄一致)</b><small data-match-reference-note>请同时准备产品图和采集商品对应参考图,才能启用展示状态一致。</small></span></label><label class="fission-pattern-toggle" data-fission-wrap><input type="checkbox" data-fission-pattern checked> 默认裂变花型,先生成相近商品基准图</label><label class="image-user-direction"><span>用户补充要求</span><textarea data-user-direction rows="3" placeholder="例如:更偏高端酒店感、浅色背景、突出面料垂坠;不要填写价格、品牌Logo、认证或改商品结构。"></textarea></label></div></section><section class="reference-upload" aria-label="商品主图参考"><header><b>产品图(商品身份)</b><span>本地上传 · 最多 4 张</span></header><div class="reference-list" data-reference-list></div><div class="reference-empty"><span>+</span><b>点击或拖拽上传参考图</b><small>本地上传 · 最多 4 张</small></div><label class="reference-upload-btn">+ 上传产品图<input type="file" accept="image/*" multiple data-reference-input></label><p data-reference-hint></p><em>提示:上传主体清晰、结构完整的产品图;采集商品对应图片自动作为展示状态参考,两者不是同一张图。</em></section></div><section class="image-generation-panel"><header><div><span>AI VISUAL PRODUCTION</span><h3>AI 生图任务</h3><p>勾选任意图片任务,可横向滑动查看;主图最多 5 张,详情图最多 15 张。</p></div><button class="generate-images-btn" type="button" data-generate-images disabled>正在计算生成数量…</button></header><div class="image-generation-controls" data-generation-controls><b>默认勾选:5 张主图 + 6 张详情图。可按需增减。</b><label><input type="checkbox" data-select-type="main"> 生主图 <span>0/0</span></label><label><input type="checkbox" data-select-type="detail"> 生详情 <span>0/0</span></label></div><p class="image-generation-status" data-generate-status aria-live="polite"></p><div class="generated-image-results" data-generate-results></div></section></dialog>':''}<footer class="report-footer"><div class="wrap"><b>数据边界</b> 本报告仅使用本次采集证据;评论主题可以重叠,未准备采集证据时不宣称页面全量。</div></footer>`;
|
||||
const modelControls=document.createElement('div');
|
||||
modelControls.className='image-model-controls';
|
||||
modelControls.setAttribute('data-image-model-controls','');
|
||||
modelControls.innerHTML='<span class="image-model-loading">正在读取生图模型…</span>';
|
||||
const generationControlsNode=document.querySelector('[data-generation-controls]');
|
||||
generationControlsNode?.after(modelControls);
|
||||
const generationHeading=document.querySelector('#quick-plan-dialog .image-generation-panel h3');
|
||||
if (generationHeading) generationHeading.textContent='选择要生成的图片';
|
||||
const generationButton=document.querySelector('#quick-plan-dialog [data-generate-images]');
|
||||
const generationStatus=document.querySelector('#quick-plan-dialog [data-generate-status]');
|
||||
const generationRunSummary=document.createElement('div');
|
||||
generationRunSummary.className='generation-run-summary';
|
||||
generationRunSummary.setAttribute('data-generation-run-summary','');
|
||||
generationRunSummary.hidden=true;
|
||||
generationStatus?.after(generationRunSummary);
|
||||
const historyTrigger=document.createElement('button');
|
||||
historyTrigger.className='generation-history-fab';
|
||||
historyTrigger.type='button';
|
||||
historyTrigger.setAttribute('data-generation-history-open','');
|
||||
historyTrigger.setAttribute('aria-label','打开生成记录');
|
||||
historyTrigger.innerHTML='<span class="generation-history-fab-icon" aria-hidden="true">↺</span><span>生成记录</span><b data-generation-history-count hidden>0</b>';
|
||||
const historyDialog=document.createElement('dialog');
|
||||
historyDialog.className='generation-history-dialog';
|
||||
historyDialog.setAttribute('data-generation-history-dialog','');
|
||||
historyDialog.innerHTML='<header class="generation-history-dialog-head"><div><span>AI VISUAL PRODUCTION</span><h2>生成记录</h2><p>按任务查看生成结果;点击图片查看原图,已有成果可直接打包下载。</p></div><button class="dialog-close" type="button" data-generation-history-close aria-label="关闭生成记录">×</button></header><div class="generation-history" data-generation-history><p class="generation-history-empty">正在读取生成记录…</p></div>';
|
||||
document.body.append(historyTrigger,historyDialog);
|
||||
initHeroCarousel();
|
||||
initPlanActions(plans);
|
||||
const userDirectionField=document.querySelector('[data-user-direction]');
|
||||
if (userDirectionField) {
|
||||
userDirectionField.previousElementSibling.textContent='用户最终要求';
|
||||
userDirectionField.placeholder='例如:改成天丝棉材质、颜色改为浅灰;或更偏高端酒店感、背景留白更大。不要填写价格、品牌Logo、认证、二维码等平台禁区。';
|
||||
}
|
||||
const footer=document.querySelector('.report-footer .wrap');
|
||||
if (footer) {
|
||||
const brand=document.createElement('div'); brand.className='report-footer-brand'; brand.innerHTML='<b>三笙AI</b><span>商品开品分析</span>';
|
||||
footer.prepend(brand);
|
||||
footer.append(document.createElement('br'),'参数事实、验证门与验证闭环统一收录在底部完整分析区。');
|
||||
}
|
||||
initPlanActions(plans,{source:typeof source==='string'?source:'',data:typeof source==='object'?data:null});
|
||||
initGenerationHistory({source:typeof source==='string'?source:'',data:typeof source==='object'?data:null});
|
||||
}
|
||||
main().catch(error => { document.getElementById('app').innerHTML = `<div class="report-error"><h1>报告加载失败</h1><p>${esc(error.message)}</p></div>`; });
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'server'))
|
||||
@@ -47,13 +48,13 @@ class EvidenceOnlyReportTest(unittest.TestCase):
|
||||
'meta': {},
|
||||
}
|
||||
|
||||
def test_empty_model_result_still_generates_reviewable_report(self):
|
||||
def test_empty_model_result_repairs_core_modules_before_report(self):
|
||||
result = ensure_experience_solution(self.analysis)
|
||||
self.assertFalse(result['meta']['fallbackReport'])
|
||||
self.assertTrue(result['meta']['reportReady'])
|
||||
self.assertFalse(result['meta']['reportAuditPassed'])
|
||||
self.assertEqual('needs_review', result['meta']['reportQuality'])
|
||||
self.assertIn('审核未通过', result['meta']['reportNotice'])
|
||||
self.assertTrue(result['meta']['reportAuditPassed'])
|
||||
self.assertEqual('passed', result['meta']['reportQuality'])
|
||||
self.assertEqual([], result['meta']['reportValidation']['missing'])
|
||||
self.assertEqual('', result['facts']['product']['title'])
|
||||
self.assertEqual(['面料支数', '床单面料材质', '适用床尺寸'], [x['name'] for x in result['facts']['attributes']])
|
||||
self.assertEqual(2, result['baseline']['reviewCount'])
|
||||
@@ -62,9 +63,20 @@ class EvidenceOnlyReportTest(unittest.TestCase):
|
||||
self.assertTrue(cards)
|
||||
self.assertEqual('下一款明确卖点', cards[0]['label'])
|
||||
self.assertIn('ATTR_0003', cards[0]['evidenceIds'])
|
||||
self.assertEqual([], result['experienceSolution']['productExperience']['gates'])
|
||||
self.assertGreaterEqual(len(result['experienceSolution']['productExperience']['parameterFacts']), 2)
|
||||
self.assertGreaterEqual(len(result['experienceSolution']['productExperience']['gates']), 2)
|
||||
self.assertGreaterEqual(len(result['experienceSolution']['validationLoop']['rows']), 3)
|
||||
html = render(result, 'fixture')
|
||||
self.assertIn('审核未通过', html)
|
||||
self.assertNotIn('审核未通过', html)
|
||||
|
||||
def test_unrepairable_core_audit_blocks_report(self):
|
||||
with patch('analysis.repair_core_solution_with_evidence', side_effect=lambda draft, raw, base=None: draft):
|
||||
result = ensure_experience_solution(self.analysis)
|
||||
self.assertFalse(result['meta']['reportReady'])
|
||||
self.assertFalse(result['meta']['reportAuditPassed'])
|
||||
self.assertEqual('blocked', result['meta']['reportQuality'])
|
||||
self.assertIn('已停止生成', result['meta']['reportBlockedReason'])
|
||||
self.assertNotIn('reportNotice', result['meta'])
|
||||
|
||||
def test_analysis_runs_without_model_channel_uses_evidence_fallback(self):
|
||||
raw = dict(self.analysis['facts'])
|
||||
@@ -194,7 +206,10 @@ class EvidenceOnlyReportTest(unittest.TestCase):
|
||||
self.assertIn('材质 / 成分', labels)
|
||||
self.assertEqual([], signals['risks'])
|
||||
self.assertTrue(signals['questions'][0]['label'].startswith('购买前关注:'))
|
||||
self.assertEqual(2, len(result['experienceSolution']['newProductPlans']['plans']))
|
||||
plans = result['experienceSolution']['newProductPlans']['plans']
|
||||
self.assertEqual(3, len(plans))
|
||||
self.assertEqual(['证据驱动优化', '反馈驱动升级', '探索性方向'], [x['sourceType'] for x in plans])
|
||||
self.assertTrue(all(x['name'].endswith('款') for x in plans))
|
||||
self.assertNotIn('无证据升级', output)
|
||||
self.assertNotIn('项目负责人', output)
|
||||
self.assertNotIn('客户为什么购买', output)
|
||||
@@ -204,7 +219,7 @@ class EvidenceOnlyReportTest(unittest.TestCase):
|
||||
doc = render(result, 'evidence-only')
|
||||
self.assertIn('window.REPORT_DATA=', doc)
|
||||
self.assertIn('Sansong Product Intelligence V9.3.0', doc)
|
||||
self.assertIn('<script src="/report.js"></script>', doc)
|
||||
self.assertIn('<script src="/report.js?v=', doc)
|
||||
self.assertNotIn('项目负责人', doc)
|
||||
|
||||
def test_incomplete_final_editor_output_is_repaired_from_real_evidence(self):
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import copy
|
||||
import json
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'server'))
|
||||
import server as app
|
||||
|
||||
|
||||
class ImageUserIntentTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
app.IMAGE_INTENT_CACHE.clear()
|
||||
self.report={'facts':{'product':{'title':'被子','category':'床品'},'attributes':{'尺寸':'200x230'}}}
|
||||
self.plan={'name':'方案内部名称','productAction':'舒适睡眠','pageAction':'保持信息层级'}
|
||||
self.slots=[{'assetType':'main','index':i,'task':['默认槽位任务'],
|
||||
'referenceTextInfo':'被子尺寸200x230',
|
||||
'referenceAnalysis':{'theme':'舒适支撑','composition':'上方文字、下方商品','imageObservation':{'textLayout':'左对齐'}}}
|
||||
for i in (1,2)]
|
||||
self.response={'productUpdates':[{'field':'category','value':'枕头','sourceText':'品类换成枕头'}],
|
||||
'globalInstructions':['整套产品换成枕头,保留舒适睡眠方案方向'],
|
||||
'perSlot':{f'main:{i}':{'instructions':['文案随枕头调整'], 'theme':'枕头舒适支撑',
|
||||
'display':'保持参考构图,枕头不执行被子折叠动作',
|
||||
'copy':'生成枕头支撑文案,保持参考文字位置大小,不沿用被子尺寸'} for i in (1,2)}}
|
||||
|
||||
def resolve(self,text='品类换成枕头,文案跟随改变',edits=None):
|
||||
return app._resolve_image_user_intent(self.report,self.plan,self.slots,app._image_user_direction(text),edits or {},[],True)
|
||||
|
||||
def test_empty_input_uses_plan_without_analysis(self):
|
||||
direction=app._image_user_direction('')
|
||||
with patch.object(app,'chat_json') as model:
|
||||
self.assertIs(direction,app._resolve_image_user_intent(self.report,self.plan,self.slots,direction,{},[],True))
|
||||
model.assert_not_called()
|
||||
prompt=app.build_image_prompt(self.report,self.plan,slot=self.slots[0],user_direction=direction)
|
||||
self.assertIn('舒适睡眠',prompt)
|
||||
self.assertNotIn('统一产品—方案—参考关系',prompt)
|
||||
|
||||
def test_category_copy_and_reference_are_resolved_for_every_image(self):
|
||||
original=copy.deepcopy(self.report)
|
||||
with patch.object(app,'chat_json',return_value=self.response) as model:
|
||||
direction=self.resolve()
|
||||
request=json.loads(model.call_args.args[1])
|
||||
self.assertEqual(self.plan,request['plan'])
|
||||
self.assertEqual(self.slots[0]['referenceAnalysis'],request['images'][0]['referenceAnalysis'])
|
||||
lock=app.build_identity_lock(self.report,self.plan,direction,True)
|
||||
self.assertEqual('枕头',lock['productName'])
|
||||
for slot in self.slots:
|
||||
per_slot=app._user_direction_for_slot(direction,slot)
|
||||
prompt=app.build_image_prompt(self.report,self.plan,slot=slot,user_direction=per_slot,
|
||||
identity_lock=lock,product_reference_mode='uploaded_identity_collected_reference',match_reference_shooting=True)
|
||||
self.assertIn('当前品类以用户要求为准:枕头',prompt)
|
||||
self.assertIn('舒适睡眠',prompt)
|
||||
self.assertIn('生成枕头支撑文案',prompt)
|
||||
self.assertIn('不沿用被子尺寸',prompt)
|
||||
self.assertNotIn('商品外观完全由第1张上传产品图决定',prompt)
|
||||
self.assertNotIn('商品品类完全一致',prompt)
|
||||
self.assertEqual(original,self.report)
|
||||
|
||||
def test_preview_generation_share_result_but_changed_input_is_reinterpreted(self):
|
||||
with patch.object(app,'chat_json',return_value=self.response) as model:
|
||||
preview=self.resolve()
|
||||
execution=self.resolve()
|
||||
self.assertEqual(preview,execution)
|
||||
model.assert_called_once()
|
||||
self.resolve('品类换成枕头,标题改为舒适支撑')
|
||||
self.assertEqual(2,model.call_count)
|
||||
|
||||
def test_long_global_and_per_image_input_arrives_complete(self):
|
||||
text='品类换成枕头;'+('要求保持每张参考图的文字位置并调整文案;'*100)
|
||||
edit='标题写成舒适支撑;'+('保留文字层级和完整商品展示;'*600)
|
||||
with patch.object(app,'chat_json',return_value=self.response) as model:
|
||||
self.resolve(text,{'main:1':edit})
|
||||
request=json.loads(model.call_args.args[1])
|
||||
self.assertEqual(text,request['userInput'])
|
||||
self.assertEqual(edit,request['perImageInput']['main:1'])
|
||||
self.assertEqual(text,app._image_user_direction(text)['raw'])
|
||||
|
||||
def test_per_image_visual_edit_is_not_promoted_to_other_images(self):
|
||||
response=copy.deepcopy(self.response)
|
||||
response['productUpdates']=[]
|
||||
response['globalInstructions']=[]
|
||||
response['perSlot']['main:1']['instructions']=['标题写成舒适支撑']
|
||||
response['perSlot']['main:2']['instructions']=[]
|
||||
with patch.object(app,'chat_json',return_value=response):
|
||||
direction=self.resolve('',{'main:1':'标题写成舒适支撑'})
|
||||
self.assertEqual(['标题写成舒适支撑'],app._user_direction_for_slot(direction,self.slots[0])['accepted'])
|
||||
self.assertEqual([],app._user_direction_for_slot(direction,self.slots[1])['accepted'])
|
||||
base='已经完成统一判断的提示词'
|
||||
result,_=app.merge_image_prompt_with_user_edit(base,'标题写成舒适支撑',self.slots[0],direction)
|
||||
self.assertEqual(base,result)
|
||||
|
||||
def test_product_change_in_single_image_input_is_shared(self):
|
||||
with patch.object(app,'chat_json',return_value=self.response):
|
||||
direction=self.resolve('',{'main:1':'品类换成枕头'})
|
||||
for slot in self.slots:
|
||||
self.assertEqual('枕头',app._user_direction_for_slot(direction,slot)['productUpdates'][0]['value'])
|
||||
|
||||
def test_missing_image_or_invented_update_preserves_input_without_adopting_invention(self):
|
||||
for invalid in ('missing_slot','invented_update'):
|
||||
response=copy.deepcopy(self.response)
|
||||
if invalid=='missing_slot':del response['perSlot']['main:2']
|
||||
else:response['productUpdates'][0]['sourceText']='用户没有要求的新品'
|
||||
with self.subTest(invalid=invalid),patch.object(app,'chat_json',return_value=response):
|
||||
result=self.resolve()
|
||||
self.assertEqual('品类换成枕头,文案跟随改变',result['raw'])
|
||||
self.assertIn('main:2',result['resolvedBySlot'])
|
||||
if invalid=='invented_update':self.assertEqual([],result['productUpdates'])
|
||||
app.IMAGE_INTENT_CACHE.clear()
|
||||
|
||||
def test_expression_request_and_analysis_failure_never_block_generation_prompt(self):
|
||||
for response in ({'productUpdates':[{'field':'text','value':'无字','sourceText':'图片上不要有文字'}]},None):
|
||||
app.IMAGE_INTENT_CACHE.clear()
|
||||
with patch.object(app,'chat_json',return_value=response):
|
||||
direction=self.resolve('图片上不要有文字')
|
||||
slot=self.slots[0]
|
||||
per_slot=app._user_direction_for_slot(direction,slot)
|
||||
prompt=app.build_image_prompt(self.report,self.plan,slot=slot,user_direction=per_slot)
|
||||
self.assertIn('图片上不要有文字',prompt)
|
||||
self.assertNotIn('生成并真实绘制当前产品中文文案',prompt)
|
||||
self.assertNotIn('Render Chinese copy',prompt)
|
||||
self.assertLess(prompt.index('本图用户完整输入'),prompt.index('对应参考图职责'))
|
||||
app.IMAGE_INTENT_CACHE.clear()
|
||||
with patch.object(app,'chat_json',side_effect=RuntimeError('分析接口不可用')):
|
||||
direction=self.resolve('背景换成室内,主体不变')
|
||||
self.assertEqual('背景换成室内,主体不变',direction['raw'])
|
||||
self.assertEqual('分析接口不可用',direction['analysisRecord']['error'])
|
||||
|
||||
def test_rejected_identity_inference_cannot_leak_into_reference_display(self):
|
||||
response=copy.deepcopy(self.response)
|
||||
response['productUpdates']=[{'field':'category','value':'四件套','sourceText':'图片是床品套装'}]
|
||||
response['perSlot']['main:1']['display']='被套和枕套铺展在床上'
|
||||
response['globalInstructions']=['保留床铺场景']
|
||||
self.slots[0]['referenceAnalysis']['composition']='折叠商品居中,背景纯色'
|
||||
with patch.object(app,'chat_json',return_value=response):
|
||||
direction=self.resolve('图片上不要有文字')
|
||||
per_slot=app._user_direction_for_slot(direction,self.slots[0])
|
||||
prompt=app.build_image_prompt(self.report,self.plan,slot=self.slots[0],user_direction=per_slot,match_reference_shooting=True)
|
||||
self.assertIn('折叠商品居中,背景纯色',prompt)
|
||||
self.assertIn('图片上不要有文字',prompt)
|
||||
self.assertNotIn('被套和枕套铺展在床上',prompt)
|
||||
self.assertNotIn('保留床铺场景',prompt)
|
||||
self.assertEqual(response,direction['analysisRecord']['response'])
|
||||
self.assertLess(prompt.index('对应图展示状态执行依据'),prompt.index('辅助解析资料'))
|
||||
|
||||
def test_spatial_relationships_are_shared_by_default_and_user_paths(self):
|
||||
self.slots[0]['referenceAnalysis']['displayRelations']={
|
||||
'visibleParts':[{'id':'a','description':'悬挂主体'},{'id':'b','description':'支撑位置'}],
|
||||
'relations':[{'type':'接触','from':'a','to':'b','observation':'顶部接触支撑,右侧遮挡'}],
|
||||
'uncertainAreas':['背面连接不可见']}
|
||||
with patch.object(app,'chat_json',return_value={}):
|
||||
direction=self.resolve('保持产品,调整文字')
|
||||
for d in (app._image_user_direction(''),app._user_direction_for_slot(direction,self.slots[0])):
|
||||
prompt=app.build_image_prompt(self.report,self.plan,slot=self.slots[0],user_direction=d,match_reference_shooting=True)
|
||||
self.assertIn('顶部接触支撑,右侧遮挡',prompt)
|
||||
self.assertIn('背面连接不可见',prompt)
|
||||
self.assertIn('可见层数不等于商品件数',prompt)
|
||||
disabled=app.build_image_prompt(self.report,self.plan,slot=self.slots[0],match_reference_shooting=False)
|
||||
self.assertNotIn('可见展示空间关系|对应原图约束',disabled)
|
||||
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
@@ -0,0 +1,121 @@
|
||||
import base64
|
||||
import json
|
||||
import sys
|
||||
import unittest
|
||||
from email import policy
|
||||
from email.parser import BytesParser
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'server'))
|
||||
import server as app
|
||||
import ai_client
|
||||
|
||||
|
||||
class ReferenceContractTest(unittest.TestCase):
|
||||
def test_original_analysis_survives_rewritten_report_roles(self):
|
||||
report = {
|
||||
'facts': {'images': {'main': ['https://example.test/1.png', 'https://example.test/2.png']}},
|
||||
'evidenceLedger': [
|
||||
{'id': f'IMG_MAIN_{i:04d}', 'type': 'image', 'value': f'https://example.test/{i}.png', 'meta': {'group': 'main'}}
|
||||
for i in (1, 2)
|
||||
],
|
||||
'experienceSolution': {'visualCommerce': {'items': [
|
||||
{'imageEvidenceId': 'IMG_MAIN_0002', 'imageRole': '使用场景', 'visualSignals': ['四季通用']}
|
||||
], 'evidenceDetail': []}},
|
||||
'visualDecision': {
|
||||
'imageRoles': [
|
||||
{'imageEvidenceId': 'IMG_MAIN_0002', 'imageRole': '材质触感', 'visualSignals': ['面料细节'], 'businessMeaning': '面料特写'}
|
||||
],
|
||||
'evidenceDetail': [
|
||||
{'imageEvidenceId': 'IMG_MAIN_0002', 'composition': '面料特写背景,中央信息卡片', 'textInfo': '面料细节'}
|
||||
],
|
||||
},
|
||||
}
|
||||
observation = report['visualDecision']['evidenceDetail'][0]
|
||||
observation['textInfo'] = '面料细节' + '完整参考文字' * 60
|
||||
observation['composition'] += '保持原始布局' * 100
|
||||
observation['textLayout'] = {'position': '中央', 'alignment': 'left', 'lines': 3}
|
||||
observation['displayRelations'] = {'visibleParts':[{'id':'a','description':'上层可见区域'},{'id':'b','description':'回折区域'}],
|
||||
'relations':[{'type':'连接','from':'a','to':'b','observation':'左侧连续回折,不是独立叠放'}]}
|
||||
role = report['visualDecision']['imageRoles'][0]
|
||||
role['visualSignals'] = ['面料细节'] + ['完整分析' * 30] * 6
|
||||
slot = app.build_generation_slots(report, {})[1]
|
||||
self.assertEqual('材质触感', slot['referenceAnalysis']['theme'])
|
||||
self.assertEqual(observation['textInfo'], slot['referenceTextInfo'])
|
||||
self.assertEqual(observation['composition'], slot['referenceAnalysis']['composition'])
|
||||
self.assertEqual(observation, slot['referenceAnalysis']['imageObservation'])
|
||||
self.assertEqual(role, slot['referenceAnalysis']['imageRoleAnalysis'])
|
||||
self.assertEqual(role['visualSignals'], slot['referenceAnalysis']['signals'])
|
||||
self.assertEqual(observation['displayRelations'],slot['referenceAnalysis']['displayRelations'])
|
||||
prompt = app.build_image_prompt(report, {}, slot=slot, variant_index=1,
|
||||
product_reference_mode='uploaded_identity_collected_reference', match_reference_shooting=True)
|
||||
self.assertIn('原始图片主题:材质触感', prompt)
|
||||
self.assertIn('面料特写背景,中央信息卡片', prompt)
|
||||
self.assertNotIn('唯一任务:', prompt)
|
||||
self.assertIn(observation['textInfo'], prompt)
|
||||
self.assertIn(observation['composition'], prompt)
|
||||
self.assertIn('左侧连续回折,不是独立叠放',prompt)
|
||||
|
||||
def test_reference_download_uses_certificate_context_and_exact_bytes(self):
|
||||
image = b'\x89PNG\r\n\x1a\nimage-content'
|
||||
with patch.object(app, '_download_image', return_value=(image, 'image/png')):
|
||||
data_url = app._reference_data_url('https://example.test/reference.png')
|
||||
self.assertEqual(image, base64.b64decode(data_url.split(',', 1)[1]))
|
||||
response = MagicMock()
|
||||
response.__enter__.return_value = response
|
||||
response.read.return_value = image
|
||||
response.headers.get.return_value = 'image/png'
|
||||
with patch.object(app, '_ssl_context', return_value='trusted-context'), \
|
||||
patch.object(app, 'load_config', return_value={}), \
|
||||
patch.object(app.urllib.request, 'urlopen', return_value=response) as open_url:
|
||||
self.assertEqual(image, app._download_image('https://example.test/reference.png')[0])
|
||||
self.assertEqual('trusted-context', open_url.call_args.kwargs['context'])
|
||||
|
||||
def test_apiyi_request_contains_ordered_image_files_and_complete_prompt(self):
|
||||
images = [b'\x89PNG\r\n\x1a\nproduct-image', b'\x89PNG\r\n\x1a\ncollected-image']
|
||||
refs = ['data:image/png;base64,' + base64.b64encode(data).decode() for data in images]
|
||||
prompt = '图1提供产品身份;图2为展示母版。\n保持折叠状态和文字排版。'
|
||||
config = {'api_base': 'https://api.apiyi.com/v1', 'api_key': 'test-key',
|
||||
'image_model': 'gpt-image-2', 'image_path': '/images/generations',
|
||||
'timeout': 10, 'retries': 0}
|
||||
response = MagicMock()
|
||||
response.__enter__.return_value = response
|
||||
response.read.return_value = json.dumps({'data': [{'b64_json': 'result'}]}).encode()
|
||||
with patch.object(ai_client, 'image_channel', return_value=config), \
|
||||
patch.object(ai_client, '_ssl_context', return_value='trusted-context'), \
|
||||
patch.object(ai_client.urllib.request, 'urlopen', return_value=response) as open_url:
|
||||
result = ai_client.image_generate(prompt, model='gpt-image-2', size='1024x1024', quality='high', reference_images=refs)
|
||||
self.assertEqual('result', result['data'][0]['b64_json'])
|
||||
request = open_url.call_args.args[0]
|
||||
self.assertEqual('https://api.apiyi.com/v1/images/edits', request.full_url)
|
||||
content_type = request.get_header('Content-type')
|
||||
message = BytesParser(policy=policy.default).parsebytes(
|
||||
('Content-Type: ' + content_type + '\r\nMIME-Version: 1.0\r\n\r\n').encode() + request.data)
|
||||
fields = {}
|
||||
files = []
|
||||
for part in message.iter_parts():
|
||||
name = part.get_param('name', header='content-disposition')
|
||||
data = part.get_payload(decode=True)
|
||||
if name == 'image[]':
|
||||
files.append(data)
|
||||
else:
|
||||
fields[name] = data.decode('utf-8')
|
||||
self.assertEqual(images, files)
|
||||
self.assertEqual(prompt, fields['prompt'])
|
||||
self.assertEqual({'model': 'gpt-image-2', 'size': '1024x1024', 'quality': 'high', 'n': '1'},
|
||||
{key: value for key, value in fields.items() if key != 'prompt'})
|
||||
|
||||
def test_invalid_reference_never_sends_request(self):
|
||||
config = {'api_base': 'https://api.apiyi.com/v1', 'api_key': 'test-key',
|
||||
'image_model': 'gpt-image-2', 'image_path': '/images/generations',
|
||||
'timeout': 10, 'retries': 0}
|
||||
with patch.object(ai_client, 'image_channel', return_value=config), \
|
||||
patch.object(ai_client.urllib.request, 'urlopen') as open_url:
|
||||
with self.assertRaises(RuntimeError):
|
||||
ai_client.image_generate('保留展示状态', reference_images=['data:image/png;base64,aW52YWxpZA=='])
|
||||
open_url.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -34,7 +34,7 @@ class V920ImageDrivenReportTest(unittest.TestCase):
|
||||
def test_renderer_is_image_driven_and_professional(self):
|
||||
doc=render(self.fixture(),'v920')
|
||||
self.assertIn('window.REPORT_DATA=',doc)
|
||||
self.assertIn('<script src="/report.js"></script>',doc)
|
||||
self.assertIn('<script src="/report.js?v=',doc)
|
||||
self.assertIn('IMG_MAIN_0001',doc)
|
||||
self.assertIn('IMG_DETAIL_0001',doc)
|
||||
self.assertNotIn('v92-main-grid',doc)
|
||||
|
||||