@web-auto/webauto 0.1.7 → 0.1.9

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.
Files changed (32) hide show
  1. package/apps/desktop-console/dist/main/index.mjs +802 -90
  2. package/apps/desktop-console/dist/main/preload.mjs +3 -0
  3. package/apps/desktop-console/dist/renderer/index.html +9 -1
  4. package/apps/desktop-console/dist/renderer/index.js +784 -332
  5. package/apps/desktop-console/entry/ui-cli.mjs +23 -8
  6. package/apps/desktop-console/entry/ui-console.mjs +8 -3
  7. package/apps/webauto/entry/account.mjs +69 -8
  8. package/apps/webauto/entry/lib/account-detect.mjs +106 -25
  9. package/apps/webauto/entry/lib/account-store.mjs +121 -22
  10. package/apps/webauto/entry/lib/schedule-store.mjs +0 -12
  11. package/apps/webauto/entry/profilepool.mjs +45 -3
  12. package/apps/webauto/entry/schedule.mjs +44 -2
  13. package/apps/webauto/entry/weibo-unified.mjs +2 -2
  14. package/apps/webauto/entry/xhs-install.mjs +220 -51
  15. package/apps/webauto/entry/xhs-unified.mjs +33 -6
  16. package/bin/webauto.mjs +80 -4
  17. package/dist/modules/camo-runtime/src/utils/browser-service.mjs +4 -0
  18. package/dist/services/unified-api/server.js +5 -0
  19. package/dist/services/unified-api/task-state.js +2 -0
  20. package/modules/camo-runtime/src/autoscript/action-providers/xhs/interaction.mjs +142 -14
  21. package/modules/camo-runtime/src/autoscript/action-providers/xhs/search.mjs +16 -1
  22. package/modules/camo-runtime/src/autoscript/action-providers/xhs.mjs +104 -0
  23. package/modules/camo-runtime/src/autoscript/runtime.mjs +14 -4
  24. package/modules/camo-runtime/src/autoscript/schema.mjs +9 -0
  25. package/modules/camo-runtime/src/autoscript/xhs-unified-template.mjs +9 -2
  26. package/modules/camo-runtime/src/container/runtime-core/checkpoint.mjs +107 -1
  27. package/modules/camo-runtime/src/container/runtime-core/subscription.mjs +24 -2
  28. package/modules/camo-runtime/src/utils/browser-service.mjs +4 -0
  29. package/package.json +6 -3
  30. package/scripts/bump-version.mjs +120 -0
  31. package/services/unified-api/server.ts +4 -0
  32. package/services/unified-api/task-state.ts +5 -0
@@ -59,12 +59,10 @@ function buildCollectLikeTargetsScript() {
59
59
  const className = String(node.className || '').toLowerCase();
60
60
  const ariaPressed = String(node.getAttribute?.('aria-pressed') || '').toLowerCase();
61
61
  const text = String(node.textContent || '');
62
- const useNode = node.querySelector('use');
63
- const useHref = String(useNode?.getAttribute?.('xlink:href') || useNode?.getAttribute?.('href') || '').toLowerCase();
64
- return className.includes('like-active')
62
+ const hasActiveClass = /(?:^|\\s)like-active(?:\\s|$)/.test(className);
63
+ return hasActiveClass
65
64
  || ariaPressed === 'true'
66
- || /已赞|取消赞/.test(text)
67
- || useHref.includes('liked');
65
+ || /已赞|取消赞/.test(text);
68
66
  };
