tt-help-cli-ycl 1.3.4 → 1.3.6
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 +1 -1
- package/src/cli/auto.js +62 -35
- package/src/cli/explore.js +59 -37
- package/src/cli/watch.js +5 -2
- package/src/lib/browser/page.js +7 -0
- package/src/scraper/auto-core.mjs +2 -0
- package/src/scraper/core.mjs +2 -0
- package/src/scraper/explore-core.mjs +3 -0
- package/src/scraper/modules/page-helpers.mjs +2 -0
- package/src/watch/data-store.mjs +40 -18
- package/src/watch/public/index.html +25 -6
- package/src/watch/server.mjs +7 -2
package/package.json
CHANGED
package/src/cli/auto.js
CHANGED
|
@@ -1,17 +1,36 @@
|
|
|
1
1
|
import { getOrCreatePage } from '../lib/browser/page.js';
|
|
2
2
|
|
|
3
|
+
const MAX_RETRY_WAIT = 5 * 60 * 1000;
|
|
4
|
+
|
|
5
|
+
async function withRetry(label, fn) {
|
|
6
|
+
let backoff = 1000;
|
|
7
|
+
while (true) {
|
|
8
|
+
try {
|
|
9
|
+
return await fn();
|
|
10
|
+
} catch (err) {
|
|
11
|
+
console.error(`[连接] ${label} 失败: ${err.message},${backoff / 1000}秒后重试...`);
|
|
12
|
+
await new Promise(r => setTimeout(r, backoff));
|
|
13
|
+
if (backoff < MAX_RETRY_WAIT) backoff *= 2;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
3
18
|
async function apiPost(url, body) {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
19
|
+
return withRetry(`POST ${url}`, async () => {
|
|
20
|
+
const res = await fetch(url, {
|
|
21
|
+
method: 'POST',
|
|
22
|
+
headers: { 'Content-Type': 'application/json' },
|
|
23
|
+
body: JSON.stringify(body),
|
|
24
|
+
});
|
|
25
|
+
return res.json();
|
|
8
26
|
});
|
|
9
|
-
return res.json();
|
|
10
27
|
}
|
|
11
28
|
|
|
12
29
|
async function apiGet(url) {
|
|
13
|
-
|
|
14
|
-
|
|
30
|
+
return withRetry(`GET ${url}`, async () => {
|
|
31
|
+
const res = await fetch(url);
|
|
32
|
+
return res.json();
|
|
33
|
+
});
|
|
15
34
|
}
|
|
16
35
|
|
|
17
36
|
export async function handleAuto(options) {
|
|
@@ -32,44 +51,54 @@ export async function handleAuto(options) {
|
|
|
32
51
|
maxFollowers: autoMaxFollowers,
|
|
33
52
|
};
|
|
34
53
|
|
|
35
|
-
|
|
36
|
-
await apiGet(`${serverUrl}/api/stats`);
|
|
37
|
-
} catch {
|
|
38
|
-
console.error(`无法连接服务端: ${serverUrl}`);
|
|
39
|
-
console.error('请先启动服务端: tt-help watch -o <数据文件>');
|
|
40
|
-
process.exit(1);
|
|
41
|
-
}
|
|
54
|
+
await apiGet(`${serverUrl}/api/stats`);
|
|
42
55
|
|
|
43
56
|
if (autoUsernames.length > 0) {
|
|
44
57
|
const { added, skipped } = await apiPost(`${serverUrl}/api/users`, { usernames: autoUsernames });
|
|
45
58
|
console.error(`种子用户: ${added} 个新增, ${skipped} 个已存在`);
|
|
46
59
|
}
|
|
47
60
|
|
|
61
|
+
console.error(`服务器: ${serverUrl}(断开会自动重连)`);
|
|
62
|
+
|
|
48
63
|
const { ensureBrowserReady, processUser } = await import('../scraper/auto-core.mjs');
|
|
49
64
|
const browser = await ensureBrowserReady();
|
|
50
65
|
|
|
51
|
-
|
|
52
|
-
const page = await getOrCreatePage(browser);
|
|
66
|
+
const page = await getOrCreatePage(browser);
|
|
53
67
|
|
|
54
|
-
|
|
55
|
-
|
|
68
|
+
let processedCount = 0;
|
|
69
|
+
let errorCount = 0;
|
|
56
70
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
71
|
+
while (true) {
|
|
72
|
+
const job = await apiGet(`${serverUrl}/api/job`);
|
|
73
|
+
if (!job.hasJob) break;
|
|
60
74
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
75
|
+
const username = job.user.uniqueId;
|
|
76
|
+
processedCount++;
|
|
77
|
+
let proxyRetry = 0;
|
|
78
|
+
|
|
79
|
+
while (true) {
|
|
80
|
+
console.error(`\n[${processedCount}] 处理 @${username}...${proxyRetry > 0 ? ` (代理重试 ${proxyRetry})` : ''}`);
|
|
64
81
|
|
|
65
82
|
const result = await processUser(page, username, { ...runOptions, browser }, console.error);
|
|
66
83
|
|
|
67
|
-
if (result.restricted
|
|
68
|
-
if (result.error) errorCount++;
|
|
84
|
+
if (result.restricted) {
|
|
69
85
|
await apiPost(`${serverUrl}/api/job/${username}`, result);
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (result.error && result.error.includes('代理错误')) {
|
|
90
|
+
proxyRetry++;
|
|
91
|
+
console.error(` [代理错误] ${result.error},等待 10s 后重试...`);
|
|
92
|
+
await new Promise(r => setTimeout(r, 10000));
|
|
70
93
|
continue;
|
|
71
94
|
}
|
|
72
95
|
|
|
96
|
+
if (result.error) {
|
|
97
|
+
errorCount++;
|
|
98
|
+
await apiPost(`${serverUrl}/api/job/${username}`, result);
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
|
|
73
102
|
const payload = {
|
|
74
103
|
userInfo: result.userInfo || {},
|
|
75
104
|
discoveredVideoAuthors: result.discoveredVideoAuthors || [],
|
|
@@ -80,15 +109,13 @@ export async function handleAuto(options) {
|
|
|
80
109
|
};
|
|
81
110
|
await apiPost(`${serverUrl}/api/job/${username}`, payload);
|
|
82
111
|
console.error(' 已提交');
|
|
112
|
+
break;
|
|
83
113
|
}
|
|
84
|
-
|
|
85
|
-
const stats = await apiGet(`${serverUrl}/api/stats`);
|
|
86
|
-
console.error(`\n完成: ${processedCount} 个用户处理, ${errorCount} 个出错`);
|
|
87
|
-
console.error(` 总用户: ${stats.totalUsers}, 已完成: ${stats.processedUsers}, 待处理: ${stats.pendingUsers}, 错误: ${stats.errorUsers}`);
|
|
88
|
-
} catch (err) {
|
|
89
|
-
console.error(`自动抓取失败: ${err.message}`);
|
|
90
|
-
process.exit(1);
|
|
91
|
-
} finally {
|
|
92
|
-
await browser.close().catch(() => {});
|
|
93
114
|
}
|
|
115
|
+
|
|
116
|
+
const stats = await apiGet(`${serverUrl}/api/stats`);
|
|
117
|
+
console.error(`\n完成: ${processedCount} 个用户处理, ${errorCount} 个出错`);
|
|
118
|
+
console.error(` 总用户: ${stats.totalUsers}, 已完成: ${stats.processedUsers}, 待处理: ${stats.pendingUsers}, 错误: ${stats.errorUsers}`);
|
|
119
|
+
|
|
120
|
+
await browser.close().catch(() => {});
|
|
94
121
|
}
|
package/src/cli/explore.js
CHANGED
|
@@ -1,18 +1,37 @@
|
|
|
1
1
|
import { getOrCreatePage } from '../lib/browser/page.js';
|
|
2
2
|
import { delay, getDelayConfig, setDelayConfig } from '../scraper/modules/page-helpers.mjs';
|
|
3
3
|
|
|
4
|
+
const MAX_RETRY_WAIT = 5 * 60 * 1000;
|
|
5
|
+
|
|
6
|
+
async function withRetry(label, fn) {
|
|
7
|
+
let backoff = 1000;
|
|
8
|
+
while (true) {
|
|
9
|
+
try {
|
|
10
|
+
return await fn();
|
|
11
|
+
} catch (err) {
|
|
12
|
+
console.error(`[连接] ${label} 失败: ${err.message},${backoff / 1000}秒后重试...`);
|
|
13
|
+
await new Promise(r => setTimeout(r, backoff));
|
|
14
|
+
if (backoff < MAX_RETRY_WAIT) backoff *= 2;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
4
19
|
async function apiPost(url, body) {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
20
|
+
return withRetry(`POST ${url}`, async () => {
|
|
21
|
+
const res = await fetch(url, {
|
|
22
|
+
method: 'POST',
|
|
23
|
+
headers: { 'Content-Type': 'application/json' },
|
|
24
|
+
body: JSON.stringify(body),
|
|
25
|
+
});
|
|
26
|
+
return res.json();
|
|
9
27
|
});
|
|
10
|
-
return res.json();
|
|
11
28
|
}
|
|
12
29
|
|
|
13
30
|
async function apiGet(url) {
|
|
14
|
-
|
|
15
|
-
|
|
31
|
+
return withRetry(`GET ${url}`, async () => {
|
|
32
|
+
const res = await fetch(url);
|
|
33
|
+
return res.json();
|
|
34
|
+
});
|
|
16
35
|
}
|
|
17
36
|
|
|
18
37
|
export async function handleExplore(options) {
|
|
@@ -24,12 +43,7 @@ export async function handleExplore(options) {
|
|
|
24
43
|
|
|
25
44
|
setDelayConfig(explorePreset);
|
|
26
45
|
|
|
27
|
-
|
|
28
|
-
await apiGet(`${serverUrl}/api/stats`);
|
|
29
|
-
} catch {
|
|
30
|
-
console.error(`无法连接服务端: ${serverUrl},退出`);
|
|
31
|
-
process.exit(1);
|
|
32
|
-
}
|
|
46
|
+
await apiGet(`${serverUrl}/api/stats`);
|
|
33
47
|
|
|
34
48
|
if (exploreUsernames && exploreUsernames.length > 0) {
|
|
35
49
|
const { added, skipped } = await apiPost(`${serverUrl}/api/users`, { usernames: exploreUsernames });
|
|
@@ -39,24 +53,27 @@ export async function handleExplore(options) {
|
|
|
39
53
|
console.error(`\n国家筛选: ${exploreLocation}`);
|
|
40
54
|
console.error(`评论: ${exploreMaxComments}, 猜你喜欢: ${exploreMaxGuess}`);
|
|
41
55
|
console.error(`关注/粉丝: ${exploreEnableFollow ? '启用' : '禁用'}`);
|
|
56
|
+
console.error(`服务器: ${serverUrl}(断开会自动重连)`);
|
|
42
57
|
if (exploreMaxUsers > 0) console.error(`上限: ${exploreMaxUsers} 个用户`);
|
|
43
58
|
|
|
44
59
|
const { ensureBrowserReady, processExplore } = await import('../scraper/explore-core.mjs');
|
|
45
60
|
const browser = await ensureBrowserReady();
|
|
46
61
|
|
|
47
|
-
|
|
48
|
-
const page = await getOrCreatePage(browser);
|
|
62
|
+
const page = await getOrCreatePage(browser);
|
|
49
63
|
|
|
50
|
-
|
|
51
|
-
|
|
64
|
+
let processedCount = 0;
|
|
65
|
+
let errorCount = 0;
|
|
52
66
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
67
|
+
while (true) {
|
|
68
|
+
const job = await apiGet(`${serverUrl}/api/job`);
|
|
69
|
+
if (!job.hasJob) break;
|
|
56
70
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
71
|
+
const username = job.user.uniqueId;
|
|
72
|
+
processedCount++;
|
|
73
|
+
let proxyRetry = 0;
|
|
74
|
+
|
|
75
|
+
while (true) {
|
|
76
|
+
console.error(`\n[${processedCount}] 探索 @${username}...${proxyRetry > 0 ? ` (代理重试 ${proxyRetry})` : ''}`);
|
|
60
77
|
|
|
61
78
|
const { switchMax } = getDelayConfig();
|
|
62
79
|
await delay(switchMax, switchMax * 3);
|
|
@@ -73,13 +90,20 @@ export async function handleExplore(options) {
|
|
|
73
90
|
|
|
74
91
|
if (result.restricted) {
|
|
75
92
|
await apiPost(`${serverUrl}/api/job/${username}`, { restricted: true, userInfo: result.userInfo || {} });
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (result.error && result.error.includes('代理错误')) {
|
|
97
|
+
proxyRetry++;
|
|
98
|
+
console.error(` [代理错误] ${result.error},等待 10s 后重试...`);
|
|
99
|
+
await new Promise(r => setTimeout(r, 10000));
|
|
76
100
|
continue;
|
|
77
101
|
}
|
|
78
102
|
|
|
79
103
|
if (result.error) {
|
|
80
104
|
errorCount++;
|
|
81
105
|
await apiPost(`${serverUrl}/api/job/${username}`, { error: result.error });
|
|
82
|
-
|
|
106
|
+
break;
|
|
83
107
|
}
|
|
84
108
|
|
|
85
109
|
const payload = {
|
|
@@ -97,20 +121,18 @@ export async function handleExplore(options) {
|
|
|
97
121
|
};
|
|
98
122
|
await apiPost(`${serverUrl}/api/job/${username}`, payload);
|
|
99
123
|
console.error(' 已提交');
|
|
100
|
-
|
|
101
|
-
if (exploreMaxUsers > 0 && processedCount >= exploreMaxUsers) {
|
|
102
|
-
console.error(`\n已达上限 ${exploreMaxUsers} 个用户,停止处理`);
|
|
103
|
-
break;
|
|
104
|
-
}
|
|
124
|
+
break;
|
|
105
125
|
}
|
|
106
126
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
console.error(`探索失败: ${err.message}`);
|
|
112
|
-
process.exit(1);
|
|
113
|
-
} finally {
|
|
114
|
-
await browser.close().catch(() => {});
|
|
127
|
+
if (exploreMaxUsers > 0 && processedCount >= exploreMaxUsers) {
|
|
128
|
+
console.error(`\n已达上限 ${exploreMaxUsers} 个用户,停止处理`);
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
115
131
|
}
|
|
132
|
+
|
|
133
|
+
const stats = await apiGet(`${serverUrl}/api/stats`);
|
|
134
|
+
console.error(`\n完成: ${processedCount} 个用户处理, ${errorCount} 个出错`);
|
|
135
|
+
console.error(` 总用户: ${stats.totalUsers}, 已完成: ${stats.processedUsers}, 待处理: ${stats.pendingUsers}, 错误: ${stats.errorUsers}`);
|
|
136
|
+
|
|
137
|
+
await browser.close().catch(() => {});
|
|
116
138
|
}
|
package/src/cli/watch.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { existsSync } from 'fs';
|
|
2
|
+
import { createStore } from '../watch/data-store.mjs';
|
|
2
3
|
import { startWatchServer, openBrowser } from '../watch/server.mjs';
|
|
3
4
|
|
|
4
5
|
export async function handleWatch(options) {
|
|
@@ -16,10 +17,12 @@ export async function handleWatch(options) {
|
|
|
16
17
|
process.exit(1);
|
|
17
18
|
}
|
|
18
19
|
|
|
19
|
-
const
|
|
20
|
+
const store = createStore(outputFile);
|
|
21
|
+
const { server, port } = await startWatchServer(outputFile, watchPort, store);
|
|
20
22
|
openBrowser(port);
|
|
21
23
|
|
|
22
24
|
process.once('SIGINT', () => {
|
|
25
|
+
store.stopBackup();
|
|
23
26
|
server.close();
|
|
24
27
|
process.exit(0);
|
|
25
28
|
});
|
package/src/lib/browser/page.js
CHANGED
|
@@ -78,3 +78,10 @@ export async function getOrCreatePage(browser) {
|
|
|
78
78
|
}
|
|
79
79
|
return page;
|
|
80
80
|
}
|
|
81
|
+
|
|
82
|
+
export function assertPageUrl(page, expectedPath) {
|
|
83
|
+
const actual = page.url();
|
|
84
|
+
if (!actual.includes(expectedPath)) {
|
|
85
|
+
throw new Error(`[代理错误] 预期访问 ${expectedPath},实际跳转到了 ${actual}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
retryWithBackoff,
|
|
9
9
|
detectPageError,
|
|
10
10
|
isLoggedIn,
|
|
11
|
+
assertPageUrl,
|
|
11
12
|
} from './modules/page-helpers.mjs';
|
|
12
13
|
export { ensureBrowserReady };
|
|
13
14
|
import {
|
|
@@ -66,6 +67,7 @@ async function processUser(page, username, options, log) {
|
|
|
66
67
|
await retryWithBackoff(() => page.goto(`https://www.tiktok.com/@${username}`, {
|
|
67
68
|
waitUntil: 'load', timeout: 30000,
|
|
68
69
|
}), { log });
|
|
70
|
+
assertPageUrl(page, `@${username}`);
|
|
69
71
|
await page.waitForSelector('[class*="DivVideoList"]', { timeout: 10000 }).catch(() => {});
|
|
70
72
|
await delay(1000, 2000);
|
|
71
73
|
|
package/src/scraper/core.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
setDelayConfig,
|
|
7
7
|
getDelayConfig,
|
|
8
8
|
retryWithBackoff,
|
|
9
|
+
assertPageUrl,
|
|
9
10
|
} from './modules/page-helpers.mjs';
|
|
10
11
|
import { extractCommentAuthors } from './modules/comment-extractor.mjs';
|
|
11
12
|
import { extractGuessVideos } from './modules/guess-extractor.mjs';
|
|
@@ -100,6 +101,7 @@ async function runScrape(options) {
|
|
|
100
101
|
}
|
|
101
102
|
|
|
102
103
|
await retryWithBackoff(() => page.goto(videoUrl, { waitUntil: 'load', timeout: 30000 }), { log });
|
|
104
|
+
assertPageUrl(page, videoUrl.split('/video/')[0]);
|
|
103
105
|
await delay(Math.round(config.switchMax * 0.5), config.switchMax);
|
|
104
106
|
await closeCommentPanel(page);
|
|
105
107
|
await delay(Math.round(config.commentMax * 0.5), config.commentMax);
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
retryWithBackoff,
|
|
7
7
|
detectPageError,
|
|
8
8
|
isLoggedIn,
|
|
9
|
+
assertPageUrl,
|
|
9
10
|
} from './modules/page-helpers.mjs';
|
|
10
11
|
export { ensureBrowserReady };
|
|
11
12
|
import {
|
|
@@ -47,6 +48,7 @@ async function processExplore(page, username, options, log) {
|
|
|
47
48
|
log(` 访问 @${username} 主页...`);
|
|
48
49
|
const homeUrl = `https://www.tiktok.com/@${username}`;
|
|
49
50
|
await retryWithBackoff(() => page.goto(homeUrl, { waitUntil: 'domcontentloaded', timeout: 30000 }), { log });
|
|
51
|
+
assertPageUrl(page, `@${username}`);
|
|
50
52
|
await page.waitForSelector('[class*="DivVideoList"]', { timeout: 10000 }).catch(() => {});
|
|
51
53
|
await delay(1000, 2000);
|
|
52
54
|
|
|
@@ -107,6 +109,7 @@ async function processExplore(page, username, options, log) {
|
|
|
107
109
|
|
|
108
110
|
log(` 进入第一个视频: ${videoUrl}`);
|
|
109
111
|
await retryWithBackoff(() => page.goto(videoUrl, { waitUntil: 'domcontentloaded', timeout: 30000 }), { log });
|
|
112
|
+
assertPageUrl(page, videoUrl.split('/video/')[0]);
|
|
110
113
|
await delay(1500, 2500);
|
|
111
114
|
|
|
112
115
|
const videoData = await scrapeSingleVideo(page, 0, 0, log, 'NEVER_MATCH');
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
findTikTokPage,
|
|
13
13
|
getOrCreatePage,
|
|
14
14
|
isLoggedIn,
|
|
15
|
+
assertPageUrl,
|
|
15
16
|
} from '../../lib/browser/page.js';
|
|
16
17
|
import { retryWithBackoff, isRetryableError } from '../../lib/retry.js';
|
|
17
18
|
import {
|
|
@@ -34,6 +35,7 @@ export {
|
|
|
34
35
|
findTikTokPage,
|
|
35
36
|
getOrCreatePage,
|
|
36
37
|
isLoggedIn,
|
|
38
|
+
assertPageUrl,
|
|
37
39
|
retryWithBackoff,
|
|
38
40
|
isRetryableError,
|
|
39
41
|
extractUserSection,
|
package/src/watch/data-store.mjs
CHANGED
|
@@ -11,18 +11,47 @@ function inferStatus(u) {
|
|
|
11
11
|
export function createStore(filePath) {
|
|
12
12
|
let data = [];
|
|
13
13
|
|
|
14
|
+
let backupTimer = null;
|
|
15
|
+
|
|
14
16
|
if (filePath) {
|
|
15
17
|
const resolved = path.resolve(filePath);
|
|
18
|
+
const backupDir = path.join(path.dirname(resolved), '.backup');
|
|
19
|
+
const maxBackups = 3;
|
|
20
|
+
|
|
16
21
|
if (fs.existsSync(resolved)) {
|
|
17
22
|
try {
|
|
18
|
-
const
|
|
19
|
-
data = JSON.parse(
|
|
20
|
-
if (!Array.isArray(data))
|
|
23
|
+
const content = fs.readFileSync(resolved, 'utf-8');
|
|
24
|
+
data = JSON.parse(content);
|
|
25
|
+
if (!Array.isArray(data)) {
|
|
26
|
+
data = [];
|
|
27
|
+
}
|
|
21
28
|
} catch (e) {
|
|
22
29
|
console.error(`[data-store] 读取文件失败: ${e.message}`);
|
|
23
30
|
data = [];
|
|
24
31
|
}
|
|
25
32
|
}
|
|
33
|
+
|
|
34
|
+
function runBackup() {
|
|
35
|
+
if (!fs.existsSync(resolved)) return;
|
|
36
|
+
if (!fs.existsSync(backupDir)) fs.mkdirSync(backupDir, { recursive: true });
|
|
37
|
+
const now = new Date();
|
|
38
|
+
const timestamp = now.toISOString().replace(/[:.]/g, '-').slice(0, 13);
|
|
39
|
+
const backupFile = path.join(backupDir, `data-${timestamp}.json`);
|
|
40
|
+
try {
|
|
41
|
+
fs.copyFileSync(resolved, backupFile);
|
|
42
|
+
const files = fs.readdirSync(backupDir)
|
|
43
|
+
.filter(f => f.startsWith('data-') && f.endsWith('.json'))
|
|
44
|
+
.sort()
|
|
45
|
+
.map(f => path.join(backupDir, f));
|
|
46
|
+
while (files.length > maxBackups) {
|
|
47
|
+
fs.unlinkSync(files.shift());
|
|
48
|
+
}
|
|
49
|
+
} catch (e) {
|
|
50
|
+
console.error(`[data-store] 备份失败: ${e.message}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
backupTimer = setInterval(runBackup, 60 * 60 * 1000);
|
|
26
55
|
}
|
|
27
56
|
|
|
28
57
|
for (const u of data) {
|
|
@@ -32,25 +61,17 @@ export function createStore(filePath) {
|
|
|
32
61
|
function save() {
|
|
33
62
|
if (!filePath) return;
|
|
34
63
|
const resolved = path.resolve(filePath);
|
|
35
|
-
try {
|
|
36
|
-
if (fs.existsSync(resolved)) {
|
|
37
|
-
const raw = fs.readFileSync(resolved, 'utf-8');
|
|
38
|
-
const diskData = JSON.parse(raw);
|
|
39
|
-
if (Array.isArray(diskData)) {
|
|
40
|
-
const memIds = new Set(data.map(u => u.uniqueId));
|
|
41
|
-
for (const diskUser of diskData) {
|
|
42
|
-
if (!memIds.has(diskUser.uniqueId)) {
|
|
43
|
-
if (!diskUser.status) diskUser.status = inferStatus(diskUser);
|
|
44
|
-
data.push(diskUser);
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
} catch (e) { console.error(`[data-store] 合并磁盘数据失败: ${e.message}`); }
|
|
50
64
|
const json = JSON.stringify(data, null, 2);
|
|
51
65
|
fs.writeFileSync(resolved, json, 'utf-8');
|
|
52
66
|
}
|
|
53
67
|
|
|
68
|
+
function stopBackup() {
|
|
69
|
+
if (backupTimer) {
|
|
70
|
+
clearInterval(backupTimer);
|
|
71
|
+
backupTimer = null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
54
75
|
function getUser(uid) {
|
|
55
76
|
return data.find(u => u.uniqueId === uid);
|
|
56
77
|
}
|
|
@@ -234,6 +255,7 @@ export function createStore(filePath) {
|
|
|
234
255
|
save, getUser, hasUser, addUser,
|
|
235
256
|
getPendingUsers, getProcessedUsers, getAllUsers,
|
|
236
257
|
claimNextJob, commitJob, resetJob, togglePin,
|
|
258
|
+
stopBackup,
|
|
237
259
|
data,
|
|
238
260
|
};
|
|
239
261
|
}
|
|
@@ -11,7 +11,7 @@
|
|
|
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
|
-
.stats { display: grid; grid-template-columns: repeat(
|
|
14
|
+
.stats { display: grid; grid-template-columns: repeat(7, 1fr); gap: 12px; margin-bottom: 16px; }
|
|
15
15
|
.stat-card { background: #1a1a24; border-radius: 8px; padding: 16px; text-align: center; }
|
|
16
16
|
.stat-card .label { font-size: 12px; color: #888; margin-bottom: 8px; }
|
|
17
17
|
.stat-card .value { font-size: 28px; font-weight: 700; }
|
|
@@ -96,7 +96,8 @@
|
|
|
96
96
|
<div class="stat-card"><div class="label">处理中</div><div class="value total" id="statProcessing">0</div></div>
|
|
97
97
|
<div class="stat-card"><div class="label">已完成</div><div class="value done" id="statDone">0</div></div>
|
|
98
98
|
<div class="stat-card"><div class="label">待处理</div><div class="value pending" id="statPending">0</div></div>
|
|
99
|
-
<div class="stat-card"><div class="label"
|
|
99
|
+
<div class="stat-card"><div class="label">错误</div><div class="value error" id="statError">0</div></div>
|
|
100
|
+
<div class="stat-card"><div class="label">受限</div><div class="value error" id="statRestricted">0</div></div>
|
|
100
101
|
<div class="stat-card clickable" id="statTargetCard"><div class="label">目标用户(ES商家)</div><div class="value target" id="statTarget">0</div></div>
|
|
101
102
|
</div>
|
|
102
103
|
<div class="charts">
|
|
@@ -125,6 +126,7 @@
|
|
|
125
126
|
<button data-filter="done" onclick="setFilter('done')">已处理</button>
|
|
126
127
|
<button data-filter="error" onclick="setFilter('error')">错误</button>
|
|
127
128
|
<button data-filter="restricted" onclick="setFilter('restricted')">受限</button>
|
|
129
|
+
<button data-filter="target" onclick="setFilter('target')" style="background:#7c3aed;color:#fff">目标用户</button>
|
|
128
130
|
</div>
|
|
129
131
|
<div class="table-scroll">
|
|
130
132
|
<table>
|
|
@@ -156,7 +158,11 @@ async function fetchStats() {
|
|
|
156
158
|
async function fetchUsers() {
|
|
157
159
|
try {
|
|
158
160
|
const params = new URLSearchParams();
|
|
159
|
-
if (currentFilter
|
|
161
|
+
if (currentFilter === 'target') {
|
|
162
|
+
params.set('target', '1');
|
|
163
|
+
} else if (currentFilter !== 'all') {
|
|
164
|
+
params.set('status', currentFilter);
|
|
165
|
+
}
|
|
160
166
|
const search = document.getElementById('searchInput').value.trim();
|
|
161
167
|
if (search) params.set('search', search);
|
|
162
168
|
params.set('limit', '200');
|
|
@@ -187,7 +193,8 @@ function renderStats() {
|
|
|
187
193
|
flashEl('statProcessing', d.processingUsers || 0);
|
|
188
194
|
flashEl('statDone', d.processedUsers);
|
|
189
195
|
flashEl('statPending', d.pendingUsers);
|
|
190
|
-
flashEl('statError', d.
|
|
196
|
+
flashEl('statError', d.errorUsers);
|
|
197
|
+
flashEl('statRestricted', d.restrictedUsers);
|
|
191
198
|
flashEl('statTarget', d.targetUsers);
|
|
192
199
|
document.getElementById('lastUpdate').textContent = '\u66f4\u65b0\u4e8e ' + new Date().toLocaleTimeString();
|
|
193
200
|
document.getElementById('fileMeta').textContent = (d.processingUsers || 0) + ' \u5904\u7406\u4e2d, ' + d.totalUsers + ' \u4e2a\u7528\u6237';
|
|
@@ -431,8 +438,20 @@ document.getElementById('statTargetCard').addEventListener('click', async () =>
|
|
|
431
438
|
const data = await res.json();
|
|
432
439
|
if (!data.users.length) { showToast('暂无目标用户', true); return; }
|
|
433
440
|
const text = data.users.map(u => '@' + u.uniqueId).join(', ');
|
|
434
|
-
|
|
435
|
-
|
|
441
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
442
|
+
await navigator.clipboard.writeText(text);
|
|
443
|
+
showToast(data.users.length + ' 个目标用户 ID 已复制到剪贴板');
|
|
444
|
+
} else {
|
|
445
|
+
const ta = document.createElement('textarea');
|
|
446
|
+
ta.value = text;
|
|
447
|
+
ta.style.position = 'fixed';
|
|
448
|
+
ta.style.left = '-9999px';
|
|
449
|
+
document.body.appendChild(ta);
|
|
450
|
+
ta.select();
|
|
451
|
+
document.execCommand('copy');
|
|
452
|
+
document.body.removeChild(ta);
|
|
453
|
+
showToast(data.users.length + ' 个目标用户 ID 已复制到剪贴板');
|
|
454
|
+
}
|
|
436
455
|
} catch (e) {
|
|
437
456
|
showToast('获取失败: ' + e.message, true);
|
|
438
457
|
}
|
package/src/watch/server.mjs
CHANGED
|
@@ -104,9 +104,9 @@ function sendJSON(res, code, data) {
|
|
|
104
104
|
res.end(JSON.stringify(data));
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
export function startWatchServer(outputFile, port = 3000) {
|
|
107
|
+
export function startWatchServer(outputFile, port = 3000, existingStore) {
|
|
108
108
|
return new Promise((_resolve, reject) => {
|
|
109
|
-
const store = createStore(outputFile);
|
|
109
|
+
const store = existingStore || createStore(outputFile);
|
|
110
110
|
|
|
111
111
|
const server = http.createServer(async (req, res) => {
|
|
112
112
|
const { path: routePath, params } = parseQuery(req.url);
|
|
@@ -211,6 +211,11 @@ export function startWatchServer(outputFile, port = 3000) {
|
|
|
211
211
|
if (params.status && params.status !== 'all') {
|
|
212
212
|
filtered = filtered.filter(u => u.status === params.status);
|
|
213
213
|
}
|
|
214
|
+
if (params.target === '1') {
|
|
215
|
+
filtered = filtered.filter(u =>
|
|
216
|
+
u.ttSeller && u.verified === false && u.locationCreated === 'ES'
|
|
217
|
+
);
|
|
218
|
+
}
|
|
214
219
|
if (params.search) {
|
|
215
220
|
const s = params.search.toLowerCase();
|
|
216
221
|
filtered = filtered.filter(u =>
|