Headless‑браузер без браузера: 5 408 байт. API.. API. duckduckgo.. API. duckduckgo. headless.. API. duckduckgo. headless. JavaScript.. API. duckduckgo. headless. JavaScript. llm.. API. duckduckgo. headless. JavaScript. llm. serverless.. API. duckduckgo. headless. JavaScript. llm. serverless. telegram bot.. API. duckduckgo. headless. JavaScript. llm. serverless. telegram bot. Информационная безопасность.. API. duckduckgo. headless. JavaScript. llm. serverless. telegram bot. Информационная безопасность. Проектирование API.

Или история о том, как 5 408 байт обфусцированного JavaScript, прилетающих в HTTP‑заголовке, превращаются в свободный доступ к GPT, Claude, Mistral и генерации картинок если построить браузерный манекен на чистом JS.

Пролог: та самая ошибка

Всё началось с невзрачного сообщения в логах:

Ошибка API (ddg_haiku): Error code: 500 - {'error': 'no x-vqd-hash-1 token'}

Если вы когда‑нибудь пробовали написать прокси к duck.ai встроенному в DuckDuckGo AI‑чату, где под капотом живут GPT и Claude, вы почти наверняка видели что‑то из этого репертуара: то 500 no x-vqd-hash-1 token, то дразнящую 418 I'm a teapot, то молчаливый обрыв соединения. Открытые прокси‑проекты (вся братва в духе duck2api) периодически отваливаются на ровном месте

Мы с напарником делали Telegram‑бота‑прокси к duck.ai и, разумеется, встали на эти грабли обеими ногами. Пришлось разбираться. Эта статья подробный разбор того, как устроен антибот DuckDuckGo в 2 026 году, что именно проверяет их челлендж (мы деобфусцировали четыре варианта «вкуса» и теперь знаем о них всё), и как мы построили headless‑браузер, в котором нет ни одного байта настоящего браузера: только ~40 КБ чистого JavaScript, исполняющего их собственный код против фиктивного DOM.

Спойлер: работает. На день публикации челлендж решается за ~580 мс без единого фолбэка, а бот стримит ответы моделей прямо в чат Телеграма.

Что такое duck.ai и почему это лакомый кусок

Для тех, кто не следил: duck.ai — это ИИ чат от DuckDuckGo, доступный прямо на duckduckgo.com. Его главная магия в том, что DuckDuckGo оплачивает модели за вас: в интерфейсе доступны свежие модели OpenAI, Anthropic, Mistral и открытые весовые модели, а пользователь платит ноль рублей, ноль центов и даже не логинится. Сервис работает анонимно (DuckDuckGo славится своим отношением к приватности и анонимизирует запросы перед передачей вендорам моделей).

Живой список моделей из нашего бота на день публикации:

Модель

Доступ

Что умеет

gpt-6-luna

free

текст, генерация картинок

gpt-5.4-mini

free

текст, картинки

claude-haiku-4-5

free

текст

mistral-small-2603

free

текст

tinfoil/gpt-oss-120b

free

открытая модель

tinfoil/gemma4-31b

free

открытая модель

gpt-5.6-terra / gpt-5.6-sol

premium

текст, генерация картинок

claude-sonnet-4-6

premium

текст, генерация картинок

claude-opus-4-8

premium

самый жирный Claude

Шесть бесплатных моделей, стриминг, веб‑поиск, загрузка картинок, генерация изображений и всё это через один HTTP‑эндпоинт /duckchat/v1/chat, без API‑ключей, без регистрации. Единственное, что стоит между вами и моделями — антибот. DuckDuckGo не может позволить, чтобы их щедрость сливали в безлимитные API‑прокси, поэтому вход на эндпоинт охраняет система, которая хочет убедиться: с той стороны сидит настоящий человек с настоящим Chrome.

Архитектура бота за 90 секунд

Прежде чем нырять в антибот, зафиксируем, что мы вообще строим. Бот называется headless‑duckapi — это Telegram‑бот, который принимает сообщения, пересылает их в duck.ai и стримит ответы обратно. Работает на serverless‑платформе Telegram (BotFather → Serverless): апдейты приходят вебхуком в изолят, код исполняется по запросу, никакого постоянно живущего сервера.

Headless‑браузер без браузера: 5 408 байт - 1

