tt-help-cli-ycl 1.3.7 → 1.3.9

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tt-help-cli-ycl",
3
- "version": "1.3.7",
3
+ "version": "1.3.9",
4
4
  "description": "TikTok user & video data scraper - extract ttSeller, verified, locationCreated from HTML source",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,6 +11,8 @@
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
+ .script-link { font-size: 12px; color: #60a5fa; text-decoration: none; padding: 2px 8px; border: 1px solid #60a5fa; border-radius: 4px; }
15
+ .script-link:hover { background: #60a5fa; color: #fff; }
14
16
  .stats { display: grid; grid-template-columns: repeat(7, 1fr); gap: 12px; margin-bottom: 16px; }
15
17
  .stat-card { background: #1a1a24; border-radius: 8px; padding: 16px; text-align: center; }
16
18
  .stat-card .label { font-size: 12px; color: #888; margin-bottom: 8px; }
@@ -42,11 +44,22 @@
42
44
  .controls button:hover { border-color: #fe2c55; color: #fff; }
43
45
  .controls button.active { background: #fe2c55; color: #fff; border-color: #fe2c55; }
44
46
  .add-users { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; }
45
- .add-users input { flex: 1; padding: 6px 12px; border: 1px solid #333; border-radius: 6px; background: #0f0f13; color: #e0e0e0; font-size: 13px; outline: none; }
46
- .add-users input:focus { border-color: #fe2c55; }
47
47
  .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; }
48
48
  .add-users button:hover { background: #e61944; }
49
- .add-users .hint { font-size: 11px; color: #666; white-space: nowrap; }
49
+ .modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.65); z-index: 1000; display: flex; align-items: center; justify-content: center; }
50
+ .modal { background: #1a1a24; border-radius: 12px; padding: 24px; width: 520px; max-width: 90vw; box-shadow: 0 20px 60px rgba(0,0,0,0.5); }
51
+ .modal h3 { font-size: 16px; color: #e0e0e0; margin-bottom: 6px; }
52
+ .modal .hint { font-size: 12px; color: #888; margin-bottom: 16px; }
53
+ .modal textarea { width: 100%; height: 180px; padding: 10px 14px; border: 1px solid #333; border-radius: 8px; background: #0f0f13; color: #e0e0e0; font-size: 13px; font-family: inherit; outline: none; resize: vertical; line-height: 1.6; }
54
+ .modal textarea:focus { border-color: #fe2c55; }
55
+ .modal textarea::placeholder { color: #555; }
56
+ .modal .preview { margin-top: 8px; font-size: 12px; color: #60a5fa; min-height: 20px; }
57
+ .modal .btn-row { display: flex; gap: 8px; margin-top: 16px; justify-content: flex-end; }
58
+ .modal .btn-row button { padding: 8px 20px; border: none; border-radius: 6px; font-size: 13px; cursor: pointer; font-weight: 600; transition: all 0.2s; }
59
+ .modal .btn-cancel { background: #2a2a3a; color: #ccc; }
60
+ .modal .btn-cancel:hover { background: #333; }
61
+ .modal .btn-submit { background: #fe2c55; color: #fff; }
62
+ .modal .btn-submit:hover { background: #e61944; }
50
63
  .toast { position: fixed; top: 16px; right: 16px; padding: 10px 20px; border-radius: 6px; font-size: 13px; z-index: 999; transition: opacity 0.3s; }
51
64
  .toast.success { background: #166534; color: #fff; }
52
65
  .toast.error { background: #991b1b; color: #fff; }
@@ -82,15 +95,47 @@
82
95
  ::-webkit-scrollbar { width: 6px; }
83
96
  ::-webkit-scrollbar-track { background: #1a1a24; }
84
97
  ::-webkit-scrollbar-thumb { background: #333; border-radius: 3px; }
85
- @media (max-width: 768px) { .stats { grid-template-columns: repeat(3, 1fr); } .charts { grid-template-columns: 1fr; } }
98
+ @media (max-width: 768px) {
99
+ body { padding: 8px; }
100
+ .header { flex-direction: column; gap: 6px; align-items: flex-start; }
101
+ .header h1 { font-size: 16px; }
102
+ .stats { grid-template-columns: repeat(2, 1fr); gap: 8px; }
103
+ .stat-card { padding: 10px; }
104
+ .stat-card .label { font-size: 11px; }
105
+ .stat-card .value { font-size: 18px; }
106
+ .charts { grid-template-columns: 1fr; }
107
+ .table-wrap { padding: 10px; }
108
+ .controls { flex-wrap: wrap; gap: 6px; }
109
+ .controls input { flex: 0 0 100%; width: 100%; }
110
+ .controls button { flex: 0 0 calc(33.33% - 4px); min-width: 0; text-align: center; white-space: nowrap; font-size: 11px; padding: 8px 4px; }
111
+ #batchResetBtn { flex: 0 0 100% !important; font-size: 12px !important; padding: 8px 12px !important; }
112
+ .controls select { flex: 0 0 100%; width: 100%; }
113
+ .table-scroll { max-height: none; overflow: visible; }
114
+ table, thead, tbody, th, td, tr { display: block; }
115
+ thead { display: none; }
116
+ tr { background: #22222e; border-radius: 8px; padding: 10px 12px; margin-bottom: 8px; border: 1px solid #2a2a3a; }
117
+ tr:hover { background: #2a2a3a; }
118
+ td { padding: 4px 0; border: none; text-align: left; position: relative; padding-left: 40%; font-size: 13px; }
119
+ td::before { content: attr(data-label); position: absolute; left: 0; width: 36%; text-align: right; color: #888; font-size: 12px; font-weight: 600; white-space: nowrap; }
120
+ td.user-id { font-size: 15px; font-weight: 700; color: #60a5fa; padding-left: 0; border-bottom: 1px solid #2a2a3a; margin-bottom: 4px; padding-bottom: 6px; }
121
+ td.user-id::before { display: none; }
122
+ td.user-id:hover { color: #fe2c55; }
123
+ .add-users { justify-content: center; }
124
+ .modal { width: 95vw; padding: 16px; }
125
+ .modal textarea { height: 140px; }
126
+ }
86
127
  </style>
87
128
  </head>
88
129
  <body>
89
- <div class="header">
90
- <h1>TikTok 采集监控</h1>
91
- <span class="meta" id="fileMeta"></span>
92
- <span class="status" id="lastUpdate"></span>
93
- </div>
130
+ <div class="header">
131
+ <h1>TikTok 采集监控</h1>
132
+ <div class="meta" id="fileMeta">加载中...</div>
133
+ <div style="display:flex;gap:8px;align-items:center">
134
+ <a href="/scripts/run-explore.sh" class="script-link" download>mac</a>
135
+ <a href="/scripts/run-explore.bat" class="script-link" download>windows</a>
136
+ <span class="status" id="lastUpdate">--</span>
137
+ </div>
138
+ </div>
94
139
  <div class="stats">
95
140
  <div class="stat-card"><div class="label">总任务</div><div class="value total" id="statTotal">0</div></div>
96
141
  <div class="stat-card"><div class="label">处理中</div><div class="value total" id="statProcessing">0</div></div>
@@ -113,9 +158,7 @@
113
158
  <div class="table-wrap">
114
159
  <h3>用户列表</h3>
115
160
  <div class="add-users">
116
- <input type="text" id="addUserInput" placeholder="输入用户名,多个用逗号或空格分隔(如 @user1,@user2)">
117
- <button onclick="addUsers()">插入队列</button>
118
- <span class="hint">插入到队列最前面优先处理</span>
161
+ <button onclick="openAddModal()">+ 插入队列</button>
119
162
  </div>
120
163
  <div id="toast" class="toast" style="display:none"></div>
121
164
  <div class="controls">
@@ -127,6 +170,10 @@
127
170
  <button data-filter="error" onclick="setFilter('error')">错误</button>
128
171
  <button data-filter="restricted" onclick="setFilter('restricted')">受限</button>
129
172
  <button data-filter="target" onclick="setFilter('target')" style="background:#7c3aed;color:#fff">目标用户</button>
173
+ <button id="batchResetBtn" onclick="batchResetErrors()" style="display:none;padding:6px 10px;border:1px solid #f87171;border-radius:6px;background:transparent;color:#f87171;font-size:12px;cursor:pointer;font-weight:600;transition:all 0.2s;white-space:nowrap;">&#x21bb; 批量重新处理 (<span id="batchResetCount">0</span>)</button>
174
+ <select id="locationFilter" onchange="onLocationChange()" style="padding:6px 10px;border:1px solid #333;border-radius:6px;background:#2a2a3a;color:#ccc;font-size:12px;cursor:pointer;outline:none;">
175
+ <option value="">全部国家</option>
176
+ </select>
130
177
  </div>
131
178
  <div class="table-scroll">
132
179
  <table>
@@ -142,6 +189,7 @@ const COLORS = ['#fe2c55','#60a5fa','#4ade80','#facc15','#f97316','#a855f7','#ec
142
189
  let currentFilter = 'all';
143
190
  let currentStats = null;
144
191
  let currentUsers = [];
192
+ let currentLocation = '';
145
193
  let prevStatValues = {};
146
194
  let prevUserMap = {};
147
195
 
@@ -150,6 +198,7 @@ async function fetchStats() {
150
198
  const res = await fetch('/api/stats');
151
199
  currentStats = await res.json();
152
200
  renderStats();
201
+ renderLocationFilter();
153
202
  } catch (e) {
154
203
  document.getElementById('lastUpdate').textContent = '\u8fde\u63a5\u5931\u8d25';
155
204
  }
@@ -165,6 +214,7 @@ async function fetchUsers() {
165
214
  }
166
215
  const search = document.getElementById('searchInput').value.trim();
167
216
  if (search) params.set('search', search);
217
+ if (currentLocation) params.set('location', currentLocation);
168
218
  params.set('limit', '200');
169
219
  const res = await fetch('/api/users?' + params.toString());
170
220
  const data = await res.json();
@@ -255,20 +305,31 @@ function renderTable(users) {
255
305
  if (u.noVideo) extraTags.push('<span class="tag no-video">\u65e0\u89c6\u9891</span>');
256
306
  if (u.keepFollow) extraTags.push('<span class="tag keep-follow">\u5173\u6ce8\u5df2\u4fdd\u7559</span>');
257
307
  if (u.hasFollowData === false) extraTags.push('<span class="tag no-follow">\u5173\u6ce8\u672a\u83b7\u53d6</span>');
308
+ const nick = (u.nickname || '').replace(/</g, '&lt;').replace(/>/g, '&gt;');
309
+ const fans = u.followerCount != null ? formatNum(u.followerCount) : '-';
310
+ const videos = u.videoCount != null ? u.videoCount : '-';
311
+ const loc = u.locationCreated || '-';
312
+ const claimer = u.claimedBy || '-';
313
+ const claimTime = u.claimedAt ? formatTime(u.claimedAt) : '-';
314
+ const procTime = u.processedAt ? formatTime(u.processedAt) : '-';
258
315
  return `<tr${rowClass}>
259
- <td class="user-id">@${u.uniqueId}</td>
260
- <td>${(u.nickname || '').replace(/</g, '&lt;').replace(/>/g, '&gt;')}</td>
261
- <td>${u.followerCount != null ? formatNum(u.followerCount) : '-'}</td>
262
- <td>${u.videoCount != null ? u.videoCount : '-'}</td>
263
- <td>${u.locationCreated || '-'}</td>
264
- <td>${sources || '-'}</td>
265
- <td>${statusTag} ${extraTags.join(' ')}</td>
266
- <td style="font-size:11px;color:#888">${u.claimedBy || '-'}</td>
267
- <td style="font-size:11px;color:#888">${u.claimedAt ? formatTime(u.claimedAt) : '-'}</td>
268
- <td style="font-size:11px;color:#888">${u.processedAt ? formatTime(u.processedAt) : '-'}</td>
316
+ <td class="user-id" data-label="用户名">@${u.uniqueId}</td>
317
+ <td data-label="昵称">${nick}</td>
318
+ <td data-label="粉丝">${fans}</td>
319
+ <td data-label="视频">${videos}</td>
320
+ <td data-label="国家">${loc}</td>
321
+ <td data-label="来源">${sources || '-'}</td>
322
+ <td data-label="状态">${statusTag} ${extraTags.join(' ')}</td>
323
+ <td data-label="接收人" style="font-size:11px;color:#888">${claimer}</td>
324
+ <td data-label="领取时间" style="font-size:11px;color:#888">${claimTime}</td>
325
+ <td data-label="提交时间" style="font-size:11px;color:#888">${procTime}</td>
269
326
  </tr>`;
270
327
  }).join('');
271
328
 
329
+ const errorCount = users.filter(u => u.status === 'error').length;
330
+ const countEl = document.getElementById('batchResetCount');
331
+ if (countEl) countEl.textContent = errorCount;
332
+
272
333
  prevUserMap = newUserMap;
273
334
  }
274
335
 
@@ -289,6 +350,24 @@ function setFilter(f) {
289
350
  document.querySelectorAll('.controls button').forEach(b => {
290
351
  b.classList.toggle('active', b.dataset.filter === f);
291
352
  });
353
+ const btn = document.getElementById('batchResetBtn');
354
+ btn.style.display = f === 'error' ? '' : 'none';
355
+ fetchUsers();
356
+ }
357
+
358
+ function renderLocationFilter() {
359
+ if (!currentStats || !currentStats.countryStats) return;
360
+ const sel = document.getElementById('locationFilter');
361
+ if (!sel) return;
362
+ const val = sel.value;
363
+ const entries = currentStats.countryStats.sort((a, b) => b.count - a.count);
364
+ sel.innerHTML = '<option value="">全部国家</option>' +
365
+ entries.map(c => `<option value="${c.country}"${val === c.country ? ' selected' : ''}>${c.country} (${c.count})</option>`).join('');
366
+ }
367
+
368
+ function onLocationChange() {
369
+ const sel = document.getElementById('locationFilter');
370
+ currentLocation = sel.value;
292
371
  fetchUsers();
293
372
  }
294
373
 
@@ -296,12 +375,56 @@ document.getElementById('searchInput').addEventListener('input', () => {
296
375
  fetchUsers();
297
376
  });
298
377
 
299
- async function addUsers() {
300
- const input = document.getElementById('addUserInput');
301
- const raw = input.value.trim();
378
+ function parseUsernames(raw) {
379
+ return raw.split(/[,,\n\r]+/).map(s => s.replace(/^@/, '').trim()).filter(Boolean);
380
+ }
381
+
382
+ function openAddModal() {
383
+ let overlay = document.getElementById('addModalOverlay');
384
+ if (overlay) return;
385
+ overlay = document.createElement('div');
386
+ overlay.id = 'addModalOverlay';
387
+ overlay.className = 'modal-overlay';
388
+ overlay.innerHTML = `
389
+ <div class="modal">
390
+ <h3>插入用户到队列</h3>
391
+ <div class="hint">每行一个用户名,或用逗号分隔。支持 @username 或 username 格式。插入到队列最前面优先处理。</div>
392
+ <textarea id="modalUserInput" placeholder="例如:&#10;user1&#10;user2&#10;user3&#10;&#10;或:user1, user2, user3"></textarea>
393
+ <div class="preview" id="modalPreview"></div>
394
+ <div class="btn-row">
395
+ <button class="btn-cancel" onclick="closeAddModal()">取消</button>
396
+ <button class="btn-submit" onclick="submitAddUsers()">确认插入</button>
397
+ </div>
398
+ </div>
399
+ `;
400
+ document.body.appendChild(overlay);
401
+ overlay.addEventListener('click', e => { if (e.target === overlay) closeAddModal(); });
402
+ const ta = document.getElementById('modalUserInput');
403
+ ta.focus();
404
+ ta.addEventListener('input', () => {
405
+ const names = parseUsernames(ta.value);
406
+ const preview = document.getElementById('modalPreview');
407
+ preview.textContent = names.length ? `共 ${names.length} 个用户名` : '';
408
+ });
409
+ ta.addEventListener('keydown', e => {
410
+ if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
411
+ e.preventDefault();
412
+ submitAddUsers();
413
+ }
414
+ });
415
+ }
416
+
417
+ function closeAddModal() {
418
+ const overlay = document.getElementById('addModalOverlay');
419
+ if (overlay) overlay.remove();
420
+ }
421
+
422
+ async function submitAddUsers() {
423
+ const ta = document.getElementById('modalUserInput');
424
+ const raw = ta.value.trim();
302
425
  if (!raw) return;
303
426
 
304
- const names = raw.split(/[,,\s]+/).map(s => s.replace(/^@/, '').trim()).filter(Boolean);
427
+ const names = parseUsernames(raw);
305
428
  if (names.length === 0) return;
306
429
 
307
430
  try {
@@ -312,8 +435,8 @@ async function addUsers() {
312
435
  });
313
436
  const data = await res.json();
314
437
  if (data.error) { showToast(data.error, true); return; }
438
+ closeAddModal();
315
439
  showToast(data.message || `\u5df2\u63d2\u5165 ${data.added} \u4e2a\u7528\u6237`);
316
- input.value = '';
317
440
  fetchStats();
318
441
  fetchUsers();
319
442
  } catch (e) {
@@ -329,8 +452,8 @@ function showToast(msg, isError) {
329
452
  setTimeout(() => { toast.style.display = 'none'; }, 3000);
330
453
  }
331
454
 
332
- document.getElementById('addUserInput').addEventListener('keydown', e => {
333
- if (e.key === 'Enter') addUsers();
455
+ document.addEventListener('keydown', e => {
456
+ if (e.key === 'Escape') closeAddModal();
334
457
  });
335
458
 
336
459
  document.getElementById('userTable').addEventListener('click', e => {
@@ -433,6 +556,32 @@ async function resetJob(uniqueId) {
433
556
  }
434
557
  }
435
558
 
559
+ async function batchResetErrors() {
560
+ const errorUsers = currentUsers.filter(u => u.status === 'error');
561
+ if (errorUsers.length === 0) {
562
+ showToast('\u6ca1\u6709\u9700\u8981\u91cd\u7f6e\u7684\u9519\u8bef\u7528\u6237', true);
563
+ return;
564
+ }
565
+ const userIds = errorUsers.map(u => u.uniqueId);
566
+ try {
567
+ const res = await fetch('/api/jobs/batch-reset', {
568
+ method: 'POST',
569
+ headers: { 'Content-Type': 'application/json' },
570
+ body: JSON.stringify({ userIds })
571
+ });
572
+ const data = await res.json();
573
+ if (data.error) {
574
+ showToast(data.error, true);
575
+ return;
576
+ }
577
+ showToast(`\u5df2\u91cd\u7f6e ${data.reset} / ${data.total} \u4e2a\u7528\u6237`);
578
+ fetchUsers();
579
+ fetchStats();
580
+ } catch (e) {
581
+ showToast('\u6279\u91cf\u91cd\u7f6e\u5931\u8d25: ' + e.message, true);
582
+ }
583
+ }
584
+
436
585
  document.getElementById('statTargetCard').addEventListener('click', async () => {
437
586
  try {
438
587
  const res = await fetch('/api/target-users');
@@ -460,8 +609,8 @@ document.getElementById('statTargetCard').addEventListener('click', async () =>
460
609
 
461
610
  fetchStats();
462
611
  fetchUsers();
463
- setInterval(fetchStats, 1000);
464
- setInterval(fetchUsers, 2000);
612
+ setInterval(fetchStats, 10000);
613
+ setInterval(fetchUsers, 10000);
465
614
  </script>
466
615
  </body>
467
616
  </html>
@@ -1,7 +1,7 @@
1
1
  import http from 'http';
2
2
  import os from 'os';
3
3
 
4
- import { readFileSync } from 'fs';
4
+ import { readFileSync, existsSync } from 'fs';
5
5
  import { join, dirname } from 'path';
6
6
  import { fileURLToPath } from 'url';
7
7
  import { spawn } from 'child_process';
@@ -187,6 +187,22 @@ export function startWatchServer(outputFile, port = 3000, existingStore) {
187
187
  return;
188
188
  }
189
189
 
190
+ if (req.method === 'POST' && routePath === '/api/jobs/batch-reset') {
191
+ const body = await readBody(req);
192
+ const ids = Array.isArray(body.userIds) ? body.userIds : [];
193
+ if (ids.length === 0) {
194
+ sendJSON(res, 400, { error: 'userIds 不能为空' });
195
+ return;
196
+ }
197
+ let count = 0;
198
+ for (const uid of ids) {
199
+ const ret = store.resetJob(uid);
200
+ if (ret.saved) count++;
201
+ }
202
+ sendJSON(res, 200, { reset: count, total: ids.length });
203
+ return;
204
+ }
205
+
190
206
  const jobPinMatch = routePath.match(/^\/api\/job\/([^/]+)\/pin$/);
191
207
  if (req.method === 'POST' && jobPinMatch) {
192
208
  const uniqueId = jobPinMatch[1];
@@ -233,6 +249,9 @@ export function startWatchServer(outputFile, port = 3000, existingStore) {
233
249
  (u.nickname || '').toLowerCase().includes(s)
234
250
  );
235
251
  }
252
+ if (params.location) {
253
+ filtered = filtered.filter(u => u.locationCreated === params.location);
254
+ }
236
255
 
237
256
  const statusOrder = { processing: 0, pending: 1, done: 2, error: 3, restricted: 4 };
238
257
  filtered.sort((a, b) => {
@@ -261,6 +280,20 @@ export function startWatchServer(outputFile, port = 3000, existingStore) {
261
280
  return;
262
281
  }
263
282
 
283
+ const scriptMatch = routePath.match(/^\/scripts\/(.+)$/);
284
+ if (req.method === 'GET' && scriptMatch) {
285
+ const batDir = join(__dirname, '../../bat');
286
+ const scriptFile = join(batDir, scriptMatch[1]);
287
+ if (existsSync(scriptFile)) {
288
+ const content = readFileSync(scriptFile);
289
+ const ext = scriptMatch[1].split('.').pop();
290
+ const mime = ext === 'sh' ? 'text/x-sh' : ext === 'bat' ? 'text/bat' : 'text/plain';
291
+ res.writeHead(200, { 'Content-Type': `${mime}; charset=utf-8` });
292
+ res.end(content);
293
+ return;
294
+ }
295
+ }
296
+
264
297
  res.writeHead(404);
265
298
  res.end('Not Found');
266
299
  });