Files
ObsidianHome/小鱼/小配件闲鱼运营助手.html
2026-11-05 15:16:24 +08:00

1506 lines
48 KiB
HTML
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.
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>小配件闲鱼运营助手</title>
<style>
:root {
--bg: #f6f7f9;
--panel: #ffffff;
--text: #1f2933;
--muted: #677483;
--line: #dde3ea;
--primary: #176b87;
--primary-dark: #0f5369;
--accent: #d97706;
--good: #0f8a5f;
--warn: #b45309;
--bad: #b42318;
--soft-good: #e9f7f1;
--soft-warn: #fff4df;
--soft-bad: #ffebe8;
--soft-blue: #e8f3f7;
--shadow: 0 8px 24px rgba(31, 41, 51, 0.08);
font-family: "Microsoft YaHei", "PingFang SC", "Segoe UI", Arial, sans-serif;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-size: 14px;
line-height: 1.5;
}
header {
position: sticky;
top: 0;
z-index: 10;
background: #ffffff;
border-bottom: 1px solid var(--line);
box-shadow: 0 2px 12px rgba(31, 41, 51, 0.04);
}
.topbar {
max-width: 1440px;
margin: 0 auto;
padding: 14px 24px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.brand {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 240px;
}
.brand h1 {
margin: 0;
font-size: 20px;
font-weight: 700;
letter-spacing: 0;
}
.brand span {
color: var(--muted);
font-size: 12px;
}
.toolbar {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
}
button,
.file-label {
border: 1px solid var(--line);
background: #ffffff;
color: var(--text);
border-radius: 6px;
padding: 8px 12px;
font-size: 13px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 36px;
transition: 0.16s ease;
user-select: none;
}
button:hover,
.file-label:hover {
border-color: var(--primary);
color: var(--primary);
}
button.primary {
background: var(--primary);
border-color: var(--primary);
color: #ffffff;
}
button.primary:hover {
background: var(--primary-dark);
color: #ffffff;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
input[type="file"] {
display: none;
}
main {
max-width: 1440px;
margin: 0 auto;
padding: 20px 24px 36px;
display: grid;
grid-template-columns: 390px minmax(0, 1fr);
gap: 18px;
}
section,
aside {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
box-shadow: var(--shadow);
}
.input-panel {
padding: 16px;
align-self: start;
position: sticky;
top: 78px;
max-height: calc(100vh - 96px);
overflow: auto;
}
.panel-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 10px;
}
.panel-title h2 {
margin: 0;
font-size: 16px;
letter-spacing: 0;
}
.hint {
color: var(--muted);
font-size: 12px;
margin: 0 0 10px;
}
textarea {
width: 100%;
min-height: 280px;
resize: vertical;
border: 1px solid var(--line);
border-radius: 6px;
padding: 12px;
font-family: Consolas, "Microsoft YaHei", monospace;
font-size: 12px;
line-height: 1.55;
color: var(--text);
background: #fbfcfd;
}
textarea:focus,
input:focus {
outline: 2px solid rgba(23, 107, 135, 0.16);
border-color: var(--primary);
}
.input-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 10px;
}
.input-actions button.primary {
grid-column: 1 / -1;
justify-content: center;
}
.settings {
margin-top: 14px;
display: grid;
gap: 10px;
}
.field {
display: grid;
gap: 5px;
}
.field label {
color: var(--muted);
font-size: 12px;
}
.field input {
border: 1px solid var(--line);
border-radius: 6px;
padding: 8px 10px;
min-height: 36px;
font-size: 13px;
}
.sample {
margin-top: 14px;
border-top: 1px solid var(--line);
padding-top: 12px;
}
.sample button {
width: 100%;
justify-content: center;
}
.collector-panel {
margin-top: 14px;
border-top: 1px solid var(--line);
padding-top: 12px;
}
.collector-panel h3 {
margin: 0 0 10px;
font-size: 14px;
letter-spacing: 0;
}
.collector-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-bottom: 10px;
}
.collector-panel details {
border: 1px solid var(--line);
border-radius: 8px;
background: #fbfcfd;
}
.collector-panel summary {
cursor: pointer;
padding: 9px 10px;
color: var(--text);
font-weight: 600;
}
.script-box {
min-height: 160px;
max-height: 220px;
border: 0;
border-top: 1px solid var(--line);
border-radius: 0 0 8px 8px;
font-family: Consolas, "Cascadia Mono", monospace;
font-size: 12px;
white-space: pre;
}
.content {
display: grid;
gap: 16px;
min-width: 0;
}
.metrics {
display: grid;
grid-template-columns: repeat(5, minmax(120px, 1fr));
gap: 10px;
}
.metric {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
padding: 12px;
min-height: 82px;
display: flex;
flex-direction: column;
justify-content: space-between;
box-shadow: var(--shadow);
}
.metric span {
color: var(--muted);
font-size: 12px;
}
.metric strong {
font-size: 22px;
letter-spacing: 0;
}
.metric em {
color: var(--muted);
font-style: normal;
font-size: 12px;
}
.tabs {
display: flex;
gap: 8px;
padding: 12px 12px 0;
border-bottom: 1px solid var(--line);
overflow-x: auto;
}
.tab {
border: 0;
border-bottom: 3px solid transparent;
background: transparent;
border-radius: 0;
color: var(--muted);
white-space: nowrap;
min-height: 38px;
}
.tab.active {
color: var(--primary);
border-bottom-color: var(--primary);
font-weight: 700;
}
.result-panel {
min-width: 0;
}
.panel-body {
padding: 14px;
}
.summary-line {
background: var(--soft-blue);
color: var(--primary-dark);
border: 1px solid #c9e4ed;
border-radius: 6px;
padding: 10px 12px;
margin-bottom: 12px;
font-weight: 600;
}
.table-wrap {
overflow: auto;
border: 1px solid var(--line);
border-radius: 6px;
background: #ffffff;
}
table {
width: 100%;
border-collapse: collapse;
min-width: 900px;
}
th,
td {
border-bottom: 1px solid var(--line);
padding: 9px 10px;
text-align: left;
vertical-align: top;
font-size: 13px;
}
th {
background: #f8fafc;
color: #425466;
font-weight: 700;
position: sticky;
top: 0;
z-index: 1;
}
tr:last-child td {
border-bottom: 0;
}
.title-cell {
max-width: 340px;
min-width: 260px;
}
.muted {
color: var(--muted);
font-size: 12px;
}
.badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 28px;
height: 22px;
border-radius: 999px;
padding: 0 8px;
font-size: 12px;
font-weight: 700;
}
.badge.s {
background: var(--soft-good);
color: var(--good);
}
.badge.a {
background: var(--soft-blue);
color: var(--primary);
}
.badge.b {
background: var(--soft-warn);
color: var(--warn);
}
.badge.c {
background: #eef2f7;
color: var(--muted);
}
.badge.risk {
background: var(--soft-bad);
color: var(--bad);
}
.cards-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.note-card {
border: 1px solid var(--line);
border-radius: 8px;
padding: 12px;
background: #ffffff;
}
.note-card h3 {
margin: 0 0 8px;
font-size: 15px;
}
.note-card p {
margin: 0;
color: var(--muted);
}
.markdown-box {
width: 100%;
min-height: 460px;
white-space: pre;
}
.empty {
padding: 28px;
text-align: center;
color: var(--muted);
border: 1px dashed var(--line);
border-radius: 8px;
background: #fbfcfd;
}
.toast {
position: fixed;
right: 24px;
bottom: 24px;
background: #1f2933;
color: #ffffff;
padding: 10px 14px;
border-radius: 6px;
box-shadow: var(--shadow);
opacity: 0;
pointer-events: none;
transform: translateY(8px);
transition: 0.18s ease;
z-index: 30;
}
.toast.show {
opacity: 1;
transform: translateY(0);
}
@media (max-width: 980px) {
main {
grid-template-columns: 1fr;
}
.input-panel {
position: static;
max-height: none;
}
.metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.cards-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {
.topbar {
align-items: flex-start;
flex-direction: column;
}
.toolbar {
width: 100%;
justify-content: flex-start;
}
main {
padding: 14px;
}
.metrics {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<header>
<div class="topbar">
<div class="brand">
<h1>小配件闲鱼运营助手</h1>
<span>粘贴商品数据,自动拆出补品、优化、下架和周报</span>
</div>
<div class="toolbar">
<button id="copyMarkdownBtn" type="button" title="复制周报 Markdown">复制周报</button>
<button id="downloadMarkdownBtn" type="button" title="下载周报 Markdown">下载周报</button>
<button id="resetBtn" type="button" title="清空输入和分析结果">清空</button>
</div>
</div>
</header>
<main>
<aside class="input-panel">
<div class="panel-title">
<h2>数据输入</h2>
</div>
<p class="hint">从闲鱼商品数据页复制表格后粘贴进来。第一版支持制表符、CSV、空格分隔文本和包含“商品ID”的行。</p>
<textarea id="dataInput" placeholder="粘贴商品数据,例如:
商品信息 商品曝光人数 商品浏览人数 询单人数 支付人数 支付订单数 支付金额 成功退款订单数 成功退款金额
小天才D2A/Q2A表带 商品ID 953520780202 ¥6.80 5317 219 84 71 74 607 0 0"></textarea>
<div class="input-actions">
<label class="file-label" for="fileInput">上传 CSV/TXT</label>
<input id="fileInput" type="file" accept=".csv,.txt,.tsv">
<button id="loadSampleBtn" type="button">载入示例</button>
<button id="analyzeBtn" class="primary" type="button">分析数据</button>
</div>
<div class="settings">
<div class="field">
<label for="dateRange">数据范围</label>
<input id="dateRange" value="2026-05-30 ~ 2026-06-28">
</div>
<div class="field">
<label for="shopMode">运营模式</label>
<input id="shopMode" value="拼多多代下单,单单利润约 2~5 元">
</div>
</div>
<div class="sample">
<p class="hint">建议每周固定复制近 7 天或近 30 天商品数据,导出 Markdown 存到 Obsidian。</p>
</div>
<div class="collector-panel">
<h3>浏览器采集</h3>
<p class="hint">打开闲鱼商品数据页后,把下面脚本粘到浏览器控制台执行;脚本会自动滚动当前页并尝试翻页采集,结果复制到剪贴板后回到这里粘贴分析。</p>
<div class="collector-actions">
<button id="copyCollectorBtn" type="button">复制采集脚本</button>
<button id="pasteClipboardBtn" type="button">粘贴剪贴板</button>
</div>
<details>
<summary>查看采集脚本</summary>
<textarea id="collectorScript" class="script-box" readonly></textarea>
</details>
</div>
</aside>
<div class="content">
<div class="metrics">
<div class="metric">
<span>分析商品</span>
<strong id="metricItems">0</strong>
<em>按商品 ID 去重</em>
</div>
<div class="metric">
<span>支付订单</span>
<strong id="metricOrders">0</strong>
<em>近周期合计</em>
</div>
<div class="metric">
<span>支付金额</span>
<strong id="metricAmount">0</strong>
<em>元</em>
</div>
<div class="metric">
<span>退款金额</span>
<strong id="metricRefund">0</strong>
<em>元</em>
</div>
<div class="metric">
<span>主力类目</span>
<strong id="metricCategory">-</strong>
<em>按支付订单判断</em>
</div>
</div>
<section class="result-panel">
<div class="tabs" role="tablist">
<button class="tab active" data-tab="supplement" type="button">补品清单</button>
<button class="tab" data-tab="optimize" type="button">优化清单</button>
<button class="tab" data-tab="remove" type="button">下架清单</button>
<button class="tab" data-tab="review" type="button">本周复盘</button>
<button class="tab" data-tab="data" type="button">解析数据</button>
</div>
<div id="tabBody" class="panel-body">
<div class="empty">粘贴数据后点击“分析数据”,这里会生成三张清单和周报。</div>
</div>
</section>
</div>
</main>
<div id="toast" class="toast">已复制</div>
<script>
const state = {
activeTab: "supplement",
rows: [],
results: {
supplement: [],
optimize: [],
remove: [],
markdown: "",
categoryStats: [],
},
};
const sampleText = `商品信息\t商品曝光人数\t商品浏览人数\t询单人数\t支付人数\t支付订单数\t支付金额\t成功退款订单数\t成功退款金额
小天才电话手表表带,适用D2A/Q2A型号,全新未使用。黑色 商品ID 953520780202 ¥6.80\t5317\t219\t84\t71\t74\t607\t0\t0
小天才D3电话手表表带,适用W2028AC机型。蓝色和红色两 商品ID 973482389112 ¥6.80\t3287\t133\t49\t40\t41\t309.6\t0\t0
【全新正品】适用小天才电话手表Z6Pro保护壳,渐变色软胶全 商品ID 978206766501 ¥8.80\t1262\t52\t6\t4\t5\t51.6\t0\t0
【全新】适用小天才Z7/Z7A/Z7S儿童电话手表表带,晴海 商品ID 988230832539 ¥6.80\t826\t22\t7\t4\t4\t28.3\t0\t0
个人闲置小米手环10腕带,编织款,女生戴很有高级感 商品ID 1063128096716 ¥20.90\t940\t116\t1\t0\t0\t0\t0\t0
个人闲置,适用小天才电话手表Z11保护壳,卡通立体公仔款 商品ID 1002451280948 ¥7.80\t226\t4\t0\t0\t0\t0\t0\t0
个人闲置三个小厨电动刮鱼鳞神器,无线充电款 商品ID 1055460443702 ¥55.00\t382\t15\t1\t1\t1\t47.8\t1\t47.8
个人闲置含羞草大盆栽,绿植,室内阳台都能养 商品ID 1042983596529 ¥10.90\t327\t14\t1\t0\t0\t0\t0\t0
仿真三蹦子三轮车,特价处理!蓝色和红色可选 商品ID 993119465849 ¥13.50\t2279\t15\t0\t0\t0\t0\t0\t0`;
const els = {
input: document.getElementById("dataInput"),
file: document.getElementById("fileInput"),
analyze: document.getElementById("analyzeBtn"),
sample: document.getElementById("loadSampleBtn"),
reset: document.getElementById("resetBtn"),
copy: document.getElementById("copyMarkdownBtn"),
download: document.getElementById("downloadMarkdownBtn"),
copyCollector: document.getElementById("copyCollectorBtn"),
pasteClipboard: document.getElementById("pasteClipboardBtn"),
collectorScript: document.getElementById("collectorScript"),
tabBody: document.getElementById("tabBody"),
toast: document.getElementById("toast"),
dateRange: document.getElementById("dateRange"),
shopMode: document.getElementById("shopMode"),
metricItems: document.getElementById("metricItems"),
metricOrders: document.getElementById("metricOrders"),
metricAmount: document.getElementById("metricAmount"),
metricRefund: document.getElementById("metricRefund"),
metricCategory: document.getElementById("metricCategory"),
};
document.querySelectorAll(".tab").forEach((tab) => {
tab.addEventListener("click", () => {
state.activeTab = tab.dataset.tab;
document.querySelectorAll(".tab").forEach((x) => x.classList.toggle("active", x === tab));
renderTab();
});
});
els.sample.addEventListener("click", () => {
els.input.value = sampleText;
showToast("已载入示例数据");
});
els.analyze.addEventListener("click", () => {
const rows = parseInput(els.input.value);
if (!rows.length) {
showToast("没有识别到商品数据");
return;
}
state.rows = rows;
state.results = analyzeRows(rows);
updateMetrics();
renderTab();
showToast(`已分析 ${rows.length} 个商品`);
});
els.reset.addEventListener("click", () => {
els.input.value = "";
state.rows = [];
state.results = { supplement: [], optimize: [], remove: [], markdown: "", categoryStats: [] };
updateMetrics();
renderEmpty();
showToast("已清空");
});
els.copy.addEventListener("click", async () => {
if (!state.results.markdown) {
showToast("暂无周报可复制");
return;
}
await navigator.clipboard.writeText(state.results.markdown);
showToast("周报已复制");
});
els.download.addEventListener("click", () => {
if (!state.results.markdown) {
showToast("暂无周报可下载");
return;
}
const blob = new Blob([state.results.markdown], { type: "text/markdown;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
const safeDate = els.dateRange.value.replace(/[^\d]+/g, "-").replace(/^-|-$/g, "");
a.href = url;
a.download = `小配件闲鱼运营周报_${safeDate || "未命名"}.md`;
a.click();
URL.revokeObjectURL(url);
});
els.file.addEventListener("change", async (event) => {
const file = event.target.files[0];
if (!file) return;
const text = await file.text();
els.input.value = text;
showToast(`已读取 ${file.name}`);
});
els.copyCollector.addEventListener("click", async () => {
const script = getCollectorScript();
els.collectorScript.value = script;
const copied = await writeClipboard(script);
if (!copied) {
els.collectorScript.focus();
els.collectorScript.select();
}
showToast(copied ? "采集脚本已复制" : "已选中脚本,请手动复制");
});
els.pasteClipboard.addEventListener("click", async () => {
try {
const text = await navigator.clipboard.readText();
if (!text.trim()) {
showToast("剪贴板为空");
return;
}
els.input.value = text;
showToast("已粘贴剪贴板数据");
} catch (error) {
els.input.focus();
showToast("请手动粘贴到输入框");
}
});
async function writeClipboard(text) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch (error) {
const helper = document.createElement("textarea");
helper.value = text;
helper.setAttribute("readonly", "readonly");
helper.style.position = "fixed";
helper.style.left = "-9999px";
document.body.appendChild(helper);
helper.select();
const ok = document.execCommand("copy");
document.body.removeChild(helper);
return ok;
}
}
function getCollectorScript() {
return `(async () => {
const header = ["商品信息", "商品曝光人数", "商品浏览人数", "询单人数", "支付人数", "支付订单数", "支付金额", "成功退款订单数", "成功退款金额"];
const config = {
maxPages: 12,
maxScrollRounds: 18,
scrollDelay: 450,
pageDelay: 1200
};
const clean = (value) => String(value || "").replace(/\\s+/g, " ").trim();
const rowSelectors = [
".ant-table-tbody tr",
".next-table-body tr",
"table tbody tr",
"[role='row']"
];
const seen = new Set();
const outputRows = [];
let pageCount = 1;
function cellsOf(row) {
const cellNodes = row.querySelectorAll("td, th, [role='gridcell'], [role='cell']");
let cells = Array.from(cellNodes).map((cell) => clean(cell.innerText)).filter(Boolean);
if (!cells.length) cells = clean(row.innerText).split(/\\n+/).map(clean).filter(Boolean);
return cells;
}
function findTitleIndex(cells) {
const index = cells.findIndex((cell) => /商品ID\\s*\\d+|\\b\\d{10,}\\b/.test(cell));
return index >= 0 ? index : 0;
}
function normalizeRow(cells) {
if (!cells.length) return null;
const titleIndex = findTitleIndex(cells);
const title = clean(cells[titleIndex]);
if (!/商品ID\\s*\\d+|\\b\\d{10,}\\b/.test(title)) return null;
const nums = cells
.slice(titleIndex + 1)
.flatMap((cell) => (cell.match(/-?\\d+(?:\\.\\d+)?/g) || []))
.map(Number)
.filter(Number.isFinite);
if (nums.length < 3) return null;
while (nums.length < 8) nums.push(0);
return [title, ...nums.slice(0, 8)].join("\\t");
}
function pushRow(line) {
if (!line || seen.has(line)) return;
seen.add(line);
outputRows.push(line);
}
function collectVisibleRows() {
let before = outputRows.length;
for (const selector of rowSelectors) {
document.querySelectorAll(selector).forEach((row) => pushRow(normalizeRow(cellsOf(row))));
}
return outputRows.length - before;
}
function getScrollTargets() {
const nodes = Array.from(document.querySelectorAll("main, section, div, table, tbody"));
const targets = nodes
.filter((node) => {
const style = getComputedStyle(node);
const overflowY = style.overflowY;
return node.scrollHeight > node.clientHeight + 80 && /(auto|scroll|overlay)/.test(overflowY);
})
.sort((a, b) => (b.scrollHeight - b.clientHeight) - (a.scrollHeight - a.clientHeight));
targets.push(document.scrollingElement || document.documentElement);
return Array.from(new Set(targets));
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function scrollAndCollectPage() {
let stableRounds = 0;
let lastCount = outputRows.length;
const targets = getScrollTargets();
collectVisibleRows();
for (let round = 0; round < config.maxScrollRounds; round += 1) {
for (const target of targets) {
target.scrollTop = Math.min(target.scrollTop + Math.max(420, target.clientHeight || 600), target.scrollHeight);
}
window.scrollBy(0, Math.max(480, window.innerHeight * 0.8));
await sleep(config.scrollDelay);
collectVisibleRows();
if (outputRows.length === lastCount) stableRounds += 1;
else stableRounds = 0;
lastCount = outputRows.length;
if (stableRounds >= 3) break;
}
}
function isDisabled(element) {
if (!element) return true;
const text = clean(element.innerText || element.getAttribute("aria-label") || element.title || "");
const className = String(element.className || "");
return element.disabled ||
element.getAttribute("aria-disabled") === "true" ||
/disabled|next-disabled|btn-disabled/.test(className) ||
/没有下一页|最后一页/.test(text);
}
function findNextButton() {
const candidates = Array.from(document.querySelectorAll("button, a, li, span, div"))
.filter((node) => {
const text = clean(node.innerText || node.getAttribute("aria-label") || node.title || "");
const className = String(node.className || "");
if (!text && !className) return false;
const looksNext = /下一页|下页|Next|next|right|arrow-right|chevron-right/i.test(text + " " + className);
const rect = node.getBoundingClientRect();
return looksNext && rect.width > 0 && rect.height > 0 && !isDisabled(node);
});
return candidates.find((node) => node.closest("ul, nav, .pagination, [class*='pagination'], [class*='pager']")) || candidates[0] || null;
}
function pageFingerprint() {
const rows = [];
for (const selector of rowSelectors) {
document.querySelectorAll(selector).forEach((row) => rows.push(clean(row.innerText)));
}
return location.href + "||" + rows.slice(0, 20).join("||").slice(0, 2400);
}
async function goNextPage() {
const before = pageFingerprint();
const button = findNextButton();
if (!button) return false;
button.click();
await sleep(config.pageDelay);
const after = pageFingerprint();
return before !== after;
}
const status = document.createElement("div");
status.style.cssText = "position:fixed;right:16px;bottom:16px;z-index:2147483647;background:#1f2933;color:#fff;padding:10px 12px;border-radius:6px;font:13px sans-serif;box-shadow:0 8px 24px rgba(0,0,0,.18)";
document.body.appendChild(status);
for (let page = 1; page <= config.maxPages; page += 1) {
pageCount = page;
status.textContent = "正在采集第 " + page + " 页,已采集 " + outputRows.length + " 行...";
await scrollAndCollectPage();
status.textContent = "第 " + page + " 页采集完成,累计 " + outputRows.length + " 行。";
const moved = await goNextPage();
if (!moved) break;
}
status.remove();
const text = [header.join("\\t"), ...outputRows].join("\\n");
if (!outputRows.length) {
alert("没有采集到商品数据,请确认当前页是闲鱼商品数据表格,或先滚动/翻页加载列表。");
return;
}
navigator.clipboard.writeText(text)
.then(() => alert("已采集 " + outputRows.length + " 行商品数据,覆盖约 " + pageCount + " 页,并复制到剪贴板。"))
.catch(() => {
const box = document.createElement("textarea");
box.value = text;
box.style.position = "fixed";
box.style.left = "12px";
box.style.top = "12px";
box.style.width = "520px";
box.style.height = "260px";
box.style.zIndex = "2147483647";
document.body.appendChild(box);
box.focus();
box.select();
alert("已采集 " + outputRows.length + " 行,复制权限被浏览器拦截。请手动复制左上角文本框。");
});
})();`;
}
function parseInput(rawText) {
const lines = rawText
.replace(/\r/g, "")
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
if (!lines.length) return [];
const dataLines = lines.filter((line) => /商品ID\s*\d+/.test(line) || /\t/.test(line));
const rows = [];
for (const line of dataLines) {
if (!/商品ID\s*\d+/.test(line)) continue;
rows.push(parseLine(line));
}
const map = new Map();
rows.filter(Boolean).forEach((row) => {
if (!map.has(row.itemId)) map.set(row.itemId, row);
});
return Array.from(map.values()).map(enrichRow);
}
function parseLine(line) {
const itemId = matchFirst(line, /商品ID\s*(\d+)/);
if (!itemId) return null;
const title = cleanTitle(line);
const price = numberFrom(matchFirst(line, /¥\s*([\d.]+)/));
const parts = line.includes("\t") ? line.split("\t").map((x) => x.trim()).filter(Boolean) : [];
let nums = [];
if (parts.length >= 8) {
nums = parts.slice(1).map(numberFrom).filter((n) => Number.isFinite(n));
} else {
const tail = line.replace(/^.*?商品ID\s*\d+/, "");
nums = (tail.match(/-?\d+(?:\.\d+)?%?/g) || [])
.map((x) => x.includes("%") ? null : numberFrom(x))
.filter((n) => Number.isFinite(n));
if (nums.length && Math.abs(nums[0] - price) < 0.001) nums.shift();
}
return {
itemId,
title,
price,
exposurePeople: nums[0] || 0,
viewPeople: nums[1] || 0,
inquiryPeople: nums[2] || 0,
payPeople: nums[3] || 0,
payOrders: nums[4] || 0,
payAmount: nums[5] || 0,
successRefundOrders: nums[6] || 0,
successRefundAmount: nums[7] || 0,
};
}
function cleanTitle(line) {
return line
.replace(/\t/g, " ")
.replace(/商品ID\s*\d+.*$/, "")
.replace(/\s+/g, " ")
.trim();
}
function matchFirst(text, regex) {
const m = text.match(regex);
return m ? m[1] : "";
}
function numberFrom(value) {
if (value === undefined || value === null || value === "" || value === "-") return 0;
const n = Number(String(value).replace(/[,%¥]/g, "").trim());
return Number.isFinite(n) ? n : 0;
}
function enrichRow(row) {
const category = classifyCategory(row.title);
const model = detectModel(row.title);
const accessoryType = detectAccessoryType(row.title);
const viewRate = row.exposurePeople ? row.viewPeople / row.exposurePeople : 0;
const payRate = row.viewPeople ? row.payPeople / row.viewPeople : 0;
const refundRate = row.payOrders ? row.successRefundOrders / row.payOrders : 0;
return { ...row, category, model, accessoryType, viewRate, payRate, refundRate };
}
function classifyCategory(title) {
if (/小天才|米兔|电话手表|手环|腕带|表带|保护壳|充电线|螺丝杆|底托|卡盖|卡托|Z\d|D\d|Q\d|Apple|苹果/i.test(title)) return "手表/手环配件";
if (/拔毛|刮鱼鳞|菜板|厨房|切菜|蟹|杀鸡|鸡鸭鹅|家禽|封口机|锅|猫砂铲|烧烤架/i.test(title)) return "厨房/家居工具";
if (/马扎|折叠凳|钓鱼|户外|自行车|气嘴灯|后视镜|露营|赶海|筛铲/i.test(title)) return "户外/骑行小物";
if (/儿童|玩具|捞鱼|象棋|积木|教具|文具|算盘|华容道|叠叠杯|宝宝|早教|学习桌|跳棋|故事书/i.test(title)) return "儿童玩具/教具";
if (/蓝莓|树苗|含羞草|盆栽|绿植|薄荷|浇水/i.test(title)) return "绿植盆栽";
return "其他";
}
function detectModel(title) {
const upper = title.toUpperCase();
const knownCombos = [
"D2A/Q2A", "Q1A/Q2A/D2/D2A", "Z7/Z7A/Z7S", "Z6/Z6A/Z6S/Z6PRO",
"Z6A/Z6S", "S10/S11", "9PRO/8PRO"
];
for (const combo of knownCombos) {
if (upper.includes(combo)) return combo;
}
const matches = upper.match(/Z\d+[A-Z]*|D\d+[A-Z]*|Q\d+[A-Z]*|\d+X|5C|B4|S\d+/g);
return matches ? Array.from(new Set(matches)).slice(0, 3).join("/") : "";
}
function detectAccessoryType(title) {
if (/充电线|充电器|磁吸充电/i.test(title)) return "充电线";
if (/保护壳|保护套|表壳|壳膜/i.test(title)) return "保护壳";
if (/表带|腕带|表链/i.test(title)) return "表带";
if (/螺丝|螺丝杆|螺丝刀/i.test(title)) return "螺丝杆";
if (/底托|托盘|旋转配件/i.test(title)) return "底托";
if (/卡盖|卡托/i.test(title)) return "卡盖/卡托";
if (/贴膜|钢化膜|保护膜/i.test(title)) return "贴膜";
return "其他配件";
}
function analyzeRows(rows) {
const categoryStats = buildCategoryStats(rows);
const supplement = buildSupplementList(rows);
const optimize = buildOptimizeList(rows);
const remove = buildRemoveList(rows);
const markdown = buildMarkdown(rows, categoryStats, supplement, optimize, remove);
return { supplement, optimize, remove, markdown, categoryStats };
}
function buildCategoryStats(rows) {
const stats = {};
rows.forEach((row) => {
const item = stats[row.category] || {
category: row.category,
items: 0,
exposurePeople: 0,
viewPeople: 0,
inquiryPeople: 0,
payOrders: 0,
payAmount: 0,
refundOrders: 0,
refundAmount: 0,
};
item.items += 1;
item.exposurePeople += row.exposurePeople;
item.viewPeople += row.viewPeople;
item.inquiryPeople += row.inquiryPeople;
item.payOrders += row.payOrders;
item.payAmount += row.payAmount;
item.refundOrders += row.successRefundOrders;
item.refundAmount += row.successRefundAmount;
stats[row.category] = item;
});
return Object.values(stats)
.map((item) => ({
...item,
payRate: item.viewPeople ? item.payOrders / item.viewPeople : 0,
}))
.sort((a, b) => b.payOrders - a.payOrders || b.payAmount - a.payAmount);
}
function buildSupplementList(rows) {
const candidates = rows
.filter((row) => row.payOrders >= 3 && row.successRefundOrders === 0 && isSmallAccessory(row))
.map((row) => {
const priority = row.payOrders >= 20 ? "S" : row.payOrders >= 5 ? "A" : "B";
return {
...row,
priority,
reason: `${row.model || row.accessoryType} 近周期 ${row.payOrders} 单,退款 0`,
suggestion: supplementSuggestion(row),
};
})
.sort(prioritySort);
return candidates;
}
function buildOptimizeList(rows) {
return rows
.filter((row) => {
const hasTrafficNoPay = row.exposurePeople >= 200 && row.viewPeople > 0 && row.payOrders === 0;
const hasInquiryNoPay = row.inquiryPeople > 0 && row.payOrders === 0;
return hasTrafficNoPay || hasInquiryNoPay;
})
.map((row) => ({
...row,
priority: row.inquiryPeople >= 3 || row.viewPeople >= 50 ? "A" : "B",
reason: optimizeReason(row),
suggestion: optimizeSuggestion(row),
}))
.sort((a, b) => b.viewPeople - a.viewPeople || b.exposurePeople - a.exposurePeople);
}
function buildRemoveList(rows) {
return rows
.filter((row) => {
const refundBad = row.payOrders > 0 && row.successRefundOrders / row.payOrders >= 0.4;
const categoryRisk = ["厨房/家居工具", "绿植盆栽", "其他"].includes(row.category) && row.payOrders === 0 && row.exposurePeople >= 150;
const noClick = row.exposurePeople >= 200 && row.viewPeople === 0;
const highExposeLowClick = row.exposurePeople >= 800 && row.viewRate < 0.012 && row.payOrders === 0;
return refundBad || categoryRisk || noClick || highExposeLowClick;
})
.map((row) => ({
...row,
priority: row.successRefundOrders > 0 || row.exposurePeople >= 800 ? "S" : "A",
reason: removeReason(row),
suggestion: removeSuggestion(row),
}))
.sort(prioritySort);
}
function isSmallAccessory(row) {
if (row.category !== "手表/手环配件") return false;
if (!row.model && !/小天才|米兔|手环|电话手表/i.test(row.title)) return false;
return row.price <= 20 || row.payOrders >= 3;
}
function supplementSuggestion(row) {
const model = row.model || "同型号";
if (row.accessoryType === "表带") return `补充 ${model} 充电线、保护壳、卡盖、螺丝杆。`;
if (row.accessoryType === "保护壳") return `补充 ${model} 表带、充电线、贴膜。`;
if (row.accessoryType === "充电线") return `补充 ${model} 表带、保护壳。`;
if (row.accessoryType.includes("卡盖") || row.accessoryType.includes("螺丝")) return `补充 ${model} 维修小配件组合。`;
return `围绕 ${model} 补充表带、保护壳、充电线。`;
}
function optimizeReason(row) {
if (row.inquiryPeople > 0) return `有 ${row.inquiryPeople} 个询单但成交 0。`;
if (row.viewPeople >= 50) return `浏览 ${row.viewPeople},成交 0。`;
return `曝光 ${row.exposurePeople},浏览 ${row.viewPeople},成交 0。`;
}
function optimizeSuggestion(row) {
if (row.viewPeople >= 50 && row.payOrders === 0) return "先降价或换主图,补清楚适配型号,不扩同类新链接。";
if (row.inquiryPeople > 0 && row.payOrders === 0) return "检查价格、发货时效和规格说明,整理买家疑虑。";
if (row.viewRate < 0.015) return "换首图,标题前 20 字放型号和配件类型。";
return "优化标题、主图和详情,观察 7 天。";
}
function removeReason(row) {
if (row.successRefundOrders > 0) return `退款 ${row.successRefundOrders} 单 / ${money(row.successRefundAmount)} 元。`;
if (row.viewPeople === 0) return `曝光 ${row.exposurePeople} 但无浏览。`;
if (row.viewRate < 0.012) return `曝光 ${row.exposurePeople},点击率过低。`;
return `${row.category} 非主线且无成交。`;
}
function removeSuggestion(row) {
if (row.successRefundOrders > 0) return "停止扩品,复查货源质量和售后,必要时下架。";
if (row.category === "绿植盆栽") return "不继续维护,优先下架或停止上新。";
if (row.category === "厨房/家居工具") return "售后风险高,不再新增,低效链接逐步清理。";
if (row.viewPeople === 0 || row.viewRate < 0.012) return "下架或重做主图标题后只观察 7 天。";
return "与主线不一致,优先清理货盘。";
}
function prioritySort(a, b) {
const weight = { S: 3, A: 2, B: 1, C: 0 };
return (weight[b.priority] || 0) - (weight[a.priority] || 0) || b.payOrders - a.payOrders || b.payAmount - a.payAmount;
}
function buildMarkdown(rows, categoryStats, supplement, optimize, remove) {
const totals = getTotals(rows);
const mainCategory = getMainCategory(categoryStats);
const dateRange = els.dateRange.value || "未填写";
const lines = [];
lines.push("# 小配件闲鱼运营助手周报");
lines.push("");
lines.push(`数据范围:\`${dateRange}\``);
lines.push("");
lines.push("## 总览");
lines.push("");
lines.push("| 指标 | 数值 |");
lines.push("|---|---:|");
lines.push(`| 分析商品 | ${rows.length} 个 |`);
lines.push(`| 支付订单 | ${totals.payOrders} 单 |`);
lines.push(`| 支付金额 | ${money(totals.payAmount)} 元 |`);
lines.push(`| 退款订单 | ${totals.refundOrders} 单 |`);
lines.push(`| 退款金额 | ${money(totals.refundAmount)} 元 |`);
lines.push(`| 主力类目 | ${mainCategory || "-"} |`);
lines.push("");
lines.push(`核心结论:${mainCategory || "当前数据不足"} 是当前最值得优先观察的方向。`);
lines.push("");
lines.push("## 类目表现");
lines.push("");
lines.push("| 类目 | 商品数 | 支付订单 | 支付金额 | 退款金额 | 浏览支付率 |");
lines.push("|---|---:|---:|---:|---:|---:|");
categoryStats.forEach((item) => {
lines.push(`| ${item.category} | ${item.items} | ${item.payOrders} | ${money(item.payAmount)} | ${money(item.refundAmount)} | ${percent(item.payRate)} |`);
});
lines.push("");
lines.push("## 补品清单");
lines.push("");
pushResultTable(lines, supplement);
lines.push("");
lines.push("## 优化清单");
lines.push("");
pushResultTable(lines, optimize);
lines.push("");
lines.push("## 下架清单");
lines.push("");
pushResultTable(lines, remove);
lines.push("");
lines.push("## 下周动作");
lines.push("");
const actions = buildNextActions(supplement, remove);
actions.forEach((action, index) => lines.push(`${index + 1}. ${action}`));
lines.push("");
lines.push("## 一句话建议");
lines.push("");
lines.push(buildOneLineAdvice(categoryStats, supplement));
lines.push("");
return lines.join("\n");
}
function pushResultTable(lines, rows) {
if (!rows.length) {
lines.push("暂无。");
return;
}
lines.push("| 优先级 | 商品 | 数据 | 建议 |");
lines.push("|---|---|---:|---|");
rows.slice(0, 20).forEach((row) => {
const data = `${row.payOrders}单 / ${money(row.payAmount)}元 / 退款${row.successRefundOrders}`;
lines.push(`| ${row.priority} | ${escapeMd(row.title)} | ${data} | ${escapeMd(row.suggestion)} |`);
});
}
function buildNextActions(supplement, remove) {
const actions = [];
supplement.slice(0, 5).forEach((row) => actions.push(row.suggestion.replace(/。$/, "")));
if (remove.length) actions.push("清理厨房工具、绿植、泛杂货等低效或高售后链接");
if (!actions.length) actions.push("补充更多商品数据后再做动作判断");
return actions.slice(0, 6);
}
function buildOneLineAdvice(categoryStats, supplement) {
const main = getMainCategory(categoryStats);
const top = supplement[0];
if (top) return `下一周优先围绕 ${top.model || top.title} 做配件补全,不要继续扩杂货。`;
return main ? `下一周先围绕 ${main} 做标题和主图优化。` : "先补充近 7 天或近 30 天商品数据。";
}
function getTotals(rows) {
return rows.reduce((acc, row) => {
acc.payOrders += row.payOrders;
acc.payAmount += row.payAmount;
acc.refundOrders += row.successRefundOrders;
acc.refundAmount += row.successRefundAmount;
return acc;
}, { payOrders: 0, payAmount: 0, refundOrders: 0, refundAmount: 0 });
}
function getMainCategory(categoryStats) {
return categoryStats.length ? categoryStats[0].category : "";
}
function updateMetrics() {
const totals = getTotals(state.rows);
els.metricItems.textContent = state.rows.length;
els.metricOrders.textContent = totals.payOrders;
els.metricAmount.textContent = money(totals.payAmount);
els.metricRefund.textContent = money(totals.refundAmount);
els.metricCategory.textContent = getMainCategory(state.results.categoryStats || []) || "-";
}
function renderTab() {
if (!state.rows.length) {
renderEmpty();
return;
}
if (state.activeTab === "supplement") renderResultTable("补品清单", state.results.supplement, "符合成交稳定、退款低、型号明确的商品线。");
if (state.activeTab === "optimize") renderResultTable("优化清单", state.results.optimize, "有流量或询单但没有成交,先改标题、主图、价格和适配说明。");
if (state.activeTab === "remove") renderResultTable("下架清单", state.results.remove, "退款高、非主线或低效曝光商品,优先收缩货盘。");
if (state.activeTab === "review") renderReview();
if (state.activeTab === "data") renderData();
}
function renderEmpty() {
els.tabBody.innerHTML = '<div class="empty">粘贴数据后点击“分析数据”,这里会生成三张清单和周报。</div>';
}
function renderResultTable(title, rows, summary) {
const empty = rows.length ? "" : '<div class="empty">暂无符合条件的商品。</div>';
const table = rows.length ? `
<div class="table-wrap">
<table>
<thead>
<tr>
<th>优先级</th>
<th>商品</th>
<th>型号/配件</th>
<th>关键数据</th>
<th>判断</th>
<th>操作建议</th>
</tr>
</thead>
<tbody>
${rows.map(resultRowHtml).join("")}
</tbody>
</table>
</div>` : "";
els.tabBody.innerHTML = `
<div class="summary-line">${title}:${rows.length} 项。${summary}</div>
${empty}
${table}
`;
}
function resultRowHtml(row) {
return `
<tr>
<td><span class="badge ${row.priority.toLowerCase()}">${row.priority}</span></td>
<td class="title-cell">
<strong>${escapeHtml(row.title)}</strong>
<div class="muted">ID ${row.itemId} · ${row.category}</div>
</td>
<td>${escapeHtml(row.model || "-")}<div class="muted">${escapeHtml(row.accessoryType || "-")}</div></td>
<td>
${row.payOrders} 单 / ${money(row.payAmount)} 元
<div class="muted">曝光 ${row.exposurePeople} · 浏览 ${row.viewPeople} · 询单 ${row.inquiryPeople} · 退款 ${row.successRefundOrders}</div>
</td>
<td>${escapeHtml(row.reason)}</td>
<td>${escapeHtml(row.suggestion)}</td>
</tr>
`;
}
function renderReview() {
const markdown = state.results.markdown || "";
els.tabBody.innerHTML = `
<div class="summary-line">周报 Markdown 已生成,可复制或下载到 Obsidian。</div>
<textarea class="markdown-box" readonly>${escapeHtml(markdown)}</textarea>
`;
}
function renderData() {
const categoryCards = (state.results.categoryStats || []).map((item) => `
<div class="note-card">
<h3>${escapeHtml(item.category)}</h3>
<p>${item.items} 个商品,${item.payOrders} 单,${money(item.payAmount)} 元,退款 ${money(item.refundAmount)} 元,浏览支付率 ${percent(item.payRate)}。</p>
</div>
`).join("");
const rows = state.rows.map((row) => `
<tr>
<td>${escapeHtml(row.itemId)}</td>
<td class="title-cell">${escapeHtml(row.title)}</td>
<td>${escapeHtml(row.category)}</td>
<td>${escapeHtml(row.model || "-")}</td>
<td>${escapeHtml(row.accessoryType || "-")}</td>
<td>${row.exposurePeople}</td>
<td>${row.viewPeople}</td>
<td>${row.inquiryPeople}</td>
<td>${row.payOrders}</td>
<td>${money(row.payAmount)}</td>
<td>${row.successRefundOrders}</td>
</tr>
`).join("");
els.tabBody.innerHTML = `
<div class="summary-line">解析数据:${state.rows.length} 个商品。字段解析有误时,可以调整粘贴格式后重新分析。</div>
<div class="cards-grid">${categoryCards}</div>
<div style="height:12px"></div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>商品ID</th>
<th>标题</th>
<th>类目</th>
<th>型号</th>
<th>配件</th>
<th>曝光</th>
<th>浏览</th>
<th>询单</th>
<th>订单</th>
<th>金额</th>
<th>退款单</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
</div>
`;
}
function showToast(message) {
els.toast.textContent = message;
els.toast.classList.add("show");
setTimeout(() => els.toast.classList.remove("show"), 1800);
}
function money(value) {
return Number(value || 0).toFixed(2).replace(/\.00$/, "");
}
function percent(value) {
return `${((value || 0) * 100).toFixed(2)}%`;
}
function escapeHtml(value) {
return String(value || "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function escapeMd(value) {
return String(value || "").replace(/\|/g, "/").replace(/\n/g, " ");
}
els.collectorScript.value = getCollectorScript();
updateMetrics();
</script>
</body>
</html>