@web-auto/camo 0.1.2 → 0.1.4

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 (51) hide show
  1. package/README.md +137 -0
  2. package/package.json +7 -3
  3. package/scripts/check-file-size.mjs +80 -0
  4. package/scripts/file-size-policy.json +8 -0
  5. package/src/autoscript/action-providers/index.mjs +9 -0
  6. package/src/autoscript/action-providers/xhs/comments.mjs +412 -0
  7. package/src/autoscript/action-providers/xhs/common.mjs +77 -0
  8. package/src/autoscript/action-providers/xhs/detail.mjs +181 -0
  9. package/src/autoscript/action-providers/xhs/interaction.mjs +466 -0
  10. package/src/autoscript/action-providers/xhs/like-rules.mjs +57 -0
  11. package/src/autoscript/action-providers/xhs/persistence.mjs +167 -0
  12. package/src/autoscript/action-providers/xhs/search.mjs +174 -0
  13. package/src/autoscript/action-providers/xhs.mjs +133 -0
  14. package/src/autoscript/impact-engine.mjs +78 -0
  15. package/src/autoscript/runtime.mjs +1015 -0
  16. package/src/autoscript/schema.mjs +370 -0
  17. package/src/autoscript/xhs-unified-template.mjs +931 -0
  18. package/src/cli.mjs +190 -78
  19. package/src/commands/autoscript.mjs +1100 -0
  20. package/src/commands/browser.mjs +20 -4
  21. package/src/commands/container.mjs +401 -0
  22. package/src/commands/events.mjs +152 -0
  23. package/src/commands/lifecycle.mjs +17 -3
  24. package/src/commands/window.mjs +32 -1
  25. package/src/container/change-notifier.mjs +311 -0
  26. package/src/container/element-filter.mjs +143 -0
  27. package/src/container/index.mjs +3 -0
  28. package/src/container/runtime-core/checkpoint.mjs +195 -0
  29. package/src/container/runtime-core/index.mjs +21 -0
  30. package/src/container/runtime-core/operations/index.mjs +351 -0
  31. package/src/container/runtime-core/operations/selector-scripts.mjs +68 -0
  32. package/src/container/runtime-core/operations/tab-pool.mjs +544 -0
  33. package/src/container/runtime-core/operations/viewport.mjs +143 -0
  34. package/src/container/runtime-core/subscription.mjs +87 -0
  35. package/src/container/runtime-core/utils.mjs +94 -0
  36. package/src/container/runtime-core/validation.mjs +127 -0
  37. package/src/container/runtime-core.mjs +1 -0
  38. package/src/container/subscription-registry.mjs +459 -0
  39. package/src/core/actions.mjs +573 -0
  40. package/src/core/browser.mjs +270 -0
  41. package/src/core/index.mjs +53 -0
  42. package/src/core/utils.mjs +87 -0
  43. package/src/events/daemon-entry.mjs +33 -0
  44. package/src/events/daemon.mjs +80 -0
  45. package/src/events/progress-log.mjs +109 -0
  46. package/src/events/ws-server.mjs +239 -0
  47. package/src/lib/client.mjs +200 -0
  48. package/src/lifecycle/session-registry.mjs +8 -4
  49. package/src/lifecycle/session-watchdog.mjs +220 -0
  50. package/src/utils/browser-service.mjs +232 -9
  51. package/src/utils/help.mjs +28 -0
