diff --git a/src/clis/antigravity/model.ts b/src/clis/antigravity/model.ts index e74a38e3..2b2e99f3 100644 --- a/src/clis/antigravity/model.ts +++ b/src/clis/antigravity/model.ts @@ -1,4 +1,5 @@ import { cli, Strategy } from '../../registry.js'; +import { SelectorError, ArgumentError } from '../../errors.js'; export const modelCommand = cli({ site: 'antigravity', @@ -13,34 +14,45 @@ export const modelCommand = cli({ columns: ['Status'], func: async (page, kwargs) => { const targetName = kwargs.name.toLowerCase(); - - await page.evaluate(` - async () => { - const targetModelName = ${JSON.stringify(targetName)}; - - // 1. Locate the model selector dropdown trigger - const trigger = document.querySelector('div[aria-haspopup="dialog"] > div[tabindex="0"]'); - if (!trigger) throw new Error('Could not find the model selector trigger in the UI'); - trigger.click(); - - // 2. Wait a brief moment for React to mount the Portal/Dialog - await new Promise(r => setTimeout(r, 200)); - - // 3. Find the option spanning target text - const spans = Array.from(document.querySelectorAll('[role="dialog"] span')); - const target = spans.find(s => s.innerText.toLowerCase().includes(targetModelName)); - if (!target) { - // If not found, click the trigger again to close it safely + + try { + const result = await page.evaluate(` + async () => { + const targetModelName = ${JSON.stringify(targetName)}; + + // 1. Locate the model selector dropdown trigger + const trigger = document.querySelector('div[aria-haspopup="dialog"] > div[tabindex="0"]'); + if (!trigger) throw new Error('Could not find the model selector trigger in the UI'); trigger.click(); - throw new Error('Model matching "' + targetModelName + '" was not found in the dropdown list.'); + + // 2. Wait a brief moment for React to mount the Portal/Dialog + await new Promise(r => setTimeout(r, 200)); + + // 3. Find the option spanning target text + const spans = Array.from(document.querySelectorAll('[role="dialog"] span')); + const target = spans.find(s => s.innerText.toLowerCase().includes(targetModelName)); + if (!target) { + // If not found, click the trigger again to close it safely + trigger.click(); + throw new Error('Model matching "' + targetModelName + '" was not found in the dropdown list.'); + } + + // 4. Click the closest parent that handles the row action + const optionNode = target.closest('.cursor-pointer') || target; + optionNode.click(); + return { ok: true }; } - - // 4. Click the closest parent that handles the row action - const optionNode = target.closest('.cursor-pointer') || target; - optionNode.click(); + `); + if (!result?.ok) throw new SelectorError('model selector', 'Could not find or select model in UI'); + } catch (e: any) { + if (e.message?.includes('Could not find the model selector trigger')) { + throw new SelectorError('model selector trigger', 'Could not find the model selector trigger in Antigravity UI'); + } + if (e.message?.includes('was not found in the dropdown list')) { + throw new ArgumentError(`Model matching "${targetName}" was not found in the dropdown list.`); } - `); - + throw e; + } await page.wait(0.5); return [{ Status: `Model switched to: ${kwargs.name}` }]; }, diff --git a/src/clis/antigravity/new.ts b/src/clis/antigravity/new.ts index ab13befb..1ba87d14 100644 --- a/src/clis/antigravity/new.ts +++ b/src/clis/antigravity/new.ts @@ -1,4 +1,5 @@ import { cli, Strategy } from '../../registry.js'; +import { SelectorError } from '../../errors.js'; export const newCommand = cli({ site: 'antigravity', @@ -10,19 +11,26 @@ export const newCommand = cli({ args: [], columns: ['status'], func: async (page) => { - await page.evaluate(` - async () => { - const btn = document.querySelector('[data-tooltip-id="new-conversation-tooltip"]'); - if (!btn) throw new Error('Could not find New Conversation button'); - - // In case it's disabled, we must check, but we'll try to click it anyway - btn.click(); + try { + await page.evaluate(` + async () => { + const btn = document.querySelector('[data-tooltip-id="new-conversation-tooltip"]'); + if (!btn) throw new Error('Could not find New Conversation button'); + + // In case it's disabled, we must check, but we'll try to click it anyway + btn.click(); + } + `); + } catch (e: any) { + if (e.message?.includes('Could not find New Conversation button')) { + throw new SelectorError('New Conversation button', 'Could not find New Conversation button in Antigravity UI'); } - `); - + throw e; + } + // Give it a moment to reset the UI await page.wait(0.5); - + return [{ status: 'Successfully started a new conversation' }]; }, }); diff --git a/src/clis/antigravity/read.ts b/src/clis/antigravity/read.ts index 46a0cff4..94a237c3 100644 --- a/src/clis/antigravity/read.ts +++ b/src/clis/antigravity/read.ts @@ -1,4 +1,5 @@ import { cli, Strategy } from '../../registry.js'; +import { SelectorError } from '../../errors.js'; export const readCommand = cli({ site: 'antigravity', @@ -12,25 +13,33 @@ export const readCommand = cli({ ], columns: ['role', 'content'], func: async (page, kwargs) => { - // We execute a script inside Antigravity's Chromium environment to extract the text + // We execute a script inside Antigravity's Chromium environment to extract the text // of the entire conversation pane. - const rawText = await page.evaluate(` - async () => { - const container = document.getElementById('conversation'); - if (!container) throw new Error('Could not find conversation container'); - - // Extract the full visible text of the conversation - // In Electron/Chromium, innerText preserves basic visual line breaks nicely - return container.innerText; + let rawText: string; + try { + rawText = await page.evaluate(` + async () => { + const container = document.getElementById('conversation'); + if (!container) throw new Error('Could not find conversation container'); + + // Extract the full visible text of the conversation + // In Electron/Chromium, innerText preserves basic visual line breaks nicely + return container.innerText; + } + `); + } catch (e: any) { + if (e.message?.includes('Could not find conversation container')) { + throw new SelectorError('#conversation element', 'Could not find conversation container in Antigravity UI'); } - `); - + throw e; + } + // We can do simple heuristic parsing based on typical visual markers if needed. // For now, we return the entire text blob, or just the last 2000 characters if it's too long. const cleanText = String(rawText).trim(); - return [{ - role: 'history', - content: cleanText + return [{ + role: 'history', + content: cleanText }]; }, }); diff --git a/src/clis/antigravity/send.ts b/src/clis/antigravity/send.ts index 3563ffad..6bd88e5b 100644 --- a/src/clis/antigravity/send.ts +++ b/src/clis/antigravity/send.ts @@ -1,4 +1,5 @@ import { cli, Strategy } from '../../registry.js'; +import { SelectorError } from '../../errors.js'; export const sendCommand = cli({ site: 'antigravity', @@ -17,18 +18,24 @@ export const sendCommand = cli({ // We use evaluate to focus and insert text because Lexical editors maintain // absolute control over their DOM and don't respond to raw node.textContent. // document.execCommand simulates a native paste/typing action perfectly. - await page.evaluate(` - async () => { - const container = document.getElementById('antigravity.agentSidePanelInputBox'); - if (!container) throw new Error('Could not find antigravity.agentSidePanelInputBox'); - const editor = container.querySelector('[data-lexical-editor="true"]'); - if (!editor) throw new Error('Could not find Antigravity input box'); - - editor.focus(); - document.execCommand('insertText', false, ${JSON.stringify(text)}); + try { + await page.evaluate(` + async () => { + const container = document.getElementById('antigravity.agentSidePanelInputBox'); + if (!container) throw new Error('Could not find antigravity.agentSidePanelInputBox'); + const editor = container.querySelector('[data-lexical-editor="true"]'); + if (!editor) throw new Error('Could not find Antigravity input box'); + + editor.focus(); + document.execCommand('insertText', false, ${JSON.stringify(text)}); + } + `); + } catch (e: any) { + if (e.message?.includes('Could not find')) { + throw new SelectorError('Antigravity input box', 'Could not find or focus input element'); } - `); - + throw e; + } // Wait for the React/Lexical state to flush the new input await page.wait(0.5); diff --git a/src/clis/antigravity/serve.ts b/src/clis/antigravity/serve.ts index ad57a0e6..102f08bc 100644 --- a/src/clis/antigravity/serve.ts +++ b/src/clis/antigravity/serve.ts @@ -13,6 +13,7 @@ import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; import { CDPBridge } from '../../browser/cdp.js'; import type { IPage } from '../../types.js'; +import { SelectorError, ArgumentError, TimeoutError, ConfigError, BrowserConnectError } from '../../errors.js'; // ─── Types ─────────────────────────────────────────────────────────── @@ -255,47 +256,64 @@ async function getLastAssistantReply(page: IPage, userText?: string): Promise { if (!bridge) { // Fallback: use JS-based approach - await page.evaluate(` - (() => { - const container = document.getElementById('antigravity.agentSidePanelInputBox'); - const editor = container?.querySelector('[data-lexical-editor="true"]'); - if (!editor) throw new Error('Could not find input box'); - editor.focus(); - document.execCommand('insertText', false, ${JSON.stringify(message)}); - })() - `); + try { + await page.evaluate(` + (() => { + const container = document.getElementById('antigravity.agentSidePanelInputBox'); + const editor = container?.querySelector('[data-lexical-editor="true"]'); + if (!editor) throw new Error('Could not find input box'); + editor.focus(); + document.execCommand('insertText', false, ${JSON.stringify(message)}); + })() + `); + } catch (e: any) { + if (e.message?.includes('Could not find input box')) { + throw new SelectorError('Antigravity input box', 'Could not find Antigravity input box (Lexical editor) in UI'); + } + throw e; + } await sleep(500); await page.pressKey('Enter'); return; } // Get the bounding box of the Lexical editor for a physical mouse click - const rect = await page.evaluate(` - (() => { - const container = document.getElementById('antigravity.agentSidePanelInputBox'); - if (!container) throw new Error('Could not find antigravity.agentSidePanelInputBox'); - const editor = container.querySelector('[data-lexical-editor="true"]'); - if (!editor) throw new Error('Could not find Antigravity input box'); - const r = editor.getBoundingClientRect(); - return JSON.stringify({ x: r.left + r.width / 2, y: r.top + r.height / 2 }); - })() - `); - const { x, y } = JSON.parse(String(rect)); - - // Physical mouse click to give the element real browser focus - await bridge.send('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', clickCount: 1 }); - await sleep(50); - await bridge.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', clickCount: 1 }); - await sleep(200); - - // Inject text at the CDP level (no deprecated execCommand) - await bridge.send('Input.insertText', { text: message }); - await sleep(300); - - // Send Enter via native CDP key event - await bridge.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13 }); - await sleep(50); - await bridge.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13 }); + try { + const rect = await page.evaluate(` + (() => { + const container = document.getElementById('antigravity.agentSidePanelInputBox'); + if (!container) throw new Error('Could not find antigravity.agentSidePanelInputBox'); + const editor = container.querySelector('[data-lexical-editor="true"]'); + if (!editor) throw new Error('Could not find Antigravity input box'); + const r = editor.getBoundingClientRect(); + return JSON.stringify({ x: r.left + r.width / 2, y: r.top + r.height / 2 }); + })() + `); + const { x, y } = JSON.parse(String(rect)); + + // Physical mouse click to give the element real browser focus + await bridge.send('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', clickCount: 1 }); + await sleep(50); + await bridge.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', clickCount: 1 }); + await sleep(200); + + // Inject text at the CDP level (no deprecated execCommand) + await bridge.send('Input.insertText', { text: message }); + await sleep(300); + + // Send Enter via native CDP key event + await bridge.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13 }); + await sleep(50); + await bridge.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13 }); + } catch (e: any) { + if (e.message?.includes('Could not find antigravity.agentSidePanelInputBox')) { + throw new SelectorError('#antigravity.agentSidePanelInputBox', 'Could not find Antigravity input container in UI'); + } + if (e.message?.includes('Could not find Antigravity input box')) { + throw new SelectorError('Antigravity input box', 'Could not find Antigravity input box (Lexical editor) in UI'); + } + throw e; + } } async function waitForReply( @@ -349,7 +367,7 @@ async function waitForReply( await sleep(pollInterval); } - throw new Error('Timeout waiting for Antigravity reply'); + throw new TimeoutError('Antigravity reply', timeout / 1000); } // ─── Request Handlers ──────────────────────────────────────────────── @@ -362,13 +380,13 @@ async function handleMessages( // Extract the last user message const userMessages = body.messages.filter(m => m.role === 'user'); if (userMessages.length === 0) { - throw new Error('No user message found in request'); + throw new ArgumentError('No user message found in request'); } const lastUserMsg = userMessages[userMessages.length - 1]; const userText = extractTextContent(lastUserMsg.content); if (!userText.trim()) { - throw new Error('Empty user message'); + throw new ArgumentError('Empty user message'); } // Optimization 1: New conversation if this is the first message in the session @@ -437,7 +455,7 @@ export async function startServe(opts: { port?: number } = {}): Promise { const endpoint = process.env.OPENCLI_CDP_ENDPOINT; if (!endpoint) { - throw new Error( + throw new ConfigError( 'OPENCLI_CDP_ENDPOINT is not set.\n' + 'Usage: OPENCLI_CDP_ENDPOINT=http://127.0.0.1:9224 opencli antigravity serve' ); @@ -464,7 +482,7 @@ export async function startServe(opts: { port?: number } = {}): Promise { } catch (err: any) { cdp = null; const isRefused = err?.cause?.code === 'ECONNREFUSED' || err?.message?.includes('ECONNREFUSED'); - throw new Error( + throw new BrowserConnectError( isRefused ? `Cannot connect to Antigravity at ${endpoint}.\n` + ' 1. Make sure Antigravity is running\n' + diff --git a/src/clis/barchart/flow.ts b/src/clis/barchart/flow.ts index 6718bea2..dde819aa 100644 --- a/src/clis/barchart/flow.ts +++ b/src/clis/barchart/flow.ts @@ -4,6 +4,7 @@ * Auth: CSRF token from + session cookies. */ import { cli, Strategy } from '../../registry.js'; +import { AuthRequiredError } from '../../errors.js'; cli({ site: 'barchart', @@ -100,7 +101,7 @@ cli({ if (!data) return []; if (data.error === 'no-csrf') { - throw new Error('Could not extract CSRF token from barchart.com. Make sure you are logged in.'); + throw new AuthRequiredError('barchart.com', 'Could not extract CSRF token. Make sure you are logged in.'); } if (!Array.isArray(data)) return []; diff --git a/src/clis/boss/chatmsg.ts b/src/clis/boss/chatmsg.ts index c8ed4aec..ac96db81 100644 --- a/src/clis/boss/chatmsg.ts +++ b/src/clis/boss/chatmsg.ts @@ -1,4 +1,5 @@ import { cli, Strategy } from '../../registry.js'; +import { EmptyResultError } from '../../errors.js'; import { requirePage, navigateToChat, bossFetch, findFriendByUid } from './utils.js'; cli({ @@ -19,7 +20,7 @@ cli({ await navigateToChat(page); const friend = await findFriendByUid(page, kwargs.uid); - if (!friend) throw new Error('未找到该候选人'); + if (!friend) throw new EmptyResultError('未找到该候选人'); const gid = friend.uid; const securityId = encodeURIComponent(friend.securityId); diff --git a/src/clis/boss/detail.ts b/src/clis/boss/detail.ts index 667ea4a3..311fd406 100644 --- a/src/clis/boss/detail.ts +++ b/src/clis/boss/detail.ts @@ -2,6 +2,7 @@ * BOSS直聘 job detail — fetch full job posting details via browser cookie API. */ import { cli, Strategy } from '../../registry.js'; +import { EmptyResultError } from '../../errors.js'; import { requirePage, navigateTo, bossFetch, verbose } from './utils.js'; cli({ @@ -40,7 +41,7 @@ cli({ const brandComInfo = zpData.brandComInfo || {}; if (!jobInfo.jobName) { - throw new Error('该职位信息不存在或已下架'); + throw new EmptyResultError('该职位信息不存在或已下架'); } return [{ diff --git a/src/clis/boss/exchange.ts b/src/clis/boss/exchange.ts index d3c9e8ea..23ba496e 100644 --- a/src/clis/boss/exchange.ts +++ b/src/clis/boss/exchange.ts @@ -2,6 +2,7 @@ * BOSS直聘 exchange — request phone/wechat exchange with a candidate. */ import { cli, Strategy } from '../../registry.js'; +import { EmptyResultError } from '../../errors.js'; import { requirePage, navigateToChat, bossFetch, findFriendByUid, verbose } from './utils.js'; cli({ @@ -26,7 +27,7 @@ cli({ await navigateToChat(page); const friend = await findFriendByUid(page, kwargs.uid, { checkGreetList: true }); - if (!friend) throw new Error('未找到该候选人'); + if (!friend) throw new EmptyResultError('未找到该候选人'); const friendName = friend.name || '候选人'; const typeId = exchangeType === 'wechat' ? 2 : 1; diff --git a/src/clis/boss/greet.ts b/src/clis/boss/greet.ts index cb7af0a6..a871c77c 100644 --- a/src/clis/boss/greet.ts +++ b/src/clis/boss/greet.ts @@ -2,6 +2,7 @@ * BOSS直聘 greet — send greeting to a new candidate (initiate chat). */ import { cli, Strategy } from '../../registry.js'; +import { CommandExecutionError, EmptyResultError, SelectorError } from '../../errors.js'; import { requirePage, navigateToChat, findFriendByUid, clickCandidateInList, typeAndSendMessage, verbose, @@ -35,7 +36,7 @@ cli({ }); if (!friend) { - throw new Error('未找到该候选人,请确认 uid 是否正确(可从 recommend 命令获取)'); + throw new EmptyResultError('未找到该候选人,请确认 uid 是否正确(可从 recommend 命令获取)'); } const numericUid = friend.uid; @@ -43,7 +44,7 @@ cli({ const clicked = await clickCandidateInList(page, numericUid); if (!clicked) { - throw new Error('无法在聊天列表中找到该用户,候选人可能不在当前列表中'); + throw new SelectorError('candidate in chat list', '无法在聊天列表中找到该用户,候选人可能不在当前列表中'); } await page.wait({ time: 2 }); @@ -51,7 +52,7 @@ cli({ const msgText = kwargs.text || '你好,请问您对这个职位感兴趣吗?'; const sent = await typeAndSendMessage(page, msgText); if (!sent) { - throw new Error('找不到消息输入框'); + throw new SelectorError('message input box', '找不到消息输入框'); } await page.wait({ time: 1 }); diff --git a/src/clis/boss/invite.ts b/src/clis/boss/invite.ts index 73ed6908..55be1ef5 100644 --- a/src/clis/boss/invite.ts +++ b/src/clis/boss/invite.ts @@ -2,6 +2,7 @@ * BOSS直聘 invite — send interview invitation to a candidate. */ import { cli, Strategy } from '../../registry.js'; +import { CommandExecutionError, EmptyResultError } from '../../errors.js'; import { requirePage, navigateToChat, bossFetch, findFriendByUid, verbose } from './utils.js'; cli({ @@ -26,7 +27,7 @@ cli({ await navigateToChat(page); const friend = await findFriendByUid(page, kwargs.uid, { checkGreetList: true }); - if (!friend) throw new Error('未找到该候选人'); + if (!friend) throw new EmptyResultError('未找到该候选人'); const friendName = friend.name || '候选人'; @@ -52,7 +53,7 @@ cli({ // Parse interview time const interviewTime = new Date(kwargs.time).getTime(); if (isNaN(interviewTime)) { - throw new Error(`时间格式错误: ${kwargs.time},请使用格式如 2025-04-01 14:00`); + throw new CommandExecutionError(`时间格式错误: ${kwargs.time},请使用格式如 2025-04-01 14:00`); } const params = new URLSearchParams({ diff --git a/src/clis/boss/resume.ts b/src/clis/boss/resume.ts index 5c1ce5f1..fc772980 100644 --- a/src/clis/boss/resume.ts +++ b/src/clis/boss/resume.ts @@ -10,6 +10,7 @@ * .position-content → job being discussed + expectation */ import { cli, Strategy } from '../../registry.js'; +import { CommandExecutionError, EmptyResultError, SelectorError } from '../../errors.js'; import { requirePage, navigateToChat, findFriendByUid, clickCandidateInList } from './utils.js'; cli({ @@ -34,13 +35,13 @@ 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('未找到该候选人,请确认 uid 是否正确'); const numericUid = friend.uid; const clicked = await clickCandidateInList(page, numericUid); if (!clicked) { - throw new Error('无法在聊天列表中找到该用户,请确认聊天列表中有此人'); + throw new SelectorError('candidate in chat list', '无法在聊天列表中找到该用户,请确认聊天列表中有此人'); } await page.wait({ time: 2 }); @@ -138,7 +139,7 @@ cli({ `); if (resumeInfo.error) { - throw new Error('无法获取简历面板: ' + resumeInfo.error); + throw new CommandExecutionError('无法获取简历面板: ' + resumeInfo.error); } return [{ diff --git a/src/clis/boss/utils.ts b/src/clis/boss/utils.ts index 0b8f8881..39e6f570 100644 --- a/src/clis/boss/utils.ts +++ b/src/clis/boss/utils.ts @@ -8,6 +8,7 @@ * - Verbose logging */ import type { IPage } from '../../types.js'; +import { AuthRequiredError, CommandExecutionError, SelectorError } from '../../errors.js'; // ── Constants ─────────────────────────────────────────────────────────────── @@ -43,7 +44,7 @@ export interface FetchOptions { * Assert that page is available (non-null). */ export function requirePage(page: IPage | null): asserts page is IPage { - if (!page) throw new Error('Browser page required'); + if (!page) throw new CommandExecutionError('Browser page required'); } /** @@ -69,7 +70,7 @@ export async function navigateTo(page: IPage, url: string, waitSeconds = 1): Pro */ export function checkAuth(data: BossApiResponse): void { if (COOKIE_EXPIRED_CODES.has(data.code)) { - throw new Error(COOKIE_EXPIRED_MSG); + throw new AuthRequiredError('www.zhipin.com', COOKIE_EXPIRED_MSG); } } @@ -81,7 +82,7 @@ export function assertOk(data: BossApiResponse, errorPrefix?: string): void { if (data.code === 0) return; checkAuth(data); const prefix = errorPrefix ? `${errorPrefix}: ` : ''; - throw new Error(`${prefix}${data.message || 'Unknown error'} (code=${data.code})`); + throw new CommandExecutionError(`${prefix}${data.message || 'Unknown error'} (code=${data.code})`); } /** diff --git a/src/clis/chaoxing/assignments.ts b/src/clis/chaoxing/assignments.ts index 51752231..40f5899d 100644 --- a/src/clis/chaoxing/assignments.ts +++ b/src/clis/chaoxing/assignments.ts @@ -1,5 +1,5 @@ import { cli, Strategy } from '../../registry.js'; -import { AuthRequiredError } from '../../errors.js'; +import { AuthRequiredError, EmptyResultError } from '../../errors.js'; import { getCourses, initSession, enterCourse, getTabIframeUrl, parseAssignmentsFromDom, sleep, @@ -40,7 +40,7 @@ cli({ ? courses.filter(c => c.title.includes(courseFilter)) : courses; if (courseFilter && !filtered.length) { - throw new Error(`未找到匹配「${courseFilter}」的课程`); + throw new EmptyResultError('chaoxing courses', `未找到匹配「${courseFilter}」的课程`); } // 3. Per-course: enter → click 作业 tab → navigate to iframe → parse diff --git a/src/clis/chaoxing/exams.ts b/src/clis/chaoxing/exams.ts index 509c8311..39fa0f73 100644 --- a/src/clis/chaoxing/exams.ts +++ b/src/clis/chaoxing/exams.ts @@ -1,4 +1,5 @@ import { cli, Strategy } from '../../registry.js'; +import { AuthRequiredError, EmptyResultError } from '../../errors.js'; import { getCourses, initSession, enterCourse, getTabIframeUrl, parseExamsFromDom, sleep, @@ -33,13 +34,13 @@ cli({ // 2. Get courses const courses = await getCourses(page); - if (!courses.length) throw new Error('未获取到课程列表,请确认已登录学习通'); + if (!courses.length) throw new AuthRequiredError('mooc2-ans.chaoxing.com', '未获取到课程列表,请确认已登录学习通'); const filtered = courseFilter ? courses.filter(c => c.title.includes(courseFilter)) : courses; if (courseFilter && !filtered.length) { - throw new Error(`未找到匹配「${courseFilter}」的课程`); + throw new EmptyResultError('chaoxing courses', `未找到匹配「${courseFilter}」的课程`); } // 3. Per-course: enter → click 考试 tab → navigate to iframe → parse diff --git a/src/clis/coupang/add-to-cart.ts b/src/clis/coupang/add-to-cart.ts index 3a7e8d15..359358b9 100644 --- a/src/clis/coupang/add-to-cart.ts +++ b/src/clis/coupang/add-to-cart.ts @@ -1,4 +1,5 @@ import { cli, Strategy } from '../../registry.js'; +import { ArgumentError, AuthRequiredError, CommandExecutionError, SelectorError } from '../../errors.js'; import { canonicalizeProductUrl, normalizeProductId } from './utils.js'; function escapeJsString(value: string): string { @@ -112,7 +113,7 @@ cli({ const targetUrl = canonicalizeProductUrl(kwargs.url, productId); if (!productId && !targetUrl) { - throw new Error('Either --product-id or --url is required'); + throw new ArgumentError('Either --product-id or --url is required'); } const finalUrl = targetUrl || canonicalizeProductUrl('', productId); @@ -121,21 +122,21 @@ cli({ const result = await page.evaluate(buildAddToCartEvaluate(productId)); const loginHints = result?.loginHints ?? {}; if (loginHints.hasLoginLink && !loginHints.hasMyCoupang) { - throw new Error('Coupang login required. Please log into Coupang in Chrome and retry.'); + throw new AuthRequiredError('www.coupang.com', 'Coupang login required. Please log into Coupang in Chrome and retry.'); } const actualProductId = normalizeProductId(result?.currentProductId || productId); if (result?.reason === 'PRODUCT_MISMATCH') { - throw new Error(`Product mismatch: expected ${productId}, got ${actualProductId || 'unknown'}`); + throw new CommandExecutionError(`Product mismatch: expected ${productId}, got ${actualProductId || 'unknown'}`); } if (result?.reason === 'OPTION_REQUIRED') { - throw new Error('This product requires option selection and is not supported in v1.'); + throw new CommandExecutionError('This product requires option selection and is not supported in v1.'); } if (result?.reason === 'ADD_TO_CART_BUTTON_NOT_FOUND') { - throw new Error('Could not find an add-to-cart button on the product page.'); + throw new SelectorError('add-to-cart button', 'Could not find an add-to-cart button on the product page.'); } if (!result?.ok) { - throw new Error('Failed to confirm add-to-cart success.'); + throw new CommandExecutionError('Failed to confirm add-to-cart success.'); } return [{ diff --git a/src/clis/coupang/search.ts b/src/clis/coupang/search.ts index b7095cfe..1a86706c 100644 --- a/src/clis/coupang/search.ts +++ b/src/clis/coupang/search.ts @@ -1,4 +1,5 @@ import { cli, Strategy } from '../../registry.js'; +import { ArgumentError, AuthRequiredError, CommandExecutionError } from '../../errors.js'; import { mergeSearchItems, normalizeSearchItem, sanitizeSearchItems } from './utils.js'; function escapeJsString(value: string): string { @@ -420,7 +421,7 @@ cli({ const pageNumber = Math.max(Number(kwargs.page || 1), 1); const limit = Math.min(Math.max(Number(kwargs.limit || 20), 1), 50); const filter = String(kwargs.filter || '').trim().toLowerCase(); - if (!query) throw new Error('Query is required'); + if (!query) throw new ArgumentError('Query is required'); const initialPage = filter ? 1 : pageNumber; const url = `https://www.coupang.com/np/search?q=${encodeURIComponent(query)}&channel=user&page=${initialPage}`; @@ -428,7 +429,7 @@ cli({ if (filter) { const filterResult = await page.evaluate(buildApplyFilterEvaluate(filter)); if (!filterResult?.ok) { - throw new Error(`Unsupported or unavailable filter: ${filter}`); + throw new CommandExecutionError(`Unsupported or unavailable filter: ${filter}`); } await page.wait(3); if (pageNumber > 1) { @@ -457,7 +458,7 @@ cli({ : mergeSearchItems(normalizedBase, normalizedDom, limit); if (!normalized.length && loginHints.hasLoginLink && !loginHints.hasMyCoupang) { - throw new Error('Coupang login required. Please log into Coupang in Chrome and retry.'); + throw new AuthRequiredError('www.coupang.com', 'Coupang login required. Please log into Coupang in Chrome and retry.'); } return normalized; }, diff --git a/src/clis/discord-app/search.ts b/src/clis/discord-app/search.ts index e900f704..cf081283 100644 --- a/src/clis/discord-app/search.ts +++ b/src/clis/discord-app/search.ts @@ -1,4 +1,5 @@ import { cli, Strategy } from '../../registry.js'; +import { SelectorError } from '../../errors.js'; import type { IPage } from '../../types.js'; export const searchCommand = cli({ @@ -19,16 +20,23 @@ export const searchCommand = cli({ await page.wait(0.5); // Type query into search box - await page.evaluate(` - (function(q) { - const input = document.querySelector('[aria-label*="Search"], [class*="searchBar"] input, [placeholder*="Search"]'); - if (!input) throw new Error('Search input not found'); - input.focus(); - const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; - setter.call(input, q); - input.dispatchEvent(new Event('input', { bubbles: true })); - })(${JSON.stringify(query)}) - `); + try { + await page.evaluate(` + (function(q) { + const input = document.querySelector('[aria-label*="Search"], [class*="searchBar"] input, [placeholder*="Search"]'); + if (!input) throw new Error('Search input not found'); + input.focus(); + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; + setter.call(input, q); + input.dispatchEvent(new Event('input', { bubbles: true })); + })(${JSON.stringify(query)}) + `); + } catch (e: any) { + if (e.message?.includes('Search input not found')) { + throw new SelectorError('Discord search input', 'Search input not found in Discord UI'); + } + throw e; + } await page.pressKey('Enter'); await page.wait(2); diff --git a/src/clis/discord-app/send.ts b/src/clis/discord-app/send.ts index e899d33c..0d31c4c3 100644 --- a/src/clis/discord-app/send.ts +++ b/src/clis/discord-app/send.ts @@ -1,4 +1,5 @@ import { cli, Strategy } from '../../registry.js'; +import { SelectorError } from '../../errors.js'; import type { IPage } from '../../types.js'; export const sendCommand = cli({ @@ -13,16 +14,23 @@ export const sendCommand = cli({ func: async (page: IPage, kwargs: any) => { const text = kwargs.text as string; - await page.evaluate(` - (function(text) { - // Discord uses a Slate-based editor with [data-slate-editor="true"] or role="textbox" - const editor = document.querySelector('[role="textbox"][data-slate-editor="true"], [class*="slateTextArea"]'); - if (!editor) throw new Error('Could not find Discord message input. Make sure a channel is open.'); - - editor.focus(); - document.execCommand('insertText', false, text); - })(${JSON.stringify(text)}) - `); + try { + await page.evaluate(` + (function(text) { + // Discord uses a Slate-based editor with [data-slate-editor="true"] or role="textbox" + const editor = document.querySelector('[role="textbox"][data-slate-editor="true"], [class*="slateTextArea"]'); + if (!editor) throw new Error('Could not find Discord message input. Make sure a channel is open.'); + + editor.focus(); + document.execCommand('insertText', false, text); + })(${JSON.stringify(text)}) + `); + } catch (e: any) { + if (e.message?.includes('Could not find Discord message input')) { + throw new SelectorError('Discord message input', 'Could not find Discord message input. Make sure a channel is open.'); + } + throw e; + } await page.wait(0.3); await page.pressKey('Enter'); diff --git a/src/clis/douban/utils.ts b/src/clis/douban/utils.ts index b30aa6cf..e383cfd2 100644 --- a/src/clis/douban/utils.ts +++ b/src/clis/douban/utils.ts @@ -2,7 +2,7 @@ * Douban adapter utilities. */ -import { CliError } from '../../errors.js'; +import { CliError, EmptyResultError } from '../../errors.js'; import type { IPage } from '../../types.js'; function clampLimit(limit: number): number { @@ -210,7 +210,7 @@ export async function getSelfUid(page: IPage): Promise { })() `); if (!uid) { - throw new Error('Not logged in to Douban. Please login in Chrome first.'); + throw new EmptyResultError('Douban user ID', 'Not logged in to Douban. Please login in Chrome first.'); } return uid; } diff --git a/src/clis/doubao-app/ask.ts b/src/clis/doubao-app/ask.ts index 5ae9c126..64195341 100644 --- a/src/clis/doubao-app/ask.ts +++ b/src/clis/doubao-app/ask.ts @@ -1,4 +1,5 @@ import { cli, Strategy } from '../../registry.js'; +import { SelectorError } from '../../errors.js'; import { SEL, injectTextScript, clickSendScript, pollResponseScript } from './utils.js'; export const askCommand = cli({ @@ -24,7 +25,7 @@ export const askCommand = cli({ // Inject text + send const injected = await page.evaluate(injectTextScript(text)); - if (!injected?.ok) throw new Error('Could not find chat input.'); + if (!injected?.ok) throw new SelectorError('Doubao chat input', 'Could not find chat input'); await page.wait(0.5); const clicked = await page.evaluate(clickSendScript()); diff --git a/src/clis/doubao-app/send.ts b/src/clis/doubao-app/send.ts index 649796ff..eb4e58a0 100644 --- a/src/clis/doubao-app/send.ts +++ b/src/clis/doubao-app/send.ts @@ -1,4 +1,5 @@ import { cli, Strategy } from '../../registry.js'; +import { SelectorError } from '../../errors.js'; import { injectTextScript, clickSendScript } from './utils.js'; export const sendCommand = cli({ @@ -17,7 +18,7 @@ export const sendCommand = cli({ const injected = await page.evaluate(injectTextScript(text)); if (!injected || !injected.ok) { - throw new Error('Could not find chat input: ' + (injected?.error || 'unknown')); + throw new SelectorError('Doubao chat input', 'Could not find chat input: ' + (injected?.error || 'unknown')); } await page.wait(0.5); diff --git a/src/clis/notion/write.ts b/src/clis/notion/write.ts index 73f225c6..75427a34 100644 --- a/src/clis/notion/write.ts +++ b/src/clis/notion/write.ts @@ -1,4 +1,5 @@ import { cli, Strategy } from '../../registry.js'; +import { SelectorError } from '../../errors.js'; import type { IPage } from '../../types.js'; export const writeCommand = cli({ @@ -14,29 +15,36 @@ export const writeCommand = cli({ const text = kwargs.text as string; // Focus the page body and move to the end - await page.evaluate(` - (function(text) { - // Find the editable area in Notion - const editables = document.querySelectorAll('.notion-page-content [contenteditable="true"], [class*="page-content"] [contenteditable="true"]'); - let target = editables.length > 0 ? editables[editables.length - 1] : null; - - if (!target) { - // Fallback: just find any contenteditable - const all = document.querySelectorAll('[contenteditable="true"]'); - target = all.length > 0 ? all[all.length - 1] : null; - } - - if (!target) throw new Error('Could not find editable area in Notion page'); - - target.focus(); - // Move to end - const sel = window.getSelection(); - sel.selectAllChildren(target); - sel.collapseToEnd(); - - document.execCommand('insertText', false, text); - })(${JSON.stringify(text)}) - `); + try { + await page.evaluate(` + (function(text) { + // Find the editable area in Notion + const editables = document.querySelectorAll('.notion-page-content [contenteditable="true"], [class*="page-content"] [contenteditable="true"]'); + let target = editables.length > 0 ? editables[editables.length - 1] : null; + + if (!target) { + // Fallback: just find any contenteditable + const all = document.querySelectorAll('[contenteditable="true"]'); + target = all.length > 0 ? all[all.length - 1] : null; + } + + if (!target) throw new Error('Could not find editable area in Notion page'); + + target.focus(); + // Move to end + const sel = window.getSelection(); + sel.selectAllChildren(target); + sel.collapseToEnd(); + + document.execCommand('insertText', false, text); + })(${JSON.stringify(text)}) + `); + } catch (e: any) { + if (e.message?.includes('Could not find editable area')) { + throw new SelectorError('Notion editable area', 'Could not find editable area in Notion page'); + } + throw e; + } await page.wait(0.5); diff --git a/src/clis/sinablog/search.ts b/src/clis/sinablog/search.ts index 520c059a..4eff7336 100644 --- a/src/clis/sinablog/search.ts +++ b/src/clis/sinablog/search.ts @@ -1,4 +1,5 @@ import { cli, Strategy } from '../../registry.js'; +import { CommandExecutionError } from '../../errors.js'; function normalize(value: unknown): string { return typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : ''; @@ -23,7 +24,7 @@ async function searchSinaBlog(keyword: string, limit: number): Promise { Accept: 'application/json', }, }); - if (!resp.ok) throw new Error(`Sina blog search failed: HTTP ${resp.status}`); + if (!resp.ok) throw new CommandExecutionError(`Sina blog search failed: HTTP ${resp.status}`); const data = await resp.json() as { data?: { list?: any[] } }; const list = Array.isArray(data?.data?.list) ? data.data.list : []; diff --git a/src/clis/xiaohongshu/creator-note-detail.ts b/src/clis/xiaohongshu/creator-note-detail.ts index c6c25157..91c7f2ec 100644 --- a/src/clis/xiaohongshu/creator-note-detail.ts +++ b/src/clis/xiaohongshu/creator-note-detail.ts @@ -11,6 +11,7 @@ import { cli, Strategy } from '../../registry.js'; import type { IPage } from '../../types.js'; +import { EmptyResultError } from '../../errors.js'; type CreatorNoteDetailRow = { section: string; @@ -439,7 +440,7 @@ cli({ const hasCoreMetric = rows.some((row) => row.section !== '笔记信息' && row.value); if (!hasCoreMetric) { - throw new Error('No note detail data found. Check note_id and login status for creator.xiaohongshu.com.'); + throw new EmptyResultError('xiaohongshu creator note detail', 'Check note_id and login status for creator.xiaohongshu.com.'); } return rows; diff --git a/src/clis/xiaohongshu/creator-notes-summary.ts b/src/clis/xiaohongshu/creator-notes-summary.ts index bb24b4ad..71aca78b 100644 --- a/src/clis/xiaohongshu/creator-notes-summary.ts +++ b/src/clis/xiaohongshu/creator-notes-summary.ts @@ -8,6 +8,7 @@ import { cli, Strategy } from '../../registry.js'; import { fetchCreatorNotes, type CreatorNoteRow } from './creator-notes.js'; import { fetchCreatorNoteDetailRows, type CreatorNoteDetailRow } from './creator-note-detail.js'; +import { EmptyResultError } from '../../errors.js'; type CreatorNoteSummaryRow = { rank: number; @@ -84,7 +85,7 @@ cli({ const notes = await fetchCreatorNotes(page, limit); if (!notes.length) { - throw new Error('No notes found. Are you logged into creator.xiaohongshu.com?'); + throw new EmptyResultError('xiaohongshu creator notes summary', 'Are you logged into creator.xiaohongshu.com?'); } const results: CreatorNoteSummaryRow[] = []; diff --git a/src/clis/xiaohongshu/creator-notes.ts b/src/clis/xiaohongshu/creator-notes.ts index 49d1936e..01c662ee 100644 --- a/src/clis/xiaohongshu/creator-notes.ts +++ b/src/clis/xiaohongshu/creator-notes.ts @@ -10,6 +10,7 @@ import { cli, Strategy } from '../../registry.js'; import type { IPage } from '../../types.js'; +import { EmptyResultError } from '../../errors.js'; const DATE_LINE_RE = /^发布于 (\d{4}年\d{2}月\d{2}日 \d{2}:\d{2})$/; const METRIC_LINE_RE = /^\d+$/; @@ -275,7 +276,7 @@ cli({ const notes = await fetchCreatorNotes(page, limit); if (!Array.isArray(notes) || notes.length === 0) { - throw new Error('No notes found. Are you logged into creator.xiaohongshu.com?'); + throw new EmptyResultError('xiaohongshu creator notes', 'Are you logged into creator.xiaohongshu.com?'); } return notes diff --git a/src/clis/xiaohongshu/creator-profile.ts b/src/clis/xiaohongshu/creator-profile.ts index e1ecdfa5..da8247da 100644 --- a/src/clis/xiaohongshu/creator-profile.ts +++ b/src/clis/xiaohongshu/creator-profile.ts @@ -9,6 +9,7 @@ */ import { cli, Strategy } from '../../registry.js'; +import { AuthRequiredError, CommandExecutionError } from '../../errors.js'; cli({ site: 'xiaohongshu', @@ -37,10 +38,10 @@ cli({ `); if (data?.error) { - throw new Error(data.error + '. Are you logged into creator.xiaohongshu.com?'); + throw new AuthRequiredError('creator.xiaohongshu.com', data.error + '. Are you logged into creator.xiaohongshu.com?'); } if (!data?.data) { - throw new Error('Unexpected response structure'); + throw new CommandExecutionError('Unexpected response structure'); } const d = data.data; diff --git a/src/clis/xiaohongshu/creator-stats.ts b/src/clis/xiaohongshu/creator-stats.ts index d71cb2c8..d1bba8b6 100644 --- a/src/clis/xiaohongshu/creator-stats.ts +++ b/src/clis/xiaohongshu/creator-stats.ts @@ -9,6 +9,7 @@ */ import { cli, Strategy } from '../../registry.js'; +import { AuthRequiredError, ArgumentError, CommandExecutionError } from '../../errors.js'; cli({ site: 'xiaohongshu', @@ -48,15 +49,15 @@ cli({ `); if (data?.error) { - throw new Error(data.error + '. Are you logged into creator.xiaohongshu.com?'); + throw new AuthRequiredError('creator.xiaohongshu.com', data.error + '. Are you logged into creator.xiaohongshu.com?'); } if (!data?.data) { - throw new Error('Unexpected response structure'); + throw new CommandExecutionError('Unexpected response structure'); } const stats = data.data[period]; if (!stats) { - throw new Error(`No data for period "${period}". Available: ${Object.keys(data.data).join(', ')}`); + throw new ArgumentError(`No data for period "${period}". Available: ${Object.keys(data.data).join(', ')}`); } // Format daily trend as sparkline-like summary diff --git a/src/clis/xiaohongshu/publish.ts b/src/clis/xiaohongshu/publish.ts index da764109..9878a0ff 100644 --- a/src/clis/xiaohongshu/publish.ts +++ b/src/clis/xiaohongshu/publish.ts @@ -20,6 +20,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { cli, Strategy } from '../../registry.js'; +import { ArgumentError, AuthRequiredError, CommandExecutionError, SelectorError } from '../../errors.js'; import type { IPage } from '../../types.js'; const PUBLISH_URL = 'https://creator.xiaohongshu.com/publish/publish?from=menu_left'; @@ -35,7 +36,7 @@ type ImagePayload = { name: string; mimeType: string; base64: string }; */ function readImageFile(filePath: string): ImagePayload { const absPath = path.resolve(filePath); - if (!fs.existsSync(absPath)) throw new Error(`Image file not found: ${absPath}`); + if (!fs.existsSync(absPath)) throw new ArgumentError(`Image file not found: ${absPath}`); const ext = path.extname(absPath).toLowerCase(); const mimeMap: Record = { '.jpg': 'image/jpeg', @@ -45,7 +46,7 @@ function readImageFile(filePath: string): ImagePayload { '.webp': 'image/webp', }; const mimeType = mimeMap[ext]; - if (!mimeType) throw new Error(`Unsupported image format "${ext}". Supported: jpg, png, gif, webp`); + if (!mimeType) throw new ArgumentError(`Unsupported image format "${ext}". Supported: jpg, png, gif, webp`); const base64 = fs.readFileSync(absPath).toString('base64'); return { name: path.basename(absPath), mimeType, base64 }; } @@ -145,7 +146,8 @@ async function fillField(page: IPage, selectors: string[], text: string, fieldNa `); if (!result.ok) { await page.screenshot({ path: `/tmp/xhs_publish_${fieldName}_debug.png` }); - throw new Error( + throw new SelectorError( + fieldName, `Could not find ${fieldName} input. Debug screenshot: /tmp/xhs_publish_${fieldName}_debug.png` ); } @@ -167,7 +169,7 @@ cli({ ], columns: ['status', 'detail'], func: async (page: IPage | null, kwargs) => { - if (!page) throw new Error('Browser page required'); + if (!page) throw new CommandExecutionError('Browser page required'); const title = String(kwargs.title ?? '').trim(); const content = String(kwargs.content ?? '').trim(); @@ -180,12 +182,12 @@ cli({ const isDraft = Boolean(kwargs.draft); // ── Validate inputs ──────────────────────────────────────────────────────── - if (!title) throw new Error('--title is required'); + if (!title) throw new ArgumentError('--title is required'); if (title.length > MAX_TITLE_LEN) - throw new Error(`Title is ${title.length} chars — must be ≤ ${MAX_TITLE_LEN}`); - if (!content) throw new Error('Positional argument is required'); + throw new ArgumentError(`Title is ${title.length} chars — must be ≤ ${MAX_TITLE_LEN}`); + if (!content) throw new ArgumentError('Positional argument is required'); if (imagePaths.length > MAX_IMAGES) - throw new Error(`Too many images: ${imagePaths.length} (max ${MAX_IMAGES})`); + throw new ArgumentError(`Too many images: ${imagePaths.length} (max ${MAX_IMAGES})`); // Read images in Node.js context before navigating (fast-fail on bad paths) const imageData: ImagePayload[] = imagePaths.map(readImageFile); @@ -197,7 +199,8 @@ cli({ // Verify we landed on the creator site (not redirected to login) const pageUrl: string = await page.evaluate('() => location.href'); if (!pageUrl.includes('creator.xiaohongshu.com')) { - throw new Error( + throw new AuthRequiredError( + 'creator.xiaohongshu.com', 'Redirected away from creator center — session may have expired. ' + 'Re-capture browser login via: opencli xiaohongshu creator-profile' ); @@ -224,7 +227,7 @@ cli({ const upload = await injectImages(page, imageData); if (!upload.ok) { await page.screenshot({ path: '/tmp/xhs_publish_upload_debug.png' }); - throw new Error( + throw new CommandExecutionError( `Image injection failed: ${upload.error ?? 'unknown'}. ` + 'Debug screenshot: /tmp/xhs_publish_upload_debug.png' ); @@ -348,7 +351,8 @@ cli({ if (!btnClicked) { await page.screenshot({ path: '/tmp/xhs_publish_submit_debug.png' }); - throw new Error( + throw new SelectorError( + actionLabel, `Could not find "${actionLabel}" button. ` + 'Debug screenshot: /tmp/xhs_publish_submit_debug.png' ); diff --git a/src/clis/xiaohongshu/user.ts b/src/clis/xiaohongshu/user.ts index 44fa9cf0..2fcb42b6 100644 --- a/src/clis/xiaohongshu/user.ts +++ b/src/clis/xiaohongshu/user.ts @@ -1,5 +1,6 @@ import { cli, Strategy } from '../../registry.js'; import { extractXhsUserNotes, normalizeXhsUserId } from './user-helpers.js'; +import { EmptyResultError } from '../../errors.js'; async function readUserSnapshot(page: any) { return await page.evaluate(` @@ -56,7 +57,7 @@ cli({ } if (results.length === 0) { - throw new Error('No public notes found for this Xiaohongshu user.'); + throw new EmptyResultError('xiaohongshu user notes', 'Are you logged into xiaohongshu.com?'); } return results.slice(0, limit);