diff --git a/README.md b/README.md index 6ea5a6e..873f944 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,7 @@ Run `opencli list` for the live registry. | **antigravity** | `status` `send` `read` `new` `evaluate` | 🖥️ Desktop | | **chatgpt** | `status` `new` `send` `read` `ask` | 🖥️ Desktop | | **xiaohongshu** | `search` `notifications` `feed` `me` `user` `download` | 🔐 Browser | +| **apple-podcasts** | `search` `episodes` `top` | 🌐 Public | | **xiaoyuzhou** | `podcast` `podcast-episodes` `episode` | 🌐 Public | | **zhihu** | `hot` `search` `question` `download` | 🔐 Browser | | **youtube** | `search` `video` `transcript` | 🔐 Browser | diff --git a/README.zh-CN.md b/README.zh-CN.md index 5f3011d..85b5cd7 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -120,6 +120,7 @@ npm install -g @jackwener/opencli@latest | **antigravity** | `status` `send` `read` `new` `evaluate` | 🖥️ 桌面端 | | **chatgpt** | `status` `new` `send` `read` `ask` | 🖥️ 桌面端 | | **xiaohongshu** | `search` `notifications` `feed` `me` `user` `download` | 🔐 浏览器 | +| **apple-podcasts** | `search` `episodes` `top` | 🌐 公开 | | **xiaoyuzhou** | `podcast` `podcast-episodes` `episode` | 🌐 公开 | | **zhihu** | `hot` `search` `question` `download` | 🔐 浏览器 | | **youtube** | `search` `video` `transcript` | 🔐 浏览器 | diff --git a/src/clis/apple-podcasts/episodes.ts b/src/clis/apple-podcasts/episodes.ts new file mode 100644 index 0000000..758524f --- /dev/null +++ b/src/clis/apple-podcasts/episodes.ts @@ -0,0 +1,28 @@ +import { cli, Strategy } from '../../registry.js'; +import { CliError } from '../../errors.js'; +import { itunesFetch, formatDuration, formatDate } from './utils.js'; + +cli({ + site: 'apple-podcasts', + name: 'episodes', + description: 'List recent episodes of an Apple Podcast (use ID from search)', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { name: 'id', positional: true, required: true, help: 'Podcast ID (collectionId from search output)' }, + { name: 'limit', type: 'int', default: 15, help: 'Max episodes to show' }, + ], + columns: ['title', 'duration', 'date'], + func: async (_page, args) => { + const limit = Math.max(1, Math.min(Number(args.limit), 200)); + // results[0] is the podcast itself; the rest are episodes + const data = await itunesFetch(`/lookup?id=${args.id}&entity=podcastEpisode&limit=${limit + 1}`); + const episodes = (data.results ?? []).filter((r: any) => r.kind === 'podcast-episode'); + if (!episodes.length) throw new CliError('NOT_FOUND', 'No episodes found', 'Check the podcast ID from: opencli apple-podcasts search '); + return episodes.slice(0, limit).map((ep: any) => ({ + title: ep.trackName, + duration: formatDuration(ep.trackTimeMillis), + date: formatDate(ep.releaseDate), + })); + }, +}); diff --git a/src/clis/apple-podcasts/search.ts b/src/clis/apple-podcasts/search.ts new file mode 100644 index 0000000..00fb92f --- /dev/null +++ b/src/clis/apple-podcasts/search.ts @@ -0,0 +1,29 @@ +import { cli, Strategy } from '../../registry.js'; +import { CliError } from '../../errors.js'; +import { itunesFetch } from './utils.js'; + +cli({ + site: 'apple-podcasts', + name: 'search', + description: 'Search Apple Podcasts', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { name: 'keyword', positional: true, required: true, help: 'Search keyword' }, + { name: 'limit', type: 'int', default: 10, help: 'Max results' }, + ], + columns: ['id', 'title', 'author', 'episodes', 'genre'], + func: async (_page, args) => { + const term = encodeURIComponent(args.keyword); + const limit = Math.max(1, Math.min(Number(args.limit), 25)); + const data = await itunesFetch(`/search?term=${term}&media=podcast&limit=${limit}`); + if (!data.results?.length) throw new CliError('NOT_FOUND', 'No podcasts found', `Try a different keyword`); + return data.results.map((p: any) => ({ + id: p.collectionId, + title: p.collectionName, + author: p.artistName, + episodes: p.trackCount ?? '-', + genre: p.primaryGenreName ?? '-', + })); + }, +}); diff --git a/src/clis/apple-podcasts/top.ts b/src/clis/apple-podcasts/top.ts new file mode 100644 index 0000000..c3a72c5 --- /dev/null +++ b/src/clis/apple-podcasts/top.ts @@ -0,0 +1,34 @@ +import { cli, Strategy } from '../../registry.js'; +import { CliError } from '../../errors.js'; + +// Apple Marketing Tools RSS API — public, no key required +const CHARTS_URL = 'https://rss.applemarketingtools.com/api/v2'; + +cli({ + site: 'apple-podcasts', + name: 'top', + description: 'Top podcasts chart on Apple Podcasts', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { name: 'limit', type: 'int', default: 20, help: 'Number of podcasts (max 100)' }, + { name: 'country', default: 'us', help: 'Country code (e.g. us, cn, gb, jp)' }, + ], + columns: ['rank', 'title', 'author', 'id'], + func: async (_page, args) => { + const limit = Math.max(1, Math.min(Number(args.limit), 100)); + const country = String(args.country || 'us').trim().toLowerCase(); + const url = `${CHARTS_URL}/${country}/podcasts/top/${limit}/podcasts.json`; + const resp = await fetch(url); + if (!resp.ok) throw new CliError('FETCH_ERROR', `Charts API HTTP ${resp.status}`, `Check country code: ${country}`); + const data = await resp.json(); + const results = data?.feed?.results; + if (!results?.length) throw new CliError('NOT_FOUND', 'No chart data found', `Try a different country code`); + return results.map((p: any, i: number) => ({ + rank: i + 1, + title: p.name, + author: p.artistName, + id: p.id, + })); + }, +}); diff --git a/src/clis/apple-podcasts/utils.test.ts b/src/clis/apple-podcasts/utils.test.ts new file mode 100644 index 0000000..af28adb --- /dev/null +++ b/src/clis/apple-podcasts/utils.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { formatDuration, formatDate, itunesFetch } from './utils.js'; + +describe('formatDuration', () => { + it('formats typical duration in ms', () => { + expect(formatDuration(3661000)).toBe('61:01'); + }); + + it('pads single-digit seconds', () => { + expect(formatDuration(65000)).toBe('1:05'); + }); + + it('formats exact minutes', () => { + expect(formatDuration(3600000)).toBe('60:00'); + }); + + it('rounds fractional milliseconds', () => { + expect(formatDuration(3600500)).toBe('60:01'); + }); + + it('returns dash for zero', () => { + expect(formatDuration(0)).toBe('-'); + }); + + it('returns dash for NaN', () => { + expect(formatDuration(NaN)).toBe('-'); + }); +}); + +describe('formatDate', () => { + it('extracts YYYY-MM-DD from ISO string', () => { + expect(formatDate('2026-03-19T12:00:00.000Z')).toBe('2026-03-19'); + }); + + it('handles date-only string', () => { + expect(formatDate('2025-01-01')).toBe('2025-01-01'); + }); + + it('returns dash for empty string', () => { + expect(formatDate('')).toBe('-'); + }); + + it('returns dash for undefined', () => { + expect(formatDate(undefined as any)).toBe('-'); + }); +}); + +describe('itunesFetch', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('returns parsed JSON on success', async () => { + const mockData = { resultCount: 1, results: [{ collectionId: 123 }] }; + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockData), + })); + + const result = await itunesFetch('/search?term=test&media=podcast&limit=1'); + expect(result).toEqual(mockData); + }); + + it('throws CliError on HTTP error', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: false, + status: 403, + })); + + await expect(itunesFetch('/search?term=test')).rejects.toThrow('iTunes API HTTP 403'); + }); +}); diff --git a/src/clis/apple-podcasts/utils.ts b/src/clis/apple-podcasts/utils.ts new file mode 100644 index 0000000..1719b1d --- /dev/null +++ b/src/clis/apple-podcasts/utils.ts @@ -0,0 +1,37 @@ +/** + * Shared Apple Podcasts utilities. + * + * Uses the public iTunes Search API — no API key required. + * https://developer.apple.com/library/archive/documentation/AudioVideo/Conceptual/iTuneSearchAPI/ + */ + +import { CliError } from '../../errors.js'; + +const BASE = 'https://itunes.apple.com'; + +export async function itunesFetch(path: string): Promise { + const resp = await fetch(`${BASE}${path}`); + if (!resp.ok) { + throw new CliError( + 'FETCH_ERROR', + `iTunes API HTTP ${resp.status}`, + 'Check your search term or podcast ID', + ); + } + return resp.json(); +} + +/** Format milliseconds to mm:ss. Returns '-' for missing input. */ +export function formatDuration(ms: number): string { + if (!ms || !Number.isFinite(ms)) return '-'; + const totalSec = Math.round(ms / 1000); + const m = Math.floor(totalSec / 60); + const s = totalSec % 60; + return `${m}:${String(s).padStart(2, '0')}`; +} + +/** Format ISO date string to YYYY-MM-DD. Returns '-' for missing input. */ +export function formatDate(iso: string): string { + if (!iso) return '-'; + return iso.slice(0, 10); +} diff --git a/tests/e2e/public-commands.test.ts b/tests/e2e/public-commands.test.ts index 5148687..ef2ce97 100644 --- a/tests/e2e/public-commands.test.ts +++ b/tests/e2e/public-commands.test.ts @@ -12,6 +12,40 @@ function isExpectedXiaoyuzhouRestriction(code: number, stderr: string): boolean } describe('public commands E2E', () => { + // ── apple-podcasts ── + it('apple-podcasts search returns structured podcast results', async () => { + const { stdout, code } = await runCli(['apple-podcasts', 'search', 'technology', '--limit', '3', '-f', 'json']); + expect(code).toBe(0); + const data = parseJsonOutput(stdout); + expect(Array.isArray(data)).toBe(true); + expect(data.length).toBeGreaterThanOrEqual(1); + expect(data[0]).toHaveProperty('id'); + expect(data[0]).toHaveProperty('title'); + expect(data[0]).toHaveProperty('author'); + }, 30_000); + + it('apple-podcasts episodes returns episode list from a known show', async () => { + const { stdout, code } = await runCli(['apple-podcasts', 'episodes', '275699983', '--limit', '3', '-f', 'json']); + expect(code).toBe(0); + const data = parseJsonOutput(stdout); + expect(Array.isArray(data)).toBe(true); + expect(data.length).toBeGreaterThanOrEqual(1); + expect(data[0]).toHaveProperty('title'); + expect(data[0]).toHaveProperty('duration'); + expect(data[0]).toHaveProperty('date'); + }, 30_000); + + it('apple-podcasts top returns ranked podcasts', async () => { + const { stdout, code } = await runCli(['apple-podcasts', 'top', '--limit', '3', '--country', 'us', '-f', 'json']); + expect(code).toBe(0); + const data = parseJsonOutput(stdout); + expect(Array.isArray(data)).toBe(true); + expect(data.length).toBe(3); + expect(data[0]).toHaveProperty('rank'); + expect(data[0]).toHaveProperty('title'); + expect(data[0]).toHaveProperty('id'); + }, 30_000); + // ── hackernews ── it('hackernews top returns structured data', async () => { const { stdout, code } = await runCli(['hackernews', 'top', '--limit', '3', '-f', 'json']);