tt-help-cli-ycl 1.4.17 → 1.4.19

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.
@@ -9,6 +9,7 @@
9
9
  </head>
10
10
 
11
11
  <body>
12
+ <canvas id="fireworkCanvas"></canvas>
12
13
  <div class="header">
13
14
  <h1>TikTok 采集监控</h1>
14
15
  <div class="meta" id="fileMeta">加载中...</div>
@@ -325,6 +326,7 @@
325
326
  <div class="label">🔄 刷新</div>
326
327
  </div>
327
328
  </div>
329
+ <div id="targetRefreshStats" style="margin-bottom:16px"></div>
328
330
  <div class="target-page-layout">
329
331
  <div class="target-side-card">
330
332
  <h3>🎯 目标商家国家分布</h3>
@@ -370,7 +372,7 @@
370
372
  </div>
371
373
  </div>
372
374
  </div>
373
- <div id="backToTop" title="回到顶部" onclick="scrollToTop()">↑</div>
375
+ <div id="backToTop" title="回到顶部(可拖动)">↑</div>
374
376
  </div>
375
377
  <div id="tagsPage">
376
378
  <div class="stats" style="margin-bottom:16px">
@@ -397,6 +399,7 @@
397
399
  <div class="label">🔄 刷新</div>
398
400
  </div>
399
401
  </div>
402
+ <div id="tagsRefreshStats" style="margin-bottom:16px"></div>
400
403
  <div class="tags-layout">
401
404
  <div class="tags-table-card">
402
405
  <h3>关键词列表</h3>
@@ -306,6 +306,10 @@ body {
306
306
  font-weight: 600;
307
307
  }
308
308
 
309
+ .target-badge-other {
310
+ color: #666 !important;
311
+ }
312
+
309
313
  .source-row {
310
314
  display: flex;
311
315
  align-items: center;
@@ -869,6 +873,12 @@ td.user-id:hover {
869
873
  display: flex;
870
874
  }
871
875
 
876
+ #backToTop.dragging {
877
+ transition: none;
878
+ opacity: 0.9;
879
+ cursor: grabbing;
880
+ }
881
+
872
882
  #mainPage.hidden {
873
883
  display: none;
874
884
  }
@@ -1483,6 +1493,26 @@ td.user-id:hover {
1483
1493
  color: #ef4444;
1484
1494
  }
1485
1495
 
1496
+ /* 待确认按钮 */
1497
+ .btn-pending {
1498
+ padding: 4px 10px;
1499
+ border: 1px solid #fbbf24;
1500
+ border-radius: 4px;
1501
+ background: transparent;
1502
+ color: #fbbf24;
1503
+ font-size: 11px;
1504
+ font-weight: 600;
1505
+ cursor: pointer;
1506
+ transition: all 0.15s;
1507
+ white-space: nowrap;
1508
+ }
1509
+
1510
+ .btn-pending:hover {
1511
+ background: rgba(251, 191, 36, 0.12);
1512
+ border-color: #f59e0b;
1513
+ color: #f59e0b;
1514
+ }
1515
+
1486
1516
  /* ===== 关键词页面 ===== */
1487
1517
  #tagsPage {
1488
1518
  display: none;
@@ -1582,3 +1612,14 @@ td.user-id:hover {
1582
1612
  .checkbox-label input[type="checkbox"] {
1583
1613
  accent-color: #a78bfa;
1584
1614
  }
1615
+
1616
+ /* ========== 烟花画布 ========== */
1617
+ #fireworkCanvas {
1618
+ position: fixed;
1619
+ top: 0;
1620
+ left: 0;
1621
+ width: 100%;
1622
+ height: 100%;
1623
+ pointer-events: none;
1624
+ z-index: 9999;
1625
+ }
@@ -103,12 +103,115 @@ function inferClientType(routePath) {
103
103
  return null;
104
104
  }
105
105
 
