tt-help-cli-ycl 1.3.35 → 1.3.37

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/README.md CHANGED
@@ -14,4 +14,20 @@ npm i -g tt-help
14
14
  tt-help [options] <urls...>
15
15
  ```
16
16
 
17
- 示例、代理配置、和更多用法请查看仓库或 `src/main.mjs` 中的帮助文本。
17
+ ## Current Recommended Entrypoints
18
+
19
+ ```bash
20
+ tt-help db-import --db data/result.db --users data/result.json --done data/result-done.json --videos data/result-videos.json
21
+ tt-help watch -o data/result.db
22
+ tt-help attach -p 5 --server http://127.0.0.1:3001
23
+ tt-help explore --base-port 9223 --server http://127.0.0.1:3001
24
+ tt-help videostats data/result.db -p 3
25
+ ```
26
+
27
+ 说明:
28
+
29
+ - `watch` / `attach` / `explore` / `comments` / `videostats` 运行期只依赖 SQLite
30
+ - legacy JSON 的初始化/迁移通过 `db-import` 显式执行,不再在运行时自动导入
31
+ - 推荐把运行期主库固定为 `data/result.db`
32
+
33
+ 更多命令说明请查看 [FEATURES.md](FEATURES.md) 或帮助文本。
package/cli.js CHANGED
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
- import { fileURLToPath } from 'url';
3
- import { dirname, resolve } from 'path';
2
+ import { fileURLToPath } from "url";
3
+ import { dirname, resolve } from "path";
4
4
 
5
5
  const __filename = fileURLToPath(import.meta.url);
6
6
  const __dirname = dirname(__filename);
7
7
 
8
- const mainPath = resolve(__dirname, 'src', 'main.js');
8
+ const mainPath = resolve(__dirname, "src", "npm-main.js");
9
9
  await import(`file://${mainPath}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tt-help-cli-ycl",
3
- "version": "1.3.35",
3
+ "version": "1.3.37",
4
4
  "description": "TikTok user & video data scraper - extract ttSeller, verified, locationCreated from HTML source",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,7 +13,8 @@
13
13
  "scripts/"
14
14
  ],
15
15
  "scripts": {
16
- "start": "node src/main.js"
16
+ "start": "node src/main.js",
17
+ "test:watch-db-smoke": "node scripts/test-watch-db-smoke.mjs"
17
18
  },
18
19
  "keywords": [
19
20
  "tiktok",
@@ -42,6 +43,10 @@
42
43
  "axios": "^1.16.1",
43
44
  "https-proxy-agent": "^9.0.0",
44
45
  "playwright": "^1.59.1",
46
+ "tt-help-cli-ycl": "^1.3.36",
45
47
  "undici": "^8.1.0"
48
+ },
49
+ "devDependencies": {
50
+ "better-sqlite3": "^12.10.0"
46
51
  }
47
52
  }
