/ clis / deepseek / utils.js
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  }