106
+ // ── 简易登录鉴权 ──
107
+ let WATCH_PASSWORD = "";
108
+ let PASSWORD_ENABLED = false;
109
+ const AUTH_COOKIE = "watch_token";
110
+ const AUTH_MAX_AGE = 86400; // 24 小时
111
+ const authSessions = new Map(); // token -> expiry (ms timestamp)
112
+
113
+ function todayPassword() {
114
+ const d = new Date();
115
+ const pad = (n) => String(n).padStart(2, "0");
116
+ return `tt@${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`;
117
+ }
118
+
119
+ function generateToken() {
120
+ return Array.from({ length: 32 }, () =>
121
+ Math.floor(Math.random() * 36).toString(36),
122
+ ).join("");
123
+ }
124
+
125
+ function parseAuthToken(req, params) {
126
+ // Cookie
127
+ const cookie = req.headers.cookie || "";
128
+ const cookieMatch = cookie.match(new RegExp(`${AUTH_COOKIE}=([^;]+)`));
129
+ if (cookieMatch) return cookieMatch[1];
130
+ // Header
131
+ const headerToken = req.headers["x-watch-token"];
132
+ if (headerToken) return headerToken;
133
+ // Query param
134
+ if (params.token) return params.token;
135
+ return null;
136
+ }
137
+
138
+ function serveLoginPage(res, errorMsg) {
139
+ const err = errorMsg
140
+ ? `<p style="color:#fe2c55;margin-top:12px">${errorMsg}</p>`
141
+ : "";
142
+ const html = `<!DOCTYPE html>
143
+ <html lang="zh-CN">
144
+ <head>
145
+ <meta charset="UTF-8">
146
+ <meta name="viewport" content="width=device-width,initial-scale=1.0">
147
+ <title>登录 - TikTok 采集监控</title>
148
+ <style>
149
+ *{margin:0;padding:0;box-sizing:border-box}
150
+ body{background:#0d0d0d;color:#e0e0e0;font-family:-apple-system,BlinkMacSystemFont,sans-serif;display:flex;align-items:center;justify-content:center;height:100vh}
151
+ .box{background:#1e1e1e;border:1px solid #333;border-radius:12px;padding:40px;text-align:center;min-width:320px}
152
+ h1{font-size:20px;margin-bottom:4px}
153
+ p.sub{color:#888;font-size:13px;margin-bottom:20px}
154
+ input[type="password"]{width:100%;padding:12px;border:1px solid #444;border-radius:6px;background:#2a2a2a;color:#e0e0e0;font-size:14px;margin-bottom:12px;outline:none}
155
+ input[type="password"]:focus{border-color:#fe2c55}
156
+ .remember-row{display:flex;align-items:center;justify-content:center;gap:8px;margin-bottom:14px;font-size:13px;color:#aaa;cursor:pointer}
157
+ .remember-row input[type="checkbox"]{width:auto;margin:0;accent-color:#fe2c55;cursor:pointer}
158
+ .remember-row label{cursor:pointer;user-select:none}
159
+ button{width:100%;padding:12px;background:#fe2c55;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:15px;font-weight:600}
160
+ button:hover{opacity:.9}
161
+ </style>
162
+ </head>
163
+ <body>
164
+ <div class="box">
165
+ <h1>TikTok 采集监控</h1>
166
+ <p class="sub">请输入监控密码</p>
167
+ <form method="post" action="/api/login" id="login-form">
168
+ <input name="password" id="password" type="password" placeholder="密码" autocomplete="off" autofocus>
169
+ <div class="remember-row">
170
+ <input type="checkbox" id="remember-pwd">
171
+ <label for="remember-pwd">记住密码</label>
172
+ </div>
173
+ <button type="submit">登录</button>
174
+ </form>
175
+ ${err}
176
+ </div>
177
+ <script>
178
+ (function() {
179
+ var pw = document.getElementById('password');
180
+ var cb = document.getElementById('remember-pwd');
181
+ var form = document.getElementById('login-form');
182
+ // 页面加载时恢复已保存的密码
183
+ try {
184
+ var saved = localStorage.getItem('watch_pwd');
185
+ if (saved) { pw.value = saved; cb.checked = true; }
186
+ } catch(e) {}
187
+ // 提交时保存或清除密码
188
+ form.addEventListener('submit', function() {
189
+ try {
190
+ if (cb.checked) {
191
+ localStorage.setItem('watch_pwd', pw.value);
192
+ } else {
193
+ localStorage.removeItem('watch_pwd');
194
+ }
195
+ } catch(e) {}
196
+ });
197
+ })();
198
+ </script>
199
+ </body>
200
+ </html>`;
201
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
202
+ res.end(html);
203
+ }
204
+
106
205
  export function startWatchServer(
107
206
  dataAnchor,
108
207
  port = 3000,
109
208
  existingStore,
110
209
  options = {},
210
+ password = null,
111
211
  ) {
212
+ PASSWORD_ENABLED = true;
213
+ if (password) WATCH_PASSWORD = password;
214
+ // 不传 -P 时 WATCH_PASSWORD 保持空,登录时动态计算当日密码
112
215
  return new Promise((_resolve, reject) => {
113
216
  const store = existingStore || createStore(dataAnchor, options);
114
217
 
@@ -124,6 +227,12 @@ export function startWatchServer(
124
227
  }
125
228
 
126
229
  // ── 标签发现机制(定时 + 按需双保险) ──
230
+ // --no-llm-discover 或 TAG_DISCOVER_DISABLE=1 可关闭 LLM tag 发现
231
+ const DISCOVER_DISABLED =
232
+ options.llmDiscover === false ||
233
+ ["1", "true", "yes"].includes(
234
+ (process.env.TAG_DISCOVER_DISABLE || "").toLowerCase(),
235
+ );
127
236
  const DISCOVER_INTERVAL_MS = Math.max(
128
237
  180000,
129
238
  parseInt(process.env.TAG_DISCOVER_INTERVAL_MS || "300000", 10) || 300000,
@@ -151,6 +260,10 @@ export function startWatchServer(
151
260
  );
152
261
 
153
262
  async function runAutoDiscover(trigger = "timer") {
263
+ if (DISCOVER_DISABLED) {
264
+ console.error("[discover] LLM tag 发现已禁用 (TAG_DISCOVER_DISABLE=1)");
265
+ return;
266
+ }
154
267
  if (discoverRunning) return;
155
268
  const elapsed = Date.now() - lastDiscoverTime;
156
269
  if (elapsed < DISCOVER_COOLDOWN_MS) {
@@ -369,6 +482,30 @@ export function startWatchServer(
369
482
  return;
370
483
  }
371
484
 
485
+ // 将 jobs 中 status='done' 且无国家数据的记录移动到 jobs_base
486
+ if (
487
+ req.method === "POST" &&
488
+ routePath === "/api/jobs/move-no-country-to-base"
489
+ ) {
490
+ try {
491
+ const result = store.moveNoCountryJobsToBase();
492
+ const ts = new Date().toISOString().slice(11, 19);
493
+ if (result.ok) {
494
+ console.error(
495
+ `[JOB ${ts}] MOVE-NO-COUNTRY-TO-BASE: moved=${result.moved}, total=${result.total}`,
496
+ );
497
+ } else {
498
+ console.error(
499
+ `[JOB ${ts}] MOVE-NO-COUNTRY-TO-BASE ERROR: ${result.error}`,
500
+ );
501
+ }
502
+ sendJSON(res, result.ok ? 200 : 400, result);
503
+ } catch (e) {
504
+ sendJSON(res, 500, { error: e.message });
505
+ }
506
+ return;
507
+ }
508
+
372
509
  // 将 jobs/raw_jobs 中 tt_seller=1 且 video_count=0 的记录移动到 jobs_base
373
510
  if (
374
511
  req.method === "POST" &&
@@ -691,6 +828,27 @@ export function startWatchServer(
691
828
  return;
692
829
  }
693
830
 
831
+ // 移到 jobs_base 重走流程(待确认)
832
+ const moveToBaseMatch = routePath.match(
833
+ /^\/api\/user-move-to-base\/([^/]+)$/,
834
+ );
835
+ if (req.method === "PUT" && moveToBaseMatch) {
836
+ const uniqueId = moveToBaseMatch[1];
837
+ try {
838
+ const ret = store.moveSingleJobToBase(uniqueId);
839
+ if (ret.error) {
840
+ sendJSON(res, 404, { error: ret.error });
841
+ return;
842
+ }
843
+ const ts = new Date().toISOString().slice(11, 19);
844
+ console.error(`[JOB ${ts}] MOVE-TO-BASE: ${uniqueId} → jobs_base`);
845
+ sendJSON(res, 200, ret);
846
+ } catch (e) {
847
+ sendJSON(res, 400, { error: e.message });
848
+ }
849
+ return;
850
+ }
851
+
694
852
  if (req.method === "GET" && routePath === "/api/comment-tasks") {
695
853
  const limit = parseInt(params.limit) || 1;
696
854
  const locationsParam = params.locations || "";
@@ -913,6 +1071,14 @@ export function startWatchServer(
913
1071
  return;
914
1072
  }
915
1073
 
1074
+ // GET /api/targets/refresh-stats — 目标商家最近 N 小时刷新进度
1075
+ if (req.method === "GET" && routePath === "/api/targets/refresh-stats") {
1076
+ const hours = Math.max(1, parseInt(params.hours) || 24);
1077
+ const data = store.getTargetRefreshStats(hours);
1078
+ sendJSON(res, 200, data);
1079
+ return;
1080
+ }
1081
+
916
1082
  if (req.method === "GET" && routePath === "/api/client-errors") {
917
1083
  sendJSON(res, 200, { clients: store.getClientErrors() });
918
1084
  return;
@@ -1286,6 +1452,14 @@ export function startWatchServer(
1286
1452
  return;
1287
1453
  }
1288
1454
 
1455
+ // GET /api/tags/refresh-stats — 最近 24h 每国刷新进度
1456
+ if (req.method === "GET" && routePath === "/api/tags/refresh-stats") {
1457
+ const hours = Math.max(1, parseInt(params.hours) || 24);
1458
+ const data = store.getRefreshStats(hours);
1459
+ sendJSON(res, 200, data);
1460
+ return;
1461
+ }
1462
+
1289
1463
  // GET /api/tags?status=&country=&limit=&offset=
1290
1464
  if (req.method === "GET" && routePath === "/api/tags") {
1291
1465
  const status = params.status || null;
@@ -1907,12 +2081,77 @@ export function startWatchServer(
1907
2081
  return;
1908
2082
  }
1909
2083
 
1910
- // ====== 原有静态资源 ======
2084
+ // POST /api/login — HTML 表单提交(密码正确则设 Cookie 跳转,错误则显示错误页)
2085
+ // 同时也支持 JSON 请求
2086
+ if (req.method === "POST" && routePath === "/api/login") {
2087
+ try {
2088
+ const rawBody = await new Promise((resolve) => {
2089
+ let body = "";
2090
+ req.on("data", (chunk) => (body += chunk));
2091
+ req.on("end", () => resolve(body));
2092
+ });
2093
+ const isForm = req.headers["content-type"]?.includes(
2094
+ "x-www-form-urlencoded",
2095
+ );
2096
+ const pwd = isForm
2097
+ ? new URLSearchParams(rawBody).get("password") || ""
2098
+ : (() => {
2099
+ try {
2100
+ return JSON.parse(rawBody).password || "";
2101
+ } catch {
2102
+ return "";
2103
+ }
2104
+ })();
2105
+
2106
+ const expectedPassword = WATCH_PASSWORD || todayPassword();
2107
+ if (pwd === expectedPassword) {
2108
+ const token = generateToken();
2109
+ authSessions.set(token, Date.now() + AUTH_MAX_AGE * 1000);
2110
+ if (isForm) {
2111
+ res.writeHead(302, {
2112
+ Location: "/",
2113
+ "Set-Cookie": `${AUTH_COOKIE}=${token}; Path=/; HttpOnly; Max-Age=${AUTH_MAX_AGE}; SameSite=Lax`,
2114
+ });
2115
+ res.end();
2116
+ } else {
2117
+ res.writeHead(200, {
2118
+ "Content-Type": "application/json; charset=utf-8",
2119
+ "Set-Cookie": `${AUTH_COOKIE}=${token}; Path=/; HttpOnly; Max-Age=${AUTH_MAX_AGE}; SameSite=Lax`,
2120
+ });
2121
+ res.end(JSON.stringify({ ok: true }));
2122
+ }
2123
+ } else {
2124
+ if (isForm) {
2125
+ serveLoginPage(res, "密码错误");
2126
+ } else {
2127
+ sendJSON(res, 401, { error: "密码错误" });
2128
+ }
2129
+ }
2130
+ } catch (e) {
2131
+ sendJSON(res, 400, { error: e.message });
2132
+ }
2133
+ return;
2134
+ }
2135
+
2136
+ // ====== 静态资源(HTML 页面需登录) ======
1911
2137
 
1912
2138
  if (
1913
2139
  req.method === "GET" &&
1914
2140
  (routePath === "/" || routePath === "/index.html")
1915
2141
  ) {
2142
+ // 设有密码时,检查登录态
2143
+ if (PASSWORD_ENABLED) {
2144
+ const token = parseAuthToken(req, params);
2145
+ const valid =
2146
+ token &&
2147
+ authSessions.has(token) &&
2148
+ authSessions.get(token) > Date.now();
2149
+ if (!valid) {
2150
+ serveLoginPage(res);
2151
+ return;
2152
+ }
2153
+ authSessions.set(token, Date.now() + AUTH_MAX_AGE * 1000);
2154
+ }
1916
2155
  const html = readFileSync(join(publicDir, "index.html"), "utf-8");
1917
2156
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
1918
2157
  res.end(html);
@@ -1997,13 +2236,40 @@ export function startWatchServer(
1997
2236
  }
1998
2237
 
1999
2238
  // 启动后 30 秒首次发现(给服务启动缓冲),之后定时执行
2000
- const discoverTimer = setInterval(
2001
- () => runAutoDiscover("timer"),
2002
- DISCOVER_INTERVAL_MS,
2003
- );
2004
- setTimeout(() => runAutoDiscover("startup"), 30000);
2239
+ if (DISCOVER_DISABLED) {
2240
+ console.error(
2241
+ `[discover] LLM tag 发现已禁用 (TAG_DISCOVER_DISABLE=1),不会启动定时发现`,
2242
+ );
2243
+ } else {
2244
+ const discoverTimer = setInterval(
2245
+ () => runAutoDiscover("timer"),
2246
+ DISCOVER_INTERVAL_MS,
2247
+ );
2248
+ setTimeout(() => runAutoDiscover("startup"), 30000);
2249
+ console.error(
2250
+ `[discover] 定时+按需发现已启动: 定时间隔 ${DISCOVER_INTERVAL_MS / 60000} 分钟, 每国 ${DISCOVER_COUNT_PER_COUNTRY} 个标签, 每国不足阈值 ${DISCOVER_PER_COUNTRY_THRESHOLD}, 按需触发阈值 ${DISCOVER_LOW_THRESHOLD}, 冷却期 ${DISCOVER_COOLDOWN_MS / 1000}s`,
2251
+ );
2252
+ }
2253
+
2254
+ // 每 2 小时自动将无国家数据的 done job 移入 jobs_base 重走流程
2255
+ const NO_COUNTRY_MOVE_INTERVAL_MS = 2 * 60 * 60 * 1000;
2256
+ setInterval(() => {
2257
+ try {
2258
+ const result = store.moveNoCountryJobsToBase();
2259
+ const ts = new Date().toISOString().slice(11, 19);
2260
+ if (result.moved > 0) {
2261
+ console.error(
2262
+ `[JOB ${ts}] AUTO-MOVE-NO-COUNTRY: moved=${result.moved}, total=${result.total}`,
2263
+ );
2264
+ }
2265
+ } catch (e) {
2266
+ console.error(
2267
+ `[JOB] AUTO-MOVE-NO-COUNTRY 定时任务异常: ${e.message}`,
2268
+ );
2269
+ }
2270
+ }, NO_COUNTRY_MOVE_INTERVAL_MS);
2005
2271
  console.error(
2006
- `[discover] 定时+按需发现已启动: 定时间隔 ${DISCOVER_INTERVAL_MS / 60000} 分钟, 每国 ${DISCOVER_COUNT_PER_COUNTRY} 个标签, 每国不足阈值 ${DISCOVER_PER_COUNTRY_THRESHOLD}, 按需触发阈值 ${DISCOVER_LOW_THRESHOLD}, 冷却期 ${DISCOVER_COOLDOWN_MS / 1000}s`,
2272
+ `[job] 无国家数据自动回炉已启动: 间隔 ${NO_COUNTRY_MOVE_INTERVAL_MS / 3600000} 小时`,
2007
2273
  );
2008
2274
 
2009
2275
  _resolve({ server, port });