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:
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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 |
@@ -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/*"
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user