Compare commits

...

5 Commits

Author SHA1 Message Date
jackwener f8d6319f87 refactor: use CliError subclasses in youtube, bilibili, and boss adapters
Replace raw Error throws with appropriate CliError subclasses:
- youtube/transcript.ts: CommandExecutionError, EmptyResultError
- youtube/video.ts: CommandExecutionError
- bilibili/utils.ts: EmptyResultError
- boss/send.ts: EmptyResultError, SelectorError
- boss/mark.ts: ArgumentError, EmptyResultError

This enables better error handling and user-facing error messages.
2026-03-24 20:59:09 +08:00
jackwener bf6207a4e2 refactor: use CliError subclasses in adapters for better error handling
- linkedin/timeline: AuthRequiredError, EmptyResultError
- linkedin/search: ArgumentError, CommandExecutionError
- bilibili/subtitle: AuthRequiredError, CommandExecutionError, EmptyResultError, SelectorError
- bilibili/following: CommandExecutionError
- medium/shared: CommandExecutionError
- twitter/delete, twitter/unfollow: CommandExecutionError

This allows the top-level error handler to render consistent,
helpful output with emoji-coded severity and actionable hints.
2026-03-24 20:44:31 +08:00
jackwener 4e669629fd refactor: use CliError subclasses in reddit adapters
Replace raw Error throws with appropriate CliError subclasses:
- reddit/read.ts: CommandExecutionError for API-related errors

This enables better error handling and user-facing error messages.
2026-03-24 20:44:10 +08:00
jackwener 6cf14e0800 refactor: use CliError subclasses in twitter adapters
Replace raw Error throws with appropriate CliError subclasses:
- twitter/trending.ts: AuthRequiredError, EmptyResultError
- twitter/bookmarks.ts: AuthRequiredError, CommandExecutionError

This enables better error handling and user-facing error messages.
2026-03-24 20:43:44 +08:00
jackwener 45370b50e1 refactor: use CliError subclasses in linkedin adapters
Replace raw Error throws with appropriate CliError subclasses:
- linkedin/timeline.ts: AuthRequiredError, EmptyResultError
- linkedin/search.ts: ArgumentError, CommandExecutionError

