From 04dddfcec455111579dcc08ec7a3143da293cb73 Mon Sep 17 00:00:00 2001 From: Benjamin Liu Date: Mon, 20 Apr 2026 00:16:19 +0900 Subject: [PATCH] feat(deepseek): add DeepSeek browser adapter with ask, new, status, read, history Closes #548 --- cli-manifest.json | 141 ++++++++++++++++++++ clis/deepseek/ask.js | 74 +++++++++++ clis/deepseek/history.js | 25 ++++ clis/deepseek/new.js | 20 +++ clis/deepseek/read.js | 22 ++++ clis/deepseek/status.js | 24 ++++ clis/deepseek/utils.js | 208 ++++++++++++++++++++++++++++++ docs/adapters/browser/deepseek.md | 76 +++++++++++ 8 files changed, 590 insertions(+) create mode 100644 clis/deepseek/ask.js create mode 100644 clis/deepseek/history.js create mode 100644 clis/deepseek/new.js create mode 100644 clis/deepseek/read.js create mode 100644 clis/deepseek/status.js create mode 100644 clis/deepseek/utils.js create mode 100644 docs/adapters/browser/deepseek.md diff --git a/cli-manifest.json b/cli-manifest.json index 00ec7f51e..84352d4b7 100644 --- a/cli-manifest.json +++ b/cli-manifest.json @@ -4065,6 +4065,147 @@ "sourceFile": "cursor/send.js", "navigateBefore": true }, + { + "site": "deepseek", + "name": "ask", + "description": "Send a prompt to DeepSeek and get the response", + "domain": "chat.deepseek.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "prompt", + "type": "str", + "required": true, + "positional": true, + "help": "Prompt to send" + }, + { + "name": "timeout", + "type": "int", + "default": 120, + "required": false, + "help": "Max seconds to wait for response" + }, + { + "name": "new", + "type": "boolean", + "default": false, + "required": false, + "help": "Start a new chat before sending" + }, + { + "name": "model", + "type": "str", + "default": "instant", + "required": false, + "help": "Model to use: instant or expert", + "choices": [ + "instant", + "expert" + ] + }, + { + "name": "think", + "type": "boolean", + "default": false, + "required": false, + "help": "Enable DeepThink mode" + }, + { + "name": "search", + "type": "boolean", + "default": false, + "required": false, + "help": "Enable web search" + } + ], + "columns": [ + "response" + ], + "timeout": 180, + "type": "js", + "modulePath": "deepseek/ask.js", + "sourceFile": "deepseek/ask.js", + "navigateBefore": false + }, + { + "site": "deepseek", + "name": "history", + "description": "List conversation history from DeepSeek sidebar", + "domain": "chat.deepseek.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Max conversations to show" + } + ], + "columns": [ + "Index", + "Title", + "Url" + ], + "type": "js", + "modulePath": "deepseek/history.js", + "sourceFile": "deepseek/history.js", + "navigateBefore": false + }, + { + "site": "deepseek", + "name": "new", + "description": "Start a new conversation in DeepSeek", + "domain": "chat.deepseek.com", + "strategy": "cookie", + "browser": true, + "args": [], + "columns": [ + "Status" + ], + "type": "js", + "modulePath": "deepseek/new.js", + "sourceFile": "deepseek/new.js", + "navigateBefore": false + }, + { + "site": "deepseek", + "name": "read", + "description": "Read the current DeepSeek conversation", + "domain": "chat.deepseek.com", + "strategy": "cookie", + "browser": true, + "args": [], + "columns": [ + "Role", + "Text" + ], + "type": "js", + "modulePath": "deepseek/read.js", + "sourceFile": "deepseek/read.js", + "navigateBefore": false + }, + { + "site": "deepseek", + "name": "status", + "description": "Check DeepSeek page availability and login state", + "domain": "chat.deepseek.com", + "strategy": "cookie", + "browser": true, + "args": [], + "columns": [ + "Status", + "Login", + "Url" + ], + "type": "js", + "modulePath": "deepseek/status.js", + "sourceFile": "deepseek/status.js", + "navigateBefore": false + }, { "site": "devto", "name": "tag", diff --git a/clis/deepseek/ask.js b/clis/deepseek/ask.js new file mode 100644 index 000000000..04119f28b --- /dev/null +++ b/clis/deepseek/ask.js @@ -0,0 +1,74 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { CommandExecutionError } from '@jackwener/opencli/errors'; +import { + DEEPSEEK_DOMAIN, DEEPSEEK_URL, ensureOnDeepSeek, selectModel, setFeature, + sendMessage, getBubbleCount, waitForResponse, parseBoolFlag, withRetry, +} from './utils.js'; + +export const askCommand = cli({ + site: 'deepseek', + name: 'ask', + description: 'Send a prompt to DeepSeek and get the response', + domain: DEEPSEEK_DOMAIN, + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + timeoutSeconds: 180, + args: [ + { name: 'prompt', positional: true, required: true, help: 'Prompt to send' }, + { name: 'timeout', type: 'int', default: 120, help: 'Max seconds to wait for response' }, + { name: 'new', type: 'boolean', default: false, help: 'Start a new chat before sending' }, + { name: 'model', default: 'instant', choices: ['instant', 'expert'], help: 'Model to use: instant or expert' }, + { name: 'think', type: 'boolean', default: false, help: 'Enable DeepThink mode' }, + { name: 'search', type: 'boolean', default: false, help: 'Enable web search' }, + ], + columns: ['response'], + + func: async (page, kwargs) => { + const prompt = kwargs.prompt; + const timeoutMs = (kwargs.timeout || 120) * 1000; + const wantThink = parseBoolFlag(kwargs.think); + const wantSearch = parseBoolFlag(kwargs.search); + + if (parseBoolFlag(kwargs.new)) { + await page.goto(DEEPSEEK_URL); + await page.wait(3); + } else { + await ensureOnDeepSeek(page); + } + + await page.wait(2); + + const wantModel = kwargs.model || 'instant'; + const modelResult = await withRetry(() => selectModel(page, wantModel)); + if (!modelResult?.ok) { + throw new CommandExecutionError(`Could not switch to ${wantModel} model`); + } + if (modelResult.toggled) await page.wait(0.5); + + const thinkResult = await withRetry(() => setFeature(page, 'DeepThink', wantThink)); + if (!thinkResult?.ok) { + throw new CommandExecutionError('Could not toggle DeepThink'); + } + + const searchResult = await withRetry(() => setFeature(page, 'Search', wantSearch)); + if (!searchResult?.ok) { + throw new CommandExecutionError('Could not toggle Search'); + } + + if (thinkResult.toggled || searchResult.toggled) await page.wait(0.5); + + const baseline = await withRetry(() => getBubbleCount(page)); + const sendResult = await withRetry(() => sendMessage(page, prompt)); + if (!sendResult?.ok) { + throw new CommandExecutionError(sendResult?.reason || 'Failed to send message'); + } + + const response = await waitForResponse(page, baseline, prompt, timeoutMs); + if (!response) { + return [{ response: `[NO RESPONSE] No reply within ${kwargs.timeout}s.` }]; + } + + return [{ response }]; + }, +}); diff --git a/clis/deepseek/history.js b/clis/deepseek/history.js new file mode 100644 index 000000000..30b438260 --- /dev/null +++ b/clis/deepseek/history.js @@ -0,0 +1,25 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { DEEPSEEK_DOMAIN, getConversationList } from './utils.js'; + +export const historyCommand = cli({ + site: 'deepseek', + name: 'history', + description: 'List conversation history from DeepSeek sidebar', + domain: DEEPSEEK_DOMAIN, + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [ + { name: 'limit', type: 'int', default: 20, help: 'Max conversations to show' }, + ], + columns: ['Index', 'Title', 'Url'], + + func: async (page, kwargs) => { + const limit = Math.max(1, kwargs.limit || 20); + const conversations = await getConversationList(page); + if (conversations.length === 0) { + return [{ Index: 0, Title: 'No conversation history found.', Url: '' }]; + } + return conversations.slice(0, limit); + }, +}); diff --git a/clis/deepseek/new.js b/clis/deepseek/new.js new file mode 100644 index 000000000..2dda48e16 --- /dev/null +++ b/clis/deepseek/new.js @@ -0,0 +1,20 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { DEEPSEEK_DOMAIN, DEEPSEEK_URL } from './utils.js'; + +export const newCommand = cli({ + site: 'deepseek', + name: 'new', + description: 'Start a new conversation in DeepSeek', + domain: DEEPSEEK_DOMAIN, + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [], + columns: ['Status'], + + func: async (page) => { + await page.goto(DEEPSEEK_URL); + await page.wait(2); + return [{ Status: 'New chat started' }]; + }, +}); diff --git a/clis/deepseek/read.js b/clis/deepseek/read.js new file mode 100644 index 000000000..b92b2d3ba --- /dev/null +++ b/clis/deepseek/read.js @@ -0,0 +1,22 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { DEEPSEEK_DOMAIN, ensureOnDeepSeek, getVisibleMessages } from './utils.js'; + +export const readCommand = cli({ + site: 'deepseek', + name: 'read', + description: 'Read the current DeepSeek conversation', + domain: DEEPSEEK_DOMAIN, + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [], + columns: ['Role', 'Text'], + + func: async (page) => { + await ensureOnDeepSeek(page); + await page.wait(5); + const messages = await getVisibleMessages(page); + if (messages.length > 0) return messages; + return [{ Role: 'system', Text: 'No visible messages found.' }]; + }, +}); diff --git a/clis/deepseek/status.js b/clis/deepseek/status.js new file mode 100644 index 000000000..d511410ef --- /dev/null +++ b/clis/deepseek/status.js @@ -0,0 +1,24 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { DEEPSEEK_DOMAIN, ensureOnDeepSeek, getPageState } from './utils.js'; + +export const statusCommand = cli({ + site: 'deepseek', + name: 'status', + description: 'Check DeepSeek page availability and login state', + domain: DEEPSEEK_DOMAIN, + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [], + columns: ['Status', 'Login', 'Url'], + + func: async (page) => { + await ensureOnDeepSeek(page); + const state = await getPageState(page); + return [{ + Status: state.hasTextarea ? 'Connected' : 'Page not ready', + Login: state.isLoggedIn ? 'Yes' : 'No', + Url: state.url, + }]; + }, +}); diff --git a/clis/deepseek/utils.js b/clis/deepseek/utils.js new file mode 100644 index 000000000..c9f735161 --- /dev/null +++ b/clis/deepseek/utils.js @@ -0,0 +1,208 @@ +export const DEEPSEEK_DOMAIN = 'chat.deepseek.com'; +export const DEEPSEEK_URL = 'https://chat.deepseek.com/'; +export const TEXTAREA_SELECTOR = 'textarea[placeholder*="DeepSeek"]'; +export const MESSAGE_SELECTOR = '.ds-message'; + +export async function isOnDeepSeek(page) { + const url = await page.evaluate('window.location.href').catch(() => ''); + if (typeof url !== 'string' || !url) return false; + try { + const h = new URL(url).hostname; + return h === 'deepseek.com' || h.endsWith('.deepseek.com'); + } catch { + return false; + } +} + +export async function ensureOnDeepSeek(page) { + if (!(await isOnDeepSeek(page))) { + await page.goto(DEEPSEEK_URL); + await page.wait(3); + } +} + +export async function getPageState(page) { + return page.evaluate(`(() => { + const url = window.location.href; + const title = document.title; + const textarea = document.querySelector('${TEXTAREA_SELECTOR}'); + const avatar = document.querySelector('img[src*="user-avatar"]'); + return { + url, + title, + hasTextarea: !!textarea, + isLoggedIn: !!avatar, + }; + })()`); +} + +export async function selectModel(page, modelName) { + return page.evaluate(`(() => { + const radios = document.querySelectorAll('div[role="radio"]'); + for (const radio of radios) { + const span = radio.querySelector('span'); + if (span && span.textContent.trim().toLowerCase() === '${modelName}'.toLowerCase()) { + const alreadySelected = radio.getAttribute('aria-checked') === 'true'; + if (!alreadySelected) radio.click(); + return { ok: true, toggled: !alreadySelected }; + } + } + return { ok: false }; + })()`); +} + +export async function setFeature(page, featureName, enabled) { + return page.evaluate(`(() => { + const btns = document.querySelectorAll('div[role="button"]'); + for (const btn of btns) { + const span = btn.querySelector('span'); + if (span && span.textContent.trim() === '${featureName}') { + const isActive = btn.classList.contains('ds-toggle-button--selected'); + if (${enabled} !== isActive) btn.click(); + return { ok: true, toggled: ${enabled} !== isActive }; + } + } + return { ok: false }; + })()`); +} + +export async function sendMessage(page, prompt) { + const promptJson = JSON.stringify(prompt); + return page.evaluate(`(async () => { + const box = document.querySelector('${TEXTAREA_SELECTOR}'); + if (!box) return { ok: false, reason: 'textarea not found' }; + + box.focus(); + box.value = ''; + document.execCommand('selectAll'); + document.execCommand('insertText', false, ${promptJson}); + await new Promise(r => setTimeout(r, 800)); + + const btns = document.querySelectorAll('div[role="button"]'); + for (const btn of btns) { + if (btn.getAttribute('aria-disabled') === 'false') { + const svgs = btn.querySelectorAll('svg'); + if (svgs.length > 0 && btn.closest('div')?.querySelector('textarea')) { + btn.click(); + return { ok: true }; + } + } + } + + box.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true })); + return { ok: true, method: 'enter' }; + })()`); +} + +export async function getBubbleCount(page) { + const count = await page.evaluate(`(() => { + return document.querySelectorAll('${MESSAGE_SELECTOR}').length; + })()`); + return count || 0; +} + +export async function waitForResponse(page, baselineCount, prompt, timeoutMs) { + const startTime = Date.now(); + let lastText = ''; + let stableCount = 0; + + while (Date.now() - startTime < timeoutMs) { + await page.wait(3); + + let result; + try { + result = await page.evaluate(`(() => { + const bubbles = document.querySelectorAll('${MESSAGE_SELECTOR}'); + const texts = Array.from(bubbles).map(b => (b.innerText || '').trim()).filter(Boolean); + return { count: texts.length, last: texts[texts.length - 1] || '' }; + })()`); + } catch { + continue; + } + + if (!result) continue; + + const candidate = result.last; + if (candidate && result.count > baselineCount && candidate !== prompt.trim()) { + if (candidate === lastText) { + stableCount++; + if (stableCount >= 3) return candidate; + } else { + stableCount = 0; + } + lastText = candidate; + } + } + + return lastText || null; +} + +export async function getVisibleMessages(page) { + const result = await page.evaluate(`(() => { + const msgs = document.querySelectorAll('${MESSAGE_SELECTOR}'); + return Array.from(msgs).map(m => { + // User messages carry an extra hash-class alongside ds-message + const isUser = m.className.split(/\\s+/).length > 2; + return { + Role: isUser ? 'user' : 'assistant', + Text: (m.innerText || '').trim(), + }; + }).filter(m => m.Text); + })()`); + return Array.isArray(result) ? result : []; +} + +export async function getConversationList(page) { + await ensureOnDeepSeek(page); + // Expand sidebar if collapsed + await page.evaluate(`(() => { + if (document.querySelectorAll('a[href*="/a/chat/s/"]').length === 0) { + const btn = document.querySelector('div[tabindex="0"][role="button"]'); + if (btn) btn.click(); + } + })()`); + // Poll for sidebar history links to render + for (let attempt = 0; attempt < 5; attempt++) { + await page.wait(2); + const items = await page.evaluate(`(() => { + const items = []; + const links = document.querySelectorAll('a[href*="/a/chat/s/"]'); + links.forEach((link, i) => { + const titleEl = link.querySelector('div'); + const title = titleEl ? titleEl.textContent.trim() : ''; + const href = link.getAttribute('href') || ''; + const idMatch = href.match(/\\/s\\/([a-f0-9-]+)/); + items.push({ + Index: i + 1, + Id: idMatch ? idMatch[1] : href, + Title: title || '(untitled)', + Url: 'https://chat.deepseek.com' + href, + }); + }); + return items; + })()`); + if (Array.isArray(items) && items.length > 0) return items; + } + return []; +} + +// Retries on CDP "Promise was collected" errors caused by DeepSeek's SPA router transitions. +export async function withRetry(fn, retries = 2) { + for (let i = 0; i <= retries; i++) { + try { + return await fn(); + } catch (err) { + const msg = String(err?.message || err); + if (i < retries && msg.includes('Promise was collected')) { + await new Promise(r => setTimeout(r, 2000)); + continue; + } + throw err; + } + } +} + +export function parseBoolFlag(value) { + if (typeof value === 'boolean') return value; + return String(value ?? '').trim().toLowerCase() === 'true'; +} diff --git a/docs/adapters/browser/deepseek.md b/docs/adapters/browser/deepseek.md new file mode 100644 index 000000000..4ebfa7a19 --- /dev/null +++ b/docs/adapters/browser/deepseek.md @@ -0,0 +1,76 @@ +# DeepSeek + +**Mode**: Browser ยท **Domain**: `chat.deepseek.com` + +## Commands + +| Command | Description | +|---------|-------------| +| `opencli deepseek ask ` | Send a prompt and get the response | +| `opencli deepseek new` | Start a new conversation | +| `opencli deepseek status` | Check login state and page availability | +| `opencli deepseek read` | Read the current conversation | +| `opencli deepseek history` | List conversation history from sidebar | + +## Usage Examples + +```bash +# Ask a question +opencli deepseek ask "explain quicksort in 3 sentences" + +# Start a new chat before asking +opencli deepseek ask "hello" --new + +# Use Expert model instead of Instant +opencli deepseek ask "prove that sqrt(2) is irrational" --model expert + +# Enable DeepThink mode +opencli deepseek ask "prove that sqrt(2) is irrational" --think + +# Enable web search +opencli deepseek ask "latest news about AI" --search + +# Combine modes +opencli deepseek ask "what happened today?" --model expert --think --search --new + +# Custom timeout (default: 120s) +opencli deepseek ask "write a long essay" --timeout 180 + +# JSON output +opencli deepseek ask "hello" -f json + +# Check login status +opencli deepseek status + +# Start a fresh conversation +opencli deepseek new + +# Read current conversation +opencli deepseek read + +# List recent conversations +opencli deepseek history --limit 10 +``` + +### Options (ask) + +| Option | Description | +|--------|-------------| +| `` | The message to send (required, positional) | +| `--timeout` | Wait timeout in seconds (default: 120) | +| `--new` | Start a new chat before sending (default: false) | +| `--model` | Model to use: `instant` or `expert` (default: instant) | +| `--think` | Enable DeepThink mode (default: false) | +| `--search` | Enable web search (default: false) | + +## Prerequisites + +- Chrome running with [Browser Bridge extension](/guide/browser-bridge) installed +- Logged in to [chat.deepseek.com](https://chat.deepseek.com) + +## Caveats + +- This adapter drives the DeepSeek web UI in the browser, not an API +- Default mode is Instant with DeepThink and Search disabled; each flag (`--model`, `--think`, `--search`) is synced on every invocation so omitting a flag resets it +- Long responses (code, essays) may need a higher `--timeout` +- File upload is not yet supported