Files
deepseek-api/browser.mjs
T
Alex Cube bd32845458 Вкладка открывается с нужным адресом, а не пустая
DevTools берёт всё, что стоит после '?' в /json/new, как сам адрес целиком —
это не параметры запроса. Строка 'url=https%3A%2F%2Fchat.deepseek.com%2F'
честно считалась адресом, и открывалась about:blank. Место срабатывает
только когда браузер жив, а вкладки с чатом нет, поэтому лежало незамеченным.

Заодно ensureBrowser теперь проверяет, что открылось именно то: беззвучный
промах в этом файле уже был один раз, с кнопкой New chat.
2026-08-26 17:06:47 +03:00

1219 lines
69 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Управление вкладкой 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())
})) };
}
// Сайдбар без <a>: берём кликабельные строки с текстом из левой колонки.
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 tabs = () => fetch(`http://127.0.0.1:${PORT}/json/list`)
.then(r => r.json()).catch(() => []);
const isChat = t => String(t.url || '').includes('deepseek');
if ((await tabs()).some(isChat)) return;
// Новая вкладка в свежих сборках Chromium открывается ТОЛЬКО методом PUT:
// на GET протокол отвечает 405 и молча ничего не делает.
//
// И адрес НЕ параметр запроса: всё, что стоит после '?', протокол берёт
// как сам адрес целиком. С '?url=...' открывалась пустая about:blank —
// строку 'url=https%3A%2F%2F...' он честно считал адресом.
const r = await fetch(`http://127.0.0.1:${PORT}/json/new?${BROWSER.url}`,
{ method: 'PUT' });
if (!r.ok) throw new Error(`Не удалось открыть вкладку DeepSeek: ${r.status}.`);
await sleep(4000); // дать чату прогрузиться до первого вопроса
// Открыл — убедись, что открылось именно то: молчаливый промах здесь уже был.
if (!(await tabs()).some(isChat)) {
throw new Error(`Вкладка открылась, но не с ${BROWSER.url}. `
+ 'Похоже, браузер не принял адрес — закройте лишние вкладки и повторите.');
}
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 };
}
}
// Картинка приезжает к нам в памяти, а странице нужен файл на диске: другого
// способа наполнить <input type="file"> у протокола нет.
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() };
});
}