tt-help-cli-ycl 1.3.20 → 1.3.22

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.20",
3
+ "version": "1.3.22",
4
4
  "description": "TikTok user & video data scraper - extract ttSeller, verified, locationCreated from HTML source",
5
5
  "type": "module",
6
6
  "bin": {
@@ -3,6 +3,7 @@ import { delay, getDelayConfig, setDelayConfig } from '../scraper/modules/page-h
3
3
  import { userId as configuredUserId, saveUserId } from '../lib/constants.js';
4
4
  import { getMacOrUuid } from '../lib/mac-or-uuid.js';
5
5
  import { ensureBrowserReady as ensureBrowserReadyCDP } from '../lib/browser/cdp.js';
6
+ import { showResourceUsage } from '../utils/index.js';
6
7
  import path from 'path';
7
8
  import os from 'os';
8
9
 
@@ -83,6 +84,16 @@ export async function handleExplore(options) {
83
84
 
84
85
  const page = await getOrCreatePage(browser);
85
86
 
87
+ // 全局拦截图片资源,减少内存占用和加载时间
88
+ await page.route("**/*", (route) => {
89
+ const resourceType = route.request().resourceType();
90
+ if (resourceType === 'image' || resourceType === 'stylesheet') {
91
+ route.abort();
92
+ } else {
93
+ route.continue();
94
+ }
95
+ });
96
+
86
97
  let processedCount = 0;
87
98
  let errorCount = 0;
88
99
  let consecutiveNetworkErrors = 0;
@@ -94,6 +105,10 @@ export async function handleExplore(options) {
94
105
  const username = job.user.uniqueId;
95
106
  processedCount++;
96
107
 
108
+ if (processedCount % 10 === 0) {
109
+ showResourceUsage();
110
+ }
111
+
97
112
  if (consecutiveNetworkErrors > 0) {
98
113
  const waitTime = consecutiveNetworkErrors <= 2
99
114
  ? 0
@@ -151,7 +166,7 @@ export async function handleExplore(options) {
151
166
  consecutiveNetworkErrors++;
152
167
  errorCount++;
153
168
  await apiPost(`${serverUrl}/api/job/${username}`, { error: result.error });
154
- const errorType = consecutiveNetworkErrors > 1 ? 'network' : 'other';
169
+ const errorType = result.error.startsWith('被封:') ? '被封' : (consecutiveNetworkErrors > 1 ? 'network' : 'other');
155
170
  await withRetry('report error', () =>
156
171
  apiPost(`${serverUrl}/api/error-report`, {
157
172
  userId,
@@ -162,6 +177,10 @@ export async function handleExplore(options) {
162
177
  errorStack: result.errorStack || '',
163
178
  })
164
179
  ).catch(() => {});
180
+ if (errorType === '被封') {
181
+ console.error(`\n账号被封,停止处理`);
182
+ break;
183
+ }
165
184
  if (exploreMaxUsers > 0 && processedCount >= exploreMaxUsers) {
166
185
  console.error(`\n已达上限 ${exploreMaxUsers} 个用户,停止处理`);
167
186
  break;
@@ -1,4 +1,6 @@
1
1
  import { delay } from './delay.js';
2
+ import { retryWithBackoff } from './retry.js';
3
+ import { assertPageUrl } from './browser/page.js';
2
4
 
3
5
  /**
4
6
  * 通过拦截 TikTok 内部 API 获取用户视频列表
@@ -44,7 +46,10 @@ async function fetchUserVideosAPI(page, username, maxVideos, log) {
44
46
  log(' [API拦截] 导航到用户页,等待 /api/post/item_list/ ...');
45
47
  const t0 = Date.now();
46
48
 
47
- await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
49
+ await retryWithBackoff(async () => {
50
+ await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
51
+ await assertPageUrl(page, `@${username}`);
52
+ }, { maxRetries: 3, baseDelay: 3000, log });
48
53
 
49
54
  const data = await Promise.race([
50
55
  apiPromise,
@@ -62,16 +62,26 @@ export async function withBrowserRecovery(fn, browser, page, cdpOptions, port) {
62
62
  }
63
63
 
64
64
  export async function isLoggedIn(page) {
65
+ // 先等客户端渲染完成:登录态元素或登录按钮,哪个先出现就停止等待
66
+ const loginOrLoggedInSelector = [
67
+ '[class*="DivProfileContainer"]',
68
+ '[class*="DivUserContainer"]',
69
+ '[class*="UserMenu"]',
70
+ '[class*="CurrentUserInfo"]',
71
+ 'button:has-text("登录")',
72
+ 'button:has-text("Log in")',
73
+ 'button:has-text("Sign in")',
74
+ ].join(', ');
75
+
76
+ await page.waitForSelector(loginOrLoggedInSelector, { timeout: 10000 }).catch(() => {});
77
+
65
78
  return page.evaluate(() => {
66
- // 已登录时会出现在个人主页区域的元素
67
79
  const hasProfileContainer = !!document.querySelector(
68
80
  '[class*="DivProfileContainer"], [class*="DivUserContainer"]'
69
81
  );
70
- // 已登录时顶部导航栏有用户相关菜单
71
82
  const hasUserMenu = !!document.querySelector(
72
83
  '[class*="UserMenu"], [class*="user-menu"], [class*="CurrentUserInfo"]'
73
84
  );
74
- // 有登录按钮说明未登录
75
85
  const hasLoginButton = Array.from(document.querySelectorAll('button, [role="button"]'))
76
86
  .some(el => /^(登录|Log in|Sign in)$/i.test(el.textContent.trim()));
77
87
 
@@ -15,8 +15,8 @@ let server = 'http://127.0.0.1:3001';
15
15
  let configFile = null;
16
16
  let browser = null;
17
17
  let userId = null;
18
- let maxFollowing = 5;
19
- let maxFollowers = 5;
18
+ let maxFollowing = 50;
19
+ let maxFollowers = 50;
20
20
  let maxVideos = 16;
21
21
  let maxComments = 10;
22
22
 
@@ -50,6 +50,7 @@ const PATTERNS = {
50
50
  "发起对话",
51
51
  "0 条评论",
52
52
  ],
53
+ service_error: ["出错了", "很抱歉"],
53
54
  };
54
55
 
55
56
  export async function detectPageError(page) {
@@ -1,25 +1,26 @@
1
1
  import {
2
- delay,
3
2
  ensureBrowserReady,
4
- retryWithBackoff,
5
3
  detectPageError,
6
4
  isLoggedIn,
7
- assertPageUrl,
8
- } from './modules/page-helpers.js';
9
- import { detectCaptcha } from './modules/captcha-handler.js';
5
+ delay,
6
+ } from "./modules/page-helpers.js";
7
+ import { detectCaptcha } from "./modules/captcha-handler.js";
10
8
  export { ensureBrowserReady };
11
- import { getUserInfo, collectVideos } from '../videos/core.js';
12
- import { extractFollowAndFollowers } from './modules/follow-extractor.js';
13
- import { extractVideoLocation } from '../lib/scrape.js';
14
- import { maxFollowing as globalMaxFollowing, maxFollowers as globalMaxFollowers, maxVideos as globalMaxVideos } from '../lib/constants.js';
9
+ import { getUserInfo, collectVideos } from "../videos/core.js";
10
+ import { extractFollowAndFollowers } from "./modules/follow-extractor.js";
11
+ import { extractVideoLocation } from "../lib/scrape.js";
12
+ import {
13
+ maxFollowing as globalMaxFollowing,
14
+ maxFollowers as globalMaxFollowers,
15
+ } from "../lib/constants.js";
15
16
 
16
17
  async function processExplore(page, username, options, log) {
17
18
  const {
18
19
  maxVideos = 16,
19
20
  enableFollow = true,
20
- maxFollowing = 5,
21
- maxFollowers = 5,
22
- location = 'PL,NL,BE,DE,FR,IT,ES,IE',
21
+ maxFollowing = 50,
22
+ maxFollowers = 50,
23
+ location = "PL,NL,BE,DE,FR,IT,ES,IE",
23
24
  } = options;
24
25
 
25
26
  const result = {
@@ -40,43 +41,53 @@ async function processExplore(page, username, options, log) {
40
41
 
41
42
  try {
42
43
  log(` 访问 @${username} 主页...`);
43
- const homeUrl = `https://www.tiktok.com/@${username}`;
44
- await retryWithBackoff(async () => {
45
- await page.goto(homeUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
46
- assertPageUrl(page, `@${username}`);
47
- }, { log });
48
- await page.waitForSelector('[class*="DivVideoList"]', { timeout: 10000 }).catch(() => {});
49
- await delay(1000, 2000);
50
-
51
- log(' 获取用户信息...');
44
+ const videoList = await collectVideos(page, username, maxVideos, log);
45
+
46
+ log(" 获取用户信息...");
52
47
  const info = await getUserInfo(page);
53
48
  if (info) {
54
49
  result.userInfo = info;
55
- log(` 用户: ${info.nickname || username} | 粉丝: ${info.followerCount || '-'} | 视频: ${info.videoCount || '-'}`);
50
+ log(
51
+ ` 用户: ${info.nickname || username} | 粉丝: ${info.followerCount || "-"} | 视频: ${info.videoCount || "-"}`,
52
+ );
56
53
  }
57
54
 
58
55
  const captcha = await detectCaptcha(page);
59
56
  if (captcha && captcha.visible) {
60
57
  log(`[验证码] @${username} 页面出现验证码`);
61
58
  result.captchaDetected = true;
62
- result.captchaStage = result.captchaStage || 'video-page';
63
- result.captchaMessage = result.captchaMessage || '视频页出现验证码';
59
+ result.captchaStage = result.captchaStage || "video-page";
60
+ result.captchaMessage = result.captchaMessage || "视频页出现验证码";
64
61
  }
65
-
66
- const isSeller = result.userInfo?.ttSeller === true;
67
- const effectiveMaxVideos = isSeller ? globalMaxVideos : maxVideos;
68
- if (isSeller) log(` 商家用户,视频采集数: ${effectiveMaxVideos}`);
69
- const videoList = await collectVideos(page, username, effectiveMaxVideos, log);
70
62
  const videoArray = videoList ? [...videoList.values()] : [];
71
63
  result.collectedVideos = videoArray.length;
72
64
 
73
65
  if (videoArray.length <= 0) {
74
66
  result.processed = true;
75
67
  result.noVideo = true;
76
- const pageError = await detectPageError(page);
77
- if (pageError) {
78
- result.restricted = true;
79
- log(` @${username} 页面受限(${pageError}),标记跳过`);
68
+ const hasErrorPage = await page.waitForFunction(
69
+ (keywords) => {
70
+ const text = document.body.innerText;
71
+ return keywords.some(k => text.includes(k));
72
+ },
73
+ ["出错了", "很抱歉"],
74
+ { timeout: 5000 }
75
+ ).then(() => true).catch(() => false);
76
+
77
+ if (hasErrorPage) {
78
+ const pageError = await detectPageError(page);
79
+ if (pageError === 'service_error') {
80
+ result.error = `被封: ${username}`;
81
+ result.errorStack = '';
82
+ result.processed = false;
83
+ result.noVideo = false;
84
+ log(` @${username} 服务异常(service_error),标记错误`);
85
+ } else if (pageError) {
86
+ result.restricted = true;
87
+ log(` @${username} 页面受限(${pageError}),标记跳过`);
88
+ } else {
89
+ log(` @${username} 没有视频,标记已处理`);
90
+ }
80
91
  } else {
81
92
  log(` @${username} 没有视频,标记已处理`);
82
93
  }
@@ -87,23 +98,23 @@ async function processExplore(page, username, options, log) {
87
98
  let locationCreated = null;
88
99
  if (videoArray.length > 0) {
89
100
  const firstVideo = videoArray[0];
90
- const firstVideoUrl = firstVideo.href.startsWith('http')
101
+ const firstVideoUrl = firstVideo.href.startsWith("http")
91
102
  ? firstVideo.href
92
103
  : `https://www.tiktok.com${firstVideo.href}`;
93
104
 
94
- try {
95
- locationCreated = await extractVideoLocation(firstVideoUrl);
96
- } catch (e) {
97
- log(` 获取视频国家失败: ${e.message}`);
98
- }
105
+ locationCreated = await extractVideoLocation(firstVideoUrl);
99
106
  }
100
107
 
101
108
  result.locationCreated = locationCreated || null;
102
- log(` 国家: ${result.locationCreated || '未知'}`);
109
+ log(` 国家: ${result.locationCreated || "未知"}`);
103
110
 
104
111
  // 国家筛选
105
- const locationList = (location || 'ES').split(',').map(s => s.trim().toUpperCase());
106
- const isTargetLocation = locationList.includes(result.locationCreated?.toUpperCase?.() || result.locationCreated);
112
+ const locationList = (location || "ES")
113
+ .split(",")
114
+ .map((s) => s.trim().toUpperCase());
115
+ const isTargetLocation = locationList.includes(
116
+ result.locationCreated?.toUpperCase?.() || result.locationCreated,
117
+ );
107
118
 
108
119
  if (isTargetLocation) {
109
120
  result.keepFollow = true;
@@ -112,23 +123,39 @@ async function processExplore(page, username, options, log) {
112
123
  // 提取关注/粉丝
113
124
  if (enableFollow) {
114
125
  const loggedIn = await isLoggedIn(page);
126
+ await delay(100, 1000);
115
127
  if (!loggedIn) {
116
- log(' [跳过] 获取关注/粉丝:未登录,请先登录 TikTok');
128
+ log(" [跳过] 获取关注/粉丝:未登录,请先登录 TikTok");
117
129
  result.hasFollowData = false;
118
130
  result.discoveredFollowing = [];
119
131
  result.discoveredFollowers = [];
120
132
  } else {
121
133
  try {
122
- const effectiveMaxFollowing = isSeller ? globalMaxFollowing : maxFollowing;
123
- const effectiveMaxFollowers = isSeller ? globalMaxFollowers : maxFollowers;
124
- if (isSeller) log(` 商家用户,关注采集: ${effectiveMaxFollowing}, 粉丝采集: ${effectiveMaxFollowers}`);
134
+ const isSeller = result.userInfo?.ttSeller === true;
135
+ const effectiveMaxFollowing = isSeller
136
+ ? globalMaxFollowing
137
+ : maxFollowing;
138
+ const effectiveMaxFollowers = isSeller
139
+ ? globalMaxFollowers
140
+ : maxFollowers;
141
+ if (isSeller)
142
+ log(
143
+ ` 商家用户,关注采集: ${effectiveMaxFollowing}, 粉丝采集: ${effectiveMaxFollowers}`,
144
+ );
125
145
  const { following, followers } = await extractFollowAndFollowers(
126
- page, { maxFollowing: effectiveMaxFollowing, maxFollowers: effectiveMaxFollowers, log }
146
+ page,
147
+ {
148
+ maxFollowing: effectiveMaxFollowing,
149
+ maxFollowers: effectiveMaxFollowers,
150
+ log,
151
+ },
127
152
  );
128
153
  result.discoveredFollowing = following || [];
129
154
  result.discoveredFollowers = followers || [];
130
155
  result.hasFollowData = true;
131
- log(` 关注: ${result.discoveredFollowing.length}, 粉丝: ${result.discoveredFollowers.length}`);
156
+ log(
157
+ ` 关注: ${result.discoveredFollowing.length}, 粉丝: ${result.discoveredFollowers.length}`,
158
+ );
132
159
  } catch (e) {
133
160
  log(` 关注/粉丝提取失败: ${e.message}`);
134
161
  result.hasFollowData = false;
@@ -152,7 +179,7 @@ async function processExplore(page, username, options, log) {
152
179
  result.processed = true;
153
180
  } catch (e) {
154
181
  result.error = e.message;
155
- result.errorStack = e.stack || '';
182
+ result.errorStack = e.stack || "";
156
183
  log(` [错误] ${e.message}`);
157
184
  }
158
185
 
@@ -26,11 +26,7 @@ async function openFollowModal(page) {
26
26
  );
27
27
  }
28
28
  await el.evaluate((el) => el.parentElement.click());
29
- await page
30
- .waitForSelector("[class*=DivUserListContainer]", { timeout: 5000 })
31
- .catch(() => {
32
- throw new Error("关注弹窗未出现 DivUserListContainer");
33
- });
29
+ await page.waitForSelector("[class*=DivUserListContainer]", { timeout: 15000 });
34
30
  await waitForListContent(page, 1, 3000);
35
31
  }
36
32
 
@@ -45,6 +41,7 @@ async function switchToFollowersTab(page) {
45
41
  }
46
42
  throw new Error("未找到粉丝 Tab");
47
43
  });
44
+ await page.waitForSelector("[class*=DivUserListContainer]", { timeout: 15000 });
48
45
  await waitForListContent(page, 1, 3000);
49
46
  }
50
47
 
@@ -0,0 +1,31 @@
1
+ /**
2
+ * 显示当前进程的内存和 CPU 使用情况
3
+ *
4
+ * @param {Function} log - 日志函数
5
+ * @returns {string} 格式化的状态字符串
6
+ */
7
+ export function showResourceUsage(log = console.error) {
8
+ const mem = process.memoryUsage();
9
+ const memMB = (mem.rss / 1024 / 1024).toFixed(1);
10
+ const heapUsedMB = (mem.heapUsed / 1024 / 1024).toFixed(1);
11
+ const heapTotalMB = (mem.heapTotal / 1024 / 1024).toFixed(1);
12
+
13
+ // CPU 使用率需要两次采样
14
+ const cpuStart = process.cpuUsage();
15
+ const now = new Date().toISOString().substring(11, 23);
16
+
17
+ const msg = `[${now}] 内存 RSS: ${memMB}MB | Heap: ${heapUsedMB}/${heapTotalMB}MB`;
18
+ log(msg);
19
+ return msg;
20
+ }
21
+
22
+ /**
23
+ * 启动周期性资源监控,每隔 intervalMs 打印一次
24
+ *
25
+ * @param {number} intervalMs - 间隔毫秒,默认 30000
26
+ * @param {Function} log - 日志函数
27
+ * @returns {NodeJS.Timeout} 定时器句柄, clearInterval 可停止
28
+ */
29
+ export function startResourceMonitor(intervalMs, log) {
30
+ return setInterval(() => showResourceUsage(log), intervalMs);
31
+ }
@@ -1,4 +1,5 @@
1
1
  import fs from 'fs';
2
+ import { promises as fsPromises } from 'fs';
2
3
  import path from 'path';
3
4
 
4
5
  function inferStatus(u) {
@@ -37,6 +38,7 @@ export function createStore(filePath) {
37
38
 
38
39
  function markStatsDirty() {
39
40
  statsDirty = true;
41
+ groupsDirty = true;
40
42
  }
41
43
 
42
44
  function computeStatsInternal() {
@@ -57,6 +59,63 @@ export function createStore(filePath) {
57
59
  return statsCache;
58
60
  }
59
61
 
62
+ // 按 status 的分组索引,避免每次请求全量遍历
63
+ let statusGroups = null;
64
+ let groupsDirty = true;
65
+
66
+ const tier1LocSet = new Set(['PL', 'NL', 'BE']);
67
+ const tier2LocSet = new Set(['DE', 'FR', 'IT', 'IE', 'ES']);
68
+ function locationTier(u) {
69
+ const loc = (u.guessedLocation || '').toUpperCase();
70
+ if (tier1LocSet.has(loc)) return 0;
71
+ if (tier2LocSet.has(loc)) return 1;
72
+ return 2;
73
+ }
74
+
75
+ function sortGroup(key, arr) {
76
+ if (key === 'done') arr.sort((a, b) => (b.processedAt || 0) - (a.processedAt || 0));
77
+ else if (key === 'pending') arr.sort((a, b) => {
78
+ const aSeller = a.ttSeller === true && a.verified === false ? 0 : 1;
79
+ const bSeller = b.ttSeller === true && b.verified === false ? 0 : 1;
80
+ if (aSeller !== bSeller) return aSeller - bSeller;
81
+ const la = locationTier(a), lb = locationTier(b);
82
+ if (la !== lb) return la - lb;
83
+ return (b.followerCount || 0) - (a.followerCount || 0);
84
+ });
85
+ else arr.sort((a, b) => (b.followerCount || 0) - (a.followerCount || 0));
86
+ // 置顶冒泡到组首
87
+ const pinned = arr.filter(u => u.pinned);
88
+ const unpinned = arr.filter(u => !u.pinned);
89
+ return pinned.concat(unpinned);
90
+ }
91
+
92
+ function rebuildStatusGroups() {
93
+ statusGroups = { pending: [], processing: [], done: [], error: [], restricted: [] };
94
+ for (const u of data) {
95
+ const key = u.status || 'pending';
96
+ if (statusGroups[key]) statusGroups[key].push(u);
97
+ else statusGroups[key] = [u];
98
+ }
99
+ // done 归档单独处理
100
+ if (doneArchive.length > 0) {
101
+ statusGroups.done = statusGroups.done.concat(doneArchive);
102
+ }
103
+ // 各组内排序
104
+ for (const key of Object.keys(statusGroups)) {
105
+ statusGroups[key] = sortGroup(key, statusGroups[key]);
106
+ }
107
+ groupsDirty = false;
108
+ }
109
+
110
+ function getStatusGroups() {
111
+ if (groupsDirty) rebuildStatusGroups();
112
+ return statusGroups;
113
+ }
114
+
115
+ function markGroupsDirty() {
116
+ groupsDirty = true;
117
+ }
118
+
60
119
  // 视频存储(独立 JSON 文件)
61
120
  let videos = [];
62
121
  let videoFilePath = null;
@@ -138,7 +197,21 @@ export function createStore(filePath) {
138
197
  }
139
198
  }
140
199
 
141
- function save() {
200
+ let saveTimer = null;
201
+ let savePending = false;
202
+
203
+ function scheduleSave() {
204
+ if (saveTimer) return;
205
+ savePending = true;
206
+ saveTimer = setTimeout(() => {
207
+ saveTimer = null;
208
+ savePending = false;
209
+ doSave();
210
+ }, 2000);
211
+ saveTimer.unref();
212
+ }
213
+
214
+ function doSave() {
142
215
  if (!filePath) return;
143
216
  const resolved = path.resolve(filePath);
144
217
 
@@ -167,22 +240,91 @@ export function createStore(filePath) {
167
240
 
168
241
  const json = JSON.stringify(data);
169
242
  const tmpPath = resolved + '.tmp';
170
- fs.writeFileSync(tmpPath, json, 'utf-8');
171
- fs.renameSync(tmpPath, resolved);
172
-
173
- // done 归档
174
- if (doneArchivePath && doneArchive.length > 0) {
175
- const doneJson = JSON.stringify(doneArchive);
176
- const doneTmp = doneArchivePath + '.tmp';
177
- fs.writeFileSync(doneTmp, doneJson, 'utf-8');
178
- fs.renameSync(doneTmp, doneArchivePath);
179
- }
243
+ fsPromises.writeFile(tmpPath, json, 'utf-8')
244
+ .then(() => fsPromises.rename(tmpPath, resolved))
245
+ .then(() => {
246
+ if (doneArchivePath && doneArchive.length > 0) {
247
+ const doneJson = JSON.stringify(doneArchive);
248
+ const doneTmp = doneArchivePath + '.tmp';
249
+ return fsPromises.writeFile(doneTmp, doneJson, 'utf-8')
250
+ .then(() => fsPromises.rename(doneTmp, doneArchivePath));
251
+ }
252
+ })
253
+ .catch(err => {
254
+ console.error(`[data-store] save 写入失败: ${err.message}`);
255
+ });
256
+ }
257
+
258
+ function save() {
259
+ scheduleSave();
180
260
  }
181
261
 
262
+ function flushSave() {
263
+ return new Promise(resolve => {
264
+ if (saveTimer) {
265
+ clearTimeout(saveTimer);
266
+ saveTimer = null;
267
+ savePending = false;
268
+ }
269
+ if (!filePath) { resolve(); return; }
270
+ const resolved = path.resolve(filePath);
271
+
272
+ // 将 done 用户归档到独立文件
273
+ if (doneArchivePath && data.length > 1000) {
274
+ const active = [];
275
+ const toArchive = [];
276
+ for (const u of data) {
277
+ if (u.status === 'done') {
278
+ toArchive.push(u);
279
+ } else {
280
+ active.push(u);
281
+ }
282
+ }
283
+ if (toArchive.length > 0) {
284
+ doneArchive = doneArchive.concat(toArchive);
285
+ data = active;
286
+ rebuildIndex();
287
+ doneUidIndex = new Map();
288
+ for (let i = 0; i < doneArchive.length; i++) {
289
+ doneUidIndex.set(doneArchive[i].uniqueId, i);
290
+ }
291
+ }
292
+ }
293
+
294
+ const json = JSON.stringify(data);
295
+ const tmpPath = resolved + '.tmp';
296
+ fsPromises.writeFile(tmpPath, json, 'utf-8')
297
+ .then(() => fsPromises.rename(tmpPath, resolved))
298
+ .then(() => {
299
+ if (doneArchivePath && doneArchive.length > 0) {
300
+ const doneJson = JSON.stringify(doneArchive);
301
+ const doneTmp = doneArchivePath + '.tmp';
302
+ return fsPromises.writeFile(doneTmp, doneJson, 'utf-8')
303
+ .then(() => fsPromises.rename(doneTmp, doneArchivePath));
304
+ }
305
+ resolve();
306
+ })
307
+ .catch(err => {
308
+ console.error(`[data-store] flushSave 写入失败: ${err.message}`);
309
+ resolve();
310
+ });
311
+ });
312
+ }
313
+
314
+ let videosSaveTimer = null;
315
+
182
316
  function saveVideos() {
183
317
  if (!videoFilePath) return;
184
- const json = JSON.stringify(videos, null, 2);
185
- fs.writeFileSync(videoFilePath, json, 'utf-8');
318
+ if (videosSaveTimer) return;
319
+ videosSaveTimer = setTimeout(() => {
320
+ videosSaveTimer = null;
321
+ const json = JSON.stringify(videos, null, 2);
322
+ fsPromises.writeFile(videoFilePath, json, 'utf-8')
323
+ .catch(err => {
324
+ console.error(`[data-store] saveVideos 写入失败: ${err.message}`);
325
+ });
326
+ }, 2000);
327
+ videosSaveTimer.unref();
186
328
  }
187
329
 
188
330
  function stopBackup() {
@@ -259,7 +401,7 @@ export function createStore(filePath) {
259
401
  return { uniqueId: ongoing.uniqueId, nickname: ongoing.nickname, claimedAt: ongoing.claimedAt, claimedBy: userId };
260
402
  }
261
403
 
262
- // 按猜测国家梯队排序:第一梯队优先,无国家排最后
404
+ // 按猜测国家梯队排序
263
405
  const tier1 = new Set(['PL', 'NL', 'BE']);
264
406
  const tier2 = new Set(['DE', 'FR', 'IT', 'IE', 'ES']);
265
407
  function locationTier(u) {
@@ -269,48 +411,53 @@ export function createStore(filePath) {
269
411
  return 2;
270
412
  }
271
413
 
272
- function pickFirst(sorted) {
273
- return sorted.length > 0 ? sorted[0] : null;
274
- }
414
+ // 从候选列表中按优先级取第一个:pinned > 超时回收 > seed > ttSeller > follow > other
415
+ function pickCandidate(candidates) {
416
+ let next = candidates.find(u => u.pinned);
417
+
418
+ if (!next) {
419
+ const expired = data.find(u =>
420
+ u.status === 'processing' && u.claimedAt && (now - u.claimedAt) > expireMs
421
+ );
422
+ if (expired) {
423
+ expired.status = 'pending';
424
+ markStatsDirty();
425
+ delete expired.claimedAt;
426
+ next = expired;
427
+ }
428
+ }
275
429
 
276
- let next = data.find(u => u.status === 'pending' && u.pinned);
277
-
278
- if (!next) {
279
- const expired = data.find(u =>
280
- u.status === 'processing' && u.claimedAt && (now - u.claimedAt) > expireMs
281
- );
282
- if (expired) {
283
- expired.status = 'pending';
284
- markStatsDirty();
285
- delete expired.claimedAt;
286
- next = expired;
430
+ if (!next) {
431
+ const seed = candidates.filter(u => u.sources && u.sources.includes('seed'));
432
+ seed.sort((a, b) => locationTier(a) - locationTier(b));
433
+ next = seed[0] || null;
287
434
  }
288
- }
289
435
 
290
- if (!next) {
291
- const seed = data.filter(u => u.status === 'pending' && u.sources && u.sources.includes('seed'));
292
- seed.sort((a, b) => locationTier(a) - locationTier(b));
293
- next = pickFirst(seed);
294
- }
436
+ if (!next) {
437
+ const ttSeller = candidates.filter(u => u.ttSeller === true && u.verified === false);
438
+ ttSeller.sort((a, b) => locationTier(a) - locationTier(b));
439
+ next = ttSeller[0] || null;
440
+ }
295
441
 
296
- if (!next) {
297
- const ttSeller = data.filter(u => u.status === 'pending' && u.ttSeller === true && u.verified === false);
298
- ttSeller.sort((a, b) => locationTier(a) - locationTier(b));
299
- next = pickFirst(ttSeller);
300
- }
442
+ if (!next) {
443
+ const follow = candidates.filter(u => u.sources && (u.sources.includes('following') || u.sources.includes('follower')));
444
+ follow.sort((a, b) => locationTier(a) - locationTier(b));
445
+ next = follow[0] || null;
446
+ }
301
447
 
302
- if (!next) {
303
- const follow = data.filter(u => u.status === 'pending' && u.sources && (u.sources.includes('following') || u.sources.includes('follower')));
304
- follow.sort((a, b) => locationTier(a) - locationTier(b));
305
- next = pickFirst(follow);
306
- }
448
+ if (!next) {
449
+ candidates.sort((a, b) => locationTier(a) - locationTier(b));
450
+ next = candidates[0] || null;
451
+ }
307
452
 
308
- if (!next) {
309
- const all = data.filter(u => u.status === 'pending');
310
- all.sort((a, b) => locationTier(a) - locationTier(b));
311
- next = pickFirst(all);
453
+ return next;
312
454
  }
313
455
 
456
+ // 先在有视频的 pending 用户中找;找不到再用全部 pending 用户兜底
457
+ const pending = data.filter(u => u.status === 'pending');
458
+ const hasVideo = pending.filter(u => u.videoCount > 0);
459
+ const next = pickCandidate(hasVideo) || pickCandidate(pending);
460
+
314
461
  if (next) {
315
462
  next.status = 'processing';
316
463
  markStatsDirty();
@@ -667,8 +814,9 @@ export function createStore(filePath) {
667
814
  }
668
815
 
669
816
  return {
670
- save, getUser, hasUser, userExists, addUser,
817
+ save, flushSave, getUser, hasUser, userExists, addUser,
671
818
  getPendingUsers, getProcessedUsers, getAllUsers, getStats,
819
+ getStatusGroups, markGroupsDirty,
672
820
  claimNextJob, commitJob, commitNewExplore, resetJob, togglePin,
673
821
  getNextRedoJob, commitRedoJob,
674
822
  getPendingUserUpdateTasks, updateUserInfo, batchUpdateUserInfo,
@@ -156,6 +156,7 @@
156
156
  <div class="stat-card"><div class="label">待处理</div><div class="value pending" id="statPending">0</div></div>
157
157
  <div class="stat-card"><div class="label">错误</div><div class="value error" id="statError">0</div></div>
158
158
  <div class="stat-card"><div class="label">受限</div><div class="value error" id="statRestricted">0</div></div>
159
+ <div class="stat-card"><div class="label">预处理任务</div><div class="value target" id="statUserUpdateTasks">0</div></div>
159
160
  <div class="stat-card clickable" id="statTargetCard"><div class="label">目标用户(EU商家)</div><div class="value target" id="statTarget">0</div></div>
160
161
  </div>
161
162
  <div class="client-errors-section" id="clientErrorsSection" style="display:none">
@@ -205,6 +206,9 @@
205
206
  <button data-filter="error" onclick="setFilter('error')">错误</button>
206
207
  <button data-filter="restricted" onclick="setFilter('restricted')">受限</button>
207
208
  <button data-filter="target" onclick="setFilter('target')" style="background:#7c3aed;color:#fff">目标用户(EU)</button>
209
+ <select id="targetLocationFilter" onchange="onTargetLocationChange()" style="padding:6px 10px;border:1px solid #7c3aed;border-radius:6px;background:#2a2a3a;color:#ccc;font-size:12px;cursor:pointer;outline:none;display:none">
210
+ <option value="">全部目标国家</option>
211
+ </select>
208
212
  <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>
209
213
  <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;">
210
214
  <option value="">全部国家</option>
@@ -225,6 +229,7 @@ let currentFilter = 'all';
225
229
  let currentStats = null;
226
230
  let currentUsers = [];
227
231
  let currentLocation = '';
232
+ let currentTargetLocation = '';
228
233
  let prevStatValues = {};
229
234
  let prevUserMap = {};
230
235
 
@@ -244,6 +249,7 @@ async function fetchUsers() {
244
249
  const params = new URLSearchParams();
245
250
  if (currentFilter === 'target') {
246
251
  params.set('target', '1');
252
+ if (currentTargetLocation) params.set('targetLocation', currentTargetLocation);
247
253
  } else if (currentFilter !== 'all') {
248
254
  params.set('status', currentFilter);
249
255
  }
@@ -278,7 +284,7 @@ async function fetchClientErrors() {
278
284
  }
279
285
  section.style.display = '';
280
286
  badge.textContent = clients.length;
281
- const typeMap = { captcha: ['验证码', 'error-type-captcha'], network: ['网络', 'error-type-network'], other: ['其他', 'error-type-other'] };
287
+ const typeMap = { captcha: ['验证码', 'error-type-captcha'], network: ['网络', 'error-type-network'], other: ['其他', 'error-type-other'], '被封': ['被封', 'error-type-captcha'] };
282
288
  const stageMap = { 'video-page': '视频页', 'comment': '评论', 'follow': '关注/粉丝', 'scrape': 'scrape', 'process': '处理' };
283
289
  tbody.innerHTML = clients.map(c => {
284
290
  const [typeText, typeClass] = typeMap[c.errorType] || ['未知', ''];
@@ -332,11 +338,21 @@ function renderStats() {
332
338
  flashEl('statError', d.errorUsers);
333
339
  flashEl('statRestricted', d.restrictedUsers);
334
340
  flashEl('statTarget', d.targetUsers);
341
+ flashEl('statUserUpdateTasks', d.userUpdateTasks || 0);
335
342
  document.getElementById('lastUpdate').textContent = '\u66f4\u65b0\u4e8e ' + new Date().toLocaleTimeString();
336
343
  document.getElementById('fileMeta').textContent = (d.processingUsers || 0) + ' \u5904\u7406\u4e2d, ' + d.totalUsers + ' \u4e2a\u7528\u6237';
337
344
 
338
345
  renderCountryChart(d.countryStats);
339
346
  renderSourceChart(d.sourceStats);
347
+ renderTargetLocationFilter(d.targetCountryStats);
348
+ }
349
+
350
+ function renderTargetLocationFilter(targetCountryStats) {
351
+ const sel = document.getElementById('targetLocationFilter');
352
+ if (!sel) return;
353
+ const val = sel.value;
354
+ sel.innerHTML = '<option value="">全部目标国家</option>' +
355
+ (targetCountryStats || []).map(c => `<option value="${c.country}"${val === c.country ? ' selected' : ''}>${c.country} (${c.count})</option>`).join('');
340
356
  }
341
357
 
342
358
  function renderCountryChart(countries) {
@@ -440,6 +456,13 @@ function setFilter(f) {
440
456
  });
441
457
  const btn = document.getElementById('batchResetBtn');
442
458
  btn.style.display = f === 'error' ? '' : 'none';
459
+ const targetLocSel = document.getElementById('targetLocationFilter');
460
+ if (f === 'target') {
461
+ targetLocSel.style.display = '';
462
+ } else {
463
+ targetLocSel.style.display = 'none';
464
+ currentTargetLocation = '';
465
+ }
443
466
  fetchUsers();
444
467
  }
445
468
 
@@ -459,8 +482,16 @@ function onLocationChange() {
459
482
  fetchUsers();
460
483
  }
461
484
 
462
- document.getElementById('searchInput').addEventListener('input', () => {
485
+ function onTargetLocationChange() {
486
+ const sel = document.getElementById('targetLocationFilter');
487
+ currentTargetLocation = sel.value;
463
488
  fetchUsers();
489
+ }
490
+
491
+ let searchTimer = null;
492
+ document.getElementById('searchInput').addEventListener('input', () => {
493
+ if (searchTimer) clearTimeout(searchTimer);
494
+ searchTimer = setTimeout(fetchUsers, 300);
464
495
  });
465
496
 
466
497
  function parseUsernames(raw) {
@@ -86,6 +86,72 @@ function computeStats(users) {
86
86
  };
87
87
  }
88
88
 
89
+ function computeStatsIncremental(st) {
90
+ const quick = st.getStats();
91
+ const all = st.getAllUsers();
92
+ const total = all.length;
93
+ const statusCounts = quick.statusCounts;
94
+
95
+ const countryMap = {};
96
+ const sourceCounts = { seed: 0, video: 0, comment: 0, guess: 0, following: 0, follower: 0, processed: 0, restricted: 0, error: 0, noVideo: 0 };
97
+ let targetUsers = 0;
98
+ let userUpdateTasks = 0;
99
+ const targetCountryMap = {};
100
+
101
+ for (const u of all) {
102
+ // 国家统计
103
+ if (u.status === 'done') {
104
+ const loc = u.locationCreated || '未知';
105
+ countryMap[loc] = (countryMap[loc] || 0) + 1;
106
+ }
107
+ // 预处理任务统计(与 /api/user-update-tasks 条件一致,不做 continue 跳过)
108
+ const ttSellerEmpty = u.ttSeller === null || u.ttSeller === undefined || u.ttSeller === '';
109
+ const updateCountNotSet = u.userUpdateCount === null || u.userUpdateCount === undefined || u.userUpdateCount <= 0;
110
+ if (ttSellerEmpty && updateCountNotSet) userUpdateTasks++;
111
+
112
+ // 目标用户统计(按国家分组)
113
+ if (u.ttSeller && u.verified === false && TARGET_LOCATIONS.includes(u.locationCreated)) {
114
+ targetUsers++;
115
+ const loc = u.locationCreated;
116
+ targetCountryMap[loc] = (targetCountryMap[loc] || 0) + 1;
117
+ }
118
+
119
+ // 来源统计(restricted/error 跳过后续统计)
120
+ if (u.status === 'restricted') { sourceCounts.restricted++; continue; }
121
+ if (u.status === 'error') { sourceCounts.error++; continue; }
122
+ if (u.noVideo) sourceCounts.noVideo++;
123
+ const sources = u.sources || [];
124
+ if (u.status === 'done') sourceCounts.processed++;
125
+ if (sources.includes('video') && u.status !== 'done') sourceCounts.video++;
126
+ if (sources.includes('comment') && u.status !== 'done') sourceCounts.comment++;
127
+ if (sources.includes('guess') && u.status !== 'done') sourceCounts.guess++;
128
+ if (sources.includes('following') && u.status !== 'done') sourceCounts.following++;
129
+ if (sources.includes('follower') && u.status !== 'done') sourceCounts.follower++;
130
+ if (!sources.includes('video') && !sources.includes('comment') && !sources.includes('guess') &&
131
+ !sources.includes('following') && !sources.includes('follower') && u.status !== 'done') sourceCounts.seed++;
132
+ }
133
+ const countryStats = Object.entries(countryMap)
134
+ .map(([country, count]) => ({ country, count }))
135
+ .sort((a, b) => b.count - a.count);
136
+ const targetCountryStats = Object.entries(targetCountryMap)
137
+ .map(([country, count]) => ({ country, count }))
138
+ .sort((a, b) => b.count - a.count);
139
+
140
+ return {
141
+ totalUsers: total,
142
+ processedUsers: statusCounts.done,
143
+ pendingUsers: statusCounts.pending,
144
+ processingUsers: statusCounts.processing,
145
+ restrictedUsers: statusCounts.restricted,
146
+ errorUsers: statusCounts.error,
147
+ targetUsers,
148
+ userUpdateTasks,
149
+ targetCountryStats,
150
+ countryStats,
151
+ sourceStats: sourceCounts,
152
+ };
153
+ }
154
+
89
155
  function readBody(req) {
90
156
  return new Promise((resolve, reject) => {
91
157
  let body = '';
@@ -279,9 +345,7 @@ export function startWatchServer(outputFile, port = 3000, existingStore) {
279
345
  }
280
346
 
281
347
  if (req.method === 'GET' && routePath === '/api/stats') {
282
- const stats = computeStats(store.getAllUsers());
283
- const quick = store.getStats();
284
- if (quick) stats.quickStats = quick;
348
+ const stats = computeStatsIncremental(store);
285
349
  sendJSON(res, 200, stats);
286
350
  return;
287
351
  }
@@ -435,8 +499,37 @@ export function startWatchServer(outputFile, port = 3000, existingStore) {
435
499
 
436
500
  if (req.method === 'GET' && routePath === '/api/users') {
437
501
  const all = store.getAllUsers();
438
- let filtered = [...all];
502
+ const limit = parseInt(params.limit) || 50;
503
+ const offset = parseInt(params.offset) || 0;
504
+
505
+ // 简单筛选:直接用预分组索引(已排序,免全量遍历)
506
+ if (!params.search && !params.target && !params.location && !params.targetLocation) {
507
+ const groups = store.getStatusGroups();
508
+ if (params.status && params.status !== 'all') {
509
+ // 单状态快路径:直接取已排序的组
510
+ const group = groups[params.status] || [];
511
+ const paged = group.slice(offset, offset + limit);
512
+ sendJSON(res, 200, { total: group.length, users: paged });
513
+ return;
514
+ }
515
+ // status=all 快路径:按分组顺序 early-exit(各组已排序)
516
+ const sOrder = { processing: 0, pending: 1, done: 2, error: 3, restricted: 4 };
517
+ const sortedKeys = Object.keys(groups).sort((a, b) => (sOrder[a] ?? 9) - (sOrder[b] ?? 9));
518
+ let totalCount = 0;
519
+ for (const key of sortedKeys) totalCount += groups[key].length;
520
+ const result = [];
521
+ outer: for (const key of sortedKeys) {
522
+ for (const u of groups[key]) {
523
+ result.push(u);
524
+ if (result.length >= offset + limit) break outer;
525
+ }
526
+ }
527
+ const paged = result.slice(offset, offset + limit);
528
+ sendJSON(res, 200, { total: totalCount, users: paged });
529
+ return;
530
+ }
439
531
 
532
+ let filtered = all;
440
533
  if (params.status && params.status !== 'all') {
441
534
  filtered = filtered.filter(u => u.status === params.status);
442
535
  }
@@ -455,7 +548,13 @@ export function startWatchServer(outputFile, port = 3000, existingStore) {
455
548
  if (params.location) {
456
549
  filtered = filtered.filter(u => u.locationCreated === params.location);
457
550
  }
551
+ if (params.targetLocation) {
552
+ filtered = filtered.filter(u =>
553
+ u.ttSeller && u.verified === false && u.locationCreated === params.targetLocation
554
+ );
555
+ }
458
556
 
557
+ const needCount = offset + limit;
459
558
  const statusOrder = { processing: 0, pending: 1, done: 2, error: 3, restricted: 4 };
460
559
  const tier1Loc = new Set(['PL', 'NL', 'BE']);
461
560
  const tier2Loc = new Set(['DE', 'FR', 'IT', 'IE', 'ES']);
@@ -465,29 +564,60 @@ export function startWatchServer(outputFile, port = 3000, existingStore) {
465
564
  if (tier2Loc.has(loc)) return 1;
466
565
  return 2;
467
566
  }
468
- filtered.sort((a, b) => {
469
- // 置顶优先
470
- if (a.pinned && !b.pinned) return -1;
471
- if (!a.pinned && b.pinned) return 1;
472
- const sa = statusOrder[a.status] ?? 9;
473
- const sb = statusOrder[b.status] ?? 9;
474
- if (sa !== sb) return sa - sb;
475
- if (a.status === 'done' && b.status === 'done') return (b.processedAt || 0) - (a.processedAt || 0);
476
- if (a.status === 'pending' && b.status === 'pending') {
477
- const aSeller = a.ttSeller === true && a.verified === false ? 0 : 1;
478
- const bSeller = b.ttSeller === true && b.verified === false ? 0 : 1;
479
- if (aSeller !== bSeller) return aSeller - bSeller;
480
- const la = locationTier(a), lb = locationTier(b);
481
- if (la !== lb) return la - lb;
567
+
568
+ let sorted;
569
+ if (filtered.length > needCount * 3) {
570
+ const groups = {};
571
+ for (const u of filtered) {
572
+ const key = u.status || 'pending';
573
+ if (!groups[key]) groups[key] = [];
574
+ groups[key].push(u);
575
+ }
576
+ const sortedKeys = Object.keys(groups).sort((a, b) => (statusOrder[a] ?? 9) - (statusOrder[b] ?? 9));
577
+ for (const key of sortedKeys) {
578
+ const g = groups[key];
579
+ if (key === 'done') g.sort((a, b) => (b.processedAt || 0) - (a.processedAt || 0));
580
+ else if (key === 'pending') g.sort((a, b) => {
581
+ const aSeller = a.ttSeller === true && a.verified === false ? 0 : 1;
582
+ const bSeller = b.ttSeller === true && b.verified === false ? 0 : 1;
583
+ if (aSeller !== bSeller) return aSeller - bSeller;
584
+ const la = locationTier(a), lb = locationTier(b);
585
+ if (la !== lb) return la - lb;
586
+ return (b.followerCount || 0) - (a.followerCount || 0);
587
+ });
588
+ else g.sort((a, b) => (b.followerCount || 0) - (a.followerCount || 0));
589
+ const pinned = g.filter(u => u.pinned);
590
+ const unpinned = g.filter(u => !u.pinned);
591
+ groups[key] = pinned.concat(unpinned);
482
592
  }
483
- return (b.followerCount || 0) - (a.followerCount || 0);
484
- });
593
+ sorted = [];
594
+ outer: for (const key of sortedKeys) {
595
+ for (const u of groups[key]) {
596
+ sorted.push(u);
597
+ if (sorted.length >= needCount) break outer;
598
+ }
599
+ }
600
+ } else {
601
+ sorted = filtered.slice().sort((a, b) => {
602
+ if (a.pinned && !b.pinned) return -1;
603
+ if (!a.pinned && b.pinned) return 1;
604
+ const sa = statusOrder[a.status] ?? 9;
605
+ const sb = statusOrder[b.status] ?? 9;
606
+ if (sa !== sb) return sa - sb;
607
+ if (a.status === 'done' && b.status === 'done') return (b.processedAt || 0) - (a.processedAt || 0);
608
+ if (a.status === 'pending' && b.status === 'pending') {
609
+ const aSeller = a.ttSeller === true && a.verified === false ? 0 : 1;
610
+ const bSeller = b.ttSeller === true && b.verified === false ? 0 : 1;
611
+ if (aSeller !== bSeller) return aSeller - bSeller;
612
+ const la = locationTier(a), lb = locationTier(b);
613
+ if (la !== lb) return la - lb;
614
+ }
615
+ return (b.followerCount || 0) - (a.followerCount || 0);
616
+ });
617
+ }
485
618
 
486
- const limit = parseInt(params.limit) || 50;
487
- const offset = parseInt(params.offset) || 0;
488
- let paged = filtered.slice(offset, offset + limit);
619
+ let paged = sorted.slice(offset, offset + limit);
489
620
 
490
- // 字段裁剪:轻量视图只返回必要字段
491
621
  if (params.view === 'light') {
492
622
  paged = paged.map(u => ({
493
623
  uniqueId: u.uniqueId,
@@ -553,6 +683,19 @@ export function startWatchServer(outputFile, port = 3000, existingStore) {
553
683
  console.error(` 局域网访问: http://${localIP}:${port}`);
554
684
  _resolve({ server, port });
555
685
  });
686
+
687
+ async function gracefulShutdown(signal) {
688
+ console.error(`\n[server] 收到 ${signal},正在保存数据...`);
689
+ server.close(() => {
690
+ console.error('[server] HTTP 服务已关闭');
691
+ });
692
+ await store.flushSave();
693
+ console.error('[server] 数据已保存,退出');
694
+ process.exit(0);
695
+ }
696
+
697
+ process.on('SIGINT', () => gracefulShutdown('SIGINT'));
698
+ process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
556
699
  });
557
700
  }
558
701