@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.
- package/.github/workflows/ci.yml +6 -7
- package/README.md +21 -362
- package/dist/browser/cdp.js +20 -1
- package/dist/browser/daemon-client.js +3 -2
- package/dist/browser/dom-helpers.d.ts +11 -0
- package/dist/browser/dom-helpers.js +42 -0
- package/dist/browser/dom-helpers.test.d.ts +1 -0
- package/dist/browser/dom-helpers.test.js +92 -0
- package/dist/browser/index.d.ts +0 -12
- package/dist/browser/index.js +0 -13
- package/dist/browser/mcp.js +4 -3
- package/dist/browser/page.d.ts +1 -0
- package/dist/browser/page.js +14 -1
- package/dist/browser.test.js +15 -11
- package/dist/clis/36kr/hot.js +1 -1
- package/dist/clis/36kr/search.js +1 -1
- package/dist/clis/_shared/common.d.ts +8 -0
- package/dist/clis/_shared/common.js +10 -0
- package/dist/clis/bloomberg/news.js +1 -1
- package/dist/clis/douban/utils.js +3 -6
- package/dist/clis/medium/utils.js +1 -1
- package/dist/clis/producthunt/browse.js +1 -1
- package/dist/clis/producthunt/hot.js +1 -1
- package/dist/clis/sinablog/utils.js +6 -7
- package/dist/clis/substack/utils.js +2 -2
- package/dist/clis/twitter/block.js +1 -1
- package/dist/clis/twitter/bookmark.js +1 -1
- package/dist/clis/twitter/delete.js +1 -1
- package/dist/clis/twitter/follow.js +1 -1
- package/dist/clis/twitter/followers.js +2 -2
- package/dist/clis/twitter/following.js +2 -2
- package/dist/clis/twitter/hide-reply.js +1 -1
- package/dist/clis/twitter/like.js +1 -1
- package/dist/clis/twitter/notifications.js +1 -1
- package/dist/clis/twitter/profile.js +1 -1
- package/dist/clis/twitter/reply-dm.js +1 -1
- package/dist/clis/twitter/reply.js +1 -1
- package/dist/clis/twitter/search.js +1 -1
- package/dist/clis/twitter/unblock.js +1 -1
- package/dist/clis/twitter/unbookmark.js +1 -1
- package/dist/clis/twitter/unfollow.js +1 -1
- package/dist/clis/xiaohongshu/comments.test.js +1 -0
- package/dist/clis/xiaohongshu/creator-note-detail.test.js +1 -0
- package/dist/clis/xiaohongshu/creator-notes.test.js +1 -0
- package/dist/clis/xiaohongshu/publish.test.js +1 -0
- package/dist/clis/xiaohongshu/search.test.js +1 -0
- package/dist/download/index.js +39 -33
- package/dist/download/index.test.js +15 -1
- package/dist/execution.js +3 -2
- package/dist/main.js +2 -0
- package/dist/node-network.d.ts +10 -0
- package/dist/node-network.js +174 -0
- package/dist/node-network.test.d.ts +1 -0
- package/dist/node-network.test.js +55 -0
- package/dist/pipeline/executor.test.js +1 -0
- package/dist/pipeline/steps/download.test.js +1 -0
- package/dist/pipeline/steps/intercept.js +4 -5
- package/dist/types.d.ts +2 -0
- package/dist/utils.d.ts +2 -0
- package/dist/utils.js +4 -0
- package/docs/superpowers/plans/2026-03-28-perf-smart-wait.md +1143 -0
- package/docs/superpowers/specs/2026-03-28-perf-smart-wait-design.md +170 -0
- package/extension/dist/background.js +1 -1
- package/extension/manifest.json +1 -1
- package/extension/package-lock.json +2 -2
- package/extension/package.json +1 -1
- package/extension/src/background.ts +1 -1
- package/package.json +2 -1
- package/src/browser/cdp.ts +21 -0
- package/src/browser/daemon-client.ts +3 -2
- package/src/browser/dom-helpers.test.ts +100 -0
- package/src/browser/dom-helpers.ts +44 -0
- package/src/browser/index.ts +0 -15
- package/src/browser/mcp.ts +4 -3
- package/src/browser/page.ts +16 -0
- package/src/browser.test.ts +16 -12
- package/src/clis/36kr/hot.ts +1 -1
- package/src/clis/36kr/search.ts +1 -1
- package/src/clis/_shared/common.ts +11 -0
- package/src/clis/bloomberg/news.ts +1 -1
- package/src/clis/douban/utils.ts +3 -7
- package/src/clis/medium/utils.ts +1 -1
- package/src/clis/producthunt/browse.ts +1 -1
- package/src/clis/producthunt/hot.ts +1 -1
- package/src/clis/sinablog/utils.ts +6 -7
- package/src/clis/substack/utils.ts +2 -2
- package/src/clis/twitter/block.ts +1 -1
- package/src/clis/twitter/bookmark.ts +1 -1
- package/src/clis/twitter/delete.ts +1 -1
- package/src/clis/twitter/follow.ts +1 -1
- package/src/clis/twitter/followers.ts +2 -2
- package/src/clis/twitter/following.ts +2 -2
- package/src/clis/twitter/hide-reply.ts +1 -1
- package/src/clis/twitter/like.ts +1 -1
- package/src/clis/twitter/notifications.ts +1 -1
- package/src/clis/twitter/profile.ts +1 -1
- package/src/clis/twitter/reply-dm.ts +1 -1
- package/src/clis/twitter/reply.ts +1 -1
- package/src/clis/twitter/search.ts +1 -1
- package/src/clis/twitter/unblock.ts +1 -1
- package/src/clis/twitter/unbookmark.ts +1 -1
- package/src/clis/twitter/unfollow.ts +1 -1
- package/src/clis/xiaohongshu/comments.test.ts +1 -0
- package/src/clis/xiaohongshu/creator-note-detail.test.ts +1 -0
- package/src/clis/xiaohongshu/creator-notes.test.ts +1 -0
- package/src/clis/xiaohongshu/publish.test.ts +1 -0
- package/src/clis/xiaohongshu/search.test.ts +1 -0
- package/src/download/index.test.ts +19 -1
- package/src/download/index.ts +50 -41
- package/src/execution.ts +3 -2
- package/src/main.ts +3 -0
- package/src/node-network.test.ts +93 -0
- package/src/node-network.ts +213 -0
- package/src/pipeline/executor.test.ts +1 -0
- package/src/pipeline/steps/download.test.ts +1 -0
- package/src/pipeline/steps/intercept.ts +4 -5
- package/src/types.ts +2 -0
- package/src/utils.ts +5 -0
package/src/clis/douban/utils.ts
CHANGED
|
@@ -4,17 +4,13 @@
|
|
|
4
4
|
|
|
5
5
|
import { ArgumentError, CliError, EmptyResultError } from '../../errors.js';
|
|
6
6
|
import type { IPage } from '../../types.js';
|
|
7
|
+
import { clamp } from '../_shared/common.js';
|
|
7
8
|
|
|
8
9
|
const DOUBAN_PHOTO_PAGE_SIZE = 30;
|
|
9
10
|
const MAX_DOUBAN_PHOTOS = 500;
|
|
10
11
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
function clampPhotoLimit(limit: number): number {
|
|
16
|
-
return Math.max(1, Math.min(limit || 120, MAX_DOUBAN_PHOTOS));
|
|
17
|
-
}
|
|
12
|
+
const clampLimit = (limit: number) => clamp(limit || 20, 1, 50);
|
|
13
|
+
const clampPhotoLimit = (limit: number) => clamp(limit || 120, 1, MAX_DOUBAN_PHOTOS);
|
|
18
14
|
|
|
19
15
|
async function ensureDoubanReady(page: IPage): Promise<void> {
|
|
20
16
|
const state = await page.evaluate(`
|
package/src/clis/medium/utils.ts
CHANGED
|
@@ -16,7 +16,7 @@ export function buildMediumUserUrl(username: string): string {
|
|
|
16
16
|
export async function loadMediumPosts(page: IPage, url: string, limit: number): Promise<any[]> {
|
|
17
17
|
if (!page) throw new CommandExecutionError('Browser session required for medium posts');
|
|
18
18
|
await page.goto(url);
|
|
19
|
-
await page.wait(5);
|
|
19
|
+
await page.wait({ selector: 'article', timeout: 5 });
|
|
20
20
|
const data = await page.evaluate(`
|
|
21
21
|
(async () => {
|
|
22
22
|
await new Promise((resolve) => setTimeout(resolve, 3000));
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import type { IPage } from '../../types.js';
|
|
2
|
+
import { clamp } from '../_shared/common.js';
|
|
2
3
|
|
|
3
|
-
|
|
4
|
-
return Math.max(1, Math.min(limit || 20, 50));
|
|
5
|
-
}
|
|
4
|
+
const clampLimit = (limit: number) => clamp(limit || 20, 1, 50);
|
|
6
5
|
|
|
7
6
|
export function buildSinaBlogSearchUrl(keyword: string): string {
|
|
8
7
|
return `https://search.sina.com.cn/search?q=${encodeURIComponent(keyword)}&tp=mix`;
|
|
@@ -14,7 +13,7 @@ export function buildSinaBlogUserUrl(uid: string): string {
|
|
|
14
13
|
|
|
15
14
|
export async function loadSinaBlogArticle(page: IPage, url: string): Promise<any> {
|
|
16
15
|
await page.goto(url);
|
|
17
|
-
await page.wait(3);
|
|
16
|
+
await page.wait({ selector: 'h1', timeout: 3 });
|
|
18
17
|
return page.evaluate(`
|
|
19
18
|
(async () => {
|
|
20
19
|
await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
@@ -52,7 +51,7 @@ export async function loadSinaBlogArticle(page: IPage, url: string): Promise<any
|
|
|
52
51
|
export async function loadSinaBlogHot(page: IPage, limit: number): Promise<any[]> {
|
|
53
52
|
const safeLimit = clampLimit(limit);
|
|
54
53
|
await page.goto('https://blog.sina.com.cn/');
|
|
55
|
-
await page.wait(3);
|
|
54
|
+
await page.wait({ selector: 'h1', timeout: 3 });
|
|
56
55
|
const data = await page.evaluate(`
|
|
57
56
|
(async () => {
|
|
58
57
|
await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
@@ -122,7 +121,7 @@ export async function loadSinaBlogHot(page: IPage, limit: number): Promise<any[]
|
|
|
122
121
|
export async function loadSinaBlogSearch(page: IPage, keyword: string, limit: number): Promise<any[]> {
|
|
123
122
|
const safeLimit = clampLimit(limit);
|
|
124
123
|
await page.goto(buildSinaBlogSearchUrl(keyword));
|
|
125
|
-
await page.wait(5);
|
|
124
|
+
await page.wait({ selector: '.result-item', timeout: 5 });
|
|
126
125
|
const data = await page.evaluate(`
|
|
127
126
|
(async () => {
|
|
128
127
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
@@ -159,7 +158,7 @@ export async function loadSinaBlogSearch(page: IPage, keyword: string, limit: nu
|
|
|
159
158
|
export async function loadSinaBlogUser(page: IPage, uid: string, limit: number): Promise<any[]> {
|
|
160
159
|
const safeLimit = clampLimit(limit);
|
|
161
160
|
await page.goto(buildSinaBlogUserUrl(uid));
|
|
162
|
-
await page.wait(3);
|
|
161
|
+
await page.wait({ selector: 'h1', timeout: 3 });
|
|
163
162
|
const data = await page.evaluate(`
|
|
164
163
|
(async () => {
|
|
165
164
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
@@ -10,7 +10,7 @@ export function buildSubstackBrowseUrl(category?: string): string {
|
|
|
10
10
|
export async function loadSubstackFeed(page: IPage, url: string, limit: number): Promise<any[]> {
|
|
11
11
|
if (!page) throw new CommandExecutionError('Browser session required for substack feed');
|
|
12
12
|
await page.goto(url);
|
|
13
|
-
await page.wait(5);
|
|
13
|
+
await page.wait({ selector: 'article', timeout: 5 });
|
|
14
14
|
const data = await page.evaluate(`
|
|
15
15
|
(async () => {
|
|
16
16
|
await new Promise((resolve) => setTimeout(resolve, 3000));
|
|
@@ -79,7 +79,7 @@ export async function loadSubstackFeed(page: IPage, url: string, limit: number):
|
|
|
79
79
|
export async function loadSubstackArchive(page: IPage, baseUrl: string, limit: number): Promise<any[]> {
|
|
80
80
|
if (!page) throw new CommandExecutionError('Browser session required for substack archive');
|
|
81
81
|
await page.goto(`${baseUrl}/archive`);
|
|
82
|
-
await page.wait(5);
|
|
82
|
+
await page.wait({ selector: 'article', timeout: 5 });
|
|
83
83
|
const data = await page.evaluate(`
|
|
84
84
|
(async () => {
|
|
85
85
|
await new Promise((resolve) => setTimeout(resolve, 3000));
|
|
@@ -18,7 +18,7 @@ cli({
|
|
|
18
18
|
const username = kwargs.username.replace(/^@/, '');
|
|
19
19
|
|
|
20
20
|
await page.goto(`https://x.com/${username}`);
|
|
21
|
-
await page.wait(
|
|
21
|
+
await page.wait({ selector: '[data-testid="primaryColumn"]' });
|
|
22
22
|
|
|
23
23
|
const result = await page.evaluate(`(async () => {
|
|
24
24
|
try {
|
|
@@ -17,7 +17,7 @@ cli({
|
|
|
17
17
|
if (!page) throw new CommandExecutionError('Browser session required for twitter bookmark');
|
|
18
18
|
|
|
19
19
|
await page.goto(kwargs.url);
|
|
20
|
-
await page.wait(
|
|
20
|
+
await page.wait({ selector: '[data-testid="primaryColumn"]' });
|
|
21
21
|
|
|
22
22
|
const result = await page.evaluate(`(async () => {
|
|
23
23
|
try {
|
|
@@ -17,7 +17,7 @@ cli({
|
|
|
17
17
|
if (!page) throw new CommandExecutionError('Browser session required for twitter delete');
|
|
18
18
|
|
|
19
19
|
await page.goto(kwargs.url);
|
|
20
|
-
await page.wait(
|
|
20
|
+
await page.wait({ selector: '[data-testid="primaryColumn"]' }); // Wait for tweet to load completely
|
|
21
21
|
|
|
22
22
|
const result = await page.evaluate(`(async () => {
|
|
23
23
|
try {
|
|
@@ -18,7 +18,7 @@ cli({
|
|
|
18
18
|
const username = kwargs.username.replace(/^@/, '');
|
|
19
19
|
|
|
20
20
|
await page.goto(`https://x.com/${username}`);
|
|
21
|
-
await page.wait(
|
|
21
|
+
await page.wait({ selector: '[data-testid="primaryColumn"]' });
|
|
22
22
|
|
|
23
23
|
const result = await page.evaluate(`(async () => {
|
|
24
24
|
try {
|
|
@@ -19,7 +19,7 @@ cli({
|
|
|
19
19
|
// If no user is specified, figure out the logged-in user's handle
|
|
20
20
|
if (!targetUser) {
|
|
21
21
|
await page.goto('https://x.com/home');
|
|
22
|
-
await page.wait(
|
|
22
|
+
await page.wait({ selector: '[data-testid="primaryColumn"]' });
|
|
23
23
|
|
|
24
24
|
const href = await page.evaluate(`() => {
|
|
25
25
|
const link = document.querySelector('a[data-testid="AppTabBar_Profile_Link"]');
|
|
@@ -58,7 +58,7 @@ cli({
|
|
|
58
58
|
if (!clicked) {
|
|
59
59
|
throw new SelectorError('Twitter followers link', 'Twitter may have changed the layout.');
|
|
60
60
|
}
|
|
61
|
-
await page.
|
|
61
|
+
await page.waitForCapture(5);
|
|
62
62
|
|
|
63
63
|
// 4. Scroll to trigger pagination API calls
|
|
64
64
|
await page.autoScroll({ times: Math.ceil(kwargs.limit / 20), delayMs: 2000 });
|
|
@@ -19,7 +19,7 @@ cli({
|
|
|
19
19
|
// If no user is specified, figure out the logged-in user's handle
|
|
20
20
|
if (!targetUser) {
|
|
21
21
|
await page.goto('https://x.com/home');
|
|
22
|
-
await page.wait(
|
|
22
|
+
await page.wait({ selector: '[data-testid="primaryColumn"]' });
|
|
23
23
|
|
|
24
24
|
const href = await page.evaluate(`() => {
|
|
25
25
|
const link = document.querySelector('a[data-testid="AppTabBar_Profile_Link"]');
|
|
@@ -51,7 +51,7 @@ cli({
|
|
|
51
51
|
if (!clicked) {
|
|
52
52
|
throw new SelectorError('Twitter following link', 'Twitter may have changed the layout.');
|
|
53
53
|
}
|
|
54
|
-
await page.
|
|
54
|
+
await page.waitForCapture(5);
|
|
55
55
|
|
|
56
56
|
// 4. Scroll to trigger pagination API calls
|
|
57
57
|
await page.autoScroll({ times: Math.ceil(kwargs.limit / 20), delayMs: 2000 });
|
|
@@ -17,7 +17,7 @@ cli({
|
|
|
17
17
|
if (!page) throw new CommandExecutionError('Browser session required for twitter hide-reply');
|
|
18
18
|
|
|
19
19
|
await page.goto(kwargs.url);
|
|
20
|
-
await page.wait(
|
|
20
|
+
await page.wait({ selector: '[data-testid="primaryColumn"]' });
|
|
21
21
|
|
|
22
22
|
const result = await page.evaluate(`(async () => {
|
|
23
23
|
try {
|
package/src/clis/twitter/like.ts
CHANGED
|
@@ -17,7 +17,7 @@ cli({
|
|
|
17
17
|
if (!page) throw new CommandExecutionError('Browser session required for twitter like');
|
|
18
18
|
|
|
19
19
|
await page.goto(kwargs.url);
|
|
20
|
-
await page.wait(
|
|
20
|
+
await page.wait({ selector: '[data-testid="primaryColumn"]' }); // Wait for tweet to load completely
|
|
21
21
|
|
|
22
22
|
const result = await page.evaluate(`(async () => {
|
|
23
23
|
try {
|
|
@@ -25,7 +25,7 @@ cli({
|
|
|
25
25
|
window.history.pushState({}, '', '/notifications');
|
|
26
26
|
window.dispatchEvent(new PopStateEvent('popstate', { state: {} }));
|
|
27
27
|
}`);
|
|
28
|
-
await page.
|
|
28
|
+
await page.waitForCapture(5);
|
|
29
29
|
|
|
30
30
|
// Verify SPA navigation succeeded
|
|
31
31
|
const currentUrl = await page.evaluate('() => window.location.pathname');
|
|
@@ -21,7 +21,7 @@ cli({
|
|
|
21
21
|
// If no username, detect the logged-in user
|
|
22
22
|
if (!username) {
|
|
23
23
|
await page.goto('https://x.com/home');
|
|
24
|
-
await page.wait(
|
|
24
|
+
await page.wait({ selector: '[data-testid="primaryColumn"]' });
|
|
25
25
|
const href = await page.evaluate(`() => {
|
|
26
26
|
const link = document.querySelector('a[data-testid="AppTabBar_Profile_Link"]');
|
|
27
27
|
return link ? link.getAttribute('href') : null;
|
|
@@ -27,7 +27,7 @@ cli({
|
|
|
27
27
|
|
|
28
28
|
// Step 1: Navigate to messages to get conversation list
|
|
29
29
|
await page.goto('https://x.com/messages');
|
|
30
|
-
await page.wait(
|
|
30
|
+
await page.wait({ selector: '[data-testid="primaryColumn"]' });
|
|
31
31
|
|
|
32
32
|
// Step 2: Collect conversations with scroll-to-load
|
|
33
33
|
const needed = maxSend + 10; // extra buffer for skips
|
|
@@ -19,7 +19,7 @@ cli({
|
|
|
19
19
|
|
|
20
20
|
// 1. Navigate to the tweet page
|
|
21
21
|
await page.goto(kwargs.url);
|
|
22
|
-
await page.wait(
|
|
22
|
+
await page.wait({ selector: '[data-testid="primaryColumn"]' });
|
|
23
23
|
|
|
24
24
|
// 2. Automate typing the reply and clicking reply
|
|
25
25
|
const result = await page.evaluate(`(async () => {
|
|
@@ -20,7 +20,7 @@ async function navigateToSearch(page: Pick<IPage, 'evaluate' | 'wait'>, query: s
|
|
|
20
20
|
window.dispatchEvent(new PopStateEvent('popstate', { state: {} }));
|
|
21
21
|
})()
|
|
22
22
|
`);
|
|
23
|
-
await page.wait(
|
|
23
|
+
await page.wait({ selector: '[data-testid="primaryColumn"]' });
|
|
24
24
|
|
|
25
25
|
lastPath = String(await page.evaluate('() => window.location.pathname') || '');
|
|
26
26
|
if (lastPath.startsWith('/search')) {
|
|
@@ -18,7 +18,7 @@ cli({
|
|
|
18
18
|
const username = kwargs.username.replace(/^@/, '');
|
|
19
19
|
|
|
20
20
|
await page.goto(`https://x.com/${username}`);
|
|
21
|
-
await page.wait(
|
|
21
|
+
await page.wait({ selector: '[data-testid="primaryColumn"]' });
|
|
22
22
|
|
|
23
23
|
const result = await page.evaluate(`(async () => {
|
|
24
24
|
try {
|
|
@@ -17,7 +17,7 @@ cli({
|
|
|
17
17
|
if (!page) throw new CommandExecutionError('Browser session required for twitter unbookmark');
|
|
18
18
|
|
|
19
19
|
await page.goto(kwargs.url);
|
|
20
|
-
await page.wait(
|
|
20
|
+
await page.wait({ selector: '[data-testid="primaryColumn"]' });
|
|
21
21
|
|
|
22
22
|
const result = await page.evaluate(`(async () => {
|
|
23
23
|
try {
|
|
@@ -18,7 +18,7 @@ cli({
|
|
|
18
18
|
const username = kwargs.username.replace(/^@/, '');
|
|
19
19
|
|
|
20
20
|
await page.goto(`https://x.com/${username}`);
|
|
21
|
-
await page.wait(
|
|
21
|
+
await page.wait({ selector: '[data-testid="primaryColumn"]' });
|
|
22
22
|
|
|
23
23
|
const result = await page.evaluate(`(async () => {
|
|
24
24
|
try {
|
|
@@ -26,6 +26,7 @@ function createPageMock(evaluateResult: any): IPage {
|
|
|
26
26
|
getInterceptedRequests: vi.fn().mockResolvedValue([]),
|
|
27
27
|
getCookies: vi.fn().mockResolvedValue([]),
|
|
28
28
|
screenshot: vi.fn().mockResolvedValue(''),
|
|
29
|
+
waitForCapture: vi.fn().mockResolvedValue(undefined),
|
|
29
30
|
};
|
|
30
31
|
}
|
|
31
32
|
|
|
@@ -33,6 +33,7 @@ function createPageMock(evaluateResult: any): IPage {
|
|
|
33
33
|
getInterceptedRequests: vi.fn().mockResolvedValue([]),
|
|
34
34
|
getCookies: vi.fn().mockResolvedValue([]),
|
|
35
35
|
screenshot: vi.fn().mockResolvedValue(''),
|
|
36
|
+
waitForCapture: vi.fn().mockResolvedValue(undefined),
|
|
36
37
|
};
|
|
37
38
|
}
|
|
38
39
|
|
|
@@ -37,6 +37,7 @@ function createPageMock(evaluateResult: any, interceptedRequests: any[] = []): I
|
|
|
37
37
|
getInterceptedRequests,
|
|
38
38
|
getCookies: vi.fn().mockResolvedValue([]),
|
|
39
39
|
screenshot: vi.fn().mockResolvedValue(''),
|
|
40
|
+
waitForCapture: vi.fn().mockResolvedValue(undefined),
|
|
40
41
|
};
|
|
41
42
|
}
|
|
42
43
|
|
|
@@ -36,6 +36,7 @@ function createPageMock(evaluateResults: any[]): IPage {
|
|
|
36
36
|
getInterceptedRequests: vi.fn().mockResolvedValue([]),
|
|
37
37
|
getCookies: vi.fn().mockResolvedValue([]),
|
|
38
38
|
screenshot: vi.fn().mockResolvedValue(''),
|
|
39
|
+
waitForCapture: vi.fn().mockResolvedValue(undefined),
|
|
39
40
|
};
|
|
40
41
|
}
|
|
41
42
|
|
|
@@ -31,6 +31,7 @@ function createPageMock(evaluateResults: any[]): IPage {
|
|
|
31
31
|
getInterceptedRequests: vi.fn().mockResolvedValue([]),
|
|
32
32
|
getCookies: vi.fn().mockResolvedValue([]),
|
|
33
33
|
screenshot: vi.fn().mockResolvedValue(''),
|
|
34
|
+
waitForCapture: vi.fn().mockResolvedValue(undefined),
|
|
34
35
|
};
|
|
35
36
|
}
|
|
36
37
|
|
|
@@ -2,13 +2,14 @@ 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
|
|
|
8
8
|
const servers: http.Server[] = [];
|
|
9
9
|
const tempDirs: string[] = [];
|
|
10
10
|
|
|
11
11
|
afterEach(async () => {
|
|
12
|
+
vi.unstubAllEnvs();
|
|
12
13
|
await Promise.all(servers.map((server) => new Promise<void>((resolve, reject) => {
|
|
13
14
|
server.close((err) => (err ? reject(err) : resolve()));
|
|
14
15
|
})));
|
|
@@ -114,4 +115,21 @@ describe('download helpers', { retry: process.platform === 'win32' ? 2 : 0 }, ()
|
|
|
114
115
|
expect(forwardedCookie).toBeUndefined();
|
|
115
116
|
expect(fs.readFileSync(destPath, 'utf8')).toBe('ok');
|
|
116
117
|
});
|
|
118
|
+
|
|
119
|
+
it('bypasses proxy settings for loopback downloads', async () => {
|
|
120
|
+
vi.stubEnv('HTTP_PROXY', 'http://127.0.0.1:9');
|
|
121
|
+
|
|
122
|
+
const baseUrl = await startServer((_req, res) => {
|
|
123
|
+
res.statusCode = 200;
|
|
124
|
+
res.end('ok');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'opencli-dl-'));
|
|
128
|
+
tempDirs.push(tempDir);
|
|
129
|
+
const destPath = path.join(tempDir, 'loopback.txt');
|
|
130
|
+
const result = await httpDownload(`${baseUrl}/ok`, destPath);
|
|
131
|
+
|
|
132
|
+
expect(result).toEqual({ success: true, size: 2 });
|
|
133
|
+
expect(fs.readFileSync(destPath, 'utf8')).toBe('ok');
|
|
134
|
+
});
|
|
117
135
|
});
|
package/src/download/index.ts
CHANGED
|
@@ -5,16 +5,16 @@
|
|
|
5
5
|
import { spawn } from 'node:child_process';
|
|
6
6
|
import * as fs from 'node:fs';
|
|
7
7
|
import * as path from 'node:path';
|
|
8
|
-
import * as https from 'node:https';
|
|
9
|
-
import * as http from 'node:http';
|
|
10
8
|
import * as os from 'node:os';
|
|
11
|
-
import { Transform } from 'node:stream';
|
|
9
|
+
import { Readable, Transform } from 'node:stream';
|
|
10
|
+
import type { ReadableStream as WebReadableStream } from 'node:stream/web';
|
|
12
11
|
import { pipeline } from 'node:stream/promises';
|
|
13
12
|
import { URL } from 'node:url';
|
|
14
13
|
import type { ProgressBar } from './progress.js';
|
|
15
14
|
import { isBinaryInstalled } from '../external.js';
|
|
16
15
|
import type { BrowserCookie } from '../types.js';
|
|
17
16
|
import { getErrorMessage } from '../errors.js';
|
|
17
|
+
import { fetchWithNodeNetwork } from '../node-network.js';
|
|
18
18
|
|
|
19
19
|
export type { BrowserCookie } from '../types.js';
|
|
20
20
|
|
|
@@ -89,9 +89,6 @@ export async function httpDownload(
|
|
|
89
89
|
const { cookies, headers = {}, timeout = 30000, onProgress, maxRedirects = 10 } = options;
|
|
90
90
|
|
|
91
91
|
return new Promise((resolve) => {
|
|
92
|
-
const parsedUrl = new URL(url);
|
|
93
|
-
const protocol = parsedUrl.protocol === 'https:' ? https : http;
|
|
94
|
-
|
|
95
92
|
const requestHeaders: Record<string, string> = {
|
|
96
93
|
'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',
|
|
97
94
|
...headers,
|
|
@@ -118,37 +115,52 @@ export async function httpDownload(
|
|
|
118
115
|
}
|
|
119
116
|
};
|
|
120
117
|
|
|
121
|
-
|
|
122
|
-
|
|
118
|
+
void (async () => {
|
|
119
|
+
const controller = new AbortController();
|
|
120
|
+
const timer = setTimeout(() => controller.abort(), timeout);
|
|
121
|
+
try {
|
|
122
|
+
const response = await fetchWithNodeNetwork(url, {
|
|
123
|
+
headers: requestHeaders,
|
|
124
|
+
signal: controller.signal,
|
|
125
|
+
redirect: 'manual',
|
|
126
|
+
});
|
|
127
|
+
clearTimeout(timer);
|
|
128
|
+
|
|
123
129
|
// Handle redirects before creating any file handles.
|
|
124
|
-
if (response.
|
|
125
|
-
response.
|
|
126
|
-
if (
|
|
127
|
-
|
|
130
|
+
if (response.status >= 300 && response.status < 400) {
|
|
131
|
+
const location = response.headers.get('location');
|
|
132
|
+
if (location) {
|
|
133
|
+
if (redirectCount >= maxRedirects) {
|
|
134
|
+
finish({ success: false, size: 0, error: `Too many redirects (> ${maxRedirects})` });
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const redirectUrl = resolveRedirectUrl(url, location);
|
|
138
|
+
const originalHost = new URL(url).hostname;
|
|
139
|
+
const redirectHost = new URL(redirectUrl).hostname;
|
|
140
|
+
const redirectOptions = originalHost === redirectHost
|
|
141
|
+
? options
|
|
142
|
+
: { ...options, cookies: undefined, headers: stripCookieHeaders(options.headers) };
|
|
143
|
+
finish(await httpDownload(
|
|
144
|
+
redirectUrl,
|
|
145
|
+
destPath,
|
|
146
|
+
redirectOptions,
|
|
147
|
+
redirectCount + 1,
|
|
148
|
+
));
|
|
128
149
|
return;
|
|
129
150
|
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
? options
|
|
135
|
-
: { ...options, cookies: undefined, headers: stripCookieHeaders(options.headers) };
|
|
136
|
-
finish(await httpDownload(
|
|
137
|
-
redirectUrl,
|
|
138
|
-
destPath,
|
|
139
|
-
redirectOptions,
|
|
140
|
-
redirectCount + 1,
|
|
141
|
-
));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (response.status !== 200) {
|
|
154
|
+
finish({ success: false, size: 0, error: `HTTP ${response.status}` });
|
|
142
155
|
return;
|
|
143
156
|
}
|
|
144
157
|
|
|
145
|
-
if (response.
|
|
146
|
-
response
|
|
147
|
-
finish({ success: false, size: 0, error: `HTTP ${response.statusCode}` });
|
|
158
|
+
if (!response.body) {
|
|
159
|
+
finish({ success: false, size: 0, error: 'Empty response body' });
|
|
148
160
|
return;
|
|
149
161
|
}
|
|
150
162
|
|
|
151
|
-
const totalSize = parseInt(response.headers
|
|
163
|
+
const totalSize = parseInt(response.headers.get('content-length') || '0', 10);
|
|
152
164
|
let received = 0;
|
|
153
165
|
const progressStream = new Transform({
|
|
154
166
|
transform(chunk, _encoding, callback) {
|
|
@@ -160,26 +172,23 @@ export async function httpDownload(
|
|
|
160
172
|
|
|
161
173
|
try {
|
|
162
174
|
await fs.promises.mkdir(path.dirname(destPath), { recursive: true });
|
|
163
|
-
await pipeline(
|
|
175
|
+
await pipeline(
|
|
176
|
+
Readable.fromWeb(response.body as unknown as WebReadableStream),
|
|
177
|
+
progressStream,
|
|
178
|
+
fs.createWriteStream(tempPath),
|
|
179
|
+
);
|
|
164
180
|
await fs.promises.rename(tempPath, destPath);
|
|
165
181
|
finish({ success: true, size: received });
|
|
166
182
|
} catch (err) {
|
|
167
183
|
await cleanupTempFile();
|
|
168
184
|
finish({ success: false, size: 0, error: getErrorMessage(err) });
|
|
169
185
|
}
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
request.on('error', (err) => {
|
|
174
|
-
void (async () => {
|
|
186
|
+
} catch (err) {
|
|
187
|
+
clearTimeout(timer);
|
|
175
188
|
await cleanupTempFile();
|
|
176
|
-
finish({ success: false, size: 0, error: err.message });
|
|
177
|
-
}
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
request.on('timeout', () => {
|
|
181
|
-
request.destroy(new Error('Timeout'));
|
|
182
|
-
});
|
|
189
|
+
finish({ success: false, size: 0, error: err instanceof Error ? err.message : String(err) });
|
|
190
|
+
}
|
|
191
|
+
})();
|
|
183
192
|
});
|
|
184
193
|
}
|
|
185
194
|
|
package/src/execution.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { shouldUseBrowserSession } from './capabilityRouting.js';
|
|
|
19
19
|
import { getBrowserFactory, browserSession, runWithTimeout, DEFAULT_BROWSER_COMMAND_TIMEOUT } from './runtime.js';
|
|
20
20
|
import { emitHook, type HookContext } from './hooks.js';
|
|
21
21
|
import { checkDaemonStatus } from './browser/discover.js';
|
|
22
|
+
import { log } from './logger.js';
|
|
22
23
|
|
|
23
24
|
const _loadedModules = new Set<string>();
|
|
24
25
|
|
|
@@ -191,14 +192,14 @@ export async function executeCommand(
|
|
|
191
192
|
if (preNavUrl) {
|
|
192
193
|
const skip = await isAlreadyOnDomain(page, preNavUrl);
|
|
193
194
|
if (skip) {
|
|
194
|
-
if (debug)
|
|
195
|
+
if (debug) log.debug('[pre-nav] Already on target domain, skipping navigation');
|
|
195
196
|
} else {
|
|
196
197
|
try {
|
|
197
198
|
// goto() already includes smart DOM-settle detection (waitForDomStable).
|
|
198
199
|
// No additional fixed sleep needed.
|
|
199
200
|
await page.goto(preNavUrl);
|
|
200
201
|
} catch (err) {
|
|
201
|
-
if (debug)
|
|
202
|
+
if (debug) log.debug(`[pre-nav] Failed to navigate to ${preNavUrl}: ${err instanceof Error ? err.message : err}`);
|
|
202
203
|
}
|
|
203
204
|
}
|
|
204
205
|
}
|
package/src/main.ts
CHANGED
|
@@ -20,8 +20,11 @@ import { discoverClis, discoverPlugins } from './discovery.js';
|
|
|
20
20
|
import { getCompletions } from './completion.js';
|
|
21
21
|
import { runCli } from './cli.js';
|
|
22
22
|
import { emitHook } from './hooks.js';
|
|
23
|
+
import { installNodeNetwork } from './node-network.js';
|
|
23
24
|
import { registerUpdateNoticeOnExit, checkForUpdateBackground } from './update-check.js';
|
|
24
25
|
|
|
26
|
+
installNodeNetwork();
|
|
27
|
+
|
|
25
28
|
const __filename = fileURLToPath(import.meta.url);
|
|
26
29
|
const __dirname = path.dirname(__filename);
|
|
27
30
|
const BUILTIN_CLIS = path.resolve(__dirname, 'clis');
|