@jackwener/opencli 0.4.1 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/CLI-CREATOR.md +103 -142
  2. package/LICENSE +28 -0
  3. package/README.md +113 -63
  4. package/README.zh-CN.md +114 -63
  5. package/SKILL.md +21 -4
  6. package/dist/browser.d.ts +21 -2
  7. package/dist/browser.js +269 -15
  8. package/dist/browser.test.d.ts +1 -0
  9. package/dist/browser.test.js +43 -0
  10. package/dist/build-manifest.js +66 -2
  11. package/dist/cli-manifest.json +905 -109
  12. package/dist/clis/boss/search.js +186 -30
  13. package/dist/clis/twitter/delete.d.ts +1 -0
  14. package/dist/clis/twitter/delete.js +73 -0
  15. package/dist/clis/twitter/followers.d.ts +1 -0
  16. package/dist/clis/twitter/followers.js +104 -0
  17. package/dist/clis/twitter/following.d.ts +1 -0
  18. package/dist/clis/twitter/following.js +90 -0
  19. package/dist/clis/twitter/like.d.ts +1 -0
  20. package/dist/clis/twitter/like.js +69 -0
  21. package/dist/clis/twitter/notifications.d.ts +1 -0
  22. package/dist/clis/twitter/notifications.js +109 -0
  23. package/dist/clis/twitter/post.d.ts +1 -0
  24. package/dist/clis/twitter/post.js +63 -0
  25. package/dist/clis/twitter/reply.d.ts +1 -0
  26. package/dist/clis/twitter/reply.js +57 -0
  27. package/dist/clis/v2ex/daily.d.ts +1 -0
  28. package/dist/clis/v2ex/daily.js +98 -0
  29. package/dist/clis/v2ex/me.d.ts +1 -0
  30. package/dist/clis/v2ex/me.js +99 -0
  31. package/dist/clis/v2ex/notifications.d.ts +1 -0
  32. package/dist/clis/v2ex/notifications.js +72 -0
  33. package/dist/clis/xiaohongshu/search.d.ts +5 -2
  34. package/dist/clis/xiaohongshu/search.js +35 -41
  35. package/dist/doctor.d.ts +50 -0
  36. package/dist/doctor.js +372 -0
  37. package/dist/doctor.test.d.ts +1 -0
  38. package/dist/doctor.test.js +114 -0
  39. package/dist/main.js +47 -5
  40. package/dist/output.test.d.ts +1 -0
  41. package/dist/output.test.js +20 -0
  42. package/dist/registry.d.ts +4 -0
  43. package/dist/registry.js +1 -0
  44. package/dist/runtime.d.ts +3 -1
  45. package/dist/runtime.js +2 -2
  46. package/package.json +2 -2
  47. package/src/browser.test.ts +51 -0
  48. package/src/browser.ts +318 -22
  49. package/src/build-manifest.ts +67 -2
  50. package/src/clis/boss/search.ts +196 -29
  51. package/src/clis/twitter/delete.ts +78 -0
  52. package/src/clis/twitter/followers.ts +119 -0
  53. package/src/clis/twitter/following.ts +105 -0
  54. package/src/clis/twitter/like.ts +74 -0
  55. package/src/clis/twitter/notifications.ts +119 -0
  56. package/src/clis/twitter/post.ts +68 -0
  57. package/src/clis/twitter/reply.ts +62 -0
  58. package/src/clis/v2ex/daily.ts +105 -0
  59. package/src/clis/v2ex/me.ts +103 -0
  60. package/src/clis/v2ex/notifications.ts +77 -0
  61. package/src/clis/xiaohongshu/search.ts +41 -44
  62. package/src/doctor.test.ts +133 -0
  63. package/src/doctor.ts +424 -0
  64. package/src/main.ts +47 -4
  65. package/src/output.test.ts +27 -0
  66. package/src/registry.ts +5 -0
  67. package/src/runtime.ts +2 -1