@@ -0,0 +1,246 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+
6
+ import { createStore, closeStoreDb } from "../src/watch/data-store.js";
7
+ import { startWatchServer } from "../src/watch/server.js";
8
+
9
+ function createTempDbPath(prefix) {
10
+ const dir = mkdtempSync(path.join(os.tmpdir(), `${prefix}-`));
11
+ return {
12
+ dir,
13
+ dbPath: path.join(dir, "result.db"),
14
+ };
15
+ }
16
+
17
+ function cleanupTempDir(dir) {
18
+ rmSync(dir, { recursive: true, force: true });
19
+ }
20
+
21
+ function seedClaimStore(store) {
22
+ store.addUser({
23
+ uniqueId: "follow-tier2",
24
+ status: "pending",
25
+ followerCount: 100,
26
+ videoCount: 1,
27
+ guessedLocation: "DE",
28
+ sources: ["following"],
29
+ });
30
+ store.addUser({
31
+ uniqueId: "seller-tier2",
32
+ status: "pending",
33
+ followerCount: 50,
34
+ videoCount: 1,
35
+ guessedLocation: "DE",
36
+ ttSeller: true,
37
+ verified: false,
38
+ sources: ["comment"],
39
+ });
40
+ store.addUser({
41
+ uniqueId: "seed-tier1",
42
+ status: "pending",
43
+ followerCount: 10,
44
+ videoCount: 1,
45
+ guessedLocation: "PL",
46
+ sources: ["seed"],
47
+ });
48
+ }
49
+
50
+ function seedWatchStore(store) {
51
+ store.addUser({
52
+ uniqueId: "seed-tier1",
53
+ nickname: "Seed User",
54
+ status: "pending",
55
+ followerCount: 10,
56
+ videoCount: 1,
57
+ guessedLocation: "PL",
58
+ ttSeller: false,
59
+ verified: false,
60
+ sources: ["seed"],
61
+ });
62
+ store.addUser({
63
+ uniqueId: "seller-target",
64
+ nickname: "Seller Target",
65
+ status: "pending",
66
+ followerCount: 500,
67
+ videoCount: 1,
68
+ guessedLocation: "DE",
69
+ locationCreated: "DE",
70
+ ttSeller: true,
71
+ verified: false,
72
+ sources: ["comment"],
73
+ });
74
+ store.addUser({
75
+ uniqueId: "follow-tier2",
76
+ nickname: "Follow User",
77
+ status: "pending",
78
+ followerCount: 300,
79
+ videoCount: 1,
80
+ guessedLocation: "FR",
81
+ ttSeller: false,
82
+ verified: false,
83
+ sources: ["following"],
84
+ });
85
+ store.addUser({
86
+ uniqueId: "pending-update",
87
+ nickname: "Needs Update",
88
+ status: "pending",
89
+ guessedLocation: "US",
90
+ ttSeller: null,
91
+ verified: false,
92
+ userUpdateCount: 0,
93
+ sources: ["comment"],
94
+ });
95
+ store.addUser({
96
+ uniqueId: "done-es",
97
+ nickname: "Done ES",
98
+ status: "done",
99
+ followerCount: 800,
100
+ videoCount: 3,
101
+ guessedLocation: "ES",
102
+ locationCreated: "ES",
103
+ ttSeller: true,
104
+ verified: false,
105
+ processedAt: Date.now(),
106
+ processed: true,
107
+ sources: ["processed"],
108
+ });
109
+ store.addUser({
110
+ uniqueId: "restricted-it",
111
+ nickname: "Restricted IT",
112
+ status: "restricted",
113
+ locationCreated: "IT",
114
+ ttSeller: false,
115
+ verified: false,
116
+ sources: ["comment"],
117
+ });
118
+ store.addUser({
119
+ uniqueId: "error-user",
120
+ nickname: "Error User",
121
+ status: "error",
122
+ locationCreated: "US",
123
+ ttSeller: false,
124
+ verified: false,
125
+ sources: ["comment"],
126
+ });
127
+ }
128
+
129
+ async function testClaimPriorityAndRenewal() {
130
+ const { dir, dbPath } = createTempDbPath("tt-watch-claim");
131
+ try {
132
+ const store = createStore(dbPath);
133
+ seedClaimStore(store);
134
+
135
+ const first = store.claimNextJob("worker-a", 5 * 60 * 1000, null, true);
136
+ assert.ok(first, "expected a claim for logged-in worker");
137
+ assert.equal(first.uniqueId, "seed-tier1");
138
+
139
+ const renewed = store.claimNextJob("worker-a", 5 * 60 * 1000, null, true);
140
+ assert.ok(renewed, "expected renewal claim for same worker");
141
+ assert.equal(renewed.uniqueId, "seed-tier1");
142
+ assert.ok(
143
+ renewed.claimedAt >= first.claimedAt,
144
+ "expected renewal claimedAt to advance or stay equal",
145
+ );
146
+
147
+ const loggedOut = store.claimNextJob(
148
+ "worker-b",
149
+ 5 * 60 * 1000,
150
+ null,
151
+ false,
152
+ );
153
+ assert.ok(loggedOut, "expected a claim for logged-out worker");
154
+ assert.equal(loggedOut.uniqueId, "follow-tier2");
155
+ } finally {
156
+ closeStoreDb();
157
+ cleanupTempDir(dir);
158
+ }
159
+ }
160
+
161
+ async function testWatchHttpEndpoints() {
162
+ const { dir, dbPath } = createTempDbPath("tt-watch-http");
163
+ let server;
164
+ try {
165
+ const store = createStore(dbPath);
166
+ seedWatchStore(store);
167
+
168
+ const started = await startWatchServer(dbPath, 0, store);
169
+ server = started.server;
170
+ const actualPort = server.address().port;
171
+ const baseUrl = `http://127.0.0.1:${actualPort}`;
172
+
173
+ const [statsRes, usersRes, targetRes, lightUsersRes] = await Promise.all([
174
+ fetch(`${baseUrl}/api/stats`),
175
+ fetch(`${baseUrl}/api/users?limit=3`),
176
+ fetch(`${baseUrl}/api/target-users`),
177
+ fetch(`${baseUrl}/api/users?limit=2&view=light`),
178
+ ]);
179
+
180
+ assert.equal(statsRes.status, 200);
181
+ assert.equal(usersRes.status, 200);
182
+ assert.equal(targetRes.status, 200);
183
+ assert.equal(lightUsersRes.status, 200);
184
+
185
+ const [stats, users, targets, lightUsers] = await Promise.all([
186
+ statsRes.json(),
187
+ usersRes.json(),
188
+ targetRes.json(),
189
+ lightUsersRes.json(),
190
+ ]);
191
+
192
+ assert.equal(stats.totalUsers, 7);
193
+ assert.equal(stats.pendingUsers, 4);
194
+ assert.equal(stats.processedUsers, 1);
195
+ assert.equal(stats.restrictedUsers, 1);
196
+ assert.equal(stats.errorUsers, 1);
197
+ assert.equal(stats.targetUsers, 2);
198
+ assert.equal(stats.userUpdateTasks, 1);
199
+ assert.ok(Array.isArray(stats.countryStats));
200
+ assert.ok(stats.countryStats.some((item) => item.country === "ES"));
201
+
202
+ assert.equal(users.total, 7);
203
+ assert.equal(users.users.length, 3);
204
+ assert.equal(users.users[0].uniqueId, "seller-target");
205
+
206
+ assert.equal(targets.total, 2);
207
+ assert.deepEqual(
208
+ targets.users.map((item) => item.uniqueId),
209
+ ["done-es", "seller-target"],
210
+ );
211
+
212
+ assert.equal(lightUsers.total, 7);
213
+ assert.equal(lightUsers.users.length, 2);
214
+ assert.deepEqual(Object.keys(lightUsers.users[0]).sort(), [
215
+ "followerCount",
216
+ "guessedLocation",
217
+ "locationCreated",
218
+ "nickname",
219
+ "pinned",
220
+ "processedAt",
221
+ "sources",
222
+ "status",
223
+ "ttSeller",
224
+ "uniqueId",
225
+ "verified",
226
+ ]);
227
+ } finally {
228
+ if (server) {
229
+ await new Promise((resolve) => server.close(resolve));
230
+ }
231
+ closeStoreDb();
232
+ cleanupTempDir(dir);
233
+ }
234
+ }
235
+
236
+ async function main() {
237
+ await testClaimPriorityAndRenewal();
238
+ await testWatchHttpEndpoints();
239
+ console.log("watch db smoke test passed");
240
+ }
241
+
242
+ main().catch((error) => {
243
+ console.error("watch db smoke test failed");
244
+ console.error(error);
245
+ process.exitCode = 1;
246
+ });
package/src/cli/attach.js CHANGED
@@ -1,4 +1,4 @@
1
- import { TikTokScraper } from '../lib/tiktok-scraper.mjs';
1
+ import { TikTokScraper } from "../lib/tiktok-scraper.mjs";
2
2
 
