Add apple-podcasts coverage and docs (#92)
Co-authored-by: 刘启灏 <liuqihao@liuqihaodeMacBook-Pro.local>
This commit is contained in:
1 parent
aeb1cb6a3a
commit
aa2f37be32
8 files changed
+236
No files matched your search
@@ -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 |
|
||||
|
||||
@@ -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` | 🔐 浏览器 |
|
||||
|
||||
@@ -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),
|
||||
}));
|
||||
},
|
||||
});
|
||||
@@ -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 ?? '-',
|
||||
}));
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
}));
|
||||
},
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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']);
|
||||
|
||||
Reference in new issue
Block a user