@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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jackwener/opencli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
@@ -12,7 +12,9 @@
|
|
|
12
12
|
},
|
|
13
13
|
"scripts": {
|
|
14
14
|
"dev": "tsx src/main.ts",
|
|
15
|
-
"build": "tsc",
|
|
15
|
+
"build": "tsc && npm run clean-yaml && npm run copy-yaml",
|
|
16
|
+
"clean-yaml": "find dist/clis -name '*.yaml' -o -name '*.yml' 2>/dev/null | xargs rm -f",
|
|
17
|
+
"copy-yaml": "find src/clis -name '*.yaml' -o -name '*.yml' | while read f; do d=\"dist/${f#src/}\"; mkdir -p \"$(dirname \"$d\")\"; cp \"$f\" \"$d\"; done",
|
|
16
18
|
"start": "node dist/main.js",
|
|
17
19
|
"typecheck": "tsc --noEmit",
|
|
18
20
|
"lint": "tsc --noEmit",
|
package/src/browser.ts
CHANGED
|
@@ -36,7 +36,18 @@ export class Page {
|
|
|
36
36
|
if (result?.content) {
|
|
37
37
|
const textParts = result.content.filter((c: any) => c.type === 'text');
|
|
38
38
|
if (textParts.length === 1) {
|
|
39
|
-
|
|
39
|
+
let text = textParts[0].text;
|
|
40
|
+
// MCP browser_evaluate returns: "[JSON]\n### Ran Playwright code\n```js\n...\n```"
|
|
41
|
+
// Strip the "### Ran Playwright code" suffix to get clean JSON
|
|
42
|
+
const codeMarker = text.indexOf('### Ran Playwright code');
|
|
43
|
+
if (codeMarker !== -1) {
|
|
44
|
+
text = text.slice(0, codeMarker).trim();
|
|
45
|
+
}
|
|
46
|
+
// Also handle "### Result\n[JSON]" format (some MCP versions)
|
|
47
|
+
const resultMarker = text.indexOf('### Result\n');
|
|
48
|
+
if (resultMarker !== -1) {
|
|
49
|
+
text = text.slice(resultMarker + '### Result\n'.length).trim();
|
|
50
|
+
}
|
|
40
51
|
try { return JSON.parse(text); } catch { return text; }
|
|
41
52
|
}
|
|
42
53
|
}
|
|
@@ -115,6 +126,8 @@ export class PlaywrightMCP {
|
|
|
115
126
|
private _lockAcquired = false;
|
|
116
127
|
private _initialTabCount = 0;
|
|
117
128
|
|
|
129
|
+
private _page: Page | null = null;
|
|
130
|
+
|
|
118
131
|
async connect(opts: { timeout?: number } = {}): Promise<Page> {
|
|
119
132
|
await this._acquireLock();
|
|
120
133
|
const timeout = opts.timeout ?? CONNECT_TIMEOUT;
|
|
@@ -137,6 +150,7 @@ export class PlaywrightMCP {
|
|
|
137
150
|
(msg) => { if (this._proc?.stdin?.writable) this._proc.stdin.write(msg); },
|
|
138
151
|
() => new Promise<any>((res) => { this._waiters.push(res); }),
|
|
139
152
|
);
|
|
153
|
+
this._page = page;
|
|
140
154
|
|
|
141
155
|
this._proc.stdout?.on('data', (chunk: Buffer) => {
|
|
142
156
|
this._buffer += chunk.toString();
|
|
@@ -185,11 +199,29 @@ export class PlaywrightMCP {
|
|
|
185
199
|
|
|
186
200
|
async close(): Promise<void> {
|
|
187
201
|
try {
|
|
202
|
+
// Close tabs opened during this session (site tabs + extension tabs)
|
|
203
|
+
if (this._page && this._proc && !this._proc.killed) {
|
|
204
|
+
try {
|
|
205
|
+
const tabs = await this._page.tabs();
|
|
206
|
+
const tabStr = typeof tabs === 'string' ? tabs : JSON.stringify(tabs);
|
|
207
|
+
const allTabs = tabStr.match(/Tab (\d+)/g) || [];
|
|
208
|
+
const currentTabCount = allTabs.length;
|
|
209
|
+
|
|
210
|
+
// Close tabs in reverse order to avoid index shifting issues
|
|
211
|
+
// Keep the original tabs that existed before the command started
|
|
212
|
+
if (currentTabCount > this._initialTabCount && this._initialTabCount > 0) {
|
|
213
|
+
for (let i = currentTabCount - 1; i >= this._initialTabCount; i--) {
|
|
214
|
+
try { await this._page.closeTab(i); } catch {}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
} catch {}
|
|
218
|
+
}
|
|
188
219
|
if (this._proc && !this._proc.killed) {
|
|
189
220
|
this._proc.kill('SIGTERM');
|
|
190
221
|
await new Promise<void>((res) => { this._proc?.on('exit', () => res()); setTimeout(res, 3000); });
|
|
191
222
|
}
|
|
192
223
|
} finally {
|
|
224
|
+
this._page = null;
|
|
193
225
|
this._releaseLock();
|
|
194
226
|
}
|
|
195
227
|
}
|
package/src/cascade.ts
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
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
|
+
|
|
13
|
+
import { Strategy } from './registry.js';
|
|
14
|
+
|
|
15
|
+
/** Strategy cascade order (simplest → most complex) */
|
|
16
|
+
const CASCADE_ORDER: Strategy[] = [
|
|
17
|
+
Strategy.PUBLIC,
|
|
18
|
+
Strategy.COOKIE,
|
|
19
|
+
Strategy.HEADER,
|
|
20
|
+
Strategy.INTERCEPT,
|
|
21
|
+
Strategy.UI,
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
interface ProbeResult {
|
|
25
|
+
strategy: Strategy;
|
|
26
|
+
success: boolean;
|
|
27
|
+
statusCode?: number;
|
|
28
|
+
hasData?: boolean;
|
|
29
|
+
error?: string;
|
|
30
|
+
responsePreview?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface CascadeResult {
|
|
34
|
+
bestStrategy: Strategy;
|
|
35
|
+
probes: ProbeResult[];
|
|
36
|
+
confidence: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Probe an endpoint with a specific strategy.
|
|
41
|
+
* Returns whether the probe succeeded and basic response info.
|
|
42
|
+
*/
|
|
43
|
+
export async function probeEndpoint(
|
|
44
|
+
page: any,
|
|
45
|
+
url: string,
|
|
46
|
+
strategy: Strategy,
|
|
47
|
+
opts: { timeout?: number } = {},
|
|
48
|
+
): Promise<ProbeResult> {
|
|
49
|
+
const result: ProbeResult = { strategy, success: false };
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
switch (strategy) {
|
|
53
|
+
case Strategy.PUBLIC: {
|
|
54
|
+
// Try direct fetch without browser (no credentials)
|
|
55
|
+
const js = `
|
|
56
|
+
async () => {
|
|
57
|
+
try {
|
|
58
|
+
const resp = await fetch(${JSON.stringify(url)});
|
|
59
|
+
const status = resp.status;
|
|
60
|
+
if (!resp.ok) return { status, ok: false };
|
|
61
|
+
const text = await resp.text();
|
|
62
|
+
let hasData = false;
|
|
63
|
+
try {
|
|
64
|
+
const json = JSON.parse(text);
|
|
65
|
+
hasData = !!json && (Array.isArray(json) ? json.length > 0 :
|
|
66
|
+
typeof json === 'object' && Object.keys(json).length > 0);
|
|
67
|
+
} catch {}
|
|
68
|
+
return { status, ok: true, hasData, preview: text.slice(0, 200) };
|
|
69
|
+
} catch (e) { return { ok: false, error: e.message }; }
|
|
70
|
+
}
|
|
71
|
+
`;
|
|
72
|
+
const resp = await page.evaluate(js);
|
|
73
|
+
result.statusCode = resp?.status;
|
|
74
|
+
result.success = resp?.ok && resp?.hasData;
|
|
75
|
+
result.hasData = resp?.hasData;
|
|
76
|
+
result.responsePreview = resp?.preview;
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
case Strategy.COOKIE: {
|
|
81
|
+
// Fetch with credentials: 'include' (uses browser cookies)
|
|
82
|
+
const js = `
|
|
83
|
+
async () => {
|
|
84
|
+
try {
|
|
85
|
+
const resp = await fetch(${JSON.stringify(url)}, { credentials: 'include' });
|
|
86
|
+
const status = resp.status;
|
|
87
|
+
if (!resp.ok) return { status, ok: false };
|
|
88
|
+
const text = await resp.text();
|
|
89
|
+
let hasData = false;
|
|
90
|
+
try {
|
|
91
|
+
const json = JSON.parse(text);
|
|
92
|
+
hasData = !!json && (Array.isArray(json) ? json.length > 0 :
|
|
93
|
+
typeof json === 'object' && Object.keys(json).length > 0);
|
|
94
|
+
// Check for API-level error codes (common in Chinese sites)
|
|
95
|
+
if (json.code !== undefined && json.code !== 0) hasData = false;
|
|
96
|
+
} catch {}
|
|
97
|
+
return { status, ok: true, hasData, preview: text.slice(0, 200) };
|
|
98
|
+
} catch (e) { return { ok: false, error: e.message }; }
|
|
99
|
+
}
|
|
100
|
+
`;
|
|
101
|
+
const resp = await page.evaluate(js);
|
|
102
|
+
result.statusCode = resp?.status;
|
|
103
|
+
result.success = resp?.ok && resp?.hasData;
|
|
104
|
+
result.hasData = resp?.hasData;
|
|
105
|
+
result.responsePreview = resp?.preview;
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
case Strategy.HEADER: {
|
|
110
|
+
// Fetch with credentials + try to extract common auth headers
|
|
111
|
+
const js = `
|
|
112
|
+
async () => {
|
|
113
|
+
try {
|
|
114
|
+
// Try to extract CSRF tokens from cookies
|
|
115
|
+
const cookies = document.cookie.split(';').map(c => c.trim());
|
|
116
|
+
const csrf = cookies.find(c => c.startsWith('ct0=') || c.startsWith('csrf_token=') || c.startsWith('_csrf='))?.split('=').slice(1).join('=');
|
|
117
|
+
|
|
118
|
+
const headers = {};
|
|
119
|
+
if (csrf) {
|
|
120
|
+
headers['X-Csrf-Token'] = csrf;
|
|
121
|
+
headers['X-XSRF-Token'] = csrf;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const resp = await fetch(${JSON.stringify(url)}, {
|
|
125
|
+
credentials: 'include',
|
|
126
|
+
headers
|
|
127
|
+
});
|
|
128
|
+
const status = resp.status;
|
|
129
|
+
if (!resp.ok) return { status, ok: false };
|
|
130
|
+
const text = await resp.text();
|
|
131
|
+
let hasData = false;
|
|
132
|
+
try {
|
|
133
|
+
const json = JSON.parse(text);
|
|
134
|
+
hasData = !!json && (Array.isArray(json) ? json.length > 0 :
|
|
135
|
+
typeof json === 'object' && Object.keys(json).length > 0);
|
|
136
|
+
if (json.code !== undefined && json.code !== 0) hasData = false;
|
|
137
|
+
} catch {}
|
|
138
|
+
return { status, ok: true, hasData, preview: text.slice(0, 200) };
|
|
139
|
+
} catch (e) { return { ok: false, error: e.message }; }
|
|
140
|
+
}
|
|
141
|
+
`;
|
|
142
|
+
const resp = await page.evaluate(js);
|
|
143
|
+
result.statusCode = resp?.status;
|
|
144
|
+
result.success = resp?.ok && resp?.hasData;
|
|
145
|
+
result.hasData = resp?.hasData;
|
|
146
|
+
result.responsePreview = resp?.preview;
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
case Strategy.INTERCEPT:
|
|
151
|
+
case Strategy.UI:
|
|
152
|
+
// These require specific implementation per-site
|
|
153
|
+
// Mark as needing manual implementation
|
|
154
|
+
result.success = false;
|
|
155
|
+
result.error = `Strategy ${strategy} requires site-specific implementation`;
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
} catch (err: any) {
|
|
159
|
+
result.success = false;
|
|
160
|
+
result.error = err.message ?? String(err);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return result;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Run the cascade: try each strategy in order until one works.
|
|
168
|
+
* Returns the simplest working strategy.
|
|
169
|
+
*/
|
|
170
|
+
export async function cascadeProbe(
|
|
171
|
+
page: any,
|
|
172
|
+
url: string,
|
|
173
|
+
opts: { maxStrategy?: Strategy; timeout?: number } = {},
|
|
174
|
+
): Promise<CascadeResult> {
|
|
175
|
+
const maxIdx = opts.maxStrategy
|
|
176
|
+
? CASCADE_ORDER.indexOf(opts.maxStrategy)
|
|
177
|
+
: CASCADE_ORDER.indexOf(Strategy.HEADER); // Don't auto-try INTERCEPT/UI
|
|
178
|
+
|
|
179
|
+
const probes: ProbeResult[] = [];
|
|
180
|
+
|
|
181
|
+
for (let i = 0; i <= Math.min(maxIdx, CASCADE_ORDER.length - 1); i++) {
|
|
182
|
+
const strategy = CASCADE_ORDER[i];
|
|
183
|
+
const probe = await probeEndpoint(page, url, strategy, opts);
|
|
184
|
+
probes.push(probe);
|
|
185
|
+
|
|
186
|
+
if (probe.success) {
|
|
187
|
+
return {
|
|
188
|
+
bestStrategy: strategy,
|
|
189
|
+
probes,
|
|
190
|
+
confidence: 1.0 - (i * 0.1), // Higher confidence for simpler strategies
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// None worked — default to COOKIE (most common for logged-in sites)
|
|
196
|
+
return {
|
|
197
|
+
bestStrategy: Strategy.COOKIE,
|
|
198
|
+
probes,
|
|
199
|
+
confidence: 0.3,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Render cascade results for display.
|
|
205
|
+
*/
|
|
206
|
+
export function renderCascadeResult(result: CascadeResult): string {
|
|
207
|
+
const lines = [
|
|
208
|
+
`Strategy Cascade: ${result.bestStrategy} (${(result.confidence * 100).toFixed(0)}% confidence)`,
|
|
209
|
+
];
|
|
210
|
+
for (const probe of result.probes) {
|
|
211
|
+
const icon = probe.success ? '✅' : '❌';
|
|
212
|
+
const status = probe.statusCode ? ` [${probe.statusCode}]` : '';
|
|
213
|
+
const err = probe.error ? ` — ${probe.error}` : '';
|
|
214
|
+
lines.push(` ${icon} ${probe.strategy}${status}${err}`);
|
|
215
|
+
}
|
|
216
|
+
return lines.join('\n');
|
|
217
|
+
}
|
package/src/clis/index.ts
CHANGED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
site: reddit
|
|
2
|
+
name: hot
|
|
3
|
+
description: Reddit 热门帖子
|
|
4
|
+
domain: www.reddit.com
|
|
5
|
+
|
|
6
|
+
args:
|
|
7
|
+
subreddit:
|
|
8
|
+
type: str
|
|
9
|
+
default: ""
|
|
10
|
+
description: "Subreddit name (e.g. programming). Empty for frontpage"
|
|
11
|
+
limit:
|
|
12
|
+
type: int
|
|
13
|
+
default: 20
|
|
14
|
+
description: Number of posts
|
|
15
|
+
|
|
16
|
+
pipeline:
|
|
17
|
+
- navigate: https://www.reddit.com
|
|
18
|
+
|
|
19
|
+
- evaluate: |
|
|
20
|
+
(async () => {
|
|
21
|
+
const sub = '${{ args.subreddit }}';
|
|
22
|
+
const path = sub ? '/r/' + sub + '/hot.json' : '/hot.json';
|
|
23
|
+
const res = await fetch(path + '?limit=${{ args.limit }}&raw_json=1', {
|
|
24
|
+
credentials: 'include'
|
|
25
|
+
});
|
|
26
|
+
const d = await res.json();
|
|
27
|
+
return (d?.data?.children || []).map(c => ({
|
|
28
|
+
title: c.data.title,
|
|
29
|
+
subreddit: c.data.subreddit_name_prefixed,
|
|
30
|
+
score: c.data.score,
|
|
31
|
+
comments: c.data.num_comments,
|
|
32
|
+
author: c.data.author,
|
|
33
|
+
url: 'https://www.reddit.com' + c.data.permalink,
|
|
34
|
+
}));
|
|
35
|
+
})()
|
|
36
|
+
|
|
37
|
+
- map:
|
|
38
|
+
rank: ${{ index + 1 }}
|
|
39
|
+
title: ${{ item.title }}
|
|
40
|
+
subreddit: ${{ item.subreddit }}
|
|
41
|
+
score: ${{ item.score }}
|
|
42
|
+
comments: ${{ item.comments }}
|
|
43
|
+
|
|
44
|
+
- limit: ${{ args.limit }}
|
|
45
|
+
|
|
46
|
+
columns: [rank, title, subreddit, score, comments]
|
package/src/clis/v2ex/hot.yaml
CHANGED
|
@@ -2,6 +2,8 @@ site: v2ex
|
|
|
2
2
|
name: hot
|
|
3
3
|
description: V2EX 热门话题
|
|
4
4
|
domain: www.v2ex.com
|
|
5
|
+
strategy: public
|
|
6
|
+
browser: false
|
|
5
7
|
|
|
6
8
|
args:
|
|
7
9
|
limit:
|
|
@@ -10,20 +12,14 @@ args:
|
|
|
10
12
|
description: Number of topics
|
|
11
13
|
|
|
12
14
|
pipeline:
|
|
13
|
-
-
|
|
14
|
-
|
|
15
|
-
const res = await fetch('https://www.v2ex.com/api/topics/hot.json');
|
|
16
|
-
return await res.json();
|
|
17
|
-
})()
|
|
15
|
+
- fetch:
|
|
16
|
+
url: https://www.v2ex.com/api/topics/hot.json
|
|
18
17
|
|
|
19
18
|
- map:
|
|
20
19
|
rank: ${{ index + 1 }}
|
|
21
20
|
title: ${{ item.title }}
|
|
22
|
-
node: ${{ item.node?.title }}
|
|
23
|
-
author: ${{ item.member?.username }}
|
|
24
21
|
replies: ${{ item.replies }}
|
|
25
|
-
url: ${{ item.url }}
|
|
26
22
|
|
|
27
23
|
- limit: ${{ args.limit }}
|
|
28
24
|
|
|
29
|
-
columns: [rank, title,
|
|
25
|
+
columns: [rank, title, replies]
|
|
@@ -2,6 +2,8 @@ site: v2ex
|
|
|
2
2
|
name: latest
|
|
3
3
|
description: V2EX 最新话题
|
|
4
4
|
domain: www.v2ex.com
|
|
5
|
+
strategy: public
|
|
6
|
+
browser: false
|
|
5
7
|
|
|
6
8
|
args:
|
|
7
9
|
limit:
|
|
@@ -10,19 +12,14 @@ args:
|
|
|
10
12
|
description: Number of topics
|
|
11
13
|
|
|
12
14
|
pipeline:
|
|
13
|
-
-
|
|
14
|
-
|
|
15
|
-
const res = await fetch('https://www.v2ex.com/api/topics/latest.json');
|
|
16
|
-
return await res.json();
|
|
17
|
-
})()
|
|
15
|
+
- fetch:
|
|
16
|
+
url: https://www.v2ex.com/api/topics/latest.json
|
|
18
17
|
|
|
19
18
|
- map:
|
|
20
19
|
rank: ${{ index + 1 }}
|
|
21
20
|
title: ${{ item.title }}
|
|
22
|
-
node: ${{ item.node?.title }}
|
|
23
|
-
author: ${{ item.member?.username }}
|
|
24
21
|
replies: ${{ item.replies }}
|
|
25
22
|
|
|
26
23
|
- limit: ${{ args.limit }}
|
|
27
24
|
|
|
28
|
-
columns: [rank, title,
|
|
25
|
+
columns: [rank, title, replies]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
site: v2ex
|
|
2
|
+
name: topic
|
|
3
|
+
description: V2EX 主题详情和回复
|
|
4
|
+
domain: www.v2ex.com
|
|
5
|
+
strategy: public
|
|
6
|
+
browser: false
|
|
7
|
+
|
|
8
|
+
args:
|
|
9
|
+
id:
|
|
10
|
+
type: str
|
|
11
|
+
required: true
|
|
12
|
+
description: Topic ID
|
|
13
|
+
|
|
14
|
+
pipeline:
|
|
15
|
+
- fetch:
|
|
16
|
+
url: https://www.v2ex.com/api/topics/show.json
|
|
17
|
+
params:
|
|
18
|
+
id: ${{ args.id }}
|
|
19
|
+
|
|
20
|
+
- map:
|
|
21
|
+
title: ${{ item.title }}
|
|
22
|
+
replies: ${{ item.replies }}
|
|
23
|
+
url: ${{ item.url }}
|
|
24
|
+
|
|
25
|
+
- limit: 1
|
|
26
|
+
|
|
27
|
+
columns: [title, replies, url]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
site: xiaohongshu
|
|
2
|
+
name: feed
|
|
3
|
+
description: "小红书首页推荐 Feed (via Pinia Store Action)"
|
|
4
|
+
domain: www.xiaohongshu.com
|
|
5
|
+
strategy: intercept
|
|
6
|
+
browser: true
|
|
7
|
+
|
|
8
|
+
args:
|
|
9
|
+
limit:
|
|
10
|
+
type: int
|
|
11
|
+
default: 20
|
|
12
|
+
description: Number of items to return
|
|
13
|
+
|
|
14
|
+
columns: [title, author, likes, type, url]
|
|
15
|
+
|
|
16
|
+
pipeline:
|
|
17
|
+
- navigate: https://www.xiaohongshu.com/explore
|
|
18
|
+
- wait: 3
|
|
19
|
+
- tap:
|
|
20
|
+
store: feed
|
|
21
|
+
action: fetchFeeds
|
|
22
|
+
capture: homefeed
|
|
23
|
+
select: data.items
|
|
24
|
+
timeout: 8
|
|
25
|
+
- map:
|
|
26
|
+
id: ${{ item.id }}
|
|
27
|
+
title: ${{ item.note_card.display_title }}
|
|
28
|
+
type: ${{ item.note_card.type }}
|
|
29
|
+
author: ${{ item.note_card.user.nickname }}
|
|
30
|
+
likes: ${{ item.note_card.interact_info.liked_count }}
|
|
31
|
+
url: https://www.xiaohongshu.com/explore/${{ item.id }}
|
|
32
|
+
- limit: ${{ args.limit | default(20) }}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
site: xiaohongshu
|
|
2
|
+
name: notifications
|
|
3
|
+
description: "小红书通知 (mentions/likes/connections)"
|
|
4
|
+
domain: www.xiaohongshu.com
|
|
5
|
+
strategy: intercept
|
|
6
|
+
browser: true
|
|
7
|
+
|
|
8
|
+
args:
|
|
9
|
+
type:
|
|
10
|
+
type: str
|
|
11
|
+
default: mentions
|
|
12
|
+
description: "Notification type: mentions, likes, or connections"
|
|
13
|
+
limit:
|
|
14
|
+
type: int
|
|
15
|
+
default: 20
|
|
16
|
+
description: Number of notifications to return
|
|
17
|
+
|
|
18
|
+
columns: [rank, user, action, content, note, time]
|
|
19
|
+
|
|
20
|
+
pipeline:
|
|
21
|
+
- navigate: https://www.xiaohongshu.com/notification
|
|
22
|
+
- wait: 3
|
|
23
|
+
- tap:
|
|
24
|
+
store: notification
|
|
25
|
+
action: getNotification
|
|
26
|
+
args:
|
|
27
|
+
- ${{ args.type | default('mentions') }}
|
|
28
|
+
capture: /you/
|
|
29
|
+
select: data.message_list
|
|
30
|
+
timeout: 8
|
|
31
|
+
- map:
|
|
32
|
+
rank: ${{ index + 1 }}
|
|
33
|
+
user: ${{ item.user_info.nickname }}
|
|
34
|
+
action: ${{ item.title }}
|
|
35
|
+
content: ${{ item.comment_info.content }}
|
|
36
|
+
note: ${{ item.item_info.content }}
|
|
37
|
+
time: ${{ item.time }}
|
|
38
|
+
- limit: ${{ args.limit | default(20) }}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Xiaohongshu search — trigger search via Pinia store + XHR interception.
|
|
3
|
+
* Inspired by bb-sites/xiaohongshu/search.js but adapted for opencli pipeline.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { cli, Strategy } from '../../registry.js';
|
|
7
|
+
|
|
8
|
+
cli({
|
|
9
|
+
site: 'xiaohongshu',
|
|
10
|
+
name: 'search',
|
|
11
|
+
description: '搜索小红书笔记',
|
|
12
|
+
domain: 'www.xiaohongshu.com',
|
|
13
|
+
strategy: Strategy.COOKIE,
|
|
14
|
+
args: [
|
|
15
|
+
{ name: 'keyword', required: true, help: 'Search keyword' },
|
|
16
|
+
{ name: 'limit', type: 'int', default: 20, help: 'Number of results' },
|
|
17
|
+
],
|
|
18
|
+
columns: ['rank', 'title', 'author', 'likes', 'type'],
|
|
19
|
+
func: async (page, kwargs) => {
|
|
20
|
+
await page.goto('https://www.xiaohongshu.com');
|
|
21
|
+
await page.wait(2);
|
|
22
|
+
|
|
23
|
+
const data = await page.evaluate(`
|
|
24
|
+
(async () => {
|
|
25
|
+
const app = document.querySelector('#app')?.__vue_app__;
|
|
26
|
+
const pinia = app?.config?.globalProperties?.$pinia;
|
|
27
|
+
if (!pinia?._s) return {error: 'Page not ready'};
|
|
28
|
+
|
|
29
|
+
const searchStore = pinia._s.get('search');
|
|
30
|
+
if (!searchStore) return {error: 'Search store not found'};
|
|
31
|
+
|
|
32
|
+
let captured = null;
|
|
33
|
+
const origOpen = XMLHttpRequest.prototype.open;
|
|
34
|
+
const origSend = XMLHttpRequest.prototype.send;
|
|
35
|
+
XMLHttpRequest.prototype.open = function(m, u) { this.__url = u; return origOpen.apply(this, arguments); };
|
|
36
|
+
XMLHttpRequest.prototype.send = function(b) {
|
|
37
|
+
if (this.__url?.includes('search/notes')) {
|
|
38
|
+
const x = this;
|
|
39
|
+
const orig = x.onreadystatechange;
|
|
40
|
+
x.onreadystatechange = function() { if (x.readyState === 4 && !captured) { try { captured = JSON.parse(x.responseText); } catch {} } if (orig) orig.apply(this, arguments); };
|
|
41
|
+
}
|
|
42
|
+
return origSend.apply(this, arguments);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
searchStore.mutateSearchValue('${kwargs.keyword}');
|
|
47
|
+
await searchStore.loadMore();
|
|
48
|
+
await new Promise(r => setTimeout(r, 800));
|
|
49
|
+
} finally {
|
|
50
|
+
XMLHttpRequest.prototype.open = origOpen;
|
|
51
|
+
XMLHttpRequest.prototype.send = origSend;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (!captured?.success) return {error: captured?.msg || 'Search failed'};
|
|
55
|
+
return (captured.data?.items || []).map(i => ({
|
|
56
|
+
title: i.note_card?.display_title || '',
|
|
57
|
+
type: i.note_card?.type || '',
|
|
58
|
+
url: 'https://www.xiaohongshu.com/explore/' + i.id,
|
|
59
|
+
author: i.note_card?.user?.nickname || '',
|
|
60
|
+
likes: i.note_card?.interact_info?.liked_count || '0',
|
|
61
|
+
}));
|
|
62
|
+
})()
|
|
63
|
+
`);
|
|
64
|
+
|
|
65
|
+
if (!Array.isArray(data)) return [];
|
|
66
|
+
return data.slice(0, kwargs.limit).map((item: any, i: number) => ({
|
|
67
|
+
rank: i + 1,
|
|
68
|
+
...item,
|
|
69
|
+
}));
|
|
70
|
+
},
|
|
71
|
+
});
|
package/src/clis/zhihu/hot.yaml
CHANGED
|
@@ -12,17 +12,31 @@ args:
|
|
|
12
12
|
pipeline:
|
|
13
13
|
- navigate: https://www.zhihu.com
|
|
14
14
|
|
|
15
|
-
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
15
|
+
- evaluate: |
|
|
16
|
+
(async () => {
|
|
17
|
+
const res = await fetch('https://www.zhihu.com/api/v3/feed/topstory/hot-lists/total?limit=50', {
|
|
18
|
+
credentials: 'include'
|
|
19
|
+
});
|
|
20
|
+
const d = await res.json();
|
|
21
|
+
return (d?.data || []).map((item) => {
|
|
22
|
+
const t = item.target || {};
|
|
23
|
+
return {
|
|
24
|
+
title: t.title,
|
|
25
|
+
url: 'https://www.zhihu.com/question/' + t.id,
|
|
26
|
+
answer_count: t.answer_count,
|
|
27
|
+
follower_count: t.follower_count,
|
|
28
|
+
heat: item.detail_text || '',
|
|
29
|
+
};
|
|
30
|
+
});
|
|
31
|
+
})()
|
|
21
32
|
|
|
22
33
|
- map:
|
|
23
34
|
rank: ${{ index + 1 }}
|
|
24
|
-
title: ${{ item.
|
|
35
|
+
title: ${{ item.title }}
|
|
36
|
+
heat: ${{ item.heat }}
|
|
37
|
+
answers: ${{ item.answer_count }}
|
|
38
|
+
url: ${{ item.url }}
|
|
25
39
|
|
|
26
40
|
- limit: ${{ args.limit }}
|
|
27
41
|
|
|
28
|
-
columns: [rank, title]
|
|
42
|
+
columns: [rank, title, heat, answers]
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { cli, Strategy } from '../../registry.js';
|
|
2
|
+
|
|
3
|
+
cli({
|
|
4
|
+
site: 'zhihu',
|
|
5
|
+
name: 'question',
|
|
6
|
+
description: '知乎问题详情和回答',
|
|
7
|
+
domain: 'www.zhihu.com',
|
|
8
|
+
strategy: Strategy.COOKIE,
|
|
9
|
+
args: [
|
|
10
|
+
{ name: 'id', required: true, help: 'Question ID (numeric)' },
|
|
11
|
+
{ name: 'limit', type: 'int', default: 5, help: 'Number of answers' },
|
|
12
|
+
],
|
|
13
|
+
columns: ['rank', 'author', 'votes', 'content'],
|
|
14
|
+
func: async (page, kwargs) => {
|
|
15
|
+
const { id, limit = 5 } = kwargs;
|
|
16
|
+
|
|
17
|
+
const stripHtml = (html: string) =>
|
|
18
|
+
(html || '').replace(/<[^>]+>/g, '').replace(/ /g, ' ').replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&').trim();
|
|
19
|
+
|
|
20
|
+
// Fetch question detail and answers in parallel via evaluate
|
|
21
|
+
const result = await page.evaluate(`
|
|
22
|
+
async () => {
|
|
23
|
+
const [qResp, aResp] = await Promise.all([
|
|
24
|
+
fetch('https://www.zhihu.com/api/v4/questions/${id}?include=data[*].detail,excerpt,answer_count,follower_count,visit_count', {credentials: 'include'}),
|
|
25
|
+
fetch('https://www.zhihu.com/api/v4/questions/${id}/answers?limit=${limit}&offset=0&sort_by=default&include=data[*].content,voteup_count,comment_count,author', {credentials: 'include'})
|
|
26
|
+
]);
|
|
27
|
+
if (!qResp.ok || !aResp.ok) return { error: true };
|
|
28
|
+
const q = await qResp.json();
|
|
29
|
+
const a = await aResp.json();
|
|
30
|
+
return { question: q, answers: a.data || [] };
|
|
31
|
+
}
|
|
32
|
+
`);
|
|
33
|
+
|
|
34
|
+
if (!result || result.error) throw new Error('Failed to fetch question. Are you logged in?');
|
|
35
|
+
|
|
36
|
+
const answers = (result.answers ?? []).slice(0, Number(limit)).map((a: any, i: number) => ({
|
|
37
|
+
rank: i + 1,
|
|
38
|
+
author: a.author?.name ?? 'anonymous',
|
|
39
|
+
votes: a.voteup_count ?? 0,
|
|
40
|
+
content: stripHtml(a.content ?? '').slice(0, 200),
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
return answers;
|
|
44
|
+
},
|
|
45
|
+
});
|