tt-help-cli-ycl 1.0.5 → 1.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/auto-core.cjs +288 -0
- package/src/data-store.cjs +65 -0
- package/src/get-user-videos-core.cjs +165 -0
- package/src/get-user-videos.cjs +59 -0
- package/src/lib/args.js +227 -1
- package/src/lib/auto-browser.mjs +11 -0
- package/src/lib/constants.js +46 -0
- package/src/lib/explore.js +27 -1
- package/src/lib/fetcher.js +20 -10
- package/src/lib/get-user-videos-browser.mjs +6 -0
- package/src/lib/io.js +63 -0
- package/src/lib/scrape-browser.mjs +6 -0
- package/src/main.mjs +391 -18
- package/src/results/user-videos-bar.lar.lar.moeta.json +37 -0
- package/src/scraper/core.cjs +192 -0
- package/src/scraper/index.cjs +93 -0
- package/src/scraper/modules/comment-extractor.cjs +122 -0
- package/src/scraper/modules/page-helpers.cjs +422 -0
- package/src/scraper/modules/video-scanner.cjs +43 -0
- package/src/watch/public/index.html +266 -0
- package/src/watch/server.mjs +145 -0
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>TikTok Auto Monitor</title>
|
|
7
|
+
<style>
|
|
8
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
9
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0f0f13; color: #e0e0e0; padding: 16px; }
|
|
10
|
+
.header { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; background: #1a1a24; border-radius: 8px; margin-bottom: 16px; }
|
|
11
|
+
.header h1 { font-size: 18px; color: #fe2c55; }
|
|
12
|
+
.header .meta { font-size: 12px; color: #888; }
|
|
13
|
+
.header .status { font-size: 12px; color: #4ade80; }
|
|
14
|
+
.stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 16px; }
|
|
15
|
+
.stat-card { background: #1a1a24; border-radius: 8px; padding: 16px; text-align: center; }
|
|
16
|
+
.stat-card .label { font-size: 12px; color: #888; margin-bottom: 8px; }
|
|
17
|
+
.stat-card .value { font-size: 28px; font-weight: 700; }
|
|
18
|
+
.stat-card .value.total { color: #60a5fa; }
|
|
19
|
+
.stat-card .value.done { color: #4ade80; }
|
|
20
|
+
.stat-card .value.pending { color: #facc15; }
|
|
21
|
+
.stat-card .value.error { color: #f87171; }
|
|
22
|
+
.charts { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 16px; }
|
|
23
|
+
.chart-box { background: #1a1a24; border-radius: 8px; padding: 16px; }
|
|
24
|
+
.chart-box h3 { font-size: 14px; color: #888; margin-bottom: 12px; }
|
|
25
|
+
.bar-row { display: flex; align-items: center; margin-bottom: 8px; font-size: 13px; }
|
|
26
|
+
.bar-row .name { width: 50px; color: #ccc; flex-shrink: 0; }
|
|
27
|
+
.bar-row .bar-bg { flex: 1; background: #2a2a3a; border-radius: 4px; height: 20px; overflow: hidden; margin: 0 8px; }
|
|
28
|
+
.bar-row .bar-fill { height: 100%; border-radius: 4px; transition: width 0.3s; display: flex; align-items: center; padding-left: 6px; font-size: 11px; color: #fff; }
|
|
29
|
+
.bar-row .count { width: 80px; text-align: right; color: #888; flex-shrink: 0; }
|
|
30
|
+
.source-row { display: flex; align-items: center; margin-bottom: 6px; font-size: 13px; }
|
|
31
|
+
.source-row .s-name { width: 80px; color: #ccc; flex-shrink: 0; }
|
|
32
|
+
.source-row .s-val { color: #888; }
|
|
33
|
+
.table-wrap { background: #1a1a24; border-radius: 8px; padding: 16px; }
|
|
34
|
+
.table-wrap h3 { font-size: 14px; color: #888; margin-bottom: 12px; }
|
|
35
|
+
.controls { display: flex; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; }
|
|
36
|
+
.controls input { flex: 1; min-width: 150px; padding: 6px 12px; border: 1px solid #333; border-radius: 6px; background: #0f0f13; color: #e0e0e0; font-size: 13px; outline: none; }
|
|
37
|
+
.controls input:focus { border-color: #fe2c55; }
|
|
38
|
+
.controls button { padding: 6px 14px; border: 1px solid #333; border-radius: 6px; background: #2a2a3a; color: #ccc; font-size: 12px; cursor: pointer; transition: all 0.2s; }
|
|
39
|
+
.controls button:hover { border-color: #fe2c55; color: #fff; }
|
|
40
|
+
.controls button.active { background: #fe2c55; color: #fff; border-color: #fe2c55; }
|
|
41
|
+
.add-users { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; }
|
|
42
|
+
.add-users input { flex: 1; padding: 6px 12px; border: 1px solid #333; border-radius: 6px; background: #0f0f13; color: #e0e0e0; font-size: 13px; outline: none; }
|
|
43
|
+
.add-users input:focus { border-color: #fe2c55; }
|
|
44
|
+
.add-users button { padding: 6px 16px; border: none; border-radius: 6px; background: #fe2c55; color: #fff; font-size: 13px; cursor: pointer; font-weight: 600; transition: all 0.2s; }
|
|
45
|
+
.add-users button:hover { background: #e61944; }
|
|
46
|
+
.add-users .hint { font-size: 11px; color: #666; white-space: nowrap; }
|
|
47
|
+
.toast { position: fixed; top: 16px; right: 16px; padding: 10px 20px; border-radius: 6px; font-size: 13px; z-index: 999; transition: opacity 0.3s; }
|
|
48
|
+
.toast.success { background: #166534; color: #fff; }
|
|
49
|
+
.toast.error { background: #991b1b; color: #fff; }
|
|
50
|
+
.table-scroll { max-height: 500px; overflow-y: auto; }
|
|
51
|
+
table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
|
52
|
+
th { position: sticky; top: 0; background: #22222e; padding: 8px 10px; text-align: left; color: #888; font-weight: 600; border-bottom: 1px solid #333; white-space: nowrap; }
|
|
53
|
+
td { padding: 6px 10px; border-bottom: 1px solid #1f1f2a; white-space: nowrap; }
|
|
54
|
+
tr:hover { background: #1f1f2a; }
|
|
55
|
+
.tag { display: inline-block; padding: 1px 6px; border-radius: 3px; font-size: 10px; }
|
|
56
|
+
.tag.seller { background: #dc2626; color: #fff; }
|
|
57
|
+
.tag.verified { background: #2563eb; color: #fff; }
|
|
58
|
+
.tag.pending { background: #ca8a04; color: #000; }
|
|
59
|
+
.tag.error { background: #991b1b; color: #fff; }
|
|
60
|
+
.tag.processed { background: #166534; color: #fff; }
|
|
61
|
+
::-webkit-scrollbar { width: 6px; }
|
|
62
|
+
::-webkit-scrollbar-track { background: #1a1a24; }
|
|
63
|
+
::-webkit-scrollbar-thumb { background: #333; border-radius: 3px; }
|
|
64
|
+
@media (max-width: 768px) { .stats { grid-template-columns: repeat(2, 1fr); } .charts { grid-template-columns: 1fr; } }
|
|
65
|
+
</style>
|
|
66
|
+
</head>
|
|
67
|
+
<body>
|
|
68
|
+
<div class="header">
|
|
69
|
+
<h1>TikTok Auto Monitor</h1>
|
|
70
|
+
<span class="meta" id="fileMeta"></span>
|
|
71
|
+
<span class="status" id="lastUpdate"></span>
|
|
72
|
+
</div>
|
|
73
|
+
<div class="stats">
|
|
74
|
+
<div class="stat-card"><div class="label">总任务</div><div class="value total" id="statTotal">0</div></div>
|
|
75
|
+
<div class="stat-card"><div class="label">已完成</div><div class="value done" id="statDone">0</div></div>
|
|
76
|
+
<div class="stat-card"><div class="label">待处理</div><div class="value pending" id="statPending">0</div></div>
|
|
77
|
+
<div class="stat-card"><div class="label">错误/受限</div><div class="value error" id="statError">0</div></div>
|
|
78
|
+
</div>
|
|
79
|
+
<div class="charts">
|
|
80
|
+
<div class="chart-box">
|
|
81
|
+
<h3>国家统计</h3>
|
|
82
|
+
<div id="countryChart"></div>
|
|
83
|
+
</div>
|
|
84
|
+
<div class="chart-box">
|
|
85
|
+
<h3>来源分布</h3>
|
|
86
|
+
<div id="sourceChart"></div>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
<div class="table-wrap">
|
|
90
|
+
<h3>用户列表</h3>
|
|
91
|
+
<div class="add-users">
|
|
92
|
+
<input type="text" id="addUserInput" placeholder="输入用户名,多个用逗号或空格分隔(如 @user1,@user2)">
|
|
93
|
+
<button onclick="addUsers()">插入队列</button>
|
|
94
|
+
<span class="hint">插入到队列最前面优先处理</span>
|
|
95
|
+
</div>
|
|
96
|
+
<div id="toast" class="toast" style="display:none"></div>
|
|
97
|
+
<div class="controls">
|
|
98
|
+
<input type="text" id="searchInput" placeholder="搜索用户名 / 昵称...">
|
|
99
|
+
<button data-filter="all" class="active" onclick="setFilter('all')">全部</button>
|
|
100
|
+
<button data-filter="restricted" onclick="setFilter('restricted')">受限/错误</button>
|
|
101
|
+
<button data-filter="pending" onclick="setFilter('pending')">待处理</button>
|
|
102
|
+
<button data-filter="processed" onclick="setFilter('processed')">已处理</button>
|
|
103
|
+
</div>
|
|
104
|
+
<div class="table-scroll">
|
|
105
|
+
<table>
|
|
106
|
+
<thead>
|
|
107
|
+
<tr><th>用户名</th><th>昵称</th><th>粉丝</th><th>视频</th><th>国家</th><th>来源</th><th>状态</th></tr>
|
|
108
|
+
</thead>
|
|
109
|
+
<tbody id="userTable"></tbody>
|
|
110
|
+
</table>
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
<script>
|
|
114
|
+
const COLORS = ['#fe2c55','#60a5fa','#4ade80','#facc15','#f97316','#a855f7','#ec4899','#14b8a6','#e11d48','#0ea5e9','#8b5cf6','#84cc16'];
|
|
115
|
+
let currentFilter = 'all';
|
|
116
|
+
let currentData = null;
|
|
117
|
+
|
|
118
|
+
async function fetchData() {
|
|
119
|
+
try {
|
|
120
|
+
const res = await fetch('/api/data');
|
|
121
|
+
const data = await res.json();
|
|
122
|
+
currentData = data;
|
|
123
|
+
render();
|
|
124
|
+
} catch (e) {
|
|
125
|
+
document.getElementById('lastUpdate').textContent = '连接失败';
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function render() {
|
|
130
|
+
if (!currentData) return;
|
|
131
|
+
const d = currentData;
|
|
132
|
+
document.getElementById('statTotal').textContent = d.totalUsers;
|
|
133
|
+
document.getElementById('statDone').textContent = d.processedUsers;
|
|
134
|
+
document.getElementById('statPending').textContent = d.pendingUsers;
|
|
135
|
+
document.getElementById('statError').textContent = d.restrictedUsers + d.errorUsers;
|
|
136
|
+
document.getElementById('lastUpdate').textContent = '更新于 ' + new Date().toLocaleTimeString();
|
|
137
|
+
document.getElementById('fileMeta').textContent = d.totalUsers + ' 个用户';
|
|
138
|
+
|
|
139
|
+
renderCountryChart(d.countryStats);
|
|
140
|
+
renderSourceChart(d.sourceStats);
|
|
141
|
+
renderTable(d.users);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function renderCountryChart(countries) {
|
|
145
|
+
const el = document.getElementById('countryChart');
|
|
146
|
+
if (!countries.length) { el.innerHTML = '<span style="color:#666;font-size:12px">暂无数据</span>'; return; }
|
|
147
|
+
const max = countries[0].count;
|
|
148
|
+
const top = countries.slice(0, 15);
|
|
149
|
+
el.innerHTML = top.map((c, i) => `
|
|
150
|
+
<div class="bar-row">
|
|
151
|
+
<span class="name">${c.country}</span>
|
|
152
|
+
<div class="bar-bg"><div class="bar-fill" style="width:${(c.count / max * 100)}%;background:${COLORS[i % COLORS.length]}">${c.count}</div></div>
|
|
153
|
+
<span class="count">${((c.count / currentData.totalUsers) * 100).toFixed(1)}%</span>
|
|
154
|
+
</div>
|
|
155
|
+
`).join('');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function renderSourceChart(sources) {
|
|
159
|
+
const el = document.getElementById('sourceChart');
|
|
160
|
+
const labels = { seed: '种子', video: '视频发现', comment: '评论发现', processed: '已处理', restricted: '受限(跳过)', error: '错误(待重试)' };
|
|
161
|
+
const entries = Object.entries(sources);
|
|
162
|
+
el.innerHTML = entries.map(([key, val]) => `
|
|
163
|
+
<div class="source-row"><span class="s-name">${labels[key] || key}:</span><span class="s-val">${val}</span></div>
|
|
164
|
+
`).join('');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function renderTable(users) {
|
|
168
|
+
const el = document.getElementById('userTable');
|
|
169
|
+
const search = document.getElementById('searchInput').value.toLowerCase();
|
|
170
|
+
|
|
171
|
+
let filtered = users.filter(u => {
|
|
172
|
+
if (search && !u.uniqueId.toLowerCase().includes(search) && !(u.nickname || '').toLowerCase().includes(search)) return false;
|
|
173
|
+
if (currentFilter === 'restricted' && !(u.restricted || u.error)) return false;
|
|
174
|
+
if (currentFilter === 'pending' && u.followerCount !== undefined && !u.error && !u.restricted) return false;
|
|
175
|
+
if (currentFilter === 'processed' && u.followerCount === undefined) return false;
|
|
176
|
+
return true;
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
filtered.sort((a, b) => {
|
|
180
|
+
if (a.followerCount !== undefined && b.followerCount === undefined) return -1;
|
|
181
|
+
if (a.followerCount === undefined && b.followerCount !== undefined) return 1;
|
|
182
|
+
return (b.followerCount || 0) - (a.followerCount || 0);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
el.innerHTML = filtered.map(u => {
|
|
186
|
+
const statusTag = u.restricted
|
|
187
|
+
? '<span class="tag error">受限(跳过)</span>'
|
|
188
|
+
: u.error
|
|
189
|
+
? '<span class="tag error">错误(待重试)</span>'
|
|
190
|
+
: u.followerCount !== undefined
|
|
191
|
+
? '<span class="tag processed">已处理</span>'
|
|
192
|
+
: '<span class="tag pending">待处理</span>';
|
|
193
|
+
const sources = (u.sources || []).join(', ');
|
|
194
|
+
const extraTags = [];
|
|
195
|
+
if (u.ttSeller) extraTags.push('<span class="tag seller">商家</span>');
|
|
196
|
+
if (u.verified) extraTags.push('<span class="tag verified">认证</span>');
|
|
197
|
+
return `<tr>
|
|
198
|
+
<td>@${u.uniqueId}</td>
|
|
199
|
+
<td>${(u.nickname || '').replace(/</g, '<').replace(/>/g, '>')}</td>
|
|
200
|
+
<td>${u.followerCount != null ? formatNum(u.followerCount) : '-'}</td>
|
|
201
|
+
<td>${u.videoCount != null ? u.videoCount : '-'}</td>
|
|
202
|
+
<td>${u.locationCreated || '-'}</td>
|
|
203
|
+
<td>${sources || '-'}</td>
|
|
204
|
+
<td>${statusTag} ${extraTags.join(' ')}</td>
|
|
205
|
+
</tr>`;
|
|
206
|
+
}).join('');
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function formatNum(n) {
|
|
210
|
+
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
|
|
211
|
+
if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
|
|
212
|
+
return n;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function setFilter(f) {
|
|
216
|
+
currentFilter = f;
|
|
217
|
+
document.querySelectorAll('.controls button').forEach(b => {
|
|
218
|
+
b.classList.toggle('active', b.dataset.filter === f);
|
|
219
|
+
});
|
|
220
|
+
if (currentData) renderTable(currentData.users);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
document.getElementById('searchInput').addEventListener('input', () => {
|
|
224
|
+
if (currentData) renderTable(currentData.users);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
async function addUsers() {
|
|
228
|
+
const input = document.getElementById('addUserInput');
|
|
229
|
+
const raw = input.value.trim();
|
|
230
|
+
if (!raw) return;
|
|
231
|
+
|
|
232
|
+
const names = raw.split(/[,,\s]+/).map(s => s.replace(/^@/, '').trim()).filter(Boolean);
|
|
233
|
+
if (names.length === 0) return;
|
|
234
|
+
|
|
235
|
+
try {
|
|
236
|
+
const res = await fetch('/api/users', {
|
|
237
|
+
method: 'POST',
|
|
238
|
+
headers: { 'Content-Type': 'application/json' },
|
|
239
|
+
body: JSON.stringify({ users: names })
|
|
240
|
+
});
|
|
241
|
+
const data = await res.json();
|
|
242
|
+
showToast(`${data.message || `${names.length} 个用户已插入队列`}`);
|
|
243
|
+
input.value = '';
|
|
244
|
+
if (currentData) renderTable(currentData.users);
|
|
245
|
+
} catch (e) {
|
|
246
|
+
showToast('插入失败: ' + e.message, true);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function showToast(msg, isError) {
|
|
251
|
+
const toast = document.getElementById('toast');
|
|
252
|
+
toast.textContent = msg;
|
|
253
|
+
toast.className = 'toast' + (isError ? ' error' : '');
|
|
254
|
+
toast.style.display = 'block';
|
|
255
|
+
setTimeout(() => { toast.style.display = 'none'; }, 3000);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
document.getElementById('addUserInput').addEventListener('keydown', e => {
|
|
259
|
+
if (e.key === 'Enter') addUsers();
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
fetchData();
|
|
263
|
+
setInterval(fetchData, 1000);
|
|
264
|
+
</script>
|
|
265
|
+
</body>
|
|
266
|
+
</html>
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import http from 'http';
|
|
2
|
+
import { readFileSync, existsSync, writeFileSync } from 'fs';
|
|
3
|
+
import { join, dirname, resolve } from 'path';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
import { spawn } from 'child_process';
|
|
6
|
+
|
|
7
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
8
|
+
const __dirname = dirname(__filename);
|
|
9
|
+
const publicDir = join(__dirname, 'public');
|
|
10
|
+
|
|
11
|
+
function analyzeData(users) {
|
|
12
|
+
if (!Array.isArray(users)) users = [];
|
|
13
|
+
|
|
14
|
+
const totalUsers = users.length;
|
|
15
|
+
const processedUsers = users.filter(u => u.followerCount !== undefined).length;
|
|
16
|
+
const pendingUsers = users.filter(u => u.followerCount === undefined && !u.error && !u.restricted).length;
|
|
17
|
+
const restrictedUsers = users.filter(u => u.restricted).length;
|
|
18
|
+
const errorUsers = users.filter(u => u.error && !u.followerCount).length;
|
|
19
|
+
|
|
20
|
+
const countryMap = {};
|
|
21
|
+
for (const u of users) {
|
|
22
|
+
if (u.followerCount === undefined) continue;
|
|
23
|
+
const loc = u.locationCreated || '未知';
|
|
24
|
+
countryMap[loc] = (countryMap[loc] || 0) + 1;
|
|
25
|
+
}
|
|
26
|
+
const countryStats = Object.entries(countryMap)
|
|
27
|
+
.map(([country, count]) => ({ country, count }))
|
|
28
|
+
.sort((a, b) => b.count - a.count);
|
|
29
|
+
|
|
30
|
+
const sourceCounts = { seed: 0, video: 0, comment: 0, processed: 0, restricted: 0, error: 0 };
|
|
31
|
+
for (const u of users) {
|
|
32
|
+
if (u.restricted) {
|
|
33
|
+
sourceCounts.restricted++;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (u.error && !u.followerCount) {
|
|
37
|
+
sourceCounts.error++;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
const sources = u.sources || [];
|
|
41
|
+
if (sources.includes('processed')) sourceCounts.processed++;
|
|
42
|
+
if (sources.includes('video') && !sources.includes('processed')) sourceCounts.video++;
|
|
43
|
+
if (sources.includes('comment') && !sources.includes('processed')) sourceCounts.comment++;
|
|
44
|
+
if (!sources.includes('video') && !sources.includes('comment') && !sources.includes('processed')) sourceCounts.seed++;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
totalUsers,
|
|
49
|
+
processedUsers,
|
|
50
|
+
pendingUsers,
|
|
51
|
+
restrictedUsers,
|
|
52
|
+
errorUsers,
|
|
53
|
+
countryStats,
|
|
54
|
+
sourceStats: sourceCounts,
|
|
55
|
+
users,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function readDataFile(filePath) {
|
|
60
|
+
const resolved = resolve(filePath);
|
|
61
|
+
if (!existsSync(resolved)) {
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
const raw = readFileSync(resolved, 'utf-8');
|
|
66
|
+
const data = JSON.parse(raw);
|
|
67
|
+
return Array.isArray(data) ? data : [];
|
|
68
|
+
} catch (e) {
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function startWatchServer(outputFile, port = 3000) {
|
|
74
|
+
return new Promise((resolve, reject) => {
|
|
75
|
+
const server = http.createServer((req, res) => {
|
|
76
|
+
if (req.url === '/' || req.url === '/index.html') {
|
|
77
|
+
const html = readFileSync(join(publicDir, 'index.html'), 'utf-8');
|
|
78
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
79
|
+
res.end(html);
|
|
80
|
+
} else if (req.url === '/api/data') {
|
|
81
|
+
const users = readDataFile(outputFile);
|
|
82
|
+
const data = analyzeData(users);
|
|
83
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
84
|
+
res.end(JSON.stringify(data));
|
|
85
|
+
} else if (req.url === '/api/users' && req.method === 'POST') {
|
|
86
|
+
let body = '';
|
|
87
|
+
req.on('data', chunk => body += chunk);
|
|
88
|
+
req.on('end', () => {
|
|
89
|
+
try {
|
|
90
|
+
const { usernames } = JSON.parse(body);
|
|
91
|
+
if (!Array.isArray(usernames) || usernames.length === 0) {
|
|
92
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
93
|
+
res.end(JSON.stringify({ error: 'usernames 数组不能为空' }));
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const resolved = resolve(outputFile);
|
|
97
|
+
const existing = existsSync(resolved) ? readDataFile(outputFile) : [];
|
|
98
|
+
const existingIds = new Set(existing.map(u => u.uniqueId));
|
|
99
|
+
const newUsers = usernames
|
|
100
|
+
.map(u => u.replace(/^@/, '').trim())
|
|
101
|
+
.filter(u => u && !existingIds.has(u))
|
|
102
|
+
.map(u => ({ uniqueId: u, sources: ['seed'] }));
|
|
103
|
+
const updated = [...newUsers, ...existing];
|
|
104
|
+
writeFileSync(resolved, JSON.stringify(updated, null, 2));
|
|
105
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
106
|
+
res.end(JSON.stringify({ added: newUsers.length, skipped: usernames.length - newUsers.length }));
|
|
107
|
+
} catch (e) {
|
|
108
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
109
|
+
res.end(JSON.stringify({ error: e.message }));
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
} else {
|
|
113
|
+
res.writeHead(404);
|
|
114
|
+
res.end('Not Found');
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
server.on('error', (err) => {
|
|
119
|
+
if (err.code === 'EADDRINUSE') {
|
|
120
|
+
console.error(`端口 ${port} 已被占用,请更换端口后重试`);
|
|
121
|
+
reject(err);
|
|
122
|
+
} else {
|
|
123
|
+
reject(err);
|
|
124
|
+
}
|
|
125
|
+
return;
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
server.listen(port, '127.0.0.1', () => {
|
|
129
|
+
console.error(`Watch 监控服务已启动: http://127.0.0.1:${port}`);
|
|
130
|
+
resolve({ server, port });
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function startWatchServerStandalone(outputFile, port = 3000) {
|
|
136
|
+
return new Promise((resolve, reject) => {
|
|
137
|
+
const openProcess = spawn('open', [`http://127.0.0.1:${port}`]);
|
|
138
|
+
openProcess.on('error', () => {});
|
|
139
|
+
startWatchServer(outputFile, port).then(resolve).catch(reject);
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function openBrowser(port) {
|
|
144
|
+
spawn('open', [`http://127.0.0.1:${port}`]).on('error', () => {});
|
|
145
|
+
}
|