@jackwener/opencli 1.5.2 → 1.5.4
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/.agents/skills/cross-project-adapter-migration/SKILL.md +3 -3
- package/.github/workflows/ci.yml +6 -7
- package/README.md +89 -235
- 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/build-manifest.d.ts +2 -3
- package/dist/build-manifest.js +75 -170
- package/dist/build-manifest.test.js +113 -88
- package/dist/cli-manifest.json +1199 -1106
- 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/daemon.js +14 -3
- package/dist/download/index.js +39 -33
- package/dist/download/index.test.js +15 -1
- package/dist/execution.js +3 -2
- package/dist/external-clis.yaml +16 -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/serialization.js +6 -1
- package/dist/serialization.test.d.ts +1 -0
- package/dist/serialization.test.js +23 -0
- 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 +12 -5
- package/extension/manifest.json +2 -2
- package/extension/package-lock.json +2 -2
- package/extension/package.json +1 -1
- package/extension/src/background.ts +20 -6
- package/extension/src/protocol.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/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/build-manifest.test.ts +117 -88
- package/src/build-manifest.ts +81 -180
- 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/daemon.ts +16 -4
- package/src/download/index.test.ts +19 -1
- package/src/download/index.ts +50 -41
- package/src/execution.ts +3 -2
- package/src/external-clis.yaml +16 -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/serialization.test.ts +26 -0
- package/src/serialization.ts +6 -1
- package/src/types.ts +2 -0
- package/src/utils.ts +5 -0
package/dist/clis/36kr/hot.js
CHANGED
|
@@ -48,7 +48,7 @@ cli({
|
|
|
48
48
|
const url = buildHotListUrl(listType);
|
|
49
49
|
await page.installInterceptor('36kr.com/api');
|
|
50
50
|
await page.goto(url);
|
|
51
|
-
await page.
|
|
51
|
+
await page.waitForCapture(6);
|
|
52
52
|
// Scrape rendered article links from DOM (deduplicated)
|
|
53
53
|
const domItems = await page.evaluate(`
|
|
54
54
|
(() => {
|
package/dist/clis/36kr/search.js
CHANGED
|
@@ -21,7 +21,7 @@ cli({
|
|
|
21
21
|
const query = encodeURIComponent(String(args.query ?? ''));
|
|
22
22
|
await page.installInterceptor('36kr.com/api');
|
|
23
23
|
await page.goto(`https://www.36kr.com/search/articles/${query}`);
|
|
24
|
-
await page.
|
|
24
|
+
await page.waitForCapture(6);
|
|
25
25
|
const domItems = await page.evaluate(`
|
|
26
26
|
(() => {
|
|
27
27
|
const seen = new Set();
|
|
@@ -16,7 +16,7 @@ cli({
|
|
|
16
16
|
const url = validateBloombergLink(kwargs.link);
|
|
17
17
|
// Navigate and wait for the page to hydrate before extracting story data.
|
|
18
18
|
await page.goto(url);
|
|
19
|
-
await page.wait(5);
|
|
19
|
+
await page.wait({ selector: 'article', timeout: 5 });
|
|
20
20
|
const loadStory = async () => page.evaluate(`(() => {
|
|
21
21
|
const isRobot = /Are you a robot/i.test(document.title)
|
|
22
22
|
|| /unusual activity/i.test(document.body.innerText)
|
|
@@ -2,14 +2,11 @@
|
|
|
2
2
|
* Douban adapter utilities.
|
|
3
3
|
*/
|
|
4
4
|
import { ArgumentError, CliError, EmptyResultError } from '../../errors.js';
|
|
5
|
+
import { clamp } from '../_shared/common.js';
|
|
5
6
|
const DOUBAN_PHOTO_PAGE_SIZE = 30;
|
|
6
7
|
const MAX_DOUBAN_PHOTOS = 500;
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
}
|
|
10
|
-
function clampPhotoLimit(limit) {
|
|
11
|
-
return Math.max(1, Math.min(limit || 120, MAX_DOUBAN_PHOTOS));
|
|
12
|
-
}
|
|
8
|
+
const clampLimit = (limit) => clamp(limit || 20, 1, 50);
|
|
9
|
+
const clampPhotoLimit = (limit) => clamp(limit || 120, 1, MAX_DOUBAN_PHOTOS);
|
|
13
10
|
async function ensureDoubanReady(page) {
|
|
14
11
|
const state = await page.evaluate(`
|
|
15
12
|
(() => {
|
|
@@ -12,7 +12,7 @@ export async function loadMediumPosts(page, url, limit) {
|
|
|
12
12
|
if (!page)
|
|
13
13
|
throw new CommandExecutionError('Browser session required for medium posts');
|
|
14
14
|
await page.goto(url);
|
|
15
|
-
await page.wait(5);
|
|
15
|
+
await page.wait({ selector: 'article', timeout: 5 });
|
|
16
16
|
const data = await page.evaluate(`
|
|
17
17
|
(async () => {
|
|
18
18
|
await new Promise((resolve) => setTimeout(resolve, 3000));
|
|
@@ -29,7 +29,7 @@ cli({
|
|
|
29
29
|
const slug = String(args.category || '').trim().toLowerCase();
|
|
30
30
|
await page.installInterceptor('producthunt.com');
|
|
31
31
|
await page.goto(`https://www.producthunt.com/categories/${slug}`);
|
|
32
|
-
await page.
|
|
32
|
+
await page.waitForCapture(5);
|
|
33
33
|
const domItems = await page.evaluate(`
|
|
34
34
|
(() => {
|
|
35
35
|
const seen = new Set();
|
|
@@ -20,7 +20,7 @@ cli({
|
|
|
20
20
|
const count = Math.min(Number(args.limit) || 20, 50);
|
|
21
21
|
await page.installInterceptor('producthunt.com');
|
|
22
22
|
await page.goto('https://www.producthunt.com');
|
|
23
|
-
await page.
|
|
23
|
+
await page.waitForCapture(5);
|
|
24
24
|
const domItems = await page.evaluate(`
|
|
25
25
|
(() => {
|
|
26
26
|
const seen = new Set();
|
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
}
|
|
1
|
+
import { clamp } from '../_shared/common.js';
|
|
2
|
+
const clampLimit = (limit) => clamp(limit || 20, 1, 50);
|
|
4
3
|
export function buildSinaBlogSearchUrl(keyword) {
|
|
5
4
|
return `https://search.sina.com.cn/search?q=${encodeURIComponent(keyword)}&tp=mix`;
|
|
6
5
|
}
|
|
@@ -9,7 +8,7 @@ export function buildSinaBlogUserUrl(uid) {
|
|
|
9
8
|
}
|
|
10
9
|
export async function loadSinaBlogArticle(page, url) {
|
|
11
10
|
await page.goto(url);
|
|
12
|
-
await page.wait(3);
|
|
11
|
+
await page.wait({ selector: 'h1', timeout: 3 });
|
|
13
12
|
return page.evaluate(`
|
|
14
13
|
(async () => {
|
|
15
14
|
await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
@@ -46,7 +45,7 @@ export async function loadSinaBlogArticle(page, url) {
|
|
|
46
45
|
export async function loadSinaBlogHot(page, limit) {
|
|
47
46
|
const safeLimit = clampLimit(limit);
|
|
48
47
|
await page.goto('https://blog.sina.com.cn/');
|
|
49
|
-
await page.wait(3);
|
|
48
|
+
await page.wait({ selector: 'h1', timeout: 3 });
|
|
50
49
|
const data = await page.evaluate(`
|
|
51
50
|
(async () => {
|
|
52
51
|
await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
@@ -114,7 +113,7 @@ export async function loadSinaBlogHot(page, limit) {
|
|
|
114
113
|
export async function loadSinaBlogSearch(page, keyword, limit) {
|
|
115
114
|
const safeLimit = clampLimit(limit);
|
|
116
115
|
await page.goto(buildSinaBlogSearchUrl(keyword));
|
|
117
|
-
await page.wait(5);
|
|
116
|
+
await page.wait({ selector: '.result-item', timeout: 5 });
|
|
118
117
|
const data = await page.evaluate(`
|
|
119
118
|
(async () => {
|
|
120
119
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
@@ -149,7 +148,7 @@ export async function loadSinaBlogSearch(page, keyword, limit) {
|
|
|
149
148
|
export async function loadSinaBlogUser(page, uid, limit) {
|
|
150
149
|
const safeLimit = clampLimit(limit);
|
|
151
150
|
await page.goto(buildSinaBlogUserUrl(uid));
|
|
152
|
-
await page.wait(3);
|
|
151
|
+
await page.wait({ selector: 'h1', timeout: 3 });
|
|
153
152
|
const data = await page.evaluate(`
|
|
154
153
|
(async () => {
|
|
155
154
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
@@ -9,7 +9,7 @@ export async function loadSubstackFeed(page, url, limit) {
|
|
|
9
9
|
if (!page)
|
|
10
10
|
throw new CommandExecutionError('Browser session required for substack feed');
|
|
11
11
|
await page.goto(url);
|
|
12
|
-
await page.wait(5);
|
|
12
|
+
await page.wait({ selector: 'article', timeout: 5 });
|
|
13
13
|
const data = await page.evaluate(`
|
|
14
14
|
(async () => {
|
|
15
15
|
await new Promise((resolve) => setTimeout(resolve, 3000));
|
|
@@ -77,7 +77,7 @@ export async function loadSubstackArchive(page, baseUrl, limit) {
|
|
|
77
77
|
if (!page)
|
|
78
78
|
throw new CommandExecutionError('Browser session required for substack archive');
|
|
79
79
|
await page.goto(`${baseUrl}/archive`);
|
|
80
|
-
await page.wait(5);
|
|
80
|
+
await page.wait({ selector: 'article', timeout: 5 });
|
|
81
81
|
const data = await page.evaluate(`
|
|
82
82
|
(async () => {
|
|
83
83
|
await new Promise((resolve) => setTimeout(resolve, 3000));
|
|
@@ -16,7 +16,7 @@ cli({
|
|
|
16
16
|
throw new CommandExecutionError('Browser session required for twitter block');
|
|
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 bookmark');
|
|
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;
|
|
@@ -15,7 +15,7 @@ cli({
|
|
|
15
15
|
if (!page)
|
|
16
16
|
throw new CommandExecutionError('Browser session required for twitter delete');
|
|
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
|
// Wait for caret button (which has 'More' aria-label) within the main tweet body
|
|
@@ -16,7 +16,7 @@ cli({
|
|
|
16
16
|
throw new CommandExecutionError('Browser session required for twitter follow');
|
|
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;
|
|
@@ -17,7 +17,7 @@ cli({
|
|
|
17
17
|
// If no user is specified, figure out the logged-in user's handle
|
|
18
18
|
if (!targetUser) {
|
|
19
19
|
await page.goto('https://x.com/home');
|
|
20
|
-
await page.wait(
|
|
20
|
+
await page.wait({ selector: '[data-testid="primaryColumn"]' });
|
|
21
21
|
const href = await page.evaluate(`() => {
|
|
22
22
|
const link = document.querySelector('a[data-testid="AppTabBar_Profile_Link"]');
|
|
23
23
|
return link ? link.getAttribute('href') : null;
|
|
@@ -51,7 +51,7 @@ cli({
|
|
|
51
51
|
if (!clicked) {
|
|
52
52
|
throw new SelectorError('Twitter followers link', 'Twitter may have changed the layout.');
|
|
53
53
|
}
|
|
54
|
-
await page.
|
|
54
|
+
await page.waitForCapture(5);
|
|
55
55
|
// 4. Scroll to trigger pagination API calls
|
|
56
56
|
await page.autoScroll({ times: Math.ceil(kwargs.limit / 20), delayMs: 2000 });
|
|
57
57
|
// 5. Retrieve intercepted data
|
|
@@ -17,7 +17,7 @@ cli({
|
|
|
17
17
|
// If no user is specified, figure out the logged-in user's handle
|
|
18
18
|
if (!targetUser) {
|
|
19
19
|
await page.goto('https://x.com/home');
|
|
20
|
-
await page.wait(
|
|
20
|
+
await page.wait({ selector: '[data-testid="primaryColumn"]' });
|
|
21
21
|
const href = await page.evaluate(`() => {
|
|
22
22
|
const link = document.querySelector('a[data-testid="AppTabBar_Profile_Link"]');
|
|
23
23
|
return link ? link.getAttribute('href') : null;
|
|
@@ -44,7 +44,7 @@ cli({
|
|
|
44
44
|
if (!clicked) {
|
|
45
45
|
throw new SelectorError('Twitter following link', 'Twitter may have changed the layout.');
|
|
46
46
|
}
|
|
47
|
-
await page.
|
|
47
|
+
await page.waitForCapture(5);
|
|
48
48
|
// 4. Scroll to trigger pagination API calls
|
|
49
49
|
await page.autoScroll({ times: Math.ceil(kwargs.limit / 20), delayMs: 2000 });
|
|
50
50
|
// 5. Retrieve intercepted data
|
|
@@ -15,7 +15,7 @@ cli({
|
|
|
15
15
|
if (!page)
|
|
16
16
|
throw new CommandExecutionError('Browser session required for twitter hide-reply');
|
|
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;
|
|
@@ -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;
|
|
@@ -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
|
@@ -90,7 +90,20 @@ async function handleRequest(req, res) {
|
|
|
90
90
|
res.end();
|
|
91
91
|
return;
|
|
92
92
|
}
|
|
93
|
-
|
|
93
|
+
const url = req.url ?? '/';
|
|
94
|
+
const pathname = url.split('?')[0];
|
|
95
|
+
// Health-check endpoint — no X-OpenCLI header required.
|
|
96
|
+
// Used by the extension to silently probe daemon reachability before
|
|
97
|
+
// attempting a WebSocket connection (avoids uncatchable ERR_CONNECTION_REFUSED).
|
|
98
|
+
// Security note: this endpoint is reachable by any client that passes the
|
|
99
|
+
// origin check above (chrome-extension:// or no Origin header, e.g. curl).
|
|
100
|
+
// Timing side-channels can reveal daemon presence to local processes, which
|
|
101
|
+
// is an accepted risk given the daemon is loopback-only and short-lived.
|
|
102
|
+
if (req.method === 'GET' && pathname === '/ping') {
|
|
103
|
+
jsonResponse(res, 200, { ok: true });
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
// Require custom header on all other HTTP requests. Browsers cannot attach
|
|
94
107
|
// custom headers in "simple" requests, and our preflight returns no
|
|
95
108
|
// Access-Control-Allow-Headers, so scripted fetch() from web pages is
|
|
96
109
|
// blocked even if Origin check is somehow bypassed.
|
|
@@ -98,8 +111,6 @@ async function handleRequest(req, res) {
|
|
|
98
111
|
jsonResponse(res, 403, { ok: false, error: 'Forbidden: missing X-OpenCLI header' });
|
|
99
112
|
return;
|
|
100
113
|
}
|
|
101
|
-
const url = req.url ?? '/';
|
|
102
|
-
const pathname = url.split('?')[0];
|
|
103
114
|
if (req.method === 'GET' && pathname === '/status') {
|
|
104
115
|
jsonResponse(res, 200, {
|
|
105
116
|
ok: true,
|
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,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
|
-
|
|
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
|
-
|
|
181
|
+
log.debug(`[pre-nav] Failed to navigate to ${preNavUrl}: ${err instanceof Error ? err.message : err}`);
|
|
181
182
|
}
|
|
182
183
|
}
|
|
183
184
|
}
|
package/dist/external-clis.yaml
CHANGED
|
@@ -21,3 +21,19 @@
|
|
|
21
21
|
tags: [docker, containers, devops]
|
|
22
22
|
install:
|
|
23
23
|
mac: "brew install --cask docker"
|
|
24
|
+
|
|
25
|
+
- name: lark-cli
|
|
26
|
+
binary: lark-cli
|
|
27
|
+
description: "Lark/Feishu CLI — messages, documents, spreadsheets, calendar, tasks and 200+ commands for AI agents"
|
|
28
|
+
homepage: "https://github.com/larksuite/cli"
|
|
29
|
+
tags: [lark, feishu, collaboration, productivity, ai-agent]
|
|
30
|
+
install:
|
|
31
|
+
default: "npm install -g @larksuite/cli"
|
|
32
|
+
|
|
33
|
+
- name: vercel
|
|
34
|
+
binary: vercel
|
|
35
|
+
description: "Vercel CLI — deploy projects, manage domains, env vars, logs and serverless functions"
|
|
36
|
+
homepage: "https://vercel.com/docs/cli"
|
|
37
|
+
tags: [vercel, deployment, serverless, frontend, devops]
|
|
38
|
+
install:
|
|
39
|
+
default: "npm install -g vercel"
|
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');
|