Files
opencli/src/download/index.ts
T
8a355dfd2d feat: Add download support for xhs, twi, bilibili, zhihu (#22)
* feat: add download support for images, videos, and articles

Add comprehensive download functionality to OpenCLI with support for
multiple platforms and content types.

- Add `src/download/index.ts`: HTTP download with progress, yt-dlp
  wrapper for video platforms, cookie export to Netscape format for
  authenticated downloads
- Add `src/download/progress.ts`: Terminal progress bars, multi-file
  download tracker with status summary
- Add `src/pipeline/steps/download.ts`: New `download` pipeline step
  for declarative YAML pipelines

- Register `download` step in executor.ts
- Add template filters: `slugify`, `sanitize`, `ext`, `basename` for
  filename templating

- `xiaohongshu download`: Download images and videos from notes
- `bilibili download`: Download videos using yt-dlp with cookie auth
- `twitter download`: Download media from user timeline or single tweet
- `zhihu download`: Export articles to Markdown with optional image
  download

```yaml
pipeline:
  - download:
      url: ${{ item.imageUrl }}
      dir: ./downloads
      filename: ${{ item.title | sanitize }}.jpg
      concurrency: 5
      skip_existing: true
      use_ytdlp: false
      type: auto  # auto|image|video|document
```

- Concurrent downloads with configurable parallelism
- Progress bars with file size display
- Skip existing files option
- Cookie forwarding for authenticated downloads
- yt-dlp integration for video platforms (YouTube, Bilibili, Twitter)
- HTML to Markdown conversion for article export

- yt-dlp: Required for video downloads from streaming platforms
- ffmpeg: Optional for video format conversion

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* docs: add download support documentation

- Add Download Support section to both README.md and README.zh-CN.md
- Document supported platforms: Xiaohongshu, Bilibili, Twitter, Zhihu
- Include prerequisites (yt-dlp installation)
- Add usage examples for all download commands
- Document the `download` pipeline step for YAML adapters
- Update built-in commands table with new `download` commands

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: preserve zhihu ordered list content

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-18 22:20:13 +08:00

396 lines
11 KiB
TypeScript

/**
* Download utilities: HTTP downloads, yt-dlp wrapper, format conversion.
*/
import { spawn, execSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as https from 'node:https';
import * as http from 'node:http';
import * as os from 'node:os';
import { URL } from 'node:url';
import type { ProgressBar } from './progress.js';
export interface DownloadOptions {
cookies?: string;
headers?: Record<string, string>;
timeout?: number;
onProgress?: (received: number, total: number) => void;
}
export interface YtdlpOptions {
cookies?: string;
cookiesFile?: string;
format?: string;
extraArgs?: string[];
onProgress?: (percent: number) => void;
}
/**
* Check if yt-dlp is available in PATH.
*/
export function checkYtdlp(): boolean {
try {
execSync('yt-dlp --version', { encoding: 'utf-8', stdio: 'pipe' });
return true;
} catch {
return false;
}
}
/**
* Check if ffmpeg is available in PATH.
*/
export function checkFfmpeg(): boolean {
try {
execSync('ffmpeg -version', { encoding: 'utf-8', stdio: 'pipe' });
return true;
} catch {
return false;
}
}
/**
* Detect content type from URL and optional headers.
*/
export function detectContentType(url: string, contentType?: string): 'image' | 'video' | 'document' | 'binary' {
// Check content-type header first
if (contentType) {
if (contentType.startsWith('image/')) return 'image';
if (contentType.startsWith('video/')) return 'video';
if (contentType.startsWith('text/') || contentType.includes('json') || contentType.includes('xml')) return 'document';
}
// Detect from URL
const urlLower = url.toLowerCase();
const ext = path.extname(new URL(url).pathname).toLowerCase();
// Image extensions
if (['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.ico', '.bmp', '.avif'].includes(ext)) {
return 'image';
}
// Video extensions
if (['.mp4', '.webm', '.avi', '.mov', '.mkv', '.flv', '.m3u8', '.ts'].includes(ext)) {
return 'video';
}
// Video platforms (need yt-dlp)
if (urlLower.includes('youtube.com') || urlLower.includes('youtu.be') ||
urlLower.includes('bilibili.com') || urlLower.includes('twitter.com') ||
urlLower.includes('x.com') || urlLower.includes('tiktok.com') ||
urlLower.includes('vimeo.com') || urlLower.includes('twitch.tv')) {
return 'video';
}
// Document extensions
if (['.html', '.htm', '.json', '.xml', '.txt', '.md', '.markdown'].includes(ext)) {
return 'document';
}
return 'binary';
}
/**
* Check if URL requires yt-dlp for download.
*/
export function requiresYtdlp(url: string): boolean {
const urlLower = url.toLowerCase();
return (
urlLower.includes('youtube.com') ||
urlLower.includes('youtu.be') ||
urlLower.includes('bilibili.com/video') ||
urlLower.includes('twitter.com') ||
urlLower.includes('x.com') ||
urlLower.includes('tiktok.com') ||
urlLower.includes('vimeo.com') ||
urlLower.includes('twitch.tv')
);
}
/**
* HTTP download with progress callback.
*/
export async function httpDownload(
url: string,
destPath: string,
options: DownloadOptions = {},
): Promise<{ success: boolean; size: number; error?: string }> {
const { cookies, headers = {}, timeout = 30000, onProgress } = options;
return new Promise((resolve) => {
const parsedUrl = new URL(url);
const protocol = parsedUrl.protocol === 'https:' ? https : http;
const requestHeaders: Record<string, string> = {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
...headers,
};
if (cookies) {
requestHeaders['Cookie'] = cookies;
}
// Ensure directory exists
const dir = path.dirname(destPath);
fs.mkdirSync(dir, { recursive: true });
const tempPath = `${destPath}.tmp`;
const file = fs.createWriteStream(tempPath);
const request = protocol.get(url, { headers: requestHeaders, timeout }, (response) => {
// Handle redirects
if (response.statusCode && response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
file.close();
fs.unlinkSync(tempPath);
httpDownload(response.headers.location, destPath, options).then(resolve);
return;
}
if (response.statusCode !== 200) {
file.close();
fs.unlinkSync(tempPath);
resolve({ success: false, size: 0, error: `HTTP ${response.statusCode}` });
return;
}
const totalSize = parseInt(response.headers['content-length'] || '0', 10);
let received = 0;
response.on('data', (chunk: Buffer) => {
received += chunk.length;
if (onProgress) onProgress(received, totalSize);
});
response.pipe(file);
file.on('finish', () => {
file.close();
// Rename temp file to final destination
fs.renameSync(tempPath, destPath);
resolve({ success: true, size: received });
});
});
request.on('error', (err) => {
file.close();
if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath);
resolve({ success: false, size: 0, error: err.message });
});
request.on('timeout', () => {
request.destroy();
file.close();
if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath);
resolve({ success: false, size: 0, error: 'Timeout' });
});
});
}
/**
* Export cookies to Netscape format for yt-dlp.
*/
export function exportCookiesToNetscape(
cookies: Array<{ name: string; value: string; domain: string; path?: string; secure?: boolean; httpOnly?: boolean }>,
filePath: string,
): void {
const lines = [
'# Netscape HTTP Cookie File',
'# https://curl.se/docs/http-cookies.html',
'# This is a generated file! Do not edit.',
'',
];
for (const cookie of cookies) {
const domain = cookie.domain.startsWith('.') ? cookie.domain : `.${cookie.domain}`;
const includeSubdomains = 'TRUE';
const cookiePath = cookie.path || '/';
const secure = cookie.secure ? 'TRUE' : 'FALSE';
const expiry = Math.floor(Date.now() / 1000) + 86400 * 365; // 1 year from now
lines.push(`${domain}\t${includeSubdomains}\t${cookiePath}\t${secure}\t${expiry}\t${cookie.name}\t${cookie.value}`);
}
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, lines.join('\n'));
}
/**
* Download video using yt-dlp.
*/
export async function ytdlpDownload(
url: string,
destPath: string,
options: YtdlpOptions = {},
): Promise<{ success: boolean; size: number; error?: string }> {
const { cookiesFile, format = 'best', extraArgs = [], onProgress } = options;
if (!checkYtdlp()) {
return { success: false, size: 0, error: 'yt-dlp not installed. Install with: pip install yt-dlp' };
}
return new Promise((resolve) => {
const dir = path.dirname(destPath);
fs.mkdirSync(dir, { recursive: true });
// Build yt-dlp arguments
const args = [
url,
'-o', destPath,
'-f', format,
'--no-playlist',
'--progress',
];
if (cookiesFile && fs.existsSync(cookiesFile)) {
args.push('--cookies', cookiesFile);
} else {
// Try to use browser cookies
args.push('--cookies-from-browser', 'chrome');
}
args.push(...extraArgs);
const proc = spawn('yt-dlp', args, {
stdio: ['ignore', 'pipe', 'pipe'],
});
let lastPercent = 0;
let errorOutput = '';
proc.stderr.on('data', (data: Buffer) => {
const line = data.toString();
errorOutput += line;
// Parse progress from yt-dlp output
const match = line.match(/(\d+\.?\d*)%/);
if (match && onProgress) {
const percent = parseFloat(match[1]);
if (percent > lastPercent) {
lastPercent = percent;
onProgress(percent);
}
}
});
proc.stdout.on('data', (data: Buffer) => {
const line = data.toString();
const match = line.match(/(\d+\.?\d*)%/);
if (match && onProgress) {
const percent = parseFloat(match[1]);
if (percent > lastPercent) {
lastPercent = percent;
onProgress(percent);
}
}
});
proc.on('close', (code) => {
if (code === 0 && fs.existsSync(destPath)) {
const stats = fs.statSync(destPath);
resolve({ success: true, size: stats.size });
} else {
// Check for common yt-dlp output patterns
const patterns = fs.readdirSync(dir).filter(f => f.startsWith(path.basename(destPath, path.extname(destPath))));
if (patterns.length > 0) {
const actualFile = path.join(dir, patterns[0]);
const stats = fs.statSync(actualFile);
resolve({ success: true, size: stats.size });
} else {
resolve({ success: false, size: 0, error: errorOutput.slice(0, 200) || `Exit code ${code}` });
}
}
});
proc.on('error', (err) => {
resolve({ success: false, size: 0, error: err.message });
});
});
}
/**
* Save document content to file.
*/
export async function saveDocument(
content: string,
destPath: string,
format: 'json' | 'markdown' | 'html' | 'text' = 'markdown',
metadata?: Record<string, any>,
): Promise<{ success: boolean; size: number; error?: string }> {
try {
const dir = path.dirname(destPath);
fs.mkdirSync(dir, { recursive: true });
let output: string;
if (format === 'json') {
output = JSON.stringify({ ...metadata, content }, null, 2);
} else if (format === 'markdown') {
// Add frontmatter if metadata exists
const frontmatter = metadata ? `---\n${Object.entries(metadata).map(([k, v]) => `${k}: ${JSON.stringify(v)}`).join('\n')}\n---\n\n` : '';
output = frontmatter + content;
} else {
output = content;
}
fs.writeFileSync(destPath, output, 'utf-8');
return { success: true, size: Buffer.byteLength(output, 'utf-8') };
} catch (err: any) {
return { success: false, size: 0, error: err.message };
}
}
/**
* Sanitize filename by removing invalid characters.
*/
export function sanitizeFilename(name: string, maxLength: number = 200): string {
return name
.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_') // Remove invalid chars
.replace(/\s+/g, '_') // Replace spaces with underscores
.replace(/_+/g, '_') // Collapse multiple underscores
.replace(/^_|_$/g, '') // Trim underscores
.slice(0, maxLength);
}
/**
* Generate filename from URL if not provided.
*/
export function generateFilename(url: string, index: number, extension?: string): string {
try {
const parsedUrl = new URL(url);
const pathname = parsedUrl.pathname;
const basename = path.basename(pathname);
if (basename && basename !== '/' && basename.includes('.')) {
return sanitizeFilename(basename);
}
// Generate from hostname and index
const ext = extension || detectExtension(url);
const hostname = parsedUrl.hostname.replace(/^www\./, '');
return sanitizeFilename(`${hostname}_${index + 1}${ext}`);
} catch {
const ext = extension || '.bin';
return `download_${index + 1}${ext}`;
}
}
/**
* Detect file extension from URL.
*/
function detectExtension(url: string): string {
const type = detectContentType(url);
switch (type) {
case 'image': return '.jpg';
case 'video': return '.mp4';
case 'document': return '.md';
default: return '.bin';
}
}
/**
* Get temp directory for cookie files.
*/
export function getTempDir(): string {
return path.join(os.tmpdir(), 'opencli-download');
}