diff --git a/README.md b/README.md index 10ff2c3..9224594 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,7 @@ npm install -g @jackwener/opencli@latest | **v2ex** | `hot` `latest` `topic` `daily` `me` `notifications` | 6 | 🌐 / 🔐 | | **xueqiu** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` | 6 | 🔐 Browser | | **xiaohongshu** | `search` `notifications` `feed` `me` `user` | 5 | 🔐 Browser | +| **xiaoyuzhou** | `podcast` `podcast-episodes` `episode` | 3 | 🌐 Public | | **youtube** | `search` `video` `transcript` | 3 | 🔐 Browser | | **zhihu** | `hot` `search` `question` | 3 | 🔐 Browser | | **boss** | `search` `detail` | 2 | 🔐 Browser | diff --git a/README.zh-CN.md b/README.zh-CN.md index 68460b6..352f3a3 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -148,6 +148,7 @@ npm install -g @jackwener/opencli@latest | **v2ex** | `hot` `latest` `topic` `daily` `me` `notifications` | 6 | 🌐 / 🔐 | | **xueqiu** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` | 6 | 🔐 浏览器 | | **xiaohongshu** | `search` `notifications` `feed` `me` `user` | 5 | 🔐 浏览器 | +| **xiaoyuzhou** | `podcast` `podcast-episodes` `episode` | 3 | 🌐 公开 | | **youtube** | `search` `video` `transcript` | 3 | 🔐 浏览器 | | **zhihu** | `hot` `search` `question` | 3 | 🔐 浏览器 | | **boss** | `search` `detail` | 2 | 🔐 浏览器 | diff --git a/src/clis/xiaoyuzhou/episode.ts b/src/clis/xiaoyuzhou/episode.ts new file mode 100644 index 0000000..2e7f8d8 --- /dev/null +++ b/src/clis/xiaoyuzhou/episode.ts @@ -0,0 +1,28 @@ +import { cli, Strategy } from '../../registry.js'; +import { CliError } from '../../errors.js'; +import { fetchPageProps, formatDuration, formatDate } from './utils.js'; + +cli({ + site: 'xiaoyuzhou', + name: 'episode', + description: 'View details of a Xiaoyuzhou podcast episode', + domain: 'www.xiaoyuzhoufm.com', + strategy: Strategy.PUBLIC, + browser: false, + args: [{ name: 'id', positional: true, required: true, help: 'Episode ID (eid from podcast-episodes output)' }], + columns: ['title', 'podcast', 'duration', 'plays', 'comments', 'likes', 'date'], + func: async (_page, args) => { + const pageProps = await fetchPageProps(`/episode/${args.id}`); + const ep = pageProps.episode; + if (!ep) throw new CliError('NOT_FOUND', 'Episode not found', 'Please check the ID'); + return [{ + title: ep.title, + podcast: ep.podcast?.title, + duration: formatDuration(ep.duration), + plays: ep.playCount, + comments: ep.commentCount, + likes: ep.clapCount, + date: formatDate(ep.pubDate), + }]; + }, +}); diff --git a/src/clis/xiaoyuzhou/podcast-episodes.ts b/src/clis/xiaoyuzhou/podcast-episodes.ts new file mode 100644 index 0000000..64b4972 --- /dev/null +++ b/src/clis/xiaoyuzhou/podcast-episodes.ts @@ -0,0 +1,32 @@ +import { cli, Strategy } from '../../registry.js'; +import { CliError } from '../../errors.js'; +import { fetchPageProps, formatDuration, formatDate } from './utils.js'; + +cli({ + site: 'xiaoyuzhou', + name: 'podcast-episodes', + description: 'List recent episodes of a Xiaoyuzhou podcast (up to 15, SSR limit)', + domain: 'www.xiaoyuzhoufm.com', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { name: 'id', positional: true, required: true, help: 'Podcast ID (from xiaoyuzhoufm.com URL)' }, + { name: 'limit', type: 'int', default: 15, help: 'Max episodes to show (up to 15, SSR limit)' }, + ], + columns: ['eid', 'title', 'duration', 'plays', 'date'], + func: async (_page, args) => { + const pageProps = await fetchPageProps(`/podcast/${args.id}`); + const podcast = pageProps.podcast; + if (!podcast) throw new CliError('NOT_FOUND', 'Podcast not found', 'Please check the ID'); + const allEpisodes = podcast.episodes ?? []; + const limit = Math.max(1, Math.min(Number(args.limit) ?? 15, allEpisodes.length)); + const episodes = allEpisodes.slice(0, limit); + return episodes.map((ep: any) => ({ + eid: ep.eid, + title: ep.title, + duration: formatDuration(ep.duration), + plays: ep.playCount, + date: formatDate(ep.pubDate), + })); + }, +}); diff --git a/src/clis/xiaoyuzhou/podcast.ts b/src/clis/xiaoyuzhou/podcast.ts new file mode 100644 index 0000000..840891e --- /dev/null +++ b/src/clis/xiaoyuzhou/podcast.ts @@ -0,0 +1,27 @@ +import { cli, Strategy } from '../../registry.js'; +import { CliError } from '../../errors.js'; +import { fetchPageProps, formatDate } from './utils.js'; + +cli({ + site: 'xiaoyuzhou', + name: 'podcast', + description: 'View a Xiaoyuzhou podcast profile', + domain: 'www.xiaoyuzhoufm.com', + strategy: Strategy.PUBLIC, + browser: false, + args: [{ name: 'id', positional: true, required: true, help: 'Podcast ID (from xiaoyuzhoufm.com URL)' }], + columns: ['title', 'author', 'description', 'subscribers', 'episodes', 'updated'], + func: async (_page, args) => { + const pageProps = await fetchPageProps(`/podcast/${args.id}`); + const p = pageProps.podcast; + if (!p) throw new CliError('NOT_FOUND', 'Podcast not found', 'Please check the ID'); + return [{ + title: p.title, + author: p.author, + description: p.brief, + subscribers: p.subscriptionCount, + episodes: p.episodeCount, + updated: formatDate(p.latestEpisodePubDate), + }]; + }, +}); diff --git a/src/clis/xiaoyuzhou/utils.test.ts b/src/clis/xiaoyuzhou/utils.test.ts new file mode 100644 index 0000000..b450dbd --- /dev/null +++ b/src/clis/xiaoyuzhou/utils.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { formatDuration, formatDate, fetchPageProps } from './utils.js'; + +describe('formatDuration', () => { + it('formats typical duration', () => { + expect(formatDuration(3890)).toBe('64:50'); + }); + + it('formats zero seconds', () => { + expect(formatDuration(0)).toBe('0:00'); + }); + + it('pads single-digit seconds', () => { + expect(formatDuration(65)).toBe('1:05'); + }); + + it('formats exact minutes', () => { + expect(formatDuration(3600)).toBe('60:00'); + }); + + it('rounds floating-point seconds', () => { + expect(formatDuration(3890.7)).toBe('64:51'); + }); + + it('returns dash for NaN', () => { + expect(formatDuration(NaN)).toBe('-'); + }); + + it('returns dash for negative', () => { + expect(formatDuration(-1)).toBe('-'); + }); +}); + +describe('formatDate', () => { + it('extracts YYYY-MM-DD from ISO string', () => { + expect(formatDate('2026-03-13T11:00:06.686Z')).toBe('2026-03-13'); + }); + + it('handles date-only string', () => { + expect(formatDate('2025-01-01')).toBe('2025-01-01'); + }); + + it('returns dash for undefined/empty', () => { + expect(formatDate('')).toBe('-'); + expect(formatDate(undefined as any)).toBe('-'); + }); +}); + +describe('fetchPageProps', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('extracts pageProps from valid HTML', async () => { + const mockHtml = ``; + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + text: () => Promise.resolve(mockHtml), + })); + + const result = await fetchPageProps('/podcast/abc123'); + expect(result).toEqual({ podcast: { title: 'Test' } }); + }); + + it('throws on HTTP error', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: false, + status: 404, + text: () => Promise.resolve('Not Found'), + })); + + await expect(fetchPageProps('/podcast/invalid')).rejects.toThrow('HTTP 404'); + }); + + it('throws when __NEXT_DATA__ is missing', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + text: () => Promise.resolve('
No data here'), + })); + + await expect(fetchPageProps('/podcast/abc')).rejects.toThrow('Failed to extract'); + }); + + it('throws when pageProps is empty', async () => { + const mockHtml = ``; + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + text: () => Promise.resolve(mockHtml), + })); + + await expect(fetchPageProps('/podcast/abc')).rejects.toThrow('Resource not found'); + }); + + it('throws on malformed JSON in __NEXT_DATA__', async () => { + const mockHtml = ``; + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + text: () => Promise.resolve(mockHtml), + })); + + await expect(fetchPageProps('/podcast/abc')).rejects.toThrow('Malformed __NEXT_DATA__'); + }); + + it('handles multiline JSON in __NEXT_DATA__', async () => { + const mockHtml = ``; + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + text: () => Promise.resolve(mockHtml), + })); + + const result = await fetchPageProps('/episode/abc'); + expect(result).toEqual({ episode: { title: 'Multiline Test' } }); + }); +}); diff --git a/src/clis/xiaoyuzhou/utils.ts b/src/clis/xiaoyuzhou/utils.ts new file mode 100644 index 0000000..8556a8d --- /dev/null +++ b/src/clis/xiaoyuzhou/utils.ts @@ -0,0 +1,65 @@ +/** + * Shared Xiaoyuzhou utilities — page data extraction and formatting. + * + * Xiaoyuzhou (小宇宙) is a Next.js app that embeds full page data in + *