@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.
Files changed (65) hide show
  1. package/README.md +122 -53
  2. package/apps/desktop-console/dist/main/index.mjs +227 -12
  3. package/apps/desktop-console/dist/renderer/index.js +237 -8
  4. package/apps/desktop-console/entry/ui-cli.mjs +282 -16
  5. package/apps/desktop-console/entry/ui-console.mjs +46 -15
  6. package/apps/webauto/entry/account.mjs +126 -27
  7. package/apps/webauto/entry/lib/account-detect.mjs +399 -9
  8. package/apps/webauto/entry/lib/account-store.mjs +201 -109
  9. package/apps/webauto/entry/lib/iflow-reply.mjs +194 -0
  10. package/apps/webauto/entry/lib/profile-policy.mjs +48 -0
  11. package/apps/webauto/entry/lib/profilepool.mjs +12 -0
  12. package/apps/webauto/entry/lib/schedule-store.mjs +29 -2
  13. package/apps/webauto/entry/lib/session-init.mjs +227 -0
  14. package/apps/webauto/entry/lib/upgrade-check.mjs +269 -0
  15. package/apps/webauto/entry/lib/xhs-unified-blocks.mjs +160 -0
  16. package/apps/webauto/entry/lib/xhs-unified-output-blocks.mjs +83 -0
  17. package/apps/webauto/entry/lib/xhs-unified-plan-blocks.mjs +55 -0
  18. package/apps/webauto/entry/lib/xhs-unified-profile-blocks.mjs +542 -0
  19. package/apps/webauto/entry/lib/xhs-unified-runtime-blocks.mjs +436 -0
  20. package/apps/webauto/entry/profilepool.mjs +56 -9
  21. package/apps/webauto/entry/smart-reply-cli.mjs +267 -0
  22. package/apps/webauto/entry/weibo-unified.mjs +84 -11
  23. package/apps/webauto/entry/xhs-orchestrate.mjs +43 -1
  24. package/apps/webauto/entry/xhs-unified.mjs +92 -997
  25. package/bin/webauto.mjs +22 -4
  26. package/dist/modules/camo-backend/src/index.js +33 -0
  27. package/dist/modules/camo-backend/src/internal/BrowserSession.js +232 -49
  28. package/dist/modules/camo-backend/src/internal/engine-manager.js +14 -13
  29. package/dist/modules/camo-backend/src/internal/ws-server.js +16 -19
  30. package/dist/modules/camo-runtime/src/utils/browser-service.mjs +38 -6
  31. package/dist/modules/workflow/blocks/EnsureSession.js +0 -8
  32. package/dist/modules/workflow/blocks/WeiboCollectFromLinksBlock.js +78 -6
  33. package/dist/modules/workflow/blocks/WeiboCollectSearchLinksBlock.js +266 -192
  34. package/dist/modules/workflow/definitions/weibo-search-workflow-v1.js +2 -0
  35. package/dist/modules/workflow/src/runner.js +2 -0
  36. package/dist/modules/xiaohongshu/app/src/blocks/ReplyInteractBlock.js +150 -37
  37. package/dist/modules/xiaohongshu/app/src/blocks/SmartReplyBlock.js +491 -0
  38. package/modules/camo-backend/src/index.ts +31 -0
  39. package/modules/camo-backend/src/internal/BrowserSession.ts +224 -53
  40. package/modules/camo-backend/src/internal/engine-manager.ts +14 -15
  41. package/modules/camo-backend/src/internal/ws-server.ts +17 -17
  42. package/modules/camo-runtime/src/autoscript/action-providers/xhs/common.mjs +12 -2
  43. package/modules/camo-runtime/src/autoscript/action-providers/xhs/persistence.mjs +57 -0
  44. package/modules/camo-runtime/src/autoscript/action-providers/xhs.mjs +2475 -243
  45. package/modules/camo-runtime/src/autoscript/runtime.mjs +35 -30
  46. package/modules/camo-runtime/src/autoscript/xhs-unified-template.mjs +80 -443
  47. package/modules/camo-runtime/src/container/runtime-core/checkpoint.mjs +39 -6
  48. package/modules/camo-runtime/src/container/runtime-core/operations/index.mjs +206 -39
  49. package/modules/camo-runtime/src/container/runtime-core/operations/tab-pool.mjs +0 -79
  50. package/modules/camo-runtime/src/container/runtime-core/operations/viewport.mjs +46 -0
  51. package/modules/camo-runtime/src/utils/browser-service.mjs +41 -6
  52. package/modules/camo-runtime/src/utils/js-policy.mjs +28 -0
  53. package/modules/workflow/blocks/EnsureSession.ts +0 -4
  54. package/modules/workflow/blocks/WeiboCollectFromLinksBlock.ts +81 -6
  55. package/modules/workflow/blocks/WeiboCollectSearchLinksBlock.ts +316 -0
  56. package/modules/workflow/definitions/weibo-search-workflow-v1.ts +2 -0
  57. package/modules/workflow/src/runner.ts +2 -0
  58. package/modules/xiaohongshu/app/src/blocks/ReplyInteractBlock.ts +198 -53
  59. package/modules/xiaohongshu/app/src/blocks/SmartReplyBlock.ts +706 -0
  60. package/package.json +2 -2
  61. package/modules/camo-runtime/src/autoscript/action-providers/xhs/comments.mjs +0 -498
  62. package/modules/camo-runtime/src/autoscript/action-providers/xhs/detail.mjs +0 -181
  63. package/modules/camo-runtime/src/autoscript/action-providers/xhs/interaction.mjs +0 -691
  64. package/modules/camo-runtime/src/autoscript/action-providers/xhs/search.mjs +0 -388
  65. package/modules/camo-runtime/src/container/runtime-core/operations/selector-scripts.mjs +0 -135
