@jackwener/opencli 0.9.6 → 0.9.8
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/.github/ISSUE_TEMPLATE/bug_report.yml +83 -0
- package/.github/ISSUE_TEMPLATE/config.yml +8 -0
- package/.github/ISSUE_TEMPLATE/feature_request.yml +42 -0
- package/.github/ISSUE_TEMPLATE/new_site_adapter.yml +57 -0
- package/.github/dependabot.yml +27 -0
- package/.github/pull_request_template.md +24 -0
- package/.github/workflows/ci.yml +14 -8
- package/.github/workflows/e2e-headed.yml +6 -2
- package/.github/workflows/pkg-pr-new.yml +2 -2
- package/.github/workflows/release-please.yml +25 -0
- package/.github/workflows/release.yml +2 -2
- package/.github/workflows/security.yml +36 -0
- package/CLI-ELECTRON.md +89 -36
- package/CONTRIBUTING.md +167 -0
- package/README.md +98 -32
- package/README.zh-CN.md +99 -33
- package/dist/browser/discover.js +22 -7
- package/dist/browser.test.js +23 -0
- package/dist/build-manifest.d.ts +26 -0
- package/dist/build-manifest.js +132 -60
- package/dist/build-manifest.test.d.ts +1 -0
- package/dist/build-manifest.test.js +26 -0
- package/dist/cli-manifest.json +1415 -29
- package/dist/clis/bilibili/download.d.ts +10 -0
- package/dist/clis/bilibili/download.js +135 -0
- package/dist/clis/chatwise/ask.d.ts +1 -0
- package/dist/clis/chatwise/ask.js +76 -0
- package/dist/clis/chatwise/export.d.ts +1 -0
- package/dist/clis/chatwise/export.js +46 -0
- package/dist/clis/chatwise/history.d.ts +1 -0
- package/dist/clis/chatwise/history.js +43 -0
- package/dist/clis/chatwise/model.d.ts +1 -0
- package/dist/clis/chatwise/model.js +81 -0
- package/dist/clis/chatwise/new.d.ts +1 -0
- package/dist/clis/chatwise/new.js +18 -0
- package/dist/clis/chatwise/read.d.ts +1 -0
- package/dist/clis/chatwise/read.js +39 -0
- package/dist/clis/chatwise/screenshot.d.ts +1 -0
- package/dist/clis/chatwise/screenshot.js +27 -0
- package/dist/clis/chatwise/send.d.ts +1 -0
- package/dist/clis/chatwise/send.js +45 -0
- package/dist/clis/chatwise/status.d.ts +1 -0
- package/dist/clis/chatwise/status.js +22 -0
- package/dist/clis/discord-app/channels.d.ts +1 -0
- package/dist/clis/discord-app/channels.js +45 -0
- package/dist/clis/discord-app/members.d.ts +1 -0
- package/dist/clis/discord-app/members.js +38 -0
- package/dist/clis/discord-app/read.d.ts +1 -0
- package/dist/clis/discord-app/read.js +45 -0
- package/dist/clis/discord-app/search.d.ts +1 -0
- package/dist/clis/discord-app/search.js +56 -0
- package/dist/clis/discord-app/send.d.ts +1 -0
- package/dist/clis/discord-app/send.js +27 -0
- package/dist/clis/discord-app/servers.d.ts +1 -0
- package/dist/clis/discord-app/servers.js +36 -0
- package/dist/clis/discord-app/status.d.ts +1 -0
- package/dist/clis/discord-app/status.js +16 -0
- package/dist/clis/feishu/new.d.ts +1 -0
- package/dist/clis/feishu/new.js +27 -0
- package/dist/clis/feishu/read.d.ts +1 -0
- package/dist/clis/feishu/read.js +40 -0
- package/dist/clis/feishu/search.d.ts +1 -0
- package/dist/clis/feishu/search.js +30 -0
- package/dist/clis/feishu/send.d.ts +1 -0
- package/dist/clis/feishu/send.js +39 -0
- package/dist/clis/feishu/status.d.ts +1 -0
- package/dist/clis/feishu/status.js +28 -0
- package/dist/clis/grok/ask.d.ts +1 -0
- package/dist/clis/grok/ask.js +82 -0
- package/dist/clis/grok/debug.d.ts +1 -0
- package/dist/clis/grok/debug.js +45 -0
- package/dist/clis/jimeng/generate.yaml +84 -0
- package/dist/clis/jimeng/history.yaml +47 -0
- package/dist/clis/linux-do/categories.yaml +41 -0
- package/dist/clis/linux-do/category.yaml +49 -0
- package/dist/clis/linux-do/hot.yaml +50 -0
- package/dist/clis/linux-do/latest.yaml +40 -0
- package/dist/clis/linux-do/search.yaml +45 -0
- package/dist/clis/linux-do/topic.yaml +38 -0
- package/dist/clis/notion/export.d.ts +1 -0
- package/dist/clis/notion/export.js +31 -0
- package/dist/clis/notion/favorites.d.ts +1 -0
- package/dist/clis/notion/favorites.js +84 -0
- package/dist/clis/notion/new.d.ts +1 -0
- package/dist/clis/notion/new.js +34 -0
- package/dist/clis/notion/read.d.ts +1 -0
- package/dist/clis/notion/read.js +30 -0
- package/dist/clis/notion/search.d.ts +1 -0
- package/dist/clis/notion/search.js +46 -0
- package/dist/clis/notion/sidebar.d.ts +1 -0
- package/dist/clis/notion/sidebar.js +41 -0
- package/dist/clis/notion/status.d.ts +1 -0
- package/dist/clis/notion/status.js +16 -0
- package/dist/clis/notion/write.d.ts +1 -0
- package/dist/clis/notion/write.js +40 -0
- package/dist/clis/twitter/download.d.ts +8 -0
- package/dist/clis/twitter/download.js +204 -0
- package/dist/clis/wechat/chats.d.ts +1 -0
- package/dist/clis/wechat/chats.js +28 -0
- package/dist/clis/wechat/contacts.d.ts +1 -0
- package/dist/clis/wechat/contacts.js +28 -0
- package/dist/clis/wechat/read.d.ts +1 -0
- package/dist/clis/wechat/read.js +58 -0
- package/dist/clis/wechat/search.d.ts +1 -0
- package/dist/clis/wechat/search.js +31 -0
- package/dist/clis/wechat/send.d.ts +1 -0
- package/dist/clis/wechat/send.js +42 -0
- package/dist/clis/wechat/status.d.ts +1 -0
- package/dist/clis/wechat/status.js +29 -0
- package/dist/clis/xiaohongshu/creator-note-detail.d.ts +10 -0
- package/dist/clis/xiaohongshu/creator-note-detail.js +88 -0
- package/dist/clis/xiaohongshu/creator-notes.d.ts +11 -0
- package/dist/clis/xiaohongshu/creator-notes.js +109 -0
- package/dist/clis/xiaohongshu/creator-profile.d.ts +10 -0
- package/dist/clis/xiaohongshu/creator-profile.js +54 -0
- package/dist/clis/xiaohongshu/creator-stats.d.ts +10 -0
- package/dist/clis/xiaohongshu/creator-stats.js +74 -0
- package/dist/clis/xiaohongshu/download.d.ts +7 -0
- package/dist/clis/xiaohongshu/download.js +155 -0
- package/dist/clis/xiaohongshu/search.js +1 -1
- package/dist/clis/xiaohongshu/user-helpers.d.ts +15 -0
- package/dist/clis/xiaohongshu/user-helpers.js +67 -0
- package/dist/clis/xiaohongshu/user-helpers.test.d.ts +1 -0
- package/dist/clis/xiaohongshu/user-helpers.test.js +81 -0
- package/dist/clis/xiaohongshu/user.js +46 -29
- package/dist/clis/zhihu/download.d.ts +11 -0
- package/dist/clis/zhihu/download.js +186 -0
- package/dist/clis/zhihu/download.test.d.ts +1 -0
- package/dist/clis/zhihu/download.test.js +10 -0
- package/dist/download/index.d.ts +79 -0
- package/dist/download/index.js +325 -0
- package/dist/download/progress.d.ts +36 -0
- package/dist/download/progress.js +111 -0
- package/dist/engine.test.js +15 -0
- package/dist/main.js +16 -3
- package/dist/pipeline/registry.js +2 -0
- package/dist/pipeline/steps/download.d.ts +34 -0
- package/dist/pipeline/steps/download.js +251 -0
- package/dist/pipeline/template.js +28 -0
- package/package.json +4 -3
- package/scripts/test-site.mjs +70 -0
- package/src/browser/discover.ts +23 -7
- package/src/browser.test.ts +23 -0
- package/src/build-manifest.test.ts +28 -0
- package/src/build-manifest.ts +147 -57
- package/src/clis/bilibili/download.ts +161 -0
- package/src/clis/chatwise/README.md +38 -0
- package/src/clis/chatwise/README.zh-CN.md +38 -0
- package/src/clis/chatwise/ask.ts +87 -0
- package/src/clis/chatwise/export.ts +51 -0
- package/src/clis/chatwise/history.ts +47 -0
- package/src/clis/chatwise/model.ts +87 -0
- package/src/clis/chatwise/new.ts +21 -0
- package/src/clis/chatwise/read.ts +42 -0
- package/src/clis/chatwise/screenshot.ts +33 -0
- package/src/clis/chatwise/send.ts +50 -0
- package/src/clis/chatwise/status.ts +25 -0
- package/src/clis/discord-app/README.md +28 -0
- package/src/clis/discord-app/README.zh-CN.md +28 -0
- package/src/clis/discord-app/channels.ts +48 -0
- package/src/clis/discord-app/members.ts +41 -0
- package/src/clis/discord-app/read.ts +49 -0
- package/src/clis/discord-app/search.ts +64 -0
- package/src/clis/discord-app/send.ts +32 -0
- package/src/clis/discord-app/servers.ts +39 -0
- package/src/clis/discord-app/status.ts +18 -0
- package/src/clis/feishu/README.md +20 -0
- package/src/clis/feishu/README.zh-CN.md +20 -0
- package/src/clis/feishu/new.ts +32 -0
- package/src/clis/feishu/read.ts +48 -0
- package/src/clis/feishu/search.ts +35 -0
- package/src/clis/feishu/send.ts +46 -0
- package/src/clis/feishu/status.ts +34 -0
- package/src/clis/grok/ask.ts +90 -0
- package/src/clis/grok/debug.ts +49 -0
- package/src/clis/jimeng/generate.yaml +84 -0
- package/src/clis/jimeng/history.yaml +47 -0
- package/src/clis/linux-do/categories.yaml +41 -0
- package/src/clis/linux-do/category.yaml +49 -0
- package/src/clis/linux-do/hot.yaml +50 -0
- package/src/clis/linux-do/latest.yaml +40 -0
- package/src/clis/linux-do/search.yaml +45 -0
- package/src/clis/linux-do/topic.yaml +38 -0
- package/src/clis/notion/README.md +29 -0
- package/src/clis/notion/README.zh-CN.md +29 -0
- package/src/clis/notion/export.ts +36 -0
- package/src/clis/notion/favorites.ts +87 -0
- package/src/clis/notion/new.ts +39 -0
- package/src/clis/notion/read.ts +33 -0
- package/src/clis/notion/search.ts +54 -0
- package/src/clis/notion/sidebar.ts +44 -0
- package/src/clis/notion/status.ts +18 -0
- package/src/clis/notion/write.ts +45 -0
- package/src/clis/twitter/download.ts +227 -0
- package/src/clis/wechat/README.md +28 -0
- package/src/clis/wechat/README.zh-CN.md +28 -0
- package/src/clis/wechat/chats.ts +33 -0
- package/src/clis/wechat/contacts.ts +33 -0
- package/src/clis/wechat/read.ts +72 -0
- package/src/clis/wechat/search.ts +36 -0
- package/src/clis/wechat/send.ts +49 -0
- package/src/clis/wechat/status.ts +35 -0
- package/src/clis/xiaohongshu/creator-note-detail.ts +95 -0
- package/src/clis/xiaohongshu/creator-notes.ts +116 -0
- package/src/clis/xiaohongshu/creator-profile.ts +60 -0
- package/src/clis/xiaohongshu/creator-stats.ts +81 -0
- package/src/clis/xiaohongshu/download.ts +173 -0
- package/src/clis/xiaohongshu/search.ts +1 -1
- package/src/clis/xiaohongshu/user-helpers.test.ts +106 -0
- package/src/clis/xiaohongshu/user-helpers.ts +85 -0
- package/src/clis/xiaohongshu/user.ts +52 -32
- package/src/clis/zhihu/download.test.ts +12 -0
- package/src/clis/zhihu/download.ts +223 -0
- package/src/download/index.ts +395 -0
- package/src/download/progress.ts +125 -0
- package/src/engine.test.ts +17 -0
- package/src/main.ts +12 -3
- package/src/pipeline/registry.ts +2 -0
- package/src/pipeline/steps/download.ts +310 -0
- package/src/pipeline/template.ts +26 -0
- package/tests/e2e/browser-auth.test.ts +25 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Twitter/X download — download images and videos from tweets.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* opencli twitter download --username elonmusk --limit 10 --output ./twitter
|
|
6
|
+
* opencli twitter download --tweet-url https://x.com/xxx/status/123 --output ./twitter
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as fs from 'node:fs';
|
|
10
|
+
import * as path from 'node:path';
|
|
11
|
+
import { cli, Strategy } from '../../registry.js';
|
|
12
|
+
import {
|
|
13
|
+
httpDownload,
|
|
14
|
+
ytdlpDownload,
|
|
15
|
+
checkYtdlp,
|
|
16
|
+
sanitizeFilename,
|
|
17
|
+
getTempDir,
|
|
18
|
+
exportCookiesToNetscape,
|
|
19
|
+
} from '../../download/index.js';
|
|
20
|
+
import { DownloadProgressTracker, formatBytes } from '../../download/progress.js';
|
|
21
|
+
|
|
22
|
+
cli({
|
|
23
|
+
site: 'twitter',
|
|
24
|
+
name: 'download',
|
|
25
|
+
description: '下载 Twitter/X 媒体(图片和视频)',
|
|
26
|
+
domain: 'x.com',
|
|
27
|
+
strategy: Strategy.COOKIE,
|
|
28
|
+
args: [
|
|
29
|
+
{ name: 'username', help: 'Twitter username (downloads from media tab)' },
|
|
30
|
+
{ name: 'tweet-url', help: 'Single tweet URL to download' },
|
|
31
|
+
{ name: 'limit', type: 'int', default: 10, help: 'Number of tweets to scan' },
|
|
32
|
+
{ name: 'output', default: './twitter-downloads', help: 'Output directory' },
|
|
33
|
+
],
|
|
34
|
+
columns: ['index', 'type', 'status', 'size'],
|
|
35
|
+
func: async (page, kwargs) => {
|
|
36
|
+
const username = kwargs.username;
|
|
37
|
+
const tweetUrl = kwargs['tweet-url'];
|
|
38
|
+
const limit = kwargs.limit;
|
|
39
|
+
const output = kwargs.output;
|
|
40
|
+
|
|
41
|
+
if (!username && !tweetUrl) {
|
|
42
|
+
return [{
|
|
43
|
+
index: 0,
|
|
44
|
+
type: '-',
|
|
45
|
+
status: 'failed',
|
|
46
|
+
size: 'Must provide --username or --tweet-url',
|
|
47
|
+
}];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Navigate to the appropriate page
|
|
51
|
+
if (tweetUrl) {
|
|
52
|
+
await page.goto(tweetUrl);
|
|
53
|
+
} else {
|
|
54
|
+
await page.goto(`https://x.com/${username}/media`);
|
|
55
|
+
}
|
|
56
|
+
await page.wait(3);
|
|
57
|
+
|
|
58
|
+
// Scroll to load more content
|
|
59
|
+
if (!tweetUrl) {
|
|
60
|
+
await page.autoScroll({ times: Math.ceil(limit / 5) });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Extract media URLs
|
|
64
|
+
const data = await page.evaluate(`
|
|
65
|
+
(() => {
|
|
66
|
+
const media = [];
|
|
67
|
+
|
|
68
|
+
// Find images (high quality)
|
|
69
|
+
document.querySelectorAll('img[src*="pbs.twimg.com/media"]').forEach(img => {
|
|
70
|
+
let src = img.src || '';
|
|
71
|
+
// Get large version
|
|
72
|
+
src = src.replace(/&name=\\w+$/, '&name=large');
|
|
73
|
+
src = src.replace(/\\?format=/, '?format=');
|
|
74
|
+
if (!src.includes('&name=')) {
|
|
75
|
+
src = src + '&name=large';
|
|
76
|
+
}
|
|
77
|
+
media.push({ type: 'image', url: src });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// Find videos
|
|
81
|
+
document.querySelectorAll('video').forEach(video => {
|
|
82
|
+
const src = video.src || '';
|
|
83
|
+
if (src) {
|
|
84
|
+
media.push({ type: 'video', url: src, poster: video.poster || '' });
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// Find video tweets (for yt-dlp)
|
|
89
|
+
document.querySelectorAll('[data-testid="videoPlayer"]').forEach(player => {
|
|
90
|
+
const tweetLink = player.closest('article')?.querySelector('a[href*="/status/"]');
|
|
91
|
+
const href = tweetLink?.getAttribute('href') || '';
|
|
92
|
+
if (href) {
|
|
93
|
+
const tweetUrl = 'https://x.com' + href;
|
|
94
|
+
media.push({ type: 'video-tweet', url: tweetUrl });
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
return media;
|
|
99
|
+
})()
|
|
100
|
+
`);
|
|
101
|
+
|
|
102
|
+
if (!data || data.length === 0) {
|
|
103
|
+
return [{
|
|
104
|
+
index: 0,
|
|
105
|
+
type: '-',
|
|
106
|
+
status: 'failed',
|
|
107
|
+
size: 'No media found',
|
|
108
|
+
}];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Extract cookies
|
|
112
|
+
const cookieString = await page.evaluate(`(() => document.cookie)()`);
|
|
113
|
+
|
|
114
|
+
// Create output directory
|
|
115
|
+
const outputDir = tweetUrl
|
|
116
|
+
? path.join(output, 'tweets')
|
|
117
|
+
: path.join(output, username || 'media');
|
|
118
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
119
|
+
|
|
120
|
+
// Export cookies for yt-dlp
|
|
121
|
+
let cookiesFile: string | undefined;
|
|
122
|
+
if (typeof cookieString === 'string' && cookieString) {
|
|
123
|
+
const tempDir = getTempDir();
|
|
124
|
+
fs.mkdirSync(tempDir, { recursive: true });
|
|
125
|
+
cookiesFile = path.join(tempDir, `twitter_cookies_${Date.now()}.txt`);
|
|
126
|
+
|
|
127
|
+
const cookies = cookieString.split(';').map((c) => {
|
|
128
|
+
const [name, ...rest] = c.trim().split('=');
|
|
129
|
+
return {
|
|
130
|
+
name: name || '',
|
|
131
|
+
value: rest.join('=') || '',
|
|
132
|
+
domain: '.x.com',
|
|
133
|
+
path: '/',
|
|
134
|
+
secure: true,
|
|
135
|
+
httpOnly: false,
|
|
136
|
+
};
|
|
137
|
+
}).filter((c) => c.name);
|
|
138
|
+
|
|
139
|
+
exportCookiesToNetscape(cookies, cookiesFile);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Deduplicate media
|
|
143
|
+
const seen = new Set<string>();
|
|
144
|
+
const uniqueMedia = data.filter((m: any) => {
|
|
145
|
+
if (seen.has(m.url)) return false;
|
|
146
|
+
seen.add(m.url);
|
|
147
|
+
return true;
|
|
148
|
+
}).slice(0, limit);
|
|
149
|
+
|
|
150
|
+
const tracker = new DownloadProgressTracker(uniqueMedia.length, true);
|
|
151
|
+
const results: any[] = [];
|
|
152
|
+
|
|
153
|
+
for (let i = 0; i < uniqueMedia.length; i++) {
|
|
154
|
+
const media = uniqueMedia[i];
|
|
155
|
+
const ext = media.type === 'image' ? 'jpg' : 'mp4';
|
|
156
|
+
const filename = `${username || 'tweet'}_${i + 1}.${ext}`;
|
|
157
|
+
const destPath = path.join(outputDir, filename);
|
|
158
|
+
|
|
159
|
+
const progressBar = tracker.onFileStart(filename, i);
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
let result: { success: boolean; size: number; error?: string };
|
|
163
|
+
|
|
164
|
+
if (media.type === 'video-tweet' && checkYtdlp()) {
|
|
165
|
+
// Use yt-dlp for video tweets
|
|
166
|
+
result = await ytdlpDownload(media.url, destPath, {
|
|
167
|
+
cookiesFile,
|
|
168
|
+
extraArgs: ['--merge-output-format', 'mp4'],
|
|
169
|
+
onProgress: (percent) => {
|
|
170
|
+
if (progressBar) progressBar.update(percent, 100);
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
} else if (media.type === 'image') {
|
|
174
|
+
// Direct HTTP download for images
|
|
175
|
+
result = await httpDownload(media.url, destPath, {
|
|
176
|
+
cookies: typeof cookieString === 'string' ? cookieString : '',
|
|
177
|
+
timeout: 30000,
|
|
178
|
+
onProgress: (received, total) => {
|
|
179
|
+
if (progressBar) progressBar.update(received, total);
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
} else {
|
|
183
|
+
// Direct HTTP download for direct video URLs
|
|
184
|
+
result = await httpDownload(media.url, destPath, {
|
|
185
|
+
cookies: typeof cookieString === 'string' ? cookieString : '',
|
|
186
|
+
timeout: 60000,
|
|
187
|
+
onProgress: (received, total) => {
|
|
188
|
+
if (progressBar) progressBar.update(received, total);
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (progressBar) {
|
|
194
|
+
progressBar.complete(result.success, result.success ? formatBytes(result.size) : undefined);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
tracker.onFileComplete(result.success);
|
|
198
|
+
|
|
199
|
+
results.push({
|
|
200
|
+
index: i + 1,
|
|
201
|
+
type: media.type === 'video-tweet' ? 'video' : media.type,
|
|
202
|
+
status: result.success ? 'success' : 'failed',
|
|
203
|
+
size: result.success ? formatBytes(result.size) : (result.error || 'unknown error'),
|
|
204
|
+
});
|
|
205
|
+
} catch (err: any) {
|
|
206
|
+
if (progressBar) progressBar.fail(err.message);
|
|
207
|
+
tracker.onFileComplete(false);
|
|
208
|
+
|
|
209
|
+
results.push({
|
|
210
|
+
index: i + 1,
|
|
211
|
+
type: media.type,
|
|
212
|
+
status: 'failed',
|
|
213
|
+
size: err.message,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
tracker.finish();
|
|
219
|
+
|
|
220
|
+
// Cleanup cookies file
|
|
221
|
+
if (cookiesFile && fs.existsSync(cookiesFile)) {
|
|
222
|
+
fs.unlinkSync(cookiesFile);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return results;
|
|
226
|
+
},
|
|
227
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# WeChat (微信) Desktop Adapter
|
|
2
|
+
|
|
3
|
+
Control **WeChat Mac Desktop** from the terminal via AppleScript + Accessibility API.
|
|
4
|
+
|
|
5
|
+
> **Note:** WeChat is a native macOS app (not Electron), so CDP is not available. This adapter uses AppleScript keyboard simulation and clipboard operations.
|
|
6
|
+
|
|
7
|
+
## Prerequisites
|
|
8
|
+
|
|
9
|
+
1. WeChat must be running and logged in
|
|
10
|
+
2. Terminal must have **Accessibility permission** (System Settings → Privacy & Security → Accessibility)
|
|
11
|
+
|
|
12
|
+
## Commands
|
|
13
|
+
|
|
14
|
+
| Command | Description |
|
|
15
|
+
|---------|-------------|
|
|
16
|
+
| `wechat status` | Check if WeChat is running |
|
|
17
|
+
| `wechat send "msg"` | Send message in the active chat (clipboard paste + Enter) |
|
|
18
|
+
| `wechat read` | Read current chat content (Cmd+A → Cmd+C) |
|
|
19
|
+
| `wechat search "keyword"` | Open search and type a query (Cmd+F) |
|
|
20
|
+
| `wechat chats` | Switch to Chats tab (Cmd+1) |
|
|
21
|
+
| `wechat contacts` | Switch to Contacts tab (Cmd+2) |
|
|
22
|
+
|
|
23
|
+
## Limitations
|
|
24
|
+
|
|
25
|
+
- **No CDP support** — WeChat is native Cocoa, not Electron
|
|
26
|
+
- `send` requires the correct conversation to be already open
|
|
27
|
+
- `read` captures whatever is visible via select-all + copy
|
|
28
|
+
- `search` types the query but cannot programmatically click results
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# 微信桌面端适配器
|
|
2
|
+
|
|
3
|
+
通过 AppleScript + Accessibility API 在终端中控制**微信 Mac 桌面端**。
|
|
4
|
+
|
|
5
|
+
> **注意:** 微信是原生 macOS 应用(非 Electron),因此无法使用 CDP。此适配器使用 AppleScript 键盘模拟和剪贴板操作。
|
|
6
|
+
|
|
7
|
+
## 前置条件
|
|
8
|
+
|
|
9
|
+
1. 微信必须正在运行且已登录
|
|
10
|
+
2. Terminal 需要 **辅助功能权限**(系统设置 → 隐私与安全性 → 辅助功能)
|
|
11
|
+
|
|
12
|
+
## 命令
|
|
13
|
+
|
|
14
|
+
| 命令 | 说明 |
|
|
15
|
+
|------|------|
|
|
16
|
+
| `wechat status` | 检查微信是否在运行 |
|
|
17
|
+
| `wechat send "消息"` | 发送消息(剪贴板粘贴 + 回车) |
|
|
18
|
+
| `wechat read` | 读取当前聊天内容(Cmd+A → Cmd+C) |
|
|
19
|
+
| `wechat search "关键词"` | 打开搜索并输入关键词(Cmd+F) |
|
|
20
|
+
| `wechat chats` | 切换到聊天列表(Cmd+1) |
|
|
21
|
+
| `wechat contacts` | 切换到通讯录(Cmd+2) |
|
|
22
|
+
|
|
23
|
+
## 限制
|
|
24
|
+
|
|
25
|
+
- **不支持 CDP** — 微信是原生 Cocoa 应用
|
|
26
|
+
- `send` 需要先手动打开正确的会话
|
|
27
|
+
- `read` 通过全选+复制来获取可见内容
|
|
28
|
+
- `search` 可以输入关键词但无法自动点击结果
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process';
|
|
2
|
+
import { cli, Strategy } from '../../registry.js';
|
|
3
|
+
import type { IPage } from '../../types.js';
|
|
4
|
+
|
|
5
|
+
export const chatsCommand = cli({
|
|
6
|
+
site: 'wechat',
|
|
7
|
+
name: 'chats',
|
|
8
|
+
description: 'Open the WeChat chats panel (conversation list)',
|
|
9
|
+
domain: 'localhost',
|
|
10
|
+
strategy: Strategy.PUBLIC,
|
|
11
|
+
browser: false,
|
|
12
|
+
args: [],
|
|
13
|
+
columns: ['Status'],
|
|
14
|
+
func: async (page: IPage | null) => {
|
|
15
|
+
try {
|
|
16
|
+
// Activate WeChat
|
|
17
|
+
execSync("osascript -e 'tell application \"WeChat\" to activate'");
|
|
18
|
+
execSync("osascript -e 'delay 0.3'");
|
|
19
|
+
|
|
20
|
+
// Cmd+1 switches to Chats tab in WeChat Mac
|
|
21
|
+
execSync(
|
|
22
|
+
"osascript " +
|
|
23
|
+
"-e 'tell application \"System Events\"' " +
|
|
24
|
+
"-e 'keystroke \"1\" using command down' " +
|
|
25
|
+
"-e 'end tell'"
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
return [{ Status: 'Chats panel opened (Cmd+1)' }];
|
|
29
|
+
} catch (err: any) {
|
|
30
|
+
return [{ Status: 'Error: ' + err.message }];
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process';
|
|
2
|
+
import { cli, Strategy } from '../../registry.js';
|
|
3
|
+
import type { IPage } from '../../types.js';
|
|
4
|
+
|
|
5
|
+
export const contactsCommand = cli({
|
|
6
|
+
site: 'wechat',
|
|
7
|
+
name: 'contacts',
|
|
8
|
+
description: 'Open the WeChat contacts panel',
|
|
9
|
+
domain: 'localhost',
|
|
10
|
+
strategy: Strategy.PUBLIC,
|
|
11
|
+
browser: false,
|
|
12
|
+
args: [],
|
|
13
|
+
columns: ['Status'],
|
|
14
|
+
func: async (page: IPage | null) => {
|
|
15
|
+
try {
|
|
16
|
+
// Activate WeChat
|
|
17
|
+
execSync("osascript -e 'tell application \"WeChat\" to activate'");
|
|
18
|
+
execSync("osascript -e 'delay 0.3'");
|
|
19
|
+
|
|
20
|
+
// Cmd+2 switches to Contacts tab in WeChat Mac
|
|
21
|
+
execSync(
|
|
22
|
+
"osascript " +
|
|
23
|
+
"-e 'tell application \"System Events\"' " +
|
|
24
|
+
"-e 'keystroke \"2\" using command down' " +
|
|
25
|
+
"-e 'end tell'"
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
return [{ Status: 'Contacts panel opened (Cmd+2)' }];
|
|
29
|
+
} catch (err: any) {
|
|
30
|
+
return [{ Status: 'Error: ' + err.message }];
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { execSync, spawnSync } from 'node:child_process';
|
|
2
|
+
import { cli, Strategy } from '../../registry.js';
|
|
3
|
+
import type { IPage } from '../../types.js';
|
|
4
|
+
|
|
5
|
+
export const readCommand = cli({
|
|
6
|
+
site: 'wechat',
|
|
7
|
+
name: 'read',
|
|
8
|
+
description: 'Read the current chat content by selecting all and copying',
|
|
9
|
+
domain: 'localhost',
|
|
10
|
+
strategy: Strategy.PUBLIC,
|
|
11
|
+
browser: false,
|
|
12
|
+
args: [],
|
|
13
|
+
columns: ['Content'],
|
|
14
|
+
func: async (page: IPage | null) => {
|
|
15
|
+
try {
|
|
16
|
+
// Backup clipboard
|
|
17
|
+
let clipBackup = '';
|
|
18
|
+
try {
|
|
19
|
+
clipBackup = execSync('pbpaste', { encoding: 'utf-8' });
|
|
20
|
+
} catch { /* empty */ }
|
|
21
|
+
|
|
22
|
+
// Activate WeChat
|
|
23
|
+
execSync("osascript -e 'tell application \"WeChat\" to activate'");
|
|
24
|
+
execSync("osascript -e 'delay 0.3'");
|
|
25
|
+
|
|
26
|
+
// Click on the chat area first, then select all and copy
|
|
27
|
+
execSync(
|
|
28
|
+
"osascript " +
|
|
29
|
+
"-e 'tell application \"System Events\"' " +
|
|
30
|
+
"-e 'tell application process \"WeChat\"' " +
|
|
31
|
+
// Click in the message area (center-right of the window)
|
|
32
|
+
"-e 'set frontWin to front window' " +
|
|
33
|
+
"-e 'set winPos to position of frontWin' " +
|
|
34
|
+
"-e 'set winSize to size of frontWin' " +
|
|
35
|
+
"-e 'end tell' " +
|
|
36
|
+
"-e 'end tell'"
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
execSync("osascript -e 'delay 0.2'");
|
|
40
|
+
|
|
41
|
+
// Select all text in chat area and copy
|
|
42
|
+
execSync(
|
|
43
|
+
"osascript " +
|
|
44
|
+
"-e 'tell application \"System Events\"' " +
|
|
45
|
+
"-e 'keystroke \"a\" using command down' " +
|
|
46
|
+
"-e 'delay 0.2' " +
|
|
47
|
+
"-e 'keystroke \"c\" using command down' " +
|
|
48
|
+
"-e 'delay 0.2' " +
|
|
49
|
+
"-e 'end tell'"
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const content = execSync('pbpaste', { encoding: 'utf-8' }).trim();
|
|
53
|
+
|
|
54
|
+
// Restore clipboard
|
|
55
|
+
if (clipBackup) {
|
|
56
|
+
spawnSync('pbcopy', { input: clipBackup });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Press Escape to deselect
|
|
60
|
+
execSync(
|
|
61
|
+
"osascript " +
|
|
62
|
+
"-e 'tell application \"System Events\"' " +
|
|
63
|
+
"-e 'key code 53' " + // Escape
|
|
64
|
+
"-e 'end tell'"
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
return [{ Content: content || '(no content captured)' }];
|
|
68
|
+
} catch (err: any) {
|
|
69
|
+
return [{ Content: 'Error: ' + err.message }];
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process';
|
|
2
|
+
import { cli, Strategy } from '../../registry.js';
|
|
3
|
+
import type { IPage } from '../../types.js';
|
|
4
|
+
|
|
5
|
+
export const searchCommand = cli({
|
|
6
|
+
site: 'wechat',
|
|
7
|
+
name: 'search',
|
|
8
|
+
description: 'Open WeChat search and type a query (find contacts or messages)',
|
|
9
|
+
domain: 'localhost',
|
|
10
|
+
strategy: Strategy.PUBLIC,
|
|
11
|
+
browser: false,
|
|
12
|
+
args: [{ name: 'query', required: true, positional: true, help: 'Search query (contact name or keyword)' }],
|
|
13
|
+
columns: ['Status'],
|
|
14
|
+
func: async (page: IPage | null, kwargs: any) => {
|
|
15
|
+
const query = kwargs.query as string;
|
|
16
|
+
try {
|
|
17
|
+
// Activate WeChat
|
|
18
|
+
execSync("osascript -e 'tell application \"WeChat\" to activate'");
|
|
19
|
+
execSync("osascript -e 'delay 0.3'");
|
|
20
|
+
|
|
21
|
+
// Cmd+F to open search (WeChat Mac uses Cmd+F for search)
|
|
22
|
+
execSync(
|
|
23
|
+
"osascript " +
|
|
24
|
+
"-e 'tell application \"System Events\"' " +
|
|
25
|
+
"-e 'keystroke \"f\" using command down' " +
|
|
26
|
+
"-e 'delay 0.5' " +
|
|
27
|
+
`-e 'keystroke ${JSON.stringify(query)}' ` +
|
|
28
|
+
"-e 'end tell'"
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
return [{ Status: `Searching for: ${query}` }];
|
|
32
|
+
} catch (err: any) {
|
|
33
|
+
return [{ Status: 'Error: ' + err.message }];
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { execSync, spawnSync } from 'node:child_process';
|
|
2
|
+
import { cli, Strategy } from '../../registry.js';
|
|
3
|
+
import type { IPage } from '../../types.js';
|
|
4
|
+
|
|
5
|
+
export const sendCommand = cli({
|
|
6
|
+
site: 'wechat',
|
|
7
|
+
name: 'send',
|
|
8
|
+
description: 'Send a message in the active WeChat conversation via clipboard paste',
|
|
9
|
+
domain: 'localhost',
|
|
10
|
+
strategy: Strategy.PUBLIC,
|
|
11
|
+
browser: false,
|
|
12
|
+
args: [{ name: 'text', required: true, positional: true, help: 'Message to send' }],
|
|
13
|
+
columns: ['Status'],
|
|
14
|
+
func: async (page: IPage | null, kwargs: any) => {
|
|
15
|
+
const text = kwargs.text as string;
|
|
16
|
+
try {
|
|
17
|
+
// Backup clipboard
|
|
18
|
+
let clipBackup = '';
|
|
19
|
+
try {
|
|
20
|
+
clipBackup = execSync('pbpaste', { encoding: 'utf-8' });
|
|
21
|
+
} catch { /* clipboard may be empty */ }
|
|
22
|
+
|
|
23
|
+
// Copy text to clipboard
|
|
24
|
+
spawnSync('pbcopy', { input: text });
|
|
25
|
+
|
|
26
|
+
// Activate WeChat and paste
|
|
27
|
+
execSync("osascript -e 'tell application \"WeChat\" to activate'");
|
|
28
|
+
execSync("osascript -e 'delay 0.5'");
|
|
29
|
+
|
|
30
|
+
execSync(
|
|
31
|
+
"osascript " +
|
|
32
|
+
"-e 'tell application \"System Events\"' " +
|
|
33
|
+
"-e 'keystroke \"v\" using command down' " +
|
|
34
|
+
"-e 'delay 0.2' " +
|
|
35
|
+
"-e 'keystroke return' " +
|
|
36
|
+
"-e 'end tell'"
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
// Restore clipboard
|
|
40
|
+
if (clipBackup) {
|
|
41
|
+
spawnSync('pbcopy', { input: clipBackup });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return [{ Status: 'Success' }];
|
|
45
|
+
} catch (err: any) {
|
|
46
|
+
return [{ Status: 'Error: ' + err.message }];
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process';
|
|
2
|
+
import { cli, Strategy } from '../../registry.js';
|
|
3
|
+
import type { IPage } from '../../types.js';
|
|
4
|
+
|
|
5
|
+
export const statusCommand = cli({
|
|
6
|
+
site: 'wechat',
|
|
7
|
+
name: 'status',
|
|
8
|
+
description: 'Check if WeChat Desktop is running on macOS',
|
|
9
|
+
domain: 'localhost',
|
|
10
|
+
strategy: Strategy.PUBLIC,
|
|
11
|
+
browser: false,
|
|
12
|
+
args: [],
|
|
13
|
+
columns: ['Status', 'Detail'],
|
|
14
|
+
func: async (page: IPage | null) => {
|
|
15
|
+
try {
|
|
16
|
+
const running = execSync("osascript -e 'application \"WeChat\" is running'", { encoding: 'utf-8' }).trim();
|
|
17
|
+
if (running !== 'true') {
|
|
18
|
+
return [{ Status: 'Stopped', Detail: 'WeChat is not running' }];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Get window count to check if logged in
|
|
22
|
+
const windowCount = execSync(
|
|
23
|
+
"osascript -e 'tell application \"System Events\" to count windows of application process \"WeChat\"'",
|
|
24
|
+
{ encoding: 'utf-8' }
|
|
25
|
+
).trim();
|
|
26
|
+
|
|
27
|
+
return [{
|
|
28
|
+
Status: 'Running',
|
|
29
|
+
Detail: `${windowCount} window(s) open`,
|
|
30
|
+
}];
|
|
31
|
+
} catch (err: any) {
|
|
32
|
+
return [{ Status: 'Error', Detail: err.message }];
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Xiaohongshu Creator Note Detail — per-note analytics breakdown.
|
|
3
|
+
*
|
|
4
|
+
* Uses the creator.xiaohongshu.com internal API (cookie auth).
|
|
5
|
+
* Returns total reads, engagement, likes, collects, comments, shares
|
|
6
|
+
* for a specific note, split by channel (organic vs promoted vs video).
|
|
7
|
+
*
|
|
8
|
+
* Requires: logged into creator.xiaohongshu.com in Chrome.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { cli, Strategy } from '../../registry.js';
|
|
12
|
+
|
|
13
|
+
cli({
|
|
14
|
+
site: 'xiaohongshu',
|
|
15
|
+
name: 'creator-note-detail',
|
|
16
|
+
description: '小红书单篇笔记详细数据 (阅读/互动/点赞/收藏/评论/分享,区分自然流量/推广/视频)',
|
|
17
|
+
domain: 'creator.xiaohongshu.com',
|
|
18
|
+
strategy: Strategy.COOKIE,
|
|
19
|
+
browser: true,
|
|
20
|
+
args: [
|
|
21
|
+
{ name: 'note_id', type: 'string', required: true, help: 'Note ID (from note URL or creator-notes command)' },
|
|
22
|
+
],
|
|
23
|
+
columns: ['channel', 'reads', 'engagement', 'likes', 'collects', 'comments', 'shares'],
|
|
24
|
+
func: async (page, kwargs) => {
|
|
25
|
+
const noteId: string = kwargs.note_id;
|
|
26
|
+
const encodedNoteId = encodeURIComponent(noteId);
|
|
27
|
+
|
|
28
|
+
// Navigate for cookie context
|
|
29
|
+
await page.goto('https://creator.xiaohongshu.com/new/home');
|
|
30
|
+
await page.wait(2);
|
|
31
|
+
|
|
32
|
+
const data = await page.evaluate(`
|
|
33
|
+
async () => {
|
|
34
|
+
try {
|
|
35
|
+
const resp = await fetch(
|
|
36
|
+
'/api/galaxy/creator/data/note_detail?note_id=${encodedNoteId}',
|
|
37
|
+
{ credentials: 'include' }
|
|
38
|
+
);
|
|
39
|
+
if (!resp.ok) return { error: 'HTTP ' + resp.status };
|
|
40
|
+
return await resp.json();
|
|
41
|
+
} catch (e) {
|
|
42
|
+
return { error: e.message };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
`);
|
|
46
|
+
|
|
47
|
+
if (data?.error) {
|
|
48
|
+
throw new Error(data.error + '. Check note_id and login status.');
|
|
49
|
+
}
|
|
50
|
+
if (!data?.data) {
|
|
51
|
+
throw new Error('Unexpected response structure');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const d = data.data;
|
|
55
|
+
|
|
56
|
+
return [
|
|
57
|
+
{
|
|
58
|
+
channel: 'Total',
|
|
59
|
+
reads: d.total_read ?? 0,
|
|
60
|
+
engagement: d.total_engage ?? 0,
|
|
61
|
+
likes: d.total_like ?? 0,
|
|
62
|
+
collects: d.total_fav ?? 0,
|
|
63
|
+
comments: d.total_cmt ?? 0,
|
|
64
|
+
shares: d.total_share ?? 0,
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
channel: 'Organic',
|
|
68
|
+
reads: d.normal_read ?? 0,
|
|
69
|
+
engagement: d.normal_engage ?? 0,
|
|
70
|
+
likes: d.normal_like ?? 0,
|
|
71
|
+
collects: d.normal_fav ?? 0,
|
|
72
|
+
comments: d.normal_cmt ?? 0,
|
|
73
|
+
shares: d.normal_share ?? 0,
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
channel: 'Promoted',
|
|
77
|
+
reads: d.total_promo_read ?? 0,
|
|
78
|
+
engagement: 0,
|
|
79
|
+
likes: 0,
|
|
80
|
+
collects: 0,
|
|
81
|
+
comments: 0,
|
|
82
|
+
shares: 0,
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
channel: 'Video',
|
|
86
|
+
reads: d.video_read ?? 0,
|
|
87
|
+
engagement: d.video_engage ?? 0,
|
|
88
|
+
likes: d.video_like ?? 0,
|
|
89
|
+
collects: d.video_fav ?? 0,
|
|
90
|
+
comments: d.video_cmt ?? 0,
|
|
91
|
+
shares: d.video_share ?? 0,
|
|
92
|
+
},
|
|
93
|
+
];
|
|
94
|
+
},
|
|
95
|
+
});
|