diff --git a/.gitignore b/.gitignore index b42c93c..aab6b18 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ server/config.json .env .env.* +*.pem # python __pycache__/ diff --git a/extension/auth.js b/extension/auth.js new file mode 100644 index 0000000..a36cd7f --- /dev/null +++ b/extension/auth.js @@ -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}; diff --git a/extension/background.js b/extension/background.js index 60c5dcb..7599077 100644 --- a/extension/background.js +++ b/extension/background.js @@ -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} }; } diff --git a/extension/icon-128.png b/extension/icon-128.png new file mode 100644 index 0000000..b4f7dec Binary files /dev/null and b/extension/icon-128.png differ diff --git a/extension/manifest.json b/extension/manifest.json index 8735b79..5c92b74 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -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": [ { diff --git a/extension/popup.css b/extension/popup.css new file mode 100644 index 0000000..fa5025e --- /dev/null +++ b/extension/popup.css @@ -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; } diff --git a/extension/popup.html b/extension/popup.html index fae21d5..a2bc6ce 100644 --- a/extension/popup.html +++ b/extension/popup.html @@ -1,41 +1,131 @@ -
-