e9939ef9dc
Add GitAtlas deploy assets, DATA_FILE support for isolated server data, and port documentation for sourcing.simosen.cn. Co-authored-by: Cursor <cursoragent@cursor.com>
190 lines
6.4 KiB
JavaScript
190 lines
6.4 KiB
JavaScript
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;
|
||
}
|