@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,1015 @@
1
+ import crypto from 'node:crypto';
2
+ import {
3
+ captureCheckpoint,
4
+ executeOperation,
5
+ restoreCheckpoint,
6
+ validateOperation,
7
+ watchSubscriptions,
8
+ } from '../container/runtime-core.mjs';
9
+ import { executeAutoscriptAction } from './action-providers/index.mjs';
10
+ import { ImpactEngine } from './impact-engine.mjs';
11
+
12
+ function sleep(ms) {
13
+ return new Promise((resolve) => setTimeout(resolve, ms));
14
+ }
15
+
16
+ function nowIso() {
17
+ return new Date().toISOString();
18
+ }
19
+
20
+ function createRunId() {
21
+ return crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(16).slice(2)}`;
22
+ }
23
+
24
+ function withTimeout(promise, timeoutMs, onTimeout) {
25
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return promise;
26
+ let timer = null;
27
+ return Promise.race([
28
+ promise.finally(() => {
29
+ if (timer) clearTimeout(timer);
30
+ }),
31
+ new Promise((resolve) => {
32
+ timer = setTimeout(() => {
33
+ resolve(onTimeout());
34
+ }, timeoutMs);
35
+ }),
36
+ ]);
37
+ }
38
+
39
+ function extractTerminalDoneCode(result) {
40
+ const text = `${result?.code || ''} ${result?.message || ''}`;
41
+ const matched = text.match(/AUTOSCRIPT_DONE_[A-Z_]+/);
42
+ return matched ? matched[0] : null;
43
+ }
44
+
45
+ function normalizeExecutionResult(result) {
46
+ if (result && typeof result === 'object' && typeof result.ok === 'boolean') {
47
+ return {
48
+ ok: result.ok,
49
+ code: result.code || (result.ok ? 'OPERATION_DONE' : 'OPERATION_FAILED'),
50
+ message: result.message || (result.ok ? 'operation done' : 'operation failed'),
51
+ data: result.data ?? result.result ?? null,
52
+ };
53
+ }
54
+ return {
55
+ ok: false,
56
+ code: 'OPERATION_FAILED',
57
+ message: 'invalid operation result payload',
58
+ data: { resultType: typeof result },
59
+ };
60
+ }
61
+
62
+ function mapToPlainObject(map) {
63
+ return Object.fromEntries(map.entries());
64
+ }
65
+
66
+ function plainObjectToMap(value) {
67
+ if (!value || typeof value !== 'object') return new Map();
68
+ if (value instanceof Map) return new Map(value.entries());
69
+ return new Map(Object.entries(value));
70
+ }
71
+
72
+ export class AutoscriptRunner {
73
+ constructor(script, options = {}) {
74
+ this.script = script;
75
+ this.profileId = options.profileId || script.profileId || null;
76
+ this.logger = options.log || ((payload) => console.log(JSON.stringify(payload)));
77
+ this.runId = options.runId || createRunId();
78
+ this.state = {
79
+ active: false,
80
+ reason: null,
81
+ startedAt: null,
82
+ stoppedAt: null,
83
+ };
84
+
85
+ this.impactEngine = new ImpactEngine();
86
+ this.subscriptionState = new Map();
87
+ this.operationState = new Map();
88
+ this.operationQueue = Promise.resolve();
89
+ this.pendingOperations = new Set();
90
+ this.watchHandle = null;
91
+ this.donePromise = null;
92
+ this.resolveDone = null;
93
+ this.operationScheduleState = new Map();
94
+ this.runtimeContext = {
95
+ vars: {},
96
+ tabPool: null,
97
+ currentTab: null,
98
+ };
99
+ this.lastNavigationAt = 0;
100
+ this.random = typeof options.random === 'function' ? options.random : Math.random;
101
+ this.executeExternalOperation = options.executeExternalOperation === false
102
+ ? null
103
+ : (typeof options.executeExternalOperation === 'function'
104
+ ? options.executeExternalOperation
105
+ : executeAutoscriptAction);
106
+ this.executeMockOperation = typeof options.executeMockOperation === 'function'
107
+ ? options.executeMockOperation
108
+ : null;
109
+ this.skipValidation = options.skipValidation === true;
110
+ this.mockEvents = Array.isArray(options.mockEvents) ? options.mockEvents : null;
111
+ this.mockEventBaseDelayMs = Math.max(0, Number(options.mockEventBaseDelayMs ?? 0) || 0);
112
+ this.stopWhenMockEventsExhausted = options.stopWhenMockEventsExhausted !== false;
113
+ this.forceRunOperationIds = new Set(
114
+ Array.isArray(options.forceRunOperationIds)
115
+ ? options.forceRunOperationIds.map((item) => String(item || '').trim()).filter(Boolean)
116
+ : [],
117
+ );
118
+
119
+ for (const subscription of script.subscriptions || []) {
120
+ this.subscriptionState.set(subscription.id, {
121
+ exists: false,
122
+ appearCount: 0,
123
+ lastEventAt: null,
124
+ version: 0,
125
+ });
126
+ }
127
+ for (const operation of script.operations || []) {
128
+ this.operationState.set(operation.id, {
129
+ status: 'pending',
130
+ runs: 0,
131
+ lastError: null,
132
+ updatedAt: null,
133
+ result: null,
134
+ });
135
+ this.operationScheduleState.set(operation.id, {
136
+ lastScheduledAt: null,
137
+ lastStartedAt: null,
138
+ lastEventAt: null,
139
+ lastTriggerKey: null,
140
+ lastScheduledAppearCount: null,
141
+ lastCompletedAppearCount: null,
142
+ });
143
+ }
144
+ this.applyInitialState(options.initialState || null);
145
+ }
146
+
147
+ applyInitialState(initialState) {
148
+ if (!initialState || typeof initialState !== 'object') return;
149
+ const root = initialState.state && typeof initialState.state === 'object'
150
+ ? initialState.state
151
+ : initialState;
152
+ const subState = plainObjectToMap(root.subscriptionState);
153
+ for (const [key, value] of subState.entries()) {
154
+ if (!key) continue;
155
+ if (!value || typeof value !== 'object') continue;
156
+ const prev = this.subscriptionState.get(key) || {};
157
+ this.subscriptionState.set(key, {
158
+ exists: value.exists === true,
159
+ appearCount: Math.max(0, Number(value.appearCount ?? prev.appearCount ?? 0) || 0),
160
+ lastEventAt: value.lastEventAt || prev.lastEventAt || null,
161
+ version: Math.max(0, Number(value.version ?? prev.version ?? 0) || 0),
162
+ });
163
+ }
164
+
165
+ const opState = plainObjectToMap(root.operationState);
166
+ for (const [key, value] of opState.entries()) {
167
+ if (!key) continue;
168
+ if (!value || typeof value !== 'object') continue;
169
+ const prev = this.operationState.get(key) || {};
170
+ this.operationState.set(key, {
171
+ status: String(value.status || prev.status || 'pending'),
172
+ runs: Math.max(0, Number(value.runs ?? prev.runs ?? 0) || 0),
173
+ lastError: value.lastError || prev.lastError || null,
174
+ updatedAt: value.updatedAt || prev.updatedAt || null,
175
+ result: value.result ?? prev.result ?? null,
176
+ });
177
+ }
178
+
179
+ const scheduleState = plainObjectToMap(root.operationScheduleState);
180
+ for (const [key, value] of scheduleState.entries()) {
181
+ if (!key) continue;
182
+ if (!value || typeof value !== 'object') continue;
183
+ this.operationScheduleState.set(key, {
184
+ lastScheduledAt: value.lastScheduledAt ?? null,
185
+ lastStartedAt: value.lastStartedAt ?? null,
186
+ lastEventAt: value.lastEventAt ?? null,
187
+ lastTriggerKey: value.lastTriggerKey ?? null,
188
+ lastScheduledAppearCount: value.lastScheduledAppearCount ?? null,
189
+ lastCompletedAppearCount: value.lastCompletedAppearCount ?? null,
190
+ });
191
+ }
192
+
193
+ if (root.runtimeContext && typeof root.runtimeContext === 'object') {
194
+ this.runtimeContext = {
195
+ ...this.runtimeContext,
196
+ ...root.runtimeContext,
197
+ vars: root.runtimeContext.vars && typeof root.runtimeContext.vars === 'object'
198
+ ? { ...root.runtimeContext.vars }
199
+ : { ...this.runtimeContext.vars },
200
+ };
201
+ }
202
+ if (Number.isFinite(Number(root.lastNavigationAt))) {
203
+ this.lastNavigationAt = Number(root.lastNavigationAt);
204
+ }
205
+ }
206
+
207
+ createSnapshot(reason = 'runtime_snapshot') {
208
+ return {
209
+ kind: 'autoscript_snapshot',
210
+ version: 1,
211
+ reason,
212
+ createdAt: nowIso(),
213
+ runId: this.runId,
214
+ profileId: this.profileId,
215
+ scriptName: this.script?.name || null,
216
+ state: {
217
+ state: this.state,
218
+ subscriptionState: mapToPlainObject(this.subscriptionState),
219
+ operationState: mapToPlainObject(this.operationState),
220
+ operationScheduleState: mapToPlainObject(this.operationScheduleState),
221
+ runtimeContext: this.runtimeContext,
222
+ lastNavigationAt: this.lastNavigationAt,
223
+ },
224
+ };
225
+ }
226
+
227
+ log(event, payload = {}) {
228
+ this.logger({
229
+ runId: this.runId,
230
+ profileId: this.profileId,
231
+ event,
232
+ ts: nowIso(),
233
+ ...payload,
234
+ });
235
+ }
236
+
237
+ isDependencySatisfied(operation) {
238
+ const deps = operation.dependsOn || [];
239
+ for (const dep of deps) {
240
+ const depState = this.operationState.get(dep);
241
+ if (depState?.status !== 'done' && depState?.status !== 'skipped') return false;
242
+ }
243
+ return true;
244
+ }
245
+
246
+ isConditionSatisfied(condition) {
247
+ if (condition.type === 'operation_done') {
248
+ return this.operationState.get(condition.operationId)?.status === 'done';
249
+ }
250
+ if (condition.type === 'subscription_exist') {
251
+ return this.subscriptionState.get(condition.subscriptionId)?.exists === true;
252
+ }
253
+ if (condition.type === 'subscription_appear') {
254
+ return Number(this.subscriptionState.get(condition.subscriptionId)?.appearCount || 0) > 0;
255
+ }
256
+ return false;
257
+ }
258
+
259
+ areConditionsSatisfied(operation) {
260
+ for (const condition of operation.conditions || []) {
261
+ if (!this.isConditionSatisfied(condition)) return false;
262
+ }
263
+ return true;
264
+ }
265
+
266
+ isTriggered(operation, event) {
267
+ const trigger = operation.trigger || { type: 'startup' };
268
+ if (trigger.type === 'startup') return event.type === 'startup';
269
+ if (trigger.type === 'manual') return event.type === 'manual';
270
+ if (trigger.type !== 'subscription_event') return false;
271
+ return (
272
+ event.subscriptionId === trigger.subscriptionId
273
+ && event.type === trigger.event
274
+ );
275
+ }
276
+
277
+ isTriggerStillValid(operation) {
278
+ const trigger = operation?.trigger || { type: 'startup' };
279
+ if (trigger.type !== 'subscription_event') return true;
280
+ const state = this.subscriptionState.get(trigger.subscriptionId);
281
+ const exists = state?.exists === true;
282
+ if (trigger.event === 'exist' || trigger.event === 'appear') {
283
+ return exists;
284
+ }
285
+ if (trigger.event === 'disappear') {
286
+ return !exists;
287
+ }
288
+ return true;
289
+ }
290
+
291
+ shouldTreatAsStaleValidationSkip(operation, result) {
292
+ if (!operation || !result || result.ok) return false;
293
+ const phase = String(result?.data?.phase || '').trim().toLowerCase();
294
+ if (phase !== 'pre') return false;
295
+ const code = String(result?.code || '').toUpperCase();
296
+ if (!code.includes('VALIDATION')) return false;
297
+ return !this.isTriggerStillValid(operation);
298
+ }
299
+
300
+ resolvePacing(operation) {
301
+ const scriptPacing = this.script?.defaults?.pacing || {};
302
+ const opPacing = operation?.pacing || {};
303
+ const timeoutRaw = operation?.timeoutMs ?? opPacing.timeoutMs ?? this.script?.defaults?.timeoutMs ?? scriptPacing.timeoutMs;
304
+ return {
305
+ operationMinIntervalMs: Math.max(0, Number(opPacing.operationMinIntervalMs ?? scriptPacing.operationMinIntervalMs ?? 0) || 0),
306
+ eventCooldownMs: Math.max(0, Number(opPacing.eventCooldownMs ?? scriptPacing.eventCooldownMs ?? 0) || 0),
307
+ jitterMs: Math.max(0, Number(opPacing.jitterMs ?? scriptPacing.jitterMs ?? 0) || 0),
308
+ navigationMinIntervalMs: Math.max(0, Number(opPacing.navigationMinIntervalMs ?? scriptPacing.navigationMinIntervalMs ?? 0) || 0),
309
+ timeoutMs: timeoutRaw === null || timeoutRaw === undefined ? null : Math.max(0, Number(timeoutRaw) || 0),
310
+ };
311
+ }
312
+
313
+ isNavigationAction(action) {
314
+ const normalized = String(action || '').trim().toLowerCase();
315
+ return [
316
+ 'goto',
317
+ 'back',
318
+ 'new_page',
319
+ 'switch_page',
320
+ 'ensure_tab_pool',
321
+ 'tab_pool_switch_next',
322
+ 'tab_pool_switch_slot',
323
+ ].includes(normalized);
324
+ }
325
+
326
+ getDefaultTimeoutMs(operation) {
327
+ const action = String(operation?.action || '').trim().toLowerCase();
328
+ if (action === 'wait') {
329
+ const ms = Math.max(0, Number(operation?.params?.ms ?? operation?.params?.value ?? 0) || 0);
330
+ return Math.max(30_000, ms + 5_000);
331
+ }
332
+ if ([
333
+ 'evaluate',
334
+ 'goto',
335
+ 'new_page',
336
+ 'switch_page',
337
+ 'ensure_tab_pool',
338
+ 'tab_pool_switch_next',
339
+ 'tab_pool_switch_slot',
340
+ 'sync_window_viewport',
341
+ 'verify_subscriptions',
342
+ 'xhs_submit_search',
343
+ 'xhs_open_detail',
344
+ 'xhs_detail_harvest',
345
+ 'xhs_expand_replies',
346
+ 'xhs_comments_harvest',
347
+ 'xhs_comment_match',
348
+ 'xhs_comment_like',
349
+ 'xhs_comment_reply',
350
+ 'xhs_close_detail',
351
+ ].includes(action)) {
352
+ return 45_000;
353
+ }
354
+ if (['click', 'type', 'back', 'scroll_into_view', 'scroll', 'press_key', 'get_current_url', 'raise_error'].includes(action)) {
355
+ return 30_000;
356
+ }
357
+ return 20_000;
358
+ }
359
+
360
+ resolveTimeoutMs(operation) {
361
+ const pacing = this.resolvePacing(operation);
362
+ if (Number.isFinite(pacing.timeoutMs) && pacing.timeoutMs > 0) return pacing.timeoutMs;
363
+ return this.getDefaultTimeoutMs(operation);
364
+ }
365
+
366
+ buildTriggerKey(operation, event) {
367
+ const trigger = operation?.trigger || { type: 'startup' };
368
+ if (trigger.type === 'startup') return 'startup';
369
+ if (trigger.type === 'manual') {
370
+ return `manual:${event?.timestamp || event?.type || 'event'}`;
371
+ }
372
+ if (trigger.type !== 'subscription_event') {
373
+ return `${trigger.type || 'unknown'}:${event?.timestamp || event?.type || 'event'}`;
374
+ }
375
+
376
+ const subState = this.subscriptionState.get(trigger.subscriptionId);
377
+ if (trigger.event === 'exist') {
378
+ return `${trigger.subscriptionId}:exist:a${Number(subState?.appearCount || 0)}`;
379
+ }
380
+ if (trigger.event === 'appear') {
381
+ return `${trigger.subscriptionId}:appear:n${Number(subState?.appearCount || 0)}`;
382
+ }
383
+ return `${trigger.subscriptionId}:${trigger.event}:v${Number(subState?.version || 0)}`;
384
+ }
385
+
386
+ getTriggerAppearCount(operation) {
387
+ const trigger = operation?.trigger || {};
388
+ if (trigger.type !== 'subscription_event') return null;
389
+ if (!trigger.subscriptionId) return null;
390
+ const subState = this.subscriptionState.get(trigger.subscriptionId) || null;
391
+ const appearCount = Number(subState?.appearCount || 0);
392
+ if (!Number.isFinite(appearCount) || appearCount <= 0) return null;
393
+ return appearCount;
394
+ }
395
+
396
+ shouldSchedule(operation, event) {
397
+ const forceRun = this.forceRunOperationIds.has(operation.id);
398
+ if (!operation.enabled) return false;
399
+ if (!forceRun && !this.isTriggered(operation, event)) return false;
400
+ if (operation.once && this.operationState.get(operation.id)?.status === 'done') return false;
401
+ if (!this.isDependencySatisfied(operation)) return false;
402
+ if (!this.areConditionsSatisfied(operation)) return false;
403
+ if (!this.impactEngine.canRunOperation(operation, event)) return false;
404
+ if (this.pendingOperations.has(operation.id)) return false;
405
+
406
+ const scheduleState = this.operationScheduleState.get(operation.id) || {};
407
+ const pacing = this.resolvePacing(operation);
408
+ const now = Date.now();
409
+ const appearCount = this.getTriggerAppearCount(operation);
410
+
411
+ if (pacing.operationMinIntervalMs > 0 && scheduleState.lastStartedAt) {
412
+ if ((now - scheduleState.lastStartedAt) < pacing.operationMinIntervalMs) return false;
413
+ }
414
+
415
+ if (pacing.eventCooldownMs > 0 && scheduleState.lastEventAt) {
416
+ if ((now - scheduleState.lastEventAt) < pacing.eventCooldownMs) return false;
417
+ }
418
+
419
+ if (
420
+ operation?.oncePerAppear === true
421
+ && Number.isFinite(appearCount)
422
+ && appearCount > 0
423
+ && (
424
+ Number(scheduleState.lastScheduledAppearCount || 0) === appearCount
425
+ || Number(scheduleState.lastCompletedAppearCount || 0) === appearCount
426
+ )
427
+ ) {
428
+ return false;
429
+ }
430
+
431
+ const triggerKey = forceRun ? `force:${operation.id}` : this.buildTriggerKey(operation, event);
432
+ const trigger = operation?.trigger || {};
433
+ const allowExistReschedule = trigger?.type === 'subscription_event'
434
+ && trigger?.event === 'exist'
435
+ && operation?.once === false
436
+ && operation?.oncePerAppear !== true
437
+ && (pacing.operationMinIntervalMs > 0 || pacing.eventCooldownMs > 0);
438
+ if (!forceRun && !allowExistReschedule && triggerKey && scheduleState.lastTriggerKey === triggerKey) {
439
+ return false;
440
+ }
441
+ return true;
442
+ }
443
+
444
+ resetCycleOperationsForSubscription(subscriptionId) {
445
+ if (!subscriptionId) return;
446
+ for (const operation of this.script.operations || []) {
447
+ if (operation?.oncePerAppear !== true) continue;
448
+ const trigger = operation?.trigger || {};
449
+ if (trigger.type !== 'subscription_event') continue;
450
+ if (trigger.subscriptionId !== subscriptionId) continue;
451
+
452
+ const prevState = this.operationState.get(operation.id);
453
+ if (!prevState || prevState.status === 'pending') continue;
454
+ this.operationState.set(operation.id, {
455
+ ...prevState,
456
+ status: 'pending',
457
+ lastError: null,
458
+ result: null,
459
+ updatedAt: nowIso(),
460
+ });
461
+ }
462
+ }
463
+
464
+ scheduleReadyOperations(event) {
465
+ for (const operation of this.script.operations || []) {
466
+ if (!this.shouldSchedule(operation, event)) continue;
467
+ this.enqueueOperation(operation, event);
468
+ }
469
+ }
470
+
471
+ async runValidation(operation, phase, context) {
472
+ if (this.skipValidation) {
473
+ return {
474
+ ok: true,
475
+ code: 'VALIDATION_SKIPPED',
476
+ message: 'validation skipped in mock/resume mode',
477
+ data: { phase },
478
+ };
479
+ }
480
+ const validation = operation.validation || {};
481
+ return validateOperation({
482
+ profileId: this.profileId,
483
+ validationSpec: validation,
484
+ phase,
485
+ context,
486
+ platform: 'xiaohongshu',
487
+ });
488
+ }
489
+
490
+ async executeOnce(operation, context) {
491
+ if (this.executeMockOperation) {
492
+ const mocked = await this.executeMockOperation({
493
+ operation,
494
+ context,
495
+ profileId: this.profileId,
496
+ });
497
+ if (mocked !== undefined) {
498
+ return normalizeExecutionResult(mocked);
499
+ }
500
+ }
501
+
502
+ const pre = await this.runValidation(operation, 'pre', context);
503
+ if (!pre.ok) {
504
+ return { ok: false, code: pre.code || 'VALIDATION_FAILED', message: pre.message || 'pre validation failed', data: { phase: 'pre', detail: pre } };
505
+ }
506
+
507
+ const execution = await executeOperation({
508
+ profileId: this.profileId,
509
+ operation,
510
+ context: {
511
+ ...context,
512
+ executeExternalOperation: this.executeExternalOperation,
513
+ },
514
+ });
515
+ if (!execution.ok) {
516
+ return { ok: false, code: execution.code || 'OPERATION_FAILED', message: execution.message || 'operation failed', data: { phase: 'execute', detail: execution } };
517
+ }
518
+
519
+ const post = await this.runValidation(operation, 'post', context);
520
+ if (!post.ok) {
521
+ return { ok: false, code: post.code || 'VALIDATION_FAILED', message: post.message || 'post validation failed', data: { phase: 'post', detail: post } };
522
+ }
523
+
524
+ return { ok: true, code: 'OPERATION_DONE', message: 'operation done', data: execution.data || execution };
525
+ }
526
+
527
+ async applyPacingBeforeAttempt(operation, attempt) {
528
+ const pacing = this.resolvePacing(operation);
529
+ if (this.isNavigationAction(operation?.action) && pacing.navigationMinIntervalMs > 0 && this.lastNavigationAt > 0) {
530
+ const elapsed = Date.now() - this.lastNavigationAt;
531
+ if (elapsed < pacing.navigationMinIntervalMs) {
532
+ const waitMs = pacing.navigationMinIntervalMs - elapsed;
533
+ if (waitMs > 0) {
534
+ this.log('autoscript:pacing_wait', {
535
+ operationId: operation.id,
536
+ action: operation.action,
537
+ attempt,
538
+ reason: 'navigation_cooldown',
539
+ waitMs,
540
+ });
541
+ await sleep(waitMs);
542
+ }
543
+ }
544
+ }
545
+
546
+ if (pacing.jitterMs > 0) {
547
+ const waitMs = Math.floor(this.random() * (pacing.jitterMs + 1));
548
+ if (waitMs > 0) {
549
+ this.log('autoscript:pacing_wait', {
550
+ operationId: operation.id,
551
+ action: operation.action,
552
+ attempt,
553
+ reason: 'jitter',
554
+ waitMs,
555
+ });
556
+ await sleep(waitMs);
557
+ }
558
+ }
559
+ }
560
+
561
+ async runRecovery(operation, event, failure) {
562
+ const checkpoint = operation.checkpoint || {};
563
+ const recovery = checkpoint.recovery || {};
564
+ const actions = Array.isArray(recovery.actions) ? recovery.actions : [];
565
+ const attempts = Math.max(0, Number(recovery.attempts) || 0);
566
+ if (attempts <= 0 || actions.length === 0) {
567
+ return { ok: false, code: 'RECOVERY_NOT_CONFIGURED', message: 'recovery not configured' };
568
+ }
569
+
570
+ const checkpointDoc = await captureCheckpoint({
571
+ profileId: this.profileId,
572
+ containerId: checkpoint.containerId || null,
573
+ selector: operation.params?.selector || null,
574
+ platform: 'xiaohongshu',
575
+ });
576
+ const baseCheckpoint = checkpointDoc?.data || {};
577
+
578
+ for (let i = 1; i <= attempts; i += 1) {
579
+ let allActionOk = true;
580
+ for (const action of actions) {
581
+ const restored = await restoreCheckpoint({
582
+ profileId: this.profileId,
583
+ checkpoint: baseCheckpoint,
584
+ action,
585
+ containerId: checkpoint.containerId || null,
586
+ selector: operation.params?.selector || null,
587
+ targetCheckpoint: checkpoint.targetCheckpoint || null,
588
+ platform: 'xiaohongshu',
589
+ });
590
+ this.log('autoscript:recovery_action', {
591
+ operationId: operation.id,
592
+ subscriptionId: event.subscriptionId || null,
593
+ action,
594
+ attempt: i,
595
+ ok: restored.ok,
596
+ code: restored.code || null,
597
+ message: restored.message || null,
598
+ });
599
+ if (!restored.ok) {
600
+ allActionOk = false;
601
+ }
602
+ }
603
+ if (allActionOk) {
604
+ return { ok: true, code: 'RECOVERY_DONE', message: 'recovery done', data: { attempts: i } };
605
+ }
606
+ }
607
+
608
+ return {
609
+ ok: false,
610
+ code: 'RECOVERY_EXHAUSTED',
611
+ message: 'recovery attempts exhausted',
612
+ data: { operationId: operation.id, failure },
613
+ };
614
+ }
615
+
616
+ async runOperation(operation, event) {
617
+ const retry = operation.retry || {};
618
+ const maxAttempts = Math.max(1, Number(retry.attempts) || 1);
619
+ const backoffMs = Math.max(0, Number(retry.backoffMs) || 0);
620
+
621
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
622
+ const context = {
623
+ runId: this.runId,
624
+ event,
625
+ attempt,
626
+ maxAttempts,
627
+ runtime: this.runtimeContext,
628
+ };
629
+
630
+ await this.applyPacingBeforeAttempt(operation, attempt);
631
+
632
+ if (!this.isTriggerStillValid(operation)) {
633
+ const trigger = operation?.trigger || {};
634
+ const subState = this.subscriptionState.get(trigger.subscriptionId) || null;
635
+ this.operationState.set(operation.id, {
636
+ status: 'skipped',
637
+ runs: Number(this.operationState.get(operation.id)?.runs || 0) + 1,
638
+ lastError: null,
639
+ updatedAt: nowIso(),
640
+ result: {
641
+ code: 'OPERATION_SKIPPED_STALE_TRIGGER',
642
+ trigger,
643
+ currentSubscriptionState: subState,
644
+ },
645
+ });
646
+ this.log('autoscript:operation_skipped', {
647
+ operationId: operation.id,
648
+ action: operation.action,
649
+ attempt,
650
+ reason: 'stale_trigger',
651
+ trigger,
652
+ currentSubscriptionState: subState,
653
+ });
654
+ return {
655
+ ok: true,
656
+ terminalState: 'skipped_stale',
657
+ result: {
658
+ ok: true,
659
+ code: 'OPERATION_SKIPPED_STALE_TRIGGER',
660
+ message: 'operation skipped because trigger is no longer valid',
661
+ data: {
662
+ trigger,
663
+ currentSubscriptionState: subState,
664
+ },
665
+ },
666
+ };
667
+ }
668
+
669
+ this.log('autoscript:operation_start', {
670
+ operationId: operation.id,
671
+ action: operation.action,
672
+ attempt,
673
+ maxAttempts,
674
+ trigger: operation.trigger,
675
+ subscriptionId: event.subscriptionId || null,
676
+ });
677
+
678
+ const startedAt = Date.now();
679
+ const timeoutMs = this.resolveTimeoutMs(operation);
680
+ const result = await withTimeout(
681
+ this.executeOnce(operation, context),
682
+ timeoutMs,
683
+ () => ({
684
+ ok: false,
685
+ code: 'OPERATION_TIMEOUT',
686
+ message: `operation timed out after ${timeoutMs}ms`,
687
+ data: { timeoutMs },
688
+ }),
689
+ );
690
+ const latencyMs = Date.now() - startedAt;
691
+ if (result.ok) {
692
+ if (this.isNavigationAction(operation?.action)) {
693
+ this.lastNavigationAt = Date.now();
694
+ }
695
+ this.operationState.set(operation.id, {
696
+ status: 'done',
697
+ runs: Number(this.operationState.get(operation.id)?.runs || 0) + 1,
698
+ lastError: null,
699
+ updatedAt: nowIso(),
700
+ result: result.data || null,
701
+ });
702
+ this.log('autoscript:operation_done', {
703
+ operationId: operation.id,
704
+ action: operation.action,
705
+ attempt,
706
+ latencyMs,
707
+ result: result.data || null,
708
+ });
709
+ // Re-evaluate graph on the same event so dependencies can continue in one trigger chain.
710
+ this.scheduleReadyOperations(event);
711
+ return { ok: true, terminalState: 'done', result };
712
+ }
713
+
714
+ if (this.shouldTreatAsStaleValidationSkip(operation, result)) {
715
+ const trigger = operation?.trigger || {};
716
+ const subState = this.subscriptionState.get(trigger.subscriptionId) || null;
717
+ this.operationState.set(operation.id, {
718
+ status: 'skipped',
719
+ runs: Number(this.operationState.get(operation.id)?.runs || 0) + 1,
720
+ lastError: null,
721
+ updatedAt: nowIso(),
722
+ result: {
723
+ code: 'OPERATION_SKIPPED_STALE_TRIGGER_PRE_VALIDATION',
724
+ trigger,
725
+ currentSubscriptionState: subState,
726
+ },
727
+ });
728
+ this.log('autoscript:operation_skipped', {
729
+ operationId: operation.id,
730
+ action: operation.action,
731
+ attempt,
732
+ reason: 'stale_trigger_pre_validation',
733
+ trigger,
734
+ currentSubscriptionState: subState,
735
+ validation: result.data?.detail || null,
736
+ });
737
+ return {
738
+ ok: true,
739
+ terminalState: 'skipped_stale_pre_validation',
740
+ result: {
741
+ ok: true,
742
+ code: 'OPERATION_SKIPPED_STALE_TRIGGER_PRE_VALIDATION',
743
+ message: 'operation skipped after pre-validation because trigger is no longer valid',
744
+ data: {
745
+ trigger,
746
+ currentSubscriptionState: subState,
747
+ },
748
+ },
749
+ };
750
+ }
751
+
752
+ const terminalDoneCode = extractTerminalDoneCode(result);
753
+ if (terminalDoneCode) {
754
+ this.operationState.set(operation.id, {
755
+ status: 'done',
756
+ runs: Number(this.operationState.get(operation.id)?.runs || 0) + 1,
757
+ lastError: null,
758
+ updatedAt: nowIso(),
759
+ result: { terminalDoneCode },
760
+ });
761
+ this.log('autoscript:operation_terminal', {
762
+ operationId: operation.id,
763
+ action: operation.action,
764
+ attempt,
765
+ latencyMs,
766
+ code: terminalDoneCode,
767
+ });
768
+ this.stop('script_complete');
769
+ return {
770
+ ok: true,
771
+ terminalState: 'done_terminal',
772
+ result: {
773
+ ok: true,
774
+ code: terminalDoneCode,
775
+ message: 'autoscript completed',
776
+ data: { terminalDoneCode },
777
+ },
778
+ };
779
+ }
780
+
781
+ this.operationState.set(operation.id, {
782
+ status: 'failed',
783
+ runs: Number(this.operationState.get(operation.id)?.runs || 0) + 1,
784
+ lastError: result.message || 'operation failed',
785
+ updatedAt: nowIso(),
786
+ result: null,
787
+ });
788
+ this.log('autoscript:operation_error', {
789
+ operationId: operation.id,
790
+ action: operation.action,
791
+ attempt,
792
+ latencyMs,
793
+ code: result.code || 'OPERATION_FAILED',
794
+ message: result.message || 'operation failed',
795
+ });
796
+
797
+ const recoveryResult = await this.runRecovery(operation, event, result);
798
+ if (recoveryResult.ok) {
799
+ this.log('autoscript:operation_recovered', {
800
+ operationId: operation.id,
801
+ action: operation.action,
802
+ attempt,
803
+ code: recoveryResult.code,
804
+ });
805
+ } else {
806
+ this.log('autoscript:operation_recovery_failed', {
807
+ operationId: operation.id,
808
+ action: operation.action,
809
+ attempt,
810
+ code: recoveryResult.code,
811
+ message: recoveryResult.message,
812
+ });
813
+ }
814
+
815
+ if (attempt < maxAttempts) {
816
+ if (backoffMs > 0) await sleep(backoffMs);
817
+ continue;
818
+ }
819
+
820
+ const impact = this.impactEngine.applyFailure({ operation, event });
821
+ this.log('autoscript:impact', {
822
+ operationId: operation.id,
823
+ action: operation.action,
824
+ scope: impact.scope,
825
+ scriptStopped: impact.scriptStopped,
826
+ blockedSubscriptions: impact.blockedSubscriptions,
827
+ blockedOperations: impact.blockedOperations,
828
+ });
829
+
830
+ if (impact.scriptStopped) {
831
+ this.stop('script_failure');
832
+ }
833
+ return { ok: false, terminalState: 'failed', result };
834
+ }
835
+
836
+ return { ok: false, terminalState: 'failed', result: null };
837
+ }
838
+
839
+ enqueueOperation(operation, event) {
840
+ if (this.pendingOperations.has(operation.id)) return;
841
+ if (!this.state.active) return;
842
+
843
+ const scheduleState = this.operationScheduleState.get(operation.id) || {};
844
+ scheduleState.lastScheduledAt = Date.now();
845
+ scheduleState.lastEventAt = Date.now();
846
+ scheduleState.lastTriggerKey = this.buildTriggerKey(operation, event);
847
+ const scheduledAppearCount = this.getTriggerAppearCount(operation);
848
+ if (Number.isFinite(scheduledAppearCount) && scheduledAppearCount > 0) {
849
+ scheduleState.lastScheduledAppearCount = scheduledAppearCount;
850
+ }
851
+ this.operationScheduleState.set(operation.id, scheduleState);
852
+ this.forceRunOperationIds.delete(operation.id);
853
+
854
+ this.pendingOperations.add(operation.id);
855
+ this.operationQueue = this.operationQueue
856
+ .then(async () => {
857
+ if (!this.state.active) return;
858
+ const innerState = this.operationScheduleState.get(operation.id) || {};
859
+ innerState.lastStartedAt = Date.now();
860
+ this.operationScheduleState.set(operation.id, innerState);
861
+ const outcome = await this.runOperation(operation, event);
862
+ if (
863
+ outcome
864
+ && operation?.oncePerAppear === true
865
+ && Number.isFinite(scheduledAppearCount)
866
+ && scheduledAppearCount > 0
867
+ && outcome.terminalState !== 'skipped_stale'
868
+ && outcome.terminalState !== 'skipped_stale_pre_validation'
869
+ ) {
870
+ const completedState = this.operationScheduleState.get(operation.id) || {};
871
+ completedState.lastCompletedAppearCount = scheduledAppearCount;
872
+ this.operationScheduleState.set(operation.id, completedState);
873
+ }
874
+ })
875
+ .finally(() => {
876
+ this.pendingOperations.delete(operation.id);
877
+ });
878
+ }
879
+
880
+ async handleEvent(event) {
881
+ if (!this.state.active) return;
882
+ if (event.subscriptionId) {
883
+ const prev = this.subscriptionState.get(event.subscriptionId) || { exists: false, appearCount: 0, lastEventAt: null };
884
+ const next = { ...prev, lastEventAt: event.timestamp || nowIso() };
885
+ if (event.type === 'appear') {
886
+ next.exists = true;
887
+ next.appearCount = Number(prev.appearCount || 0) + 1;
888
+ next.version = Number(prev.version || 0) + 1;
889
+ this.resetCycleOperationsForSubscription(event.subscriptionId);
890
+ } else if (event.type === 'disappear') {
891
+ next.exists = false;
892
+ next.version = Number(prev.version || 0) + 1;
893
+ } else if (event.type === 'exist') {
894
+ next.exists = true;
895
+ } else if (event.type === 'change') {
896
+ next.exists = Number(event.count || 0) > 0 || prev.exists === true;
897
+ next.version = Number(prev.version || 0) + 1;
898
+ }
899
+ this.subscriptionState.set(event.subscriptionId, next);
900
+ }
901
+
902
+ this.scheduleReadyOperations(event);
903
+ }
904
+
905
+ async start() {
906
+ if (this.state.active) {
907
+ throw new Error('Autoscript runtime already running');
908
+ }
909
+ if (!this.profileId) {
910
+ throw new Error('profileId is required');
911
+ }
912
+ this.state.active = true;
913
+ this.state.reason = null;
914
+ this.state.startedAt = nowIso();
915
+ this.donePromise = new Promise((resolve) => {
916
+ this.resolveDone = resolve;
917
+ });
918
+
919
+ this.log('autoscript:start', {
920
+ name: this.script.name,
921
+ subscriptions: this.script.subscriptions.length,
922
+ operations: this.script.operations.length,
923
+ throttle: this.script.throttle,
924
+ });
925
+
926
+ if (this.mockEvents) {
927
+ this.watchHandle = { stop: () => {} };
928
+ const events = this.mockEvents.map((item) => {
929
+ if (!item || typeof item !== 'object') return null;
930
+ const type = String(item.type || '').trim();
931
+ if (!type) return null;
932
+ return {
933
+ type,
934
+ subscriptionId: item.subscriptionId ? String(item.subscriptionId) : null,
935
+ selector: item.selector ? String(item.selector) : null,
936
+ count: item.count ?? null,
937
+ timestamp: item.timestamp || nowIso(),
938
+ delayMs: Math.max(0, Number(item.delayMs ?? this.mockEventBaseDelayMs) || this.mockEventBaseDelayMs),
939
+ };
940
+ }).filter(Boolean);
941
+
942
+ (async () => {
943
+ for (const event of events) {
944
+ if (!this.state.active) return;
945
+ if (event.delayMs > 0) await sleep(event.delayMs);
946
+ this.log('autoscript:event', {
947
+ type: event.type,
948
+ subscriptionId: event.subscriptionId || null,
949
+ selector: event.selector || null,
950
+ count: event.count ?? null,
951
+ });
952
+ await this.handleEvent(event);
953
+ }
954
+ if (this.stopWhenMockEventsExhausted && this.state.active) {
955
+ // Allow startup-trigger scheduling to enqueue operations before drain check.
956
+ await Promise.resolve();
957
+ await this.operationQueue;
958
+ if (this.state.active) this.stop('mock_events_exhausted');
959
+ }
960
+ })().catch((err) => {
961
+ this.log('autoscript:watch_error', {
962
+ code: 'MOCK_EVENT_FEED_FAILED',
963
+ message: err?.message || String(err),
964
+ });
965
+ if (this.state.active) this.stop('mock_event_feed_failure');
966
+ });
967
+ } else {
968
+ this.watchHandle = await watchSubscriptions({
969
+ profileId: this.profileId,
970
+ subscriptions: this.script.subscriptions,
971
+ throttle: this.script.throttle,
972
+ onEvent: async (event) => {
973
+ this.log('autoscript:event', {
974
+ type: event.type,
975
+ subscriptionId: event.subscriptionId || null,
976
+ selector: event.selector || null,
977
+ count: event.count ?? null,
978
+ });
979
+ await this.handleEvent(event);
980
+ },
981
+ onError: (err) => {
982
+ this.log('autoscript:watch_error', {
983
+ code: 'SUBSCRIPTION_WATCH_FAILED',
984
+ message: err?.message || String(err),
985
+ });
986
+ },
987
+ });
988
+ }
989
+
990
+ await this.handleEvent({ type: 'startup', timestamp: nowIso() });
991
+ return {
992
+ runId: this.runId,
993
+ stop: (reason = 'stopped') => this.stop(reason),
994
+ done: this.donePromise,
995
+ };
996
+ }
997
+
998
+ stop(reason = 'stopped') {
999
+ if (!this.state.active) return;
1000
+ this.state.active = false;
1001
+ this.state.reason = reason;
1002
+ this.state.stoppedAt = nowIso();
1003
+ if (this.watchHandle?.stop) this.watchHandle.stop();
1004
+ this.log('autoscript:stop', { reason });
1005
+ if (this.resolveDone) {
1006
+ this.resolveDone({
1007
+ runId: this.runId,
1008
+ reason,
1009
+ startedAt: this.state.startedAt,
1010
+ stoppedAt: this.state.stoppedAt,
1011
+ });
1012
+ this.resolveDone = null;
1013
+ }
1014
+ }
1015
+ }