Files
xhs_crawler/app/static/index.html
T

1489 lines
50 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>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>