69
67
  const readText = (item, selectors) => {
70
68
  for (const selector of selectors) {
@@ -164,7 +162,7 @@ function buildCollectLikeTargetsScript() {
164
162
  })()`;
165
163
  }
166
164
 
167
- function buildClickLikeByIndexScript(index, highlight) {
165
+ function buildClickLikeByIndexScript(index, highlight, skipAlreadyCheck = false) {
168
166
  return `(async () => {
169
167
  const idx = Number(${JSON.stringify(index)});
170
168
  const items = Array.from(document.querySelectorAll('.comment-item'));
@@ -190,18 +188,16 @@ function buildClickLikeByIndexScript(index, highlight) {
190
188
  const className = String(node.className || '').toLowerCase();
191
189
  const ariaPressed = String(node.getAttribute?.('aria-pressed') || '').toLowerCase();
192
190
  const text = String(node.textContent || '');
193
- const useNode = node.querySelector('use');
194
- const useHref = String(useNode?.getAttribute?.('xlink:href') || useNode?.getAttribute?.('href') || '').toLowerCase();
195
- return className.includes('like-active')
191
+ const hasActiveClass = /(?:^|\\s)like-active(?:\\s|$)/.test(className);
192
+ return hasActiveClass
196
193
  || ariaPressed === 'true'
197
- || /已赞|取消赞/.test(text)
198
- || useHref.includes('liked');
194
+ || /已赞|取消赞/.test(text);
199
195
  };
200
196
 
201
197
  const likeControl = findLikeControl(item);
202
198
  if (!likeControl) return { clicked: false, reason: 'like_control_not_found', index: idx };
203
199
  const beforeLiked = isAlreadyLiked(likeControl);
204
- if (beforeLiked) {
200
+ if (beforeLiked && !${skipAlreadyCheck ? 'true' : 'false'}) {
205
201
  return { clicked: false, alreadyLiked: true, reason: 'already_liked', index: idx };
206
202
  }
207
203
  item.scrollIntoView({ behavior: 'auto', block: 'center' });
@@ -216,7 +212,7 @@ function buildClickLikeByIndexScript(index, highlight) {
216
212
  await new Promise((resolve) => setTimeout(resolve, 220));
217
213
  return {
218
214
  clicked: true,
219
- alreadyLiked: false,
215
+ alreadyLiked: beforeLiked,
220
216
  likedAfter: isAlreadyLiked(likeControl),
221
217
  reason: 'clicked',
222
218
  index: idx,
@@ -244,6 +240,7 @@ export async function executeCommentLikeOperation({ profileId, params = {} }) {
244
240
  const saveEvidence = params.saveEvidence !== false;
245
241
  const persistLikeState = params.persistLikeState !== false;
246
242
  const persistComments = params.persistComments === true || params.persistCollectedComments === true;
243
+ const fallbackPickOne = params.pickOneIfNoNew !== false;
247
244
 
248
245
  const stateRaw = await runEvaluateScript({
249
246
  profileId,
@@ -273,7 +270,8 @@ export async function executeCommentLikeOperation({ profileId, params = {} }) {
273
270
  const likedSignatures = persistLikeState ? await loadLikedSignatures(output.likeStatePath) : new Set();
274
271
  const likedComments = [];
275
272
  const matchedByStateCount = Number(collected.matchedByStateCount || 0);
276
- const useStateMatches = matchedByStateCount > 0;
273
+ // If explicit like rules are provided, honor them instead of inheriting state matches.
274
+ const useStateMatches = matchedByStateCount > 0 && rules.length === 0;
277
275
 
278
276
  let hitCount = 0;
279
277
  let likedCount = 0;
@@ -411,6 +409,136 @@ export async function executeCommentLikeOperation({ profileId, params = {} }) {
411
409
  });
412
410
  }
413
411
 
412
+ if (!dryRun && fallbackPickOne && likedCount < maxLikes) {
413
+ for (const row of rows) {
414
+ if (likedCount >= maxLikes) break;
415
+ if (!row || typeof row !== 'object') continue;
416
+ const text = normalizeText(row.text);
417
+ if (!text) continue;
418
+
419
+ const signature = makeLikeSignature({
420
+ noteId: output.noteId,
421
+ userId: String(row.userId || ''),
422
+ userName: String(row.userName || ''),
423
+ text,
424
+ });
425
+ if (!row.hasLikeControl) {
426
+ missingLikeControl += 1;
427
+ continue;
428
+ }
429
+
430
+ hitCount += 1;
431
+ if (row.alreadyLiked) {
432
+ alreadyLikedSkipped += 1;
433
+ if (persistLikeState && signature) {
434
+ likedSignatures.add(signature);
435
+ await appendLikedSignature(output.likeStatePath, signature, {
436
+ noteId: output.noteId,
437
+ userId: String(row.userId || ''),
438
+ userName: String(row.userName || ''),
439
+ reason: 'already_liked_fallback',
440
+ }).catch(() => null);
441
+ }
442
+ continue;
443
+ }
444
+
445
+ const beforePath = saveEvidence
446
+ ? await captureScreenshotToFile({
447
+ profileId,
448
+ filePath: path.join(evidenceDir, `like-before-fallback-idx-${String(row.index).padStart(3, '0')}-${Date.now()}.png`),
449
+ })
450
+ : null;
451
+ const clickRaw = await runEvaluateScript({
452
+ profileId,
453
+ script: buildClickLikeByIndexScript(row.index, highlight, true),
454
+ highlight: false,
455
+ });
456
+ const clickResult = extractEvaluateResultData(clickRaw) || {};
457
+ const afterPath = saveEvidence
458
+ ? await captureScreenshotToFile({
459
+ profileId,
460
+ filePath: path.join(evidenceDir, `like-after-fallback-idx-${String(row.index).padStart(3, '0')}-${Date.now()}.png`),
461
+ })
462
+ : null;
463
+
464
+ if (clickResult.alreadyLiked) {
465
+ alreadyLikedSkipped += 1;
466
+ if (persistLikeState && signature) {
467
+ likedSignatures.add(signature);
468
+ await appendLikedSignature(output.likeStatePath, signature, {
469
+ noteId: output.noteId,
470
+ userId: String(row.userId || ''),
471
+ userName: String(row.userName || ''),
472
+ reason: 'already_liked_after_click_fallback',
473
+ }).catch(() => null);
474
+ }
475
+ continue;
476
+ }
477
+ if (!clickResult.clicked) {
478
+ clickFailed += 1;
479
+ continue;
480
+ }
481
+ if (!clickResult.likedAfter) {
482
+ if (clickResult.alreadyLiked) {
483
+ // Fallback proof path: toggle back to keep original state, but keep one verified candidate.
484
+ await runEvaluateScript({
485
+ profileId,
486
+ script: buildClickLikeByIndexScript(row.index, false, true),
487
+ highlight: false,
488
+ }).catch(() => null);
489
+ likedCount += 1;
490
+ likedComments.push({
491
+ index: Number(row.index),
492
+ userId: String(row.userId || ''),
493
+ userName: String(row.userName || ''),
494
+ content: text,
495
+ timestamp: String(row.timestamp || ''),
496
+ matchedRule: 'fallback_already_liked_verified',
497
+ screenshots: {
498
+ before: beforePath,
499
+ after: afterPath,
500
+ },
501
+ });
502
+ if (persistLikeState && signature) {
503
+ likedSignatures.add(signature);
504
+ await appendLikedSignature(output.likeStatePath, signature, {
505
+ noteId: output.noteId,
506
+ userId: String(row.userId || ''),
507
+ userName: String(row.userName || ''),
508
+ reason: 'already_liked_verified',
509
+ }).catch(() => null);
510
+ }
511
+ break;
512
+ }
513
+ verifyFailed += 1;
514
+ continue;
515
+ }
516
+
517
+ likedCount += 1;
518
+ if (persistLikeState && signature) {
519
+ likedSignatures.add(signature);
520
+ await appendLikedSignature(output.likeStatePath, signature, {
521
+ noteId: output.noteId,
522
+ userId: String(row.userId || ''),
523
+ userName: String(row.userName || ''),
524
+ reason: 'liked_fallback',
525
+ }).catch(() => null);
526
+ }
527
+ likedComments.push({
528
+ index: Number(row.index),
529
+ userId: String(row.userId || ''),
530
+ userName: String(row.userName || ''),
531
+ content: text,
532
+ timestamp: String(row.timestamp || ''),
533
+ matchedRule: 'fallback_first_available',
534
+ screenshots: {
535
+ before: beforePath,
536
+ after: afterPath,
537
+ },
538
+ });
539
+ }
540
+ }
541
+
414
542
  const skippedCount = missingLikeControl + clickFailed + verifyFailed;
415
543
  const likedTotal = likedCount + dedupSkipped + alreadyLikedSkipped;
416
544
  const hitCheckOk = likedTotal + skippedCount === hitCount;
@@ -213,6 +213,12 @@ export function buildOpenDetailScript(params = {}) {
213
213
  '.note-detail-mask .interaction-container',
214
214
  '.note-detail-mask .comments-container',
215
215
  ];
216
+ const searchSelectors = [
217
+ '.note-item',
218
+ '.search-result-list',
219
+ '#search-input',
220
+ '.feeds-page',
221
+ ];
216
222
  const isVisible = (node) => {
217
223
  if (!node || !(node instanceof HTMLElement)) return false;
218
224
  const style = window.getComputedStyle(node);
@@ -221,7 +227,16 @@ export function buildOpenDetailScript(params = {}) {
221
227
  const rect = node.getBoundingClientRect();
222
228
  return rect.width > 1 && rect.height > 1;
223
229
  };
224
- const isDetailReady = () => detailSelectors.some((selector) => isVisible(document.querySelector(selector)));
230
+ const hasVisible = (selectors) => selectors.some((selector) => isVisible(document.querySelector(selector)));
231
+ const isDetailReady = () => {
232
+ const detailVisible = hasVisible(detailSelectors);
233
+ if (!detailVisible) return false;
234
+ const href = String(window.location.href || '');
235
+ const isDetailUrl = href.includes('/explore/') && !href.includes('/search_result');
236
+ if (isDetailUrl) return true;
237
+ const searchVisible = hasVisible(searchSelectors);
238
+ return !searchVisible;
239
+ };
225
240
 
226
241
  next.cover.scrollIntoView({ behavior: 'auto', block: 'center' });
227
242
  await new Promise((resolve) => setTimeout(resolve, 140));
@@ -253,6 +253,109 @@ async function readXhsRuntimeState(profileId) {
253
253
  }
254
254
  }
255
255
 
256
+ function buildAssertLoggedInScript(params = {}) {
257
+ const selectors = Array.isArray(params.loginSelectors) && params.loginSelectors.length > 0
258
+ ? params.loginSelectors.map((item) => String(item || '').trim()).filter(Boolean)
259
+ : [
260
+ '.login-container',
261
+ '.login-dialog',
262
+ '#login-container',
263
+ ];
264
+ const loginPattern = String(
265
+ params.loginPattern || '登录|扫码|验证码|手机号|请先登录|注册|sign\\s*in',
266
+ ).trim();
267
+
268
+ return `(() => {
269
+ const guardSelectors = ${JSON.stringify(selectors)};
270
+ const loginPattern = new RegExp(${JSON.stringify(loginPattern || '登录|扫码|验证码|手机号|请先登录|注册|sign\\\\s*in')}, 'i');
271
+
272
+ const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim();
273
+ const isVisible = (node) => {
274
+ if (!(node instanceof HTMLElement)) return false;
275
+ const style = window.getComputedStyle(node);
276
+ if (!style) return false;
277
+ if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity || '1') === 0) return false;
278
+ const rect = node.getBoundingClientRect();
279
+ return rect.width > 0 && rect.height > 0;
280
+ };
281
+
282
+ const guardNodes = guardSelectors.flatMap((selector) => Array.from(document.querySelectorAll(selector)));
283
+ const visibleGuardNodes = guardNodes.filter((node) => isVisible(node));
284
+ const guardTexts = visibleGuardNodes
285
+ .slice(0, 10)
286
+ .map((node) => normalize(node.textContent || ''))
287
+ .filter(Boolean);
288
+ const mergedGuardText = guardTexts.join(' ');
289
+ const hasLoginText = loginPattern.test(mergedGuardText);
290
+ const loginUrl = /\\/login|signin|passport|account\\/login/i.test(String(location.href || ''));
291
+
292
+ let accountId = '';
293
+ try {
294
+ const initialState = (typeof window !== 'undefined' && window.__INITIAL_STATE__) || null;
295
+ const rawUserInfo = initialState && initialState.user && initialState.user.userInfo
296
+ ? (
297
+ (initialState.user.userInfo._rawValue && typeof initialState.user.userInfo._rawValue === 'object' && initialState.user.userInfo._rawValue)
298
+ || (initialState.user.userInfo._value && typeof initialState.user.userInfo._value === 'object' && initialState.user.userInfo._value)
299
+ || (typeof initialState.user.userInfo === 'object' ? initialState.user.userInfo : null)
300
+ )
301
+ : null;
302
+ accountId = normalize(rawUserInfo?.user_id || rawUserInfo?.userId || '');
303
+ } catch {}
304
+
305
+ if (!accountId) {
306
+ const selfAnchor = Array.from(document.querySelectorAll('a[href*="/user/profile/"]'))
307
+ .find((node) => {
308
+ const text = normalize(node.textContent || '');
309
+ const title = normalize(node.getAttribute('title') || '');
310
+ const aria = normalize(node.getAttribute('aria-label') || '');
311
+ return ['我', '我的', '个人主页', '我的主页'].includes(text)
312
+ || ['我', '我的', '个人主页', '我的主页'].includes(title)
313
+ || ['我', '我的', '个人主页', '我的主页'].includes(aria);
314
+ });
315
+ if (selfAnchor) {
316
+ const href = normalize(selfAnchor.getAttribute('href') || '');
317
+ const matched = href.match(/\\/user\\/profile\\/([^/?#]+)/);
318
+ if (matched && matched[1]) accountId = normalize(matched[1]);
319
+ }
320
+ }
321
+
322
+ const hasAccountSignal = Boolean(accountId);
323
+ const hasLoginGuard = (visibleGuardNodes.length > 0 && hasLoginText) || loginUrl;
324
+
325
+ return {
326
+ hasLoginGuard,
327
+ hasAccountSignal,
328
+ accountId: accountId || null,
329
+ url: String(location.href || ''),
330
+ visibleGuardCount: visibleGuardNodes.length,
331
+ guardTextPreview: mergedGuardText.slice(0, 240),
332
+ loginUrl,
333
+ hasLoginText,
334
+ guardSelectors,
335
+ };
336
+ })()`;
337
+ }
338
+
339
+ async function executeAssertLoggedInOperation({ profileId, params = {} }) {
340
+ const highlight = params.highlight !== false;
341
+ const payload = await runEvaluateScript({
342
+ profileId,
343
+ script: buildAssertLoggedInScript(params),
344
+ highlight,
345
+ });
346
+ const data = extractEvaluateResultData(payload) || {};
347
+ if (data?.hasLoginGuard === true) {
348
+ const code = String(params.code || 'LOGIN_GUARD_DETECTED').trim() || 'LOGIN_GUARD_DETECTED';
349
+ return asErrorPayload('OPERATION_FAILED', code, { guard: data });
350
+ }
351
+ return {
352
+ ok: true,
353
+ code: 'OPERATION_DONE',
354
+ message: 'xhs_assert_logged_in done',
355
+ data,
356
+ };
357
+ }
358
+
256
359
  async function handleRaiseError({ params }) {
257
360
  const code = String(params.code || params.message || 'AUTOSCRIPT_ABORT').trim();
258
361
  return asErrorPayload('OPERATION_FAILED', code || 'AUTOSCRIPT_ABORT');
@@ -314,6 +417,7 @@ async function executeCommentsHarvestOperation({ profileId, params = {} }) {
314
417
 
315
418
  const XHS_ACTION_HANDLERS = {
316
419
  raise_error: handleRaiseError,
420
+ xhs_assert_logged_in: executeAssertLoggedInOperation,
317
421
  xhs_submit_search: executeSubmitSearchOperation,
318
422
  xhs_open_detail: executeOpenDetailOperation,
319
423
  xhs_detail_harvest: createEvaluateHandler('xhs_detail_harvest done', buildDetailHarvestScript),
@@ -340,6 +340,7 @@ export class AutoscriptRunner {
340
340
  'sync_window_viewport',
341
341
  'verify_subscriptions',
342
342
  'xhs_submit_search',
343
+ 'xhs_assert_logged_in',
343
344
  'xhs_open_detail',
344
345
  'xhs_detail_harvest',
345
346
  'xhs_expand_replies',
@@ -359,10 +360,19 @@ export class AutoscriptRunner {
359
360
 
360
361
  resolveTimeoutMs(operation) {
361
362
  const pacing = this.resolvePacing(operation);
362
- const disableTimeout = Boolean(
363
- operation?.disableTimeout ?? this.script?.defaults?.disableTimeout,
364
- );
365
- if (disableTimeout) return 0;
363
+ const operationDisableTimeout = operation?.disableTimeout;
364
+ if (operationDisableTimeout === true) return 0;
365
+
366
+ const rawOperationTimeout = operation?.timeoutMs ?? operation?.pacing?.timeoutMs;
367
+ const hasOperationTimeout = Number.isFinite(Number(rawOperationTimeout))
368
+ && Number(rawOperationTimeout) > 0;
369
+ const defaultDisableTimeout = Boolean(this.script?.defaults?.disableTimeout);
370
+
371
+ // Keep default "no-timeout" mode, but allow operation-level timeout to opt in.
372
+ if (defaultDisableTimeout && operationDisableTimeout !== false && !hasOperationTimeout) {
373
+ return 0;
374
+ }
375
+
366
376
  if (pacing.timeoutMs === 0) return 0;
367
377
  if (Number.isFinite(pacing.timeoutMs) && pacing.timeoutMs > 0) return pacing.timeoutMs;
368
378
  return this.getDefaultTimeoutMs(operation);
@@ -126,10 +126,19 @@ function normalizeSubscription(item, index, defaults) {
126
126
  const id = toTrimmedString(item.id) || `subscription_${index + 1}`;
127
127
  const selector = toTrimmedString(item.selector);
128
128
  if (!selector) return null;
129
+ const pageUrlIncludes = toArray(item.pageUrlIncludes || item.urlIncludes)
130
+ .map((value) => toTrimmedString(value))
131
+ .filter(Boolean);
132
+ const pageUrlExcludes = toArray(item.pageUrlExcludes || item.urlExcludes)
133
+ .map((value) => toTrimmedString(value))
134
+ .filter(Boolean);
129
135
  const events = toArray(item.events).map((name) => toTrimmedString(name)).filter(Boolean);
130
136
  return {
131
137
  id,
132
138
  selector,
139
+ visible: item.visible === false ? false : true,
140
+ pageUrlIncludes,
141
+ pageUrlExcludes,
133
142
  events: events.length > 0 ? events : ['appear', 'exist', 'disappear', 'change'],
134
143
  dependsOn: toArray(item.dependsOn).map((x) => toTrimmedString(x)).filter(Boolean),
135
144
  retry: normalizeRetry(item.retry, defaults.retry),
@@ -575,7 +575,14 @@ export function buildXhsUnifiedAutoscript(rawOptions = {}) {
575
575
  { id: 'home_search_input', selector: '#search-input, input.search-input', events: ['appear', 'exist', 'disappear'] },
576
576
  { id: 'home_search_button', selector: '.input-button, .input-button .search-icon', events: ['exist'] },
577
577
  { id: 'search_result_item', selector: '.note-item', events: ['appear', 'exist', 'change'] },
578
- { id: 'detail_modal', selector: '.note-detail-mask, .note-detail-page, .note-detail-dialog, .note-detail-mask .detail-container, .note-detail-mask .media-container, .note-detail-mask .note-scroller, .note-detail-mask .note-content, .note-detail-mask .interaction-container, .note-detail-mask .comments-container', events: ['appear', 'exist', 'disappear'] },
578
+ {
579
+ id: 'detail_modal',
580
+ selector: 'body',
581
+ visible: false,
582
+ pageUrlIncludes: ['/explore/'],
583
+ pageUrlExcludes: ['/search_result'],
584
+ events: ['appear', 'exist', 'disappear'],
585
+ },
579
586
  { id: 'detail_comment_item', selector: '.comment-item, [class*="comment-item"]', events: ['appear', 'exist', 'change'] },
580
587
  { id: 'detail_show_more', selector: '.note-detail-mask .show-more, .note-detail-mask .reply-expand, .note-detail-mask [class*="expand"], .note-detail-page .show-more, .note-detail-page .reply-expand, .note-detail-page [class*="expand"]', events: ['appear', 'exist'] },
581
588
  { id: 'detail_discover_button', selector: 'a[href*="/explore?channel_id=homefeed_recommend"]', events: ['appear', 'exist'] },
@@ -884,7 +891,7 @@ export function buildXhsUnifiedAutoscript(rawOptions = {}) {
884
891
  },
885
892
  {
886
893
  id: 'abort_on_login_guard',
887
- action: 'raise_error',
894
+ action: 'xhs_assert_logged_in',
888
895
  params: { code: 'LOGIN_GUARD_DETECTED' },
889
896
  trigger: 'login_guard.appear',
890
897
  once: false,
@@ -40,6 +40,105 @@ export const XHS_CHECKPOINTS = {
40
40
  ],
41
41
  };
42
42
 
43
+ function extractEvaluateResult(payload) {
44
+ if (!payload || typeof payload !== 'object') return {};
45
+ if (payload.result && typeof payload.result === 'object') return payload.result;
46
+ if (payload.data && typeof payload.data === 'object') return payload.data;
47
+ return payload;
48
+ }
49
+
50
+ async function detectXhsLoginSignal(profileId) {
51
+ const script = `(() => {
52
+ const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim();
53
+ const selectors = ${JSON.stringify(XHS_CHECKPOINTS.login_guard)};
54
+ const isVisible = (node) => {
55
+ if (!(node instanceof HTMLElement)) return false;
56
+ const style = window.getComputedStyle(node);
57
+ if (!style) return false;
58
+ if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity || '1') === 0) return false;
59
+ const rect = node.getBoundingClientRect();
60
+ return rect.width > 0 && rect.height > 0;
61
+ };
62
+ const nodes = selectors.flatMap((selector) => Array.from(document.querySelectorAll(selector)));
63
+ const visibleNodes = nodes.filter((node) => isVisible(node));
64
+ const guardText = visibleNodes
65
+ .slice(0, 10)
66
+ .map((node) => normalize(node.textContent || ''))
67
+ .filter(Boolean)
68
+ .join(' ');
69
+ const hasLoginText = /登录|扫码|验证码|手机号|请先登录|注册|sign\\s*in/i.test(guardText);
70
+ const loginUrl = /\\/login|signin|passport|account\\/login/i.test(String(location.href || ''));
71
+
72
+ let accountId = '';
73
+ try {
74
+ const initialState = (typeof window !== 'undefined' && window.__INITIAL_STATE__) || null;
75
+ const rawUserInfo = initialState && initialState.user && initialState.user.userInfo
76
+ ? (
77
+ (initialState.user.userInfo._rawValue && typeof initialState.user.userInfo._rawValue === 'object' && initialState.user.userInfo._rawValue)
78
+ || (initialState.user.userInfo._value && typeof initialState.user.userInfo._value === 'object' && initialState.user.userInfo._value)
79
+ || (typeof initialState.user.userInfo === 'object' ? initialState.user.userInfo : null)
80
+ )
81
+ : null;
82
+ accountId = normalize(rawUserInfo?.user_id || rawUserInfo?.userId || '');
83
+ } catch {}
84
+
85
+ if (!accountId) {
86
+ const selfAnchor = Array.from(document.querySelectorAll('a[href*="/user/profile/"]'))
87
+ .find((node) => {
88
+ const text = normalize(node.textContent || '');
89
+ const title = normalize(node.getAttribute('title') || '');
90
+ const aria = normalize(node.getAttribute('aria-label') || '');
91
+ return ['我', '我的', '个人主页', '我的主页'].includes(text)
92
+ || ['我', '我的', '个人主页', '我的主页'].includes(title)
93
+ || ['我', '我的', '个人主页', '我的主页'].includes(aria);
94
+ });
95
+ if (selfAnchor) {
96
+ const href = normalize(selfAnchor.getAttribute('href') || '');
97
+ const matched = href.match(/\\/user\\/profile\\/([^/?#]+)/);
98
+ if (matched && matched[1]) accountId = normalize(matched[1]);
99
+ }
100
+ }
101
+
102
+ const hasAccountSignal = Boolean(accountId);
103
+ const hasLoginGuard = (visibleNodes.length > 0 && hasLoginText) || loginUrl;
104
+ return {
105
+ hasLoginGuard,
106
+ hasLoginText,
107
+ hasAccountSignal,
108
+ accountId: accountId || null,
109
+ visibleGuardCount: visibleNodes.length,
110
+ guardTextPreview: guardText.slice(0, 240),
111
+ loginUrl,
112
+ };
113
+ })()`;
114
+
115
+ try {
116
+ const ret = await callAPI('evaluate', { profileId, script });
117
+ const data = extractEvaluateResult(ret);
118
+ return {
119
+ hasLoginGuard: data?.hasLoginGuard === true,
120
+ hasLoginText: data?.hasLoginText === true,
121
+ hasAccountSignal: data?.hasAccountSignal === true,
122
+ accountId: String(data?.accountId || '').trim() || null,
123
+ visibleGuardCount: Math.max(0, Number(data?.visibleGuardCount || 0) || 0),
124
+ guardTextPreview: String(data?.guardTextPreview || '').trim(),
125
+ loginUrl: data?.loginUrl === true,
126
+ ok: true,
127
+ };
128
+ } catch {
129
+ return {
130
+ hasLoginGuard: false,
131
+ hasLoginText: false,
132
+ hasAccountSignal: false,
133
+ accountId: null,
134
+ visibleGuardCount: 0,
135
+ guardTextPreview: '',
136
+ loginUrl: false,
137
+ ok: false,
138
+ };
139
+ }
140
+ }
141
+
43
142
  export async function detectCheckpoint({ profileId, platform = 'xiaohongshu' }) {
44
143
  if (platform !== 'xiaohongshu') {
45
144
  return asErrorPayload('UNSUPPORTED_PLATFORM', `Unsupported platform: ${platform}`);
@@ -72,10 +171,16 @@ export async function detectCheckpoint({ profileId, platform = 'xiaohongshu' })
72
171
  addCount('login_guard', XHS_CHECKPOINTS.login_guard);
73
172
  addCount('risk_control', XHS_CHECKPOINTS.risk_control);
74
173
 
174
+ const loginSignal = await detectXhsLoginSignal(resolvedProfile);
175
+ const loginGuardBySelector = signals.some((item) => item.startsWith('login_guard:'));
176
+ const loginGuardConfirmed = loginSignal.ok
177
+ ? loginSignal.hasLoginGuard
178
+ : loginGuardBySelector;
179
+
75
180
  let checkpoint = 'unknown';
76
181
  if (!url || !url.includes('xiaohongshu.com')) checkpoint = 'offsite';
77
182
  else if (isCheckpointRiskUrl(url)) checkpoint = 'risk_control';
78
- else if (signals.some((item) => item.startsWith('login_guard:'))) checkpoint = 'login_guard';
183
+ else if (loginGuardConfirmed) checkpoint = 'login_guard';
79
184
  else if (signals.some((item) => item.startsWith('comments_ready:'))) checkpoint = 'comments_ready';
80
185
  else if (signals.some((item) => item.startsWith('detail_ready:'))) checkpoint = 'detail_ready';
81
186
  else if (signals.some((item) => item.startsWith('search_ready:'))) checkpoint = 'search_ready';
@@ -92,6 +197,7 @@ export async function detectCheckpoint({ profileId, platform = 'xiaohongshu' })
92
197
  url,
93
198
  signals,
94
199
  selectorHits: counter,
200
+ loginSignal,
95
201
  },
96
202
  };
97
203
  } catch (err) {
@@ -18,8 +18,22 @@ export async function watchSubscriptions({
18
18
  const id = String(item.id || `sub_${index + 1}`);
19
19
  const selector = String(item.selector || '').trim();
20
20
  if (!selector) return null;
21
+ const visible = item.visible !== false;
22
+ const pageUrlIncludes = normalizeArray(item.pageUrlIncludes || item.urlIncludes)
23
+ .map((value) => String(value || '').trim())
24
+ .filter(Boolean);
25
+ const pageUrlExcludes = normalizeArray(item.pageUrlExcludes || item.urlExcludes)
26
+ .map((value) => String(value || '').trim())
27
+ .filter(Boolean);
21
28
  const events = normalizeArray(item.events).map((name) => String(name).trim()).filter(Boolean);
22
- return { id, selector, events: events.length > 0 ? new Set(events) : null };
29
+ return {
30
+ id,
31
+ selector,
32
+ visible,
33
+ pageUrlIncludes,
34
+ pageUrlExcludes,
35
+ events: events.length > 0 ? new Set(events) : null,
36
+ };
23
37
  })
24
38
  .filter(Boolean);
25
39
 
@@ -40,9 +54,17 @@ export async function watchSubscriptions({
40
54
  try {
41
55
  const snapshot = await getDomSnapshotByProfile(resolvedProfile);
42
56
  const ts = new Date().toISOString();
57
+ const currentUrl = String(snapshot?.__url || '');
43
58
  for (const item of items) {
44
59
  const prev = state.get(item.id) || { exists: false, stateSig: '', appearCount: 0 };
45
- const elements = notifier.findElements(snapshot, { css: item.selector });
60
+ const includeOk = item.pageUrlIncludes.length === 0
61
+ || item.pageUrlIncludes.some((token) => currentUrl.includes(token));
62
+ const excludeHit = item.pageUrlExcludes.length > 0
63
+ && item.pageUrlExcludes.some((token) => currentUrl.includes(token));
64
+ const pageUrlMatched = includeOk && !excludeHit;
65
+ const elements = pageUrlMatched
66
+ ? notifier.findElements(snapshot, { css: item.selector, visible: item.visible })
67
+ : [];
46
68
  const exists = elements.length > 0;
47
69
  const stateSig = elements.map((node) => node.path).sort().join(',');
48
70
  const changed = stateSig !== prev.stateSig;
@@ -215,6 +215,7 @@ function buildDomSnapshotScript(maxDepth, maxChildren) {
215
215
  const root = collect(document.body || document.documentElement, 0, 'root');
216
216
  return {
217
217
  dom_tree: root,
218
+ current_url: String(window.location.href || ''),
218
219
  viewport: {
219
220
  width: viewportWidth,
220
221
  height: viewportHeight,
@@ -238,6 +239,9 @@ export async function getDomSnapshotByProfile(profileId, options = {}) {
238
239
  height: Number(payload.viewport.height) || 0,
239
240
  };
240
241
  }
242
+ if (tree && payload.current_url) {
243
+ tree.__url = String(payload.current_url);
244
+ }
241
245
  return tree;
242
246
  }
243
247