@jackwener/opencli 1.5.2 → 1.5.3

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 (118) hide show
  1. package/.github/workflows/ci.yml +6 -7
  2. package/README.md +21 -362
  3. package/dist/browser/cdp.js +20 -1
  4. package/dist/browser/daemon-client.js +3 -2
  5. package/dist/browser/dom-helpers.d.ts +11 -0
  6. package/dist/browser/dom-helpers.js +42 -0
  7. package/dist/browser/dom-helpers.test.d.ts +1 -0
  8. package/dist/browser/dom-helpers.test.js +92 -0
  9. package/dist/browser/index.d.ts +0 -12
  10. package/dist/browser/index.js +0 -13
  11. package/dist/browser/mcp.js +4 -3
  12. package/dist/browser/page.d.ts +1 -0
  13. package/dist/browser/page.js +14 -1
  14. package/dist/browser.test.js +15 -11
  15. package/dist/clis/36kr/hot.js +1 -1
  16. package/dist/clis/36kr/search.js +1 -1
  17. package/dist/clis/_shared/common.d.ts +8 -0
  18. package/dist/clis/_shared/common.js +10 -0
  19. package/dist/clis/bloomberg/news.js +1 -1
  20. package/dist/clis/douban/utils.js +3 -6
  21. package/dist/clis/medium/utils.js +1 -1
  22. package/dist/clis/producthunt/browse.js +1 -1
  23. package/dist/clis/producthunt/hot.js +1 -1
  24. package/dist/clis/sinablog/utils.js +6 -7
  25. package/dist/clis/substack/utils.js +2 -2
  26. package/dist/clis/twitter/block.js +1 -1
  27. package/dist/clis/twitter/bookmark.js +1 -1
  28. package/dist/clis/twitter/delete.js +1 -1
  29. package/dist/clis/twitter/follow.js +1 -1
  30. package/dist/clis/twitter/followers.js +2 -2
  31. package/dist/clis/twitter/following.js +2 -2
  32. package/dist/clis/twitter/hide-reply.js +1 -1
  33. package/dist/clis/twitter/like.js +1 -1
  34. package/dist/clis/twitter/notifications.js +1 -1
  35. package/dist/clis/twitter/profile.js +1 -1
  36. package/dist/clis/twitter/reply-dm.js +1 -1
  37. package/dist/clis/twitter/reply.js +1 -1
  38. package/dist/clis/twitter/search.js +1 -1
  39. package/dist/clis/twitter/unblock.js +1 -1
  40. package/dist/clis/twitter/unbookmark.js +1 -1
  41. package/dist/clis/twitter/unfollow.js +1 -1
  42. package/dist/clis/xiaohongshu/comments.test.js +1 -0
  43. package/dist/clis/xiaohongshu/creator-note-detail.test.js +1 -0
  44. package/dist/clis/xiaohongshu/creator-notes.test.js +1 -0
  45. package/dist/clis/xiaohongshu/publish.test.js +1 -0
  46. package/dist/clis/xiaohongshu/search.test.js +1 -0
  47. package/dist/download/index.js +39 -33
  48. package/dist/download/index.test.js +15 -1
  49. package/dist/execution.js +3 -2
  50. package/dist/main.js +2 -0
  51. package/dist/node-network.d.ts +10 -0
  52. package/dist/node-network.js +174 -0
  53. package/dist/node-network.test.d.ts +1 -0
  54. package/dist/node-network.test.js +55 -0
  55. package/dist/pipeline/executor.test.js +1 -0
  56. package/dist/pipeline/steps/download.test.js +1 -0
  57. package/dist/pipeline/steps/intercept.js +4 -5
  58. package/dist/types.d.ts +2 -0
  59. package/dist/utils.d.ts +2 -0
  60. package/dist/utils.js +4 -0
  61. package/docs/superpowers/plans/2026-03-28-perf-smart-wait.md +1143 -0
  62. package/docs/superpowers/specs/2026-03-28-perf-smart-wait-design.md +170 -0
  63. package/extension/dist/background.js +1 -1
  64. package/extension/manifest.json +1 -1
  65. package/extension/package-lock.json +2 -2
  66. package/extension/package.json +1 -1
  67. package/extension/src/background.ts +1 -1
  68. package/package.json +2 -1
  69. package/src/browser/cdp.ts +21 -0
  70. package/src/browser/daemon-client.ts +3 -2
  71. package/src/browser/dom-helpers.test.ts +100 -0
  72. package/src/browser/dom-helpers.ts +44 -0
  73. package/src/browser/index.ts +0 -15
  74. package/src/browser/mcp.ts +4 -3
  75. package/src/browser/page.ts +16 -0
  76. package/src/browser.test.ts +16 -12
  77. package/src/clis/36kr/hot.ts +1 -1
  78. package/src/clis/36kr/search.ts +1 -1
  79. package/src/clis/_shared/common.ts +11 -0
  80. package/src/clis/bloomberg/news.ts +1 -1
  81. package/src/clis/douban/utils.ts +3 -7
  82. package/src/clis/medium/utils.ts +1 -1
  83. package/src/clis/producthunt/browse.ts +1 -1
  84. package/src/clis/producthunt/hot.ts +1 -1
  85. package/src/clis/sinablog/utils.ts +6 -7
  86. package/src/clis/substack/utils.ts +2 -2
  87. package/src/clis/twitter/block.ts +1 -1
  88. package/src/clis/twitter/bookmark.ts +1 -1
  89. package/src/clis/twitter/delete.ts +1 -1
  90. package/src/clis/twitter/follow.ts +1 -1
  91. package/src/clis/twitter/followers.ts +2 -2
  92. package/src/clis/twitter/following.ts +2 -2
  93. package/src/clis/twitter/hide-reply.ts +1 -1
  94. package/src/clis/twitter/like.ts +1 -1
  95. package/src/clis/twitter/notifications.ts +1 -1
  96. package/src/clis/twitter/profile.ts +1 -1
  97. package/src/clis/twitter/reply-dm.ts +1 -1
  98. package/src/clis/twitter/reply.ts +1 -1
  99. package/src/clis/twitter/search.ts +1 -1
  100. package/src/clis/twitter/unblock.ts +1 -1
  101. package/src/clis/twitter/unbookmark.ts +1 -1
  102. package/src/clis/twitter/unfollow.ts +1 -1
  103. package/src/clis/xiaohongshu/comments.test.ts +1 -0
  104. package/src/clis/xiaohongshu/creator-note-detail.test.ts +1 -0
  105. package/src/clis/xiaohongshu/creator-notes.test.ts +1 -0
  106. package/src/clis/xiaohongshu/publish.test.ts +1 -0
  107. package/src/clis/xiaohongshu/search.test.ts +1 -0
  108. package/src/download/index.test.ts +19 -1
  109. package/src/download/index.ts +50 -41
  110. package/src/execution.ts +3 -2
  111. package/src/main.ts +3 -0
  112. package/src/node-network.test.ts +93 -0
  113. package/src/node-network.ts +213 -0
  114. package/src/pipeline/executor.test.ts +1 -0
  115. package/src/pipeline/steps/download.test.ts +1 -0
  116. package/src/pipeline/steps/intercept.ts +4 -5
  117. package/src/types.ts +2 -0
  118. package/src/utils.ts +5 -0
