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 +1 -1
- package/src/cli/explore.js +20 -1
- package/src/lib/api-interceptor.js +6 -1
- package/src/lib/browser/page.js +13 -3
- package/src/lib/constants.js +2 -2
- package/src/lib/page-error-detector.js +1 -0
- package/src/scraper/explore-core.js +76 -49
- package/src/scraper/modules/follow-extractor.js +2 -5
- package/src/utils/index.js +31 -0
- package/src/watch/data-store.js +197 -49
- package/src/watch/public/index.html +33 -2
- package/src/watch/server.js +167 -24
package/package.json
CHANGED
package/src/cli/explore.js
CHANGED
|
@@ -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
|
|
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,
|
package/src/lib/browser/page.js
CHANGED
|
@@ -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
|
|
package/src/lib/constants.js
CHANGED
|
@@ -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 =
|
|
19
|
-
let maxFollowers =
|
|
18
|
+
let maxFollowing = 50;
|
|
19
|
+
let maxFollowers = 50;
|
|
20
20
|
let maxVideos = 16;
|
|
21
21
|
let maxComments = 10;
|
|
22
22
|
|
|
@@ -1,25 +1,26 @@
|
|
|
1
1
|
import {
|
|
2
|
-
delay,
|
|
3
2
|
ensureBrowserReady,
|
|
4
|
-
retryWithBackoff,
|
|
5
3
|
detectPageError,
|
|
6
4
|
isLoggedIn,
|
|
7
|
-
|
|
8
|
-
} from
|
|
9
|
-
import { detectCaptcha } from
|
|
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
|
|
12
|
-
import { extractFollowAndFollowers } from
|
|
13
|
-
import { extractVideoLocation } from
|
|
14
|
-
import {
|
|
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 =
|
|
21
|
-
maxFollowers =
|
|
22
|
-
location =
|
|
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
|
|
44
|
-
|
|
45
|
-
|
|
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(
|
|
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 ||
|
|
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
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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(
|
|
101
|
+
const firstVideoUrl = firstVideo.href.startsWith("http")
|
|
91
102
|
? firstVideo.href
|
|
92
103
|
: `https://www.tiktok.com${firstVideo.href}`;
|
|
93
104
|
|
|
94
|
-
|
|
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 ||
|
|
106
|
-
|
|
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(
|
|
128
|
+
log(" [跳过] 获取关注/粉丝:未登录,请先登录 TikTok");
|
|
117
129
|
result.hasFollowData = false;
|
|
118
130
|
result.discoveredFollowing = [];
|
|
119
131
|
result.discoveredFollowers = [];
|
|
120
132
|
} else {
|
|
121
133
|
try {
|
|
122
|
-
const
|
|
123
|
-
const
|
|
124
|
-
|
|
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,
|
|
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(
|
|
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
|
+
}
|
package/src/watch/data-store.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
185
|
-
|
|
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
|
-
|
|
273
|
-
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
}
|
|
448
|
+
if (!next) {
|
|
449
|
+
candidates.sort((a, b) => locationTier(a) - locationTier(b));
|
|
450
|
+
next = candidates[0] || null;
|
|
451
|
+
}
|
|
307
452
|
|
|
308
|
-
|
|
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;">↻ 批量重新处理 (<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
|
-
|
|
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) {
|
package/src/watch/server.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
const
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|