utils.js
1 export const DEEPSEEK_DOMAIN = 'chat.deepseek.com'; 2 export const DEEPSEEK_URL = 'https://chat.deepseek.com/'; 3 export const TEXTAREA_SELECTOR = 'textarea[placeholder*="DeepSeek"]'; 4 export const MESSAGE_SELECTOR = '.ds-message'; 5 6 export async function isOnDeepSeek(page) { 7 const url = await page.evaluate('window.location.href').catch(() => ''); 8 if (typeof url !== 'string' || !url) return false; 9 try { 10 const h = new URL(url).hostname; 11 return h === 'deepseek.com' || h.endsWith('.deepseek.com'); 12 } catch { 13 return false; 14 } 15 } 16 17 export async function ensureOnDeepSeek(page) { 18 if (await isOnDeepSeek(page)) return false; 19 await page.goto(DEEPSEEK_URL); 20 await page.wait(3); 21 return true; 22 } 23 24 export async function getPageState(page) { 25 return page.evaluate(`(() => { 26 const url = window.location.href; 27 const title = document.title; 28 const textarea = document.querySelector('${TEXTAREA_SELECTOR}'); 29 const avatar = document.querySelector('img[src*="user-avatar"]'); 30 return { 31 url, 32 title, 33 hasTextarea: !!textarea, 34 isLoggedIn: !!avatar, 35 }; 36 })()`); 37 } 38 39 export async function selectModel(page, modelName) { 40 return page.evaluate(`(() => { 41 var radios = document.querySelectorAll('div[role="radio"]'); 42 if (radios.length === 0) return { ok: false }; 43 var isFirst = '${modelName}'.toLowerCase() === 'instant'; 44 if (!isFirst && radios.length < 2) return { ok: false }; 45 var target = isFirst ? radios[0] : radios[radios.length - 1]; 46 var alreadySelected = target.getAttribute('aria-checked') === 'true'; 47 if (!alreadySelected) target.click(); 48 return { ok: true, toggled: !alreadySelected }; 49 })()`); 50 } 51 52 export async function setFeature(page, featureName, enabled) { 53 // Match by position: DeepThink is the first toggle, Search is the second 54 var index = featureName === 'DeepThink' ? 0 : 1; 55 return page.evaluate(`(() => { 56 var toggles = Array.from(document.querySelectorAll('.ds-toggle-button')); 57 var btn = toggles[${index}]; 58 if (!btn) return { ok: false }; 59 var isActive = btn.classList.contains('ds-toggle-button--selected'); 60 if (${enabled} !== isActive) btn.click(); 61 return { ok: true, toggled: ${enabled} !== isActive }; 62 })()`); 63 } 64 65 export async function sendMessage(page, prompt) { 66 const promptJson = JSON.stringify(prompt); 67 return page.evaluate(`(async () => { 68 const box = document.querySelector('${TEXTAREA_SELECTOR}'); 69 if (!box) return { ok: false, reason: 'textarea not found' }; 70 71 box.focus(); 72 box.value = ''; 73 document.execCommand('selectAll'); 74 document.execCommand('insertText', false, ${promptJson}); 75 await new Promise(r => setTimeout(r, 800)); 76 77 const btns = document.querySelectorAll('div[role="button"]'); 78 for (const btn of btns) { 79 if (btn.getAttribute('aria-disabled') === 'false') { 80 const svgs = btn.querySelectorAll('svg'); 81 if (svgs.length > 0 && btn.closest('div')?.querySelector('textarea')) { 82 btn.click(); 83 return { ok: true }; 84 } 85 } 86 } 87 88 box.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true })); 89 return { ok: true, method: 'enter' }; 90 })()`); 91 } 92 93 export async function getBubbleCount(page) { 94 const count = await page.evaluate(`(() => { 95 return document.querySelectorAll('${MESSAGE_SELECTOR}').length; 96 })()`); 97 return count || 0; 98 } 99 100 // Parse thinking response using text as a fallback when DOM-level extraction 101 // is not available. Does NOT split on \n\n — that heuristic silently corrupts 102 // multi-paragraph thinking or multi-paragraph answers. Instead, everything 103 // after the header is treated as thinking content, and `response` stays empty 104 // until the caller provides a DOM-separated answer. 105 export function parseThinkingResponse(rawText) { 106 if (!rawText) return null; 107 108 // Match thinking header patterns: "Thought for X seconds" or "已思考(用时 X 秒)" 109 const thinkHeaderMatch = rawText.match(/^(Thought for ([\d.]+) seconds?|已思考(用时 ([\d.]+) 秒))\s*/); 110 111 if (!thinkHeaderMatch) { 112 // No thinking section found, return plain response 113 return { response: rawText, thinking: null, thinking_time: null }; 114 } 115 116 const thinkingTime = thinkHeaderMatch[2] || thinkHeaderMatch[3]; 117 const afterHeader = rawText.slice(thinkHeaderMatch[0].length); 118 119 // Treat everything after the header as thinking. The response will be 120 // populated by the DOM-level extraction in waitForResponse(). 121 return { 122 response: '', 123 thinking: afterHeader.trim(), 124 thinking_time: thinkingTime, 125 }; 126 } 127 128 export async function waitForResponse(page, baselineCount, prompt, timeoutMs, parseThinking = false) { 129 const startTime = Date.now(); 130 let lastText = ''; 131 let stableCount = 0; 132 133 while (Date.now() - startTime < timeoutMs) { 134 await page.wait(3); 135 136 let result; 137 try { 138 result = await page.evaluate(`(() => { 139 const bubbles = document.querySelectorAll('${MESSAGE_SELECTOR}'); 140 const texts = Array.from(bubbles).map(b => (b.innerText || '').trim()).filter(Boolean); 141 var last = texts[texts.length - 1] || ''; 142 143 // DOM-level thinking/response separation. 144 // DeepSeek renders thinking in a collapsible container with a 145 // distinct class (e.g. .ds-markdown--think or similar) and the 146 // final answer in the main .ds-markdown region. By querying 147 // these separately we avoid any text-heuristic split. 148 var thinkEl = null, answerEl = null, thinkTime = null; 149 if (${parseThinking} && bubbles.length > 0) { 150 var lastBubble = bubbles[bubbles.length - 1]; 151 // Thinking container — DeepSeek uses various class names; 152 // try common selectors. 153 thinkEl = lastBubble.querySelector('.ds-markdown--think') 154 || lastBubble.querySelector('[class*="think"]'); 155 // Final answer container — the main markdown block that is 156 // NOT the thinking section. 157 var markdownEls = lastBubble.querySelectorAll('.ds-markdown'); 158 for (var i = 0; i < markdownEls.length; i++) { 159 if (markdownEls[i] !== thinkEl 160 && !(thinkEl && thinkEl.contains(markdownEls[i])) 161 && !markdownEls[i].classList.contains('ds-markdown--think')) { 162 answerEl = markdownEls[i]; 163 } 164 } 165 // Thinking time from the toggle/header element 166 var timeEl = lastBubble.querySelector('[class*="think"] ~ *') 167 || lastBubble.querySelector('.ds-thinking-header'); 168 if (!timeEl) { 169 // Fallback: parse from raw text header 170 var m = last.match(/^(?:Thought for ([\\d.]+) seconds?|已思考(用时 ([\\d.]+) 秒))/); 171 if (m) thinkTime = m[1] || m[2]; 172 } else { 173 var tm = (timeEl.textContent || '').match(/([\\d.]+)/); 174 if (tm) thinkTime = tm[1]; 175 } 176 } 177 178 return { 179 count: texts.length, 180 last: last, 181 // DOM-separated fields (null when not available) 182 thinkText: thinkEl ? (thinkEl.innerText || '').trim() : null, 183 answerText: answerEl ? (answerEl.innerText || '').trim() : null, 184 thinkTime: thinkTime, 185 }; 186 })()`); 187 } catch { 188 continue; 189 } 190 191 if (!result) continue; 192 193 const candidate = result.last; 194 if (candidate && result.count > baselineCount && candidate !== prompt.trim()) { 195 if (candidate === lastText) { 196 stableCount++; 197 if (stableCount >= 3) { 198 if (parseThinking) { 199 // Prefer DOM-level separation 200 if (result.thinkText != null || result.answerText != null) { 201 return { 202 thinking: result.thinkText || '', 203 response: result.answerText || '', 204 thinking_time: result.thinkTime || null, 205 }; 206 } 207 // Fallback to text-header parsing (no \n\n split) 208 return parseThinkingResponse(candidate); 209 } 210 return candidate; 211 } 212 } else { 213 stableCount = 0; 214 } 215 lastText = candidate; 216 } 217 } 218 219 if (parseThinking && lastText) { 220 return parseThinkingResponse(lastText); 221 } 222 return lastText || null; 223 } 224 225 export async function getVisibleMessages(page) { 226 const result = await page.evaluate(`(() => { 227 const msgs = document.querySelectorAll('${MESSAGE_SELECTOR}'); 228 return Array.from(msgs).map(m => { 229 // User messages carry an extra hash-class alongside ds-message 230 const isUser = m.className.split(/\\s+/).length > 2; 231 return { 232 Role: isUser ? 'user' : 'assistant', 233 Text: (m.innerText || '').trim(), 234 }; 235 }).filter(m => m.Text); 236 })()`); 237 return Array.isArray(result) ? result : []; 238 } 239 240 export async function getConversationList(page) { 241 await ensureOnDeepSeek(page); 242 // Expand sidebar if collapsed 243 await page.evaluate(`(() => { 244 if (document.querySelectorAll('a[href*="/a/chat/s/"]').length === 0) { 245 const btn = document.querySelector('div[tabindex="0"][role="button"]'); 246 if (btn) btn.click(); 247 } 248 })()`); 249 for (let attempt = 0; attempt < 5; attempt++) { 250 await page.wait(2); 251 const items = await page.evaluate(`(() => { 252 const items = []; 253 const links = document.querySelectorAll('a[href*="/a/chat/s/"]'); 254 links.forEach((link, i) => { 255 const title = (link.innerText || '').trim().split('\\n')[0].trim(); 256 const href = link.getAttribute('href') || ''; 257 const idMatch = href.match(/\\/s\\/([a-f0-9-]+)/); 258 items.push({ 259 Index: i + 1, 260 Id: idMatch ? idMatch[1] : href, 261 Title: title || '(untitled)', 262 Url: 'https://chat.deepseek.com' + href, 263 }); 264 }); 265 return items; 266 })()`); 267 if (Array.isArray(items) && items.length > 0) return items; 268 } 269 return []; 270 } 271 272 async function waitForFilePreview(page, fileName) { 273 for (let attempt = 0; attempt < 8; attempt++) { 274 await page.wait(2); 275 const ready = await page.evaluate(`(() => { 276 const name = ${JSON.stringify(fileName)}; 277 return Array.from(document.querySelectorAll('div')) 278 .some((el) => el.children.length === 0 && (el.textContent || '').trim() === name); 279 })()`); 280 if (ready) return true; 281 } 282 return false; 283 } 284 285 export async function sendWithFile(page, filePath, prompt) { 286 const fs = await import('node:fs'); 287 const path = await import('node:path'); 288 const absPath = path.default.resolve(filePath); 289 290 if (!fs.default.existsSync(absPath)) { 291 return { ok: false, reason: `File not found: ${absPath}` }; 292 } 293 294 const stats = fs.default.statSync(absPath); 295 if (stats.size > 100 * 1024 * 1024) { 296 return { ok: false, reason: `File too large (${(stats.size / 1024 / 1024).toFixed(1)} MB). Max: 100 MB` }; 297 } 298 299 const fileName = path.default.basename(absPath); 300 301 // Collapse sidebar to keep DOM simple for send button matching 302 await page.evaluate(`(() => { 303 if (document.querySelectorAll('a[href*="/a/chat/s/"]').length > 0) { 304 const btn = document.querySelector('div[tabindex="0"][role="button"]'); 305 if (btn) btn.click(); 306 } 307 })()`); 308 await page.wait(0.5); 309 310 let uploaded = false; 311 if (page.setFileInput) { 312 try { 313 await page.setFileInput([absPath], 'input[type="file"]'); 314 uploaded = true; 315 } catch (err) { 316 const msg = String(err?.message || err); 317 if (!msg.includes('Unknown action') && !msg.includes('not supported')) { 318 throw err; 319 } 320 } 321 } 322 323 if (!uploaded) { 324 const content = fs.default.readFileSync(absPath); 325 const base64 = content.toString('base64'); 326 const fallbackResult = await page.evaluate(`(async () => { 327 var binary = atob('${base64}'); 328 var bytes = new Uint8Array(binary.length); 329 for (var i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); 330 331 var file = new File([bytes], ${JSON.stringify(fileName)}); 332 var dt = new DataTransfer(); 333 dt.items.add(file); 334 335 var inp = document.querySelector('input[type="file"]'); 336 if (!inp) return { ok: false, reason: 'file input not found' }; 337 338 var propsKey = Object.keys(inp).find(function(k) { return k.startsWith('__reactProps$'); }); 339 if (!propsKey || typeof inp[propsKey].onChange !== 'function') { 340 return { ok: false, reason: 'React onChange not found' }; 341 } 342 343 inp.files = dt.files; 344 inp[propsKey].onChange({ target: { files: dt.files } }); 345 return { ok: true }; 346 })()`); 347 if (fallbackResult && !fallbackResult.ok) return fallbackResult; 348 } 349 350 const ready = await waitForFilePreview(page, fileName); 351 if (!ready) return { ok: false, reason: 'file preview did not appear' }; 352 353 return sendMessage(page, prompt); 354 } 355 356 // Retries on CDP "Promise was collected" errors caused by DeepSeek's SPA router transitions. 357 export async function withRetry(fn, retries = 2) { 358 for (let i = 0; i <= retries; i++) { 359 try { 360 return await fn(); 361 } catch (err) { 362 const msg = String(err?.message || err); 363 if (i < retries && msg.includes('Promise was collected')) { 364 await new Promise(r => setTimeout(r, 2000)); 365 continue; 366 } 367 throw err; 368 } 369 } 370 } 371 372 export function parseBoolFlag(value) { 373 if (typeof value === 'boolean') return value; 374 return String(value ?? '').trim().toLowerCase() === 'true'; 375 }