chrome-control-proxy 1.0.0

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.
@@ -0,0 +1,609 @@
1
+ const vm = require('vm');
2
+ const { chromium } = require('playwright');
3
+ const { getChromeStatus, CHROME_PORT } = require('./browser-controller');
4
+ const log = require('./logger');
5
+
6
+ const CDP_URL = process.env.CDP_URL || `http://127.0.0.1:${CHROME_PORT}`;
7
+
8
+ const PLAYWRIGHT_RUN_MAX_SCRIPT_CHARS =
9
+ Number(process.env.PLAYWRIGHT_RUN_MAX_SCRIPT_CHARS) || 524288;
10
+ const PLAYWRIGHT_RUN_DEFAULT_MS = Number(process.env.PLAYWRIGHT_RUN_DEFAULT_MS) || 120000;
11
+ const PLAYWRIGHT_PAGE_DOM_MAX_CHARS =
12
+ Number(process.env.PLAYWRIGHT_PAGE_DOM_MAX_CHARS) || 2000000;
13
+ const PLAYWRIGHT_SNAPSHOT_MAX_ITEMS = Number(process.env.PLAYWRIGHT_SNAPSHOT_MAX_ITEMS) || 300;
14
+
15
+ function parsePositiveMs(envVal, fallback) {
16
+ const n = Number(envVal);
17
+ if (Number.isFinite(n) && n > 0) {
18
+ return n;
19
+ }
20
+ return fallback;
21
+ }
22
+
23
+ function applyPageDefaultTimeouts(page) {
24
+ const actionMs = parsePositiveMs(process.env.PLAYWRIGHT_PAGE_DEFAULT_TIMEOUT_MS, 60000);
25
+ const navMs = parsePositiveMs(
26
+ process.env.PLAYWRIGHT_NAVIGATION_DEFAULT_TIMEOUT_MS,
27
+ Math.max(actionMs, 90000),
28
+ );
29
+ page.setDefaultTimeout(actionMs);
30
+ page.setDefaultNavigationTimeout(navMs);
31
+ log.debug('playwright', 'setDefaultTimeout', { actionMs, navigationMs: navMs });
32
+ }
33
+
34
+ let playwrightBrowserPromise = null;
35
+ let queueJobSeq = 0;
36
+
37
+ function createAsyncQueue() {
38
+ let tail = Promise.resolve();
39
+ return function enqueue(fn) {
40
+ const id = ++queueJobSeq;
41
+ const run = tail.then(async () => {
42
+ const t0 = Date.now();
43
+ log.info('queue', `playwright job #${id} start`);
44
+ try {
45
+ const r = await fn();
46
+ log.info('queue', `playwright job #${id} ok ${Date.now() - t0}ms`);
47
+ return r;
48
+ } catch (e) {
49
+ log.error('queue', `playwright job #${id} failed ${Date.now() - t0}ms`, e);
50
+ throw e;
51
+ }
52
+ });
53
+ tail = run.catch(() => {});
54
+ return run;
55
+ };
56
+ }
57
+
58
+ const enqueuePlaywright = createAsyncQueue();
59
+
60
+ function tryPageUrl(page) {
61
+ try {
62
+ if (!page || typeof page.url !== 'function') {
63
+ return undefined;
64
+ }
65
+ if (typeof page.isClosed === 'function' && page.isClosed()) {
66
+ return undefined;
67
+ }
68
+ return page.url();
69
+ } catch (_) {
70
+ return undefined;
71
+ }
72
+ }
73
+
74
+ function truncateStr(s, max) {
75
+ if (typeof s !== 'string') {
76
+ return { text: '', truncated: false };
77
+ }
78
+ const n = Math.min(Number(max) || PLAYWRIGHT_PAGE_DOM_MAX_CHARS, PLAYWRIGHT_PAGE_DOM_MAX_CHARS);
79
+ if (s.length <= n) {
80
+ return { text: s, truncated: false };
81
+ }
82
+ return { text: s.slice(0, n), truncated: true };
83
+ }
84
+
85
+ function truncateJsonValue(obj, maxChars) {
86
+ const raw = JSON.stringify(obj);
87
+ if (raw === undefined) {
88
+ return { value: obj, truncated: false, jsonLength: 0 };
89
+ }
90
+ const t = truncateStr(raw, maxChars);
91
+ if (!t.truncated) {
92
+ return { value: obj, truncated: false, jsonLength: raw.length };
93
+ }
94
+ return {
95
+ value: null,
96
+ truncated: true,
97
+ jsonLength: raw.length,
98
+ preview: t.text,
99
+ };
100
+ }
101
+
102
+ async function collectPlaywrightInteractiveSnapshot(page, options) {
103
+ const { selector, maxItems } = options;
104
+ const maxN = Math.min(
105
+ Math.max(1, Number(maxItems) || PLAYWRIGHT_SNAPSHOT_MAX_ITEMS),
106
+ 2000,
107
+ );
108
+
109
+ return page.evaluate(
110
+ ({ rootSelector, maxCount }) => {
111
+ const root = rootSelector ? document.querySelector(rootSelector) : document.body;
112
+ if (!root) {
113
+ return {
114
+ error: 'selector did not match any element',
115
+ targets: [],
116
+ visibleTotal: 0,
117
+ listTruncated: false,
118
+ };
119
+ }
120
+
121
+ const query = [
122
+ 'input:not([type="hidden"])',
123
+ 'button',
124
+ 'a[href]',
125
+ 'select',
126
+ 'textarea',
127
+ '[role="button"]',
128
+ '[role="link"]',
129
+ '[role="textbox"]',
130
+ '[role="combobox"]',
131
+ '[role="searchbox"]',
132
+ '[role="checkbox"]',
133
+ '[role="radio"]',
134
+ '[contenteditable="true"]',
135
+ ].join(',');
136
+
137
+ function isVisible(el) {
138
+ const st = window.getComputedStyle(el);
139
+ if (st.display === 'none' || st.visibility === 'hidden' || Number(st.opacity) === 0) {
140
+ return false;
141
+ }
142
+ const r = el.getBoundingClientRect();
143
+ if (r.width < 1 && r.height < 1) {
144
+ return false;
145
+ }
146
+ return true;
147
+ }
148
+
149
+ function suggestLocator(el) {
150
+ const tag = el.tagName.toLowerCase();
151
+ const tid =
152
+ el.getAttribute('data-testid') ||
153
+ el.getAttribute('data-test-id') ||
154
+ el.getAttribute('data-cy');
155
+ if (tid) {
156
+ return `page.getByTestId(${JSON.stringify(tid)})`;
157
+ }
158
+ const name = el.getAttribute('name');
159
+ const id = el.id;
160
+ if (id) {
161
+ const safe = '#' + CSS.escape(id);
162
+ return `page.locator(${JSON.stringify(safe)})`;
163
+ }
164
+ if (name) {
165
+ if (tag === 'input') {
166
+ const t = (el.getAttribute('type') || 'text').toLowerCase();
167
+ return `page.locator(${JSON.stringify(`input[type="${t}"][name="${name}"]`)})`;
168
+ }
169
+ return `page.locator(${JSON.stringify(`[name="${name}"]`)})`;
170
+ }
171
+ const ph = el.getAttribute('placeholder');
172
+ if (ph && (tag === 'input' || tag === 'textarea')) {
173
+ return `page.getByPlaceholder(${JSON.stringify(ph)})`;
174
+ }
175
+ const al = el.getAttribute('aria-label');
176
+ if (al) {
177
+ return `page.getByLabel(${JSON.stringify(al)})`;
178
+ }
179
+ if (tag === 'a') {
180
+ const href = el.getAttribute('href') || '';
181
+ const txt = (el.textContent || '').trim().slice(0, 80);
182
+ if (txt) {
183
+ return `page.getByRole('link', { name: ${JSON.stringify(txt)} })`;
184
+ }
185
+ if (href) {
186
+ return `page.locator(${JSON.stringify(`a[href="${href}"]`)})`;
187
+ }
188
+ }
189
+ const r = el.getAttribute('role');
190
+ const bt = (el.innerText || el.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 80);
191
+ if (tag === 'button' || r === 'button') {
192
+ if (bt) {
193
+ return `page.getByRole('button', { name: ${JSON.stringify(bt)} })`;
194
+ }
195
+ }
196
+ if (tag === 'select') {
197
+ return `page.locator('select')`;
198
+ }
199
+ if (tag === 'textarea') {
200
+ return `page.locator('textarea')`;
201
+ }
202
+ return `page.locator(${JSON.stringify(tag)})`;
203
+ }
204
+
205
+ const nodes = Array.from(root.querySelectorAll(query));
206
+ const visibleEls = nodes.filter(isVisible);
207
+ const listTruncated = visibleEls.length > maxCount;
208
+ const slice = visibleEls.slice(0, maxCount);
209
+
210
+ const targets = slice.map((el) => {
211
+ const tag = el.tagName.toLowerCase();
212
+ const typeAttr = el.getAttribute('type');
213
+ return {
214
+ tag,
215
+ type: typeAttr ? typeAttr.toLowerCase() : null,
216
+ name: el.getAttribute('name'),
217
+ id: el.id || null,
218
+ testId:
219
+ el.getAttribute('data-testid') ||
220
+ el.getAttribute('data-test-id') ||
221
+ el.getAttribute('data-cy') ||
222
+ null,
223
+ placeholder: el.getAttribute('placeholder'),
224
+ ariaLabel: el.getAttribute('aria-label'),
225
+ role: el.getAttribute('role'),
226
+ href: el.getAttribute('href') ? el.getAttribute('href').slice(0, 400) : null,
227
+ disabled:
228
+ el.disabled === true || String(el.getAttribute('aria-disabled')).toLowerCase() === 'true',
229
+ text: (el.innerText || el.textContent || '')
230
+ .trim()
231
+ .replace(/\s+/g, ' ')
232
+ .slice(0, 160),
233
+ suggestedLocator: suggestLocator(el),
234
+ };
235
+ });
236
+
237
+ return {
238
+ targets,
239
+ visibleTotal: visibleEls.length,
240
+ listTruncated,
241
+ maxCount,
242
+ };
243
+ },
244
+ { rootSelector: selector || null, maxCount: maxN },
245
+ );
246
+ }
247
+
248
+ function resetPlaywrightConnection() {
249
+ log.warn('playwright', 'CDP browser handle cache cleared');
250
+ playwrightBrowserPromise = null;
251
+ }
252
+
253
+ async function getPlaywrightBrowser() {
254
+ if (playwrightBrowserPromise) {
255
+ try {
256
+ const browser = await playwrightBrowserPromise;
257
+ if (browser.isConnected()) {
258
+ log.debug('playwright', 'reuse CDP connection', { cdpUrl: CDP_URL });
259
+ return browser;
260
+ }
261
+ } catch (_) {
262
+ /* reconnect */
263
+ }
264
+ playwrightBrowserPromise = null;
265
+ log.warn('playwright', 'previous CDP connection invalid, reconnecting');
266
+ }
267
+
268
+ const status = await getChromeStatus();
269
+ if (!status.running) {
270
+ log.error('playwright', 'Chrome not listening, cannot connect CDP', { cdpUrl: CDP_URL });
271
+ const err = new Error('Chrome is not running; start browser first');
272
+ err.code = 'CHROME_DOWN';
273
+ throw err;
274
+ }
275
+
276
+ log.info('playwright', 'connecting CDP', { cdpUrl: CDP_URL });
277
+ playwrightBrowserPromise = chromium.connectOverCDP(CDP_URL);
278
+ try {
279
+ const b = await playwrightBrowserPromise;
280
+ log.info('playwright', 'CDP connected', { connected: b.isConnected() });
281
+ return b;
282
+ } catch (err) {
283
+ playwrightBrowserPromise = null;
284
+ log.error('playwright', 'CDP connect failed', err);
285
+ throw err;
286
+ }
287
+ }
288
+
289
+ async function resolveBrowserContextPage(options) {
290
+ const {
291
+ url,
292
+ waitUntil = 'load',
293
+ timeout = 30000,
294
+ target = 'first',
295
+ } = options;
296
+
297
+ const browser = await getPlaywrightBrowser();
298
+ let context = browser.contexts()[0];
299
+ if (!context) {
300
+ log.info('playwright', 'no browser context, creating new context');
301
+ context = await browser.newContext();
302
+ }
303
+
304
+ let page;
305
+ if (target === 'new') {
306
+ page = await context.newPage();
307
+ log.debug('playwright', 'new page', { target });
308
+ } else {
309
+ const pages = context.pages();
310
+ if (pages.length === 0) {
311
+ page = await context.newPage();
312
+ log.debug('playwright', 'no pages, new page', { target });
313
+ } else if (target === 'last') {
314
+ page = pages[pages.length - 1];
315
+ log.debug('playwright', 'using last page', { index: pages.length - 1 });
316
+ } else {
317
+ page = pages[0];
318
+ log.debug('playwright', 'using first page', { pageCount: pages.length });
319
+ }
320
+ }
321
+
322
+ if (url) {
323
+ log.info('playwright', 'page.goto', { url, waitUntil, timeout, target });
324
+ await page.goto(url, { waitUntil, timeout });
325
+ log.debug('playwright', 'after goto', { currentUrl: page.url() });
326
+ }
327
+
328
+ applyPageDefaultTimeouts(page);
329
+
330
+ return { browser, context, page };
331
+ }
332
+
333
+ async function resolvePage(options) {
334
+ const { page } = await resolveBrowserContextPage(options);
335
+ return page;
336
+ }
337
+
338
+ function sleepRace(promise, ms, label) {
339
+ return new Promise((resolve, reject) => {
340
+ const t = setTimeout(() => {
341
+ reject(new Error(`${label} exceeded ${ms}ms`));
342
+ }, ms);
343
+ Promise.resolve(promise).then(
344
+ (v) => {
345
+ clearTimeout(t);
346
+ resolve(v);
347
+ },
348
+ (e) => {
349
+ clearTimeout(t);
350
+ reject(e);
351
+ },
352
+ );
353
+ });
354
+ }
355
+
356
+ function packScriptReturnValue(value) {
357
+ if (value === undefined) {
358
+ return { finished: true, hasReturnValue: false };
359
+ }
360
+ try {
361
+ const serialized = JSON.parse(
362
+ JSON.stringify(value, (_k, v) => (typeof v === 'bigint' ? v.toString() : v)),
363
+ );
364
+ return { finished: true, hasReturnValue: true, result: serialized };
365
+ } catch {
366
+ return { finished: true, hasReturnValue: true, resultText: String(value) };
367
+ }
368
+ }
369
+
370
+ async function getPageDomPayload(options) {
371
+ let page;
372
+ const tStart = Date.now();
373
+ try {
374
+ const {
375
+ url,
376
+ waitUntil = 'load',
377
+ timeout = 30000,
378
+ target = 'first',
379
+ maxHtmlChars = PLAYWRIGHT_PAGE_DOM_MAX_CHARS,
380
+ maxTextChars = PLAYWRIGHT_PAGE_DOM_MAX_CHARS,
381
+ maxA11yChars = PLAYWRIGHT_PAGE_DOM_MAX_CHARS,
382
+ maxPlaywrightJsonChars = PLAYWRIGHT_PAGE_DOM_MAX_CHARS,
383
+ selector,
384
+ includeHtml = true,
385
+ includeInnerText = false,
386
+ includeAccessibility = false,
387
+ includePlaywrightSnapshot = false,
388
+ maxPlaywrightTargets,
389
+ } = options;
390
+
391
+ log.info('playwright', 'page-dom start', {
392
+ url: url || null,
393
+ target,
394
+ waitUntil,
395
+ includeHtml,
396
+ includeInnerText,
397
+ includeAccessibility,
398
+ includePlaywrightSnapshot,
399
+ selector: selector || null,
400
+ });
401
+
402
+ page = await resolvePage({
403
+ url,
404
+ waitUntil,
405
+ timeout,
406
+ target,
407
+ });
408
+
409
+ const title = await page.title();
410
+ const currentUrl = page.url();
411
+
412
+ if (selector) {
413
+ await page.locator(selector).first().waitFor({ state: 'attached', timeout });
414
+ }
415
+
416
+ const payload = {
417
+ currentUrl,
418
+ title,
419
+ selector: selector || null,
420
+ };
421
+
422
+ if (includeHtml) {
423
+ let htmlFull;
424
+ if (selector) {
425
+ htmlFull = await page.locator(selector).first().evaluate((el) => el.outerHTML);
426
+ } else {
427
+ htmlFull = await page.content();
428
+ }
429
+
430
+ const htmlLength = htmlFull.length;
431
+ const maxH = Math.min(
432
+ Number(maxHtmlChars) || PLAYWRIGHT_PAGE_DOM_MAX_CHARS,
433
+ PLAYWRIGHT_PAGE_DOM_MAX_CHARS,
434
+ );
435
+ let html = htmlFull;
436
+ let truncated = false;
437
+ if (html.length > maxH) {
438
+ html = html.slice(0, maxH);
439
+ truncated = true;
440
+ }
441
+
442
+ payload.html = html;
443
+ payload.htmlLength = htmlLength;
444
+ payload.truncated = truncated;
445
+ } else {
446
+ payload.html = null;
447
+ payload.htmlLength = null;
448
+ payload.truncated = null;
449
+ }
450
+
451
+ if (includeInnerText) {
452
+ let textFull;
453
+ if (selector) {
454
+ textFull = await page.locator(selector).first().innerText();
455
+ } else {
456
+ textFull = await page.innerText('body');
457
+ }
458
+ const maxT = Math.min(
459
+ Number(maxTextChars) || PLAYWRIGHT_PAGE_DOM_MAX_CHARS,
460
+ PLAYWRIGHT_PAGE_DOM_MAX_CHARS,
461
+ );
462
+ const tt = truncateStr(textFull, maxT);
463
+ payload.innerText = tt.text;
464
+ payload.innerTextLength = textFull.length;
465
+ payload.innerTextTruncated = tt.truncated;
466
+ }
467
+
468
+ if (includeAccessibility) {
469
+ let rootHandle = null;
470
+ if (selector) {
471
+ rootHandle = await page.locator(selector).first().elementHandle();
472
+ }
473
+ const snap = await page.accessibility.snapshot({ root: rootHandle || undefined });
474
+ const maxA = Math.min(
475
+ Number(maxA11yChars) || PLAYWRIGHT_PAGE_DOM_MAX_CHARS,
476
+ PLAYWRIGHT_PAGE_DOM_MAX_CHARS,
477
+ );
478
+ const packed = truncateJsonValue(snap, maxA);
479
+ if (packed.truncated) {
480
+ payload.accessibility = null;
481
+ payload.accessibilityTruncated = true;
482
+ payload.accessibilityJsonLength = packed.jsonLength;
483
+ payload.accessibilityPreview = packed.preview;
484
+ } else {
485
+ payload.accessibility = packed.value;
486
+ payload.accessibilityTruncated = false;
487
+ payload.accessibilityJsonLength = packed.jsonLength;
488
+ }
489
+ }
490
+
491
+ if (includePlaywrightSnapshot) {
492
+ const pw = await collectPlaywrightInteractiveSnapshot(page, {
493
+ selector,
494
+ maxItems: maxPlaywrightTargets,
495
+ });
496
+ const maxP = Math.min(
497
+ Number(maxPlaywrightJsonChars) || PLAYWRIGHT_PAGE_DOM_MAX_CHARS,
498
+ PLAYWRIGHT_PAGE_DOM_MAX_CHARS,
499
+ );
500
+ const packed = truncateJsonValue(pw, maxP);
501
+ if (packed.truncated) {
502
+ payload.playwright = null;
503
+ payload.playwrightTruncated = true;
504
+ payload.playwrightJsonLength = packed.jsonLength;
505
+ payload.playwrightPreview = packed.preview;
506
+ } else {
507
+ payload.playwright = packed.value;
508
+ payload.playwrightTruncated = false;
509
+ payload.playwrightJsonLength = packed.jsonLength;
510
+ }
511
+ }
512
+
513
+ log.info('playwright', `page-dom done ${Date.now() - tStart}ms`, {
514
+ title: payload.title,
515
+ currentUrl: payload.currentUrl,
516
+ });
517
+ return payload;
518
+ } catch (err) {
519
+ err.currentUrl = err.currentUrl || tryPageUrl(page);
520
+ log.error('playwright', `page-dom failed ${Date.now() - tStart}ms`, err);
521
+ throw err;
522
+ }
523
+ }
524
+
525
+ async function runPlaywrightUserScript(userScript, options) {
526
+ let page;
527
+ const tStart = Date.now();
528
+ try {
529
+ const {
530
+ url,
531
+ waitUntil,
532
+ timeout,
533
+ target,
534
+ scriptTimeout = PLAYWRIGHT_RUN_DEFAULT_MS,
535
+ } = options;
536
+
537
+ if (typeof userScript !== 'string') {
538
+ const err = new Error('script must be a string');
539
+ err.code = 'BAD_SCRIPT';
540
+ throw err;
541
+ }
542
+ if (userScript.length > PLAYWRIGHT_RUN_MAX_SCRIPT_CHARS) {
543
+ const err = new Error(`script exceeds ${PLAYWRIGHT_RUN_MAX_SCRIPT_CHARS} characters`);
544
+ err.code = 'SCRIPT_TOO_LARGE';
545
+ throw err;
546
+ }
547
+
548
+ log.info('playwright', 'run script start', {
549
+ scriptChars: userScript.length,
550
+ outerUrl: url || null,
551
+ waitUntil: waitUntil || 'load',
552
+ target: target || 'first',
553
+ scriptTimeout,
554
+ });
555
+
556
+ const { browser, context, page: resolvedPage } = await resolveBrowserContextPage({
557
+ url,
558
+ waitUntil: waitUntil || 'load',
559
+ timeout: timeout ?? 30000,
560
+ target: target || 'first',
561
+ });
562
+ page = resolvedPage;
563
+
564
+ const sandbox = vm.createContext({
565
+ browser,
566
+ context,
567
+ page,
568
+ console,
569
+ setTimeout,
570
+ clearTimeout,
571
+ setInterval,
572
+ clearInterval,
573
+ });
574
+
575
+ const wrapped = `(async () => {\n${userScript}\n})()`;
576
+ const scriptVm = new vm.Script(wrapped, { filename: 'playwright-run-user.js' });
577
+
578
+ const completion = scriptVm.runInContext(sandbox, { displayErrors: true });
579
+ const run = async () => {
580
+ if (completion && typeof completion.then === 'function') {
581
+ return completion;
582
+ }
583
+ return completion;
584
+ };
585
+
586
+ const scriptReturn = await sleepRace(run(), scriptTimeout, 'playwright script');
587
+
588
+ log.info('playwright', `run script done ${Date.now() - tStart}ms`, {
589
+ currentUrl: page.url(),
590
+ hasReturn: scriptReturn !== undefined,
591
+ });
592
+ return { browser, context, page, result: scriptReturn };
593
+ } catch (err) {
594
+ err.currentUrl = err.currentUrl || tryPageUrl(page);
595
+ log.error('playwright', `run script failed ${Date.now() - tStart}ms`, err);
596
+ throw err;
597
+ }
598
+ }
599
+
600
+ module.exports = {
601
+ CDP_URL,
602
+ PLAYWRIGHT_RUN_DEFAULT_MS,
603
+ resetPlaywrightConnection,
604
+ getPlaywrightBrowser,
605
+ enqueuePlaywright,
606
+ getPageDomPayload,
607
+ runPlaywrightUserScript,
608
+ packScriptReturnValue,
609
+ };
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "chrome-control-proxy",
3
+ "version": "1.0.0",
4
+ "description": "Chrome CDP control proxy: HTTP API for browser lifecycle and Playwright automation",
5
+ "main": "index.js",
6
+ "bin": {
7
+ "ccp": "./bin/ccp.js"
8
+ },
9
+ "files": [
10
+ "index.js",
11
+ "lib",
12
+ "bin"
13
+ ],
14
+ "engines": {
15
+ "node": ">=18"
16
+ },
17
+ "scripts": {
18
+ "start": "node index.js",
19
+ "ccp": "node bin/ccp.js",
20
+ "test": "echo \"Error: no test specified\" && exit 1"
21
+ },
22
+ "keywords": [
23
+ "chrome",
24
+ "cdp",
25
+ "playwright",
26
+ "proxy",
27
+ "browser-automation",
28
+ "devtools-protocol"
29
+ ],
30
+ "author": "xiangqi.zheng",
31
+ "license": "MIT",
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/zhengxiangqi/chrome-control-proxy.git"
35
+ },
36
+ "bugs": {
37
+ "url": "https://github.com/zhengxiangqi/chrome-control-proxy/issues"
38
+ },
39
+ "homepage": "https://github.com/zhengxiangqi/chrome-control-proxy#readme",
40
+ "dependencies": {
41
+ "express": "^5.2.1",
42
+ "playwright": "^1.51.0"
43
+ }
44
+ }