Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 70 additions & 2 deletions src/build-manifest.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { describe, expect, it } from 'vitest';
import { parseTsArgsBlock } from './build-manifest.js';
import { afterEach, describe, expect, it } from 'vitest';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { parseTsArgsBlock, scanTs, shouldReplaceManifestEntry } from './build-manifest.js';

describe('parseTsArgsBlock', () => {
it('keeps args with nested choices arrays', () => {
Expand Down Expand Up @@ -62,3 +65,68 @@ describe('parseTsArgsBlock', () => {
]);
});
});

describe('manifest helper rules', () => {
const tempDirs: string[] = [];

afterEach(() => {
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});

it('prefers TS adapters over duplicate YAML adapters', () => {
expect(shouldReplaceManifestEntry(
{
site: 'demo',
name: 'search',
description: 'yaml',
strategy: 'public',
browser: false,
args: [],
type: 'yaml',
},
{
site: 'demo',
name: 'search',
description: 'ts',
strategy: 'public',
browser: false,
args: [],
type: 'ts',
modulePath: 'demo/search.js',
},
)).toBe(true);

expect(shouldReplaceManifestEntry(
{
site: 'demo',
name: 'search',
description: 'ts',
strategy: 'public',
browser: false,
args: [],
type: 'ts',
modulePath: 'demo/search.js',
},
{
site: 'demo',
name: 'search',
description: 'yaml',
strategy: 'public',
browser: false,
args: [],
type: 'yaml',
},
)).toBe(false);
});

it('skips TS files that do not register a cli', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-manifest-'));
tempDirs.push(dir);
const file = path.join(dir, 'utils.ts');
fs.writeFileSync(file, `export function helper() { return 'noop'; }`);

expect(scanTs(file, 'demo')).toBeNull();
});
});
37 changes: 32 additions & 5 deletions src/build-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ function scanYaml(filePath: string, site: string): ManifestEntry | null {
}
}