This enables better error handling and user-facing error messages.
2026-03-24 20:43:20 +08:00
15 changed files with 52 additions and 38 deletions
+3 -2
View File
@@ -1,4 +1,5 @@
import { cli, Strategy } from '../../registry.js';
import { CommandExecutionError } from '../../errors.js';
import type { IPage } from '../../types.js';
import { fetchJson, getSelfUid, resolveUid } from './utils.js';
@@ -14,7 +15,7 @@ cli({
],
columns: ['mid', 'name', 'sign', 'following', 'fans'],
func: async (page: IPage | null, kwargs: any) => {
if (!page) throw new Error('Requires browser');
if (!page) throw new CommandExecutionError('Browser session required for bilibili following');
// 1. Resolve UID (default to self)
const uid = kwargs.uid
@@ -30,7 +31,7 @@ cli({
);
if (payload.code !== 0) {
throw new Error(`获取关注列表失败: ${payload.message} (${payload.code})`);
throw new CommandExecutionError(`获取关注列表失败: ${payload.message} (${payload.code})`);
}
const list = payload.data?.list || [];
+8 -7
View File
@@ -1,4 +1,5 @@
import { cli, Strategy } from '../../registry.js';
import { AuthRequiredError, CommandExecutionError, EmptyResultError, SelectorError } from '../../errors.js';
import type { IPage } from '../../types.js';
import { apiGet } from './utils.js';
@@ -13,7 +14,7 @@ cli({
],
columns: ['index', 'from', 'to', 'content'],
func: async (page: IPage | null, kwargs: any) => {
if (!page) throw new Error('Requires browser');
if (!page) throw new CommandExecutionError('Browser session required for bilibili subtitle');
// 1. 先前往视频详情页 (建立有鉴权的 Session,且这里不需要加载完整个视频)
await page.goto(`https://www.bilibili.com/video/${kwargs.bvid}/`);
@@ -24,7 +25,7 @@ cli({
})()`);
if (!cid) {
throw new Error('无法在页面中提取到当前视频的 CID,请检查页面是否正常加载。');
throw new SelectorError('videoData.cid', '无法在页面中提取到当前视频的 CID,请检查页面是否正常加载。');
}
// 3. 在 Node 端使用 apiGet 获取带 Wbi 签名的字幕列表
@@ -35,12 +36,12 @@ cli({
});
if (payload.code !== 0) {
throw new Error(`获取视频播放信息失败: ${payload.message} (${payload.code})`);
throw new CommandExecutionError(`获取视频播放信息失败: ${payload.message} (${payload.code})`);
}
const subtitles = payload.data?.subtitle?.subtitles || [];
if (subtitles.length === 0) {
throw new Error('此视频没有发现外挂或智能字幕。');
throw new EmptyResultError('bilibili subtitle', '此视频没有发现外挂或智能字幕。');
}
// 4. 选择目标字幕语言
@@ -50,7 +51,7 @@ cli({
const targetSubUrl = target.subtitle_url;
if (!targetSubUrl || targetSubUrl === '') {
throw new Error('[风控拦截/未登录] 获取到的 subtitle_url 为空!请确保 CLI 已成功登录且风控未封锁此账号。');
throw new AuthRequiredError('bilibili.com', '[风控拦截/未登录] 获取到的 subtitle_url 为空!请确保 CLI 已成功登录且风控未封锁此账号。');
}
const finalUrl = targetSubUrl.startsWith('//') ? 'https:' + targetSubUrl : targetSubUrl;
@@ -81,12 +82,12 @@ cli({
const items = await page.evaluate(fetchJs);
if (items?.error) {
throw new Error(`字幕获取失败: ${items.error}${items.text ? ' — ' + items.text : ''}`);
throw new CommandExecutionError(`字幕获取失败: ${items.error}${items.text ? ' — ' + items.text : ''}`);
}
const finalItems = items?.data || [];
if (!Array.isArray(finalItems)) {
throw new Error('解析到的字幕列表对象不符合数组格式');
throw new CommandExecutionError('解析到的字幕列表对象不符合数组格式');
}
// 6. 数据映射
+2 -2
View File
@@ -3,7 +3,7 @@
*/
import type { IPage } from '../../types.js';
import { AuthRequiredError } from '../../errors.js';
import { AuthRequiredError, EmptyResultError } from '../../errors.js';
const MIXIN_KEY_ENC_TAB = [
46,47,18,2,53,8,23,32,15,50,10,31,58,3,45,35,27,43,5,49,
@@ -112,5 +112,5 @@ export async function resolveUid(page: IPage, input: string): Promise<string> {
});
const results = payload?.data?.result ?? [];
if (results.length > 0) return String(results[0].mid);
throw new Error(`Cannot resolve UID for: ${input}`);
throw new EmptyResultError(`bilibili user search: ${input}`, 'User may not exist or username may have changed.');
}
+3 -2
View File
@@ -7,6 +7,7 @@
*/
import { cli, Strategy } from '../../registry.js';
import { requirePage, navigateToChat, bossFetch, findFriendByUid, verbose } from './common.js';
import { ArgumentError, EmptyResultError } from '../../errors.js';
const LABEL_MAP: Record<string, number> = {
'新招呼': 1, '沟通中': 2, '已约面': 3, '已获取简历': 4,
@@ -44,7 +45,7 @@ cli({
if (entry) {
labelId = entry[1];
} else {
throw new Error(`未知标签: ${labelInput}。可用标签: ${Object.keys(LABEL_MAP).join(', ')}`);
throw new ArgumentError(`未知标签: ${labelInput}。可用标签: ${Object.keys(LABEL_MAP).join(', ')}`);
}
}
@@ -53,7 +54,7 @@ cli({
await navigateToChat(page);
const friend = await findFriendByUid(page, kwargs.uid, { checkGreetList: true });
if (!friend) throw new Error('未找到该候选人');
if (!friend) throw new EmptyResultError('boss candidate search');
const friendName = friend.name || '候选人';
const action = remove ? 'deleteMark' : 'addMark';
+4 -3
View File
@@ -9,6 +9,7 @@ import {
requirePage, navigateToChat, findFriendByUid,
clickCandidateInList, typeAndSendMessage,
} from './common.js';
import { EmptyResultError, SelectorError } from '../../errors.js';
cli({
site: 'boss',
@@ -29,21 +30,21 @@ cli({
await navigateToChat(page, 3);
const friend = await findFriendByUid(page, kwargs.uid, { maxPages: 5 });
if (!friend) throw new Error('未找到该候选人,请确认 uid 是否正确');
if (!friend) throw new EmptyResultError('boss candidate search', '请确认 uid 是否正确');
const numericUid = friend.uid;
const friendName = friend.name || '候选人';
const clicked = await clickCandidateInList(page, numericUid);
if (!clicked) {
throw new Error('无法在聊天列表中找到该用户,请确认聊天列表中有此人');
throw new SelectorError('聊天列表中的用户', '请确认聊天列表中有此人');
}
await page.wait({ time: 2 });
const sent = await typeAndSendMessage(page, kwargs.text);
if (!sent) {
throw new Error('找不到消息输入框');
throw new SelectorError('消息输入框', '聊天页面 UI 可能已改变');
}
await page.wait({ time: 1 });
+5 -4
View File
@@ -1,5 +1,6 @@
import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';
import { ArgumentError, CommandExecutionError } from '../../errors.js';
// ── Filter value mappings ──────────────────────────────────────────────
@@ -64,7 +65,7 @@ function mapFilterValues(input: unknown, mapping: Record<string, string>, label:
const resolved = values.map(value => {
const key = value.toLowerCase();
const mapped = mapping[key];
if (!mapped) throw new Error(`Unsupported ${label}: ${value}`);
if (!mapped) throw new ArgumentError(`Unsupported ${label}: ${value}`);
return mapped;
});
return [...new Set(resolved)];
@@ -214,7 +215,7 @@ async function resolveCompanyIds(page: IPage, input: unknown): Promise<string[]>
}
if (unresolved.length) {
throw new Error(`Could not resolve LinkedIn company filter: ${unresolved.join(', ')}`);
throw new ArgumentError(`Could not resolve LinkedIn company filter: ${unresolved.join(', ')}`);
}
return [...ids];
@@ -252,7 +253,7 @@ async function fetchJobCards(
})()`);
if (!batch || batch.error) {
throw new Error(batch?.error || 'LinkedIn search returned an unexpected response');
throw new CommandExecutionError(batch?.error || 'LinkedIn search returned an unexpected response');
}
const elements: any[] = Array.isArray(batch?.elements) ? batch.elements : [];
@@ -387,7 +388,7 @@ cli({
const location = (kwargs.location ?? '').trim();
const keywords = String(kwargs.query ?? '').trim();
if (!keywords) throw new Error('query is required');
if (!keywords) throw new ArgumentError('query is required');
const searchParams = new URLSearchParams({ keywords });
if (location) searchParams.set('location', location);
+3 -2
View File
@@ -1,5 +1,6 @@
import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';
import { AuthRequiredError, EmptyResultError } from '../../errors.js';
interface TimelinePost {
rank?: number;
@@ -510,11 +511,11 @@ cli({
}
if (sawLoginWall && posts.length === 0) {
throw new Error('LinkedIn timeline requires an active signed-in browser session');
throw new AuthRequiredError('linkedin.com', 'LinkedIn timeline requires an active signed-in browser session');
}
if (posts.length === 0) {
throw new Error('No LinkedIn timeline posts found. Make sure your LinkedIn home feed is visible in the browser.');
throw new EmptyResultError('linkedin timeline', 'Make sure your LinkedIn home feed is visible in the browser.');
}
return posts.slice(0, limit).map((post, index) => ({
+2 -1
View File
@@ -1,3 +1,4 @@
import { CommandExecutionError } from '../../errors.js';
import type { IPage } from '../../types.js';
export function buildMediumTagUrl(topic?: string): string {
@@ -13,7 +14,7 @@ export function buildMediumUserUrl(username: string): string {
}
export async function loadMediumPosts(page: IPage, url: string, limit: number): Promise<any[]> {
if (!page) throw new Error('Requires browser session');
if (!page) throw new CommandExecutionError('Browser session required for medium posts');
await page.goto(url);
await page.wait(5);
const data = await page.evaluate(`
+4 -3
View File
@@ -7,6 +7,7 @@
* - Indented output showing conversation threads
*/
import { cli, Strategy } from '../../registry.js';
import { CommandExecutionError } from '../../errors.js';
cli({
site: 'reddit',
@@ -176,9 +177,9 @@ cli({
})()
`);
if (!data || typeof data !== 'object') throw new Error('Failed to fetch post data');
if (!Array.isArray(data) && data.error) throw new Error(data.error);
if (!Array.isArray(data)) throw new Error('Unexpected response');
if (!data || typeof data !== 'object') throw new CommandExecutionError('Failed to fetch post data');
if (!Array.isArray(data) && data.error) throw new CommandExecutionError(data.error);
if (!Array.isArray(data)) throw new CommandExecutionError('Unexpected response');
return data;
},
+3 -2
View File
@@ -1,4 +1,5 @@
import { cli, Strategy } from '../../registry.js';
import { AuthRequiredError, CommandExecutionError } from '../../errors.js';
const BEARER_TOKEN = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
const BOOKMARKS_QUERY_ID = 'Fy0QMy4q_aZCpkO0PnyLYw';
@@ -137,7 +138,7 @@ cli({
const ct0 = await page.evaluate(`() => {
return document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith('ct0='))?.split('=')[1] || null;
}`);
if (!ct0) throw new Error('Not logged into x.com (no ct0 cookie)');
if (!ct0) throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
const queryId = await page.evaluate(`async () => {
try {
@@ -185,7 +186,7 @@ cli({
}`);
if (data?.error) {
if (allTweets.length === 0) throw new Error(`HTTP ${data.error}: Failed to fetch bookmarks. queryId may have expired.`);
if (allTweets.length === 0) throw new CommandExecutionError(`HTTP ${data.error}: Failed to fetch bookmarks. queryId may have expired.`);
break;
}
+2 -1
View File
@@ -1,4 +1,5 @@
import { cli, Strategy } from '../../registry.js';
import { CommandExecutionError } from '../../errors.js';
import type { IPage } from '../../types.js';
cli({
@@ -13,7 +14,7 @@ cli({
],
columns: ['status', 'message'],
func: async (page: IPage | null, kwargs: any) => {
if (!page) throw new Error('Requires browser');
if (!page) throw new CommandExecutionError('Browser session required for twitter delete');
await page.goto(kwargs.url);
await page.wait(5); // Wait for tweet to load completely
+3 -2
View File
@@ -1,4 +1,5 @@
import { cli, Strategy } from '../../registry.js';
import { AuthRequiredError, EmptyResultError } from '../../errors.js';
// ── Twitter GraphQL constants ──────────────────────────────────────────
@@ -37,7 +38,7 @@ cli({
const ct0 = await page.evaluate(`(() => {
return document.cookie.split(';').map(c=>c.trim()).find(c=>c.startsWith('ct0='))?.split('=')[1] || null;
})()`);
if (!ct0) throw new Error('Not logged into x.com (no ct0 cookie)');
if (!ct0) throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
// Try legacy guide.json API first (faster than DOM scraping)
let trends: TrendItem[] = [];
@@ -105,7 +106,7 @@ cli({
}
if (trends.length === 0) {
throw new Error('No trending data found. API may have changed or login may be required.');
throw new EmptyResultError('twitter trending', 'API may have changed or login may be required.');
}
return trends.slice(0, limit);
+2 -1
View File
@@ -1,4 +1,5 @@
import { cli, Strategy } from '../../registry.js';
import { CommandExecutionError } from '../../errors.js';
import type { IPage } from '../../types.js';
cli({
@@ -13,7 +14,7 @@ cli({
],
columns: ['status', 'message'],
func: async (page: IPage | null, kwargs: any) => {
if (!page) throw new Error('Requires browser');
if (!page) throw new CommandExecutionError('Browser session required for twitter unfollow');
const username = kwargs.username.replace(/^@/, '');
await page.goto(`https://x.com/${username}`);
+5 -4
View File
@@ -17,6 +17,7 @@ import {
type RawSegment,
type Chapter,
} from './transcript-group.js';
import { CommandExecutionError, EmptyResultError } from '../../errors.js';
cli({
site: 'youtube',
@@ -91,10 +92,10 @@ cli({
`);
if (!captionData || typeof captionData === 'string') {
throw new Error(`Failed to get caption info: ${typeof captionData === 'string' ? captionData : 'null response'}`);
throw new CommandExecutionError(`Failed to get caption info: ${typeof captionData === 'string' ? captionData : 'null response'}`);
}
if (captionData.error) {
throw new Error(`${captionData.error}${captionData.available ? ' (available: ' + captionData.available.join(', ') + ')' : ''}`);
throw new CommandExecutionError(`${captionData.error}${captionData.available ? ' (available: ' + captionData.available.join(', ') + ')' : ''}`);
}
// Warn if --lang was specified but not matched
@@ -176,10 +177,10 @@ cli({
`);
if (!Array.isArray(segments)) {
throw new Error((segments as any)?.error || 'Failed to parse caption segments');
throw new CommandExecutionError((segments as any)?.error || 'Failed to parse caption segments');
}
if (segments.length === 0) {
throw new Error('No caption segments found');
throw new EmptyResultError('youtube transcript');
}
// Step 3: Fetch chapters (for grouped mode)
+3 -2
View File
@@ -3,6 +3,7 @@
*/
import { cli, Strategy } from '../../registry.js';
import { parseVideoId } from './utils.js';
import { CommandExecutionError } from '../../errors.js';
cli({
site: 'youtube',
@@ -104,8 +105,8 @@ cli({
})()
`);
if (!data || typeof data !== 'object') throw new Error('Failed to extract video metadata from page');
if (data.error) throw new Error(data.error);
if (!data || typeof data !== 'object') throw new CommandExecutionError('Failed to extract video metadata from page');
if (data.error) throw new CommandExecutionError(data.error);
// Return as field/value pairs for table display
return Object.entries(data).map(([field, value]) => ({