Проверено вживую: браузер убит через 10 с после начала запроса. Сокет умирал честно, браузер поднимался, но повтор падал на «кнопка New chat не найдена» — ensureBrowser возвращался, едва отладочный порт отозвался. У холодного браузера адрес задан аргументом командной строки, поэтому вкладка есть в списке сразу, а разметки нет ещё несколько секунд, и ветка с ожиданием после /json/new в этом случае не выполнялась вовсе. Теперь ensureBrowser в обоих случаях ждёт поле ввода — примету того, что страницей можно пользоваться, — и отдельно распознаёт страницу входа, где ждать нечего. После правки тот же опыт даёт полный ответ: запрос пережил смерть браузера и вернул 9888 символов за 140 с.
1245 lines
70 KiB
JavaScript
1245 lines
70 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;
|
||
}
|
||
|
||
// Роли — это отдельные чаты под разные режимы. Режим выбирается при СОЗДАНИИ
|
||
// чата и потом не меняется: тумблеры исчезают после первого сообщения.
|
||
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 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, 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() };
|
||
});
|
||
}
|