function scanTs(filePath: string, site: string): ManifestEntry | null {
export function scanTs(filePath: string, site: string): ManifestEntry | null {
// TS adapters self-register via cli() at import time.
// We statically parse the source to extract metadata for the manifest stub.
const baseName = path.basename(filePath, path.extname(filePath));
Expand Down Expand Up @@ -263,8 +263,17 @@ function scanTs(filePath: string, site: string): ManifestEntry | null {
}
}

/**
* When both YAML and TS adapters exist for the same site/name,
* prefer the TS version (it self-registers and typically has richer logic).
*/
export function shouldReplaceManifestEntry(current: ManifestEntry, next: ManifestEntry): boolean {
if (current.type === next.type) return true;
return current.type === 'yaml' && next.type === 'ts';
}

export function buildManifest(): ManifestEntry[] {
const manifest: ManifestEntry[] = [];
const manifest = new Map<string, ManifestEntry>();

if (fs.existsSync(CLIS_DIR)) {
for (const site of fs.readdirSync(CLIS_DIR)) {
Expand All @@ -274,19 +283,37 @@ export function buildManifest(): ManifestEntry[] {
const filePath = path.join(siteDir, file);
if (file.endsWith('.yaml') || file.endsWith('.yml')) {
const entry = scanYaml(filePath, site);
if (entry) manifest.push(entry);
if (entry) {
const key = `${entry.site}/${entry.name}`;
const existing = manifest.get(key);
if (!existing || shouldReplaceManifestEntry(existing, entry)) {
if (existing && existing.type !== entry.type) {
process.stderr.write(`⚠️ Duplicate adapter ${key}: ${existing.type} superseded by ${entry.type}\n`);
}
manifest.set(key, entry);
}
}
} else if (
(file.endsWith('.ts') && !file.endsWith('.d.ts') && !file.endsWith('.test.ts') && file !== 'index.ts') ||
(file.endsWith('.js') && !file.endsWith('.d.js') && !file.endsWith('.test.js') && file !== 'index.js')
) {
const entry = scanTs(filePath, site);
if (entry) manifest.push(entry);
if (entry) {
const key = `${entry.site}/${entry.name}`;
const existing = manifest.get(key);
if (!existing || shouldReplaceManifestEntry(existing, entry)) {
if (existing && existing.type !== entry.type) {
process.stderr.write(`⚠️ Duplicate adapter ${key}: ${existing.type} superseded by ${entry.type}\n`);
}
manifest.set(key, entry);
}
}
}
}
}
}

return manifest;
return [...manifest.values()];
}

function main(): void {
Expand Down
15 changes: 15 additions & 0 deletions src/clis/douban/book-hot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { cli, Strategy } from '../../registry.js';
import { loadDoubanBookHot } from './shared.js';

cli({
site: 'douban',
name: 'book-hot',
description: '豆瓣图书热门榜单',
domain: 'book.douban.com',
strategy: Strategy.COOKIE,
args: [
{ name: 'limit', type: 'int', default: 20, help: '返回的图书数量' },
],
columns: ['rank', 'title', 'rating', 'quote', 'author', 'publisher', 'year', 'url'],
func: async (page, args) => loadDoubanBookHot(page, Number(args.limit) || 20),
});
15 changes: 15 additions & 0 deletions src/clis/douban/movie-hot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { cli, Strategy } from '../../registry.js';
import { loadDoubanMovieHot } from './shared.js';

cli({
site: 'douban',
name: 'movie-hot',
description: '豆瓣电影热门榜单',
domain: 'movie.douban.com',
strategy: Strategy.COOKIE,
args: [
{ name: 'limit', type: 'int', default: 20, help: '返回的电影数量' },
],
columns: ['rank', 'title', 'rating', 'quote', 'director', 'year', 'region', 'url'],
func: async (page, args) => loadDoubanMovieHot(page, Number(args.limit) || 20),
});
17 changes: 17 additions & 0 deletions src/clis/douban/search.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { cli, Strategy } from '../../registry.js';
import { searchDouban } from './shared.js';

cli({
site: 'douban',
name: 'search',
description: '搜索豆瓣电影、图书或音乐',
domain: 'search.douban.com',
strategy: Strategy.COOKIE,
args: [
{ name: 'type', default: 'movie', choices: ['movie', 'book', 'music'], help: '搜索类型(movie=电影, book=图书, music=音乐)' },
{ name: 'keyword', required: true, help: '搜索关键词' },
{ name: 'limit', type: 'int', default: 20, help: '返回结果数量' },
],
columns: ['rank', 'title', 'rating', 'abstract', 'url'],
func: async (page, args) => searchDouban(page, args.type, args.keyword, Number(args.limit) || 20),
});
165 changes: 165 additions & 0 deletions src/clis/douban/shared.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import { CliError } from '../../errors.js';
import type { IPage } from '../../types.js';

function clampLimit(limit: number): number {
return Math.max(1, Math.min(limit || 20, 50));
}

async function ensureDoubanReady(page: IPage): Promise<void> {
const state = await page.evaluate(`
(() => {
const title = (document.title || '').trim();
const href = (location.href || '').trim();
const blocked = href.includes('sec.douban.com') || /登录跳转/.test(title) || /异常请求/.test(document.body?.innerText || '');
return { blocked, title, href };
})()
`);
if (state?.blocked) {
throw new CliError(
'AUTH_REQUIRED',
'Douban requires a logged-in browser session before these commands can load data.',
'Please sign in to douban.com in the browser that opencli reuses, then rerun the command.',
);
}
}

export async function loadDoubanBookHot(page: IPage, limit: number): Promise<any[]> {
const safeLimit = clampLimit(limit);
await page.goto('https://book.douban.com/chart');
await page.wait(4);
await ensureDoubanReady(page);
const data = await page.evaluate(`
(() => {
const normalize = (value) => (value || '').replace(/\\s+/g, ' ').trim();
const books = [];
for (const el of Array.from(document.querySelectorAll('.media.clearfix'))) {
try {
const titleEl = el.querySelector('h2 a[href*="/subject/"]');
const title = normalize(titleEl?.textContent);
let url = titleEl?.getAttribute('href') || '';
if (!title || !url) continue;
if (!url.startsWith('http')) url = 'https://book.douban.com' + url;

const info = normalize(el.querySelector('.subject-abstract, .pl, .pub')?.textContent);
const infoParts = info.split('/').map((part) => part.trim()).filter(Boolean);
const ratingText = normalize(el.querySelector('.subject-rating .font-small, .rating_nums, .rating')?.textContent);
const quote = Array.from(el.querySelectorAll('.subject-tags .tag'))
.map((node) => normalize(node.textContent))
.filter(Boolean)
.join(' / ');

books.push({
rank: parseInt(normalize(el.querySelector('.green-num-box')?.textContent), 10) || books.length + 1,
title,
rating: parseFloat(ratingText) || 0,
quote,
author: infoParts[0] || '',
publisher: infoParts.find((part) => /出版社|出版公司|Press/i.test(part)) || infoParts[2] || '',
year: infoParts.find((part) => /\\d{4}(?:-\\d{1,2})?/.test(part))?.match(/\\d{4}/)?.[0] || '',
price: infoParts.find((part) => /元|USD|\\$|¥/.test(part)) || '',
url,
cover: el.querySelector('img')?.getAttribute('src') || '',
});
} catch {}
}
return books.slice(0, ${safeLimit});
})()
`);
return Array.isArray(data) ? data : [];
}

export async function loadDoubanMovieHot(page: IPage, limit: number): Promise<any[]> {
const safeLimit = clampLimit(limit);
await page.goto('https://movie.douban.com/chart');
await page.wait(4);
await ensureDoubanReady(page);
const data = await page.evaluate(`
(() => {
const normalize = (value) => (value || '').replace(/\\s+/g, ' ').trim();
const results = [];
for (const el of Array.from(document.querySelectorAll('.item'))) {
const titleEl = el.querySelector('.pl2 a');
const title = normalize(titleEl?.textContent);
let url = titleEl?.getAttribute('href') || '';
if (!title || !url) continue;
if (!url.startsWith('http')) url = 'https://movie.douban.com' + url;

const info = normalize(el.querySelector('.pl2 p')?.textContent);
const infoParts = info.split('/').map((part) => part.trim()).filter(Boolean);
const releaseIndex = (() => {
for (let i = infoParts.length - 1; i >= 0; i -= 1) {
if (/\\d{4}-\\d{2}-\\d{2}|\\d{4}\\/\\d{2}\\/\\d{2}/.test(infoParts[i])) return i;
}
return -1;
})();
const directorPart = releaseIndex >= 1 ? infoParts[releaseIndex - 1] : '';
const regionPart = releaseIndex >= 2 ? infoParts[releaseIndex - 2] : '';
const yearMatch = info.match(/\\b(19|20)\\d{2}\\b/);
results.push({
rank: results.length + 1,
title,
rating: parseFloat(normalize(el.querySelector('.rating_nums')?.textContent)) || 0,
quote: normalize(el.querySelector('.inq')?.textContent),
director: directorPart.replace(/^导演:\\s*/, ''),
year: yearMatch?.[0] || '',
region: regionPart,
url,
cover: el.querySelector('img')?.getAttribute('src') || '',
});
if (results.length >= ${safeLimit}) break;
}
return results;
})()
`);
return Array.isArray(data) ? data : [];
}

export async function searchDouban(page: IPage, type: string, keyword: string, limit: number): Promise<any[]> {
const safeLimit = clampLimit(limit);
await page.goto(`https://search.douban.com/${encodeURIComponent(type)}/subject_search?search_text=${encodeURIComponent(keyword)}`);
await page.wait(2);
await ensureDoubanReady(page);
const data = await page.evaluate(`
(async () => {
const type = ${JSON.stringify(type)};
const normalize = (value) => (value || '').replace(/\\s+/g, ' ').trim();
const seen = new Set();
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

for (let i = 0; i < 20; i += 1) {
if (document.querySelector('.item-root .title-text, .item-root .title a')) break;
await sleep(300);
}

const items = Array.from(document.querySelectorAll('.item-root'));

const results = [];
for (const el of items) {
const titleEl = el.querySelector('.title-text, .title a, a[title]');
const title = normalize(titleEl?.textContent) || normalize(titleEl?.getAttribute('title'));
let url = titleEl?.getAttribute('href') || '';
if (!title || !url) continue;
if (!url.startsWith('http')) url = 'https://search.douban.com' + url;
if (!url.includes('/subject/') || seen.has(url)) continue;
seen.add(url);
const ratingText = normalize(el.querySelector('.rating_nums')?.textContent);
const abstract = normalize(
el.querySelector('.meta.abstract, .meta, .abstract, p')?.textContent,
);
results.push({
rank: results.length + 1,
id: url.match(/subject\\/(\\d+)/)?.[1] || '',
type,
title,
rating: ratingText.includes('.') ? parseFloat(ratingText) : 0,
abstract: abstract.slice(0, 100) + (abstract.length > 100 ? '...' : ''),
url,
cover: el.querySelector('img')?.getAttribute('src') || '',
});
if (results.length >= ${safeLimit}) break;
}
return results;
})()
`);
return Array.isArray(data) ? data : [];
}
16 changes: 16 additions & 0 deletions src/clis/medium/feed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { cli, Strategy } from '../../registry.js';
import { buildMediumTagUrl, loadMediumPosts } from './shared.js';

cli({
site: 'medium',
name: 'feed',
description: 'Medium 热门文章 Feed',
domain: 'medium.com',
strategy: Strategy.COOKIE,
args: [
{ name: 'topic', default: '', help: '话题标签(如 technology, programming, ai)' },
{ name: 'limit', type: 'int', default: 20, help: '返回的文章数量' },
],
columns: ['rank', 'title', 'author', 'date', 'readTime', 'claps'],
func: async (page, args) => loadMediumPosts(page, buildMediumTagUrl(args.topic), Number(args.limit) || 20),
});
Loading