e6845620cc
修复账号解析逻辑,正确提取昵称和账号 优化 Web 性能
1499 lines
50 KiB
HTML
1499 lines
50 KiB
HTML
<!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 = '';
|
||
let _lastUpdateTime = 0;
|
||
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;
|
||
// 限制更新频率:最多每 2 秒更新一次进度
|
||
const now = Date.now();
|
||
if(now - _lastUpdateTime < 2000 && key === _lastProg) return;
|
||
_lastProg = key;
|
||
_lastUpdateTime = now;
|
||
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,'"') : '';
|
||
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,'&').replace(/</g,'<').replace(/>/g,'>')
|
||
.replace(/"/g,'"').replace(/'/g,''');
|
||
}
|
||
|
||
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);
|
||
}
|
||
});
|
||
|
||
// ========= 文件输出 =========
|
||
let _refreshFilesPending = false;
|
||
async function refreshOutFiles(){
|
||
if(!authed) return;
|
||
if(_refreshFilesPending) return; // 防止重复请求
|
||
_refreshFilesPending = true;
|
||
try{
|
||
const files = await apiGet('/api/files');
|
||
renderFiles(files || []);
|
||
}catch(e){
|
||
// ignore
|
||
}finally{
|
||
_refreshFilesPending = false;
|
||
}
|
||
}
|
||
|
||
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();
|
||
// 状态轮询:降低频率到 10 秒(原 5 秒)
|
||
setInterval(pollStatus, 10000);
|
||
// 输出文件偶尔刷新一下:降低频率到 30 秒(原 15 秒)
|
||
setInterval(refreshOutFiles, 30000);
|
||
})();
|
||
</script>
|
||
|
||
</body>
|
||
</html>
|