// Управление вкладкой DeepSeek через Chrome DevTools Protocol. // // Перенесено из ask.mjs проекта DeepShim (git.08h.ru/alex/deepshim), чтобы // сервис был самодостаточным: там это часть большого инструмента с веб-пультом // и разбором патчей, здесь нужен только чат. Всё, что удалось выяснить про // разметку DeepSeek, сохранено вместе с комментариями — это знание дороже кода. // // Зависимостей нет: Node 22+ со встроенными fetch и WebSocket. import { readFile, writeFile } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import { dirname, join, resolve as resolvePath } from 'node:path'; import { existsSync } from 'node:fs'; import { spawn } from 'node:child_process'; const HERE = dirname(fileURLToPath(import.meta.url)); const CHATS_FILE = join(HERE, 'chats.json'); const CHAT_HOST = 'chat.deepseek.com'; // Настройки браузера приходят снаружи (из config.json сервиса) — модуль // не лезет в чужие файлы и не знает, где живёт вызывающий. let PORT = 9333; let BROWSER = { exe: '', profileDir: '', url: `https://${CHAT_HOST}/` }; export function configure({ cdpPort, browserExe, profileDir, chatUrl }) { if (cdpPort) PORT = Number(cdpPort); if (browserExe !== undefined) BROWSER.exe = browserExe; if (profileDir !== undefined) BROWSER.profileDir = profileDir; if (chatUrl) BROWSER.url = chatUrl; } // Роли — это отдельные чаты под разные режимы. Режим выбирается при СОЗДАНИИ // чата и потом не меняется: тумблеры исчезают после первого сообщения. export const ROLES = ['think', 'search', 'vision']; // Роль → id чата из chats.json. Файл — рабочее состояние, а не настройка: // он заводится сам и переписывается при каждой смене чата. async function chatsMap() { try { return JSON.parse(await readFile(CHATS_FILE, 'utf8')); } catch { return {}; } } // ------------------------------------------------------------------ CDP async function findChatTab() { let targets; try { const res = await fetch(`http://127.0.0.1:${PORT}/json`, { signal: AbortSignal.timeout(3000) }); targets = await res.json(); } catch { throw new Error( `Браузер с отладочным портом ${PORT} не найден. Обычно его поднимает сам ` + 'сервис; если этого не произошло, проверьте browserExe и profileDir ' + 'в config.json.'); } const pages = targets.filter(t => t.type === 'page'); const chat = pages.find(t => String(t.url).includes(CHAT_HOST)); if (!chat) { throw new Error( `Вкладка ${CHAT_HOST} не открыта. Открыто:\n` + pages.map(p => ' ' + p.url).join('\n')); } return chat; } // Сколько ждать ОДИН вызов протокола. Обычные вызовы — чтение DOM, миллисекунды; // самый долгий, Page.captureScreenshot на тяжёлой странице, укладывается в пару // секунд. Минута — потолок, за которым уже не «медленно», а «не ответит никогда». const CDP_CALL_TIMEOUT = 60; const CDP_CONNECT_TIMEOUT = 10; class Session { #ws; #id = 1; #pending = new Map(); #dead = null; static connect(wsUrl) { return new Promise((resolve, reject) => { const ws = new WebSocket(wsUrl); const s = new Session(); s.#ws = ws; const giveUp = setTimeout( () => reject(new Error(`вкладка не отозвалась за ${CDP_CONNECT_TIMEOUT} с`)), CDP_CONNECT_TIMEOUT * 1000); ws.addEventListener('open', () => { clearTimeout(giveUp); resolve(s); }, { once: true }); ws.addEventListener('error', e => { clearTimeout(giveUp); const err = new Error(`WebSocket: ${e?.message ?? e}`); s.#fail(err); reject(err); }, { once: true }); // Вкладку закрыли — все висящие вызовы обязаны отвалиться. Без этого // обработчика они не разрешались НИКОГДА: запрос висел вечно, очередь // за ним стояла, а сервис при этом выглядел живым и молчал. ws.addEventListener('close', () => { clearTimeout(giveUp); s.#fail(new Error('вкладка DeepSeek закрылась во время работы')); }, { once: true }); ws.addEventListener('message', ev => { let m; try { m = JSON.parse(ev.data); } catch { return; } if (m.id === undefined) return; const p = s.#pending.get(m.id); if (!p) return; s.#pending.delete(m.id); if (m.error) p.reject(new Error(m.error.message)); else p.resolve(m.result); }); }); } // Сокет умер: разрешить всё, что ждёт, и запомнить причину — следующие // вызовы должны падать сразу, а не копиться на мёртвом сокете. #fail(err) { this.#dead = err; for (const p of this.#pending.values()) p.reject(err); this.#pending.clear(); } send(method, params = {}) { if (this.#dead) return Promise.reject(this.#dead); const id = this.#id++; return new Promise((resolve, reject) => { const giveUp = setTimeout(() => { this.#pending.delete(id); reject(new Error(`вкладка не ответила на ${method} за ${CDP_CALL_TIMEOUT} с`)); }, CDP_CALL_TIMEOUT * 1000); this.#pending.set(id, { resolve: v => { clearTimeout(giveUp); resolve(v); }, reject: e => { clearTimeout(giveUp); reject(e); }, }); try { this.#ws.send(JSON.stringify({ id, method, params })); } catch (e) { clearTimeout(giveUp); this.#pending.delete(id); reject(new Error(`не удалось отправить ${method}: ${e.message}`)); } }); } async eval(expression, awaitPromise = true) { const r = await this.send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise, userGesture: true, }); if (r.exceptionDetails) { throw new Error(`JS на странице: ${r.exceptionDetails.exception?.description ?? r.exceptionDetails.text}`); } return r.result?.value; } close() { try { this.#ws.close(); } catch { } } } async function withChat(fn) { const tab = await findChatTab(); const s = await Session.connect(tab.webSocketDebuggerUrl); try { return await fn(s, tab); } finally { s.close(); } } // -------------------------------------------------- код, живущий на странице // Поле ввода: у DeepSeek это textarea, но разметка меняется — ищем устойчиво. const PAGE_HELPERS = ` function __visible(el) { const r = el.getBoundingClientRect(); const cs = getComputedStyle(el); return r.width > 0 && r.height > 0 && cs.visibility !== 'hidden' && cs.display !== 'none'; } function __input() { const cands = [ ...document.querySelectorAll('textarea'), ...document.querySelectorAll('[contenteditable="true"]') ].filter(__visible); if (cands.length === 0) return null; // самое нижнее видимое поле — это строка ввода чата return cands.sort((a, b) => b.getBoundingClientRect().top - a.getBoundingClientRect().top)[0]; } function __answerNodes() { const selectors = ['.ds-markdown', '[class*="markdown"]', '[data-message-id]', '[class*="message-content"]']; for (const sel of selectors) { const found = [...document.querySelectorAll(sel)].filter(__visible); if (found.length) return { sel, nodes: found }; } return { sel: null, nodes: [] }; } function __toggles() { // В диалоге подписи пропадают и остаются иконки, поэтому опора на класс тумблера, // а текст/подсказка — лишь для имени. return [...document.querySelectorAll('div[role="button"], button, [class*="toggle-button"]')] .filter(__visible) .filter(e => { const c = e.getAttribute('class') || ''; if (/toggle-button__/.test(c)) return false; // вложенные иконки, не сами тумблеры return /ds-toggle-button/.test(c) || /^(DeepThink|Search)$/i.test((e.innerText || '').trim()); }) .map(e => { const cls = e.getAttribute('class') || ''; return { name: (e.innerText || '').trim() || e.getAttribute('aria-label') || e.getAttribute('title') || (e.querySelector('[aria-label]')?.getAttribute('aria-label')) || '(без подписи)', // активный тумблер помечается доп. классом либо aria-атрибутом on: /active|checked|selected|--on\\b/i.test(cls) || e.getAttribute('aria-pressed') === 'true' || e.getAttribute('aria-checked') === 'true', cls }; }); } function __clickToggle(name) { // Ищем среди тех же элементов, что показывает __toggles: тумблер — это div // с классом ds-toggle-button, а не button с role. const el = [...document.querySelectorAll('div[role="button"], button, [class*="toggle-button"]')] .filter(__visible) .filter(e => !/toggle-button__/.test(e.getAttribute('class') || '')) .find(e => (e.innerText || '').trim().toLowerCase() === name.toLowerCase()); if (!el) return { ok: false, reason: 'кнопка ' + name + ' не найдена' }; el.click(); return { ok: true }; } // Режим (Search / DeepThink) задаётся при СОЗДАНИИ чата и потом не меняется: // тумблеры пропадают, как только в чате появилось первое сообщение. Поэтому // работа идёт через переключение между заранее созданными чатами в сайдбаре. function __chatItems() { const byHref = [...document.querySelectorAll('a[href*="/chat/s/"]')].filter(__visible); if (byHref.length) { return { how: 'ссылки', items: byHref.map(e => ({ title: (e.innerText || '').replace(/\\s+/g, ' ').trim().slice(0, 80), href: e.getAttribute('href'), current: location.href.includes((e.getAttribute('href') || '').split('/').pop()) })) }; } // Сайдбар без : берём кликабельные строки с текстом из левой колонки. const rows = [...document.querySelectorAll('div[class*="chat"], div[class*="history"], li')] .filter(__visible) .filter(e => e.getBoundingClientRect().left < 320 && e.getBoundingClientRect().width < 340) .filter(e => { const t = (e.innerText || '').trim(); return t && t.length < 120; }); const uniq = rows.filter(e => !rows.some(o => o !== e && o.contains(e))); return { how: 'сайдбар', items: uniq.map(e => ({ title: (e.innerText || '').replace(/\\s+/g, ' ').trim().slice(0, 80), href: null, current: false })) }; } function __openChat(needle) { const low = String(needle).toLowerCase(); const cand = [...document.querySelectorAll('a[href*="/chat/s/"]')].filter(__visible) .find(e => (e.innerText || '').toLowerCase().includes(low) || (e.getAttribute('href') || '').toLowerCase().includes(low)); if (cand) { cand.click(); return { ok: true, title: (cand.innerText || '').trim() }; } const rows = __chatItems().items; const idx = rows.findIndex(r => r.title.toLowerCase().includes(low)); if (idx === -1) return { ok: false, reason: 'чат не найден: ' + needle }; const all = [...document.querySelectorAll('div[class*="chat"], div[class*="history"], li')] .filter(__visible).filter(e => (e.innerText || '').replace(/\\s+/g, ' ').trim().slice(0, 80) === rows[idx].title); if (!all.length) return { ok: false, reason: 'не удалось кликнуть по чату' }; all[0].click(); return { ok: true, title: rows[idx].title }; } // Индикатор выбранной модели рядом с полем ввода: Instant / DeepThink / Search. // Это единственный надёжный признак — классы тумблеров врут, а подпись показывает, // чем чат будет отвечать на самом деле. function __modelLabel() { // Выбор модели — это radiogroup: div[data-model-type=default|expert|vision] // с aria-checked. Это точный источник, подписи и классы вторичны. const radios = [...document.querySelectorAll('[data-model-type][role="radio"]')]; if (radios.length) { const on = radios.find(r => r.getAttribute('aria-checked') === 'true'); return on ? [on.getAttribute('data-model-type')] : []; } // Чат уже начат: radiogroup спрятан, остаётся только подпись рядом с полем ввода. const known = /^(instant|expert|vision)$/i; return [...document.querySelectorAll('span, div')] .filter(__visible) .filter(e => !e.children.length && known.test((e.innerText || '').trim())) .filter(e => !e.closest('[class*="ds-toggle-button"]')) .map(e => (e.innerText || '').trim().toLowerCase()); } // Только координаты: сам клик — настоящими мышиными событиями снаружи. // Причина найдена замером 26.08.2026: btn.click() из DOM React глотает молча, // адрес не менялся, а __newChat отчитывался успехом. Дальше makeChat считывал // СТАРЫЙ id, считал его новым, ничего не удалял, и вопрос уходил в тот же чат. // Наружу это выглядело как «ротация не работает». function __newChatBox() { const btn = [...document.querySelectorAll('div, button, a')].filter(__visible) .find(e => /^new chat$/i.test((e.innerText || '').trim())); if (!btn) return null; const r = btn.getBoundingClientRect(); return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; } // Список сообщений виртуализован; единственный data-атрибут на странице — // data-virtual-list-item-key. Ответ отличается от вопроса тем, что внутри // него есть .ds-markdown. // // За номер ключа цепляться НЕЛЬЗЯ: после каждой правки список // перенумеровывается (было 1/2, стало 3/4), а невидимых элементов в DOM // вообще нет — виртуализация их выбрасывает. Берём самый ранний из // отрисованных вопросов. После правки в чате остаётся ровно один обмен, // поэтому такой вопрос там один. function __firstMsg() { const items = [...document.querySelectorAll('[data-virtual-list-item-key]')] .map(e => ({ e, k: Number(e.getAttribute('data-virtual-list-item-key')) })) .filter(o => Number.isFinite(o.k) && !o.e.querySelector('.ds-markdown')) .sort((a, b) => a.k - b.k); return items.length ? items[0].e : null; } // Кнопки под сообщением (копировать, редактировать). Появляются по наведению // и прячутся, когда курсор уходит вниз к ним, поэтому жать только из DOM. function __msgButtons() { const it = __firstMsg(); if (!it) return []; return [...it.querySelectorAll('[role="button"], button')].filter(__visible); } function __clickEdit() { const b = __msgButtons(); if (b.length < 2) return { ok: false, reason: 'кнопок под сообщением ' + b.length }; b[1].click(); // вторая — редактирование, первая — копирование return { ok: true }; } // Поле правки — любая видимая textarea ВЫШЕ поля ввода (оно всегда самое нижнее). function __editArea() { const all = [...document.querySelectorAll('textarea')].filter(__visible) .sort((a, b) => a.getBoundingClientRect().top - b.getBoundingClientRect().top); return all.length > 1 ? all[0] : null; } function __setEditText(value) { const el = __editArea(); if (!el) return { ok: false, reason: 'поле правки не открылось' }; el.focus(); const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set; setter.call(el, value); el.dispatchEvent(new Event('input', { bubbles: true })); return { ok: true, len: el.value.length }; } // Все чаты сайдбара. Адресоваться только по id: порядок меняется — тот, // куда писали последним, встаёт первым, поэтому индексы протухают между шагами. function __chatIds() { return [...document.querySelectorAll('a[href^="/a/chat/s/"]')] .filter(__visible) .map(e => ({ id: e.getAttribute('href').split('/').pop(), title: (e.innerText || '').trim().slice(0, 40) })); } // Куда навести мышь, чтобы у чата проявились три точки. function __chatRowBox(id) { const a = [...document.querySelectorAll('a[href^="/a/chat/s/"]')] .find(e => e.getAttribute('href').endsWith(id)); if (!a) return null; a.scrollIntoView({ block: 'center' }); const r = a.getBoundingClientRect(); return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), top: r.y, bottom: r.y + r.height }; } // Кнопка "..." ИМЕННО этой строки: её центр по вертикали лежит внутри строки. // Брать самую правую видимую кнопку нельзя — у активного чата точки видны // всегда, и однажды так удалился рабочий чат вместо мусорного. function __dotsBox(top, bottom) { const d = [...document.querySelectorAll('[role="button"], button')] .filter(__visible) .map(e => ({ r: e.getBoundingClientRect() })) .filter(o => o.r.left < 340 && o.r.width < 60 && o.r.height < 60) .filter(o => { const cy = o.r.y + o.r.height / 2; return cy >= top && cy <= bottom; }) .sort((p, q) => q.r.x - p.r.x)[0]; return d ? { x: Math.round(d.r.x + d.r.width / 2), y: Math.round(d.r.y + d.r.height / 2) } : null; } function __menuItemBox(re) { for (const e of document.querySelectorAll('div,span,li,button')) { if (e.children.length || !__visible(e)) continue; if (!re.test((e.innerText || '').trim())) continue; const r = e.getBoundingClientRect(); return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; } return null; } // Кнопка подтверждения в диалоге. Подпись лежит во вложенном элементе, // поэтому фильтр «элемент без детей» её не находит — берём самый мелкий. function __confirmBox() { const box = [...document.querySelectorAll('[class*="modal"],[class*="dialog"],[role="dialog"],[class*="confirm"]')] .filter(__visible).pop(); if (!box) return null; return [...box.querySelectorAll('*')] .filter(e => __visible(e) && /^(delete chat|delete|удалить)$/i.test((e.innerText || '').trim())) .map(e => { const r = e.getBoundingClientRect(); return { area: r.width * r.height, x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; }) .sort((a, b) => a.area - b.area)[0] || null; } function __chatId() { const m = location.href.match(/\\/chat\\/s\\/([0-9a-f-]+)/i); return m ? m[1] : null; } // Кнопка отправки. Подписи и aria у неё нет, но класс-модификатор устойчив: // ds-button--primary есть только у неё (проверено 06.08.2026 — на странице // ровно один такой элемент). Геометрия оставлена запасным путём на случай, // если вёрстка поедет. function __sendButton() { const byClass = [...document.querySelectorAll('[role="button"], button')] .filter(__visible) .filter(e => /\\bds-button--primary\\b/.test(e.getAttribute('class') || '')); if (byClass.length) return byClass[byClass.length - 1]; const ta = __input(); if (!ta) return null; const tr = ta.getBoundingClientRect(); return [...document.querySelectorAll('div[role="button"], button')] .filter(__visible) .filter(e => /ds-button/.test(e.getAttribute('class') || '')) .filter(e => { const r = e.getBoundingClientRect(); return r.width < 90 && r.height < 90 && r.y > tr.y && r.x > tr.x + tr.width - 260; }) .sort((a, b) => b.getBoundingClientRect().x - a.getBoundingClientRect().x)[0] || null; } function __sendBox() { const el = __sendButton(); if (!el) return null; const r = el.getBoundingClientRect(); return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; } // Идёт ли генерация. Пока DeepSeek печатает, та же кнопка превращается в // «стоп» и остаётся активной, ХОТЯ поле ввода уже пустое. В покое при пустом // поле она помечена ds-button--disabled. Отсюда признак: // пусто + кнопка активна = печатает // пусто + кнопка погашена = готово // Замерено 06.08.2026: класс --disabled пропал на 3-й секунде ответа и // вернулся на 42-й, ровно когда текст перестал расти. function __isGenerating() { const b = __sendButton(); if (!b) return false; const off = /ds-button--disabled/.test(b.getAttribute('class') || ''); return __inputEmpty() && !off; } // Пусто ли поле ввода — единственный честный признак, что сообщение ушло. function __inputEmpty() { const el = __input(); if (!el) return true; const v = el.tagName.toLowerCase() === 'textarea' ? el.value : el.innerText; return !String(v || '').trim(); } // В innerText попадает служебная обвязка страницы: у каждого блока кода — // подписи кнопок "Copy" и "Download" отдельными строками, у ответов поискового // чата — номера сносок ("5", "8" и голые дефисы между ними). Всё это ломает // код при вставке и раздувает ответ. Чистим построчно: трогаем только строки, // целиком состоящие из мусора, поэтому содержимое кода не страдает. function __cleanAnswer(raw) { const lines = String(raw || '').split('\\n'); const out = []; for (let i = 0; i < lines.length; i++) { const t = lines[i].trim(); if (/^(Copy|Download|Копировать|Скачать)$/i.test(t)) continue; // Сноски поискового ответа: номера источников и дефисы-разделители едут // отдельными строками («6», «12», «-») и рвут текст на середине фразы. // Убираем только строку ЦЕЛИКОМ из номера или дефиса — числа внутри // предложений и нумерованные списки («1.») это не задевает. if (/^\\d{1,3}$/.test(t) || /^[-–—.]$/.test(t)) { const prev = (out[out.length - 1] || '').trim(); const next = (lines[i + 1] || '').trim(); // Строка-одиночка среди текста — сноска. Если вокруг пусто, значит это // самостоятельная строка ответа, её не трогаем. if (prev || next) continue; } out.push(lines[i]); } return out.join('\\n').replace(/\\n{3,}/g, '\\n\\n').trim(); } function __lastAnswer() { const { nodes } = __answerNodes(); if (!nodes.length) return null; return __cleanAnswer(nodes[nodes.length - 1].innerText); } function __answerCount() { return __answerNodes().nodes.length; } // Кнопка Continue под ответом: DeepSeek обрывает длинный ответ и предлагает // дописать. Текст продолжения уходит В ТОТ ЖЕ блок ответа (проверено // 11.08.2026: 32 073 → 46 422 символов, число блоков не изменилось), поэтому // склеивать ничего не нужно — достаточно нажать и ждать дальше. // Искать только среди КНОПОК. Поиск по тексту где угодно ловит ключевое слово // continue внутри блока кода: подсветка оборачивает его в отдельный span, и // текст такого span равен ровно "continue". Проверено 11.08.2026 — мост // десять раз подряд кликал по слову в коде вместо кнопки и упирался в предел. // Ответ с кодом на JavaScript почти всегда содержит такое слово, так что это // не редкий случай, а обычный. // Селектор строго '.ds-button': маска [class*="ds-button"] цепляет вложенный // ds-button__content, и клик уходит мимо — обработчик висит на самой кнопке. // Кнопка Retry: DeepSeek иногда отвечает «Server is busy. Try again later, or // use Instant Mode.» вместо ответа и рисует круглую оранжевую кнопку ↻ слева // от сообщения. Подписи у неё нет, зато модификатор ds-button--warning на // странице только у неё (проверено 12.08.2026). Ждать в такой ситуации // бессмысленно: генерация не идёт, и мост честно упирался в таймаут. function __retryBox() { const busy = /server is busy|сервер занят|try again later/i.test(document.body.innerText || ''); const btn = [...document.querySelectorAll('[role="button"], button')] .filter(__visible) .find(e => /\\bds-button--warning\\b/.test(e.getAttribute('class') || '')); if (!btn || !busy) return null; const r = btn.getBoundingClientRect(); return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; } function __continueBox() { const btn = [...document.querySelectorAll('.ds-button')] .filter(__visible) .filter(e => !e.closest('.ds-markdown')) // не из текста ответа .find(e => /^(continue|продолжить)$/i.test((e.innerText || '').trim())); if (!btn) return null; btn.scrollIntoView({ block: 'center' }); const r = btn.getBoundingClientRect(); return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; } `; // Вставка текста в React-поле: нативный сеттер + событие input, // иначе React не заметит изменения и отправит пустое сообщение. const SET_TEXT = (text) => `(() => { ${PAGE_HELPERS} const el = __input(); if (!el) return { ok: false, reason: 'поле ввода не найдено' }; const value = ${JSON.stringify(text)}; el.focus(); if (el.tagName.toLowerCase() === 'textarea') { const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set; setter.call(el, value); el.dispatchEvent(new Event('input', { bubbles: true })); } else { el.textContent = value; el.dispatchEvent(new InputEvent('input', { bubbles: true, data: value })); } // textBefore снимается ДО отправки: по нему waitForAnswer поймёт, что ответ // сменился, даже если новый успел напечататься раньше первого замера. return { ok: true, tag: el.tagName.toLowerCase(), before: __answerCount(), textBefore: __lastAnswer() }; })()`; async function pressEnter(s) { const base = { type: 'keyDown', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13 }; await s.send('Input.dispatchKeyEvent', { ...base, text: '\r' }); await s.send('Input.dispatchKeyEvent', { ...base, type: 'keyUp' }); } const sleep = ms => new Promise(r => setTimeout(r, ms)); async function mouseClickAt(s, box) { const p = { x: box.x, y: box.y, button: 'left', clickCount: 1 }; await s.send('Input.dispatchMouseEvent', { ...p, type: 'mouseMoved', button: 'none', clickCount: 0 }); await sleep(120); await s.send('Input.dispatchMouseEvent', { ...p, type: 'mousePressed' }); await s.send('Input.dispatchMouseEvent', { ...p, type: 'mouseReleased' }); } // Enter срабатывает не всегда: текст остаётся висеть в поле, а мы уходим ждать // ответ, которого не будет. Поэтому отправку ПОДТВЕРЖДАЕМ — поле должно опустеть, // иначе жмём саму кнопку настоящим мышиным кликом. async function sendAndConfirm(s, log) { const empty = () => s.eval(`(() => { ${PAGE_HELPERS} return __inputEmpty(); })()`); const ways = [ async () => { await pressEnter(s); return 'Enter'; }, // DOM-вызов click() на самой кнопке. Оказался надёжнее всего: срабатывает // и тогда, когда Enter и настоящий мышиный клик молчат — например, если // окно браузера свёрнуто или неактивно и события ввода до страницы не // доходят. Проверено 06.08.2026: поле опустело за 500 мс после того, как // оба других способа подряд не сработали. async () => { const r = await s.eval(`(() => { ${PAGE_HELPERS} const b = __sendButton(); if (!b) return false; b.click(); return true; })()`); return r ? 'DOM-клик по кнопке отправки' : null; }, async () => { const box = await s.eval(`(() => { ${PAGE_HELPERS} return __sendBox(); })()`); if (!box) return null; await mouseClickAt(s, box); return 'мышиный клик по кнопке отправки'; }, async () => { await pressEnter(s); return 'Enter (повтор)'; }, ]; for (const way of ways) { const how = await way(); if (!how) continue; for (let i = 0; i < 12; i++) { // до ~3 с на опустошение поля await sleep(250); if (await empty()) { log(`отправлено: ${how}`); return; } } log(`${how} не сработал — поле всё ещё занято, пробую иначе`); } throw new Error( 'Сообщение не ушло: текст остался в поле ввода.\n' + 'Посмотрите вкладку — возможна капча, лимит сообщений или требуется вход.'); } // Задать вопрос ПРАВКОЙ первого сообщения вместо отправки нового. // Чат остаётся из одного обмена: контекст не растёт, мусор не копится, // приставка из seed-prompt.md уезжает вместе с каждым вопросом. async function editFirstAndSend(s, text, log) { const box = await s.eval(`(() => { ${PAGE_HELPERS} const it = __firstMsg(); if (!it) return null; it.scrollIntoView({ block: 'center' }); const r = it.getBoundingClientRect(); return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; })()`); if (!box) throw new Error('первое сообщение чата не найдено (нет [data-virtual-list-item-key="1"])'); // наведение обязательно: без него кнопок под сообщением в DOM нет await s.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: box.x, y: box.y, button: 'none', clickCount: 0 }); await sleep(700); const ed = await s.eval(`(() => { ${PAGE_HELPERS} return __clickEdit(); })()`); if (!ed.ok) throw new Error('не удалось открыть правку: ' + ed.reason); await sleep(900); const set = await s.eval(`(() => { ${PAGE_HELPERS} return __setEditText(${JSON.stringify(text)}); })()`); if (!set.ok) throw new Error(set.reason); log(`правка первого сообщения: ${set.len} символов`); await sleep(400); const state = await s.eval(`(() => { ${PAGE_HELPERS} return { before: __answerCount(), textBefore: __lastAnswer() }; })()`); await pressEnter(s); for (let i = 0; i < 16; i++) { await sleep(300); const gone = await s.eval(`(() => { ${PAGE_HELPERS} return !__editArea(); })()`); if (gone) { log('отправлено правкой'); return state; } } throw new Error('правка не отправилась: поле правки всё ещё открыто'); } // Открыть чат по id из chats.json — переходом по адресу, а не кликом в сайдбаре: // названия чатов DeepSeek меняет сам, а id из URL постоянен. async function openChatById(s, id, log) { const cur = await s.eval(`(() => { ${PAGE_HELPERS} return __chatId(); })()`); if (cur === id) { log(`чат: уже открыт (${id})`); return; } await s.send('Page.enable').catch(() => { }); await s.send('Page.navigate', { url: `https://${CHAT_HOST}/a/chat/s/${id}` }); for (let i = 0; i < 40; i++) { // до ~20 с на загрузку SPA await sleep(500); const ok = await s.eval(`(() => { ${PAGE_HELPERS} return __chatId() === ${JSON.stringify(id)} && !!__input(); })()`).catch(() => false); if (ok) { await sleep(800); log(`чат: ${id}`); return; } } throw new Error(`не удалось открыть чат ${id} — страница не загрузилась.`); } // Настоящий клик мышью по координатам: el.click() у DeepSeek только красит // тумблер, но модель чата не меняет — проверяется по индикатору Instant/DeepThink. async function realClick(s, name) { const box = await s.eval(`(() => { ${PAGE_HELPERS} const el = [...document.querySelectorAll('div[role="button"], button, [class*="toggle-button"]')] .filter(__visible) .filter(e => !/toggle-button__/.test(e.getAttribute('class') || '')) .find(e => (e.innerText || '').trim().toLowerCase() === ${JSON.stringify(name)}.toLowerCase()); if (!el) return null; const r = el.getBoundingClientRect(); return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; })()`); if (!box) return false; const p = { x: box.x, y: box.y, button: 'left', clickCount: 1 }; await s.send('Input.dispatchMouseEvent', { ...p, type: 'mouseMoved', button: 'none', clickCount: 0 }); await sleep(120); await s.send('Input.dispatchMouseEvent', { ...p, type: 'mousePressed' }); await s.send('Input.dispatchMouseEvent', { ...p, type: 'mouseReleased' }); await sleep(600); return true; } // Клик мышью по видимому элементу с ТОЧНЫМ текстом (пункт меню моделей). async function clickExactText(s, text) { const box = await s.eval(`(() => { ${PAGE_HELPERS} const el = [...document.querySelectorAll('div, span, li, button')].filter(__visible) .filter(e => (e.innerText || '').trim() === ${JSON.stringify(text)}) .sort((a, b) => a.getBoundingClientRect().width - b.getBoundingClientRect().width)[0]; if (!el) return null; const r = el.getBoundingClientRect(); return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; })()`); if (!box) return false; const p = { x: box.x, y: box.y, button: 'left', clickCount: 1 }; await s.send('Input.dispatchMouseEvent', { ...p, type: 'mouseMoved', button: 'none', clickCount: 0 }); await sleep(120); await s.send('Input.dispatchMouseEvent', { ...p, type: 'mousePressed' }); await s.send('Input.dispatchMouseEvent', { ...p, type: 'mouseReleased' }); await sleep(700); return true; } // У DeepSeek два независимых механизма: выпадающий выбор МОДЕЛИ // (Instant / Expert / Vision) и тумблеры DeepThink / Search поверх неё. async function ensureModel(s, wantModel, wantToggle, log) { const read = async () => (await s.eval(`(() => { ${PAGE_HELPERS} return __modelLabel(); })()`))[0] || '?'; for (let attempt = 1; attempt <= 3; attempt++) { const cur = await read(); if (cur.toLowerCase() === wantModel.toLowerCase()) break; if (attempt === 3) { throw new Error(`не удалось выбрать модель ${wantModel} — выбрана ${cur}.`); } const box = await s.eval(`(() => { ${PAGE_HELPERS} const el = document.querySelector('[data-model-type="${wantModel}"][role="radio"]'); if (!el || !__visible(el)) return null; const r = el.getBoundingClientRect(); return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; })()`); if (!box) throw new Error(`переключатель модели ${wantModel} не найден (чат уже начат?)`); const p = { x: box.x, y: box.y, button: 'left', clickCount: 1 }; await s.send('Input.dispatchMouseEvent', { ...p, type: 'mouseMoved', button: 'none', clickCount: 0 }); await sleep(120); await s.send('Input.dispatchMouseEvent', { ...p, type: 'mousePressed' }); await s.send('Input.dispatchMouseEvent', { ...p, type: 'mouseReleased' }); await sleep(700); log(`выбор модели: попытка ${attempt} → ${await read()}`); } if (wantToggle) { const before = await s.eval(`(() => { ${PAGE_HELPERS} return __toggles(); })()`); const t = before.find(x => x.name.toLowerCase() === wantToggle.toLowerCase()); if (t && !t.on) { await realClick(s, wantToggle); const after = await s.eval(`(() => { ${PAGE_HELPERS} return __toggles(); })()`); const now = after.find(x => x.name.toLowerCase() === wantToggle.toLowerCase()); log(`тумблер ${wantToggle}: ${now?.on ? 'включён' : 'НЕ включился'}`); } else if (t?.on) { log(`тумблер ${wantToggle}: уже включён`); } } const model = await read(); const toggles = await s.eval(`(() => { ${PAGE_HELPERS} return __toggles(); })()`); const on = toggles.filter(x => x.on).map(x => x.name); return on.length ? `${model}+${on.join('+')}` : model; } // Ответ считается готовым, когда его текст перестал меняться. // Ответ считается готовым, только когда страница ПЕРЕСТАЛА печатать. // Раньше признаком был «текст не менялся 2,5 с», и это врало: в режиме expert // DeepSeek думает паузами, любая заминка длиннее порога выглядела как конец. // Замерено 06.08.2026 на одном ответе: наружу ушло 640 символов, тогда как // ответ дорос до 4135 — терялось шесть седьмых текста вместе со всем кодом, // причём молча. Теперь ждём, пока __isGenerating() не станет false. // stallSec — НЕ общий срок на ответ, а максимум БЕЗДЕЙСТВИЯ. Пока страница // печатает или текст растёт, ждём сколько угодно: длинный ответ на 13 КБ // дописывался 240 с и старый общий таймаут рубил его на живом месте. Срок // отсчитывается от последнего признака жизни, поэтому срабатывает только // там, где и должен — капча, разлогин, зависшая вкладка, пустой экран. // Ответ дописан — но в DOM его ещё нет целиком. Блоки кода DeepSeek рисует // лениво, а браузер работает В ФОНЕ: окно неактивно, страница не // отрисовывается, и содержимое блоков в DOM просто не создаётся. От него // остаются первые две строки-заглушки, и никакой textContent остального не // достанет — данных нет. // // Прокрутка не помогает (проверено: проход по всему ответу шагами не дал ни // одного лишнего символа). Помогает единственное — заставить браузер // отрисовать страницу, а через CDP это делает Page.captureScreenshot. // Замерено 11.08.2026 на одном ответе: 3136 символов до съёмки, 30 817 после. // Картинка не нужна и выбрасывается, поэтому берём самую дешёвую — jpeg // с качеством 1. async function revealAnswer(s, current, log) { let best = current || ''; for (let i = 0; i < 3; i++) { try { await s.send('Page.captureScreenshot', { format: 'jpeg', quality: 1 }); } catch { break; } // отрисовка недоступна — отдаём что есть await sleep(350); const full = await s.eval(`(() => { ${PAGE_HELPERS} return __lastAnswer(); })()`); if (!full || full.length <= best.length) break; // больше не растёт if (log) log(`после отрисовки ответ вырос: ${best.length} → ${full.length} символов`); best = full; } return best; } async function waitForAnswer(s, countBefore, stallSec, log, textBefore = null) { const STABLE_MS = 2500; const MAX_CONTINUE = 10; // предел на случай, если кнопка не гаснет const MAX_RETRY = 5; // «Server is busy» — сколько раз пробовать снова let continues = 0; let retries = 0; let lastText = null; // Что было на экране ДО отправки. Раньше это снималось при первом замере, уже // после — и быстрый ответ (меньше 600 мс) выглядел как «ничего не изменилось»: // grew не взводился, и команда честно ждала весь таймаут, приписывая // «замерло», хотя ответ давно лежал на экране. Поймано на «столица // Португалии»: ответ пришёл мгновенно, ожидание длилось 240 с. let firstText = textBefore; let lastChange = Date.now(); // когда текст в последний раз менялся let lastProgress = Date.now(); // когда были ЛЮБЫЕ признаки жизни let grew = false; for (;;) { await sleep(600); const state = await s.eval(`(() => { ${PAGE_HELPERS} return { count: __answerCount(), text: __lastAnswer(), generating: __isGenerating() }; })()`); const text = state.text ?? ''; if (firstText === null) firstText = text; // При ПРАВКЕ первого сообщения число ответов не растёт: старый заменяется // новым. Поэтому признаком «ответ пошёл» служит любой из трёх: появился // новый блок, началась генерация, текст отличается от исходного. if (state.count > countBefore || state.generating || text !== firstText) grew = true; if (text !== lastText) { lastText = text; lastChange = Date.now(); lastProgress = Date.now(); continue; } if (state.generating) { // пауза в размышлении — это не конец lastChange = Date.now(); lastProgress = Date.now(); continue; } // «Server is busy» — ответа не будет вовсе, ждать нечего. Жмём Retry и // ждём заново. Проверяем ДО условия стабильности: текста может не быть // совсем, и тогда мост просто досиживал бы до таймаута. if (!state.generating && retries < MAX_RETRY) { const retry = await s.eval(`(() => { ${PAGE_HELPERS} return __retryBox(); })()`); if (retry) { retries++; if (log) log(`сервер занят — повторяю (${retries}/${MAX_RETRY})`); await sleep(3000); // сразу долбить занятый сервер смысла нет await hoverClick(s, retry); await sleep(2000); lastChange = Date.now(); lastProgress = Date.now(); continue; } } if (grew && text && Date.now() - lastChange >= STABLE_MS) { // Печатать перестали — но это может быть не конец ответа, а обрыв по // лимиту с предложением дописать. Пока кнопка есть, жмём её и ждём // дальше: продолжение уходит в тот же блок, склейка не нужна. const box = continues < MAX_CONTINUE ? await s.eval(`(() => { ${PAGE_HELPERS} return __continueBox(); })()`) : null; if (box) { await sleep(400); // дать прокрутке доехать await hoverClick(s, box); continues++; if (log) log(`нажата кнопка Continue (${continues}) — ответ был оборван`); await sleep(1500); lastChange = Date.now(); lastProgress = Date.now(); continue; } return revealAnswer(s, text, log); } if (Date.now() - lastProgress >= stallSec * 1000) { // И на аварийном выходе текст снимаем после прокрутки: иначе к «замерло» // добавится ещё и потеря блоков кода, а причину потом не разделить. if (lastText) return (await revealAnswer(s, lastText, log)) + `\n\n[замерло: ${stallSec} с без признаков генерации — ответ может быть неполным]`; throw new Error( `Ответ не появился, страница ${stallSec} с не подаёт признаков жизни.\n` + 'Проверьте вкладку: возможна капча, лимит сообщений или требуется вход.'); } } } // Навести мышь и щёлкнуть настоящим мышиным событием. async function hoverClick(s, box) { await s.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: box.x, y: box.y, button: 'none', clickCount: 0 }); await sleep(250); const p = { x: box.x, y: box.y, button: 'left', clickCount: 1 }; await s.send('Input.dispatchMouseEvent', { ...p, type: 'mousePressed' }); await s.send('Input.dispatchMouseEvent', { ...p, type: 'mouseReleased' }); } // Новый чат. Единственное место, где это делается: раньше клик был внутри // помощника страницы и его беззвучный отказ никто не замечал. Теперь ждём // подтверждения адресом — из старого чата мы обязаны уйти. async function clickNewChat(s, log = () => {}) { await pressEscape(s); await repaint(s); const was = await s.eval(`(() => { ${PAGE_HELPERS} return __chatId(); })()`); const box = await s.eval(`(() => { ${PAGE_HELPERS} return __newChatBox(); })()`); if (!box) throw new Error('кнопка New chat не найдена'); await hoverClick(s, box); // Пустой чат адреса не имеет: __chatId() становится null. Ждём именно смены. for (let i = 0; i < 20; i++) { await sleep(300); const now = await s.eval(`(() => { ${PAGE_HELPERS} return __chatId(); })()`); if (now !== was) { log('открыт новый чат'); return; } } throw new Error('нажал New chat, но чат не сменился — вкладка не откликнулась'); } // Заставить браузер перерисовать страницу. У окна в фоне рисование стоит, и // то, что появляется по :hover — кнопка «…» у строки чата, пункты меню — в DOM // просто не создаётся. Через CDP единственный способ вынудить отрисовку — // снять кадр; сама картинка не нужна, поэтому берём самую дешёвую. // Тот же корень, что у пропадавших блоков кода (см. revealAnswer). async function repaint(s) { try { await s.send('Page.captureScreenshot', { format: 'jpeg', quality: 1 }); } catch { /* не вышло — работаем как есть */ } } // Закрыть открытое меню: иначе следующий клик попадёт в него, а не в строку. async function pressEscape(s) { for (const type of ['keyDown', 'keyUp']) await s.send('Input.dispatchKeyEvent', { type, key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27, nativeVirtualKeyCode: 27 }); await sleep(350); } // Удалить ОДИН чат по id. Порядок шагов проверен на 12 чатах подряд: навести // мышь на строку (без этого у неактивного чата точек в DOM нет), щёлкнуть // "...", выбрать Delete, подтвердить "Delete chat". Адресуемся строго по id: // порядок чатов в сайдбаре меняется, а у активного чата кнопка видна всегда — // на этом уже удалялся не тот чат. async function deleteChat(s, id, log = () => {}) { await pressEscape(s); await repaint(s); const row = await s.eval(`(() => { ${PAGE_HELPERS} return __chatRowBox(${JSON.stringify(id)}); })()`); if (!row) return true; // уже нет — считаем удалённым await s.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: row.x, y: row.y, button: 'none', clickCount: 0 }); await sleep(450); await repaint(s); // без этого кнопки «…» в DOM нет const dots = await s.eval(`(() => { ${PAGE_HELPERS} return __dotsBox(${row.top}, ${row.bottom}); })()`); if (!dots) return false; await hoverClick(s, dots); await sleep(600); await repaint(s); const del = await s.eval(`(() => { ${PAGE_HELPERS} return __menuItemBox(/^(delete|удалить)$/i); })()`); if (!del) return false; await hoverClick(s, del); await sleep(800); await repaint(s); const yes = await s.eval(`(() => { ${PAGE_HELPERS} return __confirmBox(); })()`); if (yes) { await hoverClick(s, yes); await sleep(1000); } const gone = await s.eval(`(() => { ${PAGE_HELPERS} return !__chatIds().some(c => c.id === ${JSON.stringify(id)}); })()`); if (gone) log(`удалён чат ${id}`); return gone; } // Убрать из сайдбара всё, чего нет в chats.json. Вкладка DeepSeek — рабочее // место моста, а не хранилище переписки: отработавшие чаты копились без счёта // (за сутки работы Эмилии их набралось семнадцать) и мешали искать нужный. // Стоит недорого: список чатов — один eval, и если лишнего нет, дальше ничего // не происходит. async function pruneChats(s, log = () => {}) { let keep = new Set(); try { const map = JSON.parse(await readFile(CHATS_FILE, 'utf8')); keep = new Set(Object.values(map).map(v => v && v.id).filter(Boolean)); } catch { return 0; } // нет chats.json — не знаем, что беречь, не трогаем if (!keep.size) return 0; const all = await s.eval(`(() => { ${PAGE_HELPERS} return __chatIds(); })()`); const extra = all.filter(c => !keep.has(c.id)); if (!extra.length) return 0; log(`лишних чатов в сайдбаре: ${extra.length}, убираю`); let done = 0; for (const c of extra) { try { if (await deleteChat(s, c.id)) done++; } catch (e) { log(`не удалось удалить «${c.title}»: ${e.message}`); } } log(`удалено чатов: ${done}`); return done; } // Роль → настройки нового чата. Тот же список, что использует init. const CHAT_PLAN = { think: { model: 'expert', toggle: null, hello: 'Рабочий чат для рассуждений. Отвечай кратко.' }, search: { model: 'default', toggle: 'Search', hello: 'Рабочий чат для поиска в интернете. Отвечай кратко.' }, vision: { model: 'vision', toggle: null, hello: 'Рабочий чат для разбора картинок. Отвечай кратко.' }, }; // DeepSeek разрешает править первое сообщение лишь ШЕСТЬ раз, дальше кнопка // правки перестаёт открывать поле. Поэтому чат — расходник: как только правки // кончились, заводим новый с тем же режимом и переписываем chats.json. // // firstText — настоящий вопрос вместо приветствия. Раньше чат // всегда открывался служебной фразой, а вопрос уходил ПРАВКОЙ этой фразы: круг // DeepSeek тратился впустую и одна правка из шести — тоже. Режим чата и его id // появляются от любого первого сообщения, так что якорь не нужен. // Возвращает { id, answered } — answered означает, что ответ на firstText уже // получен и вызывающему остаётся его забрать, а не спрашивать заново. async function makeChat(s, role, log, firstText = null, timeout = 300) { const step = CHAT_PLAN[role] || CHAT_PLAN.think; await clickNewChat(s, log); await sleep(1500); await ensureModel(s, step.model, step.toggle, log); const put = await s.eval(SET_TEXT(firstText || step.hello)); if (!put.ok) throw new Error(put.reason); await sleep(300); await sendAndConfirm(s, log); const answer = await waitForAnswer(s, put.before, firstText ? timeout : 420, log, put.textBefore); // Ждём id, отличный от прежнего. Просто «непустой» тут мало: если переход // не состоялся, в адресе остаётся старый id, и он выглядит как новый. const mapFile = CHATS_FILE; let prev = null; try { prev = JSON.parse(await readFile(mapFile, 'utf8'))[role]?.id ?? null; } catch { /* нет файла */ } let id = null; for (let i = 0; i < 20; i++) { id = await s.eval(`(() => { ${PAGE_HELPERS} return __chatId(); })()`); if (id && id !== prev) break; id = null; await sleep(500); } if (!id) throw new Error('новый чат создан, но его id не появился в адресе'); const file = mapFile; let map = {}; try { map = JSON.parse(await readFile(file, 'utf8')); } catch { /* заведём заново */ } // Чат — расходник, и раньше отработавшие копились в сайдбаре без счёта: за // сутки работы Эмилии их набралось семнадцать. Удаляем свой прежний чат этой // роли сразу, как только новый создан и записан. const old = map[role] && map[role].id; map[role] = { id, mode: step.toggle ? `${step.model}+${step.toggle}` : step.model }; await writeFile(file, JSON.stringify(map, null, 2), 'utf8'); log(`создан новый чат ${role}: ${id}`); if (old && old !== id) { try { await deleteChat(s, old, log); } catch (e) { log(`старый чат ${old} удалить не вышло: ${e.message}`); } } return { id, answer, answered: !!firstText }; } // ================================================================== // Публичный вход // ================================================================== // Флаги окна не украшение. Замер в DeepShim 11.08.2026: у неактивного окна // Windows сообщает браузеру, что оно перекрыто, рисование прекращается, а // DeepSeek рисует блоки кода лениво — ответ приезжает без кода (3136 символов // вместо 30 817). Эти флаги запрещают браузеру засыпать. const BROWSER_FLAGS = [ '--remote-allow-origins=*', '--start-maximized', '--disable-backgrounding-occluded-windows', '--disable-renderer-backgrounding', '--disable-background-timer-throttling', '--disable-features=CalculateNativeWinOcclusion', ]; export async function browserAlive() { try { const r = await fetch(`http://127.0.0.1:${PORT}/json/version`, { signal: AbortSignal.timeout(3000) }); return r.ok; } catch { return false; } } // Поднять браузер, если он не отвечает, и открыть вкладку с чатом, если её нет. export async function ensureBrowser(log = () => {}) { if (!(await browserAlive())) { if (!BROWSER.exe || !BROWSER.profileDir) { throw new Error( `Браузер не отвечает на порту ${PORT}, и запустить его сам не могу: ` + 'в config.json не задан browserExe или profileDir.'); } if (!existsSync(BROWSER.exe)) { throw new Error(`Браузер не найден: ${BROWSER.exe}. Проверьте browserExe в config.json.`); } log('Браузер не отвечает, поднимаю...'); spawn(BROWSER.exe, [`--user-data-dir=${BROWSER.profileDir}`, `--remote-debugging-port=${PORT}`, ...BROWSER_FLAGS, BROWSER.url], { detached: true, stdio: 'ignore' }).unref(); // Холодный профиль поднимается неспешно; 60 секунд с запасом. for (let i = 0; i < 30 && !(await browserAlive()); i++) await sleep(2000); if (!(await browserAlive())) { throw new Error( `Браузер так и не открыл отладочный порт ${PORT}. Скорее всего этот ` + 'профиль уже открыт БЕЗ отладочного порта: новый процесс отдаёт ссылку ' + `старому окну и завершается. Закройте окно с профилем ${BROWSER.profileDir} ` + 'и повторите.'); } log('браузер поднялся'); } const list = await (await fetch(`http://127.0.0.1:${PORT}/json/list`)).json().catch(() => []); if (list.some(t => String(t.url || '').includes('deepseek'))) return; // Новая вкладка в свежих сборках Chromium открывается ТОЛЬКО методом PUT: // на GET протокол отвечает 405 и молча ничего не делает. const r = await fetch( `http://127.0.0.1:${PORT}/json/new?url=${encodeURIComponent(BROWSER.url)}`, { method: 'PUT' }); if (!r.ok) throw new Error(`Не удалось открыть вкладку DeepSeek: ${r.status}.`); await sleep(4000); // дать чату прогрузиться до первого вопроса log('вкладка с чатом открыта'); } // Приметы того, что вкладка или браузер пропали посреди работы. По ним ask() // решает, что дело не в вопросе, и стоит восстановиться и повторить один раз. function isLostTab(e) { return /вкладка DeepSeek закрылась|вкладка не ответила на|вкладка не отозвалась|WebSocket|Вкладка chat\.deepseek\.com не открыта|Браузер с отладочным портом/i .test(String(e && e.message || e)); } // Поднять браузер и вкладку, ничего не спрашивая. Отдаёт состояние — им живёт // сторож и по нему рисуются индикаторы. export async function revive(log = () => {}) { try { await ensureBrowser(log); return { ok: true, browser: true, tab: true }; } catch (e) { return { ok: false, browser: await browserAlive(), tab: false, error: e.message }; } } // Картинка приезжает к нам в памяти, а странице нужен файл на диске: другого // способа наполнить у протокола нет. async function attachImage(s, bytes, log) { const { mkdtemp, writeFile: wf, rm } = await import('node:fs/promises'); const { tmpdir } = await import('node:os'); const dir = await mkdtemp(join(tmpdir(), 'dsapi-img-')); const file = resolvePath(join(dir, 'image.jpg')); await wf(file, bytes); try { await s.send('DOM.enable'); const { root } = await s.send('DOM.getDocument'); const { nodeId } = await s.send('DOM.querySelector', { nodeId: root.nodeId, selector: 'input[type="file"]' }); if (!nodeId) throw new Error('поле загрузки файла на странице не найдено'); await s.send('DOM.setFileInputFiles', { files: [file], nodeId }); log('приложена картинка'); await sleep(2500); // дать чату загрузить вложение } finally { // Файл нужен только на время загрузки; страница читает его сразу. setTimeout(() => rm(dir, { recursive: true, force: true }).catch(() => {}), 30000); } } // Один вопрос — один ответ. Всё остальное (какой чат, жив ли он, не кончились // ли правки) решается здесь и наружу не торчит. export async function ask({ prompt, role = 'think', imageBytes = null, timeout = 300, log = () => {} }) { if (!String(prompt || '').trim()) throw new Error('пустой вопрос'); if (!ROLES.includes(role)) role = 'think'; await ensureBrowser(log); try { return await askOnce({ prompt, role, imageBytes, timeout, log }); } catch (e) { if (!isLostTab(e)) throw e; // Вкладку закрыли или браузер уронили посреди работы. Поднимаем заново // и спрашиваем ещё раз — ровно один. // // Повтор безопасен именно из-за правки первого сообщения: вопрос не // добавляется к чату, а заменяет его содержимое. Даже если первая попытка // успела отправить вопрос, вторая его перепишет, а не задаст дважды. log(`связь с вкладкой потеряна (${e.message}); поднимаю и повторяю`); await ensureBrowser(log); return askOnce({ prompt, role, imageBytes, timeout, log }); } } async function askOnce({ prompt, role, imageBytes, timeout, log }) { return withChat(async (s) => { await pruneChats(s, log); // сайдбар держим чистым const known = (await chatsMap())[role]?.id ?? null; const alive = known && await s.eval(`(() => { ${PAGE_HELPERS} return __chatIds().some(c => c.id === ${JSON.stringify(String(known))}); })()`); // Чата нужного режима нет — заводим сразу с вопросом. Отдельное приветствие // стоило бы лишнего круга DeepSeek и одной правки из шести, а нужно не было: // режим и id чата появляются от любого первого сообщения. if (!alive) { log(`чат ${role} не найден, создаю сразу с вопросом`); const made = await makeChat(s, role, log, prompt, timeout); return made.answer; } await openChatById(s, String(known), log); if (imageBytes) await attachImage(s, imageBytes, log); // С картинкой — только новым сообщением: вложение попадает в поле ввода, // а правка идёт в отдельном поле над ним, и картинка туда не переезжает. const canEdit = !imageBytes && await s.eval( `(() => { ${PAGE_HELPERS} return !!__firstMsg(); })()`); if (canEdit) { try { const sent = await editFirstAndSend(s, prompt, log); return await waitForAnswer(s, sent.before, timeout, log, sent.textBefore); } catch (e) { // Единственная ожидаемая причина — исчерпан лимит правок (их шесть). if (!/поле правки не открылось/.test(String(e.message || e))) throw e; log('правки в этом чате кончились, завожу новый сразу с вопросом'); const made = await makeChat(s, role, log, prompt, timeout); return made.answer; } } const put = await s.eval(SET_TEXT(prompt)); if (!put.ok) throw new Error(put.reason); await sleep(250); await sendAndConfirm(s, log); return waitForAnswer(s, put.before, timeout, log, put.textBefore); }); } // Диагностика: что видно во вкладке. Используется эндпойнтом /api/probe. export async function probe() { await ensureBrowser(); return withChat(async (s, tab) => { const info = await s.eval(`(() => { ${PAGE_HELPERS} const el = __input(); const a = __answerNodes(); return { input: !!el, answerBlocks: a.nodes.length, generating: __isGenerating(), chats: __chatIds(), needsLogin: /log ?in|войти|sign in/i.test(document.body.innerText.slice(0, 3000)), }; })()`); return { tab: tab.url, ...info, chatsFile: await chatsMap() }; }); }