Files
xhs_crawler/app/static/index.html
T

1489 lines
50 KiB
HTML
Raw Normal View History

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>XHS 采集器(云端)</title>
<style>
:root{
--bg:#f6faf8;
--card:#ffffff;
--text:#0f172a;
--muted:#64748b;
--border:#dbe7e1;
--green:#16a34a;
--green2:#22c55e;
--greenSoft:#e9f9ef;
--warn:#f59e0b;
--danger:#ef4444;
--shadow: 0 10px 30px rgba(2, 44, 17, .08);
--radius: 14px;
--radius2: 10px;
}
*{box-sizing:border-box;}
body{
margin:0;
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, "Helvetica Neue", Arial, "Noto Sans", "Liberation Sans", "PingFang SC", "Microsoft YaHei", sans-serif;
color:var(--text);
background: radial-gradient(1200px 600px at 10% 0%, rgba(34,197,94,.18), transparent 60%),
radial-gradient(900px 500px at 90% 10%, rgba(22,163,74,.12), transparent 55%),
var(--bg);
}
a{color:var(--green); text-decoration:none;}
a:hover{text-decoration:underline;}
.wrap{max-width: 1180px; margin: 18px auto 26px; padding: 0 14px;}
.topbar{
background: linear-gradient(90deg, rgba(22,163,74,.10), rgba(34,197,94,.06));
border: 1px solid var(--border);
box-shadow: var(--shadow);
border-radius: var(--radius);
padding: 12px 12px;
display:flex;
gap:12px;
align-items:center;
justify-content:space-between;
}
.brand{
display:flex; align-items:center; gap:10px; min-width: 220px;
}
.logo{
width:38px; height:38px; border-radius: 12px;
background: conic-gradient(from 220deg, var(--green), var(--green2));
box-shadow: 0 12px 24px rgba(22,163,74,.25);
position:relative;
}
.logo:after{
content:"";
position:absolute; inset:10px;
background:#fff; border-radius:10px;
transform: rotate(12deg);
opacity:.85;
}
.brand h1{margin:0; font-size:16px; font-weight:800; letter-spacing:.2px;}
.brand p{margin:0; font-size:12px; color:var(--muted);}
.tabs{
display:flex; gap:8px; flex-wrap:wrap;
background: rgba(255,255,255,.65);
border: 1px solid var(--border);
border-radius: 999px;
padding: 6px;
}
.tab{
border:0;
background: transparent;
padding: 8px 14px;
border-radius: 999px;
font-weight: 700;
font-size: 13px;
color: var(--muted);
cursor:pointer;
transition: .15s ease;
white-space:nowrap;
}
.tab.active{
background: linear-gradient(180deg, rgba(22,163,74,.18), rgba(22,163,74,.10));
color: var(--green);
box-shadow: inset 0 0 0 1px rgba(22,163,74,.18);
}
.right{
display:flex; gap:10px; align-items:center; flex-wrap:wrap; justify-content:flex-end;
}
.pill{
display:flex; align-items:center; gap:8px;
padding: 7px 10px;
background: rgba(255,255,255,.75);
border: 1px solid var(--border);
border-radius: 999px;
font-size: 12px;
color: var(--muted);
}
.dot{
width:8px; height:8px; border-radius: 999px;
background: var(--warn);
box-shadow: 0 0 0 4px rgba(245,158,11,.15);
}
.dot.ok{background: var(--green2); box-shadow: 0 0 0 4px rgba(34,197,94,.15);}
.btn{
border:0;
padding: 9px 12px;
border-radius: 12px;
cursor:pointer;
font-weight: 800;
font-size: 13px;
color:#fff;
background: linear-gradient(180deg, var(--green2), var(--green));
box-shadow: 0 10px 20px rgba(22,163,74,.22);
transition:.15s ease;
}
.btn:hover{transform: translateY(-1px);}
.btn:active{transform: translateY(0px); opacity:.92;}
.btn.secondary{
background: #ffffff;
color: var(--green);
border: 1px solid rgba(22,163,74,.25);
box-shadow: none;
font-weight: 800;
}
.btn.ghost{
background: transparent;
color: var(--green);
border: 1px dashed rgba(22,163,74,.35);
box-shadow:none;
font-weight: 800;
}
.btn.danger{
background: linear-gradient(180deg, #fb7185, #ef4444);
box-shadow: 0 10px 20px rgba(239,68,68,.18);
}
.grid{
margin-top: 14px;
display:grid;
grid-template-columns: 1fr;
gap: 14px;
}
.panel{
background: rgba(255,255,255,.85);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
overflow:hidden;
}
.panel .hd{
display:flex;
align-items:center;
justify-content:space-between;
gap:10px;
padding: 12px 14px;
background: linear-gradient(180deg, rgba(34,197,94,.10), rgba(255,255,255,.0));
border-bottom: 1px solid rgba(219,231,225,.75);
}
.title{
font-size: 14px;
font-weight: 900;
letter-spacing:.2px;
display:flex; align-items:center; gap:10px;
}
.badge{
font-size: 11px;
color: var(--green);
background: var(--greenSoft);
border: 1px solid rgba(22,163,74,.2);
padding: 3px 8px;
border-radius: 999px;
font-weight: 800;
}
.panel .bd{padding: 14px;}
.subtabs{
display:flex; gap:8px; flex-wrap:wrap;
border-bottom: 1px solid rgba(219,231,225,.75);
padding: 0 14px;
background: rgba(255,255,255,.7);
}
.subtab{
border:0; background:transparent;
padding: 10px 10px;
cursor:pointer;
font-weight: 900;
font-size: 13px;
color: var(--muted);
position:relative;
}
.subtab.active{color: var(--green);}
.subtab.active:after{
content:"";
position:absolute; left:8px; right:8px; bottom: -1px;
height:3px; border-radius: 999px;
background: linear-gradient(90deg, var(--green), var(--green2));
}
.row{display:flex; gap:10px; flex-wrap:wrap; align-items:center;}
.row + .row{margin-top:10px;}
.field{
flex: 1 1 240px;
display:flex; align-items:center; gap:8px;
background:#fff;
border: 1px solid var(--border);
border-radius: 12px;
padding: 10px 12px;
min-height: 42px;
}
.field label{font-size: 12px; font-weight: 900; color: var(--muted); white-space:nowrap;}
input[type="text"], input[type="number"], select{
width: 100%;
border:0;
outline:none;
font-size: 13px;
background: transparent;
color: var(--text);
}
input[type="file"]{font-size: 13px;}
.check{
display:flex; gap:8px; align-items:center;
font-size: 12px;
color: var(--muted);
font-weight: 800;
user-select:none;
padding: 6px 10px;
background: rgba(255,255,255,.65);
border: 1px solid var(--border);
border-radius: 999px;
}
.check input{accent-color: var(--green);}
.textarea{
width:100%;
min-height: 140px;
padding: 12px 12px;
border-radius: 12px;
border: 1px solid var(--border);
background:#fff;
outline:none;
font-size: 13px;
resize: vertical;
line-height: 1.5;
}
.tableWrap{
border:1px solid var(--border);
border-radius: 12px;
background:#fff;
overflow:hidden;
}
table{width:100%; border-collapse: collapse; font-size: 13px;}
thead{background: rgba(22,163,74,.06);}
th, td{padding: 10px 10px; border-bottom: 1px solid rgba(219,231,225,.75); text-align:left;}
th{font-size: 12px; color: var(--muted); font-weight: 900;}
tbody tr:hover{background: rgba(34,197,94,.05);}
.op a{font-weight: 900; color: var(--danger);}
.pager{
display:flex; gap:8px; align-items:center; justify-content:space-between;
padding: 10px 12px;
color: var(--muted);
font-size: 12px;
background: rgba(255,255,255,.65);
border-top: 1px solid rgba(219,231,225,.75);
}
.pager .nums{display:flex; gap:6px; flex-wrap:wrap; align-items:center;}
.num{
width: 28px; height: 28px;
display:flex; align-items:center; justify-content:center;
border-radius: 10px;
border:1px solid rgba(22,163,74,.18);
cursor:pointer;
font-weight: 900;
color: var(--green);
background:#fff;
}
.num.active{background: var(--greenSoft);}
.actions{display:flex; gap:8px; flex-wrap:wrap; align-items:center;}
.twoCol{
display:grid;
grid-template-columns: 1fr;
gap: 12px;
}
@media (min-width: 980px){
.twoCol{grid-template-columns: 1fr 1fr;}
}
.miniTitle{font-size: 12px; color: var(--muted); font-weight: 900; margin: 0 0 8px;}
.box{
border:1px dashed rgba(22,163,74,.28);
border-radius: 12px;
padding: 12px;
background: rgba(255,255,255,.75);
min-height: 120px;
}
/* 禁用遮罩(未登录) */
.lock{
position:relative;
}
.lock .lockMask{
display:none;
position:absolute; inset:0;
background: rgba(246,250,248,.75);
backdrop-filter: blur(4px);
border-radius: var(--radius);
z-index: 10;
align-items:center;
justify-content:center;
padding: 18px;
text-align:center;
}
.lock.locked .lockMask{display:flex;}
.lock.maskoff .lockMask{display:none !important;}
.lockMask .card{
max-width: 520px;
background: rgba(255,255,255,.9);
border: 1px solid var(--border);
border-radius: 16px;
box-shadow: var(--shadow);
padding: 16px;
}
.lockMask h3{margin: 0 0 6px; font-size: 15px;}
.lockMask p{margin: 0 0 10px; font-size: 13px; color: var(--muted); line-height: 1.6;}
.toast{
position: fixed;
left: 50%;
bottom: 18px;
transform: translateX(-50%);
background: rgba(15, 23, 42, .92);
color: #fff;
padding: 10px 12px;
border-radius: 12px;
font-size: 13px;
font-weight: 800;
box-shadow: 0 12px 30px rgba(0,0,0,.18);
opacity: 0;
pointer-events:none;
transition: .18s ease;
z-index: 9999;
max-width: calc(100vw - 30px);
}
.toast.show{opacity: 1; transform: translateX(-50%) translateY(-4px);}
.muted{color: var(--muted);}
.spacer{height:6px;}
</style>
</head>
<body>
<div class="wrap">
<div class="topbar">
<div class="brand">
<div class="logo" aria-hidden="true"></div>
<div>
<h1>XHS 采集器(云端)</h1>
<p>自适应页面 · 绿色主调 · 登录后可用</p>
</div>
</div>
<div class="tabs" role="tablist" aria-label="主功能">
<button class="tab active" data-tab="accounts" role="tab" aria-selected="true">账号管理</button>
<button class="tab" data-tab="crawl" role="tab" aria-selected="false">采集数据</button>
</div>
<div class="right">
<div class="pill" title="登录状态由JS无感检测">
<span id="statusDot" class="dot"></span>
<span id="statusText">状态:未登录</span>
</div>
<button id="btnLogin" class="btn">登录</button>
<button id="btnLogout" class="btn secondary" style="display:none;">退出</button>
<button id="btnOpenVnc" class="btn ghost" title="打开 noVNC 登录窗口">打开登录窗口</button>
</div>
</div>
<!-- 主内容 -->
<div class="grid lock locked" id="mainLock">
<div class="lockMask">
<div class="card">
<h3>请先登录</h3>
<p>当前为 <b>未登录</b> 状态。登录成功后,才能进行账号解析、导入与采集等操作。</p>
<div class="actions" style="justify-content:center;">
<button class="btn" id="btnLogin2">去登录</button>
<button class="btn ghost" id="btnOpenVnc2">打开登录窗口(noVNC)</button>
<button class="btn secondary" id="btnMaskOff" title="仅用于预览页面样式,不影响后端登录逻辑">暂时关闭遮罩</button>
</div>
<div class="spacer"></div>
<p class="muted" style="margin:0;">提示:页面不会刷新,登录状态由 JS 自动检测更新。</p>
</div>
</div>
<!-- 账号管理 -->
<section class="panel" id="tab-accounts">
<div class="hd">
<div class="title">账号管理 <span class="badge">解析 / 导入 / 维护</span></div>
<label class="check"><input id="chkHeadlessParse" type="checkbox" checked /> 解析账号无头(默认)</label>
</div>
<div class="subtabs">
<button class="subtab active" data-subtab="search">搜索解析</button>
<button class="subtab" data-subtab="import">导入解析</button>
</div>
<div class="bd">
<!-- 搜索解析 -->
<div class="subview" id="sub-search">
<p class="miniTitle">解析账号(获取个人中心地址)</p>
<div class="row">
<div class="field" style="flex: 1 1 520px;">
<label>关键字:</label>
<input id="inpKeyword" type="text" placeholder="输入小红书号" />
</div>
<button class="btn" id="btnSearchSave">搜索&保存</button>
</div>
<div class="spacer"></div>
<p class="muted" style="margin:8px 0 0; font-size:12px;">
说明:点击“搜索&保存”后,会立即解析账号信息并保存到本地列表。
</p>
</div>
<!-- 导入解析(1-2) -->
<div class="subview" id="sub-import" style="display:none;">
<p class="miniTitle">解析账号(获取个人中心地址)</p>
<div class="row">
<div class="field" style="flex: 1 1 520px;">
<label>选择文件:</label>
<input id="fileExcel" type="file" accept=".xlsx,.xls" />
</div>
<button class="btn" id="btnImportStart">开始解析</button>
<button class="btn danger" id="btnImportStop">停止解析</button>
</div>
<div class="row">
<div class="field">
<label>Excel Sheet</label>
<select id="selSheet">
<option value="">(占位)选择Sheet</option>
</select>
</div>
<div class="field">
<label>列标题</label>
<select id="selColumn">
<option value="">(占位)选择列</option>
</select>
</div>
<button class="btn secondary" id="btnReadColumn">从Excel读取该列到文本框</button>
</div>
<div class="row">
<textarea id="taImportIds" class="textarea" placeholder="(可选)粘贴多个小红书ID:每行一个,换行追加更多"></textarea>
</div>
<div class="row" style="justify-content:space-between;">
<div class="field" style="flex:0 0 auto; min-width: 260px;">
<label>解析间隔(秒,随机)</label>
<input id="inpParseMin" type="number" step="0.1" value="2.5" style="max-width:72px;" />
<span class="muted" style="font-weight:900;">~</span>
<input id="inpParseMax" type="number" step="0.1" value="6" style="max-width:72px;" />
</div>
<span class="muted" style="font-size:12px;">提示:导入解析 = 单个解析的批量入口,将按间隔逐个解析。</span>
</div>
</div>
</div>
</section>
<!-- 已保存账号 -->
<section class="panel">
<div class="hd">
<div class="title">已保存账号 <span class="badge">本地存储</span></div>
<div class="actions">
<button class="btn secondary" id="btnReloadAccounts">刷新列表</button>
</div>
</div>
<div class="bd">
<div class="row">
<div class="field" style="flex: 1 1 520px;">
<label>搜索</label>
<input id="inpAccountSearch" type="text" placeholder="搜索(小红书号/昵称/URL)" />
</div>
<button class="btn secondary" id="btnClearSearch">清空</button>
</div>
<div class="spacer"></div>
<div class="tableWrap">
<table>
<thead>
<tr>
<th style="width: 32%;">小红书号</th>
<th style="width: 26%;">昵称</th>
<th>URL</th>
<th style="width: 90px;">操作</th>
</tr>
</thead>
<tbody id="tbAccounts">
<tr>
<td class="muted">(占位)xhs_user_001</td>
<td class="muted">昵称示例</td>
<td class="muted">https://www.xiaohongshu.com/user/profile/xxxx</td>
<td class="op"><a href="#" onclick="return false;">移除</a></td>
</tr>
</tbody>
</table>
<div class="pager">
<div class="nums">
<span class="muted">分页:</span>
<span class="num active">1</span><span class="num">2</span><span class="num">3</span>
<span class="muted">…</span>
</div>
<div class="muted">共 <b>1</b> 页(占位)</div>
</div>
</div>
</div>
</section>
<!-- 采集数据 -->
<section class="panel" id="tab-crawl" style="display:none;">
<div class="hd">
<div class="title">采集数据 <span class="badge">按账号采集</span></div>
<label class="check"><input id="chkHeadlessCrawl" type="checkbox" checked /> 采集无头(默认)</label>
</div>
<div class="bd">
<p class="miniTitle">选择采集账号(该位置的账号与「账号管理」里存储的账号一致)</p>
<div class="tableWrap">
<table>
<thead>
<tr>
<th style="width: 44px;"></th>
<th style="width: 32%;">小红书号</th>
<th style="width: 26%;">昵称</th>
<th>URL</th>
</tr>
</thead>
<tbody id="tbPickAccounts">
<tr>
<td><input type="checkbox" /></td>
<td class="muted">(占位)xhs_user_001</td>
<td class="muted">昵称示例</td>
<td class="muted">https://www.xiaohongshu.com/user/profile/xxxx</td>
</tr>
<tr>
<td><input type="checkbox" /></td>
<td class="muted">(占位)xhs_user_002</td>
<td class="muted">昵称示例2</td>
<td class="muted">https://www.xiaohongshu.com/user/profile/yyyy</td>
</tr>
</tbody>
</table>
<div class="pager">
<div class="nums">
<span class="muted">分页:</span>
<span class="num active">1</span><span class="num">2</span><span class="num">3</span>
<span class="muted">…</span>
</div>
<div class="actions">
<button class="btn secondary" id="btnSelectAll">全选</button>
<button class="btn secondary" id="btnClearSelect">清空选择</button>
</div>
</div>
</div>
<div class="spacer"></div>
<div class="twoCol">
<div class="box">
<p class="miniTitle">采集参数(基础)</p>
<div class="row">
<div class="field">
<label>每个账号采集条数</label>
<input id="inpLimit" type="number" value="20" />
</div>
<div class="field">
<label>并发线程数</label>
<input id="inpConcurrency" type="number" value="2" />
</div>
</div>
<div class="row">
<div class="field">
<label>随机批次(条)</label>
<input id="inpBatchMin" type="number" value="3" style="max-width:70px;" />
<span class="muted" style="font-weight:900;">~</span>
<input id="inpBatchMax" type="number" value="7" style="max-width:70px;" />
</div>
<div class="field">
<label>SlowMo (ms)</label>
<input id="inpSlowMo" type="number" value="80" />
</div>
</div>
</div>
<div class="box">
<p class="miniTitle">采集参数(等待/间隔)</p>
<div class="row">
<div class="field">
<label>打开后等待 (ms)</label>
<input id="inpOpenWaitMin" type="number" value="1800" style="max-width:86px;" />
<span class="muted" style="font-weight:900;">~</span>
<input id="inpBetweenWaitMax" type="number" value="3200" style="max-width:86px;" />
</div>
<div class="field">
<label>滚动后等待 (ms)</label>
<input id="inpScrollWaitMin" type="number" value="1600" style="max-width:86px;" />
<span class="muted" style="font-weight:900;">~</span>
<input id="inpScrollWaitMax" type="number" value="2800" style="max-width:86px;" />
</div>
</div>
<div class="row">
<div class="field">
<label>每条间隔 (ms)</label>
<input id="inpBetweenWaitMin" type="number" value="1500" style="max-width:86px;" />
<span class="muted" style="font-weight:900;">~</span>
<input type="number" value="3200" style="max-width:86px;" />
</div>
<div class="field">
<label>休眠总 (ms)</label>
<input id="inpLongBreakMin" type="number" value="6000" style="max-width:86px;" />
<span class="muted" style="font-weight:900;">~</span>
<input id="inpLongBreakMax" type="number" value="12000" style="max-width:86px;" />
</div>
</div>
</div>
</div>
<div class="spacer"></div>
<div class="box">
<p class="miniTitle">控制</p>
<div class="row">
<label class="check"><input id="chkEnableDedup" type="checkbox" checked /> 启用去重(已采笔记ID本地跳过)</label>
</div>
<div class="spacer"></div>
<div class="row">
<button class="btn" id="btnRunSingle">单线程开始(只跑一个选中账号)</button>
<button class="btn" id="btnRunMulti">多线程开始(并发跑选中账号)</button>
<button class="btn danger" id="btnStopAll">停止(单线程/多线程)</button>
</div>
<p class="muted" style="margin:8px 0 0; font-size:12px;">
说明:按钮会按选中账号与参数启动采集任务。
</p>
</div>
<div class="spacer"></div>
<!-- 采集结果下载 -->
<div class="panel">
<div class="hd">
<div class="title">采集结果 <span class="badge">Excel 文件</span></div>
<div class="actions">
<button class="btn secondary" id="btnRefreshFiles">刷新文件列表</button>
</div>
</div>
<div class="bd">
<div class="tableWrap">
<table>
<thead>
<tr>
<th>文件名</th>
<th style="width: 120px;">大小</th>
<th style="width: 180px;">修改时间</th>
<th style="width: 90px;">操作</th>
</tr>
</thead>
<tbody id="tbFiles">
<tr>
<td class="muted" colspan="4">暂无文件,请先进行采集</td>
</tr>
</tbody>
</table>
</div>
<div class="spacer"></div>
<div class="actions">
<button class="btn" id="btnDownloadAll">打包下载全部(ZIP)</button>
<button class="btn ghost" id="btnClearDedup">清空去重记录</button>
</div>
</div>
</div>
</div>
</section>
<!-- 运行日志 -->
<section class="panel">
<div class="hd">
<div class="title">运行日志 <span class="badge">全部输出在此</span></div>
<div class="actions">
<button class="btn secondary" id="btnLogClear">清空</button>
<button class="btn ghost" id="btnLogDemo">写入一条示例日志</button>
</div>
</div>
<div class="bd">
<textarea id="logBox" class="textarea" readonly placeholder="日志会实时追加在这里"></textarea>
</div>
</section>
</div><!-- /grid -->
</div>
<div class="toast" id="toast"></div>
<script>
// ========= 基础:Toast / 日志 =========
const toastEl = document.getElementById('toast');
function toast(msg){
toastEl.textContent = msg;
toastEl.classList.add('show');
setTimeout(()=>toastEl.classList.remove('show'), 1800);
}
const logBox = document.getElementById('logBox');
function log(line){
const ts = new Date().toLocaleString();
logBox.value += `[${ts}] ${line}\n`;
logBox.scrollTop = logBox.scrollHeight;
}
// ========= 小工具 =========
const qs = (sel)=>document.querySelector(sel);
const qsa = (sel)=>Array.from(document.querySelectorAll(sel));
async function apiGet(url){
const r = await fetch(url, {credentials:'include'});
if(!r.ok) throw new Error(`${r.status} ${r.statusText}`);
return await r.json();
}
async function apiPost(url, body){
const r = await fetch(url, {
method:'POST',
headers: {'Content-Type':'application/json'},
credentials:'include',
body: body ? JSON.stringify(body) : '{}'
});
const ct = (r.headers.get('content-type')||'');
const data = ct.includes('application/json') ? await r.json() : await r.text();
if(!r.ok){
const msg = (data && (data.detail || data.msg)) ? (data.detail || data.msg) : `${r.status} ${r.statusText}`;
throw new Error(msg);
}
return data;
}
async function apiUploadExcel(file){
const fd = new FormData();
fd.append('file', file);
const r = await fetch('/api/excel/upload', {method:'POST', body: fd, credentials:'include'});
const data = await r.json().catch(()=> ({}));
if(!r.ok) throw new Error((data && data.detail) ? data.detail : `${r.status} ${r.statusText}`);
return data;
}
function normProfileInput(v){
const s = (v||'').trim();
if(!s) return null;
if(/^https?:\/\//i.test(s)){
return {xhs_no:'', nickname:'', url:s};
}
return {xhs_no:s, nickname:'', url:`https://www.xiaohongshu.com/user/profile/${encodeURIComponent(s)}`};
}
function isUrlInput(v){
return /^https?:\/\//i.test((v||'').trim());
}
async function resolveProfileBySearch(keyword){
const headless = !!qs('#chkHeadlessParse').checked;
return await apiPost('/api/profiles/resolve', {keyword, headless});
}
function sleep(ms){
return new Promise(resolve => setTimeout(resolve, ms));
}
// ========= Tabs =========
const tabs = qsa('.tab');
const tabAccounts = qs('#tab-accounts');
const tabCrawl = qs('#tab-crawl');
tabs.forEach(btn=>{
btn.addEventListener('click', ()=>{
tabs.forEach(x=>x.classList.remove('active'));
btn.classList.add('active');
const t = btn.dataset.tab;
if(t === 'accounts'){
tabAccounts.style.display = '';
tabCrawl.style.display = 'none';
}else{
tabAccounts.style.display = 'none';
tabCrawl.style.display = '';
}
});
});
const subtabs = qsa('.subtab');
const subSearch = qs('#sub-search');
const subImport = qs('#sub-import');
subtabs.forEach(btn=>{
btn.addEventListener('click', ()=>{
subtabs.forEach(x=>x.classList.remove('active'));
btn.classList.add('active');
const s = btn.dataset.subtab;
if(s === 'search'){
subSearch.style.display='';
subImport.style.display='none';
}else{
subSearch.style.display='none';
subImport.style.display='';
}
});
});
// ========= 登录 & 状态(无感轮询) =========
const mainLock = qs('#mainLock');
const statusDot = qs('#statusDot');
const statusText = qs('#statusText');
const btnLogin = qs('#btnLogin');
const btnLogout = qs('#btnLogout');
const btnLogin2 = qs('#btnLogin2');
const btnMaskOff = qs('#btnMaskOff');
let cachedNoVncUrl = '';
let authed = false;
function requireAuthed(){
if(!authed){
toast('请先登录');
return false;
}
return true;
}
function setAuthed(isAuthed){
const changed = authed !== isAuthed;
authed = isAuthed;
if(isAuthed){
statusDot.classList.add('ok');
statusText.textContent = '状态:已登录';
mainLock.classList.remove('locked');
btnLogin.style.display='none';
btnLogout.style.display='';
}else{
statusDot.classList.remove('ok');
statusText.textContent = '状态:未登录';
mainLock.classList.add('locked');
btnLogin.style.display='';
btnLogout.style.display='none';
}
if(changed && authed){
loadProfiles();
}
}
async function pollStatus(){
try{
const s = await apiGet('/api/status');
setAuthed(!!s.login_ok);
// 把任务状态也写入日志(只在变化时写)
updateRunBadge(s);
updateProgressLine(s);
}catch(e){
setAuthed(false);
}
}
let _lastRun = '';
function updateRunBadge(s){
const running = !!(s && s.running);
const status = s && s.progress && s.progress.status ? s.progress.status : 'unknown';
const key = `${running}-${status}`;
if(key !== _lastRun){
_lastRun = key;
if(running) log(`任务状态:running(${status})`);
else log(`任务状态:${status}`);
}
}
let _lastProg = '';
function updateProgressLine(s){
if(!s || !s.progress) return;
const p = s.progress;
const key = `${p.status}-${p.done}-${p.total}-${p.current}`;
if(key === _lastProg) return;
_lastProg = key;
if(p.status === 'running'){
log(`进度:${p.done}/${p.total} 当前:${p.current || '-'}`);
}else if(p.status && p.status !== 'idle'){
log(`进度:${p.done}/${p.total} 状态:${p.status}${p.last_error?(' 错误:'+p.last_error):''}`);
}
}
async function doLogin(){
const win = window.open('about:blank', '_blank');
try{
log('请求启动登录流程…');
await apiPost('/api/login/start');
const url = await getNoVncUrl();
if(win){
win.location = url;
win.focus();
}else{
toast('浏览器拦截了弹窗,请允许弹出窗口');
}
toast('已触发登录流程(请扫码登录)');
log('登录流程已触发,请完成扫码登录。');
setTimeout(pollStatus, 800);
}catch(e){
if(win) win.close();
toast('触发登录失败:' + e.message);
log('触发登录失败:' + e.message);
}
}
async function doLogout(){
// 这个版本后端没有 logout(登录态由 storage_state.json 控制)
toast('云端模式:请在 noVNC 里退出/重新登录并保存 storage_state.json');
log('提示:云端模式不提供接口退出;登录态取决于 storage_state.json。');
}
btnLogin.addEventListener('click', doLogin);
btnLogin2.addEventListener('click', doLogin);
btnLogout.addEventListener('click', doLogout);
if(btnMaskOff){
btnMaskOff.addEventListener('click', ()=>{
mainLock.classList.add('maskoff');
toast('遮罩已临时关闭(仅预览 UI)');
log('遮罩已临时关闭(仅预览 UI)。');
});
}
async function getNoVncUrl(){
if(cachedNoVncUrl) return cachedNoVncUrl;
try{
const h = await apiGet('/api/login_hint');
if(h && h.novnc_url){
if(h.novnc_url.includes('<server-ip>')){
cachedNoVncUrl = h.novnc_url.replace('<server-ip>', location.hostname);
}else{
cachedNoVncUrl = h.novnc_url;
}
}
}catch(e){
// ignore
}
if(!cachedNoVncUrl){
cachedNoVncUrl = 'http://219.157.255.213:26080/vnc_auto.html'; // 常见 noVNC 默认端口
}
return cachedNoVncUrl;
}
async function openVnc(){
const win = window.open('about:blank', '_blank');
const url = await getNoVncUrl();
if(win){
win.location = url;
win.focus();
}else{
toast('浏览器拦截了弹窗,请允许弹出窗口');
}
}
qs('#btnOpenVnc').addEventListener('click', openVnc);
qs('#btnOpenVnc2').addEventListener('click', openVnc);
// ========= Profiles(账号列表) =========
let profiles = [];
function renderProfiles(){
const keyword = (qs('#inpAccountSearch').value || '').trim().toLowerCase();
const list = keyword
? profiles.filter(p=>{
const a = ((p.xhs_no||'')+' '+(p.nickname||'')+' '+(p.url||'')).toLowerCase();
return a.includes(keyword);
})
: profiles;
const tb = qs('#tbAccounts');
const tb2 = qs('#tbPickAccounts');
tb.innerHTML = '';
tb2.innerHTML = '';
if(!list.length){
tb.innerHTML = `<tr><td class="muted" colspan="4">暂无账号,请先在上方“搜索解析 / 导入解析”添加</td></tr>`;
tb2.innerHTML = `<tr><td class="muted" colspan="4">暂无账号</td></tr>`;
return;
}
for(const p of list){
const xhs = p.xhs_no || '';
const nick = p.nickname || '';
const url = p.url || '';
const safeUrl = url ? url.replace(/"/g,'&quot;') : '';
tb.insertAdjacentHTML('beforeend', `
<tr data-url="${safeUrl}">
<td>${escapeHtml(xhs||'(未填)')}</td>
<td>${escapeHtml(nick||'-')}</td>
<td><a href="${safeUrl}" target="_blank" rel="noreferrer">${escapeHtml(url||'-')}</a></td>
<td class="op"><a href="#" class="act-remove">移除</a></td>
</tr>
`);
tb2.insertAdjacentHTML('beforeend', `
<tr data-url="${safeUrl}">
<td><input type="checkbox" class="pick" /></td>
<td>${escapeHtml(xhs||'(未填)')}</td>
<td>${escapeHtml(nick||'-')}</td>
<td><a href="${safeUrl}" target="_blank" rel="noreferrer">${escapeHtml(url||'-')}</a></td>
</tr>
`);
}
// bind remove
qsa('#tbAccounts .act-remove').forEach(a=>{
a.addEventListener('click', async (ev)=>{
ev.preventDefault();
const tr = ev.target.closest('tr');
const url = tr ? tr.getAttribute('data-url') : '';
await removeProfileByUrl(url);
});
});
}
function escapeHtml(s){
return (s||'').toString()
.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
async function loadProfiles(){
if(!authed) return;
try{
profiles = await apiGet('/api/profiles');
if(!Array.isArray(profiles)) profiles = [];
renderProfiles();
}catch(e){
profiles = [];
renderProfiles();
log('拉取 profiles 失败:' + e.message);
}
}
async function saveProfiles(newList){
profiles = newList;
await apiPost('/api/profiles', profiles);
renderProfiles();
}
async function addProfiles(list){
const merged = [...profiles];
const seen = new Set(merged.map(p=>(p.url||'').trim()).filter(Boolean));
let add = 0;
for(const p of list){
if(!p || !p.url) continue;
const u = (p.url||'').trim();
if(!u || seen.has(u)) continue;
merged.push(p);
seen.add(u);
add++;
}
await saveProfiles(merged);
return add;
}
async function removeProfileByUrl(url){
if(!requireAuthed()) return;
const u = (url||'').trim();
if(!u) return;
const next = profiles.filter(p => ((p.url||'').trim() !== u));
await saveProfiles(next);
toast('已移除');
log('已移除账号:' + u);
}
// 搜索&保存(单个)
qs('#btnSearchSave').addEventListener('click', async ()=>{
if(!requireAuthed()) return;
const v = (qs('#inpKeyword').value || '').trim();
if(!v){ toast('请输入小红书号或URL'); return; }
try{
if(isUrlInput(v)){
const p = normProfileInput(v);
const added = await addProfiles([p]);
toast(added ? '已保存账号' : '账号已存在');
log(`保存账号:${p.url}`);
}else{
toast('解析中…');
const r = await resolveProfileBySearch(v);
if(r && Array.isArray(r.profiles)){
profiles = r.profiles;
renderProfiles();
}else{
await loadProfiles();
}
const url = r && r.profile ? r.profile.url : '';
toast('已解析并保存');
log(`解析账号:${url || v}`);
}
qs('#inpKeyword').value = '';
}catch(e){
toast('解析失败:' + e.message);
log('解析失败:' + e.message);
}
});
// 刷新/搜索
qs('#btnReloadAccounts').addEventListener('click', async ()=>{
if(!requireAuthed()) return;
toast('刷新中…');
await loadProfiles();
toast('已刷新');
});
qs('#btnClearSearch').addEventListener('click', ()=>{
qs('#inpAccountSearch').value='';
renderProfiles();
toast('已清空');
});
qs('#inpAccountSearch').addEventListener('input', renderProfiles);
// ========= Excel 导入 =========
let excelFileId = '';
let batchRunning = false;
let batchAbort = false;
qs('#fileExcel').addEventListener('change', async (ev)=>{
if(!requireAuthed()) return;
const f = ev.target.files && ev.target.files[0];
if(!f) return;
try{
toast('上传 Excel…');
const meta = await apiUploadExcel(f);
excelFileId = meta.file_id || '';
const selSheet = qs('#selSheet');
const selColumn = qs('#selColumn');
selSheet.innerHTML = '';
(meta.sheets||[]).forEach(s=>{
const opt = document.createElement('option');
opt.value = s; opt.textContent = s;
selSheet.appendChild(opt);
});
if(meta.default_sheet) selSheet.value = meta.default_sheet;
selColumn.innerHTML = '';
(meta.columns||[]).forEach(c=>{
const opt = document.createElement('option');
opt.value = c; opt.textContent = c;
selColumn.appendChild(opt);
});
toast('Excel 已上传,请选择列');
log(`Excel 上传成功:file_id=${excelFileId}`);
}catch(e){
toast('上传失败:' + e.message);
log('Excel 上传失败:' + e.message);
}
});
qs('#selSheet').addEventListener('change', async ()=>{
if(!requireAuthed()) return;
if(!excelFileId) return;
const sheet = qs('#selSheet').value;
if(!sheet) return;
try{
const meta = await apiGet(`/api/excel/meta?file_id=${encodeURIComponent(excelFileId)}&sheet=${encodeURIComponent(sheet)}`);
const selColumn = qs('#selColumn');
selColumn.innerHTML = '';
(meta.columns||[]).forEach(c=>{
const opt = document.createElement('option');
opt.value = c; opt.textContent = c;
selColumn.appendChild(opt);
});
toast('已加载列标题');
}catch(e){
toast('读取列失败:' + e.message);
log('读取列失败:' + e.message);
}
});
qs('#btnReadColumn').addEventListener('click', async ()=>{
if(!requireAuthed()) return;
if(!excelFileId){ toast('请先选择 Excel 文件'); return; }
const sheet = qs('#selSheet').value;
const column = qs('#selColumn').value;
if(!(sheet && column)){ toast('请选择 Sheet 和列标题'); return; }
try{
toast('读取中…');
const r = await apiGet(`/api/excel/column_values?file_id=${encodeURIComponent(excelFileId)}&sheet=${encodeURIComponent(sheet)}&column=${encodeURIComponent(column)}`);
const values = (r && Array.isArray(r.values)) ? r.values : [];
qs('#taImportIds').value = values.join('\n');
toast(`已读取 ${values.length} 条`);
log(`Excel 读取完成:${values.length} 条`);
}catch(e){
toast('读取失败:' + e.message);
log('读取失败:' + e.message);
}
});
// textarea 粘贴导入
qs('#btnImportStart').addEventListener('click', async ()=>{
if(!requireAuthed()) return;
if(batchRunning){ toast('正在解析中…'); return; }
const lines = (qs('#taImportIds').value || '').split(/\r?\n/).map(s=>s.trim()).filter(Boolean);
if(!lines.length){ toast('请在文本框粘贴账号(每行一个)'); return; }
const uniqLines = Array.from(new Set(lines));
const minSecRaw = parseFloat(qs('#inpParseMin').value || '2.5');
const maxSecRaw = parseFloat(qs('#inpParseMax').value || '6');
const minSec = Number.isFinite(minSecRaw) ? minSecRaw : 2.5;
const maxSec = Number.isFinite(maxSecRaw) ? maxSecRaw : minSec;
const waitMin = Math.min(minSec, maxSec);
const waitMax = Math.max(minSec, maxSec);
batchRunning = true;
batchAbort = false;
qs('#btnImportStart').disabled = true;
qs('#btnImportStop').disabled = false;
toast(`开始解析:共 ${uniqLines.length} 条`);
log(`批量解析开始:${uniqLines.length} 条`);
let addedCount = 0;
for(let i = 0; i < uniqLines.length; i++){
if(batchAbort){
log('批量解析已停止');
break;
}
const keyword = uniqLines[i];
log(`解析进度:${i + 1}/${uniqLines.length} -> ${keyword}`);
try{
if(isUrlInput(keyword)){
const p = normProfileInput(keyword);
const added = await addProfiles([p]);
addedCount += added;
log(`已保存:${p.url}`);
}else{
const r = await resolveProfileBySearch(keyword);
if(r && Array.isArray(r.profiles)){
profiles = r.profiles;
renderProfiles();
}
if(r && r.added){
addedCount += r.added;
}
const url = r && r.profile ? r.profile.url : '';
log(`解析完成:${url || keyword}`);
}
}catch(e){
log(`解析失败:${keyword} -> ${e.message}`);
}
if(i < uniqLines.length - 1 && !batchAbort){
const waitMs = (waitMin + Math.random() * (waitMax - waitMin)) * 1000;
await sleep(waitMs);
}
}
batchRunning = false;
qs('#btnImportStart').disabled = false;
qs('#btnImportStop').disabled = true;
toast(batchAbort ? '解析已停止' : `解析完成:新增 ${addedCount} 条`);
});
qs('#btnImportStop').addEventListener('click', ()=>{
if(!batchRunning){
toast('当前没有正在进行的解析');
return;
}
batchAbort = true;
toast('已发送停止请求');
log('已发送停止解析请求');
});
qs('#btnImportStop').disabled = true;
// ========= 采集任务 =========
function readCrawlConfig(){
// 采集参数(可按需继续扩展)
const headless = !!qs('#chkHeadlessCrawl').checked;
const limit = parseInt((qs('#inpLimit')?.value || '20'), 10) || 20;
const concurrency = parseInt((qs('#inpConcurrency')?.value || '2'), 10) || 2;
const batch_min = parseInt((qs('#inpBatchMin')?.value || '3'), 10) || 3;
const batch_max = parseInt((qs('#inpBatchMax')?.value || '7'), 10) || 7;
const slow_mo = parseInt((qs('#inpSlowMo')?.value || '80'), 10) || 0;
const open_wait = [parseInt(qs('#inpOpenWaitMin')?.value||'1800',10)||1800, parseInt(qs('#inpOpenWaitMax')?.value||'3200',10)||3200];
const scroll_wait = [parseInt(qs('#inpScrollWaitMin')?.value||'1600',10)||1600, parseInt(qs('#inpScrollWaitMax')?.value||'2800',10)||2800];
const between_wait = [parseInt(qs('#inpBetweenWaitMin')?.value||'1500',10)||1500, parseInt(qs('#inpBetweenWaitMax')?.value||'3200',10)||3200];
const long_break = [parseInt(qs('#inpLongBreakMin')?.value||'6000',10)||6000, parseInt(qs('#inpLongBreakMax')?.value||'12000',10)||12000];
return {
headless,
limit,
concurrency,
batch_min,
batch_max,
slow_mo,
enable_dedup: !!qs('#chkEnableDedup').checked, // 去重选项
// 后端期望的 open_wait/between_wait/scroll_wait/long_break 是 tuple;但 JSON 传 list 可以
open_wait: [open_wait[0]/1000.0, open_wait[1]/1000.0],
between_wait: [between_wait[0]/1000.0, between_wait[1]/1000.0],
scroll_wait: [scroll_wait[0]/1000.0, scroll_wait[1]/1000.0],
long_break: [long_break[0]/1000.0, long_break[1]/1000.0]
};
}
function getSelectedProfiles(){
const rows = qsa('#tbPickAccounts tr');
const out = [];
for(const tr of rows){
const cb = tr.querySelector('input.pick');
if(cb && cb.checked){
const url = tr.getAttribute('data-url') || '';
const p = profiles.find(x => (x.url||'').trim() === url.trim());
if(p) out.push(p);
}
}
return out;
}
async function saveConfig(cfg){
await apiPost('/api/config', cfg);
}
qs('#btnSelectAll').addEventListener('click', ()=>{
qsa('#tbPickAccounts input.pick').forEach(x=>x.checked=true);
toast('已全选');
});
qs('#btnClearSelect').addEventListener('click', ()=>{
qsa('#tbPickAccounts input.pick').forEach(x=>x.checked=false);
toast('已清空选择');
});
qs('#btnRunSingle').addEventListener('click', async ()=>{
if(!requireAuthed()) return;
const picked = getSelectedProfiles();
if(!picked.length){ toast('请至少勾选 1 个账号'); return; }
const cfg = readCrawlConfig();
cfg.concurrency = 1;
try{
await saveConfig(cfg);
await apiPost('/api/start', {profiles: [picked[0]]});
toast('已启动单线程采集');
log('已启动单线程采集:' + (picked[0].xhs_no || picked[0].url));
}catch(e){
toast('启动失败:' + e.message);
log('启动失败:' + e.message);
}
});
qs('#btnRunMulti').addEventListener('click', async ()=>{
if(!requireAuthed()) return;
const picked = getSelectedProfiles();
if(!picked.length){ toast('请至少勾选 1 个账号'); return; }
const cfg = readCrawlConfig();
try{
await saveConfig(cfg);
await apiPost('/api/start', {profiles: picked});
toast('已启动并发采集');
log(`已启动并发采集:选中 ${picked.length} 个账号,并发=${cfg.concurrency}`);
}catch(e){
toast('启动失败:' + e.message);
log('启动失败:' + e.message);
}
});
qs('#btnStopAll').addEventListener('click', async ()=>{
if(!requireAuthed()) return;
try{
await apiPost('/api/stop');
toast('已发送停止请求');
log('已发送停止请求');
}catch(e){
toast('停止失败:' + e.message);
log('停止失败:' + e.message);
}
});
// ========= 文件输出 =========
async function refreshOutFiles(){
if(!authed) return;
try{
const files = await apiGet('/api/files');
renderFiles(files || []);
}catch(e){
// ignore
}
}
function renderFiles(files){
const tb = qs('#tbFiles');
if(!Array.isArray(files) || files.length === 0){
tb.innerHTML = '<tr><td class="muted" colspan="4">暂无文件,请先进行采集</td></tr>';
return;
}
tb.innerHTML = '';
for(const f of files){
const sizeKB = (f.size / 1024).toFixed(2);
const date = new Date(f.mtime * 1000).toLocaleString();
tb.insertAdjacentHTML('beforeend', `
<tr>
<td>${escapeHtml(f.name)}</td>
<td>${sizeKB} KB</td>
<td>${date}</td>
<td class="op"><a href="/api/files/${encodeURIComponent(f.name)}" target="_blank">下载</a></td>
</tr>
`);
}
}
// 刷新文件列表
qs('#btnRefreshFiles').addEventListener('click', async ()=>{
if(!requireAuthed()) return;
toast('刷新中…');
await refreshOutFiles();
toast('已刷新');
});
// 打包下载全部
qs('#btnDownloadAll').addEventListener('click', async ()=>{
if(!requireAuthed()) return;
try{
const r = await fetch('/api/files_zip', { credentials: 'include' });
if(!r.ok) throw new Error(`${r.status} ${r.statusText}`);
const blob = await r.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'output_bundle.zip';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast('下载已开始');
log('已打包下载全部文件');
}catch(e){
toast('下载失败:' + e.message);
log('下载失败:' + e.message);
}
});
// 清空去重记录
qs('#btnClearDedup').addEventListener('click', async ()=>{
if(!requireAuthed()) return;
if(!confirm('确认要清空所有去重记录吗?清空后已采集的笔记会被重新采集。')) return;
try{
await apiPost('/api/dedup/clear');
toast('去重记录已清空');
log('已清空去重记录');
}catch(e){
toast('清空失败:' + e.message);
log('清空失败:' + e.message);
}
});
// ========= 日志:WebSocket 实时输出 =========
function connectLogs(){
try{
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
const ws = new WebSocket(`${proto}://${location.host}/ws/logs`);
ws.onopen = ()=>{
log('日志通道已连接(WS)');
// keepalive ping
setInterval(()=>{ try{ ws.send('ping'); }catch(e){} }, 20000);
};
ws.onmessage = (ev)=>{
// 服务端已经带时间戳
logBox.value += ev.data + '\n';
logBox.scrollTop = logBox.scrollHeight;
};
ws.onclose = ()=>{
log('日志通道已断开,3 秒后重连…');
setTimeout(connectLogs, 3000);
};
ws.onerror = ()=>{};
}catch(e){
log('WS 不可用:' + e.message);
}
}
// ========= 运行日志按钮 =========
qs('#btnLogClear').addEventListener('click', ()=>{ logBox.value=''; toast('日志已清空'); });
qs('#btnLogDemo').addEventListener('click', ()=>{ log('示例日志:页面布局已加载'); });
// ========= 启动 =========
(async function init(){
log('页面加载完成。');
connectLogs();
await pollStatus();
// 每2秒无感检测一次(不刷新页面)
setInterval(pollStatus, 5000);
// 输出文件偶尔刷新一下
setInterval(refreshOutFiles, 15000);
})();
</script>
</body>
</html>