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>
This commit is contained in:
2026-06-15 00:52:31 +08:00
commit e9939ef9dc
45 changed files with 14376 additions and 0 deletions
+189
View File
@@ -0,0 +1,189 @@
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;
}