@web-auto/camo 0.1.3 → 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 (50) hide show
  1. package/README.md +137 -0
  2. package/package.json +2 -1
  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 +185 -79
  19. package/src/commands/autoscript.mjs +1100 -0
  20. package/src/commands/browser.mjs +20 -4
  21. package/src/commands/container.mjs +298 -75
  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 +165 -24
  26. package/src/container/element-filter.mjs +51 -5
  27. package/src/container/runtime-core/checkpoint.mjs +195 -0
  28. package/src/container/runtime-core/index.mjs +21 -0
  29. package/src/container/runtime-core/operations/index.mjs +351 -0
  30. package/src/container/runtime-core/operations/selector-scripts.mjs +68 -0
  31. package/src/container/runtime-core/operations/tab-pool.mjs +544 -0
  32. package/src/container/runtime-core/operations/viewport.mjs +143 -0
  33. package/src/container/runtime-core/subscription.mjs +87 -0
  34. package/src/container/runtime-core/utils.mjs +94 -0
  35. package/src/container/runtime-core/validation.mjs +127 -0
  36. package/src/container/runtime-core.mjs +1 -0
  37. package/src/container/subscription-registry.mjs +459 -0
  38. package/src/core/actions.mjs +573 -0
  39. package/src/core/browser.mjs +270 -0
  40. package/src/core/index.mjs +53 -0
  41. package/src/core/utils.mjs +87 -0
  42. package/src/events/daemon-entry.mjs +33 -0
  43. package/src/events/daemon.mjs +80 -0
  44. package/src/events/progress-log.mjs +109 -0
  45. package/src/events/ws-server.mjs +239 -0
  46. package/src/lib/client.mjs +8 -5
  47. package/src/lifecycle/session-registry.mjs +8 -4
  48. package/src/lifecycle/session-watchdog.mjs +220 -0
  49. package/src/utils/browser-service.mjs +232 -9
  50. package/src/utils/help.mjs +26 -3
