@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.
- package/CLI-CREATOR.md +103 -142
- package/LICENSE +28 -0
- package/README.md +113 -63
- package/README.zh-CN.md +114 -63
- package/SKILL.md +21 -4
- package/dist/browser.d.ts +21 -2
- package/dist/browser.js +269 -15
- package/dist/browser.test.d.ts +1 -0
- package/dist/browser.test.js +43 -0
- package/dist/build-manifest.js +66 -2
- package/dist/cli-manifest.json +905 -109
- package/dist/clis/boss/search.js +186 -30
- package/dist/clis/twitter/delete.d.ts +1 -0
- package/dist/clis/twitter/delete.js +73 -0
- package/dist/clis/twitter/followers.d.ts +1 -0
- package/dist/clis/twitter/followers.js +104 -0
- package/dist/clis/twitter/following.d.ts +1 -0
- package/dist/clis/twitter/following.js +90 -0
- package/dist/clis/twitter/like.d.ts +1 -0
- package/dist/clis/twitter/like.js +69 -0
- package/dist/clis/twitter/notifications.d.ts +1 -0
- package/dist/clis/twitter/notifications.js +109 -0
- package/dist/clis/twitter/post.d.ts +1 -0
- package/dist/clis/twitter/post.js +63 -0
- package/dist/clis/twitter/reply.d.ts +1 -0
- package/dist/clis/twitter/reply.js +57 -0
- package/dist/clis/v2ex/daily.d.ts +1 -0
- package/dist/clis/v2ex/daily.js +98 -0
- package/dist/clis/v2ex/me.d.ts +1 -0
- package/dist/clis/v2ex/me.js +99 -0
- package/dist/clis/v2ex/notifications.d.ts +1 -0
- package/dist/clis/v2ex/notifications.js +72 -0
- package/dist/clis/xiaohongshu/search.d.ts +5 -2
- package/dist/clis/xiaohongshu/search.js +35 -41
- package/dist/doctor.d.ts +50 -0
- package/dist/doctor.js +372 -0
- package/dist/doctor.test.d.ts +1 -0
- package/dist/doctor.test.js +114 -0
- package/dist/main.js +47 -5
- package/dist/output.test.d.ts +1 -0
- package/dist/output.test.js +20 -0
- package/dist/registry.d.ts +4 -0
- package/dist/registry.js +1 -0
- package/dist/runtime.d.ts +3 -1
- package/dist/runtime.js +2 -2
- package/package.json +2 -2
- package/src/browser.test.ts +51 -0
- package/src/browser.ts +318 -22
- package/src/build-manifest.ts +67 -2
- package/src/clis/boss/search.ts +196 -29
- package/src/clis/twitter/delete.ts +78 -0
- package/src/clis/twitter/followers.ts +119 -0
- package/src/clis/twitter/following.ts +105 -0
- package/src/clis/twitter/like.ts +74 -0
- package/src/clis/twitter/notifications.ts +119 -0
- package/src/clis/twitter/post.ts +68 -0
- package/src/clis/twitter/reply.ts +62 -0
- package/src/clis/v2ex/daily.ts +105 -0
- package/src/clis/v2ex/me.ts +103 -0
- package/src/clis/v2ex/notifications.ts +77 -0
- package/src/clis/xiaohongshu/search.ts +41 -44
- package/src/doctor.test.ts +133 -0
- package/src/doctor.ts +424 -0
- package/src/main.ts +47 -4
- package/src/output.test.ts +27 -0
- package/src/registry.ts +5 -0
- 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 —
|
|
3
|
-
*
|
|
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 —
|
|
3
|
-
*
|
|
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'
|
|
19
|
+
columns: ['rank', 'title', 'author', 'likes'],
|
|
17
20
|
func: async (page, kwargs) => {
|
|
18
|
-
|
|
19
|
-
await page.
|
|
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
|
-
(
|
|
22
|
-
const
|
|
23
|
-
const
|
|
24
|
-
|
|
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
|
-
|
|
27
|
-
|
|
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
|
-
|
|
30
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
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
|
}));
|