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
+9
View File
@@ -0,0 +1,9 @@
chrome.runtime.onInstalled.addListener(() => {
console.info("直播饰品选品采集器已安装");
});
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message?.type === "PING") {
sendResponse({ ok: true, tabId: sender.tab?.id || null });
}
});
+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;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 834 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

+52
View File
@@ -0,0 +1,52 @@
{
"manifest_version": 3,
"name": "选品采购采集器",
"version": "0.2.0",
"description": "采集当前淘宝、小红书、1688 等页面可见的图片、链接、标题、价格等信息到选品采购台。",
"icons": {
"16": "icons/icon16.png",
"32": "icons/icon32.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
},
"action": {
"default_title": "采集选品",
"default_popup": "popup.html",
"default_icon": {
"16": "icons/icon16.png",
"32": "icons/icon32.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
},
"permissions": ["activeTab", "scripting", "storage", "tabs"],
"background": {
"service_worker": "background.js"
},
"content_scripts": [
{
"matches": [
"http://127.0.0.1:4777/*",
"http://localhost:4777/*",
"https://*.taobao.com/*",
"https://*.tmall.com/*",
"https://*.xiaohongshu.com/*",
"https://*.xhslink.com/*",
"https://*.1688.com/*"
],
"js": ["content.js"],
"run_at": "document_idle"
}
],
"host_permissions": [
"https://*/*",
"http://*/*",
"http://127.0.0.1:4777/*",
"http://localhost:4777/*",
"https://*.taobao.com/*",
"https://*.tmall.com/*",
"https://*.xiaohongshu.com/*",
"https://*.xhslink.com/*",
"https://*.1688.com/*"
]
}
+190
View File
@@ -0,0 +1,190 @@
* {
box-sizing: border-box;
}
body {
width: 350px;
margin: 0;
background:
radial-gradient(circle at 0 0, rgba(42, 156, 145, 0.18), transparent 34%),
linear-gradient(135deg, #fbfffd, #fff8f2);
color: #241a14;
font-family: Arial, "PingFang SC", "Microsoft YaHei", sans-serif;
}
.popup {
padding: 14px;
}
header {
display: grid;
gap: 6px;
margin-bottom: 12px;
}
.popup-brand {
display: grid;
grid-template-columns: 42px minmax(0, 1fr);
align-items: center;
gap: 10px;
}
.popup-brand img {
display: block;
width: 42px;
height: 42px;
border-radius: 8px;
box-shadow: 0 10px 22px rgba(13, 107, 88, 0.2);
}
.popup-brand > div {
display: grid;
gap: 4px;
min-width: 0;
}
header strong {
font-size: 15px;
color: #084738;
}
header span {
color: #69707d;
font-size: 12px;
}
.actions {
display: grid;
gap: 8px;
}
.settings {
margin-top: 12px;
border: 1px solid rgba(13, 107, 88, 0.18);
border-radius: 8px;
background: rgba(255, 255, 255, 0.82);
overflow: hidden;
}
.settings summary {
position: relative;
min-height: 40px;
padding: 11px 12px;
color: #084738;
cursor: pointer;
font-size: 13px;
font-weight: 700;
list-style: none;
}
.settings summary::-webkit-details-marker {
display: none;
}
.settings summary::after {
position: absolute;
right: 12px;
color: #69707d;
content: "展开";
font-size: 12px;
font-weight: 400;
}
.settings[open] summary::after {
content: "收起";
}
.settings.has-update:not([open]) summary::before {
position: absolute;
right: 48px;
color: #a56f18;
content: "有更新";
font-size: 12px;
font-weight: 700;
}
.settings-body {
display: grid;
gap: 8px;
padding: 0 10px 10px;
}
.update-panel {
display: grid;
gap: 7px;
padding: 10px;
border: 1px solid rgba(165, 111, 24, 0.32);
border-radius: 8px;
background: #fff4cf;
color: #5f4211;
font-size: 12px;
line-height: 1.55;
}
.update-panel strong {
color: #084738;
}
.update-panel p {
margin: 0;
}
label {
display: grid;
gap: 5px;
color: #65766f;
font-size: 12px;
}
label span {
color: #315f55;
font-weight: 700;
}
input {
min-height: 34px;
border: 1px solid rgba(13, 107, 88, 0.2);
border-radius: 8px;
padding: 7px 9px;
color: #241a14;
}
button {
min-height: 38px;
border: 1px solid rgba(13, 107, 88, 0.22);
border-radius: 8px;
background: #ffffff;
color: #241a14;
cursor: pointer;
font: inherit;
}
button.primary {
border-color: #0d6b58;
background: #0d6b58;
color: #fffaf0;
}
button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.result {
margin-top: 12px;
padding: 10px;
border: 1px solid rgba(13, 107, 88, 0.18);
border-radius: 8px;
background: #ffffff;
font-size: 12px;
line-height: 1.6;
}
.result code {
color: #0d6b58;
}
.result ul {
margin: 8px 0 0;
padding-left: 18px;
}
+55
View File
@@ -0,0 +1,55 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>选品采购采集器</title>
<link rel="stylesheet" href="popup.css" />
</head>
<body>
<main class="popup">
<header>
<div class="popup-brand">
<img src="icons/icon48.png" alt="" />
<div>
<strong>选品采购采集器</strong>
<span id="statusText">连接本地服务中</span>
<span id="versionText">插件版本检测中</span>
</div>
</div>
</header>
<section class="actions">
<button id="capturePageBtn" class="primary">采集当前页</button>
<button id="captureMainBtn">只采集主商品/笔记</button>
<button id="testSaveBtn">测试保存到系统</button>
<button id="openDashboardBtn">打开选品库</button>
</section>
<details class="settings" id="settingsPanel">
<summary>设置</summary>
<div class="settings-body">
<label>
<span>系统地址</span>
<input id="apiBaseInput" placeholder="https://your-domain.com" />
</label>
<label>
<span>插件 Token</span>
<input id="pluginTokenInput" type="password" placeholder="pst_..." />
</label>
<button id="saveSettingsBtn">保存连接配置</button>
<section class="update-panel" id="updatePanel" hidden>
<strong>插件更新</strong>
<p id="updateText">系统推荐使用新版采集器。</p>
<button id="updateGuideBtn" type="button">查看更新方式</button>
</section>
</div>
</details>
<section class="result" id="resultBox">
<p>首次使用请打开“设置”配置系统地址和插件 Token。配置保存后会自动收起。</p>
</section>
</main>
<script type="module" src="popup.js"></script>
</body>
</html>
+272
View File
@@ -0,0 +1,272 @@
const DEFAULT_API_BASE = "http://127.0.0.1:4777";
const statusText = document.querySelector("#statusText");
const versionText = document.querySelector("#versionText");
const updatePanel = document.querySelector("#updatePanel");
const updateText = document.querySelector("#updateText");
const updateGuideBtn = document.querySelector("#updateGuideBtn");
const resultBox = document.querySelector("#resultBox");
const settingsPanel = document.querySelector("#settingsPanel");
const capturePageBtn = document.querySelector("#capturePageBtn");
const captureMainBtn = document.querySelector("#captureMainBtn");
const testSaveBtn = document.querySelector("#testSaveBtn");
const openDashboardBtn = document.querySelector("#openDashboardBtn");
const apiBaseInput = document.querySelector("#apiBaseInput");
const pluginTokenInput = document.querySelector("#pluginTokenInput");
const saveSettingsBtn = document.querySelector("#saveSettingsBtn");
let settings = {
apiBase: DEFAULT_API_BASE,
pluginToken: "",
};
let latestExtensionInfo = null;
async function init() {
versionText.textContent = `插件版本 ${currentVersion()}`;
settings = await loadSettings();
apiBaseInput.value = settings.apiBase;
pluginTokenInput.value = settings.pluginToken;
await checkHealth();
}
async function loadSettings() {
const stored = await chromeStorageGet(["apiBase", "pluginToken"]);
return {
apiBase: normalizeApiBase(stored.apiBase || DEFAULT_API_BASE),
pluginToken: stored.pluginToken || "",
};
}
async function saveSettings() {
settings = {
apiBase: normalizeApiBase(apiBaseInput.value || DEFAULT_API_BASE),
pluginToken: pluginTokenInput.value.trim(),
};
await chromeStorageSet(settings);
settings = await loadSettings();
apiBaseInput.value = settings.apiBase;
pluginTokenInput.value = settings.pluginToken;
resultBox.innerHTML = "<p>连接配置已保存,下次打开会自动带出。</p>";
await checkHealth();
settingsPanel.open = false;
}
async function checkHealth() {
try {
const response = await fetch(`${settings.apiBase}/api/health`);
if (!response.ok) throw new Error("服务未响应");
const payload = await response.json();
latestExtensionInfo = payload.extension || null;
statusText.textContent = payload.authRequired && !settings.pluginToken ? "系统已连接,待填写插件 Token" : "系统已连接";
renderUpdatePrompt(payload.extension);
setButtonsDisabled(payload.authRequired && !settings.pluginToken);
openDashboardBtn.disabled = false;
} catch {
statusText.textContent = "系统未连接";
latestExtensionInfo = null;
renderUpdatePrompt(null);
setButtonsDisabled(true);
openDashboardBtn.disabled = false;
}
}
function renderUpdatePrompt(extensionInfo) {
if (!extensionInfo?.recommendedVersion || !isVersionGreater(extensionInfo.recommendedVersion, currentVersion())) {
updatePanel.hidden = true;
settingsPanel.classList.remove("has-update");
return;
}
updatePanel.hidden = false;
settingsPanel.classList.add("has-update");
updateText.textContent = `当前 ${currentVersion()},系统推荐 ${extensionInfo.recommendedVersion}${extensionInfo.updateNote || "建议更新插件后再采集。"}`;
}
function setButtonsDisabled(disabled) {
capturePageBtn.disabled = disabled;
captureMainBtn.disabled = disabled;
testSaveBtn.disabled = disabled;
openDashboardBtn.disabled = disabled;
}
async function getActiveTab() {
const activeTabs = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
let tab = activeTabs.find(isInjectableTab);
if (!tab) {
const allTabs = await chrome.tabs.query({});
tab = allTabs.reverse().find(isInjectableTab);
}
if (!tab?.id) throw new Error("没有找到可采集的普通网页标签页,请先打开淘宝、小红书或 1688 页面");
return tab;
}
async function runExtractor(mode) {
const tab = await getActiveTab();
try {
const response = await chrome.tabs.sendMessage(tab.id, { type: "EXTRACT_PRODUCTS", mode });
return response?.items || [];
} catch (firstError) {
try {
await chrome.scripting.executeScript({
target: { tabId: tab.id },
files: ["content.js"],
});
const response = await chrome.tabs.sendMessage(tab.id, { type: "EXTRACT_PRODUCTS", mode });
return response?.items || [];
} catch (secondError) {
throw new Error(`页面采集脚本无法运行:${secondError.message || firstError.message}`);
}
}
}
async function capture(mode) {
resultBox.innerHTML = "<p>正在采集当前页面...</p>";
setButtonsDisabled(true);
try {
const items = await runExtractor(mode);
if (!items.length) {
resultBox.innerHTML = "<p>没有识别到可采集的商品/笔记。可以滚动页面后再试,或打开详情页采集主商品。</p>";
return;
}
const payload = await postCaptures(items);
resultBox.innerHTML = renderSavedResult(payload.items || [], payload.credits);
} catch (error) {
resultBox.innerHTML = `
<p>采集失败:${escapeHtml(error.message)}</p>
<p>排查:确认系统地址和插件 Token;当前页不是浏览器内部页;淘宝/小红书/1688 页面可刷新后重试。</p>
`;
} finally {
setButtonsDisabled(false);
}
}
async function postCaptures(items) {
const headers = { "Content-Type": "application/json" };
if (settings.pluginToken) headers.Authorization = `Bearer ${settings.pluginToken}`;
const response = await fetch(`${settings.apiBase}/api/captures`, {
method: "POST",
headers,
body: JSON.stringify({ items }),
});
const payload = await response.json();
if (!response.ok) throw new Error(payload.error || "保存失败");
return payload;
}
async function testSave() {
resultBox.innerHTML = "<p>正在测试写入系统...</p>";
setButtonsDisabled(true);
try {
const payload = await postCaptures([
{
platform: "1688",
title: "插件测试款 藏式编绳手链 民族风",
url: `https://detail.1688.com/offer/plugin-test-${Date.now()}.html`,
image: "",
priceText: "¥6.80",
supplier: "插件诊断测试",
metrics: { visibleText: "现货 混批 支持拿样 1000+人付款 求链接" },
notes: "来自浏览器插件测试保存按钮",
},
]);
resultBox.innerHTML = `
<p>测试保存成功。采集链路正常。</p>
<p>已写入:${escapeHtml(payload.items?.[0]?.title || "测试款")}</p>
${payload.credits ? `<p>已扣 ${escapeHtml(payload.credits.deducted)} 积分,剩余 ${escapeHtml(payload.credits.remaining)}。</p>` : ""}
`;
} catch (error) {
resultBox.innerHTML = `
<p>测试保存失败:${escapeHtml(error.message)}</p>
<p>请确认系统地址可访问,SaaS 模式需要填写插件 Token。</p>
`;
} finally {
setButtonsDisabled(false);
}
}
function renderSavedResult(items, credits) {
return `
<p>已保存 <strong>${items.length}</strong> 条候选。</p>
${credits ? `<p>已扣 ${escapeHtml(credits.deducted)} 积分,剩余 ${escapeHtml(credits.remaining)}。</p>` : ""}
<ul>
${items
.slice(0, 5)
.map((item) => `<li>${escapeHtml(item.decision)} · ${escapeHtml(item.title || "未命名")}</li>`)
.join("")}
</ul>
`;
}
function chromeStorageGet(keys) {
return new Promise((resolve, reject) => {
chrome.storage.sync.get(keys, (value) => {
const error = chrome.runtime.lastError;
if (error) reject(new Error(error.message || "读取配置失败"));
else resolve(value || {});
});
});
}
function chromeStorageSet(value) {
return new Promise((resolve, reject) => {
chrome.storage.sync.set(value, () => {
const error = chrome.runtime.lastError;
if (error) reject(new Error(error.message || "保存配置失败,请检查插件 storage 权限"));
else resolve();
});
});
}
function isInjectableTab(tab) {
return Boolean(tab?.id && /^https?:\/\//.test(tab.url || ""));
}
function normalizeApiBase(value = "") {
return String(value).trim().replace(/\/+$/, "") || DEFAULT_API_BASE;
}
function escapeHtml(value = "") {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
capturePageBtn.addEventListener("click", () => capture("page"));
captureMainBtn.addEventListener("click", () => capture("main"));
testSaveBtn.addEventListener("click", testSave);
saveSettingsBtn.addEventListener("click", saveSettings);
openDashboardBtn.addEventListener("click", () => chrome.tabs.create({ url: settings.apiBase }));
updateGuideBtn.addEventListener("click", () => {
const guide = latestExtensionInfo?.reloadRequiredForUnpacked
? "请下载系统里的新版插件安装包,解压后到 chrome://extensions 重新加载解压后的文件夹。"
: "请按系统提示更新插件。";
resultBox.innerHTML = `<p>${escapeHtml(guide)}</p><p>安装包:<code>${escapeHtml(latestExtensionInfo?.fileName || "product-sourcing-capture-extension.zip")}</code></p>`;
const installUrl = latestExtensionInfo?.installUrl || "/";
chrome.tabs.create({ url: absoluteSystemUrl(installUrl) });
});
init();
function currentVersion() {
return chrome.runtime.getManifest().version || "0.0.0";
}
function isVersionGreater(a, b) {
const left = String(a).split(".").map((part) => Number(part) || 0);
const right = String(b).split(".").map((part) => Number(part) || 0);
for (let i = 0; i < Math.max(left.length, right.length); i += 1) {
const diff = (left[i] || 0) - (right[i] || 0);
if (diff > 0) return true;
if (diff < 0) return false;
}
return false;
}
function absoluteSystemUrl(pathOrUrl = "/") {
if (/^https?:\/\//.test(pathOrUrl)) return pathOrUrl;
return `${settings.apiBase}${String(pathOrUrl).startsWith("/") ? "" : "/"}${pathOrUrl}`;
}