tt-help-cli-ycl 1.3.35 → 1.3.36
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 +17 -1
- package/package.json +4 -2
- package/scripts/test-watch-db-smoke.mjs +246 -0
- package/src/cli/attach.js +149 -89
- package/src/cli/auto.js +180 -155
- package/src/cli/comments.js +301 -210
- package/src/cli/config.js +61 -43
- package/src/cli/db-import.js +51 -0
- package/src/cli/explore.js +373 -342
- package/src/cli/refresh.js +223 -151
- package/src/cli/videostats.js +140 -25
- package/src/cli/watch.js +15 -16
- package/src/lib/args.js +50 -6
- package/src/lib/constants.js +114 -92
- package/src/lib/tiktok-scraper.mjs +59 -21
- package/src/main.js +42 -20
- package/src/watch/data-store.js +1560 -236
- package/src/watch/public/index.html +51 -15
- package/src/watch/server.js +63 -374
|
@@ -103,6 +103,12 @@
|
|
|
103
103
|
color: #a78bfa;
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
.stat-card .value-sub {
|
|
107
|
+
font-size: 12px;
|
|
108
|
+
font-weight: 400;
|
|
109
|
+
margin-top: 2px;
|
|
110
|
+
}
|
|
111
|
+
|
|
106
112
|
.stat-card.clickable {
|
|
107
113
|
cursor: pointer;
|
|
108
114
|
}
|
|
@@ -161,10 +167,21 @@
|
|
|
161
167
|
padding-left: 6px;
|
|
162
168
|
font-size: 11px;
|
|
163
169
|
color: #fff;
|
|
170
|
+
white-space: nowrap;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
.bar-row .target-badge {
|
|
174
|
+
width: 60px;
|
|
175
|
+
text-align: center;
|
|
176
|
+
color: #4ade80;
|
|
177
|
+
font-weight: 600;
|
|
178
|
+
font-size: 12px;
|
|
179
|
+
flex-shrink: 0;
|
|
180
|
+
margin: 0 6px;
|
|
164
181
|
}
|
|
165
182
|
|
|
166
183
|
.bar-row .count {
|
|
167
|
-
width:
|
|
184
|
+
width: 55px;
|
|
168
185
|
text-align: right;
|
|
169
186
|
color: #888;
|
|
170
187
|
flex-shrink: 0;
|
|
@@ -814,7 +831,7 @@
|
|
|
814
831
|
</div>
|
|
815
832
|
<div class="stats">
|
|
816
833
|
<div class="stat-card">
|
|
817
|
-
<div class="label"
|
|
834
|
+
<div class="label">总用户</div>
|
|
818
835
|
<div class="value total" id="statTotal">0</div>
|
|
819
836
|
</div>
|
|
820
837
|
<div class="stat-card">
|
|
@@ -838,11 +855,11 @@
|
|
|
838
855
|
<div class="value error" id="statRestricted">0</div>
|
|
839
856
|
</div>
|
|
840
857
|
<div class="stat-card">
|
|
841
|
-
<div class="label"
|
|
858
|
+
<div class="label">待补资料</div>
|
|
842
859
|
<div class="value target" id="statUserUpdateTasks">0</div>
|
|
843
860
|
</div>
|
|
844
861
|
<div class="stat-card clickable" id="statTargetCard">
|
|
845
|
-
<div class="label"
|
|
862
|
+
<div class="label">目标商家</div>
|
|
846
863
|
<div class="value target" id="statTarget">0</div>
|
|
847
864
|
</div>
|
|
848
865
|
</div>
|
|
@@ -889,10 +906,10 @@
|
|
|
889
906
|
<button data-filter="all" class="active" onclick="setFilter('all')">全部</button>
|
|
890
907
|
<button data-filter="processing" onclick="setFilter('processing')">处理中</button>
|
|
891
908
|
<button data-filter="pending" onclick="setFilter('pending')">待处理</button>
|
|
892
|
-
<button data-filter="done" onclick="setFilter('done')"
|
|
909
|
+
<button data-filter="done" onclick="setFilter('done')">已完成</button>
|
|
893
910
|
<button data-filter="error" onclick="setFilter('error')">错误</button>
|
|
894
911
|
<button data-filter="restricted" onclick="setFilter('restricted')">受限</button>
|
|
895
|
-
<button data-filter="target" onclick="setFilter('target')" style="background:#7c3aed;color:#fff"
|
|
912
|
+
<button data-filter="target" onclick="setFilter('target')" style="background:#7c3aed;color:#fff">目标商家</button>
|
|
896
913
|
<select id="targetLocationFilter" onchange="onTargetLocationChange()"
|
|
897
914
|
style="padding:6px 10px;border:1px solid #7c3aed;border-radius:6px;background:#2a2a3a;color:#ccc;font-size:12px;cursor:pointer;outline:none;display:none">
|
|
898
915
|
<option value="">全部目标国家</option>
|
|
@@ -917,9 +934,9 @@
|
|
|
917
934
|
<th>猜测国家</th>
|
|
918
935
|
<th>来源</th>
|
|
919
936
|
<th>状态</th>
|
|
920
|
-
<th
|
|
937
|
+
<th>处理端</th>
|
|
921
938
|
<th>领取时间</th>
|
|
922
|
-
<th
|
|
939
|
+
<th>完成时间</th>
|
|
923
940
|
</tr>
|
|
924
941
|
</thead>
|
|
925
942
|
<tbody id="userTable"></tbody>
|
|
@@ -1018,11 +1035,19 @@
|
|
|
1018
1035
|
} catch (e) { }
|
|
1019
1036
|
}
|
|
1020
1037
|
|
|
1038
|
+
function formatStatNum(value) {
|
|
1039
|
+
const num = Number(value) || 0;
|
|
1040
|
+
if (Math.abs(num) < 1000) return String(num);
|
|
1041
|
+
if (Math.abs(num) < 10000) return num.toLocaleString('zh-CN');
|
|
1042
|
+
const wan = num / 10000;
|
|
1043
|
+
return wan.toFixed(1).replace(/\.0+$/, '') + '万';
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1021
1046
|
function flashEl(id, value) {
|
|
1022
1047
|
const el = document.getElementById(id);
|
|
1023
1048
|
if (!el) return;
|
|
1024
1049
|
const prev = prevStatValues[id];
|
|
1025
|
-
el.textContent = value;
|
|
1050
|
+
el.textContent = formatStatNum(value);
|
|
1026
1051
|
if (prev !== undefined && prev !== value) {
|
|
1027
1052
|
el.classList.remove('flash-change');
|
|
1028
1053
|
void el.offsetWidth;
|
|
@@ -1043,7 +1068,12 @@
|
|
|
1043
1068
|
flashEl('statTarget', d.targetUsers);
|
|
1044
1069
|
flashEl('statUserUpdateTasks', d.userUpdateTasks || 0);
|
|
1045
1070
|
document.getElementById('lastUpdate').textContent = '\u66f4\u65b0\u4e8e ' + new Date().toLocaleTimeString();
|
|
1046
|
-
document.getElementById('fileMeta').textContent =
|
|
1071
|
+
document.getElementById('fileMeta').textContent =
|
|
1072
|
+
formatStatNum(d.processingUsers || 0) +
|
|
1073
|
+
' \u5904\u7406\u4e2d, ' +
|
|
1074
|
+
formatStatNum(d.totalUsers) +
|
|
1075
|
+
' \u4e2a\u7528\u6237, \u5f85\u5904\u7406 ' +
|
|
1076
|
+
formatStatNum(d.pendingUsers || 0);
|
|
1047
1077
|
|
|
1048
1078
|
renderCountryChart(d.countryStats);
|
|
1049
1079
|
renderSourceChart(d.sourceStats);
|
|
@@ -1064,13 +1094,19 @@
|
|
|
1064
1094
|
if (!filtered.length) { el.innerHTML = '<span style="color:#666;font-size:12px">\u6682\u65e0\u6570\u636e</span>'; return; }
|
|
1065
1095
|
const max = filtered[0].count;
|
|
1066
1096
|
const top = filtered.slice(0, 15);
|
|
1067
|
-
el.innerHTML = top.map((c, i) =>
|
|
1097
|
+
el.innerHTML = top.map((c, i) => {
|
|
1098
|
+
const targetBadge = c.targetCount > 0
|
|
1099
|
+
? `<span class="target-badge">🎯 ${c.targetCount}</span>`
|
|
1100
|
+
: `<span class="target-badge" style="visibility:hidden"> </span>`;
|
|
1101
|
+
return `
|
|
1068
1102
|
<div class="bar-row">
|
|
1069
1103
|
<span class="name">${c.country}</span>
|
|
1070
1104
|
<div class="bar-bg"><div class="bar-fill" style="width:${(c.count / max * 100)}%;background:${COLORS[i % COLORS.length]}">${c.count}</div></div>
|
|
1105
|
+
${targetBadge}
|
|
1071
1106
|
<span class="count">${(currentStats ? (c.count / currentStats.totalUsers * 100).toFixed(1) : 0)}%</span>
|
|
1072
1107
|
</div>
|
|
1073
|
-
|
|
1108
|
+
`;
|
|
1109
|
+
}).join('');
|
|
1074
1110
|
}
|
|
1075
1111
|
|
|
1076
1112
|
function renderSourceChart(sources) {
|
|
@@ -1098,7 +1134,7 @@
|
|
|
1098
1134
|
const statusTags = {
|
|
1099
1135
|
restricted: '<span class="tag error">\u53d7\u9650(\u8df3\u8fc7)</span>',
|
|
1100
1136
|
error: '<span class="tag error">\u9519\u8bef(\u5f85\u91cd\u8bd5)</span>',
|
|
1101
|
-
done: '<span class="tag processed">\u5df2\
|
|
1137
|
+
done: '<span class="tag processed">\u5df2\u5b8c\u6210</span>',
|
|
1102
1138
|
processing: '<span class="tag processing">\u5904\u7406\u4e2d</span>',
|
|
1103
1139
|
pending: '<span class="tag pending">\u5f85\u5904\u7406</span>',
|
|
1104
1140
|
};
|
|
@@ -1128,9 +1164,9 @@
|
|
|
1128
1164
|
<td data-label="猜测国家">${guessedLoc}</td>
|
|
1129
1165
|
<td data-label="来源">${sources || '-'}</td>
|
|
1130
1166
|
<td data-label="状态">${statusTag} ${extraTags.join(' ')}</td>
|
|
1131
|
-
<td data-label="
|
|
1167
|
+
<td data-label="处理端" style="font-size:11px;color:#888">${claimer}</td>
|
|
1132
1168
|
<td data-label="领取时间" style="font-size:11px;color:#888">${claimTime}</td>
|
|
1133
|
-
<td data-label="
|
|
1169
|
+
<td data-label="完成时间" style="font-size:11px;color:#888">${procTime}</td>
|
|
1134
1170
|
</tr>`;
|
|
1135
1171
|
}).join('');
|
|
1136
1172
|
|
package/src/watch/server.js
CHANGED
|
@@ -7,7 +7,7 @@ import { fileURLToPath } from "url";
|
|
|
7
7
|
import { spawn } from "child_process";
|
|
8
8
|
import { createStore } from "./data-store.js";
|
|
9
9
|
|
|
10
|
-
const TARGET_LOCATIONS = ["ES", "PL", "NL", "BE", "DE", "FR", "IT", "IE"];
|
|
10
|
+
const TARGET_LOCATIONS = ["ES", "PL", "NL", "BE", "DE", "FR", "IT", "IE", "AT"];
|
|
11
11
|
|
|
12
12
|
const __filename = fileURLToPath(import.meta.url);
|
|
13
13
|
|
|
@@ -25,202 +25,8 @@ function getLocalIP() {
|
|
|
25
25
|
const __dirname = dirname(__filename);
|
|
26
26
|
const publicDir = join(__dirname, "public");
|
|
27
27
|
|
|
28
|
-
function parseQuery(url) {
|
|
29
|
-
const idx = url.indexOf("?");
|
|
30
|
-
if (idx === -1) return { path: url, params: {} };
|
|
31
|
-
const params = {};
|
|
32
|
-
for (const kv of url.slice(idx + 1).split("&")) {
|
|
33
|
-
const [k, v] = kv.split("=");
|
|
34
|
-
params[decodeURIComponent(k)] = decodeURIComponent(v || "");
|
|
35
|
-
}
|
|
36
|
-
return { path: url.slice(0, idx), params };
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function computeStats(users) {
|
|
40
|
-
const total = users.length;
|
|
41
|
-
const statusCounts = {
|
|
42
|
-
pending: 0,
|
|
43
|
-
processing: 0,
|
|
44
|
-
done: 0,
|
|
45
|
-
error: 0,
|
|
46
|
-
restricted: 0,
|
|
47
|
-
};
|
|
48
|
-
for (const u of users) {
|
|
49
|
-
statusCounts[u.status] = (statusCounts[u.status] || 0) + 1;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
const targetUsers = users.filter(
|
|
53
|
-
(u) =>
|
|
54
|
-
u.ttSeller &&
|
|
55
|
-
u.verified === false &&
|
|
56
|
-
TARGET_LOCATIONS.includes(u.locationCreated),
|
|
57
|
-
).length;
|
|
58
|
-
|
|
59
|
-
const countryMap = {};
|
|
60
|
-
for (const u of users) {
|
|
61
|
-
if (u.status !== "done") continue;
|
|
62
|
-
const loc = u.locationCreated || "\u672a\u77e5";
|
|
63
|
-
countryMap[loc] = (countryMap[loc] || 0) + 1;
|
|
64
|
-
}
|
|
65
|
-
const countryStats = Object.entries(countryMap)
|
|
66
|
-
.map(([country, count]) => ({ country, count }))
|
|
67
|
-
.sort((a, b) => b.count - a.count);
|
|
68
|
-
|
|
69
|
-
const sourceCounts = {
|
|
70
|
-
seed: 0,
|
|
71
|
-
video: 0,
|
|
72
|
-
comment: 0,
|
|
73
|
-
guess: 0,
|
|
74
|
-
following: 0,
|
|
75
|
-
follower: 0,
|
|
76
|
-
processed: 0,
|
|
77
|
-
restricted: 0,
|
|
78
|
-
error: 0,
|
|
79
|
-
noVideo: 0,
|
|
80
|
-
};
|
|
81
|
-
for (const u of users) {
|
|
82
|
-
if (u.status === "restricted") {
|
|
83
|
-
sourceCounts.restricted++;
|
|
84
|
-
continue;
|
|
85
|
-
}
|
|
86
|
-
if (u.status === "error") {
|
|
87
|
-
sourceCounts.error++;
|
|
88
|
-
continue;
|
|
89
|
-
}
|
|
90
|
-
if (u.noVideo) sourceCounts.noVideo++;
|
|
91
|
-
const sources = u.sources || [];
|
|
92
|
-
if (u.status === "done") sourceCounts.processed++;
|
|
93
|
-
if (sources.includes("video") && u.status !== "done") sourceCounts.video++;
|
|
94
|
-
if (sources.includes("comment") && u.status !== "done")
|
|
95
|
-
sourceCounts.comment++;
|
|
96
|
-
if (sources.includes("guess") && u.status !== "done") sourceCounts.guess++;
|
|
97
|
-
if (sources.includes("following") && u.status !== "done")
|
|
98
|
-
sourceCounts.following++;
|
|
99
|
-
if (sources.includes("follower") && u.status !== "done")
|
|
100
|
-
sourceCounts.follower++;
|
|
101
|
-
if (
|
|
102
|
-
!sources.includes("video") &&
|
|
103
|
-
!sources.includes("comment") &&
|
|
104
|
-
!sources.includes("guess") &&
|
|
105
|
-
!sources.includes("following") &&
|
|
106
|
-
!sources.includes("follower") &&
|
|
107
|
-
u.status !== "done"
|
|
108
|
-
)
|
|
109
|
-
sourceCounts.seed++;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
return {
|
|
113
|
-
totalUsers: total,
|
|
114
|
-
processedUsers: statusCounts.done,
|
|
115
|
-
pendingUsers: statusCounts.pending,
|
|
116
|
-
processingUsers: statusCounts.processing,
|
|
117
|
-
restrictedUsers: statusCounts.restricted,
|
|
118
|
-
errorUsers: statusCounts.error,
|
|
119
|
-
targetUsers,
|
|
120
|
-
countryStats,
|
|
121
|
-
sourceStats: sourceCounts,
|
|
122
|
-
};
|
|
123
|
-
}
|
|
124
|
-
|
|
125
28
|
function computeStatsIncremental(st) {
|
|
126
|
-
|
|
127
|
-
const all = st.getAllUsers();
|
|
128
|
-
const total = all.length;
|
|
129
|
-
const statusCounts = quick.statusCounts;
|
|
130
|
-
|
|
131
|
-
const countryMap = {};
|
|
132
|
-
const sourceCounts = {
|
|
133
|
-
seed: 0,
|
|
134
|
-
video: 0,
|
|
135
|
-
comment: 0,
|
|
136
|
-
guess: 0,
|
|
137
|
-
following: 0,
|
|
138
|
-
follower: 0,
|
|
139
|
-
processed: 0,
|
|
140
|
-
restricted: 0,
|
|
141
|
-
error: 0,
|
|
142
|
-
noVideo: 0,
|
|
143
|
-
};
|
|
144
|
-
let targetUsers = 0;
|
|
145
|
-
let userUpdateTasks = 0;
|
|
146
|
-
const targetCountryMap = {};
|
|
147
|
-
|
|
148
|
-
for (const u of all) {
|
|
149
|
-
// 国家统计
|
|
150
|
-
if (u.status === "done") {
|
|
151
|
-
const loc = u.locationCreated || "未知";
|
|
152
|
-
countryMap[loc] = (countryMap[loc] || 0) + 1;
|
|
153
|
-
}
|
|
154
|
-
// 预处理任务统计(与 /api/user-update-tasks 条件一致,不做 continue 跳过)
|
|
155
|
-
const ttSellerEmpty =
|
|
156
|
-
u.ttSeller === null || u.ttSeller === undefined || u.ttSeller === "";
|
|
157
|
-
const updateCountNotSet =
|
|
158
|
-
u.userUpdateCount === null ||
|
|
159
|
-
u.userUpdateCount === undefined ||
|
|
160
|
-
u.userUpdateCount <= 0;
|
|
161
|
-
if (ttSellerEmpty && updateCountNotSet) userUpdateTasks++;
|
|
162
|
-
|
|
163
|
-
// 目标用户统计(按国家分组)
|
|
164
|
-
if (
|
|
165
|
-
u.ttSeller &&
|
|
166
|
-
u.verified === false &&
|
|
167
|
-
TARGET_LOCATIONS.includes(u.locationCreated)
|
|
168
|
-
) {
|
|
169
|
-
targetUsers++;
|
|
170
|
-
const loc = u.locationCreated;
|
|
171
|
-
targetCountryMap[loc] = (targetCountryMap[loc] || 0) + 1;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
// 来源统计(restricted/error 跳过后续统计)
|
|
175
|
-
if (u.status === "restricted") {
|
|
176
|
-
sourceCounts.restricted++;
|
|
177
|
-
continue;
|
|
178
|
-
}
|
|
179
|
-
if (u.status === "error") {
|
|
180
|
-
sourceCounts.error++;
|
|
181
|
-
continue;
|
|
182
|
-
}
|
|
183
|
-
if (u.noVideo) sourceCounts.noVideo++;
|
|
184
|
-
const sources = u.sources || [];
|
|
185
|
-
if (u.status === "done") sourceCounts.processed++;
|
|
186
|
-
if (sources.includes("video") && u.status !== "done") sourceCounts.video++;
|
|
187
|
-
if (sources.includes("comment") && u.status !== "done")
|
|
188
|
-
sourceCounts.comment++;
|
|
189
|
-
if (sources.includes("guess") && u.status !== "done") sourceCounts.guess++;
|
|
190
|
-
if (sources.includes("following") && u.status !== "done")
|
|
191
|
-
sourceCounts.following++;
|
|
192
|
-
if (sources.includes("follower") && u.status !== "done")
|
|
193
|
-
sourceCounts.follower++;
|
|
194
|
-
if (
|
|
195
|
-
!sources.includes("video") &&
|
|
196
|
-
!sources.includes("comment") &&
|
|
197
|
-
!sources.includes("guess") &&
|
|
198
|
-
!sources.includes("following") &&
|
|
199
|
-
!sources.includes("follower") &&
|
|
200
|
-
u.status !== "done"
|
|
201
|
-
)
|
|
202
|
-
sourceCounts.seed++;
|
|
203
|
-
}
|
|
204
|
-
const countryStats = Object.entries(countryMap)
|
|
205
|
-
.map(([country, count]) => ({ country, count }))
|
|
206
|
-
.sort((a, b) => b.count - a.count);
|
|
207
|
-
const targetCountryStats = Object.entries(targetCountryMap)
|
|
208
|
-
.map(([country, count]) => ({ country, count }))
|
|
209
|
-
.sort((a, b) => b.count - a.count);
|
|
210
|
-
|
|
211
|
-
return {
|
|
212
|
-
totalUsers: total,
|
|
213
|
-
processedUsers: statusCounts.done,
|
|
214
|
-
pendingUsers: statusCounts.pending,
|
|
215
|
-
processingUsers: statusCounts.processing,
|
|
216
|
-
restrictedUsers: statusCounts.restricted,
|
|
217
|
-
errorUsers: statusCounts.error,
|
|
218
|
-
targetUsers,
|
|
219
|
-
userUpdateTasks,
|
|
220
|
-
targetCountryStats,
|
|
221
|
-
countryStats,
|
|
222
|
-
sourceStats: sourceCounts,
|
|
223
|
-
};
|
|
29
|
+
return st.getDashboardStats(TARGET_LOCATIONS);
|
|
224
30
|
}
|
|
225
31
|
|
|
226
32
|
function readBody(req) {
|
|
@@ -238,6 +44,14 @@ function readBody(req) {
|
|
|
238
44
|
});
|
|
239
45
|
}
|
|
240
46
|
|
|
47
|
+
function parseQuery(urlString = "/") {
|
|
48
|
+
const url = new URL(urlString, "http://127.0.0.1");
|
|
49
|
+
return {
|
|
50
|
+
path: url.pathname,
|
|
51
|
+
params: Object.fromEntries(url.searchParams.entries()),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
241
55
|
function sendJSON(res, code, data) {
|
|
242
56
|
res.writeHead(code, { "Content-Type": "application/json; charset=utf-8" });
|
|
243
57
|
res.end(JSON.stringify(data));
|
|
@@ -260,9 +74,9 @@ function sendCSV(res, columns, rows) {
|
|
|
260
74
|
res.end(body);
|
|
261
75
|
}
|
|
262
76
|
|
|
263
|
-
export function startWatchServer(
|
|
77
|
+
export function startWatchServer(dataAnchor, port = 3000, existingStore) {
|
|
264
78
|
return new Promise((_resolve, reject) => {
|
|
265
|
-
const store = existingStore || createStore(
|
|
79
|
+
const store = existingStore || createStore(dataAnchor);
|
|
266
80
|
|
|
267
81
|
function logJob(action, detail) {
|
|
268
82
|
const ts = new Date().toLocaleTimeString("zh-CN", { hour12: false });
|
|
@@ -288,12 +102,9 @@ export function startWatchServer(outputFile, port = 3000, existingStore) {
|
|
|
288
102
|
return;
|
|
289
103
|
}
|
|
290
104
|
const userSources = sources || ["seed"];
|
|
291
|
-
const existingIds = new Set(
|
|
292
|
-
store.getAllUsers().map((u) => u.uniqueId),
|
|
293
|
-
);
|
|
294
105
|
const newUsers = usernames
|
|
295
106
|
.map((u) => u.replace(/^@/, "").trim())
|
|
296
|
-
.filter((u) => u && !
|
|
107
|
+
.filter((u) => u && !store.userExists(u));
|
|
297
108
|
for (const nu of newUsers) {
|
|
298
109
|
store.addUser({
|
|
299
110
|
uniqueId: nu,
|
|
@@ -302,7 +113,6 @@ export function startWatchServer(outputFile, port = 3000, existingStore) {
|
|
|
302
113
|
guessedLocation: guessedLocation || null,
|
|
303
114
|
});
|
|
304
115
|
}
|
|
305
|
-
store.save();
|
|
306
116
|
sendJSON(res, 200, {
|
|
307
117
|
added: newUsers.length,
|
|
308
118
|
skipped: usernames.length - newUsers.length,
|
|
@@ -330,7 +140,6 @@ export function startWatchServer(outputFile, port = 3000, existingStore) {
|
|
|
330
140
|
return;
|
|
331
141
|
}
|
|
332
142
|
store.addUser(userData);
|
|
333
|
-
store.save();
|
|
334
143
|
sendJSON(res, 200, { added: true, uniqueId: userData.uniqueId });
|
|
335
144
|
} catch (e) {
|
|
336
145
|
sendJSON(res, 400, { error: e.message });
|
|
@@ -355,7 +164,6 @@ export function startWatchServer(outputFile, port = 3000, existingStore) {
|
|
|
355
164
|
loggedIn,
|
|
356
165
|
);
|
|
357
166
|
if (job) {
|
|
358
|
-
store.save();
|
|
359
167
|
logJob("CLAIM", {
|
|
360
168
|
user: job.uniqueId,
|
|
361
169
|
clientId: userId,
|
|
@@ -442,6 +250,42 @@ export function startWatchServer(outputFile, port = 3000, existingStore) {
|
|
|
442
250
|
return;
|
|
443
251
|
}
|
|
444
252
|
|
|
253
|
+
if (req.method === "GET" && routePath === "/api/videos") {
|
|
254
|
+
const result = store.getVideosPage
|
|
255
|
+
? store.getVideosPage(params.limit, params.offset)
|
|
256
|
+
: {
|
|
257
|
+
total: store.getVideoCount ? store.getVideoCount() : 0,
|
|
258
|
+
limit: Math.max(1, Math.min(200, parseInt(params.limit) || 50)),
|
|
259
|
+
offset: Math.max(0, parseInt(params.offset) || 0),
|
|
260
|
+
videos: store.getVideos
|
|
261
|
+
? store
|
|
262
|
+
.getVideos()
|
|
263
|
+
.slice(
|
|
264
|
+
Math.max(0, parseInt(params.offset) || 0),
|
|
265
|
+
Math.max(0, parseInt(params.offset) || 0) +
|
|
266
|
+
Math.max(
|
|
267
|
+
1,
|
|
268
|
+
Math.min(200, parseInt(params.limit) || 50),
|
|
269
|
+
),
|
|
270
|
+
)
|
|
271
|
+
: [],
|
|
272
|
+
};
|
|
273
|
+
sendJSON(res, 200, result);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const videoMatch = routePath.match(/^\/api\/videos\/([^/]+)$/);
|
|
278
|
+
if (req.method === "GET" && videoMatch) {
|
|
279
|
+
const videoId = videoMatch[1];
|
|
280
|
+
const video = store.getVideo ? store.getVideo(videoId) : null;
|
|
281
|
+
if (!video) {
|
|
282
|
+
sendJSON(res, 404, { error: "video not found" });
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
sendJSON(res, 200, { video });
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
|
|
445
289
|
// 视频登记
|
|
446
290
|
if (req.method === "POST" && routePath === "/api/videos") {
|
|
447
291
|
const body = await readBody(req);
|
|
@@ -478,7 +322,6 @@ export function startWatchServer(outputFile, port = 3000, existingStore) {
|
|
|
478
322
|
const userId = params.userId || "";
|
|
479
323
|
const job = store.getNextRedoJob(userId);
|
|
480
324
|
if (job) {
|
|
481
|
-
store.save();
|
|
482
325
|
logJob("REDO-CLAIM", { user: job.uniqueId, clientId: userId });
|
|
483
326
|
sendJSON(res, 200, { hasJob: true, user: job });
|
|
484
327
|
} else {
|
|
@@ -601,13 +444,8 @@ export function startWatchServer(outputFile, port = 3000, existingStore) {
|
|
|
601
444
|
}
|
|
602
445
|
|
|
603
446
|
if (req.method === "GET" && routePath === "/api/target-users") {
|
|
604
|
-
const
|
|
605
|
-
const targets =
|
|
606
|
-
(u) =>
|
|
607
|
-
u.ttSeller &&
|
|
608
|
-
u.verified === false &&
|
|
609
|
-
TARGET_LOCATIONS.includes(u.locationCreated),
|
|
610
|
-
);
|
|
447
|
+
const targetResult = store.getTargetUsers(TARGET_LOCATIONS);
|
|
448
|
+
const targets = targetResult.users;
|
|
611
449
|
if (req.headers["accept"]?.includes("text/csv")) {
|
|
612
450
|
const columns = [
|
|
613
451
|
"uniqueId",
|
|
@@ -679,168 +517,19 @@ export function startWatchServer(outputFile, port = 3000, existingStore) {
|
|
|
679
517
|
}
|
|
680
518
|
|
|
681
519
|
if (req.method === "GET" && routePath === "/api/users") {
|
|
682
|
-
const
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
) {
|
|
693
|
-
const groups = store.getStatusGroups();
|
|
694
|
-
if (params.status && params.status !== "all") {
|
|
695
|
-
// 单状态快路径:直接取已排序的组
|
|
696
|
-
const group = groups[params.status] || [];
|
|
697
|
-
const paged = group.slice(offset, offset + limit);
|
|
698
|
-
sendJSON(res, 200, { total: group.length, users: paged });
|
|
699
|
-
return;
|
|
700
|
-
}
|
|
701
|
-
// status=all 快路径:按分组顺序 early-exit(各组已排序)
|
|
702
|
-
const sOrder = {
|
|
703
|
-
processing: 0,
|
|
704
|
-
pending: 1,
|
|
705
|
-
done: 2,
|
|
706
|
-
error: 3,
|
|
707
|
-
restricted: 4,
|
|
708
|
-
};
|
|
709
|
-
const sortedKeys = Object.keys(groups).sort(
|
|
710
|
-
(a, b) => (sOrder[a] ?? 9) - (sOrder[b] ?? 9),
|
|
711
|
-
);
|
|
712
|
-
let totalCount = 0;
|
|
713
|
-
for (const key of sortedKeys) totalCount += groups[key].length;
|
|
714
|
-
const result = [];
|
|
715
|
-
outer: for (const key of sortedKeys) {
|
|
716
|
-
for (const u of groups[key]) {
|
|
717
|
-
result.push(u);
|
|
718
|
-
if (result.length >= offset + limit) break outer;
|
|
719
|
-
}
|
|
720
|
-
}
|
|
721
|
-
const paged = result.slice(offset, offset + limit);
|
|
722
|
-
sendJSON(res, 200, { total: totalCount, users: paged });
|
|
723
|
-
return;
|
|
724
|
-
}
|
|
725
|
-
|
|
726
|
-
let filtered = all;
|
|
727
|
-
if (params.status && params.status !== "all") {
|
|
728
|
-
filtered = filtered.filter((u) => u.status === params.status);
|
|
729
|
-
}
|
|
730
|
-
if (params.target === "1") {
|
|
731
|
-
filtered = filtered.filter(
|
|
732
|
-
(u) =>
|
|
733
|
-
u.ttSeller &&
|
|
734
|
-
u.verified === false &&
|
|
735
|
-
TARGET_LOCATIONS.includes(u.locationCreated),
|
|
736
|
-
);
|
|
737
|
-
}
|
|
738
|
-
if (params.search) {
|
|
739
|
-
const s = params.search.toLowerCase();
|
|
740
|
-
filtered = filtered.filter(
|
|
741
|
-
(u) =>
|
|
742
|
-
u.uniqueId.toLowerCase().includes(s) ||
|
|
743
|
-
(u.nickname || "").toLowerCase().includes(s),
|
|
744
|
-
);
|
|
745
|
-
}
|
|
746
|
-
if (params.location) {
|
|
747
|
-
filtered = filtered.filter(
|
|
748
|
-
(u) => u.locationCreated === params.location,
|
|
749
|
-
);
|
|
750
|
-
}
|
|
751
|
-
if (params.targetLocation) {
|
|
752
|
-
filtered = filtered.filter(
|
|
753
|
-
(u) =>
|
|
754
|
-
u.ttSeller &&
|
|
755
|
-
u.verified === false &&
|
|
756
|
-
u.locationCreated === params.targetLocation,
|
|
757
|
-
);
|
|
758
|
-
}
|
|
759
|
-
|
|
760
|
-
const needCount = offset + limit;
|
|
761
|
-
const statusOrder = {
|
|
762
|
-
processing: 0,
|
|
763
|
-
pending: 1,
|
|
764
|
-
done: 2,
|
|
765
|
-
error: 3,
|
|
766
|
-
restricted: 4,
|
|
767
|
-
};
|
|
768
|
-
const tier1Loc = new Set(["PL", "NL", "BE", "AT"]);
|
|
769
|
-
const tier2Loc = new Set(["DE", "FR", "IT", "IE", "ES"]);
|
|
770
|
-
function locationTier(u) {
|
|
771
|
-
const loc = (u.guessedLocation || "").toUpperCase();
|
|
772
|
-
if (tier1Loc.has(loc)) return 0;
|
|
773
|
-
if (tier2Loc.has(loc)) return 1;
|
|
774
|
-
return 2;
|
|
775
|
-
}
|
|
776
|
-
|
|
777
|
-
let sorted;
|
|
778
|
-
if (filtered.length > needCount * 3) {
|
|
779
|
-
const groups = {};
|
|
780
|
-
for (const u of filtered) {
|
|
781
|
-
const key = u.status || "pending";
|
|
782
|
-
if (!groups[key]) groups[key] = [];
|
|
783
|
-
groups[key].push(u);
|
|
784
|
-
}
|
|
785
|
-
const sortedKeys = Object.keys(groups).sort(
|
|
786
|
-
(a, b) => (statusOrder[a] ?? 9) - (statusOrder[b] ?? 9),
|
|
787
|
-
);
|
|
788
|
-
for (const key of sortedKeys) {
|
|
789
|
-
const g = groups[key];
|
|
790
|
-
if (key === "done")
|
|
791
|
-
g.sort((a, b) => (b.processedAt || 0) - (a.processedAt || 0));
|
|
792
|
-
else if (key === "pending")
|
|
793
|
-
g.sort((a, b) => {
|
|
794
|
-
const aSeller =
|
|
795
|
-
a.ttSeller === true && a.verified === false ? 0 : 1;
|
|
796
|
-
const bSeller =
|
|
797
|
-
b.ttSeller === true && b.verified === false ? 0 : 1;
|
|
798
|
-
if (aSeller !== bSeller) return aSeller - bSeller;
|
|
799
|
-
const la = locationTier(a),
|
|
800
|
-
lb = locationTier(b);
|
|
801
|
-
if (la !== lb) return la - lb;
|
|
802
|
-
return (b.followerCount || 0) - (a.followerCount || 0);
|
|
803
|
-
});
|
|
804
|
-
else
|
|
805
|
-
g.sort((a, b) => (b.followerCount || 0) - (a.followerCount || 0));
|
|
806
|
-
const pinned = g.filter((u) => u.pinned);
|
|
807
|
-
const unpinned = g.filter((u) => !u.pinned);
|
|
808
|
-
groups[key] = pinned.concat(unpinned);
|
|
809
|
-
}
|
|
810
|
-
sorted = [];
|
|
811
|
-
outer: for (const key of sortedKeys) {
|
|
812
|
-
for (const u of groups[key]) {
|
|
813
|
-
sorted.push(u);
|
|
814
|
-
if (sorted.length >= needCount) break outer;
|
|
815
|
-
}
|
|
816
|
-
}
|
|
817
|
-
} else {
|
|
818
|
-
sorted = filtered.slice().sort((a, b) => {
|
|
819
|
-
if (a.pinned && !b.pinned) return -1;
|
|
820
|
-
if (!a.pinned && b.pinned) return 1;
|
|
821
|
-
const sa = statusOrder[a.status] ?? 9;
|
|
822
|
-
const sb = statusOrder[b.status] ?? 9;
|
|
823
|
-
if (sa !== sb) return sa - sb;
|
|
824
|
-
if (a.status === "done" && b.status === "done")
|
|
825
|
-
return (b.processedAt || 0) - (a.processedAt || 0);
|
|
826
|
-
if (a.status === "pending" && b.status === "pending") {
|
|
827
|
-
const aSeller =
|
|
828
|
-
a.ttSeller === true && a.verified === false ? 0 : 1;
|
|
829
|
-
const bSeller =
|
|
830
|
-
b.ttSeller === true && b.verified === false ? 0 : 1;
|
|
831
|
-
if (aSeller !== bSeller) return aSeller - bSeller;
|
|
832
|
-
const la = locationTier(a),
|
|
833
|
-
lb = locationTier(b);
|
|
834
|
-
if (la !== lb) return la - lb;
|
|
835
|
-
}
|
|
836
|
-
return (b.followerCount || 0) - (a.followerCount || 0);
|
|
837
|
-
});
|
|
838
|
-
}
|
|
839
|
-
|
|
840
|
-
let paged = sorted.slice(offset, offset + limit);
|
|
520
|
+
const result = store.getUsersPage({
|
|
521
|
+
status: params.status,
|
|
522
|
+
search: params.search,
|
|
523
|
+
location: params.location,
|
|
524
|
+
target: params.target,
|
|
525
|
+
targetLocation: params.targetLocation,
|
|
526
|
+
limit: params.limit,
|
|
527
|
+
offset: params.offset,
|
|
528
|
+
targetLocations: TARGET_LOCATIONS,
|
|
529
|
+
});
|
|
841
530
|
|
|
842
531
|
if (params.view === "light") {
|
|
843
|
-
|
|
532
|
+
result.users = result.users.map((u) => ({
|
|
844
533
|
uniqueId: u.uniqueId,
|
|
845
534
|
nickname: u.nickname,
|
|
846
535
|
status: u.status,
|
|
@@ -855,7 +544,7 @@ export function startWatchServer(outputFile, port = 3000, existingStore) {
|
|
|
855
544
|
}));
|
|
856
545
|
}
|
|
857
546
|
|
|
858
|
-
sendJSON(res, 200,
|
|
547
|
+
sendJSON(res, 200, result);
|
|
859
548
|
return;
|
|
860
549
|
}
|
|
861
550
|
|