@@ -28,6 +28,7 @@ function createPageMock(evaluateResults) {
28
28
  getInterceptedRequests: vi.fn().mockResolvedValue([]),
29
29
  getCookies: vi.fn().mockResolvedValue([]),
30
30
  screenshot: vi.fn().mockResolvedValue(''),
31
+ waitForCapture: vi.fn().mockResolvedValue(undefined),
31
32
  };
32
33
  }
33
34
  describe('xiaohongshu search', () => {
@@ -4,14 +4,13 @@
4
4
  import { spawn } from 'node:child_process';
5
5
  import * as fs from 'node:fs';
6
6
  import * as path from 'node:path';
7
- import * as https from 'node:https';
8
- import * as http from 'node:http';
9
7
  import * as os from 'node:os';
10
- import { Transform } from 'node:stream';
8
+ import { Readable, Transform } from 'node:stream';
11
9
  import { pipeline } from 'node:stream/promises';
12
10
  import { URL } from 'node:url';
13
11
  import { isBinaryInstalled } from '../external.js';
14
12
  import { getErrorMessage } from '../errors.js';
13
+ import { fetchWithNodeNetwork } from '../node-network.js';
15
14
  /** Check if yt-dlp is available in PATH. */
16
15
  export function checkYtdlp() {
17
16
  return isBinaryInstalled('yt-dlp');
@@ -61,8 +60,6 @@ export function requiresYtdlp(url) {
61
60
  export async function httpDownload(url, destPath, options = {}, redirectCount = 0) {
62
61
  const { cookies, headers = {}, timeout = 30000, onProgress, maxRedirects = 10 } = options;
63
62
  return new Promise((resolve) => {
64
- const parsedUrl = new URL(url);
65
- const protocol = parsedUrl.protocol === 'https:' ? https : http;
66
63
  const requestHeaders = {
67
64
  'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36',
68
65
  ...headers,
@@ -86,30 +83,43 @@ export async function httpDownload(url, destPath, options = {}, redirectCount =
86
83
  // Ignore cleanup errors so the original failure is preserved.
87
84
  }
88
85
  };
89
- const request = protocol.get(url, { headers: requestHeaders, timeout }, (response) => {
90
- void (async () => {
86
+ void (async () => {
87
+ const controller = new AbortController();
88
+ const timer = setTimeout(() => controller.abort(), timeout);
89
+ try {
90
+ const response = await fetchWithNodeNetwork(url, {
91
+ headers: requestHeaders,
92
+ signal: controller.signal,
93
+ redirect: 'manual',
94
+ });
95
+ clearTimeout(timer);
91
96
  // Handle redirects before creating any file handles.
92
- if (response.statusCode && response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
93
- response.resume();
94
- if (redirectCount >= maxRedirects) {
95
- finish({ success: false, size: 0, error: `Too many redirects (> ${maxRedirects})` });
97
+ if (response.status >= 300 && response.status < 400) {
98
+ const location = response.headers.get('location');
99
+ if (location) {
100
+ if (redirectCount >= maxRedirects) {
101
+ finish({ success: false, size: 0, error: `Too many redirects (> ${maxRedirects})` });
102
+ return;
103
+ }
104
+ const redirectUrl = resolveRedirectUrl(url, location);
105
+ const originalHost = new URL(url).hostname;
106
+ const redirectHost = new URL(redirectUrl).hostname;
107
+ const redirectOptions = originalHost === redirectHost
108
+ ? options
109
+ : { ...options, cookies: undefined, headers: stripCookieHeaders(options.headers) };
110
+ finish(await httpDownload(redirectUrl, destPath, redirectOptions, redirectCount + 1));
96
111
  return;
97
112
  }
98
- const redirectUrl = resolveRedirectUrl(url, response.headers.location);
99
- const originalHost = new URL(url).hostname;
100
- const redirectHost = new URL(redirectUrl).hostname;
101
- const redirectOptions = originalHost === redirectHost
102
- ? options
103
- : { ...options, cookies: undefined, headers: stripCookieHeaders(options.headers) };
104
- finish(await httpDownload(redirectUrl, destPath, redirectOptions, redirectCount + 1));
113
+ }
114
+ if (response.status !== 200) {
115
+ finish({ success: false, size: 0, error: `HTTP ${response.status}` });
105
116
  return;
106
117
  }
107
- if (response.statusCode !== 200) {
108
- response.resume();
109
- finish({ success: false, size: 0, error: `HTTP ${response.statusCode}` });
118
+ if (!response.body) {
119
+ finish({ success: false, size: 0, error: 'Empty response body' });
110
120
  return;
111
121
  }
112
- const totalSize = parseInt(response.headers['content-length'] || '0', 10);
122
+ const totalSize = parseInt(response.headers.get('content-length') || '0', 10);
113
123
  let received = 0;
114
124
  const progressStream = new Transform({
115
125
  transform(chunk, _encoding, callback) {
@@ -121,7 +131,7 @@ export async function httpDownload(url, destPath, options = {}, redirectCount =
121
131
  });
122
132
  try {
123
133
  await fs.promises.mkdir(path.dirname(destPath), { recursive: true });
124
- await pipeline(response, progressStream, fs.createWriteStream(tempPath));
134
+ await pipeline(Readable.fromWeb(response.body), progressStream, fs.createWriteStream(tempPath));
125
135
  await fs.promises.rename(tempPath, destPath);
126
136
  finish({ success: true, size: received });
127
137
  }
@@ -129,17 +139,13 @@ export async function httpDownload(url, destPath, options = {}, redirectCount =
129
139
  await cleanupTempFile();
130
140
  finish({ success: false, size: 0, error: getErrorMessage(err) });
131
141
  }
132
- })();
133
- });
134
- request.on('error', (err) => {
135
- void (async () => {
142
+ }
143
+ catch (err) {
144
+ clearTimeout(timer);
136
145
  await cleanupTempFile();
137
- finish({ success: false, size: 0, error: err.message });
138
- })();
139
- });
140
- request.on('timeout', () => {
141
- request.destroy(new Error('Timeout'));
142
- });
146
+ finish({ success: false, size: 0, error: err instanceof Error ? err.message : String(err) });
147
+ }
148
+ })();
143
149
  });
144
150
  }
145
151
  export function resolveRedirectUrl(currentUrl, location) {
@@ -2,11 +2,12 @@ import * as fs from 'node:fs';
2
2
  import * as http from 'node:http';
3
3
  import * as os from 'node:os';
4
4
  import * as path from 'node:path';
5
- import { afterEach, describe, expect, it } from 'vitest';
5
+ import { afterEach, describe, expect, it, vi } from 'vitest';
6
6
  import { formatCookieHeader, httpDownload, resolveRedirectUrl } from './index.js';
7
7
  const servers = [];
8
8
  const tempDirs = [];
9
9
  afterEach(async () => {
10
+ vi.unstubAllEnvs();
10
11
  await Promise.all(servers.map((server) => new Promise((resolve, reject) => {
11
12
  server.close((err) => (err ? reject(err) : resolve()));
12
13
  })));
@@ -101,4 +102,17 @@ describe('download helpers', { retry: process.platform === 'win32' ? 2 : 0 }, ()
101
102
  expect(forwardedCookie).toBeUndefined();
102
103
  expect(fs.readFileSync(destPath, 'utf8')).toBe('ok');
103
104
  });
105
+ it('bypasses proxy settings for loopback downloads', async () => {
106
+ vi.stubEnv('HTTP_PROXY', 'http://127.0.0.1:9');
107
+ const baseUrl = await startServer((_req, res) => {
108
+ res.statusCode = 200;
109
+ res.end('ok');
110
+ });
111
+ const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'opencli-dl-'));
112
+ tempDirs.push(tempDir);
113
+ const destPath = path.join(tempDir, 'loopback.txt');
114
+ const result = await httpDownload(`${baseUrl}/ok`, destPath);
115
+ expect(result).toEqual({ success: true, size: 2 });
116
+ expect(fs.readFileSync(destPath, 'utf8')).toBe('ok');
117
+ });
104
118
  });
package/dist/execution.js CHANGED
@@ -17,6 +17,7 @@ import { shouldUseBrowserSession } from './capabilityRouting.js';
17
17
  import { getBrowserFactory, browserSession, runWithTimeout, DEFAULT_BROWSER_COMMAND_TIMEOUT } from './runtime.js';
18
18
  import { emitHook } from './hooks.js';
19
19
  import { checkDaemonStatus } from './browser/discover.js';
20
+ import { log } from './logger.js';
20
21
  const _loadedModules = new Set();
21
22
  export function coerceAndValidateArgs(cmdArgs, kwargs) {
22
23
  const result = { ...kwargs };
@@ -167,7 +168,7 @@ export async function executeCommand(cmd, rawKwargs, debug = false) {
167
168
  const skip = await isAlreadyOnDomain(page, preNavUrl);
168
169
  if (skip) {
169
170
  if (debug)
170
- console.error(`[pre-nav] Already on target domain, skipping navigation`);
171
+ log.debug('[pre-nav] Already on target domain, skipping navigation');
171
172
  }
172
173
  else {
173
174
  try {
@@ -177,7 +178,7 @@ export async function executeCommand(cmd, rawKwargs, debug = false) {
177
178
  }
178
179
  catch (err) {
179
180
  if (debug)
180
- console.error(`[pre-nav] Failed to navigate to ${preNavUrl}: ${err instanceof Error ? err.message : err}`);
181
+ log.debug(`[pre-nav] Failed to navigate to ${preNavUrl}: ${err instanceof Error ? err.message : err}`);
181
182
  }
182
183
  }
183
184
  }
package/dist/main.js CHANGED
@@ -19,7 +19,9 @@ import { discoverClis, discoverPlugins } from './discovery.js';
19
19
  import { getCompletions } from './completion.js';
20
20
  import { runCli } from './cli.js';
21
21
  import { emitHook } from './hooks.js';
22
+ import { installNodeNetwork } from './node-network.js';
22
23
  import { registerUpdateNoticeOnExit, checkForUpdateBackground } from './update-check.js';
24
+ installNodeNetwork();
23
25
  const __filename = fileURLToPath(import.meta.url);
24
26
  const __dirname = path.dirname(__filename);
25
27
  const BUILTIN_CLIS = path.resolve(__dirname, 'clis');
@@ -0,0 +1,10 @@
1
+ import { type Dispatcher } from 'undici';
2
+ export interface ProxyDecision {
3
+ mode: 'direct' | 'proxy';
4
+ proxyUrl?: string;
5
+ }
6
+ export declare function hasProxyEnv(env?: NodeJS.ProcessEnv): boolean;
7
+ export declare function decideProxy(url: URL, env?: NodeJS.ProcessEnv): ProxyDecision;
8
+ export declare function getDispatcherForUrl(url: URL, env?: NodeJS.ProcessEnv): Dispatcher;
9
+ export declare function fetchWithNodeNetwork(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
10
+ export declare function installNodeNetwork(): void;
@@ -0,0 +1,174 @@
1
+ import { Agent, EnvHttpProxyAgent, fetch as undiciFetch } from 'undici';
2
+ const LOOPBACK_NO_PROXY_ENTRIES = ['127.0.0.1', 'localhost', '::1'];
3
+ const PROXY_ENV_BY_PROTOCOL = {
4
+ 'http:': ['http_proxy', 'HTTP_PROXY', 'all_proxy', 'ALL_PROXY'],
5
+ 'https:': ['https_proxy', 'HTTPS_PROXY', 'all_proxy', 'ALL_PROXY'],
6
+ };
7
+ const DEFAULT_PORT_BY_PROTOCOL = {
8
+ 'http:': '80',
9
+ 'https:': '443',
10
+ };
11
+ let installed = false;
12
+ const directDispatcher = new Agent();
13
+ const proxyDispatcherCache = new Map();
14
+ const nativeFetch = globalThis.fetch.bind(globalThis);
15
+ function readEnv(env, lower, upper) {
16
+ const lowerValue = env[lower];
17
+ if (typeof lowerValue === 'string' && lowerValue.trim() !== '')
18
+ return lowerValue;
19
+ const upperValue = env[upper];
20
+ if (typeof upperValue === 'string' && upperValue.trim() !== '')
21
+ return upperValue;
22
+ return undefined;
23
+ }
24
+ function readProxyEnv(env, keys) {
25
+ for (const key of keys) {
26
+ const value = env[key];
27
+ if (typeof value === 'string' && value.trim() !== '')
28
+ return value;
29
+ }
30
+ return undefined;
31
+ }
32
+ function normalizeHostname(hostname) {
33
+ return hostname.replace(/^\[(.*)\]$/, '$1').toLowerCase();
34
+ }
35
+ function splitNoProxy(raw) {
36
+ return (raw ?? '')
37
+ .split(/[,\s]+/)
38
+ .map((token) => token.trim())
39
+ .filter(Boolean);
40
+ }
41
+ function parseNoProxyEntry(entry) {
42
+ if (entry === '*')
43
+ return { host: '*' };
44
+ const trimmed = entry.trim().replace(/^\*?\./, '');
45
+ if (trimmed.startsWith('[')) {
46
+ const end = trimmed.indexOf(']');
47
+ if (end !== -1) {
48
+ const host = trimmed.slice(1, end);
49
+ const rest = trimmed.slice(end + 1);
50
+ if (rest.startsWith(':'))
51
+ return { host: normalizeHostname(host), port: rest.slice(1) };
52
+ return { host: normalizeHostname(host) };
53
+ }
54
+ }
55
+ const colonCount = (trimmed.match(/:/g) ?? []).length;
56
+ if (colonCount === 1) {
57
+ const [host, port] = trimmed.split(':');
58
+ return { host: normalizeHostname(host), port };
59
+ }
60
+ return { host: normalizeHostname(trimmed) };
61
+ }
62
+ function effectiveNoProxyEntries(env) {
63
+ const raw = readEnv(env, 'no_proxy', 'NO_PROXY');
64
+ const entries = splitNoProxy(raw).map(parseNoProxyEntry);
65
+ const seen = new Set(entries.map((entry) => `${entry.host}:${entry.port ?? ''}`));
66
+ for (const rawEntry of LOOPBACK_NO_PROXY_ENTRIES) {
67
+ const entry = parseNoProxyEntry(rawEntry);
68
+ const key = `${entry.host}:${entry.port ?? ''}`;
69
+ if (seen.has(key))
70
+ continue;
71
+ entries.push(entry);
72
+ seen.add(key);
73
+ }
74
+ return entries;
75
+ }
76
+ function serializeNoProxyEntry(entry) {
77
+ if (entry.host === '*')
78
+ return '*';
79
+ const host = entry.host.includes(':') ? `[${entry.host}]` : entry.host;
80
+ return entry.port ? `${host}:${entry.port}` : host;
81
+ }
82
+ function effectiveNoProxyValue(entries) {
83
+ if (entries.length === 0)
84
+ return undefined;
85
+ return entries.map(serializeNoProxyEntry).join(',');
86
+ }
87
+ function matchesNoProxyEntry(url, entry) {
88
+ const { host, port } = entry;
89
+ if (host === '*')
90
+ return true;
91
+ const hostname = normalizeHostname(url.hostname);
92
+ const urlPort = url.port || DEFAULT_PORT_BY_PROTOCOL[url.protocol] || undefined;
93
+ if (port && port !== urlPort)
94
+ return false;
95
+ return hostname === host || hostname.endsWith(`.${host}`);
96
+ }
97
+ function resolveProxyConfig(env = process.env) {
98
+ const noProxyEntries = effectiveNoProxyEntries(env);
99
+ return {
100
+ httpProxy: readProxyEnv(env, PROXY_ENV_BY_PROTOCOL['http:']),
101
+ httpsProxy: readProxyEnv(env, [
102
+ 'https_proxy',
103
+ 'HTTPS_PROXY',
104
+ 'http_proxy',
105
+ 'HTTP_PROXY',
106
+ 'all_proxy',
107
+ 'ALL_PROXY',
108
+ ]),
109
+ noProxy: effectiveNoProxyValue(noProxyEntries),
110
+ noProxyEntries,
111
+ };
112
+ }
113
+ function createProxyDispatcher(config) {
114
+ const cacheKey = JSON.stringify([
115
+ config.httpProxy ?? '',
116
+ config.httpsProxy ?? '',
117
+ config.noProxy ?? '',
118
+ ]);
119
+ const cached = proxyDispatcherCache.get(cacheKey);
120
+ if (cached)
121
+ return cached;
122
+ const dispatcher = new EnvHttpProxyAgent({
123
+ httpProxy: config.httpProxy,
124
+ httpsProxy: config.httpsProxy,
125
+ noProxy: config.noProxy,
126
+ });
127
+ proxyDispatcherCache.set(cacheKey, dispatcher);
128
+ return dispatcher;
129
+ }
130
+ function resolveUrl(input) {
131
+ if (typeof input === 'string')
132
+ return new URL(input);
133
+ if (input instanceof URL)
134
+ return input;
135
+ if (typeof Request !== 'undefined' && input instanceof Request)
136
+ return new URL(input.url);
137
+ return null;
138
+ }
139
+ export function hasProxyEnv(env = process.env) {
140
+ const config = resolveProxyConfig(env);
141
+ return Boolean(config.httpProxy || config.httpsProxy);
142
+ }
143
+ export function decideProxy(url, env = process.env) {
144
+ const config = resolveProxyConfig(env);
145
+ if (config.noProxyEntries.some((entry) => matchesNoProxyEntry(url, entry))) {
146
+ return { mode: 'direct' };
147
+ }
148
+ const proxyUrl = url.protocol === 'https:' ? config.httpsProxy : config.httpProxy;
149
+ if (!proxyUrl)
150
+ return { mode: 'direct' };
151
+ return { mode: 'proxy', proxyUrl };
152
+ }
153
+ export function getDispatcherForUrl(url, env = process.env) {
154
+ const config = resolveProxyConfig(env);
155
+ if (!config.httpProxy && !config.httpsProxy)
156
+ return directDispatcher;
157
+ return createProxyDispatcher(config);
158
+ }
159
+ export async function fetchWithNodeNetwork(input, init = {}) {
160
+ const url = resolveUrl(input);
161
+ if (!url || !hasProxyEnv()) {
162
+ return nativeFetch(input, init);
163
+ }
164
+ return (await undiciFetch(input, {
165
+ ...init,
166
+ dispatcher: getDispatcherForUrl(url),
167
+ }));
168
+ }
169
+ export function installNodeNetwork() {
170
+ if (installed)
171
+ return;
172
+ globalThis.fetch = ((input, init) => (fetchWithNodeNetwork(input, init)));
173
+ installed = true;
174
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,55 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { decideProxy, hasProxyEnv } from './node-network.js';
3
+ describe('node network proxy decisions', () => {
4
+ it('detects common proxy env variables', () => {
5
+ expect(hasProxyEnv({ https_proxy: 'http://127.0.0.1:7897' })).toBe(true);
6
+ expect(hasProxyEnv({ HTTP_PROXY: 'http://proxy.example:8080' })).toBe(true);
7
+ expect(hasProxyEnv({})).toBe(false);
8
+ });
9
+ it('routes external https traffic through https_proxy', () => {
10
+ const decision = decideProxy(new URL('https://www.v2ex.com/api/topics/latest.json'), { https_proxy: 'http://127.0.0.1:7897' });
11
+ expect(decision).toEqual({
12
+ mode: 'proxy',
13
+ proxyUrl: 'http://127.0.0.1:7897',
14
+ });
15
+ });
16
+ it('falls back to HTTP_PROXY for https traffic when HTTPS_PROXY is absent', () => {
17
+ const decision = decideProxy(new URL('https://www.v2ex.com/api/topics/latest.json'), { HTTP_PROXY: 'http://127.0.0.1:7897' });
18
+ expect(decision).toEqual({
19
+ mode: 'proxy',
20
+ proxyUrl: 'http://127.0.0.1:7897',
21
+ });
22
+ });
23
+ it('bypasses proxies for loopback addresses', () => {
24
+ const env = { https_proxy: 'http://127.0.0.1:7897', http_proxy: 'http://127.0.0.1:7897' };
25
+ expect(decideProxy(new URL('http://127.0.0.1:19825/status'), env)).toEqual({ mode: 'direct' });
26
+ expect(decideProxy(new URL('http://localhost:19825/status'), env)).toEqual({ mode: 'direct' });
27
+ expect(decideProxy(new URL('http://[::1]:19825/status'), env)).toEqual({ mode: 'direct' });
28
+ });
29
+ it('honors NO_PROXY domain matches', () => {
30
+ const decision = decideProxy(new URL('https://api.example.com/v1/items'), {
31
+ https_proxy: 'http://127.0.0.1:7897',
32
+ no_proxy: '.example.com',
33
+ });
34
+ expect(decision).toEqual({ mode: 'direct' });
35
+ });
36
+ it('supports wildcard-style NO_PROXY subdomain entries', () => {
37
+ const decision = decideProxy(new URL('https://api.example.com/v1/items'), {
38
+ https_proxy: 'http://127.0.0.1:7897',
39
+ no_proxy: '*.example.com',
40
+ });
41
+ expect(decision).toEqual({ mode: 'direct' });
42
+ });
43
+ it('matches NO_PROXY entries that rely on the default URL port', () => {
44
+ const env = { https_proxy: 'http://127.0.0.1:7897', http_proxy: 'http://127.0.0.1:7897' };
45
+ expect(decideProxy(new URL('https://example.com/'), { ...env, NO_PROXY: 'example.com:443' })).toEqual({ mode: 'direct' });
46
+ expect(decideProxy(new URL('http://example.com/health'), { ...env, NO_PROXY: 'example.com:80' })).toEqual({ mode: 'direct' });
47
+ });
48
+ it('falls back to ALL_PROXY when protocol-specific settings are absent', () => {
49
+ const decision = decideProxy(new URL('http://example.net/data'), { ALL_PROXY: 'socks5://127.0.0.1:1080' });
50
+ expect(decision).toEqual({
51
+ mode: 'proxy',
52
+ proxyUrl: 'socks5://127.0.0.1:1080',
53
+ });
54
+ });
55
+ });
@@ -28,6 +28,7 @@ function createMockPage(overrides = {}) {
28
28
  installInterceptor: vi.fn(),
29
29
  getInterceptedRequests: vi.fn().mockResolvedValue([]),
30
30
  screenshot: vi.fn().mockResolvedValue(''),
31
+ waitForCapture: vi.fn().mockResolvedValue(undefined),
31
32
  ...overrides,
32
33
  };
33
34
  }
@@ -39,6 +39,7 @@ function createMockPage(getCookies) {
39
39
  installInterceptor: vi.fn(),
40
40
  getInterceptedRequests: vi.fn().mockResolvedValue([]),
41
41
  screenshot: vi.fn().mockResolvedValue(''),
42
+ waitForCapture: vi.fn().mockResolvedValue(undefined),
42
43
  };
43
44
  }
44
45
  describe('stepDownload', () => {
@@ -2,7 +2,6 @@
2
2
  * Pipeline step: intercept — declarative XHR interception.
3
3
  */
4
4
  import { render, normalizeEvaluateSource } from '../template.js';
5
- import { generateInterceptorJs, generateReadInterceptedJs } from '../../interceptor.js';
6
5
  export async function stepIntercept(page, params, data, args) {
7
6
  const cfg = typeof params === 'object' ? params : {};
8
7
  const trigger = cfg.trigger ?? '';
@@ -12,7 +11,7 @@ export async function stepIntercept(page, params, data, args) {
12
11
  if (!capturePattern)
13
12
  return data;
14
13
  // Step 1: Inject fetch/XHR interceptor BEFORE trigger
15
- await page.evaluate(generateInterceptorJs(JSON.stringify(capturePattern)));
14
+ await page.installInterceptor(capturePattern);
16
15
  // Step 2: Execute the trigger action
17
16
  if (trigger.startsWith('navigate:')) {
18
17
  const url = render(trigger.slice('navigate:'.length), { args, data });
@@ -29,10 +28,10 @@ export async function stepIntercept(page, params, data, args) {
29
28
  else if (trigger === 'scroll') {
30
29
  await page.scroll('down');
31
30
  }
32
- // Step 3: Wait a bit for network requests to fire
33
- await page.wait(Math.min(timeout, 3));
31
+ // Step 3: Wait for network capture (event-driven, not fixed sleep)
32
+ await page.waitForCapture(timeout);
34
33
  // Step 4: Retrieve captured data
35
- const matchingResponses = await page.evaluate(generateReadInterceptedJs());
34
+ const matchingResponses = await page.getInterceptedRequests();
36
35
  // Step 5: Select from response if specified
37
36
  let result = matchingResponses.length === 1 ? matchingResponses[0] :
38
37
  matchingResponses.length > 1 ? matchingResponses : data;
package/dist/types.d.ts CHANGED
@@ -23,6 +23,7 @@ export interface SnapshotOptions {
23
23
  }
24
24
  export interface WaitOptions {
25
25
  text?: string;
26
+ selector?: string;
26
27
  time?: number;
27
28
  timeout?: number;
28
29
  }
@@ -67,6 +68,7 @@ export interface IPage {
67
68
  }): Promise<void>;
68
69
  installInterceptor(pattern: string): Promise<void>;
69
70
  getInterceptedRequests(): Promise<any[]>;
71
+ waitForCapture(timeout?: number): Promise<void>;
70
72
  screenshot(options?: ScreenshotOptions): Promise<string>;
71
73
  closeWindow?(): Promise<void>;
72
74
  /** Returns the current page URL, or null if unavailable. */
package/dist/utils.d.ts CHANGED
@@ -5,5 +5,7 @@
5
5
  export declare function isRecord(value: unknown): value is Record<string, unknown>;
6
6
  /** Simple async concurrency limiter. */
7
7
  export declare function mapConcurrent<T, R>(items: T[], limit: number, fn: (item: T, index: number) => Promise<R>): Promise<R[]>;
8
+ /** Pause for the given number of milliseconds. */
9
+ export declare function sleep(ms: number): Promise<void>;
8
10
  /** Save a base64-encoded string to a file, creating parent directories as needed. */
9
11
  export declare function saveBase64ToFile(base64: string, filePath: string): Promise<void>;
package/dist/utils.js CHANGED
@@ -21,6 +21,10 @@ export async function mapConcurrent(items, limit, fn) {
21
21
  await Promise.all(workers);
22
22
  return results;
23
23
  }
24
+ /** Pause for the given number of milliseconds. */
25
+ export function sleep(ms) {
26
+ return new Promise(resolve => setTimeout(resolve, ms));
27
+ }
24
28
  /** Save a base64-encoded string to a file, creating parent directories as needed. */
25
29
  export async function saveBase64ToFile(base64, filePath) {
26
30
  const dir = path.dirname(filePath);