Files
jackwener__opencli/clis/twitter/download.js
jakevin d2974a9ff6 refactor(adapters): convert adapter layer from TypeScript to JavaScript (#928)
* 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.
2026-04-10 14:52:18 +08:00

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'],
});
},
});