diff --git a/README.md b/README.md index b4492f2a..1d40b583 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,7 @@ git clone git@github.com:jackwener/opencli.git && cd opencli && npm install && n | **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `user` `user-posts` `user-comments` `read` `save` `saved` `subscribe` `upvote` `upvoted` `comment` | | **amazon** | `bestsellers` `search` `product` `offer` `discussion` | | **gemini** | `new` `ask` `image` | +| **yuanbao** | `new` `ask` | | **notebooklm** | `status` `list` `open` `select` `current` `get` `metadata` `source-list` `source-get` `source-fulltext` `source-guide` `history` `note-list` `notes-list` `notes-get` `summary` | | **spotify** | `auth` `status` `play` `pause` `next` `prev` `volume` `search` `queue` `shuffle` `repeat` | diff --git a/README.zh-CN.md b/README.zh-CN.md index 6f5d7d3d..cfb8b17c 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -206,6 +206,7 @@ npx skills add jackwener/opencli --skill opencli-oneshot # 快速命令参 | **tiktok** | `explore` `search` `profile` `user` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `live` `notifications` `friends` | 浏览器 | | **bluesky** | `search` `trending` `user` `profile` `thread` `feeds` `followers` `following` `starter-packs` | 公开 | | **douyin** | `videos` `publish` `drafts` `draft` `delete` `stats` `profile` `update` `hashtag` `location` `activities` `collections` | 浏览器 | +| **yuanbao** | `new` `ask` | 浏览器 | 66+ 适配器 — **[→ 查看完整命令列表](./docs/adapters/index.md)** diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 45da8876..0f3da5cb 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -74,6 +74,7 @@ export default defineConfig({ { text: 'Grok', link: '/adapters/browser/grok' }, { text: 'Amazon', link: '/adapters/browser/amazon' }, { text: 'Gemini', link: '/adapters/browser/gemini' }, + { text: 'Yuanbao', link: '/adapters/browser/yuanbao' }, { text: 'NotebookLM', link: '/adapters/browser/notebooklm' }, { text: 'WeRead', link: '/adapters/browser/weread' }, { text: 'Douban', link: '/adapters/browser/douban' }, diff --git a/docs/adapters/browser/yuanbao.md b/docs/adapters/browser/yuanbao.md new file mode 100644 index 00000000..378ba052 --- /dev/null +++ b/docs/adapters/browser/yuanbao.md @@ -0,0 +1,64 @@ +# Yuanbao + +**Mode**: 🔐 Browser · **Domain**: `yuanbao.tencent.com` + +## Commands + +| Command | Description | +|---------|-------------| +| `opencli yuanbao new` | Start a new Yuanbao conversation | +| `opencli yuanbao ask ` | Send a prompt to Yuanbao web chat and wait for the reply | + +## Usage Examples + +```bash +# Start a fresh chat +opencli yuanbao new + +# Basic ask (internet search on by default, deep thinking off by default) +opencli yuanbao ask "你好" + +# Wait longer for a longer answer +opencli yuanbao ask "帮我总结这篇文章" --timeout 90 + +# Disable internet search explicitly +opencli yuanbao ask "你好" --search false + +# Enable deep thinking explicitly +opencli yuanbao ask "你好" --think true +``` + +## Options + +### `new` + +- No options + +### `ask` + +| Option | Description | +|--------|-------------| +| `prompt` | Prompt to send (required positional argument) | +| `--timeout` | Max seconds to wait for a reply (default: `60`) | +| `--search` | Enable internet search before sending (default: `true`) | +| `--think` | Enable deep thinking before sending (default: `false`) | + +## Behavior + +- The adapter targets the Yuanbao consumer web UI and sends the prompt through the visible Quill composer. +- `new` clicks the left-side Yuanbao new-chat trigger and falls back to reloading the Yuanbao homepage if needed. +- Before sending, it aligns the `联网搜索` and `深度思考` buttons to the requested `--search` / `--think` state. +- It waits for transcript changes to stabilize before returning the assistant reply. +- If Yuanbao opens a login gate instead of answering, the command returns a `[BLOCKED]` system message with a session hint. + +## Prerequisites + +- Chrome is running +- You are already logged into `yuanbao.tencent.com` +- [Browser Bridge extension](/guide/browser-bridge) is installed + +## Caveats + +- This adapter drives the Yuanbao web UI, not a public API. +- It depends on the current browser session and may fail if Yuanbao shows login, consent, challenge, or other gating UI. +- DOM or product changes on Yuanbao can break composer detection, submit behavior, or transcript extraction. diff --git a/docs/adapters/index.md b/docs/adapters/index.md index 5b5b4cc2..b97d4992 100644 --- a/docs/adapters/index.md +++ b/docs/adapters/index.md @@ -30,6 +30,7 @@ Run `opencli list` for the live registry. | **[chaoxing](./browser/chaoxing)** | `assignments` `exams` | 🔐 Browser | | **[grok](./browser/grok)** | `ask` | 🔐 Browser | | **[gemini](./browser/gemini)** | `new` `ask` `image` | 🔐 Browser | +| **[yuanbao](./browser/yuanbao)** | `new` `ask` | 🔐 Browser | | **[notebooklm](./browser/notebooklm)** | `status` `list` `open` `select` `current` `get` `metadata` `source-list` `source-get` `source-fulltext` `source-guide` `history` `note-list` `notes-list` `notes-get` `summary` | 🔐 Browser | | **[doubao](./browser/doubao)** | `status` `new` `send` `read` `ask` `history` `detail` `meeting-summary` `meeting-transcript` | 🔐 Browser | | **[weread](./browser/weread)** | `shelf` `search` `book` `ranking` `notebooks` `highlights` `notes` | 🔐 Browser | diff --git a/src/clis/yuanbao/ask.test.ts b/src/clis/yuanbao/ask.test.ts new file mode 100644 index 00000000..a36d7785 --- /dev/null +++ b/src/clis/yuanbao/ask.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { IPage } from '../../types.js'; +import { AuthRequiredError, CommandExecutionError, TimeoutError } from '../../errors.js'; +import { __test__ } from './ask.js'; +import { askCommand } from './ask.js'; + +describe('yuanbao ask helpers', () => { + describe('isOnYuanbao', () => { + const fakePage = (url: string | Error): IPage => + ({ evaluate: () => url instanceof Error ? Promise.reject(url) : Promise.resolve(url) }) as unknown as IPage; + + it('returns true for yuanbao.tencent.com URLs', async () => { + expect(await __test__.isOnYuanbao(fakePage('https://yuanbao.tencent.com/'))).toBe(true); + expect(await __test__.isOnYuanbao(fakePage('https://yuanbao.tencent.com/chat/abc'))).toBe(true); + }); + + it('returns false for non-yuanbao domains', async () => { + expect(await __test__.isOnYuanbao(fakePage('https://example.com/?next=yuanbao.tencent.com'))).toBe(false); + expect(await __test__.isOnYuanbao(fakePage('about:blank'))).toBe(false); + }); + + it('returns false when evaluate throws', async () => { + expect(await __test__.isOnYuanbao(fakePage(new Error('detached')))).toBe(false); + }); + }); + + it('removes echoed prompt prefixes from transcript additions', () => { + expect(__test__.sanitizeYuanbaoResponseText('你好\n你好,我是元宝。', '你好')).toBe('你好,我是元宝。'); + }); + + it('filters transient in-progress assistant placeholders', () => { + expect(__test__.sanitizeYuanbaoResponseText('正在搜索资料', '张雪机车相关的股票有哪些?')).toBe(''); + }); + + it('normalizes boolean flags with explicit defaults', () => { + expect(__test__.normalizeBooleanFlag(undefined, true)).toBe(true); + expect(__test__.normalizeBooleanFlag(undefined, false)).toBe(false); + expect(__test__.normalizeBooleanFlag('true', false)).toBe(true); + expect(__test__.normalizeBooleanFlag('1', false)).toBe(true); + expect(__test__.normalizeBooleanFlag('yes', false)).toBe(true); + expect(__test__.normalizeBooleanFlag('false', true)).toBe(false); + }); + + it('ignores baseline lines and echoed prompts when collecting additions', () => { + const response = __test__.collectYuanbaoTranscriptAdditions( + ['旧消息'], + ['旧消息', '你好', '你好\n你好,我是元宝。'], + '你好', + ); + + expect(response).toBe('你好,我是元宝。'); + }); + + it('prefers fresh assistant messages over echoed prompts and older messages', () => { + const response = __test__.pickLatestYuanbaoAssistantCandidate( + ['旧回复', '你好', '你好!我是元宝,由腾讯推出的AI助手。'], + 1, + '你好', + ); + + expect(response).toBe('你好!我是元宝,由腾讯推出的AI助手。'); + }); + + it('converts assistant html tables to markdown tables via turndown', () => { + const markdown = __test__.convertYuanbaoHtmlToMarkdown(` +