@@ -0,0 +1,83 @@
1
+ import fs from 'node:fs';
2
+ import fsp from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+
6
+ import { sanitizeForPath } from './xhs-unified-blocks.mjs';
7
+
8
+ function sanitizeKeywordDirParts({ env, keyword }) {
9
+ return {
10
+ safeEnv: sanitizeForPath(env, 'prod'),
11
+ safeKeyword: sanitizeForPath(keyword, 'unknown'),
12
+ };
13
+ }
14
+
15
+ export function resolveDownloadRoot(customRoot = '') {
16
+ const fromArg = String(customRoot || '').trim();
17
+ if (fromArg) return path.resolve(fromArg);
18
+ const fromEnv = String(process.env.WEBAUTO_DOWNLOAD_ROOT || process.env.WEBAUTO_DOWNLOAD_DIR || '').trim();
19
+ if (fromEnv) return path.resolve(fromEnv);
20
+ if (process.platform === 'win32') {
21
+ try {
22
+ if (fs.existsSync('D:\\')) return 'D:\\webauto';
23
+ } catch {
24
+ // ignore
25
+ }
26
+ const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
27
+ return path.join(home, '.webauto');
28
+ }
29
+ const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
30
+ return path.join(home, '.webauto', 'download');
31
+ }
32
+
33
+ const NON_NOTE_DIR_NAMES = new Set([
34
+ 'merged',
35
+ 'profiles',
36
+ 'like-evidence',
37
+ 'virtual-like',
38
+ 'smart-reply',
39
+ 'comment-match',
40
+ 'discover-fallback',
41
+ ]);
42
+
43
+ async function collectKeywordDirs(baseOutputRoot, env, keyword) {
44
+ const { safeEnv, safeKeyword } = sanitizeKeywordDirParts({ env, keyword });
45
+ const dirs = [
46
+ path.join(baseOutputRoot, 'xiaohongshu', safeEnv, safeKeyword),
47
+ ];
48
+ const shardsRoot = path.join(baseOutputRoot, 'shards');
49
+ try {
50
+ const entries = await fsp.readdir(shardsRoot, { withFileTypes: true });
51
+ for (const entry of entries) {
52
+ if (!entry.isDirectory()) continue;
53
+ dirs.push(path.join(shardsRoot, entry.name, 'xiaohongshu', safeEnv, safeKeyword));
54
+ }
55
+ } catch {
56
+ // ignore
57
+ }
58
+ return Array.from(new Set(dirs));
59
+ }
60
+
61
+ export async function collectCompletedNoteIds(baseOutputRoot, env, keyword) {
62
+ const keywordDirs = await collectKeywordDirs(baseOutputRoot, env, keyword);
63
+ const completed = new Set();
64
+ for (const keywordDir of keywordDirs) {
65
+ let entries = [];
66
+ try {
67
+ entries = await fsp.readdir(keywordDir, { withFileTypes: true });
68
+ } catch {
69
+ continue;
70
+ }
71
+ for (const entry of entries) {
72
+ if (!entry.isDirectory()) continue;
73
+ const noteId = String(entry.name || '').trim();
74
+ if (!noteId || noteId.startsWith('.') || noteId.startsWith('_')) continue;
75
+ if (NON_NOTE_DIR_NAMES.has(noteId)) continue;
76
+ completed.add(noteId);
77
+ }
78
+ }
79
+ return {
80
+ count: completed.size,
81
+ noteIds: Array.from(completed),
82
+ };
83
+ }
@@ -0,0 +1,55 @@
1
+ export function buildEvenShardPlan({ profiles, totalNotes, defaultMaxNotes }) {
2
+ const uniqueProfiles = Array.from(new Set(profiles.map((item) => String(item || '').trim()).filter(Boolean)));
3
+ if (uniqueProfiles.length === 0) return [];
4
+
5
+ if (!Number.isFinite(totalNotes) || totalNotes <= 0) {
6
+ return uniqueProfiles.map((profileId) => ({ profileId, assignedNotes: defaultMaxNotes }));
7
+ }
8
+
9
+ const base = Math.floor(totalNotes / uniqueProfiles.length);
10
+ const remainder = totalNotes % uniqueProfiles.length;
11
+ const plan = uniqueProfiles.map((profileId, index) => ({
12
+ profileId,
13
+ assignedNotes: base + (index < remainder ? 1 : 0),
14
+ }));
15
+ return plan.filter((item) => item.assignedNotes > 0);
16
+ }
17
+
18
+ export function buildDynamicWavePlan({ profiles, remainingNotes }) {
19
+ const uniqueProfiles = Array.from(new Set(profiles.map((item) => String(item || '').trim()).filter(Boolean)));
20
+ if (uniqueProfiles.length === 0) return [];
21
+ const remaining = Math.max(0, Number(remainingNotes) || 0);
22
+ if (remaining <= 0) return [];
23
+
24
+ if (remaining < uniqueProfiles.length) {
25
+ return uniqueProfiles.slice(0, remaining).map((profileId) => ({
26
+ profileId,
27
+ assignedNotes: 1,
28
+ }));
29
+ }
30
+
31
+ const waveTotal = remaining - (remaining % uniqueProfiles.length);
32
+ return buildEvenShardPlan({
33
+ profiles: uniqueProfiles,
34
+ totalNotes: waveTotal > 0 ? waveTotal : remaining,
35
+ defaultMaxNotes: 1,
36
+ });
37
+ }
38
+
39
+ export async function runWithConcurrency(items, concurrency, worker) {
40
+ const limit = Math.max(1, Math.min(items.length || 1, concurrency || 1));
41
+ const results = new Array(items.length);
42
+ let cursor = 0;
43
+
44
+ async function consume() {
45
+ for (;;) {
46
+ const index = cursor;
47
+ cursor += 1;
48
+ if (index >= items.length) return;
49
+ results[index] = await worker(items[index], index);
50
+ }
51
+ }
52
+
53
+ await Promise.all(Array.from({ length: limit }, () => consume()));
54
+ return results;
55
+ }
@@ -0,0 +1,542 @@
1
+ import fs from 'node:fs';
2
+ import fsp from 'node:fs/promises';
3
+ import path from 'node:path';
4
+
5
+ import { buildXhsUnifiedAutoscript } from '../../../../modules/camo-runtime/src/autoscript/xhs-unified-template.mjs';
6
+ import { normalizeAutoscript, validateAutoscript } from '../../../../modules/camo-runtime/src/autoscript/schema.mjs';
7
+ import { AutoscriptRunner } from '../../../../modules/camo-runtime/src/autoscript/runtime.mjs';
8
+ import { markProfileInvalid } from './account-store.mjs';
9
+ import { runCamo } from './camo-cli.mjs';
10
+ import { ensureSessionInitialized } from './session-init.mjs';
11
+ import { publishBusEvent } from './bus-publish.mjs';
12
+ import { resolvePlatformFlowGate } from './flow-gate.mjs';
13
+ import {
14
+ nowIso,
15
+ parseBool,
16
+ parseIntFlag,
17
+ parseNonNegativeInt,
18
+ pickRandomInt,
19
+ sanitizeForPath,
20
+ } from './xhs-unified-blocks.mjs';
21
+ import {
22
+ createTaskReporter,
23
+ createProfileStats,
24
+ resolveUnifiedPhaseLabel,
25
+ resolveUnifiedActionLabel,
26
+ updateProfileStatsFromEvent,
27
+ } from './xhs-unified-runtime-blocks.mjs';
28
+
29
+ const XHS_HOME_URL = 'https://www.xiaohongshu.com';
30
+
31
+ async function ensureDir(dirPath) {
32
+ await fsp.mkdir(dirPath, { recursive: true });
33
+ }
34
+
35
+ async function writeJson(filePath, payload) {
36
+ await ensureDir(path.dirname(filePath));
37
+ await fsp.writeFile(filePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
38
+ }
39
+
40
+ function isObject(value) {
41
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
42
+ }
43
+
44
+ function buildStopScreenshotPath(profileId, reason, outputDir) {
45
+ const safeProfile = sanitizeForPath(profileId, 'profile');
46
+ const safeReason = sanitizeForPath(reason || 'stop', 'stop');
47
+ const file = `stop-${safeProfile}-${safeReason}.png`;
48
+ return path.join(outputDir, file);
49
+ }
50
+
51
+ async function captureStopScreenshot({ profileId, reason, outputDir }) {
52
+ const outDir = String(outputDir || '').trim();
53
+ if (!outDir) return null;
54
+ try {
55
+ await fsp.mkdir(outDir, { recursive: true });
56
+ } catch {}
57
+ const outputPath = buildStopScreenshotPath(profileId, reason, outDir);
58
+ const tryCapture = () => runCamo(['screenshot', profileId, '--output', outputPath], {
59
+ rootDir: process.cwd(),
60
+ timeoutMs: 60000,
61
+ });
62
+ let ret = tryCapture();
63
+ if (!ret?.ok) {
64
+ await ensureProfileSession(profileId);
65
+ ret = tryCapture();
66
+ }
67
+ if (ret?.ok) return outputPath;
68
+ return null;
69
+ }
70
+
71
+ export function resolveXhsStage(argv = {}) {
72
+ const raw = String(argv.stage || argv['xhs-stage'] || 'full').trim().toLowerCase();
73
+ const stage = raw || 'full';
74
+ const allowed = new Set(['full', 'links', 'content', 'like', 'reply']);
75
+ if (!allowed.has(stage)) {
76
+ throw new Error(`invalid --stage: ${stage}. use full|links|content|like|reply`);
77
+ }
78
+ return stage;
79
+ }
80
+
81
+ export async function ensureProfileSession(profileId, options = {}) {
82
+ const id = String(profileId || '').trim();
83
+ if (!id) return false;
84
+ const ret = await ensureSessionInitialized(id, {
85
+ url: XHS_HOME_URL,
86
+ rootDir: process.cwd(),
87
+ timeoutMs: 60000,
88
+ headless: options?.headless === true,
89
+ });
90
+ return Boolean(ret?.ok);
91
+ }
92
+
93
+ async function buildTemplateOptions(argv, profileId, overrides = {}) {
94
+ const keyword = String(argv.keyword || argv.k || '').trim();
95
+ const env = String(argv.env || 'prod').trim() || 'prod';
96
+ const inputMode = String(argv['input-mode'] || 'protocol').trim() || 'protocol';
97
+ const headless = parseBool(argv.headless, false);
98
+ const ocrCommand = String(argv['ocr-command'] || '').trim();
99
+ const maxNotes = parseIntFlag(argv['max-notes'] ?? argv.target, 30, 1);
100
+ const maxComments = parseNonNegativeInt(argv['max-comments'], 0);
101
+ let flowGate = null;
102
+ try {
103
+ flowGate = await resolvePlatformFlowGate('xiaohongshu');
104
+ } catch {
105
+ flowGate = null;
106
+ }
107
+
108
+ const throttleMin = parseIntFlag(flowGate?.throttle?.minMs, 900, 100);
109
+ const throttleMax = parseIntFlag(flowGate?.throttle?.maxMs, 1800, throttleMin);
110
+ const noteIntervalMin = parseIntFlag(flowGate?.noteInterval?.minMs, 2200, 200);
111
+ const noteIntervalMax = parseIntFlag(flowGate?.noteInterval?.maxMs, 4200, noteIntervalMin);
112
+ const tabCountDefault = parseIntFlag(flowGate?.tabPool?.tabCount, 1, 1);
113
+ const tabOpenDelayMin = parseIntFlag(flowGate?.tabPool?.openDelayMinMs, 1400, 0);
114
+ const tabOpenDelayMax = parseIntFlag(flowGate?.tabPool?.openDelayMaxMs, 2800, tabOpenDelayMin);
115
+ const submitMethodDefault = String(flowGate?.submitSearch?.method || 'click').trim().toLowerCase() || 'click';
116
+ const submitActionDelayMinDefault = parseIntFlag(flowGate?.submitSearch?.actionDelayMinMs, 180, 20);
117
+ const submitActionDelayMaxDefault = parseIntFlag(flowGate?.submitSearch?.actionDelayMaxMs, 620, submitActionDelayMinDefault);
118
+ const submitSettleMinDefault = parseIntFlag(flowGate?.submitSearch?.settleMinMs, 1200, 60);
119
+ const submitSettleMaxDefault = parseIntFlag(flowGate?.submitSearch?.settleMaxMs, 2600, submitSettleMinDefault);
120
+ const openDetailPreClickMinDefault = parseIntFlag(flowGate?.openDetail?.preClickMinMs, 220, 60);
121
+ const openDetailPreClickMaxDefault = parseIntFlag(flowGate?.openDetail?.preClickMaxMs, 700, openDetailPreClickMinDefault);
122
+ const openDetailPollDelayMinDefault = parseIntFlag(flowGate?.openDetail?.pollDelayMinMs, 130, 80);
123
+ const openDetailPollDelayMaxDefault = parseIntFlag(flowGate?.openDetail?.pollDelayMaxMs, 320, openDetailPollDelayMinDefault);
124
+ const openDetailPostOpenMinDefault = parseIntFlag(flowGate?.openDetail?.postOpenMinMs, 420, 120);
125
+ const openDetailPostOpenMaxDefault = parseIntFlag(flowGate?.openDetail?.postOpenMaxMs, 1100, openDetailPostOpenMinDefault);
126
+ const commentsScrollStepMinDefault = parseIntFlag(flowGate?.commentsHarvest?.scrollStepMin, 280, 120);
127
+ const commentsScrollStepMaxDefault = parseIntFlag(flowGate?.commentsHarvest?.scrollStepMax, 420, commentsScrollStepMinDefault);
128
+ const commentsSettleMinDefault = parseIntFlag(flowGate?.commentsHarvest?.settleMinMs, 280, 80);
129
+ const commentsSettleMaxDefault = parseIntFlag(flowGate?.commentsHarvest?.settleMaxMs, 820, commentsSettleMinDefault);
130
+ const defaultOperationMinIntervalDefault = parseIntFlag(flowGate?.pacing?.defaultOperationMinIntervalMs, 1200, 0);
131
+ const defaultEventCooldownDefault = parseIntFlag(flowGate?.pacing?.defaultEventCooldownMs, 700, 0);
132
+ const defaultPacingJitterDefault = parseIntFlag(flowGate?.pacing?.defaultJitterMs, 900, 0);
133
+ const navigationMinIntervalDefault = parseIntFlag(flowGate?.pacing?.navigationMinIntervalMs, 2200, 0);
134
+
135
+ const throttle = parseIntFlag(argv.throttle, pickRandomInt(throttleMin, throttleMax), 100);
136
+ const tabCount = parseIntFlag(argv['tab-count'], tabCountDefault, 1);
137
+ const noteIntervalMs = parseIntFlag(argv['note-interval'], pickRandomInt(noteIntervalMin, noteIntervalMax), 200);
138
+ const tabOpenDelayMs = parseIntFlag(argv['tab-open-delay'], pickRandomInt(tabOpenDelayMin, tabOpenDelayMax), 0);
139
+ const submitMethod = String(argv['search-submit-method'] || submitMethodDefault).trim().toLowerCase() || 'click';
140
+ const submitActionDelayMinMs = parseIntFlag(argv['submit-action-delay-min'], submitActionDelayMinDefault, 20);
141
+ const submitActionDelayMaxMs = parseIntFlag(argv['submit-action-delay-max'], submitActionDelayMaxDefault, submitActionDelayMinMs);
142
+ const submitSettleMinMs = parseIntFlag(argv['submit-settle-min'], submitSettleMinDefault, 60);
143
+ const submitSettleMaxMs = parseIntFlag(argv['submit-settle-max'], submitSettleMaxDefault, submitSettleMinMs);
144
+ const openDetailPreClickMinMs = parseIntFlag(argv['open-detail-preclick-min'], openDetailPreClickMinDefault, 60);
145
+ const openDetailPreClickMaxMs = parseIntFlag(argv['open-detail-preclick-max'], openDetailPreClickMaxDefault, openDetailPreClickMinMs);
146
+ const openDetailPollDelayMinMs = parseIntFlag(argv['open-detail-poll-min'], openDetailPollDelayMinDefault, 80);
147
+ const openDetailPollDelayMaxMs = parseIntFlag(argv['open-detail-poll-max'], openDetailPollDelayMaxDefault, openDetailPollDelayMinMs);
148
+ const openDetailPostOpenMinMs = parseIntFlag(argv['open-detail-postopen-min'], openDetailPostOpenMinDefault, 120);
149
+ const openDetailPostOpenMaxMs = parseIntFlag(argv['open-detail-postopen-max'], openDetailPostOpenMaxDefault, openDetailPostOpenMinMs);
150
+ const commentsScrollStepMin = parseIntFlag(argv['comments-scroll-step-min'], commentsScrollStepMinDefault, 120);
151
+ const commentsScrollStepMax = parseIntFlag(argv['comments-scroll-step-max'], commentsScrollStepMaxDefault, commentsScrollStepMin);
152
+ const commentsSettleMinMs = parseIntFlag(argv['comments-settle-min'], commentsSettleMinDefault, 80);
153
+ const commentsSettleMaxMs = parseIntFlag(argv['comments-settle-max'], commentsSettleMaxDefault, commentsSettleMinMs);
154
+ const defaultOperationMinIntervalMs = parseIntFlag(argv['operation-min-interval'], defaultOperationMinIntervalDefault, 0);
155
+ const defaultEventCooldownMs = parseIntFlag(argv['event-cooldown'], defaultEventCooldownDefault, 0);
156
+ const defaultPacingJitterMs = parseIntFlag(argv['pacing-jitter'], defaultPacingJitterDefault, 0);
157
+ const navigationMinIntervalMs = parseIntFlag(argv['navigation-min-interval'], navigationMinIntervalDefault, 0);
158
+ const maxLikesPerRound = parseNonNegativeInt(argv['max-likes'], 0);
159
+ const matchMode = String(argv['match-mode'] || 'any').trim() || 'any';
160
+ const matchMinHits = parseIntFlag(argv['match-min-hits'], 1, 1);
161
+ const matchKeywords = String(argv['match-keywords'] || keyword).trim();
162
+ const likeKeywords = String(argv['like-keywords'] || '').trim();
163
+ const replyText = String(argv['reply-text'] || '感谢分享,已关注').trim() || '感谢分享,已关注';
164
+ const outputRoot = String(argv['output-root'] || '').trim();
165
+ const uiTriggerId = String(argv['ui-trigger-id'] || process.env.WEBAUTO_UI_TRIGGER_ID || '').trim();
166
+ const resume = parseBool(argv.resume, false);
167
+ const incrementalMax = parseBool(argv['incremental-max'], true);
168
+ const sharedHarvestPath = String(overrides.sharedHarvestPath ?? argv['shared-harvest-path'] ?? '').trim();
169
+ const searchSerialKey = String(overrides.searchSerialKey ?? argv['search-serial-key'] ?? '').trim();
170
+ const seedCollectCount = parseNonNegativeInt(
171
+ overrides.seedCollectCount ?? argv['seed-collect-count'],
172
+ Math.max(1, maxNotes),
173
+ );
174
+ const seedCollectMaxRounds = parseNonNegativeInt(
175
+ overrides.seedCollectMaxRounds ?? argv['seed-collect-rounds'],
176
+ Math.max(6, Math.ceil(Math.max(1, maxNotes) / 2)),
177
+ );
178
+ const stage = resolveXhsStage(argv);
179
+
180
+ const dryRun = parseBool(argv['dry-run'], false);
181
+ const disableDryRun = parseBool(argv['no-dry-run'], false);
182
+ const effectiveDryRun = disableDryRun ? false : dryRun;
183
+ const stageLinksEnabled = true;
184
+ const stageContentEnabled = stage === 'full' || stage === 'content' || stage === 'like' || stage === 'reply';
185
+ const stageLikeEnabled = (stage === 'like' || stage === 'full')
186
+ && (parseBool(argv['do-likes'], stage === 'like') && !effectiveDryRun);
187
+ const stageReplyEnabled = (stage === 'reply' || stage === 'full')
188
+ && (parseBool(argv['do-reply'], stage === 'reply') && !effectiveDryRun);
189
+ const doComments = stageContentEnabled || stageLikeEnabled || stageReplyEnabled;
190
+
191
+ const base = {
192
+ profileId,
193
+ keyword,
194
+ env,
195
+ inputMode,
196
+ headless,
197
+ ocrCommand,
198
+ uiTriggerId,
199
+ outputRoot,
200
+ throttle,
201
+ tabCount,
202
+ tabOpenDelayMs,
203
+ noteIntervalMs,
204
+ submitMethod,
205
+ submitActionDelayMinMs,
206
+ submitActionDelayMaxMs,
207
+ submitSettleMinMs,
208
+ submitSettleMaxMs,
209
+ openDetailPreClickMinMs,
210
+ openDetailPreClickMaxMs,
211
+ openDetailPollDelayMinMs,
212
+ openDetailPollDelayMaxMs,
213
+ openDetailPostOpenMinMs,
214
+ openDetailPostOpenMaxMs,
215
+ commentsScrollStepMin,
216
+ commentsScrollStepMax,
217
+ commentsSettleMinMs,
218
+ commentsSettleMaxMs,
219
+ defaultOperationMinIntervalMs,
220
+ defaultEventCooldownMs,
221
+ defaultPacingJitterMs,
222
+ navigationMinIntervalMs,
223
+ maxNotes,
224
+ maxComments,
225
+ maxLikesPerRound,
226
+ resume,
227
+ incrementalMax,
228
+ matchMode,
229
+ matchMinHits,
230
+ matchKeywords,
231
+ likeKeywords,
232
+ replyText,
233
+ stage,
234
+ stageLinksEnabled,
235
+ stageContentEnabled,
236
+ stageLikeEnabled,
237
+ stageReplyEnabled,
238
+ doHomepage: stageContentEnabled && parseBool(argv['do-homepage'], true),
239
+ doImages: stageContentEnabled && parseBool(argv['do-images'], false),
240
+ doComments,
241
+ doLikes: stageLikeEnabled,
242
+ doReply: stageReplyEnabled,
243
+ doOcr: stageContentEnabled && parseBool(argv['do-ocr'], false),
244
+ persistComments: doComments && parseBool(argv['persist-comments'], !effectiveDryRun),
245
+ sharedHarvestPath,
246
+ searchSerialKey,
247
+ seedCollectCount,
248
+ seedCollectMaxRounds,
249
+ };
250
+ return { ...base, ...overrides };
251
+ }
252
+
253
+ export async function runProfile(spec, argv, baseOverrides = {}) {
254
+ const profileId = spec.profileId;
255
+ const busEnabled = parseBool(argv['bus-events'], false) || process.env.WEBAUTO_BUS_EVENTS === '1';
256
+ const busPublishable = new Set([
257
+ 'xhs.unified.start',
258
+ 'xhs.unified.stop',
259
+ 'xhs.unified.stop_screenshot',
260
+ 'xhs.unified.profile_failed',
261
+ 'autoscript:operation_done',
262
+ 'autoscript:operation_progress',
263
+ 'autoscript:operation_error',
264
+ 'autoscript:operation_terminal',
265
+ 'autoscript:operation_recovery_failed',
266
+ ]);
267
+ let currentRunId = null;
268
+ const overrides = {
269
+ ...baseOverrides,
270
+ maxNotes: spec.assignedNotes,
271
+ outputRoot: spec.outputRoot,
272
+ };
273
+ if (spec.sharedHarvestPath) overrides.sharedHarvestPath = spec.sharedHarvestPath;
274
+ if (spec.searchSerialKey) overrides.searchSerialKey = spec.searchSerialKey;
275
+ if (spec.seedCollectCount !== undefined && spec.seedCollectCount !== null) {
276
+ overrides.seedCollectCount = parseNonNegativeInt(spec.seedCollectCount, 0);
277
+ }
278
+ if (spec.seedCollectMaxRounds !== undefined && spec.seedCollectMaxRounds !== null) {
279
+ overrides.seedCollectMaxRounds = parseNonNegativeInt(spec.seedCollectMaxRounds, 0);
280
+ }
281
+ const options = await buildTemplateOptions(argv, profileId, overrides);
282
+ console.log(JSON.stringify({
283
+ event: 'xhs.unified.flow_gate',
284
+ profileId,
285
+ throttle: options.throttle,
286
+ noteIntervalMs: options.noteIntervalMs,
287
+ tabCount: options.tabCount,
288
+ tabOpenDelayMs: options.tabOpenDelayMs,
289
+ submitMethod: options.submitMethod,
290
+ submitActionDelayMinMs: options.submitActionDelayMinMs,
291
+ submitActionDelayMaxMs: options.submitActionDelayMaxMs,
292
+ submitSettleMinMs: options.submitSettleMinMs,
293
+ submitSettleMaxMs: options.submitSettleMaxMs,
294
+ commentsScrollStepMin: options.commentsScrollStepMin,
295
+ commentsScrollStepMax: options.commentsScrollStepMax,
296
+ commentsSettleMinMs: options.commentsSettleMinMs,
297
+ commentsSettleMaxMs: options.commentsSettleMaxMs,
298
+ }));
299
+ const script = buildXhsUnifiedAutoscript(options);
300
+ const normalized = normalizeAutoscript(script, `xhs-unified:${profileId}`);
301
+ const validation = validateAutoscript(normalized);
302
+ if (!validation.ok) throw new Error(`autoscript validation failed for ${profileId}: ${validation.errors.join('; ')}`);
303
+
304
+ await ensureDir(path.dirname(spec.logPath));
305
+ const stats = createProfileStats(spec);
306
+ const reporter = createTaskReporter({
307
+ profileId,
308
+ keyword: options.keyword,
309
+ uiTriggerId: options.uiTriggerId,
310
+ });
311
+ let activeRunId = '';
312
+ let runtimePhaseLabel = '登录校验';
313
+ let runtimeActionLabel = '启动 autoscript';
314
+ let lastSnapshotTs = 0;
315
+ const pushTaskSnapshot = (status = 'running') => {
316
+ if (!activeRunId) return;
317
+ const nowTs = Date.now();
318
+ if (status === 'running' && (nowTs - lastSnapshotTs) < 900) return;
319
+ lastSnapshotTs = nowTs;
320
+ void reporter.update(activeRunId, {
321
+ status,
322
+ phase: runtimePhaseLabel,
323
+ action: runtimeActionLabel,
324
+ progress: {
325
+ total: Math.max(0, Number(spec.assignedNotes) || 0),
326
+ processed: Math.max(0, Number(stats.openedNotes) || 0),
327
+ failed: Math.max(0, Number(stats.operationErrors) || 0),
328
+ },
329
+ stats: {
330
+ notesProcessed: Math.max(0, Number(stats.openedNotes) || 0),
331
+ commentsCollected: Math.max(0, Number(stats.commentsCollected) || 0),
332
+ likesPerformed: Math.max(0, Number(stats.likesNewCount) || 0),
333
+ repliesGenerated: 0,
334
+ imagesDownloaded: 0,
335
+ ocrProcessed: 0,
336
+ },
337
+ });
338
+ };
339
+
340
+ const logEvent = (payload) => {
341
+ const eventPayload = isObject(payload) ? payload : { event: 'autoscript:raw', payload };
342
+ const merged = {
343
+ ts: eventPayload.ts || nowIso(),
344
+ profileId,
345
+ ...eventPayload,
346
+ };
347
+ if (!merged.runId && currentRunId) merged.runId = currentRunId;
348
+ fs.appendFileSync(spec.logPath, `${JSON.stringify(merged)}\n`, 'utf8');
349
+ console.log(JSON.stringify(merged));
350
+ updateProfileStatsFromEvent(stats, merged);
351
+ if (busEnabled && busPublishable.has(String(merged.event || '').trim())) {
352
+ void publishBusEvent(merged);
353
+ }
354
+ const eventName = String(merged.event || '').trim();
355
+ const mergedRunId = String(merged.runId || '').trim();
356
+ if (mergedRunId) activeRunId = mergedRunId;
357
+ if (
358
+ eventName === 'autoscript:operation_start'
359
+ || eventName === 'autoscript:operation_progress'
360
+ || eventName === 'autoscript:operation_done'
361
+ || eventName === 'autoscript:operation_error'
362
+ || eventName === 'autoscript:operation_recovery_failed'
363
+ ) {
364
+ runtimePhaseLabel = resolveUnifiedPhaseLabel(merged.operationId, runtimePhaseLabel);
365
+ runtimeActionLabel = resolveUnifiedActionLabel(eventName, merged, runtimeActionLabel);
366
+ }
367
+ if (eventName === 'xhs.unified.start') {
368
+ runtimePhaseLabel = '登录校验';
369
+ runtimeActionLabel = '启动 autoscript';
370
+ }
371
+ if (eventName === 'xhs.unified.stop') {
372
+ const reason = String(merged.reason || '').trim();
373
+ runtimePhaseLabel = reason === 'script_failure' ? '失败' : '已结束';
374
+ runtimeActionLabel = reason || 'stop';
375
+ }
376
+ const shouldReportEvent = (
377
+ eventName === 'xhs.unified.start'
378
+ || eventName === 'xhs.unified.stop'
379
+ || eventName === 'autoscript:start'
380
+ || eventName === 'autoscript:stop'
381
+ || eventName === 'autoscript:impact'
382
+ || eventName === 'autoscript:operation_start'
383
+ || eventName === 'autoscript:operation_progress'
384
+ || eventName === 'autoscript:operation_done'
385
+ || eventName === 'autoscript:operation_error'
386
+ || eventName === 'autoscript:operation_recovery_failed'
387
+ );
388
+ if (activeRunId && shouldReportEvent) {
389
+ void reporter.pushEvent(activeRunId, eventName, merged);
390
+ }
391
+ if (
392
+ eventName === 'autoscript:operation_start'
393
+ || eventName === 'autoscript:operation_progress'
394
+ || eventName === 'xhs.unified.start'
395
+ || eventName === 'xhs.unified.stop'
396
+ || eventName === 'autoscript:stop'
397
+ || eventName === 'autoscript:start'
398
+ || eventName === 'autoscript:operation_terminal'
399
+ || eventName === 'autoscript:operation_done'
400
+ || eventName === 'autoscript:operation_error'
401
+ || eventName === 'autoscript:operation_recovery_failed'
402
+ || eventName === 'autoscript:impact'
403
+ ) {
404
+ pushTaskSnapshot(eventName === 'xhs.unified.stop' ? (runtimePhaseLabel === '失败' ? 'failed' : 'completed') : 'running');
405
+ }
406
+ if (
407
+ eventName === 'autoscript:operation_done'
408
+ || eventName === 'autoscript:operation_error'
409
+ || eventName === 'autoscript:operation_recovery_failed'
410
+ || eventName === 'autoscript:impact'
411
+ ) {
412
+ pushTaskSnapshot('running');
413
+ }
414
+ if (
415
+ merged.event === 'autoscript:operation_error'
416
+ && String(merged.operationId || '').trim() === 'abort_on_login_guard'
417
+ && String(merged.message || '').includes('LOGIN_GUARD_DETECTED')
418
+ ) {
419
+ try {
420
+ markProfileInvalid(profileId, 'login_guard_runtime');
421
+ } catch {}
422
+ }
423
+ };
424
+
425
+ const runner = new AutoscriptRunner(normalized, {
426
+ profileId,
427
+ log: logEvent,
428
+ });
429
+
430
+ const running = await runner.start();
431
+ currentRunId = running?.runId || currentRunId;
432
+ activeRunId = String(running?.runId || '').trim();
433
+ if (activeRunId) {
434
+ await reporter.ensureCreated(activeRunId, {
435
+ status: 'starting',
436
+ phase: runtimePhaseLabel,
437
+ action: runtimeActionLabel,
438
+ progress: {
439
+ total: Math.max(0, Number(spec.assignedNotes) || 0),
440
+ processed: 0,
441
+ failed: 0,
442
+ },
443
+ });
444
+ await reporter.update(activeRunId, {
445
+ status: 'running',
446
+ phase: runtimePhaseLabel,
447
+ action: runtimeActionLabel,
448
+ progress: {
449
+ total: Math.max(0, Number(spec.assignedNotes) || 0),
450
+ processed: 0,
451
+ failed: 0,
452
+ },
453
+ stats: {
454
+ notesProcessed: 0,
455
+ commentsCollected: 0,
456
+ likesPerformed: 0,
457
+ repliesGenerated: 0,
458
+ imagesDownloaded: 0,
459
+ ocrProcessed: 0,
460
+ },
461
+ });
462
+ }
463
+ logEvent({
464
+ event: 'xhs.unified.start',
465
+ runId: running?.runId || null,
466
+ keyword: options.keyword,
467
+ env: options.env,
468
+ maxNotes: options.maxNotes,
469
+ assignedNotes: spec.assignedNotes,
470
+ outputRoot: options.outputRoot,
471
+ parallelRunLabel: spec.runLabel,
472
+ });
473
+ const done = await running.done;
474
+
475
+ const stopPayload = {
476
+ event: 'xhs.unified.stop',
477
+ profileId,
478
+ runId: done?.runId || running.runId,
479
+ reason: done?.reason || null,
480
+ startedAt: done?.startedAt || null,
481
+ stoppedAt: done?.stoppedAt || null,
482
+ };
483
+ logEvent(stopPayload);
484
+
485
+ const stopScreenshotPath = await captureStopScreenshot({
486
+ profileId,
487
+ reason: stopPayload.reason || 'stop',
488
+ outputDir: path.dirname(spec.logPath),
489
+ });
490
+ if (stopScreenshotPath) {
491
+ logEvent({
492
+ event: 'xhs.unified.stop_screenshot',
493
+ profileId,
494
+ runId: stopPayload.runId,
495
+ reason: stopPayload.reason || null,
496
+ path: stopScreenshotPath,
497
+ });
498
+ }
499
+
500
+ stats.stopReason = stopPayload.reason;
501
+ const finalRunId = String(stopPayload.runId || activeRunId || '').trim();
502
+ if (finalRunId) {
503
+ activeRunId = finalRunId;
504
+ const failed = stopPayload.reason === 'script_failure';
505
+ await reporter.update(finalRunId, {
506
+ status: failed ? 'failed' : 'completed',
507
+ phase: failed ? '失败' : '已结束',
508
+ action: String(stopPayload.reason || (failed ? 'script_failure' : 'completed')),
509
+ progress: {
510
+ total: Math.max(0, Number(spec.assignedNotes) || 0),
511
+ processed: Math.max(0, Number(stats.openedNotes) || 0),
512
+ failed: Math.max(0, Number(stats.operationErrors) || 0),
513
+ },
514
+ stats: {
515
+ notesProcessed: Math.max(0, Number(stats.openedNotes) || 0),
516
+ commentsCollected: Math.max(0, Number(stats.commentsCollected) || 0),
517
+ likesPerformed: Math.max(0, Number(stats.likesNewCount) || 0),
518
+ repliesGenerated: 0,
519
+ imagesDownloaded: 0,
520
+ ocrProcessed: 0,
521
+ },
522
+ });
523
+ if (failed) {
524
+ await reporter.setError(finalRunId, `autoscript stopped: ${stopPayload.reason || 'script_failure'}`, 'SCRIPT_FAILURE', false);
525
+ }
526
+ }
527
+
528
+ const profileResult = {
529
+ ok: stopPayload.reason !== 'script_failure',
530
+ profileId,
531
+ runId: stopPayload.runId,
532
+ reason: stopPayload.reason,
533
+ assignedNotes: spec.assignedNotes,
534
+ outputRoot: options.outputRoot,
535
+ logPath: spec.logPath,
536
+ stopScreenshotPath: stopScreenshotPath || null,
537
+ stats,
538
+ };
539
+
540
+ await writeJson(spec.summaryPath, profileResult);
541
+ return profileResult;
542
+ }