@@ -0,0 +1,239 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import http from 'node:http';
4
+ import { ensureProgressEventStore, getProgressEventsFile, readRecentProgressEvents } from './progress-log.mjs';
5
+
6
+ const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
7
+ const DEFAULT_POLL_MS = 220;
8
+ const DEFAULT_REPLAY_LIMIT = 50;
9
+
10
+ function parseList(value) {
11
+ return String(value || '')
12
+ .split(',')
13
+ .map((item) => item.trim())
14
+ .filter(Boolean);
15
+ }
16
+
17
+ function buildFilter(reqUrl = '/') {
18
+ const url = new URL(reqUrl, 'http://127.0.0.1');
19
+ const events = new Set(parseList(url.searchParams.get('events')));
20
+ return {
21
+ profileId: url.searchParams.get('profileId') || null,
22
+ runId: url.searchParams.get('runId') || null,
23
+ mode: url.searchParams.get('mode') || null,
24
+ events,
25
+ replay: Math.max(0, Number(url.searchParams.get('replay') ?? DEFAULT_REPLAY_LIMIT) || DEFAULT_REPLAY_LIMIT),
26
+ };
27
+ }
28
+
29
+ function matchesFilter(filter, event) {
30
+ if (!event || typeof event !== 'object') return false;
31
+ if (filter.profileId && String(event.profileId || '') !== filter.profileId) return false;
32
+ if (filter.runId && String(event.runId || '') !== filter.runId) return false;
33
+ if (filter.mode && String(event.mode || '') !== filter.mode) return false;
34
+ if (filter.events.size > 0 && !filter.events.has(String(event.event || ''))) return false;
35
+ return true;
36
+ }
37
+
38
+ function frameText(text) {
39
+ const payload = Buffer.from(String(text), 'utf8');
40
+ const length = payload.length;
41
+ if (length < 126) {
42
+ return Buffer.concat([Buffer.from([0x81, length]), payload]);
43
+ }
44
+ if (length < 65536) {
45
+ const header = Buffer.alloc(4);
46
+ header[0] = 0x81;
47
+ header[1] = 126;
48
+ header.writeUInt16BE(length, 2);
49
+ return Buffer.concat([header, payload]);
50
+ }
51
+ const header = Buffer.alloc(10);
52
+ header[0] = 0x81;
53
+ header[1] = 127;
54
+ header.writeBigUInt64BE(BigInt(length), 2);
55
+ return Buffer.concat([header, payload]);
56
+ }
57
+
58
+ function framePong() {
59
+ return Buffer.from([0x8A, 0x00]);
60
+ }
61
+
62
+ function frameClose() {
63
+ return Buffer.from([0x88, 0x00]);
64
+ }
65
+
66
+ function parseOpcode(chunk) {
67
+ if (!Buffer.isBuffer(chunk) || chunk.length === 0) return null;
68
+ return chunk[0] & 0x0f;
69
+ }
70
+
71
+ function acceptWebSocketKey(key) {
72
+ return crypto.createHash('sha1').update(`${key}${WS_GUID}`).digest('base64');
73
+ }
74
+
75
+ export function createProgressWsServer({
76
+ host = '127.0.0.1',
77
+ port = 7788,
78
+ pollMs = DEFAULT_POLL_MS,
79
+ fromStart = false,
80
+ } = {}) {
81
+ ensureProgressEventStore();
82
+ const eventsFile = getProgressEventsFile();
83
+ const clients = new Set();
84
+ let carry = '';
85
+ let cursor = fromStart ? 0 : fs.statSync(eventsFile).size;
86
+ let pollTimer = null;
87
+
88
+ const server = http.createServer((req, res) => {
89
+ if (req.url === '/health') {
90
+ res.writeHead(200, { 'Content-Type': 'application/json' });
91
+ res.end(JSON.stringify({
92
+ ok: true,
93
+ wsPath: '/events',
94
+ clients: clients.size,
95
+ file: eventsFile,
96
+ }));
97
+ return;
98
+ }
99
+ res.writeHead(200, { 'Content-Type': 'application/json' });
100
+ res.end(JSON.stringify({
101
+ ok: true,
102
+ message: 'Use WebSocket upgrade on /events',
103
+ ws: `ws://${host}:${port}/events`,
104
+ file: eventsFile,
105
+ }));
106
+ });
107
+
108
+ const sendEvent = (client, event) => {
109
+ if (!client.socket.writable) return;
110
+ if (!matchesFilter(client.filter, event)) return;
111
+ client.socket.write(frameText(JSON.stringify(event)));
112
+ };
113
+
114
+ const broadcast = (event) => {
115
+ for (const client of clients) {
116
+ sendEvent(client, event);
117
+ }
118
+ };
119
+
120
+ const pollEvents = () => {
121
+ let stat = null;
122
+ try {
123
+ stat = fs.statSync(eventsFile);
124
+ } catch {
125
+ return;
126
+ }
127
+ if (stat.size < cursor) {
128
+ cursor = 0;
129
+ carry = '';
130
+ }
131
+ if (stat.size === cursor) return;
132
+
133
+ const fd = fs.openSync(eventsFile, 'r');
134
+ try {
135
+ const length = stat.size - cursor;
136
+ const buffer = Buffer.alloc(length);
137
+ fs.readSync(fd, buffer, 0, length, cursor);
138
+ cursor = stat.size;
139
+ carry += buffer.toString('utf8');
140
+ const lines = carry.split('\n');
141
+ carry = lines.pop() || '';
142
+ for (const line of lines) {
143
+ const raw = line.trim();
144
+ if (!raw) continue;
145
+ try {
146
+ const event = JSON.parse(raw);
147
+ broadcast(event);
148
+ } catch {
149
+ // ignore malformed lines
150
+ }
151
+ }
152
+ } finally {
153
+ fs.closeSync(fd);
154
+ }
155
+ };
156
+
157
+ server.on('upgrade', (req, socket) => {
158
+ try {
159
+ const reqUrl = req.url || '/';
160
+ const parsed = new URL(reqUrl, 'http://127.0.0.1');
161
+ if (parsed.pathname !== '/events') {
162
+ socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
163
+ socket.destroy();
164
+ return;
165
+ }
166
+
167
+ const key = req.headers['sec-websocket-key'];
168
+ if (!key || Array.isArray(key)) {
169
+ socket.write('HTTP/1.1 400 Bad Request\r\n\r\n');
170
+ socket.destroy();
171
+ return;
172
+ }
173
+
174
+ const accept = acceptWebSocketKey(key);
175
+ socket.write([
176
+ 'HTTP/1.1 101 Switching Protocols',
177
+ 'Upgrade: websocket',
178
+ 'Connection: Upgrade',
179
+ `Sec-WebSocket-Accept: ${accept}`,
180
+ '\r\n',
181
+ ].join('\r\n'));
182
+
183
+ const filter = buildFilter(reqUrl);
184
+ const client = { socket, filter };
185
+ clients.add(client);
186
+
187
+ if (filter.replay > 0) {
188
+ const replay = readRecentProgressEvents(filter.replay);
189
+ for (const event of replay) {
190
+ sendEvent(client, event);
191
+ }
192
+ }
193
+
194
+ socket.on('data', (chunk) => {
195
+ const opcode = parseOpcode(chunk);
196
+ if (opcode === 0x8) {
197
+ socket.end(frameClose());
198
+ } else if (opcode === 0x9) {
199
+ socket.write(framePong());
200
+ }
201
+ });
202
+ socket.on('error', () => {
203
+ clients.delete(client);
204
+ });
205
+ socket.on('close', () => {
206
+ clients.delete(client);
207
+ });
208
+ socket.on('end', () => {
209
+ clients.delete(client);
210
+ });
211
+ } catch {
212
+ socket.destroy();
213
+ }
214
+ });
215
+
216
+ return {
217
+ async start() {
218
+ await new Promise((resolve, reject) => {
219
+ server.once('error', reject);
220
+ server.listen(Number(port), host, resolve);
221
+ });
222
+ pollTimer = setInterval(pollEvents, Math.max(80, Number(pollMs) || DEFAULT_POLL_MS));
223
+ return {
224
+ host,
225
+ port: Number(port),
226
+ wsUrl: `ws://${host}:${port}/events`,
227
+ file: eventsFile,
228
+ };
229
+ },
230
+ async stop() {
231
+ if (pollTimer) {
232
+ clearInterval(pollTimer);
233
+ pollTimer = null;
234
+ }
235
+ await new Promise((resolve) => server.close(resolve));
236
+ },
237
+ };
238
+ }
239
+
@@ -1,6 +1,11 @@
1
1
  // Camo Container Client - High-level API for container subscription
