@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.
Files changed (221) hide show
  1. package/.github/ISSUE_TEMPLATE/bug_report.yml +83 -0
  2. package/.github/ISSUE_TEMPLATE/config.yml +8 -0
  3. package/.github/ISSUE_TEMPLATE/feature_request.yml +42 -0
  4. package/.github/ISSUE_TEMPLATE/new_site_adapter.yml +57 -0
  5. package/.github/dependabot.yml +27 -0
  6. package/.github/pull_request_template.md +24 -0
  7. package/.github/workflows/ci.yml +14 -8
  8. package/.github/workflows/e2e-headed.yml +6 -2
  9. package/.github/workflows/pkg-pr-new.yml +2 -2
  10. package/.github/workflows/release-please.yml +25 -0
  11. package/.github/workflows/release.yml +2 -2
  12. package/.github/workflows/security.yml +36 -0
  13. package/CLI-ELECTRON.md +89 -36
  14. package/CONTRIBUTING.md +167 -0
  15. package/README.md +98 -32
  16. package/README.zh-CN.md +99 -33
  17. package/dist/browser/discover.js +22 -7
  18. package/dist/browser.test.js +23 -0
  19. package/dist/build-manifest.d.ts +26 -0
  20. package/dist/build-manifest.js +132 -60
  21. package/dist/build-manifest.test.d.ts +1 -0
  22. package/dist/build-manifest.test.js +26 -0
  23. package/dist/cli-manifest.json +1415 -29
  24. package/dist/clis/bilibili/download.d.ts +10 -0
  25. package/dist/clis/bilibili/download.js +135 -0
  26. package/dist/clis/chatwise/ask.d.ts +1 -0
  27. package/dist/clis/chatwise/ask.js +76 -0
  28. package/dist/clis/chatwise/export.d.ts +1 -0
  29. package/dist/clis/chatwise/export.js +46 -0
  30. package/dist/clis/chatwise/history.d.ts +1 -0
  31. package/dist/clis/chatwise/history.js +43 -0
  32. package/dist/clis/chatwise/model.d.ts +1 -0
  33. package/dist/clis/chatwise/model.js +81 -0
  34. package/dist/clis/chatwise/new.d.ts +1 -0
  35. package/dist/clis/chatwise/new.js +18 -0
  36. package/dist/clis/chatwise/read.d.ts +1 -0
  37. package/dist/clis/chatwise/read.js +39 -0
  38. package/dist/clis/chatwise/screenshot.d.ts +1 -0
  39. package/dist/clis/chatwise/screenshot.js +27 -0
  40. package/dist/clis/chatwise/send.d.ts +1 -0
  41. package/dist/clis/chatwise/send.js +45 -0
  42. package/dist/clis/chatwise/status.d.ts +1 -0
  43. package/dist/clis/chatwise/status.js +22 -0
  44. package/dist/clis/discord-app/channels.d.ts +1 -0
  45. package/dist/clis/discord-app/channels.js +45 -0
  46. package/dist/clis/discord-app/members.d.ts +1 -0
  47. package/dist/clis/discord-app/members.js +38 -0
  48. package/dist/clis/discord-app/read.d.ts +1 -0
  49. package/dist/clis/discord-app/read.js +45 -0
  50. package/dist/clis/discord-app/search.d.ts +1 -0
  51. package/dist/clis/discord-app/search.js +56 -0
  52. package/dist/clis/discord-app/send.d.ts +1 -0
  53. package/dist/clis/discord-app/send.js +27 -0
  54. package/dist/clis/discord-app/servers.d.ts +1 -0
  55. package/dist/clis/discord-app/servers.js +36 -0
  56. package/dist/clis/discord-app/status.d.ts +1 -0
  57. package/dist/clis/discord-app/status.js +16 -0
  58. package/dist/clis/feishu/new.d.ts +1 -0
  59. package/dist/clis/feishu/new.js +27 -0
  60. package/dist/clis/feishu/read.d.ts +1 -0
  61. package/dist/clis/feishu/read.js +40 -0
  62. package/dist/clis/feishu/search.d.ts +1 -0
  63. package/dist/clis/feishu/search.js +30 -0
  64. package/dist/clis/feishu/send.d.ts +1 -0
  65. package/dist/clis/feishu/send.js +39 -0
  66. package/dist/clis/feishu/status.d.ts +1 -0
  67. package/dist/clis/feishu/status.js +28 -0
  68. package/dist/clis/grok/ask.d.ts +1 -0
  69. package/dist/clis/grok/ask.js +82 -0
  70. package/dist/clis/grok/debug.d.ts +1 -0
  71. package/dist/clis/grok/debug.js +45 -0
  72. package/dist/clis/jimeng/generate.yaml +84 -0
  73. package/dist/clis/jimeng/history.yaml +47 -0
  74. package/dist/clis/linux-do/categories.yaml +41 -0
  75. package/dist/clis/linux-do/category.yaml +49 -0
  76. package/dist/clis/linux-do/hot.yaml +50 -0
  77. package/dist/clis/linux-do/latest.yaml +40 -0
  78. package/dist/clis/linux-do/search.yaml +45 -0
  79. package/dist/clis/linux-do/topic.yaml +38 -0
  80. package/dist/clis/notion/export.d.ts +1 -0
  81. package/dist/clis/notion/export.js +31 -0
  82. package/dist/clis/notion/favorites.d.ts +1 -0
  83. package/dist/clis/notion/favorites.js +84 -0
  84. package/dist/clis/notion/new.d.ts +1 -0
  85. package/dist/clis/notion/new.js +34 -0
  86. package/dist/clis/notion/read.d.ts +1 -0
  87. package/dist/clis/notion/read.js +30 -0
  88. package/dist/clis/notion/search.d.ts +1 -0
  89. package/dist/clis/notion/search.js +46 -0
  90. package/dist/clis/notion/sidebar.d.ts +1 -0
  91. package/dist/clis/notion/sidebar.js +41 -0
  92. package/dist/clis/notion/status.d.ts +1 -0
  93. package/dist/clis/notion/status.js +16 -0
  94. package/dist/clis/notion/write.d.ts +1 -0
  95. package/dist/clis/notion/write.js +40 -0
  96. package/dist/clis/twitter/download.d.ts +8 -0
  97. package/dist/clis/twitter/download.js +204 -0
  98. package/dist/clis/wechat/chats.d.ts +1 -0
  99. package/dist/clis/wechat/chats.js +28 -0
  100. package/dist/clis/wechat/contacts.d.ts +1 -0
  101. package/dist/clis/wechat/contacts.js +28 -0
  102. package/dist/clis/wechat/read.d.ts +1 -0
  103. package/dist/clis/wechat/read.js +58 -0
  104. package/dist/clis/wechat/search.d.ts +1 -0
  105. package/dist/clis/wechat/search.js +31 -0
  106. package/dist/clis/wechat/send.d.ts +1 -0
  107. package/dist/clis/wechat/send.js +42 -0
  108. package/dist/clis/wechat/status.d.ts +1 -0
  109. package/dist/clis/wechat/status.js +29 -0
  110. package/dist/clis/xiaohongshu/creator-note-detail.d.ts +10 -0
  111. package/dist/clis/xiaohongshu/creator-note-detail.js +88 -0
  112. package/dist/clis/xiaohongshu/creator-notes.d.ts +11 -0
  113. package/dist/clis/xiaohongshu/creator-notes.js +109 -0
  114. package/dist/clis/xiaohongshu/creator-profile.d.ts +10 -0
  115. package/dist/clis/xiaohongshu/creator-profile.js +54 -0
  116. package/dist/clis/xiaohongshu/creator-stats.d.ts +10 -0
  117. package/dist/clis/xiaohongshu/creator-stats.js +74 -0
  118. package/dist/clis/xiaohongshu/download.d.ts +7 -0
  119. package/dist/clis/xiaohongshu/download.js +155 -0
  120. package/dist/clis/xiaohongshu/search.js +1 -1
  121. package/dist/clis/xiaohongshu/user-helpers.d.ts +15 -0
  122. package/dist/clis/xiaohongshu/user-helpers.js +67 -0
  123. package/dist/clis/xiaohongshu/user-helpers.test.d.ts +1 -0
  124. package/dist/clis/xiaohongshu/user-helpers.test.js +81 -0
  125. package/dist/clis/xiaohongshu/user.js +46 -29
  126. package/dist/clis/zhihu/download.d.ts +11 -0
  127. package/dist/clis/zhihu/download.js +186 -0
  128. package/dist/clis/zhihu/download.test.d.ts +1 -0
  129. package/dist/clis/zhihu/download.test.js +10 -0
  130. package/dist/download/index.d.ts +79 -0
  131. package/dist/download/index.js +325 -0
  132. package/dist/download/progress.d.ts +36 -0
  133. package/dist/download/progress.js +111 -0
  134. package/dist/engine.test.js +15 -0
  135. package/dist/main.js +16 -3
  136. package/dist/pipeline/registry.js +2 -0
  137. package/dist/pipeline/steps/download.d.ts +34 -0
  138. package/dist/pipeline/steps/download.js +251 -0
  139. package/dist/pipeline/template.js +28 -0
  140. package/package.json +4 -3
  141. package/scripts/test-site.mjs +70 -0
  142. package/src/browser/discover.ts +23 -7
  143. package/src/browser.test.ts +23 -0
  144. package/src/build-manifest.test.ts +28 -0
  145. package/src/build-manifest.ts +147 -57
  146. package/src/clis/bilibili/download.ts +161 -0
  147. package/src/clis/chatwise/README.md +38 -0
  148. package/src/clis/chatwise/README.zh-CN.md +38 -0
  149. package/src/clis/chatwise/ask.ts +87 -0
  150. package/src/clis/chatwise/export.ts +51 -0
  151. package/src/clis/chatwise/history.ts +47 -0
  152. package/src/clis/chatwise/model.ts +87 -0
  153. package/src/clis/chatwise/new.ts +21 -0
  154. package/src/clis/chatwise/read.ts +42 -0
  155. package/src/clis/chatwise/screenshot.ts +33 -0
  156. package/src/clis/chatwise/send.ts +50 -0
  157. package/src/clis/chatwise/status.ts +25 -0
  158. package/src/clis/discord-app/README.md +28 -0
  159. package/src/clis/discord-app/README.zh-CN.md +28 -0
  160. package/src/clis/discord-app/channels.ts +48 -0
  161. package/src/clis/discord-app/members.ts +41 -0
  162. package/src/clis/discord-app/read.ts +49 -0
  163. package/src/clis/discord-app/search.ts +64 -0
  164. package/src/clis/discord-app/send.ts +32 -0
  165. package/src/clis/discord-app/servers.ts +39 -0
  166. package/src/clis/discord-app/status.ts +18 -0
  167. package/src/clis/feishu/README.md +20 -0
  168. package/src/clis/feishu/README.zh-CN.md +20 -0
  169. package/src/clis/feishu/new.ts +32 -0
  170. package/src/clis/feishu/read.ts +48 -0
  171. package/src/clis/feishu/search.ts +35 -0
  172. package/src/clis/feishu/send.ts +46 -0
  173. package/src/clis/feishu/status.ts +34 -0
  174. package/src/clis/grok/ask.ts +90 -0
  175. package/src/clis/grok/debug.ts +49 -0
  176. package/src/clis/jimeng/generate.yaml +84 -0
  177. package/src/clis/jimeng/history.yaml +47 -0
  178. package/src/clis/linux-do/categories.yaml +41 -0
  179. package/src/clis/linux-do/category.yaml +49 -0
  180. package/src/clis/linux-do/hot.yaml +50 -0
  181. package/src/clis/linux-do/latest.yaml +40 -0
  182. package/src/clis/linux-do/search.yaml +45 -0
  183. package/src/clis/linux-do/topic.yaml +38 -0
  184. package/src/clis/notion/README.md +29 -0
  185. package/src/clis/notion/README.zh-CN.md +29 -0
  186. package/src/clis/notion/export.ts +36 -0
  187. package/src/clis/notion/favorites.ts +87 -0
  188. package/src/clis/notion/new.ts +39 -0
  189. package/src/clis/notion/read.ts +33 -0
  190. package/src/clis/notion/search.ts +54 -0
  191. package/src/clis/notion/sidebar.ts +44 -0
  192. package/src/clis/notion/status.ts +18 -0
  193. package/src/clis/notion/write.ts +45 -0
  194. package/src/clis/twitter/download.ts +227 -0
  195. package/src/clis/wechat/README.md +28 -0
  196. package/src/clis/wechat/README.zh-CN.md +28 -0
  197. package/src/clis/wechat/chats.ts +33 -0
  198. package/src/clis/wechat/contacts.ts +33 -0
  199. package/src/clis/wechat/read.ts +72 -0
  200. package/src/clis/wechat/search.ts +36 -0
  201. package/src/clis/wechat/send.ts +49 -0
  202. package/src/clis/wechat/status.ts +35 -0
  203. package/src/clis/xiaohongshu/creator-note-detail.ts +95 -0
  204. package/src/clis/xiaohongshu/creator-notes.ts +116 -0
  205. package/src/clis/xiaohongshu/creator-profile.ts +60 -0
  206. package/src/clis/xiaohongshu/creator-stats.ts +81 -0
  207. package/src/clis/xiaohongshu/download.ts +173 -0
  208. package/src/clis/xiaohongshu/search.ts +1 -1
  209. package/src/clis/xiaohongshu/user-helpers.test.ts +106 -0
  210. package/src/clis/xiaohongshu/user-helpers.ts +85 -0
  211. package/src/clis/xiaohongshu/user.ts +52 -32
  212. package/src/clis/zhihu/download.test.ts +12 -0
  213. package/src/clis/zhihu/download.ts +223 -0
  214. package/src/download/index.ts +395 -0
  215. package/src/download/progress.ts +125 -0
  216. package/src/engine.test.ts +17 -0
  217. package/src/main.ts +12 -3
  218. package/src/pipeline/registry.ts +2 -0
  219. package/src/pipeline/steps/download.ts +310 -0
  220. package/src/pipeline/template.ts +26 -0
  221. package/tests/e2e/browser-auth.test.ts +25 -0
