@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.
- package/README.md +137 -0
- package/package.json +2 -1
- package/scripts/check-file-size.mjs +80 -0
- package/scripts/file-size-policy.json +8 -0
- package/src/autoscript/action-providers/index.mjs +9 -0
- package/src/autoscript/action-providers/xhs/comments.mjs +412 -0
- package/src/autoscript/action-providers/xhs/common.mjs +77 -0
- package/src/autoscript/action-providers/xhs/detail.mjs +181 -0
- package/src/autoscript/action-providers/xhs/interaction.mjs +466 -0
- package/src/autoscript/action-providers/xhs/like-rules.mjs +57 -0
- package/src/autoscript/action-providers/xhs/persistence.mjs +167 -0
- package/src/autoscript/action-providers/xhs/search.mjs +174 -0
- package/src/autoscript/action-providers/xhs.mjs +133 -0
- package/src/autoscript/impact-engine.mjs +78 -0
- package/src/autoscript/runtime.mjs +1015 -0
- package/src/autoscript/schema.mjs +370 -0
- package/src/autoscript/xhs-unified-template.mjs +931 -0
- package/src/cli.mjs +185 -79
- package/src/commands/autoscript.mjs +1100 -0
- package/src/commands/browser.mjs +20 -4
- package/src/commands/container.mjs +298 -75
- package/src/commands/events.mjs +152 -0
- package/src/commands/lifecycle.mjs +17 -3
- package/src/commands/window.mjs +32 -1
- package/src/container/change-notifier.mjs +165 -24
- package/src/container/element-filter.mjs +51 -5
- package/src/container/runtime-core/checkpoint.mjs +195 -0
- package/src/container/runtime-core/index.mjs +21 -0
- package/src/container/runtime-core/operations/index.mjs +351 -0
- package/src/container/runtime-core/operations/selector-scripts.mjs +68 -0
- package/src/container/runtime-core/operations/tab-pool.mjs +544 -0
- package/src/container/runtime-core/operations/viewport.mjs +143 -0
- package/src/container/runtime-core/subscription.mjs +87 -0
- package/src/container/runtime-core/utils.mjs +94 -0
- package/src/container/runtime-core/validation.mjs +127 -0
- package/src/container/runtime-core.mjs +1 -0
- package/src/container/subscription-registry.mjs +459 -0
- package/src/core/actions.mjs +573 -0
- package/src/core/browser.mjs +270 -0
- package/src/core/index.mjs +53 -0
- package/src/core/utils.mjs +87 -0
- package/src/events/daemon-entry.mjs +33 -0
- package/src/events/daemon.mjs +80 -0
- package/src/events/progress-log.mjs +109 -0
- package/src/events/ws-server.mjs +239 -0
- package/src/lib/client.mjs +8 -5
- package/src/lifecycle/session-registry.mjs +8 -4
- package/src/lifecycle/session-watchdog.mjs +220 -0
- package/src/utils/browser-service.mjs +232 -9
- package/src/utils/help.mjs +26 -3
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { callAPI, getDomSnapshotByProfile } from '../../utils/browser-service.mjs';
|
|
2
|
+
import {
|
|
3
|
+
asErrorPayload,
|
|
4
|
+
buildSelectorCheck,
|
|
5
|
+
ensureActiveSession,
|
|
6
|
+
getCurrentUrl,
|
|
7
|
+
isCheckpointRiskUrl,
|
|
8
|
+
maybeSelector,
|
|
9
|
+
normalizeArray,
|
|
10
|
+
} from './utils.mjs';
|
|
11
|
+
|
|
12
|
+
export const XHS_CHECKPOINTS = {
|
|
13
|
+
search_ready: [
|
|
14
|
+
'#search-input',
|
|
15
|
+
'input.search-input',
|
|
16
|
+
'.search-result-list',
|
|
17
|
+
],
|
|
18
|
+
home_ready: [
|
|
19
|
+
'.feeds-page',
|
|
20
|
+
'.note-item',
|
|
21
|
+
],
|
|
22
|
+
detail_ready: [
|
|
23
|
+
'.note-scroller',
|
|
24
|
+
'.note-content',
|
|
25
|
+
'.interaction-container',
|
|
26
|
+
],
|
|
27
|
+
comments_ready: [
|
|
28
|
+
'.comments-container',
|
|
29
|
+
'.comment-item',
|
|
30
|
+
],
|
|
31
|
+
login_guard: [
|
|
32
|
+
'.login-container',
|
|
33
|
+
'.login-dialog',
|
|
34
|
+
'#login-container',
|
|
35
|
+
],
|
|
36
|
+
risk_control: [
|
|
37
|
+
'.qrcode-box',
|
|
38
|
+
'.captcha-container',
|
|
39
|
+
'[class*="captcha"]',
|
|
40
|
+
],
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export async function detectCheckpoint({ profileId, platform = 'xiaohongshu' }) {
|
|
44
|
+
if (platform !== 'xiaohongshu') {
|
|
45
|
+
return asErrorPayload('UNSUPPORTED_PLATFORM', `Unsupported platform: ${platform}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const session = await ensureActiveSession(profileId);
|
|
50
|
+
const resolvedProfile = session.profileId || profileId;
|
|
51
|
+
const [url, snapshot] = await Promise.all([
|
|
52
|
+
getCurrentUrl(resolvedProfile),
|
|
53
|
+
getDomSnapshotByProfile(resolvedProfile),
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
const signals = [];
|
|
57
|
+
const counter = {};
|
|
58
|
+
const addCount = (label, selectors) => {
|
|
59
|
+
for (const css of selectors) {
|
|
60
|
+
const count = buildSelectorCheck(snapshot, css).length;
|
|
61
|
+
if (count > 0) {
|
|
62
|
+
counter[css] = count;
|
|
63
|
+
signals.push(`${label}:${css}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
addCount('search_ready', XHS_CHECKPOINTS.search_ready);
|
|
69
|
+
addCount('home_ready', XHS_CHECKPOINTS.home_ready);
|
|
70
|
+
addCount('detail_ready', XHS_CHECKPOINTS.detail_ready);
|
|
71
|
+
addCount('comments_ready', XHS_CHECKPOINTS.comments_ready);
|
|
72
|
+
addCount('login_guard', XHS_CHECKPOINTS.login_guard);
|
|
73
|
+
addCount('risk_control', XHS_CHECKPOINTS.risk_control);
|
|
74
|
+
|
|
75
|
+
let checkpoint = 'unknown';
|
|
76
|
+
if (!url || !url.includes('xiaohongshu.com')) checkpoint = 'offsite';
|
|
77
|
+
else if (isCheckpointRiskUrl(url)) checkpoint = 'risk_control';
|
|
78
|
+
else if (signals.some((item) => item.startsWith('login_guard:'))) checkpoint = 'login_guard';
|
|
79
|
+
else if (signals.some((item) => item.startsWith('comments_ready:'))) checkpoint = 'comments_ready';
|
|
80
|
+
else if (signals.some((item) => item.startsWith('detail_ready:'))) checkpoint = 'detail_ready';
|
|
81
|
+
else if (signals.some((item) => item.startsWith('search_ready:'))) checkpoint = 'search_ready';
|
|
82
|
+
else if (signals.some((item) => item.startsWith('home_ready:'))) checkpoint = 'home_ready';
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
ok: true,
|
|
86
|
+
code: 'CHECKPOINT_DETECTED',
|
|
87
|
+
message: 'Checkpoint detected',
|
|
88
|
+
data: {
|
|
89
|
+
profileId: resolvedProfile,
|
|
90
|
+
platform,
|
|
91
|
+
checkpoint,
|
|
92
|
+
url,
|
|
93
|
+
signals,
|
|
94
|
+
selectorHits: counter,
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
} catch (err) {
|
|
98
|
+
return asErrorPayload('CHECKPOINT_DETECT_FAILED', err?.message || String(err));
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function captureCheckpoint({
|
|
103
|
+
profileId,
|
|
104
|
+
containerId = null,
|
|
105
|
+
selector = null,
|
|
106
|
+
platform = 'xiaohongshu',
|
|
107
|
+
}) {
|
|
108
|
+
try {
|
|
109
|
+
const session = await ensureActiveSession(profileId);
|
|
110
|
+
const resolvedProfile = session.profileId || profileId;
|
|
111
|
+
const checkpointRes = await detectCheckpoint({ profileId: resolvedProfile, platform });
|
|
112
|
+
const effectiveSelector = maybeSelector({ profileId: resolvedProfile, containerId, selector });
|
|
113
|
+
const snapshot = await getDomSnapshotByProfile(resolvedProfile);
|
|
114
|
+
const matched = effectiveSelector ? buildSelectorCheck(snapshot, effectiveSelector) : [];
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
ok: true,
|
|
118
|
+
code: 'CHECKPOINT_CAPTURED',
|
|
119
|
+
message: 'Checkpoint captured',
|
|
120
|
+
data: {
|
|
121
|
+
profileId: resolvedProfile,
|
|
122
|
+
checkpoint: checkpointRes?.data?.checkpoint || 'unknown',
|
|
123
|
+
checkpointUrl: checkpointRes?.data?.url || '',
|
|
124
|
+
containerId,
|
|
125
|
+
selector: effectiveSelector,
|
|
126
|
+
selectorCount: matched.length,
|
|
127
|
+
capturedAt: new Date().toISOString(),
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
} catch (err) {
|
|
131
|
+
return asErrorPayload('CHECKPOINT_CAPTURE_FAILED', err?.message || String(err));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function restoreCheckpoint({
|
|
136
|
+
profileId,
|
|
137
|
+
checkpoint = null,
|
|
138
|
+
action,
|
|
139
|
+
containerId = null,
|
|
140
|
+
selector = null,
|
|
141
|
+
targetCheckpoint = null,
|
|
142
|
+
platform = 'xiaohongshu',
|
|
143
|
+
}) {
|
|
144
|
+
try {
|
|
145
|
+
const session = await ensureActiveSession(profileId);
|
|
146
|
+
const resolvedProfile = session.profileId || profileId;
|
|
147
|
+
const effectiveSelector = maybeSelector({ profileId: resolvedProfile, containerId, selector });
|
|
148
|
+
let actionResult = null;
|
|
149
|
+
|
|
150
|
+
if (action === 'requery_container') {
|
|
151
|
+
if (!effectiveSelector) return asErrorPayload('CONTAINER_NOT_FOUND', 'Selector is required for requery_container');
|
|
152
|
+
const snapshot = await getDomSnapshotByProfile(resolvedProfile);
|
|
153
|
+
const matches = buildSelectorCheck(snapshot, effectiveSelector);
|
|
154
|
+
if (matches.length === 0) return asErrorPayload('CONTAINER_NOT_FOUND', `Container selector not found: ${effectiveSelector}`);
|
|
155
|
+
actionResult = { selector: effectiveSelector, count: matches.length };
|
|
156
|
+
} else if (action === 'scroll_into_view') {
|
|
157
|
+
if (!effectiveSelector) return asErrorPayload('CONTAINER_NOT_FOUND', 'Selector is required for scroll_into_view');
|
|
158
|
+
actionResult = await callAPI('evaluate', {
|
|
159
|
+
profileId: resolvedProfile,
|
|
160
|
+
script: `(async () => {
|
|
161
|
+
const el = document.querySelector(${JSON.stringify(effectiveSelector)});
|
|
162
|
+
if (!el) throw new Error('Element not found');
|
|
163
|
+
el.scrollIntoView({ behavior: 'auto', block: 'center', inline: 'nearest' });
|
|
164
|
+
return { ok: true, selector: ${JSON.stringify(effectiveSelector)} };
|
|
165
|
+
})()`,
|
|
166
|
+
});
|
|
167
|
+
} else if (action === 'page_back') {
|
|
168
|
+
actionResult = await callAPI('page:back', { profileId: resolvedProfile });
|
|
169
|
+
} else if (action === 'goto_checkpoint_url') {
|
|
170
|
+
const url = checkpoint?.checkpointUrl || checkpoint?.url || '';
|
|
171
|
+
if (!url) return asErrorPayload('CHECKPOINT_RESTORE_FAILED', 'checkpointUrl is required for goto_checkpoint_url');
|
|
172
|
+
actionResult = await callAPI('goto', { profileId: resolvedProfile, url });
|
|
173
|
+
} else {
|
|
174
|
+
return asErrorPayload('UNSUPPORTED_RECOVERY_ACTION', `Unsupported recovery action: ${action}`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const checkpointAfter = await detectCheckpoint({ profileId: resolvedProfile, platform });
|
|
178
|
+
const reached = checkpointAfter?.data?.checkpoint || 'unknown';
|
|
179
|
+
const targetMatched = targetCheckpoint ? reached === targetCheckpoint : true;
|
|
180
|
+
return {
|
|
181
|
+
ok: true,
|
|
182
|
+
code: targetMatched ? 'CHECKPOINT_RESTORED' : 'CHECKPOINT_RESTORE_PARTIAL',
|
|
183
|
+
message: targetMatched ? 'Checkpoint restored' : 'Recovery action completed but target checkpoint not reached',
|
|
184
|
+
data: {
|
|
185
|
+
profileId: resolvedProfile,
|
|
186
|
+
action,
|
|
187
|
+
actionResult,
|
|
188
|
+
reachedCheckpoint: reached,
|
|
189
|
+
targetCheckpoint,
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
} catch (err) {
|
|
193
|
+
return asErrorPayload('CHECKPOINT_RESTORE_FAILED', err?.message || String(err));
|
|
194
|
+
}
|
|
195
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export {
|
|
2
|
+
ensureActiveSession,
|
|
3
|
+
asErrorPayload,
|
|
4
|
+
normalizeArray,
|
|
5
|
+
extractPageList,
|
|
6
|
+
getCurrentUrl,
|
|
7
|
+
maybeSelector,
|
|
8
|
+
buildSelectorCheck,
|
|
9
|
+
isCheckpointRiskUrl,
|
|
10
|
+
} from './utils.mjs';
|
|
11
|
+
|
|
12
|
+
export {
|
|
13
|
+
XHS_CHECKPOINTS,
|
|
14
|
+
detectCheckpoint,
|
|
15
|
+
captureCheckpoint,
|
|
16
|
+
restoreCheckpoint,
|
|
17
|
+
} from './checkpoint.mjs';
|
|
18
|
+
|
|
19
|
+
export { validateOperation } from './validation.mjs';
|
|
20
|
+
export { executeOperation } from './operations/index.mjs';
|
|
21
|
+
export { watchSubscriptions } from './subscription.mjs';
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import { callAPI, getDomSnapshotByProfile } from '../../../utils/browser-service.mjs';
|
|
2
|
+
import { executeTabPoolOperation } from './tab-pool.mjs';
|
|
3
|
+
import {
|
|
4
|
+
buildSelectorClickScript,
|
|
5
|
+
buildSelectorScrollIntoViewScript,
|
|
6
|
+
buildSelectorTypeScript,
|
|
7
|
+
} from './selector-scripts.mjs';
|
|
8
|
+
import { executeViewportOperation } from './viewport.mjs';
|
|
9
|
+
import {
|
|
10
|
+
asErrorPayload,
|
|
11
|
+
buildSelectorCheck,
|
|
12
|
+
ensureActiveSession,
|
|
13
|
+
extractPageList,
|
|
14
|
+
getCurrentUrl,
|
|
15
|
+
maybeSelector,
|
|
16
|
+
normalizeArray,
|
|
17
|
+
} from '../utils.mjs';
|
|
18
|
+
|
|
19
|
+
const TAB_ACTIONS = new Set([
|
|
20
|
+
'ensure_tab_pool',
|
|
21
|
+
'tab_pool_switch_next',
|
|
22
|
+
'tab_pool_switch_slot',
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
const VIEWPORT_ACTIONS = new Set([
|
|
26
|
+
'sync_window_viewport',
|
|
27
|
+
'get_current_url',
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
async function executeExternalOperationIfAny({
|
|
31
|
+
profileId,
|
|
32
|
+
action,
|
|
33
|
+
params,
|
|
34
|
+
operation,
|
|
35
|
+
context,
|
|
36
|
+
}) {
|
|
37
|
+
const executor = context?.executeExternalOperation;
|
|
38
|
+
if (typeof executor !== 'function') return null;
|
|
39
|
+
const result = await executor({
|
|
40
|
+
profileId,
|
|
41
|
+
action,
|
|
42
|
+
params,
|
|
43
|
+
operation,
|
|
44
|
+
context,
|
|
45
|
+
});
|
|
46
|
+
if (result === null || result === undefined) return null;
|
|
47
|
+
if (result && typeof result === 'object' && typeof result.ok === 'boolean') {
|
|
48
|
+
return result;
|
|
49
|
+
}
|
|
50
|
+
return asErrorPayload('OPERATION_FAILED', 'external operation executor returned invalid payload', {
|
|
51
|
+
action,
|
|
52
|
+
resultType: typeof result,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function flashOperationViewport(profileId, params = {}) {
|
|
57
|
+
if (params.highlight === false) return;
|
|
58
|
+
try {
|
|
59
|
+
await callAPI('evaluate', {
|
|
60
|
+
profileId,
|
|
61
|
+
script: `(() => {
|
|
62
|
+
const root = document.documentElement;
|
|
63
|
+
if (!(root instanceof HTMLElement)) return { ok: false };
|
|
64
|
+
const prevShadow = root.style.boxShadow;
|
|
65
|
+
const prevTransition = root.style.transition;
|
|
66
|
+
root.style.transition = 'box-shadow 80ms ease';
|
|
67
|
+
root.style.boxShadow = 'inset 0 0 0 3px #ff7a00';
|
|
68
|
+
setTimeout(() => {
|
|
69
|
+
root.style.boxShadow = prevShadow;
|
|
70
|
+
root.style.transition = prevTransition;
|
|
71
|
+
}, 260);
|
|
72
|
+
return { ok: true };
|
|
73
|
+
})()`,
|
|
74
|
+
});
|
|
75
|
+
} catch {
|
|
76
|
+
// highlight failure should never block action execution
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function executeSelectorOperation({ profileId, action, operation, params }) {
|
|
81
|
+
const selector = maybeSelector({
|
|
82
|
+
profileId,
|
|
83
|
+
containerId: params.containerId || operation?.containerId || null,
|
|
84
|
+
selector: params.selector || operation?.selector || null,
|
|
85
|
+
});
|
|
86
|
+
if (!selector) return asErrorPayload('CONTAINER_NOT_FOUND', `${action} requires selector/containerId`);
|
|
87
|
+
|
|
88
|
+
const highlight = params.highlight !== false;
|
|
89
|
+
if (action === 'scroll_into_view') {
|
|
90
|
+
const script = buildSelectorScrollIntoViewScript({ selector, highlight });
|
|
91
|
+
const result = await callAPI('evaluate', {
|
|
92
|
+
profileId,
|
|
93
|
+
script,
|
|
94
|
+
});
|
|
95
|
+
return { ok: true, code: 'OPERATION_DONE', message: 'scroll_into_view done', data: result };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const typeText = String(params.text ?? params.value ?? '');
|
|
99
|
+
const script = action === 'click'
|
|
100
|
+
? buildSelectorClickScript({ selector, highlight })
|
|
101
|
+
: buildSelectorTypeScript({ selector, highlight, text: typeText });
|
|
102
|
+
const result = await callAPI('evaluate', { profileId, script });
|
|
103
|
+
return { ok: true, code: 'OPERATION_DONE', message: `${action} done`, data: result };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function executeVerifySubscriptions({ profileId, params }) {
|
|
107
|
+
const defaultVisible = params.visible !== false;
|
|
108
|
+
const defaultMinCount = Math.max(0, Number(params.minCount ?? 1) || 1);
|
|
109
|
+
const selectorItems = normalizeArray(params.subscriptions || params.selectors)
|
|
110
|
+
.map((item, idx) => {
|
|
111
|
+
if (typeof item === 'string') {
|
|
112
|
+
return {
|
|
113
|
+
id: `selector_${idx + 1}`,
|
|
114
|
+
selector: item,
|
|
115
|
+
visible: defaultVisible,
|
|
116
|
+
minCount: defaultMinCount,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
if (!item || typeof item !== 'object') return null;
|
|
120
|
+
const selector = String(item.selector || '').trim();
|
|
121
|
+
if (!selector) return null;
|
|
122
|
+
const visible = item.visible !== undefined
|
|
123
|
+
? item.visible !== false
|
|
124
|
+
: defaultVisible;
|
|
125
|
+
const minCount = Math.max(0, Number(item.minCount ?? defaultMinCount) || defaultMinCount);
|
|
126
|
+
return {
|
|
127
|
+
id: String(item.id || `selector_${idx + 1}`),
|
|
128
|
+
selector,
|
|
129
|
+
visible,
|
|
130
|
+
minCount,
|
|
131
|
+
};
|
|
132
|
+
})
|
|
133
|
+
.filter(Boolean);
|
|
134
|
+
if (selectorItems.length === 0) {
|
|
135
|
+
return asErrorPayload('OPERATION_FAILED', 'verify_subscriptions requires params.selectors');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const acrossPages = params.acrossPages === true;
|
|
139
|
+
const settleMs = Math.max(0, Number(params.settleMs ?? 280) || 280);
|
|
140
|
+
|
|
141
|
+
const collectForCurrentPage = async () => {
|
|
142
|
+
const snapshot = await getDomSnapshotByProfile(profileId);
|
|
143
|
+
const url = await getCurrentUrl(profileId);
|
|
144
|
+
const matches = selectorItems.map((item) => ({
|
|
145
|
+
id: item.id,
|
|
146
|
+
selector: item.selector,
|
|
147
|
+
visible: item.visible,
|
|
148
|
+
minCount: item.minCount,
|
|
149
|
+
count: buildSelectorCheck(snapshot, {
|
|
150
|
+
css: item.selector,
|
|
151
|
+
visible: item.visible,
|
|
152
|
+
}).length,
|
|
153
|
+
}));
|
|
154
|
+
return { url, matches };
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
let pagesResult = [];
|
|
158
|
+
let overallOk = true;
|
|
159
|
+
if (!acrossPages) {
|
|
160
|
+
const current = await collectForCurrentPage();
|
|
161
|
+
overallOk = current.matches.every((item) => item.count >= item.minCount);
|
|
162
|
+
pagesResult = [{ index: null, ...current }];
|
|
163
|
+
} else {
|
|
164
|
+
const listed = await callAPI('page:list', { profileId });
|
|
165
|
+
const { pages, activeIndex } = extractPageList(listed);
|
|
166
|
+
for (const page of pages) {
|
|
167
|
+
const pageIndex = Number(page.index);
|
|
168
|
+
if (Number.isFinite(activeIndex) && activeIndex !== pageIndex) {
|
|
169
|
+
await callAPI('page:switch', { profileId, index: pageIndex });
|
|
170
|
+
if (settleMs > 0) await new Promise((resolve) => setTimeout(resolve, settleMs));
|
|
171
|
+
}
|
|
172
|
+
const current = await collectForCurrentPage();
|
|
173
|
+
const pageOk = current.matches.every((item) => item.count >= item.minCount);
|
|
174
|
+
overallOk = overallOk && pageOk;
|
|
175
|
+
pagesResult.push({ index: pageIndex, ...current, ok: pageOk });
|
|
176
|
+
}
|
|
177
|
+
if (Number.isFinite(activeIndex)) {
|
|
178
|
+
await callAPI('page:switch', { profileId, index: activeIndex });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (!overallOk) {
|
|
183
|
+
return asErrorPayload('SUBSCRIPTION_MISMATCH', 'subscription selectors missing on one or more pages', {
|
|
184
|
+
acrossPages,
|
|
185
|
+
pages: pagesResult,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
ok: true,
|
|
191
|
+
code: 'OPERATION_DONE',
|
|
192
|
+
message: 'verify_subscriptions done',
|
|
193
|
+
data: { acrossPages, pages: pagesResult },
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export async function executeOperation({ profileId, operation, context = {} }) {
|
|
198
|
+
try {
|
|
199
|
+
const session = await ensureActiveSession(profileId);
|
|
200
|
+
const resolvedProfile = session.profileId || profileId;
|
|
201
|
+
const action = String(operation?.action || '').trim();
|
|
202
|
+
const params = operation?.params || operation?.config || {};
|
|
203
|
+
|
|
204
|
+
if (!action) {
|
|
205
|
+
return asErrorPayload('OPERATION_FAILED', 'operation.action is required');
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (action !== 'wait') {
|
|
209
|
+
await flashOperationViewport(resolvedProfile, params);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (TAB_ACTIONS.has(action)) {
|
|
213
|
+
return await executeTabPoolOperation({
|
|
214
|
+
profileId: resolvedProfile,
|
|
215
|
+
action,
|
|
216
|
+
params,
|
|
217
|
+
context,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (VIEWPORT_ACTIONS.has(action)) {
|
|
222
|
+
return await executeViewportOperation({
|
|
223
|
+
profileId: resolvedProfile,
|
|
224
|
+
action,
|
|
225
|
+
params,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (action === 'goto') {
|
|
230
|
+
const url = String(params.url || params.value || '').trim();
|
|
231
|
+
if (!url) return asErrorPayload('OPERATION_FAILED', 'goto requires params.url');
|
|
232
|
+
const result = await callAPI('goto', { profileId: resolvedProfile, url });
|
|
233
|
+
return { ok: true, code: 'OPERATION_DONE', message: 'goto done', data: result };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (action === 'back') {
|
|
237
|
+
const result = await callAPI('page:back', { profileId: resolvedProfile });
|
|
238
|
+
return { ok: true, code: 'OPERATION_DONE', message: 'back done', data: result };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (action === 'list_pages') {
|
|
242
|
+
const result = await callAPI('page:list', { profileId: resolvedProfile });
|
|
243
|
+
const { pages, activeIndex } = extractPageList(result);
|
|
244
|
+
return { ok: true, code: 'OPERATION_DONE', message: 'list_pages done', data: { pages, activeIndex, raw: result } };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (action === 'new_page') {
|
|
248
|
+
const rawUrl = String(params.url || params.value || '').trim();
|
|
249
|
+
const payload = rawUrl ? { profileId: resolvedProfile, url: rawUrl } : { profileId: resolvedProfile };
|
|
250
|
+
const result = await callAPI('newPage', payload);
|
|
251
|
+
return { ok: true, code: 'OPERATION_DONE', message: 'new_page done', data: result };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (action === 'switch_page') {
|
|
255
|
+
const index = Number(params.index ?? params.value);
|
|
256
|
+
if (!Number.isFinite(index)) {
|
|
257
|
+
return asErrorPayload('OPERATION_FAILED', 'switch_page requires params.index');
|
|
258
|
+
}
|
|
259
|
+
const result = await callAPI('page:switch', { profileId: resolvedProfile, index });
|
|
260
|
+
return { ok: true, code: 'OPERATION_DONE', message: 'switch_page done', data: result };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (action === 'wait') {
|
|
264
|
+
const ms = Math.max(0, Number(params.ms ?? params.value ?? 0));
|
|
265
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
266
|
+
return { ok: true, code: 'OPERATION_DONE', message: 'wait done', data: { ms } };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (action === 'scroll') {
|
|
270
|
+
const amount = Math.max(1, Number(params.amount ?? params.value ?? 300) || 300);
|
|
271
|
+
const direction = String(params.direction || 'down').toLowerCase();
|
|
272
|
+
let deltaX = 0;
|
|
273
|
+
let deltaY = amount;
|
|
274
|
+
if (direction === 'up') deltaY = -amount;
|
|
275
|
+
else if (direction === 'left') {
|
|
276
|
+
deltaX = -amount;
|
|
277
|
+
deltaY = 0;
|
|
278
|
+
} else if (direction === 'right') {
|
|
279
|
+
deltaX = amount;
|
|
280
|
+
deltaY = 0;
|
|
281
|
+
}
|
|
282
|
+
const result = await callAPI('mouse:wheel', { profileId: resolvedProfile, deltaX, deltaY });
|
|
283
|
+
return {
|
|
284
|
+
ok: true,
|
|
285
|
+
code: 'OPERATION_DONE',
|
|
286
|
+
message: 'scroll done',
|
|
287
|
+
data: { direction, amount, deltaX, deltaY, result },
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (action === 'press_key') {
|
|
292
|
+
const key = String(params.key || params.value || '').trim();
|
|
293
|
+
if (!key) return asErrorPayload('OPERATION_FAILED', 'press_key requires params.key');
|
|
294
|
+
const result = await callAPI('evaluate', {
|
|
295
|
+
profileId: resolvedProfile,
|
|
296
|
+
script: `(async () => {
|
|
297
|
+
const target = document.activeElement || document.body || document.documentElement;
|
|
298
|
+
const key = ${JSON.stringify(key)};
|
|
299
|
+
const code = key.length === 1 ? 'Key' + key.toUpperCase() : key;
|
|
300
|
+
const opts = { key, code, bubbles: true, cancelable: true };
|
|
301
|
+
target.dispatchEvent(new KeyboardEvent('keydown', opts));
|
|
302
|
+
target.dispatchEvent(new KeyboardEvent('keypress', opts));
|
|
303
|
+
target.dispatchEvent(new KeyboardEvent('keyup', opts));
|
|
304
|
+
if (key === 'Escape') {
|
|
305
|
+
const closeButton = document.querySelector('.note-detail-mask .close-box, .note-detail-mask .close-circle');
|
|
306
|
+
if (closeButton instanceof HTMLElement) closeButton.click();
|
|
307
|
+
}
|
|
308
|
+
if (key === 'Enter' && target instanceof HTMLInputElement && target.form) {
|
|
309
|
+
if (typeof target.form.requestSubmit === 'function') target.form.requestSubmit();
|
|
310
|
+
else target.form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
|
311
|
+
}
|
|
312
|
+
return { key, targetTag: target?.tagName || null };
|
|
313
|
+
})()`,
|
|
314
|
+
});
|
|
315
|
+
return { ok: true, code: 'OPERATION_DONE', message: 'press_key done', data: result };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (action === 'verify_subscriptions') {
|
|
319
|
+
return executeVerifySubscriptions({ profileId: resolvedProfile, params });
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (action === 'evaluate') {
|
|
323
|
+
const script = String(params.script || '').trim();
|
|
324
|
+
if (!script) return asErrorPayload('OPERATION_FAILED', 'evaluate requires params.script');
|
|
325
|
+
const result = await callAPI('evaluate', { profileId: resolvedProfile, script });
|
|
326
|
+
return { ok: true, code: 'OPERATION_DONE', message: 'evaluate done', data: result };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (action === 'click' || action === 'type' || action === 'scroll_into_view') {
|
|
330
|
+
return executeSelectorOperation({
|
|
331
|
+
profileId: resolvedProfile,
|
|
332
|
+
action,
|
|
333
|
+
operation,
|
|
334
|
+
params,
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const externalResult = await executeExternalOperationIfAny({
|
|
339
|
+
profileId: resolvedProfile,
|
|
340
|
+
action,
|
|
341
|
+
params,
|
|
342
|
+
operation,
|
|
343
|
+
context,
|
|
344
|
+
});
|
|
345
|
+
if (externalResult) return externalResult;
|
|
346
|
+
|
|
347
|
+
return asErrorPayload('UNSUPPORTED_OPERATION', `Unsupported operation action: ${action}`);
|
|
348
|
+
} catch (err) {
|
|
349
|
+
return asErrorPayload('OPERATION_FAILED', err?.message || String(err), { context });
|
|
350
|
+
}
|
|
351
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
function asBoolLiteral(value) {
|
|
2
|
+
return value ? 'true' : 'false';
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function buildSelectorScrollIntoViewScript({ selector, highlight }) {
|
|
6
|
+
const selectorLiteral = JSON.stringify(selector);
|
|
7
|
+
const highlightLiteral = asBoolLiteral(highlight);
|
|
8
|
+
return `(async () => {
|
|
9
|
+
const el = document.querySelector(${selectorLiteral});
|
|
10
|
+
if (!el) throw new Error('Element not found: ' + ${selectorLiteral});
|
|
11
|
+
const restoreOutline = el instanceof HTMLElement ? el.style.outline : '';
|
|
12
|
+
if (${highlightLiteral} && el instanceof HTMLElement) {
|
|
13
|
+
el.style.outline = '2px solid #ff4d4f';
|
|
14
|
+
}
|
|
15
|
+
el.scrollIntoView({ behavior: 'auto', block: 'center', inline: 'nearest' });
|
|
16
|
+
await new Promise((r) => setTimeout(r, 120));
|
|
17
|
+
if (${highlightLiteral} && el instanceof HTMLElement) {
|
|
18
|
+
el.style.outline = restoreOutline;
|
|
19
|
+
}
|
|
20
|
+
return { ok: true, selector: ${selectorLiteral} };
|
|
21
|
+
})()`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function buildSelectorClickScript({ selector, highlight }) {
|
|
25
|
+
const selectorLiteral = JSON.stringify(selector);
|
|
26
|
+
const highlightLiteral = asBoolLiteral(highlight);
|
|
27
|
+
return `(async () => {
|
|
28
|
+
const el = document.querySelector(${selectorLiteral});
|
|
29
|
+
if (!el) throw new Error('Element not found: ' + ${selectorLiteral});
|
|
30
|
+
const restoreOutline = el instanceof HTMLElement ? el.style.outline : '';
|
|
31
|
+
if (${highlightLiteral} && el instanceof HTMLElement) {
|
|
32
|
+
el.style.outline = '2px solid #ff4d4f';
|
|
33
|
+
}
|
|
34
|
+
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
35
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
36
|
+
el.click();
|
|
37
|
+
if (${highlightLiteral} && el instanceof HTMLElement) {
|
|
38
|
+
setTimeout(() => { el.style.outline = restoreOutline; }, 260);
|
|
39
|
+
}
|
|
40
|
+
return { ok: true, selector: ${selectorLiteral}, action: 'click', highlight: ${highlightLiteral} };
|
|
41
|
+
})()`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function buildSelectorTypeScript({ selector, highlight, text }) {
|
|
45
|
+
const selectorLiteral = JSON.stringify(selector);
|
|
46
|
+
const highlightLiteral = asBoolLiteral(highlight);
|
|
47
|
+
const textLiteral = JSON.stringify(String(text || ''));
|
|
48
|
+
const textLength = String(text || '').length;
|
|
49
|
+
|
|
50
|
+
return `(async () => {
|
|
51
|
+
const el = document.querySelector(${selectorLiteral});
|
|
52
|
+
if (!el) throw new Error('Element not found: ' + ${selectorLiteral});
|
|
53
|
+
const restoreOutline = el instanceof HTMLElement ? el.style.outline : '';
|
|
54
|
+
if (${highlightLiteral} && el instanceof HTMLElement) {
|
|
55
|
+
el.style.outline = '2px solid #ff4d4f';
|
|
56
|
+
}
|
|
57
|
+
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
58
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
59
|
+
el.focus();
|
|
60
|
+
el.value = ${textLiteral};
|
|
61
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
62
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
63
|
+
if (${highlightLiteral} && el instanceof HTMLElement) {
|
|
64
|
+
setTimeout(() => { el.style.outline = restoreOutline; }, 260);
|
|
65
|
+
}
|
|
66
|
+
return { ok: true, selector: ${selectorLiteral}, action: 'type', length: ${textLength}, highlight: ${highlightLiteral} };
|
|
67
|
+
})()`;
|
|
68
|
+
}
|