Add product analysis extension release
This commit is contained in:
+255
-23
@@ -1,15 +1,234 @@
|
||||
const SERVER='http://127.0.0.1:17962';
|
||||
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 health(){ try{const r=await fetch(SERVER+'/health');return r.ok?await r.json():{ok:false}}catch(e){return {ok:false,error:String(e)}} }
|
||||
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,请确认 http://localhost:19174 已启动');
|
||||
}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{
|
||||
@@ -45,27 +264,41 @@ chrome.runtime.onMessage.addListener((m,s,send)=>{
|
||||
raw.collection.analysisStartedBeforeReviewComplete=!raw.collection.reviewCollectionComplete;
|
||||
raw.collection.analysisStartedAt=new Date().toISOString();
|
||||
|
||||
const resp=await fetch('http://127.0.0.1:17962/api/analyze-current',{
|
||||
method:'POST',headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({raw,allowPartialReviews:true})
|
||||
});
|
||||
const j=await resp.json().catch(()=>({ok:false,error:'后端返回无法解析'}));
|
||||
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.sampleMode||'500').then(x=>send(x)).catch(e=>send({ok:false,error:String(e)})); return true; }
|
||||
if(m?.type==='HEALTH'){ health().then(send); 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; }
|
||||
});
|
||||
|
||||
async function run(tabId,manual,sampleMode='500'){
|
||||
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 h=await health();
|
||||
if(!h.ok){ await setStatus(tabId,{state:'server_offline',message:'本地 AI 服务未启动'}); throw new Error('本地 AI 服务未启动,请先运行 根目录的「start-tmall-ai.command」'); }
|
||||
if(!h.modelConfigured){ await setStatus(tabId,{state:'model_unconfigured',message:'模型未配置:请填写 本地设置页'}); throw new Error('模型未配置。请先填写 本地设置页 的 api_key 与 model。'); }
|
||||
const 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()=>{
|
||||
@@ -80,10 +313,10 @@ async function run(tabId,manual,sampleMode='500'){
|
||||
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',sampleMode,message:`正在采集当前商品:主图、SKU、详情、参数、问答与评论(评论样本:${sampleMode==='all'?'全部':sampleMode+' 条'})。不会采集推荐商品…`,progress:10});
|
||||
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,sampleMode]}).then(v=>v).catch(e=>{collectorError=e;return null}).finally(()=>{collectorDone=true});
|
||||
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)}
|
||||
const collectorResult=await collectorPromise;
|
||||
if(collectorError)throw new Error('页面采集脚本执行失败:'+String(collectorError?.message||collectorError));
|
||||
@@ -110,18 +343,17 @@ async function run(tabId,manual,sampleMode='500'){
|
||||
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',sampleMode, message:msg,progress:68,itemId:raw.product.itemId,reviewStopReason:reason,reviewLive:raw.collection?.reviewLive||null,parameterStatus:raw.product?.parameterCollection||null});
|
||||
await setStatus(tabId,{state:stopped?'reviews_stopped':'reviews_incomplete',message:msg,progress:68,itemId:raw.product.itemId,reviewStopReason:reason,reviewLive:raw.collection?.reviewLive||null,parameterStatus:raw.product?.parameterCollection||null});
|
||||
return {ok:false,needsReviewResume:true,message:msg,reviewStopReason:reason,rawStats:{title:raw.product?.title||'',reviews:raw.reviews?.length||0,questions:raw.questions?.length||0,sku:raw.sku?.length||0,attributes:raw.attributes?.length||0,promotions:raw.promotions?.length||0,images:raw.images?.all?.length||0}};
|
||||
}
|
||||
await setStatus(tabId,{state:'sending',message:`评论完整性已确认,共 ${raw.reviews?.length||0} 条。正在生成新品开品闭环…`,progress:78,itemId:raw.product.itemId});
|
||||
const r=await fetch(SERVER+'/api/analyze',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(raw)});
|
||||
const j=await r.json(); if(!r.ok||!j.ok) throw new Error(j.error||'提交分析失败');
|
||||
const j=await submitProductAnalysis(raw);
|
||||
await setStatus(tabId,{state:'analyzing',message:'正在分析全方位产品体验并生成解决方案',progress:82,taskId:j.taskId,taskUrl:j.taskUrl});
|
||||
await chrome.tabs.create({url: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,sampleMode='500'){
|
||||
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();
|
||||
@@ -144,7 +376,7 @@ async function collector(manual=false,sampleMode='500'){
|
||||
const bodyText=clean(bodyClone?.innerText||document.body?.innerText||'');
|
||||
const captured=[];
|
||||
const reviews=[]; const questions=[];
|
||||
const reviewCap=sampleMode==='all'?Infinity:Math.max(1,Math.min(10000,Number(sampleMode)||500));
|
||||
const reviewCap=collectionMode==='quick'?300:(collectionMode==='full'?1500:800);
|
||||
const reviewNetMeta={totals:[],hasMoreFalse:false,lastPageFlags:[],pageHints:[],networkReviewObjects:0,networkResponses:0};
|
||||
const reviewStartedAt=Date.now();
|
||||
const processedCaptureKeys=new Set();
|
||||
@@ -331,7 +563,7 @@ async function collector(manual=false,sampleMode='500'){
|
||||
// 每轮即时解析已捕获网络响应
|
||||
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(Number.isFinite(reviewCap) && after>=reviewCap){collectionComplete=true;explicitEnd=true;stopReason='sample_cap';publishReviewProgress(round+1,stagnant,`已采够 ${reviewCap} 条评论样本`);break}
|
||||
if(collectionMode!=='full' && after>=reviewCap){collectionComplete=true;explicitEnd=true;stopReason='sample_cap';publishReviewProgress(round+1,stagnant,collectionMode==='quick'?'已采够快速样本':'已采够标准样本');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}
|
||||
@@ -581,6 +813,6 @@ async function collector(manual=false,sampleMode='500'){
|
||||
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:Number.isFinite(reviewCap)?reviewCap:null,reviewCollectionMode:sampleMode,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}
|
||||
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}
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user