Add product analysis extension release
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
const SANSHENG_AUTH_CONFIG={
|
||||
apiBase:'http://localhost:19174',
|
||||
pcBase:'http://localhost:5173',
|
||||
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};
|
||||
+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}
|
||||
};
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.2 KiB |
+14
-5
@@ -1,26 +1,35 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "三笙 · 电商商品分析闭环 V9.3.0",
|
||||
"version": "9.3.0",
|
||||
"name": "三笙 · 电商商品分析闭环 V9.2.5",
|
||||
"version": "9.2.5",
|
||||
"description": "跨类目电商商品分析:动态参数与SKU采集、图文分源、评论证据、开品与AI视觉方案。",
|
||||
"permissions": [
|
||||
"scripting",
|
||||
"storage",
|
||||
"tabs"
|
||||
"tabs",
|
||||
"alarms",
|
||||
"notifications"
|
||||
],
|
||||
"host_permissions": [
|
||||
"https://detail.tmall.com/*",
|
||||
"https://*.tmall.com/*",
|
||||
"https://*.taobao.com/*",
|
||||
"https://*.alicdn.com/*",
|
||||
"http://127.0.0.1:17962/*"
|
||||
"https://api.sanshengai.net/*",
|
||||
"https://www.sanshengai.net/*",
|
||||
"http://localhost:19174/*",
|
||||
"http://localhost:5173/*"
|
||||
],
|
||||
"background": {
|
||||
"service_worker": "background.js"
|
||||
},
|
||||
"icons": {
|
||||
"128": "icon-128.png"
|
||||
},
|
||||
"action": {
|
||||
"default_popup": "popup.html",
|
||||
"default_title": "三笙 · 电商商品分析闭环 V9.3.0"
|
||||
"default_title": "三笙 · 电商商品分析闭环 V9.2.5",
|
||||
"default_icon": "icon-128.png"
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
: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; }
|
||||
+128
-38
@@ -1,41 +1,131 @@
|
||||
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><style>
|
||||
body{width:360px;margin:0;padding:18px;font-family:-apple-system,BlinkMacSystemFont,"PingFang SC",sans-serif;background:#f5faf9;color:#10191d}h1{font-size:18px;margin:0 0 6px;background:linear-gradient(110deg,#00d9b6,#008bd8);-webkit-background-clip:text;color:transparent}.sub{font-size:12px;color:#60757c;line-height:1.6}.card{margin-top:14px;padding:14px;background:#fff;border:1px solid #dbe8e8;border-radius:14px}.state{font-weight:800;margin-bottom:8px}.bar{height:6px;background:#e6f0ef;border-radius:8px;overflow:hidden}.bar i{display:block;height:100%;background:linear-gradient(110deg,#00d9b6,#008bd8);width:0}.btn{width:100%;margin-top:12px;border:0;background:linear-gradient(110deg,#00cdb0,#008fd8);color:#fff;padding:11px;border-radius:999px;font-weight:800;cursor:pointer}.secondary{background:#fff;color:#17434b;border:1px solid #a9cfcc}.tiny{font-size:11px;color:#71858a;margin-top:9px}.monitor{display:flex;gap:8px}.monitor .btn{flex:1}.ok{color:#008b78}.warn{color:#a66d18}.file{display:none}.live{margin-top:10px;padding:10px;background:#edf9f7;border-radius:10px;font-size:11px;line-height:1.6;color:#526a70}.live strong{font-size:12px;color:#10191d}.live-grid{display:grid;grid-template-columns:1fr 1fr;gap:4px 10px;margin-top:6px}.live-preview{margin-top:7px;padding-top:7px;border-top:1px solid #d6e8e5;max-height:78px;overflow:auto}.stop{background:#a83f49!important;color:#fff!important;border:0!important}
|
||||
<!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>电商单品洞察</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="text-action" id="openReports" style="display:none">报告记录</button>
|
||||
</div>
|
||||
<p>采集商品、图片、参数和评论,生成可追溯的产品分析报告。</p>
|
||||
</header>
|
||||
|
||||
.analysis-now{background:#fff;color:#1f1d1a;border:1px solid #1f1d1a}
|
||||
.mode-row{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-top:10px}
|
||||
.mode{padding:10px;border:1px solid #ded7ce;border-radius:12px;background:#fff;cursor:pointer;font-size:12px}
|
||||
.mode.active{background:#1f1d1a;color:#fff;border-color:#1f1d1a}
|
||||
.mode small{display:block;opacity:.72;margin-top:4px;line-height:1.45}
|
||||
<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>
|
||||
|
||||
.param-live{margin-top:10px;padding:10px;background:#f6f3ed;border-radius:10px;font-size:11px;line-height:1.6;color:#5f5a54}
|
||||
.param-live strong{font-size:12px;color:#181716}
|
||||
.param-tags{display:flex;flex-wrap:wrap;gap:6px;margin-top:7px}
|
||||
.param-tag{padding:4px 8px;border-radius:999px;background:#fff;border:1px solid #e0d8cf;font-size:11px}
|
||||
.param-miss{margin-top:8px;padding-top:8px;border-top:1px solid #e5dfd6}
|
||||
</style></head><body>
|
||||
<h1>三笙 · 电商商品分析引擎</h1>
|
||||
<div class="sub">采集当前商品后自动识别类目和分析维度,生成标题、主图、详情、SKU、评论、新品设计与 AI 视觉报告。</div>
|
||||
<div class="card"><div id="server" class="state">检查本地服务…</div><div id="status" class="sub">等待当前商品页</div><div class="bar"><i id="bar"></i></div></div>
|
||||
<div class="card sample-card"><div class="state">采集前先选择评论样本</div><div class="sub">只限制评论样本数量;商品信息、参数、图片、问答和完整分析内容都会保留。</div><div class="mode-row">
|
||||
<button data-sample="50" class="mode">50 条<small>快速判断</small></button>
|
||||
<button data-sample="100" class="mode">100 条<small>常规小样本</small></button>
|
||||
<button data-sample="500" class="mode active">500 条<small>推荐</small></button>
|
||||
<button data-sample="all" class="mode">全部<small>采到可访问末页</small></button>
|
||||
<button data-sample="custom" class="mode">自定义<small>输入数量</small></button>
|
||||
</div><div id="customSampleWrap" style="display:none;margin-top:10px"><input id="customSample" type="number" min="1" max="10000" placeholder="输入评论条数(1-10000)"></div><div class="tiny">采集开始后不能切换样本档位;如中途停止,可按原档位继续。</div></div>
|
||||
<button class="btn" id="run">开始深度采集</button>
|
||||
<button class="btn stop" id="stop" style="display:none">终止采集并保存进度</button>
|
||||
<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 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 id="paramLive" class="param-live" style="display:none">
|
||||
<strong>参数采集</strong>
|
||||
<div>状态:<b id="paramState">等待采集</b></div>
|
||||
<div>已识别:<b id="paramCount">0</b> 项</div>
|
||||
<div class="param-tags" id="paramTags"></div>
|
||||
<div class="param-miss" id="paramMissing">缺失项:—</div>
|
||||
</div>
|
||||
<div id="reviewLive" class="live" style="display:none"><strong>评论实时采集</strong><div class="live-grid"><span>去重:<b id="liveCount">0</b></span><span>网络对象:<b id="liveNet">0</b></span><span>当前轮次:<b id="liveRound">0</b></span><span>无新增:<b id="liveStagnant">0</b></span><span>模式:<b id="liveMode">—</b></span><span>耗时:<b id="liveElapsed">0s</b></span></div><div id="livePreview" class="live-preview">等待评论数据…</div></div>
|
||||
<div class="card"><div class="state">分析入口</div><div class="sub">已有采集数据时,可直接生成报告;不会清空当前采集进度。</div><button id="analyzeNow" class="btn analysis-now">基于当前数据开始分析</button></div>
|
||||
<button class="btn secondary" id="open">打开本地服务</button>
|
||||
<div class="tiny">不会采集推荐商品。评论会自动去重、分类和抽样;采集过程中可随时停止并继续。</div>
|
||||
<script src="popup.js"></script>
|
||||
</body></html>
|
||||
<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>最多 300 条</small>
|
||||
</button>
|
||||
<button id="modeStandard" class="mode active">
|
||||
<strong>标准</strong><small>最多 800 条</small>
|
||||
</button>
|
||||
<button id="modeFull" class="mode">
|
||||
<strong>深度</strong><small>最多 1500 条</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>
|
||||
|
||||
+270
-113
@@ -1,132 +1,289 @@
|
||||
const SERVER='http://127.0.0.1:17962';
|
||||
const EXPECTED_SERVER_VERSION='9.3.0';
|
||||
let backendCompatible=false;
|
||||
const server=document.getElementById('server'),status=document.getElementById('status'),bar=document.getElementById('bar'),runBtn=document.getElementById('run'),stopBtn=document.getElementById('stop'),reviewLive=document.getElementById('reviewLive'),liveCount=document.getElementById('liveCount'),liveNet=document.getElementById('liveNet'),liveRound=document.getElementById('liveRound'),liveStagnant=document.getElementById('liveStagnant'),liveMode=document.getElementById('liveMode'),liveElapsed=document.getElementById('liveElapsed'),livePreview=document.getElementById('livePreview');
|
||||
const paramLive=document.getElementById('paramLive'),paramState=document.getElementById('paramState'),paramCount=document.getElementById('paramCount'),paramTags=document.getElementById('paramTags'),paramMissing=document.getElementById('paramMissing');
|
||||
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''}}
|
||||
async function refresh(){
|
||||
const h=await chrome.runtime.sendMessage({type:'HEALTH'});
|
||||
backendCompatible=!!(h?.ok && h.serverVersion===EXPECTED_SERVER_VERSION);
|
||||
if(!h?.ok){
|
||||
server.textContent='本地服务未启动 ✕';
|
||||
server.className='state warn';
|
||||
}else if(!backendCompatible){
|
||||
server.textContent=`版本不一致 ✕ 插件 ${EXPECTED_SERVER_VERSION} / 后端 ${h.serverVersion||'未知'},请启动本包最新服务`;
|
||||
server.className='state warn';
|
||||
}else{
|
||||
server.textContent=`本地服务 ✓ V${h.serverVersion} · ${h.modelConfigured?'模型已配置':'模型未配置'}`;
|
||||
server.className='state ok';
|
||||
}
|
||||
const t=await active(); if(!t)return; const id=itemIdFromUrl(t.url||'');
|
||||
if(!id){status.textContent='请打开天猫/淘宝商品详情页';return}
|
||||
const k='status_'+t.id; const d=(await chrome.storage.local.get(k))[k];
|
||||
if(d){status.textContent=d.message||d.state;bar.style.width=(d.progress||0)+'%';runBtn.textContent=(d.state==='reviews_incomplete'||d.state==='reviews_stopped')?'继续采集评论':'开始深度采集';const lp=d.reviewLive;const activeCollect=d.state==='collecting'||d.state==='collecting_reviews';stopBtn.style.display=activeCollect?'block':'none';reviewLive.style.display=(lp||activeCollect||d.state==='reviews_incomplete'||d.state==='reviews_stopped')?'block':'none';const ps=d.parameterStatus;
|
||||
paramLive.style.display=ps?'block':'none';
|
||||
if(ps){
|
||||
paramState.textContent=ps.stateText||'已采集';
|
||||
paramCount.textContent=ps.count??0;
|
||||
paramTags.innerHTML=(ps.keys||[]).slice(0,10).map(k=>`<span class="param-tag">${String(k).replace(/[<>&]/g,s=>({'<':'<','>':'>','&':'&'}[s]))}</span>`).join('')||'<span class="param-tag">暂无</span>';
|
||||
paramMissing.textContent='缺失项:'+((ps.missing||[]).length?(ps.missing.join('、')):'无');
|
||||
}
|
||||
if(lp){liveCount.textContent=lp.count??0;liveNet.textContent=lp.networkReviewObjects??0;liveRound.textContent=lp.round??0;liveStagnant.textContent=lp.stagnant??0;liveMode.textContent=lp.mode||'—';liveElapsed.textContent=Math.round((lp.elapsedMs||0)/1000)+'s';livePreview.innerHTML=(lp.preview||[]).map((x,i)=>`<div>${i+1}. ${String(x).replace(/[<>&]/g,c=>({'<':'<','>':'>','&':'&'}[c]))}</div>`).join('')||'等待评论数据…'}}else{status.textContent='已识别当前商品,等待手动开始。';bar.style.width='0%';runBtn.textContent='开始深度采集';stopBtn.style.display='none';reviewLive.style.display='none'}
|
||||
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'}
|
||||
}
|
||||
let collectionMode='500';
|
||||
const sampleButtons=[...document.querySelectorAll('[data-sample]')];
|
||||
const customSampleWrap=document.getElementById('customSampleWrap');
|
||||
const customSample=document.getElementById('customSample');
|
||||
sampleButtons.forEach(btn=>btn.addEventListener('click',()=>{sampleButtons.forEach(x=>x.classList.remove('active'));btn.classList.add('active');collectionMode=btn.dataset.sample;customSampleWrap.style.display=collectionMode==='custom'?'block':'none';}));
|
||||
;(async()=>{try{const t=await active();const p=(await chrome.storage.local.get('status_'+t.id))['status_'+t.id];if((p?.state==='reviews_incomplete'||p?.state==='reviews_stopped')&&p.sampleMode){collectionMode=p.sampleMode;sampleButtons.forEach(x=>x.classList.toggle('active',x.dataset.sample===collectionMode));customSampleWrap.style.display=collectionMode==='custom'?'block':'none'}}catch(e){}})();
|
||||
runBtn.onclick=async()=>{
|
||||
if(!backendCompatible){status.textContent='插件与本地后端版本不一致,请先双击本包“start-tmall-ai.command”';return}
|
||||
const t=await active();let sample=collectionMode;if(sample==='custom'){const n=Number(customSample?.value||0);if(!Number.isInteger(n)||n<1||n>10000){status.textContent='请输入 1-10000 的评论数量';return}sample=String(n)}status.textContent=`开始采集当前商品(评论样本 ${sample==='all'?'全部':sample+' 条'})…`;try{const r=await chrome.runtime.sendMessage({type:'RUN_CURRENT',tabId:t.id,sampleMode:sample});if(r?.needsReviewResume){status.textContent=r.message||'评论采集已暂停,可继续';runBtn.textContent='继续采集评论'}else if(!r?.ok){status.textContent='采集失败:'+(r?.error||'未知错误')}}catch(e){status.textContent='采集失败:'+String(e)}setTimeout(refresh,500)};
|
||||
document.getElementById('open').onclick=()=>chrome.tabs.create({url:SERVER+'/'});
|
||||
document.getElementById('importMonitor')?.addEventListener('click',()=>document.getElementById('monitorFile')?.click());
|
||||
document.getElementById('monitorFile')?.addEventListener('change',async e=>{
|
||||
const f=e.target.files?.[0];if(!f)return;
|
||||
if(!backendCompatible){monitorStatus.textContent='导入失败:插件与后端版本不一致,请启动本包最新服务';e.target.value='';return}
|
||||
const t=await active();const itemId=itemIdFromUrl(t?.url||'');
|
||||
if(!itemId){monitorStatus.textContent='请先打开目标商品页';e.target.value='';return}
|
||||
monitorStatus.textContent='正在解析并导入…';
|
||||
try{
|
||||
const buf=await f.arrayBuffer();
|
||||
let binary='';const bytes=new Uint8Array(buf);
|
||||
for(let i=0;i<bytes.length;i+=0x8000)binary+=String.fromCharCode(...bytes.subarray(i,i+0x8000));
|
||||
const base64=btoa(binary);
|
||||
const send=async(forceBind=false)=>{
|
||||
const r=await fetch(SERVER+'/api/monitor-import',{
|
||||
method:'POST',headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({itemId,filename:f.name,dataBase64:base64,forceBind})
|
||||
});
|
||||
const j=await r.json().catch(()=>({ok:false,error:'服务返回内容无法解析'}));
|
||||
return {r,j};
|
||||
};
|
||||
let {r,j}=await send(false);
|
||||
if(!j.ok && j.code==='ITEM_ID_MISMATCH' && j.canForce){
|
||||
const fileId=(j.fileItemIds||[])[0]||'未知';
|
||||
const yes=confirm(`监测文件中的商品ID为 ${fileId},当前页面商品ID为 ${itemId}。
|
||||
/** 转义正文及属性中的特殊字符,长标题可安全放入 title 提示。 */
|
||||
function escapeHtml(value){return String(value||'').replace(/[<>&"']/g,c=>({'<':'<','>':'>','&':'&','"':'"',"'":'''}[c]))}
|
||||
|
||||
如果这份文件确实属于当前商品,点击“确定”后按当前商品绑定;否则点击“取消”。`);
|
||||
if(yes)({r,j}=await send(true));
|
||||
}
|
||||
if(j.ok){
|
||||
monitorStatus.textContent=`已导入 ${j.days} 天 · ${j.firstSales||'—'} → ${j.lastSales||'—'}`;
|
||||
}else{
|
||||
const extra=j.fileItemIds?.length?`(文件商品ID:${j.fileItemIds.slice(0,5).join(', ')})`:'';
|
||||
monitorStatus.textContent=`导入失败:${j.error||'未知错误'}${extra}`;
|
||||
}
|
||||
}catch(err){
|
||||
monitorStatus.textContent='导入失败:'+String(err);
|
||||
}
|
||||
e.target.value='';
|
||||
});
|
||||
document.getElementById('clearMonitor')?.addEventListener('click',async()=>{});
|
||||
refresh();setInterval(refresh,1300);
|
||||
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('、'):'无');
|
||||
}
|
||||
|
||||
stopBtn.onclick=async()=>{const t=await active();stopBtn.disabled=true;stopBtn.textContent='正在终止…';try{const r=await chrome.runtime.sendMessage({type:'STOP_COLLECTION',tabId:t?.id});status.textContent=r?.ok?'已发送终止指令,正在保存当前进度…':'终止失败,请重试'}catch(e){status.textContent='终止失败:'+String(e)}setTimeout(()=>{stopBtn.disabled=false;stopBtn.textContent='终止采集并保存进度'},1000)};
|
||||
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('')||'等待评论数据…';
|
||||
}
|
||||
|
||||
const analyzeNow=document.getElementById('analyzeNow');
|
||||
const modeQuick=document.getElementById('modeQuick');
|
||||
const modeStandard=document.getElementById('modeStandard');
|
||||
const modeFull=document.getElementById('modeFull');
|
||||
let analysisMode='standard';
|
||||
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;
|
||||
|
||||
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');
|
||||
if(analyzeNow){
|
||||
analyzeNow.textContent='基于当前数据开始分析';
|
||||
analyzeNow.disabled=false;
|
||||
}
|
||||
analyzeNow.disabled=isAnalysisRunning;
|
||||
updateRunButtonText(false);
|
||||
}
|
||||
modeQuick && (modeQuick.onclick=()=>setMode('quick'));
|
||||
modeStandard && (modeStandard.onclick=()=>setMode('standard'));
|
||||
modeFull && (modeFull.onclick=()=>setMode('full'));
|
||||
setMode('standard');
|
||||
|
||||
analyzeNow && (analyzeNow.onclick=async()=>{
|
||||
if(!backendCompatible){
|
||||
status.textContent='插件与本地后端版本不一致,请先启动本包最新服务';
|
||||
return;
|
||||
}
|
||||
const t=await active();
|
||||
if(!t?.id){status.textContent='请先打开目标商品页';return}
|
||||
analyzeNow.disabled=true;
|
||||
analyzeNow.textContent='正在提交当前数据…';
|
||||
/**
|
||||
* 将接口异常转换成用户可读提示,避免暴露 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 r=await chrome.runtime.sendMessage({type:'ANALYZE_CURRENT_NOW',tabId:t.id});
|
||||
if(r?.ok){
|
||||
status.textContent=`已开始分析当前数据${r.reviewCount!=null?` · 当前评论 ${r.reviewCount} 条`:''}`;
|
||||
if(r.taskUrl) chrome.tabs.create({url:/^https?:\/\//i.test(r.taskUrl)?r.taskUrl:SERVER+r.taskUrl});
|
||||
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='无法开始分析:'+(r?.error||'未知错误');
|
||||
status.textContent='采集失败:'+(result?.error||'未知错误');
|
||||
}
|
||||
}catch(e){
|
||||
status.textContent='无法开始分析:'+String(e);
|
||||
}finally{
|
||||
analyzeNow.disabled=false;
|
||||
analyzeNow.textContent='基于当前数据开始分析';
|
||||
}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')}
|
||||
};
|
||||
Reference in New Issue
Block a user