@jackwener/opencli 1.7.6 → 1.7.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/README.md +17 -8
- package/README.zh-CN.md +14 -8
- package/cli-manifest.json +469 -11
- package/clis/51job/company.js +125 -0
- package/clis/51job/detail.js +108 -0
- package/clis/51job/hot.js +55 -0
- package/clis/51job/search.js +79 -0
- package/clis/51job/utils.js +302 -0
- package/clis/51job/utils.test.js +69 -0
- package/clis/amazon/discussion.js +37 -6
- package/clis/amazon/discussion.test.js +147 -32
- package/clis/bilibili/video.js +11 -4
- package/clis/bilibili/video.test.js +51 -0
- package/clis/chatgpt/image.js +1 -1
- package/clis/chatgpt-app/ask.js +3 -19
- package/clis/chatgpt-app/ax.js +132 -1
- package/clis/chatgpt-app/ax.test.js +23 -0
- package/clis/chatgpt-app/send.js +2 -21
- package/clis/deepseek/ask.js +50 -18
- package/clis/deepseek/ask.test.js +195 -2
- package/clis/deepseek/utils.js +113 -29
- package/clis/deepseek/utils.test.js +109 -1
- package/clis/gemini/image.js +1 -1
- package/clis/instagram/download.js +1 -1
- package/clis/powerchina/search.js +250 -0
- package/clis/powerchina/search.test.js +67 -0
- package/clis/sinafinance/stock.js +5 -2
- package/clis/sinafinance/stock.test.js +59 -0
- package/clis/toutiao/articles.js +81 -0
- package/clis/toutiao/articles.test.js +23 -0
- package/clis/twitter/likes.js +3 -2
- package/clis/twitter/search.js +4 -2
- package/clis/twitter/search.test.js +4 -0
- package/clis/twitter/shared.js +28 -0
- package/clis/twitter/shared.test.js +96 -0
- package/clis/twitter/thread.js +3 -1
- package/clis/twitter/timeline.js +3 -2
- package/clis/twitter/tweets.js +3 -2
- package/clis/twitter/tweets.test.js +1 -1
- package/clis/web/read.js +25 -5
- package/clis/web/read.test.js +76 -0
- package/clis/weixin/create-draft.js +225 -0
- package/clis/weixin/drafts.js +65 -0
- package/clis/weixin/drafts.test.js +65 -0
- package/clis/weread/ai-outline.js +170 -0
- package/clis/weread/ai-outline.test.js +83 -0
- package/clis/weread/book.js +57 -44
- package/clis/weread/commands.test.js +24 -0
- package/clis/xiaoyuzhou/podcast-episodes.js +2 -2
- package/clis/xiaoyuzhou/podcast-episodes.test.js +78 -0
- package/dist/src/browser/analyze.d.ts +103 -0
- package/dist/src/browser/analyze.js +230 -0
- package/dist/src/browser/analyze.test.d.ts +1 -0
- package/dist/src/browser/analyze.test.js +164 -0
- package/dist/src/browser/article-extract.d.ts +57 -0
- package/dist/src/browser/article-extract.e2e.test.d.ts +1 -0
- package/dist/src/browser/article-extract.e2e.test.js +105 -0
- package/dist/src/browser/article-extract.js +169 -0
- package/dist/src/browser/article-extract.test.d.ts +1 -0
- package/dist/src/browser/article-extract.test.js +94 -0
- package/dist/src/browser/cdp.js +11 -2
- package/dist/src/browser/verify-fixture.d.ts +59 -0
- package/dist/src/browser/verify-fixture.js +213 -0
- package/dist/src/browser/verify-fixture.test.d.ts +1 -0
- package/dist/src/browser/verify-fixture.test.js +161 -0
- package/dist/src/cli.d.ts +32 -0
- package/dist/src/cli.js +333 -43
- package/dist/src/cli.test.js +257 -1
- package/dist/src/commanderAdapter.js +12 -0
- package/dist/src/commanderAdapter.test.js +11 -0
- package/dist/src/daemon.d.ts +3 -2
- package/dist/src/daemon.js +16 -4
- package/dist/src/daemon.test.d.ts +1 -0
- package/dist/src/daemon.test.js +19 -0
- package/dist/src/download/article-download.d.ts +12 -0
- package/dist/src/download/article-download.js +141 -17
- package/dist/src/download/article-download.test.js +196 -0
- package/dist/src/download/index.js +73 -86
- package/dist/src/errors.js +4 -2
- package/dist/src/errors.test.js +13 -0
- package/dist/src/launcher.d.ts +1 -1
- package/dist/src/launcher.js +3 -3
- package/dist/src/output.js +1 -1
- package/dist/src/output.test.js +6 -0
- package/package.json +5 -1
|
@@ -15,6 +15,20 @@ afterEach(() => {
|
|
|
15
15
|
}
|
|
16
16
|
tempDirs.length = 0;
|
|
17
17
|
});
|
|
18
|
+
async function runAndRead(contentHtml, opts = {}) {
|
|
19
|
+
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'opencli-article-'));
|
|
20
|
+
tempDirs.push(tempDir);
|
|
21
|
+
const result = await downloadArticle({
|
|
22
|
+
title: 'Test Article',
|
|
23
|
+
contentHtml,
|
|
24
|
+
}, {
|
|
25
|
+
output: tempDir,
|
|
26
|
+
downloadImages: false,
|
|
27
|
+
...(opts.cleanSelectors && { cleanSelectors: opts.cleanSelectors }),
|
|
28
|
+
});
|
|
29
|
+
expect(result[0].status).toBe('success');
|
|
30
|
+
return fs.readFileSync(result[0].saved, 'utf8');
|
|
31
|
+
}
|
|
18
32
|
describe('downloadArticle', () => {
|
|
19
33
|
it('returns the saved markdown file path on success', async () => {
|
|
20
34
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'opencli-article-'));
|
|
@@ -36,4 +50,186 @@ describe('downloadArticle', () => {
|
|
|
36
50
|
expect(fs.existsSync(result[0].saved)).toBe(true);
|
|
37
51
|
expect(fs.readFileSync(result[0].saved, 'utf8')).toContain('Hello world');
|
|
38
52
|
});
|
|
53
|
+
describe('markdown pipeline', () => {
|
|
54
|
+
it('converts GFM tables', async () => {
|
|
55
|
+
const md = await runAndRead('<table><thead><tr><th>a</th><th>b</th></tr></thead>' +
|
|
56
|
+
'<tbody><tr><td>1</td><td>2</td></tr></tbody></table>');
|
|
57
|
+
expect(md).toMatch(/\|\s*a\s*\|\s*b\s*\|/);
|
|
58
|
+
expect(md).toMatch(/\|\s*---\s*\|\s*---\s*\|/);
|
|
59
|
+
expect(md).toMatch(/\|\s*1\s*\|\s*2\s*\|/);
|
|
60
|
+
});
|
|
61
|
+
it('converts strikethrough and task lists', async () => {
|
|
62
|
+
const md = await runAndRead('<p><del>gone</del></p>' +
|
|
63
|
+
'<ul><li><input type="checkbox" checked>done</li><li><input type="checkbox">todo</li></ul>');
|
|
64
|
+
expect(md).toContain('~~gone~~');
|
|
65
|
+
expect(md).toContain('[x] done');
|
|
66
|
+
expect(md).toContain('[ ] todo');
|
|
67
|
+
});
|
|
68
|
+
it('strips script / style / noscript / form but keeps iframe as a link', async () => {
|
|
69
|
+
const md = await runAndRead('<p>keep</p>' +
|
|
70
|
+
'<script>alert(1)</script>' +
|
|
71
|
+
'<style>.x{color:red}</style>' +
|
|
72
|
+
'<noscript>nojs</noscript>' +
|
|
73
|
+
'<iframe src="https://www.youtube.com/embed/abc" title="Demo video"></iframe>' +
|
|
74
|
+
'<form><button>click</button></form>');
|
|
75
|
+
expect(md).toContain('keep');
|
|
76
|
+
expect(md).not.toContain('alert');
|
|
77
|
+
expect(md).not.toContain('color:red');
|
|
78
|
+
expect(md).not.toContain('nojs');
|
|
79
|
+
expect(md).not.toContain('click');
|
|
80
|
+
// Iframe degrades to a link preserving the embedded URL.
|
|
81
|
+
expect(md).toContain('[Demo video](https://www.youtube.com/embed/abc)');
|
|
82
|
+
});
|
|
83
|
+
it('strips SVG nodes entirely', async () => {
|
|
84
|
+
const md = await runAndRead('<p>before</p><svg><circle cx="5" cy="5" r="4"/></svg><p>after</p>');
|
|
85
|
+
expect(md).toContain('before');
|
|
86
|
+
expect(md).toContain('after');
|
|
87
|
+
expect(md).not.toContain('svg');
|
|
88
|
+
expect(md).not.toContain('circle');
|
|
89
|
+
});
|
|
90
|
+
it('drops base64 data URI images but keeps regular images', async () => {
|
|
91
|
+
const md = await runAndRead('<p><img alt="inline" src="data:image/png;base64,iVBORw0KGgo="></p>' +
|
|
92
|
+
'<p><img alt="keep" src="https://example.com/a.jpg"></p>');
|
|
93
|
+
expect(md).not.toContain('data:image');
|
|
94
|
+
expect(md).toContain('');
|
|
95
|
+
});
|
|
96
|
+
it('collapses 3+ blank lines and strips lone bullet / middle-dot residue', async () => {
|
|
97
|
+
const md = await runAndRead('<p>top</p>' +
|
|
98
|
+
'<p>-</p>' +
|
|
99
|
+
'<p>·</p>' +
|
|
100
|
+
'<p>bottom</p>');
|
|
101
|
+
expect(md).not.toMatch(/\n{3,}/);
|
|
102
|
+
expect(md).not.toMatch(/^\s*-\s*$/m);
|
|
103
|
+
expect(md).not.toMatch(/^\s*·\s*$/m);
|
|
104
|
+
expect(md).toContain('top');
|
|
105
|
+
expect(md).toContain('bottom');
|
|
106
|
+
});
|
|
107
|
+
it('strips page chrome (header / footer / nav / aside)', async () => {
|
|
108
|
+
const md = await runAndRead('<header><p>page-header-text</p></header>' +
|
|
109
|
+
'<nav><a href="/">home-link</a></nav>' +
|
|
110
|
+
'<p>article-body</p>' +
|
|
111
|
+
'<aside><p>sidebar-text</p></aside>' +
|
|
112
|
+
'<footer><p>page-footer-text</p></footer>');
|
|
113
|
+
expect(md).toContain('article-body');
|
|
114
|
+
expect(md).not.toContain('page-header-text');
|
|
115
|
+
expect(md).not.toContain('home-link');
|
|
116
|
+
expect(md).not.toContain('sidebar-text');
|
|
117
|
+
expect(md).not.toContain('page-footer-text');
|
|
118
|
+
});
|
|
119
|
+
it('cleanSelectors removes matching nodes before conversion', async () => {
|
|
120
|
+
const md = await runAndRead('<p>keep-me</p>' +
|
|
121
|
+
'<div class="vote-card">折叠卡</div>' +
|
|
122
|
+
'<section class="reward-panel">赞赏栏</section>' +
|
|
123
|
+
'<p>also-keep</p>', { cleanSelectors: ['.vote-card', '.reward-panel'] });
|
|
124
|
+
expect(md).toContain('keep-me');
|
|
125
|
+
expect(md).toContain('also-keep');
|
|
126
|
+
expect(md).not.toContain('折叠卡');
|
|
127
|
+
expect(md).not.toContain('赞赏栏');
|
|
128
|
+
});
|
|
129
|
+
it('cleanSelectors silently ignores invalid selectors', async () => {
|
|
130
|
+
const md = await runAndRead('<p>survives</p><div class="x">and-this-too</div>', { cleanSelectors: ['!!!not-a-valid-selector', '.missing'] });
|
|
131
|
+
expect(md).toContain('survives');
|
|
132
|
+
expect(md).toContain('and-this-too');
|
|
133
|
+
});
|
|
134
|
+
it('cleanSelectors keeps valid selectors active when one selector is invalid', async () => {
|
|
135
|
+
const md = await runAndRead('<p>keep</p><div class="vote-card">strip-me</div><p>also-keep</p>', { cleanSelectors: ['!!!not-a-valid-selector', '.vote-card'] });
|
|
136
|
+
expect(md).toContain('keep');
|
|
137
|
+
expect(md).toContain('also-keep');
|
|
138
|
+
expect(md).not.toContain('strip-me');
|
|
139
|
+
});
|
|
140
|
+
it('preserves <video> as inline HTML with src + poster', async () => {
|
|
141
|
+
const md = await runAndRead('<p>before</p>' +
|
|
142
|
+
'<video src="https://cdn.example.com/clip.mp4" poster="https://cdn.example.com/poster.jpg"></video>' +
|
|
143
|
+
'<p>after</p>');
|
|
144
|
+
expect(md).toContain('<video src="https://cdn.example.com/clip.mp4" controls poster="https://cdn.example.com/poster.jpg"></video>');
|
|
145
|
+
expect(md).toContain('before');
|
|
146
|
+
expect(md).toContain('after');
|
|
147
|
+
});
|
|
148
|
+
it('falls back to <source> inside <video> when src attribute is absent', async () => {
|
|
149
|
+
const md = await runAndRead('<video><source src="https://cdn.example.com/clip.mp4" type="video/mp4"></video>');
|
|
150
|
+
expect(md).toContain('<video src="https://cdn.example.com/clip.mp4" controls></video>');
|
|
151
|
+
});
|
|
152
|
+
it('drops <video> with no src and no <source>', async () => {
|
|
153
|
+
const md = await runAndRead('<p>before</p><video></video><p>after</p>');
|
|
154
|
+
expect(md).not.toContain('<video');
|
|
155
|
+
expect(md).toContain('before');
|
|
156
|
+
expect(md).toContain('after');
|
|
157
|
+
});
|
|
158
|
+
it('preserves <audio> as inline HTML', async () => {
|
|
159
|
+
const md = await runAndRead('<audio src="https://cdn.example.com/podcast.mp3"></audio>');
|
|
160
|
+
expect(md).toContain('<audio src="https://cdn.example.com/podcast.mp3" controls></audio>');
|
|
161
|
+
});
|
|
162
|
+
it('degrades <iframe> to a markdown link with title', async () => {
|
|
163
|
+
const md = await runAndRead('<iframe src="https://codepen.io/pen/abc" title="Live demo"></iframe>');
|
|
164
|
+
expect(md).toContain('[Live demo](https://codepen.io/pen/abc)');
|
|
165
|
+
});
|
|
166
|
+
it('defaults iframe title to "Embedded content" when missing', async () => {
|
|
167
|
+
const md = await runAndRead('<iframe src="https://example.com/embed"></iframe>');
|
|
168
|
+
expect(md).toContain('[Embedded content](https://example.com/embed)');
|
|
169
|
+
});
|
|
170
|
+
it('drops <iframe> with no src', async () => {
|
|
171
|
+
const md = await runAndRead('<p>before</p><iframe></iframe><p>after</p>');
|
|
172
|
+
expect(md).not.toContain('iframe');
|
|
173
|
+
expect(md).toContain('before');
|
|
174
|
+
expect(md).toContain('after');
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
describe('stdout mode', () => {
|
|
178
|
+
it('writes markdown to process.stdout and skips file write', async () => {
|
|
179
|
+
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'opencli-article-'));
|
|
180
|
+
tempDirs.push(tempDir);
|
|
181
|
+
const chunks = [];
|
|
182
|
+
const originalWrite = process.stdout.write.bind(process.stdout);
|
|
183
|
+
process.stdout.write = ((chunk) => {
|
|
184
|
+
chunks.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8'));
|
|
185
|
+
return true;
|
|
186
|
+
});
|
|
187
|
+
try {
|
|
188
|
+
const result = await downloadArticle({
|
|
189
|
+
title: 'Piped',
|
|
190
|
+
contentHtml: '<p>Streaming body</p>',
|
|
191
|
+
sourceUrl: 'https://example.com/a',
|
|
192
|
+
}, {
|
|
193
|
+
output: tempDir,
|
|
194
|
+
stdout: true,
|
|
195
|
+
});
|
|
196
|
+
expect(result[0].status).toBe('success');
|
|
197
|
+
expect(result[0].saved).toBe('-');
|
|
198
|
+
expect(fs.readdirSync(tempDir)).toHaveLength(0);
|
|
199
|
+
const emitted = chunks.join('');
|
|
200
|
+
expect(emitted).toContain('# Piped');
|
|
201
|
+
expect(emitted).toContain('Streaming body');
|
|
202
|
+
expect(emitted.endsWith('\n')).toBe(true);
|
|
203
|
+
}
|
|
204
|
+
finally {
|
|
205
|
+
process.stdout.write = originalWrite;
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
it('keeps remote image URLs intact in stdout mode (no download)', async () => {
|
|
209
|
+
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'opencli-article-'));
|
|
210
|
+
tempDirs.push(tempDir);
|
|
211
|
+
const chunks = [];
|
|
212
|
+
const originalWrite = process.stdout.write.bind(process.stdout);
|
|
213
|
+
process.stdout.write = ((chunk) => {
|
|
214
|
+
chunks.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8'));
|
|
215
|
+
return true;
|
|
216
|
+
});
|
|
217
|
+
try {
|
|
218
|
+
await downloadArticle({
|
|
219
|
+
title: 'WithImage',
|
|
220
|
+
contentHtml: '<p><img src="https://example.com/a.jpg"></p>',
|
|
221
|
+
imageUrls: ['https://example.com/a.jpg'],
|
|
222
|
+
}, {
|
|
223
|
+
output: tempDir,
|
|
224
|
+
downloadImages: true,
|
|
225
|
+
stdout: true,
|
|
226
|
+
});
|
|
227
|
+
expect(fs.readdirSync(tempDir)).toHaveLength(0);
|
|
228
|
+
expect(chunks.join('')).toContain('https://example.com/a.jpg');
|
|
229
|
+
}
|
|
230
|
+
finally {
|
|
231
|
+
process.stdout.write = originalWrite;
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
});
|
|
39
235
|
});
|
|
@@ -60,94 +60,79 @@ export function requiresYtdlp(url) {
|
|
|
60
60
|
*/
|
|
61
61
|
export async function httpDownload(url, destPath, options = {}, redirectCount = 0) {
|
|
62
62
|
const { cookies, headers = {}, timeout = 30000, onProgress, maxRedirects = 10 } = options;
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
63
|
+
const requestHeaders = {
|
|
64
|
+
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36',
|
|
65
|
+
...headers,
|
|
66
|
+
};
|
|
67
|
+
if (cookies) {
|
|
68
|
+
requestHeaders['Cookie'] = cookies;
|
|
69
|
+
}
|
|
70
|
+
const tempPath = `${destPath}.tmp`;
|
|
71
|
+
const cleanupTempFile = async () => {
|
|
72
|
+
try {
|
|
73
|
+
await fs.promises.rm(tempPath, { force: true });
|
|
70
74
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
try {
|
|
91
|
-
const response = await fetchWithNodeNetwork(url, {
|
|
92
|
-
headers: requestHeaders,
|
|
93
|
-
signal: controller.signal,
|
|
94
|
-
redirect: 'manual',
|
|
95
|
-
});
|
|
96
|
-
clearTimeout(timer);
|
|
97
|
-
// Handle redirects before creating any file handles.
|
|
98
|
-
if (response.status >= 300 && response.status < 400) {
|
|
99
|
-
const location = response.headers.get('location');
|
|
100
|
-
if (location) {
|
|
101
|
-
if (redirectCount >= maxRedirects) {
|
|
102
|
-
finish({ success: false, size: 0, error: `Too many redirects (> ${maxRedirects})` });
|
|
103
|
-
return;
|
|
104
|
-
}
|
|
105
|
-
const redirectUrl = resolveRedirectUrl(url, location);
|
|
106
|
-
const originalHost = new URL(url).hostname;
|
|
107
|
-
const redirectHost = new URL(redirectUrl).hostname;
|
|
108
|
-
const redirectOptions = originalHost === redirectHost
|
|
109
|
-
? options
|
|
110
|
-
: { ...options, cookies: undefined, headers: stripCookieHeaders(options.headers) };
|
|
111
|
-
finish(await httpDownload(redirectUrl, destPath, redirectOptions, redirectCount + 1));
|
|
112
|
-
return;
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
if (response.status !== 200) {
|
|
116
|
-
finish({ success: false, size: 0, error: `HTTP ${response.status}` });
|
|
117
|
-
return;
|
|
118
|
-
}
|
|
119
|
-
if (!response.body) {
|
|
120
|
-
finish({ success: false, size: 0, error: 'Empty response body' });
|
|
121
|
-
return;
|
|
122
|
-
}
|
|
123
|
-
const totalSize = parseInt(response.headers.get('content-length') || '0', 10);
|
|
124
|
-
let received = 0;
|
|
125
|
-
const progressStream = new Transform({
|
|
126
|
-
transform(chunk, _encoding, callback) {
|
|
127
|
-
received += chunk.length;
|
|
128
|
-
if (onProgress)
|
|
129
|
-
onProgress(received, totalSize);
|
|
130
|
-
callback(null, chunk);
|
|
131
|
-
},
|
|
132
|
-
});
|
|
133
|
-
try {
|
|
134
|
-
await fs.promises.mkdir(path.dirname(destPath), { recursive: true });
|
|
135
|
-
await pipeline(Readable.fromWeb(response.body), progressStream, fs.createWriteStream(tempPath));
|
|
136
|
-
await fs.promises.rename(tempPath, destPath);
|
|
137
|
-
finish({ success: true, size: received });
|
|
138
|
-
}
|
|
139
|
-
catch (err) {
|
|
140
|
-
await cleanupTempFile();
|
|
141
|
-
finish({ success: false, size: 0, error: getErrorMessage(err) });
|
|
75
|
+
catch {
|
|
76
|
+
// Ignore cleanup errors so the original failure is preserved.
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
const controller = new AbortController();
|
|
80
|
+
const timer = setTimeout(() => controller.abort(), timeout);
|
|
81
|
+
try {
|
|
82
|
+
const response = await fetchWithNodeNetwork(url, {
|
|
83
|
+
headers: requestHeaders,
|
|
84
|
+
signal: controller.signal,
|
|
85
|
+
redirect: 'manual',
|
|
86
|
+
});
|
|
87
|
+
clearTimeout(timer);
|
|
88
|
+
// Handle redirects before creating any file handles.
|
|
89
|
+
if (response.status >= 300 && response.status < 400) {
|
|
90
|
+
const location = response.headers.get('location');
|
|
91
|
+
if (location) {
|
|
92
|
+
if (redirectCount >= maxRedirects) {
|
|
93
|
+
return { success: false, size: 0, error: `Too many redirects (> ${maxRedirects})` };
|
|
142
94
|
}
|
|
95
|
+
const redirectUrl = resolveRedirectUrl(url, location);
|
|
96
|
+
const originalHost = new URL(url).hostname;
|
|
97
|
+
const redirectHost = new URL(redirectUrl).hostname;
|
|
98
|
+
const redirectOptions = originalHost === redirectHost
|
|
99
|
+
? options
|
|
100
|
+
: { ...options, cookies: undefined, headers: stripCookieHeaders(options.headers) };
|
|
101
|
+
return httpDownload(redirectUrl, destPath, redirectOptions, redirectCount + 1);
|
|
143
102
|
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
103
|
+
}
|
|
104
|
+
if (response.status !== 200) {
|
|
105
|
+
return { success: false, size: 0, error: `HTTP ${response.status}` };
|
|
106
|
+
}
|
|
107
|
+
if (!response.body) {
|
|
108
|
+
return { success: false, size: 0, error: 'Empty response body' };
|
|
109
|
+
}
|
|
110
|
+
const totalSize = parseInt(response.headers.get('content-length') || '0', 10);
|
|
111
|
+
let received = 0;
|
|
112
|
+
const progressStream = new Transform({
|
|
113
|
+
transform(chunk, _encoding, callback) {
|
|
114
|
+
received += chunk.length;
|
|
115
|
+
if (onProgress)
|
|
116
|
+
onProgress(received, totalSize);
|
|
117
|
+
callback(null, chunk);
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
try {
|
|
121
|
+
await fs.promises.mkdir(path.dirname(destPath), { recursive: true });
|
|
122
|
+
await pipeline(Readable.fromWeb(response.body), progressStream, fs.createWriteStream(tempPath));
|
|
123
|
+
await fs.promises.rename(tempPath, destPath);
|
|
124
|
+
return { success: true, size: received };
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
await cleanupTempFile();
|
|
128
|
+
return { success: false, size: 0, error: getErrorMessage(err) };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
catch (err) {
|
|
132
|
+
clearTimeout(timer);
|
|
133
|
+
await cleanupTempFile();
|
|
134
|
+
return { success: false, size: 0, error: err instanceof Error ? err.message : String(err) };
|
|
135
|
+
}
|
|
151
136
|
}
|
|
152
137
|
export function resolveRedirectUrl(currentUrl, location) {
|
|
153
138
|
return new URL(location, currentUrl).toString();
|
|
@@ -172,7 +157,9 @@ export function exportCookiesToNetscape(cookies, filePath) {
|
|
|
172
157
|
const includeSubdomains = 'TRUE';
|
|
173
158
|
const cookiePath = cookie.path || '/';
|
|
174
159
|
const secure = cookie.secure ? 'TRUE' : 'FALSE';
|
|
175
|
-
const expiry =
|
|
160
|
+
const expiry = typeof cookie.expirationDate === 'number' && cookie.expirationDate > 0
|
|
161
|
+
? Math.floor(cookie.expirationDate)
|
|
162
|
+
: Math.floor(Date.now() / 1000) + 86400 * 365; // fallback: 1 year from now
|
|
176
163
|
const safeName = cookie.name.replace(/[\t\n\r]/g, '');
|
|
177
164
|
const safeValue = cookie.value.replace(/[\t\n\r]/g, '');
|
|
178
165
|
lines.push(`${domain}\t${includeSubdomains}\t${cookiePath}\t${secure}\t${expiry}\t${safeName}\t${safeValue}`);
|
package/dist/src/errors.js
CHANGED
|
@@ -107,11 +107,13 @@ export function getErrorMessage(error) {
|
|
|
107
107
|
return error instanceof Error ? error.message : String(error);
|
|
108
108
|
}
|
|
109
109
|
/** Serialize an error cause chain into a readable string. */
|
|
110
|
-
function serializeCause(cause) {
|
|
110
|
+
function serializeCause(cause, depth = 0) {
|
|
111
|
+
if (depth > 10)
|
|
112
|
+
return '(cause chain truncated)';
|
|
111
113
|
if (cause instanceof Error) {
|
|
112
114
|
const parts = [cause.message];
|
|
113
115
|
if (cause.cause)
|
|
114
|
-
parts.push(` caused by: ${serializeCause(cause.cause)}`);
|
|
116
|
+
parts.push(` caused by: ${serializeCause(cause.cause, depth + 1)}`);
|
|
115
117
|
return parts.join('\n');
|
|
116
118
|
}
|
|
117
119
|
return String(cause);
|
package/dist/src/errors.test.js
CHANGED
|
@@ -93,4 +93,17 @@ describe('toEnvelope', () => {
|
|
|
93
93
|
expect(envelope.error.code).toBe('UNKNOWN');
|
|
94
94
|
expect(envelope.error.message).toBe('string error');
|
|
95
95
|
});
|
|
96
|
+
it('serializes deep cause chains without stack overflow', () => {
|
|
97
|
+
// Build a 20-level deep cause chain — should truncate at depth 10
|
|
98
|
+
let deepErr = new Error('root');
|
|
99
|
+
for (let i = 0; i < 20; i++) {
|
|
100
|
+
deepErr = new Error(`level-${i}`, { cause: deepErr });
|
|
101
|
+
}
|
|
102
|
+
const topErr = new CommandExecutionError('top');
|
|
103
|
+
topErr.cause = deepErr;
|
|
104
|
+
const envelope = toEnvelope(topErr);
|
|
105
|
+
const causeStr = envelope.error.cause ?? '';
|
|
106
|
+
expect(causeStr).toContain('(cause chain truncated)');
|
|
107
|
+
expect(causeStr).not.toContain('root'); // root is beyond depth 10
|
|
108
|
+
});
|
|
96
109
|
});
|
package/dist/src/launcher.d.ts
CHANGED
|
@@ -22,7 +22,7 @@ export declare function detectProcess(processName: string): boolean;
|
|
|
22
22
|
/**
|
|
23
23
|
* Kill a process by name. Sends SIGTERM first, then SIGKILL after grace period.
|
|
24
24
|
*/
|
|
25
|
-
export declare function killProcess(processName: string): void
|
|
25
|
+
export declare function killProcess(processName: string): Promise<void>;
|
|
26
26
|
/**
|
|
27
27
|
* Discover the app installation path on macOS.
|
|
28
28
|
* Uses osascript to resolve the app name to a POSIX path.
|
package/dist/src/launcher.js
CHANGED
|
@@ -52,7 +52,7 @@ export function detectProcess(processName) {
|
|
|
52
52
|
/**
|
|
53
53
|
* Kill a process by name. Sends SIGTERM first, then SIGKILL after grace period.
|
|
54
54
|
*/
|
|
55
|
-
export function killProcess(processName) {
|
|
55
|
+
export async function killProcess(processName) {
|
|
56
56
|
if (process.platform === 'win32')
|
|
57
57
|
return; // pkill not available on Windows
|
|
58
58
|
try {
|
|
@@ -65,7 +65,7 @@ export function killProcess(processName) {
|
|
|
65
65
|
while (Date.now() < deadline) {
|
|
66
66
|
if (!detectProcess(processName))
|
|
67
67
|
return;
|
|
68
|
-
|
|
68
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
69
69
|
}
|
|
70
70
|
try {
|
|
71
71
|
execFileSync('pkill', ['-9', '-x', processName], { stdio: 'pipe' });
|
|
@@ -190,7 +190,7 @@ export async function resolveElectronEndpoint(site) {
|
|
|
190
190
|
throw new CommandExecutionError(`${label} needs to be restarted with CDP enabled.`, `Manually restart: kill the app and relaunch with --remote-debugging-port=${port}`);
|
|
191
191
|
}
|
|
192
192
|
process.stderr.write(` Restarting ${label}...\n`);
|
|
193
|
-
killProcess(processName);
|
|
193
|
+
await killProcess(processName);
|
|
194
194
|
}
|
|
195
195
|
// Step 3: Discover path
|
|
196
196
|
const appPath = discoverAppPath(label);
|
package/dist/src/output.js
CHANGED
|
@@ -74,7 +74,7 @@ function renderTable(data, opts) {
|
|
|
74
74
|
console.log(table.toString());
|
|
75
75
|
const footer = [];
|
|
76
76
|
footer.push(`${rows.length} items`);
|
|
77
|
-
if (opts.elapsed)
|
|
77
|
+
if (opts.elapsed !== undefined)
|
|
78
78
|
footer.push(`${opts.elapsed.toFixed(1)}s`);
|
|
79
79
|
if (opts.source)
|
|
80
80
|
footer.push(opts.source);
|
package/dist/src/output.test.js
CHANGED
|
@@ -30,6 +30,12 @@ describe('output TTY detection', () => {
|
|
|
30
30
|
const out = logSpy.mock.calls.map((c) => c[0]).join('\n');
|
|
31
31
|
expect(JSON.parse(out)).toEqual([{ name: 'alice' }]);
|
|
32
32
|
});
|
|
33
|
+
it('shows elapsed time when elapsed is 0', () => {
|
|
34
|
+
Object.defineProperty(process.stdout, 'isTTY', { value: true, writable: true });
|
|
35
|
+
render([{ name: 'alice' }], { fmt: 'table', columns: ['name'], elapsed: 0 });
|
|
36
|
+
const out = logSpy.mock.calls.map((c) => c[0]).join('\n');
|
|
37
|
+
expect(out).toContain('0.0s');
|
|
38
|
+
});
|
|
33
39
|
it('explicit -f table overrides non-TTY auto-downgrade', () => {
|
|
34
40
|
Object.defineProperty(process.stdout, 'isTTY', { value: false, writable: true });
|
|
35
41
|
render([{ name: 'alice' }], { fmt: 'table', fmtExplicit: true, columns: ['name'] });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jackwener/opencli",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.8",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
@@ -74,18 +74,22 @@
|
|
|
74
74
|
"url": "git+https://github.com/jackwener/opencli.git"
|
|
75
75
|
},
|
|
76
76
|
"dependencies": {
|
|
77
|
+
"@mozilla/readability": "^0.6.0",
|
|
77
78
|
"cli-table3": "^0.6.5",
|
|
78
79
|
"commander": "^14.0.3",
|
|
79
80
|
"js-yaml": "^4.1.0",
|
|
80
81
|
"turndown": "^7.2.2",
|
|
82
|
+
"turndown-plugin-gfm": "^1.0.2",
|
|
81
83
|
"undici": "^8.0.2",
|
|
82
84
|
"ws": "^8.18.0"
|
|
83
85
|
},
|
|
84
86
|
"devDependencies": {
|
|
87
|
+
"@types/jsdom": "^27.0.0",
|
|
85
88
|
"@types/js-yaml": "^4.0.9",
|
|
86
89
|
"@types/node": "^25.5.2",
|
|
87
90
|
"@types/turndown": "^5.0.6",
|
|
88
91
|
"@types/ws": "^8.5.13",
|
|
92
|
+
"jsdom": "^29.0.2",
|
|
89
93
|
"tsx": "^4.19.3",
|
|
90
94
|
"typescript": "^6.0.2",
|
|
91
95
|
"vitepress": "^1.6.4",
|