mirror of
https://github.com/jackwener/OpenCLI.git
synced 2026-09-14 18:25:42 +08:00
d2974a9ff6
* refactor(adapters): convert adapter layer from TypeScript to JavaScript Core framework stays TypeScript; adapter layer moves to JS-first. Adapters are essentially "executable config + browser scripts" that barely use TS features — this simplifies the build/distribution pipeline by removing the dist/clis/ intermediate compilation step. Changes: - Convert all 753 adapter files in clis/ from .ts to .js - Update tsconfig to exclude clis/ from compilation - Simplify build-manifest to scan clis/*.js directly (no dist/clis/) - Update discovery, main, fetch-adapters to load JS adapters from clis/ - Update generate-verified to output .js artifacts - Update package.json files field: dist/clis/ → clis/ - Fix all test files for the .ts → .js transition * fix(main): use findPackageRoot for BUILTIN_CLIS path The previous relative path (../../clis from __dirname) only worked for dist/src/main.js but broke dev mode (tsx src/main.ts) where __dirname is <repo>/src — resolving to /clis instead of <repo>/clis. Use findPackageRoot() which works for both dev and prod paths.
111 lines
3.9 KiB
JavaScript
111 lines
3.9 KiB
JavaScript
/**
|
|
* Twitter/X download — download images and videos from tweets.
|
|
*
|
|
* Usage:
|
|
* opencli twitter download elonmusk --limit 10 --output ./twitter
|
|
* opencli twitter download --tweet-url https://x.com/xxx/status/123 --output ./twitter
|
|
*/
|
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
import { formatCookieHeader } from '@jackwener/opencli/download';
|
|
import { downloadMedia } from '@jackwener/opencli/download/media-download';
|
|
cli({
|
|
site: 'twitter',
|
|
name: 'download',
|
|
description: '下载 Twitter/X 媒体(图片和视频)',
|
|
domain: 'x.com',
|
|
strategy: Strategy.COOKIE,
|
|
args: [
|
|
{ name: 'username', positional: true, help: 'Twitter username (downloads from media tab)' },
|
|
{ name: 'tweet-url', help: 'Single tweet URL to download' },
|
|
{ name: 'limit', type: 'int', default: 10, help: 'Number of tweets to scan' },
|
|
{ name: 'output', default: './twitter-downloads', help: 'Output directory' },
|
|
],
|
|
columns: ['index', 'type', 'status', 'size'],
|
|
func: async (page, kwargs) => {
|
|
const username = kwargs.username;
|
|
const tweetUrl = kwargs['tweet-url'];
|
|
const limit = kwargs.limit;
|
|
const output = kwargs.output;
|
|
if (!username && !tweetUrl) {
|
|
return [{
|
|
index: 0,
|
|
type: '-',
|
|
status: 'failed',
|
|
size: 'Must provide a username or --tweet-url',
|
|
}];
|
|
}
|
|
// Navigate to the appropriate page
|
|
if (tweetUrl) {
|
|
await page.goto(tweetUrl);
|
|
}
|
|
else {
|
|
await page.goto(`https://x.com/${username}/media`);
|
|
}
|
|
await page.wait(3);
|
|
// Scroll to load more content
|
|
if (!tweetUrl) {
|
|
await page.autoScroll({ times: Math.ceil(limit / 5) });
|
|
}
|
|
// Extract media URLs
|
|
const data = await page.evaluate(`
|
|
(() => {
|
|
const media = [];
|
|
|
|
// Find images (high quality)
|
|
document.querySelectorAll('img[src*="pbs.twimg.com/media"]').forEach(img => {
|
|
let src = img.src || '';
|
|
// Get large version
|
|
src = src.replace(/&name=\\w+$/, '&name=large');
|
|
src = src.replace(/\\?format=/, '?format=');
|
|
if (!src.includes('&name=')) {
|
|
src = src + '&name=large';
|
|
}
|
|
media.push({ type: 'image', url: src });
|
|
});
|
|
|
|
// Find videos
|
|
document.querySelectorAll('video').forEach(video => {
|
|
const src = video.src || '';
|
|
if (src) {
|
|
media.push({ type: 'video', url: src, poster: video.poster || '' });
|
|
}
|
|
});
|
|
|
|
// Find video tweets (for yt-dlp)
|
|
document.querySelectorAll('[data-testid="videoPlayer"]').forEach(player => {
|
|
const tweetLink = player.closest('article')?.querySelector('a[href*="/status/"]');
|
|
const href = tweetLink?.getAttribute('href') || '';
|
|
if (href) {
|
|
const tweetUrl = 'https://x.com' + href;
|
|
media.push({ type: 'video-tweet', url: tweetUrl });
|
|
}
|
|
});
|
|
|
|
return media;
|
|
})()
|
|
`);
|
|
if (!data || data.length === 0) {
|
|
return [{ index: 0, type: '-', status: 'failed', size: 'No media found' }];
|
|
}
|
|
// Extract cookies
|
|
const browserCookies = await page.getCookies({ domain: 'x.com' });
|
|
// Deduplicate media
|
|
const seen = new Set();
|
|
const uniqueMedia = data.filter((m) => {
|
|
if (seen.has(m.url))
|
|
return false;
|
|
seen.add(m.url);
|
|
return true;
|
|
}).slice(0, limit);
|
|
const subdir = tweetUrl ? 'tweets' : (username || 'media');
|
|
return downloadMedia(uniqueMedia, {
|
|
output,
|
|
subdir,
|
|
cookies: formatCookieHeader(browserCookies),
|
|
browserCookies,
|
|
filenamePrefix: username || 'tweet',
|
|
ytdlpExtraArgs: ['--merge-output-format', 'mp4'],
|
|
});
|
|
},
|
|
});
|