@maxiaochao/pi-toolkit 0.1.9 → 0.2.1
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/CHANGELOG.md +11 -0
- package/README.md +11 -2
- package/global/AGENTS.md +5 -6
- package/package.json +5 -2
- package/skills/web-browser/LICENSE +201 -0
- package/skills/web-browser/NOTICE +15 -0
- package/skills/web-browser/SKILL.md +148 -0
- package/skills/web-browser/scripts/cdp.js +305 -0
- package/skills/web-browser/scripts/devices.js +141 -0
- package/skills/web-browser/scripts/dismiss-cookies.js +377 -0
- package/skills/web-browser/scripts/emulate.js +128 -0
- package/skills/web-browser/scripts/emulation-state.js +74 -0
- package/skills/web-browser/scripts/eval.js +84 -0
- package/skills/web-browser/scripts/logs-tail.js +69 -0
- package/skills/web-browser/scripts/nav.js +74 -0
- package/skills/web-browser/scripts/net-summary.js +94 -0
- package/skills/web-browser/scripts/pick.js +169 -0
- package/skills/web-browser/scripts/screenshot.js +185 -0
- package/skills/web-browser/scripts/start.js +325 -0
- package/skills/web-browser/scripts/watch.js +266 -0
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Cookie Consent Dismissal Helper
|
|
5
|
+
*
|
|
6
|
+
* Automatically accepts cookie consent dialogs on EU websites.
|
|
7
|
+
* Supports common CMPs: OneTrust, Cookiebot, Didomi, Quantcast, Google, BBC, Amazon, etc.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* ./dismiss-cookies.js # Accept cookies
|
|
11
|
+
* ./dismiss-cookies.js --reject # Reject cookies (where possible)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { connect } from "./cdp.js";
|
|
15
|
+
import { applyActiveEmulation } from "./emulation-state.js";
|
|
16
|
+
|
|
17
|
+
const DEBUG = process.env.DEBUG === "1";
|
|
18
|
+
const log = DEBUG ? (...args) => console.error("[debug]", ...args) : () => {};
|
|
19
|
+
|
|
20
|
+
const reject = process.argv.includes("--reject");
|
|
21
|
+
const mode = reject ? "reject" : "accept";
|
|
22
|
+
|
|
23
|
+
const COOKIE_DISMISS_SCRIPT = `(acceptCookies) => {
|
|
24
|
+
const clicked = [];
|
|
25
|
+
|
|
26
|
+
const isVisible = (el) => {
|
|
27
|
+
if (!el) return false;
|
|
28
|
+
const style = getComputedStyle(el);
|
|
29
|
+
return style.display !== 'none' &&
|
|
30
|
+
style.visibility !== 'hidden' &&
|
|
31
|
+
style.opacity !== '0' &&
|
|
32
|
+
(el.offsetParent !== null || style.position === 'fixed' || style.position === 'sticky');
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const tryClick = (selector, description) => {
|
|
36
|
+
const el = typeof selector === 'string' ? document.querySelector(selector) : selector;
|
|
37
|
+
if (isVisible(el)) {
|
|
38
|
+
el.click();
|
|
39
|
+
clicked.push(description || selector);
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
return false;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const findButtonByText = (patterns, container = document) => {
|
|
46
|
+
const buttons = Array.from(container.querySelectorAll('button, [role="button"], a.button, input[type="submit"], input[type="button"]'));
|
|
47
|
+
// Sort patterns by length descending to match more specific patterns first
|
|
48
|
+
const sortedPatterns = [...patterns].sort((a, b) => b.length - a.length);
|
|
49
|
+
|
|
50
|
+
// Check patterns in order of specificity (longest first)
|
|
51
|
+
for (const pattern of sortedPatterns) {
|
|
52
|
+
for (const btn of buttons) {
|
|
53
|
+
const text = (btn.textContent || btn.value || '').trim().toLowerCase();
|
|
54
|
+
if (text.length > 100) continue; // Skip buttons with very long text
|
|
55
|
+
if (!isVisible(btn)) continue; // Skip hidden buttons
|
|
56
|
+
if (typeof pattern === 'string' ? text.includes(pattern) : pattern.test(text)) {
|
|
57
|
+
return btn;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const acceptPatterns = [
|
|
65
|
+
'accept all', 'accept cookies', 'allow all', 'allow cookies',
|
|
66
|
+
'i agree', 'i accept', 'yes, i agree', 'agree and continue',
|
|
67
|
+
'alle akzeptieren', 'akzeptieren', 'alle zulassen', 'zustimmen',
|
|
68
|
+
'annehmen', 'einverstanden',
|
|
69
|
+
'accepter tout', 'tout accepter', "j'accepte", 'accepter et continuer', 'accepter',
|
|
70
|
+
'accetta tutti', 'accetta', 'accetto',
|
|
71
|
+
'aceptar todo', 'aceptar', 'acepto',
|
|
72
|
+
'aceitar tudo', 'aceitar',
|
|
73
|
+
'continue', 'agree',
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
const rejectPatterns = [
|
|
77
|
+
'reject all', 'decline all', 'deny all', 'refuse all',
|
|
78
|
+
'i do not agree', 'i disagree', 'no thanks',
|
|
79
|
+
'alle ablehnen', 'ablehnen', 'nicht zustimmen',
|
|
80
|
+
'refuser tout', 'tout refuser', 'refuser',
|
|
81
|
+
'rifiuta tutti', 'rifiuta',
|
|
82
|
+
'rechazar todo', 'rechazar',
|
|
83
|
+
'rejeitar tudo', 'rejeitar',
|
|
84
|
+
'only necessary', 'necessary only', 'nur notwendige',
|
|
85
|
+
'essential only', 'nur essentielle',
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
const patterns = acceptCookies ? acceptPatterns : rejectPatterns;
|
|
89
|
+
|
|
90
|
+
// OneTrust
|
|
91
|
+
if (document.querySelector('#onetrust-banner-sdk')) {
|
|
92
|
+
const selector = acceptCookies ? '#onetrust-accept-btn-handler' : '#onetrust-reject-all-handler';
|
|
93
|
+
if (tryClick(selector, 'OneTrust')) return clicked;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Google
|
|
97
|
+
if (document.querySelector('[data-consent-dialog]') || document.querySelector('form[action*="consent.google"]') || document.querySelector('#CXQnmb')) {
|
|
98
|
+
const selector = acceptCookies ? '#L2AGLb' : '#W0wltc';
|
|
99
|
+
if (tryClick(selector, 'Google Consent')) return clicked;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// YouTube (Google-owned, custom consent element)
|
|
103
|
+
if (document.querySelector('ytd-consent-bump-v2-lightbox')) {
|
|
104
|
+
const btn = Array.from(document.querySelectorAll('ytd-consent-bump-v2-lightbox button'))
|
|
105
|
+
.find(b => acceptCookies
|
|
106
|
+
? b.textContent.includes('Accept all') || b.ariaLabel?.includes('Accept')
|
|
107
|
+
: b.textContent.includes('Reject all') || b.ariaLabel?.includes('Reject'));
|
|
108
|
+
if (btn) {
|
|
109
|
+
btn.click();
|
|
110
|
+
clicked.push('YouTube');
|
|
111
|
+
return clicked;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Cookiebot
|
|
116
|
+
if (document.querySelector('#CybotCookiebotDialog')) {
|
|
117
|
+
const selector = acceptCookies
|
|
118
|
+
? '#CybotCookiebotDialogBodyLevelButtonLevelOptinAllowAll, #CybotCookiebotDialogBodyButtonAccept'
|
|
119
|
+
: '#CybotCookiebotDialogBodyButtonDecline, #CybotCookiebotDialogBodyLevelButtonLevelOptinDeclineAll';
|
|
120
|
+
if (tryClick(selector, 'Cookiebot')) return clicked;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Didomi
|
|
124
|
+
if (document.querySelector('#didomi-host') || window.Didomi) {
|
|
125
|
+
const selector = acceptCookies ? '#didomi-notice-agree-button' : '#didomi-notice-disagree-button, [data-testid="disagree-button"]';
|
|
126
|
+
if (tryClick(selector, 'Didomi')) return clicked;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Quantcast
|
|
130
|
+
if (document.querySelector('.qc-cmp2-container')) {
|
|
131
|
+
const selector = acceptCookies
|
|
132
|
+
? '.qc-cmp2-summary-buttons button[mode="primary"], .qc-cmp2-button[data-testid="accept-all"]'
|
|
133
|
+
: '.qc-cmp2-summary-buttons button[mode="secondary"], .qc-cmp2-button[data-testid="reject-all"]';
|
|
134
|
+
if (tryClick(selector, 'Quantcast')) return clicked;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Usercentrics (shadow DOM)
|
|
138
|
+
const ucRoot = document.querySelector('#usercentrics-root');
|
|
139
|
+
if (ucRoot && ucRoot.shadowRoot) {
|
|
140
|
+
const shadow = ucRoot.shadowRoot;
|
|
141
|
+
const btn = acceptCookies
|
|
142
|
+
? shadow.querySelector('[data-testid="uc-accept-all-button"]')
|
|
143
|
+
: shadow.querySelector('[data-testid="uc-deny-all-button"]');
|
|
144
|
+
if (btn) { btn.click(); clicked.push('Usercentrics'); return clicked; }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// TrustArc
|
|
148
|
+
if (document.querySelector('#truste-consent-track') || document.querySelector('.trustarc-banner')) {
|
|
149
|
+
const selector = acceptCookies ? '#truste-consent-button, .trustarc-agree-btn' : '.trustarc-decline-btn';
|
|
150
|
+
if (tryClick(selector, 'TrustArc')) return clicked;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Klaro
|
|
154
|
+
if (document.querySelector('.klaro')) {
|
|
155
|
+
const selector = acceptCookies ? '.klaro .cm-btn-accept-all, .klaro .cm-btn-success' : '.klaro .cm-btn-decline';
|
|
156
|
+
if (tryClick(selector, 'Klaro')) return clicked;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// BBC
|
|
160
|
+
if (document.querySelector('#bbccookies, .bbccookies-banner')) {
|
|
161
|
+
if (acceptCookies && tryClick('#bbccookies-continue-button', 'BBC')) return clicked;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Amazon
|
|
165
|
+
if (document.querySelector('#sp-cc') || document.querySelector('#sp-cc-accept')) {
|
|
166
|
+
const selector = acceptCookies ? '#sp-cc-accept' : '#sp-cc-rejectall-link, #sp-cc-decline';
|
|
167
|
+
if (tryClick(selector, 'Amazon')) return clicked;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// CookieYes
|
|
171
|
+
if (document.querySelector('#cookie-law-info-bar') || document.querySelector('.cky-consent-container')) {
|
|
172
|
+
const selector = acceptCookies ? '#cookie_action_close_header, .cky-btn-accept' : '.cky-btn-reject';
|
|
173
|
+
if (tryClick(selector, 'CookieYes')) return clicked;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Generic containers
|
|
177
|
+
const consentContainers = [
|
|
178
|
+
'[class*="cookie-banner"]', '[class*="cookie-consent"]', '[class*="cookie-notice"]',
|
|
179
|
+
'[class*="cookieBanner"]', '[class*="cookieConsent"]', '[class*="cookieNotice"]',
|
|
180
|
+
'[id*="cookie-banner"]', '[id*="cookie-consent"]', '[id*="cookie-notice"]',
|
|
181
|
+
'[class*="consent-banner"]', '[class*="consent-modal"]', '[class*="consent-dialog"]',
|
|
182
|
+
'[class*="gdpr"]', '[id*="gdpr"]', '[class*="privacy-banner"]', '[class*="privacy-notice"]',
|
|
183
|
+
'[role="dialog"][aria-label*="cookie" i]', '[role="dialog"][aria-label*="consent" i]',
|
|
184
|
+
];
|
|
185
|
+
|
|
186
|
+
for (const containerSel of consentContainers) {
|
|
187
|
+
const containers = document.querySelectorAll(containerSel);
|
|
188
|
+
for (const container of containers) {
|
|
189
|
+
if (!isVisible(container)) continue;
|
|
190
|
+
// Skip html/body elements that might match
|
|
191
|
+
if (container.tagName === 'HTML' || container.tagName === 'BODY') continue;
|
|
192
|
+
const btn = findButtonByText(patterns, container);
|
|
193
|
+
if (btn) { btn.click(); clicked.push('Generic (' + containerSel + ')'); return clicked; }
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Last resort: find button near cookie-related text content
|
|
198
|
+
// Look for visible containers that mention "cookie" and have accept/reject buttons
|
|
199
|
+
// Include custom elements (Reddit uses rpl-modal-card, etc.)
|
|
200
|
+
const allContainers = document.querySelectorAll('div, section, aside, [class*="modal"], [class*="dialog"], [role="dialog"]');
|
|
201
|
+
for (const container of allContainers) {
|
|
202
|
+
if (!isVisible(container)) continue;
|
|
203
|
+
const text = container.textContent?.toLowerCase() || '';
|
|
204
|
+
// Must mention cookies and be reasonably sized (not the whole page)
|
|
205
|
+
if (text.includes('cookie') && text.length > 100 && text.length < 3000) {
|
|
206
|
+
const btn = findButtonByText(patterns, container);
|
|
207
|
+
if (btn && isVisible(btn)) {
|
|
208
|
+
btn.click();
|
|
209
|
+
clicked.push('Generic (text-based)');
|
|
210
|
+
return clicked;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Final fallback: look for any visible button with exact accept/reject text
|
|
216
|
+
// that appears alongside cookie-related content on the page
|
|
217
|
+
if (document.body.textContent?.toLowerCase().includes('cookie')) {
|
|
218
|
+
const exactPatterns = acceptCookies
|
|
219
|
+
? ['accept all', 'accept cookies', 'allow all', 'i agree', 'alle akzeptieren']
|
|
220
|
+
: ['reject all', 'decline all', 'reject optional', 'alle ablehnen'];
|
|
221
|
+
const singleWordPatterns = acceptCookies ? ['accept', 'agree'] : ['reject', 'decline'];
|
|
222
|
+
const buttons = document.querySelectorAll('button, [role="button"]');
|
|
223
|
+
for (const btn of buttons) {
|
|
224
|
+
if (!isVisible(btn)) continue;
|
|
225
|
+
const text = (btn.textContent || '').trim().toLowerCase();
|
|
226
|
+
if (exactPatterns.some(p => text.includes(p))) {
|
|
227
|
+
btn.click();
|
|
228
|
+
clicked.push('Generic (exact match)');
|
|
229
|
+
return clicked;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
// Try single-word matches as last resort
|
|
233
|
+
for (const btn of buttons) {
|
|
234
|
+
if (!isVisible(btn)) continue;
|
|
235
|
+
const text = (btn.textContent || '').trim().toLowerCase();
|
|
236
|
+
if (singleWordPatterns.some(p => text === p)) {
|
|
237
|
+
btn.click();
|
|
238
|
+
clicked.push('Generic (single word)');
|
|
239
|
+
return clicked;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return clicked;
|
|
245
|
+
}`;
|
|
246
|
+
|
|
247
|
+
const IFRAME_DISMISS_SCRIPT = `(acceptCookies) => {
|
|
248
|
+
const clicked = [];
|
|
249
|
+
|
|
250
|
+
const isVisible = (el) => {
|
|
251
|
+
if (!el) return false;
|
|
252
|
+
const style = getComputedStyle(el);
|
|
253
|
+
return style.display !== 'none' &&
|
|
254
|
+
style.visibility !== 'hidden' &&
|
|
255
|
+
style.opacity !== '0' &&
|
|
256
|
+
(el.offsetParent !== null || style.position === 'fixed' || style.position === 'sticky');
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const rejectIndicators = ['do not', "don't", 'nicht', 'no ', 'refuse', 'reject', 'decline', 'deny', 'disagree', 'ablehnen', 'refuser', 'rifiuta', 'rechazar', 'manage', 'settings', 'options', 'customize'];
|
|
260
|
+
const acceptIndicators = ['accept', 'agree', 'allow', 'yes', 'ok', 'got it', 'continue', 'akzeptieren', 'zustimmen', 'accepter', 'accetta', 'aceptar'];
|
|
261
|
+
|
|
262
|
+
const isRejectButton = (text) => rejectIndicators.some(p => text.includes(p));
|
|
263
|
+
const isAcceptButton = (text) => acceptIndicators.some(p => text.includes(p)) && !isRejectButton(text);
|
|
264
|
+
|
|
265
|
+
const buttons = document.querySelectorAll('button, [role="button"]');
|
|
266
|
+
for (const btn of buttons) {
|
|
267
|
+
const text = (btn.textContent || '').trim().toLowerCase();
|
|
268
|
+
if (!isVisible(btn)) continue;
|
|
269
|
+
const shouldClick = acceptCookies ? isAcceptButton(text) : isRejectButton(text);
|
|
270
|
+
if (shouldClick) { btn.click(); clicked.push('iframe: ' + text.slice(0, 30)); return clicked; }
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const spBtn = acceptCookies
|
|
274
|
+
? document.querySelector('[title="Accept All"], [title="Accept"], [aria-label*="Accept"]')
|
|
275
|
+
: document.querySelector('[title="Reject All"], [title="Reject"], [aria-label*="Reject"]');
|
|
276
|
+
if (spBtn) { spBtn.click(); clicked.push('Sourcepoint iframe'); return clicked; }
|
|
277
|
+
|
|
278
|
+
return clicked;
|
|
279
|
+
}`;
|
|
280
|
+
|
|
281
|
+
// Recursively collect all frames
|
|
282
|
+
function collectFrames(frameTree, frames = []) {
|
|
283
|
+
frames.push({ id: frameTree.frame.id, url: frameTree.frame.url });
|
|
284
|
+
if (frameTree.childFrames) {
|
|
285
|
+
for (const child of frameTree.childFrames) {
|
|
286
|
+
collectFrames(child, frames);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return frames;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Global timeout
|
|
293
|
+
const globalTimeout = setTimeout(() => {
|
|
294
|
+
console.error("✗ Global timeout exceeded (30s)");
|
|
295
|
+
process.exit(1);
|
|
296
|
+
}, 30000);
|
|
297
|
+
|
|
298
|
+
try {
|
|
299
|
+
log("connecting...");
|
|
300
|
+
const cdp = await connect(5000);
|
|
301
|
+
|
|
302
|
+
log("getting pages...");
|
|
303
|
+
const pages = await cdp.getPages();
|
|
304
|
+
const page = pages.at(-1);
|
|
305
|
+
|
|
306
|
+
if (!page) {
|
|
307
|
+
console.error("✗ No active tab found");
|
|
308
|
+
process.exit(1);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
log("attaching to page...");
|
|
312
|
+
const sessionId = await cdp.attachToPage(page.targetId);
|
|
313
|
+
|
|
314
|
+
log("applying active emulation (if configured)...");
|
|
315
|
+
await applyActiveEmulation(cdp, sessionId);
|
|
316
|
+
|
|
317
|
+
// Wait a bit for consent dialogs to appear
|
|
318
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
319
|
+
|
|
320
|
+
log("trying main page...");
|
|
321
|
+
let result = await cdp.evaluate(sessionId, `(${COOKIE_DISMISS_SCRIPT})(${!reject})`);
|
|
322
|
+
|
|
323
|
+
// If nothing found, try iframes
|
|
324
|
+
if (result.length === 0) {
|
|
325
|
+
log("trying iframes...");
|
|
326
|
+
try {
|
|
327
|
+
const frameTree = await cdp.getFrameTree(sessionId);
|
|
328
|
+
const frames = collectFrames(frameTree);
|
|
329
|
+
|
|
330
|
+
for (const frame of frames) {
|
|
331
|
+
if (frame.url === "about:blank" || frame.url.startsWith("javascript:")) continue;
|
|
332
|
+
if (
|
|
333
|
+
frame.url.includes("sp_message") ||
|
|
334
|
+
frame.url.includes("consent") ||
|
|
335
|
+
frame.url.includes("privacy") ||
|
|
336
|
+
frame.url.includes("cmp") ||
|
|
337
|
+
frame.url.includes("sourcepoint") ||
|
|
338
|
+
frame.url.includes("cookie") ||
|
|
339
|
+
frame.url.includes("privacy-mgmt")
|
|
340
|
+
) {
|
|
341
|
+
log("trying frame:", frame.url.slice(0, 60));
|
|
342
|
+
try {
|
|
343
|
+
const frameResult = await cdp.evaluateInFrame(
|
|
344
|
+
sessionId,
|
|
345
|
+
frame.id,
|
|
346
|
+
`(${IFRAME_DISMISS_SCRIPT})(${!reject})`
|
|
347
|
+
);
|
|
348
|
+
if (frameResult.length > 0) {
|
|
349
|
+
result = frameResult;
|
|
350
|
+
break;
|
|
351
|
+
}
|
|
352
|
+
} catch (e) {
|
|
353
|
+
log("frame error:", e.message);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
} catch (e) {
|
|
358
|
+
log("getFrameTree error:", e.message);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (result.length > 0) {
|
|
363
|
+
console.log(`✓ Dismissed cookie dialog (${mode}): ${result.join(", ")}`);
|
|
364
|
+
} else {
|
|
365
|
+
console.log(`○ No cookie dialog found to ${mode}`);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
log("closing...");
|
|
369
|
+
cdp.close();
|
|
370
|
+
log("done");
|
|
371
|
+
} catch (e) {
|
|
372
|
+
console.error("✗", e.message);
|
|
373
|
+
process.exit(1);
|
|
374
|
+
} finally {
|
|
375
|
+
clearTimeout(globalTimeout);
|
|
376
|
+
setTimeout(() => process.exit(0), 100);
|
|
377
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { connect } from "./cdp.js";
|
|
4
|
+
import {
|
|
5
|
+
applyDevicePreset,
|
|
6
|
+
clearDeviceEmulation,
|
|
7
|
+
listDevicePresets,
|
|
8
|
+
resolveDevicePreset,
|
|
9
|
+
} from "./devices.js";
|
|
10
|
+
import {
|
|
11
|
+
clearEmulationPreference,
|
|
12
|
+
writeEmulationPreference,
|
|
13
|
+
} from "./emulation-state.js";
|
|
14
|
+
|
|
15
|
+
const DEBUG = process.env.DEBUG === "1";
|
|
16
|
+
const log = DEBUG ? (...args) => console.error("[debug]", ...args) : () => {};
|
|
17
|
+
|
|
18
|
+
const args = process.argv.slice(2);
|
|
19
|
+
const landscape = args.includes("--landscape");
|
|
20
|
+
const reset = args.includes("--reset");
|
|
21
|
+
const list = args.includes("--list");
|
|
22
|
+
|
|
23
|
+
let deviceName = null;
|
|
24
|
+
for (let i = 0; i < args.length; i++) {
|
|
25
|
+
const arg = args[i];
|
|
26
|
+
if (arg === "--landscape" || arg === "--reset" || arg === "--list") {
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
if (arg.startsWith("--")) {
|
|
30
|
+
console.log("Usage: emulate.js <device> [--landscape]");
|
|
31
|
+
console.log(" emulate.js --reset");
|
|
32
|
+
console.log(" emulate.js --list");
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
if (deviceName) {
|
|
36
|
+
console.error("✗ Only one device preset can be provided");
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
deviceName = arg;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (list) {
|
|
43
|
+
const presets = listDevicePresets();
|
|
44
|
+
for (const preset of presets) {
|
|
45
|
+
console.log(
|
|
46
|
+
`${preset.id.padEnd(12)} ${preset.width}x${preset.height} @${preset.deviceScaleFactor}x (${preset.title})`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
process.exit(0);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (reset && landscape) {
|
|
53
|
+
console.error("✗ --landscape cannot be combined with --reset");
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if ((reset && deviceName) || (!reset && !deviceName)) {
|
|
58
|
+
console.log("Usage: emulate.js <device> [--landscape]");
|
|
59
|
+
console.log(" emulate.js --reset");
|
|
60
|
+
console.log(" emulate.js --list");
|
|
61
|
+
console.log("\nExamples:");
|
|
62
|
+
console.log(" emulate.js --list");
|
|
63
|
+
console.log(" emulate.js iphone-14");
|
|
64
|
+
console.log(" emulate.js pixel-7 --landscape");
|
|
65
|
+
console.log(" emulate.js --reset");
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const preset = reset ? null : resolveDevicePreset(deviceName);
|
|
70
|
+
if (!reset && !preset) {
|
|
71
|
+
console.error(`✗ Unknown device preset: ${deviceName}`);
|
|
72
|
+
console.error(" Available presets:");
|
|
73
|
+
for (const item of listDevicePresets()) {
|
|
74
|
+
console.error(` - ${item.id}`);
|
|
75
|
+
}
|
|
76
|
+
process.exit(1);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Global timeout
|
|
80
|
+
const globalTimeout = setTimeout(() => {
|
|
81
|
+
console.error("✗ Global timeout exceeded (20s)");
|
|
82
|
+
process.exit(1);
|
|
83
|
+
}, 20000);
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
log("connecting...");
|
|
87
|
+
const cdp = await connect(5000);
|
|
88
|
+
|
|
89
|
+
log("getting pages...");
|
|
90
|
+
const pages = await cdp.getPages();
|
|
91
|
+
const page = pages.at(-1);
|
|
92
|
+
|
|
93
|
+
if (!page) {
|
|
94
|
+
console.error("✗ No active tab found");
|
|
95
|
+
process.exit(1);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
log("attaching to page...");
|
|
99
|
+
const sessionId = await cdp.attachToPage(page.targetId);
|
|
100
|
+
|
|
101
|
+
if (reset) {
|
|
102
|
+
log("clearing emulation...");
|
|
103
|
+
await clearDeviceEmulation(cdp, sessionId);
|
|
104
|
+
clearEmulationPreference();
|
|
105
|
+
console.log("✓ Cleared device emulation");
|
|
106
|
+
} else {
|
|
107
|
+
log("applying emulation...");
|
|
108
|
+
const metrics = await applyDevicePreset(cdp, sessionId, preset, {
|
|
109
|
+
landscape,
|
|
110
|
+
});
|
|
111
|
+
writeEmulationPreference({ device: preset.id, landscape });
|
|
112
|
+
console.log(
|
|
113
|
+
`✓ Emulating ${preset.title} (${preset.id}) at ${metrics.width}x${metrics.height} @${metrics.deviceScaleFactor}x${metrics.landscape ? " landscape" : ""}`,
|
|
114
|
+
);
|
|
115
|
+
console.log(" Saved as active emulation for subsequent skill commands");
|
|
116
|
+
console.log(" Tip: reload the page if you need mobile UA-dependent responses");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
log("closing...");
|
|
120
|
+
cdp.close();
|
|
121
|
+
log("done");
|
|
122
|
+
} catch (e) {
|
|
123
|
+
console.error("✗", e.message);
|
|
124
|
+
process.exit(1);
|
|
125
|
+
} finally {
|
|
126
|
+
clearTimeout(globalTimeout);
|
|
127
|
+
setTimeout(() => process.exit(0), 100);
|
|
128
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
rmSync,
|
|
6
|
+
writeFileSync,
|
|
7
|
+
} from "node:fs";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { applyDevicePreset, resolveDevicePreset } from "./devices.js";
|
|
11
|
+
|
|
12
|
+
const HOME = process.env["HOME"] || homedir();
|
|
13
|
+
const BROWSER_ROOT = join(HOME, ".cache", "agent-web", "browser");
|
|
14
|
+
const EMULATION_STATE_FILE = join(BROWSER_ROOT, "emulation.json");
|
|
15
|
+
|
|
16
|
+
function ensureStateDir() {
|
|
17
|
+
if (!existsSync(BROWSER_ROOT)) {
|
|
18
|
+
mkdirSync(BROWSER_ROOT, { recursive: true });
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function readEmulationPreference() {
|
|
23
|
+
if (!existsSync(EMULATION_STATE_FILE)) return null;
|
|
24
|
+
try {
|
|
25
|
+
const data = JSON.parse(readFileSync(EMULATION_STATE_FILE, "utf8"));
|
|
26
|
+
if (!data || typeof data !== "object") return null;
|
|
27
|
+
if (typeof data.device !== "string" || data.device.length === 0) return null;
|
|
28
|
+
return {
|
|
29
|
+
device: data.device.toLowerCase(),
|
|
30
|
+
landscape: data.landscape === true,
|
|
31
|
+
updatedAt: typeof data.updatedAt === "string" ? data.updatedAt : null,
|
|
32
|
+
};
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function writeEmulationPreference({ device, landscape }) {
|
|
39
|
+
ensureStateDir();
|
|
40
|
+
const payload = {
|
|
41
|
+
device: String(device).toLowerCase(),
|
|
42
|
+
landscape: landscape === true,
|
|
43
|
+
updatedAt: new Date().toISOString(),
|
|
44
|
+
};
|
|
45
|
+
writeFileSync(EMULATION_STATE_FILE, `${JSON.stringify(payload, null, 2)}\n`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function clearEmulationPreference() {
|
|
49
|
+
try {
|
|
50
|
+
rmSync(EMULATION_STATE_FILE, { force: true });
|
|
51
|
+
} catch {
|
|
52
|
+
// ignore
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function applyActiveEmulation(cdp, sessionId) {
|
|
57
|
+
const preference = readEmulationPreference();
|
|
58
|
+
if (!preference) return null;
|
|
59
|
+
|
|
60
|
+
const preset = resolveDevicePreset(preference.device);
|
|
61
|
+
if (!preset) {
|
|
62
|
+
clearEmulationPreference();
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const metrics = await applyDevicePreset(cdp, sessionId, preset, {
|
|
67
|
+
landscape: preference.landscape,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
preset,
|
|
72
|
+
...metrics,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { connect } from "./cdp.js";
|
|
4
|
+
import { applyActiveEmulation } from "./emulation-state.js";
|
|
5
|
+
|
|
6
|
+
const DEBUG = process.env.DEBUG === "1";
|
|
7
|
+
const log = DEBUG ? (...args) => console.error("[debug]", ...args) : () => {};
|
|
8
|
+
|
|
9
|
+
function isSyntaxError(error) {
|
|
10
|
+
return error?.message?.startsWith("SyntaxError");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const code = process.argv.slice(2).join(" ");
|
|
14
|
+
if (!code) {
|
|
15
|
+
console.log("Usage: eval.js 'code'");
|
|
16
|
+
console.log("\nExamples:");
|
|
17
|
+
console.log(' eval.js "document.title"');
|
|
18
|
+
console.log(" eval.js \"document.querySelectorAll('a').length\"");
|
|
19
|
+
console.log(" eval.js 'document.querySelector(\"button\")?.click(); \"clicked\"'");
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Global timeout
|
|
24
|
+
const globalTimeout = setTimeout(() => {
|
|
25
|
+
console.error("✗ Global timeout exceeded (45s)");
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}, 45000);
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
log("connecting...");
|
|
31
|
+
const cdp = await connect(5000);
|
|
32
|
+
|
|
33
|
+
log("getting pages...");
|
|
34
|
+
const pages = await cdp.getPages();
|
|
35
|
+
const page = pages.at(-1);
|
|
36
|
+
|
|
37
|
+
if (!page) {
|
|
38
|
+
console.error("✗ No active tab found");
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
log("attaching to page...");
|
|
43
|
+
const sessionId = await cdp.attachToPage(page.targetId);
|
|
44
|
+
|
|
45
|
+
log("applying active emulation (if configured)...");
|
|
46
|
+
await applyActiveEmulation(cdp, sessionId);
|
|
47
|
+
|
|
48
|
+
log("evaluating...");
|
|
49
|
+
let result;
|
|
50
|
+
try {
|
|
51
|
+
const expression = `(async () => { return (${code}); })()`;
|
|
52
|
+
result = await cdp.evaluate(sessionId, expression);
|
|
53
|
+
} catch (e) {
|
|
54
|
+
if (!isSyntaxError(e)) throw e;
|
|
55
|
+
log("falling back to console-style statement evaluation...");
|
|
56
|
+
result = await cdp.evaluateRepl(sessionId, code);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
log("formatting result...");
|
|
60
|
+
if (Array.isArray(result)) {
|
|
61
|
+
for (let i = 0; i < result.length; i++) {
|
|
62
|
+
if (i > 0) console.log("");
|
|
63
|
+
for (const [key, value] of Object.entries(result[i])) {
|
|
64
|
+
console.log(`${key}: ${value}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
} else if (typeof result === "object" && result !== null) {
|
|
68
|
+
for (const [key, value] of Object.entries(result)) {
|
|
69
|
+
console.log(`${key}: ${value}`);
|
|
70
|
+
}
|
|
71
|
+
} else {
|
|
72
|
+
console.log(result);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
log("closing...");
|
|
76
|
+
cdp.close();
|
|
77
|
+
log("done");
|
|
78
|
+
} catch (e) {
|
|
79
|
+
console.error("✗", e.message);
|
|
80
|
+
process.exit(1);
|
|
81
|
+
} finally {
|
|
82
|
+
clearTimeout(globalTimeout);
|
|
83
|
+
setTimeout(() => process.exit(0), 100);
|
|
84
|
+
}
|