Files
ObsidianHome/小鱼/小配件闲鱼运营助手.html
T

1505 lines
48 KiB
HTML
Raw Normal View History

2026-11-05 15:16:24 +08:00
<!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>