feat: 升级插件至 V1.0、更新全新Logo、调整采集梯度(50/100/200)并支持页面浮窗与弹窗双重进度可视化

This commit is contained in:
喻长发
2026-09-15 14:19:38 +08:00
parent 2c8e7a5435
commit a7f496e635
32 changed files with 4135 additions and 16 deletions
+87
View File
@@ -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(可选)。
+1 -1
View File
@@ -1 +1 @@
9.3.0
1.0.0
+1 -1
View File
@@ -1,5 +1,5 @@
// 切换环境后,在浏览器扩展管理页重新加载扩展并重新登录。
const SANSHENG_ENV='dev'; // dev | test | prod
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'},
+67 -4
View File
@@ -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},
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 627 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

+11 -5
View File
@@ -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",
@@ -26,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": [
{
@@ -48,4 +54,4 @@
"world": "MAIN"
}
]
}
}
+65
View File
@@ -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;
}
+27 -5
View File
@@ -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">
+37
View File
@@ -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';
+87
View File
@@ -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(/&amp;/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}
};
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 627 B

Binary file not shown.

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=>({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;',"'":'&#39;'}[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(/&amp;/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}
};
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 627 B

Binary file not shown.

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=>({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;',"'":'&#39;'}[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')}
};