feat(xiaoyuzhou): add podcast platform adapter (#18) (#53)

Three public commands for Xiaoyuzhou (小宇宙) podcast platform:
- podcast <id>: view podcast profile
- podcast-episodes <id> [--limit]: list recent episodes (up to 15)
- episode <id>: view episode details

Uses __NEXT_DATA__ extraction from SSR pages, no auth required.
Includes unit tests (16), E2E tests (3), and README updates.
This commit is contained in:
AstroHan authored and GitHub committed 2026-03-18 16:34:03 +08:00
1 parent 64e3a2d627
commit 5e667b9c2f
8 files changed
+311

No files matched your search

+1
View File
@@ -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 |
+1
View File
@@ -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 | 🔐 浏览器 |
+28
View File
@@ -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),
}];
},
});
+32
View File
@@ -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),
}));
},
});
+27
View File
@@ -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),
}];
},
});
+122
View File
@@ -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 = `<html><script id="__NEXT_DATA__" type="application/json">{"props":{"pageProps":{"podcast":{"title":"Test"}}}}</script></html>`;
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('<html><body>No data here</body></html>'),
}));
await expect(fetchPageProps('/podcast/abc')).rejects.toThrow('Failed to extract');
});
it('throws when pageProps is empty', async () => {
const mockHtml = `<script id="__NEXT_DATA__" type="application/json">{"props":{"pageProps":{}}}</script>`;
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 = `<script id="__NEXT_DATA__" type="application/json">{broken json</script>`;
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 = `<script id="__NEXT_DATA__" type="application/json">
{
"props": {
"pageProps": {
"episode": {"title": "Multiline Test"}
}
}
}
</script>`;
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' } });
});
});
+65
View File
@@ -0,0 +1,65 @@
/**
* Shared Xiaoyuzhou utilities — page data extraction and formatting.
*
* Xiaoyuzhou (小宇宙) is a Next.js app that embeds full page data in
* <script id="__NEXT_DATA__">. We fetch the HTML and extract that JSON
* instead of using their authenticated API.
*/
import { CliError } from '../../errors.js';
/**
* Fetch a Xiaoyuzhou page and extract __NEXT_DATA__.props.pageProps.
* @param path - URL path, e.g. '/podcast/xxx' or '/episode/xxx'
*/
export async function fetchPageProps(path: string): Promise<any> {
const url = `https://www.xiaoyuzhoufm.com${path}`;
// Node.js fetch sends UA "node" which gets blocked; use a browser-like UA
const resp = await fetch(url, {
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; opencli)' },
});
if (!resp.ok) {
throw new CliError(
'FETCH_ERROR',
`HTTP ${resp.status} for ${path}`,
'Please check the ID — you can find it in xiaoyuzhoufm.com URLs',
);
}
const html = await resp.text();
// [\s\S]*? for multiline safety (JSON may span lines)
const match = html.match(/<script id="__NEXT_DATA__"[^>]*>([\s\S]*?)<\/script>/);
if (!match) {
throw new CliError(
'PARSE_ERROR',
'Failed to extract __NEXT_DATA__',
'Page structure may have changed',
);
}
let parsed: any;
try { parsed = JSON.parse(match[1]); }
catch { throw new CliError('PARSE_ERROR', 'Malformed __NEXT_DATA__ JSON', 'Page structure may have changed'); }
const pageProps = parsed.props?.pageProps;
if (!pageProps || Object.keys(pageProps).length === 0) {
throw new CliError(
'NOT_FOUND',
'Resource not found',
'Please check the ID — you can find it in xiaoyuzhoufm.com URLs',
);
}
return pageProps;
}
/** Format seconds to mm:ss (e.g. 3890 → "64:50"). Returns '-' for invalid input. */
export function formatDuration(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) return '-';
seconds = Math.round(seconds);
const m = Math.floor(seconds / 60);
const s = seconds % 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);
}
+35
View File
@@ -53,4 +53,39 @@ describe('public commands E2E', () => {
expect(data).toBeDefined();
}
}, 30_000);
// ── xiaoyuzhou (Chinese site — may return empty on overseas CI runners) ──
it('xiaoyuzhou podcast returns podcast profile', async () => {
const { stdout, code } = await runCli(['xiaoyuzhou', 'podcast', '6013f9f58e2f7ee375cf4216', '-f', 'json']);
if (code !== 0) { console.warn('xiaoyuzhou podcast skipped (geo-blocked?)'); return; }
const data = parseJsonOutput(stdout);
expect(Array.isArray(data)).toBe(true);
expect(data.length).toBe(1);
expect(data[0]).toHaveProperty('title');
expect(data[0]).toHaveProperty('subscribers');
expect(data[0]).toHaveProperty('episodes');
}, 30_000);
it('xiaoyuzhou podcast-episodes returns episode list', async () => {
const { stdout, code } = await runCli(['xiaoyuzhou', 'podcast-episodes', '6013f9f58e2f7ee375cf4216', '-f', 'json']);
if (code !== 0) { console.warn('xiaoyuzhou podcast-episodes skipped (geo-blocked?)'); return; }
const data = parseJsonOutput(stdout);
expect(Array.isArray(data)).toBe(true);
expect(data.length).toBeGreaterThanOrEqual(1);
expect(data[0]).toHaveProperty('eid');
expect(data[0]).toHaveProperty('title');
expect(data[0]).toHaveProperty('duration');
}, 30_000);
it('xiaoyuzhou episode returns episode detail', async () => {
const { stdout, code } = await runCli(['xiaoyuzhou', 'episode', '69b3b675772ac2295bfc01d0', '-f', 'json']);
if (code !== 0) { console.warn('xiaoyuzhou episode skipped (geo-blocked?)'); return; }
const data = parseJsonOutput(stdout);
expect(Array.isArray(data)).toBe(true);
expect(data.length).toBe(1);
expect(data[0]).toHaveProperty('title');
expect(data[0]).toHaveProperty('podcast');
expect(data[0]).toHaveProperty('plays');
expect(data[0]).toHaveProperty('comments');
}, 30_000);
});