| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720 |
- /**
- * 原始 Excel 宣发数据 → 漂亮 HTML 单页
- * 输入:
- * shuhua/舒华跑步机四月宣发报备KOC发布数据.xlsx
- * majiyong/【新鲜菜项目】马记永4月宣发数据汇总(1).xlsx
- * 输出:
- * shuhua/shuhua-raw-data.html
- * majiyong/majiyong-raw-data.html
- *
- * 视觉风格:与 *-closing-report.html 一致(暗色 + ECharts)
- */
- const fs = require('fs');
- const path = require('path');
- const XLSX = require('xlsx');
- const ROOT = path.resolve(__dirname, '..');
- const SHUHUA_XLSX = path.join(ROOT, 'shuhua', '舒华跑步机四月宣发报备KOC发布数据.xlsx');
- const MAJI_XLSX = path.join(ROOT, 'majiyong', '【新鲜菜项目】马记永4月宣发数据汇总(1).xlsx');
- const SHUHUA_OUT = path.join(ROOT, 'shuhua', 'shuhua-raw-data.html');
- const MAJI_OUT = path.join(ROOT, 'majiyong', 'majiyong-raw-data.html');
- // ============== utils ==============
- const num = (v) => {
- const n = Number(v);
- return Number.isFinite(n) ? n : 0;
- };
- const esc = (s) => String(s == null ? '' : s)
- .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
- .replace(/"/g, '"').replace(/'/g, ''');
- const fmt = (n) => {
- n = Number(n) || 0;
- if (Math.abs(n) >= 1e8) return (n / 1e8).toFixed(2) + ' 亿';
- if (Math.abs(n) >= 1e4) return (n / 1e4).toFixed(2) + ' 万';
- return n.toLocaleString('zh-CN', { maximumFractionDigits: 2 });
- };
- const fmtInt = (n) => (Number(n) || 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 });
- const fmtMoney = (n) => '¥' + (Number(n) || 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 });
- // excel serial → yyyy-MM-dd
- const excelDate = (v) => {
- if (!v) return '';
- if (typeof v === 'string' && /\d{4}-\d{1,2}-\d{1,2}/.test(v)) return v;
- const n = Number(v);
- if (!Number.isFinite(n) || n < 30000 || n > 80000) return String(v);
- const ms = (n - 25569) * 86400 * 1000;
- const d = new Date(ms);
- const Y = d.getUTCFullYear();
- const M = String(d.getUTCMonth() + 1).padStart(2, '0');
- const D = String(d.getUTCDate()).padStart(2, '0');
- return `${Y}-${M}-${D}`;
- };
- const safeHref = (u) => {
- const s = String(u || '').trim();
- if (!s || !/^https?:\/\//i.test(s)) return '';
- return s;
- };
- // ============== shared CSS / shell ==============
- const COMMON_HEAD = (title) => `<!DOCTYPE html>
- <html lang="zh-CN">
- <head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>${esc(title)}</title>
- <link rel="preconnect" href="https://fonts.googleapis.com">
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
- <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;600&family=Space+Grotesk:wght@500;700;800&display=swap" rel="stylesheet">
- <script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
- <style>
- :root {
- --bg-0:#0A0A0A; --bg-1:#111; --bg-2:#1A1A1A; --bg-3:#252525;
- --border:rgba(255,255,255,0.08); --border-strong:rgba(255,255,255,0.16);
- --text-1:#F5F5F5; --text-2:#A8A8A8; --text-3:#6B6B6B;
- --amber:#F5A623; --green:#00DC82; --purple:#8B5CF6; --rose:#FF4D8D; --blue:#3B82F6; --red:#EF4444;
- --font-head:'Space Grotesk','Inter',-apple-system,'PingFang SC',sans-serif;
- --font-body:'Inter',-apple-system,'PingFang SC','Microsoft YaHei',sans-serif;
- --font-mono:'JetBrains Mono',ui-monospace,monospace;
- }
- *{margin:0;padding:0;box-sizing:border-box}
- html{scroll-behavior:smooth}
- body{font-family:var(--font-body);background:var(--bg-0);color:var(--text-1);line-height:1.65;-webkit-font-smoothing:antialiased;overflow-x:hidden}
- body::before{content:'';position:fixed;inset:0;pointer-events:none;z-index:0;
- background:radial-gradient(ellipse 900px 700px at 20% 25%,rgba(245,166,35,0.08),transparent 60%),
- radial-gradient(ellipse 800px 600px at 80% 75%,rgba(139,92,246,0.06),transparent 60%)}
- a{color:var(--amber);text-decoration:none}
- a:hover{text-decoration:underline}
- ::selection{background:var(--amber);color:#000}
- .wrap{position:relative;z-index:1;max-width:1320px;margin:0 auto;padding:clamp(40px,7vw,80px) clamp(20px,5vw,60px)}
- .hero{padding-bottom:48px;border-bottom:1px solid var(--border);margin-bottom:48px}
- .eyebrow{display:inline-flex;align-items:center;gap:10px;font-family:var(--font-mono);font-size:.72rem;letter-spacing:.18em;text-transform:uppercase;color:var(--amber);font-weight:500;margin-bottom:18px}
- .eyebrow::before{content:'';display:inline-block;width:24px;height:1px;background:var(--amber)}
- .h0{font-family:var(--font-head);font-weight:900;letter-spacing:-.04em;line-height:.98;font-size:clamp(2.4rem,6vw,4.6rem)}
- .lead{color:var(--text-2);font-size:1.05rem;max-width:780px;margin-top:18px;line-height:1.8}
- .meta-row{display:flex;gap:32px;margin-top:28px;flex-wrap:wrap}
- .meta-item{font-family:var(--font-mono);font-size:.82rem;color:var(--text-2)}
- .meta-item strong{color:var(--text-1);font-weight:600;display:block;margin-top:4px;font-size:1.1rem}
- section.block{margin-bottom:64px}
- .section-head{display:flex;align-items:baseline;gap:16px;margin-bottom:24px;flex-wrap:wrap}
- .section-num{font-family:var(--font-mono);font-size:.78rem;letter-spacing:.25em;color:var(--text-3)}
- .h1{font-family:var(--font-head);font-weight:800;letter-spacing:-.02em;line-height:1.1;font-size:clamp(1.6rem,3vw,2.2rem)}
- .sub{color:var(--text-2);max-width:880px;margin-bottom:24px}
- .grid{display:grid;gap:18px}
- .grid-2{grid-template-columns:repeat(auto-fit,minmax(360px,1fr))}
- .grid-3{grid-template-columns:repeat(auto-fit,minmax(260px,1fr))}
- .grid-4{grid-template-columns:repeat(auto-fit,minmax(220px,1fr))}
- .grid-5{grid-template-columns:repeat(auto-fit,minmax(200px,1fr))}
- .metric{background:var(--bg-1);border:1px solid var(--border);border-radius:12px;padding:22px;position:relative;overflow:hidden;min-width:0}
- .metric::before{content:'';position:absolute;top:0;left:0;right:0;height:2px;background:linear-gradient(90deg,var(--amber),transparent);opacity:.6}
- .metric.green::before{background:linear-gradient(90deg,var(--green),transparent)}
- .metric.blue::before{background:linear-gradient(90deg,var(--blue),transparent)}
- .metric.rose::before{background:linear-gradient(90deg,var(--rose),transparent)}
- .metric.purple::before{background:linear-gradient(90deg,var(--purple),transparent)}
- .metric-label{font-family:var(--font-mono);font-size:.7rem;letter-spacing:.15em;text-transform:uppercase;color:var(--text-3);margin-bottom:10px}
- .metric-value{font-family:var(--font-head);font-weight:800;font-size:clamp(1.5rem,2.4vw,2.1rem);letter-spacing:-.03em;line-height:1.05;word-break:break-all}
- .metric-unit{font-family:var(--font-mono);font-size:.78rem;color:var(--amber);font-weight:500;margin-left:4px}
- .metric-sub{margin-top:12px;font-size:.78rem;color:var(--text-2);line-height:1.5}
- .card{background:linear-gradient(180deg,rgba(255,255,255,.025) 0%,rgba(255,255,255,.005) 100%),var(--bg-1);border:1px solid var(--border);border-radius:12px;padding:24px}
- .chart{width:100%;height:340px}
- .chart-tall{width:100%;height:440px}
- .controls{display:flex;gap:12px;margin-bottom:14px;flex-wrap:wrap;align-items:center}
- .controls input,.controls select{background:var(--bg-2);color:var(--text-1);border:1px solid var(--border);border-radius:8px;padding:9px 12px;font-family:var(--font-body);font-size:.88rem;min-width:160px}
- .controls input:focus,.controls select:focus{outline:none;border-color:var(--amber)}
- .controls .hint{color:var(--text-3);font-size:.78rem;font-family:var(--font-mono)}
- .scroll-table{max-height:78vh;overflow:auto;border:1px solid var(--border);border-radius:12px;background:var(--bg-1)}
- .scroll-table::-webkit-scrollbar{width:8px;height:8px}
- .scroll-table::-webkit-scrollbar-thumb{background:var(--bg-3);border-radius:4px}
- table{width:100%;border-collapse:collapse;font-size:.84rem}
- th,td{padding:9px 11px;border-bottom:1px solid var(--border);text-align:left;white-space:nowrap}
- th{color:var(--text-2);font-weight:500;font-size:.72rem;letter-spacing:.06em;text-transform:uppercase;background:var(--bg-2);position:sticky;top:0;z-index:2;cursor:pointer;user-select:none}
- th.num,td.num{text-align:right;font-family:var(--font-mono)}
- th .arr{color:var(--text-3);margin-left:4px;font-size:.7rem}
- th.active{color:var(--amber)}
- tr:hover td{background:rgba(255,255,255,.025)}
- td.link a{color:var(--amber);font-family:var(--font-mono);font-size:.78rem}
- .tag{display:inline-block;padding:2px 8px;border-radius:4px;font-size:.7rem;font-family:var(--font-mono);background:rgba(245,166,35,.1);color:var(--amber);border:1px solid rgba(245,166,35,.22)}
- .tag.g{background:rgba(0,220,130,.08);color:var(--green);border-color:rgba(0,220,130,.22)}
- .tag.b{background:rgba(59,130,246,.08);color:var(--blue);border-color:rgba(59,130,246,.22)}
- .tag.r{background:rgba(255,77,141,.08);color:var(--rose);border-color:rgba(255,77,141,.22)}
- .tag.p{background:rgba(139,92,246,.08);color:var(--purple);border-color:rgba(139,92,246,.22)}
- footer{margin-top:80px;padding-top:32px;border-top:1px solid var(--border);color:var(--text-3);font-size:.8rem;font-family:var(--font-mono);text-align:center}
- </style>
- </head><body><div class="wrap">`;
- const COMMON_FOOT = (footText) => `
- <footer>${esc(footText)}</footer>
- </div>
- <script>
- // 通用表格排序 + 搜索
- function bindTable(tableId){
- const tbl=document.getElementById(tableId); if(!tbl) return;
- const ths=tbl.querySelectorAll('thead th'); const tbody=tbl.querySelector('tbody');
- const rows=Array.from(tbody.querySelectorAll('tr'));
- let sortKey=-1, sortDir=1;
- ths.forEach((th,idx)=>{
- th.addEventListener('click',()=>{
- const isNum=th.classList.contains('num');
- if(sortKey===idx) sortDir=-sortDir; else { sortKey=idx; sortDir=1; }
- ths.forEach(t=>{ t.classList.remove('active'); const a=t.querySelector('.arr'); if(a)a.textContent=''; });
- th.classList.add('active');
- const arr=th.querySelector('.arr'); if(arr) arr.textContent=sortDir>0?'▲':'▼';
- rows.sort((a,b)=>{
- const av=a.children[idx].dataset.v ?? a.children[idx].textContent.trim();
- const bv=b.children[idx].dataset.v ?? b.children[idx].textContent.trim();
- if(isNum){ return (Number(av)-Number(bv))*sortDir; }
- return String(av).localeCompare(String(bv),'zh')*sortDir;
- });
- rows.forEach(r=>tbody.appendChild(r));
- });
- });
- return rows;
- }
- function bindFilter(tableId,inputId,selectIds){
- const tbl=document.getElementById(tableId); if(!tbl) return;
- const tbody=tbl.querySelector('tbody');
- const rows=Array.from(tbody.querySelectorAll('tr'));
- const inp=document.getElementById(inputId);
- const sels=(selectIds||[]).map(id=>document.getElementById(id)).filter(Boolean);
- function apply(){
- const q=(inp&&inp.value||'').trim().toLowerCase();
- const filters=sels.map(s=>({col:Number(s.dataset.col),val:s.value}));
- let n=0;
- rows.forEach(r=>{
- let ok=true;
- if(q){ ok=r.textContent.toLowerCase().includes(q); }
- if(ok){
- for(const f of filters){
- if(f.val && r.children[f.col] && r.children[f.col].textContent.trim()!==f.val){ ok=false; break; }
- }
- }
- r.style.display=ok?'':'none';
- if(ok) n++;
- });
- const cnt=document.getElementById(tableId+'-cnt'); if(cnt) cnt.textContent=n+' / '+rows.length;
- }
- if(inp) inp.addEventListener('input',apply);
- sels.forEach(s=>s.addEventListener('change',apply));
- apply();
- }
- </script>
- </body></html>`;
- // ============== ECharts options ==============
- const echartsBaseTheme = `
- textStyle:{color:'#A8A8A8',fontFamily:'Inter,PingFang SC'},
- legend:{textStyle:{color:'#A8A8A8'}},
- tooltip:{backgroundColor:'#1A1A1A',borderColor:'rgba(255,255,255,0.1)',textStyle:{color:'#F5F5F5'}},
- grid:{left:60,right:30,top:50,bottom:50},
- xAxis:{axisLine:{lineStyle:{color:'rgba(255,255,255,0.1)'}},axisLabel:{color:'#A8A8A8',fontSize:11},splitLine:{lineStyle:{color:'rgba(255,255,255,0.04)'}}},
- yAxis:{axisLine:{lineStyle:{color:'rgba(255,255,255,0.1)'}},axisLabel:{color:'#A8A8A8',fontSize:11},splitLine:{lineStyle:{color:'rgba(255,255,255,0.04)'}}}
- `;
- // ============== SHUHUA builder ==============
- function buildShuhua() {
- const wb = XLSX.readFile(SHUHUA_XLSX);
- const sheet = wb.Sheets[wb.SheetNames[0]];
- const rows = XLSX.utils.sheet_to_json(sheet, { defval: '' });
- const items = rows.map((r) => ({
- idx: num(r['序号']),
- type: String(r['类型'] || '').trim(),
- name: String(r['名称'] || '').trim(),
- homepage: String(r['主页链接'] || '').trim(),
- accountId: String(r['账号ID'] || '').trim(),
- followers: num(r['粉丝']),
- liked: num(r['赞藏']),
- imgPrice: num(r['图文']),
- imgOrderPrice: num(r['图文下单价']),
- textPrice: num(r['写文']),
- priceTotal: num(r['合计含税直发价格']),
- publishDate: excelDate(r['发布时间']),
- contentType: String(r['内容类型'] || '').trim() || '未分类',
- publishLink: String(r['发布链接'] || '').trim(),
- impressions: num(r['曝光量']),
- reads: num(r['阅读量']),
- likes: num(r['点赞']),
- collects: num(r['收藏']),
- comments: num(r['评论']),
- engagement: num(r['互动总量']),
- }));
- const sum = items.reduce((a, r) => ({
- spend: a.spend + r.priceTotal,
- imp: a.imp + r.impressions,
- reads: a.reads + r.reads,
- likes: a.likes + r.likes,
- collects: a.collects + r.collects,
- comments: a.comments + r.comments,
- eng: a.eng + r.engagement,
- followers: a.followers + r.followers,
- }), { spend: 0, imp: 0, reads: 0, likes: 0, collects: 0, comments: 0, eng: 0, followers: 0 });
- const total = {
- kocs: items.length,
- ...sum,
- engRate: sum.reads ? (sum.eng / sum.reads * 100) : 0,
- cpe: sum.eng ? (sum.spend / sum.eng) : 0,
- cpm: sum.imp ? (sum.spend * 1000 / sum.imp) : 0,
- avgFans: items.length ? Math.round(sum.followers / items.length) : 0,
- };
- // 内容类型聚合
- const byCt = {};
- for (const r of items) {
- const k = r.contentType || '未分类';
- if (!byCt[k]) byCt[k] = { count: 0, eng: 0, reads: 0, spend: 0 };
- byCt[k].count++;
- byCt[k].eng += r.engagement;
- byCt[k].reads += r.reads;
- byCt[k].spend += r.priceTotal;
- }
- const ctStats = Object.entries(byCt).map(([k, v]) => ({ name: k, ...v })).sort((a, b) => b.eng - a.eng);
- // 类型聚合(达人级别)
- const byTp = {};
- for (const r of items) {
- const k = r.type || '未分类';
- if (!byTp[k]) byTp[k] = { count: 0, eng: 0, spend: 0 };
- byTp[k].count++;
- byTp[k].eng += r.engagement;
- byTp[k].spend += r.priceTotal;
- }
- const tpStats = Object.entries(byTp).map(([k, v]) => ({ name: k, ...v }));
- const ctOptions = [...new Set(items.map(r => r.contentType))].filter(Boolean).sort();
- const tpOptions = [...new Set(items.map(r => r.type))].filter(Boolean).sort();
- const tableRows = items.slice().sort((a, b) => b.engagement - a.engagement).map((r) => {
- const href = safeHref(r.publishLink);
- const homeHref = safeHref(r.homepage);
- return `<tr>
- <td data-v="${r.idx}">${r.idx}</td>
- <td><span class="tag b">${esc(r.type)}</span></td>
- <td>${homeHref ? `<a href="${esc(homeHref)}" target="_blank">${esc(r.name)}</a>` : esc(r.name)}</td>
- <td><span class="tag">${esc(r.contentType)}</span></td>
- <td>${esc(r.publishDate)}</td>
- <td class="num" data-v="${r.followers}">${fmtInt(r.followers)}</td>
- <td class="num" data-v="${r.liked}">${fmtInt(r.liked)}</td>
- <td class="num" data-v="${r.priceTotal}">${fmtMoney(r.priceTotal)}</td>
- <td class="num" data-v="${r.impressions}">${fmtInt(r.impressions)}</td>
- <td class="num" data-v="${r.reads}">${fmtInt(r.reads)}</td>
- <td class="num" data-v="${r.likes}">${fmtInt(r.likes)}</td>
- <td class="num" data-v="${r.collects}">${fmtInt(r.collects)}</td>
- <td class="num" data-v="${r.comments}">${fmtInt(r.comments)}</td>
- <td class="num" data-v="${r.engagement}"><strong style="color:var(--amber)">${fmtInt(r.engagement)}</strong></td>
- <td class="link">${href ? `<a href="${esc(href)}" target="_blank">打开</a>` : '<span style="color:var(--text-3)">—</span>'}</td>
- </tr>`;
- }).join('');
- const scatterData = JSON.stringify(items.map(r => [r.followers, r.engagement, r.name, r.contentType]));
- const ctNames = JSON.stringify(ctStats.map(x => x.name));
- const ctEng = JSON.stringify(ctStats.map(x => x.eng));
- const ctCnt = JSON.stringify(ctStats.map(x => x.count));
- const tpPie = JSON.stringify(tpStats.map(x => ({ name: x.name, value: x.count })));
- const html = COMMON_HEAD('舒华跑步机 · 四月 KOC 宣发原始数据') + `
- <section class="hero">
- <div class="eyebrow">SHUHUA · APRIL 2025 · RAW DATA</div>
- <h1 class="h0">舒华跑步机<br/>四月 KOC 宣发 · 原始数据</h1>
- <p class="lead">数据源:<code style="color:var(--text-1)">舒华跑步机四月宣发报备KOC发布数据.xlsx</code>,共 <strong style="color:var(--amber)">${total.kocs}</strong> 位 KOC 达人。本页对原始 Excel 字段进行结构化重构,所有指标可即时筛选、排序、跳转原帖。</p>
- <div class="meta-row">
- <div class="meta-item">达人数<strong>${total.kocs}</strong></div>
- <div class="meta-item">总曝光<strong>${fmt(total.imp)}</strong></div>
- <div class="meta-item">总互动<strong>${fmt(total.eng)}</strong></div>
- <div class="meta-item">总投放<strong>${fmtMoney(total.spend)}</strong></div>
- <div class="meta-item">互动率<strong>${total.engRate.toFixed(2)}%</strong></div>
- </div>
- </section>
- <section class="block">
- <div class="section-head"><span class="section-num">01 / OVERVIEW</span><h2 class="h1">核心指标总览</h2></div>
- <div class="grid grid-4">
- <div class="metric"><div class="metric-label">KOC 达人</div><div class="metric-value">${total.kocs}<span class="metric-unit">位</span></div><div class="metric-sub">平均粉丝 ${fmtInt(total.avgFans)}</div></div>
- <div class="metric green"><div class="metric-label">总曝光量</div><div class="metric-value">${fmt(total.imp)}</div><div class="metric-sub">CPM ${total.cpm.toFixed(2)} 元</div></div>
- <div class="metric blue"><div class="metric-label">总阅读量</div><div class="metric-value">${fmt(total.reads)}</div><div class="metric-sub">点赞 ${fmtInt(total.likes)} · 收藏 ${fmtInt(total.collects)} · 评论 ${fmtInt(total.comments)}</div></div>
- <div class="metric rose"><div class="metric-label">总互动量</div><div class="metric-value">${fmt(total.eng)}</div><div class="metric-sub">互动率 ${total.engRate.toFixed(2)}% · CPE ${total.cpe.toFixed(2)} 元</div></div>
- <div class="metric purple"><div class="metric-label">总投放金额</div><div class="metric-value">${fmtMoney(total.spend)}</div><div class="metric-sub">单 KOC 均价 ${fmtMoney(total.spend / total.kocs)}</div></div>
- </div>
- </section>
- <section class="block">
- <div class="section-head"><span class="section-num">02 / CHARTS</span><h2 class="h1">维度可视化</h2></div>
- <div class="grid grid-2">
- <div class="card"><div style="margin-bottom:10px;color:var(--text-2);font-size:.86rem">内容类型 · 互动量与篇数</div><div id="ct-bar" class="chart"></div></div>
- <div class="card"><div style="margin-bottom:10px;color:var(--text-2);font-size:.86rem">达人级别分布</div><div id="tp-pie" class="chart"></div></div>
- <div class="card" style="grid-column:1/-1"><div style="margin-bottom:10px;color:var(--text-2);font-size:.86rem">粉丝量 × 互动量 散点(每点 = 1 KOC)</div><div id="fans-eng" class="chart-tall"></div></div>
- </div>
- </section>
- <section class="block">
- <div class="section-head"><span class="section-num">03 / TABLE</span><h2 class="h1">明细数据表</h2></div>
- <p class="sub">点击表头即可排序,支持全字段关键词搜索 + 类型/内容类型筛选。默认按 <strong style="color:var(--amber)">互动总量</strong> 降序。</p>
- <div class="controls">
- <input id="sh-q" placeholder="🔍 搜索昵称 / 类型 / 内容类型…">
- <select id="sh-fc" data-col="3"><option value="">全部内容类型</option>${ctOptions.map(o => `<option>${esc(o)}</option>`).join('')}</select>
- <select id="sh-ft" data-col="1"><option value="">全部达人级别</option>${tpOptions.map(o => `<option>${esc(o)}</option>`).join('')}</select>
- <span class="hint" id="sh-tbl-cnt"></span>
- </div>
- <div class="scroll-table"><table id="sh-tbl"><thead><tr>
- <th class="num">#<span class="arr"></span></th>
- <th>级别<span class="arr"></span></th>
- <th>昵称<span class="arr"></span></th>
- <th>内容类型<span class="arr"></span></th>
- <th>发布日期<span class="arr"></span></th>
- <th class="num">粉丝<span class="arr"></span></th>
- <th class="num">赞藏<span class="arr"></span></th>
- <th class="num">投放价<span class="arr"></span></th>
- <th class="num">曝光<span class="arr"></span></th>
- <th class="num">阅读<span class="arr"></span></th>
- <th class="num">点赞<span class="arr"></span></th>
- <th class="num">收藏<span class="arr"></span></th>
- <th class="num">评论<span class="arr"></span></th>
- <th class="num">互动<span class="arr"></span></th>
- <th>原帖<span class="arr"></span></th>
- </tr></thead><tbody>${tableRows}</tbody></table></div>
- </section>
- <script>
- echarts.init(document.getElementById('ct-bar')).setOption({
- ${echartsBaseTheme},
- legend:{data:['互动量','篇数'],top:0},
- xAxis:{type:'category',data:${ctNames},axisLabel:{interval:0,rotate:30}},
- yAxis:[{type:'value',name:'互动量'},{type:'value',name:'篇数'}],
- series:[
- {type:'bar',name:'互动量',data:${ctEng},itemStyle:{color:'#F5A623',borderRadius:[4,4,0,0]}},
- {type:'line',name:'篇数',yAxisIndex:1,data:${ctCnt},itemStyle:{color:'#00DC82'},lineStyle:{width:2},symbol:'circle',symbolSize:8}
- ]
- });
- echarts.init(document.getElementById('tp-pie')).setOption({
- ${echartsBaseTheme},
- tooltip:{trigger:'item'},
- legend:{bottom:0,textStyle:{color:'#A8A8A8'}},
- series:[{type:'pie',radius:['45%','72%'],center:['50%','45%'],
- label:{color:'#A8A8A8',formatter:'{b}\\n{d}%'},
- itemStyle:{borderColor:'#0A0A0A',borderWidth:2},
- data:${tpPie}.map((d,i)=>({...d,itemStyle:{color:['#F5A623','#8B5CF6','#3B82F6','#00DC82','#FF4D8D','#EF4444'][i%6]}}))
- }]
- });
- echarts.init(document.getElementById('fans-eng')).setOption({
- ${echartsBaseTheme},
- tooltip:{trigger:'item',formatter:p=>p.data[2]+'<br/>粉丝: '+p.data[0].toLocaleString()+'<br/>互动: '+p.data[1].toLocaleString()+'<br/>类型: '+p.data[3]},
- xAxis:{type:'value',name:'粉丝量',axisLabel:{formatter:v=>v>=10000?(v/10000).toFixed(0)+'w':v}},
- yAxis:{type:'value',name:'互动量'},
- series:[{type:'scatter',data:${scatterData},symbolSize:d=>Math.max(6,Math.min(28,Math.sqrt(d[1])/2)),itemStyle:{color:'#F5A623',opacity:.75,borderColor:'#0A0A0A',borderWidth:1}}]
- });
- bindTable('sh-tbl');
- bindFilter('sh-tbl','sh-q',['sh-fc','sh-ft']);
- </script>` + COMMON_FOOT('舒华跑步机 · 四月 KOC 宣发原始数据 · Generated from 舒华跑步机四月宣发报备KOC发布数据.xlsx');
- fs.writeFileSync(SHUHUA_OUT, html, 'utf8');
- console.log('✅ 舒华原始数据页生成:', SHUHUA_OUT, `(${(fs.statSync(SHUHUA_OUT).size / 1024).toFixed(1)} KB)`);
- }
- // ============== MAJIYONG builder ==============
- function buildMajiyong() {
- const wb = XLSX.readFile(MAJI_XLSX);
- // 数据汇总
- const summaryRows = XLSX.utils.sheet_to_json(wb.Sheets['数据汇总'], { defval: '' });
- const summary = summaryRows.filter(r => r['合作量级']).map(r => ({
- tier: String(r['合作量级']).trim(),
- count: num(r['合作数量']),
- eng: num(r['互动量']),
- imp: num(r['曝光量']),
- spend: num(r['达人采购费用']),
- adSpend: num(r['投流费用']),
- cpe: num(r['单次互动成本']),
- cpm: num(r['千次曝光成本']),
- }));
- const grandTotal = summary.find(r => /合计|总计|总和/.test(r.tier)) || summary.reduce((a, r) => ({
- tier: '合计', count: a.count + r.count, eng: a.eng + r.eng, imp: a.imp + r.imp,
- spend: a.spend + r.spend, adSpend: a.adSpend + r.adSpend, cpe: 0, cpm: 0,
- }), { tier: '合计', count: 0, eng: 0, imp: 0, spend: 0, adSpend: 0, cpe: 0, cpm: 0 });
- const totalSpend = grandTotal.spend + grandTotal.adSpend;
- grandTotal.cpe = grandTotal.eng ? totalSpend / grandTotal.eng : 0;
- grandTotal.cpm = grandTotal.imp ? totalSpend * 1000 / grandTotal.imp : 0;
- const summaryList = summary.filter(r => !/合计|总计|总和/.test(r.tier));
- // KOL
- const kolRowsRaw = XLSX.utils.sheet_to_json(wb.Sheets['KOL合作表'], { defval: '' });
- const kol = kolRowsRaw.filter(r => r['序号'] && typeof r['序号'] === 'number').map(r => ({
- idx: num(r['序号']),
- tier: String(r['量级'] || '').trim(),
- ctype: String(r['类型'] || '').trim() || '未分类',
- name: String(r['小红书昵称'] || '').trim(),
- homepage: String(r['主页链接'] || '').trim(),
- followers: Math.round(num(r['粉丝数/w']) * 10000),
- region: String(r['地区'] || '').trim(),
- form: String(r['合作形式'] || '').trim(),
- price: num(r['下单价格']),
- sync: String(r['可同步平台'] || '').trim(),
- publishDate: excelDate(r['发布时间']),
- link: String(r['发布链接'] || '').trim(),
- likes: num(r['点赞']),
- collects: num(r['收藏']),
- comments: num(r['评论']),
- eng: num(r['总互动']),
- imp: num(r['曝光量']),
- syncEng: num(r['同步互动量']),
- syncImp: num(r['同步曝光量']),
- engTotal: num(r['互动量小计']),
- impTotal: num(r['曝光量小计']),
- }));
- // KOC
- const kocRowsRaw = XLSX.utils.sheet_to_json(wb.Sheets['KOC合作表'], { defval: '' });
- const koc = kocRowsRaw.filter(r => r['序号'] && typeof r['序号'] === 'number').map(r => ({
- idx: num(r['序号']),
- tier: String(r['量级'] || '').trim(),
- ctype: String(r['类型'] || '').trim() || '未分类',
- name: String(r['小红书昵称'] || '').trim(),
- homepage: String(r['主页链接'] || '').trim(),
- followers: Math.round(num(r['粉丝数/w']) * 10000),
- region: String(r['地区'] || '').trim(),
- publishDate: excelDate(r['发布时间']),
- link: String(r['发布链接'] || '').trim(),
- likes: num(r['点赞']),
- collects: num(r['收藏']),
- comments: num(r['评论']),
- eng: num(r['总互动']),
- imp: num(r['曝光量']),
- engTotal: num(r['互动量小计']),
- impTotal: num(r['曝光量小计']),
- }));
- // KOL/KOC 聚合
- const aggBy = (arr, key) => {
- const m = {};
- for (const r of arr) {
- const k = r[key] || '未分类';
- if (!m[k]) m[k] = { count: 0, eng: 0, imp: 0, spend: 0 };
- m[k].count++; m[k].eng += r.engTotal || r.eng; m[k].imp += r.impTotal || r.imp;
- m[k].spend += (r.price || 0);
- }
- return Object.entries(m).map(([k, v]) => ({ name: k, ...v })).sort((a, b) => b.eng - a.eng);
- };
- const kolByType = aggBy(kol, 'ctype');
- const kocByType = aggBy(koc, 'ctype');
- const kocByRegion = (() => {
- const m = {};
- for (const r of koc) {
- const k = (String(r.region).slice(0, 20)) || '未知';
- if (!m[k]) m[k] = 0; m[k] += r.engTotal || r.eng;
- }
- return Object.entries(m).map(([k, v]) => ({ name: k, value: v })).sort((a, b) => b.value - a.value).slice(0, 12);
- })();
- const buildKolRow = (r) => {
- const href = safeHref(r.link), homeHref = safeHref(r.homepage);
- return `<tr>
- <td data-v="${r.idx}">${r.idx}</td>
- <td><span class="tag p">${esc(r.tier)}</span></td>
- <td><span class="tag b">${esc(r.ctype)}</span></td>
- <td>${homeHref ? `<a href="${esc(homeHref)}" target="_blank">${esc(r.name)}</a>` : esc(r.name)}</td>
- <td class="num" data-v="${r.followers}">${fmtInt(r.followers)}</td>
- <td>${esc(r.region)}</td>
- <td>${esc(r.form)}</td>
- <td class="num" data-v="${r.price}">${fmtMoney(r.price)}</td>
- <td>${esc(r.publishDate)}</td>
- <td class="num" data-v="${r.likes}">${fmtInt(r.likes)}</td>
- <td class="num" data-v="${r.collects}">${fmtInt(r.collects)}</td>
- <td class="num" data-v="${r.comments}">${fmtInt(r.comments)}</td>
- <td class="num" data-v="${r.engTotal}"><strong style="color:var(--amber)">${fmtInt(r.engTotal)}</strong></td>
- <td class="num" data-v="${r.impTotal}">${fmtInt(r.impTotal)}</td>
- <td class="link">${href ? `<a href="${esc(href)}" target="_blank">打开</a>` : '<span style="color:var(--text-3)">—</span>'}</td>
- </tr>`;
- };
- const buildKocRow = (r) => {
- const href = safeHref(r.link), homeHref = safeHref(r.homepage);
- return `<tr>
- <td data-v="${r.idx}">${r.idx}</td>
- <td><span class="tag g">${esc(r.tier)}</span></td>
- <td><span class="tag">${esc(r.ctype)}</span></td>
- <td>${homeHref ? `<a href="${esc(homeHref)}" target="_blank">${esc(r.name)}</a>` : esc(r.name)}</td>
- <td class="num" data-v="${r.followers}">${fmtInt(r.followers)}</td>
- <td>${esc(r.region)}</td>
- <td>${esc(r.publishDate)}</td>
- <td class="num" data-v="${r.likes}">${fmtInt(r.likes)}</td>
- <td class="num" data-v="${r.collects}">${fmtInt(r.collects)}</td>
- <td class="num" data-v="${r.comments}">${fmtInt(r.comments)}</td>
- <td class="num" data-v="${r.engTotal}"><strong style="color:var(--amber)">${fmtInt(r.engTotal)}</strong></td>
- <td class="num" data-v="${r.impTotal}">${fmtInt(r.impTotal)}</td>
- <td class="link">${href ? `<a href="${esc(href)}" target="_blank">打开</a>` : '<span style="color:var(--text-3)">—</span>'}</td>
- </tr>`;
- };
- const kolRows = kol.slice().sort((a, b) => b.engTotal - a.engTotal).map(buildKolRow).join('');
- const kocRows = koc.slice().sort((a, b) => b.engTotal - a.engTotal).map(buildKocRow).join('');
- const kolCtOpts = [...new Set(kol.map(r => r.ctype))].filter(Boolean).sort();
- const kolTierOpts = [...new Set(kol.map(r => r.tier))].filter(Boolean).sort();
- const kocCtOpts = [...new Set(koc.map(r => r.ctype))].filter(Boolean).sort();
- const kocTierOpts = [...new Set(koc.map(r => r.tier))].filter(Boolean).sort();
- const kocRegionOpts = [...new Set(koc.map(r => r.region))].filter(Boolean).sort();
- const sumNames = JSON.stringify(summaryList.map(r => r.tier));
- const sumEng = JSON.stringify(summaryList.map(r => r.eng));
- const sumImp = JSON.stringify(summaryList.map(r => r.imp));
- const sumSpend = JSON.stringify(summaryList.map(r => r.spend + r.adSpend));
- const ctMix = JSON.stringify([...kolByType, ...kocByType].reduce((arr, x) => {
- const e = arr.find(y => y.name === x.name);
- if (e) e.value += x.eng; else arr.push({ name: x.name, value: x.eng });
- return arr;
- }, []).sort((a, b) => b.value - a.value));
- const regionData = JSON.stringify(kocByRegion);
- const html = COMMON_HEAD('马记永 · 四月新鲜菜宣发原始数据') + `
- <section class="hero">
- <div class="eyebrow">MAJIYONG · APRIL 2025 · RAW DATA</div>
- <h1 class="h0">马记永兰州牛肉面<br/>四月新鲜菜宣发 · 原始数据</h1>
- <p class="lead">数据源:<code style="color:var(--text-1)">【新鲜菜项目】马记永4月宣发数据汇总.xlsx</code>,包含数据汇总 / KOL 合作表 / KOC 合作表 三张工作表。本页对原始 Excel 字段进行结构化重构,所有指标可即时筛选、排序、跳转原帖。</p>
- <div class="meta-row">
- <div class="meta-item">达人总数<strong>${grandTotal.count}</strong></div>
- <div class="meta-item">总曝光<strong>${fmt(grandTotal.imp)}</strong></div>
- <div class="meta-item">总互动<strong>${fmt(grandTotal.eng)}</strong></div>
- <div class="meta-item">总投放<strong>${fmtMoney(totalSpend)}</strong></div>
- <div class="meta-item">CPE<strong>${grandTotal.cpe.toFixed(2)} 元</strong></div>
- <div class="meta-item">CPM<strong>${grandTotal.cpm.toFixed(2)} 元</strong></div>
- </div>
- </section>
- <section class="block">
- <div class="section-head"><span class="section-num">01 / OVERVIEW</span><h2 class="h1">核心指标总览</h2></div>
- <div class="grid grid-4">
- <div class="metric"><div class="metric-label">合作达人</div><div class="metric-value">${grandTotal.count}<span class="metric-unit">位</span></div><div class="metric-sub">KOL ${kol.length} · KOC ${koc.length}</div></div>
- <div class="metric green"><div class="metric-label">总曝光量</div><div class="metric-value">${fmt(grandTotal.imp)}</div><div class="metric-sub">CPM ${grandTotal.cpm.toFixed(2)} 元</div></div>
- <div class="metric rose"><div class="metric-label">总互动量</div><div class="metric-value">${fmt(grandTotal.eng)}</div><div class="metric-sub">CPE ${grandTotal.cpe.toFixed(2)} 元</div></div>
- <div class="metric purple"><div class="metric-label">总投放金额</div><div class="metric-value">${fmtMoney(totalSpend)}</div><div class="metric-sub">达人采购 ${fmtMoney(grandTotal.spend)} · 投流 ${fmtMoney(grandTotal.adSpend)}</div></div>
- </div>
- </section>
- <section class="block">
- <div class="section-head"><span class="section-num">02 / SUMMARY</span><h2 class="h1">数据汇总(按合作量级)</h2></div>
- <div class="card" style="padding:0">
- <table style="font-size:.9rem"><thead><tr>
- <th>合作量级</th><th class="num">数量</th><th class="num">互动量</th><th class="num">曝光量</th>
- <th class="num">采购费用</th><th class="num">投流费用</th><th class="num">单次互动</th><th class="num">千次曝光</th>
- </tr></thead><tbody>
- ${summary.map(r => `<tr>
- <td><strong>${esc(r.tier)}</strong></td>
- <td class="num">${fmtInt(r.count)}</td>
- <td class="num">${fmtInt(r.eng)}</td>
- <td class="num">${fmtInt(r.imp)}</td>
- <td class="num">${fmtMoney(r.spend)}</td>
- <td class="num">${fmtMoney(r.adSpend)}</td>
- <td class="num">${r.cpe ? r.cpe.toFixed(2) + ' 元' : '—'}</td>
- <td class="num">${r.cpm ? r.cpm.toFixed(2) + ' 元' : '—'}</td>
- </tr>`).join('')}
- </tbody></table>
- </div>
- </section>
- <section class="block">
- <div class="section-head"><span class="section-num">03 / CHARTS</span><h2 class="h1">维度可视化</h2></div>
- <div class="grid grid-2">
- <div class="card"><div style="margin-bottom:10px;color:var(--text-2);font-size:.86rem">合作量级 · 互动 vs 曝光 vs 花费</div><div id="sum-chart" class="chart"></div></div>
- <div class="card"><div style="margin-bottom:10px;color:var(--text-2);font-size:.86rem">内容类型 · 互动量分布</div><div id="ctmix-pie" class="chart"></div></div>
- <div class="card" style="grid-column:1/-1"><div style="margin-bottom:10px;color:var(--text-2);font-size:.86rem">KOC Top12 地区 · 互动量</div><div id="region-bar" class="chart"></div></div>
- </div>
- </section>
- <section class="block">
- <div class="section-head"><span class="section-num">04 / KOL</span><h2 class="h1">KOL 合作明细(${kol.length})</h2></div>
- <p class="sub">默认按互动量小计降序。可搜索 / 按级别 / 按内容类型筛选。</p>
- <div class="controls">
- <input id="kol-q" placeholder="🔍 搜索 KOL…">
- <select id="kol-ft" data-col="1"><option value="">全部级别</option>${kolTierOpts.map(o => `<option>${esc(o)}</option>`).join('')}</select>
- <select id="kol-fc" data-col="2"><option value="">全部内容类型</option>${kolCtOpts.map(o => `<option>${esc(o)}</option>`).join('')}</select>
- <span class="hint" id="kol-tbl-cnt"></span>
- </div>
- <div class="scroll-table"><table id="kol-tbl"><thead><tr>
- <th class="num">#<span class="arr"></span></th>
- <th>级别<span class="arr"></span></th>
- <th>类型<span class="arr"></span></th>
- <th>昵称<span class="arr"></span></th>
- <th class="num">粉丝<span class="arr"></span></th>
- <th>地区<span class="arr"></span></th>
- <th>合作形式<span class="arr"></span></th>
- <th class="num">下单价<span class="arr"></span></th>
- <th>发布<span class="arr"></span></th>
- <th class="num">点赞<span class="arr"></span></th>
- <th class="num">收藏<span class="arr"></span></th>
- <th class="num">评论<span class="arr"></span></th>
- <th class="num">互动小计<span class="arr"></span></th>
- <th class="num">曝光小计<span class="arr"></span></th>
- <th>原帖<span class="arr"></span></th>
- </tr></thead><tbody>${kolRows}</tbody></table></div>
- </section>
- <section class="block">
- <div class="section-head"><span class="section-num">05 / KOC</span><h2 class="h1">KOC 合作明细(${koc.length})</h2></div>
- <p class="sub">默认按互动量小计降序。可搜索 / 按级别 / 按内容类型 / 按地区筛选。</p>
- <div class="controls">
- <input id="koc-q" placeholder="🔍 搜索 KOC…">
- <select id="koc-ft" data-col="1"><option value="">全部级别</option>${kocTierOpts.map(o => `<option>${esc(o)}</option>`).join('')}</select>
- <select id="koc-fc" data-col="2"><option value="">全部内容类型</option>${kocCtOpts.map(o => `<option>${esc(o)}</option>`).join('')}</select>
- <select id="koc-fr" data-col="5"><option value="">全部地区</option>${kocRegionOpts.map(o => `<option>${esc(o)}</option>`).join('')}</select>
- <span class="hint" id="koc-tbl-cnt"></span>
- </div>
- <div class="scroll-table"><table id="koc-tbl"><thead><tr>
- <th class="num">#<span class="arr"></span></th>
- <th>级别<span class="arr"></span></th>
- <th>类型<span class="arr"></span></th>
- <th>昵称<span class="arr"></span></th>
- <th class="num">粉丝<span class="arr"></span></th>
- <th>地区<span class="arr"></span></th>
- <th>发布<span class="arr"></span></th>
- <th class="num">点赞<span class="arr"></span></th>
- <th class="num">收藏<span class="arr"></span></th>
- <th class="num">评论<span class="arr"></span></th>
- <th class="num">互动小计<span class="arr"></span></th>
- <th class="num">曝光小计<span class="arr"></span></th>
- <th>原帖<span class="arr"></span></th>
- </tr></thead><tbody>${kocRows}</tbody></table></div>
- </section>
- <script>
- echarts.init(document.getElementById('sum-chart')).setOption({
- ${echartsBaseTheme},
- legend:{data:['互动量','曝光量','总花费'],top:0,textStyle:{color:'#A8A8A8'}},
- xAxis:{type:'category',data:${sumNames},axisLabel:{interval:0,rotate:15}},
- yAxis:[{type:'value',name:'量'},{type:'value',name:'花费(元)'}],
- series:[
- {type:'bar',name:'互动量',data:${sumEng},itemStyle:{color:'#F5A623',borderRadius:[4,4,0,0]}},
- {type:'bar',name:'曝光量',data:${sumImp},itemStyle:{color:'#3B82F6',borderRadius:[4,4,0,0]}},
- {type:'line',name:'总花费',yAxisIndex:1,data:${sumSpend},itemStyle:{color:'#FF4D8D'},lineStyle:{width:2},symbol:'circle',symbolSize:8}
- ]
- });
- echarts.init(document.getElementById('ctmix-pie')).setOption({
- ${echartsBaseTheme},
- tooltip:{trigger:'item',formatter:'{b}<br/>{c} ({d}%)'},
- legend:{bottom:0,textStyle:{color:'#A8A8A8'},type:'scroll'},
- series:[{type:'pie',radius:['42%','70%'],center:['50%','45%'],
- label:{color:'#A8A8A8'},itemStyle:{borderColor:'#0A0A0A',borderWidth:2},
- data:${ctMix}.map((d,i)=>({...d,itemStyle:{color:['#F5A623','#8B5CF6','#3B82F6','#00DC82','#FF4D8D','#EF4444','#FCD34D','#A78BFA'][i%8]}}))
- }]
- });
- echarts.init(document.getElementById('region-bar')).setOption({
- ${echartsBaseTheme},
- grid:{left:90,right:30,top:20,bottom:30},
- tooltip:{trigger:'axis'},
- xAxis:{type:'value'},
- yAxis:{type:'category',data:${regionData}.map(d=>d.name).reverse(),axisLabel:{color:'#A8A8A8'}},
- series:[{type:'bar',data:${regionData}.map(d=>d.value).reverse(),itemStyle:{color:'#00DC82',borderRadius:[0,4,4,0]}}]
- });
- bindTable('kol-tbl'); bindFilter('kol-tbl','kol-q',['kol-ft','kol-fc']);
- bindTable('koc-tbl'); bindFilter('koc-tbl','koc-q',['koc-ft','koc-fc','koc-fr']);
- </script>` + COMMON_FOOT('马记永 · 四月新鲜菜宣发原始数据 · Generated from 【新鲜菜项目】马记永4月宣发数据汇总.xlsx');
- fs.writeFileSync(MAJI_OUT, html, 'utf8');
- console.log('✅ 马记永原始数据页生成:', MAJI_OUT, `(${(fs.statSync(MAJI_OUT).size / 1024).toFixed(1)} KB)`);
- }
- buildShuhua();
- buildMajiyong();
|