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:
1 parent
64e3a2d627
commit
5e667b9c2f
8 files changed
+311
No files matched your search
@@ -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 |
|
||||
|
||||
@@ -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 | 🔐 浏览器 |
|
||||
|
||||
@@ -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),
|
||||
}];
|
||||
},
|
||||
});
|
||||
@@ -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),
|
||||
}));
|
||||
},
|
||||
});
|
||||
@@ -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),
|
||||
}];
|
||||
},
|
||||
});
|
||||
@@ -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' } });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user