DeepSeek больше не даёт выбирать модель: radiogroup Instant / Expert /
Vision (div[data-model-type][role="radio"]) со страницы исчез, оставшаяся
модель универсальна и разбирает в том числе картинки. Вместе с выбором
отпали и три роли — think, search, vision, — а с ними отдельный чат под
каждую: теперь чат один.
Заодно выяснилось, что тумблеры DeepThink и Search больше не пропадают
после первого сообщения — они видны и переключаются в любом чате, в том
числе начатом. На их исчезновении держалась вся прежняя схема с чатами
под роль, так что нужда в ней отпала и по этой причине тоже.
DeepThink выключается перед каждым вопросом: он только показывает
размышления модели и раздувает ответ. Search не трогаем намеренно — это
тумблер человека: как поставлен во вкладке, так мост и спрашивает.
Попутно два бага, которые вылезли на живом прогоне:
- data-virtual-list-item-key у вопросов теперь отрицательные (-4, 2, -11,
8 для двух обменов подряд), и __firstMsg сортировкой по возрастанию
отдавал последний вопрос вместо первого. Правка уходила в чужое
сообщение. Берём порядок DOM — список рисуется сверху вниз.
- Вопрос с картинкой уходил новым сообщением в рабочий чат и ломал
уговор «в чате ровно один обмен», на котором держится правка первого
сообщения. После такого чат оставался из двух обменов, и наружу
отдавался ответ на предыдущий вопрос. Теперь картинка всегда открывает
новый чат, а старый удаляется как обычно.
Снаружи: /api/tags отдаёт одну модель deepseek:chat, имя присланной
клиентом модели не смотрится вовсе, ключ defaultRole из config.json убран,
chats.json стал {"id": "..."} вместо карты ролей. /api/probe показывает
положение тумблеров.
Проверено живым прогоном: обычный вопрос, вопрос с картинкой, снова
обычный — ответы верные, чат ротируется, сайдбар чистится.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ez8B2cR3mbm1fjmAMa8JXS
1089 lines
62 KiB
JavaScript
1089 lines
62 KiB
JavaScript
// Управление вкладкой 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;
|
||
}
|
||
|
||
// Модель у DeepSeek теперь одна и универсальная: выбор из трёх (Instant /
|
||
// Expert / Vision) убран из веб-морды, картинки разбирает та же модель.
|
||
// Поэтому и чат нужен один — рабочий, расходный.
|
||
|
||
// Id рабочего чата из chats.json. Файл — состояние, а не настройка: он
|
||
// заводится сам и переписывается, когда чат меняется.
|
||
async function savedChatId() {
|
||
try { return JSON.parse(await readFile(CHATS_FILE, 'utf8')).id || null; } catch { return null; }
|
||
}
|
||
|
||
|
||
|
||
// ------------------------------------------------------------------ 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: [] };
|
||
}
|
||
// Тумблеры DeepThink и Search рядом с полем ввода. Раньше они пропадали, как
|
||
// только в чате появлялось первое сообщение, — на этом держалась вся прежняя
|
||
// схема с отдельным чатом под каждый режим. Проверено 11.09.2026: теперь они
|
||
// видны и переключаются в любом чате, в том числе начатом.
|
||
//
|
||
// Разметка: div.ds-toggle-button с aria-pressed. Вложенная иконка несёт класс
|
||
// ds-toggle-button__icon — это не тумблер, кликать по ней бессмысленно.
|
||
function __toggles() {
|
||
return [...document.querySelectorAll('.ds-toggle-button')]
|
||
.filter(__visible)
|
||
.filter(e => !/ds-toggle-button__/.test(e.getAttribute('class') || ''))
|
||
.map(e => ({
|
||
name: (e.innerText || '').trim() || '(без подписи)',
|
||
on: e.getAttribute('aria-pressed') === 'true',
|
||
}));
|
||
}
|
||
// Только координаты: сам клик — настоящими мышиными событиями снаружи.
|
||
// Причина найдена замером 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.
|
||
//
|
||
// За номер ключа цепляться НЕЛЬЗЯ, и сортировать по нему тоже: номера живут
|
||
// своей жизнью — после правок список перенумеровывается, а у вопросов они
|
||
// теперь и вовсе отрицательные (замер 11.09.2026: -4, 2, -11, 8 для двух
|
||
// обменов подряд). Сортировка по возрастанию отдавала ПОСЛЕДНИЙ вопрос вместо
|
||
// первого, и правка уходила не в то сообщение. Берём порядок DOM: список
|
||
// рисуется сверху вниз, значит первый отрисованный вопрос — он и есть.
|
||
function __firstMsg() {
|
||
return [...document.querySelectorAll('[data-virtual-list-item-key]')]
|
||
.find(e => !e.querySelector('.ds-markdown')) || 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} — страница не загрузилась.`);
|
||
}
|
||
|
||
// Щёлкнуть по тумблеру настоящим мышиным событием. DOM-вызов click() тут
|
||
// тоже срабатывает, но мышь проверена на большем числе случаев и работает
|
||
// даже там, где React глотает синтетический клик.
|
||
async function clickToggle(s, name) {
|
||
const box = await s.eval(`(() => { ${PAGE_HELPERS}
|
||
const el = [...document.querySelectorAll('.ds-toggle-button')]
|
||
.filter(__visible)
|
||
.filter(e => !/ds-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;
|
||
await hoverClick(s, box);
|
||
await sleep(600);
|
||
return true;
|
||
}
|
||
|
||
// DeepThink выключаем перед каждым вопросом: он только показывает размышления
|
||
// модели и раздувает ответ, а модель и без него та же самая — выбора моделей
|
||
// у DeepSeek больше нет.
|
||
//
|
||
// Search НЕ трогаем намеренно: это тумблер человека. Как поставлен во вкладке,
|
||
// так мост и спрашивает — иначе мост отнимал бы единственный оставшийся
|
||
// способ управлять поиском в интернете.
|
||
async function ensureNoDeepThink(s, log) {
|
||
const isOn = () => s.eval(`(() => { ${PAGE_HELPERS}
|
||
return __toggles().some(t => /deepthink/i.test(t.name) && t.on); })()`);
|
||
if (!await isOn()) return;
|
||
if (!await clickToggle(s, 'DeepThink')) {
|
||
log('тумблер DeepThink не найден — оставляю как есть');
|
||
return;
|
||
}
|
||
log(await isOn() ? 'DeepThink выключить не удалось' : 'DeepThink выключен');
|
||
}
|
||
|
||
// Ответ считается готовым, когда его текст перестал меняться.
|
||
// Ответ считается готовым, только когда страница ПЕРЕСТАЛА печатать.
|
||
// Раньше признаком был «текст не менялся 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 = () => {}) {
|
||
const keep = await savedChatId();
|
||
if (!keep) return 0; // не знаем, что беречь, — не трогаем ничего
|
||
|
||
const all = await s.eval(`(() => { ${PAGE_HELPERS} return __chatIds(); })()`);
|
||
const extra = all.filter(c => c.id !== keep);
|
||
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;
|
||
}
|
||
|
||
// DeepSeek разрешает править первое сообщение лишь ШЕСТЬ раз, дальше кнопка
|
||
// правки перестаёт открывать поле. Поэтому чат — расходник: как только правки
|
||
// кончились, заводим новый и переписываем chats.json.
|
||
//
|
||
// Чат сразу открывается настоящим вопросом. Раньше он открывался служебным
|
||
// приветствием, а вопрос уходил ПРАВКОЙ этой фразы: круг DeepSeek тратился
|
||
// впустую и одна правка из шести — тоже.
|
||
async function makeChat(s, log, firstText, timeout = 300, imageBytes = null) {
|
||
await clickNewChat(s, log);
|
||
await sleep(1500);
|
||
|
||
await ensureNoDeepThink(s, log);
|
||
const put = await s.eval(SET_TEXT(firstText));
|
||
if (!put.ok) throw new Error(put.reason);
|
||
if (imageBytes) await attachImage(s, imageBytes, log);
|
||
await sleep(300);
|
||
await sendAndConfirm(s, log);
|
||
const answer = await waitForAnswer(s, put.before, timeout, log, put.textBefore);
|
||
|
||
// Ждём id, отличный от прежнего. Просто «непустой» тут мало: если переход
|
||
// не состоялся, в адресе остаётся старый id, и он выглядит как новый.
|
||
const old = await savedChatId();
|
||
|
||
let id = null;
|
||
for (let i = 0; i < 20; i++) {
|
||
id = await s.eval(`(() => { ${PAGE_HELPERS} return __chatId(); })()`);
|
||
if (id && id !== old) break;
|
||
id = null;
|
||
await sleep(500);
|
||
}
|
||
if (!id) throw new Error('новый чат создан, но его id не появился в адресе');
|
||
|
||
await writeFile(CHATS_FILE, JSON.stringify({ id }, null, 2), 'utf8');
|
||
log(`создан новый чат: ${id}`);
|
||
|
||
// Отработавшие чаты копились в сайдбаре без счёта: за сутки работы Эмилии
|
||
// их набралось семнадцать. Прежний убираем сразу, как только новый записан.
|
||
if (old && old !== id) {
|
||
try { await deleteChat(s, old, log); }
|
||
catch (e) { log(`старый чат ${old} удалить не вышло: ${e.message}`); }
|
||
}
|
||
return { id, answer };
|
||
}
|
||
|
||
// ==================================================================
|
||
// Публичный вход
|
||
// ==================================================================
|
||
|
||
// Флаги окна не украшение. Замер в 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 waitPageReady(log);
|
||
// Новая вкладка в свежих сборках 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(1000);
|
||
// Открыл — убедись, что открылось именно то: молчаливый промах здесь уже был.
|
||
if (!(await tabs()).some(isChat)) {
|
||
throw new Error(`Вкладка открылась, но не с ${BROWSER.url}. `
|
||
+ 'Похоже, браузер не принял адрес — закройте лишние вкладки и повторите.');
|
||
}
|
||
log('вкладка с чатом открыта');
|
||
await waitPageReady(log);
|
||
}
|
||
|
||
// Открытая вкладка — ещё не готовый чат. У холодного браузера адрес задан
|
||
// аргументом командной строки, вкладка в списке появляется сразу, а разметки
|
||
// нет ещё несколько секунд: повтор после смерти браузера падал на «кнопка
|
||
// New chat не найдена». Ждём поле ввода — единственную примету, что страницей
|
||
// можно пользоваться.
|
||
async function waitPageReady(log = () => {}) {
|
||
let s;
|
||
try { s = await Session.connect((await findChatTab()).webSocketDebuggerUrl); }
|
||
catch { return; } // вкладку успели закрыть — разберётся вызвавший
|
||
try {
|
||
for (let i = 0; i < 60; i++) { // до ~60 с: холодный профиль неспешен
|
||
const st = await s.eval(`(() => { ${PAGE_HELPERS}
|
||
return { input: !!__input(),
|
||
login: /log ?in|войти|sign in/i.test(document.body.innerText.slice(0, 3000)) };
|
||
})()`).catch(() => null);
|
||
if (st && st.input) { await sleep(800); return; }
|
||
// Вход руками — ждать нечего: страница жива, просто мы не залогинены.
|
||
if (st && st.login) { log('нужен вход в DeepSeek — сделайте это в окне браузера'); return; }
|
||
await sleep(1000);
|
||
}
|
||
throw new Error('Вкладка DeepSeek открыта, но чат так и не появился: '
|
||
+ 'страница не загрузилась или изменилась разметка.');
|
||
} finally { s.close(); }
|
||
}
|
||
|
||
// Приметы того, что вкладка или браузер пропали посреди работы. По ним 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, imageBytes = null,
|
||
timeout = 300, log = () => {} }) {
|
||
if (!String(prompt || '').trim()) throw new Error('пустой вопрос');
|
||
await ensureBrowser(log);
|
||
|
||
try {
|
||
return await askOnce({ prompt, imageBytes, timeout, log });
|
||
} catch (e) {
|
||
if (!isLostTab(e)) throw e;
|
||
// Вкладку закрыли или браузер уронили посреди работы. Поднимаем заново
|
||
// и спрашиваем ещё раз — ровно один.
|
||
//
|
||
// Повтор безопасен именно из-за правки первого сообщения: вопрос не
|
||
// добавляется к чату, а заменяет его содержимое. Даже если первая попытка
|
||
// успела отправить вопрос, вторая его перепишет, а не задаст дважды.
|
||
log(`связь с вкладкой потеряна (${e.message}); поднимаю и повторяю`);
|
||
await ensureBrowser(log);
|
||
return askOnce({ prompt, imageBytes, timeout, log });
|
||
}
|
||
}
|
||
|
||
async function askOnce({ prompt, imageBytes, timeout, log }) {
|
||
return withChat(async (s) => {
|
||
await pruneChats(s, log); // сайдбар держим чистым
|
||
|
||
const known = await savedChatId();
|
||
const alive = known && await s.eval(`(() => { ${PAGE_HELPERS}
|
||
return __chatIds().some(c => c.id === ${JSON.stringify(String(known))}); })()`);
|
||
|
||
// Картинка — всегда НОВЫЙ чат. Вложение цепляется к полю ввода, а правка
|
||
// идёт в отдельном поле над ним: в старый чат картинку не перенести, и
|
||
// отправка новым сообщением ломала единственный уговор, на котором всё
|
||
// держится, — «в чате ровно один обмен». Поймано 11.09.2026: после такого
|
||
// сообщения чат оставался из двух обменов, правка уходила в чужой вопрос,
|
||
// а наружу отдавался ответ на предыдущий.
|
||
//
|
||
// Рабочего чата нет — тоже заводим, сразу с вопросом. Отдельное приветствие
|
||
// стоило бы лишнего круга DeepSeek и одной правки из шести, а нужно
|
||
// не было: id чата появляется от любого первого сообщения.
|
||
if (!alive || imageBytes) {
|
||
log(imageBytes ? 'вопрос с картинкой — завожу новый чат'
|
||
: 'рабочий чат не найден, создаю сразу с вопросом');
|
||
const made = await makeChat(s, log, prompt, timeout, imageBytes);
|
||
return made.answer;
|
||
}
|
||
|
||
await openChatById(s, String(known), log);
|
||
await ensureNoDeepThink(s, log);
|
||
|
||
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, log, prompt, timeout);
|
||
return made.answer;
|
||
}
|
||
});
|
||
}
|
||
|
||
// Диагностика: что видно во вкладке. Используется эндпойнтом /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(),
|
||
toggles: __toggles(),
|
||
needsLogin: /log ?in|войти|sign in/i.test(document.body.innerText.slice(0, 3000)),
|
||
};
|
||
})()`);
|
||
return { tab: tab.url, ...info, workChat: await savedChatId() };
|
||
});
|
||
}
|