@@ -0,0 +1,109 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ cli({
3
+ site: 'twitter',
4
+ name: 'notifications',
5
+ description: 'Get Twitter/X notifications',
6
+ domain: 'x.com',
7
+ strategy: Strategy.INTERCEPT,
8
+ browser: true,
9
+ args: [
10
+ { name: 'limit', type: 'int', default: 20 },
11
+ ],
12
+ columns: ['id', 'action', 'author', 'text', 'url'],
13
+ func: async (page, kwargs) => {
14
+ // 1. Navigate to notifications
15
+ await page.goto('https://x.com/notifications');
16
+ await page.wait(5);
17
+ // 2. Inject interceptor
18
+ await page.installInterceptor('NotificationsTimeline');
19
+ // 3. Trigger API by scrolling (if we need to load more)
20
+ await page.autoScroll({ times: 2, delayMs: 2000 });
21
+ // 4. Retrieve data
22
+ const requests = await page.getInterceptedRequests();
23
+ if (!requests || requests.length === 0)
24
+ return [];
25
+ let results = [];
26
+ for (const req of requests) {
27
+ try {
28
+ let instructions = [];
29
+ if (req.data?.data?.viewer?.timeline_response?.timeline?.instructions) {
30
+ instructions = req.data.data.viewer.timeline_response.timeline.instructions;
31
+ }
32
+ else if (req.data?.data?.viewer_v2?.user_results?.result?.notification_timeline?.timeline?.instructions) {
33
+ instructions = req.data.data.viewer_v2.user_results.result.notification_timeline.timeline.instructions;
34
+ }
35
+ else if (req.data?.data?.timeline?.instructions) {
36
+ instructions = req.data.data.timeline.instructions;
37
+ }
38
+ let addEntries = instructions.find((i) => i.type === 'TimelineAddEntries');
39
+ // Sometimes it's the first object without a 'type' field but has 'entries'
40
+ if (!addEntries) {
41
+ addEntries = instructions.find((i) => i.entries && Array.isArray(i.entries));
42
+ }
43
+ if (!addEntries)
44
+ continue;
45
+ for (const entry of addEntries.entries) {
46
+ if (!entry.entryId.startsWith('notification-')) {
47
+ if (entry.content?.items) {
48
+ for (const subItem of entry.content.items) {
49
+ processNotificationItem(subItem.item?.itemContent, subItem.entryId);
50
+ }
51
+ }
52
+ continue;
53
+ }
54
+ processNotificationItem(entry.content?.itemContent, entry.entryId);
55
+ }
56
+ function processNotificationItem(itemContent, entryId) {
57
+ if (!itemContent)
58
+ return;
59
+ // Twitter wraps standard notifications
60
+ let item = itemContent?.notification_results?.result || itemContent?.tweet_results?.result || itemContent;
61
+ let actionText = 'Notification';
62
+ let author = 'unknown';
63
+ let text = '';
64
+ let urlStr = '';
65
+ if (item.__typename === 'TimelineNotification') {
66
+ // Greet likes, retweet, mentions
67
+ text = item.rich_message?.text || item.message?.text || '';
68
+ author = item.template?.from_users?.[0]?.user_results?.result?.core?.screen_name || 'unknown';
69
+ urlStr = item.notification_url?.url || '';
70
+ actionText = item.notification_icon || 'Activity';
71
+ // If there's an attached tweet
72
+ const targetTweet = item.template?.target_objects?.[0]?.tweet_results?.result;
73
+ if (targetTweet) {
74
+ text += ' | ' + (targetTweet.legacy?.full_text || '');
75
+ if (!urlStr) {
76
+ urlStr = `https://x.com/i/status/${targetTweet.rest_id}`;
77
+ }
78
+ }
79
+ }
80
+ else if (item.__typename === 'TweetNotification') {
81
+ // Direct mention/reply
82
+ const tweet = item.tweet_result?.result;
83
+ author = tweet?.core?.user_results?.result?.legacy?.screen_name || 'unknown';
84
+ text = tweet?.legacy?.full_text || item.message?.text || '';
85
+ actionText = 'Mention/Reply';
86
+ urlStr = `https://x.com/i/status/${tweet?.rest_id}`;
87
+ }
88
+ else if (item.__typename === 'Tweet') {
89
+ author = item.core?.user_results?.result?.legacy?.screen_name || 'unknown';
90
+ text = item.legacy?.full_text || '';
91
+ actionText = 'Mention';
92
+ urlStr = `https://x.com/i/status/${item.rest_id}`;
93
+ }
94
+ results.push({
95
+ id: item.id || item.rest_id || entryId,
96
+ action: actionText,
97
+ author: author,
98
+ text: text,
99
+ url: urlStr || `https://x.com/notifications`
100
+ });
101
+ }
102
+ }
103
+ catch (e) {
104
+ // ignore parsing errors for individual payloads
105
+ }
106
+ }
107
+ return results.slice(0, kwargs.limit);
108
+ }
109
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,63 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ cli({
3
+ site: 'twitter',
4
+ name: 'post',
5
+ description: 'Post a new tweet/thread',
6
+ domain: 'x.com',
7
+ strategy: Strategy.UI,
8
+ browser: true,
9
+ args: [
10
+ { name: 'text', type: 'string', required: true, help: 'The text content of the tweet' },
11
+ ],
12
+ columns: ['status', 'message', 'text'],
13
+ func: async (page, kwargs) => {
14
+ if (!page)
15
+ throw new Error('Requires browser');
16
+ // 1. Navigate directly to the compose tweet modal
17
+ await page.goto('https://x.com/compose/tweet');
18
+ await page.wait(3); // Wait for the modal and React app to hydrate
19
+ // 2. Automate typing and clicking
20
+ const result = await page.evaluate(`(async () => {
21
+ try {
22
+ // Find the active text area
23
+ const box = document.querySelector('[data-testid="tweetTextarea_0"]');
24
+ if (box) {
25
+ box.focus();
26
+ // insertText is the most reliable way to trigger React's onChange events
27
+ document.execCommand('insertText', false, ${JSON.stringify(kwargs.text)});
28
+ } else {
29
+ return { ok: false, message: 'Could not find the tweet composer text area.' };
30
+ }
31
+
32
+ // Wait a brief moment for the button state to update
33
+ await new Promise(r => setTimeout(r, 1000));
34
+
35
+ // Click the post button
36
+ const btn = document.querySelector('[data-testid="tweetButton"]');
37
+ if (btn && !btn.disabled) {
38
+ btn.click();
39
+ return { ok: true, message: 'Tweet posted successfully.' };
40
+ } else {
41
+ // Sometimes it's rendered inline depending on the viewport
42
+ const inlineBtn = document.querySelector('[data-testid="tweetButtonInline"]');
43
+ if (inlineBtn && !inlineBtn.disabled) {
44
+ inlineBtn.click();
45
+ return { ok: true, message: 'Tweet posted successfully.' };
46
+ }
47
+ return { ok: false, message: 'Tweet button is disabled or not found.' };
48
+ }
49
+ } catch (e) {
50
+ return { ok: false, message: e.toString() };
51
+ }
52
+ })()`);
53
+ // 3. Wait a few seconds for the network request to finish sending
54
+ if (result.ok) {
55
+ await page.wait(3);
56
+ }
57
+ return [{
58
+ status: result.ok ? 'success' : 'failed',
59
+ message: result.message,
60
+ text: kwargs.text
61
+ }];
62
+ }
63
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,57 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ cli({
3
+ site: 'twitter',
4
+ name: 'reply',
5
+ description: 'Reply to a specific tweet',
6
+ domain: 'x.com',
7
+ strategy: Strategy.UI, // Uses the UI directly to input and click post
8
+ browser: true,
9
+ args: [
10
+ { name: 'url', type: 'string', required: true, help: 'The URL of the tweet to reply to' },
11
+ { name: 'text', type: 'string', required: true, help: 'The text content of your reply' },
12
+ ],
13
+ columns: ['status', 'message', 'text'],
14
+ func: async (page, kwargs) => {
15
+ if (!page)
16
+ throw new Error('Requires browser');
17
+ // 1. Navigate to the tweet page
18
+ await page.goto(kwargs.url);
19
+ await page.wait(5); // Wait for the react application to hydrate
20
+ // 2. Automate typing the reply and clicking reply
21
+ const result = await page.evaluate(`(async () => {
22
+ try {
23
+ // Find the reply text area on the tweet page.
24
+ // The placeholder is usually "Post your reply"
25
+ const box = document.querySelector('[data-testid="tweetTextarea_0"]');
26
+ if (box) {
27
+ box.focus();
28
+ document.execCommand('insertText', false, ${JSON.stringify(kwargs.text)});
29
+ } else {
30
+ return { ok: false, message: 'Could not find the reply text area. Are you logged in?' };
31
+ }
32
+
33
+ // Wait for React state to register the input and enable the button
34
+ await new Promise(r => setTimeout(r, 1000));
35
+
36
+ // Find the Reply button. It usually shares the same test id tweetButtonInline in this context
37
+ const btn = document.querySelector('[data-testid="tweetButtonInline"]');
38
+ if (btn && !btn.disabled) {
39
+ btn.click();
40
+ return { ok: true, message: 'Reply posted successfully.' };
41
+ } else {
42
+ return { ok: false, message: 'Reply button is disabled or not found.' };
43
+ }
44
+ } catch (e) {
45
+ return { ok: false, message: e.toString() };
46
+ }
47
+ })()`);
48
+ if (result.ok) {
49
+ await page.wait(3); // Wait for network submission to complete
50
+ }
51
+ return [{
52
+ status: result.ok ? 'success' : 'failed',
53
+ message: result.message,
54
+ text: kwargs.text
55
+ }];
56
+ }
57
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,98 @@
1
+ /**
2
+ * V2EX Daily Check-in adapter.
3
+ */
4
+ import { cli, Strategy } from '../../registry.js';
5
+ cli({
6
+ site: 'v2ex',
7
+ name: 'daily',
8
+ description: 'V2EX 每日签到并领取铜币',
9
+ domain: 'www.v2ex.com',
10
+ strategy: Strategy.COOKIE,
11
+ browser: true,
12
+ forceExtension: true,
13
+ args: [],
14
+ columns: ['status', 'message'],
15
+ func: async (page) => {
16
+ if (!page)
17
+ throw new Error('Browser page required');
18
+ if (process.env.OPENCLI_VERBOSE) {
19
+ console.error('[opencli:v2ex] Navigating to /mission/daily');
20
+ }
21
+ await page.goto('https://www.v2ex.com/mission/daily');
22
+ // Cloudflare challenge bypass wait
23
+ for (let i = 0; i < 5; i++) {
24
+ await new Promise(r => setTimeout(r, 1500));
25
+ const title = await page.evaluate(`() => document.title`);
26
+ if (!title?.includes('Just a moment'))
27
+ break;
28
+ if (process.env.OPENCLI_VERBOSE)
29
+ console.error('[opencli:v2ex] Waiting for Cloudflare...');
30
+ }
31
+ // Evaluate DOM to find if we need to check in
32
+ const checkResult = await page.evaluate(`
33
+ async () => {
34
+ const btn = document.querySelector('input.super.normal.button');
35
+ if (!btn || !btn.value.includes('领取')) {
36
+ return { claimed: true, message: '今日奖励已发/无需领取' };
37
+ }
38
+
39
+ const onclick = btn.getAttribute('onclick');
40
+ if (onclick) {
41
+ const match = onclick.match(/once=(\\d+)/);
42
+ if (match) {
43
+ return { claimed: false, once: match[1], message: btn.value };
44
+ }
45
+ }
46
+
47
+ return {
48
+ claimed: false,
49
+ error: '找到了按钮,但未能提取 once token',
50
+ debug_title: document.title,
51
+ debug_body: document.body.innerText.substring(0, 200).replace(/\\n/g, ' ')
52
+ };
53
+ }
54
+ `);
55
+ if (checkResult.error) {
56
+ if (process.env.OPENCLI_VERBOSE) {
57
+ console.error(`[opencli:v2ex:debug] Page Title: ${checkResult.debug_title}`);
58
+ console.error(`[opencli:v2ex:debug] Page Body: ${checkResult.debug_body}`);
59
+ }
60
+ throw new Error(checkResult.error);
61
+ }
62
+ if (checkResult.claimed) {
63
+ return [{ status: '✅ 已签到', message: checkResult.message }];
64
+ }
65
+ // Perform check in
66
+ if (process.env.OPENCLI_VERBOSE) {
67
+ console.error(`[opencli:v2ex] Found check-in token: once=${checkResult.once}. Checking in...`);
68
+ }
69
+ await page.goto(`https://www.v2ex.com/mission/daily/redeem?once=${checkResult.once}`);
70
+ await new Promise(resolve => setTimeout(resolve, 3000)); // wait longer for redirect
71
+ // Verify result
72
+ const verifyResult = await page.evaluate(`
73
+ async () => {
74
+ const btn = document.querySelector('input.super.normal.button');
75
+ if (!btn || !btn.value.includes('领取')) {
76
+ // fetch balance to show user
77
+ let balance = '';
78
+ const balanceLink = document.querySelector('a.balance_area');
79
+ if (balanceLink) {
80
+ balance = Array.from(balanceLink.childNodes)
81
+ .filter(n => n.nodeType === 3)
82
+ .map(n => n.textContent?.trim())
83
+ .join(' ')
84
+ .trim();
85
+ }
86
+ return { success: true, balance };
87
+ }
88
+ return { success: false };
89
+ }
90
+ `);
91
+ if (verifyResult.success) {
92
+ return [{ status: '🎉 签到成功', message: `当前余额: ${verifyResult.balance || '未知'}` }];
93
+ }
94
+ else {
95
+ return [{ status: '❌ 签到失败', message: '未能确认签到结果,请手动检查' }];
96
+ }
97
+ },
98
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,99 @@
1
+ /**
2
+ * V2EX Me (Profile/Balance) adapter.
3
+ */
4
+ import { cli, Strategy } from '../../registry.js';
5
+ cli({
6
+ site: 'v2ex',
7
+ name: 'me',
8
+ description: 'V2EX 获取个人资料 (余额/未读提醒)',
9
+ domain: 'www.v2ex.com',
10
+ strategy: Strategy.COOKIE,
11
+ browser: true,
12
+ forceExtension: true,
13
+ args: [],
14
+ columns: ['username', 'balance', 'unread_notifications', 'daily_reward_ready'],
15
+ func: async (page) => {
16
+ if (!page)
17
+ throw new Error('Browser page required');
18
+ if (process.env.OPENCLI_VERBOSE) {
19
+ console.error('[opencli:v2ex] Navigating to /');
20
+ }
21
+ await page.goto('https://www.v2ex.com/');
22
+ // Cloudflare challenge bypass wait
23
+ for (let i = 0; i < 5; i++) {
24
+ await new Promise(r => setTimeout(r, 1500));
25
+ const title = await page.evaluate(`() => document.title`);
26
+ if (!title?.includes('Just a moment'))
27
+ break;
28
+ if (process.env.OPENCLI_VERBOSE)
29
+ console.error('[opencli:v2ex] Waiting for Cloudflare...');
30
+ }
31
+ // Evaluate DOM to extract user profile
32
+ const data = await page.evaluate(`
33
+ async () => {
34
+ let username = 'Unknown';
35
+ const navLinks = Array.from(document.querySelectorAll('a.top')).map(a => a.textContent?.trim());
36
+ if (navLinks.length > 1 && navLinks[0] === '首页') {
37
+ username = navLinks[1] || 'Unknown';
38
+ }
39
+
40
+ if (username === 'Unknown') {
41
+ // Fallback check just in case
42
+ const profileEl = document.querySelector('a[href^="/member/"]');
43
+ if (profileEl && profileEl.textContent && profileEl.textContent.trim().length > 0) {
44
+ username = profileEl.textContent.trim();
45
+ }
46
+ }
47
+
48
+ let balance = '0';
49
+ const balanceLink = document.querySelector('a.balance_area');
50
+ if (balanceLink) {
51
+ balance = Array.from(balanceLink.childNodes)
52
+ .filter(n => n.nodeType === 3)
53
+ .map(n => n.textContent?.trim())
54
+ .join(' ')
55
+ .trim();
56
+ }
57
+
58
+ let unread_notifications = '0';
59
+ const notesEl = document.querySelector('a[href="/notifications"]');
60
+ if (notesEl) {
61
+ const text = notesEl.textContent?.trim() || '';
62
+ const match = text.match(/(\\d+)\\s*未读提醒/);
63
+ if (match) {
64
+ unread_notifications = match[1];
65
+ }
66
+ }
67
+
68
+ let daily_reward_ready = false;
69
+ const dailyEl = document.querySelector('a[href^="/mission/daily"]');
70
+ if (dailyEl && dailyEl.textContent?.includes('领取今日的登录奖励')) {
71
+ daily_reward_ready = true;
72
+ }
73
+
74
+ if (username === 'Unknown') {
75
+ return {
76
+ error: '请先登录 V2EX(可能是 Cookie 未配置或已失效)',
77
+ debug_title: document.title,
78
+ debug_body: document.body.innerText.substring(0, 200).replace(/\\n/g, ' ')
79
+ };
80
+ }
81
+
82
+ return {
83
+ username,
84
+ balance,
85
+ unread_notifications,
86
+ daily_reward_ready: daily_reward_ready ? '是' : '否'
87
+ };
88
+ }
89
+ `);
90
+ if (data.error) {
91
+ if (process.env.OPENCLI_VERBOSE) {
92
+ console.error(`[opencli:v2ex:debug] Page Title: ${data.debug_title}`);
93
+ console.error(`[opencli:v2ex:debug] Page Body: ${data.debug_body}`);
94
+ }
95
+ throw new Error(data.error);
96
+ }
97
+ return [data];
98
+ },
99
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,72 @@
1
+ /**
2
+ * V2EX Notifications adapter.
3
+ */
4
+ import { cli, Strategy } from '../../registry.js';
5
+ cli({
6
+ site: 'v2ex',
7
+ name: 'notifications',
8
+ description: 'V2EX 获取提醒 (回复/由于)',
9
+ domain: 'www.v2ex.com',
10
+ strategy: Strategy.COOKIE,
11
+ browser: true,
12
+ forceExtension: true,
13
+ args: [
14
+ { name: 'limit', type: 'int', default: 20, help: 'Number of notifications' }
15
+ ],
16
+ columns: ['type', 'content', 'time'],
17
+ func: async (page, kwargs) => {
18
+ if (!page)
19
+ throw new Error('Browser page required');
20
+ if (process.env.OPENCLI_VERBOSE) {
21
+ console.error('[opencli:v2ex] Navigating to /notifications');
22
+ }
23
+ await page.goto('https://www.v2ex.com/notifications');
24
+ await new Promise(r => setTimeout(r, 1500)); // waitForLoadState doesn't always work robustly
25
+ // Evaluate DOM to extract notifications
26
+ const data = await page.evaluate(`
27
+ async () => {
28
+ const items = Array.from(document.querySelectorAll('#Main .box .cell[id^="n_"]'));
29
+ return items.map(item => {
30
+ let type = '通知';
31
+ let time = '';
32
+
33
+ // determine type based on text content
34
+ const text = item.textContent || '';
35
+ if (text.includes('回复了你')) type = '回复';
36
+ else if (text.includes('感谢了你')) type = '感谢';
37
+ else if (text.includes('收藏了你')) type = '收藏';
38
+ else if (text.includes('提及你')) type = '提及';
39
+
40
+ const timeEl = item.querySelector('.snow');
41
+ if (timeEl) {
42
+ time = timeEl.textContent?.trim() || '';
43
+ }
44
+
45
+ // payload contains the actual reply text if any
46
+ let payload = '';
47
+ const payloadEl = item.querySelector('.payload');
48
+ if (payloadEl) {
49
+ payload = payloadEl.textContent?.trim() || '';
50
+ }
51
+
52
+ // fallback to full text cleaning if no payload (e.g. for favorites/thanks)
53
+ let content = payload;
54
+ if (!content) {
55
+ content = text.replace(/\\s+/g, ' ').trim();
56
+ // strip out time from content if present
57
+ if (time && content.includes(time)) {
58
+ content = content.replace(time, '').trim();
59
+ }
60
+ }
61
+
62
+ return { type, content, time };
63
+ });
64
+ }
65
+ `);
66
+ if (!Array.isArray(data)) {
67
+ throw new Error('Failed to parse notifications data');
68
+ }
69
+ const limit = kwargs.limit || 20;
70
+ return data.slice(0, limit);
71
+ },
72
+ });
@@ -1,5 +1,8 @@
1
1
  /**
2
- * Xiaohongshu search — trigger search via Pinia store + XHR interception.
3
- * Inspired by bb-sites/xiaohongshu/search.js but adapted for opencli pipeline.
2
+ * Xiaohongshu search — DOM-based extraction from search results page.
3
+ * The previous Pinia store + XHR interception approach broke because
4
+ * the API now returns empty items. This version navigates directly to
5
+ * the search results page and extracts data from rendered DOM elements.
6
+ * Ref: https://github.com/jackwener/opencli/issues/10
4
7
  */
5
8
  export {};
@@ -1,6 +1,9 @@
1
1
  /**
2
- * Xiaohongshu search — trigger search via Pinia store + XHR interception.
3
- * Inspired by bb-sites/xiaohongshu/search.js but adapted for opencli pipeline.
2
+ * Xiaohongshu search — DOM-based extraction from search results page.
3
+ * The previous Pinia store + XHR interception approach broke because
4
+ * the API now returns empty items. This version navigates directly to
5
+ * the search results page and extracts data from rendered DOM elements.
6
+ * Ref: https://github.com/jackwener/opencli/issues/10
4
7
  */
5
8
  import { cli, Strategy } from '../../registry.js';
6
9
  cli({
@@ -13,54 +16,45 @@ cli({
13
16
  { name: 'keyword', required: true, help: 'Search keyword' },
14
17
  { name: 'limit', type: 'int', default: 20, help: 'Number of results' },
15
18
  ],
16
- columns: ['rank', 'title', 'author', 'likes', 'type'],
19
+ columns: ['rank', 'title', 'author', 'likes'],
17
20
  func: async (page, kwargs) => {
18
- await page.goto('https://www.xiaohongshu.com');
19
- await page.wait(2);
21
+ const keyword = encodeURIComponent(kwargs.keyword);
22
+ await page.goto(`https://www.xiaohongshu.com/search_result?keyword=${keyword}&source=web_search_result_notes`);
23
+ await page.wait(3);
24
+ // Scroll a couple of times to load more results
25
+ await page.autoScroll({ times: 2 });
20
26
  const data = await page.evaluate(`
21
- (async () => {
22
- const app = document.querySelector('#app')?.__vue_app__;
23
- const pinia = app?.config?.globalProperties?.$pinia;
24
- if (!pinia?._s) return {error: 'Page not ready'};
27
+ (() => {
28
+ const notes = document.querySelectorAll('section.note-item');
29
+ const results = [];
30
+ notes.forEach(el => {
31
+ // Skip "related searches" sections
32
+ if (el.classList.contains('query-note-item')) return;
25
33
 
26
- const searchStore = pinia._s.get('search');
27
- if (!searchStore) return {error: 'Search store not found'};
34
+ const titleEl = el.querySelector('.title, .note-title, a.title');
35
+ const nameEl = el.querySelector('.name, .author-name, .nick-name');
36
+ const likesEl = el.querySelector('.count, .like-count, .like-wrapper .count');
37
+ const linkEl = el.querySelector('a[href*="/explore/"], a[href*="/search_result/"], a[href*="/note/"]');
28
38
 
29
- let captured = null;
30
- const origOpen = XMLHttpRequest.prototype.open;
31
- const origSend = XMLHttpRequest.prototype.send;
32
- XMLHttpRequest.prototype.open = function(m, u) { this.__url = u; return origOpen.apply(this, arguments); };
33
- XMLHttpRequest.prototype.send = function(b) {
34
- if (this.__url?.includes('search/notes')) {
35
- const x = this;
36
- const orig = x.onreadystatechange;
37
- x.onreadystatechange = function() { if (x.readyState === 4 && !captured) { try { captured = JSON.parse(x.responseText); } catch {} } if (orig) orig.apply(this, arguments); };
38
- }
39
- return origSend.apply(this, arguments);
40
- };
39
+ const href = linkEl?.getAttribute('href') || '';
40
+ const noteId = href.match(/\\/(?:explore|note)\\/([a-f0-9]+)/)?.[1] || '';
41
41
 
42
- try {
43
- searchStore.mutateSearchValue('${kwargs.keyword}');
44
- await searchStore.loadMore();
45
- await new Promise(r => setTimeout(r, 800));
46
- } finally {
47
- XMLHttpRequest.prototype.open = origOpen;
48
- XMLHttpRequest.prototype.send = origSend;
49
- }
50
-
51
- if (!captured?.success) return {error: captured?.msg || 'Search failed'};
52
- return (captured.data?.items || []).map(i => ({
53
- title: i.note_card?.display_title || '',
54
- type: i.note_card?.type || '',
55
- url: 'https://www.xiaohongshu.com/explore/' + i.id,
56
- author: i.note_card?.user?.nickname || '',
57
- likes: i.note_card?.interact_info?.liked_count || '0',
58
- }));
42
+ results.push({
43
+ title: (titleEl?.textContent || '').trim(),
44
+ author: (nameEl?.textContent || '').trim(),
45
+ likes: (likesEl?.textContent || '0').trim(),
46
+ url: noteId ? 'https://www.xiaohongshu.com/explore/' + noteId : '',
47
+ });
48
+ });
49
+ return results;
59
50
  })()
60
51
  `);
61
52
  if (!Array.isArray(data))
62
53
  return [];
63
- return data.slice(0, kwargs.limit).map((item, i) => ({
54
+ return data
55
+ .filter((item) => item.title)
56
+ .slice(0, kwargs.limit)
57
+ .map((item, i) => ({
64
58
  rank: i + 1,
65
59
  ...item,
66
60
  }));