核心产业链概念股一览

+ + + + + + + +
细分赛道核心标的
光模块中际旭创
+ `); + + expect(markdown).toContain('### 核心产业链概念股一览'); + expect(markdown).toContain('| 细分赛道 | 核心标的 |'); + expect(markdown).toContain('| --- | --- |'); + expect(markdown).toContain('| 光模块 | 中际旭创 |'); + }); + + it('tracks stabilization by incrementing repeats and resetting on changes', () => { + expect(__test__.updateStableState('', 0, '第一段')).toEqual({ + previousText: '第一段', + stableCount: 0, + }); + + expect(__test__.updateStableState('第一段', 0, '第一段')).toEqual({ + previousText: '第一段', + stableCount: 1, + }); + + expect(__test__.updateStableState('第一段', 1, '第二段')).toEqual({ + previousText: '第二段', + stableCount: 0, + }); + }); +}); + +function createAskPageMock(overrides: { + currentUrl?: string; + hasLoginGate?: boolean; + sendResult?: { ok?: boolean; reason?: string; detail?: string; action?: string }; +} = {}): IPage { + const currentUrl = overrides.currentUrl ?? 'https://yuanbao.tencent.com/'; + const hasLoginGate = overrides.hasLoginGate ?? false; + const sendResult = overrides.sendResult; + + return { + goto: vi.fn().mockResolvedValue(undefined), + wait: vi.fn().mockResolvedValue(undefined), + evaluate: vi.fn().mockImplementation(async (script: string) => { + if (script === 'window.location.href') return currentUrl; + if (script.includes('微信扫码登录')) return hasLoginGate; + if (script.includes('[dt-button-id="internet_search"]')) return { found: false, enabled: false }; + if (script.includes('[dt-button-id="deep_think"]')) return { found: false, enabled: false }; + if (script.includes('.agent-chat__list__item--ai')) return []; + if (script.includes('const stopLines = new Set([')) return []; + if (script.includes('Failed to insert the prompt into the Yuanbao composer.')) { + return sendResult ?? { ok: true, action: 'click' }; + } + throw new Error(`Unexpected evaluate script in test: ${script.slice(0, 80)}`); + }), + } as unknown as IPage; +} + +describe('yuanbao ask command', () => { + it('throws AuthRequiredError when Yuanbao shows a login gate before sending', async () => { + const page = createAskPageMock({ hasLoginGate: true }); + + await expect(askCommand.func!(page, { prompt: '你好', timeout: '60', search: true, think: false })) + .rejects.toBeInstanceOf(AuthRequiredError); + }); + + it('throws CommandExecutionError when the prompt cannot be sent', async () => { + const page = createAskPageMock({ + sendResult: { + ok: false, + reason: 'Yuanbao composer was not found.', + }, + }); + + await expect(askCommand.func!(page, { prompt: '你好', timeout: '60', search: true, think: false })) + .rejects.toBeInstanceOf(CommandExecutionError); + }); + + it('throws TimeoutError when no response arrives before timeout', async () => { + const page = createAskPageMock({ + sendResult: { ok: true, action: 'click' }, + }); + + await expect(askCommand.func!(page, { prompt: '你好', timeout: '-1', search: true, think: false })) + .rejects.toBeInstanceOf(TimeoutError); + }); +}); diff --git a/src/clis/yuanbao/ask.ts b/src/clis/yuanbao/ask.ts new file mode 100644 index 00000000..051794a4 --- /dev/null +++ b/src/clis/yuanbao/ask.ts @@ -0,0 +1,522 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; +import TurndownService from 'turndown'; +import { CommandExecutionError, TimeoutError } from '../../errors.js'; +import { YUANBAO_DOMAIN, YUANBAO_URL, IS_VISIBLE_JS, authRequired, isOnYuanbao, ensureYuanbaoPage, hasLoginGate } from './shared.js'; + +const YUANBAO_RESPONSE_POLL_INTERVAL_SECONDS = 2; +const YUANBAO_MIN_WAIT_MS = 8_000; +const YUANBAO_STABLE_POLLS_REQUIRED = 3; + +type YuanbaoSendResult = { + ok?: boolean; + action?: string; + reason?: string; + detail?: string; +}; + +type YuanbaoToggleState = { + enabled: boolean; + found: boolean; +}; + +function sendFailure(reason?: string, detail?: string) { + const suffix = detail ? ` Detail: ${detail}` : ''; + return new CommandExecutionError( + `${reason || 'Unknown Yuanbao send failure.'}${suffix}`, + 'Make sure the Yuanbao chat composer is visible and ready before retrying.', + ); +} + +function normalizeText(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function normalizeBooleanFlag(value: unknown, fallback: boolean): boolean { + if (typeof value === 'boolean') return value; + if (value == null || value === '') return fallback; + + const normalized = String(value).trim().toLowerCase(); + return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on'; +} + +function createYuanbaoTurndown(): TurndownService { + const td = new TurndownService({ + headingStyle: 'atx', + codeBlockStyle: 'fenced', + bulletListMarker: '-', + }); + + td.addRule('linebreak', { + filter: 'br', + replacement: () => '\n', + }); + + td.addRule('table', { + filter: 'table', + replacement: (content) => `\n\n${content}\n\n`, + }); + + td.addRule('tableSection', { + filter: ['thead', 'tbody', 'tfoot'], + replacement: (content) => content, + }); + + td.addRule('tableRow', { + filter: 'tr', + replacement: (content, node) => { + const element = node as Element; + const cells = Array.from(element.children); + const isHeaderRow = element.parentElement?.tagName === 'THEAD' + || (cells.length > 0 && cells.every((cell) => cell.tagName === 'TH')); + + const row = `${content}\n`; + if (!isHeaderRow) return row; + + const separator = `| ${cells.map(() => '---').join(' | ')} |\n`; + return `${row}${separator}`; + }, + }); + + td.addRule('tableCell', { + filter: ['th', 'td'], + replacement: (content, node) => { + const element = node as Element; + const index = element.parentElement ? Array.from(element.parentElement.children).indexOf(element) : 0; + const prefix = index === 0 ? '| ' : ' '; + return `${prefix}${content.trim()} |`; + }, + }); + + return td; +} + +const yuanbaoTurndown = createYuanbaoTurndown(); + +export function convertYuanbaoHtmlToMarkdown(value: string): string { + const markdown = yuanbaoTurndown.turndown(value || ''); + return markdown + .replace(/\u00a0/g, ' ') + .replace(/\n{4,}/g, '\n\n\n') + .replace(/[ \t]+$/gm, '') + .trim(); +} + +export function sanitizeYuanbaoResponseText(value: string, promptText: string): string { + let sanitized = value + .replace(/内容由AI生成,仅供参考/gi, '') + .replace(/重新回答/gi, '') + .trim(); + + if (/^(正在搜索资料|搜索资料中|正在思考|思考中)[.。…]*$/u.test(sanitized)) { + return ''; + } + + const prompt = promptText.trim(); + if (!prompt) return sanitized; + if (sanitized === prompt) return ''; + + for (const separator of ['\n\n', '\n', '\r\n\r\n', '\r\n', ' ']) { + const prefix = `${prompt}${separator}`; + if (sanitized.startsWith(prefix)) { + sanitized = sanitized.slice(prefix.length).trim(); + break; + } + } + + return sanitized; +} + +export function collectYuanbaoTranscriptAdditions( + beforeLines: string[], + currentLines: string[], + promptText: string, +): string { + const beforeSet = new Set(beforeLines); + const additions = currentLines + .filter((line) => !beforeSet.has(line)) + .map((line) => sanitizeYuanbaoResponseText(line, promptText)) + .filter((line) => line && line !== promptText); + + return additions.join('\n').trim(); +} + +export function pickLatestYuanbaoAssistantCandidate( + messages: string[], + baselineCount: number, + promptText: string, +): string { + const freshMessages = messages + .slice(Math.max(0, baselineCount)) + .map((message) => sanitizeYuanbaoResponseText(message, promptText)) + .filter(Boolean); + + for (let i = freshMessages.length - 1; i >= 0; i -= 1) { + if (freshMessages[i] !== promptText.trim()) return freshMessages[i]; + } + + return ''; +} + +export function updateStableState(previousText: string, stableCount: number, nextText: string) { + if (!nextText) return { previousText: '', stableCount: 0 }; + if (nextText === previousText) return { previousText, stableCount: stableCount + 1 }; + return { previousText: nextText, stableCount: 0 }; +} + + +function getTranscriptLinesScript(): string { + return ` + (() => { + const clean = (value) => (value || '') + .replace(/\\u00a0/g, ' ') + .replace(/\\n{3,}/g, '\\n\\n') + .trim(); + + const root = ( + document.querySelector('.agent-dialogue__content--common') + || document.querySelector('.agent-dialogue__content') + || document.querySelector('.agent-dialogue') + || document.body + ).cloneNode(true); + + const removableSelectors = [ + '.agent-dialogue__content--common__input', + '.agent-dialogue__tool', + '.agent-dialogue__content-copyright', + '.index_chatLandingBox__G7hAT', + '.index_chatLandingBoxMobile__J8i8v', + '.index_chatLandingHintList__M69Lr', + '.yb-nav', + '.agent-dialogue__content--common__input .ql-toolbar', + '.agent-dialogue__content--common__input .ql-container', + '.agent-dialogue__content--common__input .ql-editor', + '[role="dialog"]', + 'iframe', + 'button', + 'script', + 'style', + 'noscript', + ]; + + for (const selector of removableSelectors) { + root.querySelectorAll(selector).forEach((node) => node.remove()); + } + + const stopLines = new Set([ + '元宝', + 'DeepSeek', + '深度思考', + '联网搜索', + '工具', + '登录', + '安装电脑版', + '内容由AI生成,仅供参考', + '有问题,尽管问,shift+enter换行', + '立即创建团队', + '微信', + '手机', + 'QQ', + '微信扫码登录', + '扫码默认已阅读并同意', + '用户服务协议', + '隐私协议', + ]); + + const noisyPatterns = [ + /^支持文件格式[::]/, + /^文件拖动到此处即可上传/, + /^下载元宝电脑版/, + ]; + + return clean(root.innerText || root.textContent || '') + .split('\\n') + .map((line) => clean(line)) + .filter((line) => line + && line.length <= 4000 + && !stopLines.has(line) + && !noisyPatterns.some((pattern) => pattern.test(line))); + })() + `; +} + +async function getYuanbaoTranscriptLines(page: IPage): Promise { + const result = await page.evaluate(getTranscriptLinesScript()); + return Array.isArray(result) ? result.map(normalizeText).filter(Boolean) : []; +} + +async function getYuanbaoAssistantMessages(page: IPage): Promise { + const result = await page.evaluate(`(() => { + ${IS_VISIBLE_JS} + + const roots = Array.from(document.querySelectorAll('.agent-chat__list__item--ai')) + .filter((node) => isVisible(node)); + + return roots.map((root) => { + const doneContent = root.querySelector('.hyc-content-md-done'); + const markdownContent = doneContent || root.querySelector('.hyc-content-md'); + const speechContent = root.querySelector('.agent-chat__speech-text'); + const bubbleContent = root.querySelector('.agent-chat__bubble__content'); + const content = markdownContent || speechContent || bubbleContent; + + if (content instanceof HTMLElement) { + return content.innerHTML || content.textContent || ''; + } + + return root instanceof HTMLElement ? (root.innerHTML || root.textContent || '') : ''; + }).filter(Boolean); + })()`); + + return Array.isArray(result) + ? result + .map((value) => convertYuanbaoHtmlToMarkdown(typeof value === 'string' ? value : '')) + .map(normalizeText) + .filter(Boolean) + : []; +} + +async function getYuanbaoInternetSearchState(page: IPage): Promise { + const result = await page.evaluate(`(() => { + ${IS_VISIBLE_JS} + + const button = Array.from(document.querySelectorAll('[dt-button-id="internet_search"]')) + .find((node) => isVisible(node)); + + if (!(button instanceof HTMLElement)) return { found: false, enabled: false }; + + const attr = button.getAttribute('dt-internet-search') || ''; + const className = button.className || ''; + return { + found: true, + enabled: attr === 'openInternetSearch' || className.includes('index_v2_active__'), + }; + })()`); + + return result as YuanbaoToggleState; +} + +async function setYuanbaoInternetSearch(page: IPage, enabled: boolean): Promise { + const current = await getYuanbaoInternetSearchState(page); + if (!current.found || current.enabled === enabled) return; + + await page.evaluate(`(() => { + ${IS_VISIBLE_JS} + + const button = Array.from(document.querySelectorAll('[dt-button-id="internet_search"]')) + .find((node) => isVisible(node)); + + if (button instanceof HTMLElement) button.click(); + })()`); + + await page.wait(0.5); +} + +async function getYuanbaoDeepThinkState(page: IPage): Promise { + const result = await page.evaluate(`(() => { + ${IS_VISIBLE_JS} + + const button = Array.from(document.querySelectorAll('[dt-button-id="deep_think"]')) + .find((node) => isVisible(node)); + + if (!(button instanceof HTMLElement)) return { found: false, enabled: false }; + + const className = button.className || ''; + return { + found: true, + enabled: className.includes('ThinkSelector_selected__'), + }; + })()`); + + return result as YuanbaoToggleState; +} + +async function setYuanbaoDeepThink(page: IPage, enabled: boolean): Promise { + const current = await getYuanbaoDeepThinkState(page); + if (!current.found || current.enabled === enabled) return; + + await page.evaluate(`(() => { + ${IS_VISIBLE_JS} + + const button = Array.from(document.querySelectorAll('[dt-button-id="deep_think"]')) + .find((node) => isVisible(node)); + + if (button instanceof HTMLElement) button.click(); + })()`); + + await page.wait(0.5); +} + +async function sendYuanbaoMessage(page: IPage, prompt: string): Promise { + return await page.evaluate(`(async () => { + const waitFor = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + ${IS_VISIBLE_JS} + + const composer = Array.from(document.querySelectorAll('.ql-editor[contenteditable="true"], .ql-editor, [contenteditable="true"]')) + .find(isVisible); + + if (!(composer instanceof HTMLElement)) { + return { + ok: false, + reason: 'Yuanbao composer was not found.', + }; + } + + try { + composer.focus(); + const selection = window.getSelection(); + const range = document.createRange(); + range.selectNodeContents(composer); + range.collapse(false); + selection?.removeAllRanges(); + selection?.addRange(range); + composer.textContent = ''; + document.execCommand('insertText', false, ${JSON.stringify(prompt)}); + composer.dispatchEvent(new InputEvent('input', { bubbles: true, data: ${JSON.stringify(prompt)}, inputType: 'insertText' })); + await waitFor(200); + } catch (error) { + return { + ok: false, + reason: 'Failed to insert the prompt into the Yuanbao composer.', + detail: error instanceof Error ? error.message : String(error), + }; + } + + const submit = Array.from(document.querySelectorAll('a[class*="send-btn"], button[class*="send-btn"]')) + .find((node) => { + if (!(node instanceof HTMLElement) || !isVisible(node)) return false; + const className = node.className || ''; + if (typeof className === 'string' && className.includes('disabled')) return false; + return true; + }); + + if (submit instanceof HTMLElement) { + submit.click(); + return { ok: true, action: 'click' }; + } + + composer.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true })); + composer.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true })); + return { ok: true, action: 'enter' }; + })()`) as YuanbaoSendResult; +} + +async function waitForYuanbaoResponse( + page: IPage, + baselineAssistantCount: number, + beforeLines: string[], + prompt: string, + timeoutSeconds: number, +): Promise { + const startTime = Date.now(); + let previousText = ''; + let stableCount = 0; + let latestCandidate = ''; + + while (Date.now() - startTime < timeoutSeconds * 1000) { + await page.wait(YUANBAO_RESPONSE_POLL_INTERVAL_SECONDS); + + if (await hasLoginGate(page)) return 'blocked'; + + const assistantMessages = await getYuanbaoAssistantMessages(page); + const assistantCandidate = pickLatestYuanbaoAssistantCandidate( + assistantMessages, + baselineAssistantCount, + prompt, + ); + + const candidate = assistantCandidate || collectYuanbaoTranscriptAdditions( + beforeLines, + await getYuanbaoTranscriptLines(page), + prompt, + ); + + if (!candidate) continue; + + latestCandidate = candidate; + const nextState = updateStableState(previousText, stableCount, candidate); + previousText = nextState.previousText; + stableCount = nextState.stableCount; + + const waitedLongEnough = Date.now() - startTime >= YUANBAO_MIN_WAIT_MS; + if (waitedLongEnough && stableCount >= YUANBAO_STABLE_POLLS_REQUIRED) return candidate; + } + + return latestCandidate || null; +} + +export const askCommand = cli({ + site: 'yuanbao', + name: 'ask', + description: 'Send a prompt to Yuanbao web chat and wait for the assistant response', + domain: YUANBAO_DOMAIN, + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + defaultFormat: 'plain', + timeoutSeconds: 180, + args: [ + { name: 'prompt', required: true, positional: true, help: 'Prompt to send' }, + { name: 'timeout', required: false, help: 'Max seconds to wait (default: 60)', default: '60' }, + { name: 'search', type: 'boolean', required: false, help: 'Enable Yuanbao internet search (default: true)', default: true }, + { name: 'think', type: 'boolean', required: false, help: 'Enable Yuanbao deep thinking (default: false)', default: false }, + ], + columns: ['Role', 'Text'], + func: async (page: IPage, kwargs: any) => { + const prompt = kwargs.prompt as string; + const timeout = parseInt(kwargs.timeout as string, 10) || 60; + const useSearch = normalizeBooleanFlag(kwargs.search, true); + const useThink = normalizeBooleanFlag(kwargs.think, false); + + await ensureYuanbaoPage(page); + if (await hasLoginGate(page)) { + throw authRequired('Yuanbao opened a login gate before sending the prompt.'); + } + await setYuanbaoInternetSearch(page, useSearch); + await setYuanbaoDeepThink(page, useThink); + const beforeAssistantMessages = await getYuanbaoAssistantMessages(page); + const beforeLines = await getYuanbaoTranscriptLines(page); + const sendResult = await sendYuanbaoMessage(page, prompt); + + if (!sendResult?.ok) { + if (await hasLoginGate(page)) { + throw authRequired('Yuanbao opened a login gate instead of accepting the prompt.'); + } + throw sendFailure(sendResult?.reason, sendResult?.detail); + } + + const response = await waitForYuanbaoResponse( + page, + beforeAssistantMessages.length, + beforeLines, + prompt, + timeout, + ); + + if (response === 'blocked') { + throw authRequired('Yuanbao opened a login gate instead of returning a chat response.'); + } + + if (!response) { + throw new TimeoutError( + 'yuanbao ask', + timeout, + 'No Yuanbao response was observed before the timeout. Retry with --timeout, and verify the current browser session is still interactive.', + ); + } + + return [ + { Role: 'User', Text: prompt }, + { Role: 'Assistant', Text: response }, + ]; + }, +}); + +export const __test__ = { + collectYuanbaoTranscriptAdditions, + convertYuanbaoHtmlToMarkdown, + isOnYuanbao, + normalizeBooleanFlag, + pickLatestYuanbaoAssistantCandidate, + sanitizeYuanbaoResponseText, + updateStableState, +}; diff --git a/src/clis/yuanbao/new.test.ts b/src/clis/yuanbao/new.test.ts new file mode 100644 index 00000000..91a4c154 --- /dev/null +++ b/src/clis/yuanbao/new.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { IPage } from '../../types.js'; +import { AuthRequiredError } from '../../errors.js'; +import { newCommand } from './new.js'; + +function createNewPageMock(overrides: { + currentUrl?: string; + triggerAction?: 'clicked' | 'navigate'; + hasLoginGate?: boolean; + composerText?: string; +} = {}): IPage { + const currentUrl = overrides.currentUrl ?? 'https://yuanbao.tencent.com/'; + const triggerAction = overrides.triggerAction ?? 'clicked'; + const hasLoginGate = overrides.hasLoginGate ?? false; + const composerText = overrides.composerText ?? ''; + + return { + goto: vi.fn().mockResolvedValue(undefined), + wait: vi.fn().mockResolvedValue(undefined), + evaluate: vi.fn().mockImplementation(async (script: string) => { + if (script === 'window.location.href') return currentUrl; + if (script.includes('微信扫码登录')) return hasLoginGate; + if (script.includes('.ql-editor, [contenteditable="true"]')) return composerText; + if (script.includes('const trigger = Array.from(document.querySelectorAll')) return triggerAction; + throw new Error(`Unexpected evaluate script in test: ${script.slice(0, 80)}`); + }), + } as unknown as IPage; +} + +describe('yuanbao new command', () => { + it('throws AuthRequiredError when Yuanbao shows a login gate', async () => { + const page = createNewPageMock({ hasLoginGate: true }); + + await expect(newCommand.func!(page, {})).rejects.toBeInstanceOf(AuthRequiredError); + }); +}); diff --git a/src/clis/yuanbao/new.ts b/src/clis/yuanbao/new.ts new file mode 100644 index 00000000..633122d2 --- /dev/null +++ b/src/clis/yuanbao/new.ts @@ -0,0 +1,81 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; +import { YUANBAO_DOMAIN, YUANBAO_URL, IS_VISIBLE_JS, authRequired, ensureYuanbaoPage, hasLoginGate } from './shared.js'; + +async function getCurrentUrl(page: IPage): Promise { + const result = await page.evaluate('window.location.href').catch(() => ''); + return typeof result === 'string' ? result : ''; +} + +async function getComposerText(page: IPage): Promise { + const result = await page.evaluate(`(() => { + const composer = document.querySelector('.ql-editor, [contenteditable="true"]'); + return composer ? (composer.textContent || '').trim() : ''; + })()`); + + return typeof result === 'string' ? result.trim() : ''; +} + +async function startNewYuanbaoChat(page: IPage): Promise<'clicked' | 'navigate' | 'blocked'> { + await ensureYuanbaoPage(page); + + if (await hasLoginGate(page)) return 'blocked'; + + const beforeUrl = await getCurrentUrl(page); + const action = await page.evaluate(`(() => { + ${IS_VISIBLE_JS} + + const trigger = Array.from(document.querySelectorAll('.yb-common-nav__trigger[data-desc="new-chat"]')) + .find((node) => isVisible(node)); + + if (trigger instanceof HTMLElement) { + trigger.click(); + return 'clicked'; + } + + return 'navigate'; + })()`) as 'clicked' | 'navigate'; + + if (action === 'navigate') { + await page.goto(YUANBAO_URL, { waitUntil: 'load', settleMs: 2500 }); + await page.wait(1); + if (await hasLoginGate(page)) return 'blocked'; + return 'navigate'; + } + + await page.wait(1); + + if (await hasLoginGate(page)) return 'blocked'; + + const afterUrl = await getCurrentUrl(page); + const composerText = await getComposerText(page); + if (afterUrl !== beforeUrl || !composerText) return 'clicked'; + + await page.goto(YUANBAO_URL, { waitUntil: 'load', settleMs: 2500 }); + await page.wait(1); + return 'navigate'; +} + +export const newCommand = cli({ + site: 'yuanbao', + name: 'new', + description: 'Start a new conversation in Yuanbao web chat', + domain: YUANBAO_DOMAIN, + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [], + columns: ['Status', 'Action'], + func: async (page: IPage) => { + const action = await startNewYuanbaoChat(page); + + if (action === 'blocked') { + throw authRequired('Yuanbao opened a login gate instead of starting a new chat.'); + } + + return [{ + Status: 'Success', + Action: action === 'navigate' ? 'Reloaded Yuanbao homepage as fallback' : 'Clicked New chat', + }]; + }, +}); diff --git a/src/clis/yuanbao/shared.ts b/src/clis/yuanbao/shared.ts new file mode 100644 index 00000000..0e299acb --- /dev/null +++ b/src/clis/yuanbao/shared.ts @@ -0,0 +1,57 @@ +import type { IPage } from '../../types.js'; +import { AuthRequiredError } from '../../errors.js'; + +export const YUANBAO_DOMAIN = 'yuanbao.tencent.com'; +export const YUANBAO_URL = 'https://yuanbao.tencent.com/'; + +const SESSION_HINT = 'Likely login/auth/challenge/session issue in the existing yuanbao.tencent.com browser session.'; + +/** + * Reusable visibility check for injected browser scripts. + * Embed in page.evaluate strings via `${IS_VISIBLE_JS}`. + */ +export const IS_VISIBLE_JS = `const isVisible = (node) => { + if (!(node instanceof HTMLElement)) return false; + const rect = node.getBoundingClientRect(); + const style = window.getComputedStyle(node); + return rect.width > 0 + && rect.height > 0 + && style.display !== 'none' + && style.visibility !== 'hidden'; +};`; + +export function authRequired(message: string) { + return new AuthRequiredError(YUANBAO_DOMAIN, `${message} ${SESSION_HINT}`); +} + +export async function isOnYuanbao(page: IPage): Promise { + const url = await page.evaluate('window.location.href').catch(() => ''); + if (typeof url !== 'string' || !url) return false; + + try { + const hostname = new URL(url).hostname; + return hostname === YUANBAO_DOMAIN || hostname.endsWith(`.${YUANBAO_DOMAIN}`); + } catch { + return false; + } +} + +export async function ensureYuanbaoPage(page: IPage): Promise { + if (!(await isOnYuanbao(page))) { + await page.goto(YUANBAO_URL, { waitUntil: 'load', settleMs: 2500 }); + await page.wait(1); + } +} + +export async function hasLoginGate(page: IPage): Promise { + const result = await page.evaluate(`(() => { + const bodyText = document.body.innerText || ''; + const hasWechatLoginText = bodyText.includes('微信扫码登录'); + const hasWechatIframe = Array.from(document.querySelectorAll('iframe')) + .some((frame) => (frame.getAttribute('src') || '').includes('open.weixin.qq.com/connect/qrconnect')); + + return hasWechatLoginText || hasWechatIframe; + })()`); + + return Boolean(result); +} diff --git a/tests/e2e/browser-auth.test.ts b/tests/e2e/browser-auth.test.ts index 343a25d1..c6c6a939 100644 --- a/tests/e2e/browser-auth.test.ts +++ b/tests/e2e/browser-auth.test.ts @@ -105,6 +105,15 @@ describe('login-required commands — graceful failure', () => { await expectGracefulAuthFailure(['xiaohongshu', 'notifications', '--limit', '3', '-f', 'json']); }, 60_000); + // ── yuanbao (requires login) ── + it('yuanbao new fails gracefully without login', async () => { + await expectGracefulAuthFailure(['yuanbao', 'new', '-f', 'json']); + }, 60_000); + + it('yuanbao ask fails gracefully without login', async () => { + await expectGracefulAuthFailure(['yuanbao', 'ask', '你好', '-f', 'json']); + }, 60_000); + // ── pixiv (requires login) ── it('pixiv ranking fails gracefully without login', async () => { await expectGracefulAuthFailure(['pixiv', 'ranking', '--limit', '3', '-f', 'json']);