@web-auto/webauto 0.1.18 → 0.1.19
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 +122 -53
- package/apps/desktop-console/dist/main/index.mjs +227 -12
- package/apps/desktop-console/dist/renderer/index.js +237 -8
- package/apps/desktop-console/entry/ui-cli.mjs +282 -16
- package/apps/desktop-console/entry/ui-console.mjs +46 -15
- package/apps/webauto/entry/account.mjs +126 -27
- package/apps/webauto/entry/lib/account-detect.mjs +399 -9
- package/apps/webauto/entry/lib/account-store.mjs +201 -109
- package/apps/webauto/entry/lib/iflow-reply.mjs +194 -0
- package/apps/webauto/entry/lib/profile-policy.mjs +48 -0
- package/apps/webauto/entry/lib/profilepool.mjs +12 -0
- package/apps/webauto/entry/lib/schedule-store.mjs +29 -2
- package/apps/webauto/entry/lib/session-init.mjs +227 -0
- package/apps/webauto/entry/lib/upgrade-check.mjs +269 -0
- package/apps/webauto/entry/lib/xhs-unified-blocks.mjs +160 -0
- package/apps/webauto/entry/lib/xhs-unified-output-blocks.mjs +83 -0
- package/apps/webauto/entry/lib/xhs-unified-plan-blocks.mjs +55 -0
- package/apps/webauto/entry/lib/xhs-unified-profile-blocks.mjs +542 -0
- package/apps/webauto/entry/lib/xhs-unified-runtime-blocks.mjs +436 -0
- package/apps/webauto/entry/profilepool.mjs +56 -9
- package/apps/webauto/entry/smart-reply-cli.mjs +267 -0
- package/apps/webauto/entry/weibo-unified.mjs +84 -11
- package/apps/webauto/entry/xhs-orchestrate.mjs +43 -1
- package/apps/webauto/entry/xhs-unified.mjs +92 -997
- package/bin/webauto.mjs +22 -4
- package/dist/modules/camo-backend/src/index.js +33 -0
- package/dist/modules/camo-backend/src/internal/BrowserSession.js +232 -49
- package/dist/modules/camo-backend/src/internal/engine-manager.js +14 -13
- package/dist/modules/camo-backend/src/internal/ws-server.js +16 -19
- package/dist/modules/camo-runtime/src/utils/browser-service.mjs +38 -6
- package/dist/modules/workflow/blocks/EnsureSession.js +0 -8
- package/dist/modules/workflow/blocks/WeiboCollectFromLinksBlock.js +78 -6
- package/dist/modules/workflow/blocks/WeiboCollectSearchLinksBlock.js +266 -192
- package/dist/modules/workflow/definitions/weibo-search-workflow-v1.js +2 -0
- package/dist/modules/workflow/src/runner.js +2 -0
- package/dist/modules/xiaohongshu/app/src/blocks/ReplyInteractBlock.js +150 -37
- package/dist/modules/xiaohongshu/app/src/blocks/SmartReplyBlock.js +491 -0
- package/modules/camo-backend/src/index.ts +31 -0
- package/modules/camo-backend/src/internal/BrowserSession.ts +224 -53
- package/modules/camo-backend/src/internal/engine-manager.ts +14 -15
- package/modules/camo-backend/src/internal/ws-server.ts +17 -17
- package/modules/camo-runtime/src/autoscript/action-providers/xhs/common.mjs +12 -2
- package/modules/camo-runtime/src/autoscript/action-providers/xhs/persistence.mjs +57 -0
- package/modules/camo-runtime/src/autoscript/action-providers/xhs.mjs +2475 -243
- package/modules/camo-runtime/src/autoscript/runtime.mjs +35 -30
- package/modules/camo-runtime/src/autoscript/xhs-unified-template.mjs +80 -443
- package/modules/camo-runtime/src/container/runtime-core/checkpoint.mjs +39 -6
- package/modules/camo-runtime/src/container/runtime-core/operations/index.mjs +206 -39
- package/modules/camo-runtime/src/container/runtime-core/operations/tab-pool.mjs +0 -79
- package/modules/camo-runtime/src/container/runtime-core/operations/viewport.mjs +46 -0
- package/modules/camo-runtime/src/utils/browser-service.mjs +41 -6
- package/modules/camo-runtime/src/utils/js-policy.mjs +28 -0
- package/modules/workflow/blocks/EnsureSession.ts +0 -4
- package/modules/workflow/blocks/WeiboCollectFromLinksBlock.ts +81 -6
- package/modules/workflow/blocks/WeiboCollectSearchLinksBlock.ts +316 -0
- package/modules/workflow/definitions/weibo-search-workflow-v1.ts +2 -0
- package/modules/workflow/src/runner.ts +2 -0
- package/modules/xiaohongshu/app/src/blocks/ReplyInteractBlock.ts +198 -53
- package/modules/xiaohongshu/app/src/blocks/SmartReplyBlock.ts +706 -0
- package/package.json +2 -2
- package/modules/camo-runtime/src/autoscript/action-providers/xhs/comments.mjs +0 -498
- package/modules/camo-runtime/src/autoscript/action-providers/xhs/detail.mjs +0 -181
- package/modules/camo-runtime/src/autoscript/action-providers/xhs/interaction.mjs +0 -691
- package/modules/camo-runtime/src/autoscript/action-providers/xhs/search.mjs +0 -388
- package/modules/camo-runtime/src/container/runtime-core/operations/selector-scripts.mjs +0 -135
|
@@ -1,29 +1,66 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import fsp from 'node:fs/promises';
|
|
3
|
-
import { asErrorPayload } from '../../container/runtime-core/utils.mjs';
|
|
3
|
+
import { asErrorPayload, normalizeArray } from '../../container/runtime-core/utils.mjs';
|
|
4
|
+
import { callAPI } from '../../utils/browser-service.mjs';
|
|
4
5
|
import {
|
|
5
|
-
createEvaluateHandler,
|
|
6
6
|
extractEvaluateResultData,
|
|
7
|
-
|
|
7
|
+
extractScreenshotBase64,
|
|
8
8
|
runEvaluateScript,
|
|
9
9
|
} from './xhs/common.mjs';
|
|
10
|
-
import { buildCommentsHarvestScript, buildCommentMatchScript } from './xhs/comments.mjs';
|
|
11
10
|
import {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
} from './xhs/
|
|
16
|
-
import {
|
|
17
|
-
buildCommentReplyScript,
|
|
18
|
-
executeCommentLikeOperation,
|
|
19
|
-
} from './xhs/interaction.mjs';
|
|
11
|
+
compileLikeRules,
|
|
12
|
+
matchLikeText,
|
|
13
|
+
normalizeText,
|
|
14
|
+
} from './xhs/like-rules.mjs';
|
|
20
15
|
import {
|
|
16
|
+
appendLikedSignature,
|
|
17
|
+
ensureDir,
|
|
18
|
+
loadLikedSignatures,
|
|
19
|
+
makeLikeSignature,
|
|
20
|
+
mergeLinksJsonl,
|
|
21
21
|
mergeCommentsJsonl,
|
|
22
22
|
resolveXhsOutputContext,
|
|
23
|
+
savePngBase64,
|
|
24
|
+
writeJsonFile,
|
|
23
25
|
} from './xhs/persistence.mjs';
|
|
24
|
-
import { buildOpenDetailScript, buildSubmitSearchScript } from './xhs/search.mjs';
|
|
25
26
|
|
|
26
27
|
const XHS_OPERATION_LOCKS = new Map();
|
|
28
|
+
const XHS_PROFILE_STATE = new Map();
|
|
29
|
+
|
|
30
|
+
function defaultProfileState() {
|
|
31
|
+
return {
|
|
32
|
+
keyword: null,
|
|
33
|
+
currentNoteId: null,
|
|
34
|
+
currentHref: null,
|
|
35
|
+
lastListUrl: null,
|
|
36
|
+
visitedNoteIds: [],
|
|
37
|
+
preCollectedNoteIds: [],
|
|
38
|
+
preCollectedAt: null,
|
|
39
|
+
maxNotes: 0,
|
|
40
|
+
currentComments: [],
|
|
41
|
+
matchedComments: [],
|
|
42
|
+
matchRule: null,
|
|
43
|
+
lastCommentsHarvest: null,
|
|
44
|
+
lastDetail: null,
|
|
45
|
+
lastReply: null,
|
|
46
|
+
metrics: {
|
|
47
|
+
searchCount: 0,
|
|
48
|
+
rollbackCount: 0,
|
|
49
|
+
returnToSearchCount: 0,
|
|
50
|
+
lastSearchAt: null,
|
|
51
|
+
lastRollbackAt: null,
|
|
52
|
+
lastReturnToSearchAt: null,
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function getProfileState(profileId) {
|
|
58
|
+
const key = String(profileId || '').trim() || 'default';
|
|
59
|
+
if (!XHS_PROFILE_STATE.has(key)) {
|
|
60
|
+
XHS_PROFILE_STATE.set(key, defaultProfileState());
|
|
61
|
+
}
|
|
62
|
+
return XHS_PROFILE_STATE.get(key);
|
|
63
|
+
}
|
|
27
64
|
|
|
28
65
|
function emitOperationProgress(context, payload = {}) {
|
|
29
66
|
const emit = context?.emitProgress;
|
|
@@ -94,6 +131,15 @@ function normalizeNoteIdList(items) {
|
|
|
94
131
|
return out;
|
|
95
132
|
}
|
|
96
133
|
|
|
134
|
+
function extractNoteIdFromHref(href) {
|
|
135
|
+
const text = String(href || '').trim();
|
|
136
|
+
if (!text) return '';
|
|
137
|
+
const match = text.match(/\/explore\/([^/?#]+)/);
|
|
138
|
+
if (match && match[1]) return String(match[1]).trim();
|
|
139
|
+
const seg = text.split('/').filter(Boolean).pop() || '';
|
|
140
|
+
return String(seg).split('?')[0].split('#')[0].trim();
|
|
141
|
+
}
|
|
142
|
+
|
|
97
143
|
function resolveSharedClaimPath(params = {}) {
|
|
98
144
|
const raw = String(params.sharedHarvestPath || params.sharedClaimPath || '').trim();
|
|
99
145
|
return raw ? path.resolve(raw) : '';
|
|
@@ -137,229 +183,936 @@ async function saveSharedClaimDoc(filePath, doc) {
|
|
|
137
183
|
await fsp.writeFile(filePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
|
|
138
184
|
}
|
|
139
185
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
const
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
return
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
186
|
+
function clamp(value, min, max) {
|
|
187
|
+
return Math.min(Math.max(value, min), max);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function randomBetween(min, max) {
|
|
191
|
+
const lo = Math.max(0, Math.floor(Number(min) || 0));
|
|
192
|
+
const hi = Math.max(lo, Math.floor(Number(max) || 0));
|
|
193
|
+
if (hi <= lo) return lo;
|
|
194
|
+
return lo + Math.floor(Math.random() * (hi - lo + 1));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function buildTraceRecorder() {
|
|
198
|
+
const actionTrace = [];
|
|
199
|
+
const pushTrace = (payload) => {
|
|
200
|
+
actionTrace.push({
|
|
201
|
+
ts: new Date().toISOString(),
|
|
202
|
+
...payload,
|
|
154
203
|
});
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
if (actionTrace.length > 0) {
|
|
158
|
-
emitActionTrace(context, actionTrace, { stage: 'xhs_submit_search' });
|
|
159
|
-
delete payload.actionTrace;
|
|
160
|
-
return {
|
|
161
|
-
...operationResult,
|
|
162
|
-
data: replaceEvaluateResultData(operationResult.data, payload),
|
|
163
|
-
};
|
|
164
|
-
}
|
|
165
|
-
return operationResult;
|
|
166
|
-
});
|
|
204
|
+
};
|
|
205
|
+
return { actionTrace, pushTrace };
|
|
167
206
|
}
|
|
168
207
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
context = {},
|
|
173
|
-
}) {
|
|
174
|
-
const highlight = params.highlight !== false;
|
|
175
|
-
const claimPath = resolveSharedClaimPath(params);
|
|
176
|
-
const lockKey = claimPath ? `xhs_open_detail:${claimPath}` : '';
|
|
208
|
+
function normalizeInlineText(value) {
|
|
209
|
+
return String(value || '').replace(/\s+/g, ' ').trim();
|
|
210
|
+
}
|
|
177
211
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
}
|
|
198
|
-
return null;
|
|
212
|
+
function sanitizeAuthorText(raw, commentText = '') {
|
|
213
|
+
const text = normalizeInlineText(raw);
|
|
214
|
+
if (!text) return '';
|
|
215
|
+
if (commentText && text === commentText) return '';
|
|
216
|
+
if (text.length > 40) return '';
|
|
217
|
+
if (/^(回复|展开|收起|查看更多|评论|赞|分享|发送)$/.test(text)) return '';
|
|
218
|
+
return text;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function buildElementCollectability(detail = {}, commentsSnapshot = null) {
|
|
222
|
+
const href = String(detail?.href || '').trim();
|
|
223
|
+
const videoUrl = String(detail?.videoUrl || '').trim();
|
|
224
|
+
const videoPresent = detail?.videoPresent === true;
|
|
225
|
+
const commentsContextAvailable = commentsSnapshot?.hasCommentsContext === true || detail?.commentsContextAvailable === true;
|
|
226
|
+
const collectability = {
|
|
227
|
+
canCollectText: detail?.textPresent === true,
|
|
228
|
+
canCollectImages: Number(detail?.imageCount || 0) > 0,
|
|
229
|
+
canCollectComments: commentsContextAvailable,
|
|
230
|
+
canCollectVideo: false,
|
|
199
231
|
};
|
|
200
232
|
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
233
|
+
const skippedElements = [];
|
|
234
|
+
if (videoPresent) {
|
|
235
|
+
skippedElements.push({
|
|
236
|
+
element: 'video',
|
|
237
|
+
reason: 'video_capture_not_supported',
|
|
205
238
|
});
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
239
|
+
}
|
|
240
|
+
if (!commentsContextAvailable) {
|
|
241
|
+
skippedElements.push({
|
|
242
|
+
element: 'comments',
|
|
243
|
+
reason: 'comments_context_missing',
|
|
211
244
|
});
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
payload: payload && typeof payload === 'object' ? payload : {},
|
|
223
|
-
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const fallbackCaptured = {};
|
|
248
|
+
if (href) fallbackCaptured.noteUrl = href;
|
|
249
|
+
if (videoPresent) fallbackCaptured.videoUrl = videoUrl || href || null;
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
collectability,
|
|
253
|
+
skippedElements,
|
|
254
|
+
fallbackCaptured,
|
|
224
255
|
};
|
|
256
|
+
}
|
|
225
257
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
258
|
+
async function sleep(ms) {
|
|
259
|
+
const waitMs = Math.max(0, Number(ms) || 0);
|
|
260
|
+
if (waitMs <= 0) return;
|
|
261
|
+
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function sleepRandom(minMs, maxMs, pushTrace, stage, extra = {}) {
|
|
265
|
+
const waitMs = randomBetween(minMs, maxMs);
|
|
266
|
+
if (typeof pushTrace === 'function') {
|
|
267
|
+
pushTrace({ kind: 'wait', stage, waitMs, ...extra });
|
|
235
268
|
}
|
|
269
|
+
await sleep(waitMs);
|
|
270
|
+
return waitMs;
|
|
271
|
+
}
|
|
236
272
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
273
|
+
async function evaluateReadonly(profileId, script) {
|
|
274
|
+
const payload = await runEvaluateScript({
|
|
275
|
+
profileId,
|
|
276
|
+
script,
|
|
277
|
+
highlight: false,
|
|
278
|
+
});
|
|
279
|
+
return extractEvaluateResultData(payload) || payload?.result || payload?.data || payload || {};
|
|
280
|
+
}
|
|
241
281
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
282
|
+
async function readLocation(profileId) {
|
|
283
|
+
const payload = await evaluateReadonly(profileId, '(() => String(location.href || ""))()');
|
|
284
|
+
return String(payload || '');
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function moveMouse(profileId, x, y, steps = 2) {
|
|
288
|
+
await callAPI('mouse:move', {
|
|
289
|
+
profileId,
|
|
290
|
+
x: Math.max(1, Math.round(Number(x) || 1)),
|
|
291
|
+
y: Math.max(1, Math.round(Number(y) || 1)),
|
|
292
|
+
steps: Math.max(1, Number(steps) || 1),
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async function clickPoint(profileId, point, options = {}) {
|
|
297
|
+
await moveMouse(profileId, point.x, point.y, options.steps ?? 3);
|
|
298
|
+
await callAPI('mouse:click', {
|
|
299
|
+
profileId,
|
|
300
|
+
x: Math.max(1, Math.round(Number(point.x) || 1)),
|
|
301
|
+
y: Math.max(1, Math.round(Number(point.y) || 1)),
|
|
302
|
+
button: String(options.button || 'left').trim() || 'left',
|
|
303
|
+
clicks: Math.max(1, Number(options.clicks ?? 1) || 1),
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function wheel(profileId, deltaY) {
|
|
308
|
+
await callAPI('mouse:wheel', {
|
|
309
|
+
profileId,
|
|
310
|
+
deltaX: 0,
|
|
311
|
+
deltaY: clamp(Math.round(Number(deltaY) || 0), -1200, 1200),
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async function pressKey(profileId, key) {
|
|
316
|
+
await callAPI('keyboard:press', {
|
|
317
|
+
profileId,
|
|
318
|
+
key: String(key || '').trim(),
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async function clearAndType(profileId, text, keyDelayMs = 60) {
|
|
323
|
+
await pressKey(profileId, process.platform === 'darwin' ? 'Meta+A' : 'Control+A');
|
|
324
|
+
await pressKey(profileId, 'Backspace');
|
|
325
|
+
await callAPI('keyboard:type', {
|
|
326
|
+
profileId,
|
|
327
|
+
text: String(text || ''),
|
|
328
|
+
delay: Math.max(0, Number(keyDelayMs) || 0),
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async function resolveSelectorTarget(profileId, selectors, options = {}) {
|
|
333
|
+
const normalizedSelectors = normalizeArray(selectors)
|
|
334
|
+
.map((item) => String(item || '').trim())
|
|
335
|
+
.filter(Boolean);
|
|
336
|
+
if (normalizedSelectors.length === 0) return null;
|
|
337
|
+
const script = `(() => {
|
|
338
|
+
const selectors = ${JSON.stringify(normalizedSelectors)};
|
|
339
|
+
const requireViewport = ${options.requireViewport !== false ? 'true' : 'false'};
|
|
340
|
+
const includeText = ${options.includeText === true ? 'true' : 'false'};
|
|
341
|
+
const isVisible = (node) => {
|
|
342
|
+
if (!(node instanceof Element)) return false;
|
|
343
|
+
const rect = node.getBoundingClientRect?.();
|
|
344
|
+
if (!rect || rect.width <= 0 || rect.height <= 0) return false;
|
|
345
|
+
try {
|
|
346
|
+
const style = window.getComputedStyle(node);
|
|
347
|
+
if (!style) return false;
|
|
348
|
+
if (style.display === 'none') return false;
|
|
349
|
+
if (style.visibility === 'hidden' || style.visibility === 'collapse') return false;
|
|
350
|
+
const opacity = Number.parseFloat(String(style.opacity || '1'));
|
|
351
|
+
if (Number.isFinite(opacity) && opacity <= 0.01) return false;
|
|
352
|
+
} catch {
|
|
353
|
+
return false;
|
|
354
|
+
}
|
|
355
|
+
return true;
|
|
356
|
+
};
|
|
357
|
+
const inViewport = (rect) => {
|
|
358
|
+
const vw = Number(window.innerWidth || 0);
|
|
359
|
+
const vh = Number(window.innerHeight || 0);
|
|
360
|
+
return rect.right > 0 && rect.bottom > 0 && rect.left < vw && rect.top < vh;
|
|
361
|
+
};
|
|
362
|
+
const hitVisible = (node, rect) => {
|
|
363
|
+
if (!(node instanceof Element) || !rect) return false;
|
|
364
|
+
const vw = Number(window.innerWidth || 0);
|
|
365
|
+
const vh = Number(window.innerHeight || 0);
|
|
366
|
+
if (vw <= 0 || vh <= 0) return false;
|
|
367
|
+
const x = Math.max(0, Math.min(vw - 1, rect.left + rect.width / 2));
|
|
368
|
+
const y = Math.max(0, Math.min(vh - 1, rect.top + rect.height / 2));
|
|
369
|
+
const top = document.elementFromPoint(x, y);
|
|
370
|
+
if (!top) return false;
|
|
371
|
+
return top === node || node.contains(top) || top.contains(node);
|
|
372
|
+
};
|
|
373
|
+
const toPayload = (selector, node) => {
|
|
374
|
+
const rect = node.getBoundingClientRect();
|
|
375
|
+
const vw = Number(window.innerWidth || 0);
|
|
376
|
+
const vh = Number(window.innerHeight || 0);
|
|
377
|
+
const center = {
|
|
378
|
+
x: Math.max(1, Math.min(Math.max(1, vw - 1), Math.round(rect.left + rect.width / 2))),
|
|
379
|
+
y: Math.max(1, Math.min(Math.max(1, vh - 1), Math.round(rect.top + rect.height / 2))),
|
|
254
380
|
};
|
|
381
|
+
const payload = {
|
|
382
|
+
selector,
|
|
383
|
+
center,
|
|
384
|
+
rect: {
|
|
385
|
+
left: Number(rect.left || 0),
|
|
386
|
+
top: Number(rect.top || 0),
|
|
387
|
+
width: Number(rect.width || 0),
|
|
388
|
+
height: Number(rect.height || 0),
|
|
389
|
+
},
|
|
390
|
+
viewport: { width: vw, height: vh },
|
|
391
|
+
};
|
|
392
|
+
if (includeText) payload.text = String(node.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 180);
|
|
393
|
+
return payload;
|
|
255
394
|
};
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
395
|
+
for (const selector of selectors) {
|
|
396
|
+
const nodes = Array.from(document.querySelectorAll(selector));
|
|
397
|
+
for (const node of nodes) {
|
|
398
|
+
if (!isVisible(node)) continue;
|
|
399
|
+
const rect = node.getBoundingClientRect();
|
|
400
|
+
if (requireViewport && !inViewport(rect)) continue;
|
|
401
|
+
if (requireViewport && !hitVisible(node, rect)) continue;
|
|
402
|
+
return { ok: true, target: toPayload(selector, node) };
|
|
403
|
+
}
|
|
404
|
+
for (const node of nodes) {
|
|
405
|
+
if (!isVisible(node)) continue;
|
|
406
|
+
return { ok: true, target: toPayload(selector, node) };
|
|
407
|
+
}
|
|
263
408
|
}
|
|
409
|
+
return { ok: false };
|
|
410
|
+
})()`;
|
|
411
|
+
const payload = await evaluateReadonly(profileId, script);
|
|
412
|
+
if (!payload || payload.ok !== true || !payload.target?.center) return null;
|
|
413
|
+
return payload.target;
|
|
414
|
+
}
|
|
264
415
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
416
|
+
async function isDetailVisible(profileId) {
|
|
417
|
+
const script = `(() => {
|
|
418
|
+
const detailSelectors = [
|
|
419
|
+
'.note-detail-mask',
|
|
420
|
+
'.note-detail-page',
|
|
421
|
+
'.note-detail-dialog',
|
|
422
|
+
'.note-detail-mask .detail-container',
|
|
423
|
+
'.note-detail-mask .media-container',
|
|
424
|
+
'.note-detail-mask .note-scroller',
|
|
425
|
+
'.note-detail-mask .note-content',
|
|
426
|
+
'.note-detail-mask .interaction-container',
|
|
427
|
+
'.note-detail-mask .comments-container',
|
|
428
|
+
];
|
|
429
|
+
const searchSelectors = ['.note-item', '.search-result-list', '#search-input', '.feeds-page'];
|
|
430
|
+
const isVisible = (node) => {
|
|
431
|
+
if (!(node instanceof Element)) return false;
|
|
432
|
+
const rect = node.getBoundingClientRect?.();
|
|
433
|
+
if (!rect || rect.width <= 1 || rect.height <= 1) return false;
|
|
434
|
+
try {
|
|
435
|
+
const style = window.getComputedStyle(node);
|
|
436
|
+
if (!style) return false;
|
|
437
|
+
if (style.display === 'none') return false;
|
|
438
|
+
if (style.visibility === 'hidden' || style.visibility === 'collapse') return false;
|
|
439
|
+
const opacity = Number.parseFloat(String(style.opacity || '1'));
|
|
440
|
+
if (Number.isFinite(opacity) && opacity <= 0.01) return false;
|
|
441
|
+
} catch {
|
|
442
|
+
return false;
|
|
443
|
+
}
|
|
444
|
+
const sampleX = Math.max(0, Math.min((window.innerWidth || 1) - 1, rect.left + rect.width / 2));
|
|
445
|
+
const sampleY = Math.max(0, Math.min((window.innerHeight || 1) - 1, rect.top + rect.height / 2));
|
|
446
|
+
const top = document.elementFromPoint(sampleX, sampleY);
|
|
447
|
+
if (!top) return false;
|
|
448
|
+
return top === node || node.contains(top) || top.contains(node);
|
|
271
449
|
};
|
|
272
|
-
const
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
450
|
+
const hasVisible = (selectors) => selectors.some((selector) => isVisible(document.querySelector(selector)));
|
|
451
|
+
const detailVisible = hasVisible(detailSelectors);
|
|
452
|
+
const searchVisible = hasVisible(searchSelectors);
|
|
453
|
+
const href = String(location.href || '');
|
|
276
454
|
return {
|
|
277
|
-
|
|
278
|
-
|
|
455
|
+
detailVisible,
|
|
456
|
+
searchVisible,
|
|
457
|
+
detailReady: detailVisible,
|
|
458
|
+
href,
|
|
279
459
|
};
|
|
460
|
+
})()`;
|
|
461
|
+
return evaluateReadonly(profileId, script);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
async function closeDetailToSearch(profileId, pushTrace = null) {
|
|
465
|
+
const waitForCloseAnimation = async () => {
|
|
466
|
+
for (let i = 0; i < 45; i += 1) {
|
|
467
|
+
const s = await isDetailVisible(profileId);
|
|
468
|
+
if (s?.detailVisible !== true && s?.searchVisible === true) return true;
|
|
469
|
+
await sleep(120);
|
|
470
|
+
}
|
|
471
|
+
const s = await isDetailVisible(profileId);
|
|
472
|
+
return s?.detailVisible !== true && s?.searchVisible === true;
|
|
280
473
|
};
|
|
281
474
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
475
|
+
for (let attempt = 1; attempt <= 4; attempt += 1) {
|
|
476
|
+
await pressKey(profileId, 'Escape');
|
|
477
|
+
if (typeof pushTrace === 'function') {
|
|
478
|
+
pushTrace({ kind: 'key', stage: 'collect_links_close', key: 'Escape', attempt });
|
|
479
|
+
}
|
|
480
|
+
await sleep(randomBetween(220, 480));
|
|
481
|
+
if (await waitForCloseAnimation()) return true;
|
|
288
482
|
}
|
|
483
|
+
|
|
484
|
+
const snapshot = await isDetailVisible(profileId);
|
|
485
|
+
return snapshot?.detailVisible !== true && snapshot?.searchVisible === true;
|
|
289
486
|
}
|
|
290
487
|
|
|
291
|
-
function
|
|
292
|
-
|
|
293
|
-
const
|
|
488
|
+
async function readSearchInput(profileId) {
|
|
489
|
+
const script = `(() => {
|
|
490
|
+
const input = document.querySelector('#search-input, input.search-input');
|
|
491
|
+
if (!(input instanceof HTMLInputElement)) return { ok: false };
|
|
492
|
+
const rect = input.getBoundingClientRect();
|
|
493
|
+
const vw = Number(window.innerWidth || 0);
|
|
494
|
+
const vh = Number(window.innerHeight || 0);
|
|
495
|
+
const center = {
|
|
496
|
+
x: Math.max(1, Math.min(Math.max(1, vw - 1), Math.round(rect.left + rect.width / 2))),
|
|
497
|
+
y: Math.max(1, Math.min(Math.max(1, vh - 1), Math.round(rect.top + rect.height / 2))),
|
|
498
|
+
};
|
|
294
499
|
return {
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
: null,
|
|
500
|
+
ok: true,
|
|
501
|
+
value: String(input.value || ''),
|
|
502
|
+
center,
|
|
503
|
+
viewport: { width: vw, height: vh },
|
|
300
504
|
};
|
|
301
505
|
})()`;
|
|
506
|
+
return evaluateReadonly(profileId, script);
|
|
302
507
|
}
|
|
303
508
|
|
|
304
|
-
async function
|
|
305
|
-
|
|
306
|
-
const
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
509
|
+
async function readSearchCandidates(profileId) {
|
|
510
|
+
const script = `(() => {
|
|
511
|
+
const nodes = Array.from(document.querySelectorAll('.note-item'));
|
|
512
|
+
const isVisible = (node) => {
|
|
513
|
+
if (!(node instanceof Element)) return false;
|
|
514
|
+
const rect = node.getBoundingClientRect?.();
|
|
515
|
+
if (!rect || rect.width <= 0 || rect.height <= 0) return false;
|
|
516
|
+
try {
|
|
517
|
+
const style = window.getComputedStyle(node);
|
|
518
|
+
if (!style) return false;
|
|
519
|
+
if (style.display === 'none') return false;
|
|
520
|
+
if (style.visibility === 'hidden' || style.visibility === 'collapse') return false;
|
|
521
|
+
const opacity = Number.parseFloat(String(style.opacity || '1'));
|
|
522
|
+
if (Number.isFinite(opacity) && opacity <= 0.01) return false;
|
|
523
|
+
} catch {
|
|
524
|
+
return false;
|
|
525
|
+
}
|
|
526
|
+
return true;
|
|
527
|
+
};
|
|
528
|
+
const rows = [];
|
|
529
|
+
for (let index = 0; index < nodes.length; index += 1) {
|
|
530
|
+
const item = nodes[index];
|
|
531
|
+
const cover = item.querySelector('a.cover');
|
|
532
|
+
if (!(cover instanceof Element)) continue;
|
|
533
|
+
if (!isVisible(cover)) continue;
|
|
534
|
+
const href = String(cover.getAttribute('href') || '').trim();
|
|
535
|
+
const seg = href.split('/').filter(Boolean).pop() || '';
|
|
536
|
+
const noteId = (seg.split('?')[0].split('#')[0] || ('idx_' + index)).trim();
|
|
537
|
+
const rect = cover.getBoundingClientRect();
|
|
538
|
+
const vw = Number(window.innerWidth || 0);
|
|
539
|
+
const vh = Number(window.innerHeight || 0);
|
|
540
|
+
const inViewport = rect.right > 0 && rect.bottom > 0 && rect.left < vw && rect.top < vh;
|
|
541
|
+
rows.push({
|
|
542
|
+
index,
|
|
543
|
+
noteId,
|
|
544
|
+
href,
|
|
545
|
+
inViewport,
|
|
546
|
+
center: {
|
|
547
|
+
x: Math.max(1, Math.min(Math.max(1, vw - 1), Math.round(rect.left + rect.width / 2))),
|
|
548
|
+
y: Math.max(1, Math.min(Math.max(1, vh - 1), Math.round(rect.top + rect.height / 2))),
|
|
549
|
+
},
|
|
550
|
+
rect: {
|
|
551
|
+
left: Number(rect.left || 0),
|
|
552
|
+
top: Number(rect.top || 0),
|
|
553
|
+
width: Number(rect.width || 0),
|
|
554
|
+
height: Number(rect.height || 0),
|
|
555
|
+
},
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
return {
|
|
559
|
+
rows,
|
|
560
|
+
page: {
|
|
561
|
+
href: String(location.href || ''),
|
|
562
|
+
innerHeight: Number(window.innerHeight || 0),
|
|
563
|
+
},
|
|
564
|
+
};
|
|
565
|
+
})()`;
|
|
566
|
+
return evaluateReadonly(profileId, script);
|
|
315
567
|
}
|
|
316
568
|
|
|
317
|
-
function
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
569
|
+
async function paintSearchCandidates(profileId, {
|
|
570
|
+
candidateNoteIds = [],
|
|
571
|
+
selectedNoteId = '',
|
|
572
|
+
processedNoteIds = [],
|
|
573
|
+
} = {}) {
|
|
574
|
+
const script = `(() => {
|
|
575
|
+
const candidateColor = '#3b82f6';
|
|
576
|
+
const selectedColor = '#facc15';
|
|
577
|
+
const processedColor = '#8b5cf6';
|
|
578
|
+
const candidate = new Set(${JSON.stringify(normalizeNoteIdList(candidateNoteIds))});
|
|
579
|
+
const processed = new Set(${JSON.stringify(normalizeNoteIdList(processedNoteIds))});
|
|
580
|
+
const selected = ${JSON.stringify(String(selectedNoteId || '').trim())};
|
|
581
|
+
const parseNoteId = (item, index) => {
|
|
582
|
+
const cover = item?.querySelector?.('a.cover');
|
|
583
|
+
const href = String(cover?.getAttribute?.('href') || '').trim();
|
|
584
|
+
if (!href) return 'idx_' + index;
|
|
585
|
+
const match = href.match(/\\/explore\\/([^/?#]+)/);
|
|
586
|
+
if (match && match[1]) return String(match[1]).trim();
|
|
587
|
+
const seg = href.split('/').filter(Boolean).pop() || '';
|
|
588
|
+
return String(seg).split('?')[0].split('#')[0].trim() || ('idx_' + index);
|
|
589
|
+
};
|
|
590
|
+
const clearMark = (node) => {
|
|
591
|
+
if (!(node instanceof HTMLElement)) return;
|
|
592
|
+
if (node.dataset.webautoXhsMark !== '1') return;
|
|
593
|
+
node.style.outline = '';
|
|
594
|
+
node.style.outlineOffset = '';
|
|
595
|
+
node.style.boxShadow = '';
|
|
596
|
+
node.dataset.webautoXhsMark = '0';
|
|
597
|
+
};
|
|
598
|
+
const applyMark = (node, color) => {
|
|
599
|
+
if (!(node instanceof HTMLElement)) return;
|
|
600
|
+
node.style.outline = '2px solid ' + color;
|
|
601
|
+
node.style.outlineOffset = '2px';
|
|
602
|
+
node.style.boxShadow = 'inset 0 0 0 2px ' + color;
|
|
603
|
+
node.dataset.webautoXhsMark = '1';
|
|
604
|
+
};
|
|
605
|
+
const rows = Array.from(document.querySelectorAll('.note-item'));
|
|
606
|
+
for (let i = 0; i < rows.length; i += 1) {
|
|
607
|
+
const row = rows[i];
|
|
608
|
+
const noteId = parseNoteId(row, i);
|
|
609
|
+
if (noteId && processed.has(noteId)) {
|
|
610
|
+
applyMark(row, processedColor);
|
|
611
|
+
} else if (noteId && selected && noteId === selected) {
|
|
612
|
+
applyMark(row, selectedColor);
|
|
613
|
+
} else if (noteId && candidate.has(noteId)) {
|
|
614
|
+
applyMark(row, candidateColor);
|
|
615
|
+
} else {
|
|
616
|
+
clearMark(row);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
return { ok: true, count: rows.length };
|
|
620
|
+
})()`;
|
|
621
|
+
return evaluateReadonly(profileId, script).catch(() => ({ ok: false }));
|
|
622
|
+
}
|
|
332
623
|
|
|
624
|
+
async function readDetailSnapshot(profileId) {
|
|
625
|
+
const script = `(() => {
|
|
333
626
|
const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim();
|
|
334
627
|
const isVisible = (node) => {
|
|
335
|
-
if (!(node instanceof
|
|
336
|
-
const
|
|
337
|
-
if (!
|
|
338
|
-
|
|
628
|
+
if (!(node instanceof Element)) return false;
|
|
629
|
+
const rect = node.getBoundingClientRect?.();
|
|
630
|
+
if (!rect || rect.width <= 1 || rect.height <= 1) return false;
|
|
631
|
+
try {
|
|
632
|
+
const style = window.getComputedStyle(node);
|
|
633
|
+
if (!style) return false;
|
|
634
|
+
if (style.display === 'none') return false;
|
|
635
|
+
if (style.visibility === 'hidden' || style.visibility === 'collapse') return false;
|
|
636
|
+
} catch {
|
|
637
|
+
return false;
|
|
638
|
+
}
|
|
639
|
+
return true;
|
|
640
|
+
};
|
|
641
|
+
const detailRoot = document.querySelector('.note-detail-mask')
|
|
642
|
+
|| document.querySelector('.note-detail-page')
|
|
643
|
+
|| document.querySelector('.note-detail-dialog')
|
|
644
|
+
|| document.body;
|
|
645
|
+
const text = (selector) => normalize(detailRoot?.querySelector(selector)?.textContent || '');
|
|
646
|
+
const title = text('.note-title').slice(0, 200);
|
|
647
|
+
const content = text('.note-content');
|
|
648
|
+
const href = String(location.href || '');
|
|
649
|
+
const noteMatch = href.match(/\\/explore\\/([^/?#]+)/);
|
|
650
|
+
const imageNodes = Array.from(detailRoot?.querySelectorAll?.('.note-content img, .swiper-wrapper img, .media-container img, img') || []);
|
|
651
|
+
const imageSet = new Set();
|
|
652
|
+
for (const node of imageNodes) {
|
|
653
|
+
if (!(node instanceof HTMLImageElement)) continue;
|
|
654
|
+
if (!isVisible(node)) continue;
|
|
655
|
+
const src = normalize(node.currentSrc || node.src || node.getAttribute('src') || '');
|
|
656
|
+
if (!src) continue;
|
|
657
|
+
imageSet.add(src);
|
|
658
|
+
}
|
|
659
|
+
const videoNodes = Array.from(detailRoot?.querySelectorAll?.('video, .player video, [class*="video"] video') || []);
|
|
660
|
+
let videoUrl = '';
|
|
661
|
+
let videoPresent = false;
|
|
662
|
+
for (const node of videoNodes) {
|
|
663
|
+
if (!(node instanceof HTMLVideoElement)) continue;
|
|
664
|
+
if (!isVisible(node)) continue;
|
|
665
|
+
videoPresent = true;
|
|
666
|
+
const src = normalize(node.currentSrc || node.src || node.getAttribute('src') || '');
|
|
667
|
+
if (!videoUrl && src) videoUrl = src;
|
|
668
|
+
}
|
|
669
|
+
const commentsContextAvailable = Boolean(
|
|
670
|
+
detailRoot?.querySelector?.('.comments-container')
|
|
671
|
+
|| detailRoot?.querySelector?.('.comment-list')
|
|
672
|
+
|| detailRoot?.querySelector?.('.comment-item')
|
|
673
|
+
|| detailRoot?.querySelector?.('[class*="comment-item"]')
|
|
674
|
+
|| detailRoot?.querySelector?.('.note-scroller')
|
|
675
|
+
);
|
|
676
|
+
return {
|
|
677
|
+
title,
|
|
678
|
+
contentLength: content.length,
|
|
679
|
+
contentPreview: content.slice(0, 500),
|
|
680
|
+
noteIdFromUrl: noteMatch && noteMatch[1] ? String(noteMatch[1]) : null,
|
|
681
|
+
href,
|
|
682
|
+
textPresent: Boolean(title || content),
|
|
683
|
+
imageCount: imageSet.size,
|
|
684
|
+
imageUrls: Array.from(imageSet).slice(0, 24),
|
|
685
|
+
videoPresent,
|
|
686
|
+
videoUrl: videoUrl || null,
|
|
687
|
+
commentsContextAvailable,
|
|
688
|
+
capturedAt: new Date().toISOString(),
|
|
689
|
+
};
|
|
690
|
+
})()`;
|
|
691
|
+
return evaluateReadonly(profileId, script);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
async function readExpandButtons(profileId) {
|
|
695
|
+
const script = `(() => {
|
|
696
|
+
const selectors = [
|
|
697
|
+
'.note-detail-mask .show-more',
|
|
698
|
+
'.note-detail-mask .reply-expand',
|
|
699
|
+
'.note-detail-mask [class*="expand"]',
|
|
700
|
+
'.note-detail-page .show-more',
|
|
701
|
+
'.note-detail-page .reply-expand',
|
|
702
|
+
'.note-detail-page [class*="expand"]',
|
|
703
|
+
];
|
|
704
|
+
const nodes = selectors.flatMap((selector) => Array.from(document.querySelectorAll(selector)));
|
|
705
|
+
const isVisible = (node) => {
|
|
706
|
+
if (!(node instanceof Element)) return false;
|
|
707
|
+
const rect = node.getBoundingClientRect?.();
|
|
708
|
+
if (!rect || rect.width <= 0 || rect.height <= 0) return false;
|
|
709
|
+
try {
|
|
710
|
+
const style = window.getComputedStyle(node);
|
|
711
|
+
if (!style) return false;
|
|
712
|
+
if (style.display === 'none') return false;
|
|
713
|
+
if (style.visibility === 'hidden' || style.visibility === 'collapse') return false;
|
|
714
|
+
} catch {
|
|
715
|
+
return false;
|
|
716
|
+
}
|
|
717
|
+
return true;
|
|
718
|
+
};
|
|
719
|
+
const out = [];
|
|
720
|
+
const vw = Number(window.innerWidth || 0);
|
|
721
|
+
const vh = Number(window.innerHeight || 0);
|
|
722
|
+
for (const node of nodes) {
|
|
723
|
+
if (!isVisible(node)) continue;
|
|
724
|
+
const text = String(node.textContent || '').replace(/\s+/g, ' ').trim();
|
|
725
|
+
if (!text) continue;
|
|
339
726
|
const rect = node.getBoundingClientRect();
|
|
340
|
-
|
|
727
|
+
out.push({
|
|
728
|
+
text,
|
|
729
|
+
signature: String(text)
|
|
730
|
+
+ '::' + String(Math.round(rect.left))
|
|
731
|
+
+ '::' + String(Math.round(rect.top))
|
|
732
|
+
+ '::' + String(Math.round(rect.width))
|
|
733
|
+
+ '::' + String(Math.round(rect.height)),
|
|
734
|
+
center: {
|
|
735
|
+
x: Math.max(1, Math.min(Math.max(1, vw - 1), Math.round(rect.left + rect.width / 2))),
|
|
736
|
+
y: Math.max(1, Math.min(Math.max(1, vh - 1), Math.round(rect.top + rect.height / 2))),
|
|
737
|
+
},
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
return { rows: out };
|
|
741
|
+
})()`;
|
|
742
|
+
return evaluateReadonly(profileId, script);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
async function readCommentsSnapshot(profileId) {
|
|
746
|
+
const script = `(() => {
|
|
747
|
+
const parseCountToken = (raw) => {
|
|
748
|
+
const token = String(raw || '').trim();
|
|
749
|
+
const matched = token.match(/^([0-9]+(?:\\.[0-9]+)?)(万|w|W)?$/);
|
|
750
|
+
if (!matched) return null;
|
|
751
|
+
const base = Number(matched[1]);
|
|
752
|
+
if (!Number.isFinite(base)) return null;
|
|
753
|
+
if (!matched[2]) return Math.round(base);
|
|
754
|
+
return Math.round(base * 10000);
|
|
341
755
|
};
|
|
756
|
+
const isVisible = (node) => {
|
|
757
|
+
if (!(node instanceof Element)) return false;
|
|
758
|
+
const rect = node.getBoundingClientRect?.();
|
|
759
|
+
if (!rect || rect.width <= 1 || rect.height <= 1) return false;
|
|
760
|
+
try {
|
|
761
|
+
const style = window.getComputedStyle(node);
|
|
762
|
+
if (!style) return false;
|
|
763
|
+
if (style.display === 'none') return false;
|
|
764
|
+
if (style.visibility === 'hidden' || style.visibility === 'collapse') return false;
|
|
765
|
+
} catch {
|
|
766
|
+
return false;
|
|
767
|
+
}
|
|
768
|
+
return true;
|
|
769
|
+
};
|
|
770
|
+
const detailSelectors = [
|
|
771
|
+
'.note-detail-mask',
|
|
772
|
+
'.note-detail-page',
|
|
773
|
+
'.note-detail-dialog',
|
|
774
|
+
'.note-detail-mask .detail-container',
|
|
775
|
+
'.note-detail-mask .media-container',
|
|
776
|
+
'.note-detail-mask .note-scroller',
|
|
777
|
+
'.note-detail-mask .note-content',
|
|
778
|
+
'.note-detail-mask .interaction-container',
|
|
779
|
+
'.note-detail-mask .comments-container',
|
|
780
|
+
];
|
|
781
|
+
const detailVisible = detailSelectors.some((selector) => isVisible(document.querySelector(selector)));
|
|
782
|
+
const hasCommentsContext = Boolean(
|
|
783
|
+
document.querySelector('.comments-container')
|
|
784
|
+
|| document.querySelector('.comment-list')
|
|
785
|
+
|| document.querySelector('.comment-item')
|
|
786
|
+
|| document.querySelector('[class*="comment-item"]')
|
|
787
|
+
|| document.querySelector('.note-scroller')
|
|
788
|
+
);
|
|
789
|
+
const scopeSelectors = [
|
|
790
|
+
'.note-detail-mask .interaction-container',
|
|
791
|
+
'.note-detail-mask .comments-container',
|
|
792
|
+
'.note-detail-page .interaction-container',
|
|
793
|
+
'.note-detail-page .comments-container',
|
|
794
|
+
'.note-detail-mask',
|
|
795
|
+
'.note-detail-page',
|
|
796
|
+
];
|
|
797
|
+
const patterns = [
|
|
798
|
+
/([0-9]+(?:\\.[0-9]+)?(?:万|w|W)?)\\s*条?评论/,
|
|
799
|
+
/评论\\s*([0-9]+(?:\\.[0-9]+)?(?:万|w|W)?)/,
|
|
800
|
+
/共\\s*([0-9]+(?:\\.[0-9]+)?(?:万|w|W)?)\\s*条/,
|
|
801
|
+
];
|
|
802
|
+
let expectedCommentsCount = null;
|
|
803
|
+
for (const selector of scopeSelectors) {
|
|
804
|
+
const root = document.querySelector(selector);
|
|
805
|
+
if (!root) continue;
|
|
806
|
+
const text = String(root.textContent || '').replace(/\\s+/g, ' ').trim();
|
|
807
|
+
if (!text) continue;
|
|
808
|
+
for (const re of patterns) {
|
|
809
|
+
const matched = text.match(re);
|
|
810
|
+
if (!matched || !matched[1]) continue;
|
|
811
|
+
const parsed = parseCountToken(matched[1]);
|
|
812
|
+
if (Number.isFinite(parsed) && parsed >= 0) {
|
|
813
|
+
expectedCommentsCount = parsed;
|
|
814
|
+
break;
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
if (expectedCommentsCount !== null) break;
|
|
818
|
+
}
|
|
342
819
|
|
|
343
|
-
const
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
.
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
const
|
|
350
|
-
const
|
|
351
|
-
const
|
|
820
|
+
const scroller = document.querySelector('.note-scroller')
|
|
821
|
+
|| document.querySelector('.comments-el')
|
|
822
|
+
|| document.querySelector('.comments-container')
|
|
823
|
+
|| document.scrollingElement
|
|
824
|
+
|| document.documentElement;
|
|
825
|
+
const scrollerRect = scroller?.getBoundingClientRect?.() || { left: 0, top: 0, width: 1, height: 1 };
|
|
826
|
+
const vw = Number(window.innerWidth || 0);
|
|
827
|
+
const vh = Number(window.innerHeight || 0);
|
|
828
|
+
const scrollerCenter = {
|
|
829
|
+
x: Math.max(1, Math.min(Math.max(1, vw - 1), Math.round(scrollerRect.left + scrollerRect.width / 2))),
|
|
830
|
+
y: Math.max(1, Math.min(Math.max(1, vh - 1), Math.round(scrollerRect.top + Math.min(scrollerRect.height * 0.6, Math.max(80, scrollerRect.height - 60))))),
|
|
831
|
+
};
|
|
352
832
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
833
|
+
const readText = (item, selectors) => {
|
|
834
|
+
for (const selector of selectors) {
|
|
835
|
+
const node = item.querySelector(selector);
|
|
836
|
+
const text = String(node?.textContent || '').replace(/\\s+/g, ' ').trim();
|
|
837
|
+
if (text) return text;
|
|
838
|
+
}
|
|
839
|
+
return '';
|
|
840
|
+
};
|
|
841
|
+
const readAttr = (item, attrs) => {
|
|
842
|
+
for (const attr of attrs) {
|
|
843
|
+
const value = String(item.getAttribute?.(attr) || '').trim();
|
|
844
|
+
if (value) return value;
|
|
845
|
+
}
|
|
846
|
+
return '';
|
|
847
|
+
};
|
|
848
|
+
const readUserName = (item, commentText) => {
|
|
849
|
+
const attrNames = ['data-user-name', 'data-username', 'data-user_nickname', 'data-nickname'];
|
|
850
|
+
for (const attr of attrNames) {
|
|
851
|
+
const value = String(item.getAttribute?.(attr) || '').replace(/\\s+/g, ' ').trim();
|
|
852
|
+
if (value && value !== commentText && value.length <= 40) return value;
|
|
853
|
+
}
|
|
854
|
+
const selectors = [
|
|
855
|
+
'.comment-user .name',
|
|
856
|
+
'.comment-user .username',
|
|
857
|
+
'.comment-user .user-name',
|
|
858
|
+
'.author .name',
|
|
859
|
+
'.author',
|
|
860
|
+
'.user-name',
|
|
861
|
+
'.username',
|
|
862
|
+
'.name',
|
|
863
|
+
'a[href*="/user/profile/"]',
|
|
864
|
+
'a[href*="/user/"]',
|
|
865
|
+
];
|
|
866
|
+
for (const selector of selectors) {
|
|
867
|
+
const node = item.querySelector(selector);
|
|
868
|
+
if (!node) continue;
|
|
869
|
+
const title = String(node.getAttribute?.('title') || '').replace(/\\s+/g, ' ').trim();
|
|
870
|
+
if (title && title !== commentText && title.length <= 40) return title;
|
|
871
|
+
const text = String(node.textContent || '').replace(/\\s+/g, ' ').trim();
|
|
872
|
+
if (text && text !== commentText && text.length <= 40) return text;
|
|
873
|
+
}
|
|
874
|
+
return '';
|
|
875
|
+
};
|
|
876
|
+
const readUserId = (item) => {
|
|
877
|
+
const value = readAttr(item, ['data-user-id', 'data-userid', 'data-user_id']);
|
|
878
|
+
if (value) return value;
|
|
879
|
+
const anchor = item.querySelector('a[href*="/user/profile/"], a[href*="/user/"]');
|
|
880
|
+
const href = String(anchor?.getAttribute?.('href') || '').trim();
|
|
881
|
+
const matched = href.match(/\\/user\\/(?:profile\\/)?([a-zA-Z0-9_-]+)/);
|
|
882
|
+
return matched && matched[1] ? matched[1] : '';
|
|
883
|
+
};
|
|
884
|
+
const findLikeControl = (item) => {
|
|
885
|
+
const selectors = [
|
|
886
|
+
'.like-wrapper',
|
|
887
|
+
'.comment-like',
|
|
888
|
+
'.interactions .like-wrapper',
|
|
889
|
+
'.interactions [class*="like"]',
|
|
890
|
+
'button[class*="like"]',
|
|
891
|
+
'[aria-label*="赞"]',
|
|
892
|
+
];
|
|
893
|
+
for (const selector of selectors) {
|
|
894
|
+
const node = item.querySelector(selector);
|
|
895
|
+
if (node instanceof Element) return node;
|
|
896
|
+
}
|
|
897
|
+
return null;
|
|
898
|
+
};
|
|
899
|
+
const isAlreadyLiked = (node) => {
|
|
900
|
+
if (!node) return false;
|
|
901
|
+
const className = String(node.className || '').toLowerCase();
|
|
902
|
+
const ariaPressed = String(node.getAttribute?.('aria-pressed') || '').toLowerCase();
|
|
903
|
+
const text = String(node.textContent || '');
|
|
904
|
+
return /(?:^|\\s)like-active(?:\\s|$)/.test(className) || ariaPressed === 'true' || /已赞|取消赞/.test(text);
|
|
905
|
+
};
|
|
906
|
+
|
|
907
|
+
const rows = [];
|
|
908
|
+
const commentNodes = Array.from(document.querySelectorAll('.comment-item, [class*="comment-item"]'));
|
|
909
|
+
for (let index = 0; index < commentNodes.length; index += 1) {
|
|
910
|
+
const item = commentNodes[index];
|
|
911
|
+
const text = readText(item, ['.content', '.comment-content', 'p']);
|
|
912
|
+
if (!text) continue;
|
|
913
|
+
const userName = readUserName(item, text);
|
|
914
|
+
const userId = readUserId(item);
|
|
915
|
+
const timestamp = readText(item, ['.date', '.time', '.timestamp', '[class*="time"]']);
|
|
916
|
+
const likeControl = findLikeControl(item);
|
|
917
|
+
rows.push({
|
|
918
|
+
index,
|
|
919
|
+
text,
|
|
920
|
+
userName,
|
|
921
|
+
userId,
|
|
922
|
+
timestamp,
|
|
923
|
+
hasLikeControl: Boolean(likeControl),
|
|
924
|
+
alreadyLiked: isAlreadyLiked(likeControl),
|
|
925
|
+
});
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
const href = String(location.href || '');
|
|
929
|
+
const noteMatch = href.match(/\\/explore\\/([^/?#]+)/);
|
|
930
|
+
return {
|
|
931
|
+
detailVisible,
|
|
932
|
+
hasCommentsContext,
|
|
933
|
+
noteIdFromUrl: noteMatch && noteMatch[1] ? String(noteMatch[1]) : null,
|
|
934
|
+
metrics: {
|
|
935
|
+
scrollTop: Number(scroller?.scrollTop || 0),
|
|
936
|
+
scrollHeight: Number(scroller?.scrollHeight || 0),
|
|
937
|
+
clientHeight: Number(scroller?.clientHeight || window.innerHeight || 0),
|
|
938
|
+
},
|
|
939
|
+
expectedCommentsCount,
|
|
940
|
+
rows,
|
|
941
|
+
scrollerCenter,
|
|
942
|
+
};
|
|
943
|
+
})()`;
|
|
944
|
+
return evaluateReadonly(profileId, script);
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
async function readLikeTargetByIndex(profileId, index) {
|
|
948
|
+
const idx = Math.max(0, Number(index) || 0);
|
|
949
|
+
const script = `(() => {
|
|
950
|
+
const index = Number(${JSON.stringify(idx)});
|
|
951
|
+
const findLikeControl = (item) => {
|
|
952
|
+
const selectors = [
|
|
953
|
+
'.like-wrapper',
|
|
954
|
+
'.comment-like',
|
|
955
|
+
'.interactions .like-wrapper',
|
|
956
|
+
'.interactions [class*="like"]',
|
|
957
|
+
'button[class*="like"]',
|
|
958
|
+
'[aria-label*="赞"]',
|
|
959
|
+
];
|
|
960
|
+
for (const selector of selectors) {
|
|
961
|
+
const node = item.querySelector(selector);
|
|
962
|
+
if (node instanceof Element) return node;
|
|
963
|
+
}
|
|
964
|
+
return null;
|
|
965
|
+
};
|
|
966
|
+
const isAlreadyLiked = (node) => {
|
|
967
|
+
if (!node) return false;
|
|
968
|
+
const className = String(node.className || '').toLowerCase();
|
|
969
|
+
const ariaPressed = String(node.getAttribute?.('aria-pressed') || '').toLowerCase();
|
|
970
|
+
const text = String(node.textContent || '');
|
|
971
|
+
return /(?:^|\\s)like-active(?:\\s|$)/.test(className) || ariaPressed === 'true' || /已赞|取消赞/.test(text);
|
|
972
|
+
};
|
|
973
|
+
const nodes = Array.from(document.querySelectorAll('.comment-item, [class*="comment-item"]'));
|
|
974
|
+
const target = nodes[index];
|
|
975
|
+
if (!(target instanceof Element)) return { ok: false, reason: 'comment_item_not_found' };
|
|
976
|
+
const likeNode = findLikeControl(target);
|
|
977
|
+
if (!(likeNode instanceof Element)) return { ok: false, reason: 'like_control_not_found' };
|
|
978
|
+
const rect = likeNode.getBoundingClientRect();
|
|
979
|
+
const vw = Number(window.innerWidth || 0);
|
|
980
|
+
const vh = Number(window.innerHeight || 0);
|
|
981
|
+
return {
|
|
982
|
+
ok: true,
|
|
983
|
+
index,
|
|
984
|
+
alreadyLiked: isAlreadyLiked(likeNode),
|
|
985
|
+
center: {
|
|
986
|
+
x: Math.max(1, Math.min(Math.max(1, vw - 1), Math.round(rect.left + rect.width / 2))),
|
|
987
|
+
y: Math.max(1, Math.min(Math.max(1, vh - 1), Math.round(rect.top + rect.height / 2))),
|
|
988
|
+
},
|
|
989
|
+
};
|
|
990
|
+
})()`;
|
|
991
|
+
return evaluateReadonly(profileId, script);
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
async function readReplyTargetByIndex(profileId, index) {
|
|
995
|
+
const idx = Math.max(0, Number(index) || 0);
|
|
996
|
+
const script = `(() => {
|
|
997
|
+
const index = Number(${JSON.stringify(idx)});
|
|
998
|
+
const rows = Array.from(document.querySelectorAll('.comment-item, [class*="comment-item"]'));
|
|
999
|
+
const target = rows[index];
|
|
1000
|
+
if (!(target instanceof Element)) return { ok: false, reason: 'match_not_visible' };
|
|
1001
|
+
const targetRect = target.getBoundingClientRect();
|
|
1002
|
+
const vw = Number(window.innerWidth || 0);
|
|
1003
|
+
const vh = Number(window.innerHeight || 0);
|
|
1004
|
+
const center = {
|
|
1005
|
+
x: Math.max(1, Math.min(Math.max(1, vw - 1), Math.round(targetRect.left + targetRect.width / 2))),
|
|
1006
|
+
y: Math.max(1, Math.min(Math.max(1, vh - 1), Math.round(targetRect.top + Math.min(32, Math.max(12, targetRect.height / 3))))),
|
|
1007
|
+
};
|
|
1008
|
+
return { ok: true, center };
|
|
1009
|
+
})()`;
|
|
1010
|
+
return evaluateReadonly(profileId, script);
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
async function readReplyInputTarget(profileId) {
|
|
1014
|
+
return resolveSelectorTarget(profileId, [
|
|
1015
|
+
'textarea',
|
|
1016
|
+
'input[placeholder*="说点"]',
|
|
1017
|
+
'[contenteditable="true"]',
|
|
1018
|
+
]);
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
async function readReplySendButtonTarget(profileId) {
|
|
1022
|
+
const script = `(() => {
|
|
1023
|
+
const buttons = Array.from(document.querySelectorAll('button'));
|
|
1024
|
+
const vw = Number(window.innerWidth || 0);
|
|
1025
|
+
const vh = Number(window.innerHeight || 0);
|
|
1026
|
+
const isVisible = (node) => {
|
|
1027
|
+
if (!(node instanceof Element)) return false;
|
|
1028
|
+
const rect = node.getBoundingClientRect?.();
|
|
1029
|
+
if (!rect || rect.width <= 0 || rect.height <= 0) return false;
|
|
1030
|
+
try {
|
|
1031
|
+
const style = window.getComputedStyle(node);
|
|
1032
|
+
if (!style) return false;
|
|
1033
|
+
if (style.display === 'none') return false;
|
|
1034
|
+
if (style.visibility === 'hidden' || style.visibility === 'collapse') return false;
|
|
1035
|
+
} catch {
|
|
1036
|
+
return false;
|
|
1037
|
+
}
|
|
1038
|
+
return true;
|
|
1039
|
+
};
|
|
1040
|
+
const target = buttons.find((button) => {
|
|
1041
|
+
if (!isVisible(button)) return false;
|
|
1042
|
+
const text = String(button.textContent || '').replace(/\s+/g, ' ').trim();
|
|
1043
|
+
return /发送|回复/.test(text);
|
|
1044
|
+
}) || null;
|
|
1045
|
+
if (!target) return { ok: false };
|
|
1046
|
+
const rect = target.getBoundingClientRect();
|
|
1047
|
+
return {
|
|
1048
|
+
ok: true,
|
|
1049
|
+
center: {
|
|
1050
|
+
x: Math.max(1, Math.min(Math.max(1, vw - 1), Math.round(rect.left + rect.width / 2))),
|
|
1051
|
+
y: Math.max(1, Math.min(Math.max(1, vh - 1), Math.round(rect.top + rect.height / 2))),
|
|
1052
|
+
},
|
|
1053
|
+
};
|
|
1054
|
+
})()`;
|
|
1055
|
+
const payload = await evaluateReadonly(profileId, script);
|
|
1056
|
+
return payload?.ok === true && payload.center ? payload.center : null;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
async function captureScreenshotToFile({ profileId, filePath }) {
|
|
1060
|
+
try {
|
|
1061
|
+
const payload = await callAPI('screenshot', { profileId, fullPage: false });
|
|
1062
|
+
const base64 = extractScreenshotBase64(payload);
|
|
1063
|
+
if (!base64) return null;
|
|
1064
|
+
return savePngBase64(filePath, base64);
|
|
1065
|
+
} catch {
|
|
1066
|
+
return null;
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
function buildAssertLoggedInScript(params = {}) {
|
|
1071
|
+
const selectors = Array.isArray(params.loginSelectors) && params.loginSelectors.length > 0
|
|
1072
|
+
? params.loginSelectors.map((item) => String(item || '').trim()).filter(Boolean)
|
|
1073
|
+
: [
|
|
1074
|
+
'.login-container',
|
|
1075
|
+
'.login-dialog',
|
|
1076
|
+
'#login-container',
|
|
1077
|
+
];
|
|
1078
|
+
const loginPattern = String(
|
|
1079
|
+
params.loginPattern || '登录|扫码|验证码|手机号|请先登录|注册|sign\\s*in',
|
|
1080
|
+
).trim();
|
|
1081
|
+
|
|
1082
|
+
return `(() => {
|
|
1083
|
+
const guardSelectors = ${JSON.stringify(selectors)};
|
|
1084
|
+
const loginPattern = new RegExp(${JSON.stringify(loginPattern || '登录|扫码|验证码|手机号|请先登录|注册|sign\\\\s*in')}, 'i');
|
|
1085
|
+
|
|
1086
|
+
const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim();
|
|
1087
|
+
const isVisible = (node) => {
|
|
1088
|
+
if (!(node instanceof HTMLElement)) return false;
|
|
1089
|
+
const style = window.getComputedStyle(node);
|
|
1090
|
+
if (!style) return false;
|
|
1091
|
+
if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity || '1') === 0) return false;
|
|
1092
|
+
const rect = node.getBoundingClientRect();
|
|
1093
|
+
return rect.width > 0 && rect.height > 0;
|
|
1094
|
+
};
|
|
1095
|
+
|
|
1096
|
+
const guardNodes = guardSelectors.flatMap((selector) => Array.from(document.querySelectorAll(selector)));
|
|
1097
|
+
const visibleGuardNodes = guardNodes.filter((node) => isVisible(node));
|
|
1098
|
+
const guardTexts = visibleGuardNodes
|
|
1099
|
+
.slice(0, 10)
|
|
1100
|
+
.map((node) => normalize(node.textContent || ''))
|
|
1101
|
+
.filter(Boolean);
|
|
1102
|
+
const mergedGuardText = guardTexts.join(' ');
|
|
1103
|
+
const hasLoginText = loginPattern.test(mergedGuardText);
|
|
1104
|
+
const loginUrl = /\\/login|signin|passport|account\\/login/i.test(String(location.href || ''));
|
|
1105
|
+
|
|
1106
|
+
let accountId = '';
|
|
1107
|
+
try {
|
|
1108
|
+
const initialState = (typeof window !== 'undefined' && window.__INITIAL_STATE__) || null;
|
|
1109
|
+
const rawUserInfo = initialState && initialState.user && initialState.user.userInfo
|
|
1110
|
+
? (
|
|
1111
|
+
(initialState.user.userInfo._rawValue && typeof initialState.user.userInfo._rawValue === 'object' && initialState.user.userInfo._rawValue)
|
|
1112
|
+
|| (initialState.user.userInfo._value && typeof initialState.user.userInfo._value === 'object' && initialState.user.userInfo._value)
|
|
1113
|
+
|| (typeof initialState.user.userInfo === 'object' ? initialState.user.userInfo : null)
|
|
1114
|
+
)
|
|
1115
|
+
: null;
|
|
363
1116
|
accountId = normalize(rawUserInfo?.user_id || rawUserInfo?.userId || '');
|
|
364
1117
|
} catch {}
|
|
365
1118
|
|
|
@@ -394,32 +1147,696 @@ function buildAssertLoggedInScript(params = {}) {
|
|
|
394
1147
|
hasLoginText,
|
|
395
1148
|
guardSelectors,
|
|
396
1149
|
};
|
|
397
|
-
})()`;
|
|
1150
|
+
})()`;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
async function executeAssertLoggedInOperation({ profileId, params = {} }) {
|
|
1154
|
+
const payload = await runEvaluateScript({
|
|
1155
|
+
profileId,
|
|
1156
|
+
script: buildAssertLoggedInScript(params),
|
|
1157
|
+
highlight: false,
|
|
1158
|
+
});
|
|
1159
|
+
const data = extractEvaluateResultData(payload) || {};
|
|
1160
|
+
if (data?.hasLoginGuard === true) {
|
|
1161
|
+
const code = String(params.code || 'LOGIN_GUARD_DETECTED').trim() || 'LOGIN_GUARD_DETECTED';
|
|
1162
|
+
return asErrorPayload('OPERATION_FAILED', code, { guard: data });
|
|
1163
|
+
}
|
|
1164
|
+
return {
|
|
1165
|
+
ok: true,
|
|
1166
|
+
code: 'OPERATION_DONE',
|
|
1167
|
+
message: 'xhs_assert_logged_in done',
|
|
1168
|
+
data,
|
|
1169
|
+
};
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
async function executeSubmitSearchOperation({
|
|
1173
|
+
profileId,
|
|
1174
|
+
params = {},
|
|
1175
|
+
context = {},
|
|
1176
|
+
}) {
|
|
1177
|
+
const lockKey = resolveSearchLockKey(params);
|
|
1178
|
+
return withSerializedLock(lockKey ? `xhs_submit_search:${lockKey}` : '', async () => {
|
|
1179
|
+
const profileState = getProfileState(profileId);
|
|
1180
|
+
const metrics = profileState.metrics || (profileState.metrics = {});
|
|
1181
|
+
const { actionTrace, pushTrace } = buildTraceRecorder();
|
|
1182
|
+
|
|
1183
|
+
const methodRequested = String(params.method || params.submitMethod || 'click').trim().toLowerCase();
|
|
1184
|
+
const method = ['click', 'enter', 'form'].includes(methodRequested) ? methodRequested : 'click';
|
|
1185
|
+
const keyword = String(params.keyword || '').trim();
|
|
1186
|
+
const actionDelayMinMs = Math.max(300, Number(params.actionDelayMinMs ?? 500) || 500);
|
|
1187
|
+
const actionDelayMaxMs = Math.max(actionDelayMinMs, Number(params.actionDelayMaxMs ?? 1600) || 1600);
|
|
1188
|
+
const settleMinMs = Math.max(500, Number(params.settleMinMs ?? 1200) || 1200);
|
|
1189
|
+
const settleMaxMs = Math.max(settleMinMs, Number(params.settleMaxMs ?? 2800) || 2800);
|
|
1190
|
+
|
|
1191
|
+
const input = await readSearchInput(profileId);
|
|
1192
|
+
if (!input || input.ok !== true || !input.center) {
|
|
1193
|
+
throw new Error('SEARCH_INPUT_NOT_FOUND');
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
await clickPoint(profileId, input.center, { steps: 3 });
|
|
1197
|
+
pushTrace({ kind: 'click', stage: 'submit_search', target: 'search_input' });
|
|
1198
|
+
await sleepRandom(actionDelayMinMs, actionDelayMaxMs, pushTrace, 'submit_pre_type');
|
|
1199
|
+
|
|
1200
|
+
if (keyword && String(input.value || '') !== keyword) {
|
|
1201
|
+
await clearAndType(profileId, keyword, Number(params.keyDelayMs ?? 65) || 65);
|
|
1202
|
+
pushTrace({ kind: 'type', stage: 'submit_search', target: 'search_input', length: keyword.length });
|
|
1203
|
+
await sleepRandom(actionDelayMinMs, actionDelayMaxMs, pushTrace, 'submit_after_type');
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
const beforeUrl = await readLocation(profileId);
|
|
1207
|
+
let via = method;
|
|
1208
|
+
|
|
1209
|
+
if (method === 'click') {
|
|
1210
|
+
const target = await resolveSelectorTarget(profileId, [
|
|
1211
|
+
'.input-button .search-icon',
|
|
1212
|
+
'.input-button',
|
|
1213
|
+
'button.min-width-search-icon',
|
|
1214
|
+
], { requireViewport: true });
|
|
1215
|
+
if (target && target.center) {
|
|
1216
|
+
await clickPoint(profileId, target.center, { steps: 3 });
|
|
1217
|
+
via = target.selector || 'click';
|
|
1218
|
+
pushTrace({ kind: 'click', stage: 'submit_search', selector: via });
|
|
1219
|
+
} else {
|
|
1220
|
+
await pressKey(profileId, 'Enter');
|
|
1221
|
+
via = 'enter_fallback';
|
|
1222
|
+
pushTrace({ kind: 'key', stage: 'submit_search', key: 'Enter', fallback: true });
|
|
1223
|
+
}
|
|
1224
|
+
} else {
|
|
1225
|
+
await pressKey(profileId, 'Enter');
|
|
1226
|
+
via = 'Enter';
|
|
1227
|
+
pushTrace({ kind: 'key', stage: 'submit_search', key: 'Enter' });
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
await sleepRandom(settleMinMs, settleMaxMs, pushTrace, 'submit_settle');
|
|
1231
|
+
const afterUrl = await readLocation(profileId);
|
|
1232
|
+
|
|
1233
|
+
metrics.searchCount = Number(metrics.searchCount || 0) + 1;
|
|
1234
|
+
metrics.lastSearchAt = new Date().toISOString();
|
|
1235
|
+
if (keyword) profileState.keyword = keyword;
|
|
1236
|
+
|
|
1237
|
+
emitActionTrace(context, actionTrace, { stage: 'xhs_submit_search' });
|
|
1238
|
+
|
|
1239
|
+
return {
|
|
1240
|
+
ok: true,
|
|
1241
|
+
code: 'OPERATION_DONE',
|
|
1242
|
+
message: 'xhs_submit_search done',
|
|
1243
|
+
data: {
|
|
1244
|
+
submitted: true,
|
|
1245
|
+
via,
|
|
1246
|
+
beforeUrl,
|
|
1247
|
+
afterUrl,
|
|
1248
|
+
method,
|
|
1249
|
+
searchCount: Number(metrics.searchCount || 0),
|
|
1250
|
+
},
|
|
1251
|
+
};
|
|
1252
|
+
});
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
async function executeOpenDetailOperation({
|
|
1256
|
+
profileId,
|
|
1257
|
+
params = {},
|
|
1258
|
+
context = {},
|
|
1259
|
+
}) {
|
|
1260
|
+
const claimPath = resolveSharedClaimPath(params);
|
|
1261
|
+
const lockKey = claimPath ? `xhs_open_detail:${claimPath}` : '';
|
|
1262
|
+
|
|
1263
|
+
const mapOpenDetailError = (err, paramsRef = {}) => {
|
|
1264
|
+
const message = String(err?.message || err || '');
|
|
1265
|
+
const mode = String(paramsRef?.mode || '').trim().toLowerCase();
|
|
1266
|
+
if (message.includes('AUTOSCRIPT_DONE_NO_MORE_NOTES') || message.includes('AUTOSCRIPT_DONE_MAX_NOTES')) {
|
|
1267
|
+
return {
|
|
1268
|
+
ok: true,
|
|
1269
|
+
code: 'AUTOSCRIPT_DONE_NO_MORE_NOTES',
|
|
1270
|
+
message: 'no more notes',
|
|
1271
|
+
data: { stopReason: 'no_more_notes' },
|
|
1272
|
+
};
|
|
1273
|
+
}
|
|
1274
|
+
if (message.includes('NO_SEARCH_RESULT_ITEM')) {
|
|
1275
|
+
if (mode === 'collect') {
|
|
1276
|
+
return {
|
|
1277
|
+
ok: true,
|
|
1278
|
+
code: 'AUTOSCRIPT_DONE_NO_MORE_NOTES',
|
|
1279
|
+
message: 'no notes collected',
|
|
1280
|
+
data: { stopReason: 'no_more_notes' },
|
|
1281
|
+
};
|
|
1282
|
+
}
|
|
1283
|
+
if (mode === 'first') return null;
|
|
1284
|
+
return {
|
|
1285
|
+
ok: true,
|
|
1286
|
+
code: 'OPERATION_SKIPPED_NO_SEARCH_RESULT_ITEM',
|
|
1287
|
+
message: 'search result item missing',
|
|
1288
|
+
data: { skipped: true },
|
|
1289
|
+
};
|
|
1290
|
+
}
|
|
1291
|
+
return null;
|
|
1292
|
+
};
|
|
1293
|
+
|
|
1294
|
+
const runWithExclude = async (excludeNoteIds) => {
|
|
1295
|
+
const profileState = getProfileState(profileId);
|
|
1296
|
+
const { actionTrace, pushTrace } = buildTraceRecorder();
|
|
1297
|
+
const mode = String(params.mode || 'first').trim().toLowerCase();
|
|
1298
|
+
const maxNotes = Math.max(1, Number(params.maxNotes ?? params.limit ?? 20) || 20);
|
|
1299
|
+
const keyword = String(params.keyword || '').trim();
|
|
1300
|
+
const resume = params.resume !== false;
|
|
1301
|
+
const incrementalMax = params.incrementalMax !== false;
|
|
1302
|
+
const excluded = new Set(normalizeNoteIdList(excludeNoteIds));
|
|
1303
|
+
|
|
1304
|
+
const previousKeyword = String(profileState.keyword || '').trim();
|
|
1305
|
+
const keywordChanged = Boolean(keyword && previousKeyword && keyword !== previousKeyword);
|
|
1306
|
+
if (mode === 'first' || mode === 'collect') {
|
|
1307
|
+
if (!resume || keywordChanged) {
|
|
1308
|
+
profileState.visitedNoteIds = [];
|
|
1309
|
+
profileState.preCollectedNoteIds = [];
|
|
1310
|
+
profileState.preCollectedAt = null;
|
|
1311
|
+
}
|
|
1312
|
+
if (incrementalMax && resume && !keywordChanged) {
|
|
1313
|
+
profileState.maxNotes = Number(profileState.visitedNoteIds.length || 0) + maxNotes;
|
|
1314
|
+
} else {
|
|
1315
|
+
profileState.maxNotes = maxNotes;
|
|
1316
|
+
}
|
|
1317
|
+
} else if (!Number.isFinite(Number(profileState.maxNotes)) || Number(profileState.maxNotes) <= 0) {
|
|
1318
|
+
profileState.maxNotes = maxNotes;
|
|
1319
|
+
}
|
|
1320
|
+
if (keyword) profileState.keyword = keyword;
|
|
1321
|
+
|
|
1322
|
+
if (mode === 'next' && normalizeNoteIdList(profileState.visitedNoteIds).length >= Number(profileState.maxNotes || maxNotes)) {
|
|
1323
|
+
throw new Error('AUTOSCRIPT_DONE_MAX_NOTES');
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
const seedCollectCount = Math.max(0, Number(params.seedCollectCount || 0) || 0);
|
|
1327
|
+
const seedCollectMaxRounds = Math.max(0, Number(params.seedCollectMaxRounds || 0) || 0);
|
|
1328
|
+
const seedCollectStep = Math.max(120, Number(params.seedCollectStep || 420) || 420);
|
|
1329
|
+
const seedCollectSettleMs = Math.max(200, Number(params.seedCollectSettleMs || 480) || 480);
|
|
1330
|
+
const seedResetToTop = params.seedResetToTop !== false;
|
|
1331
|
+
const targetSeedCollectCount = Math.max(1, seedCollectCount || Number(profileState.maxNotes || maxNotes) || maxNotes);
|
|
1332
|
+
const targetSeedCollectMaxRounds = Math.max(
|
|
1333
|
+
1,
|
|
1334
|
+
seedCollectMaxRounds || Math.max(6, Math.ceil(targetSeedCollectCount / 2)),
|
|
1335
|
+
);
|
|
1336
|
+
|
|
1337
|
+
const seekRounds = Math.max(0, Number(params.nextSeekRounds || 8) || 8);
|
|
1338
|
+
const seekStep = Math.max(240, Number(params.nextSeekStep || 0) || 0) || 0;
|
|
1339
|
+
const seekSettleMs = Math.max(280, Number(params.nextSeekSettleMs || 620) || 620);
|
|
1340
|
+
|
|
1341
|
+
const preClickDelayMinMs = Math.max(500, Number(params.preClickDelayMinMs ?? 600) || 600);
|
|
1342
|
+
const preClickDelayMaxMs = Math.max(preClickDelayMinMs, Number(params.preClickDelayMaxMs ?? 1800) || 1800);
|
|
1343
|
+
const pollDelayMinMs = Math.max(200, Number(params.pollDelayMinMs ?? 260) || 260);
|
|
1344
|
+
const pollDelayMaxMs = Math.max(pollDelayMinMs, Number(params.pollDelayMaxMs ?? 600) || 600);
|
|
1345
|
+
const postOpenDelayMinMs = Math.max(500, Number(params.postOpenDelayMinMs ?? 700) || 700);
|
|
1346
|
+
const postOpenDelayMaxMs = Math.max(postOpenDelayMinMs, Number(params.postOpenDelayMaxMs ?? 1800) || 1800);
|
|
1347
|
+
const collectOpenLinksOnly = params.collectOpenLinksOnly === true;
|
|
1348
|
+
|
|
1349
|
+
const waitDetailReady = async () => {
|
|
1350
|
+
for (let i = 0; i < 60; i += 1) {
|
|
1351
|
+
const snapshot = await isDetailVisible(profileId);
|
|
1352
|
+
if (snapshot?.detailReady === true) return true;
|
|
1353
|
+
await sleep(randomBetween(pollDelayMinMs, pollDelayMaxMs));
|
|
1354
|
+
}
|
|
1355
|
+
return false;
|
|
1356
|
+
};
|
|
1357
|
+
|
|
1358
|
+
const collectVisibleRows = async () => {
|
|
1359
|
+
const snapshot = await readSearchCandidates(profileId);
|
|
1360
|
+
const rows = Array.isArray(snapshot?.rows) ? snapshot.rows : [];
|
|
1361
|
+
return rows;
|
|
1362
|
+
};
|
|
1363
|
+
|
|
1364
|
+
const collectLinksFirst = async () => {
|
|
1365
|
+
const seedCollectedSet = new Set();
|
|
1366
|
+
const collectVisible = async () => {
|
|
1367
|
+
const rows = await collectVisibleRows();
|
|
1368
|
+
for (const row of rows) {
|
|
1369
|
+
if (row?.noteId) seedCollectedSet.add(String(row.noteId));
|
|
1370
|
+
}
|
|
1371
|
+
return rows;
|
|
1372
|
+
};
|
|
1373
|
+
|
|
1374
|
+
let rows = await collectVisible();
|
|
1375
|
+
if (rows.length === 0) throw new Error('NO_SEARCH_RESULT_ITEM');
|
|
1376
|
+
|
|
1377
|
+
for (let round = 0; round < targetSeedCollectMaxRounds && seedCollectedSet.size < targetSeedCollectCount; round += 1) {
|
|
1378
|
+
const center = await resolveSelectorTarget(profileId, ['.search-result-list', '.feeds-page', 'body'], { requireViewport: true });
|
|
1379
|
+
if (center?.center) {
|
|
1380
|
+
await moveMouse(profileId, center.center.x, center.center.y, 2);
|
|
1381
|
+
}
|
|
1382
|
+
pushTrace({ kind: 'scroll', stage: 'collect_links', round: round + 1, deltaY: seedCollectStep });
|
|
1383
|
+
await wheel(profileId, seedCollectStep);
|
|
1384
|
+
await sleep(seedCollectSettleMs);
|
|
1385
|
+
rows = await collectVisible();
|
|
1386
|
+
}
|
|
1387
|
+
if (seedResetToTop) {
|
|
1388
|
+
for (let i = 0; i < 6; i += 1) {
|
|
1389
|
+
pushTrace({ kind: 'scroll', stage: 'collect_links_reset', round: i + 1, deltaY: -900 });
|
|
1390
|
+
await wheel(profileId, -900);
|
|
1391
|
+
await sleep(Math.max(140, Math.floor(seedCollectSettleMs / 2)));
|
|
1392
|
+
}
|
|
1393
|
+
rows = await collectVisible();
|
|
1394
|
+
}
|
|
1395
|
+
const collectedNoteIds = normalizeNoteIdList(Array.from(seedCollectedSet));
|
|
1396
|
+
profileState.preCollectedNoteIds = collectedNoteIds;
|
|
1397
|
+
profileState.preCollectedAt = new Date().toISOString();
|
|
1398
|
+
return { rows, collectedNoteIds };
|
|
1399
|
+
};
|
|
1400
|
+
|
|
1401
|
+
const collectLinksByOpening = async () => {
|
|
1402
|
+
const output = resolveXhsOutputContext({
|
|
1403
|
+
params,
|
|
1404
|
+
state: profileState,
|
|
1405
|
+
noteId: 'links',
|
|
1406
|
+
});
|
|
1407
|
+
const visitedSet = new Set(normalizeNoteIdList(profileState.visitedNoteIds));
|
|
1408
|
+
const collectedSet = new Set();
|
|
1409
|
+
const targetCount = Number(profileState.maxNotes || maxNotes);
|
|
1410
|
+
let stagnantRounds = 0;
|
|
1411
|
+
const maxStagnantRounds = Math.max(8, targetSeedCollectMaxRounds);
|
|
1412
|
+
const collectStallTimeoutMs = Math.max(30_000, Number(params.collectStallTimeoutMs || 180_000) || 180_000);
|
|
1413
|
+
let lastProgressAt = Date.now();
|
|
1414
|
+
let lastVisitedCount = visitedSet.size;
|
|
1415
|
+
|
|
1416
|
+
while (visitedSet.size < targetCount) {
|
|
1417
|
+
emitOperationProgress(context, {
|
|
1418
|
+
kind: 'loop',
|
|
1419
|
+
stage: 'collect_links',
|
|
1420
|
+
visitedCount: visitedSet.size,
|
|
1421
|
+
targetCount,
|
|
1422
|
+
stagnantRounds,
|
|
1423
|
+
stallTimeoutMs: collectStallTimeoutMs,
|
|
1424
|
+
elapsedSinceProgressMs: Math.max(0, Date.now() - lastProgressAt),
|
|
1425
|
+
});
|
|
1426
|
+
if ((Date.now() - lastProgressAt) > collectStallTimeoutMs) {
|
|
1427
|
+
throw new Error(`COLLECT_LINKS_STALL:${visitedSet.size}/${targetCount}`);
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
const rows = await collectVisibleRows();
|
|
1431
|
+
if (rows.length === 0) {
|
|
1432
|
+
if (visitedSet.size === 0) throw new Error('NO_SEARCH_RESULT_ITEM');
|
|
1433
|
+
break;
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
const eligibleInViewport = rows.filter((row) => (
|
|
1437
|
+
row
|
|
1438
|
+
&& row.inViewport === true
|
|
1439
|
+
&& row.center
|
|
1440
|
+
&& !excluded.has(String(row.noteId || '').trim())
|
|
1441
|
+
&& !visitedSet.has(String(row.noteId || '').trim())
|
|
1442
|
+
));
|
|
1443
|
+
const candidateIds = normalizeNoteIdList(eligibleInViewport.map((row) => row.noteId));
|
|
1444
|
+
const processedIds = normalizeNoteIdList(Array.from(visitedSet));
|
|
1445
|
+
await paintSearchCandidates(profileId, {
|
|
1446
|
+
candidateNoteIds: candidateIds,
|
|
1447
|
+
selectedNoteId: '',
|
|
1448
|
+
processedNoteIds: processedIds,
|
|
1449
|
+
});
|
|
1450
|
+
|
|
1451
|
+
if (eligibleInViewport.length === 0) {
|
|
1452
|
+
stagnantRounds += 1;
|
|
1453
|
+
if (stagnantRounds > maxStagnantRounds) break;
|
|
1454
|
+
pushTrace({ kind: 'scroll', stage: 'collect_links_seek_next_page', round: stagnantRounds, deltaY: seedCollectStep });
|
|
1455
|
+
await wheel(profileId, seedCollectStep);
|
|
1456
|
+
await sleep(seedCollectSettleMs);
|
|
1457
|
+
continue;
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
stagnantRounds = 0;
|
|
1461
|
+
const next = eligibleInViewport[Math.floor(Math.random() * eligibleInViewport.length)] || null;
|
|
1462
|
+
if (!next?.center) continue;
|
|
1463
|
+
await paintSearchCandidates(profileId, {
|
|
1464
|
+
candidateNoteIds: candidateIds,
|
|
1465
|
+
selectedNoteId: String(next.noteId || '').trim(),
|
|
1466
|
+
processedNoteIds: processedIds,
|
|
1467
|
+
});
|
|
1468
|
+
|
|
1469
|
+
const beforeUrl = await readLocation(profileId);
|
|
1470
|
+
await sleepRandom(preClickDelayMinMs, preClickDelayMaxMs, pushTrace, 'open_detail_pre_click', { noteId: next.noteId, mode: 'collect' });
|
|
1471
|
+
pushTrace({ kind: 'click', stage: 'open_detail', noteId: next.noteId, selector: 'a.cover', mode: 'collect' });
|
|
1472
|
+
await clickPoint(profileId, next.center, { steps: 4 });
|
|
1473
|
+
|
|
1474
|
+
const detailReady = await waitDetailReady();
|
|
1475
|
+
if (!detailReady) throw new Error('DETAIL_OPEN_TIMEOUT');
|
|
1476
|
+
await sleepRandom(postOpenDelayMinMs, postOpenDelayMaxMs, pushTrace, 'open_detail_post_open', { noteId: next.noteId, mode: 'collect' });
|
|
1477
|
+
|
|
1478
|
+
const afterUrl = await readLocation(profileId);
|
|
1479
|
+
const resolvedNoteId = extractNoteIdFromHref(afterUrl) || String(next.noteId || '').trim();
|
|
1480
|
+
if (!resolvedNoteId) throw new Error('LINK_NOTE_ID_MISSING');
|
|
1481
|
+
if (!String(afterUrl || '').includes('xsec_token=')) {
|
|
1482
|
+
throw new Error(`LINK_WITHOUT_XSEC_TOKEN:${resolvedNoteId}`);
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
visitedSet.add(resolvedNoteId);
|
|
1486
|
+
collectedSet.add(resolvedNoteId);
|
|
1487
|
+
profileState.visitedNoteIds = Array.from(visitedSet);
|
|
1488
|
+
profileState.currentNoteId = resolvedNoteId;
|
|
1489
|
+
profileState.currentHref = afterUrl || null;
|
|
1490
|
+
profileState.lastListUrl = beforeUrl || null;
|
|
1491
|
+
if (visitedSet.size > lastVisitedCount) {
|
|
1492
|
+
lastVisitedCount = visitedSet.size;
|
|
1493
|
+
lastProgressAt = Date.now();
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
await mergeLinksJsonl({
|
|
1497
|
+
filePath: output.linksPath,
|
|
1498
|
+
links: [{
|
|
1499
|
+
noteId: resolvedNoteId,
|
|
1500
|
+
noteUrl: afterUrl,
|
|
1501
|
+
listUrl: beforeUrl,
|
|
1502
|
+
}],
|
|
1503
|
+
});
|
|
1504
|
+
|
|
1505
|
+
await paintSearchCandidates(profileId, {
|
|
1506
|
+
candidateNoteIds: candidateIds,
|
|
1507
|
+
selectedNoteId: '',
|
|
1508
|
+
processedNoteIds: normalizeNoteIdList(Array.from(visitedSet)),
|
|
1509
|
+
});
|
|
1510
|
+
|
|
1511
|
+
const closed = await closeDetailToSearch(profileId, pushTrace);
|
|
1512
|
+
if (!closed) throw new Error(`DETAIL_CLOSE_FAILED:${resolvedNoteId}`);
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
const collectedNoteIds = normalizeNoteIdList(Array.from(collectedSet));
|
|
1516
|
+
profileState.preCollectedNoteIds = collectedNoteIds;
|
|
1517
|
+
profileState.preCollectedAt = new Date().toISOString();
|
|
1518
|
+
await paintSearchCandidates(profileId, {
|
|
1519
|
+
candidateNoteIds: [],
|
|
1520
|
+
selectedNoteId: '',
|
|
1521
|
+
processedNoteIds: normalizeNoteIdList(Array.from(visitedSet)),
|
|
1522
|
+
});
|
|
1523
|
+
return {
|
|
1524
|
+
collectedNoteIds,
|
|
1525
|
+
linksPath: output.linksPath,
|
|
1526
|
+
};
|
|
1527
|
+
};
|
|
1528
|
+
|
|
1529
|
+
let nodes = await collectVisibleRows();
|
|
1530
|
+
if (nodes.length === 0) throw new Error('NO_SEARCH_RESULT_ITEM');
|
|
1531
|
+
|
|
1532
|
+
if (mode === 'collect') {
|
|
1533
|
+
const collected = collectOpenLinksOnly
|
|
1534
|
+
? await collectLinksByOpening()
|
|
1535
|
+
: await collectLinksFirst();
|
|
1536
|
+
const collectedNoteIds = normalizeNoteIdList(collected?.collectedNoteIds);
|
|
1537
|
+
emitActionTrace(context, actionTrace, { stage: 'xhs_collect_links' });
|
|
1538
|
+
return {
|
|
1539
|
+
operationResult: {
|
|
1540
|
+
ok: true,
|
|
1541
|
+
code: 'OPERATION_DONE',
|
|
1542
|
+
message: 'xhs_collect_links done',
|
|
1543
|
+
data: {
|
|
1544
|
+
collected: collectedNoteIds.length,
|
|
1545
|
+
target: targetSeedCollectCount,
|
|
1546
|
+
maxRounds: targetSeedCollectMaxRounds,
|
|
1547
|
+
noteIds: collectedNoteIds,
|
|
1548
|
+
seedCollectedCount: collectedNoteIds.length,
|
|
1549
|
+
seedCollectedNoteIds: collectedNoteIds,
|
|
1550
|
+
linksPath: collected?.linksPath || null,
|
|
1551
|
+
linksWithXsecToken: collectOpenLinksOnly ? collectedNoteIds.length : 0,
|
|
1552
|
+
searchOnly: true,
|
|
1553
|
+
},
|
|
1554
|
+
},
|
|
1555
|
+
payload: {
|
|
1556
|
+
opened: false,
|
|
1557
|
+
source: 'collect_links',
|
|
1558
|
+
collected: collectedNoteIds.length,
|
|
1559
|
+
target: targetSeedCollectCount,
|
|
1560
|
+
maxRounds: targetSeedCollectMaxRounds,
|
|
1561
|
+
noteIds: collectedNoteIds,
|
|
1562
|
+
seedCollectedCount: collectedNoteIds.length,
|
|
1563
|
+
seedCollectedNoteIds: collectedNoteIds,
|
|
1564
|
+
linksPath: collected?.linksPath || null,
|
|
1565
|
+
linksWithXsecToken: collectOpenLinksOnly ? collectedNoteIds.length : 0,
|
|
1566
|
+
searchOnly: true,
|
|
1567
|
+
},
|
|
1568
|
+
};
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
let preCollectedSet = new Set(normalizeNoteIdList(profileState.preCollectedNoteIds));
|
|
1572
|
+
if (preCollectedSet.size === 0) {
|
|
1573
|
+
const collected = await collectLinksFirst();
|
|
1574
|
+
preCollectedSet = new Set(collected.collectedNoteIds);
|
|
1575
|
+
nodes = collected.rows;
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
const visitedSet = new Set(normalizeNoteIdList(profileState.visitedNoteIds));
|
|
1579
|
+
if (preCollectedSet.size > 0 && mode === 'next') {
|
|
1580
|
+
const pending = Array.from(preCollectedSet).filter((noteId) => !visitedSet.has(noteId) && !excluded.has(noteId));
|
|
1581
|
+
if (pending.length === 0) {
|
|
1582
|
+
throw new Error('AUTOSCRIPT_DONE_NO_MORE_NOTES');
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
const isEligible = (row) => {
|
|
1586
|
+
if (!row || typeof row !== 'object') return false;
|
|
1587
|
+
const noteId = String(row.noteId || '').trim();
|
|
1588
|
+
if (!noteId) return false;
|
|
1589
|
+
if (excluded.has(noteId)) return false;
|
|
1590
|
+
if (visitedSet.has(noteId)) return false;
|
|
1591
|
+
if (preCollectedSet.size > 0 && !preCollectedSet.has(noteId)) return false;
|
|
1592
|
+
return true;
|
|
1593
|
+
};
|
|
1594
|
+
|
|
1595
|
+
const pickRandom = (rows) => {
|
|
1596
|
+
if (!Array.isArray(rows) || rows.length === 0) return null;
|
|
1597
|
+
return rows[Math.floor(Math.random() * rows.length)] || null;
|
|
1598
|
+
};
|
|
1599
|
+
const pickNode = (rows) => {
|
|
1600
|
+
const inViewport = rows.filter((row) => isEligible(row) && row.inViewport);
|
|
1601
|
+
if (inViewport.length > 0) return pickRandom(inViewport);
|
|
1602
|
+
const fallback = rows.filter((row) => isEligible(row));
|
|
1603
|
+
return pickRandom(fallback);
|
|
1604
|
+
};
|
|
1605
|
+
|
|
1606
|
+
let next = pickNode(nodes);
|
|
1607
|
+
const dynamicSeekStep = seekStep || Math.max(260, Math.floor((Number(nodes?.[0]?.viewport?.height || 900) || 900) * 0.9));
|
|
1608
|
+
if (!next) {
|
|
1609
|
+
for (let round = 0; round < seekRounds; round += 1) {
|
|
1610
|
+
pushTrace({ kind: 'scroll', stage: 'seek_next_detail', round: round + 1, deltaY: dynamicSeekStep });
|
|
1611
|
+
await wheel(profileId, dynamicSeekStep);
|
|
1612
|
+
await sleep(seekSettleMs);
|
|
1613
|
+
nodes = await collectVisibleRows();
|
|
1614
|
+
next = pickNode(nodes);
|
|
1615
|
+
if (next) break;
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
if (!next) {
|
|
1620
|
+
throw new Error('AUTOSCRIPT_DONE_NO_MORE_NOTES');
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
const beforeUrl = await readLocation(profileId);
|
|
1624
|
+
await sleepRandom(preClickDelayMinMs, preClickDelayMaxMs, pushTrace, 'open_detail_pre_click', { noteId: next.noteId });
|
|
1625
|
+
pushTrace({ kind: 'click', stage: 'open_detail', noteId: next.noteId, selector: 'a.cover' });
|
|
1626
|
+
await clickPoint(profileId, next.center, { steps: 4 });
|
|
1627
|
+
|
|
1628
|
+
let detailReady = false;
|
|
1629
|
+
for (let i = 0; i < 60; i += 1) {
|
|
1630
|
+
const snapshot = await isDetailVisible(profileId);
|
|
1631
|
+
if (snapshot?.detailReady === true) {
|
|
1632
|
+
detailReady = true;
|
|
1633
|
+
break;
|
|
1634
|
+
}
|
|
1635
|
+
await sleep(randomBetween(pollDelayMinMs, pollDelayMaxMs));
|
|
1636
|
+
}
|
|
1637
|
+
if (!detailReady) {
|
|
1638
|
+
throw new Error('DETAIL_OPEN_TIMEOUT');
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
await sleepRandom(postOpenDelayMinMs, postOpenDelayMaxMs, pushTrace, 'open_detail_post_open', { noteId: next.noteId });
|
|
1642
|
+
const afterUrl = await readLocation(profileId);
|
|
1643
|
+
|
|
1644
|
+
if (!visitedSet.has(next.noteId)) {
|
|
1645
|
+
visitedSet.add(next.noteId);
|
|
1646
|
+
profileState.visitedNoteIds = Array.from(visitedSet);
|
|
1647
|
+
}
|
|
1648
|
+
profileState.currentNoteId = next.noteId;
|
|
1649
|
+
profileState.currentHref = next.href || null;
|
|
1650
|
+
profileState.lastListUrl = beforeUrl || null;
|
|
1651
|
+
|
|
1652
|
+
emitActionTrace(context, actionTrace, { stage: 'xhs_open_detail' });
|
|
1653
|
+
|
|
1654
|
+
return {
|
|
1655
|
+
operationResult: {
|
|
1656
|
+
ok: true,
|
|
1657
|
+
code: 'OPERATION_DONE',
|
|
1658
|
+
message: 'xhs_open_detail done',
|
|
1659
|
+
data: {
|
|
1660
|
+
opened: true,
|
|
1661
|
+
source: mode === 'next' ? 'open_next_detail' : 'open_first_detail',
|
|
1662
|
+
noteId: next.noteId,
|
|
1663
|
+
visited: profileState.visitedNoteIds.length,
|
|
1664
|
+
maxNotes: Number(profileState.maxNotes || maxNotes),
|
|
1665
|
+
openByClick: true,
|
|
1666
|
+
beforeUrl,
|
|
1667
|
+
afterUrl,
|
|
1668
|
+
excludedCount: excluded.size,
|
|
1669
|
+
seedCollectedCount: preCollectedSet.size,
|
|
1670
|
+
seedCollectedNoteIds: Array.from(preCollectedSet),
|
|
1671
|
+
},
|
|
1672
|
+
},
|
|
1673
|
+
payload: {
|
|
1674
|
+
opened: true,
|
|
1675
|
+
source: mode === 'next' ? 'open_next_detail' : 'open_first_detail',
|
|
1676
|
+
noteId: next.noteId,
|
|
1677
|
+
visited: profileState.visitedNoteIds.length,
|
|
1678
|
+
maxNotes: Number(profileState.maxNotes || maxNotes),
|
|
1679
|
+
openByClick: true,
|
|
1680
|
+
beforeUrl,
|
|
1681
|
+
afterUrl,
|
|
1682
|
+
excludedCount: excluded.size,
|
|
1683
|
+
seedCollectedCount: preCollectedSet.size,
|
|
1684
|
+
seedCollectedNoteIds: Array.from(preCollectedSet),
|
|
1685
|
+
},
|
|
1686
|
+
};
|
|
1687
|
+
};
|
|
1688
|
+
|
|
1689
|
+
if (!claimPath) {
|
|
1690
|
+
try {
|
|
1691
|
+
const { operationResult } = await runWithExclude([]);
|
|
1692
|
+
return operationResult;
|
|
1693
|
+
} catch (err) {
|
|
1694
|
+
const mapped = mapOpenDetailError(err, params);
|
|
1695
|
+
if (mapped) return mapped;
|
|
1696
|
+
throw err;
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
const runLocked = async () => {
|
|
1701
|
+
const claimDoc = await loadSharedClaimDoc(claimPath);
|
|
1702
|
+
const excludeNoteIds = normalizeNoteIdList(claimDoc.noteIds);
|
|
1703
|
+
const { operationResult, payload } = await runWithExclude(excludeNoteIds);
|
|
1704
|
+
|
|
1705
|
+
const claimSet = new Set(excludeNoteIds);
|
|
1706
|
+
const claimAdded = [];
|
|
1707
|
+
const markClaim = (noteId, source = 'open_detail') => {
|
|
1708
|
+
const id = String(noteId || '').trim();
|
|
1709
|
+
if (!id || claimSet.has(id)) return;
|
|
1710
|
+
claimSet.add(id);
|
|
1711
|
+
claimAdded.push(id);
|
|
1712
|
+
claimDoc.byNoteId[id] = {
|
|
1713
|
+
noteId: id,
|
|
1714
|
+
profileId,
|
|
1715
|
+
source,
|
|
1716
|
+
ts: new Date().toISOString(),
|
|
1717
|
+
};
|
|
1718
|
+
};
|
|
1719
|
+
|
|
1720
|
+
const seeded = normalizeNoteIdList(payload.seedCollectedNoteIds);
|
|
1721
|
+
for (const noteId of seeded) markClaim(noteId, 'seed_collect');
|
|
1722
|
+
if (payload.opened === true) markClaim(payload.noteId, 'open_detail');
|
|
1723
|
+
claimDoc.noteIds = Array.from(claimSet);
|
|
1724
|
+
if (claimAdded.length > 0) {
|
|
1725
|
+
await saveSharedClaimDoc(claimPath, claimDoc);
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
const mergedPayload = {
|
|
1729
|
+
...payload,
|
|
1730
|
+
sharedClaimPath: claimPath,
|
|
1731
|
+
sharedClaimCount: claimDoc.noteIds.length,
|
|
1732
|
+
sharedClaimAdded: claimAdded,
|
|
1733
|
+
dedupExcluded: excludeNoteIds.length,
|
|
1734
|
+
};
|
|
1735
|
+
const mergedData = operationResult.data && typeof operationResult.data === 'object'
|
|
1736
|
+
? { ...operationResult.data, result: mergedPayload }
|
|
1737
|
+
: { result: mergedPayload };
|
|
1738
|
+
|
|
1739
|
+
return {
|
|
1740
|
+
...operationResult,
|
|
1741
|
+
data: mergedData,
|
|
1742
|
+
};
|
|
1743
|
+
};
|
|
1744
|
+
|
|
1745
|
+
try {
|
|
1746
|
+
return await withSerializedLock(lockKey, runLocked);
|
|
1747
|
+
} catch (err) {
|
|
1748
|
+
const mapped = mapOpenDetailError(err, params);
|
|
1749
|
+
if (mapped) return mapped;
|
|
1750
|
+
throw err;
|
|
1751
|
+
}
|
|
398
1752
|
}
|
|
399
1753
|
|
|
400
|
-
async function
|
|
401
|
-
const
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
1754
|
+
async function readXhsRuntimeState(profileId) {
|
|
1755
|
+
const state = getProfileState(profileId);
|
|
1756
|
+
return {
|
|
1757
|
+
keyword: state.keyword || null,
|
|
1758
|
+
currentNoteId: state.currentNoteId || null,
|
|
1759
|
+
lastCommentsHarvest: state.lastCommentsHarvest && typeof state.lastCommentsHarvest === 'object'
|
|
1760
|
+
? state.lastCommentsHarvest
|
|
1761
|
+
: null,
|
|
1762
|
+
};
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
async function executeDetailHarvestOperation({ profileId, context = {} }) {
|
|
1766
|
+
const state = getProfileState(profileId);
|
|
1767
|
+
const { actionTrace } = buildTraceRecorder();
|
|
1768
|
+
|
|
1769
|
+
const detail = await readDetailSnapshot(profileId);
|
|
1770
|
+
const commentsSnapshot = await readCommentsSnapshot(profileId);
|
|
1771
|
+
const elementMeta = buildElementCollectability(detail, commentsSnapshot);
|
|
1772
|
+
if (detail?.noteIdFromUrl) {
|
|
1773
|
+
state.currentNoteId = String(detail.noteIdFromUrl);
|
|
411
1774
|
}
|
|
1775
|
+
state.lastDetail = {
|
|
1776
|
+
title: String(detail?.title || '').trim().slice(0, 200),
|
|
1777
|
+
contentLength: Number(detail?.contentLength || 0),
|
|
1778
|
+
href: String(detail?.href || '').trim() || null,
|
|
1779
|
+
textPresent: detail?.textPresent === true,
|
|
1780
|
+
imageCount: Number(detail?.imageCount || 0),
|
|
1781
|
+
imageUrls: Array.isArray(detail?.imageUrls) ? detail.imageUrls : [],
|
|
1782
|
+
videoPresent: detail?.videoPresent === true,
|
|
1783
|
+
videoUrl: String(detail?.videoUrl || '').trim() || null,
|
|
1784
|
+
commentsContextAvailable: commentsSnapshot?.hasCommentsContext === true || detail?.commentsContextAvailable === true,
|
|
1785
|
+
collectability: elementMeta.collectability,
|
|
1786
|
+
skippedElements: elementMeta.skippedElements,
|
|
1787
|
+
fallbackCaptured: elementMeta.fallbackCaptured,
|
|
1788
|
+
capturedAt: detail?.capturedAt || new Date().toISOString(),
|
|
1789
|
+
};
|
|
1790
|
+
|
|
1791
|
+
emitActionTrace(context, actionTrace, { stage: 'xhs_detail_harvest' });
|
|
1792
|
+
|
|
412
1793
|
return {
|
|
413
1794
|
ok: true,
|
|
414
1795
|
code: 'OPERATION_DONE',
|
|
415
|
-
message: '
|
|
416
|
-
data
|
|
1796
|
+
message: 'xhs_detail_harvest done',
|
|
1797
|
+
data: {
|
|
1798
|
+
harvested: true,
|
|
1799
|
+
detail: state.lastDetail,
|
|
1800
|
+
collectability: elementMeta.collectability,
|
|
1801
|
+
skippedElements: elementMeta.skippedElements,
|
|
1802
|
+
fallbackCaptured: elementMeta.fallbackCaptured,
|
|
1803
|
+
},
|
|
417
1804
|
};
|
|
418
1805
|
}
|
|
419
1806
|
|
|
420
|
-
async function
|
|
421
|
-
const
|
|
422
|
-
|
|
1807
|
+
async function executeExpandRepliesOperation({ profileId, context = {} }) {
|
|
1808
|
+
const { actionTrace, pushTrace } = buildTraceRecorder();
|
|
1809
|
+
const seen = new Set();
|
|
1810
|
+
let expanded = 0;
|
|
1811
|
+
let scanned = 0;
|
|
1812
|
+
|
|
1813
|
+
for (let round = 0; round < 8; round += 1) {
|
|
1814
|
+
const snapshot = await readExpandButtons(profileId);
|
|
1815
|
+
const rows = Array.isArray(snapshot?.rows) ? snapshot.rows : [];
|
|
1816
|
+
scanned = Math.max(scanned, rows.length);
|
|
1817
|
+
const next = rows.find((row) => row && row.center && !seen.has(row.signature));
|
|
1818
|
+
if (!next) break;
|
|
1819
|
+
seen.add(next.signature);
|
|
1820
|
+
|
|
1821
|
+
await moveMouse(profileId, next.center.x, next.center.y, 2);
|
|
1822
|
+
await sleepRandom(500, 1200, pushTrace, 'expand_pre_click', { round: round + 1, text: String(next.text || '').slice(0, 40) });
|
|
1823
|
+
await clickPoint(profileId, next.center, { steps: 3 });
|
|
1824
|
+
pushTrace({ kind: 'click', stage: 'xhs_expand_replies', round: round + 1, text: String(next.text || '').slice(0, 40) });
|
|
1825
|
+
expanded += 1;
|
|
1826
|
+
await sleepRandom(700, 1800, pushTrace, 'expand_post_click', { round: round + 1 });
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
emitActionTrace(context, actionTrace, { stage: 'xhs_expand_replies' });
|
|
1830
|
+
|
|
1831
|
+
return {
|
|
1832
|
+
ok: true,
|
|
1833
|
+
code: 'OPERATION_DONE',
|
|
1834
|
+
message: 'xhs_expand_replies done',
|
|
1835
|
+
data: {
|
|
1836
|
+
expanded,
|
|
1837
|
+
scanned,
|
|
1838
|
+
},
|
|
1839
|
+
};
|
|
423
1840
|
}
|
|
424
1841
|
|
|
425
1842
|
async function executeCommentsHarvestOperation({
|
|
@@ -427,48 +1844,375 @@ async function executeCommentsHarvestOperation({
|
|
|
427
1844
|
params = {},
|
|
428
1845
|
context = {},
|
|
429
1846
|
}) {
|
|
430
|
-
const
|
|
431
|
-
const
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
message: 'xhs_comments_harvest done',
|
|
436
|
-
highlight,
|
|
437
|
-
});
|
|
1847
|
+
const state = getProfileState(profileId);
|
|
1848
|
+
const metricsState = state.metrics || (state.metrics = {});
|
|
1849
|
+
metricsState.searchCount = Number(metricsState.searchCount || 0);
|
|
1850
|
+
|
|
1851
|
+
const { actionTrace, pushTrace } = buildTraceRecorder();
|
|
438
1852
|
|
|
439
|
-
const
|
|
440
|
-
const
|
|
441
|
-
|
|
1853
|
+
const maxRounds = Math.max(1, Number(params.maxRounds ?? params.maxScrollRounds ?? 24) || 24);
|
|
1854
|
+
const scrollStepMin = Math.max(120, Number(params.scrollStepMin ?? params.scrollStep ?? 420) || 420);
|
|
1855
|
+
const scrollStepMax = Math.max(scrollStepMin, Number(params.scrollStepMax ?? scrollStepMin) || scrollStepMin);
|
|
1856
|
+
const settleMinMs = Math.max(500, Number(params.settleMinMs ?? params.settleMs ?? 900) || 900);
|
|
1857
|
+
const settleMaxMs = Math.max(settleMinMs, Number(params.settleMaxMs ?? 2200) || 2200);
|
|
1858
|
+
const stallRounds = Math.max(2, Number(params.stallRounds ?? 5) || 5);
|
|
1859
|
+
const requireBottom = params.requireBottom !== false;
|
|
1860
|
+
const includeComments = params.includeComments !== false;
|
|
1861
|
+
const commentsLimit = Math.max(0, Number(params.commentsLimit ?? 0) || 0);
|
|
1862
|
+
const detailSnapshot = await readDetailSnapshot(profileId).catch(() => ({}));
|
|
1863
|
+
|
|
1864
|
+
let detailVisible = false;
|
|
1865
|
+
let commentsReady = false;
|
|
1866
|
+
let precheckSnapshot = null;
|
|
1867
|
+
for (let probe = 0; probe < 40; probe += 1) {
|
|
1868
|
+
const snapshot = await readCommentsSnapshot(profileId);
|
|
1869
|
+
precheckSnapshot = snapshot;
|
|
1870
|
+
if (snapshot?.detailVisible === true) {
|
|
1871
|
+
detailVisible = true;
|
|
1872
|
+
}
|
|
1873
|
+
if (snapshot?.detailVisible === true && snapshot?.hasCommentsContext === true) {
|
|
1874
|
+
commentsReady = true;
|
|
1875
|
+
break;
|
|
1876
|
+
}
|
|
1877
|
+
await sleepRandom(settleMinMs, settleMaxMs, pushTrace, 'comments_precheck', { probe: probe + 1 });
|
|
1878
|
+
}
|
|
1879
|
+
const elementMeta = buildElementCollectability(detailSnapshot, precheckSnapshot);
|
|
1880
|
+
const returnSkippedComments = (commentsSkippedReason) => {
|
|
1881
|
+
const now = new Date().toISOString();
|
|
1882
|
+
state.currentComments = [];
|
|
1883
|
+
state.commentsCollectedAt = now;
|
|
1884
|
+
state.lastCommentsHarvest = {
|
|
1885
|
+
noteId: state.currentNoteId || detailSnapshot?.noteIdFromUrl || null,
|
|
1886
|
+
searchCount: Number(metricsState.searchCount || 0),
|
|
1887
|
+
collected: 0,
|
|
1888
|
+
expectedCommentsCount: Number.isFinite(Number(precheckSnapshot?.expectedCommentsCount))
|
|
1889
|
+
? Number(precheckSnapshot.expectedCommentsCount)
|
|
1890
|
+
: null,
|
|
1891
|
+
commentCoverageRate: null,
|
|
1892
|
+
recoveries: 0,
|
|
1893
|
+
maxRecoveries: Math.max(0, Number(params.maxRecoveries ?? 2) || 2),
|
|
1894
|
+
reachedBottom: false,
|
|
1895
|
+
exitReason: commentsSkippedReason,
|
|
1896
|
+
rounds: 0,
|
|
1897
|
+
configuredMaxRounds: maxRounds,
|
|
1898
|
+
maxRounds,
|
|
1899
|
+
maxRoundsSource: 'configured',
|
|
1900
|
+
budgetExpectedCommentsCount: null,
|
|
1901
|
+
scroll: precheckSnapshot?.metrics && typeof precheckSnapshot.metrics === 'object'
|
|
1902
|
+
? {
|
|
1903
|
+
scrollTop: Number(precheckSnapshot.metrics.scrollTop || 0),
|
|
1904
|
+
scrollHeight: Number(precheckSnapshot.metrics.scrollHeight || 0),
|
|
1905
|
+
clientHeight: Number(precheckSnapshot.metrics.clientHeight || 0),
|
|
1906
|
+
}
|
|
1907
|
+
: { scrollTop: 0, scrollHeight: 0, clientHeight: 0 },
|
|
1908
|
+
collectability: elementMeta.collectability,
|
|
1909
|
+
skippedElements: elementMeta.skippedElements,
|
|
1910
|
+
fallbackCaptured: elementMeta.fallbackCaptured,
|
|
1911
|
+
commentsSkippedReason,
|
|
1912
|
+
at: now,
|
|
1913
|
+
};
|
|
1914
|
+
let payload = {
|
|
1915
|
+
noteId: state.currentNoteId || detailSnapshot?.noteIdFromUrl || null,
|
|
1916
|
+
searchCount: Number(metricsState.searchCount || 0),
|
|
1917
|
+
collected: 0,
|
|
1918
|
+
expectedCommentsCount: state.lastCommentsHarvest.expectedCommentsCount,
|
|
1919
|
+
commentCoverageRate: null,
|
|
1920
|
+
recoveries: 0,
|
|
1921
|
+
maxRecoveries: state.lastCommentsHarvest.maxRecoveries,
|
|
1922
|
+
firstComment: null,
|
|
1923
|
+
reachedBottom: false,
|
|
1924
|
+
exitReason: commentsSkippedReason,
|
|
1925
|
+
commentsSkippedReason,
|
|
1926
|
+
rounds: 0,
|
|
1927
|
+
configuredMaxRounds: maxRounds,
|
|
1928
|
+
maxRounds,
|
|
1929
|
+
maxRoundsSource: 'configured',
|
|
1930
|
+
budgetExpectedCommentsCount: null,
|
|
1931
|
+
scroll: state.lastCommentsHarvest.scroll,
|
|
1932
|
+
collectability: elementMeta.collectability,
|
|
1933
|
+
skippedElements: elementMeta.skippedElements,
|
|
1934
|
+
fallbackCaptured: elementMeta.fallbackCaptured,
|
|
1935
|
+
actionTrace,
|
|
1936
|
+
};
|
|
1937
|
+
if (includeComments) {
|
|
1938
|
+
payload = {
|
|
1939
|
+
...payload,
|
|
1940
|
+
comments: [],
|
|
1941
|
+
commentsTruncated: false,
|
|
1942
|
+
};
|
|
1943
|
+
}
|
|
442
1944
|
emitActionTrace(context, actionTrace, { stage: 'xhs_comments_harvest' });
|
|
443
|
-
|
|
1945
|
+
return {
|
|
1946
|
+
ok: true,
|
|
1947
|
+
code: 'OPERATION_DONE',
|
|
1948
|
+
message: 'xhs_comments_harvest done',
|
|
1949
|
+
data: {
|
|
1950
|
+
...payload,
|
|
1951
|
+
commentsPath: null,
|
|
1952
|
+
commentsAdded: 0,
|
|
1953
|
+
commentsTotal: 0,
|
|
1954
|
+
},
|
|
1955
|
+
};
|
|
1956
|
+
};
|
|
1957
|
+
if (!detailVisible) {
|
|
1958
|
+
return returnSkippedComments('detail_not_ready_before_scroll');
|
|
1959
|
+
}
|
|
1960
|
+
if (!commentsReady) {
|
|
1961
|
+
return returnSkippedComments('comments_context_missing');
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
const commentMap = new Map();
|
|
1965
|
+
let rounds = 0;
|
|
1966
|
+
let reachedBottom = false;
|
|
1967
|
+
let exitReason = 'max_rounds_reached';
|
|
1968
|
+
let noProgressRounds = 0;
|
|
1969
|
+
let recoveries = 0;
|
|
1970
|
+
const maxRecoveries = Math.max(0, Number(params.maxRecoveries ?? 2) || 2);
|
|
1971
|
+
|
|
1972
|
+
let expectedCommentsCount = null;
|
|
1973
|
+
let lastMetrics = { scrollTop: 0, scrollHeight: 0, clientHeight: 0 };
|
|
1974
|
+
let lastScrollerCenter = null;
|
|
1975
|
+
|
|
1976
|
+
for (let round = 1; round <= maxRounds; round += 1) {
|
|
1977
|
+
rounds = round;
|
|
1978
|
+
const beforeSnapshot = await readCommentsSnapshot(profileId);
|
|
1979
|
+
if (beforeSnapshot?.detailVisible !== true) {
|
|
1980
|
+
exitReason = 'detail_hidden';
|
|
1981
|
+
break;
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
if (Number.isFinite(Number(beforeSnapshot?.expectedCommentsCount)) && Number(beforeSnapshot.expectedCommentsCount) >= 0) {
|
|
1985
|
+
expectedCommentsCount = Number(beforeSnapshot.expectedCommentsCount);
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
const beforeCount = commentMap.size;
|
|
1989
|
+
const rows = Array.isArray(beforeSnapshot?.rows) ? beforeSnapshot.rows : [];
|
|
1990
|
+
for (const row of rows) {
|
|
1991
|
+
if (!row || typeof row !== 'object') continue;
|
|
1992
|
+
const text = normalizeInlineText(row.text);
|
|
1993
|
+
if (!text) continue;
|
|
1994
|
+
const author = sanitizeAuthorText(row.userName || '', text);
|
|
1995
|
+
const key = `${author}::${text}`;
|
|
1996
|
+
if (commentMap.has(key)) continue;
|
|
1997
|
+
commentMap.set(key, {
|
|
1998
|
+
index: Number(row.index),
|
|
1999
|
+
userName: author,
|
|
2000
|
+
userId: String(row.userId || ''),
|
|
2001
|
+
text,
|
|
2002
|
+
timestamp: String(row.timestamp || ''),
|
|
2003
|
+
liked: row.alreadyLiked === true,
|
|
2004
|
+
firstSeenRound: round,
|
|
2005
|
+
});
|
|
2006
|
+
}
|
|
2007
|
+
|
|
2008
|
+
lastMetrics = beforeSnapshot?.metrics && typeof beforeSnapshot.metrics === 'object'
|
|
2009
|
+
? {
|
|
2010
|
+
scrollTop: Number(beforeSnapshot.metrics.scrollTop || 0),
|
|
2011
|
+
scrollHeight: Number(beforeSnapshot.metrics.scrollHeight || 0),
|
|
2012
|
+
clientHeight: Number(beforeSnapshot.metrics.clientHeight || 0),
|
|
2013
|
+
}
|
|
2014
|
+
: lastMetrics;
|
|
2015
|
+
lastScrollerCenter = beforeSnapshot?.scrollerCenter && typeof beforeSnapshot.scrollerCenter === 'object'
|
|
2016
|
+
? beforeSnapshot.scrollerCenter
|
|
2017
|
+
: lastScrollerCenter;
|
|
2018
|
+
|
|
2019
|
+
const beforeDiff = Number(lastMetrics.scrollHeight - (lastMetrics.scrollTop + lastMetrics.clientHeight));
|
|
2020
|
+
if (Number.isFinite(beforeDiff) && beforeDiff <= 6) {
|
|
2021
|
+
reachedBottom = true;
|
|
2022
|
+
exitReason = 'bottom_reached';
|
|
2023
|
+
break;
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
if (commentsLimit > 0 && commentMap.size >= commentsLimit && !requireBottom) {
|
|
2027
|
+
exitReason = 'comments_limit_reached';
|
|
2028
|
+
break;
|
|
2029
|
+
}
|
|
2030
|
+
|
|
2031
|
+
if (lastScrollerCenter?.x && lastScrollerCenter?.y) {
|
|
2032
|
+
await moveMouse(profileId, lastScrollerCenter.x, lastScrollerCenter.y, 2);
|
|
2033
|
+
}
|
|
2034
|
+
const roundStep = randomBetween(scrollStepMin, scrollStepMax);
|
|
2035
|
+
pushTrace({ kind: 'scroll', stage: 'xhs_comments_harvest', round, deltaY: roundStep });
|
|
2036
|
+
await wheel(profileId, roundStep);
|
|
2037
|
+
await sleepRandom(settleMinMs, settleMaxMs, pushTrace, 'comments_settle', { round });
|
|
2038
|
+
|
|
2039
|
+
const afterSnapshot = await readCommentsSnapshot(profileId);
|
|
2040
|
+
const afterRows = Array.isArray(afterSnapshot?.rows) ? afterSnapshot.rows : [];
|
|
2041
|
+
for (const row of afterRows) {
|
|
2042
|
+
if (!row || typeof row !== 'object') continue;
|
|
2043
|
+
const text = normalizeInlineText(row.text);
|
|
2044
|
+
if (!text) continue;
|
|
2045
|
+
const author = sanitizeAuthorText(row.userName || '', text);
|
|
2046
|
+
const key = `${author}::${text}`;
|
|
2047
|
+
if (commentMap.has(key)) continue;
|
|
2048
|
+
commentMap.set(key, {
|
|
2049
|
+
index: Number(row.index),
|
|
2050
|
+
userName: author,
|
|
2051
|
+
userId: String(row.userId || ''),
|
|
2052
|
+
text,
|
|
2053
|
+
timestamp: String(row.timestamp || ''),
|
|
2054
|
+
liked: row.alreadyLiked === true,
|
|
2055
|
+
firstSeenRound: round,
|
|
2056
|
+
});
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
const afterMetrics = afterSnapshot?.metrics && typeof afterSnapshot.metrics === 'object'
|
|
2060
|
+
? {
|
|
2061
|
+
scrollTop: Number(afterSnapshot.metrics.scrollTop || 0),
|
|
2062
|
+
scrollHeight: Number(afterSnapshot.metrics.scrollHeight || 0),
|
|
2063
|
+
clientHeight: Number(afterSnapshot.metrics.clientHeight || 0),
|
|
2064
|
+
}
|
|
2065
|
+
: lastMetrics;
|
|
2066
|
+
lastMetrics = afterMetrics;
|
|
2067
|
+
|
|
2068
|
+
const moved = Math.abs(Number(afterMetrics.scrollTop || 0) - Number(beforeSnapshot?.metrics?.scrollTop || 0)) > 1;
|
|
2069
|
+
const increased = commentMap.size > beforeCount;
|
|
2070
|
+
if (!moved && !increased) {
|
|
2071
|
+
noProgressRounds += 1;
|
|
2072
|
+
} else {
|
|
2073
|
+
noProgressRounds = 0;
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
const afterDiff = Number(afterMetrics.scrollHeight - (afterMetrics.scrollTop + afterMetrics.clientHeight));
|
|
2077
|
+
if (Number.isFinite(afterDiff) && afterDiff <= 6) {
|
|
2078
|
+
reachedBottom = true;
|
|
2079
|
+
exitReason = 'bottom_reached';
|
|
2080
|
+
break;
|
|
2081
|
+
}
|
|
2082
|
+
|
|
2083
|
+
if (commentsLimit > 0 && commentMap.size >= commentsLimit && !requireBottom) {
|
|
2084
|
+
exitReason = 'comments_limit_reached';
|
|
2085
|
+
break;
|
|
2086
|
+
}
|
|
2087
|
+
|
|
2088
|
+
if (noProgressRounds >= stallRounds) {
|
|
2089
|
+
if (recoveries < maxRecoveries) {
|
|
2090
|
+
recoveries += 1;
|
|
2091
|
+
noProgressRounds = 0;
|
|
2092
|
+
pushTrace({ kind: 'scroll', stage: 'xhs_comments_harvest_recovery', round, recovery: recoveries, deltaY: -420 });
|
|
2093
|
+
await wheel(profileId, -420);
|
|
2094
|
+
await sleepRandom(settleMinMs, settleMaxMs, pushTrace, 'comments_recovery_settle', { round, recovery: recoveries });
|
|
2095
|
+
pushTrace({ kind: 'scroll', stage: 'xhs_comments_harvest_recovery', round, recovery: recoveries, deltaY: 760 });
|
|
2096
|
+
await wheel(profileId, 760);
|
|
2097
|
+
await sleepRandom(settleMinMs, settleMaxMs, pushTrace, 'comments_recovery_settle', { round, recovery: recoveries, pass: 'down' });
|
|
2098
|
+
} else if (!requireBottom) {
|
|
2099
|
+
exitReason = 'no_new_comments';
|
|
2100
|
+
break;
|
|
2101
|
+
} else {
|
|
2102
|
+
exitReason = 'scroll_stalled_after_recovery';
|
|
2103
|
+
break;
|
|
2104
|
+
}
|
|
2105
|
+
}
|
|
2106
|
+
|
|
2107
|
+
if (round === maxRounds) {
|
|
2108
|
+
exitReason = 'max_rounds_reached';
|
|
2109
|
+
}
|
|
2110
|
+
}
|
|
2111
|
+
|
|
2112
|
+
const comments = Array.from(commentMap.values())
|
|
2113
|
+
.sort((a, b) => Number(a.firstSeenRound || 0) - Number(b.firstSeenRound || 0))
|
|
2114
|
+
.map((item, index) => ({
|
|
2115
|
+
index,
|
|
2116
|
+
author: item.userName,
|
|
2117
|
+
userName: item.userName,
|
|
2118
|
+
userId: item.userId,
|
|
2119
|
+
text: item.text,
|
|
2120
|
+
liked: item.liked,
|
|
2121
|
+
timestamp: item.timestamp,
|
|
2122
|
+
}));
|
|
2123
|
+
|
|
2124
|
+
const commentCoverageRate = Number.isFinite(Number(expectedCommentsCount)) && Number(expectedCommentsCount) > 0
|
|
2125
|
+
? Number(Math.min(1, comments.length / Number(expectedCommentsCount)).toFixed(4))
|
|
2126
|
+
: null;
|
|
2127
|
+
|
|
2128
|
+
state.currentComments = comments;
|
|
2129
|
+
state.commentsCollectedAt = new Date().toISOString();
|
|
2130
|
+
state.lastCommentsHarvest = {
|
|
2131
|
+
noteId: state.currentNoteId || null,
|
|
2132
|
+
searchCount: Number(metricsState.searchCount || 0),
|
|
2133
|
+
collected: comments.length,
|
|
2134
|
+
expectedCommentsCount,
|
|
2135
|
+
commentCoverageRate,
|
|
2136
|
+
recoveries,
|
|
2137
|
+
maxRecoveries,
|
|
2138
|
+
reachedBottom,
|
|
2139
|
+
exitReason,
|
|
2140
|
+
rounds,
|
|
2141
|
+
configuredMaxRounds: maxRounds,
|
|
2142
|
+
maxRounds,
|
|
2143
|
+
maxRoundsSource: 'configured',
|
|
2144
|
+
budgetExpectedCommentsCount: expectedCommentsCount,
|
|
2145
|
+
scroll: lastMetrics,
|
|
2146
|
+
collectability: elementMeta.collectability,
|
|
2147
|
+
skippedElements: elementMeta.skippedElements,
|
|
2148
|
+
fallbackCaptured: elementMeta.fallbackCaptured,
|
|
2149
|
+
commentsSkippedReason: null,
|
|
2150
|
+
at: state.commentsCollectedAt,
|
|
2151
|
+
};
|
|
2152
|
+
|
|
2153
|
+
let payload = {
|
|
2154
|
+
noteId: state.currentNoteId || null,
|
|
2155
|
+
searchCount: Number(metricsState.searchCount || 0),
|
|
2156
|
+
collected: comments.length,
|
|
2157
|
+
expectedCommentsCount,
|
|
2158
|
+
commentCoverageRate,
|
|
2159
|
+
recoveries,
|
|
2160
|
+
maxRecoveries,
|
|
2161
|
+
firstComment: comments[0] || null,
|
|
2162
|
+
reachedBottom,
|
|
2163
|
+
exitReason,
|
|
2164
|
+
rounds,
|
|
2165
|
+
configuredMaxRounds: maxRounds,
|
|
2166
|
+
maxRounds,
|
|
2167
|
+
maxRoundsSource: 'configured',
|
|
2168
|
+
budgetExpectedCommentsCount: expectedCommentsCount,
|
|
2169
|
+
scroll: lastMetrics,
|
|
2170
|
+
collectability: elementMeta.collectability,
|
|
2171
|
+
skippedElements: elementMeta.skippedElements,
|
|
2172
|
+
fallbackCaptured: elementMeta.fallbackCaptured,
|
|
2173
|
+
commentsSkippedReason: null,
|
|
2174
|
+
actionTrace,
|
|
2175
|
+
};
|
|
2176
|
+
if (includeComments) {
|
|
2177
|
+
const bounded = commentsLimit > 0 ? comments.slice(0, commentsLimit) : comments;
|
|
2178
|
+
payload = {
|
|
2179
|
+
...payload,
|
|
2180
|
+
comments: bounded,
|
|
2181
|
+
commentsTruncated: commentsLimit > 0 && comments.length > commentsLimit,
|
|
2182
|
+
};
|
|
444
2183
|
}
|
|
2184
|
+
|
|
2185
|
+
emitActionTrace(context, actionTrace, { stage: 'xhs_comments_harvest' });
|
|
2186
|
+
|
|
445
2187
|
const shouldPersistComments = params.persistComments === true || params.persistCollectedComments === true;
|
|
446
|
-
const
|
|
447
|
-
const
|
|
2188
|
+
const includePayloadComments = params.includeComments !== false;
|
|
2189
|
+
const payloadComments = Array.isArray(payload.comments) ? payload.comments : [];
|
|
448
2190
|
|
|
449
|
-
if (!shouldPersistComments || !
|
|
2191
|
+
if (!shouldPersistComments || !includePayloadComments || payloadComments.length === 0) {
|
|
450
2192
|
return {
|
|
451
|
-
|
|
452
|
-
|
|
2193
|
+
ok: true,
|
|
2194
|
+
code: 'OPERATION_DONE',
|
|
2195
|
+
message: 'xhs_comments_harvest done',
|
|
2196
|
+
data: {
|
|
453
2197
|
...payload,
|
|
454
2198
|
commentsPath: null,
|
|
455
2199
|
commentsAdded: 0,
|
|
456
|
-
commentsTotal: Number(payload.collected ||
|
|
457
|
-
}
|
|
2200
|
+
commentsTotal: Number(payload.collected || payloadComments.length || 0),
|
|
2201
|
+
},
|
|
458
2202
|
};
|
|
459
2203
|
}
|
|
460
2204
|
|
|
461
|
-
const
|
|
2205
|
+
const runtimeState = await readXhsRuntimeState(profileId);
|
|
462
2206
|
const output = resolveXhsOutputContext({
|
|
463
2207
|
params,
|
|
464
|
-
state,
|
|
465
|
-
noteId: payload.noteId ||
|
|
2208
|
+
state: runtimeState,
|
|
2209
|
+
noteId: payload.noteId || runtimeState.currentNoteId || params.noteId,
|
|
466
2210
|
});
|
|
467
2211
|
|
|
468
2212
|
const merged = await mergeCommentsJsonl({
|
|
469
2213
|
filePath: output.commentsPath,
|
|
470
2214
|
noteId: output.noteId,
|
|
471
|
-
comments,
|
|
2215
|
+
comments: payloadComments,
|
|
472
2216
|
});
|
|
473
2217
|
|
|
474
2218
|
return {
|
|
@@ -485,18 +2229,494 @@ async function executeCommentsHarvestOperation({
|
|
|
485
2229
|
};
|
|
486
2230
|
}
|
|
487
2231
|
|
|
2232
|
+
async function executeCommentMatchOperation({ profileId, params = {} }) {
|
|
2233
|
+
const state = getProfileState(profileId);
|
|
2234
|
+
const rows = Array.isArray(state.currentComments) ? state.currentComments : [];
|
|
2235
|
+
const keywords = normalizeArray(params.keywords || params.matchKeywords)
|
|
2236
|
+
.map((item) => String(item || '').trim())
|
|
2237
|
+
.filter(Boolean);
|
|
2238
|
+
if (keywords.length === 0) {
|
|
2239
|
+
const text = String(params.keywords || params.matchKeywords || '').trim();
|
|
2240
|
+
if (text) {
|
|
2241
|
+
for (const token of text.split(',')) {
|
|
2242
|
+
const normalized = String(token || '').trim();
|
|
2243
|
+
if (normalized) keywords.push(normalized);
|
|
2244
|
+
}
|
|
2245
|
+
}
|
|
2246
|
+
}
|
|
2247
|
+
|
|
2248
|
+
const mode = String(params.mode || params.matchMode || 'any').trim();
|
|
2249
|
+
const minHits = Math.max(1, Number(params.minHits ?? params.matchMinHits ?? 1) || 1);
|
|
2250
|
+
const tokens = keywords
|
|
2251
|
+
.map((item) => String(item || '').toLowerCase().replace(/\s+/g, ' ').trim())
|
|
2252
|
+
.filter(Boolean);
|
|
2253
|
+
|
|
2254
|
+
const matches = [];
|
|
2255
|
+
for (const row of rows) {
|
|
2256
|
+
const text = String(row?.text || row?.content || '').toLowerCase().replace(/\s+/g, ' ').trim();
|
|
2257
|
+
if (!text || tokens.length === 0) continue;
|
|
2258
|
+
const hits = tokens.filter((token) => text.includes(token));
|
|
2259
|
+
if (mode === 'all' && hits.length < tokens.length) continue;
|
|
2260
|
+
if (mode === 'atLeast' && hits.length < minHits) continue;
|
|
2261
|
+
if (mode !== 'all' && mode !== 'atLeast' && hits.length === 0) continue;
|
|
2262
|
+
matches.push({ index: Number(row?.index || 0), hits });
|
|
2263
|
+
}
|
|
2264
|
+
|
|
2265
|
+
state.matchedComments = matches;
|
|
2266
|
+
state.matchRule = { tokens, mode, minHits };
|
|
2267
|
+
|
|
2268
|
+
return {
|
|
2269
|
+
ok: true,
|
|
2270
|
+
code: 'OPERATION_DONE',
|
|
2271
|
+
message: 'xhs_comment_match done',
|
|
2272
|
+
data: {
|
|
2273
|
+
matchCount: matches.length,
|
|
2274
|
+
mode,
|
|
2275
|
+
minHits,
|
|
2276
|
+
},
|
|
2277
|
+
};
|
|
2278
|
+
}
|
|
2279
|
+
|
|
2280
|
+
async function executeCommentLikeOperation({ profileId, params = {}, context = {} }) {
|
|
2281
|
+
const state = getProfileState(profileId);
|
|
2282
|
+
const maxLikes = Math.max(1, Number(params.maxLikes ?? params.maxLikesPerRound ?? 1) || 1);
|
|
2283
|
+
const rawKeywords = normalizeArray(params.keywords || params.likeKeywords);
|
|
2284
|
+
const rules = compileLikeRules(rawKeywords);
|
|
2285
|
+
const dryRun = params.dryRun === true;
|
|
2286
|
+
const saveEvidence = params.saveEvidence !== false;
|
|
2287
|
+
const persistLikeState = params.persistLikeState !== false;
|
|
2288
|
+
const persistComments = params.persistComments === true || params.persistCollectedComments === true;
|
|
2289
|
+
const fallbackPickOne = params.pickOneIfNoNew !== false;
|
|
2290
|
+
|
|
2291
|
+
const snapshot = await readCommentsSnapshot(profileId);
|
|
2292
|
+
const rows = Array.isArray(snapshot?.rows) ? snapshot.rows : [];
|
|
2293
|
+
if (rows.length > 0) {
|
|
2294
|
+
state.currentComments = rows.map((row, idx) => ({
|
|
2295
|
+
index: Number(row.index ?? idx),
|
|
2296
|
+
userName: String(row.userName || ''),
|
|
2297
|
+
userId: String(row.userId || ''),
|
|
2298
|
+
text: String(row.text || ''),
|
|
2299
|
+
timestamp: String(row.timestamp || ''),
|
|
2300
|
+
liked: row.alreadyLiked === true,
|
|
2301
|
+
}));
|
|
2302
|
+
}
|
|
2303
|
+
|
|
2304
|
+
const runtimeState = await readXhsRuntimeState(profileId);
|
|
2305
|
+
const output = resolveXhsOutputContext({
|
|
2306
|
+
params,
|
|
2307
|
+
state: runtimeState,
|
|
2308
|
+
noteId: snapshot?.noteIdFromUrl || runtimeState.currentNoteId || params.noteId,
|
|
2309
|
+
});
|
|
2310
|
+
const evidenceDir = dryRun ? output.virtualLikeEvidenceDir : output.likeEvidenceDir;
|
|
2311
|
+
if (saveEvidence) {
|
|
2312
|
+
await ensureDir(evidenceDir);
|
|
2313
|
+
}
|
|
2314
|
+
|
|
2315
|
+
const likedSignatures = persistLikeState ? await loadLikedSignatures(output.likeStatePath) : new Set();
|
|
2316
|
+
const likedComments = [];
|
|
2317
|
+
|
|
2318
|
+
let hitCount = 0;
|
|
2319
|
+
let likedCount = 0;
|
|
2320
|
+
let dedupSkipped = 0;
|
|
2321
|
+
let alreadyLikedSkipped = 0;
|
|
2322
|
+
let missingLikeControl = 0;
|
|
2323
|
+
let clickFailed = 0;
|
|
2324
|
+
let verifyFailed = 0;
|
|
2325
|
+
|
|
2326
|
+
if (persistComments && rows.length > 0) {
|
|
2327
|
+
await mergeCommentsJsonl({
|
|
2328
|
+
filePath: output.commentsPath,
|
|
2329
|
+
noteId: output.noteId,
|
|
2330
|
+
comments: rows,
|
|
2331
|
+
}).catch(() => null);
|
|
2332
|
+
}
|
|
2333
|
+
|
|
2334
|
+
const { actionTrace, pushTrace } = buildTraceRecorder();
|
|
2335
|
+
|
|
2336
|
+
const candidates = rows.map((row) => ({
|
|
2337
|
+
...row,
|
|
2338
|
+
text: normalizeText(row.text),
|
|
2339
|
+
})).filter((row) => row.text);
|
|
2340
|
+
|
|
2341
|
+
const tryLikeRow = async (row, matchedRule = 'fallback') => {
|
|
2342
|
+
const signature = makeLikeSignature({
|
|
2343
|
+
noteId: output.noteId,
|
|
2344
|
+
userId: String(row.userId || ''),
|
|
2345
|
+
userName: String(row.userName || ''),
|
|
2346
|
+
text: row.text,
|
|
2347
|
+
});
|
|
2348
|
+
|
|
2349
|
+
if (signature && likedSignatures.has(signature)) {
|
|
2350
|
+
dedupSkipped += 1;
|
|
2351
|
+
return false;
|
|
2352
|
+
}
|
|
2353
|
+
|
|
2354
|
+
if (!row.hasLikeControl) {
|
|
2355
|
+
missingLikeControl += 1;
|
|
2356
|
+
return false;
|
|
2357
|
+
}
|
|
2358
|
+
|
|
2359
|
+
if (row.alreadyLiked) {
|
|
2360
|
+
alreadyLikedSkipped += 1;
|
|
2361
|
+
if (persistLikeState && signature) {
|
|
2362
|
+
likedSignatures.add(signature);
|
|
2363
|
+
await appendLikedSignature(output.likeStatePath, signature, {
|
|
2364
|
+
noteId: output.noteId,
|
|
2365
|
+
userId: String(row.userId || ''),
|
|
2366
|
+
userName: String(row.userName || ''),
|
|
2367
|
+
reason: 'already_liked',
|
|
2368
|
+
}).catch(() => null);
|
|
2369
|
+
}
|
|
2370
|
+
return false;
|
|
2371
|
+
}
|
|
2372
|
+
|
|
2373
|
+
if (dryRun) return false;
|
|
2374
|
+
|
|
2375
|
+
const beforePath = saveEvidence
|
|
2376
|
+
? await captureScreenshotToFile({
|
|
2377
|
+
profileId,
|
|
2378
|
+
filePath: path.join(evidenceDir, `like-before-idx-${String(row.index).padStart(3, '0')}-${Date.now()}.png`),
|
|
2379
|
+
})
|
|
2380
|
+
: null;
|
|
2381
|
+
|
|
2382
|
+
const targetBefore = await readLikeTargetByIndex(profileId, row.index);
|
|
2383
|
+
if (!targetBefore || targetBefore.ok !== true || !targetBefore.center) {
|
|
2384
|
+
clickFailed += 1;
|
|
2385
|
+
return false;
|
|
2386
|
+
}
|
|
2387
|
+
if (targetBefore.alreadyLiked) {
|
|
2388
|
+
alreadyLikedSkipped += 1;
|
|
2389
|
+
return false;
|
|
2390
|
+
}
|
|
2391
|
+
|
|
2392
|
+
await clickPoint(profileId, targetBefore.center, { steps: 4 });
|
|
2393
|
+
pushTrace({ kind: 'click', stage: 'xhs_comment_like', commentIndex: Number(row.index) });
|
|
2394
|
+
await sleepRandom(500, 1600, pushTrace, 'like_post_click', { commentIndex: Number(row.index) });
|
|
2395
|
+
|
|
2396
|
+
const targetAfter = await readLikeTargetByIndex(profileId, row.index);
|
|
2397
|
+
const afterPath = saveEvidence
|
|
2398
|
+
? await captureScreenshotToFile({
|
|
2399
|
+
profileId,
|
|
2400
|
+
filePath: path.join(evidenceDir, `like-after-idx-${String(row.index).padStart(3, '0')}-${Date.now()}.png`),
|
|
2401
|
+
})
|
|
2402
|
+
: null;
|
|
2403
|
+
|
|
2404
|
+
if (!targetAfter || targetAfter.ok !== true) {
|
|
2405
|
+
verifyFailed += 1;
|
|
2406
|
+
return false;
|
|
2407
|
+
}
|
|
2408
|
+
if (!targetAfter.alreadyLiked) {
|
|
2409
|
+
verifyFailed += 1;
|
|
2410
|
+
return false;
|
|
2411
|
+
}
|
|
2412
|
+
|
|
2413
|
+
likedCount += 1;
|
|
2414
|
+
if (persistLikeState && signature) {
|
|
2415
|
+
likedSignatures.add(signature);
|
|
2416
|
+
await appendLikedSignature(output.likeStatePath, signature, {
|
|
2417
|
+
noteId: output.noteId,
|
|
2418
|
+
userId: String(row.userId || ''),
|
|
2419
|
+
userName: String(row.userName || ''),
|
|
2420
|
+
reason: 'liked',
|
|
2421
|
+
}).catch(() => null);
|
|
2422
|
+
}
|
|
2423
|
+
likedComments.push({
|
|
2424
|
+
index: Number(row.index),
|
|
2425
|
+
userId: String(row.userId || ''),
|
|
2426
|
+
userName: String(row.userName || ''),
|
|
2427
|
+
content: row.text,
|
|
2428
|
+
timestamp: String(row.timestamp || ''),
|
|
2429
|
+
matchedRule,
|
|
2430
|
+
screenshots: {
|
|
2431
|
+
before: beforePath,
|
|
2432
|
+
after: afterPath,
|
|
2433
|
+
},
|
|
2434
|
+
});
|
|
2435
|
+
return true;
|
|
2436
|
+
};
|
|
2437
|
+
|
|
2438
|
+
for (const row of candidates) {
|
|
2439
|
+
if (likedCount >= maxLikes) break;
|
|
2440
|
+
let match = null;
|
|
2441
|
+
if (rules.length === 0) {
|
|
2442
|
+
const matchedByState = Array.isArray(state.matchedComments)
|
|
2443
|
+
&& state.matchedComments.some((item) => Number(item?.index) === Number(row.index));
|
|
2444
|
+
if (!matchedByState) continue;
|
|
2445
|
+
match = { ok: true, reason: 'state_match', matchedRule: 'state_match' };
|
|
2446
|
+
} else {
|
|
2447
|
+
match = matchLikeText(row.text, rules);
|
|
2448
|
+
if (!match.ok) continue;
|
|
2449
|
+
}
|
|
2450
|
+
hitCount += 1;
|
|
2451
|
+
await tryLikeRow(row, match.matchedRule || match.reason || 'match');
|
|
2452
|
+
}
|
|
2453
|
+
|
|
2454
|
+
if (!dryRun && fallbackPickOne && likedCount < maxLikes) {
|
|
2455
|
+
for (const row of candidates) {
|
|
2456
|
+
if (likedCount >= maxLikes) break;
|
|
2457
|
+
hitCount += 1;
|
|
2458
|
+
const ok = await tryLikeRow(row, 'fallback_first_available');
|
|
2459
|
+
if (ok) break;
|
|
2460
|
+
}
|
|
2461
|
+
}
|
|
2462
|
+
|
|
2463
|
+
emitActionTrace(context, actionTrace, { stage: 'xhs_comment_like', noteId: output.noteId });
|
|
2464
|
+
|
|
2465
|
+
const skippedCount = missingLikeControl + clickFailed + verifyFailed;
|
|
2466
|
+
const likedTotal = likedCount + dedupSkipped + alreadyLikedSkipped;
|
|
2467
|
+
const hitCheckOk = likedTotal + skippedCount === hitCount;
|
|
2468
|
+
const summary = {
|
|
2469
|
+
noteId: output.noteId,
|
|
2470
|
+
keyword: output.keyword,
|
|
2471
|
+
env: output.env,
|
|
2472
|
+
likeKeywords: rawKeywords,
|
|
2473
|
+
maxLikes,
|
|
2474
|
+
scannedCount: rows.length,
|
|
2475
|
+
hitCount,
|
|
2476
|
+
likedCount,
|
|
2477
|
+
skippedCount,
|
|
2478
|
+
likedTotal,
|
|
2479
|
+
hitCheckOk,
|
|
2480
|
+
skippedBreakdown: {
|
|
2481
|
+
missingLikeControl,
|
|
2482
|
+
clickFailed,
|
|
2483
|
+
verifyFailed,
|
|
2484
|
+
},
|
|
2485
|
+
likedBreakdown: {
|
|
2486
|
+
newLikes: likedCount,
|
|
2487
|
+
alreadyLiked: alreadyLikedSkipped,
|
|
2488
|
+
dedup: dedupSkipped,
|
|
2489
|
+
},
|
|
2490
|
+
reachedBottom: snapshot?.metrics
|
|
2491
|
+
? Number(snapshot.metrics.scrollHeight || 0) - (Number(snapshot.metrics.scrollTop || 0) + Number(snapshot.metrics.clientHeight || 0)) <= 6
|
|
2492
|
+
: false,
|
|
2493
|
+
stopReason: String(state.lastCommentsHarvest?.exitReason || '').trim() || null,
|
|
2494
|
+
likedComments,
|
|
2495
|
+
ts: new Date().toISOString(),
|
|
2496
|
+
};
|
|
2497
|
+
|
|
2498
|
+
let summaryPath = null;
|
|
2499
|
+
if (saveEvidence) {
|
|
2500
|
+
summaryPath = await writeJsonFile(path.join(evidenceDir, `summary-${Date.now()}.json`), summary).catch(() => null);
|
|
2501
|
+
}
|
|
2502
|
+
|
|
2503
|
+
return {
|
|
2504
|
+
ok: true,
|
|
2505
|
+
code: 'OPERATION_DONE',
|
|
2506
|
+
message: 'xhs_comment_like done',
|
|
2507
|
+
data: {
|
|
2508
|
+
noteId: output.noteId,
|
|
2509
|
+
scannedCount: rows.length,
|
|
2510
|
+
hitCount,
|
|
2511
|
+
likedCount,
|
|
2512
|
+
skippedCount,
|
|
2513
|
+
likedTotal,
|
|
2514
|
+
hitCheckOk,
|
|
2515
|
+
dedupSkipped,
|
|
2516
|
+
alreadyLikedSkipped,
|
|
2517
|
+
missingLikeControl,
|
|
2518
|
+
clickFailed,
|
|
2519
|
+
verifyFailed,
|
|
2520
|
+
likedComments,
|
|
2521
|
+
commentsPath: persistComments ? output.commentsPath : null,
|
|
2522
|
+
likeStatePath: persistLikeState ? output.likeStatePath : null,
|
|
2523
|
+
evidenceDir: saveEvidence ? evidenceDir : null,
|
|
2524
|
+
summaryPath,
|
|
2525
|
+
reachedBottom: summary.reachedBottom,
|
|
2526
|
+
stopReason: summary.stopReason,
|
|
2527
|
+
},
|
|
2528
|
+
};
|
|
2529
|
+
}
|
|
2530
|
+
|
|
2531
|
+
async function executeCommentReplyOperation({ profileId, params = {}, context = {} }) {
|
|
2532
|
+
const state = getProfileState(profileId);
|
|
2533
|
+
const replyText = String(params.replyText || '').trim();
|
|
2534
|
+
if (!replyText) {
|
|
2535
|
+
return asErrorPayload('OPERATION_FAILED', 'replyText is required');
|
|
2536
|
+
}
|
|
2537
|
+
|
|
2538
|
+
const matches = Array.isArray(state.matchedComments) ? state.matchedComments : [];
|
|
2539
|
+
if (matches.length === 0) {
|
|
2540
|
+
return {
|
|
2541
|
+
ok: true,
|
|
2542
|
+
code: 'OPERATION_DONE',
|
|
2543
|
+
message: 'xhs_comment_reply done',
|
|
2544
|
+
data: { typed: false, reason: 'no_match' },
|
|
2545
|
+
};
|
|
2546
|
+
}
|
|
2547
|
+
|
|
2548
|
+
const index = Number(matches[0]?.index || 0);
|
|
2549
|
+
const { actionTrace, pushTrace } = buildTraceRecorder();
|
|
2550
|
+
|
|
2551
|
+
const target = await readReplyTargetByIndex(profileId, index);
|
|
2552
|
+
if (!target || target.ok !== true || !target.center) {
|
|
2553
|
+
return {
|
|
2554
|
+
ok: true,
|
|
2555
|
+
code: 'OPERATION_DONE',
|
|
2556
|
+
message: 'xhs_comment_reply done',
|
|
2557
|
+
data: { typed: false, reason: 'match_not_visible', index },
|
|
2558
|
+
};
|
|
2559
|
+
}
|
|
2560
|
+
|
|
2561
|
+
await clickPoint(profileId, target.center, { steps: 3 });
|
|
2562
|
+
pushTrace({ kind: 'click', stage: 'xhs_comment_reply', target: 'comment', index });
|
|
2563
|
+
await sleepRandom(500, 1200, pushTrace, 'reply_after_comment_click', { index });
|
|
2564
|
+
|
|
2565
|
+
const inputTarget = await readReplyInputTarget(profileId);
|
|
2566
|
+
if (!inputTarget || !inputTarget.center) {
|
|
2567
|
+
return {
|
|
2568
|
+
ok: true,
|
|
2569
|
+
code: 'OPERATION_DONE',
|
|
2570
|
+
message: 'xhs_comment_reply done',
|
|
2571
|
+
data: { typed: false, reason: 'reply_input_not_found', index },
|
|
2572
|
+
};
|
|
2573
|
+
}
|
|
2574
|
+
|
|
2575
|
+
await clickPoint(profileId, inputTarget.center, { steps: 2 });
|
|
2576
|
+
pushTrace({ kind: 'click', stage: 'xhs_comment_reply', target: 'reply_input', index });
|
|
2577
|
+
await sleepRandom(500, 1100, pushTrace, 'reply_pre_type', { index });
|
|
2578
|
+
await clearAndType(profileId, replyText, Number(params.keyDelayMs ?? 65) || 65);
|
|
2579
|
+
pushTrace({ kind: 'type', stage: 'xhs_comment_reply', target: 'reply_input', length: replyText.length, index });
|
|
2580
|
+
await sleepRandom(500, 1400, pushTrace, 'reply_post_type', { index });
|
|
2581
|
+
|
|
2582
|
+
const sendCenter = await readReplySendButtonTarget(profileId);
|
|
2583
|
+
if (sendCenter) {
|
|
2584
|
+
await clickPoint(profileId, sendCenter, { steps: 2 });
|
|
2585
|
+
pushTrace({ kind: 'click', stage: 'xhs_comment_reply', target: 'reply_send', index });
|
|
2586
|
+
}
|
|
2587
|
+
|
|
2588
|
+
state.lastReply = { typed: true, index, at: new Date().toISOString() };
|
|
2589
|
+
emitActionTrace(context, actionTrace, { stage: 'xhs_comment_reply', index });
|
|
2590
|
+
|
|
2591
|
+
return {
|
|
2592
|
+
ok: true,
|
|
2593
|
+
code: 'OPERATION_DONE',
|
|
2594
|
+
message: 'xhs_comment_reply done',
|
|
2595
|
+
data: state.lastReply,
|
|
2596
|
+
};
|
|
2597
|
+
}
|
|
2598
|
+
|
|
2599
|
+
async function executeCloseDetailOperation({ profileId, context = {} }) {
|
|
2600
|
+
const state = getProfileState(profileId);
|
|
2601
|
+
const metrics = state.metrics || (state.metrics = {});
|
|
2602
|
+
metrics.searchCount = Number(metrics.searchCount || 0);
|
|
2603
|
+
metrics.rollbackCount = Number(metrics.rollbackCount || 0);
|
|
2604
|
+
metrics.returnToSearchCount = Number(metrics.returnToSearchCount || 0);
|
|
2605
|
+
|
|
2606
|
+
const harvest = state.lastCommentsHarvest && typeof state.lastCommentsHarvest === 'object'
|
|
2607
|
+
? state.lastCommentsHarvest
|
|
2608
|
+
: null;
|
|
2609
|
+
const exitMeta = {
|
|
2610
|
+
pageExitReason: String(harvest?.exitReason || 'close_without_harvest').trim(),
|
|
2611
|
+
reachedBottom: typeof harvest?.reachedBottom === 'boolean' ? harvest.reachedBottom : null,
|
|
2612
|
+
commentsCollected: Number.isFinite(Number(harvest?.collected)) ? Number(harvest.collected) : null,
|
|
2613
|
+
expectedCommentsCount: Number.isFinite(Number(harvest?.expectedCommentsCount)) ? Number(harvest.expectedCommentsCount) : null,
|
|
2614
|
+
commentCoverageRate: Number.isFinite(Number(harvest?.commentCoverageRate)) ? Number(harvest.commentCoverageRate) : null,
|
|
2615
|
+
scrollRecoveries: Number.isFinite(Number(harvest?.recoveries)) ? Number(harvest.recoveries) : 0,
|
|
2616
|
+
harvestRounds: Number.isFinite(Number(harvest?.rounds)) ? Number(harvest.rounds) : null,
|
|
2617
|
+
};
|
|
2618
|
+
|
|
2619
|
+
const { actionTrace, pushTrace } = buildTraceRecorder();
|
|
2620
|
+
const firstSnapshot = await isDetailVisible(profileId);
|
|
2621
|
+
if (firstSnapshot?.detailVisible !== true) {
|
|
2622
|
+
return {
|
|
2623
|
+
ok: true,
|
|
2624
|
+
code: 'OPERATION_DONE',
|
|
2625
|
+
message: 'xhs_close_detail done',
|
|
2626
|
+
data: {
|
|
2627
|
+
closed: true,
|
|
2628
|
+
via: 'already_closed',
|
|
2629
|
+
searchVisible: firstSnapshot?.searchVisible === true,
|
|
2630
|
+
searchCount: Number(metrics.searchCount || 0),
|
|
2631
|
+
rollbackCount: Number(metrics.rollbackCount || 0),
|
|
2632
|
+
returnToSearchCount: Number(metrics.returnToSearchCount || 0),
|
|
2633
|
+
returnedToSearch: false,
|
|
2634
|
+
...exitMeta,
|
|
2635
|
+
},
|
|
2636
|
+
};
|
|
2637
|
+
}
|
|
2638
|
+
|
|
2639
|
+
metrics.rollbackCount += 1;
|
|
2640
|
+
metrics.lastRollbackAt = new Date().toISOString();
|
|
2641
|
+
|
|
2642
|
+
const waitForCloseAnimation = async () => {
|
|
2643
|
+
for (let i = 0; i < 45; i += 1) {
|
|
2644
|
+
const s = await isDetailVisible(profileId);
|
|
2645
|
+
if (s?.detailVisible !== true && s?.searchVisible === true) return true;
|
|
2646
|
+
await sleep(120);
|
|
2647
|
+
}
|
|
2648
|
+
const s = await isDetailVisible(profileId);
|
|
2649
|
+
return s?.detailVisible !== true && s?.searchVisible === true;
|
|
2650
|
+
};
|
|
2651
|
+
|
|
2652
|
+
for (let attempt = 1; attempt <= 4; attempt += 1) {
|
|
2653
|
+
await pressKey(profileId, 'Escape');
|
|
2654
|
+
pushTrace({ kind: 'key', stage: 'xhs_close_detail', key: 'Escape', attempt });
|
|
2655
|
+
await sleep(randomBetween(220, 480));
|
|
2656
|
+
if (await waitForCloseAnimation()) {
|
|
2657
|
+
const s = await isDetailVisible(profileId);
|
|
2658
|
+
const searchVisible = s?.searchVisible === true;
|
|
2659
|
+
if (searchVisible) {
|
|
2660
|
+
metrics.returnToSearchCount += 1;
|
|
2661
|
+
metrics.lastReturnToSearchAt = new Date().toISOString();
|
|
2662
|
+
}
|
|
2663
|
+
emitActionTrace(context, actionTrace, { stage: 'xhs_close_detail' });
|
|
2664
|
+
return {
|
|
2665
|
+
ok: true,
|
|
2666
|
+
code: 'OPERATION_DONE',
|
|
2667
|
+
message: 'xhs_close_detail done',
|
|
2668
|
+
data: {
|
|
2669
|
+
closed: true,
|
|
2670
|
+
via: 'escape',
|
|
2671
|
+
attempts: attempt,
|
|
2672
|
+
searchVisible,
|
|
2673
|
+
searchCount: Number(metrics.searchCount || 0),
|
|
2674
|
+
rollbackCount: Number(metrics.rollbackCount || 0),
|
|
2675
|
+
returnToSearchCount: Number(metrics.returnToSearchCount || 0),
|
|
2676
|
+
returnedToSearch: searchVisible,
|
|
2677
|
+
...exitMeta,
|
|
2678
|
+
},
|
|
2679
|
+
};
|
|
2680
|
+
}
|
|
2681
|
+
}
|
|
2682
|
+
|
|
2683
|
+
const finalSnapshot = await isDetailVisible(profileId);
|
|
2684
|
+
emitActionTrace(context, actionTrace, { stage: 'xhs_close_detail' });
|
|
2685
|
+
return {
|
|
2686
|
+
ok: true,
|
|
2687
|
+
code: 'OPERATION_DONE',
|
|
2688
|
+
message: 'xhs_close_detail done',
|
|
2689
|
+
data: {
|
|
2690
|
+
closed: false,
|
|
2691
|
+
via: 'escape_failed',
|
|
2692
|
+
detailVisible: finalSnapshot?.detailVisible === true,
|
|
2693
|
+
searchVisible: finalSnapshot?.searchVisible === true,
|
|
2694
|
+
searchCount: Number(metrics.searchCount || 0),
|
|
2695
|
+
rollbackCount: Number(metrics.rollbackCount || 0),
|
|
2696
|
+
returnToSearchCount: Number(metrics.returnToSearchCount || 0),
|
|
2697
|
+
returnedToSearch: false,
|
|
2698
|
+
...exitMeta,
|
|
2699
|
+
},
|
|
2700
|
+
};
|
|
2701
|
+
}
|
|
2702
|
+
|
|
2703
|
+
async function handleRaiseError({ params }) {
|
|
2704
|
+
const code = String(params.code || params.message || 'AUTOSCRIPT_ABORT').trim();
|
|
2705
|
+
return asErrorPayload('OPERATION_FAILED', code || 'AUTOSCRIPT_ABORT');
|
|
2706
|
+
}
|
|
2707
|
+
|
|
488
2708
|
const XHS_ACTION_HANDLERS = {
|
|
489
2709
|
raise_error: handleRaiseError,
|
|
490
2710
|
xhs_assert_logged_in: executeAssertLoggedInOperation,
|
|
491
2711
|
xhs_submit_search: executeSubmitSearchOperation,
|
|
492
2712
|
xhs_open_detail: executeOpenDetailOperation,
|
|
493
|
-
xhs_detail_harvest:
|
|
494
|
-
xhs_expand_replies:
|
|
2713
|
+
xhs_detail_harvest: executeDetailHarvestOperation,
|
|
2714
|
+
xhs_expand_replies: executeExpandRepliesOperation,
|
|
495
2715
|
xhs_comments_harvest: executeCommentsHarvestOperation,
|
|
496
|
-
xhs_comment_match:
|
|
2716
|
+
xhs_comment_match: executeCommentMatchOperation,
|
|
497
2717
|
xhs_comment_like: executeCommentLikeOperation,
|
|
498
|
-
xhs_comment_reply:
|
|
499
|
-
xhs_close_detail:
|
|
2718
|
+
xhs_comment_reply: executeCommentReplyOperation,
|
|
2719
|
+
xhs_close_detail: executeCloseDetailOperation,
|
|
500
2720
|
};
|
|
501
2721
|
|
|
502
2722
|
export function isXhsAutoscriptAction(action) {
|
|
@@ -515,5 +2735,17 @@ export async function executeXhsAutoscriptOperation({
|
|
|
515
2735
|
if (!handler) {
|
|
516
2736
|
return asErrorPayload('UNSUPPORTED_OPERATION', `Unsupported xhs operation: ${action}`);
|
|
517
2737
|
}
|
|
518
|
-
|
|
2738
|
+
try {
|
|
2739
|
+
return await handler({ profileId, params, operation, context });
|
|
2740
|
+
} catch (err) {
|
|
2741
|
+
const message = String(err?.message || err || '');
|
|
2742
|
+
if (message.includes('forbidden_js_action')) {
|
|
2743
|
+
return asErrorPayload('JS_DISABLED', message);
|
|
2744
|
+
}
|
|
2745
|
+
return asErrorPayload('OPERATION_FAILED', message);
|
|
2746
|
+
}
|
|
2747
|
+
}
|
|
2748
|
+
|
|
2749
|
+
export function __unsafe_getProfileStateForTests(profileId) {
|
|
2750
|
+
return getProfileState(profileId);
|
|
519
2751
|
}
|