tt-help-cli-ycl 1.4.107 → 1.4.110

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.4.107",
3
+ "version": "1.4.110",
4
4
  "description": "TikTok user & video data scraper - extract ttSeller, verified, locationCreated from HTML source",
5
5
  "type": "module",
6
6
  "bin": {
@@ -255,33 +255,149 @@ async function terminateProcess(pid, waitMs = 5000) {
255
255
  return false;
256
256
  }
257
257
 
258
- /** 枚举指定父进程的直接子进程 PID(Windows: PowerShell;POSIX: pgrep) */
259
- async function listChildPids(parentPid) {
260
- if (!parentPid) return [];
258
+ /**
259
+ * Windows:向 targetPid 所在控制台发送 CTRL_C_EVENT(等效用户在 cmd 窗口按 Ctrl+C)。
260
+ * 旧 watchdog 收到后触发 SIGINT → gracefulShutdown → 优雅停业务进程后退出。
261
+ * 实现:PowerShell P/Invoke AttachConsole(attach 到目标控制台)+
262
+ * GenerateConsoleCtrlEvent(0, 0)(发给共享该控制台的所有进程)。
263
+ * 非 Windows / attach 失败(目标无控制台)返回 false,由调用方强杀兜底。
264
+ */
265
+ async function sendConsoleCtrlC(targetPid) {
266
+ if (process.platform !== "win32" || !targetPid) return false;
267
+ const ps =
268
+ "Add-Type -TypeDefinition 'using System;using System.Runtime.InteropServices;" +
269
+ 'public class CC{[DllImport("kernel32.dll",SetLastError=true)]public static extern bool FreeConsole();' +
270
+ '[DllImport("kernel32.dll",SetLastError=true)]public static extern bool AttachConsole(uint p);' +
271
+ '[DllImport("kernel32.dll",SetLastError=true)]public static extern bool GenerateConsoleCtrlEvent(uint e,uint g);}\';' +
272
+ `[void][CC]::FreeConsole();$ok=[CC]::AttachConsole(${Number(targetPid)});Write-Output $ok;if($ok){[void][CC]::GenerateConsoleCtrlEvent(0,0)};exit 0`;
273
+ // 注意:信号发出后 PowerShell 自己也会收到 Ctrl+C 而退出(execFile 报错),
274
+ // 因此手动包装只取 stdout(True=attach 成功=信号已发出),忽略 exit code。
275
+ const { stdout } = await new Promise((resolve) => {
276
+ execFile(
277
+ "powershell.exe",
278
+ ["-NoProfile", "-Command", ps],
279
+ { timeout: 15000, windowsHide: true },
280
+ (err, out) => resolve({ stdout: String(out || "") }),
281
+ );
282
+ });
283
+ return /True/i.test(stdout);
284
+ }
285
+
286
+ /** 轮询探测进程是否退出(不发送任何信号) */
287
+ async function waitProcessExit(pid, waitMs) {
288
+ if (!pid) return true;
289
+ const deadline = Date.now() + waitMs;
290
+ while (Date.now() < deadline) {
291
+ try {
292
+ process.kill(pid, 0);
293
+ } catch {
294
+ return true;
295
+ }
296
+ await new Promise((r) => setTimeout(r, 500));
297
+ }
298
+ return false;
299
+ }
300
+
301
+ /** 向上遍历父链,返回 cmd.exe 祖先进程 PID(旧 watchdog 所在窗口,升级后关窗用) */
302
+ function listCmdAncestorPids(rows, pid) {
303
+ const byId = new Map(rows.map((r) => [r.pid, r]));
304
+ const out = [];
305
+ const seen = new Set();
306
+ let cur = byId.get(pid)?.ppid;
307
+ while (cur && !seen.has(cur)) {
308
+ seen.add(cur);
309
+ const r = byId.get(cur);
310
+ if (!r) break;
311
+ if (/cmd(\.exe)?\b/i.test(r.cmd || "")) out.push(cur);
312
+ cur = r.ppid;
313
+ }
314
+ return out;
315
+ }
316
+
317
+ /** 一次拉取全进程表 [{pid, ppid, cmd}](Windows: CIM;POSIX: ps) */
318
+ async function snapshotAllProcesses() {
319
+ const rows = [];
261
320
  try {
262
321
  if (process.platform === "win32") {
263
322
  const ps =
264
- `Get-CimInstance Win32_Process -Filter "ParentProcessId=${parentPid}" ` +
265
- `| Select-Object -ExpandProperty ProcessId`;
323
+ "Get-CimInstance Win32_Process | ForEach-Object { Write-Output (" +
324
+ "$_.ProcessId.ToString() + '|' + $_.ParentProcessId.ToString() + '|' + $_.CommandLine) }";
266
325
  const { stdout } = await execFileP(
267
326
  "powershell.exe",
268
327
  ["-NoProfile", "-Command", ps],
269
- { timeout: 15000, windowsHide: true },
328
+ { timeout: 20000, windowsHide: true },
270
329
  );
271
- return String(stdout)
272
- .split(/\r?\n/)
273
- .map((s) => parseInt(s.trim(), 10))
274
- .filter((n) => n > 0);
330
+ for (const line of String(stdout).split(/\r?\n/)) {
331
+ const m = line.match(/^(\d+)\|(\d+)\|(.*)$/);
332
+ if (m)
333
+ rows.push({
334
+ pid: parseInt(m[1], 10),
335
+ ppid: parseInt(m[2], 10),
336
+ cmd: m[3] || "",
337
+ });
338
+ }
339
+ } else {
340
+ const { stdout } = await execFileP("ps", ["-eo", "pid=,ppid=,args="], {
341
+ timeout: 20000,
342
+ });
343
+ for (const line of String(stdout).split(/\n/)) {
344
+ const m = line.match(/^\s*(\d+)\s+(\d+)\s+(.*)$/);
345
+ if (m)
346
+ rows.push({
347
+ pid: parseInt(m[1], 10),
348
+ ppid: parseInt(m[2], 10),
349
+ cmd: m[3] || "",
350
+ });
351
+ }
275
352
  }
276
- const { stdout } = await execFileP("pgrep", ["-P", String(parentPid)], {
277
- timeout: 15000,
278
- });
279
- return String(stdout)
280
- .split(/\r?\n/)
281
- .map((s) => parseInt(s.trim(), 10))
282
- .filter((n) => n > 0);
283
353
  } catch {
284
- return [];
354
+ /* 快照失败时返回空,清理环节自然跳过 */
355
+ }
356
+ return rows;
357
+ }
358
+
359
+ /**
360
+ * BFS 枚举 rootPid 的所有后代 PID(含孙进程),排除 excludePid。
361
+ * 必须基于【杀旧前】的全表快照:Windows 上业务命令经 cmd 中间层启动,
362
+ * 杀旧后中间层死亡导致父链断裂,再枚举会漏掉孙进程(211 实测:
363
+ * webserver 成孤儿占端口,新版拉起 EADDRINUSE)。
364
+ */
365
+ function listDescendantPids(rows, rootPid, excludePid = 0) {
366
+ if (!rootPid) return [];
367
+ const result = new Set();
368
+ const queue = [rootPid];
369
+ while (queue.length > 0) {
370
+ const cur = queue.shift();
371
+ for (const r of rows) {
372
+ if (r.ppid === cur && r.pid !== excludePid && !result.has(r.pid)) {
373
+ result.add(r.pid);
374
+ queue.push(r.pid);
375
+ }
376
+ }
377
+ }
378
+ return [...result];
379
+ }
380
+
381
+ /** 快照中「父进程已死」的孤儿业务进程(本包 node 业务,排除 watchdog/upgrader 自身) */
382
+ function listOrphanBusinessPids(rows) {
383
+ const alive = new Set(rows.map((r) => r.pid));
384
+ return rows
385
+ .filter(
386
+ (r) =>
387
+ r.cmd.includes("tt-help-cli-ycl") &&
388
+ !/watchdog|upgrader/.test(r.cmd) &&
389
+ !alive.has(r.ppid),
390
+ )
391
+ .map((r) => r.pid);
392
+ }
393
+
394
+ /** 探测进程是否存活(Windows kill(pid,0);EPERM 也视为存活) */
395
+ async function isProcessAlive(pid) {
396
+ try {
397
+ process.kill(pid, 0);
398
+ return true;
399
+ } catch (e) {
400
+ return !!(e && e.code === "EPERM");
285
401
  }
286
402
  }
287
403
 
@@ -289,11 +405,10 @@ async function listChildPids(parentPid) {
289
405
  async function forceKillTree(pid) {
290
406
  try {
291
407
  if (process.platform === "win32") {
292
- await execFileP(
293
- "taskkill.exe",
294
- ["/PID", String(pid), "/T", "/F"],
295
- { timeout: 15000, windowsHide: true },
296
- );
408
+ await execFileP("taskkill.exe", ["/PID", String(pid), "/T", "/F"], {
409
+ timeout: 15000,
410
+ windowsHide: true,
411
+ });
297
412
  } else {
298
413
  process.kill(pid, "SIGKILL");
299
414
  }
@@ -302,34 +417,24 @@ async function forceKillTree(pid) {
302
417
  }
303
418
  }
304
419
 
305
- /**
306
- * 清理旧 watchdog 残留的子进程(升级后不留孤儿占端口/资源)。
307
- * 流程:枚举直接子进程(排除 excludePid)→ 逐个强杀整棵子树 → 等 1s → 复查残留再杀。
308
- * 背景:Windows 上 SIGTERM=强杀,旧 watchdog 的业务子进程(webserver 等)会变孤儿;
309
- * macOS/Linux 上优雅退出未完成(SIGKILL 兜底)时同样需要清理。
310
- * @returns {Promise<number[]>} 被清理的 PID 列表
311
- */
312
- async function killChildProcesses(parentPid, excludePid = 0) {
420
+ /** 按 PID 列表逐个强杀(存活才杀),等 1s 后复查再杀一轮 */
421
+ async function killPidList(pids, label) {
313
422
  const killed = [];
314
- const firstRound = (await listChildPids(parentPid)).filter(
315
- (p) => p !== excludePid,
316
- );
317
- for (const pid of firstRound) {
318
- await forceKillTree(pid);
319
- killed.push(pid);
423
+ for (const pid of pids) {
424
+ if (await isProcessAlive(pid)) {
425
+ await forceKillTree(pid);
426
+ killed.push(pid);
427
+ }
320
428
  }
321
429
  if (killed.length > 0) {
322
- console.error(
323
- `[upgrade-proc] 已清理旧 watchdog 残留子进程: ${killed.join(", ")}`,
324
- );
430
+ console.error(`[upgrade-proc] ${label}: ${killed.join(", ")}`);
325
431
  await new Promise((r) => setTimeout(r, 1000));
326
- // 复查:仍有残留则再杀一轮
327
- const remain = (await listChildPids(parentPid)).filter(
328
- (p) => p !== excludePid,
329
- );
330
- for (const pid of remain) {
331
- await forceKillTree(pid);
332
- killed.push(pid);
432
+ // 复查:仍存活的再杀一轮(防杀树竞态)
433
+ for (const pid of pids) {
434
+ if (await isProcessAlive(pid)) {
435
+ await forceKillTree(pid);
436
+ if (!killed.includes(pid)) killed.push(pid);
437
+ }
333
438
  }
334
439
  }
335
440
  return killed;
@@ -437,18 +542,46 @@ export async function runUpgradeMode(argv) {
437
542
  }
438
543
  console.error(`[upgrade-proc] 新版本验证通过 (${verify.version})`);
439
544
 
440
- // 3) 杀旧:先优雅发退出信号 → 等待 → 检测残留子进程 → 强杀
441
- // Windows 上 SIGTERM=强杀(无优雅),旧 watchdog 的业务子进程(webserver 等)
442
- // 会变孤儿占端口,因此杀旧后必须检测并强杀残留子进程(排除 U 自己)。
545
+ // 3) 杀旧:先优雅退出(等效 Ctrl+C)→ 等待 → 强杀兜底 → 按快照清理进程树 → 关旧窗口
546
+ // Windows 上 process.kill(SIGTERM)=强杀(无优雅),但用户在 cmd 窗口按 Ctrl+C
547
+ // 会触发旧 watchdog 的 SIGINT → gracefulShutdown 优雅停业务。因此先向旧 watchdog
548
+ // 的控制台发送 CTRL_C_EVENT(等效手动按键),8s 未退再强杀兜底;随后按【杀旧前】
549
+ // 快照强杀整棵后代树(排除 U 自己;杀旧后中间层死亡会断父链,必须提前快照),
550
+ // 再兜底清理「父已死」的孤儿业务进程,最后关闭旧 watchdog 所在的 cmd 窗口。
443
551
  console.error(`[upgrade-proc] 通知旧 watchdog (pid=${oldPid}) 退出 ...`);
444
- const exited = await terminateProcess(oldPid, 5000);
552
+ const procSnapshot = await snapshotAllProcesses();
553
+ const descPids = listDescendantPids(procSnapshot, oldPid, process.pid);
554
+ const cmdAncestors = listCmdAncestorPids(procSnapshot, oldPid);
555
+
556
+ let exited = false;
557
+ const gracefulSent =
558
+ process.platform === "win32" && (await sendConsoleCtrlC(oldPid));
559
+ if (gracefulSent) {
560
+ console.error(
561
+ `[upgrade-proc] 已发送 Ctrl+C(等效手动按键),等待旧 watchdog 优雅停止业务...`,
562
+ );
563
+ exited = await waitProcessExit(oldPid, 8000);
564
+ }
445
565
  if (exited) {
446
- console.error(`[upgrade-proc] 旧 watchdog 已退出`);
566
+ console.error(`[upgrade-proc] 旧 watchdog 已优雅退出`);
447
567
  } else {
448
- console.error(`[upgrade-proc] 旧 watchdog 未在 5s 内退出,已强制终止`);
449
- await new Promise((r) => setTimeout(r, 1000)); // 给强杀一点生效时间
568
+ if (gracefulSent) {
569
+ console.error(`[upgrade-proc] 旧 watchdog 未在 8s 内优雅退出,强制终止`);
570
+ }
571
+ exited = await terminateProcess(oldPid, 5000);
572
+ if (exited) {
573
+ console.error(`[upgrade-proc] 旧 watchdog 已退出`);
574
+ } else {
575
+ console.error(`[upgrade-proc] 旧 watchdog 未在 5s 内退出,已强制终止`);
576
+ await new Promise((r) => setTimeout(r, 1000)); // 给强杀一点生效时间
577
+ }
450
578
  }
451
- await killChildProcesses(oldPid, process.pid);
579
+ await killPidList(descPids, "已清理旧 watchdog 进程树");
580
+ // 兜底:快照时父链已断(中间层先死)的孤儿业务进程,杀旧后按特征清理
581
+ const orphanPids = listOrphanBusinessPids(await snapshotAllProcesses());
582
+ await killPidList(orphanPids, "已清理孤儿业务进程");
583
+ // 关闭旧 watchdog 所在的 cmd 窗口(业务已停,窗口只剩空壳提示符)
584
+ await killPidList(cmdAncestors, "已关闭旧 cmd 窗口");
452
585
 
453
586
  // 4) 旧进程已退,启新版 watchdog
454
587
  // Windows:显式弹独立交互 cmd 窗口(start → cmd /k),日志直接显示在窗口,