chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (message?.type !== "EXTRACT_PRODUCTS") return; try { sendResponse({ ok: true, items: extractVisibleProducts(message.mode || "page") }); } catch (error) { sendResponse({ ok: false, error: error.message || String(error), items: [] }); } }); function extractVisibleProducts(mode) { const platform = detectPlatform(location.href); const sourcePage = location.href; const title = cleanText(document.title); const candidates = mode === "main" ? [document.body] : findCandidateNodes(); const items = []; const seen = new Set(); for (const node of candidates) { const item = extractFromNode(node, platform, sourcePage, title); if (!item.title && !item.image) continue; const key = item.url || `${item.title}|${item.image}`; if (seen.has(key)) continue; seen.add(key); items.push(item); if (items.length >= 30) break; } if (!items.length) { const fallback = extractFromNode(document.body, platform, sourcePage, title); if (fallback.title || fallback.image) items.push(fallback); } return items; } function findCandidateNodes() { const nodes = new Set(); const imageNodes = Array.from(document.images) .filter((img) => { const rect = img.getBoundingClientRect(); const src = img.currentSrc || img.src || img.getAttribute("data-src") || ""; return src && !src.startsWith("data:") && rect.width >= 80 && rect.height >= 80; }) .slice(0, 80); for (const img of imageNodes) { nodes.add(findCardRoot(img)); } const linkNodes = Array.from(document.querySelectorAll("a[href]")) .filter((link) => { const rect = link.getBoundingClientRect(); const text = cleanText(link.innerText || link.textContent || ""); return rect.width >= 80 && rect.height >= 40 && (link.querySelector("img") || /[¥¥]\s*\d|\d+(?:\.\d+)?\s*元/.test(text)); }) .slice(0, 60); for (const link of linkNodes) nodes.add(findCardRoot(link)); return Array.from(nodes).filter(Boolean).slice(0, 90); } function findCardRoot(node) { let current = node; let best = node; for (let i = 0; i < 5 && current?.parentElement; i += 1) { current = current.parentElement; const rect = current.getBoundingClientRect(); const text = cleanText(current.innerText || current.textContent || ""); if (rect.width >= 100 && rect.height >= 80 && text.length <= 900) best = current; if (/[¥¥]\s*\d|\d+(?:\.\d+)?\s*元/.test(text) && text.length >= 8) { best = current; break; } } return best; } function extractFromNode(node, site, pageUrl, pageTitle) { const text = cleanText(node.innerText || node.textContent || ""); const linkEl = node.matches?.("a[href]") ? node : node.querySelector?.("a[href]"); const url = absolutize(linkEl?.href || pageUrl); const image = bestImage(node); const priceText = findPriceText(text); const titleText = bestTitle(node, text, pageTitle); const supplier = findSupplier(node, text); return { platform: site, url, sourcePage: pageUrl, title: titleText, image, priceText, supplier, shop: supplier, metrics: findMetrics(text), notes: `插件采集;页面标题:${pageTitle}`, }; } function bestTitle(node, text, pageTitle) { const attrs = [ node.getAttribute?.("title"), node.getAttribute?.("aria-label"), node.querySelector?.("[title]")?.getAttribute("title"), node.querySelector?.("h1,h2,h3")?.innerText, node.querySelector?.("[class*='title'],[class*='name'],[class*='desc']")?.innerText, ]; const fromAttrs = attrs.map(cleanText).find((value) => value && value.length >= 4); if (fromAttrs) return limit(fromAttrs, 120); const lines = text .split(/[\n\r。]/) .map(cleanText) .filter((line) => line.length >= 4 && !/^[¥¥]?\d/.test(line)); const bestLine = lines.find((line) => /耳|链|饰|手|项|发|戒|藏|民族|绿松石|珍珠|银|夹|挂/.test(line)) || lines[0]; return limit(bestLine || pageTitle, 120); } function bestImage(node) { const images = Array.from(node.querySelectorAll?.("img") || (node.tagName === "IMG" ? [node] : [])) .map((img) => { const rect = img.getBoundingClientRect(); return { src: img.currentSrc || img.src || img.getAttribute("data-src") || img.getAttribute("data-lazy") || "", score: rect.width * rect.height + (img.naturalWidth || 0) * (img.naturalHeight || 0), }; }) .filter((image) => image.src && !image.src.startsWith("data:")) .sort((a, b) => b.score - a.score); return absolutize(images[0]?.src || ""); } function findPriceText(text) { const match = text.match(/(?:¥|¥)\s*\d+(?:\.\d+)?(?:\s*[-~至]\s*\d+(?:\.\d+)?)?|(?:批发价|价格|券后|到手价)?\s*\d+(?:\.\d+)?\s*元/); return cleanText(match?.[0] || ""); } function findSupplier(node, text) { const el = node.querySelector?.("[class*='shop'],[class*='seller'],[class*='supplier'],[class*='company'],[class*='store']"); const fromEl = cleanText(el?.innerText || el?.textContent || ""); if (fromEl) return limit(fromEl, 80); const match = text.match(/[\u4e00-\u9fa5A-Za-z0-9()()]{2,30}(?:工厂|厂家|旗舰店|饰品厂|商行|店|公司)/); return cleanText(match?.[0] || ""); } function findMetrics(text) { const metrics = {}; const patterns = { sales: /(?:已售|销量|人付款|成交|售出)\s*[::]?\s*[\d.万wW+]+/, comments: /(?:评论|评价)\s*[::]?\s*[\d.万wW+]+/, likes: /(?:点赞|赞|喜欢)\s*[::]?\s*[\d.万wW+]+/, saves: /(?:收藏|加购)\s*[::]?\s*[\d.万wW+]+/, }; for (const [key, pattern] of Object.entries(patterns)) { const match = text.match(pattern); if (match) metrics[key] = match[0]; } metrics.visibleText = limit(text, 500); return metrics; } function detectPlatform(url) { const host = new URL(url).hostname; if (host.includes("1688.com")) return "1688"; if (host.includes("xiaohongshu.com") || host.includes("xhslink.com")) return "xiaohongshu"; if (host.includes("tmall.com")) return "tmall"; if (host.includes("taobao.com")) return "taobao"; return "other"; } function absolutize(url) { if (!url) return ""; try { return new URL(url, location.href).href; } catch { return ""; } } function cleanText(value) { return String(value || "").replace(/\s+/g, " ").trim(); } function limit(value, length) { const text = cleanText(value); return text.length > length ? `${text.slice(0, length)}…` : text; }