@@ -0,0 +1,1100 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { getDefaultProfile } from '../utils/config.mjs';
4
+ import { explainAutoscript, loadAndValidateAutoscript } from '../autoscript/schema.mjs';
5
+ import { AutoscriptRunner } from '../autoscript/runtime.mjs';
6
+ import { buildXhsUnifiedAutoscript } from '../autoscript/xhs-unified-template.mjs';
7
+ import { safeAppendProgressEvent } from '../events/progress-log.mjs';
8
+
9
+ function readFlagValue(args, names) {
10
+ for (let i = 0; i < args.length; i += 1) {
11
+ if (!names.includes(args[i])) continue;
12
+ const value = args[i + 1];
13
+ if (!value || String(value).startsWith('-')) return null;
14
+ return value;
15
+ }
16
+ return null;
17
+ }
18
+
19
+ function collectPositionals(args, startIndex = 2, valueFlags = new Set(['--profile', '-p'])) {
20
+ const out = [];
21
+ for (let i = startIndex; i < args.length; i += 1) {
22
+ const arg = args[i];
23
+ if (!arg) continue;
24
+ if (valueFlags.has(arg)) {
25
+ i += 1;
26
+ continue;
27
+ }
28
+ if (String(arg).startsWith('-')) continue;
29
+ out.push(arg);
30
+ }
31
+ return out;
32
+ }
33
+
34
+ function readToggleFlag(args, name, fallback) {
35
+ const on = `--${name}`;
36
+ const off = `--no-${name}`;
37
+ if (args.includes(off)) return false;
38
+ if (args.includes(on)) return true;
39
+ return fallback;
40
+ }
41
+
42
+ function readIntegerFlag(args, names, fallback, min = 1) {
43
+ const raw = readFlagValue(args, names);
44
+ if (raw === null || raw === undefined || raw === '') return fallback;
45
+ const num = Number(raw);
46
+ if (!Number.isFinite(num)) return fallback;
47
+ return Math.max(min, Math.floor(num));
48
+ }
49
+
50
+ function appendJsonLine(filePath, payload) {
51
+ if (!filePath) return;
52
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
53
+ fs.appendFileSync(filePath, `${JSON.stringify(payload)}\n`, 'utf8');
54
+ }
55
+
56
+ function normalizeResultPayload(result) {
57
+ if (!result || typeof result !== 'object') return null;
58
+ if (result.result && typeof result.result === 'object' && !Array.isArray(result.result)) {
59
+ return result.result;
60
+ }
61
+ return result;
62
+ }
63
+
64
+ function bumpCounter(target, key) {
65
+ if (!target[key]) {
66
+ target[key] = 1;
67
+ return;
68
+ }
69
+ target[key] += 1;
70
+ }
71
+
72
+ function createRunSummaryTracker({ file, profileId, runId }) {
73
+ return {
74
+ file,
75
+ profileId,
76
+ runId,
77
+ runName: null,
78
+ startTs: null,
79
+ stopTs: null,
80
+ stopReason: null,
81
+ counts: {
82
+ operationStart: 0,
83
+ operationDone: 0,
84
+ operationError: 0,
85
+ operationSkipped: 0,
86
+ operationTerminal: 0,
87
+ watchError: 0,
88
+ },
89
+ target: {
90
+ visitedMax: 0,
91
+ notesClosed: 0,
92
+ harvestCount: 0,
93
+ likeOps: 0,
94
+ },
95
+ comments: {
96
+ total: 0,
97
+ expectedTotal: 0,
98
+ bottomReached: 0,
99
+ recoveriesTotal: 0,
100
+ coverageSum: 0,
101
+ coverageCount: 0,
102
+ exitReasonCounts: {},
103
+ commentsPathsCount: 0,
104
+ },
105
+ likes: {
106
+ scanned: 0,
107
+ hit: 0,
108
+ liked: 0,
109
+ dedupSkipped: 0,
110
+ alreadyLikedSkipped: 0,
111
+ verifyFailed: 0,
112
+ clickFailed: 0,
113
+ missingLikeControl: 0,
114
+ summaryCount: 0,
115
+ },
116
+ closeDetail: {
117
+ rollbackTotal: 0,
118
+ returnToSearchTotal: 0,
119
+ searchCountMax: 0,
120
+ pageExitReasonCounts: {},
121
+ },
122
+ terminal: {
123
+ operationId: null,
124
+ code: null,
125
+ ts: null,
126
+ },
127
+ };
128
+ }
129
+
130
+ function applyRunSummaryEvent(tracker, payload) {
131
+ if (!payload || typeof payload !== 'object') return;
132
+ const { event, ts } = payload;
133
+ if (event === 'autoscript:start') {
134
+ tracker.startTs = tracker.startTs || ts || null;
135
+ tracker.runId = tracker.runId || payload.runId || null;
136
+ tracker.profileId = tracker.profileId || payload.profileId || null;
137
+ tracker.runName = payload.name || tracker.runName;
138
+ return;
139
+ }
140
+ if (event === 'autoscript:stop') {
141
+ tracker.stopReason = payload.reason || tracker.stopReason;
142
+ return;
143
+ }
144
+ if (event === 'autoscript:watch_error') {
145
+ tracker.counts.watchError += 1;
146
+ return;
147
+ }
148
+ if (event === 'autoscript:operation_start') {
149
+ tracker.counts.operationStart += 1;
150
+ return;
151
+ }
152
+ if (event === 'autoscript:operation_error') {
153
+ tracker.counts.operationError += 1;
154
+ return;
155
+ }
156
+ if (event === 'autoscript:operation_skipped') {
157
+ tracker.counts.operationSkipped += 1;
158
+ return;
159
+ }
160
+ if (event === 'autoscript:operation_terminal') {
161
+ tracker.counts.operationTerminal += 1;
162
+ tracker.terminal.operationId = payload.operationId || tracker.terminal.operationId;
163
+ tracker.terminal.code = payload.code || tracker.terminal.code;
164
+ tracker.terminal.ts = payload.ts || tracker.terminal.ts;
165
+ return;
166
+ }
167
+ if (event !== 'autoscript:operation_done') return;
168
+
169
+ tracker.counts.operationDone += 1;
170
+ const result = normalizeResultPayload(payload.result);
171
+ const opId = payload.operationId;
172
+ if (!opId || !result || typeof result !== 'object') return;
173
+
174
+ if (opId === 'open_first_detail' || opId === 'open_next_detail') {
175
+ const visited = Number(result.visited);
176
+ if (Number.isFinite(visited)) {
177
+ tracker.target.visitedMax = Math.max(tracker.target.visitedMax, visited);
178
+ }
179
+ return;
180
+ }
181
+
182
+ if (opId === 'comments_harvest') {
183
+ tracker.target.harvestCount += 1;
184
+ tracker.comments.total += Number(result.collected || 0);
185
+ tracker.comments.expectedTotal += Number(result.expectedCommentsCount || 0);
186
+ tracker.comments.recoveriesTotal += Number(result.recoveries || 0);
187
+ if (result.reachedBottom === true) tracker.comments.bottomReached += 1;
188
+ if (result.commentsPath) tracker.comments.commentsPathsCount += 1;
189
+ const coverage = Number(result.commentCoverageRate);
190
+ if (Number.isFinite(coverage)) {
191
+ tracker.comments.coverageCount += 1;
192
+ tracker.comments.coverageSum += coverage;
193
+ }
194
+ bumpCounter(tracker.comments.exitReasonCounts, result.exitReason || 'unknown');
195
+ return;
196
+ }
197
+
198
+ if (opId === 'comment_like') {
199
+ tracker.target.likeOps += 1;
200
+ tracker.likes.scanned += Number(result.scannedCount || 0);
201
+ tracker.likes.hit += Number(result.hitCount || 0);
202
+ tracker.likes.liked += Number(result.likedCount || 0);
203
+ tracker.likes.dedupSkipped += Number(result.dedupSkipped || 0);
204
+ tracker.likes.alreadyLikedSkipped += Number(result.alreadyLikedSkipped || 0);
205
+ tracker.likes.verifyFailed += Number(result.verifyFailed || 0);
206
+ tracker.likes.clickFailed += Number(result.clickFailed || 0);
207
+ tracker.likes.missingLikeControl += Number(result.missingLikeControl || 0);
208
+ if (result.summaryPath) tracker.likes.summaryCount += 1;
209
+ return;
210
+ }
211
+
212
+ if (opId === 'close_detail') {
213
+ tracker.target.notesClosed += 1;
214
+ tracker.closeDetail.rollbackTotal += Number(result.rollbackCount || 0);
215
+ tracker.closeDetail.returnToSearchTotal += Number(result.returnToSearchCount || 0);
216
+ tracker.closeDetail.searchCountMax = Math.max(
217
+ tracker.closeDetail.searchCountMax,
218
+ Number(result.searchCount || 0),
219
+ );
220
+ bumpCounter(tracker.closeDetail.pageExitReasonCounts, result.pageExitReason || 'unknown');
221
+ }
222
+ }
223
+
224
+ function buildRunSummary(tracker, fallbackStopReason = null) {
225
+ const harvestCount = tracker.target.harvestCount;
226
+ const coverageCount = tracker.comments.coverageCount;
227
+ const durationSec = tracker.startTs && tracker.stopTs
228
+ ? (new Date(tracker.stopTs).getTime() - new Date(tracker.startTs).getTime()) / 1000
229
+ : null;
230
+ return {
231
+ runId: tracker.runId || null,
232
+ profileId: tracker.profileId || null,
233
+ file: tracker.file || null,
234
+ runName: tracker.runName || null,
235
+ startTs: tracker.startTs || null,
236
+ stopTs: tracker.stopTs || null,
237
+ durationSec,
238
+ stopReason: tracker.stopReason || fallbackStopReason || null,
239
+ counts: tracker.counts,
240
+ target: tracker.target,
241
+ comments: {
242
+ total: tracker.comments.total,
243
+ expectedTotal: tracker.comments.expectedTotal,
244
+ avgPerNote: harvestCount > 0 ? tracker.comments.total / harvestCount : null,
245
+ bottomReached: tracker.comments.bottomReached,
246
+ bottomRate: harvestCount > 0 ? tracker.comments.bottomReached / harvestCount : null,
247
+ recoveriesTotal: tracker.comments.recoveriesTotal,
248
+ exitReasonCounts: tracker.comments.exitReasonCounts,
249
+ coverageAvg: coverageCount > 0 ? tracker.comments.coverageSum / coverageCount : null,
250
+ commentsPathsCount: tracker.comments.commentsPathsCount,
251
+ },
252
+ likes: tracker.likes,
253
+ closeDetail: tracker.closeDetail,
254
+ terminal: tracker.terminal.code
255
+ ? tracker.terminal
256
+ : null,
257
+ };
258
+ }
259
+
260
+ function buildDefaultSummaryPath(jsonlPath) {
261
+ if (!jsonlPath) return null;
262
+ return jsonlPath.endsWith('.jsonl')
263
+ ? `${jsonlPath.slice(0, -'.jsonl'.length)}.summary.json`
264
+ : `${jsonlPath}.summary.json`;
265
+ }
266
+
267
+ function readJsonlEvents(filePath) {
268
+ const resolved = path.resolve(filePath);
269
+ if (!fs.existsSync(resolved)) {
270
+ throw new Error(`JSONL file not found: ${resolved}`);
271
+ }
272
+ const raw = fs.readFileSync(resolved, 'utf8');
273
+ const rows = raw
274
+ .split('\n')
275
+ .map((line) => line.trim())
276
+ .filter(Boolean)
277
+ .map((line, index) => {
278
+ try {
279
+ return JSON.parse(line);
280
+ } catch (err) {
281
+ throw new Error(`Invalid JSONL at line ${index + 1}: ${err?.message || String(err)}`);
282
+ }
283
+ });
284
+ return { resolvedPath: resolved, rows };
285
+ }
286
+
287
+ function cloneObject(value, fallback = {}) {
288
+ if (!value || typeof value !== 'object') return { ...fallback };
289
+ return { ...fallback, ...value };
290
+ }
291
+
292
+ function reduceSubscriptionState(subscriptionState, event) {
293
+ if (!event?.subscriptionId) return;
294
+ const prev = subscriptionState[event.subscriptionId] || {
295
+ exists: false,
296
+ appearCount: 0,
297
+ version: 0,
298
+ lastEventAt: null,
299
+ };
300
+ const next = { ...prev, lastEventAt: event.ts || null };
301
+ if (event.type === 'appear') {
302
+ next.exists = true;
303
+ next.appearCount = Number(prev.appearCount || 0) + 1;
304
+ next.version = Number(prev.version || 0) + 1;
305
+ } else if (event.type === 'exist') {
306
+ next.exists = true;
307
+ } else if (event.type === 'disappear') {
308
+ next.exists = false;
309
+ next.version = Number(prev.version || 0) + 1;
310
+ } else if (event.type === 'change') {
311
+ next.exists = Number(event.count || 0) > 0 || prev.exists === true;
312
+ next.version = Number(prev.version || 0) + 1;
313
+ }
314
+ subscriptionState[event.subscriptionId] = next;
315
+ }
316
+
317
+ function buildSnapshotFromEvents({
318
+ events,
319
+ file,
320
+ reason = 'log_snapshot',
321
+ }) {
322
+ const first = events[0] || null;
323
+ const last = events[events.length - 1] || null;
324
+ const tracker = createRunSummaryTracker({
325
+ file,
326
+ profileId: first?.profileId || null,
327
+ runId: first?.runId || null,
328
+ });
329
+
330
+ const operationState = {};
331
+ const subscriptionState = {};
332
+ let state = { active: false, reason: null, startedAt: null, stoppedAt: null };
333
+ let lastEvent = null;
334
+
335
+ for (const event of events) {
336
+ applyRunSummaryEvent(tracker, event);
337
+
338
+ if (event.event === 'autoscript:start') {
339
+ state = {
340
+ active: true,
341
+ reason: null,
342
+ startedAt: event.ts || null,
343
+ stoppedAt: null,
344
+ };
345
+ }
346
+
347
+ if (event.event === 'autoscript:event') {
348
+ reduceSubscriptionState(subscriptionState, event);
349
+ lastEvent = {
350
+ type: event.type || 'tick',
351
+ subscriptionId: event.subscriptionId || null,
352
+ selector: event.selector || null,
353
+ count: event.count ?? null,
354
+ timestamp: event.ts || null,
355
+ };
356
+ }
357
+
358
+ if (event.event === 'autoscript:operation_start') {
359
+ const prev = cloneObject(operationState[event.operationId], {
360
+ status: 'pending',
361
+ runs: 0,
362
+ lastError: null,
363
+ updatedAt: null,
364
+ result: null,
365
+ });
366
+ operationState[event.operationId] = {
367
+ ...prev,
368
+ status: 'running',
369
+ updatedAt: event.ts || prev.updatedAt,
370
+ };
371
+ } else if (event.event === 'autoscript:operation_done') {
372
+ const prev = cloneObject(operationState[event.operationId], {
373
+ status: 'pending',
374
+ runs: 0,
375
+ lastError: null,
376
+ updatedAt: null,
377
+ result: null,
378
+ });
379
+ operationState[event.operationId] = {
380
+ ...prev,
381
+ status: 'done',
382
+ runs: Number(prev.runs || 0) + 1,
383
+ lastError: null,
384
+ updatedAt: event.ts || prev.updatedAt,
385
+ result: event.result ?? null,
386
+ };
387
+ } else if (event.event === 'autoscript:operation_error') {
388
+ const prev = cloneObject(operationState[event.operationId], {
389
+ status: 'pending',
390
+ runs: 0,
391
+ lastError: null,
392
+ updatedAt: null,
393
+ result: null,
394
+ });
395
+ operationState[event.operationId] = {
396
+ ...prev,
397
+ status: 'failed',
398
+ runs: Number(prev.runs || 0) + 1,
399
+ lastError: event.message || event.code || 'operation failed',
400
+ updatedAt: event.ts || prev.updatedAt,
401
+ result: null,
402
+ };
403
+ } else if (event.event === 'autoscript:operation_skipped') {
404
+ const prev = cloneObject(operationState[event.operationId], {
405
+ status: 'pending',
406
+ runs: 0,
407
+ lastError: null,
408
+ updatedAt: null,
409
+ result: null,
410
+ });
411
+ operationState[event.operationId] = {
412
+ ...prev,
413
+ status: 'skipped',
414
+ runs: Number(prev.runs || 0) + 1,
415
+ lastError: null,
416
+ updatedAt: event.ts || prev.updatedAt,
417
+ result: {
418
+ code: event.code || null,
419
+ reason: event.reason || null,
420
+ },
421
+ };
422
+ } else if (event.event === 'autoscript:operation_terminal') {
423
+ const prev = cloneObject(operationState[event.operationId], {
424
+ status: 'pending',
425
+ runs: 0,
426
+ lastError: null,
427
+ updatedAt: null,
428
+ result: null,
429
+ });
430
+ operationState[event.operationId] = {
431
+ ...prev,
432
+ status: 'done',
433
+ runs: Number(prev.runs || 0) + 1,
434
+ lastError: null,
435
+ updatedAt: event.ts || prev.updatedAt,
436
+ result: {
437
+ terminalDoneCode: event.code || null,
438
+ },
439
+ };
440
+ } else if (event.event === 'autoscript:stop') {
441
+ state = {
442
+ ...state,
443
+ active: false,
444
+ reason: event.reason || state.reason || null,
445
+ stoppedAt: event.ts || state.stoppedAt || null,
446
+ };
447
+ }
448
+ }
449
+
450
+ tracker.stopTs = last?.ts || tracker.stopTs || null;
451
+ const summary = buildRunSummary(tracker, state.reason || null);
452
+ const snapshot = {
453
+ kind: 'autoscript_snapshot',
454
+ version: 1,
455
+ reason,
456
+ createdAt: new Date().toISOString(),
457
+ sourceJsonl: file,
458
+ runId: summary.runId || null,
459
+ profileId: summary.profileId || null,
460
+ scriptName: summary.runName || null,
461
+ summary,
462
+ state: {
463
+ state,
464
+ subscriptionState,
465
+ operationState,
466
+ operationScheduleState: {},
467
+ runtimeContext: { vars: {}, tabPool: null, currentTab: null },
468
+ lastNavigationAt: 0,
469
+ lastEvent,
470
+ },
471
+ };
472
+ return snapshot;
473
+ }
474
+
475
+ function toOperationOrder(script) {
476
+ return Array.isArray(script?.operations)
477
+ ? script.operations.map((op) => String(op?.id || '').trim()).filter(Boolean)
478
+ : [];
479
+ }
480
+
481
+ function buildDescendantSet(script, nodeId) {
482
+ const dependents = new Map();
483
+ for (const op of script.operations || []) {
484
+ for (const dep of op.dependsOn || []) {
485
+ if (!dependents.has(dep)) dependents.set(dep, new Set());
486
+ dependents.get(dep).add(op.id);
487
+ }
488
+ }
489
+ const seen = new Set([nodeId]);
490
+ const queue = [nodeId];
491
+ while (queue.length > 0) {
492
+ const current = queue.shift();
493
+ for (const next of dependents.get(current) || []) {
494
+ if (seen.has(next)) continue;
495
+ seen.add(next);
496
+ queue.push(next);
497
+ }
498
+ }
499
+ return seen;
500
+ }
501
+
502
+ function buildResumeStateFromSnapshot(script, snapshot, fromNodeId = null) {
503
+ const sourceState = snapshot?.state && typeof snapshot.state === 'object'
504
+ ? snapshot.state
505
+ : {};
506
+ const sourceOps = sourceState.operationState && typeof sourceState.operationState === 'object'
507
+ ? sourceState.operationState
508
+ : {};
509
+ const forceRunOperationIds = [];
510
+ const operationState = {};
511
+ const descendants = fromNodeId ? buildDescendantSet(script, fromNodeId) : null;
512
+
513
+ for (const opId of toOperationOrder(script)) {
514
+ const prev = sourceOps[opId] && typeof sourceOps[opId] === 'object'
515
+ ? sourceOps[opId]
516
+ : {
517
+ status: 'pending',
518
+ runs: 0,
519
+ lastError: null,
520
+ updatedAt: null,
521
+ result: null,
522
+ };
523
+ if (!descendants) {
524
+ operationState[opId] = {
525
+ status: String(prev.status || 'pending'),
526
+ runs: Math.max(0, Number(prev.runs || 0) || 0),
527
+ lastError: prev.lastError || null,
528
+ updatedAt: prev.updatedAt || null,
529
+ result: prev.result ?? null,
530
+ };
531
+ continue;
532
+ }
533
+
534
+ if (descendants.has(opId)) {
535
+ operationState[opId] = {
536
+ status: 'pending',
537
+ runs: Math.max(0, Number(prev.runs || 0) || 0),
538
+ lastError: null,
539
+ updatedAt: prev.updatedAt || null,
540
+ result: null,
541
+ };
542
+ forceRunOperationIds.push(opId);
543
+ } else {
544
+ operationState[opId] = {
545
+ status: 'done',
546
+ runs: Math.max(1, Number(prev.runs || 1) || 1),
547
+ lastError: null,
548
+ updatedAt: prev.updatedAt || null,
549
+ result: prev.result ?? { code: 'RESUME_SKIPPED_PREVIOUS_BRANCH' },
550
+ };
551
+ }
552
+ }
553
+
554
+ return {
555
+ initialState: {
556
+ state: {
557
+ state: sourceState.state || {
558
+ active: false,
559
+ reason: null,
560
+ startedAt: null,
561
+ stoppedAt: null,
562
+ },
563
+ subscriptionState: sourceState.subscriptionState || {},
564
+ operationState,
565
+ operationScheduleState: sourceState.operationScheduleState || {},
566
+ runtimeContext: sourceState.runtimeContext || { vars: {}, tabPool: null, currentTab: null },
567
+ lastNavigationAt: Number(sourceState.lastNavigationAt || 0) || 0,
568
+ },
569
+ },
570
+ forceRunOperationIds,
571
+ };
572
+ }
573
+
574
+ function createMockOperationExecutor(fixture) {
575
+ const source = fixture?.operations && typeof fixture.operations === 'object'
576
+ ? fixture.operations
577
+ : {};
578
+ const queues = new Map(
579
+ Object.entries(source).map(([key, value]) => [key, Array.isArray(value) ? [...value] : [value]]),
580
+ );
581
+ const defaultResult = fixture?.defaultResult && typeof fixture.defaultResult === 'object'
582
+ ? fixture.defaultResult
583
+ : { ok: true, code: 'OPERATION_DONE', message: 'mock operation done', data: { mock: true } };
584
+
585
+ return ({ operation }) => {
586
+ const key = String(operation?.id || '').trim();
587
+ const queue = queues.get(key);
588
+ const wildcard = queues.get('*');
589
+ const next = queue && queue.length > 0
590
+ ? queue.shift()
591
+ : (wildcard && wildcard.length > 0 ? wildcard.shift() : defaultResult);
592
+ return next;
593
+ };
594
+ }
595
+
596
+ async function handleScaffold(args) {
597
+ const valueFlags = new Set([
598
+ '--profile', '-p',
599
+ '--keyword', '-k',
600
+ '--env',
601
+ '--output', '-o',
602
+ '--output-root',
603
+ '--throttle',
604
+ '--tab-count',
605
+ '--note-interval',
606
+ '--max-notes',
607
+ '--max-likes',
608
+ '--match-mode',
609
+ '--match-min-hits',
610
+ '--match-keywords',
611
+ '--like-keywords',
612
+ '--reply-text',
613
+ '--jsonl-file',
614
+ '--jsonl',
615
+ ]);
616
+ const positionals = collectPositionals(args, 2, valueFlags);
617
+ const template = positionals[0];
618
+ if (template !== 'xhs-unified') {
619
+ throw new Error('Usage: camo autoscript scaffold xhs-unified [--output <file>] [options]');
620
+ }
621
+
622
+ const outputPath = readFlagValue(args, ['--output', '-o'])
623
+ || path.resolve('autoscripts/xiaohongshu/unified-harvest.autoscript.json');
624
+ const profileId = readFlagValue(args, ['--profile', '-p']) || getDefaultProfile() || 'xiaohongshu-batch-1';
625
+ const keyword = readFlagValue(args, ['--keyword', '-k']) || '手机膜';
626
+ const env = readFlagValue(args, ['--env']) || 'debug';
627
+ const outputRoot = readFlagValue(args, ['--output-root']) || '';
628
+ const throttle = readIntegerFlag(args, ['--throttle'], 500, 100);
629
+ const tabCount = readIntegerFlag(args, ['--tab-count'], 4, 1);
630
+ const noteIntervalMs = readIntegerFlag(args, ['--note-interval'], 900, 200);
631
+ const maxNotes = readIntegerFlag(args, ['--max-notes'], 30, 1);
632
+ const maxLikesPerRound = readIntegerFlag(args, ['--max-likes'], 2, 1);
633
+ const matchMinHits = readIntegerFlag(args, ['--match-min-hits'], 1, 1);
634
+ const matchMode = readFlagValue(args, ['--match-mode']) || 'any';
635
+ const matchKeywords = readFlagValue(args, ['--match-keywords']) || keyword;
636
+ const likeKeywords = readFlagValue(args, ['--like-keywords']) || '';
637
+ const replyText = readFlagValue(args, ['--reply-text']) || '感谢分享,已关注';
638
+
639
+ const script = buildXhsUnifiedAutoscript({
640
+ profileId,
641
+ keyword,
642
+ env,
643
+ outputRoot,
644
+ throttle,
645
+ tabCount,
646
+ noteIntervalMs,
647
+ maxNotes,
648
+ maxLikesPerRound,
649
+ matchMode,
650
+ matchMinHits,
651
+ matchKeywords,
652
+ likeKeywords,
653
+ replyText,
654
+ doHomepage: readToggleFlag(args, 'do-homepage', true),
655
+ doImages: readToggleFlag(args, 'do-images', false),
656
+ doComments: readToggleFlag(args, 'do-comments', true),
657
+ doLikes: readToggleFlag(args, 'do-likes', false),
658
+ doReply: readToggleFlag(args, 'do-reply', false),
659
+ doOcr: readToggleFlag(args, 'do-ocr', false),
660
+ persistComments: readToggleFlag(args, 'persist-comments', true),
661
+ });
662
+
663
+ const resolvedPath = path.resolve(outputPath);
664
+ fs.mkdirSync(path.dirname(resolvedPath), { recursive: true });
665
+ fs.writeFileSync(resolvedPath, `${JSON.stringify(script, null, 2)}\n`, 'utf8');
666
+
667
+ console.log(JSON.stringify({
668
+ ok: true,
669
+ command: 'autoscript.scaffold',
670
+ template,
671
+ file: resolvedPath,
672
+ profileId: script.profileId,
673
+ operationCount: script.operations.length,
674
+ subscriptionCount: script.subscriptions.length,
675
+ hint: `Run: camo autoscript validate ${resolvedPath}`,
676
+ }, null, 2));
677
+ }
678
+
679
+ async function handleValidate(args) {
680
+ const filePath = collectPositionals(args)[0];
681
+ if (!filePath) {
682
+ throw new Error('Usage: camo autoscript validate <file>');
683
+ }
684
+ const { sourcePath, validation } = loadAndValidateAutoscript(filePath);
685
+ console.log(JSON.stringify({
686
+ ok: validation.ok,
687
+ file: sourcePath,
688
+ errors: validation.errors,
689
+ warnings: validation.warnings,
690
+ operationOrder: validation.topologicalOrder,
691
+ }, null, 2));
692
+ if (!validation.ok) {
693
+ process.exitCode = 1;
694
+ }
695
+ }
696
+
697
+ async function handleExplain(args) {
698
+ const filePath = collectPositionals(args)[0];
699
+ if (!filePath) {
700
+ throw new Error('Usage: camo autoscript explain <file>');
701
+ }
702
+ const { script, sourcePath } = loadAndValidateAutoscript(filePath);
703
+ const explained = explainAutoscript(script);
704
+ console.log(JSON.stringify({
705
+ ok: explained.ok,
706
+ file: sourcePath,
707
+ ...explained,
708
+ }, null, 2));
709
+ }
710
+
711
+ async function executeAutoscriptRuntime({
712
+ commandName,
713
+ script,
714
+ sourcePath,
715
+ profileId,
716
+ jsonlPath,
717
+ summaryPath,
718
+ runnerOptions = {},
719
+ extraStartPayload = {},
720
+ }) {
721
+ let jsonlWriteError = null;
722
+ const summaryTracker = createRunSummaryTracker({
723
+ file: sourcePath,
724
+ profileId,
725
+ runId: null,
726
+ });
727
+ const appendRunJsonl = (payload) => {
728
+ if (!jsonlPath) return;
729
+ if (jsonlWriteError) return;
730
+ try {
731
+ appendJsonLine(jsonlPath, payload);
732
+ } catch (err) {
733
+ jsonlWriteError = err;
734
+ console.error(JSON.stringify({
735
+ event: 'autoscript:jsonl_error',
736
+ file: jsonlPath,
737
+ message: err?.message || String(err),
738
+ }));
739
+ }
740
+ };
741
+
742
+ const runner = new AutoscriptRunner({
743
+ ...script,
744
+ profileId,
745
+ }, {
746
+ profileId,
747
+ ...runnerOptions,
748
+ log: (payload) => {
749
+ console.log(JSON.stringify(payload));
750
+ appendRunJsonl(payload);
751
+ applyRunSummaryEvent(summaryTracker, payload);
752
+ safeAppendProgressEvent({
753
+ source: 'autoscript.runtime',
754
+ mode: 'autoscript',
755
+ profileId: payload.profileId || profileId,
756
+ runId: payload.runId || null,
757
+ event: payload.event || 'autoscript.log',
758
+ payload,
759
+ });
760
+ },
761
+ });
762
+
763
+ const running = await runner.start();
764
+ summaryTracker.runId = running.runId;
765
+ safeAppendProgressEvent({
766
+ source: 'autoscript.command',
767
+ mode: 'autoscript',
768
+ profileId,
769
+ runId: running.runId,
770
+ event: `${commandName}.start`,
771
+ payload: {
772
+ file: sourcePath,
773
+ profileId,
774
+ runId: running.runId,
775
+ ...extraStartPayload,
776
+ },
777
+ });
778
+ console.log(JSON.stringify({
779
+ ok: true,
780
+ command: commandName,
781
+ file: sourcePath,
782
+ profileId,
783
+ runId: running.runId,
784
+ message: 'Autoscript runtime started. Press Ctrl+C to stop.',
785
+ ...extraStartPayload,
786
+ }, null, 2));
787
+ appendRunJsonl({
788
+ runId: running.runId,
789
+ profileId,
790
+ event: `${commandName}:run_start`,
791
+ ts: new Date().toISOString(),
792
+ file: sourcePath,
793
+ jsonlPath,
794
+ ...extraStartPayload,
795
+ });
796
+
797
+ const onSigint = () => {
798
+ running.stop('signal_interrupt');
799
+ };
800
+ process.once('SIGINT', onSigint);
801
+
802
+ const done = await running.done.finally(() => {
803
+ process.removeListener('SIGINT', onSigint);
804
+ });
805
+ summaryTracker.stopTs = new Date().toISOString();
806
+ const summaryPayload = buildRunSummary(summaryTracker, done?.reason || null);
807
+ if (summaryPath) {
808
+ fs.mkdirSync(path.dirname(summaryPath), { recursive: true });
809
+ fs.writeFileSync(summaryPath, `${JSON.stringify(summaryPayload, null, 2)}\n`, 'utf8');
810
+ }
811
+ console.log(JSON.stringify({
812
+ event: 'autoscript:run_summary',
813
+ runId: running.runId,
814
+ profileId,
815
+ summaryPath,
816
+ summary: summaryPayload,
817
+ }, null, 2));
818
+ safeAppendProgressEvent({
819
+ source: 'autoscript.command',
820
+ mode: 'autoscript',
821
+ profileId,
822
+ runId: running.runId,
823
+ event: `${commandName}.stop`,
824
+ payload: {
825
+ file: sourcePath,
826
+ profileId,
827
+ runId: running.runId,
828
+ reason: done?.reason || null,
829
+ ...extraStartPayload,
830
+ },
831
+ });
832
+ appendRunJsonl({
833
+ runId: running.runId,
834
+ profileId,
835
+ event: `${commandName}:run_stop`,
836
+ ts: new Date().toISOString(),
837
+ file: sourcePath,
838
+ reason: done?.reason || null,
839
+ ...extraStartPayload,
840
+ });
841
+ appendRunJsonl({
842
+ runId: running.runId,
843
+ profileId,
844
+ event: `${commandName}:run_summary`,
845
+ ts: new Date().toISOString(),
846
+ summaryPath,
847
+ summary: summaryPayload,
848
+ ...extraStartPayload,
849
+ });
850
+
851
+ return {
852
+ done,
853
+ summaryPath,
854
+ summary: summaryPayload,
855
+ };
856
+ }
857
+
858
+ async function handleRun(args) {
859
+ const filePath = collectPositionals(args)[0];
860
+ if (!filePath) {
861
+ throw new Error('Usage: camo autoscript run <file> [--profile <id>] [--jsonl-file <path>] [--summary-file <path>]');
862
+ }
863
+ const profileOverride = readFlagValue(args, ['--profile', '-p']);
864
+ const jsonlPathRaw = readFlagValue(args, ['--jsonl-file', '--jsonl']);
865
+ const jsonlPath = jsonlPathRaw ? path.resolve(jsonlPathRaw) : null;
866
+ const summaryPathRaw = readFlagValue(args, ['--summary-file', '--summary']);
867
+ const summaryPath = summaryPathRaw
868
+ ? path.resolve(summaryPathRaw)
869
+ : buildDefaultSummaryPath(jsonlPath);
870
+ const { script, sourcePath, validation } = loadAndValidateAutoscript(filePath);
871
+ if (!validation.ok) {
872
+ console.log(JSON.stringify({
873
+ ok: false,
874
+ file: sourcePath,
875
+ errors: validation.errors,
876
+ warnings: validation.warnings,
877
+ }, null, 2));
878
+ process.exitCode = 1;
879
+ return;
880
+ }
881
+
882
+ const profileId = profileOverride || script.profileId || getDefaultProfile();
883
+ if (!profileId) {
884
+ throw new Error('profileId is required. Set in script or pass --profile <id>');
885
+ }
886
+
887
+ await executeAutoscriptRuntime({
888
+ commandName: 'autoscript.run',
889
+ script,
890
+ sourcePath,
891
+ profileId,
892
+ jsonlPath,
893
+ summaryPath,
894
+ });
895
+ }
896
+
897
+ function readJsonFile(filePath) {
898
+ const resolved = path.resolve(filePath);
899
+ if (!fs.existsSync(resolved)) throw new Error(`File not found: ${resolved}`);
900
+ try {
901
+ return {
902
+ resolvedPath: resolved,
903
+ payload: JSON.parse(fs.readFileSync(resolved, 'utf8')),
904
+ };
905
+ } catch (err) {
906
+ throw new Error(`Invalid JSON file: ${resolved} (${err?.message || String(err)})`);
907
+ }
908
+ }
909
+
910
+ async function handleSnapshot(args) {
911
+ const valueFlags = new Set(['--out', '-o']);
912
+ const filePath = collectPositionals(args, 2, valueFlags)[0];
913
+ if (!filePath) {
914
+ throw new Error('Usage: camo autoscript snapshot <jsonl-file> [--out <snapshot-file>]');
915
+ }
916
+ const outRaw = readFlagValue(args, ['--out', '-o']);
917
+ const { resolvedPath, rows } = readJsonlEvents(filePath);
918
+ const snapshot = buildSnapshotFromEvents({ events: rows, file: resolvedPath });
919
+ const outputPath = outRaw
920
+ ? path.resolve(outRaw)
921
+ : (resolvedPath.endsWith('.jsonl')
922
+ ? `${resolvedPath.slice(0, -'.jsonl'.length)}.snapshot.json`
923
+ : `${resolvedPath}.snapshot.json`);
924
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
925
+ fs.writeFileSync(outputPath, `${JSON.stringify(snapshot, null, 2)}\n`, 'utf8');
926
+ console.log(JSON.stringify({
927
+ ok: true,
928
+ command: 'autoscript.snapshot',
929
+ input: resolvedPath,
930
+ output: outputPath,
931
+ runId: snapshot.runId || null,
932
+ profileId: snapshot.profileId || null,
933
+ summary: snapshot.summary,
934
+ }, null, 2));
935
+ }
936
+
937
+ async function handleReplay(args) {
938
+ const valueFlags = new Set(['--summary-file', '--summary']);
939
+ const filePath = collectPositionals(args, 2, valueFlags)[0];
940
+ if (!filePath) {
941
+ throw new Error('Usage: camo autoscript replay <jsonl-file> [--summary-file <path>]');
942
+ }
943
+ const summaryPathRaw = readFlagValue(args, ['--summary-file', '--summary']);
944
+ const { resolvedPath, rows } = readJsonlEvents(filePath);
945
+ const snapshot = buildSnapshotFromEvents({ events: rows, file: resolvedPath, reason: 'log_replay' });
946
+ const summaryPath = summaryPathRaw
947
+ ? path.resolve(summaryPathRaw)
948
+ : buildDefaultSummaryPath(resolvedPath);
949
+ if (summaryPath) {
950
+ fs.mkdirSync(path.dirname(summaryPath), { recursive: true });
951
+ fs.writeFileSync(summaryPath, `${JSON.stringify(snapshot.summary, null, 2)}\n`, 'utf8');
952
+ }
953
+ console.log(JSON.stringify({
954
+ ok: true,
955
+ command: 'autoscript.replay',
956
+ input: resolvedPath,
957
+ summaryPath,
958
+ summary: snapshot.summary,
959
+ }, null, 2));
960
+ }
961
+
962
+ async function handleResume(args) {
963
+ const valueFlags = new Set(['--profile', '-p', '--snapshot', '--from-node', '--jsonl-file', '--jsonl', '--summary-file', '--summary']);
964
+ const filePath = collectPositionals(args, 2, valueFlags)[0];
965
+ if (!filePath) {
966
+ throw new Error('Usage: camo autoscript resume <file> --snapshot <snapshot-file> [--from-node <nodeId>] [--profile <id>] [--jsonl-file <path>] [--summary-file <path>]');
967
+ }
968
+ const snapshotPathRaw = readFlagValue(args, ['--snapshot']);
969
+ if (!snapshotPathRaw) {
970
+ throw new Error('autoscript resume requires --snapshot <snapshot-file>');
971
+ }
972
+ const fromNode = readFlagValue(args, ['--from-node']);
973
+ const profileOverride = readFlagValue(args, ['--profile', '-p']);
974
+ const jsonlPathRaw = readFlagValue(args, ['--jsonl-file', '--jsonl']);
975
+ const jsonlPath = jsonlPathRaw ? path.resolve(jsonlPathRaw) : null;
976
+ const summaryPathRaw = readFlagValue(args, ['--summary-file', '--summary']);
977
+ const summaryPath = summaryPathRaw ? path.resolve(summaryPathRaw) : buildDefaultSummaryPath(jsonlPath);
978
+
979
+ const { payload: snapshot, resolvedPath: snapshotPath } = readJsonFile(snapshotPathRaw);
980
+ const { script, sourcePath, validation } = loadAndValidateAutoscript(filePath);
981
+ if (!validation.ok) {
982
+ console.log(JSON.stringify({
983
+ ok: false,
984
+ file: sourcePath,
985
+ errors: validation.errors,
986
+ warnings: validation.warnings,
987
+ }, null, 2));
988
+ process.exitCode = 1;
989
+ return;
990
+ }
991
+ if (fromNode && !script.operations.some((op) => op.id === fromNode)) {
992
+ throw new Error(`Unknown --from-node operation id: ${fromNode}`);
993
+ }
994
+
995
+ const profileId = profileOverride || snapshot?.profileId || script.profileId || getDefaultProfile();
996
+ if (!profileId) {
997
+ throw new Error('profileId is required. Set in script or pass --profile <id>');
998
+ }
999
+ const resumeState = buildResumeStateFromSnapshot(script, snapshot, fromNode || null);
1000
+ await executeAutoscriptRuntime({
1001
+ commandName: 'autoscript.resume',
1002
+ script,
1003
+ sourcePath,
1004
+ profileId,
1005
+ jsonlPath,
1006
+ summaryPath,
1007
+ runnerOptions: {
1008
+ initialState: resumeState.initialState,
1009
+ forceRunOperationIds: resumeState.forceRunOperationIds,
1010
+ },
1011
+ extraStartPayload: {
1012
+ snapshotPath,
1013
+ fromNode: fromNode || null,
1014
+ },
1015
+ });
1016
+ }
1017
+
1018
+ async function handleMockRun(args) {
1019
+ const valueFlags = new Set(['--profile', '-p', '--fixture', '--jsonl-file', '--jsonl', '--summary-file', '--summary']);
1020
+ const filePath = collectPositionals(args, 2, valueFlags)[0];
1021
+ if (!filePath) {
1022
+ throw new Error('Usage: camo autoscript mock-run <file> --fixture <fixture.json> [--profile <id>] [--jsonl-file <path>] [--summary-file <path>]');
1023
+ }
1024
+ const fixturePathRaw = readFlagValue(args, ['--fixture']);
1025
+ if (!fixturePathRaw) {
1026
+ throw new Error('autoscript mock-run requires --fixture <fixture.json>');
1027
+ }
1028
+ const profileOverride = readFlagValue(args, ['--profile', '-p']);
1029
+ const jsonlPathRaw = readFlagValue(args, ['--jsonl-file', '--jsonl']);
1030
+ const jsonlPath = jsonlPathRaw ? path.resolve(jsonlPathRaw) : null;
1031
+ const summaryPathRaw = readFlagValue(args, ['--summary-file', '--summary']);
1032
+ const summaryPath = summaryPathRaw ? path.resolve(summaryPathRaw) : buildDefaultSummaryPath(jsonlPath);
1033
+
1034
+ const { payload: fixture, resolvedPath: fixturePath } = readJsonFile(fixturePathRaw);
1035
+ const { script, sourcePath, validation } = loadAndValidateAutoscript(filePath);
1036
+ if (!validation.ok) {
1037
+ console.log(JSON.stringify({
1038
+ ok: false,
1039
+ file: sourcePath,
1040
+ errors: validation.errors,
1041
+ warnings: validation.warnings,
1042
+ }, null, 2));
1043
+ process.exitCode = 1;
1044
+ return;
1045
+ }
1046
+ const profileId = profileOverride || fixture?.profileId || script.profileId || 'mock-profile';
1047
+ await executeAutoscriptRuntime({
1048
+ commandName: 'autoscript.mock_run',
1049
+ script,
1050
+ sourcePath,
1051
+ profileId,
1052
+ jsonlPath,
1053
+ summaryPath,
1054
+ runnerOptions: {
1055
+ skipValidation: fixture?.skipValidation !== false,
1056
+ mockEvents: Array.isArray(fixture?.events) ? fixture.events : [],
1057
+ mockEventBaseDelayMs: Math.max(0, Number(fixture?.mockEventBaseDelayMs ?? 0) || 0),
1058
+ stopWhenMockEventsExhausted: fixture?.stopWhenMockEventsExhausted !== false,
1059
+ executeMockOperation: createMockOperationExecutor(fixture),
1060
+ },
1061
+ extraStartPayload: {
1062
+ fixturePath,
1063
+ },
1064
+ });
1065
+ }
1066
+
1067
+ export async function handleAutoscriptCommand(args) {
1068
+ const sub = args[1];
1069
+ switch (sub) {
1070
+ case 'scaffold':
1071
+ return handleScaffold(args);
1072
+ case 'validate':
1073
+ return handleValidate(args);
1074
+ case 'explain':
1075
+ return handleExplain(args);
1076
+ case 'snapshot':
1077
+ return handleSnapshot(args);
1078
+ case 'replay':
1079
+ return handleReplay(args);
1080
+ case 'run':
1081
+ return handleRun(args);
1082
+ case 'resume':
1083
+ return handleResume(args);
1084
+ case 'mock-run':
1085
+ return handleMockRun(args);
1086
+ default:
1087
+ console.log(`Usage: camo autoscript <scaffold|validate|explain|snapshot|replay|run|resume|mock-run> [args]
1088
+
1089
+ Commands:
1090
+ scaffold xhs-unified [--output <file>] [options] Generate xiaohongshu unified-harvest autoscript
1091
+ validate <file> Validate autoscript schema and references
1092
+ explain <file> Print normalized graph and defaults
1093
+ snapshot <jsonl-file> [--out <snapshot-file>] Build resumable snapshot from run JSONL
1094
+ replay <jsonl-file> [--summary-file <path>] Rebuild summary from run JSONL
1095
+ run <file> [--profile <id>] [--jsonl-file <path>] [--summary-file <path>] Run autoscript runtime
1096
+ resume <file> --snapshot <snapshot-file> [--from-node <nodeId>] [--profile <id>] [--jsonl-file <path>] [--summary-file <path>] Resume from snapshot
1097
+ mock-run <file> --fixture <fixture.json> [--profile <id>] [--jsonl-file <path>] [--summary-file <path>] Run in mock replay mode
1098
+ `);
1099
+ }
1100
+ }