Add apple-podcasts coverage and docs (#92)

Co-authored-by: 刘启灏 <liuqihao@liuqihaodeMacBook-Pro.local>
This commit is contained in:
BruceLoveDecimaland刘启灏 authored and GitHub committed 2026-03-19 20:58:08 +08:00
1 parent aeb1cb6a3a
commit aa2f37be32
8 files changed
+236

No files matched your search

+1
View File
@@ -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 |
+1
View File
@@ -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` | 🔐 浏览器 |
+28
View File
@@ -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 <keyword>');
return episodes.slice(0, limit).map((ep: any) => ({
title: ep.trackName,
duration: formatDuration(ep.trackTimeMillis),
date: formatDate(ep.releaseDate),
}));
},
});
+29
View File
@@ -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 ?? '-',
}));
},
});
+34
View File
@@ -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,
}));
},
});
+72
View File
@@ -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');
});
});
+37
View File
@@ -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<any> {
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);
}
+34
View File
@@ -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']);