@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.
Files changed (52) hide show
  1. package/CLI-CREATOR.md +594 -0
  2. package/README.md +116 -38
  3. package/README.zh-CN.md +143 -0
  4. package/SKILL.md +154 -102
  5. package/dist/browser.d.ts +1 -0
  6. package/dist/browser.js +35 -1
  7. package/dist/cascade.d.ts +45 -0
  8. package/dist/cascade.js +180 -0
  9. package/dist/clis/bilibili/hot.yaml +38 -0
  10. package/dist/clis/github/trending.yaml +58 -0
  11. package/dist/clis/hackernews/top.yaml +36 -0
  12. package/dist/clis/index.d.ts +2 -1
  13. package/dist/clis/index.js +3 -1
  14. package/dist/clis/reddit/hot.yaml +46 -0
  15. package/dist/clis/twitter/trending.yaml +40 -0
  16. package/dist/clis/v2ex/hot.yaml +25 -0
  17. package/dist/clis/v2ex/latest.yaml +25 -0
  18. package/dist/clis/v2ex/topic.yaml +27 -0
  19. package/dist/clis/xiaohongshu/feed.yaml +32 -0
  20. package/dist/clis/xiaohongshu/notifications.yaml +38 -0
  21. package/dist/clis/xiaohongshu/search.d.ts +5 -0
  22. package/dist/clis/xiaohongshu/search.js +68 -0
  23. package/dist/clis/zhihu/hot.yaml +42 -0
  24. package/dist/clis/zhihu/question.js +39 -0
  25. package/dist/clis/zhihu/search.yaml +55 -0
  26. package/dist/explore.d.ts +23 -13
  27. package/dist/explore.js +293 -422
  28. package/dist/main.js +17 -0
  29. package/dist/pipeline.js +238 -2
  30. package/dist/synthesize.d.ts +11 -8
  31. package/dist/synthesize.js +142 -118
  32. package/package.json +4 -2
  33. package/src/browser.ts +33 -1
  34. package/src/cascade.ts +217 -0
  35. package/src/clis/index.ts +4 -1
  36. package/src/clis/reddit/hot.yaml +46 -0
  37. package/src/clis/v2ex/hot.yaml +5 -9
  38. package/src/clis/v2ex/latest.yaml +5 -8
  39. package/src/clis/v2ex/topic.yaml +27 -0
  40. package/src/clis/xiaohongshu/feed.yaml +32 -0
  41. package/src/clis/xiaohongshu/notifications.yaml +38 -0
  42. package/src/clis/xiaohongshu/search.ts +71 -0
  43. package/src/clis/zhihu/hot.yaml +22 -8
  44. package/src/clis/zhihu/question.ts +45 -0
  45. package/src/clis/zhihu/search.yaml +55 -0
  46. package/src/explore.ts +303 -465
  47. package/src/main.ts +14 -0
  48. package/src/pipeline.ts +239 -2
  49. package/src/synthesize.ts +142 -137
  50. package/dist/clis/zhihu/search.js +0 -58
  51. package/src/clis/zhihu/search.ts +0 -65
  52. /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.0",
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
- const text = textParts[0].text;
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
@@ -16,4 +16,7 @@ import './bilibili/user-videos.js';
16
16
  import './github/search.js';
17
17
 
18
18
  // zhihu
19
- import './zhihu/search.js';
19
+ import './zhihu/question.js';
20
+
21
+ // xiaohongshu
22
+ import './xiaohongshu/search.js';
@@ -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]
@@ -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
- - evaluate: |
14
- (async () => {
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, node, author, replies]
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
- - evaluate: |
14
- (async () => {
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, node, author, replies]
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
+ });
@@ -12,17 +12,31 @@ args:
12
12
  pipeline:
13
13
  - navigate: https://www.zhihu.com
14
14
 
15
- - fetch:
16
- url: https://www.zhihu.com/api/v4/search/top_search
17
- params:
18
- limit: ${{ args.limit }}
19
-
20
- - select: top_search.words
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.query }}
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(/&nbsp;/g, ' ').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/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
+ });