@jackwener/opencli 1.5.1 → 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/discover.js +11 -7
- 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 -10
- package/dist/browser/index.js +0 -11
- package/dist/browser/mcp.js +4 -3
- package/dist/browser/page.d.ts +2 -0
- package/dist/browser/page.js +42 -3
- package/dist/browser.test.js +17 -8
- package/dist/cli-manifest.json +4 -5
- 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/apple-podcasts/commands.test.js +26 -3
- package/dist/clis/apple-podcasts/top.js +4 -1
- 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/v2ex/hot.yaml +3 -17
- package/dist/clis/weread/shelf.js +132 -9
- package/dist/clis/weread/utils.js +5 -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/daemon.js +1 -0
- package/dist/doctor.js +7 -3
- package/dist/download/index.js +39 -33
- package/dist/download/index.test.js +15 -1
- package/dist/execution.js +3 -8
- package/dist/extension-manifest-regression.test.d.ts +1 -0
- package/dist/extension-manifest-regression.test.js +12 -0
- 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/dist/weread-private-api-regression.test.js +185 -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 +4 -2
- package/extension/manifest.json +4 -1
- package/extension/package-lock.json +2 -2
- package/extension/package.json +1 -1
- package/extension/src/background.ts +2 -1
- package/package.json +2 -1
- package/src/browser/cdp.ts +21 -0
- package/src/browser/daemon-client.ts +3 -2
- package/src/browser/discover.ts +10 -7
- package/src/browser/dom-helpers.test.ts +100 -0
- package/src/browser/dom-helpers.ts +44 -0
- package/src/browser/index.ts +0 -13
- package/src/browser/mcp.ts +4 -3
- package/src/browser/page.ts +41 -2
- package/src/browser.test.ts +19 -9
- 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/apple-podcasts/commands.test.ts +30 -2
- package/src/clis/apple-podcasts/top.ts +4 -1
- 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/v2ex/hot.yaml +3 -17
- package/src/clis/weread/shelf.ts +169 -9
- package/src/clis/weread/utils.ts +6 -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/daemon.ts +1 -0
- package/src/doctor.ts +9 -5
- package/src/download/index.test.ts +19 -1
- package/src/download/index.ts +50 -41
- package/src/execution.ts +3 -11
- package/src/extension-manifest-regression.test.ts +17 -0
- 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/weread-private-api-regression.test.ts +207 -0
- package/tests/e2e/browser-public.test.ts +1 -1
- package/tests/e2e/output-formats.test.ts +10 -14
- package/tests/e2e/plugin-management.test.ts +4 -1
- package/tests/e2e/public-commands.test.ts +12 -1
- package/vitest.config.ts +1 -15
|
@@ -15,7 +15,7 @@ cli({
|
|
|
15
15
|
if (!page)
|
|
16
16
|
throw new CommandExecutionError('Browser session required for twitter like');
|
|
17
17
|
await page.goto(kwargs.url);
|
|
18
|
-
await page.wait(
|
|
18
|
+
await page.wait({ selector: '[data-testid="primaryColumn"]' }); // Wait for tweet to load completely
|
|
19
19
|
const result = await page.evaluate(`(async () => {
|
|
20
20
|
try {
|
|
21
21
|
// Poll for the tweet to render
|
|
@@ -22,7 +22,7 @@ cli({
|
|
|
22
22
|
window.history.pushState({}, '', '/notifications');
|
|
23
23
|
window.dispatchEvent(new PopStateEvent('popstate', { state: {} }));
|
|
24
24
|
}`);
|
|
25
|
-
await page.
|
|
25
|
+
await page.waitForCapture(5);
|
|
26
26
|
// Verify SPA navigation succeeded
|
|
27
27
|
const currentUrl = await page.evaluate('() => window.location.pathname');
|
|
28
28
|
if (currentUrl !== '/notifications') {
|
|
@@ -18,7 +18,7 @@ cli({
|
|
|
18
18
|
// If no username, detect the logged-in user
|
|
19
19
|
if (!username) {
|
|
20
20
|
await page.goto('https://x.com/home');
|
|
21
|
-
await page.wait(
|
|
21
|
+
await page.wait({ selector: '[data-testid="primaryColumn"]' });
|
|
22
22
|
const href = await page.evaluate(`() => {
|
|
23
23
|
const link = document.querySelector('a[data-testid="AppTabBar_Profile_Link"]');
|
|
24
24
|
return link ? link.getAttribute('href') : null;
|
|
@@ -24,7 +24,7 @@ cli({
|
|
|
24
24
|
let sentCount = 0;
|
|
25
25
|
// Step 1: Navigate to messages to get conversation list
|
|
26
26
|
await page.goto('https://x.com/messages');
|
|
27
|
-
await page.wait(
|
|
27
|
+
await page.wait({ selector: '[data-testid="primaryColumn"]' });
|
|
28
28
|
// Step 2: Collect conversations with scroll-to-load
|
|
29
29
|
const needed = maxSend + 10; // extra buffer for skips
|
|
30
30
|
const convList = await page.evaluate(`(async () => {
|
|
@@ -17,7 +17,7 @@ cli({
|
|
|
17
17
|
throw new CommandExecutionError('Browser session required for twitter reply');
|
|
18
18
|
// 1. Navigate to the tweet page
|
|
19
19
|
await page.goto(kwargs.url);
|
|
20
|
-
await page.wait(
|
|
20
|
+
await page.wait({ selector: '[data-testid="primaryColumn"]' });
|
|
21
21
|
// 2. Automate typing the reply and clicking reply
|
|
22
22
|
const result = await page.evaluate(`(async () => {
|
|
23
23
|
try {
|
|
@@ -17,7 +17,7 @@ async function navigateToSearch(page, query, filter) {
|
|
|
17
17
|
window.dispatchEvent(new PopStateEvent('popstate', { state: {} }));
|
|
18
18
|
})()
|
|
19
19
|
`);
|
|
20
|
-
await page.wait(
|
|
20
|
+
await page.wait({ selector: '[data-testid="primaryColumn"]' });
|
|
21
21
|
lastPath = String(await page.evaluate('() => window.location.pathname') || '');
|
|
22
22
|
if (lastPath.startsWith('/search')) {
|
|
23
23
|
return;
|
|
@@ -16,7 +16,7 @@ cli({
|
|
|
16
16
|
throw new CommandExecutionError('Browser session required for twitter unblock');
|
|
17
17
|
const username = kwargs.username.replace(/^@/, '');
|
|
18
18
|
await page.goto(`https://x.com/${username}`);
|
|
19
|
-
await page.wait(
|
|
19
|
+
await page.wait({ selector: '[data-testid="primaryColumn"]' });
|
|
20
20
|
const result = await page.evaluate(`(async () => {
|
|
21
21
|
try {
|
|
22
22
|
let attempts = 0;
|
|
@@ -15,7 +15,7 @@ cli({
|
|
|
15
15
|
if (!page)
|
|
16
16
|
throw new CommandExecutionError('Browser session required for twitter unbookmark');
|
|
17
17
|
await page.goto(kwargs.url);
|
|
18
|
-
await page.wait(
|
|
18
|
+
await page.wait({ selector: '[data-testid="primaryColumn"]' });
|
|
19
19
|
const result = await page.evaluate(`(async () => {
|
|
20
20
|
try {
|
|
21
21
|
let attempts = 0;
|
|
@@ -16,7 +16,7 @@ cli({
|
|
|
16
16
|
throw new CommandExecutionError('Browser session required for twitter unfollow');
|
|
17
17
|
const username = kwargs.username.replace(/^@/, '');
|
|
18
18
|
await page.goto(`https://x.com/${username}`);
|
|
19
|
-
await page.wait(
|
|
19
|
+
await page.wait({ selector: '[data-testid="primaryColumn"]' });
|
|
20
20
|
const result = await page.evaluate(`(async () => {
|
|
21
21
|
try {
|
|
22
22
|
let attempts = 0;
|
package/dist/clis/v2ex/hot.yaml
CHANGED
|
@@ -3,7 +3,7 @@ name: hot
|
|
|
3
3
|
description: V2EX 热门话题
|
|
4
4
|
domain: www.v2ex.com
|
|
5
5
|
strategy: public
|
|
6
|
-
browser:
|
|
6
|
+
browser: false
|
|
7
7
|
|
|
8
8
|
args:
|
|
9
9
|
limit:
|
|
@@ -12,22 +12,8 @@ args:
|
|
|
12
12
|
description: Number of topics
|
|
13
13
|
|
|
14
14
|
pipeline:
|
|
15
|
-
-
|
|
16
|
-
|
|
17
|
-
- evaluate: |
|
|
18
|
-
(async () => {
|
|
19
|
-
const response = await fetch('/api/topics/hot.json', {
|
|
20
|
-
credentials: 'include',
|
|
21
|
-
headers: {
|
|
22
|
-
accept: 'application/json, text/plain, */*',
|
|
23
|
-
'x-requested-with': 'XMLHttpRequest',
|
|
24
|
-
},
|
|
25
|
-
});
|
|
26
|
-
if (!response.ok) {
|
|
27
|
-
throw new Error(`V2EX hot API request failed: ${response.status}`);
|
|
28
|
-
}
|
|
29
|
-
return await response.json();
|
|
30
|
-
})()
|
|
15
|
+
- fetch:
|
|
16
|
+
url: https://www.v2ex.com/api/topics/hot.json
|
|
31
17
|
|
|
32
18
|
- map:
|
|
33
19
|
rank: ${{ index + 1 }}
|
|
@@ -1,5 +1,120 @@
|
|
|
1
1
|
import { cli, Strategy } from '../../registry.js';
|
|
2
|
+
import { CliError } from '../../errors.js';
|
|
2
3
|
import { fetchPrivateApi } from './utils.js';
|
|
4
|
+
const WEREAD_DOMAIN = 'weread.qq.com';
|
|
5
|
+
const WEREAD_SHELF_URL = `https://${WEREAD_DOMAIN}/web/shelf`;
|
|
6
|
+
function normalizeShelfLimit(limit) {
|
|
7
|
+
if (!Number.isFinite(limit))
|
|
8
|
+
return 0;
|
|
9
|
+
return Math.max(0, Math.trunc(limit));
|
|
10
|
+
}
|
|
11
|
+
function normalizePrivateApiRows(data, limit) {
|
|
12
|
+
const books = data?.books ?? [];
|
|
13
|
+
return books.slice(0, limit).map((item) => ({
|
|
14
|
+
title: item.bookInfo?.title ?? item.title ?? '',
|
|
15
|
+
author: item.bookInfo?.author ?? item.author ?? '',
|
|
16
|
+
// TODO: readingProgress field name from community docs, verify with real API response
|
|
17
|
+
progress: item.readingProgress != null ? `${item.readingProgress}%` : '-',
|
|
18
|
+
bookId: item.bookId ?? item.bookInfo?.bookId ?? '',
|
|
19
|
+
}));
|
|
20
|
+
}
|
|
21
|
+
function normalizeWebShelfRows(snapshot, limit) {
|
|
22
|
+
if (limit <= 0)
|
|
23
|
+
return [];
|
|
24
|
+
const bookById = new Map();
|
|
25
|
+
for (const book of snapshot.rawBooks) {
|
|
26
|
+
const bookId = String(book?.bookId || '').trim();
|
|
27
|
+
if (!bookId)
|
|
28
|
+
continue;
|
|
29
|
+
bookById.set(bookId, book);
|
|
30
|
+
}
|
|
31
|
+
const orderedBookIds = snapshot.shelfIndexes
|
|
32
|
+
.filter((entry) => String(entry?.role || 'book') === 'book')
|
|
33
|
+
.sort((left, right) => Number(left?.idx ?? Number.MAX_SAFE_INTEGER) - Number(right?.idx ?? Number.MAX_SAFE_INTEGER))
|
|
34
|
+
.map((entry) => String(entry?.bookId || '').trim())
|
|
35
|
+
.filter(Boolean);
|
|
36
|
+
const fallbackOrder = snapshot.rawBooks
|
|
37
|
+
.map((book) => String(book?.bookId || '').trim())
|
|
38
|
+
.filter(Boolean);
|
|
39
|
+
const orderedUniqueBookIds = Array.from(new Set([
|
|
40
|
+
...orderedBookIds,
|
|
41
|
+
...fallbackOrder,
|
|
42
|
+
]));
|
|
43
|
+
return orderedUniqueBookIds
|
|
44
|
+
.map((bookId) => {
|
|
45
|
+
const book = bookById.get(bookId);
|
|
46
|
+
if (!book)
|
|
47
|
+
return null;
|
|
48
|
+
return {
|
|
49
|
+
title: String(book.title || '').trim(),
|
|
50
|
+
author: String(book.author || '').trim(),
|
|
51
|
+
progress: '-',
|
|
52
|
+
bookId,
|
|
53
|
+
};
|
|
54
|
+
})
|
|
55
|
+
.filter((item) => Boolean(item && (item.title || item.bookId)))
|
|
56
|
+
.slice(0, limit);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Read the structured shelf cache from the web shelf page.
|
|
60
|
+
* The page hydrates localStorage with raw book data plus shelf ordering.
|
|
61
|
+
*/
|
|
62
|
+
async function loadWebShelfSnapshot(page) {
|
|
63
|
+
await page.goto(WEREAD_SHELF_URL);
|
|
64
|
+
const cookies = await page.getCookies({ domain: WEREAD_DOMAIN });
|
|
65
|
+
const currentVid = String(cookies.find((cookie) => cookie.name === 'wr_vid')?.value || '').trim();
|
|
66
|
+
if (!currentVid) {
|
|
67
|
+
return { cacheFound: false, rawBooks: [], shelfIndexes: [] };
|
|
68
|
+
}
|
|
69
|
+
const rawBooksKey = `shelf:rawBooks:${currentVid}`;
|
|
70
|
+
const shelfIndexesKey = `shelf:shelfIndexes:${currentVid}`;
|
|
71
|
+
const result = await page.evaluate(`
|
|
72
|
+
(() => new Promise((resolve) => {
|
|
73
|
+
const deadline = Date.now() + 5000;
|
|
74
|
+
const rawBooksKey = ${JSON.stringify(rawBooksKey)};
|
|
75
|
+
const shelfIndexesKey = ${JSON.stringify(shelfIndexesKey)};
|
|
76
|
+
|
|
77
|
+
const readJson = (raw) => {
|
|
78
|
+
if (typeof raw !== 'string') return null;
|
|
79
|
+
try {
|
|
80
|
+
return JSON.parse(raw);
|
|
81
|
+
} catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const poll = () => {
|
|
87
|
+
const rawBooksRaw = localStorage.getItem(rawBooksKey);
|
|
88
|
+
const shelfIndexesRaw = localStorage.getItem(shelfIndexesKey);
|
|
89
|
+
const rawBooks = readJson(rawBooksRaw);
|
|
90
|
+
const shelfIndexes = readJson(shelfIndexesRaw);
|
|
91
|
+
const cacheFound = Array.isArray(rawBooks);
|
|
92
|
+
|
|
93
|
+
if (cacheFound || Date.now() >= deadline) {
|
|
94
|
+
resolve({
|
|
95
|
+
cacheFound,
|
|
96
|
+
rawBooks: Array.isArray(rawBooks) ? rawBooks : [],
|
|
97
|
+
shelfIndexes: Array.isArray(shelfIndexes) ? shelfIndexes : [],
|
|
98
|
+
});
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
setTimeout(poll, 100);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
poll();
|
|
106
|
+
}))
|
|
107
|
+
`);
|
|
108
|
+
if (!result || typeof result !== 'object') {
|
|
109
|
+
return { cacheFound: false, rawBooks: [], shelfIndexes: [] };
|
|
110
|
+
}
|
|
111
|
+
const snapshot = result;
|
|
112
|
+
return {
|
|
113
|
+
cacheFound: snapshot.cacheFound === true,
|
|
114
|
+
rawBooks: Array.isArray(snapshot.rawBooks) ? snapshot.rawBooks : [],
|
|
115
|
+
shelfIndexes: Array.isArray(snapshot.shelfIndexes) ? snapshot.shelfIndexes : [],
|
|
116
|
+
};
|
|
117
|
+
}
|
|
3
118
|
cli({
|
|
4
119
|
site: 'weread',
|
|
5
120
|
name: 'shelf',
|
|
@@ -11,14 +126,22 @@ cli({
|
|
|
11
126
|
],
|
|
12
127
|
columns: ['title', 'author', 'progress', 'bookId'],
|
|
13
128
|
func: async (page, args) => {
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
129
|
+
const limit = normalizeShelfLimit(Number(args.limit));
|
|
130
|
+
if (limit <= 0)
|
|
131
|
+
return [];
|
|
132
|
+
try {
|
|
133
|
+
const data = await fetchPrivateApi(page, '/shelf/sync', { synckey: '0', lectureSynckey: '0' });
|
|
134
|
+
return normalizePrivateApiRows(data, limit);
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
if (!(error instanceof CliError) || error.code !== 'AUTH_REQUIRED') {
|
|
138
|
+
throw error;
|
|
139
|
+
}
|
|
140
|
+
const snapshot = await loadWebShelfSnapshot(page);
|
|
141
|
+
if (!snapshot.cacheFound) {
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
144
|
+
return normalizeWebShelfRows(snapshot, limit);
|
|
145
|
+
}
|
|
23
146
|
},
|
|
24
147
|
});
|
|
@@ -9,9 +9,13 @@ import { CliError } from '../../errors.js';
|
|
|
9
9
|
const WEB_API = 'https://weread.qq.com/web';
|
|
10
10
|
const API = 'https://i.weread.qq.com';
|
|
11
11
|
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
|
|
12
|
+
const WEREAD_AUTH_ERRCODES = new Set([-2010, -2012]);
|
|
12
13
|
function buildCookieHeader(cookies) {
|
|
13
14
|
return cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join('; ');
|
|
14
15
|
}
|
|
16
|
+
function isAuthErrorResponse(resp, data) {
|
|
17
|
+
return resp.status === 401 || WEREAD_AUTH_ERRCODES.has(Number(data?.errcode));
|
|
18
|
+
}
|
|
15
19
|
/**
|
|
16
20
|
* Fetch a public WeRead web endpoint (Node.js direct fetch).
|
|
17
21
|
* Used by search and ranking commands (browser: false).
|
|
@@ -69,7 +73,7 @@ export async function fetchPrivateApi(page, path, params) {
|
|
|
69
73
|
catch {
|
|
70
74
|
throw new CliError('PARSE_ERROR', `Invalid JSON response for ${path}`, 'WeRead may have returned an HTML error page');
|
|
71
75
|
}
|
|
72
|
-
if (resp
|
|
76
|
+
if (isAuthErrorResponse(resp, data)) {
|
|
73
77
|
throw new CliError('AUTH_REQUIRED', 'Not logged in to WeRead', 'Please log in to weread.qq.com in Chrome first');
|
|
74
78
|
}
|
|
75
79
|
if (!resp.ok) {
|
|
@@ -24,6 +24,7 @@ function createPageMock(evaluateResult) {
|
|
|
24
24
|
getInterceptedRequests: vi.fn().mockResolvedValue([]),
|
|
25
25
|
getCookies: vi.fn().mockResolvedValue([]),
|
|
26
26
|
screenshot: vi.fn().mockResolvedValue(''),
|
|
27
|
+
waitForCapture: vi.fn().mockResolvedValue(undefined),
|
|
27
28
|
};
|
|
28
29
|
}
|
|
29
30
|
describe('xiaohongshu comments', () => {
|
|
@@ -30,6 +30,7 @@ function createPageMock(evaluateResult) {
|
|
|
30
30
|
getInterceptedRequests: vi.fn().mockResolvedValue([]),
|
|
31
31
|
getCookies: vi.fn().mockResolvedValue([]),
|
|
32
32
|
screenshot: vi.fn().mockResolvedValue(''),
|
|
33
|
+
waitForCapture: vi.fn().mockResolvedValue(undefined),
|
|
33
34
|
};
|
|
34
35
|
}
|
|
35
36
|
describe('xiaohongshu creator-note-detail', () => {
|
|
@@ -33,6 +33,7 @@ function createPageMock(evaluateResult, interceptedRequests = []) {
|
|
|
33
33
|
getInterceptedRequests,
|
|
34
34
|
getCookies: vi.fn().mockResolvedValue([]),
|
|
35
35
|
screenshot: vi.fn().mockResolvedValue(''),
|
|
36
|
+
waitForCapture: vi.fn().mockResolvedValue(undefined),
|
|
36
37
|
};
|
|
37
38
|
}
|
|
38
39
|
describe('xiaohongshu creator-notes', () => {
|
|
@@ -31,6 +31,7 @@ function createPageMock(evaluateResults) {
|
|
|
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
|
describe('xiaohongshu publish', () => {
|
|
@@ -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', () => {
|
package/dist/daemon.js
CHANGED
|
@@ -175,6 +175,7 @@ const wss = new WebSocketServer({
|
|
|
175
175
|
wss.on('connection', (ws) => {
|
|
176
176
|
console.error('[daemon] Extension connected');
|
|
177
177
|
extensionWs = ws;
|
|
178
|
+
extensionVersion = null; // cleared until hello message arrives
|
|
178
179
|
// ── Heartbeat: ping every 15s, close if 2 pongs missed ──
|
|
179
180
|
let missedPongs = 0;
|
|
180
181
|
const heartbeatInterval = setInterval(() => {
|
package/dist/doctor.js
CHANGED
|
@@ -65,9 +65,13 @@ export async function runBrowserDoctor(opts = {}) {
|
|
|
65
65
|
if (connectivity && !connectivity.ok) {
|
|
66
66
|
issues.push(`Browser connectivity test failed: ${connectivity.error ?? 'unknown'}`);
|
|
67
67
|
}
|
|
68
|
-
if (status.extensionVersion && opts.cliVersion
|
|
69
|
-
|
|
70
|
-
|
|
68
|
+
if (status.extensionVersion && opts.cliVersion) {
|
|
69
|
+
const extMajor = status.extensionVersion.split('.')[0];
|
|
70
|
+
const cliMajor = opts.cliVersion.split('.')[0];
|
|
71
|
+
if (extMajor !== cliMajor) {
|
|
72
|
+
issues.push(`Extension major version mismatch: extension v${status.extensionVersion} ≠ CLI v${opts.cliVersion}\n` +
|
|
73
|
+
' Download the latest extension from: https://github.com/jackwener/opencli/releases');
|
|
74
|
+
}
|
|
71
75
|
}
|
|
72
76
|
return {
|
|
73
77
|
cliVersion: opts.cliVersion,
|
package/dist/download/index.js
CHANGED
|
@@ -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
|
-
|
|
90
|
-
|
|
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.
|
|
93
|
-
response.
|
|
94
|
-
if (
|
|
95
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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.
|
|
108
|
-
response
|
|
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
|
|
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
|
-
|
|
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,8 +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 {
|
|
21
|
-
import chalk from 'chalk';
|
|
20
|
+
import { log } from './logger.js';
|
|
22
21
|
const _loadedModules = new Set();
|
|
23
22
|
export function coerceAndValidateArgs(cmdArgs, kwargs) {
|
|
24
23
|
const result = { ...kwargs };
|
|
@@ -161,10 +160,6 @@ export async function executeCommand(cmd, rawKwargs, debug = false) {
|
|
|
161
160
|
' 2. chrome://extensions → Developer Mode → Load unpacked\n' +
|
|
162
161
|
' Then run: opencli doctor');
|
|
163
162
|
}
|
|
164
|
-
// ── Version mismatch: warn but don't block ──
|
|
165
|
-
if (status.extensionVersion && status.extensionVersion !== PKG_VERSION) {
|
|
166
|
-
process.stderr.write(chalk.yellow(`⚠ Extension v${status.extensionVersion} ≠ CLI v${PKG_VERSION} — consider updating the extension.\n`));
|
|
167
|
-
}
|
|
168
163
|
ensureRequiredEnv(cmd);
|
|
169
164
|
const BrowserFactory = getBrowserFactory();
|
|
170
165
|
result = await browserSession(BrowserFactory, async (page) => {
|
|
@@ -173,7 +168,7 @@ export async function executeCommand(cmd, rawKwargs, debug = false) {
|
|
|
173
168
|
const skip = await isAlreadyOnDomain(page, preNavUrl);
|
|
174
169
|
if (skip) {
|
|
175
170
|
if (debug)
|
|
176
|
-
|
|
171
|
+
log.debug('[pre-nav] Already on target domain, skipping navigation');
|
|
177
172
|
}
|
|
178
173
|
else {
|
|
179
174
|
try {
|
|
@@ -183,7 +178,7 @@ export async function executeCommand(cmd, rawKwargs, debug = false) {
|
|
|
183
178
|
}
|
|
184
179
|
catch (err) {
|
|
185
180
|
if (debug)
|
|
186
|
-
|
|
181
|
+
log.debug(`[pre-nav] Failed to navigate to ${preNavUrl}: ${err instanceof Error ? err.message : err}`);
|
|
187
182
|
}
|
|
188
183
|
}
|
|
189
184
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import * as fs from 'node:fs/promises';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
describe('extension manifest regression', () => {
|
|
5
|
+
it('keeps host permissions required by chrome.cookies.getAll', async () => {
|
|
6
|
+
const manifestPath = path.resolve(process.cwd(), 'extension', 'manifest.json');
|
|
7
|
+
const raw = await fs.readFile(manifestPath, 'utf8');
|
|
8
|
+
const manifest = JSON.parse(raw);
|
|
9
|
+
expect(manifest.permissions).toContain('cookies');
|
|
10
|
+
expect(manifest.host_permissions).toContain('<all_urls>');
|
|
11
|
+
});
|
|
12
|
+
});
|
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;
|