3
3
  const MAX_RETRY_WAIT = 5 * 60 * 1000;
4
4
 
@@ -8,8 +8,10 @@ async function withRetry(label, fn) {
8
8
  try {
9
9
  return await fn();
10
10
  } catch (err) {
11
- console.error(`[连接] ${label} 失败: ${err.message},${backoff / 1000}秒后重试...`);
12
- await new Promise(r => setTimeout(r, backoff));
11
+ console.error(
12
+ `[连接] ${label} 失败: ${err.message},${backoff / 1000}秒后重试...`,
13
+ );
14
+ await new Promise((r) => setTimeout(r, backoff));
13
15
  if (backoff < MAX_RETRY_WAIT) backoff *= 2;
14
16
  }
15
17
  }
@@ -22,12 +24,11 @@ async function apiGet(url) {
22
24
  });
23
25
  }
24
26
 
25
-
26
27
  async function apiPost(url, body) {
27
28
  return withRetry(`POST ${url}`, async () => {
28
29
  const res = await fetch(url, {
29
- method: 'POST',
30
- headers: { 'Content-Type': 'application/json' },
30
+ method: "POST",
31
+ headers: { "Content-Type": "application/json" },
31
32
  body: JSON.stringify(body),
32
33
  });
33
34
  return res.json();
@@ -36,64 +37,111 @@ async function apiPost(url, body) {
36
37
 
37
38
  function isBrowserClosedError(err) {
38
39
  if (!err) return false;
39
- const msg = err.message || err.toString() || '';
40
- return msg.includes('Target page, context or browser has been closed') ||
41
- msg.includes('browser has been closed') ||
42
- msg.includes('browserContext.newPage') ||
43
- msg.includes('Protocol error');
40
+ const msg = err.message || err.toString() || "";
41
+ return (
42
+ msg.includes("Target page, context or browser has been closed") ||
43
+ msg.includes("browser has been closed") ||
44
+ msg.includes("browserContext.newPage") ||
45
+ msg.includes("Protocol error")
46
+ );
44
47
  }
45
48
 
46
49
  export async function handleAttach(options) {
47
50
  const { attachParallel, attachInterval, serverUrl, showHelp } = options;
51
+ let shuttingDown = false;
52
+ let forceExitTimer = null;
48
53
 
49
54
  if (showHelp) {
50
- console.error('用法: tt-help attach [-p 并行数] [-i 间隔秒数] [-s 服务端地址]');
51
- console.error('');
52
- console.error('参数:');
53
- console.error(' -p, --parallel <N> 并行抓取数(默认: 1)');
54
- console.error(' -i, --interval <N> 无任务时轮询间隔,单位秒(默认: 10)');
55
- console.error(' -s, --server <URL> 服务端地址(默认: http://127.0.0.1:3001');
56
- console.error('');
57
- console.error('说明:');
58
- console.error(' 后台轮询服务端 /api/user-update-tasks 接口,自动抓取 TikTok 用户信息');
59
- console.error(' 抓取完成后通过 PUT /api/user-update-result/{uniqueId} 回传结果');
60
- console.error(' 浏览器崩溃时自动重启,支持长时间无人值守运行');
61
- console.error('');
62
- console.error('示例:');
63
- console.error(' tt-help attach');
64
- console.error(' tt-help attach -p 5 -i 10');
65
- console.error(' tt-help attach -p 3 -i 5 -s http://127.0.0.1:3001');
55
+ console.error(
56
+ "用法: tt-help attach [-p 并行数] [-i 间隔秒数] [-s 服务端地址]",
57
+ );
58
+ console.error("");
59
+ console.error("参数:");
60
+ console.error(" -p, --parallel <N> 并行抓取数(默认: 1)");
61
+ console.error(
62
+ " -i, --interval <N> 无任务时轮询间隔,单位秒(默认: 10)",
63
+ );
64
+ console.error(
65
+ " -s, --server <URL> 服务端地址(默认: http://127.0.0.1:3001)",
66
+ );
67
+ console.error("");
68
+ console.error("说明:");
69
+ console.error(
70
+ " 后台轮询服务端 /api/user-update-tasks 接口,自动抓取 TikTok 用户信息",
71
+ );
72
+ console.error(" 抓取完成后通过 POST /api/user-info-batch 批量回传结果");
73
+ console.error(" 浏览器崩溃时自动重启,支持长时间无人值守运行");
74
+ console.error("");
75
+ console.error("示例:");
76
+ console.error(" tt-help attach");
77
+ console.error(" tt-help attach -p 5 -i 10");
78
+ console.error(" tt-help attach -p 3 -i 5 -s http://127.0.0.1:3001");
66
79
  return;
67
80
  }
68
81
 
69
- console.error(`[Attach] 并行数: ${attachParallel}, 空闲间隔: ${attachInterval}秒, 服务端: ${serverUrl}`);
82
+ console.error(
83
+ `[Attach] 并行数: ${attachParallel}, 空闲间隔: ${attachInterval}秒, 服务端: ${serverUrl}`,
84
+ );
70
85
 
71
86
  const scraper = new TikTokScraper();
87
+ const shutdown = async (signal) => {
88
+ if (shuttingDown) return;
89
+ shuttingDown = true;
90
+ forceExitTimer = setTimeout(() => {
91
+ console.error("[Attach] 关闭超时,强制退出");
92
+ process.exit(0);
93
+ }, 8000);
94
+ forceExitTimer.unref?.();
95
+ console.error(`\n[Attach] 收到 ${signal},正在关闭浏览器...`);
96
+ await scraper.close().catch(() => {});
97
+ if (forceExitTimer) {
98
+ clearTimeout(forceExitTimer);
99
+ forceExitTimer = null;
100
+ }
101
+ console.error("[Attach] 已退出");
102
+ process.exit(0);
103
+ };
104
+
105
+ const onSigint = () => {
106
+ void shutdown("SIGINT");
107
+ };
108
+ const onSigterm = () => {
109
+ void shutdown("SIGTERM");
110
+ };
111
+
112
+ process.on("SIGINT", onSigint);
113
+ process.on("SIGTERM", onSigterm);
72
114
 
73
115
  try {
74
116
  await scraper.init();
75
- console.error('[Attach] 浏览器初始化完成,开始循环接收任务...');
117
+ console.error("[Attach] 浏览器初始化完成,开始循环接收任务...");
76
118
 
77
119
  let loopCount = 0;
78
120
  let browserRestartCount = 0;
79
121
 
80
- while (true) {
122
+ while (!shuttingDown) {
81
123
  loopCount++;
82
124
 
83
125
  // 检查浏览器是否存活,不存活则重启
84
126
  if (!scraper.isAlive()) {
85
- console.error(`[Attach] 浏览器已关闭,正在重启 (${++browserRestartCount})...`);
127
+ console.error(
128
+ `[Attach] 浏览器已关闭,正在重启 (${++browserRestartCount})...`,
129
+ );
86
130
  await scraper.restart();
87
- console.error('[Attach] 浏览器重启完成');
131
+ console.error("[Attach] 浏览器重启完成");
88
132
  }
89
133
 
90
- const { total, tasks } = await apiGet(`${serverUrl}/api/user-update-tasks?limit=${attachParallel}`);
134
+ const { total, tasks } = await apiGet(
135
+ `${serverUrl}/api/user-update-tasks?limit=${attachParallel}`,
136
+ );
91
137
 
92
138
  if (!tasks || tasks.length === 0) {
93
139
  if (loopCount === 1) {
94
- console.error(`[Attach] 当前无待更新任务,${attachInterval}秒后重试...`);
140
+ console.error(
141
+ `[Attach] 当前无待更新任务,${attachInterval}秒后重试...`,
142
+ );
95
143
  }
96
- await new Promise(r => setTimeout(r, attachInterval * 1000));
144
+ await new Promise((r) => setTimeout(r, attachInterval * 1000));
97
145
  continue;
98
146
  }
99
147
 
@@ -109,72 +157,84 @@ export async function handleAttach(options) {
109
157
  } catch (err) {
110
158
  return { uniqueId, info: null, error: err };
111
159
  }
112
- })
160
+ }),
113
161
  );
114
162
 
115
- let successCount = 0;
116
- let failCount = 0;
117
- let needRestart = false;
118
-
119
- // 收集抓取成功的任务,记录抓取失败的
120
- const successTasks = [];
121
- for (const result of results) {
122
- if (result.status === 'fulfilled') {
123
- const { uniqueId, info, error } = result.value;
124
- if (error) {
125
- if (isBrowserClosedError(error)) {
126
- needRestart = true;
127
- }
128
- console.error(` ✗ @${uniqueId} 获取失败: ${error.message || '未知错误'}`);
129
- failCount++;
130
- } else if (info) {
131
- successTasks.push({ uniqueId, info });
132
- } else {
133
- console.error(` ✗ @${uniqueId} 未获取到用户信息`);
134
- failCount++;
135
- }
136
- } else {
137
- console.error(` ✗ 任务执行异常: ${result.reason?.message || '未知错误'}`);
138
- failCount++;
139
- }
140
- }
141
-
142
- // 批量提交成功的结果
143
- if (successTasks.length > 0) {
144
- try {
145
- const batchRet = await apiPost(`${serverUrl}/api/user-info-batch`, { updates: successTasks });
146
- if (batchRet && batchRet.results) {
147
- for (const r of batchRet.results) {
148
- if (r.ok) {
149
- successCount++;
150
- console.error(` ✓ @${r.uniqueId} 已提交更新`);
151
- } else {
152
- failCount++;
153
- console.error(` ✗ @${r.uniqueId} 提交失败: ${r.error}`);
154
- }
155
- }
156
- } else {
157
- successCount = successTasks.length;
158
- console.error(` ✓ 批量提交完成 (${successTasks.length} 条)`);
159
- }
160
- } catch (err) {
161
- failCount += successTasks.length;
162
- console.error(` ✗ 批量提交失败: ${err.message}`);
163
- }
164
- }
163
+ let successCount = 0;
164
+ let failCount = 0;
165
+ let needRestart = false;
166
+
167
+ // 收集抓取成功的任务,记录抓取失败的
168
+ const successTasks = [];
169
+ for (const result of results) {
170
+ if (result.status === "fulfilled") {
171
+ const { uniqueId, info, error } = result.value;
172
+ if (error) {
173
+ if (isBrowserClosedError(error)) {
174
+ needRestart = true;
175
+ }
176
+ console.error(
177
+ ` ✗ @${uniqueId} 获取失败: ${error.message || "未知错误"}`,
178
+ );
179
+ failCount++;
180
+ } else if (info) {
181
+ successTasks.push({ uniqueId, info });
182
+ } else {
183
+ console.error(` ✗ @${uniqueId} 未获取到用户信息`);
184
+ failCount++;
185
+ }
186
+ } else {
187
+ console.error(
188
+ ` ✗ 任务执行异常: ${result.reason?.message || "未知错误"}`,
189
+ );
190
+ failCount++;
191
+ }
192
+ }
193
+
194
+ // 批量提交成功的结果
195
+ if (successTasks.length > 0) {
196
+ try {
197
+ const batchRet = await apiPost(`${serverUrl}/api/user-info-batch`, {
198
+ updates: successTasks,
199
+ });
200
+ if (batchRet && batchRet.results) {
201
+ for (const r of batchRet.results) {
202
+ if (r.ok) {
203
+ successCount++;
204
+ console.error(` ✓ @${r.uniqueId} 已提交更新`);
205
+ } else {
206
+ failCount++;
207
+ console.error(` ✗ @${r.uniqueId} 提交失败: ${r.error}`);
208
+ }
209
+ }
210
+ } else {
211
+ successCount = successTasks.length;
212
+ console.error(` ✓ 批量提交完成 (${successTasks.length} 条)`);
213
+ }
214
+ } catch (err) {
215
+ failCount += successTasks.length;
216
+ console.error(` ✗ 批量提交失败: ${err.message}`);
217
+ }
218
+ }
165
219
 
166
220
  console.error(` 本批结果: ${successCount} 成功, ${failCount} 失败\n`);
167
221
 
168
222
  if (needRestart) {
169
- console.error('[Attach] 检测到浏览器异常,将在下一轮重启...');
223
+ console.error("[Attach] 检测到浏览器异常,将在下一轮重启...");
170
224
  }
171
225
 
172
- await new Promise(r => setTimeout(r, 500));
226
+ await new Promise((r) => setTimeout(r, 500));
173
227
  }
174
228
  } catch (err) {
175
229
  console.error(`[Attach] 运行异常: ${err.message}`);
176
230
  throw err;
177
231
  } finally {
178
- await scraper.close();
232
+ if (forceExitTimer) {
233
+ clearTimeout(forceExitTimer);
234
+ forceExitTimer = null;
235
+ }
236
+ process.removeListener("SIGINT", onSigint);
237
+ process.removeListener("SIGTERM", onSigterm);
238
+ await scraper.close().catch(() => {});
179
239
  }
180
240
  }