diff --git a/src/clis/bilibili/following.ts b/src/clis/bilibili/following.ts index 2815b432..76d57b3c 100644 --- a/src/clis/bilibili/following.ts +++ b/src/clis/bilibili/following.ts @@ -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 || []; diff --git a/src/clis/bilibili/subtitle.ts b/src/clis/bilibili/subtitle.ts index 466114e9..75d3e62b 100644 --- a/src/clis/bilibili/subtitle.ts +++ b/src/clis/bilibili/subtitle.ts @@ -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. 数据映射 diff --git a/src/clis/bilibili/utils.ts b/src/clis/bilibili/utils.ts index b158700a..fd47f7fd 100644 --- a/src/clis/bilibili/utils.ts +++ b/src/clis/bilibili/utils.ts @@ -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 { }); 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.'); } diff --git a/src/clis/boss/mark.ts b/src/clis/boss/mark.ts index 55a75107..d8d5a1d1 100644 --- a/src/clis/boss/mark.ts +++ b/src/clis/boss/mark.ts @@ -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 = { '新招呼': 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'; diff --git a/src/clis/boss/send.ts b/src/clis/boss/send.ts index ac74beb5..de7fb36a 100644 --- a/src/clis/boss/send.ts +++ b/src/clis/boss/send.ts @@ -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 }); diff --git a/src/clis/linkedin/search.ts b/src/clis/linkedin/search.ts index 69794379..a2945387 100644 --- a/src/clis/linkedin/search.ts +++ b/src/clis/linkedin/search.ts @@ -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, 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 } 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); diff --git a/src/clis/linkedin/timeline.ts b/src/clis/linkedin/timeline.ts index b292f45a..355c729d 100644 --- a/src/clis/linkedin/timeline.ts +++ b/src/clis/linkedin/timeline.ts @@ -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) => ({ diff --git a/src/clis/medium/shared.ts b/src/clis/medium/shared.ts index 9d35a44e..9f6fcc80 100644 --- a/src/clis/medium/shared.ts +++ b/src/clis/medium/shared.ts @@ -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 { - 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(` diff --git a/src/clis/reddit/read.ts b/src/clis/reddit/read.ts index 3cd6ef3e..1e748220 100644 --- a/src/clis/reddit/read.ts +++ b/src/clis/reddit/read.ts @@ -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; }, diff --git a/src/clis/twitter/bookmarks.ts b/src/clis/twitter/bookmarks.ts index c954b179..b49141ec 100644 --- a/src/clis/twitter/bookmarks.ts +++ b/src/clis/twitter/bookmarks.ts @@ -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; } diff --git a/src/clis/twitter/delete.ts b/src/clis/twitter/delete.ts index dd0f37de..15a2817f 100644 --- a/src/clis/twitter/delete.ts +++ b/src/clis/twitter/delete.ts @@ -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 diff --git a/src/clis/twitter/trending.ts b/src/clis/twitter/trending.ts index e6b82913..b97f59ce 100644 --- a/src/clis/twitter/trending.ts +++ b/src/clis/twitter/trending.ts @@ -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); diff --git a/src/clis/twitter/unfollow.ts b/src/clis/twitter/unfollow.ts index 77980075..5c7ec4e8 100644 --- a/src/clis/twitter/unfollow.ts +++ b/src/clis/twitter/unfollow.ts @@ -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}`); diff --git a/src/clis/youtube/transcript.ts b/src/clis/youtube/transcript.ts index bc002619..441f9081 100644 --- a/src/clis/youtube/transcript.ts +++ b/src/clis/youtube/transcript.ts @@ -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) diff --git a/src/clis/youtube/video.ts b/src/clis/youtube/video.ts index 0796f49d..7ed4aab3 100644 --- a/src/clis/youtube/video.ts +++ b/src/clis/youtube/video.ts @@ -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]) => ({