@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,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Download utilities: HTTP downloads, yt-dlp wrapper, format conversion.
|
|
3
|
+
*/
|
|
4
|
+
import { spawn, execSync } from 'node:child_process';
|
|
5
|
+
import * as fs from 'node:fs';
|
|
6
|
+
import * as path from 'node:path';
|
|
7
|
+
import * as https from 'node:https';
|
|
8
|
+
import * as http from 'node:http';
|
|
9
|
+
import * as os from 'node:os';
|
|
10
|
+
import { URL } from 'node:url';
|
|
11
|
+
/**
|
|
12
|
+
* Check if yt-dlp is available in PATH.
|
|
13
|
+
*/
|
|
14
|
+
export function checkYtdlp() {
|
|
15
|
+
try {
|
|
16
|
+
execSync('yt-dlp --version', { encoding: 'utf-8', stdio: 'pipe' });
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Check if ffmpeg is available in PATH.
|
|
25
|
+
*/
|
|
26
|
+
export function checkFfmpeg() {
|
|
27
|
+
try {
|
|
28
|
+
execSync('ffmpeg -version', { encoding: 'utf-8', stdio: 'pipe' });
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Detect content type from URL and optional headers.
|
|
37
|
+
*/
|
|
38
|
+
export function detectContentType(url, contentType) {
|
|
39
|
+
// Check content-type header first
|
|
40
|
+
if (contentType) {
|
|
41
|
+
if (contentType.startsWith('image/'))
|
|
42
|
+
return 'image';
|
|
43
|
+
if (contentType.startsWith('video/'))
|
|
44
|
+
return 'video';
|
|
45
|
+
if (contentType.startsWith('text/') || contentType.includes('json') || contentType.includes('xml'))
|
|
46
|
+
return 'document';
|
|
47
|
+
}
|
|
48
|
+
// Detect from URL
|
|
49
|
+
const urlLower = url.toLowerCase();
|
|
50
|
+
const ext = path.extname(new URL(url).pathname).toLowerCase();
|
|
51
|
+
// Image extensions
|
|
52
|
+
if (['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.ico', '.bmp', '.avif'].includes(ext)) {
|
|
53
|
+
return 'image';
|
|
54
|
+
}
|
|
55
|
+
// Video extensions
|
|
56
|
+
if (['.mp4', '.webm', '.avi', '.mov', '.mkv', '.flv', '.m3u8', '.ts'].includes(ext)) {
|
|
57
|
+
return 'video';
|
|
58
|
+
}
|
|
59
|
+
// Video platforms (need yt-dlp)
|
|
60
|
+
if (urlLower.includes('youtube.com') || urlLower.includes('youtu.be') ||
|
|
61
|
+
urlLower.includes('bilibili.com') || urlLower.includes('twitter.com') ||
|
|
62
|
+
urlLower.includes('x.com') || urlLower.includes('tiktok.com') ||
|
|
63
|
+
urlLower.includes('vimeo.com') || urlLower.includes('twitch.tv')) {
|
|
64
|
+
return 'video';
|
|
65
|
+
}
|
|
66
|
+
// Document extensions
|
|
67
|
+
if (['.html', '.htm', '.json', '.xml', '.txt', '.md', '.markdown'].includes(ext)) {
|
|
68
|
+
return 'document';
|
|
69
|
+
}
|
|
70
|
+
return 'binary';
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Check if URL requires yt-dlp for download.
|
|
74
|
+
*/
|
|
75
|
+
export function requiresYtdlp(url) {
|
|
76
|
+
const urlLower = url.toLowerCase();
|
|
77
|
+
return (urlLower.includes('youtube.com') ||
|
|
78
|
+
urlLower.includes('youtu.be') ||
|
|
79
|
+
urlLower.includes('bilibili.com/video') ||
|
|
80
|
+
urlLower.includes('twitter.com') ||
|
|
81
|
+
urlLower.includes('x.com') ||
|
|
82
|
+
urlLower.includes('tiktok.com') ||
|
|
83
|
+
urlLower.includes('vimeo.com') ||
|
|
84
|
+
urlLower.includes('twitch.tv'));
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* HTTP download with progress callback.
|
|
88
|
+
*/
|
|
89
|
+
export async function httpDownload(url, destPath, options = {}) {
|
|
90
|
+
const { cookies, headers = {}, timeout = 30000, onProgress } = options;
|
|
91
|
+
return new Promise((resolve) => {
|
|
92
|
+
const parsedUrl = new URL(url);
|
|
93
|
+
const protocol = parsedUrl.protocol === 'https:' ? https : http;
|
|
94
|
+
const requestHeaders = {
|
|
95
|
+
'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',
|
|
96
|
+
...headers,
|
|
97
|
+
};
|
|
98
|
+
if (cookies) {
|
|
99
|
+
requestHeaders['Cookie'] = cookies;
|
|
100
|
+
}
|
|
101
|
+
// Ensure directory exists
|
|
102
|
+
const dir = path.dirname(destPath);
|
|
103
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
104
|
+
const tempPath = `${destPath}.tmp`;
|
|
105
|
+
const file = fs.createWriteStream(tempPath);
|
|
106
|
+
const request = protocol.get(url, { headers: requestHeaders, timeout }, (response) => {
|
|
107
|
+
// Handle redirects
|
|
108
|
+
if (response.statusCode && response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
|
|
109
|
+
file.close();
|
|
110
|
+
fs.unlinkSync(tempPath);
|
|
111
|
+
httpDownload(response.headers.location, destPath, options).then(resolve);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (response.statusCode !== 200) {
|
|
115
|
+
file.close();
|
|
116
|
+
fs.unlinkSync(tempPath);
|
|
117
|
+
resolve({ success: false, size: 0, error: `HTTP ${response.statusCode}` });
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const totalSize = parseInt(response.headers['content-length'] || '0', 10);
|
|
121
|
+
let received = 0;
|
|
122
|
+
response.on('data', (chunk) => {
|
|
123
|
+
received += chunk.length;
|
|
124
|
+
if (onProgress)
|
|
125
|
+
onProgress(received, totalSize);
|
|
126
|
+
});
|
|
127
|
+
response.pipe(file);
|
|
128
|
+
file.on('finish', () => {
|
|
129
|
+
file.close();
|
|
130
|
+
// Rename temp file to final destination
|
|
131
|
+
fs.renameSync(tempPath, destPath);
|
|
132
|
+
resolve({ success: true, size: received });
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
request.on('error', (err) => {
|
|
136
|
+
file.close();
|
|
137
|
+
if (fs.existsSync(tempPath))
|
|
138
|
+
fs.unlinkSync(tempPath);
|
|
139
|
+
resolve({ success: false, size: 0, error: err.message });
|
|
140
|
+
});
|
|
141
|
+
request.on('timeout', () => {
|
|
142
|
+
request.destroy();
|
|
143
|
+
file.close();
|
|
144
|
+
if (fs.existsSync(tempPath))
|
|
145
|
+
fs.unlinkSync(tempPath);
|
|
146
|
+
resolve({ success: false, size: 0, error: 'Timeout' });
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Export cookies to Netscape format for yt-dlp.
|
|
152
|
+
*/
|
|
153
|
+
export function exportCookiesToNetscape(cookies, filePath) {
|
|
154
|
+
const lines = [
|
|
155
|
+
'# Netscape HTTP Cookie File',
|
|
156
|
+
'# https://curl.se/docs/http-cookies.html',
|
|
157
|
+
'# This is a generated file! Do not edit.',
|
|
158
|
+
'',
|
|
159
|
+
];
|
|
160
|
+
for (const cookie of cookies) {
|
|
161
|
+
const domain = cookie.domain.startsWith('.') ? cookie.domain : `.${cookie.domain}`;
|
|
162
|
+
const includeSubdomains = 'TRUE';
|
|
163
|
+
const cookiePath = cookie.path || '/';
|
|
164
|
+
const secure = cookie.secure ? 'TRUE' : 'FALSE';
|
|
165
|
+
const expiry = Math.floor(Date.now() / 1000) + 86400 * 365; // 1 year from now
|
|
166
|
+
lines.push(`${domain}\t${includeSubdomains}\t${cookiePath}\t${secure}\t${expiry}\t${cookie.name}\t${cookie.value}`);
|
|
167
|
+
}
|
|
168
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
169
|
+
fs.writeFileSync(filePath, lines.join('\n'));
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Download video using yt-dlp.
|
|
173
|
+
*/
|
|
174
|
+
export async function ytdlpDownload(url, destPath, options = {}) {
|
|
175
|
+
const { cookiesFile, format = 'best', extraArgs = [], onProgress } = options;
|
|
176
|
+
if (!checkYtdlp()) {
|
|
177
|
+
return { success: false, size: 0, error: 'yt-dlp not installed. Install with: pip install yt-dlp' };
|
|
178
|
+
}
|
|
179
|
+
return new Promise((resolve) => {
|
|
180
|
+
const dir = path.dirname(destPath);
|
|
181
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
182
|
+
// Build yt-dlp arguments
|
|
183
|
+
const args = [
|
|
184
|
+
url,
|
|
185
|
+
'-o', destPath,
|
|
186
|
+
'-f', format,
|
|
187
|
+
'--no-playlist',
|
|
188
|
+
'--progress',
|
|
189
|
+
];
|
|
190
|
+
if (cookiesFile && fs.existsSync(cookiesFile)) {
|
|
191
|
+
args.push('--cookies', cookiesFile);
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
// Try to use browser cookies
|
|
195
|
+
args.push('--cookies-from-browser', 'chrome');
|
|
196
|
+
}
|
|
197
|
+
args.push(...extraArgs);
|
|
198
|
+
const proc = spawn('yt-dlp', args, {
|
|
199
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
200
|
+
});
|
|
201
|
+
let lastPercent = 0;
|
|
202
|
+
let errorOutput = '';
|
|
203
|
+
proc.stderr.on('data', (data) => {
|
|
204
|
+
const line = data.toString();
|
|
205
|
+
errorOutput += line;
|
|
206
|
+
// Parse progress from yt-dlp output
|
|
207
|
+
const match = line.match(/(\d+\.?\d*)%/);
|
|
208
|
+
if (match && onProgress) {
|
|
209
|
+
const percent = parseFloat(match[1]);
|
|
210
|
+
if (percent > lastPercent) {
|
|
211
|
+
lastPercent = percent;
|
|
212
|
+
onProgress(percent);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
proc.stdout.on('data', (data) => {
|
|
217
|
+
const line = data.toString();
|
|
218
|
+
const match = line.match(/(\d+\.?\d*)%/);
|
|
219
|
+
if (match && onProgress) {
|
|
220
|
+
const percent = parseFloat(match[1]);
|
|
221
|
+
if (percent > lastPercent) {
|
|
222
|
+
lastPercent = percent;
|
|
223
|
+
onProgress(percent);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
proc.on('close', (code) => {
|
|
228
|
+
if (code === 0 && fs.existsSync(destPath)) {
|
|
229
|
+
const stats = fs.statSync(destPath);
|
|
230
|
+
resolve({ success: true, size: stats.size });
|
|
231
|
+
}
|
|
232
|
+
else {
|
|
233
|
+
// Check for common yt-dlp output patterns
|
|
234
|
+
const patterns = fs.readdirSync(dir).filter(f => f.startsWith(path.basename(destPath, path.extname(destPath))));
|
|
235
|
+
if (patterns.length > 0) {
|
|
236
|
+
const actualFile = path.join(dir, patterns[0]);
|
|
237
|
+
const stats = fs.statSync(actualFile);
|
|
238
|
+
resolve({ success: true, size: stats.size });
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
resolve({ success: false, size: 0, error: errorOutput.slice(0, 200) || `Exit code ${code}` });
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
proc.on('error', (err) => {
|
|
246
|
+
resolve({ success: false, size: 0, error: err.message });
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Save document content to file.
|
|
252
|
+
*/
|
|
253
|
+
export async function saveDocument(content, destPath, format = 'markdown', metadata) {
|
|
254
|
+
try {
|
|
255
|
+
const dir = path.dirname(destPath);
|
|
256
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
257
|
+
let output;
|
|
258
|
+
if (format === 'json') {
|
|
259
|
+
output = JSON.stringify({ ...metadata, content }, null, 2);
|
|
260
|
+
}
|
|
261
|
+
else if (format === 'markdown') {
|
|
262
|
+
// Add frontmatter if metadata exists
|
|
263
|
+
const frontmatter = metadata ? `---\n${Object.entries(metadata).map(([k, v]) => `${k}: ${JSON.stringify(v)}`).join('\n')}\n---\n\n` : '';
|
|
264
|
+
output = frontmatter + content;
|
|
265
|
+
}
|
|
266
|
+
else {
|
|
267
|
+
output = content;
|
|
268
|
+
}
|
|
269
|
+
fs.writeFileSync(destPath, output, 'utf-8');
|
|
270
|
+
return { success: true, size: Buffer.byteLength(output, 'utf-8') };
|
|
271
|
+
}
|
|
272
|
+
catch (err) {
|
|
273
|
+
return { success: false, size: 0, error: err.message };
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Sanitize filename by removing invalid characters.
|
|
278
|
+
*/
|
|
279
|
+
export function sanitizeFilename(name, maxLength = 200) {
|
|
280
|
+
return name
|
|
281
|
+
.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_') // Remove invalid chars
|
|
282
|
+
.replace(/\s+/g, '_') // Replace spaces with underscores
|
|
283
|
+
.replace(/_+/g, '_') // Collapse multiple underscores
|
|
284
|
+
.replace(/^_|_$/g, '') // Trim underscores
|
|
285
|
+
.slice(0, maxLength);
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Generate filename from URL if not provided.
|
|
289
|
+
*/
|
|
290
|
+
export function generateFilename(url, index, extension) {
|
|
291
|
+
try {
|
|
292
|
+
const parsedUrl = new URL(url);
|
|
293
|
+
const pathname = parsedUrl.pathname;
|
|
294
|
+
const basename = path.basename(pathname);
|
|
295
|
+
if (basename && basename !== '/' && basename.includes('.')) {
|
|
296
|
+
return sanitizeFilename(basename);
|
|
297
|
+
}
|
|
298
|
+
// Generate from hostname and index
|
|
299
|
+
const ext = extension || detectExtension(url);
|
|
300
|
+
const hostname = parsedUrl.hostname.replace(/^www\./, '');
|
|
301
|
+
return sanitizeFilename(`${hostname}_${index + 1}${ext}`);
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
const ext = extension || '.bin';
|
|
305
|
+
return `download_${index + 1}${ext}`;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Detect file extension from URL.
|
|
310
|
+
*/
|
|
311
|
+
function detectExtension(url) {
|
|
312
|
+
const type = detectContentType(url);
|
|
313
|
+
switch (type) {
|
|
314
|
+
case 'image': return '.jpg';
|
|
315
|
+
case 'video': return '.mp4';
|
|
316
|
+
case 'document': return '.md';
|
|
317
|
+
default: return '.bin';
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Get temp directory for cookie files.
|
|
322
|
+
*/
|
|
323
|
+
export function getTempDir() {
|
|
324
|
+
return path.join(os.tmpdir(), 'opencli-download');
|
|
325
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Download progress display: terminal progress bars, status updates.
|
|
3
|
+
*/
|
|
4
|
+
export interface ProgressBar {
|
|
5
|
+
update(current: number, total: number, label?: string): void;
|
|
6
|
+
complete(success: boolean, message?: string): void;
|
|
7
|
+
fail(error: string): void;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Format bytes as human-readable string (KB, MB, GB).
|
|
11
|
+
*/
|
|
12
|
+
export declare function formatBytes(bytes: number): string;
|
|
13
|
+
/**
|
|
14
|
+
* Format milliseconds as human-readable duration.
|
|
15
|
+
*/
|
|
16
|
+
export declare function formatDuration(ms: number): string;
|
|
17
|
+
/**
|
|
18
|
+
* Create a simple progress bar for terminal display.
|
|
19
|
+
*/
|
|
20
|
+
export declare function createProgressBar(filename: string, index: number, total: number): ProgressBar;
|
|
21
|
+
/**
|
|
22
|
+
* Multi-file download progress tracker.
|
|
23
|
+
*/
|
|
24
|
+
export declare class DownloadProgressTracker {
|
|
25
|
+
private completed;
|
|
26
|
+
private failed;
|
|
27
|
+
private skipped;
|
|
28
|
+
private total;
|
|
29
|
+
private startTime;
|
|
30
|
+
private verbose;
|
|
31
|
+
constructor(total: number, verbose?: boolean);
|
|
32
|
+
onFileStart(filename: string, index: number): ProgressBar | null;
|
|
33
|
+
onFileComplete(success: boolean, skipped?: boolean): void;
|
|
34
|
+
getSummary(): string;
|
|
35
|
+
finish(): void;
|
|
36
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Download progress display: terminal progress bars, status updates.
|
|
3
|
+
*/
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
/**
|
|
6
|
+
* Format bytes as human-readable string (KB, MB, GB).
|
|
7
|
+
*/
|
|
8
|
+
export function formatBytes(bytes) {
|
|
9
|
+
if (bytes === 0)
|
|
10
|
+
return '0 B';
|
|
11
|
+
const k = 1024;
|
|
12
|
+
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
13
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
14
|
+
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Format milliseconds as human-readable duration.
|
|
18
|
+
*/
|
|
19
|
+
export function formatDuration(ms) {
|
|
20
|
+
if (ms < 1000)
|
|
21
|
+
return `${ms}ms`;
|
|
22
|
+
const seconds = Math.floor(ms / 1000);
|
|
23
|
+
if (seconds < 60)
|
|
24
|
+
return `${seconds}s`;
|
|
25
|
+
const minutes = Math.floor(seconds / 60);
|
|
26
|
+
const remainingSeconds = seconds % 60;
|
|
27
|
+
return `${minutes}m ${remainingSeconds}s`;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Create a simple progress bar for terminal display.
|
|
31
|
+
*/
|
|
32
|
+
export function createProgressBar(filename, index, total) {
|
|
33
|
+
const prefix = chalk.dim(`[${index + 1}/${total}]`);
|
|
34
|
+
const truncatedName = filename.length > 40 ? filename.slice(0, 37) + '...' : filename;
|
|
35
|
+
return {
|
|
36
|
+
update(current, totalBytes, label) {
|
|
37
|
+
const percent = totalBytes > 0 ? Math.round((current / totalBytes) * 100) : 0;
|
|
38
|
+
const bar = createBar(percent);
|
|
39
|
+
const size = totalBytes > 0 ? formatBytes(totalBytes) : '';
|
|
40
|
+
const extra = label ? ` ${label}` : '';
|
|
41
|
+
process.stderr.write(`\r${prefix} ${truncatedName} ${bar} ${percent}% ${size}${extra}`);
|
|
42
|
+
},
|
|
43
|
+
complete(success, message) {
|
|
44
|
+
const icon = success ? chalk.green('✓') : chalk.red('✗');
|
|
45
|
+
const msg = message ? ` ${chalk.dim(message)}` : '';
|
|
46
|
+
process.stderr.write(`\r${prefix} ${icon} ${truncatedName}${msg}\n`);
|
|
47
|
+
},
|
|
48
|
+
fail(error) {
|
|
49
|
+
process.stderr.write(`\r${prefix} ${chalk.red('✗')} ${truncatedName} ${chalk.red(error)}\n`);
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Create a progress bar string.
|
|
55
|
+
*/
|
|
56
|
+
function createBar(percent, width = 20) {
|
|
57
|
+
const filled = Math.round((percent / 100) * width);
|
|
58
|
+
const empty = width - filled;
|
|
59
|
+
return chalk.cyan('█'.repeat(filled)) + chalk.dim('░'.repeat(empty));
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Multi-file download progress tracker.
|
|
63
|
+
*/
|
|
64
|
+
export class DownloadProgressTracker {
|
|
65
|
+
completed = 0;
|
|
66
|
+
failed = 0;
|
|
67
|
+
skipped = 0;
|
|
68
|
+
total;
|
|
69
|
+
startTime;
|
|
70
|
+
verbose;
|
|
71
|
+
constructor(total, verbose = true) {
|
|
72
|
+
this.total = total;
|
|
73
|
+
this.startTime = Date.now();
|
|
74
|
+
this.verbose = verbose;
|
|
75
|
+
}
|
|
76
|
+
onFileStart(filename, index) {
|
|
77
|
+
if (!this.verbose)
|
|
78
|
+
return null;
|
|
79
|
+
return createProgressBar(filename, index, this.total);
|
|
80
|
+
}
|
|
81
|
+
onFileComplete(success, skipped = false) {
|
|
82
|
+
if (skipped) {
|
|
83
|
+
this.skipped++;
|
|
84
|
+
}
|
|
85
|
+
else if (success) {
|
|
86
|
+
this.completed++;
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
this.failed++;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
getSummary() {
|
|
93
|
+
const elapsed = formatDuration(Date.now() - this.startTime);
|
|
94
|
+
const parts = [];
|
|
95
|
+
if (this.completed > 0) {
|
|
96
|
+
parts.push(chalk.green(`${this.completed} downloaded`));
|
|
97
|
+
}
|
|
98
|
+
if (this.skipped > 0) {
|
|
99
|
+
parts.push(chalk.yellow(`${this.skipped} skipped`));
|
|
100
|
+
}
|
|
101
|
+
if (this.failed > 0) {
|
|
102
|
+
parts.push(chalk.red(`${this.failed} failed`));
|
|
103
|
+
}
|
|
104
|
+
return `${parts.join(', ')} in ${elapsed}`;
|
|
105
|
+
}
|
|
106
|
+
finish() {
|
|
107
|
+
if (this.verbose) {
|
|
108
|
+
process.stderr.write(`\n${chalk.bold('Download complete:')} ${this.getSummary()}\n`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
package/dist/engine.test.js
CHANGED
|
@@ -11,6 +11,21 @@ describe('discoverClis', () => {
|
|
|
11
11
|
});
|
|
12
12
|
});
|
|
13
13
|
describe('executeCommand', () => {
|
|
14
|
+
it('accepts kebab-case option names after Commander camelCases them', async () => {
|
|
15
|
+
const cmd = cli({
|
|
16
|
+
site: 'test-engine',
|
|
17
|
+
name: 'kebab-arg-test',
|
|
18
|
+
description: 'test command with kebab-case arg',
|
|
19
|
+
browser: false,
|
|
20
|
+
strategy: Strategy.PUBLIC,
|
|
21
|
+
args: [
|
|
22
|
+
{ name: 'note-id', required: true, help: 'Note ID' },
|
|
23
|
+
],
|
|
24
|
+
func: async (_page, kwargs) => [{ noteId: kwargs['note-id'] }],
|
|
25
|
+
});
|
|
26
|
+
const result = await executeCommand(cmd, null, { 'note-id': 'abc123' });
|
|
27
|
+
expect(result).toEqual([{ noteId: 'abc123' }]);
|
|
28
|
+
});
|
|
14
29
|
it('executes a command with func', async () => {
|
|
15
30
|
const cmd = cli({
|
|
16
31
|
site: 'test-engine',
|
package/dist/main.js
CHANGED
|
@@ -8,7 +8,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
8
8
|
import { Command } from 'commander';
|
|
9
9
|
import chalk from 'chalk';
|
|
10
10
|
import { discoverClis, executeCommand } from './engine.js';
|
|
11
|
-
import { fullName, getRegistry, strategyLabel } from './registry.js';
|
|
11
|
+
import { Strategy, fullName, getRegistry, strategyLabel } from './registry.js';
|
|
12
12
|
import { render as renderOutput } from './output.js';
|
|
13
13
|
import { PlaywrightMCP } from './browser/index.js';
|
|
14
14
|
import { browserSession, DEFAULT_BROWSER_COMMAND_TIMEOUT, runWithTimeout } from './runtime.js';
|
|
@@ -205,7 +205,8 @@ for (const [, cmd] of registry) {
|
|
|
205
205
|
for (const arg of cmd.args) {
|
|
206
206
|
if (arg.positional)
|
|
207
207
|
continue;
|
|
208
|
-
const
|
|
208
|
+
const camelName = arg.name.replace(/-([a-z])/g, (_m, ch) => ch.toUpperCase());
|
|
209
|
+
const v = actionOpts[arg.name] ?? actionOpts[camelName];
|
|
209
210
|
if (v !== undefined)
|
|
210
211
|
kwargs[arg.name] = v;
|
|
211
212
|
}
|
|
@@ -214,7 +215,19 @@ for (const [, cmd] of registry) {
|
|
|
214
215
|
process.env.OPENCLI_VERBOSE = '1';
|
|
215
216
|
let result;
|
|
216
217
|
if (cmd.browser) {
|
|
217
|
-
result = await browserSession(PlaywrightMCP, async (page) =>
|
|
218
|
+
result = await browserSession(PlaywrightMCP, async (page) => {
|
|
219
|
+
// Cookie/header strategies require same-origin context for credentialed fetch.
|
|
220
|
+
// In CDP mode the active tab may be on an unrelated domain, causing CORS failures.
|
|
221
|
+
// Navigate to the command's domain first (mirrors cascade command behavior).
|
|
222
|
+
if ((cmd.strategy === Strategy.COOKIE || cmd.strategy === Strategy.HEADER) && cmd.domain) {
|
|
223
|
+
try {
|
|
224
|
+
await page.goto(`https://${cmd.domain}`);
|
|
225
|
+
await page.wait(2);
|
|
226
|
+
}
|
|
227
|
+
catch { }
|
|
228
|
+
}
|
|
229
|
+
return runWithTimeout(executeCommand(cmd, page, kwargs, actionOpts.verbose), { timeout: cmd.timeoutSeconds ?? DEFAULT_BROWSER_COMMAND_TIMEOUT, label: fullName(cmd) });
|
|
230
|
+
});
|
|
218
231
|
}
|
|
219
232
|
else {
|
|
220
233
|
result = await executeCommand(cmd, null, kwargs, actionOpts.verbose);
|
|
@@ -8,6 +8,7 @@ import { stepFetch } from './steps/fetch.js';
|
|
|
8
8
|
import { stepSelect, stepMap, stepFilter, stepSort, stepLimit } from './steps/transform.js';
|
|
9
9
|
import { stepIntercept } from './steps/intercept.js';
|
|
10
10
|
import { stepTap } from './steps/tap.js';
|
|
11
|
+
import { stepDownload } from './steps/download.js';
|
|
11
12
|
const _stepRegistry = new Map();
|
|
12
13
|
/**
|
|
13
14
|
* Get a registered step handler by name.
|
|
@@ -39,3 +40,4 @@ registerStep('sort', stepSort);
|
|
|
39
40
|
registerStep('limit', stepLimit);
|
|
40
41
|
registerStep('intercept', stepIntercept);
|
|
41
42
|
registerStep('tap', stepTap);
|
|
43
|
+
registerStep('download', stepDownload);
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pipeline step: download — file download with concurrency and progress.
|
|
3
|
+
*
|
|
4
|
+
* Supports:
|
|
5
|
+
* - Direct HTTP downloads (images, documents)
|
|
6
|
+
* - yt-dlp integration for video platforms
|
|
7
|
+
* - Browser cookie forwarding for authenticated downloads
|
|
8
|
+
* - Filename templating and deduplication
|
|
9
|
+
*/
|
|
10
|
+
import type { IPage } from '../../types.js';
|
|
11
|
+
export interface DownloadResult {
|
|
12
|
+
status: 'success' | 'skipped' | 'failed';
|
|
13
|
+
path?: string;
|
|
14
|
+
size?: number;
|
|
15
|
+
error?: string;
|
|
16
|
+
duration?: number;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Download step handler for YAML pipelines.
|
|
20
|
+
*
|
|
21
|
+
* Usage in YAML:
|
|
22
|
+
* ```yaml
|
|
23
|
+
* pipeline:
|
|
24
|
+
* - download:
|
|
25
|
+
* url: ${{ item.imageUrl }}
|
|
26
|
+
* dir: ./downloads
|
|
27
|
+
* filename: ${{ item.title }}.jpg
|
|
28
|
+
* concurrency: 5
|
|
29
|
+
* skip_existing: true
|
|
30
|
+
* use_ytdlp: false
|
|
31
|
+
* type: auto
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
export declare function stepDownload(page: IPage | null, params: any, data: any, args: Record<string, any>): Promise<any>;
|