@@ -0,0 +1,310 @@
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
+
11
+ import * as fs from 'node:fs';
12
+ import * as path from 'node:path';
13
+ import * as os from 'node:os';
14
+ import type { IPage } from '../../types.js';
15
+ import { render } from '../template.js';
16
+ import {
17
+ httpDownload,
18
+ ytdlpDownload,
19
+ saveDocument,
20
+ detectContentType,
21
+ requiresYtdlp,
22
+ sanitizeFilename,
23
+ generateFilename,
24
+ exportCookiesToNetscape,
25
+ getTempDir,
26
+ } from '../../download/index.js';
27
+ import { DownloadProgressTracker, formatBytes } from '../../download/progress.js';
28
+
29
+ export interface DownloadResult {
30
+ status: 'success' | 'skipped' | 'failed';
31
+ path?: string;
32
+ size?: number;
33
+ error?: string;
34
+ duration?: number;
35
+ }
36
+
37
+ /**
38
+ * Simple async concurrency limiter for downloads.
39
+ */
40
+ async function mapConcurrent<T, R>(
41
+ items: T[],
42
+ limit: number,
43
+ fn: (item: T, index: number) => Promise<R>,
44
+ ): Promise<R[]> {
45
+ const results: R[] = new Array(items.length);
46
+ let index = 0;
47
+
48
+ async function worker() {
49
+ while (index < items.length) {
50
+ const i = index++;
51
+ results[i] = await fn(items[i], i);
52
+ }
53
+ }
54
+
55
+ const workers = Array.from({ length: Math.min(limit, items.length) }, () => worker());
56
+ await Promise.all(workers);
57
+ return results;
58
+ }
59
+
60
+ /**
61
+ * Extract cookies from browser page.
62
+ */
63
+ async function extractBrowserCookies(page: IPage, domain?: string): Promise<string> {
64
+ try {
65
+ // Use browser evaluate to get document.cookie
66
+ const cookieString = await page.evaluate(`(() => document.cookie)()`);
67
+ return typeof cookieString === 'string' ? cookieString : '';
68
+ } catch {
69
+ return '';
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Extract cookies as array for yt-dlp Netscape format.
75
+ */
76
+ async function extractCookiesArray(
77
+ page: IPage,
78
+ domain: string,
79
+ ): Promise<Array<{ name: string; value: string; domain: string; path: string; secure: boolean; httpOnly: boolean }>> {
80
+ try {
81
+ const cookieString = await extractBrowserCookies(page);
82
+ if (!cookieString) return [];
83
+
84
+ return cookieString.split(';').map((c) => {
85
+ const [name, ...rest] = c.trim().split('=');
86
+ return {
87
+ name: name || '',
88
+ value: rest.join('=') || '',
89
+ domain,
90
+ path: '/',
91
+ secure: true,
92
+ httpOnly: false,
93
+ };
94
+ }).filter((c) => c.name);
95
+ } catch {
96
+ return [];
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Download step handler for YAML pipelines.
102
+ *
103
+ * Usage in YAML:
104
+ * ```yaml
105
+ * pipeline:
106
+ * - download:
107
+ * url: ${{ item.imageUrl }}
108
+ * dir: ./downloads
109
+ * filename: ${{ item.title }}.jpg
110
+ * concurrency: 5
111
+ * skip_existing: true
112
+ * use_ytdlp: false
113
+ * type: auto
114
+ * ```
115
+ */
116
+ export async function stepDownload(
117
+ page: IPage | null,
118
+ params: any,
119
+ data: any,
120
+ args: Record<string, any>,
121
+ ): Promise<any> {
122
+ // Parse parameters with defaults
123
+ const urlTemplate = typeof params === 'string' ? params : (params?.url ?? '');
124
+ const dirTemplate = params?.dir ?? './downloads';
125
+ const filenameTemplate = params?.filename ?? '';
126
+ const concurrency = typeof params?.concurrency === 'number' ? params.concurrency : 3;
127
+ const skipExisting = params?.skip_existing !== false;
128
+ const timeout = typeof params?.timeout === 'number' ? params.timeout * 1000 : 30000;
129
+ const useYtdlp = params?.use_ytdlp ?? false;
130
+ const ytdlpArgs = Array.isArray(params?.ytdlp_args) ? params.ytdlp_args : [];
131
+ const contentType = params?.type ?? 'auto';
132
+ const showProgress = params?.progress !== false;
133
+ const contentTemplate = params?.content;
134
+ const metadataTemplate = params?.metadata;
135
+
136
+ // Resolve output directory
137
+ const dir = String(render(dirTemplate, { args, data }));
138
+ fs.mkdirSync(dir, { recursive: true });
139
+
140
+ // Normalize data to array
141
+ const items: any[] = Array.isArray(data) ? data : data ? [data] : [];
142
+ if (items.length === 0) {
143
+ return [];
144
+ }
145
+
146
+ // Create progress tracker
147
+ const tracker = new DownloadProgressTracker(items.length, showProgress);
148
+
149
+ // Extract cookies if browser is available
150
+ let cookies = '';
151
+ let cookiesFile: string | undefined;
152
+
153
+ if (page) {
154
+ cookies = await extractBrowserCookies(page);
155
+
156
+ // For yt-dlp, we need to export cookies to Netscape format
157
+ if (useYtdlp || items.some((item, index) => {
158
+ const url = String(render(urlTemplate, { args, data, item, index }));
159
+ return requiresYtdlp(url);
160
+ })) {
161
+ try {
162
+ // Try to get domain from first URL
163
+ const firstUrl = String(render(urlTemplate, { args, data, item: items[0], index: 0 }));
164
+ const domain = new URL(firstUrl).hostname;
165
+ const cookiesArray = await extractCookiesArray(page, domain);
166
+
167
+ if (cookiesArray.length > 0) {
168
+ const tempDir = getTempDir();
169
+ fs.mkdirSync(tempDir, { recursive: true });
170
+ cookiesFile = path.join(tempDir, `cookies_${Date.now()}.txt`);
171
+ exportCookiesToNetscape(cookiesArray, cookiesFile);
172
+ }
173
+ } catch {
174
+ // Ignore cookie extraction errors
175
+ }
176
+ }
177
+ }
178
+
179
+ // Process downloads with concurrency
180
+ const results = await mapConcurrent(items, concurrency, async (item, index): Promise<any> => {
181
+ const startTime = Date.now();
182
+
183
+ // Render URL
184
+ const url = String(render(urlTemplate, { args, data, item, index }));
185
+ if (!url) {
186
+ tracker.onFileComplete(false);
187
+ return {
188
+ ...item,
189
+ _download: { status: 'failed', error: 'Empty URL' } as DownloadResult,
190
+ };
191
+ }
192
+
193
+ // Render filename
194
+ let filename: string;
195
+ if (filenameTemplate) {
196
+ filename = String(render(filenameTemplate, { args, data, item, index }));
197
+ } else {
198
+ filename = generateFilename(url, index);
199
+ }
200
+ filename = sanitizeFilename(filename);
201
+
202
+ const destPath = path.join(dir, filename);
203
+
204
+ // Check if file exists and skip_existing is true
205
+ if (skipExisting && fs.existsSync(destPath)) {
206
+ tracker.onFileComplete(true, true);
207
+ return {
208
+ ...item,
209
+ _download: {
210
+ status: 'skipped',
211
+ path: destPath,
212
+ size: fs.statSync(destPath).size,
213
+ } as DownloadResult,
214
+ };
215
+ }
216
+
217
+ // Create progress bar for this file
218
+ const progressBar = tracker.onFileStart(filename, index);
219
+
220
+ // Determine download method
221
+ const detectedType = contentType === 'auto' ? detectContentType(url) : contentType;
222
+ const shouldUseYtdlp = useYtdlp || (detectedType === 'video' && requiresYtdlp(url));
223
+
224
+ let result: { success: boolean; size: number; error?: string };
225
+
226
+ try {
227
+ if (detectedType === 'document' && contentTemplate) {
228
+ // Save extracted content as document
229
+ const content = String(render(contentTemplate, { args, data, item, index }));
230
+ const metadata = metadataTemplate
231
+ ? Object.fromEntries(
232
+ Object.entries(metadataTemplate).map(([k, v]) => [k, render(v, { args, data, item, index })]),
233
+ )
234
+ : undefined;
235
+
236
+ const ext = path.extname(filename).toLowerCase();
237
+ const format = ext === '.json' ? 'json' : ext === '.html' ? 'html' : 'markdown';
238
+ result = await saveDocument(content, destPath, format, metadata);
239
+
240
+ if (progressBar) {
241
+ progressBar.complete(result.success, result.success ? formatBytes(result.size) : undefined);
242
+ }
243
+ } else if (shouldUseYtdlp) {
244
+ // Use yt-dlp for video downloads
245
+ result = await ytdlpDownload(url, destPath, {
246
+ cookiesFile,
247
+ extraArgs: ytdlpArgs,
248
+ onProgress: (percent) => {
249
+ if (progressBar) {
250
+ progressBar.update(percent, 100);
251
+ }
252
+ },
253
+ });
254
+
255
+ if (progressBar) {
256
+ progressBar.complete(result.success, result.success ? formatBytes(result.size) : undefined);
257
+ }
258
+ } else {
259
+ // Direct HTTP download
260
+ result = await httpDownload(url, destPath, {
261
+ cookies,
262
+ timeout,
263
+ onProgress: (received, total) => {
264
+ if (progressBar) {
265
+ progressBar.update(received, total);
266
+ }
267
+ },
268
+ });
269
+
270
+ if (progressBar) {
271
+ progressBar.complete(result.success, result.success ? formatBytes(result.size) : undefined);
272
+ }
273
+ }
274
+ } catch (err: any) {
275
+ result = { success: false, size: 0, error: err.message };
276
+ if (progressBar) {
277
+ progressBar.fail(err.message);
278
+ }
279
+ }
280
+
281
+ tracker.onFileComplete(result.success);
282
+
283
+ const duration = Date.now() - startTime;
284
+
285
+ return {
286
+ ...item,
287
+ _download: {
288
+ status: result.success ? 'success' : 'failed',
289
+ path: result.success ? destPath : undefined,
290
+ size: result.size,
291
+ error: result.error,
292
+ duration,
293
+ } as DownloadResult,
294
+ };
295
+ });
296
+
297
+ // Cleanup temp cookie file
298
+ if (cookiesFile && fs.existsSync(cookiesFile)) {
299
+ try {
300
+ fs.unlinkSync(cookiesFile);
301
+ } catch {
302
+ // Ignore cleanup errors
303
+ }
304
+ }
305
+
306
+ // Show summary
307
+ tracker.finish();
308
+
309
+ return results;
310
+ }
@@ -119,6 +119,32 @@ function applyFilter(filterExpr: string, value: any): any {
119
119
  return Array.isArray(value) ? value[value.length - 1] : value;
120
120
  case 'json':
121
121
  return JSON.stringify(value ?? null);
122
+ case 'slugify':
123
+ // Convert to URL-safe slug
124
+ return typeof value === 'string'
125
+ ? value
126
+ .toLowerCase()
127
+ .replace(/[^\p{L}\p{N}]+/gu, '-')
128
+ .replace(/^-|-$/g, '')
129
+ : value;
130
+ case 'sanitize':
131
+ // Remove invalid filename characters
132
+ return typeof value === 'string'
133
+ ? value.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_')
134
+ : value;
135
+ case 'ext': {
136
+ // Extract file extension from URL or path
137
+ if (typeof value !== 'string') return value;
138
+ const lastDot = value.lastIndexOf('.');
139
+ const lastSlash = Math.max(value.lastIndexOf('/'), value.lastIndexOf('\\'));
140
+ return lastDot > lastSlash ? value.slice(lastDot) : '';
141
+ }
142
+ case 'basename': {
143
+ // Extract filename from URL or path
144
+ if (typeof value !== 'string') return value;
145
+ const parts = value.split(/[/\\]/);
146
+ return parts[parts.length - 1] || value;
147
+ }
122
148
  default:
123
149
  return value;
124
150
  }
@@ -79,6 +79,31 @@ describe('login-required commands — graceful failure', () => {
79
79
  await expectGracefulAuthFailure(['xueqiu', 'watchlist', '-f', 'json'], 'xueqiu watchlist');
80
80
  }, 60_000);
81
81
 
82
+ // ── linux-do (requires login — all endpoints need authentication) ──
83
+ it('linux-do hot fails gracefully without login', async () => {
84
+ await expectGracefulAuthFailure(['linux-do', 'hot', '--limit', '3', '-f', 'json'], 'linux-do hot');
85
+ }, 60_000);
86
+
87
+ it('linux-do latest fails gracefully without login', async () => {
88
+ await expectGracefulAuthFailure(['linux-do', 'latest', '--limit', '3', '-f', 'json'], 'linux-do latest');
89
+ }, 60_000);
90
+
91
+ it('linux-do categories fails gracefully without login', async () => {
92
+ await expectGracefulAuthFailure(['linux-do', 'categories', '--limit', '3', '-f', 'json'], 'linux-do categories');
93
+ }, 60_000);
94
+
95
+ it('linux-do category fails gracefully without login', async () => {
96
+ await expectGracefulAuthFailure(['linux-do', 'category', '--slug', 'general', '--id', '1', '--limit', '3', '-f', 'json'], 'linux-do category');
97
+ }, 60_000);
98
+
99
+ it('linux-do topic fails gracefully without login', async () => {
100
+ await expectGracefulAuthFailure(['linux-do', 'topic', '--id', '1', '-f', 'json'], 'linux-do topic');
101
+ }, 60_000);
102
+
103
+ it('linux-do search fails gracefully without login', async () => {
104
+ await expectGracefulAuthFailure(['linux-do', 'search', '--keyword', 'test', '--limit', '3', '-f', 'json'], 'linux-do search');
105
+ }, 60_000);
106
+
82
107
  // ── xiaohongshu (requires login) ──
83
108
  it('xiaohongshu feed fails gracefully without login', async () => {
84
109
  await expectGracefulAuthFailure(['xiaohongshu', 'feed', '--limit', '3', '-f', 'json'], 'xiaohongshu feed');