Ключевые архитектурные решения:

  • Один файл протокола. Вся магия обхода антибота живёт в lib/duckai.js самодостаточном модуле на ~950 строк без единой npm‑зависимости. Свой SHA-256, свой base64, свой HTML5-парсер, свой браузерный манекен. Это принципиально: код должен одинаково работать и в serverless‑изоляте (где нет Buffer, atob и половины Node‑API), и в обычном Node‑процессе.

  • SQLite для всего состояния. Настройки чатов, история диалогов, метрики в схеме из шести таблиц (chats, messages, users, stats_daily, duck_daily, config). Последняя таблица ключ к выживанию: в ней хранится решённый челлендж‑токен, переживающий рестарт изолята.

Главные фичи

Коротко, что бот умеет на момент публикации:

  • Стриминг. Одно Telegram‑сообщение, которое прогрессивно редактируется по мере прихода токенов (не чаще раза в секунду и не раньше, чем накопится 8 новых символов иначе Telegram душит rate‑limit’ом). Длинные ответы аккуратно перекатываются в следующее сообщение на 3 600 символах. Markdown на лету конвертируется в Telegram‑HTML, причём незакрытые посреди стрима ** и ``` дорисовываются виртуальными закрывателями, чтобы половина ответа не выглядела мусором.

  • 10 моделей с живым списком. Список не захардкожен — бот дергает /duckchat/v1/models и показывает то, что актуально прямо сейчас (когда DDG добавили GPT-5.6 и Claude 4.8, они появились у нас без единого коммита).

  • /img генерация картинок. Внутри обычного чат‑хода включается тул GenerateImage (GPT Image 2 на бэкенде): сначала приходит заголовок («Painting a cute robot duck…»), потом черновик‑превью уже на ~9-й секунде, потом финал 1024×1024 за ~13–17 секунд. Работает и спонтанно: попросите в обычном чате «нарисуй утку‑космонавта» картинка приедет сама.

  • Фото + vision + img2img. Шлёте фото с подписью «замени утку на кота» модель редактирует изображение. Забавная деталь: duck.ai отвергает JPEG в любой обёртке (бэкенд сниффает байты, а не MIME), а Telegram присылает фото строго в JPEG. Пришлось вендорить JPEG‑декодер и PNG‑энкодер (итого ~46 КБ), чтобы конвертировать фото в PNG с даунскейлом до 640 px прямо в изоляте, без ImageMagick.

  • Память диалога. История последних N ходов передаётся модели с каждым запросом; «очистить историю» вычищает таблицу.

  • Настройки текстом. Никаких команд‑зоопарков: claude, размышления низкий, поиск выкл, очистить историю exact‑match грамматика на русском и английском прямо в чате.

  • Метрики здоровья протокола. В отдельной таблице duck_daily копятся счётчики: сколько челленджей решено, сколько ушло в fallback, сколько 418/429. Команда /stats (только владелец) рисует панель в том числе «здоровье» антибот‑обхода в динамике по дням.

А теперь — то, ради чего вы, собственно, здесь.

Антибот DuckDuckGo: как выглядит честный браузер

Сначала разберёмся, что сервер ждёт от клиента. Полный цикл одного запроса к чату выглядит так:

Шаг 1. GET /duckchat/v1/status. Браузер стучится в статусный эндпоинт с набором заголовков, маскирующихся под браузерные:

user-agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) ... Chrome/149.0.0.0 Safari/537.36

sec-ch-ua: "Google Chrome";v="149", "Chromium";v="149", "Not)A;Brand";v="24"

sec-ch-ua-mobile: ?0

sec-ch-ua-platform:"Windows"

sec-fetch-dest: empty

sec-fetch-mode: cors

sec-fetch-site: same-origin

origin/referer: https://duck.ai

cookie: dcm=3

x-vqd-accept: 1

В ответ сервер кладёт в заголовок x-vqd-hash-1… челлендж. Не токен, не хеш а 5–6 килобайт base64-кодированного обфусцированного JavaScript. Челлендж приходит в том же заголовке, в котором потом надо вернуть ответ на него.

Шаг 2. Браузер исполняет челлендж. Тот JS — это async IIFE, которая внутри проверяет окружение (об этом ниже целая глава) и возвращает объект с массивом client_hashes значений, вычисленных на основе проверки окружения.

Шаг 3. POST /duckchat/v1/chat. Решение челленджа уезжает в заголовке x-vqd-hash-1 (того самого имени), рядом с ним ещё пара заголовков честности:

Заголовок

Что содержит

Зачем серверу

x-vqd-hash-1

base64(JSON) с ответами челленджа

доказательство «я браузер»

x-fe-version

версия сборки фронта, напр. serp_20261021_143533_ET-...

сверка, что фронт свежий

x-fe-signals

base64(JSON) лог телеметрии сессии

доказательство «я человек»

x-ddg-journey-id

случайный hex

идентификатор сессии

Ответ text/event-stream (SSE) со стримом токенов, в котором между чанками болтаются служебные события (вплоть до [PING]) и ивенты тулов вроде генерации картинок.

Теперь о штрахах. Если заголовка x-vqd-hash-1 нет вовсе — сервер честно отвечает 500 {'error': 'no x-vqd-hash-1 token'} (та самая ошибка из пролога: так умирают все прокси первого поколения, которые еще шлют старый токен x-vqd-4 и не знают про новый). Если заголовок есть, но содержимое не проходит проверку вас ждёт 418 I'm a teapot. Не 403, не 429 418, что придаёт всему действию особый цинизм.

Челлендж изнутри

Декодируем x-vqd-hash-1 из status‑ответа и видим знакомую картину — классический obfuscator.io:

(async function() {

const 0x53c4ff = 0x3dda;

function 0x3dda(0x8a4cc5, _0x54eae6) {

const 0x2b9bc6 = 0x2b9b();

return 0x3dda = function(0x3ddaef, _0x4750ce) {

0x3ddaef = 0x3ddaef - 0x181;

let 0x3acba3 = 0x2b9bc6[_0x3ddaef];

return _0x3acba3;

}, 0x3dda(0x8a4cc5, _0x54eae6);

}

// ...

Классика жанра: массив строк, функция‑декодер со сдвигом индексов, а сверху ротатор, который до запуска основного кода перетасовывает таблицу строк, пока чек‑сумма из parseInt‑ов не сойдётся. Цикл выглядит безобидно:

while (!![]) {

try {

const _0xefc59c = -parseInt(...)/0x1 + parseInt(...)/0x2 * ... ;

if (_0xefc59c === _0x1c89fc) break;

else 0x5f3a68.push(0x5f3a68.shift());

} catch (_0x51be09) { 0x5f3a68.push(0x5f3a68.shift()); }

}

Это значит, что статически расколоть код нельзя — индексы строк меняются при каждом обновлении, и деобфусцировать надо уже после самой ротации (мы гоняли код через AST‑трансформацию и собственный резолвер строк‑таблицы на это ушла пара вечеров).

После деобфускации скелет челленджа прост до безобразия: он исполняет набор проверок окружения, каждая из которых либо возвращает булево значение, либо число, затем сворачивает результаты проверок в числа вида:

String(BASE + сумма_проверок) // например: String(0×11a0 + innerHTML.length querySelectorAll(‘‘).length)

и возвращает объект:

return { client_hashes: [hash1, hash2,…],… }

А дальше — важный нюанс, который многие упускают: сырые значения из челленджа ещё не токен. Наш клиент прогоняет каждый элемент client_hashes через собственную чисто‑JS реализацию SHA-256 (в изоляте нет crypto.subtle, а ждать его негде), добавляет метаданные:

meta.origin = 'https://duck.ai';

meta.stack = 'Errornat l (https://duck.ai/dist/duckai-dist/entry.duckai.<hash>.js:2:1446307)n...';

meta.duration = String(случайные_3_30_мс);

И только затем сериализует всё это в JSON, кодирует в base64 и отправляет заголовком. Да, meta.stack — это фальшивый stack trace, который должен выглядеть так, будто ошибка выпала внутри собственного бандла duck.ai. Строку entry.duckai.<hash>.js мы живьём выдираем регуляркой из главной страницы duck.a она меняется при каждой выкладке фронта. А duration мы подмешиваем, потому что честно: реальный браузер исполняет челлендж за десятки миллисекунд, и значение 0 выглядит подозрительно синтетическим.

Четыре вкуса: что именно проверяет челлендж

В момент нашего исследования актуальными были четыре варианта челленджа — мы прозвали их вкусами. Каждый комбинирует проверки по своему, но строительные блоки одни и те же. Разберём все четыре.

Вкус 1. UA и «jsa‑iframe»

Первый хеш тривиален: в client_hashes первым элементом уходит navigator.userAgent. А вот вторая проверка этого вкуса уже интереснее. Челлендж создаёт <iframe>, выставляет ему srcdoc и смотрит, как устроен его внутренний мир:

const iframe = document.createElement(‘iframe’);

iframe.srcdoc = ‘DuckDuckGo Fraud & Abuse’; // ← без комментариев

document.body.appendChild(iframe);

let result;

if (iframe.contentWindow && iframe.contentWindow.self && iframe.contentWindow.self.get) {

result = iframe.contentWindow.self.get.toString();

} else {

result = undefined;

}

document.body.removeChild(iframe);

return!!result;

Обратите внимание на строку DuckDuckGo Fraud & Abuse отдел борьбы с фродом DuckDuckGo буквально подписывает своё оружие. Сама проверка тонкая: в настоящем Chrome у contentWindow.self из srcdoc‑iframe есть свои особенности структуры, и манекен, который вернёт здесь что‑то отличное от реального браузера, немедленно палится.

Вкус 2. Математика HTML5-парсера

А вот это моя любимая проверка, потому что она бьёт по самому больному месту любого фейкового DOM: innerHTML в настоящем браузере это не «сохранённая строка», это результат полноценного HTML5-парсера:

const div = document.createElement(‘div’);

div.innerHTML = ‘<p><div></p><p></div’;

return String(0×11a0 + div.innerHTML.length div.querySelectorAll(‘‘).length);

Строка на входе намеренно сломанный HTML с незакрытыми тегами и оборванным концом (</div без закрывающей скобки!). Какой результат должен получиться в настоящем Chrome? Раскрутим по спецификации HTML5 («in body»):

  1. <p> — открыт параграф;

  2. <div> — div входит в список тегов, закрывающих открытый <p> (p‑closers in button scope): <p> закрывается, открывается <div>;

  3. </p> — закрывающего </p> при открытом <div> нет… но по спеке сиротский </p> синтезирует пустой <p></p> да, именно так делает настоящий браузер;

  4. <p> — открывается параграф внутри div;

  5. </div — обрыв посреди тега (EOF‑in‑tag) вся лексема отбрасывается, а открытый <p> дожимается на EOF.

Итог: innerHTML возвращает <p></p><div><p></p><p></p></div> и из этой нормализованной строки считается length, а querySelectorAll('*') считает узлы. Числа умножаются, прибавляется база 0x11a0 и любой фейковый DOM, который тупо хранит innerHTML как строку, выдаёт совершенно другое число. Серьёзно, попробуйте угадать: сколько узлов в этом мусоре на самом деле?

Второй вариант той же проверки использует строку '<div><div></div><div></div' и другую базу (0x25f4). Всё, что нужно чтобы парсер, сериализатор и счётчик узлов были побитово совместимы с Chrome.

Вкус 3. V8-фингерпринт

Третий вкус — дюжина проверок, каждая из которых по отдельности кажется тривиальной, а вместе образуют отпечаток конкретного JS‑движка. Полный список из деобфусцированного кода:

const bits = [];

const bits = [];

// 1. Нативные функции содержат [native code] в toString()

bits.push(nativeFn.toString().includes('[native code]'));

// 2. Субклассирование Array: map() возвращает экземпляр субкласса (спека V8)

class Sub extends Array {}

const arr = new Sub(1, 2, 3);

bits.push(arr.map(x => x * 2) instanceof Sub);

// 3. window.toString() === '[object Window]'

bits.push(Object.prototype.toString.call(window) === '[object Window]');

// 4. new Error() instanceof Error

bits.push(new Error() instanceof Error);

// 5. Error.captureStackTrace: undefined ИЛИ функция (V8-специфичное!)

bits.push(Error.captureStackTrace === undefined || typeof Error.captureStackTrace === 'function');

// 6. Object.isSealed(Math) — фолс в норме

bits.push(Object.isSealed(Math));

// 7. Sloppy-идиома: (function(){return this;})() === window

bits.push((function() { return this; })() === window);

// 8. children живой: после appendChild длина коллекции выросла на 1

const kids = document.body.children;

const len = kids.length;

const div = document.createElement('div');

document.body.appendChild(div);

bits.push(kids.length === len + 1);

document.body.removeChild(div);

// 9. querySelectorAll('*') НЕ массив...

const qsa = document.querySelectorAll('*');

bits.push(!Array.isArray(qsa));

// 10. ...а NodeList с точным именем конструктора

bits.push(qsa.constructor.name === 'NodeList');

// 11-13. Цепочка прототипов DOM-классов

bits.push(div instanceof HTMLDivElement);

bits.push(HTMLDivElement.prototype instanceof HTMLElement);

bits.push(HTMLElement.prototype instanceof Element);

return String(bits.map(Number).reduce((a, b) => a + b, 0x1b1e));

Здесь прячутся самые коварные ловушки:

  • Пункт 7 — легендарная sloppy‑идиома (function(){return this;})(). В настоящем браузере она возвращает window. Но если вы исполняете челлендж, скажем, в Node‑изоляте через new Function, она вернёт настоящий глобальный объект рантайма а не ваш фейковый window. Мы решаем это регэксп‑переписыванием исходника: (function(){return this;})() → (function(){return window;})() до исполнения. Грубо? Грубо. Работает? Работает.

  • Пункт 10 — constructor.name === 'NodeList'. Значит, наш манекен должен иметь класс, именованный ровно NodeList минификация и переименование сломают проверку. В коде это даже прокомментировано: // NOTE: named exactly "NodeList".

  • Пункт 3 — Object.prototype.toString.call(window) требует Symbol.toStringTag === 'Window' у объекта‑манекена, плюс класс Window с прототипом, замкнутым на сам объект.

  • Пункт 2 — если ваш Array это не настоящий V8-шный Array, map() на субклассе вернёт не то.

Вкус 4. Webdriver‑тройка

Последний блок охота на автоматизацию:

return String([

navigator.webdriver === false, // 1. явный флаг puppeteer/selenium

/* 2. iframe contentWindow.self.get — см. вкус 1 */

/* 3. поиск алиасов песочниц: */

Object.keys(window.top).filter(k =>

['Array','Object','Promise','Proxy','Symbol','JSON','Window'].some(base =>

k !== base && k.endsWith('_' + base) && window.top[k] === window.top[base]

)

).length > 0

].map(Number).reduce((a, b) => a + b, 0xcdb));

Пункт 3 — самый занимательный: челлендж ищет в глобальной области алиасы вида Array, Promise, _Window, равные настоящим конструкторам. Это классический артефакт JS‑песочниц, которые «оборачивают» глобальные объекты в подчёркнутые копии. Фактически DuckDuckGo держит в челлендже детектор популярных схем эмуляции браузера: если ваша песочница так делает вы уже спалились до остальных проверок.

Headless‑браузер без браузера

Теперь главное. Как исполнять челлендж, если у тебя нет браузера?

Классический ответ индустрии: поднимать настоящий headless Chrome (Puppeteer/Playwright). Это работает, но для серверлесс‑бота приговор: сотни мегабайт бинарников, секунды на холодный старт, память на каждый инстанс, CAPTCHA‑инфраструктура на каждый чих. Для лёгкого прокси это стрельба из пушки по воробьям.

Наш ответ радикальнее: зачем браузер, если челлендж проверяет лишь узкий набор фактов об окружении? Мы построили браузерный манекен — объектную модель, достаточно правдоподобную, чтобы челлендж не отличил её от Chrome. ~300 строк кода, ноль зависимостей, исполнение за миллисекунды.

Как это устроено

Ядро — функция buildBrowserMock(ua), которая строит два связанных объекта: «окно» (win) и «документ» (doc), плюс navigator, location, screen, localStorage, performance, заглушки таймеров, crypto, TextEncoder… Собственно исполнение:

const { params } = buildBrowserMock(ua); // window, document, navigator, btoa, ...

const argNames = Object.keys(params);

const argVals = argNames.map(k => params[k]);

const fn = new Function(...argNames, 'return (' + jsCode + ');');

let result = fn(...argVals); // челлендж исполняется!

if (result && typeof result.then === 'function') result = await result;

Заметьте элегантный трюк: окружение инжектится параметрами функции. Челлендж обращается к window, document, navigator — и получает наши объекты, потому что они замыкают настоящие глобалы изолята. Никакого vm.createContext, никакой изоляции просто область видимости функции, в которой «window» объявлен.

Дальше детали, каждая из которых родилась из конкретной проверки:

NodeList обязан называться NodeList. Класс назван ровно так, потому что челлендж читает constructor.name. Комментарий в коде: // NOTE: named exactly "NodeList" - challenges check constructor.name.

window обязан притворяться окном. Три хака в одном флаконе:

win.self = win; win.top = win; win.parent = win; win.window = win;

win[Symbol.toStringTag] = 'Window'; // → Object.prototype.toString.call(win) === '[object Window]'

win.Window = function Window() {};

win.Window.prototype = win; // → instanceof-проверки

innerHTML это парсер. Самая трудоёмкая часть (~100 строк) мини‑реализация HTML5-фрагментного парсера. При div.innerHTML = '<p><div></p><p></div' наш сеттер не сохраняет строку, а парсит её с соблюдением спецификации:

const VOID_TAGS = new Set(['br','img','hr','meta','input','link','area','base','col','embed','source','track','wbr']);

// Теги, закрывающие открытый <p> (button scope) - по спецификации "in body":

const P_CLOSERS = new Set(['address','article','aside','blockquote','center','details','dialog','dir','div',

'dl','fieldset','figcaption','figure','footer','header','hgroup','main','menu','nav','ol','p','section',

'summary','ul','h1','h2','h3','h4','h5','h6','pre','listing','form','li','dd','dt','plaintext','table','hr','xmp']);

const SCOPE_STOPPERS = new Set(['html','table','td','th','caption','applet','marquee','object','template','select','optgroup','option']);

плюс гейт‑кейсы: сиротский </p> синтезирует пустой <p></p>, оборванный тег (EOF-in-tag) отбрасывается целиком, комментарии пропускаются. Геттер возвращает нормализованную сериализацию дерева как настоящий браузер, который пересобирает разметку из DOM. Именно она даёт те самые числа, которые челлендж перемножает и сравнивает.

querySelectorAll — это движок. Весь селектор‑движок поддерживает три вида селекторов — *, tag, #id ровно то, что используют челленджи, и возвращает объект класса NodeList (см. выше). А children живой: геттер каждый раз отдаёт актуальную длину, поэтому проверка «appendChild увеличивает длину на 1» проходит.

Sloppy‑идиома чинится переписыванием исходника. До исполнения челлендж прогоняется через:

function normalizeChallengeSource(js) {

return String(js).replace(

/(s*functions*(s*)s*{s*returns+thiss*;?s*}s*(s*)s*)/g,

'(function(){return window;})()'

);

}

navigator правдоподобный обыватель. platform: 'Win32', vendor: 'Google Inc.', hardwareConcurrency: 4, deviceMemory: 8, webdriver: false, пустые plugins/mimeTypes, languages: ['en-US', 'en']. Значения согласованы с User‑Agent (Chrome 149 на Windows x64) рассинхрон между UA и navigator‑ом тоже палится в реальных антиботах.

Честность таймингов. После исполнения в meta.duration пишется реальное время работы (с нижней планкой 3–30 мс): «0 мс» выглядит синтетикой.

А если челлендж упал? Оборачиваем всё в try/catch и внимание всё равно формируем токен: сериализуем исходник челленджа, текст ошибки, стек и origin в строку и кодируем в base64 (флаг fallback: true). Это не шаманство: так делал клиент‑протокол первого поколения, и сервер какое‑то время принимал такие «сломанные» ответы. Сегодня это аварийный люк: даже если DDG добавят проверку, которую мы пока не фейкаем, бот не умрёт мгновенно, а метрика fallbacks в /stats покажет, что пора патчить манекен.

Что происходит дальше

Решённый токен отправляется заголовком x-vqd-hash-1 в POST‑запросе, и если сервер доволен начинается SSE‑стрим. Тело запроса тоже небанально:

const body = {

model, // 'gpt-5.4-mini', 'claude-haiku-4-5', ...

metadata: {

toolChoice: { GenerateImage: !!image, WebSearch: !!webSearch,

NewsSearch: false, VideosSearch: false,

LocalSearch: false, WeatherForecast: false },

},

messages: [...], // история диалога

canUseTools: true,

reasoningEffort: 'none' | 'low' | 'medium',

canDelegateImageGeneration: image ? true : null,

durableStream: await makeDurableStream(), // RSA-ключи для канала догоняющего стрима

};

toolChoice — это врубалки тулов (генерация картинок, веб‑поиск), которые мы переключаем фичами бота, а durableStream генерит RSA‑OAEP‑пару ключей (или берёт зашитый fallback‑JWK, если crypto.subtle недоступен). SSE‑ответ парсится построчно: data: {"message": "..."} дельты текста, data: {"name": "generate-image", ...} прогресс генерации картинок (заголовок → превью → финал в base64), data: [DONE] — конец.

Не хешем единым: fe‑signals, fe‑version и прочие мелочи

Челлендж центральная, но не единственная проверка. Протокол ожидает ещё два заголовка, без которых тоже можно словить 418.

x-fe-signals это base64-сжатый лог телеметрии «сессии человека». Настоящий duck.ai‑фронт пишет события: пользователь увидел онбординг, кликнул, начал чат. Наш клиент генерирует правдоподобный лог:

{

start: now - end,

events: [

{ name: 'onboarding_impression', delta: 50..150 },

{ name: 'action', delta: +5..30 сек, trusted: true },

{ name: 'onboarding_finish', delta: ... },

{ name: 'startNewChat_free', delta: ... },

],

end: 0..10

}

То есть мы имитируем человека, который «открыл страничку, повнимал на онбординг, нажал Start a new chat» с таймингами, случайно разбросанными в человеческих пределах. Поле trusted: true телеметрия «настоящего» пользовательского действия. Захардкодить один и тот же лог на все запросы нельзя тайминги должны различаться, как различаются реальные сессии.

x-fe-version версия сборки фронта. Вот тут начинается самое интересное для вопроса «как DDG меняет антибот». Значение выглядит так:

serp_20261021_143533_ET-add6453709b9fbf89b2650161444a93199480dda

Присмотритесь: 20261021 дата. DuckDuckGo выкатывает новый билд фронта чуть ли не каждый день, и каждый билд несёт новую версию‑тег, новый entry.duckai.<hash>.js и (потенциально) новый набор проверок в челлендже. Поэтому мы не хардкодим версию: бот периодически (кэш на 25 минут) скачивает главную страницу duck.ai и выдирает data-version-tag и data-version-sha из HTML, а также свежий URL entry‑бандла для фальшивого stack trace. Любая выкладка фронта подхватывается автоматически, без релиза.

Мелочи, без которых тоже не взлетает: cookie dcm=3 (судя по всему, consent‑флаг DDG), заголовок priority: u=1, i (браузерный приоритет fetch), корректная связка origin/referer/sec-fetch-*, и cache-control: no-store. По отдельности каждая мелочь может и не проверяется но отклонение любой из них делает отпечаток клиента менее правдоподобным, а на фоне тысяч запросов статистика не врёт.

Гонка вооружений: как DuckDuckGo часто меняет антибот

Постоянно. Вот что мы наблюдали за время жизни проекта:

  • Ежедневные сборки фронта. serp_20261021_... в версии‑теге это буквально сборка от 21 октября 2026. Вместе с билдом меняется entry‑бандл (следовательно, «правильный» stack trace), а раз в несколько недель и сам челлендж.

  • Ротация обфускации. Таблицы строк, индексные базы (0x181 в одном варианте, 0x136 в другом), контрольные суммы ротатора всё это пересобирается при каждой выкладке. Деобфускатор, привязанный к конкретным индексам, живёт ровно до следующего билда.

  • Ротация комбинаций проверок. Четыре известных нам вкуса это разные миксы блоков (iframe, innerHTML‑математика, V8-фингерпринт, webdriver‑детектор) с разными базами. Очевидно, сервер раздаёт варианты динамически вероятно, и A/B‑тестирует новые проверки, выдавая их подмножеству клиентов.

  • Ужесточение протокола. Исторически хватало токена x-vqd-4 (просто число из status‑ответа). Потом появился x-vqd-hash-1 с исполняемым челленджем — и всё поколение прокси, которое не умело исполнять JS, получило ту самую 500 no x-vqd-hash-1 token. Дальше добавилась телеметрия x-fe-signals, сверка x-fe-version… Тренд очевиден: каждое полугодие новый слой.

Почему мы всё ещё живы? Потому что наша архитектура построена на единственно правильной для такой гонки идее: мы не хардкодим ответы — мы исполняем их собственный код против фейкового окружения.

Смотрите на разницу подходов. Прокси, которые хардкодят: «на проверку X отвечай числом 4640» умирают при каждой смене базы или комбинации. Мы же скармливаем челлендж манекену, и челлендж сам вычисляет ответ из нашего окружения. Пока новые проверки спрашивают о вещах, которые наш манекен уже умеет правдоподобно изображать (DOM‑парсинг, прототипы, имена конструкторов, тайминги), смена вкусов и баз проходит для нас незамеченной. Смена типа проверок (например, добавят Canvas/WebGL‑фингерпринт или WASM‑вычисления) потребует патча, но точечного, а не переписывания всего клиента с нуля.

Вторая половина живучести self‑healing. Токен кэшируется на два уровня: L1 в памяти изолята (~9 минут), L2 в SQLite‑таблице config (переживает рестарт изолята). При 418/429 бот молча выкидывает токен, заново решает челлендж и повторяет запрос (до 4 попыток с нарастающей задержкой). Пользователь этого не видит ему просто приходит ответ на секунду позже. А все события пишутся в метрики: в /stats видно по дням, сколько челленджей решено, сколько 418 отработано, сколько запросов ушло в fallback.

Где реальные пределы этой схемы? Честно:

  1. TLS/HTTP2-фингерпринт. Мы контролируем заголовки, но не конвейер TLS. Если DDG начнут сверять JA3/JA4-отпечаток соединения с заявленным Chrome 149 эмуляция чистым fetch перестанет проходить. Пока (по нашему опыту) на duck.ai этого нет.

  2. IP‑репутация. Бесплатный лимит у DDG привязан в том числе к IP; изолят с общим адресом быстрее упирается в квоты (429), чем домашний браузер. Антибот тут ни при чём это просто честная квота.

  3. Каждый новый DOM API — новый патч. Если челлендж завтра попросит canvas.getContext('2d').measureText(...), придётся рисовать ещё один кусок манекена. Фундамент (парсер, прототипы, NodeList) уже стоит докладывать кирпичи проще, чем строить дом заново.

Живой трейс: цифры дня публикации

Чтобы не быть голословным, прогнали весь цикл прямо перед написанием статьи (22 октября 2026, холодный изолят):

GET /duckchat/v1/status → HTTP 200

challenge (x-vqd-hash-1) → 5408 байт base64

начало (декодированное): (async function(){const 0x111979=0x...

solveChallenge() → 580 мс, fallback=false

токен (base64 JSON) → 952 байта

GET /duck.ai/ (fe-meta) → feVersion serp_20261021_143533_ET-add64...

entry.duckai.b64cc6ca5bb8451f4f08.js

GET /duckchat/v1/models → 10 моделей (6 free, 4 premium)

POST /duckchat/v1/chat

попытка 1 → 418 (холодная сессия)

попытка 2 (новый челлендж) → 418

попытка 3 (новый челлендж) → 418

попытка 4 (новый челлендж) → 200 OK, SSE

ответ модели → "pong" за 1.64 с суммарно

Любопытная деталь: первые три попытки с корректно решённым челленджем всё равно словили 418. Это «прогрев» сессии сервер, судя по всему, какое‑то время не доверяет freshly‑выданным токенам с незнакомого клиента (репликация токена между нодами? накопление статистики по путешествию?). Важно другое: ретраи с пересолом челленджа это лечат автоматически, и на тёплой сессии (токен из L2-кэша) первый же запрос проходит с ходу. Мы не знаем точной серверной логики но протокол сам подсказывает, как с ним ладить.

Итого вся цепочка «сообщение в Telegram → ответ модели»: ~2.5 секунды на холодном изоляте, ~1 секунда на тёплом. Стриминг в Телеграм начинается с первым же дельта‑чанком.

Потрогать руками

Если дочитали досюда у нас есть подарок: всё описанное упаковано в опенсорс‑проект headless‑duckapi. Это тот самый бот целиком: протокольный драйвер с браузерным манекеном, Telegram‑обвязка со стримингом, генерация картинок, vision, память диалога, rich‑text настройки и метрики.

Внутри:

  • lib/duckai.js герой этой статьи: клиент duck.ai + headless‑браузер, ~950 строк без единой npm‑зависимости (свой SHA-256, свой base64, свой HTML5-парсер);

  • Telegram‑часть: хендлеры, стриминговый рендер ответов, /img с черновиками‑превью, конверсия JPEG→PNG для фото;

  • деплой в двух режимах: serverless‑платформа Telegram (BotFather → Serverless) или обычный Node‑процесс (Docker Compose / systemd в комплекте);

  • единственная обязательная переменная окружения BOT_TOKEN.

Деплой занимает минуты: git clone → вписать токен бота из BotFather → docker compose up (или node .). Никаких API‑ключей от OpenAI/Anthropic не нужно модели оплачивает DuckDuckGo, вы лишь притворяетесь браузером. Релиз v1.0.0 уже в releases, звёздочки и issues с новыми «вкусами» челленджа приветствуются если DDG что‑то подкрутили, чинить будем вместе.

Автор: HabrUsernotbot

Источник