@jackwener/opencli 1.0.0 → 1.0.3

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 (171) hide show
  1. package/.github/workflows/build-extension.yml +62 -0
  2. package/.github/workflows/ci.yml +6 -6
  3. package/.github/workflows/e2e-headed.yml +2 -2
  4. package/.github/workflows/pkg-pr-new.yml +2 -2
  5. package/.github/workflows/release.yml +2 -5
  6. package/.github/workflows/security.yml +2 -2
  7. package/CDP.md +1 -1
  8. package/CDP.zh-CN.md +1 -1
  9. package/README.md +35 -8
  10. package/README.zh-CN.md +35 -8
  11. package/SKILL.md +3 -5
  12. package/dist/browser/cdp.d.ts +27 -0
  13. package/dist/browser/cdp.js +295 -0
  14. package/dist/browser/daemon-client.d.ts +1 -1
  15. package/dist/browser/index.d.ts +4 -2
  16. package/dist/browser/index.js +5 -5
  17. package/dist/browser/mcp.d.ts +5 -8
  18. package/dist/browser/mcp.js +9 -10
  19. package/dist/browser/page.d.ts +8 -1
  20. package/dist/browser/page.js +25 -40
  21. package/dist/browser/utils.d.ts +10 -0
  22. package/dist/browser/utils.js +27 -0
  23. package/dist/browser.test.js +48 -7
  24. package/dist/chaoxing.d.ts +58 -0
  25. package/dist/chaoxing.js +225 -0
  26. package/dist/chaoxing.test.d.ts +1 -0
  27. package/dist/chaoxing.test.js +38 -0
  28. package/dist/cli-manifest.json +597 -14
  29. package/dist/cli.d.ts +1 -0
  30. package/dist/cli.js +197 -0
  31. package/dist/clis/apple-podcasts/episodes.d.ts +1 -0
  32. package/dist/clis/apple-podcasts/episodes.js +28 -0
  33. package/dist/clis/apple-podcasts/search.d.ts +1 -0
  34. package/dist/clis/apple-podcasts/search.js +29 -0
  35. package/dist/clis/apple-podcasts/top.d.ts +1 -0
  36. package/dist/clis/apple-podcasts/top.js +34 -0
  37. package/dist/clis/apple-podcasts/utils.d.ts +11 -0
  38. package/dist/clis/apple-podcasts/utils.js +30 -0
  39. package/dist/clis/apple-podcasts/utils.test.d.ts +1 -0
  40. package/dist/clis/apple-podcasts/utils.test.js +57 -0
  41. package/dist/clis/boss/chatlist.d.ts +1 -0
  42. package/dist/clis/boss/chatlist.js +50 -0
  43. package/dist/clis/boss/chatmsg.d.ts +1 -0
  44. package/dist/clis/boss/chatmsg.js +73 -0
  45. package/dist/clis/boss/send.d.ts +1 -0
  46. package/dist/clis/boss/send.js +176 -0
  47. package/dist/clis/chaoxing/assignments.d.ts +1 -0
  48. package/dist/clis/chaoxing/assignments.js +74 -0
  49. package/dist/clis/chaoxing/exams.d.ts +1 -0
  50. package/dist/clis/chaoxing/exams.js +74 -0
  51. package/dist/clis/chatgpt/ask.js +15 -14
  52. package/dist/clis/chatgpt/ax.d.ts +1 -0
  53. package/dist/clis/chatgpt/ax.js +78 -0
  54. package/dist/clis/chatgpt/read.js +5 -6
  55. package/dist/clis/chatwise/history.js +18 -1
  56. package/dist/clis/discord-app/channels.js +33 -21
  57. package/dist/clis/twitter/accept.d.ts +1 -0
  58. package/dist/clis/twitter/accept.js +202 -0
  59. package/dist/clis/twitter/followers.js +30 -22
  60. package/dist/clis/twitter/following.js +19 -14
  61. package/dist/clis/twitter/notifications.js +29 -22
  62. package/dist/clis/twitter/post.js +9 -2
  63. package/dist/clis/twitter/reply-dm.d.ts +1 -0
  64. package/dist/clis/twitter/reply-dm.js +181 -0
  65. package/dist/clis/twitter/search.js +30 -11
  66. package/dist/clis/weread/book.d.ts +1 -0
  67. package/dist/clis/weread/book.js +26 -0
  68. package/dist/clis/weread/highlights.d.ts +1 -0
  69. package/dist/clis/weread/highlights.js +23 -0
  70. package/dist/clis/weread/notebooks.d.ts +1 -0
  71. package/dist/clis/weread/notebooks.js +21 -0
  72. package/dist/clis/weread/notes.d.ts +1 -0
  73. package/dist/clis/weread/notes.js +29 -0
  74. package/dist/clis/weread/ranking.d.ts +1 -0
  75. package/dist/clis/weread/ranking.js +28 -0
  76. package/dist/clis/weread/search.d.ts +1 -0
  77. package/dist/clis/weread/search.js +25 -0
  78. package/dist/clis/weread/shelf.d.ts +1 -0
  79. package/dist/clis/weread/shelf.js +24 -0
  80. package/dist/clis/weread/utils.d.ts +20 -0
  81. package/dist/clis/weread/utils.js +72 -0
  82. package/dist/clis/weread/utils.test.d.ts +1 -0
  83. package/dist/clis/weread/utils.test.js +85 -0
  84. package/dist/clis/xiaohongshu/download.d.ts +1 -1
  85. package/dist/clis/xiaohongshu/download.js +1 -1
  86. package/dist/daemon.js +2 -2
  87. package/dist/doctor.d.ts +0 -21
  88. package/dist/doctor.js +2 -24
  89. package/dist/engine.js +24 -13
  90. package/dist/explore.js +46 -101
  91. package/dist/main.js +4 -203
  92. package/dist/output.d.ts +1 -1
  93. package/dist/registry.d.ts +3 -3
  94. package/dist/runtime.d.ts +1 -4
  95. package/dist/runtime.js +1 -4
  96. package/dist/scripts/framework.d.ts +4 -0
  97. package/dist/scripts/framework.js +21 -0
  98. package/dist/scripts/interact.d.ts +4 -0
  99. package/dist/scripts/interact.js +20 -0
  100. package/dist/scripts/store.d.ts +9 -0
  101. package/dist/scripts/store.js +44 -0
  102. package/dist/setup.js +2 -2
  103. package/dist/synthesize.js +1 -1
  104. package/extension/dist/background.js +392 -0
  105. package/extension/manifest.json +3 -3
  106. package/extension/package.json +1 -1
  107. package/extension/src/background.ts +101 -24
  108. package/extension/src/protocol.ts +1 -1
  109. package/package.json +1 -1
  110. package/src/browser/cdp.ts +295 -0
  111. package/src/browser/daemon-client.ts +1 -1
  112. package/src/browser/index.ts +5 -6
  113. package/src/browser/mcp.ts +14 -15
  114. package/src/browser/page.ts +25 -41
  115. package/src/browser/utils.ts +27 -0
  116. package/src/browser.test.ts +52 -6
  117. package/src/chaoxing.test.ts +45 -0
  118. package/src/chaoxing.ts +268 -0
  119. package/src/cli.ts +185 -0
  120. package/src/clis/antigravity/SKILL.md +5 -0
  121. package/src/clis/apple-podcasts/episodes.ts +28 -0
  122. package/src/clis/apple-podcasts/search.ts +29 -0
  123. package/src/clis/apple-podcasts/top.ts +34 -0
  124. package/src/clis/apple-podcasts/utils.test.ts +72 -0
  125. package/src/clis/apple-podcasts/utils.ts +37 -0
  126. package/src/clis/boss/chatlist.ts +50 -0
  127. package/src/clis/boss/chatmsg.ts +70 -0
  128. package/src/clis/boss/send.ts +193 -0
  129. package/src/clis/chaoxing/README.md +36 -0
  130. package/src/clis/chaoxing/README.zh-CN.md +35 -0
  131. package/src/clis/chaoxing/assignments.ts +88 -0
  132. package/src/clis/chaoxing/exams.ts +88 -0
  133. package/src/clis/chatgpt/ask.ts +14 -15
  134. package/src/clis/chatgpt/ax.ts +81 -0
  135. package/src/clis/chatgpt/read.ts +5 -7
  136. package/src/clis/chatwise/history.ts +15 -1
  137. package/src/clis/discord-app/channels.ts +33 -21
  138. package/src/clis/twitter/accept.ts +213 -0
  139. package/src/clis/twitter/followers.ts +36 -29
  140. package/src/clis/twitter/following.ts +25 -20
  141. package/src/clis/twitter/notifications.ts +34 -27
  142. package/src/clis/twitter/post.ts +9 -2
  143. package/src/clis/twitter/reply-dm.ts +193 -0
  144. package/src/clis/twitter/search.ts +34 -12
  145. package/src/clis/weread/book.ts +28 -0
  146. package/src/clis/weread/highlights.ts +25 -0
  147. package/src/clis/weread/notebooks.ts +23 -0
  148. package/src/clis/weread/notes.ts +31 -0
  149. package/src/clis/weread/ranking.ts +29 -0
  150. package/src/clis/weread/search.ts +26 -0
  151. package/src/clis/weread/shelf.ts +26 -0
  152. package/src/clis/weread/utils.test.ts +104 -0
  153. package/src/clis/weread/utils.ts +74 -0
  154. package/src/clis/xiaohongshu/download.ts +1 -1
  155. package/src/daemon.ts +2 -2
  156. package/src/doctor.ts +2 -19
  157. package/src/engine.ts +20 -13
  158. package/src/explore.ts +51 -100
  159. package/src/main.ts +4 -186
  160. package/src/output.ts +12 -12
  161. package/src/registry.ts +3 -3
  162. package/src/runtime.ts +2 -6
  163. package/src/scripts/framework.ts +20 -0
  164. package/src/scripts/interact.ts +22 -0
  165. package/src/scripts/store.ts +40 -0
  166. package/src/setup.ts +2 -2
  167. package/src/synthesize.ts +1 -1
  168. package/tests/e2e/public-commands.test.ts +68 -1
  169. package/dist/clis/grok/debug.d.ts +0 -1
  170. package/dist/clis/grok/debug.js +0 -45
  171. package/src/clis/grok/debug.ts +0 -49