2
2
 
3
- import { callAPI, getSessionByProfile, checkBrowserService } from '../utils/browser-service.mjs';
3
+ import {
4
+ checkBrowserService,
5
+ getDomSnapshotByProfile,
6
+ getSessionByProfile,
7
+ getViewportByProfile,
8
+ } from '../utils/browser-service.mjs';
4
9
  import { getDefaultProfile } from '../utils/config.mjs';
5
10
  import { getChangeNotifier } from '../container/change-notifier.mjs';
6
11
  import { createElementFilter } from '../container/element-filter.mjs';
@@ -36,8 +41,7 @@ export class CamoContainerClient {
36
41
  async getSnapshot() {
37
42
  await this.ensureSession();
38
43
 
39
- const result = await callAPI(`/session/${this.session.session_id}/dom-tree`, { method: 'POST' });
40
- this.lastSnapshot = result.dom_tree || result;
44
+ this.lastSnapshot = await getDomSnapshotByProfile(this.profileId);
41
45
  return this.lastSnapshot;
42
46
  }
43
47
 
@@ -45,8 +49,7 @@ export class CamoContainerClient {
45
49
  await this.ensureSession();
46
50
 
47
51
  try {
48
- const result = await callAPI(`/session/${this.session.session_id}/viewport`);
49
- this.viewport = result.viewport || this.viewport;
52
+ this.viewport = await getViewportByProfile(this.profileId);
50
53
  } catch {}
51
54
 
52
55
  return this.viewport;
@@ -105,10 +105,14 @@ export function markSessionActive(profileId, updates = {}) {
105
105
  export function markSessionClosed(profileId) {
106
106
  const sessionFile = getSessionFile(profileId);
107
107
  if (fs.existsSync(sessionFile)) {
108
- const existing = JSON.parse(fs.readFileSync(sessionFile, 'utf-8'));
109
- existing.status = 'closed';
110
- existing.closedAt = Date.now();
111
- fs.writeFileSync(sessionFile, JSON.stringify(existing, null, 2));
108
+ try {
109
+ const existing = JSON.parse(fs.readFileSync(sessionFile, 'utf-8'));
110
+ existing.status = 'closed';
111
+ existing.closedAt = Date.now();
112
+ fs.writeFileSync(sessionFile, JSON.stringify(existing, null, 2));
113
+ } catch {
114
+ // Best-effort close: continue with removal even for corrupted metadata.
115
+ }
112
116
  }
113
117
  return unregisterSession(profileId);
114
118
  }
@@ -0,0 +1,220 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { spawn } from 'node:child_process';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { callAPI } from '../utils/browser-service.mjs';
8
+ import { releaseLock } from './lock.mjs';
9
+ import { getSessionInfo, markSessionClosed } from './session-registry.mjs';
10
+
11
+ const WATCHDOG_DIR = path.join(os.homedir(), '.webauto', 'run', 'camo-watchdogs');
12
+
13
+ function ensureWatchdogDir() {
14
+ if (!fs.existsSync(WATCHDOG_DIR)) {
15
+ fs.mkdirSync(WATCHDOG_DIR, { recursive: true });
16
+ }
17
+ }
18
+
19
+ function getWatchdogFile(profileId) {
20
+ ensureWatchdogDir();
21
+ return path.join(WATCHDOG_DIR, `${profileId}.json`);
22
+ }
23
+
24
+ function readWatchdogRecord(profileId) {
25
+ const file = getWatchdogFile(profileId);
26
+ if (!fs.existsSync(file)) return null;
27
+ try {
28
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ function writeWatchdogRecord(profileId, record) {
35
+ const file = getWatchdogFile(profileId);
36
+ fs.writeFileSync(file, JSON.stringify(record, null, 2));
37
+ }
38
+
39
+ function removeWatchdogRecord(profileId) {
40
+ const file = getWatchdogFile(profileId);
41
+ if (fs.existsSync(file)) {
42
+ fs.unlinkSync(file);
43
+ }
44
+ }
45
+
46
+ function isProcessAlive(pid) {
47
+ const target = Number(pid);
48
+ if (!Number.isFinite(target) || target <= 0) return false;
49
+ try {
50
+ process.kill(target, 0);
51
+ return true;
52
+ } catch {
53
+ return false;
54
+ }
55
+ }
56
+
57
+ function sleep(ms) {
58
+ return new Promise((resolve) => setTimeout(resolve, ms));
59
+ }
60
+
61
+ function isBlankUrl(url) {
62
+ const text = String(url || '').trim().toLowerCase();
63
+ return text === '' || text === 'about:blank' || text === 'about:blank#blocked';
64
+ }
65
+
66
+ function shouldExitMonitor(profileId) {
67
+ const info = getSessionInfo(profileId);
68
+ return !info || info.status !== 'active';
69
+ }
70
+
71
+ async function cleanupSession(profileId) {
72
+ await callAPI('stop', { profileId }).catch(() => {});
73
+ releaseLock(profileId);
74
+ markSessionClosed(profileId);
75
+ }
76
+
77
+ async function runMonitor(profileId, options = {}) {
78
+ const intervalMs = Math.max(500, Number(options.intervalMs) || 1200);
79
+ const emptyThreshold = Math.max(1, Number(options.emptyThreshold) || 2);
80
+ const blankThreshold = Math.max(1, Number(options.blankThreshold) || 3);
81
+
82
+ let seenAnyPage = false;
83
+ let seenNonBlankPage = false;
84
+ let emptyStreak = 0;
85
+ let blankOnlyStreak = 0;
86
+
87
+ while (true) {
88
+ if (shouldExitMonitor(profileId)) return;
89
+
90
+ let sessions = [];
91
+ try {
92
+ const status = await callAPI('getStatus', {});
93
+ sessions = Array.isArray(status?.sessions) ? status.sessions : [];
94
+ } catch {
95
+ // Service unavailable; exit monitor silently.
96
+ return;
97
+ }
98
+
99
+ const liveSession = sessions.find((item) => item?.profileId === profileId);
100
+ if (!liveSession) {
101
+ releaseLock(profileId);
102
+ markSessionClosed(profileId);
103
+ return;
104
+ }
105
+
106
+ let pages = [];
107
+ try {
108
+ const listed = await callAPI('page:list', { profileId });
109
+ pages = Array.isArray(listed?.pages) ? listed.pages : [];
110
+ } catch {
111
+ // Session lookup failed in page:list: treat as closed.
112
+ releaseLock(profileId);
113
+ markSessionClosed(profileId);
114
+ return;
115
+ }
116
+
117
+ if (pages.length > 0) {
118
+ seenAnyPage = true;
119
+ if (pages.some((item) => !isBlankUrl(item?.url))) {
120
+ seenNonBlankPage = true;
121
+ }
122
+ }
123
+
124
+ const blankOnly = pages.length > 0 && pages.every((item) => isBlankUrl(item?.url));
125
+
126
+ if (seenAnyPage && pages.length === 0) {
127
+ emptyStreak += 1;
128
+ } else {
129
+ emptyStreak = 0;
130
+ }
131
+
132
+ if (seenNonBlankPage && blankOnly) {
133
+ blankOnlyStreak += 1;
134
+ } else {
135
+ blankOnlyStreak = 0;
136
+ }
137
+
138
+ if (emptyStreak >= emptyThreshold || blankOnlyStreak >= blankThreshold) {
139
+ await cleanupSession(profileId);
140
+ return;
141
+ }
142
+
143
+ await sleep(intervalMs);
144
+ }
145
+ }
146
+
147
+ export function startSessionWatchdog(profileId) {
148
+ const normalized = String(profileId || '').trim();
149
+ if (!normalized) return { ok: false, reason: 'profile_required' };
150
+
151
+ const existing = readWatchdogRecord(normalized);
152
+ if (existing?.pid && isProcessAlive(existing.pid)) {
153
+ return { ok: true, started: false, pid: existing.pid };
154
+ }
155
+
156
+ const commandPath = fileURLToPath(new URL('../cli.mjs', import.meta.url));
157
+ const child = spawn(process.execPath, [commandPath, '__session-watchdog', normalized], {
158
+ detached: true,
159
+ stdio: 'ignore',
160
+ env: {
161
+ ...process.env,
162
+ CAMO_WATCHDOG_CHILD: '1',
163
+ },
164
+ });
165
+ child.unref();
166
+
167
+ const childPid = Number(child.pid);
168
+ if (!Number.isFinite(childPid) || childPid <= 0) {
169
+ return { ok: false, reason: 'spawn_failed' };
170
+ }
171
+
172
+ writeWatchdogRecord(normalized, {
173
+ profileId: normalized,
174
+ pid: childPid,
175
+ startedAt: Date.now(),
176
+ });
177
+ return { ok: true, started: true, pid: childPid };
178
+ }
179
+
180
+ export function stopSessionWatchdog(profileId) {
181
+ const normalized = String(profileId || '').trim();
182
+ if (!normalized) return false;
183
+ const record = readWatchdogRecord(normalized);
184
+ if (record?.pid && isProcessAlive(record.pid)) {
185
+ try {
186
+ process.kill(record.pid, 'SIGTERM');
187
+ } catch {
188
+ // Ignore kill failure and still cleanup record.
189
+ }
190
+ }
191
+ removeWatchdogRecord(normalized);
192
+ return true;
193
+ }
194
+
195
+ export function stopAllSessionWatchdogs() {
196
+ ensureWatchdogDir();
197
+ const files = fs.readdirSync(WATCHDOG_DIR).filter((name) => name.endsWith('.json'));
198
+ for (const file of files) {
199
+ const profileId = file.slice(0, -'.json'.length);
200
+ stopSessionWatchdog(profileId);
201
+ }
202
+ }
203
+
204
+ export async function handleSessionWatchdogCommand(args) {
205
+ const profileId = String(args[1] || '').trim();
206
+ if (!profileId) {
207
+ throw new Error('Usage: camo __session-watchdog <profileId>');
208
+ }
209
+
210
+ const cleanup = () => removeWatchdogRecord(profileId);
211
+ process.on('SIGINT', cleanup);
212
+ process.on('SIGTERM', cleanup);
213
+ process.on('exit', cleanup);
214
+
215
+ try {
216
+ await runMonitor(profileId);
217
+ } finally {
218
+ cleanup();
219
+ }
220
+ }