tt-help-cli-ycl 1.3.0 → 1.3.1
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.mjs +174 -0
- package/src/cli/auto.js +94 -0
- package/src/cli/explore.js +117 -0
- package/src/cli/progress.js +111 -0
- package/src/cli/scrape.js +47 -0
- package/src/cli/utils.js +18 -0
- package/src/cli/videos.js +41 -0
- package/src/cli/watch.js +28 -0
- package/src/data-store.mjs +213 -0
- package/src/{explore-core.cjs → explore-core.mjs} +148 -157
- package/src/{get-user-videos-core.cjs → get-user-videos-core.mjs} +6 -23
- package/src/lib/args.js +19 -38
- package/src/lib/auto-browser.mjs +5 -12
- package/src/lib/browser/anti-detect.js +23 -0
- package/src/lib/browser/cdp.js +142 -0
- package/src/lib/browser/launch.js +43 -0
- package/src/lib/browser/page.js +62 -0
- package/src/lib/constants.js +13 -95
- package/src/lib/delay.js +54 -0
- package/src/lib/explore.js +16 -123
- package/src/lib/fetcher.js +3 -18
- package/src/lib/get-user-videos-browser.mjs +1 -6
- package/src/lib/io.js +8 -30
- package/src/lib/parser.js +1 -1
- package/src/lib/retry.js +44 -0
- package/src/lib/scrape-browser.mjs +1 -6
- package/src/lib/scrape.js +5 -4
- package/src/lib/url.js +52 -0
- package/src/main.mjs +59 -822
- package/src/scraper/{core.cjs → core.mjs} +25 -57
- package/src/scraper/modules/{comment-extractor.cjs → comment-extractor.mjs} +23 -15
- package/src/scraper/modules/follow-extractor.mjs +121 -0
- package/src/scraper/modules/{guess-extractor.cjs → guess-extractor.mjs} +3 -5
- package/src/scraper/modules/page-error-detector.mjs +68 -0
- package/src/scraper/modules/page-helpers.mjs +44 -0
- package/src/scraper/modules/scroll-collector.mjs +189 -0
- package/src/watch/public/index.html +139 -64
- package/src/watch/server.mjs +234 -153
- package/src/auto-core.cjs +0 -367
- package/src/data-store.cjs +0 -69
- package/src/get-user-videos.cjs +0 -59
- package/src/scraper/index.cjs +0 -97
- package/src/scraper/modules/follow-extractor.cjs +0 -112
- package/src/scraper/modules/page-helpers.cjs +0 -422
- package/src/scraper/modules/scroll-collector.cjs +0 -173
- package/src/scraper/modules/video-scanner.cjs +0 -43
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="UTF-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
-
<title>TikTok
|
|
6
|
+
<title>TikTok 采集监控</title>
|
|
7
7
|
<style>
|
|
8
8
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
9
9
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0f0f13; color: #e0e0e0; padding: 16px; }
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
.header h1 { font-size: 18px; color: #fe2c55; }
|
|
12
12
|
.header .meta { font-size: 12px; color: #888; }
|
|
13
13
|
.header .status { font-size: 12px; color: #4ade80; }
|
|
14
|
-
.stats { display: grid; grid-template-columns: repeat(
|
|
14
|
+
.stats { display: grid; grid-template-columns: repeat(6, 1fr); gap: 12px; margin-bottom: 16px; }
|
|
15
15
|
.stat-card { background: #1a1a24; border-radius: 8px; padding: 16px; text-align: center; }
|
|
16
16
|
.stat-card .label { font-size: 12px; color: #888; margin-bottom: 8px; }
|
|
17
17
|
.stat-card .value { font-size: 28px; font-weight: 700; }
|
|
@@ -19,6 +19,9 @@
|
|
|
19
19
|
.stat-card .value.done { color: #4ade80; }
|
|
20
20
|
.stat-card .value.pending { color: #facc15; }
|
|
21
21
|
.stat-card .value.error { color: #f87171; }
|
|
22
|
+
.stat-card .value.target { color: #a78bfa; }
|
|
23
|
+
.stat-card.clickable { cursor: pointer; }
|
|
24
|
+
.stat-card.clickable:hover { background: #25253a; }
|
|
22
25
|
.charts { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 16px; }
|
|
23
26
|
.chart-box { background: #1a1a24; border-radius: 8px; padding: 16px; }
|
|
24
27
|
.chart-box h3 { font-size: 14px; color: #888; margin-bottom: 12px; }
|
|
@@ -47,16 +50,25 @@
|
|
|
47
50
|
.toast { position: fixed; top: 16px; right: 16px; padding: 10px 20px; border-radius: 6px; font-size: 13px; z-index: 999; transition: opacity 0.3s; }
|
|
48
51
|
.toast.success { background: #166534; color: #fff; }
|
|
49
52
|
.toast.error { background: #991b1b; color: #fff; }
|
|
53
|
+
@keyframes flashChange { 0% { filter: brightness(1.8); box-shadow: 0 0 12px rgba(254,44,85,0.6); } 100% { filter: brightness(1); box-shadow: none; } }
|
|
54
|
+
.flash-change { animation: flashChange 0.6s ease-out; }
|
|
55
|
+
@keyframes rowFlash { 0% { background: rgba(254,44,85,0.25); } 100% { background: transparent; } }
|
|
56
|
+
tr.row-flash { animation: rowFlash 0.8s ease-out; }
|
|
57
|
+
@keyframes barFlash { 0% { filter: brightness(1.6); } 100% { filter: brightness(1); } }
|
|
58
|
+
.bar-fill.bar-flash { animation: barFlash 0.5s ease-out; }
|
|
50
59
|
.table-scroll { max-height: 500px; overflow-y: auto; }
|
|
51
60
|
table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
|
52
61
|
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
62
|
td { padding: 6px 10px; border-bottom: 1px solid #1f1f2a; white-space: nowrap; }
|
|
54
63
|
tr:hover { background: #1f1f2a; }
|
|
64
|
+
td.user-id { cursor: pointer; color: #60a5fa; }
|
|
65
|
+
td.user-id:hover { color: #fe2c55; }
|
|
55
66
|
.tag { display: inline-block; padding: 1px 6px; border-radius: 3px; font-size: 10px; }
|
|
56
67
|
.tag.seller { background: #dc2626; color: #fff; }
|
|
57
68
|
.tag.verified { background: #2563eb; color: #fff; }
|
|
58
|
-
|
|
59
|
-
|
|
69
|
+
.tag.pending { background: #ca8a04; color: #000; }
|
|
70
|
+
.tag.processing { background: #0ea5e9; color: #fff; }
|
|
71
|
+
.tag.error { background: #991b1b; color: #fff; }
|
|
60
72
|
.tag.processed { background: #166534; color: #fff; }
|
|
61
73
|
.tag.no-video { background: #7c3aed; color: #fff; }
|
|
62
74
|
.tag.no-follow { background: #b45309; color: #fff; }
|
|
@@ -64,20 +76,22 @@
|
|
|
64
76
|
::-webkit-scrollbar { width: 6px; }
|
|
65
77
|
::-webkit-scrollbar-track { background: #1a1a24; }
|
|
66
78
|
::-webkit-scrollbar-thumb { background: #333; border-radius: 3px; }
|
|
67
|
-
@media (max-width: 768px) { .stats { grid-template-columns: repeat(
|
|
79
|
+
@media (max-width: 768px) { .stats { grid-template-columns: repeat(3, 1fr); } .charts { grid-template-columns: 1fr; } }
|
|
68
80
|
</style>
|
|
69
81
|
</head>
|
|
70
82
|
<body>
|
|
71
83
|
<div class="header">
|
|
72
|
-
<h1>TikTok
|
|
84
|
+
<h1>TikTok 采集监控</h1>
|
|
73
85
|
<span class="meta" id="fileMeta"></span>
|
|
74
86
|
<span class="status" id="lastUpdate"></span>
|
|
75
87
|
</div>
|
|
76
88
|
<div class="stats">
|
|
77
89
|
<div class="stat-card"><div class="label">总任务</div><div class="value total" id="statTotal">0</div></div>
|
|
90
|
+
<div class="stat-card"><div class="label">处理中</div><div class="value total" id="statProcessing">0</div></div>
|
|
78
91
|
<div class="stat-card"><div class="label">已完成</div><div class="value done" id="statDone">0</div></div>
|
|
79
92
|
<div class="stat-card"><div class="label">待处理</div><div class="value pending" id="statPending">0</div></div>
|
|
80
93
|
<div class="stat-card"><div class="label">错误/受限</div><div class="value error" id="statError">0</div></div>
|
|
94
|
+
<div class="stat-card clickable" id="statTargetCard"><div class="label">目标用户(ES商家)</div><div class="value target" id="statTarget">0</div></div>
|
|
81
95
|
</div>
|
|
82
96
|
<div class="charts">
|
|
83
97
|
<div class="chart-box">
|
|
@@ -100,14 +114,16 @@
|
|
|
100
114
|
<div class="controls">
|
|
101
115
|
<input type="text" id="searchInput" placeholder="搜索用户名 / 昵称...">
|
|
102
116
|
<button data-filter="all" class="active" onclick="setFilter('all')">全部</button>
|
|
103
|
-
<button data-filter="
|
|
117
|
+
<button data-filter="processing" onclick="setFilter('processing')">处理中</button>
|
|
104
118
|
<button data-filter="pending" onclick="setFilter('pending')">待处理</button>
|
|
105
|
-
<button data-filter="
|
|
119
|
+
<button data-filter="done" onclick="setFilter('done')">已处理</button>
|
|
120
|
+
<button data-filter="error" onclick="setFilter('error')">错误</button>
|
|
121
|
+
<button data-filter="restricted" onclick="setFilter('restricted')">受限</button>
|
|
106
122
|
</div>
|
|
107
123
|
<div class="table-scroll">
|
|
108
124
|
<table>
|
|
109
125
|
<thead>
|
|
110
|
-
<tr><th>用户名</th><th>昵称</th><th>粉丝</th><th>视频</th><th>国家</th><th>来源</th><th>状态</th></tr>
|
|
126
|
+
<tr><th>用户名</th><th>昵称</th><th>粉丝</th><th>视频</th><th>国家</th><th>来源</th><th>状态</th><th>处理时间</th></tr>
|
|
111
127
|
</thead>
|
|
112
128
|
<tbody id="userTable"></tbody>
|
|
113
129
|
</table>
|
|
@@ -116,51 +132,81 @@
|
|
|
116
132
|
<script>
|
|
117
133
|
const COLORS = ['#fe2c55','#60a5fa','#4ade80','#facc15','#f97316','#a855f7','#ec4899','#14b8a6','#e11d48','#0ea5e9','#8b5cf6','#84cc16'];
|
|
118
134
|
let currentFilter = 'all';
|
|
119
|
-
let
|
|
135
|
+
let currentStats = null;
|
|
136
|
+
let currentUsers = [];
|
|
137
|
+
let prevStatValues = {};
|
|
138
|
+
let prevUserMap = {};
|
|
120
139
|
|
|
121
|
-
async function
|
|
140
|
+
async function fetchStats() {
|
|
122
141
|
try {
|
|
123
|
-
const res = await fetch('/api/
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
render();
|
|
142
|
+
const res = await fetch('/api/stats');
|
|
143
|
+
currentStats = await res.json();
|
|
144
|
+
renderStats();
|
|
127
145
|
} catch (e) {
|
|
128
|
-
document.getElementById('lastUpdate').textContent = '
|
|
146
|
+
document.getElementById('lastUpdate').textContent = '\u8fde\u63a5\u5931\u8d25';
|
|
129
147
|
}
|
|
130
148
|
}
|
|
131
149
|
|
|
132
|
-
function
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
150
|
+
async function fetchUsers() {
|
|
151
|
+
try {
|
|
152
|
+
const params = new URLSearchParams();
|
|
153
|
+
if (currentFilter !== 'all') params.set('status', currentFilter);
|
|
154
|
+
const search = document.getElementById('searchInput').value.trim();
|
|
155
|
+
if (search) params.set('search', search);
|
|
156
|
+
params.set('limit', '200');
|
|
157
|
+
const res = await fetch('/api/users?' + params.toString());
|
|
158
|
+
const data = await res.json();
|
|
159
|
+
currentUsers = data.users || [];
|
|
160
|
+
renderTable(currentUsers);
|
|
161
|
+
} catch (e) {}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function flashEl(id, value) {
|
|
165
|
+
const el = document.getElementById(id);
|
|
166
|
+
if (!el) return;
|
|
167
|
+
const prev = prevStatValues[id];
|
|
168
|
+
el.textContent = value;
|
|
169
|
+
if (prev !== undefined && prev !== value) {
|
|
170
|
+
el.classList.remove('flash-change');
|
|
171
|
+
void el.offsetWidth;
|
|
172
|
+
el.classList.add('flash-change');
|
|
173
|
+
}
|
|
174
|
+
prevStatValues[id] = value;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function renderStats() {
|
|
178
|
+
if (!currentStats) return;
|
|
179
|
+
const d = currentStats;
|
|
180
|
+
flashEl('statTotal', d.totalUsers);
|
|
181
|
+
flashEl('statProcessing', d.processingUsers || 0);
|
|
182
|
+
flashEl('statDone', d.processedUsers);
|
|
183
|
+
flashEl('statPending', d.pendingUsers);
|
|
184
|
+
flashEl('statError', d.restrictedUsers + d.errorUsers);
|
|
185
|
+
flashEl('statTarget', d.targetUsers);
|
|
186
|
+
document.getElementById('lastUpdate').textContent = '\u66f4\u65b0\u4e8e ' + new Date().toLocaleTimeString();
|
|
187
|
+
document.getElementById('fileMeta').textContent = (d.processingUsers || 0) + ' \u5904\u7406\u4e2d, ' + d.totalUsers + ' \u4e2a\u7528\u6237';
|
|
141
188
|
|
|
142
189
|
renderCountryChart(d.countryStats);
|
|
143
190
|
renderSourceChart(d.sourceStats);
|
|
144
|
-
renderTable(d.users);
|
|
145
191
|
}
|
|
146
192
|
|
|
147
193
|
function renderCountryChart(countries) {
|
|
148
194
|
const el = document.getElementById('countryChart');
|
|
149
|
-
if (!countries.length) { el.innerHTML = '<span style="color:#666;font-size:12px"
|
|
195
|
+
if (!countries.length) { el.innerHTML = '<span style="color:#666;font-size:12px">\u6682\u65e0\u6570\u636e</span>'; return; }
|
|
150
196
|
const max = countries[0].count;
|
|
151
197
|
const top = countries.slice(0, 15);
|
|
152
198
|
el.innerHTML = top.map((c, i) => `
|
|
153
199
|
<div class="bar-row">
|
|
154
200
|
<span class="name">${c.country}</span>
|
|
155
201
|
<div class="bar-bg"><div class="bar-fill" style="width:${(c.count / max * 100)}%;background:${COLORS[i % COLORS.length]}">${c.count}</div></div>
|
|
156
|
-
<span class="count">${((c.count /
|
|
202
|
+
<span class="count">${(currentStats ? (c.count / currentStats.totalUsers * 100).toFixed(1) : 0)}%</span>
|
|
157
203
|
</div>
|
|
158
204
|
`).join('');
|
|
159
205
|
}
|
|
160
206
|
|
|
161
207
|
function renderSourceChart(sources) {
|
|
162
208
|
const el = document.getElementById('sourceChart');
|
|
163
|
-
const labels = { seed: '
|
|
209
|
+
const labels = { seed: '\u79cd\u5b50', video: '\u89c6\u9891\u53d1\u73b0', comment: '\u8bc4\u8bba\u53d1\u73b0', guess: '\u731c\u4f60\u559c\u6b22', following: '\u5173\u6ce8', follower: '\u7c89\u4e1d', processed: '\u5df2\u5904\u7406', restricted: '\u53d7\u9650(\u8df3\u8fc7)', error: '\u9519\u8bef(\u5f85\u91cd\u8bd5)', noVideo: '\u65e0\u89c6\u9891' };
|
|
164
210
|
const entries = Object.entries(sources);
|
|
165
211
|
el.innerHTML = entries.map(([key, val]) => `
|
|
166
212
|
<div class="source-row"><span class="s-name">${labels[key] || key}:</span><span class="s-val">${val}</span></div>
|
|
@@ -169,47 +215,45 @@ function renderSourceChart(sources) {
|
|
|
169
215
|
|
|
170
216
|
function renderTable(users) {
|
|
171
217
|
const el = document.getElementById('userTable');
|
|
172
|
-
const search = document.getElementById('searchInput').value.toLowerCase();
|
|
173
218
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
if (currentFilter === 'restricted' && !(u.restricted || u.error)) return false;
|
|
177
|
-
if (currentFilter === 'pending' && u.processed && !u.error && !u.restricted) return false;
|
|
178
|
-
if (currentFilter === 'processed' && !u.processed) return false;
|
|
179
|
-
return true;
|
|
180
|
-
});
|
|
219
|
+
const newUserMap = {};
|
|
220
|
+
for (const u of users) newUserMap[u.uniqueId] = u;
|
|
181
221
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
222
|
+
el.innerHTML = users.map(u => {
|
|
223
|
+
const wasStatus = prevUserMap[u.uniqueId]?.status;
|
|
224
|
+
const nowStatus = u.status;
|
|
225
|
+
const changed = wasStatus !== nowStatus &&
|
|
226
|
+
(nowStatus === 'done' || nowStatus === 'restricted' || nowStatus === 'error');
|
|
227
|
+
const rowClass = changed ? ' class="row-flash"' : '';
|
|
187
228
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
:
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
229
|
+
const statusTags = {
|
|
230
|
+
restricted: '<span class="tag error">\u53d7\u9650(\u8df3\u8fc7)</span>',
|
|
231
|
+
error: '<span class="tag error">\u9519\u8bef(\u5f85\u91cd\u8bd5)</span>',
|
|
232
|
+
done: '<span class="tag processed">\u5df2\u5904\u7406</span>',
|
|
233
|
+
processing: '<span class="tag processing">\u5904\u7406\u4e2d</span>',
|
|
234
|
+
pending: '<span class="tag pending">\u5f85\u5904\u7406</span>',
|
|
235
|
+
};
|
|
236
|
+
const statusTag = statusTags[u.status] || '<span class="tag pending">' + (u.status || '\u672a\u77e5') + '</span>';
|
|
196
237
|
const sources = (u.sources || []).join(', ');
|
|
197
238
|
const extraTags = [];
|
|
198
|
-
if (u.ttSeller) extraTags.push('<span class="tag seller"
|
|
199
|
-
if (u.verified) extraTags.push('<span class="tag verified"
|
|
200
|
-
if (u.noVideo) extraTags.push('<span class="tag no-video"
|
|
201
|
-
if (u.keepFollow) extraTags.push('<span class="tag keep-follow"
|
|
202
|
-
if (u.hasFollowData === false) extraTags.push('<span class="tag no-follow"
|
|
203
|
-
return `<tr>
|
|
204
|
-
<td>@${u.uniqueId}</td>
|
|
239
|
+
if (u.ttSeller) extraTags.push('<span class="tag seller">\u5546\u5bb6</span>');
|
|
240
|
+
if (u.verified) extraTags.push('<span class="tag verified">\u8ba4\u8bc1</span>');
|
|
241
|
+
if (u.noVideo) extraTags.push('<span class="tag no-video">\u65e0\u89c6\u9891</span>');
|
|
242
|
+
if (u.keepFollow) extraTags.push('<span class="tag keep-follow">\u5173\u6ce8\u5df2\u4fdd\u7559</span>');
|
|
243
|
+
if (u.hasFollowData === false) extraTags.push('<span class="tag no-follow">\u5173\u6ce8\u672a\u83b7\u53d6</span>');
|
|
244
|
+
return `<tr${rowClass}>
|
|
245
|
+
<td class="user-id">@${u.uniqueId}</td>
|
|
205
246
|
<td>${(u.nickname || '').replace(/</g, '<').replace(/>/g, '>')}</td>
|
|
206
247
|
<td>${u.followerCount != null ? formatNum(u.followerCount) : '-'}</td>
|
|
207
248
|
<td>${u.videoCount != null ? u.videoCount : '-'}</td>
|
|
208
249
|
<td>${u.locationCreated || '-'}</td>
|
|
209
250
|
<td>${sources || '-'}</td>
|
|
210
251
|
<td>${statusTag} ${extraTags.join(' ')}</td>
|
|
252
|
+
<td style="font-size:11px;color:#888">${u.processedAt ? formatTime(u.processedAt) : '-'}</td>
|
|
211
253
|
</tr>`;
|
|
212
254
|
}).join('');
|
|
255
|
+
|
|
256
|
+
prevUserMap = newUserMap;
|
|
213
257
|
}
|
|
214
258
|
|
|
215
259
|
function formatNum(n) {
|
|
@@ -218,16 +262,22 @@ function formatNum(n) {
|
|
|
218
262
|
return n;
|
|
219
263
|
}
|
|
220
264
|
|
|
265
|
+
function formatTime(ts) {
|
|
266
|
+
const d = new Date(ts);
|
|
267
|
+
const pad = n => String(n).padStart(2, '0');
|
|
268
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
|
269
|
+
}
|
|
270
|
+
|
|
221
271
|
function setFilter(f) {
|
|
222
272
|
currentFilter = f;
|
|
223
273
|
document.querySelectorAll('.controls button').forEach(b => {
|
|
224
274
|
b.classList.toggle('active', b.dataset.filter === f);
|
|
225
275
|
});
|
|
226
|
-
|
|
276
|
+
fetchUsers();
|
|
227
277
|
}
|
|
228
278
|
|
|
229
279
|
document.getElementById('searchInput').addEventListener('input', () => {
|
|
230
|
-
|
|
280
|
+
fetchUsers();
|
|
231
281
|
});
|
|
232
282
|
|
|
233
283
|
async function addUsers() {
|
|
@@ -245,11 +295,13 @@ async function addUsers() {
|
|
|
245
295
|
body: JSON.stringify({ usernames: names })
|
|
246
296
|
});
|
|
247
297
|
const data = await res.json();
|
|
248
|
-
|
|
298
|
+
if (data.error) { showToast(data.error, true); return; }
|
|
299
|
+
showToast(data.message || `\u5df2\u63d2\u5165 ${data.added} \u4e2a\u7528\u6237`);
|
|
249
300
|
input.value = '';
|
|
250
|
-
|
|
301
|
+
fetchStats();
|
|
302
|
+
fetchUsers();
|
|
251
303
|
} catch (e) {
|
|
252
|
-
showToast('
|
|
304
|
+
showToast('\u63d2\u5165\u5931\u8d25: ' + e.message, true);
|
|
253
305
|
}
|
|
254
306
|
}
|
|
255
307
|
|
|
@@ -265,8 +317,31 @@ document.getElementById('addUserInput').addEventListener('keydown', e => {
|
|
|
265
317
|
if (e.key === 'Enter') addUsers();
|
|
266
318
|
});
|
|
267
319
|
|
|
268
|
-
|
|
269
|
-
|
|
320
|
+
document.getElementById('userTable').addEventListener('click', e => {
|
|
321
|
+
const td = e.target.closest('td.user-id');
|
|
322
|
+
if (!td) return;
|
|
323
|
+
const username = td.textContent.trim().replace(/^@/, '');
|
|
324
|
+
if (!username) return;
|
|
325
|
+
window.open('https://www.tiktok.com/@' + username, '_blank');
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
document.getElementById('statTargetCard').addEventListener('click', async () => {
|
|
329
|
+
try {
|
|
330
|
+
const res = await fetch('/api/target-users');
|
|
331
|
+
const data = await res.json();
|
|
332
|
+
if (!data.users.length) { showToast('暂无目标用户', true); return; }
|
|
333
|
+
const text = data.users.map(u => '@' + u.uniqueId).join(', ');
|
|
334
|
+
await navigator.clipboard.writeText(text);
|
|
335
|
+
showToast(data.users.length + ' 个目标用户 ID 已复制到剪贴板');
|
|
336
|
+
} catch (e) {
|
|
337
|
+
showToast('获取失败: ' + e.message, true);
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
fetchStats();
|
|
342
|
+
fetchUsers();
|
|
343
|
+
setInterval(fetchStats, 1000);
|
|
344
|
+
setInterval(fetchUsers, 2000);
|
|
270
345
|
</script>
|
|
271
346
|
</body>
|
|
272
347
|
</html>
|