package/dist/cli.js ADDED
@@ -0,0 +1,197 @@
1
+ import { Command } from 'commander';
2
+ import chalk from 'chalk';
3
+ import { executeCommand } from './engine.js';
4
+ import { fullName, getRegistry, strategyLabel } from './registry.js';
5
+ import { render as renderOutput } from './output.js';
6
+ import { BrowserBridge, CDPBridge } from './browser/index.js';
7
+ import { browserSession, DEFAULT_BROWSER_COMMAND_TIMEOUT, runWithTimeout } from './runtime.js';
8
+ import { PKG_VERSION } from './version.js';
9
+ import { printCompletionScript } from './completion.js';
10
+ import { CliError } from './errors.js';
11
+ export function runCli(BUILTIN_CLIS, USER_CLIS) {
12
+ const program = new Command();
13
+ program.name('opencli').description('Make any website your CLI. Zero setup. AI-powered.').version(PKG_VERSION);
14
+ // ── Built-in commands ──────────────────────────────────────────────────────
15
+ program.command('list').description('List all available CLI commands').option('-f, --format <fmt>', 'Output format: table, json, yaml, md, csv', 'table').option('--json', 'JSON output (deprecated)')
16
+ .action((opts) => {
17
+ const registry = getRegistry();
18
+ const commands = [...registry.values()].sort((a, b) => fullName(a).localeCompare(fullName(b)));
19
+ const rows = commands.map(c => ({
20
+ command: fullName(c),
21
+ site: c.site,
22
+ name: c.name,
23
+ description: c.description,
24
+ strategy: strategyLabel(c),
25
+ browser: c.browser,
26
+ args: c.args.map(a => a.name).join(', '),
27
+ }));
28
+ const fmt = opts.json && opts.format === 'table' ? 'json' : opts.format;
29
+ if (fmt !== 'table') {
30
+ renderOutput(rows, {
31
+ fmt,
32
+ columns: ['command', 'site', 'name', 'description', 'strategy', 'browser', 'args'],
33
+ title: 'opencli/list',
34
+ source: 'opencli list',
35
+ });
36
+ return;
37
+ }
38
+ const sites = new Map();
39
+ for (const cmd of commands) {
40
+ const g = sites.get(cmd.site) ?? [];
41
+ g.push(cmd);
42
+ sites.set(cmd.site, g);
43
+ }
44
+ console.log();
45
+ console.log(chalk.bold(' opencli') + chalk.dim(' — available commands'));
46
+ console.log();
47
+ for (const [site, cmds] of sites) {
48
+ console.log(chalk.bold.cyan(` ${site}`));
49
+ for (const cmd of cmds) {
50
+ const tag = strategyLabel(cmd) === 'public' ? chalk.green('[public]') : chalk.yellow(`[${strategyLabel(cmd)}]`);
51
+ console.log(` ${cmd.name} ${tag}${cmd.description ? chalk.dim(` — ${cmd.description}`) : ''}`);
52
+ }
53
+ console.log();
54
+ }
55
+ console.log(chalk.dim(` ${commands.length} commands across ${sites.size} sites`));
56
+ console.log();
57
+ });
58
+ program.command('validate').description('Validate CLI definitions').argument('[target]', 'site or site/name')
59
+ .action(async (target) => {
60
+ const { validateClisWithTarget, renderValidationReport } = await import('./validate.js');
61
+ console.log(renderValidationReport(validateClisWithTarget([BUILTIN_CLIS, USER_CLIS], target)));
62
+ });
63
+ program.command('verify').description('Validate + smoke test').argument('[target]').option('--smoke', 'Run smoke tests', false)
64
+ .action(async (target, opts) => {
65
+ const { verifyClis, renderVerifyReport } = await import('./verify.js');
66
+ const r = await verifyClis({ builtinClis: BUILTIN_CLIS, userClis: USER_CLIS, target, smoke: opts.smoke });
67
+ console.log(renderVerifyReport(r));
68
+ process.exitCode = r.ok ? 0 : 1;
69
+ });
70
+ program.command('explore').alias('probe').description('Explore a website: discover APIs, stores, and recommend strategies').argument('<url>').option('--site <name>').option('--goal <text>').option('--wait <s>', '', '3').option('--auto', 'Enable interactive fuzzing (simulate clicks to trigger lazy APIs)').option('--click <labels>', 'Comma-separated labels to click before fuzzing (e.g. "字幕,CC,评论")')
71
+ .action(async (url, opts) => { const { exploreUrl, renderExploreSummary } = await import('./explore.js'); const clickLabels = opts.click ? opts.click.split(',').map((s) => s.trim()) : undefined; const BrowserFactory = process.env.OPENCLI_CDP_ENDPOINT ? CDPBridge : BrowserBridge; console.log(renderExploreSummary(await exploreUrl(url, { BrowserFactory: BrowserFactory, site: opts.site, goal: opts.goal, waitSeconds: parseFloat(opts.wait), auto: opts.auto, clickLabels }))); });
72
+ program.command('synthesize').description('Synthesize CLIs from explore').argument('<target>').option('--top <n>', '', '3')
73
+ .action(async (target, opts) => { const { synthesizeFromExplore, renderSynthesizeSummary } = await import('./synthesize.js'); console.log(renderSynthesizeSummary(synthesizeFromExplore(target, { top: parseInt(opts.top) }))); });
74
+ program.command('generate').description('One-shot: explore → synthesize → register').argument('<url>').option('--goal <text>').option('--site <name>')
75
+ .action(async (url, opts) => { const { generateCliFromUrl, renderGenerateSummary } = await import('./generate.js'); const BrowserFactory = process.env.OPENCLI_CDP_ENDPOINT ? CDPBridge : BrowserBridge; const r = await generateCliFromUrl({ url, BrowserFactory: BrowserFactory, builtinClis: BUILTIN_CLIS, userClis: USER_CLIS, goal: opts.goal, site: opts.site }); console.log(renderGenerateSummary(r)); process.exitCode = r.ok ? 0 : 1; });
76
+ program.command('cascade').description('Strategy cascade: find simplest working strategy').argument('<url>').option('--site <name>')
77
+ .action(async (url, opts) => {
78
+ const { cascadeProbe, renderCascadeResult } = await import('./cascade.js');
79
+ const BrowserFactory = process.env.OPENCLI_CDP_ENDPOINT ? CDPBridge : BrowserBridge;
80
+ const result = await browserSession(BrowserFactory, async (page) => {
81
+ // Navigate to the site first for cookie context
82
+ try {
83
+ const siteUrl = new URL(url);
84
+ await page.goto(`${siteUrl.protocol}//${siteUrl.host}`);
85
+ await page.wait(2);
86
+ }
87
+ catch { }
88
+ return cascadeProbe(page, url);
89
+ });
90
+ console.log(renderCascadeResult(result));
91
+ });
92
+ program.command('doctor')
93
+ .description('Diagnose opencli browser bridge connectivity')
94
+ .option('--live', 'Test browser connectivity (requires Chrome running)', false)
95
+ .action(async (opts) => {
96
+ const { runBrowserDoctor, renderBrowserDoctorReport } = await import('./doctor.js');
97
+ const report = await runBrowserDoctor({ live: opts.live, cliVersion: PKG_VERSION });
98
+ console.log(renderBrowserDoctorReport(report));
99
+ });
100
+ program.command('setup')
101
+ .description('Interactive setup: verify browser bridge connectivity')
102
+ .action(async () => {
103
+ const { runSetup } = await import('./setup.js');
104
+ await runSetup({ cliVersion: PKG_VERSION });
105
+ });
106
+ program.command('completion')
107
+ .description('Output shell completion script')
108
+ .argument('<shell>', 'Shell type: bash, zsh, or fish')
109
+ .action((shell) => {
110
+ printCompletionScript(shell);
111
+ });
112
+ // ── Dynamic site commands ──────────────────────────────────────────────────
113
+ const registry = getRegistry();
114
+ const siteGroups = new Map();
115
+ for (const [, cmd] of registry) {
116
+ let siteCmd = siteGroups.get(cmd.site);
117
+ if (!siteCmd) {
118
+ siteCmd = program.command(cmd.site).description(`${cmd.site} commands`);
119
+ siteGroups.set(cmd.site, siteCmd);
120
+ }
121
+ const subCmd = siteCmd.command(cmd.name).description(cmd.description);
122
+ // Register positional args first, then named options
123
+ const positionalArgs = [];
124
+ for (const arg of cmd.args) {
125
+ if (arg.positional) {
126
+ const bracket = arg.required ? `<${arg.name}>` : `[${arg.name}]`;
127
+ subCmd.argument(bracket, arg.help ?? '');
128
+ positionalArgs.push(arg);
129
+ }
130
+ else {
131
+ const flag = arg.required ? `--${arg.name} <value>` : `--${arg.name} [value]`;
132
+ if (arg.required)
133
+ subCmd.requiredOption(flag, arg.help ?? '');
134
+ else if (arg.default != null)
135
+ subCmd.option(flag, arg.help ?? '', String(arg.default));
136
+ else
137
+ subCmd.option(flag, arg.help ?? '');
138
+ }
139
+ }
140
+ subCmd.option('-f, --format <fmt>', 'Output format: table, json, yaml, md, csv', 'table').option('-v, --verbose', 'Debug output', false);
141
+ subCmd.action(async (...actionArgs) => {
142
+ // Commander passes positional args first, then options object, then the Command
143
+ const actionOpts = actionArgs[positionalArgs.length] ?? {};
144
+ const startTime = Date.now();
145
+ const kwargs = {};
146
+ // Collect positional args
147
+ for (let i = 0; i < positionalArgs.length; i++) {
148
+ const arg = positionalArgs[i];
149
+ const v = actionArgs[i];
150
+ if (v !== undefined)
151
+ kwargs[arg.name] = v;
152
+ }
153
+ // Collect named options
154
+ for (const arg of cmd.args) {
155
+ if (arg.positional)
156
+ continue;
157
+ const camelName = arg.name.replace(/-([a-z])/g, (_m, ch) => ch.toUpperCase());
158
+ const v = actionOpts[arg.name] ?? actionOpts[camelName];
159
+ if (v !== undefined)
160
+ kwargs[arg.name] = v;
161
+ }
162
+ try {
163
+ if (actionOpts.verbose)
164
+ process.env.OPENCLI_VERBOSE = '1';
165
+ let result;
166
+ if (cmd.browser) {
167
+ const BrowserFactory = process.env.OPENCLI_CDP_ENDPOINT ? CDPBridge : BrowserBridge;
168
+ result = await browserSession(BrowserFactory, async (page) => {
169
+ return runWithTimeout(executeCommand(cmd, page, kwargs, actionOpts.verbose), { timeout: cmd.timeoutSeconds ?? DEFAULT_BROWSER_COMMAND_TIMEOUT, label: fullName(cmd) });
170
+ });
171
+ }
172
+ else {
173
+ result = await executeCommand(cmd, null, kwargs, actionOpts.verbose);
174
+ }
175
+ if (actionOpts.verbose && (!result || (Array.isArray(result) && result.length === 0))) {
176
+ console.error(chalk.yellow(`[Verbose] Warning: Command returned an empty result. If the website structural API changed or requires authentication, check the network or update the adapter.`));
177
+ }
178
+ renderOutput(result, { fmt: actionOpts.format, columns: cmd.columns, title: `${cmd.site}/${cmd.name}`, elapsed: (Date.now() - startTime) / 1000, source: fullName(cmd) });
179
+ }
180
+ catch (err) {
181
+ if (err instanceof CliError) {
182
+ console.error(chalk.red(`Error [${err.code}]: ${err.message}`));
183
+ if (err.hint)
184
+ console.error(chalk.yellow(`Hint: ${err.hint}`));
185
+ }
186
+ else if (actionOpts.verbose && err.stack) {
187
+ console.error(chalk.red(err.stack));
188
+ }
189
+ else {
190
+ console.error(chalk.red(`Error: ${err.message ?? err}`));
191
+ }
192
+ process.exitCode = 1;
193
+ }
194
+ });
195
+ }
196
+ program.parse();
197
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,28 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import { CliError } from '../../errors.js';
3
+ import { itunesFetch, formatDuration, formatDate } from './utils.js';
4
+ cli({
5
+ site: 'apple-podcasts',
6
+ name: 'episodes',
7
+ description: 'List recent episodes of an Apple Podcast (use ID from search)',
8
+ strategy: Strategy.PUBLIC,
9
+ browser: false,
10
+ args: [
11
+ { name: 'id', positional: true, required: true, help: 'Podcast ID (collectionId from search output)' },
12
+ { name: 'limit', type: 'int', default: 15, help: 'Max episodes to show' },
13
+ ],
14
+ columns: ['title', 'duration', 'date'],
15
+ func: async (_page, args) => {
16
+ const limit = Math.max(1, Math.min(Number(args.limit), 200));
17
+ // results[0] is the podcast itself; the rest are episodes
18
+ const data = await itunesFetch(`/lookup?id=${args.id}&entity=podcastEpisode&limit=${limit + 1}`);
19
+ const episodes = (data.results ?? []).filter((r) => r.kind === 'podcast-episode');
20
+ if (!episodes.length)
21
+ throw new CliError('NOT_FOUND', 'No episodes found', 'Check the podcast ID from: opencli apple-podcasts search <keyword>');
22
+ return episodes.slice(0, limit).map((ep) => ({
23
+ title: ep.trackName,
24
+ duration: formatDuration(ep.trackTimeMillis),
25
+ date: formatDate(ep.releaseDate),
26
+ }));
27
+ },
28
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,29 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import { CliError } from '../../errors.js';
3
+ import { itunesFetch } from './utils.js';
4
+ cli({
5
+ site: 'apple-podcasts',
6
+ name: 'search',
7
+ description: 'Search Apple Podcasts',
8
+ strategy: Strategy.PUBLIC,
9
+ browser: false,
10
+ args: [
11
+ { name: 'keyword', positional: true, required: true, help: 'Search keyword' },
12
+ { name: 'limit', type: 'int', default: 10, help: 'Max results' },
13
+ ],
14
+ columns: ['id', 'title', 'author', 'episodes', 'genre'],
15
+ func: async (_page, args) => {
16
+ const term = encodeURIComponent(args.keyword);
17
+ const limit = Math.max(1, Math.min(Number(args.limit), 25));
18
+ const data = await itunesFetch(`/search?term=${term}&media=podcast&limit=${limit}`);
19
+ if (!data.results?.length)
20
+ throw new CliError('NOT_FOUND', 'No podcasts found', `Try a different keyword`);
21
+ return data.results.map((p) => ({
22
+ id: p.collectionId,
23
+ title: p.collectionName,
24
+ author: p.artistName,
25
+ episodes: p.trackCount ?? '-',
26
+ genre: p.primaryGenreName ?? '-',
27
+ }));
28
+ },
29
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,34 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import { CliError } from '../../errors.js';
3
+ // Apple Marketing Tools RSS API — public, no key required
4
+ const CHARTS_URL = 'https://rss.applemarketingtools.com/api/v2';
5
+ cli({
6
+ site: 'apple-podcasts',
7
+ name: 'top',
8
+ description: 'Top podcasts chart on Apple Podcasts',
9
+ strategy: Strategy.PUBLIC,
10
+ browser: false,
11
+ args: [
12
+ { name: 'limit', type: 'int', default: 20, help: 'Number of podcasts (max 100)' },
13
+ { name: 'country', default: 'us', help: 'Country code (e.g. us, cn, gb, jp)' },
14
+ ],
15
+ columns: ['rank', 'title', 'author', 'id'],
16
+ func: async (_page, args) => {
17
+ const limit = Math.max(1, Math.min(Number(args.limit), 100));
18
+ const country = String(args.country || 'us').trim().toLowerCase();
19
+ const url = `${CHARTS_URL}/${country}/podcasts/top/${limit}/podcasts.json`;
20
+ const resp = await fetch(url);
21
+ if (!resp.ok)
22
+ throw new CliError('FETCH_ERROR', `Charts API HTTP ${resp.status}`, `Check country code: ${country}`);
23
+ const data = await resp.json();
24
+ const results = data?.feed?.results;
25
+ if (!results?.length)
26
+ throw new CliError('NOT_FOUND', 'No chart data found', `Try a different country code`);
27
+ return results.map((p, i) => ({
28
+ rank: i + 1,
29
+ title: p.name,
30
+ author: p.artistName,
31
+ id: p.id,
32
+ }));
33
+ },
34
+ });
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Shared Apple Podcasts utilities.
3
+ *
4
+ * Uses the public iTunes Search API — no API key required.
5
+ * https://developer.apple.com/library/archive/documentation/AudioVideo/Conceptual/iTuneSearchAPI/
6
+ */
7
+ export declare function itunesFetch(path: string): Promise<any>;
8
+ /** Format milliseconds to mm:ss. Returns '-' for missing input. */
9
+ export declare function formatDuration(ms: number): string;
10
+ /** Format ISO date string to YYYY-MM-DD. Returns '-' for missing input. */
11
+ export declare function formatDate(iso: string): string;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Shared Apple Podcasts utilities.
3
+ *
4
+ * Uses the public iTunes Search API — no API key required.
5
+ * https://developer.apple.com/library/archive/documentation/AudioVideo/Conceptual/iTuneSearchAPI/
6
+ */
7
+ import { CliError } from '../../errors.js';
8
+ const BASE = 'https://itunes.apple.com';
9
+ export async function itunesFetch(path) {
10
+ const resp = await fetch(`${BASE}${path}`);
11
+ if (!resp.ok) {
12
+ throw new CliError('FETCH_ERROR', `iTunes API HTTP ${resp.status}`, 'Check your search term or podcast ID');
13
+ }
14
+ return resp.json();
15
+ }
16
+ /** Format milliseconds to mm:ss. Returns '-' for missing input. */
17
+ export function formatDuration(ms) {
18
+ if (!ms || !Number.isFinite(ms))
19
+ return '-';
20
+ const totalSec = Math.round(ms / 1000);
21
+ const m = Math.floor(totalSec / 60);
22
+ const s = totalSec % 60;
23
+ return `${m}:${String(s).padStart(2, '0')}`;
24
+ }
25
+ /** Format ISO date string to YYYY-MM-DD. Returns '-' for missing input. */
26
+ export function formatDate(iso) {
27
+ if (!iso)
28
+ return '-';
29
+ return iso.slice(0, 10);
30
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,57 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { formatDuration, formatDate, itunesFetch } from './utils.js';
3
+ describe('formatDuration', () => {
4
+ it('formats typical duration in ms', () => {
5
+ expect(formatDuration(3661000)).toBe('61:01');
6
+ });
7
+ it('pads single-digit seconds', () => {
8
+ expect(formatDuration(65000)).toBe('1:05');
9
+ });
10
+ it('formats exact minutes', () => {
11
+ expect(formatDuration(3600000)).toBe('60:00');
12
+ });
13
+ it('rounds fractional milliseconds', () => {
14
+ expect(formatDuration(3600500)).toBe('60:01');
15
+ });
16
+ it('returns dash for zero', () => {
17
+ expect(formatDuration(0)).toBe('-');
18
+ });
19
+ it('returns dash for NaN', () => {
20
+ expect(formatDuration(NaN)).toBe('-');
21
+ });
22
+ });
23
+ describe('formatDate', () => {
24
+ it('extracts YYYY-MM-DD from ISO string', () => {
25
+ expect(formatDate('2026-03-19T12:00:00.000Z')).toBe('2026-03-19');
26
+ });
27
+ it('handles date-only string', () => {
28
+ expect(formatDate('2025-01-01')).toBe('2025-01-01');
29
+ });
30
+ it('returns dash for empty string', () => {
31
+ expect(formatDate('')).toBe('-');
32
+ });
33
+ it('returns dash for undefined', () => {
34
+ expect(formatDate(undefined)).toBe('-');
35
+ });
36
+ });
37
+ describe('itunesFetch', () => {
38
+ beforeEach(() => {
39
+ vi.restoreAllMocks();
40
+ });
41
+ it('returns parsed JSON on success', async () => {
42
+ const mockData = { resultCount: 1, results: [{ collectionId: 123 }] };
43
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
44
+ ok: true,
45
+ json: () => Promise.resolve(mockData),
46
+ }));
47
+ const result = await itunesFetch('/search?term=test&media=podcast&limit=1');
48
+ expect(result).toEqual(mockData);
49
+ });
50
+ it('throws CliError on HTTP error', async () => {
51
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
52
+ ok: false,
53
+ status: 403,
54
+ }));
55
+ await expect(itunesFetch('/search?term=test')).rejects.toThrow('iTunes API HTTP 403');
56
+ });
57
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,50 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ cli({
3
+ site: 'boss',
4
+ name: 'chatlist',
5
+ description: 'BOSS直聘查看聊天列表(招聘端)',
6
+ domain: 'www.zhipin.com',
7
+ strategy: Strategy.COOKIE,
8
+ browser: true,
9
+ args: [
10
+ { name: 'page', type: 'int', default: 1, help: 'Page number' },
11
+ { name: 'limit', type: 'int', default: 20, help: 'Number of results' },
12
+ { name: 'job_id', default: '0', help: 'Filter by job ID (0=all)' },
13
+ ],
14
+ columns: ['name', 'job', 'last_msg', 'last_time', 'uid', 'security_id'],
15
+ func: async (page, kwargs) => {
16
+ if (!page)
17
+ throw new Error('Browser page required');
18
+ await page.goto('https://www.zhipin.com/web/chat/index');
19
+ await page.wait({ time: 2 });
20
+ const jobId = kwargs.job_id || '0';
21
+ const pageNum = kwargs.page || 1;
22
+ const limit = kwargs.limit || 20;
23
+ const targetUrl = `https://www.zhipin.com/wapi/zprelation/friend/getBossFriendListV2.json?page=${pageNum}&status=0&jobId=${jobId}`;
24
+ const data = await page.evaluate(`
25
+ async () => {
26
+ return new Promise((resolve, reject) => {
27
+ const xhr = new XMLHttpRequest();
28
+ xhr.open('GET', '${targetUrl}', true);
29
+ xhr.withCredentials = true;
30
+ xhr.timeout = 15000;
31
+ xhr.setRequestHeader('Accept', 'application/json');
32
+ xhr.onload = () => { try { resolve(JSON.parse(xhr.responseText)); } catch(e) { reject(new Error('JSON parse failed')); } };
33
+ xhr.onerror = () => reject(new Error('Network Error'));
34
+ xhr.send();
35
+ });
36
+ }
37
+ `);
38
+ if (data.code !== 0)
39
+ throw new Error(`API error: ${data.message} (code=${data.code})`);
40
+ const friends = (data.zpData?.friendList || []).slice(0, limit);
41
+ return friends.map((f) => ({
42
+ name: f.name || '',
43
+ job: f.jobName || '',
44
+ last_msg: f.lastMessageInfo?.text || '',
45
+ last_time: f.lastTime || '',
46
+ uid: f.encryptUid || '',
47
+ security_id: f.securityId || '',
48
+ }));
49
+ },
50
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,73 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ cli({
3
+ site: 'boss',
4
+ name: 'chatmsg',
5
+ description: 'BOSS直聘查看与候选人的聊天消息',
6
+ domain: 'www.zhipin.com',
7
+ strategy: Strategy.COOKIE,
8
+ browser: true,
9
+ args: [
10
+ { name: 'uid', required: true, help: 'Encrypted UID (from chatlist)' },
11
+ { name: 'page', type: 'int', default: 1, help: 'Page number' },
12
+ ],
13
+ columns: ['from', 'type', 'text', 'time'],
14
+ func: async (page, kwargs) => {
15
+ if (!page)
16
+ throw new Error('Browser page required');
17
+ await page.goto('https://www.zhipin.com/web/chat/index');
18
+ await page.wait({ time: 2 });
19
+ const uid = kwargs.uid;
20
+ const friendData = await page.evaluate(`
21
+ async () => {
22
+ return new Promise((resolve, reject) => {
23
+ const xhr = new XMLHttpRequest();
24
+ xhr.open('GET', 'https://www.zhipin.com/wapi/zprelation/friend/getBossFriendListV2.json?page=1&status=0&jobId=0', true);
25
+ xhr.withCredentials = true;
26
+ xhr.timeout = 15000;
27
+ xhr.setRequestHeader('Accept', 'application/json');
28
+ xhr.onload = () => { try { resolve(JSON.parse(xhr.responseText)); } catch(e) { reject(e); } };
29
+ xhr.onerror = () => reject(new Error('Network Error'));
30
+ xhr.send();
31
+ });
32
+ }
33
+ `);
34
+ if (friendData.code !== 0)
35
+ throw new Error('获取好友列表失败');
36
+ const friend = (friendData.zpData?.friendList || []).find((f) => f.encryptUid === uid);
37
+ if (!friend)
38
+ throw new Error('未找到该候选人');
39
+ const gid = friend.uid;
40
+ const securityId = encodeURIComponent(friend.securityId);
41
+ const msgUrl = `https://www.zhipin.com/wapi/zpchat/boss/historyMsg?gid=${gid}&securityId=${securityId}&page=${kwargs.page}&c=20&src=0`;
42
+ const msgData = await page.evaluate(`
43
+ async () => {
44
+ return new Promise((resolve, reject) => {
45
+ const xhr = new XMLHttpRequest();
46
+ xhr.open('GET', '${msgUrl}', true);
47
+ xhr.withCredentials = true;
48
+ xhr.timeout = 15000;
49
+ xhr.setRequestHeader('Accept', 'application/json');
50
+ xhr.onload = () => { try { resolve(JSON.parse(xhr.responseText)); } catch(e) { resolve({raw: xhr.responseText.substring(0,500)}); } };
51
+ xhr.onerror = () => reject(new Error('Network Error'));
52
+ xhr.send();
53
+ });
54
+ }
55
+ `);
56
+ if (msgData.raw)
57
+ throw new Error('Non-JSON: ' + msgData.raw);
58
+ if (msgData.code !== 0)
59
+ throw new Error('API error: ' + (msgData.message || msgData.code));
60
+ const TYPE_MAP = { 1: '文本', 2: '图片', 3: '招呼', 4: '简历', 5: '系统', 6: '名片', 7: '语音', 8: '视频', 9: '表情' };
61
+ const messages = msgData.zpData?.messages || msgData.zpData?.historyMsgList || [];
62
+ return messages.map((m) => {
63
+ const fromObj = m.from || {};
64
+ const isSelf = typeof fromObj === 'object' ? fromObj.uid !== friend.uid : false;
65
+ return {
66
+ from: isSelf ? '我' : (typeof fromObj === 'object' ? fromObj.name : friend.name),
67
+ type: TYPE_MAP[m.type] || '其他(' + m.type + ')',
68
+ text: m.text || m.body?.text || '',
69
+ time: m.time ? new Date(m.time).toLocaleString('zh-CN') : '',
70
+ };
71
+ });
72
+ },
73
+ });
@@ -0,0 +1 @@
1
+ export {};