Files
sansyn-pc-extension/collector-extension-standalone/service_worker.js
T

197 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const API_BASE='http://127.0.0.1:3300/api/collector/product-page'
async function capture(tabId){
const result=await chrome.scripting.executeScript({target:{tabId},func:()=>{
const clean=value=>String(value||'').replace(/\s+/g,' ').trim()
const uniq=rows=>[...new Set(rows.filter(Boolean))]
const bodyText=document.body?.innerText||''
const lines=bodyText.split(/\n+/).map(clean).filter(Boolean)
const url=location.href
const itemId=url.match(/[?&]id=(\d+)/)?.[1]||url.match(/\/(\d+)\.html/)?.[1]||''
const skuId=url.match(/[?&]skuId=(\d+)/)?.[1]||''
const platform=/tmall/i.test(location.hostname)?'tmall':/taobao/i.test(location.hostname)?'taobao':/jd\.com/i.test(location.hostname)?'jd':'unknown'
const title=clean(document.querySelector('h1')?.textContent||document.querySelector('meta[property="og:title"]')?.content||document.title)
const readAfter=patterns=>{const row=lines.find(line=>patterns.some(pattern=>pattern.test(line)));if(!row)return'';const parts=row.split(/[::]/);return clean(parts.length>1?parts.slice(1).join(':'):row)}
const shop=clean(document.querySelector('[class*="shop-name"],[class*="shopName"],[class*="seller-name"],[class*="sellerName"]')?.textContent)||readAfter([/^店铺/,/^店名/,/^卖家/])
const priceText=clean(document.querySelector('[class*="promotionPrice"],[class*="salePrice"],[class*="price"]')?.textContent||'')
const currentPrice=clean(priceText.match(/[¥¥]?\s*(\d+(?:\.\d+)?)/)?.[1]||bodyText.match(/(?:券后|到手价|活动价|价格)\s*[¥¥]?\s*(\d+(?:\.\d+)?)/)?.[1]||'')
const originalPrice=clean(bodyText.match(/(?:原价|划线价|优惠前)\s*[¥¥]?\s*(\d+(?:\.\d+)?)/)?.[1]||'')
const sold=clean(bodyText.match(/(?:已售|付款|销量|累计销量)\s*([0-9.万wW+]+)/)?.[1]||'')
const publicReviewCount=clean(bodyText.match(/(?:累计评价|全部评价|商品评价|评论)\s*([0-9.万wW+]+)/)?.[1]||'')
const ranking=clean(bodyText.match(/(?:热销榜|排行榜|榜第)\s*第?\s*([0-9]+)\s*名/)?.[0]||'')
const meta={}
document.querySelectorAll('meta').forEach(el=>{const key=el.getAttribute('property')||el.getAttribute('name');const value=el.getAttribute('content');if(key&&value&&!/cookie|authorization|token|password/i.test(key))meta[key]=value})
const jsonld=[]
document.querySelectorAll('script[type="application/ld+json"]').forEach(el=>{try{jsonld.push(JSON.parse(el.textContent||'null'))}catch{}})
const badImage=/avatar|icon|logo|sprite|loading|placeholder|qrcode|qr-code|emoji|badge/i
const imageRows=[...document.images].map(img=>({
url:img.currentSrc||img.src,
width:img.naturalWidth||img.width||0,
height:img.naturalHeight||img.height||0,
alt:clean(img.alt),
context:clean(`${img.alt||''} ${img.closest('[class],[id]')?.className||''} ${img.closest('[class],[id]')?.id||''}`)
})).filter(row=>/^https?:\/\//.test(row.url)&&!badImage.test(`${row.url} ${row.context}`)&&(row.width>=120||row.height>=120))
const uniqueImages=rows=>{const seen=new Set();return rows.filter(row=>{const key=row.url.replace(/_[0-9]+x[0-9]+[^/]*$/,'');if(seen.has(key))return false;seen.add(key);return true})}
const reviewRows=uniqueImages(imageRows.filter(row=>/review|comment|rate|evaluate|buyer|show|评价|评论|买家秀|晒单/i.test(row.context)))
const detailRows=uniqueImages(imageRows.filter(row=>/detail|description|desc|richtext|module|详情|图文|描述/i.test(row.context)&&!reviewRows.some(x=>x.url===row.url)))
const skuRows=uniqueImages(imageRows.filter(row=>/sku|prop|variant|option|规格|颜色|尺码|型号|口味/i.test(row.context)&&!reviewRows.some(x=>x.url===row.url)&&!detailRows.some(x=>x.url===row.url)))
const mainRows=uniqueImages(imageRows.filter(row=>/gallery|thumb|preview|main|swiper|carousel|主图|轮播/i.test(row.context)&&!reviewRows.some(x=>x.url===row.url)&&!detailRows.some(x=>x.url===row.url))).slice(0,30)
const allImages=uniqueImages(imageRows)
const blockedAttribute=/优惠|券|立减|包邮|发货|送达|价保|退货|客服|店铺|销量|付款|评论|评价|问答/
const attributes=[]
const attributeSeen=new Set()
const addAttribute=(name,value,source='dom')=>{name=clean(name).replace(/[::]$/,'');value=clean(value);if(!name||!value||name.length>36||value.length>600||blockedAttribute.test(name))return;const key=`${name}\u0000${value}`;if(attributeSeen.has(key))return;attributeSeen.add(key);attributes.push({name,value,source})}
document.querySelectorAll('table tr,[class*="attribute"] li,[class*="Attribute"] li,[class*="parameter"] li,[class*="Parameter"] li,[class*="property"] li,[class*="Property"] li,[class*="attr"] li').forEach(el=>{
const cells=[...el.querySelectorAll(':scope > th,:scope > td,:scope > span,:scope > div')].map(x=>clean(x.textContent)).filter(Boolean)
if(cells.length>=2)addAttribute(cells[0],cells.slice(1).join(' '),'attribute_dom')
else{const text=clean(el.textContent);const match=text.match(/^([^::]{1,36})[::]\s*(.{1,600})$/);if(match)addAttribute(match[1],match[2],'attribute_dom')}
})
lines.forEach(line=>{const match=line.match(/^([^::]{1,30})[::]\s*(.{1,400})$/);if(match)addAttribute(match[1],match[2],'page_text')})
const breadcrumb=uniq([...document.querySelectorAll('[class*="breadcrumb"] a,[class*="crumb"] a,[class*="category"] a')].map(el=>clean(el.textContent))).filter(x=>x.length<=40).slice(0,12)
const categoryCandidates=uniq([...breadcrumb,...attributes.filter(x=>/类目|分类|品类|产品类型|商品类型/.test(x.name)).map(x=>x.value)]).slice(0,20)
const variants=[]
const variantSeen=new Set()
const addVariant=row=>{const name=clean(row.name);if(!name||name.length>180)return;const key=`${clean(row.group)}\u0000${name}`;if(variantSeen.has(key))return;variantSeen.add(key);variants.push({...row,name,group:clean(row.group),source:row.source||'sku_dom'})}
document.querySelectorAll('[class*="sku"],[class*="Sku"],[class*="variant"],[class*="Variant"],[class*="prop"] ,[class*="Prop"]').forEach(container=>{
const group=clean(container.querySelector('[class*="title"],[class*="label"],[class*="name"]')?.textContent||'')
container.querySelectorAll('button,[role="option"],li,[class*="item"],[class*="Item"]').forEach(el=>{
const name=clean(el.getAttribute('title')||el.getAttribute('aria-label')||el.textContent)
const image=el.querySelector('img')?.currentSrc||el.querySelector('img')?.src||''
addVariant({group,name,image,selected:el.getAttribute('aria-selected')==='true'||/selected|active|current/i.test(el.className),disabled:el.getAttribute('aria-disabled')==='true'||el.hasAttribute('disabled')})
})
})
const variantKeywords=/(颜色|色号|尺码|尺寸|规格|型号|版本|配置|容量|净含量|口味|香型|套装|套餐|款式|适用|包装|重量|数量|码数|度数|功率|内存|存储)/
lines.filter(line=>variantKeywords.test(line)&&line.length<=500).slice(0,180).forEach(line=>line.split(/[,,]/).map(clean).filter(Boolean).forEach(name=>addVariant({group:'',name,source:'page_text'})))
const promotionPattern=/(优惠|券|立减|满减|包邮|免运费|价保|退货|退款|七天无理由|预计.*发货|送达|大促|活动|限时|到手价|店铺优惠|赠品|买一送一)/i
const promotions=uniq(lines.filter(line=>promotionPattern.test(line)&&line.length<=120)).slice(0,100).map(text=>({text,source:'page_text'}))
const questions=[]
document.querySelectorAll('[class*="question"],[class*="Question"],[class*="ask"],[class*="Ask"]').forEach((el,index)=>{
const text=clean(el.innerText);if(!text)return
const question=clean(el.querySelector('[class*="question"],[class*="title"]')?.textContent||text.match(/(.{2,160}[??])/)?.[1])
const answer=clean(el.querySelector('[class*="answer"]')?.textContent||'')
if(question)questions.push({id:`question-${index+1}`,question,answer,source:'question_dom'})
})
const reviewSelectors=['[class*="review-item"]','[class*="comment-item"]','[class*="rate-item"]','[class*="ReviewItem"]','[class*="CommentItem"]','[data-testid*="review"]']
const reviews=[...document.querySelectorAll(reviewSelectors.join(','))].slice(0,500).map((el,index)=>{
const content=clean(el.innerText);if(content.length<6||content.length>3000)return null
const images=uniq([...el.querySelectorAll('img')].map(img=>img.currentSrc||img.src).filter(src=>/^https?:\/\//.test(src)&&!badImage.test(src)))
const date=content.match(/20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?(?:\s+\d{1,2}:\d{2})?/)?.[0]||''
const appendContent=clean(content.match(/(?:追评|追加评价)[::\s]*(.{2,800})/)?.[1]||'')
const sku=clean(el.querySelector('[class*="sku"],[class*="spec"],[class*="attr"]')?.textContent||'')
const merchantReply=clean(el.querySelector('[class*="reply"],[class*="seller"]')?.textContent||'')
const rating=Number(el.getAttribute('data-score')||el.getAttribute('data-rating')||el.querySelector('[data-score]')?.getAttribute('data-score'))||null
return{id:el.getAttribute('data-id')||el.id||`review-${index+1}`,content,appendContent,date,rating,sku,hasImage:images.length>0,images,merchantReply,source:'review_dom'}
}).filter(Boolean)
const collection={
publicReviewCount,
reviewActual:reviews.length,
questionActual:questions.length,
reviewCompleteness:reviews.length?'visible_content_collected':'no_visible_review_nodes',
reviewStopReason:reviews.length?'visible_dom_exhausted':'reviews_not_open_or_platform_folded',
capturedAt:new Date().toISOString(),
collectorVersion:chrome.runtime.getManifest().version,
platform
}
const structured={
meta:{...collection,url,imageClassificationVersion:'strict-v1',collectorVersion:'9.2.2'},
category:{candidates:categoryCandidates,breadcrumb},
product:{itemId,skuId,title,shop,brand:attributes.find(item=>/^品牌$/.test(item.name))?.value||'',url,platform},
sales:{currentPrice,originalPrice,sold,ranking},
promotions,attributes,sku:variants,questions,reviews,
images:(()=>{
const main=mainRows, detail=detailRows.slice(0,120), sku=skuRows.slice(0,120), buyerShow=reviewRows.slice(0,160);
const provenance=[
...main.map(url=>({url,group:'main',source:'dom_main_gallery'})),
...detail.map(url=>({url,group:'detail',source:'dom_product_description'})),
...sku.map(url=>({url,group:'sku',source:'dom_sku_selector'})),
...buyerShow.map(url=>({url,group:'buyerShow',source:'review_record'}))
];
return {all:allImages,main,detail,sku,buyerShow,provenance,classification:{version:'strict-v1',classifiedCount:allImages.length,groupsAreDisjoint:true}};
})(),
collection,pageText:bodyText
}
return{structured,...structured,jsonld,meta}
}})
return result?.[0]?.result
}
const lastCaptured=new Map()
function isProductUrl(url=''){return /^https:\/\/(?:item\.jd\.com\/\d+\.html|item\.taobao\.com\/item\.htm|detail\.tmall\.com\/item\.htm)/i.test(url)}
async function reportAuthState(){
try{
const [taobaoCookies,jdCookies]=await Promise.all([chrome.cookies.getAll({domain:'.taobao.com'}),chrome.cookies.getAll({domain:'.jd.com'})])
const response=await fetch(`${API_BASE}/auth-heartbeat`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({version:chrome.runtime.getManifest().version,platforms:{taobao:{loggedIn:taobaoCookies.some(cookie=>/tracknick|cookie2|_tb_token_|sgcookie/i.test(cookie.name))},jd:{loggedIn:jdCookies.some(cookie=>/pin|thor|unick/i.test(cookie.name))}}})})
return response.ok
}catch(error){console.error('[三笙登录态上报]',error);return false}
}
async function captureAndSend(tab,{force=false}={}){
if(!tab?.id||!isProductUrl(tab.url))return
const key=`${tab.id}:${tab.url}`
if(!force&&Date.now()-(lastCaptured.get(key)||0)<30_000)return
lastCaptured.set(key,Date.now())
try{
await chrome.action.setBadgeText({tabId:tab.id,text:'采'})
await chrome.action.setBadgeBackgroundColor({tabId:tab.id,color:'#2563EB'})
const payload=await capture(tab.id)
const response=await fetch(`${API_BASE}/capture`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)})
if(!response.ok)throw new Error(await response.text())
await chrome.action.setBadgeText({tabId:tab.id,text:'OK'})
await chrome.action.setBadgeBackgroundColor({tabId:tab.id,color:'#155C4E'})
setTimeout(()=>chrome.action.setBadgeText({tabId:tab.id,text:''}),2500)
}catch(error){
lastCaptured.delete(key)
await chrome.action.setBadgeText({tabId:tab.id,text:'!'})
await chrome.action.setBadgeBackgroundColor({tabId:tab.id,color:'#A54F43'})
console.error('[三笙商品采集]',error)
}
}
chrome.tabs.onUpdated.addListener((tabId,changeInfo,tab)=>{if(changeInfo.status==='complete'&&isProductUrl(tab.url))setTimeout(()=>captureAndSend({...tab,id:tabId}),2500)})
chrome.tabs.onRemoved.addListener(tabId=>{for(const key of lastCaptured.keys())if(key.startsWith(`${tabId}:`))lastCaptured.delete(key)})
chrome.action.onClicked.addListener(tab=>captureAndSend(tab,{force:true}))
let queueBusy=false
let queueBackoffMs=1000
async function processCaptureQueue(){
if(queueBusy)return
queueBusy=true
let tab
try{
const response=await fetch(`${API_BASE}/next`)
if(response.status===204){queueBackoffMs=Math.min(queueBackoffMs*1.5,15_000);return}
if(!response.ok)throw new Error(`任务队列 HTTP ${response.status}`)
const task=await response.json()
tab=await chrome.tabs.create({url:task.url,active:false})
await new Promise((resolve,reject)=>{
const timeout=setTimeout(()=>{chrome.tabs.onUpdated.removeListener(listener);reject(new Error('商品页加载超时'))},45_000)
const listener=(tabId,changeInfo,updated)=>{if(tabId===tab.id&&changeInfo.status==='complete'){clearTimeout(timeout);chrome.tabs.onUpdated.removeListener(listener);resolve(updated)}}
chrome.tabs.onUpdated.addListener(listener)
})
await new Promise(resolve=>setTimeout(resolve,3500))
await captureAndSend(await chrome.tabs.get(tab.id),{force:true})
queueBackoffMs=1000
}catch(error){console.error('[三笙采集队列]',error);queueBackoffMs=Math.min(queueBackoffMs*2,30_000)}
finally{if(tab?.id)await chrome.tabs.remove(tab.id).catch(()=>{});queueBusy=false;setTimeout(processCaptureQueue,queueBackoffMs)}
}
chrome.runtime.onInstalled.addListener(()=>{chrome.alarms.create('sansong-product-queue',{periodInMinutes:.5});processCaptureQueue()})
chrome.runtime.onStartup.addListener(()=>{chrome.alarms.create('sansong-product-queue',{periodInMinutes:.5});processCaptureQueue()})
chrome.alarms.onAlarm.addListener(alarm=>{if(alarm.name==='sansong-product-queue')processCaptureQueue();if(alarm.name==='sansong-auth-heartbeat')reportAuthState()})
chrome.alarms.create('sansong-product-queue',{periodInMinutes:.5})
chrome.alarms.create('sansong-auth-heartbeat',{periodInMinutes:1})
processCaptureQueue()
reportAuthState()
chrome.runtime.onMessage.addListener((message,_sender,sendResponse)=>{if(message?.type!=='SANSONG_COLLECTOR_WAKE')return;reportAuthState().then(connected=>sendResponse({connected})).catch(()=>sendResponse({connected:false}));return true})
chrome.cookies.onChanged.addListener(change=>{if(/(?:^|\.)(?:taobao|tmall|jd)\.com$/i.test(change.cookie.domain))setTimeout(reportAuthState,300)})