@jackwener/opencli 0.9.8 → 1.0.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/CDP.md +1 -1
- package/CDP.zh-CN.md +1 -1
- package/CLI-ELECTRON.md +2 -2
- package/CLI-EXPLORER.md +4 -4
- package/README.md +35 -58
- package/README.zh-CN.md +36 -60
- package/SKILL.md +10 -8
- package/TESTING.md +7 -7
- package/dist/browser/daemon-client.d.ts +37 -0
- package/dist/browser/daemon-client.js +82 -0
- package/dist/browser/discover.d.ts +11 -34
- package/dist/browser/discover.js +15 -205
- package/dist/browser/errors.d.ts +6 -20
- package/dist/browser/errors.js +24 -63
- package/dist/browser/index.d.ts +2 -12
- package/dist/browser/index.js +2 -12
- package/dist/browser/mcp.d.ts +9 -21
- package/dist/browser/mcp.js +70 -285
- package/dist/browser/page.d.ts +36 -7
- package/dist/browser/page.js +212 -81
- package/dist/browser.test.js +10 -231
- package/dist/cli-manifest.json +561 -14
- package/dist/clis/apple-podcasts/episodes.d.ts +1 -0
- package/dist/clis/apple-podcasts/episodes.js +28 -0
- package/dist/clis/apple-podcasts/search.d.ts +1 -0
- package/dist/clis/apple-podcasts/search.js +29 -0
- package/dist/clis/apple-podcasts/top.d.ts +1 -0
- package/dist/clis/apple-podcasts/top.js +34 -0
- package/dist/clis/apple-podcasts/utils.d.ts +11 -0
- package/dist/clis/apple-podcasts/utils.js +30 -0
- package/dist/clis/apple-podcasts/utils.test.d.ts +1 -0
- package/dist/clis/apple-podcasts/utils.test.js +57 -0
- package/dist/clis/chatwise/history.js +18 -1
- package/dist/clis/discord-app/channels.js +33 -21
- package/dist/clis/neteasemusic/like.d.ts +1 -0
- package/dist/clis/neteasemusic/like.js +25 -0
- package/dist/clis/neteasemusic/lyrics.d.ts +1 -0
- package/dist/clis/neteasemusic/lyrics.js +47 -0
- package/dist/clis/neteasemusic/next.d.ts +1 -0
- package/dist/clis/neteasemusic/next.js +26 -0
- package/dist/clis/neteasemusic/play.d.ts +1 -0
- package/dist/clis/neteasemusic/play.js +26 -0
- package/dist/clis/neteasemusic/playing.d.ts +1 -0
- package/dist/clis/neteasemusic/playing.js +59 -0
- package/dist/clis/neteasemusic/playlist.d.ts +1 -0
- package/dist/clis/neteasemusic/playlist.js +46 -0
- package/dist/clis/neteasemusic/prev.d.ts +1 -0
- package/dist/clis/neteasemusic/prev.js +25 -0
- package/dist/clis/neteasemusic/search.d.ts +1 -0
- package/dist/clis/neteasemusic/search.js +52 -0
- package/dist/clis/neteasemusic/status.d.ts +1 -0
- package/dist/clis/neteasemusic/status.js +16 -0
- package/dist/clis/neteasemusic/volume.d.ts +1 -0
- package/dist/clis/neteasemusic/volume.js +54 -0
- package/dist/clis/twitter/accept.d.ts +1 -0
- package/dist/clis/twitter/accept.js +202 -0
- package/dist/clis/twitter/followers.js +30 -22
- package/dist/clis/twitter/following.js +19 -14
- package/dist/clis/twitter/notifications.js +29 -22
- package/dist/clis/twitter/reply-dm.d.ts +1 -0
- package/dist/clis/twitter/reply-dm.js +181 -0
- package/dist/clis/twitter/search.js +50 -12
- package/dist/clis/weread/book.d.ts +1 -0
- package/dist/clis/weread/book.js +26 -0
- package/dist/clis/weread/highlights.d.ts +1 -0
- package/dist/clis/weread/highlights.js +23 -0
- package/dist/clis/weread/notebooks.d.ts +1 -0
- package/dist/clis/weread/notebooks.js +21 -0
- package/dist/clis/weread/notes.d.ts +1 -0
- package/dist/clis/weread/notes.js +29 -0
- package/dist/clis/weread/ranking.d.ts +1 -0
- package/dist/clis/weread/ranking.js +28 -0
- package/dist/clis/weread/search.d.ts +1 -0
- package/dist/clis/weread/search.js +25 -0
- package/dist/clis/weread/shelf.d.ts +1 -0
- package/dist/clis/weread/shelf.js +24 -0
- package/dist/clis/weread/utils.d.ts +20 -0
- package/dist/clis/weread/utils.js +72 -0
- package/dist/clis/weread/utils.test.d.ts +1 -0
- package/dist/clis/weread/utils.test.js +85 -0
- package/dist/daemon.d.ts +13 -0
- package/dist/daemon.js +187 -0
- package/dist/doctor.d.ts +10 -65
- package/dist/doctor.js +49 -602
- package/dist/doctor.test.js +30 -170
- package/dist/main.js +12 -41
- package/dist/pipeline/executor.test.js +1 -0
- package/dist/pipeline/steps/browser.js +2 -2
- package/dist/pipeline/steps/intercept.js +1 -2
- package/dist/runtime.d.ts +1 -4
- package/dist/runtime.js +1 -4
- package/dist/setup.d.ts +6 -0
- package/dist/setup.js +46 -160
- package/dist/types.d.ts +6 -0
- package/extension/dist/background.js +484 -0
- package/extension/icons/icon-128.png +0 -0
- package/extension/icons/icon-16.png +0 -0
- package/extension/icons/icon-32.png +0 -0
- package/extension/icons/icon-48.png +0 -0
- package/extension/manifest.json +31 -0
- package/extension/package.json +16 -0
- package/extension/src/background.ts +370 -0
- package/extension/src/cdp.ts +125 -0
- package/extension/src/protocol.ts +57 -0
- package/extension/store-assets/screenshot-1280x800.png +0 -0
- package/extension/tsconfig.json +15 -0
- package/extension/vite.config.ts +18 -0
- package/package.json +5 -5
- package/src/browser/daemon-client.ts +113 -0
- package/src/browser/discover.ts +18 -232
- package/src/browser/errors.ts +30 -100
- package/src/browser/index.ts +2 -13
- package/src/browser/mcp.ts +81 -282
- package/src/browser/page.ts +223 -83
- package/src/browser.test.ts +9 -239
- package/src/clis/apple-podcasts/episodes.ts +28 -0
- package/src/clis/apple-podcasts/search.ts +29 -0
- package/src/clis/apple-podcasts/top.ts +34 -0
- package/src/clis/apple-podcasts/utils.test.ts +72 -0
- package/src/clis/apple-podcasts/utils.ts +37 -0
- package/src/clis/chatgpt/README.md +1 -1
- package/src/clis/chatgpt/README.zh-CN.md +1 -1
- package/src/clis/chatwise/history.ts +15 -1
- package/src/clis/discord-app/channels.ts +33 -21
- package/src/clis/neteasemusic/README.md +31 -0
- package/src/clis/neteasemusic/README.zh-CN.md +31 -0
- package/src/clis/neteasemusic/like.ts +28 -0
- package/src/clis/neteasemusic/lyrics.ts +53 -0
- package/src/clis/neteasemusic/next.ts +30 -0
- package/src/clis/neteasemusic/play.ts +30 -0
- package/src/clis/neteasemusic/playing.ts +62 -0
- package/src/clis/neteasemusic/playlist.ts +51 -0
- package/src/clis/neteasemusic/prev.ts +29 -0
- package/src/clis/neteasemusic/search.ts +58 -0
- package/src/clis/neteasemusic/status.ts +18 -0
- package/src/clis/neteasemusic/volume.ts +61 -0
- package/src/clis/twitter/accept.ts +213 -0
- package/src/clis/twitter/followers.ts +36 -29
- package/src/clis/twitter/following.ts +25 -20
- package/src/clis/twitter/notifications.ts +34 -27
- package/src/clis/twitter/reply-dm.ts +193 -0
- package/src/clis/twitter/search.ts +53 -13
- package/src/clis/weread/book.ts +28 -0
- package/src/clis/weread/highlights.ts +25 -0
- package/src/clis/weread/notebooks.ts +23 -0
- package/src/clis/weread/notes.ts +31 -0
- package/src/clis/weread/ranking.ts +29 -0
- package/src/clis/weread/search.ts +26 -0
- package/src/clis/weread/shelf.ts +26 -0
- package/src/clis/weread/utils.test.ts +104 -0
- package/src/clis/weread/utils.ts +74 -0
- package/src/daemon.ts +217 -0
- package/src/doctor.test.ts +32 -193
- package/src/doctor.ts +58 -669
- package/src/main.ts +11 -34
- package/src/pipeline/executor.test.ts +1 -0
- package/src/pipeline/steps/browser.ts +2 -2
- package/src/pipeline/steps/intercept.ts +1 -2
- package/src/runtime.ts +2 -6
- package/src/setup.ts +47 -183
- package/src/types.ts +1 -0
- package/tests/e2e/public-commands.test.ts +68 -1
- package/dist/clis/grok/debug.d.ts +0 -1
- package/dist/clis/grok/debug.js +0 -45
- package/src/clis/grok/debug.ts +0 -49
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { cli, Strategy } from '../../registry.js';
|
|
2
|
+
import type { IPage } from '../../types.js';
|
|
3
|
+
|
|
4
|
+
export const searchCommand = cli({
|
|
5
|
+
site: 'neteasemusic',
|
|
6
|
+
name: 'search',
|
|
7
|
+
description: 'Search for songs, artists, albums, or playlists',
|
|
8
|
+
domain: 'localhost',
|
|
9
|
+
strategy: Strategy.UI,
|
|
10
|
+
browser: true,
|
|
11
|
+
args: [{ name: 'query', required: true, positional: true, help: 'Search query' }],
|
|
12
|
+
columns: ['Index', 'Title', 'Artist'],
|
|
13
|
+
func: async (page: IPage, kwargs: any) => {
|
|
14
|
+
const query = kwargs.query as string;
|
|
15
|
+
|
|
16
|
+
// Focus and fill the search box
|
|
17
|
+
await page.evaluate(`
|
|
18
|
+
(function(q) {
|
|
19
|
+
const input = document.querySelector('.m-search input, #srch, [class*="search"] input, input[type="search"]');
|
|
20
|
+
if (!input) throw new Error('Search input not found');
|
|
21
|
+
input.focus();
|
|
22
|
+
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
|
|
23
|
+
setter.call(input, q);
|
|
24
|
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
25
|
+
})(${JSON.stringify(query)})
|
|
26
|
+
`);
|
|
27
|
+
|
|
28
|
+
await page.pressKey('Enter');
|
|
29
|
+
await page.wait(2);
|
|
30
|
+
|
|
31
|
+
// Scrape results
|
|
32
|
+
const results = await page.evaluate(`
|
|
33
|
+
(function() {
|
|
34
|
+
const items = [];
|
|
35
|
+
// Song list items in search results
|
|
36
|
+
const rows = document.querySelectorAll('.srchsongst li, .m-table tbody tr, [class*="songlist"] [class*="item"], table tbody tr');
|
|
37
|
+
|
|
38
|
+
rows.forEach((row, i) => {
|
|
39
|
+
if (i >= 20) return;
|
|
40
|
+
const nameEl = row.querySelector('.sn, .name a, [class*="songName"], td:nth-child(2) a, b[title]');
|
|
41
|
+
const artistEl = row.querySelector('.ar, .artist, [class*="artist"], td:nth-child(4) a, td:nth-child(3) a');
|
|
42
|
+
|
|
43
|
+
const title = nameEl ? (nameEl.getAttribute('title') || nameEl.textContent || '').trim() : '';
|
|
44
|
+
const artist = artistEl ? (artistEl.getAttribute('title') || artistEl.textContent || '').trim() : '';
|
|
45
|
+
|
|
46
|
+
if (title) items.push({ Index: i + 1, Title: title, Artist: artist });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
return items;
|
|
50
|
+
})()
|
|
51
|
+
`);
|
|
52
|
+
|
|
53
|
+
if (results.length === 0) {
|
|
54
|
+
return [{ Index: 0, Title: `No results for "${query}"`, Artist: '—' }];
|
|
55
|
+
}
|
|
56
|
+
return results;
|
|
57
|
+
},
|
|
58
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { cli, Strategy } from '../../registry.js';
|
|
2
|
+
import type { IPage } from '../../types.js';
|
|
3
|
+
|
|
4
|
+
export const statusCommand = cli({
|
|
5
|
+
site: 'neteasemusic',
|
|
6
|
+
name: 'status',
|
|
7
|
+
description: 'Check CDP connection to NeteaseMusic Desktop',
|
|
8
|
+
domain: 'localhost',
|
|
9
|
+
strategy: Strategy.UI,
|
|
10
|
+
browser: true,
|
|
11
|
+
args: [],
|
|
12
|
+
columns: ['Status', 'Url', 'Title'],
|
|
13
|
+
func: async (page: IPage) => {
|
|
14
|
+
const url = await page.evaluate('window.location.href');
|
|
15
|
+
const title = await page.evaluate('document.title');
|
|
16
|
+
return [{ Status: 'Connected', Url: url, Title: title }];
|
|
17
|
+
},
|
|
18
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { cli, Strategy } from '../../registry.js';
|
|
2
|
+
import type { IPage } from '../../types.js';
|
|
3
|
+
|
|
4
|
+
export const volumeCommand = cli({
|
|
5
|
+
site: 'neteasemusic',
|
|
6
|
+
name: 'volume',
|
|
7
|
+
description: 'Get or set the volume level (0-100)',
|
|
8
|
+
domain: 'localhost',
|
|
9
|
+
strategy: Strategy.UI,
|
|
10
|
+
browser: true,
|
|
11
|
+
args: [
|
|
12
|
+
{ name: 'level', required: false, positional: true, help: 'Volume level 0-100 (omit to read current)' },
|
|
13
|
+
],
|
|
14
|
+
columns: ['Status', 'Volume'],
|
|
15
|
+
func: async (page: IPage, kwargs: any) => {
|
|
16
|
+
const level = kwargs.level as string | undefined;
|
|
17
|
+
|
|
18
|
+
if (!level) {
|
|
19
|
+
// Read current volume
|
|
20
|
+
const vol = await page.evaluate(`
|
|
21
|
+
(function() {
|
|
22
|
+
const bar = document.querySelector('.m-playbar .vol .barbg .rng, [class*="volume"] [class*="progress"], [class*="volume"] [class*="played"]');
|
|
23
|
+
if (bar) {
|
|
24
|
+
const style = bar.getAttribute('style') || '';
|
|
25
|
+
const match = style.match(/width:\\s*(\\d+\\.?\\d*)%/);
|
|
26
|
+
if (match) return match[1];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const vol = document.querySelector('.m-playbar .j-vol, [class*="volume-value"]');
|
|
30
|
+
if (vol) return vol.textContent.trim();
|
|
31
|
+
|
|
32
|
+
return 'Unknown';
|
|
33
|
+
})()
|
|
34
|
+
`);
|
|
35
|
+
|
|
36
|
+
return [{ Status: 'Current', Volume: vol + '%' }];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Set volume by clicking on the volume bar at the right position
|
|
40
|
+
const targetVol = Math.max(0, Math.min(100, parseInt(level, 10)));
|
|
41
|
+
|
|
42
|
+
await page.evaluate(`
|
|
43
|
+
(function(target) {
|
|
44
|
+
const bar = document.querySelector('.m-playbar .vol .barbg, [class*="volume-bar"], [class*="volume"] [class*="track"]');
|
|
45
|
+
if (!bar) return;
|
|
46
|
+
|
|
47
|
+
const rect = bar.getBoundingClientRect();
|
|
48
|
+
const x = rect.left + (rect.width * target / 100);
|
|
49
|
+
const y = rect.top + rect.height / 2;
|
|
50
|
+
|
|
51
|
+
bar.dispatchEvent(new MouseEvent('click', {
|
|
52
|
+
clientX: x,
|
|
53
|
+
clientY: y,
|
|
54
|
+
bubbles: true,
|
|
55
|
+
}));
|
|
56
|
+
})(${targetVol})
|
|
57
|
+
`);
|
|
58
|
+
|
|
59
|
+
return [{ Status: 'Set', Volume: targetVol + '%' }];
|
|
60
|
+
},
|
|
61
|
+
});
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { cli, Strategy } from '../../registry.js';
|
|
2
|
+
import type { IPage } from '../../types.js';
|
|
3
|
+
|
|
4
|
+
cli({
|
|
5
|
+
site: 'twitter',
|
|
6
|
+
name: 'accept',
|
|
7
|
+
description: 'Auto-accept DM requests containing specific keywords',
|
|
8
|
+
domain: 'x.com',
|
|
9
|
+
strategy: Strategy.UI,
|
|
10
|
+
browser: true,
|
|
11
|
+
timeoutSeconds: 600, // 10 min — batch operation iterating many conversations
|
|
12
|
+
args: [
|
|
13
|
+
{ name: 'keyword', type: 'string', required: true, help: 'Keywords to match (comma-separated for OR, e.g. "群,微信")' },
|
|
14
|
+
{ name: 'max', type: 'int', required: false, default: 20, help: 'Maximum number of requests to accept (default: 20)' },
|
|
15
|
+
],
|
|
16
|
+
columns: ['index', 'status', 'user', 'message'],
|
|
17
|
+
func: async (page: IPage | null, kwargs: any) => {
|
|
18
|
+
if (!page) throw new Error('Requires browser');
|
|
19
|
+
|
|
20
|
+
const keywords: string[] = kwargs.keyword.split(',').map((k: string) => k.trim()).filter(Boolean);
|
|
21
|
+
const maxAccepts: number = kwargs.max ?? 20;
|
|
22
|
+
const results: Array<{ index: number; status: string; user: string; message: string }> = [];
|
|
23
|
+
let acceptCount = 0;
|
|
24
|
+
// Track already-visited conversations to avoid infinite loops
|
|
25
|
+
const visited = new Set<string>();
|
|
26
|
+
|
|
27
|
+
for (let round = 0; round < maxAccepts + 50; round++) {
|
|
28
|
+
if (acceptCount >= maxAccepts) break;
|
|
29
|
+
|
|
30
|
+
// Step 1: Navigate to DM requests page
|
|
31
|
+
await page.goto('https://x.com/messages/requests');
|
|
32
|
+
await page.wait(4);
|
|
33
|
+
|
|
34
|
+
// Step 2: Get conversations with scroll-to-load
|
|
35
|
+
const convInfo = await page.evaluate(`(async () => {
|
|
36
|
+
try {
|
|
37
|
+
// Wait for initial items
|
|
38
|
+
let attempts = 0;
|
|
39
|
+
while (attempts < 10) {
|
|
40
|
+
const convs = document.querySelectorAll('[data-testid="conversation"]');
|
|
41
|
+
if (convs.length > 0) break;
|
|
42
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
43
|
+
attempts++;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Scroll to load more
|
|
47
|
+
const seenCount = new Set();
|
|
48
|
+
let noNewCount = 0;
|
|
49
|
+
for (let scroll = 0; scroll < 20; scroll++) {
|
|
50
|
+
const convs = Array.from(document.querySelectorAll('[data-testid="conversation"]'));
|
|
51
|
+
const prevSize = seenCount.size;
|
|
52
|
+
convs.forEach((_, i) => seenCount.add(i));
|
|
53
|
+
if (convs.length >= ${maxAccepts + 10}) break;
|
|
54
|
+
|
|
55
|
+
// Scroll last item into view
|
|
56
|
+
if (convs.length > 0) {
|
|
57
|
+
convs[convs.length - 1].scrollIntoView({ behavior: 'instant', block: 'end' });
|
|
58
|
+
}
|
|
59
|
+
await new Promise(r => setTimeout(r, 1500));
|
|
60
|
+
|
|
61
|
+
if (seenCount.size <= prevSize) {
|
|
62
|
+
noNewCount++;
|
|
63
|
+
if (noNewCount >= 3) break;
|
|
64
|
+
} else {
|
|
65
|
+
noNewCount = 0;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const convs = Array.from(document.querySelectorAll('[data-testid="conversation"]'));
|
|
70
|
+
if (convs.length === 0) return { ok: false, count: 0, items: [] };
|
|
71
|
+
|
|
72
|
+
const items = convs.map((conv, idx) => {
|
|
73
|
+
const text = conv.innerText || '';
|
|
74
|
+
const link = conv.querySelector('a[href]');
|
|
75
|
+
const href = link ? link.href : '';
|
|
76
|
+
const lines = text.split('\\n').filter(l => l.trim());
|
|
77
|
+
const user = lines[0] || 'Unknown';
|
|
78
|
+
return { idx, text, href, user };
|
|
79
|
+
});
|
|
80
|
+
return { ok: true, count: convs.length, items };
|
|
81
|
+
} catch(e) {
|
|
82
|
+
return { ok: false, error: String(e), count: 0, items: [] };
|
|
83
|
+
}
|
|
84
|
+
})()`);
|
|
85
|
+
|
|
86
|
+
if (!convInfo?.ok || convInfo.count === 0) {
|
|
87
|
+
if (results.length === 0) {
|
|
88
|
+
results.push({ index: 1, status: 'info', user: 'System', message: 'No message requests found' });
|
|
89
|
+
}
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let foundInThisRound = false;
|
|
94
|
+
|
|
95
|
+
// Step 3: Find first unvisited conversation with keyword match in preview
|
|
96
|
+
for (const item of convInfo.items) {
|
|
97
|
+
if (acceptCount >= maxAccepts) break;
|
|
98
|
+
const convKey = item.href || `conv-${item.idx}`;
|
|
99
|
+
if (visited.has(convKey)) continue;
|
|
100
|
+
visited.add(convKey);
|
|
101
|
+
|
|
102
|
+
// Check if preview text contains any keyword
|
|
103
|
+
const previewMatch = keywords.some((k: string) => item.text.includes(k));
|
|
104
|
+
if (!previewMatch) continue;
|
|
105
|
+
|
|
106
|
+
// Step 4: Click this conversation to open it
|
|
107
|
+
const clickResult = await page.evaluate(`(async () => {
|
|
108
|
+
try {
|
|
109
|
+
const convs = Array.from(document.querySelectorAll('[data-testid="conversation"]'));
|
|
110
|
+
const conv = convs[${item.idx}];
|
|
111
|
+
if (!conv) return { ok: false, error: 'Conversation element not found' };
|
|
112
|
+
conv.click();
|
|
113
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
114
|
+
return { ok: true };
|
|
115
|
+
} catch(e) {
|
|
116
|
+
return { ok: false, error: String(e) };
|
|
117
|
+
}
|
|
118
|
+
})()`);
|
|
119
|
+
|
|
120
|
+
if (!clickResult?.ok) continue;
|
|
121
|
+
|
|
122
|
+
// Wait for conversation to load
|
|
123
|
+
await page.wait(2);
|
|
124
|
+
|
|
125
|
+
// Step 5: Read full chat content and find Accept button
|
|
126
|
+
const res = await page.evaluate(`(async () => {
|
|
127
|
+
try {
|
|
128
|
+
const keywords = ${JSON.stringify(keywords)};
|
|
129
|
+
|
|
130
|
+
// Get username from conversation header
|
|
131
|
+
const heading = document.querySelector('[data-testid="conversation-header"]') ||
|
|
132
|
+
document.querySelector('[data-testid="DM-conversation-header"]');
|
|
133
|
+
let username = 'Unknown';
|
|
134
|
+
if (heading) {
|
|
135
|
+
username = heading.innerText.trim().split('\\n')[0];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Read full chat area text
|
|
139
|
+
const chatArea = document.querySelector('[data-testid="DmScrollerContainer"]') ||
|
|
140
|
+
document.querySelector('[data-testid="DMConversationBody"]') ||
|
|
141
|
+
document.querySelector('main [data-testid="cellInnerDiv"]')?.closest('section') ||
|
|
142
|
+
document.querySelector('main');
|
|
143
|
+
const text = chatArea ? chatArea.innerText : '';
|
|
144
|
+
|
|
145
|
+
// Verify keyword match in full chat content
|
|
146
|
+
const matchedKw = keywords.filter(k => text.includes(k));
|
|
147
|
+
if (matchedKw.length === 0) {
|
|
148
|
+
return { status: 'skipped', user: username, message: 'No keyword match in full content' };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Find the Accept button
|
|
152
|
+
const allBtns = Array.from(document.querySelectorAll('[role="button"]'));
|
|
153
|
+
const acceptBtn = allBtns.find(btn => {
|
|
154
|
+
const t = btn.innerText.trim().toLowerCase();
|
|
155
|
+
return t === 'accept' || t === '接受';
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
if (!acceptBtn) {
|
|
159
|
+
return { status: 'no_button', user: username, message: 'Keyword matched but no Accept button (already accepted?)' };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Click Accept
|
|
163
|
+
acceptBtn.click();
|
|
164
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
165
|
+
|
|
166
|
+
// Check for confirmation dialog
|
|
167
|
+
const btnsAfter = Array.from(document.querySelectorAll('[role="button"]'));
|
|
168
|
+
const confirmBtn = btnsAfter.find(btn => {
|
|
169
|
+
const t = btn.innerText.trim().toLowerCase();
|
|
170
|
+
return (t === 'accept' || t === '接受') && btn !== acceptBtn;
|
|
171
|
+
});
|
|
172
|
+
if (confirmBtn) {
|
|
173
|
+
confirmBtn.click();
|
|
174
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return { status: 'accepted', user: username, message: 'Accepted! Matched: ' + matchedKw.join(', ') };
|
|
178
|
+
} catch(e) {
|
|
179
|
+
return { status: 'error', user: 'system', message: String(e) };
|
|
180
|
+
}
|
|
181
|
+
})()`);
|
|
182
|
+
|
|
183
|
+
if (res?.status === 'accepted') {
|
|
184
|
+
acceptCount++;
|
|
185
|
+
foundInThisRound = true;
|
|
186
|
+
results.push({
|
|
187
|
+
index: acceptCount,
|
|
188
|
+
status: 'accepted',
|
|
189
|
+
user: res.user || 'Unknown',
|
|
190
|
+
message: res.message || 'Accepted',
|
|
191
|
+
});
|
|
192
|
+
// After accept, Twitter redirects to /messages — loop back to /messages/requests
|
|
193
|
+
await page.wait(2);
|
|
194
|
+
break; // break inner loop, outer loop will re-navigate to requests
|
|
195
|
+
} else if (res?.status === 'no_button') {
|
|
196
|
+
// Already accepted, skip
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// If no match found in this round, we've exhausted all visible requests
|
|
202
|
+
if (!foundInThisRound) {
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (results.length === 0) {
|
|
208
|
+
results.push({ index: 0, status: 'info', user: 'System', message: `No requests matched keywords "${keywords.join(', ')}"` });
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return results;
|
|
212
|
+
}
|
|
213
|
+
});
|
|
@@ -15,65 +15,73 @@ cli({
|
|
|
15
15
|
func: async (page, kwargs) => {
|
|
16
16
|
let targetUser = kwargs.user;
|
|
17
17
|
|
|
18
|
-
// If no user is specified,
|
|
18
|
+
// If no user is specified, figure out the logged-in user's handle
|
|
19
19
|
if (!targetUser) {
|
|
20
20
|
await page.goto('https://x.com/home');
|
|
21
|
-
// wait for home page navigation
|
|
22
21
|
await page.wait(5);
|
|
23
|
-
|
|
22
|
+
|
|
24
23
|
const href = await page.evaluate(`() => {
|
|
25
24
|
const link = document.querySelector('a[data-testid="AppTabBar_Profile_Link"]');
|
|
26
25
|
return link ? link.getAttribute('href') : null;
|
|
27
26
|
}`);
|
|
28
|
-
|
|
27
|
+
|
|
29
28
|
if (!href) {
|
|
30
29
|
throw new Error('Could not find logged-in user profile link. Are you logged in?');
|
|
31
30
|
}
|
|
32
31
|
targetUser = href.replace('/', '');
|
|
33
32
|
}
|
|
34
33
|
|
|
35
|
-
// 1. Navigate to
|
|
34
|
+
// 1. Navigate to profile page
|
|
36
35
|
await page.goto(`https://x.com/${targetUser}`);
|
|
37
36
|
await page.wait(3);
|
|
38
37
|
|
|
39
|
-
// 2.
|
|
38
|
+
// 2. Install interceptor BEFORE SPA navigation.
|
|
39
|
+
// goto() resets JS context, but SPA click preserves it.
|
|
40
40
|
await page.installInterceptor('Followers');
|
|
41
|
-
|
|
42
|
-
// 3. Click the followers link
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
41
|
+
|
|
42
|
+
// 3. Click the followers link via SPA navigation (preserves interceptor).
|
|
43
|
+
// Twitter uses /verified_followers instead of /followers now.
|
|
44
|
+
const safeUser = JSON.stringify(targetUser);
|
|
45
|
+
const clicked = await page.evaluate(`() => {
|
|
46
|
+
const target = ${safeUser};
|
|
47
|
+
const selectors = [
|
|
48
|
+
'a[href="/' + target + '/verified_followers"]',
|
|
49
|
+
'a[href="/' + target + '/followers"]',
|
|
50
|
+
];
|
|
51
|
+
for (const sel of selectors) {
|
|
52
|
+
const link = document.querySelector(sel);
|
|
53
|
+
if (link) { link.click(); return true; }
|
|
54
|
+
}
|
|
55
|
+
return false;
|
|
47
56
|
}`);
|
|
48
|
-
|
|
57
|
+
if (!clicked) {
|
|
58
|
+
throw new Error('Could not find followers link on profile page. Twitter may have changed the layout.');
|
|
59
|
+
}
|
|
60
|
+
await page.wait(5);
|
|
49
61
|
|
|
50
|
-
// 4.
|
|
62
|
+
// 4. Scroll to trigger pagination API calls
|
|
51
63
|
await page.autoScroll({ times: Math.ceil(kwargs.limit / 20), delayMs: 2000 });
|
|
52
64
|
|
|
53
|
-
//
|
|
54
|
-
const
|
|
55
|
-
const requestList = Array.isArray(
|
|
56
|
-
|
|
65
|
+
// 5. Retrieve intercepted data
|
|
66
|
+
const requests = await page.getInterceptedRequests();
|
|
67
|
+
const requestList = Array.isArray(requests) ? requests : [];
|
|
68
|
+
|
|
57
69
|
if (requestList.length === 0) {
|
|
58
70
|
return [];
|
|
59
71
|
}
|
|
60
|
-
|
|
61
|
-
const requests = requestList.filter((r: any) => r?.url?.includes('Followers'));
|
|
62
|
-
if (!requests || requests.length === 0) {
|
|
63
|
-
return [];
|
|
64
|
-
}
|
|
65
72
|
|
|
66
73
|
let results: any[] = [];
|
|
67
|
-
for (const req of
|
|
74
|
+
for (const req of requestList) {
|
|
68
75
|
try {
|
|
69
|
-
|
|
76
|
+
// GraphQL response: { data: { user: { result: { timeline: ... } } } }
|
|
77
|
+
let instructions = req.data?.user?.result?.timeline?.timeline?.instructions;
|
|
70
78
|
if (!instructions) continue;
|
|
71
79
|
|
|
72
80
|
let addEntries = instructions.find((i: any) => i.type === 'TimelineAddEntries');
|
|
73
81
|
if (!addEntries) {
|
|
74
82
|
addEntries = instructions.find((i: any) => i.entries && Array.isArray(i.entries));
|
|
75
83
|
}
|
|
76
|
-
|
|
84
|
+
|
|
77
85
|
if (!addEntries) continue;
|
|
78
86
|
|
|
79
87
|
for (const entry of addEntries.entries) {
|
|
@@ -82,10 +90,9 @@ cli({
|
|
|
82
90
|
const item = entry.content?.itemContent?.user_results?.result;
|
|
83
91
|
if (!item || item.__typename !== 'User') continue;
|
|
84
92
|
|
|
85
|
-
// Twitter GraphQL sometimes nests `core` differently depending on the endpoint profile state
|
|
86
93
|
const core = item.core || {};
|
|
87
94
|
const legacy = item.legacy || {};
|
|
88
|
-
|
|
95
|
+
|
|
89
96
|
results.push({
|
|
90
97
|
screen_name: core.screen_name || legacy.screen_name || 'unknown',
|
|
91
98
|
name: core.name || legacy.name || 'unknown',
|
|
@@ -98,7 +105,7 @@ cli({
|
|
|
98
105
|
}
|
|
99
106
|
}
|
|
100
107
|
|
|
101
|
-
// Deduplicate by screen_name
|
|
108
|
+
// Deduplicate by screen_name
|
|
102
109
|
const unique = new Map();
|
|
103
110
|
results.forEach(r => unique.set(r.screen_name, r));
|
|
104
111
|
const deduplicatedResults = Array.from(unique.values());
|
|
@@ -15,45 +15,50 @@ cli({
|
|
|
15
15
|
func: async (page, kwargs) => {
|
|
16
16
|
let targetUser = kwargs.user;
|
|
17
17
|
|
|
18
|
-
// If no user is specified,
|
|
18
|
+
// If no user is specified, figure out the logged-in user's handle
|
|
19
19
|
if (!targetUser) {
|
|
20
20
|
await page.goto('https://x.com/home');
|
|
21
|
-
// wait for home page navigation
|
|
22
21
|
await page.wait(5);
|
|
23
|
-
|
|
22
|
+
|
|
24
23
|
const href = await page.evaluate(`() => {
|
|
25
24
|
const link = document.querySelector('a[data-testid="AppTabBar_Profile_Link"]');
|
|
26
25
|
return link ? link.getAttribute('href') : null;
|
|
27
26
|
}`);
|
|
28
|
-
|
|
27
|
+
|
|
29
28
|
if (!href) {
|
|
30
29
|
throw new Error('Could not find logged-in user profile link. Are you logged in?');
|
|
31
30
|
}
|
|
32
31
|
targetUser = href.replace('/', '');
|
|
33
32
|
}
|
|
34
33
|
|
|
35
|
-
// 1. Navigate to
|
|
34
|
+
// 1. Navigate to profile page
|
|
36
35
|
await page.goto(`https://x.com/${targetUser}`);
|
|
37
36
|
await page.wait(3);
|
|
38
37
|
|
|
39
|
-
// 2.
|
|
38
|
+
// 2. Install interceptor BEFORE SPA navigation.
|
|
39
|
+
// goto() resets JS context, but SPA click preserves it.
|
|
40
40
|
await page.installInterceptor('Following');
|
|
41
|
-
|
|
42
|
-
// 3. Click the following link
|
|
43
|
-
|
|
44
|
-
|
|
41
|
+
|
|
42
|
+
// 3. Click the following link via SPA navigation (preserves interceptor)
|
|
43
|
+
const safeUser = JSON.stringify(targetUser);
|
|
44
|
+
const clicked = await page.evaluate(`() => {
|
|
45
|
+
const target = ${safeUser};
|
|
45
46
|
const link = document.querySelector('a[href="/' + target + '/following"]');
|
|
46
|
-
if (link) link.click();
|
|
47
|
+
if (link) { link.click(); return true; }
|
|
48
|
+
return false;
|
|
47
49
|
}`);
|
|
48
|
-
|
|
50
|
+
if (!clicked) {
|
|
51
|
+
throw new Error('Could not find following link on profile page. Twitter may have changed the layout.');
|
|
52
|
+
}
|
|
53
|
+
await page.wait(5);
|
|
49
54
|
|
|
50
|
-
// 4.
|
|
55
|
+
// 4. Scroll to trigger pagination API calls
|
|
51
56
|
await page.autoScroll({ times: Math.ceil(kwargs.limit / 20), delayMs: 2000 });
|
|
52
57
|
|
|
53
|
-
//
|
|
58
|
+
// 5. Retrieve intercepted data
|
|
54
59
|
const requests = await page.getInterceptedRequests();
|
|
55
60
|
const requestList = Array.isArray(requests) ? requests : [];
|
|
56
|
-
|
|
61
|
+
|
|
57
62
|
if (requestList.length === 0) {
|
|
58
63
|
return [];
|
|
59
64
|
}
|
|
@@ -61,14 +66,15 @@ cli({
|
|
|
61
66
|
let results: any[] = [];
|
|
62
67
|
for (const req of requestList) {
|
|
63
68
|
try {
|
|
64
|
-
|
|
69
|
+
// GraphQL response: { data: { user: { result: { timeline: ... } } } }
|
|
70
|
+
let instructions = req.data?.user?.result?.timeline?.timeline?.instructions;
|
|
65
71
|
if (!instructions) continue;
|
|
66
72
|
|
|
67
73
|
let addEntries = instructions.find((i: any) => i.type === 'TimelineAddEntries');
|
|
68
74
|
if (!addEntries) {
|
|
69
75
|
addEntries = instructions.find((i: any) => i.entries && Array.isArray(i.entries));
|
|
70
76
|
}
|
|
71
|
-
|
|
77
|
+
|
|
72
78
|
if (!addEntries) continue;
|
|
73
79
|
|
|
74
80
|
for (const entry of addEntries.entries) {
|
|
@@ -77,10 +83,9 @@ cli({
|
|
|
77
83
|
const item = entry.content?.itemContent?.user_results?.result;
|
|
78
84
|
if (!item || item.__typename !== 'User') continue;
|
|
79
85
|
|
|
80
|
-
// Twitter GraphQL sometimes nests `core` differently depending on the endpoint profile state
|
|
81
86
|
const core = item.core || {};
|
|
82
87
|
const legacy = item.legacy || {};
|
|
83
|
-
|
|
88
|
+
|
|
84
89
|
results.push({
|
|
85
90
|
screen_name: core.screen_name || legacy.screen_name || 'unknown',
|
|
86
91
|
name: core.name || legacy.name || 'unknown',
|
|
@@ -93,7 +98,7 @@ cli({
|
|
|
93
98
|
}
|
|
94
99
|
}
|
|
95
100
|
|
|
96
|
-
// Deduplicate by screen_name
|
|
101
|
+
// Deduplicate by screen_name
|
|
97
102
|
const unique = new Map();
|
|
98
103
|
results.forEach(r => unique.set(r.screen_name, r));
|
|
99
104
|
const deduplicatedResults = Array.from(unique.values());
|