Files
mosen e9939ef9dc Initial release with production deploy config.
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>
2026-06-15 00:52:31 +08:00

190 lines
6.4 KiB
JavaScript
Raw Permalink 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.
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;
}