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.
Files changed (47) hide show
  1. package/package.json +1 -1
  2. package/src/auto-core.mjs +174 -0
  3. package/src/cli/auto.js +94 -0
  4. package/src/cli/explore.js +117 -0
  5. package/src/cli/progress.js +111 -0
  6. package/src/cli/scrape.js +47 -0
  7. package/src/cli/utils.js +18 -0
  8. package/src/cli/videos.js +41 -0
  9. package/src/cli/watch.js +28 -0
  10. package/src/data-store.mjs +213 -0
  11. package/src/{explore-core.cjs → explore-core.mjs} +148 -157
  12. package/src/{get-user-videos-core.cjs → get-user-videos-core.mjs} +6 -23
  13. package/src/lib/args.js +19 -38
  14. package/src/lib/auto-browser.mjs +5 -12
  15. package/src/lib/browser/anti-detect.js +23 -0
  16. package/src/lib/browser/cdp.js +142 -0
  17. package/src/lib/browser/launch.js +43 -0
  18. package/src/lib/browser/page.js +62 -0
  19. package/src/lib/constants.js +13 -95
  20. package/src/lib/delay.js +54 -0
  21. package/src/lib/explore.js +16 -123
  22. package/src/lib/fetcher.js +3 -18
  23. package/src/lib/get-user-videos-browser.mjs +1 -6
  24. package/src/lib/io.js +8 -30
  25. package/src/lib/parser.js +1 -1
  26. package/src/lib/retry.js +44 -0
  27. package/src/lib/scrape-browser.mjs +1 -6
  28. package/src/lib/scrape.js +5 -4
  29. package/src/lib/url.js +52 -0
  30. package/src/main.mjs +59 -822
  31. package/src/scraper/{core.cjs → core.mjs} +25 -57
  32. package/src/scraper/modules/{comment-extractor.cjs → comment-extractor.mjs} +23 -15
  33. package/src/scraper/modules/follow-extractor.mjs +121 -0
  34. package/src/scraper/modules/{guess-extractor.cjs → guess-extractor.mjs} +3 -5
  35. package/src/scraper/modules/page-error-detector.mjs +68 -0
  36. package/src/scraper/modules/page-helpers.mjs +44 -0
  37. package/src/scraper/modules/scroll-collector.mjs +189 -0
  38. package/src/watch/public/index.html +139 -64
  39. package/src/watch/server.mjs +234 -153
  40. package/src/auto-core.cjs +0 -367
  41. package/src/data-store.cjs +0 -69
  42. package/src/get-user-videos.cjs +0 -59
  43. package/src/scraper/index.cjs +0 -97
  44. package/src/scraper/modules/follow-extractor.cjs +0 -112
  45. package/src/scraper/modules/page-helpers.cjs +0 -422
  46. package/src/scraper/modules/scroll-collector.cjs +0 -173
  47. 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 Auto Monitor</title>
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(4, 1fr); gap: 12px; margin-bottom: 16px; }
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
- .tag.pending { background: #ca8a04; color: #000; }
59
- .tag.error { background: #991b1b; color: #fff; }
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(2, 1fr); } .charts { grid-template-columns: 1fr; } }
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 Auto Monitor</h1>
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="restricted" onclick="setFilter('restricted')">受限/错误</button>
117
+ <button data-filter="processing" onclick="setFilter('processing')">处理中</button>
104
118
  <button data-filter="pending" onclick="setFilter('pending')">待处理</button>
105
- <button data-filter="processed" onclick="setFilter('processed')">已处理</button>
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 currentData = null;
135
+ let currentStats = null;
136
+ let currentUsers = [];
137
+ let prevStatValues = {};
138
+ let prevUserMap = {};
120
139
 
121
- async function fetchData() {
140
+ async function fetchStats() {
122
141
  try {
123
- const res = await fetch('/api/data');
124
- const data = await res.json();
125
- currentData = data;
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 render() {
133
- if (!currentData) return;
134
- const d = currentData;
135
- document.getElementById('statTotal').textContent = d.totalUsers;
136
- document.getElementById('statDone').textContent = d.processedUsers;
137
- document.getElementById('statPending').textContent = d.pendingUsers;
138
- document.getElementById('statError').textContent = d.restrictedUsers + d.errorUsers;
139
- document.getElementById('lastUpdate').textContent = '更新于 ' + new Date().toLocaleTimeString();
140
- document.getElementById('fileMeta').textContent = d.totalUsers + ' 个用户';
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">暂无数据</span>'; return; }
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 / currentData.totalUsers) * 100).toFixed(1)}%</span>
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: '种子', video: '视频发现', comment: '评论发现', guess: '猜你喜欢', following: '关注', follower: '粉丝', processed: '已处理', restricted: '受限(跳过)', error: '错误(待重试)', noVideo: '无视频' };
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
- let filtered = users.filter(u => {
175
- if (search && !u.uniqueId.toLowerCase().includes(search) && !(u.nickname || '').toLowerCase().includes(search)) return false;
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
- filtered.sort((a, b) => {
183
- if (a.processed && !b.processed) return -1;
184
- if (!a.processed && b.processed) return 1;
185
- return (b.followerCount || 0) - (a.followerCount || 0);
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
- el.innerHTML = filtered.map(u => {
189
- const statusTag = u.restricted
190
- ? '<span class="tag error">受限(跳过)</span>'
191
- : u.error
192
- ? '<span class="tag error">错误(待重试)</span>'
193
- : u.processed
194
- ? '<span class="tag processed">已处理</span>'
195
- : '<span class="tag pending">待处理</span>';
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">商家</span>');
199
- if (u.verified) extraTags.push('<span class="tag verified">认证</span>');
200
- if (u.noVideo) extraTags.push('<span class="tag no-video">无视频</span>');
201
- if (u.keepFollow) extraTags.push('<span class="tag keep-follow">关注已保留</span>');
202
- if (u.hasFollowData === false) extraTags.push('<span class="tag no-follow">关注未获取</span>');
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, '&lt;').replace(/>/g, '&gt;')}</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
- if (currentData) renderTable(currentData.users);
276
+ fetchUsers();
227
277
  }
228
278
 
229
279
  document.getElementById('searchInput').addEventListener('input', () => {
230
- if (currentData) renderTable(currentData.users);
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
- showToast(`${data.message || `${names.length} 个用户已插入队列`}`);
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
- if (currentData) renderTable(currentData.users);
301
+ fetchStats();
302
+ fetchUsers();
251
303
  } catch (e) {
252
- showToast('插入失败: ' + e.message, true);
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
- fetchData();
269
- setInterval(fetchData, 1000);
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>