@jackwener/opencli 0.1.0 → 0.1.1
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/CLI-CREATOR.md +594 -0
- package/README.md +116 -38
- package/README.zh-CN.md +143 -0
- package/SKILL.md +154 -102
- package/dist/browser.d.ts +1 -0
- package/dist/browser.js +35 -1
- package/dist/cascade.d.ts +45 -0
- package/dist/cascade.js +180 -0
- package/dist/clis/bilibili/hot.yaml +38 -0
- package/dist/clis/github/trending.yaml +58 -0
- package/dist/clis/hackernews/top.yaml +36 -0
- package/dist/clis/index.d.ts +2 -1
- package/dist/clis/index.js +3 -1
- package/dist/clis/reddit/hot.yaml +46 -0
- package/dist/clis/twitter/trending.yaml +40 -0
- package/dist/clis/v2ex/hot.yaml +25 -0
- package/dist/clis/v2ex/latest.yaml +25 -0
- package/dist/clis/v2ex/topic.yaml +27 -0
- package/dist/clis/xiaohongshu/feed.yaml +32 -0
- package/dist/clis/xiaohongshu/notifications.yaml +38 -0
- package/dist/clis/xiaohongshu/search.d.ts +5 -0
- package/dist/clis/xiaohongshu/search.js +68 -0
- package/dist/clis/zhihu/hot.yaml +42 -0
- package/dist/clis/zhihu/question.js +39 -0
- package/dist/clis/zhihu/search.yaml +55 -0
- package/dist/explore.d.ts +23 -13
- package/dist/explore.js +293 -422
- package/dist/main.js +17 -0
- package/dist/pipeline.js +238 -2
- package/dist/synthesize.d.ts +11 -8
- package/dist/synthesize.js +142 -118
- package/package.json +4 -2
- package/src/browser.ts +33 -1
- package/src/cascade.ts +217 -0
- package/src/clis/index.ts +4 -1
- package/src/clis/reddit/hot.yaml +46 -0
- package/src/clis/v2ex/hot.yaml +5 -9
- package/src/clis/v2ex/latest.yaml +5 -8
- package/src/clis/v2ex/topic.yaml +27 -0
- package/src/clis/xiaohongshu/feed.yaml +32 -0
- package/src/clis/xiaohongshu/notifications.yaml +38 -0
- package/src/clis/xiaohongshu/search.ts +71 -0
- package/src/clis/zhihu/hot.yaml +22 -8
- package/src/clis/zhihu/question.ts +45 -0
- package/src/clis/zhihu/search.yaml +55 -0
- package/src/explore.ts +303 -465
- package/src/main.ts +14 -0
- package/src/pipeline.ts +239 -2
- package/src/synthesize.ts +142 -137
- package/dist/clis/zhihu/search.js +0 -58
- package/src/clis/zhihu/search.ts +0 -65
- /package/dist/clis/zhihu/{search.d.ts → question.d.ts} +0 -0
package/SKILL.md
CHANGED
|
@@ -1,47 +1,47 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: opencli
|
|
3
|
-
description: "OpenCLI — Make any website your CLI. Zero
|
|
3
|
+
description: "OpenCLI — Make any website your CLI. Zero risk, AI-powered, reuse Chrome login."
|
|
4
4
|
version: 0.1.0
|
|
5
5
|
author: jackwener
|
|
6
|
-
tags: [cli, browser, web, mcp, playwright, bilibili, zhihu, twitter, github, v2ex, hackernews,
|
|
6
|
+
tags: [cli, browser, web, mcp, playwright, bilibili, zhihu, twitter, github, v2ex, hackernews, reddit, xiaohongshu, AI, agent]
|
|
7
7
|
---
|
|
8
8
|
|
|
9
9
|
# OpenCLI
|
|
10
10
|
|
|
11
|
-
> Make any website your CLI.
|
|
11
|
+
> Make any website your CLI. Reuse Chrome login, zero risk, AI-powered discovery.
|
|
12
12
|
|
|
13
|
-
##
|
|
13
|
+
## Install & Run
|
|
14
14
|
|
|
15
15
|
```bash
|
|
16
|
-
|
|
17
|
-
npm install
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
## 使用方式
|
|
16
|
+
# npm global install (recommended)
|
|
17
|
+
npm install -g @jackwener/opencli
|
|
18
|
+
opencli <command>
|
|
21
19
|
|
|
22
|
-
|
|
23
|
-
|
|
20
|
+
# Or from source
|
|
21
|
+
cd ~/code/opencli && npm install
|
|
24
22
|
npx tsx src/main.ts <command>
|
|
25
23
|
|
|
26
|
-
#
|
|
27
|
-
npm
|
|
24
|
+
# Update to latest
|
|
25
|
+
npm update -g @jackwener/opencli
|
|
28
26
|
```
|
|
29
27
|
|
|
30
|
-
##
|
|
28
|
+
## Prerequisites
|
|
29
|
+
|
|
30
|
+
Browser commands require:
|
|
31
|
+
1. Chrome browser running **(logged into target sites)**
|
|
32
|
+
2. [Playwright MCP Bridge](https://chromewebstore.google.com/detail/playwright-mcp-bridge/mmlmfjhmonkocbjadbfplnigmagldckm) extension
|
|
33
|
+
3. Configure `PLAYWRIGHT_MCP_EXTENSION_TOKEN` (from extension settings) in your MCP config
|
|
31
34
|
|
|
32
|
-
|
|
33
|
-
1. Chrome 浏览器正在运行
|
|
34
|
-
2. 安装 [Playwright MCP Bridge](https://chromewebstore.google.com/detail/playwright-mcp-bridge/mmlmfjhmonkocbjadbfplnigmagldckm) 扩展
|
|
35
|
-
3. 点击扩展图标批准连接
|
|
35
|
+
> **Note**: You must be logged into the target website in Chrome before running commands. Tabs opened during command execution are auto-closed afterwards.
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
Public API commands (`hackernews`, `github search`, `v2ex`) need no browser.
|
|
38
38
|
|
|
39
|
-
##
|
|
39
|
+
## Commands Reference
|
|
40
40
|
|
|
41
|
-
###
|
|
41
|
+
### Data Commands
|
|
42
42
|
|
|
43
43
|
```bash
|
|
44
|
-
# Bilibili
|
|
44
|
+
# Bilibili (browser)
|
|
45
45
|
opencli bilibili hot --limit 10 # B站热门视频
|
|
46
46
|
opencli bilibili search --keyword "rust" # 搜索视频
|
|
47
47
|
opencli bilibili me # 我的信息
|
|
@@ -50,70 +50,91 @@ opencli bilibili history --limit 20 # 观看历史
|
|
|
50
50
|
opencli bilibili feed --limit 10 # 动态时间线
|
|
51
51
|
opencli bilibili user-videos --uid 12345 # 用户投稿
|
|
52
52
|
|
|
53
|
-
# 知乎
|
|
53
|
+
# 知乎 (browser)
|
|
54
54
|
opencli zhihu hot --limit 10 # 知乎热榜
|
|
55
55
|
opencli zhihu search --keyword "AI" # 搜索
|
|
56
|
+
opencli zhihu question --id 34816524 # 问题详情和回答
|
|
56
57
|
|
|
57
|
-
#
|
|
58
|
+
# 小红书 (browser)
|
|
59
|
+
opencli xiaohongshu search --keyword "美食" # 搜索笔记
|
|
60
|
+
opencli xiaohongshu notifications # 通知(mentions/likes/connections)
|
|
61
|
+
opencli xiaohongshu feed --limit 10 # 推荐 Feed
|
|
62
|
+
|
|
63
|
+
# GitHub (trending=browser, search=public)
|
|
58
64
|
opencli github trending --limit 10 # GitHub Trending
|
|
59
|
-
opencli github search --keyword "cli" #
|
|
65
|
+
opencli github search --keyword "cli" # 搜索仓库
|
|
60
66
|
|
|
61
|
-
# Twitter/X
|
|
67
|
+
# Twitter/X (browser)
|
|
62
68
|
opencli twitter trending --limit 10 # 热门话题
|
|
63
69
|
|
|
64
|
-
#
|
|
70
|
+
# Reddit (browser)
|
|
71
|
+
opencli reddit hot --limit 10 # 热门帖子
|
|
72
|
+
opencli reddit hot --subreddit programming # 指定子版块
|
|
73
|
+
|
|
74
|
+
# V2EX (public)
|
|
65
75
|
opencli v2ex hot --limit 10 # 热门话题
|
|
66
76
|
opencli v2ex latest --limit 10 # 最新话题
|
|
77
|
+
opencli v2ex topic --id 1024 # 主题详情
|
|
67
78
|
|
|
68
|
-
# Hacker News
|
|
69
|
-
opencli hackernews top --limit 10 #
|
|
79
|
+
# Hacker News (public)
|
|
80
|
+
opencli hackernews top --limit 10 # Top stories
|
|
70
81
|
```
|
|
71
82
|
|
|
72
|
-
###
|
|
83
|
+
### Management Commands
|
|
73
84
|
|
|
74
85
|
```bash
|
|
75
|
-
opencli list #
|
|
76
|
-
opencli list --json # JSON
|
|
77
|
-
opencli validate #
|
|
78
|
-
opencli validate bilibili #
|
|
86
|
+
opencli list # List all commands
|
|
87
|
+
opencli list --json # JSON output
|
|
88
|
+
opencli validate # Validate all CLI definitions
|
|
89
|
+
opencli validate bilibili # Validate specific site
|
|
79
90
|
```
|
|
80
91
|
|
|
81
|
-
### AI
|
|
92
|
+
### AI Agent Workflow
|
|
82
93
|
|
|
83
94
|
```bash
|
|
84
|
-
|
|
85
|
-
opencli
|
|
86
|
-
|
|
87
|
-
|
|
95
|
+
# Deep Explore: network intercept → response analysis → capability inference
|
|
96
|
+
opencli explore <url> --site <name>
|
|
97
|
+
|
|
98
|
+
# Synthesize: generate evaluate-based YAML pipelines from explore artifacts
|
|
99
|
+
opencli synthesize <site>
|
|
100
|
+
|
|
101
|
+
# Generate: one-shot explore → synthesize → register
|
|
102
|
+
opencli generate <url> --goal "hot"
|
|
103
|
+
|
|
104
|
+
# Strategy Cascade: auto-probe PUBLIC → COOKIE → HEADER
|
|
105
|
+
opencli cascade <api-url>
|
|
106
|
+
|
|
107
|
+
# Verify: smoke-test a generated adapter
|
|
108
|
+
opencli verify <site/name> --smoke
|
|
88
109
|
```
|
|
89
110
|
|
|
90
|
-
##
|
|
111
|
+
## Output Formats
|
|
91
112
|
|
|
92
|
-
|
|
113
|
+
All commands support `--format` / `-f`:
|
|
93
114
|
|
|
94
115
|
```bash
|
|
95
|
-
opencli bilibili hot -f table #
|
|
96
|
-
opencli bilibili hot -f json # JSON
|
|
116
|
+
opencli bilibili hot -f table # Default: rich table
|
|
117
|
+
opencli bilibili hot -f json # JSON (pipe to jq, feed to AI agent)
|
|
97
118
|
opencli bilibili hot -f md # Markdown
|
|
98
119
|
opencli bilibili hot -f csv # CSV
|
|
99
120
|
```
|
|
100
121
|
|
|
101
|
-
##
|
|
122
|
+
## Verbose Mode
|
|
102
123
|
|
|
103
124
|
```bash
|
|
104
|
-
opencli bilibili hot -v #
|
|
125
|
+
opencli bilibili hot -v # Show each pipeline step and data flow
|
|
105
126
|
```
|
|
106
127
|
|
|
107
|
-
##
|
|
128
|
+
## Creating Adapters
|
|
108
129
|
|
|
109
|
-
### YAML
|
|
130
|
+
### YAML Pipeline (declarative, recommended)
|
|
110
131
|
|
|
111
|
-
|
|
132
|
+
Create `src/clis/<site>/<name>.yaml`:
|
|
112
133
|
|
|
113
134
|
```yaml
|
|
114
135
|
site: mysite
|
|
115
136
|
name: hot
|
|
116
|
-
description: Hot topics
|
|
137
|
+
description: Hot topics
|
|
117
138
|
domain: www.mysite.com
|
|
118
139
|
strategy: cookie # public | cookie | header | intercept | ui
|
|
119
140
|
browser: true
|
|
@@ -130,11 +151,13 @@ pipeline:
|
|
|
130
151
|
- evaluate: |
|
|
131
152
|
(async () => {
|
|
132
153
|
const res = await fetch('/api/hot', { credentials: 'include' });
|
|
133
|
-
|
|
154
|
+
const d = await res.json();
|
|
155
|
+
return d.data.items.map(item => ({
|
|
156
|
+
title: item.title,
|
|
157
|
+
score: item.score,
|
|
158
|
+
}));
|
|
134
159
|
})()
|
|
135
160
|
|
|
136
|
-
- select: data.items
|
|
137
|
-
|
|
138
161
|
- map:
|
|
139
162
|
rank: ${{ index + 1 }}
|
|
140
163
|
title: ${{ item.title }}
|
|
@@ -145,9 +168,24 @@ pipeline:
|
|
|
145
168
|
columns: [rank, title, score]
|
|
146
169
|
```
|
|
147
170
|
|
|
148
|
-
|
|
171
|
+
For public APIs (no browser):
|
|
149
172
|
|
|
150
|
-
|
|
173
|
+
```yaml
|
|
174
|
+
strategy: public
|
|
175
|
+
browser: false
|
|
176
|
+
|
|
177
|
+
pipeline:
|
|
178
|
+
- fetch:
|
|
179
|
+
url: https://api.example.com/hot.json
|
|
180
|
+
- select: data.items
|
|
181
|
+
- map:
|
|
182
|
+
title: ${{ item.title }}
|
|
183
|
+
- limit: ${{ args.limit }}
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
### TypeScript Adapter (programmatic)
|
|
187
|
+
|
|
188
|
+
Create `src/clis/<site>/<name>.ts` and import in `clis/index.ts`:
|
|
151
189
|
|
|
152
190
|
```typescript
|
|
153
191
|
import { cli, Strategy } from '../../registry.js';
|
|
@@ -159,72 +197,86 @@ cli({
|
|
|
159
197
|
args: [{ name: 'keyword', required: true }],
|
|
160
198
|
columns: ['rank', 'title', 'url'],
|
|
161
199
|
func: async (page, kwargs) => {
|
|
200
|
+
await page.goto('https://www.mysite.com');
|
|
162
201
|
const data = await page.evaluate(`
|
|
163
|
-
async () => {
|
|
164
|
-
const res = await fetch('/api/search?q=${kwargs.keyword}', {
|
|
202
|
+
(async () => {
|
|
203
|
+
const res = await fetch('/api/search?q=${kwargs.keyword}', {
|
|
204
|
+
credentials: 'include'
|
|
205
|
+
});
|
|
165
206
|
return await res.json();
|
|
166
|
-
}
|
|
207
|
+
})()
|
|
167
208
|
`);
|
|
168
209
|
return data.items.map((item, i) => ({
|
|
169
|
-
rank: i + 1,
|
|
170
|
-
title: item.title,
|
|
171
|
-
url: item.url,
|
|
210
|
+
rank: i + 1, title: item.title, url: item.url,
|
|
172
211
|
}));
|
|
173
212
|
},
|
|
174
213
|
});
|
|
175
214
|
```
|
|
176
215
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
|
182
|
-
|
|
183
|
-
| `
|
|
184
|
-
| `
|
|
185
|
-
| `
|
|
186
|
-
| `
|
|
187
|
-
| `
|
|
188
|
-
| `
|
|
189
|
-
| `
|
|
190
|
-
| `
|
|
191
|
-
| `
|
|
192
|
-
| `
|
|
193
|
-
| `
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
216
|
+
**When to use TS**: XHR interception (小红书), cookie extraction (Twitter ct0), Wbi signing (Bilibili), auto-pagination, complex data transforms.
|
|
217
|
+
|
|
218
|
+
## Pipeline Steps
|
|
219
|
+
|
|
220
|
+
| Step | Description | Example |
|
|
221
|
+
|------|-------------|---------|
|
|
222
|
+
| `navigate` | Go to URL | `navigate: https://example.com` |
|
|
223
|
+
| `fetch` | HTTP request (browser cookies) | `fetch: { url: "...", params: { q: "..." } }` |
|
|
224
|
+
| `evaluate` | Run JavaScript in page | `evaluate: \| (async () => { ... })()` |
|
|
225
|
+
| `select` | Extract JSON path | `select: data.items` |
|
|
226
|
+
| `map` | Map fields | `map: { title: "${{ item.title }}" }` |
|
|
227
|
+
| `filter` | Filter items | `filter: item.score > 100` |
|
|
228
|
+
| `sort` | Sort items | `sort: { by: score, order: desc }` |
|
|
229
|
+
| `limit` | Cap result count | `limit: ${{ args.limit }}` |
|
|
230
|
+
| `intercept` | Declarative XHR capture | `intercept: { trigger: "navigate:...", capture: "api/hot" }` |
|
|
231
|
+
| `tap` | Store action + XHR capture | `tap: { store: "feed", action: "fetchFeeds", capture: "homefeed" }` |
|
|
232
|
+
| `snapshot` | Page accessibility tree | `snapshot: { interactive: true }` |
|
|
233
|
+
| `click` | Click element | `click: ${{ ref }}` |
|
|
234
|
+
| `type` | Type text | `type: { ref: "@1", text: "hello" }` |
|
|
235
|
+
| `wait` | Wait for time/text | `wait: 2` or `wait: { text: "loaded" }` |
|
|
236
|
+
| `press` | Press key | `press: Enter` |
|
|
237
|
+
|
|
238
|
+
## Template Syntax
|
|
198
239
|
|
|
199
240
|
```yaml
|
|
200
|
-
#
|
|
241
|
+
# Arguments with defaults
|
|
201
242
|
${{ args.keyword }}
|
|
202
243
|
${{ args.limit | default(20) }}
|
|
203
244
|
|
|
204
|
-
#
|
|
245
|
+
# Current item (in map/filter)
|
|
205
246
|
${{ item.title }}
|
|
206
247
|
${{ item.data.nested.field }}
|
|
207
248
|
|
|
208
|
-
#
|
|
249
|
+
# Index (0-based)
|
|
209
250
|
${{ index }}
|
|
210
251
|
${{ index + 1 }}
|
|
211
252
|
```
|
|
212
253
|
|
|
213
|
-
##
|
|
214
|
-
|
|
215
|
-
|
|
|
216
|
-
|
|
217
|
-
| `
|
|
218
|
-
| `
|
|
219
|
-
| `
|
|
220
|
-
| `
|
|
221
|
-
| `
|
|
222
|
-
|
|
223
|
-
##
|
|
224
|
-
|
|
225
|
-
|
|
|
226
|
-
|
|
227
|
-
| `
|
|
228
|
-
| `
|
|
229
|
-
| `
|
|
230
|
-
| `
|
|
254
|
+
## 5-Tier Authentication Strategy
|
|
255
|
+
|
|
256
|
+
| Tier | Name | Method | Example |
|
|
257
|
+
|------|------|--------|---------|
|
|
258
|
+
| 1 | `public` | No auth, Node.js fetch | Hacker News, V2EX |
|
|
259
|
+
| 2 | `cookie` | Browser fetch with `credentials: include` | Bilibili, Zhihu |
|
|
260
|
+
| 3 | `header` | Custom headers (ct0, Bearer) | Twitter GraphQL |
|
|
261
|
+
| 4 | `intercept` | XHR interception + store mutation | 小红书 Pinia |
|
|
262
|
+
| 5 | `ui` | Full UI automation (click/type/scroll) | Last resort |
|
|
263
|
+
|
|
264
|
+
## Environment Variables
|
|
265
|
+
|
|
266
|
+
| Variable | Default | Description |
|
|
267
|
+
|----------|---------|-------------|
|
|
268
|
+
| `OPENCLI_BROWSER_CONNECT_TIMEOUT` | 30 | Browser connection timeout (sec) |
|
|
269
|
+
| `OPENCLI_BROWSER_COMMAND_TIMEOUT` | 45 | Command execution timeout (sec) |
|
|
270
|
+
| `OPENCLI_BROWSER_EXPLORE_TIMEOUT` | 120 | Explore timeout (sec) |
|
|
271
|
+
| `OPENCLI_EXTENSION_LOCK_TIMEOUT` | 120 | Extension lock timeout (sec) |
|
|
272
|
+
| `PLAYWRIGHT_MCP_EXTENSION_TOKEN` | — | Auto-approve extension connection |
|
|
273
|
+
|
|
274
|
+
## Troubleshooting
|
|
275
|
+
|
|
276
|
+
| Issue | Solution |
|
|
277
|
+
|-------|----------|
|
|
278
|
+
| `npx not found` | Install Node.js: `brew install node` |
|
|
279
|
+
| `Timed out connecting to browser` | 1) Chrome must be open 2) Install MCP Bridge extension 3) Click to approve |
|
|
280
|
+
| `Extension lock timed out` | Another opencli command is running; browser commands run serially |
|
|
281
|
+
| `Target page context` error | Add `navigate:` step before `evaluate:` in YAML |
|
|
282
|
+
| Empty table data | Check if evaluate returns JSON string (MCP parsing) or data path is wrong |
|
package/dist/browser.d.ts
CHANGED
package/dist/browser.js
CHANGED
|
@@ -37,7 +37,18 @@ export class Page {
|
|
|
37
37
|
if (result?.content) {
|
|
38
38
|
const textParts = result.content.filter((c) => c.type === 'text');
|
|
39
39
|
if (textParts.length === 1) {
|
|
40
|
-
|
|
40
|
+
let text = textParts[0].text;
|
|
41
|
+
// MCP browser_evaluate returns: "[JSON]\n### Ran Playwright code\n```js\n...\n```"
|
|
42
|
+
// Strip the "### Ran Playwright code" suffix to get clean JSON
|
|
43
|
+
const codeMarker = text.indexOf('### Ran Playwright code');
|
|
44
|
+
if (codeMarker !== -1) {
|
|
45
|
+
text = text.slice(0, codeMarker).trim();
|
|
46
|
+
}
|
|
47
|
+
// Also handle "### Result\n[JSON]" format (some MCP versions)
|
|
48
|
+
const resultMarker = text.indexOf('### Result\n');
|
|
49
|
+
if (resultMarker !== -1) {
|
|
50
|
+
text = text.slice(resultMarker + '### Result\n'.length).trim();
|
|
51
|
+
}
|
|
41
52
|
try {
|
|
42
53
|
return JSON.parse(text);
|
|
43
54
|
}
|
|
@@ -106,6 +117,7 @@ export class PlaywrightMCP {
|
|
|
106
117
|
_waiters = [];
|
|
107
118
|
_lockAcquired = false;
|
|
108
119
|
_initialTabCount = 0;
|
|
120
|
+
_page = null;
|
|
109
121
|
async connect(opts = {}) {
|
|
110
122
|
await this._acquireLock();
|
|
111
123
|
const timeout = opts.timeout ?? CONNECT_TIMEOUT;
|
|
@@ -124,6 +136,7 @@ export class PlaywrightMCP {
|
|
|
124
136
|
this._proc.stdout.setMaxListeners(20);
|
|
125
137
|
const page = new Page((msg) => { if (this._proc?.stdin?.writable)
|
|
126
138
|
this._proc.stdin.write(msg); }, () => new Promise((res) => { this._waiters.push(res); }));
|
|
139
|
+
this._page = page;
|
|
127
140
|
this._proc.stdout?.on('data', (chunk) => {
|
|
128
141
|
this._buffer += chunk.toString();
|
|
129
142
|
const lines = this._buffer.split('\n');
|
|
@@ -174,12 +187,33 @@ export class PlaywrightMCP {
|
|
|
174
187
|
}
|
|
175
188
|
async close() {
|
|
176
189
|
try {
|
|
190
|
+
// Close tabs opened during this session (site tabs + extension tabs)
|
|
191
|
+
if (this._page && this._proc && !this._proc.killed) {
|
|
192
|
+
try {
|
|
193
|
+
const tabs = await this._page.tabs();
|
|
194
|
+
const tabStr = typeof tabs === 'string' ? tabs : JSON.stringify(tabs);
|
|
195
|
+
const allTabs = tabStr.match(/Tab (\d+)/g) || [];
|
|
196
|
+
const currentTabCount = allTabs.length;
|
|
197
|
+
// Close tabs in reverse order to avoid index shifting issues
|
|
198
|
+
// Keep the original tabs that existed before the command started
|
|
199
|
+
if (currentTabCount > this._initialTabCount && this._initialTabCount > 0) {
|
|
200
|
+
for (let i = currentTabCount - 1; i >= this._initialTabCount; i--) {
|
|
201
|
+
try {
|
|
202
|
+
await this._page.closeTab(i);
|
|
203
|
+
}
|
|
204
|
+
catch { }
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
catch { }
|
|
209
|
+
}
|
|
177
210
|
if (this._proc && !this._proc.killed) {
|
|
178
211
|
this._proc.kill('SIGTERM');
|
|
179
212
|
await new Promise((res) => { this._proc?.on('exit', () => res()); setTimeout(res, 3000); });
|
|
180
213
|
}
|
|
181
214
|
}
|
|
182
215
|
finally {
|
|
216
|
+
this._page = null;
|
|
183
217
|
this._releaseLock();
|
|
184
218
|
}
|
|
185
219
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strategy Cascade: automatic strategy downgrade chain.
|
|
3
|
+
*
|
|
4
|
+
* Probes an API endpoint starting from the simplest strategy (PUBLIC)
|
|
5
|
+
* and automatically downgrades through the strategy tiers until one works:
|
|
6
|
+
*
|
|
7
|
+
* PUBLIC → COOKIE → HEADER → INTERCEPT → UI
|
|
8
|
+
*
|
|
9
|
+
* This eliminates the need for manual strategy selection — the system
|
|
10
|
+
* automatically finds the minimum-privilege strategy that works.
|
|
11
|
+
*/
|
|
12
|
+
import { Strategy } from './registry.js';
|
|
13
|
+
interface ProbeResult {
|
|
14
|
+
strategy: Strategy;
|
|
15
|
+
success: boolean;
|
|
16
|
+
statusCode?: number;
|
|
17
|
+
hasData?: boolean;
|
|
18
|
+
error?: string;
|
|
19
|
+
responsePreview?: string;
|
|
20
|
+
}
|
|
21
|
+
interface CascadeResult {
|
|
22
|
+
bestStrategy: Strategy;
|
|
23
|
+
probes: ProbeResult[];
|
|
24
|
+
confidence: number;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Probe an endpoint with a specific strategy.
|
|
28
|
+
* Returns whether the probe succeeded and basic response info.
|
|
29
|
+
*/
|
|
30
|
+
export declare function probeEndpoint(page: any, url: string, strategy: Strategy, opts?: {
|
|
31
|
+
timeout?: number;
|
|
32
|
+
}): Promise<ProbeResult>;
|
|
33
|
+
/**
|
|
34
|
+
* Run the cascade: try each strategy in order until one works.
|
|
35
|
+
* Returns the simplest working strategy.
|
|
36
|
+
*/
|
|
37
|
+
export declare function cascadeProbe(page: any, url: string, opts?: {
|
|
38
|
+
maxStrategy?: Strategy;
|
|
39
|
+
timeout?: number;
|
|
40
|
+
}): Promise<CascadeResult>;
|
|
41
|
+
/**
|
|
42
|
+
* Render cascade results for display.
|
|
43
|
+
*/
|
|
44
|
+
export declare function renderCascadeResult(result: CascadeResult): string;
|
|
45
|
+
export {};
|
package/dist/cascade.js
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strategy Cascade: automatic strategy downgrade chain.
|
|
3
|
+
*
|
|
4
|
+
* Probes an API endpoint starting from the simplest strategy (PUBLIC)
|
|
5
|
+
* and automatically downgrades through the strategy tiers until one works:
|
|
6
|
+
*
|
|
7
|
+
* PUBLIC → COOKIE → HEADER → INTERCEPT → UI
|
|
8
|
+
*
|
|
9
|
+
* This eliminates the need for manual strategy selection — the system
|
|
10
|
+
* automatically finds the minimum-privilege strategy that works.
|
|
11
|
+
*/
|
|
12
|
+
import { Strategy } from './registry.js';
|
|
13
|
+
/** Strategy cascade order (simplest → most complex) */
|
|
14
|
+
const CASCADE_ORDER = [
|
|
15
|
+
Strategy.PUBLIC,
|
|
16
|
+
Strategy.COOKIE,
|
|
17
|
+
Strategy.HEADER,
|
|
18
|
+
Strategy.INTERCEPT,
|
|
19
|
+
Strategy.UI,
|
|
20
|
+
];
|
|
21
|
+
/**
|
|
22
|
+
* Probe an endpoint with a specific strategy.
|
|
23
|
+
* Returns whether the probe succeeded and basic response info.
|
|
24
|
+
*/
|
|
25
|
+
export async function probeEndpoint(page, url, strategy, opts = {}) {
|
|
26
|
+
const result = { strategy, success: false };
|
|
27
|
+
try {
|
|
28
|
+
switch (strategy) {
|
|
29
|
+
case Strategy.PUBLIC: {
|
|
30
|
+
// Try direct fetch without browser (no credentials)
|
|
31
|
+
const js = `
|
|
32
|
+
async () => {
|
|
33
|
+
try {
|
|
34
|
+
const resp = await fetch(${JSON.stringify(url)});
|
|
35
|
+
const status = resp.status;
|
|
36
|
+
if (!resp.ok) return { status, ok: false };
|
|
37
|
+
const text = await resp.text();
|
|
38
|
+
let hasData = false;
|
|
39
|
+
try {
|
|
40
|
+
const json = JSON.parse(text);
|
|
41
|
+
hasData = !!json && (Array.isArray(json) ? json.length > 0 :
|
|
42
|
+
typeof json === 'object' && Object.keys(json).length > 0);
|
|
43
|
+
} catch {}
|
|
44
|
+
return { status, ok: true, hasData, preview: text.slice(0, 200) };
|
|
45
|
+
} catch (e) { return { ok: false, error: e.message }; }
|
|
46
|
+
}
|
|
47
|
+
`;
|
|
48
|
+
const resp = await page.evaluate(js);
|
|
49
|
+
result.statusCode = resp?.status;
|
|
50
|
+
result.success = resp?.ok && resp?.hasData;
|
|
51
|
+
result.hasData = resp?.hasData;
|
|
52
|
+
result.responsePreview = resp?.preview;
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
case Strategy.COOKIE: {
|
|
56
|
+
// Fetch with credentials: 'include' (uses browser cookies)
|
|
57
|
+
const js = `
|
|
58
|
+
async () => {
|
|
59
|
+
try {
|
|
60
|
+
const resp = await fetch(${JSON.stringify(url)}, { credentials: 'include' });
|
|
61
|
+
const status = resp.status;
|
|
62
|
+
if (!resp.ok) return { status, ok: false };
|
|
63
|
+
const text = await resp.text();
|
|
64
|
+
let hasData = false;
|
|
65
|
+
try {
|
|
66
|
+
const json = JSON.parse(text);
|
|
67
|
+
hasData = !!json && (Array.isArray(json) ? json.length > 0 :
|
|
68
|
+
typeof json === 'object' && Object.keys(json).length > 0);
|
|
69
|
+
// Check for API-level error codes (common in Chinese sites)
|
|
70
|
+
if (json.code !== undefined && json.code !== 0) hasData = false;
|
|
71
|
+
} catch {}
|
|
72
|
+
return { status, ok: true, hasData, preview: text.slice(0, 200) };
|
|
73
|
+
} catch (e) { return { ok: false, error: e.message }; }
|
|
74
|
+
}
|
|
75
|
+
`;
|
|
76
|
+
const resp = await page.evaluate(js);
|
|
77
|
+
result.statusCode = resp?.status;
|
|
78
|
+
result.success = resp?.ok && resp?.hasData;
|
|
79
|
+
result.hasData = resp?.hasData;
|
|
80
|
+
result.responsePreview = resp?.preview;
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
case Strategy.HEADER: {
|
|
84
|
+
// Fetch with credentials + try to extract common auth headers
|
|
85
|
+
const js = `
|
|
86
|
+
async () => {
|
|
87
|
+
try {
|
|
88
|
+
// Try to extract CSRF tokens from cookies
|
|
89
|
+
const cookies = document.cookie.split(';').map(c => c.trim());
|
|
90
|
+
const csrf = cookies.find(c => c.startsWith('ct0=') || c.startsWith('csrf_token=') || c.startsWith('_csrf='))?.split('=').slice(1).join('=');
|
|
91
|
+
|
|
92
|
+
const headers = {};
|
|
93
|
+
if (csrf) {
|
|
94
|
+
headers['X-Csrf-Token'] = csrf;
|
|
95
|
+
headers['X-XSRF-Token'] = csrf;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const resp = await fetch(${JSON.stringify(url)}, {
|
|
99
|
+
credentials: 'include',
|
|
100
|
+
headers
|
|
101
|
+
});
|
|
102
|
+
const status = resp.status;
|
|
103
|
+
if (!resp.ok) return { status, ok: false };
|
|
104
|
+
const text = await resp.text();
|
|
105
|
+
let hasData = false;
|
|
106
|
+
try {
|
|
107
|
+
const json = JSON.parse(text);
|
|
108
|
+
hasData = !!json && (Array.isArray(json) ? json.length > 0 :
|
|
109
|
+
typeof json === 'object' && Object.keys(json).length > 0);
|
|
110
|
+
if (json.code !== undefined && json.code !== 0) hasData = false;
|
|
111
|
+
} catch {}
|
|
112
|
+
return { status, ok: true, hasData, preview: text.slice(0, 200) };
|
|
113
|
+
} catch (e) { return { ok: false, error: e.message }; }
|
|
114
|
+
}
|
|
115
|
+
`;
|
|
116
|
+
const resp = await page.evaluate(js);
|
|
117
|
+
result.statusCode = resp?.status;
|
|
118
|
+
result.success = resp?.ok && resp?.hasData;
|
|
119
|
+
result.hasData = resp?.hasData;
|
|
120
|
+
result.responsePreview = resp?.preview;
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
case Strategy.INTERCEPT:
|
|
124
|
+
case Strategy.UI:
|
|
125
|
+
// These require specific implementation per-site
|
|
126
|
+
// Mark as needing manual implementation
|
|
127
|
+
result.success = false;
|
|
128
|
+
result.error = `Strategy ${strategy} requires site-specific implementation`;
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
result.success = false;
|
|
134
|
+
result.error = err.message ?? String(err);
|
|
135
|
+
}
|
|
136
|
+
return result;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Run the cascade: try each strategy in order until one works.
|
|
140
|
+
* Returns the simplest working strategy.
|
|
141
|
+
*/
|
|
142
|
+
export async function cascadeProbe(page, url, opts = {}) {
|
|
143
|
+
const maxIdx = opts.maxStrategy
|
|
144
|
+
? CASCADE_ORDER.indexOf(opts.maxStrategy)
|
|
145
|
+
: CASCADE_ORDER.indexOf(Strategy.HEADER); // Don't auto-try INTERCEPT/UI
|
|
146
|
+
const probes = [];
|
|
147
|
+
for (let i = 0; i <= Math.min(maxIdx, CASCADE_ORDER.length - 1); i++) {
|
|
148
|
+
const strategy = CASCADE_ORDER[i];
|
|
149
|
+
const probe = await probeEndpoint(page, url, strategy, opts);
|
|
150
|
+
probes.push(probe);
|
|
151
|
+
if (probe.success) {
|
|
152
|
+
return {
|
|
153
|
+
bestStrategy: strategy,
|
|
154
|
+
probes,
|
|
155
|
+
confidence: 1.0 - (i * 0.1), // Higher confidence for simpler strategies
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
// None worked — default to COOKIE (most common for logged-in sites)
|
|
160
|
+
return {
|
|
161
|
+
bestStrategy: Strategy.COOKIE,
|
|
162
|
+
probes,
|
|
163
|
+
confidence: 0.3,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Render cascade results for display.
|
|
168
|
+
*/
|
|
169
|
+
export function renderCascadeResult(result) {
|
|
170
|
+
const lines = [
|
|
171
|
+
`Strategy Cascade: ${result.bestStrategy} (${(result.confidence * 100).toFixed(0)}% confidence)`,
|
|
172
|
+
];
|
|
173
|
+
for (const probe of result.probes) {
|
|
174
|
+
const icon = probe.success ? '✅' : '❌';
|
|
175
|
+
const status = probe.statusCode ? ` [${probe.statusCode}]` : '';
|
|
176
|
+
const err = probe.error ? ` — ${probe.error}` : '';
|
|
177
|
+
lines.push(` ${icon} ${probe.strategy}${status}${err}`);
|
|
178
|
+
}
|
|
179
|
+
return lines.join('\n');
|
|
180
|
+
}
|