- Регистрация
- 24.12.2024
- Сообщения
- 72
- Реакции
- 170
- Баллы
- 33
Централизованный ИИ-Оркестратор:
экономия до 90%+ бюджетов на LLM API в многопоточных проектах ZennoPoster
Полный разбор эталонного шаблона: что в нём демонстрационное, а что переносится в любой другой проект один в один
Как перестать платить нейросети за то, что она уже решала тысячу раз
экономия до 90%+ бюджетов на LLM API в многопоточных проектах ZennoPoster
Полный разбор эталонного шаблона: что в нём демонстрационное, а что переносится в любой другой проект один в один
Как перестать платить нейросети за то, что она уже решала тысячу раз
1. Парадокс ИИ в многопотоке: почему «умный» скрипт может разорить проект
У любого, кто пробовал прикрутить LLM к ZennoPoster напрямую — «в лоб», на каждый поток — рано или поздно происходит одно и то же: счёт за API начинает расти быстрее, чем количество успешно обработанных аккаунтов.Причина проста и почти всегда игнорируется на старте. ZennoPoster — это по определению многопоточная среда, где десятки или сотни независимых потоков выполняют одну и ту же логику, на одинаковых (или почти одинаковых) версиях интерфейса сайта. Если ИИ-агент подключён напрямую к каждому потоку:
- Поток №1 находит кнопку «Отправить» через LLM — платим за вызов.
- Поток №2 через секунду сталкивается с той же самой кнопкой на том же макете — снова платим за вызов, хотя ответ будет идентичным.
- Поток №50 — то же самое. И поток №100. И так на каждом шаге сценария, где применяется распознавание элемента.
Получается математика, которая убивает экономику проекта: N потоков × M точек принятия решений × стоимость одного вызова = кратный, а не линейный рост расходов. При этом мы платим не за интеллект, а за повторение уже известного решения.
К финансовой стороне добавляются ещё три проблемы, знакомые каждому, кто пытался так делать:
- Rate-limit и очереди. У большинства провайдеров жёсткие лимиты RPM/TPM. Сто параллельных потоков, одновременно бомбардирующих API одним и тем же вопросом, упираются в лимиты быстрее, чем успевают решить хоть одну реальную задачу.
- Задержка. LLM, тем более мультимодальная (со скриншотом), отвечает секундами, а не миллисекундами. Умножьте это на количество точек вызова в сценарии — и скорость всего проекта начинает определяться не логикой ZennoPoster, а очередью к нейросети.
- Недетерминированность там, где она не нужна. Задача «найти координаты кнопки на уже знакомом макете» — не творческая, а справочная. Гонять для неё LLM каждый раз — то же самое, что каждый раз заново гуглить то, что уже лежит у вас в закладках.
Главный тезис этой статьи: ИИ-агент должен думать только в двух случаях — когда произошёл сбой сценария или когда поток встретил ранее не виденное состояние интерфейса. Всё остальное — рутинное, уже решённое действие — должно выполняться без единого обращения к LLM, по кэшированному рецепту.
2. Orchestrator Pattern: разделяем «руки» и «голову»
Решение — архитектурно развести две сущности, которые физически можно упаковать в два разных проекта ZennoPoster:| Основной проект (Worker) | ИИ-Оркестратор | |
|---|---|---|
| Роль | Быстрое выполнение 95% рутинных действий: клики, ввод текста, навигация по стандартному C#/HTTP/Browser-шаблону | Центральный «мозг», к которому обращаются только при нештатной ситуации |
| Частота обращения к LLM | Практически никогда | Только на cache-miss |
| Что делает при сбое | Останавливается на нестандартном элементе, собирает контекст | Получает DOM + скриншот + описание цели, ищет решение |
| Что делает после решения | Забирает готовый ответ (координаты/шаблон действия) и продолжает работу | Записывает найденное решение в общую базу знаний (KB) |
Код:
Поток встречает шаг сценария
│
▼
Строим "снимок" текущего состояния страницы (DOM-карта + структурный отпечаток)
│
▼
Ищем отпечаток в базе знаний (KB) ──── Найден? ──yes──▶ Берём готовый рецепт, кликаем, едем дальше (без ИИ)
│
no
▼
Обращаемся к ИИ-Оркестратору (передаём DOM + скриншот + описание цели)
│
▼
ИИ находит решение (координаты элемента / шаблон действия)
│
▼
Решение записывается в KB под хэшем текущего состояния
│
▼
Все остальные потоки (и даже другие проекты), встретив тот же макет,
находят готовый ответ в KB и больше никогда не платят за этот шаг
Это шаблон, а не готовый бот
Проект, разобранный в этой статье, — это эталонный шаблон-паттерн, приложенный как демонстрация принципа: как связать ZennoPoster, файловую базу знаний и внешний ИИ-скрипт так, чтобы обращение к LLM происходило только на cache-miss. Конкретное действие, которое выполняет поток после того, как координаты найдены (в приложенном примере — клик по полю ввода и по полю поиска), — это условный демонстрационный шаг, а не факт, что шаблон умеет только это. Сама связка Preднастройка → сбор DOM → отпечаток → сравнение с KB → (опционально) вызов ИИ → клик → сохранение решения — не завязана на конкретный сайт, конкретную задачу или конкретное действие: вместо клика на её месте может быть ввод текста, выбор из выпадающего списка, свайп, проверка появления элемента — что угодно, для чего нужно определить координаты или состояние экрана.
3. Практическая универсальность: паттерн, а не «костыль под один проект»
Важный момент, который стоит подчеркнуть отдельно: описанная связка «Мэппер → Отпечаток → Сравнение с KB → (опционально) ИИ → Сохранение» — это не набор функций, зашитых под конкретный сценарий, а самостоятельный переиспользуемый модуль.Он оперирует универсальными сущностями — «страница», «интерактивный элемент», «структурный отпечаток», «база знаний по задаче» — и не знает ничего о бизнес-логике конкретного проекта. Благодаря этому один и тот же комплект C#-сниппетов:
- подключается к любому существующему шаблону ZennoPoster простым добавлением блоков в точку, где сценарий может столкнуться с нестандартным элементом;
- масштабируется на любое число параллельных проектов — они делят одну и ту же папку AiKbFolder на диске (или сетевой ресурс), поэтому решение, найденное в одном проекте, становится доступно любому другому, если там встретится тот же макет интерфейса;
- разделяется по задачам через AiTaskId — у каждого логического шага сценария («найти поле логина», «найти кнопку публикации») своя изолированная «полка» в базе знаний, что исключает коллизии между непохожими задачами с похожей геометрией.
Иными словами, инфраструктуру внедряют один раз, а дальше просто «подтягивают» её к новым ботам — это готовый защитный слой на все случаи, когда сайт меняет вёрстку, добавляет A/B-тест или показывает нестандартный попап.
4. Пошаговый разбор эталонного проекта — от Start до следующего действия
Ниже — разбор реального проекта ZennoPoster, приложенного как рабочий шаблон. Он состоит из восьми именованных блоков, пронумерованных на холсте самим автором проекта (1–
. Каждый блок разобран по фактическому содержимому скриншотов — без домысливания того, чего не видно.
. Каждый блок разобран по фактическому содержимому скриншотов — без домысливания того, чего не видно.Общий вид блоков 1–5: Преднастройка → Начало → Обработка при ошибках → Строит стабильный «отпечаток» → Сравнивает текущий AiDomFingerprint с базой знаний
4.1. Блок 1 — Преднастройка
Первый блок готовит окружение потока перед началом цикла и состоит из четырёх шагов подряд:- «очередь многопоток очистка» — файловая операция записи в служебный файл zp_python\очередь многопоток.txt. Судя по месту в цепочке (самый первый шаг, до старта цикла) и по аналогичным блокам дальше по сценарию (см. блоки 6 и 7), это сброс общего файла-флага в исходное состояние — механизм, которым разные потоки координируют доступ к разделяемому ресурсу (ниже, в блоке 6, видно, как этот же файл выставляется в «1» перед обращением к ИИ).
- «установка разрешения экрана» (C#) — фиксирует размер окна/вьюпорта браузера. Это важно именно для данного паттерна: координаты в базе знаний считаются относительно вьюпорта (см. AI DOM Fingerprint Builder, сетка 24×24 строится от vw/vh), поэтому если разные потоки будут работать с разным разрешением окна, один и тот же макет интерфейса будет давать разные ячейки сетки — и попадать в кэш будет хуже. Фиксация разрешения — простой способ держать эту переменную постоянной.
- Переход по стартовому URL (https://www.google.com/ в примере) — в приложенном шаблоне это условный, демонстрационный адрес; в реальном проекте здесь стоит ваш собственный стартовый URL.
- «Пауза 10 – 25 с» — случайная пауза перед стартом, обычный приём против слишком единообразного, легко детектируемого паттерна запуска потоков.
После этих четырёх шагов идёт отдельный зелёный блок-комментарий: «AiTaskId устанавливаем в 1, затем с каждым действием увеличиваем id» — это не код, а текстовая заметка внутри проекта, документирующая конвенцию: AiTaskId — это номер текущего шага сценария внутри потока, и он растёт на 1 при каждом переходе к следующему действию (это подтверждается в блоке 8 — см. далее).
4.2. Блок 2 — Начало
Это шаблон, а не готовый бот
Это и есть та самая условная, демонстрационная часть шаблона. Судя по содержимому блока, поток выполняет два generic-клика: click по полю, куда до этого было записано значение переменной «Клик по полю для ввода переменной», с проверкой {text}=="" (если целевой текст пуст — уходит в алерт «Запрос пуст» вместо клика) и эмуляцией клавиатуры для ввода; и второй такой же click, но по полю «Клик по поиску в режиме ии переменной». Оба действия — не более чем пример того, ЧТО можно делать после того, как координаты элемента найдены. На месте этих двух кликов в вашем проекте может быть любое другое действие: заполнение формы, выбор пункта меню, свайп, проверка текста — логика поиска координат (блоки 3–6) от этого не меняется.
Технически важная часть блока — не сами клики, а сопровождающий их инкремент AiTaskId увеличиваем id на 1 после каждого клика: именно этот счётчик потом определяет, в какую «полку» базы знаний (раздел, привязанный к AiTaskId) попадёт отпечаток текущего шага, и он должен расти на единицу при переходе от одного логического действия к другому — иначе, скажем, отпечаток поля ввода и отпечаток кнопки поиска будут конкурировать за одну и ту же полку в KB.
4.3. Блок 3 — Обработка при ошибках: сбор DOM
Когда поток не может продолжить штатно (например, не находит элемент обычным способом ZennoPoster), управление уходит в блок «Обработка при ошибках», состоящий из двух C#-действий подряд: «Собирает ВСЕ элементы и атрибуты» и `AidomPreprocessor`. Первое — это сниппет AI DOM Mapper, который собирает полную карту страницы (координаты, атрибуты, роли — полный разбор в разделе 5.1). Второе — препроцессор, который готовит карту к дальнейшей работе (в частности, к тому формату, который ожидает AI DOM Fingerprint Builder на следующем шаге — раздел page.viewport, разделение по типам элементов).
4.4. Блок 4 — Строит стабильный «отпечаток» текущей ситуации на странице
Один шаг, один C#-сниппет — AI DOM Fingerprint Builder. Раздел 5.3 разбирает его построчно: карта DOM превращается в список нормализованных сигнатур (tag|kind|role=...|lbl=...|pos=bx:by|sz=...), они сортируются и хэшируются в SHA-256 — итоговый хэш и есть «отпечаток» текущего состояния интерфейса.
4.5. Блок 5 — Сравнивает текущий AiDomFingerprint с базой знаний (KB на диске) в разрезе
Один C#-сниппет — AiDomCompareAndFetch (полный разбор в разделе 5.4). Результат — булева переменная {AiMatchFound}. Ветка {AiMatchFound}==false — единственная, ведущая к блоку 6 («Обработка с ИИ»); во всех остальных случаях (точное или нечёткое совпадение найдено) поток получает готовое решение из KB и переходит сразу к блоку 7, ни разу не обратившись к LLM.
4.6. Блок 6 — Обработка с ИИ (только на cache-miss)
Самый насыщенный блок всего проекта — то немногое, что действительно стоит денег, и то, что происходит только тогда, когда ни точное, ни нечёткое совпадение с базой знаний не нашлось.
Блок 6 «Обработка с ии»: от установки лок-файла до опроса результата click_analiser.py
- «очередь многопоток 1 в файл» — запись «Записать текст» в тот же файл zp_python\очередь многопоток.txt, но теперь со значением 1 (подтверждено скриншотом свойств действия — путь к файлу, содержимое «1», без дозаписи). По месту в сценарии — прямо перед обращением к ИИ — это, судя по всему, отметка «сейчас идёт обращение к ИИ», использующая простую файловую переменную как индикатор состояния между потоками.
- «вызов ии» — уведомление/лог-маркер о том, что сценарий заходит в ветку ИИ.
- «Сохранение изображения» + «Записать в файл» — сохраняется скриншот активной вкладки (screenshot.png — именно этот путь ожидает click_analiser.py) и DOM-карта в файл (dom.json).
- «Записать в файл» — формируется target.txt с текстовым описанием того, что нужно найти на экране (этот файл читает click_analiser.py, если не передан аргумент командной строки).
- Проверка {agent_api_key}=="" — если ключ API не заполнен, срабатывает блок-стоп «Заполните переменную с api ключом ({-Variable.agent_api_key-})» — сценарий не пытается выполнить запрос без ключа, а сразу и явно останавливается с понятным сообщением.
- Если ключ заполнен (пунктирная ветка вниз) — группа из трёх действий: «Установить значение openrouter_gemini переменной» (пишет строку openrouter_gemini в переменную ACTIVE_PROFILE — подтверждено отдельным скриншотом свойств действия), «Click analiser» (записывает содержимое click_analiser.py — целиком, включая заголовок «CLICK ANALYSER v3.0» — в переменную prv, что подтверждено скриншотом свойств: в поле «Значение» виден полный текст скрипта) и «Запуск программы» (исполняет сохранённый на диск файл интерпретатором Python).
- Правая группа: «Click analiser» / «Click analiser_check» / {prv}=='{prv_1}' — судя по названиям и положению рядом с «Запуск программы», это опрос завершения внешнего процесса: сравнение содержимого переменных prv/prv_1 — обычный в ZennoPoster приём поллинга, когда нет прямого события «процесс завершился», и вместо этого сравнивается состояние до/после с паузой между проверками.
- «Прочитать файл» → «ToLower» → «Regex» × 2 — после того как click_analiser.py записал coordinates.txt, ZennoPoster читает его, приводит к нижнему регистру (чтобы X=/x= не имели значения для парсинга) и двумя регулярными выражениями вытаскивает числовые значения X и Y.
Свойства действия «Установить значение openrouter_gemini переменной» — подтверждает, что `ACTIVE_PROFILE` в этом примере жёстко выставлен в `openrouter_gemini` (см. раздел 5.5, тот же ключ есть в `PROFILES`)
Свойства действия «Click analiser» — всё содержимое `click_analiser.py` v3.0 хранится и подставляется как текстовая переменная `prv` перед записью на диск и запуском
Свойства действия «очередь многопоток 1 в файл»: обычный блок «Записать текст», без дозаписи, в файл `{-Project.Directory-}zp_python\очередь многопоток.txt`, содержимое — цифра «1»
Как в скрипт попадают ACTIVE_PROFILE и agent_api_key:
В тексте click_analiser.py (раздел 5.5) эти два места записаны не как обычные Python-строки, а как шаблонные плейсхолдеры ZennoPoster — ACTIVE_PROFILE = "{-Variable.ACTIVE_PROFILE-}" и "openrouter": "{-Variable.agent_api_key-}". Когда ZennoPoster читает переменную prv (в которой лежит весь текст скрипта) для записи в файл, он автоматически подставляет вместо {-Variable.ИмяПеременной-} текущее значение соответствующей переменной проекта — то есть сам .py-файл на диске оказывается уже с готовыми, подставленными значениями, без отдельного шага замены текста внутри C#.
4.7. Блок 7 — Выполняем клик
Блок 7 «Выполняем клик» и блок 8 «Переходим на следующее действие»: обе ветки (KB-hit слева, AI-miss справа) сходятся к одному и тому же клику
В этот блок ведут две ветки, и обе заканчиваются одинаково:- Ветка KB-hit (совпадение нашлось в блоке 5): алерт «нашли ответ {-Variable.AiSolutionJson-}» → две Regex-операции извлекают X/Y прямо из сохранённого в KB решения.
- Ветка AI-miss (координаты только что нашёл click_analiser.py, блок 6): после ToLower и двух Regex результат записывается «Установить значение {Variable.coordinates} переменной», затем идёт «Сниппет 3: скелет (сохранение нового решения)» — это и есть AI DOM Save Solution (раздел 5.6), запускается именно здесь, только на пути, где ИИ только что нашёл новое решение — так KB пополняется только настоящими cache-miss, а не повторно на каждый cache-hit. Следом идёт «очередь многопоток очистка» — тот же файл zp_python\очередь многопоток.txt, что был установлен в «1» в начале блока 6; здесь, судя по всему, он возвращается в исходное состояние, сигнализируя, что обращение к ИИ завершено.
Обе ветки сводятся к одной паре действий: «Установить значение 0 переменной» и C#-сниппет с описанием «snippet that moves the cursor down from the initial position, pauses, then mov...» — это полностью совпадает с приложенным отдельно рабочим фрагментом клика со сдвигом (полный текст — раздел 5.7). Он не просто кликает по найденным координатам, а сначала перемещает курсор к точке, делает паузу, затем сдвигается на случайную величину по горизонтали (rndd — переменная со случайным смещением) и только после этого выполняет сам клик — обычный приём для того, чтобы траектория курсора не была идеально прямой линией «телепорт → клик».
4.8. Блок 8 — Переходим на следующее действие
Финальный блок инкрементирует счётчик AiTaskId через встроенный список переходов (Default / 0: 1 / 1: 2) — судя по конвенции из блока 1 («AiTaskId устанавливаем в 1, затем с каждым действием увеличиваем id»), это переход к следующему шагу сценария: следующий клик, следующее поле, следующая логическая точка, где снова может понадобиться распознавание элемента — и цикл «собрать DOM → построить отпечаток → сравнить с KB → (при необходимости) ИИ → клик» начинается заново, уже под новым AiTaskId, то есть на своей собственной, изолированной полке базы знаний.
5. Полный код всех компонентов — по полочкам
Вся связка состоит из шести файлов: пяти скриптов, которые исполняются внутри ZennoPoster (пять C#-сниппетов — из них три делят один и тот же служебный блок разбора JSON, вынесенный отдельно, чтобы не повторять его трижды), и одного внешнего Python-скрипта, вызывающего LLM. Порядок ниже — порядок их появления в цикле: Mapper → (общий MiniJson) → Fingerprint Builder → Compare & Fetch → click_analiser.py → Save Solution → клик со сдвигом.
5.1. AI DOM Mapper v2 — «глаза» системы
Собирает с активной вкладки полную карту интерфейса — не просто текст, а координаты, размеры и атрибуты каждого узла, включая Shadow DOM. Выполняется как JS прямо в браузере ZennoPoster и выгружает результат через скрытый textarea, чтобы обойти ограничения на размер возвращаемого значения из EvaluateScript. Координаты cx/cy — это абсолютные координаты страницы (с учётом прокрутки), а не координаты вьюпорта — поправка на скролл происходит позже, в click_analiser.py (раздел 5.5).
C#:
// ==============================================================================
// Скрипт: Универсальный AI DOM Mapper для ZennoPoster
// Версия: 2.0 — Полная карта страницы для ИИ-агента
// Собирает ВСЕ элементы с координатами, размерами и всеми доступными атрибутами
// Включает: Light DOM + Shadow DOM + iframe (если доступен)
// ==============================================================================
// Проверяем наличие переменной в проекте
if (!project.Variables.Keys.Contains("AiDomTree"))
{
project.SendErrorToLog(
"КРИТИЧЕСКАЯ ОШИБКА: Создайте переменную 'AiDomTree' во вкладке 'Переменные' проекта!",
true
);
throw new Exception("Переменная AiDomTree отсутствует в проекте.");
}
try
{
// --- Ожидаем полной загрузки страницы ---
if (instance.ActiveTab.IsBusy)
instance.ActiveTab.WaitDownloading();
System.Threading.Thread.Sleep(1500);
if (instance.ActiveTab.IsVoid || instance.ActiveTab.IsNull || instance.ActiveTab.MainDocument == null)
throw new Exception("Вкладка или документ не инициализированы.");
var bodyCheck = instance.ActiveTab.FindElementByXPath("//body", 0);
if (bodyCheck.IsVoid)
throw new Exception("Тег <body> не найден. Страница пуста или не успела отрендериться.");
// ===========================================================
// ГЛАВНЫЙ JS-СКРИПТ: Сбор всех элементов и их атрибутов
// ===========================================================
string jsScript = @"
(function() {
try {
if (!document.body) return 'Error: no body';
// Теги, которые не несут визуальной ценности для ИИ-агента
const IGNORE_TAGS = new Set([
'SCRIPT','STYLE','NOSCRIPT','META','HEAD','LINK',
'PATH','DEFS','SVG','BR','WBR','TEMPLATE'
]);
// Интерактивные теги / роли
const INTERACTIVE_TAGS = new Set([
'A','BUTTON','INPUT','SELECT','TEXTAREA',
'DETAILS','SUMMARY','LABEL','OPTION','OPTGROUP'
]);
const INTERACTIVE_ROLES = new Set([
'button','link','menuitem','tab','checkbox','radio',
'switch','combobox','listbox','option','treeitem',
'slider','spinbutton','searchbox','textbox','gridcell'
]);
let elements = [];
let idCounter = 0;
// -------------------------------------------------------
// Вспомогательные функции
// -------------------------------------------------------
function safeStr(v, max) {
if (!v) return undefined;
let s = String(v).replace(/\s+/g,' ').trim();
return s.length ? s.substring(0, max || 300) : undefined;
}
function getRect(el) {
try {
const r = el.getBoundingClientRect();
return {
x: Math.round(r.left + window.scrollX),
y: Math.round(r.top + window.scrollY),
w: Math.round(r.width),
h: Math.round(r.height),
cx: Math.round(r.left + window.scrollX + r.width / 2),
cy: Math.round(r.top + window.scrollY + r.height / 2)
};
} catch(e) {
return { x:0, y:0, w:0, h:0, cx:0, cy:0 };
}
}
function isVisible(rect) {
return rect.w > 0 && rect.h > 0;
}
function getComputedProps(el) {
try {
const cs = window.getComputedStyle(el);
return {
display: cs.display,
visibility: cs.visibility,
opacity: cs.opacity,
zIndex: cs.zIndex !== 'auto' ? cs.zIndex : undefined,
position: cs.position !== 'static' ? cs.position : undefined,
cursor: cs.cursor !== 'auto' ? cs.cursor : undefined,
fontSize: cs.fontSize,
color: cs.color,
bgColor: cs.backgroundColor !== 'rgba(0, 0, 0, 0)' ? cs.backgroundColor : undefined,
overflow: (cs.overflow !== 'visible') ? cs.overflow : undefined
};
} catch(e) { return {}; }
}
// -------------------------------------------------------
// Основная функция обхода DOM
// -------------------------------------------------------
function traverse(el, depth, shadowDepth, parentId) {
if (!el || el.nodeType !== 1) return;
let tag = el.tagName ? el.tagName.toUpperCase() : '';
if (IGNORE_TAGS.has(tag)) return;
// Пропускаем служебные элементы самого скрипта
if (el.id && el.id.startsWith('zp_')) return;
let rect = getRect(el);
let nodeId = ++idCounter;
// --- Определяем тип элемента ---
let role = el.getAttribute('role') || '';
let type = el.getAttribute('type') || '';
let isInteractive = INTERACTIVE_TAGS.has(tag)
|| INTERACTIVE_ROLES.has(role)
|| el.getAttribute('onclick') != null
|| el.getAttribute('tabindex') != null
|| el.contentEditable === 'true';
let isImage = tag === 'IMG' || tag === 'PICTURE' || tag === 'CANVAS' || tag === 'VIDEO';
let isText = tag === 'P' || tag === 'H1' || tag === 'H2' || tag === 'H3'
|| tag === 'H4' || tag === 'H5' || tag === 'H6' || tag === 'SPAN'
|| tag === 'LI' || tag === 'TD' || tag === 'TH' || tag === 'CAPTION'
|| tag === 'BLOCKQUOTE' || tag === 'FIGCAPTION' || tag === 'LABEL';
let isForm = tag === 'FORM';
let isNav = tag === 'NAV' || tag === 'HEADER' || tag === 'FOOTER'
|| tag === 'ASIDE' || tag === 'MAIN' || tag === 'SECTION' || tag === 'ARTICLE';
// --- Собираем объект ---
let obj = {
id: nodeId,
parentId: parentId || null,
tag: tag.toLowerCase(),
depth: depth
};
if (shadowDepth > 0) obj.shadowDepth = shadowDepth;
// Позиция и размеры — САМОЕ ВАЖНОЕ для ИИ-агента
obj.x = rect.x;
obj.y = rect.y;
obj.w = rect.w;
obj.h = rect.h;
obj.cx = rect.cx; // центр X — удобно для клика
obj.cy = rect.cy; // центр Y — удобно для клика
obj.visible = isVisible(rect);
// --- Тип узла ---
if (isInteractive) obj.interactive = true;
if (isImage) obj.isImage = true;
if (isText) obj.isText = true;
if (isForm) obj.isForm = true;
if (isNav) obj.isNav = true;
// --- Текстовое содержимое ---
let innerText = safeStr(el.innerText || el.textContent, 400);
if (innerText) obj.text = innerText;
// --- Атрибуты ---
if (el.id) obj.elId = el.id;
if (el.name) obj.name = el.name;
if (el.className && typeof el.className === 'string')
obj.cls = safeStr(el.className, 150);
if (el.href) obj.href = safeStr(el.href, 300);
if (el.src) obj.src = safeStr(el.src, 300);
if (el.alt) obj.alt = safeStr(el.alt, 200);
if (el.title) obj.title = safeStr(el.title,200);
if (el.placeholder) obj.ph = safeStr(el.placeholder, 200);
if (el.value !== undefined && el.value !== '')
obj.value = safeStr(String(el.value), 200);
if (el.checked !== undefined) obj.checked = el.checked;
if (el.disabled) obj.disabled= true;
if (el.readOnly) obj.readOnly= true;
if (el.required) obj.required= true;
if (role) obj.role = role;
if (type) obj.type = type;
// aria-атрибуты
let ariaLabel = el.getAttribute('aria-label');
let ariaDesc = el.getAttribute('aria-describedby');
let ariaExp = el.getAttribute('aria-expanded');
let ariaHid = el.getAttribute('aria-hidden');
let ariaLive = el.getAttribute('aria-live');
if (ariaLabel) obj.ariaLabel = safeStr(ariaLabel, 200);
if (ariaDesc) obj.ariaDesc = ariaDesc;
if (ariaExp) obj.ariaExpanded = ariaExp;
if (ariaHid) obj.ariaHidden = ariaHid;
if (ariaLive) obj.ariaLive = ariaLive;
// data-атрибуты (первые 5, полезны для ИИ)
try {
let dKeys = Object.keys(el.dataset || {}).slice(0, 5);
if (dKeys.length > 0) {
obj.data = {};
dKeys.forEach(k => { obj.data[k] = safeStr(el.dataset[k], 100); });
}
} catch(e) {}
// Стили (только для интерактивных/важных)
if (isInteractive || isImage) {
let cp = getComputedProps(el);
if (cp.display !== 'block' && cp.display) obj.display = cp.display;
if (cp.visibility !== 'visible') obj.visibility = cp.visibility;
if (cp.opacity !== '1' && cp.opacity) obj.opacity = cp.opacity;
if (cp.position) obj.position = cp.position;
if (cp.cursor && cp.cursor !== 'default') obj.cursor = cp.cursor;
if (cp.zIndex) obj.zIndex = cp.zIndex;
if (cp.fontSize) obj.fontSize = cp.fontSize;
if (cp.bgColor) obj.bgColor = cp.bgColor;
}
// --- ZennoPoster-совместимые поля ---
// leftInTab / topInTab (синонимы x/y в системе координат вкладки)
obj.leftInTab = rect.x;
obj.topInTab = rect.y;
obj.clientWidth = rect.w;
obj.clientHeight = rect.h;
// outerHTML (только для интерактивных, первые 500 символов)
if (isInteractive) {
try {
obj.outerHtml = el.outerHTML.substring(0, 500);
} catch(e) {}
}
elements.push(obj);
// --- Рекурсия: Light DOM ---
if (el.children) {
for (let i = 0; i < el.children.length; i++) {
traverse(el.children[i], depth + 1, shadowDepth, nodeId);
}
}
// --- Рекурсия: Shadow DOM ---
if (el.shadowRoot && el.shadowRoot.children) {
for (let i = 0; i < el.shadowRoot.children.length; i++) {
traverse(el.shadowRoot.children[i], depth + 1, shadowDepth + 1, nodeId);
}
}
}
traverse(document.body, 0, 0, null);
// -------------------------------------------------------
// Строим итоговый JSON с метаданными страницы
// -------------------------------------------------------
let meta = {
url: window.location.href,
title: document.title,
scrollX: window.scrollX,
scrollY: window.scrollY,
viewportW: window.innerWidth,
viewportH: window.innerHeight,
pageW: document.documentElement.scrollWidth,
pageH: document.documentElement.scrollHeight,
totalNodes: elements.length,
timestamp: new Date().toISOString()
};
// Краткий индекс интерактивных элементов (для быстрого поиска ИИ)
let interactive = elements.filter(e => e.interactive && e.visible);
let images = elements.filter(e => e.isImage && e.visible);
const result = JSON.stringify({
meta: meta,
elements: elements,
interactive: interactive,
images: images
});
// Сохраняем в скрытый textarea
let old = document.getElementById('zp_ai_dom_result');
if (old) old.remove();
let ta = document.createElement('textarea');
ta.id = 'zp_ai_dom_result';
ta.style.display = 'none';
ta.value = result;
document.body.appendChild(ta);
return 'OK:' + elements.length;
} catch(e) {
return 'JSERROR: ' + e.message + ' | ' + e.stack;
}
})();
";
// --- Выполняем JS ---
string jsResult = instance.ActiveTab.MainDocument.EvaluateScript(jsScript);
if (jsResult == null || jsResult.StartsWith("JSERROR"))
throw new Exception($"Ошибка выполнения JS: {jsResult}");
project.SendInfoToLog($"JS выполнен: {jsResult}", true);
// --- Ждём появления результата в DOM ---
var resultEl = instance.ActiveTab.FindElementById("zp_ai_dom_result");
int wait = 0;
while (resultEl.IsVoid && wait < 50)
{
System.Threading.Thread.Sleep(100);
resultEl = instance.ActiveTab.FindElementById("zp_ai_dom_result");
wait++;
}
if (resultEl.IsVoid)
throw new Exception("Результат не появился в DOM (таймаут).");
// --- Читаем JSON ---
string json = resultEl.GetValue();
// Удаляем служебный элемент
instance.ActiveTab.MainDocument.EvaluateScript(
"var e=document.getElementById('zp_ai_dom_result'); if(e) e.remove();"
);
if (string.IsNullOrWhiteSpace(json) || json == "null")
throw new Exception("Полученный JSON пустой.");
// --- Сохраняем в переменную проекта ---
project.Variables["AiDomTree"].Value = json;
// --- Краткая статистика в лог ---
// Быстрый парсинг totalNodes без внешней библиотеки
int totalIdx = json.IndexOf("\"totalNodes\":");
string totalStr = "?";
if (totalIdx >= 0)
{
int start = totalIdx + 13;
int end = json.IndexOfAny(new char[]{',','}'}, start);
if (end > start) totalStr = json.Substring(start, end - start).Trim();
}
project.SendInfoToLog(
$"✅ DOM-карта готова! Узлов: {totalStr} | Размер JSON: {json.Length} символов",
true
);
return "Success";
}
catch (Exception ex)
{
project.SendErrorToLog($"❌ AiDomMapper ОШИБКА: {ex.Message}", true);
return null;
}
5.2. MiniJson — общий блок разбора JSON без внешних сборок
ZennoPoster исполняет C#-сниппеты без доступа к NuGet-пакетам, поэтому три следующих скрипта (Fingerprint Builder, Compare & Fetch, Save Solution) используют один и тот же компактный самописный JSON-парсер/сериализатор вместо System.Text.Json/Newtonsoft.Json. Приводится один раз здесь; в реальном проекте этот блок физически скопирован в конец каждого из трёх сниппетов (ZennoPoster не поддерживает using локальных модулей между блоками действий), но чтобы не повторять идентичный код трижды, в статье он показан один раз.
C#:
// ==============================================================================
// MiniJson: свой парсер/сериализатор JSON, без внешних сборок.
// Общий для трёх C#-сниппетов (Fingerprint Builder, Compare & Fetch,
// Save Solution) — каждый из них вызывает JParse/JSerialize и геттеры
// DGet/LGet/SGet/NGet/BGet/ToStrList/Sha256Hex, определённые здесь.
// ZennoPoster исполняет сниппеты как обычный C#-код без NuGet-пакетов,
// поэтому вместо System.Text.Json/Newtonsoft — компактный самописный парсер.
// ==============================================================================
int _jp; string _js;
object JParse(string s)
{
_js = s; _jp = 0;
JSkipWs();
return JValue();
}
void JSkipWs() { while (_jp < _js.Length && char.IsWhiteSpace(_js[_jp])) _jp++; }
object JValue()
{
JSkipWs();
if (_jp >= _js.Length) return null;
char c = _js[_jp];
if (c == '{') return JObj();
if (c == '[') return JArr();
if (c == '"') return JStr();
if (c == 't') { _jp += 4; return true; }
if (c == 'f') { _jp += 5; return false; }
if (c == 'n') { _jp += 4; return null; }
return JNum();
}
Dictionary<string, object> JObj()
{
var d = new Dictionary<string, object>();
_jp++; JSkipWs();
if (_jp < _js.Length && _js[_jp] == '}') { _jp++; return d; }
while (true)
{
JSkipWs();
string key = JStr();
JSkipWs(); _jp++; // ':'
d[key] = JValue();
JSkipWs();
if (_jp < _js.Length && _js[_jp] == ',') { _jp++; continue; }
break;
}
JSkipWs();
if (_jp < _js.Length && _js[_jp] == '}') _jp++;
return d;
}
List<object> JArr()
{
var l = new List<object>();
_jp++; JSkipWs();
if (_jp < _js.Length && _js[_jp] == ']') { _jp++; return l; }
while (true)
{
l.Add(JValue());
JSkipWs();
if (_jp < _js.Length && _js[_jp] == ',') { _jp++; continue; }
break;
}
JSkipWs();
if (_jp < _js.Length && _js[_jp] == ']') _jp++;
return l;
}
string JStr()
{
var sb = new StringBuilder();
_jp++; // '"'
while (_jp < _js.Length && _js[_jp] != '"')
{
char c = _js[_jp];
if (c == '\\')
{
_jp++;
char e = _js[_jp];
switch (e)
{
case '"': sb.Append('"'); break;
case '\\': sb.Append('\\'); break;
case '/': sb.Append('/'); break;
case 'b': sb.Append('\b'); break;
case 'f': sb.Append('\f'); break;
case 'n': sb.Append('\n'); break;
case 'r': sb.Append('\r'); break;
case 't': sb.Append('\t'); break;
case 'u':
sb.Append((char)System.Convert.ToInt32(_js.Substring(_jp + 1, 4), 16));
_jp += 4;
break;
}
_jp++;
}
else { sb.Append(c); _jp++; }
}
_jp++; // '"'
return sb.ToString();
}
object JNum()
{
int start = _jp;
while (_jp < _js.Length && "-+.eE0123456789".IndexOf(_js[_jp]) >= 0) _jp++;
double d;
double.TryParse(_js.Substring(start, _jp - start), System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out d);
return d;
}
string JSerialize(object obj)
{
var sb = new StringBuilder();
JWrite(obj, sb);
return sb.ToString();
}
void JWrite(object obj, StringBuilder sb)
{
if (obj == null) { sb.Append("null"); return; }
if (obj is string) { JWriteStr((string)obj, sb); return; }
if (obj is bool) { sb.Append((bool)obj ? "true" : "false"); return; }
if (obj is int || obj is long || obj is double || obj is float)
{
sb.Append(System.Convert.ToDouble(obj).ToString(System.Globalization.CultureInfo.InvariantCulture));
return;
}
if (obj is Dictionary<string, object>)
{
sb.Append('{'); bool first = true;
foreach (var kv in (Dictionary<string, object>)obj)
{
if (!first) sb.Append(','); first = false;
JWriteStr(kv.Key, sb); sb.Append(':'); JWrite(kv.Value, sb);
}
sb.Append('}'); return;
}
if (obj is List<object>)
{
sb.Append('['); var l = (List<object>)obj;
for (int i = 0; i < l.Count; i++) { if (i > 0) sb.Append(','); JWrite(l[i], sb); }
sb.Append(']'); return;
}
if (obj is List<string>)
{
sb.Append('['); var l = (List<string>)obj;
for (int i = 0; i < l.Count; i++) { if (i > 0) sb.Append(','); JWriteStr(l[i], sb); }
sb.Append(']'); return;
}
JWriteStr(obj.ToString(), sb);
}
void JWriteStr(string s, StringBuilder sb)
{
sb.Append('"');
foreach (char c in s)
{
if (c == '"') sb.Append("\\\"");
else if (c == '\\') sb.Append("\\\\");
else if (c == '\n') sb.Append("\\n");
else if (c == '\r') sb.Append("\\r");
else if (c == '\t') sb.Append("\\t");
else if (c < ' ') sb.Append("\\u").Append(((int)c).ToString("x4"));
else sb.Append(c);
}
sb.Append('"');
}
// -------------------- Типобезопасные геттеры поверх Dictionary<string,object> --------------------
Dictionary<string, object> DGet(Dictionary<string, object> d, string key)
{
if (d == null || !d.ContainsKey(key) || d[key] == null) return null;
return d[key] as Dictionary<string, object>;
}
List<object> LGet(Dictionary<string, object> d, string key)
{
if (d == null || !d.ContainsKey(key) || d[key] == null) return new List<object>();
return d[key] as List<object> ?? new List<object>();
}
string SGet(Dictionary<string, object> d, string key)
{
if (d == null || !d.ContainsKey(key) || d[key] == null) return "";
return d[key].ToString();
}
double NGet(Dictionary<string, object> d, string key, double def)
{
if (d == null || !d.ContainsKey(key) || d[key] == null) return def;
try { return System.Convert.ToDouble(d[key]); } catch { return def; }
}
bool BGet(Dictionary<string, object> d, string key)
{
if (d == null || !d.ContainsKey(key) || d[key] == null) return false;
if (d[key] is bool) return (bool)d[key];
bool r; return bool.TryParse(d[key].ToString(), out r) && r;
}
List<string> ToStrList(List<object> l)
{
var r = new List<string>();
foreach (var x in l) r.Add(x?.ToString() ?? "");
return r;
}
string Sha256Hex(string input)
{
using (var sha = System.Security.Cryptography.SHA256.Create())
{
byte[] bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(input));
var sb = new StringBuilder();
foreach (var b in bytes) sb.Append(b.ToString("x2"));
return sb.ToString();
}
}
5.3. AI DOM Fingerprint Builder v2 — «отпечаток», а не снимок
Сырой DOM с реальными текстами пользователя нельзя использовать напрямую как ключ кэша — он уникален почти для каждого запуска. Поэтому строится структурный отпечаток, из которого исключено всё волатильное: текст, введённый пользователем, декоративные заголовки и параграфы. Каждый узел превращается в компактную сигнатуру вида tag|kind|role=...|type=...|st=...|lbl=...|pos=bx:by|sz=xs, все сигнатуры сортируются и склеиваются, а от результата берётся SHA-256 — этот хэш и есть «отпечаток» текущего состояния интерфейса. (Блок MiniJson из раздела 5.2 в конце файла опущен — используется без изменений.)
C#:
// ==============================================================================
// AI DOM Fingerprint Builder v2
// Строит отпечаток СТРУКТУРЫ страницы, а не её контента.
// Текст, введённый пользователем в поля, и декоративный текст (заголовки,
// параграфы, футер) НЕ входят в отпечаток — только стабильные UI-подписи
// интерактивных элементов (placeholder/aria-label/кнопки) + геометрия.
// ==============================================================================
int GRID = 24;
int LABEL_MAXLEN = 40;
// --- Настройки того, как относиться к тексту ---
bool INCLUDE_DECORATIVE_LABEL = false; // текст декоративных (неинтерактивных) элементов не идёт в отпечаток вообще
bool IGNORE_FIELD_VALUE = true; // для input/textarea/select НИКОГДА не берём text/value — только placeholder/aria-label
if (!project.Variables.Keys.Contains("AiDomForAI") || string.IsNullOrWhiteSpace(project.Variables["AiDomForAI"].Value))
{
project.SendErrorToLog("AiDomForAI пуста. Сначала запустите AiDomPreprocessor.", true);
return null;
}
if (!project.Variables.Keys.Contains("AiDomFingerprint"))
{
project.SendErrorToLog("Создайте переменную 'AiDomFingerprint' в проекте!", true);
return null;
}
try
{
var root = JParse(project.Variables["AiDomForAI"].Value) as Dictionary<string, object>;
if (root == null) throw new System.Exception("AiDomForAI: не удалось распарсить JSON.");
var page = DGet(root, "page");
var viewport = page != null ? DGet(page, "viewport") : null;
double vw = viewport != null ? NGet(viewport, "w", 1) : 1;
double vh = viewport != null ? NGet(viewport, "h", 1) : 1;
if (vw <= 0) vw = 1;
if (vh <= 0) vh = 1;
var elementsRaw = LGet(root, "elements");
var sigs = new List<string>();
// теги, которые считаем "полями ввода" — их текущее содержимое волатильно
var FIELD_TAGS = new HashSet<string> { "input", "textarea", "select" };
foreach (var item in elementsRaw)
{
var e = item as Dictionary<string, object>;
if (e == null) continue;
string tag = SGet(e, "tag").ToLowerInvariant();
bool interactive = BGet(e, "interactive");
string role = SGet(e, "role");
string type = SGet(e, "type");
// ---------- Определяем источник подписи в зависимости от типа элемента ----------
string rawLabel = "";
bool isField = FIELD_TAGS.Contains(tag);
if (isField && IGNORE_FIELD_VALUE)
{
// Поля ввода: берём ТОЛЬКО стабильные атрибуты, никогда не берём text/value
rawLabel = FirstNonEmpty(SGet(e, "label"), SGet(e, "placeholder"));
// 'label' здесь обычно = aria-label с вашего препроцессора; 'text' и 'value' сознательно игнорируются
}
else if (interactive)
{
// Кликабельные элементы (button/a/role=button и т.п.): текст — это обычно статичная UI-подпись
rawLabel = FirstNonEmpty(SGet(e, "label"), SGet(e, "text"), SGet(e, "placeholder"));
}
else if (INCLUDE_DECORATIVE_LABEL)
{
// Декоративные элементы, если явно включили их текст (по умолчанию — выключено)
rawLabel = FirstNonEmpty(SGet(e, "label"), SGet(e, "text"));
}
// иначе для декоративных элементов rawLabel остаётся "" — их текст в отпечаток не идёт
string label = NormalizeLabel(rawLabel, LABEL_MAXLEN);
string state = "";
if (BGet(e, "disabled")) state += "D";
if (BGet(e, "checked")) state += "C";
if (BGet(e, "required")) state += "R";
double cx = NGet(e, "cx", 0);
double cy = NGet(e, "cy", 0);
double w = NGet(e, "w", 0);
double h = NGet(e, "h", 0);
int bx = (int)System.Math.Floor(System.Math.Min(0.999, System.Math.Max(0, cx / vw)) * GRID);
int by = (int)System.Math.Floor(System.Math.Min(0.999, System.Math.Max(0, cy / vh)) * GRID);
string sizeBucket = SizeBucket(w, h, vw, vh);
// Помечаем поля ввода отдельным маркером "fld", чтобы вес/фильтрация в сравнении
// могли относиться к ним иначе, чем к обычным кликабельным элементам.
string kind = isField ? "fld" : (interactive ? "int" : "dec");
sigs.Add($"{tag}|{kind}|role={role}|type={type}|st={state}|lbl={label}|pos={bx}:{by}|sz={sizeBucket}");
}
sigs = sigs.Distinct().OrderBy(s => s, System.StringComparer.Ordinal).ToList();
string canonical = string.Join("\n", sigs);
string hash = Sha256Hex(canonical);
var outObj = new Dictionary<string, object>
{
["taskId"] = project.Variables.Keys.Contains("AiTaskId") ? project.Variables["AiTaskId"].Value : "default",
["hash"] = hash,
["count"] = (double)sigs.Count,
["grid"] = (double)GRID,
["url"] = page != null ? SGet(page, "url") : "",
["signatures"] = sigs.Cast<object>().ToList()
};
project.Variables["AiDomFingerprint"].Value = JSerialize(outObj);
project.SendInfoToLog($"✅ Fingerprint готов | hash={hash} | элементов={sigs.Count}", true);
return hash;
}
catch (System.Exception ex)
{
project.SendErrorToLog($"❌ Ошибка построения fingerprint: {ex.Message}", true);
return null;
}
// -------------------- Вспомогательные функции --------------------
string FirstNonEmpty(params string[] vals)
{
foreach (var v in vals) if (!string.IsNullOrWhiteSpace(v)) return v;
return "";
}
string NormalizeLabel(string s, int maxLen)
{
if (string.IsNullOrWhiteSpace(s)) return "";
s = s.Trim();
// --- Фильтр мусора: затёкший CSS/JS в текст (частая проблема парсеров DOM) ---
// Признаки: начинается с селектора (.class{ / #id{ / @media), содержит "{...}" паттерны,
// либо в первых 60 символах слишком много спецсимволов CSS.
if (Regex.IsMatch(s, @"^[\.\#@]?[\w\-]*\s*\{") ||
Regex.IsMatch(s, @"[\.\#][\w\-]+\{[^}]*\}") ||
s.Contains("{display:") || s.Contains("{position:") || s.Contains("{height:") ||
s.Contains("{width:") || s.Contains("{max-") || s.Contains("{margin") ||
s.StartsWith("@media") || s.StartsWith("function(") || s.StartsWith("function ("))
{
return ""; // это не подпись UI, это протёкшая разметка/скрипт — выбрасываем целиком
}
s = s.ToLowerInvariant();
s = Regex.Replace(s, @"\b\d+\s*(s|m|h|d|w)\b", "<time>");
s = Regex.Replace(s, @"\b\d+\s+(second|minute|hour|day|week)s?\s+ago\b", "<time>");
s = Regex.Replace(s, @"\d+([.,]\d+)?[kmb]?", "#");
s = Regex.Replace(s, @"\s+", " ").Trim();
if (s.Length > maxLen) s = s.Substring(0, maxLen);
return s;
}
string SizeBucket(double w, double h, double vw, double vh)
{
double areaRatio = (w * h) / (vw * vh);
if (areaRatio < 0.001) return "xs";
if (areaRatio < 0.01) return "s";
if (areaRatio < 0.05) return "m";
if (areaRatio < 0.2) return "l";
return "xl";
}
// -------------------- MiniJson + типобезопасные геттеры --------------------
// (общий для всех C#-сниппетов этого проекта блок сериализации/десериализации
// JSON без внешних сборок — см. полный листинг в разделе "MiniJson" ниже)
5.4. AI DOM Compare & Fetch — «поиск в памяти» перед обращением к ИИ
Экономическое сердце паттерна. Сначала — точный поиск файла {hash}.json в папке базы знаний по текущей задаче (AiTaskId). Если не найден — нечёткое сравнение: взвешенный коэффициент Жаккара между множествами сигнатур, где интерактивным элементам и полям ввода присвоен больший вес, чем декоративным блокам. При превышении порога SIMILARITY_THRESHOLD = 0.87 решение не только возвращается, но и записывается как алиас нового хэша на уже существующее решение — так база знаний постепенно самообучается, схлопывая небольшие визуальные вариации одного и того же макета в одну запись. (Блок MiniJson опущен — см. раздел 5.2.)
C#:
// ==============================================================================
// AI DOM Compare & Fetch
// Сравнивает текущий AiDomFingerprint с базой знаний (KB на диске) в разрезе
// AiTaskId. При совпадении отдаёт готовое решение без вызова ИИ.
// ==============================================================================
double SIMILARITY_THRESHOLD = 0.87;
string[] required = { "AiDomFingerprint", "AiKbFolder" };
foreach (var v in required)
{
if (!project.Variables.Keys.Contains(v) || string.IsNullOrWhiteSpace(project.Variables[v].Value))
{
project.SendErrorToLog($"Переменная '{v}' не заполнена.", true);
return null;
}
}
foreach (var outVar in new[] { "AiMatchFound", "AiMatchType", "AiMatchScore", "AiSolutionJson", "AiMatchedHash" })
{
if (!project.Variables.Keys.Contains(outVar))
{
project.SendErrorToLog($"Создайте переменную '{outVar}' в проекте!", true);
return null;
}
}
try
{
var current = JParse(project.Variables["AiDomFingerprint"].Value) as Dictionary<string, object>;
if (current == null) throw new System.Exception("AiDomFingerprint: не удалось распарсить JSON.");
string currentHash = SGet(current, "hash");
var currentSigs = ToStrList(LGet(current, "signatures"));
var currentWeights = BuildWeighted(currentSigs);
string taskId = project.Variables.Keys.Contains("AiTaskId") && !string.IsNullOrWhiteSpace(project.Variables["AiTaskId"].Value)
? project.Variables["AiTaskId"].Value : "default";
string kbRoot = project.Variables["AiKbFolder"].Value;
string kbDir = Path.Combine(kbRoot, SafeFolder(taskId));
project.Variables["AiMatchFound"].Value = "false";
project.Variables["AiMatchType"].Value = "none";
project.Variables["AiMatchScore"].Value = "0";
project.Variables["AiSolutionJson"].Value = "";
project.Variables["AiMatchedHash"].Value = "";
if (!Directory.Exists(kbDir))
{
Directory.CreateDirectory(kbDir);
project.SendInfoToLog($"KB для задачи '{taskId}' пуста, папка создана: {kbDir}", true);
return "no_kb";
}
string exactPath = Path.Combine(kbDir, currentHash + ".json");
if (File.Exists(exactPath))
{
string resolvedPath;
var entry = ResolveEntry(exactPath, kbDir, out resolvedPath);
if (entry != null)
{
ApplyHit(entry, resolvedPath);
project.Variables["AiMatchFound"].Value = "true";
project.Variables["AiMatchType"].Value = "exact";
project.Variables["AiMatchScore"].Value = "1";
project.Variables["AiSolutionJson"].Value = entry.ContainsKey("solution") ? JSerialize(entry["solution"]) : "";
project.Variables["AiMatchedHash"].Value = SGet(entry, "hash");
project.SendInfoToLog($"✅ Точное совпадение (hash={currentHash}). Решение из KB, без вызова ИИ.", true);
return "exact";
}
}
string bestFile = null;
double bestScore = 0;
Dictionary<string, object> bestObj = null;
foreach (var file in Directory.GetFiles(kbDir, "*.json"))
{
Dictionary<string, object> entry;
try { entry = JParse(File.ReadAllText(file)) as Dictionary<string, object>; }
catch { continue; }
if (entry == null || entry.ContainsKey("aliasOf")) continue;
var storedSigs = ToStrList(LGet(entry, "signatures"));
double score = WeightedJaccard(currentWeights, BuildWeighted(storedSigs));
if (score > bestScore) { bestScore = score; bestFile = file; bestObj = entry; }
}
project.Variables["AiMatchScore"].Value = bestScore.ToString("F3");
if (bestObj != null && bestScore >= SIMILARITY_THRESHOLD)
{
ApplyHit(bestObj, bestFile);
project.Variables["AiMatchFound"].Value = "true";
project.Variables["AiMatchType"].Value = "fuzzy";
project.Variables["AiSolutionJson"].Value = bestObj.ContainsKey("solution") ? JSerialize(bestObj["solution"]) : "";
project.Variables["AiMatchedHash"].Value = SGet(bestObj, "hash");
try
{
var alias = new Dictionary<string, object> { ["aliasOf"] = SGet(bestObj, "hash") };
File.WriteAllText(exactPath, JSerialize(alias));
}
catch { }
project.SendInfoToLog($"✅ Нечёткое совпадение (score={bestScore:F3}) с {Path.GetFileName(bestFile)}. Решение из KB.", true);
return "fuzzy";
}
project.SendInfoToLog($"⚠️ Совпадений не найдено (лучший score={bestScore:F3} < {SIMILARITY_THRESHOLD}). Нужен вызов ИИ.", true);
return "none";
}
catch (System.Exception ex)
{
project.SendErrorToLog($"❌ Ошибка сравнения fingerprint: {ex.Message}", true);
return null;
}
// -------------------- Вспомогательные функции --------------------
Dictionary<string, double> BuildWeighted(List<string> sigs)
{
var d = new Dictionary<string, double>();
foreach (var s in sigs)
{
double weight;
if (s.Contains("|int|")) weight = 3.0; // кликабельные — самое важное для распознавания
else if (s.Contains("|fld|")) weight = 2.5; // поля ввода — важна их структура, не значение
else weight = 0.5; // декоративные — низкий вес, почти не влияют на score
d[s] = weight;
}
return d;
}
double WeightedJaccard(Dictionary<string, double> a, Dictionary<string, double> b)
{
if (a.Count == 0 && b.Count == 0) return 1;
double inter = 0, uni = 0;
var keys = new HashSet<string>(a.Keys);
keys.UnionWith(b.Keys);
foreach (var k in keys)
{
double wa = a.ContainsKey(k) ? a[k] : 0;
double wb = b.ContainsKey(k) ? b[k] : 0;
inter += System.Math.Min(wa, wb);
uni += System.Math.Max(wa, wb);
}
return uni == 0 ? 0 : inter / uni;
}
Dictionary<string, object> ResolveEntry(string path, string kbDir, out string resolvedPath)
{
int guard = 0;
resolvedPath = path;
while (guard++ < 5)
{
Dictionary<string, object> obj;
try { obj = JParse(File.ReadAllText(path)) as Dictionary<string, object>; }
catch { return null; }
if (obj == null) return null;
if (obj.ContainsKey("aliasOf"))
{
path = Path.Combine(kbDir, SGet(obj, "aliasOf") + ".json");
resolvedPath = path;
if (!File.Exists(path)) return null;
continue;
}
return obj;
}
return null;
}
void ApplyHit(Dictionary<string, object> entry, string filePath)
{
try
{
double hits = NGet(entry, "hits", 0);
entry["hits"] = hits + 1;
entry["lastUsed"] = System.DateTime.UtcNow.ToString("o");
File.WriteAllText(filePath, JSerialize(entry));
}
catch { }
}
string SafeFolder(string name)
{
foreach (var c in Path.GetInvalidFileNameChars()) name = name.Replace(c, '_');
return name;
}
// -------------------- MiniJson + типобезопасные геттеры --------------------
// (общий блок JParse/JSerialize/DGet/LGet/SGet/NGet/BGet/ToStrList/Sha256Hex —
// см. полный листинг один раз в разделе 5.1, все четыре C#-сниппета делят один и тот же код)
5.5. click_analiser.py v3.0 — вызов ИИ, только когда он действительно нужен
Единственный внешний (не-ZennoPoster) файл во всей связке. Универсальный HTTP-клиент к десятку LLM-провайдеров (Ollama локально, OpenAI, Anthropic, Google Gemini, Groq, Mistral, DeepSeek, OpenRouter, xAI, любой OpenAI-совместимый сервер), переключаемый одной строкой конфигурации ACTIVE_PROFILE. Обратите внимание на строки ACTIVE_PROFILE = "{-Variable.ACTIVE_PROFILE-}" и "openrouter": "{-Variable.agent_api_key-}" в теле скрипта — это не опечатка и не недописанный код, а плейсхолдеры шаблонов ZennoPoster, которые подставляются автоматически при записи содержимого переменной prv в файл (см. разбор блока 6 в разделе 4.6).
Python:
"""
================================================================
CLICK ANALYSER v3.0
Точный поиск координат UI-элементов для ZennoPoster
================================================================
ИЗМЕНЕНИЯ v3.0 vs v2:
- Поддержка любого API-сервиса, не только локальный Ollama:
OpenAI, Anthropic (Claude), Google Gemini, Groq, Mistral,
DeepSeek, OpenRouter, xAI (Grok), любой OpenAI-совместимый
сервер. Переключение — одной строкой через ПРОФИЛИ.
- Для этой задачи (поиск координат) важна низкая temperature —
все встроенные профили используют 0.05-0.1 для максимальной
детерминированности ответа.
ИЗМЕНЕНИЯ v2.0 vs v1:
- DOM разделён на ДВЕ колонки: INPUTS и BUTTONS
(LLM больше не путает лейблы над полями с самими полями)
- Системный промпт и параметры вызова LLM взяты из LCM v2.0:
format="json", temperature=0.05, system/user раздельно
- Строгий JSON-output: {found, x, y, label, tag, reason, confidence}
- Балансированный парсинг {} (защита от обрезанного JSON)
- OCR передаётся только как список видимых строк (не как источник координат)
- Поддержка dom.json формата AiDomPreprocessor v8 (поле "split")
FLOW:
ZennoPoster -> screenshot.png (скриншот вкладки)
ZennoPoster -> dom.json (из AiDomPreprocessor)
ZennoPoster -> target.txt (описание целевого элемента)
|
[click_analyser.py] --(HTTP)--> выбранный AI-сервис
|
coordinates.txt <- ZennoPoster читает X, Y и кликает
================================================================
"""
import os
import re
import sys
import json
import logging
from typing import Optional, Tuple, List, Dict
from contextlib import redirect_stdout, redirect_stderr
import requests
# ==================================================================
# 1) РЕЕСТР СЕРВИСОВ (PROVIDERS)
# ------------------------------------------------------------------
# format: "openai" (OpenAI-совместимый /chat/completions) или
# "anthropic" (нативный Anthropic Messages API).
# presets — ориентир на момент написания скрипта: провайдеры меняют
# линейки моделей, при ошибке "model not found" впиши актуальное имя
# в MODEL_OVERRIDE ниже или прямо в профиль.
# ==================================================================
PROVIDERS = {
"ollama": {
"label": "Ollama — локальные модели, бесплатно, приватно",
"format": "openai",
"api_base": "http://127.0.0.1:11434/v1",
"api_key_env": None,
"presets": {
"llama3.1:8b": "быстро, для простых страниц",
"qwen2.5:14b": "лучше держит сложную вёрстку/много элементов",
"llava:13b": "мультимодальная — если захочешь подавать скриншот моделью напрямую",
},
"default_model": "qwen2.5:14b",
},
"openai": {
"label": "OpenAI",
"format": "openai",
"api_base": "https://api.openai.com/v1",
"api_key_env": "OPENAI_API_KEY",
"presets": {
"gpt-4.1": "лучшая точность на сложных/загруженных DOM",
"gpt-4.1-mini": "дешевле и быстрее, хватает для большинства страниц",
"gpt-4o-mini": "самая дешёвая, для больших объёмов задач",
},
"default_model": "gpt-4.1-mini",
},
"anthropic": {
"label": "Anthropic (Claude)",
"format": "anthropic",
"api_base": "https://api.anthropic.com/v1",
"api_key_env": "ANTHROPIC_API_KEY",
"presets": {
"claude-sonnet-4-6": "рабочая лошадка, хорошо следует строгой JSON-схеме",
"claude-haiku-4-6": "самая быстрая и дешёвая, для простых страниц",
"claude-opus-4-6": "максимальная точность на самых сложных DOM",
},
"default_model": "claude-sonnet-4-6",
},
"google": {
"label": "Google Gemini",
"format": "openai",
"api_base": "https://generativelanguage.googleapis.com/v1beta/openai",
"api_key_env": "GOOGLE_API_KEY",
"presets": {
"gemini-2.5-flash": "быстрая и дешёвая",
"gemini-2.5-pro": "точнее на сложных страницах",
},
"default_model": "gemini-2.5-flash",
},
"groq": {
"label": "Groq — очень быстрый инференс открытых моделей",
"format": "openai",
"api_base": "https://api.groq.com/openai/v1",
"api_key_env": "GROQ_API_KEY",
"presets": {
"llama-3.3-70b-versatile": "быстро, неплохая точность",
},
"default_model": "llama-3.3-70b-versatile",
},
"mistral": {
"label": "Mistral AI",
"format": "openai",
"api_base": "https://api.mistral.ai/v1",
"api_key_env": "MISTRAL_API_KEY",
"presets": {
"mistral-large-latest": "флагман Mistral",
"mistral-small-latest": "дешевле и быстрее",
},
"default_model": "mistral-small-latest",
},
"deepseek": {
"label": "DeepSeek",
"format": "openai",
"api_base": "https://api.deepseek.com/v1",
"api_key_env": "DEEPSEEK_API_KEY",
"presets": {
"deepseek-chat": "основная модель, очень дёшево",
},
"default_model": "deepseek-chat",
},
"openrouter": {
"label": "OpenRouter — единый доступ к десяткам моделей разных вендоров",
"format": "openai",
"api_base": "https://openrouter.ai/api/v1",
"api_key_env": "OPENROUTER_API_KEY",
"presets": {
"openai/gpt-4.1-mini": "OpenAI через OpenRouter",
"anthropic/claude-sonnet-4.5": "Claude через OpenRouter",
"google/gemini-2.5-flash": "Gemini через OpenRouter",
},
"default_model": "google/gemini-2.5-flash",
},
"xai": {
"label": "xAI (Grok)",
"format": "openai",
"api_base": "https://api.x.ai/v1",
"api_key_env": "XAI_API_KEY",
"presets": {
"grok-2": "основная модель xAI",
},
"default_model": "grok-2",
},
"custom": {
"label": "Свой OpenAI-совместимый эндпоинт (LM Studio, vLLM, Together, Fireworks и т.д.)",
"format": "openai",
"api_base": "http://127.0.0.1:1234/v1", # <- поменяй на адрес своего сервера
"api_key_env": "CUSTOM_API_KEY",
"presets": {
"your-model-name": "впиши точное имя модели своего эндпоинта",
},
"default_model": "your-model-name",
},
}
# ==================================================================
# 2) ГОТОВЫЕ ПРОФИЛИ (сервис + модель + температура одной строкой)
# ------------------------------------------------------------------
# Для поиска координат критична низкая temperature (детерминированность),
# поэтому во всех профилях она держится в диапазоне 0.05-0.1.
# ==================================================================
PROFILES = {
"local_free": dict(
provider="ollama", model="qwen2.5:14b", temperature=0.05,
note="Бесплатно и приватно. Нужен запущенный локально Ollama.",
),
"openai_cheap": dict(
provider="openai", model="gpt-4o-mini", temperature=0.05,
note="Дёшево и быстро для больших объёмов кликов.",
),
"openai_precise": dict(
provider="openai", model="gpt-4.1", temperature=0.05,
note="Лучшая точность на сложных/загруженных страницах.",
),
"claude_balanced": dict(
provider="anthropic", model="claude-sonnet-4-6", temperature=0.05,
note="Хорошо следует строгой JSON-схеме, стабильные ответы.",
),
"claude_precise": dict(
provider="anthropic", model="claude-opus-4-6", temperature=0.05,
note="Максимальная точность, дороже и медленнее.",
),
"gemini_fast": dict(
provider="google", model="gemini-2.5-flash", temperature=0.05,
note="Быстро и дёшево от Google.",
),
"groq_fast": dict(
provider="groq", model="llama-3.3-70b-versatile", temperature=0.1,
note="Очень высокая скорость ответа.",
),
"deepseek_cheap": dict(
provider="deepseek", model="deepseek-chat", temperature=0.05,
note="Очень низкая цена за токен.",
),
"openrouter_gemini": dict(
provider="openrouter", model="google/gemini-2.5-flash", temperature=0.3,
note="Доступ к gemini через единый ключ OpenRouter.",
),
}
ACTIVE_PROFILE = "{-Variable.ACTIVE_PROFILE-}" # <- ГЛАВНАЯ НАСТРОЙКА: имя профиля из PROFILES выше
# ------------------------------------------------------------------
# 3) РУЧНОЙ OVERRIDE (не обязателен)
# ------------------------------------------------------------------
PROVIDER_OVERRIDE = None # напр. "openai"
MODEL_OVERRIDE = None # напр. "gpt-4.1"
TEMPERATURE_OVERRIDE = None # напр. 0.0
# ------------------------------------------------------------------
# 4) API-КЛЮЧИ
# ------------------------------------------------------------------
# По умолчанию читаются из переменных окружения (см. api_key_env у
# каждого провайдера выше). Если так неудобно — впиши прямо сюда, но
# тогда не выкладывай файл с заполненными ключами никуда.
# ------------------------------------------------------------------
MANUAL_API_KEYS = {
# "openai": "sk-...",
# "anthropic": "sk-ant-...",
# "google": "",
# "groq": "...",
# "mistral": "...",
# "deepseek": "...",
"openrouter": "{-Variable.agent_api_key-}",
# "xai": "...",
# "custom": "...",
}
# ------------------------------------------------------------------
# 5) ПУТИ И ПРОЧИЕ НАСТРОЙКИ
# ------------------------------------------------------------------
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
DOM_FILE = os.path.join(BASE_DIR, "dom.json")
TARGET_FILE = os.path.join(BASE_DIR, "target.txt")
OUTPUT_FILE = os.path.join(BASE_DIR, "coordinates.txt")
SCREENSHOT = os.path.join(BASE_DIR, "screenshot.png")
SESSION_LOG = os.path.join(BASE_DIR, "click_analiser.log")
REQUEST_TIMEOUT = 120 # секунд на один запрос к API
# ------------------------------------------------------------------
# LOGGING
# ------------------------------------------------------------------
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)-8s | %(message)s",
datefmt="%H:%M:%S",
handlers=[
logging.StreamHandler(sys.stdout),
logging.FileHandler(SESSION_LOG, encoding="utf-8"),
],
)
log = logging.getLogger("CA")
# ==================================================================
# РАЗРЕШЕНИЕ АКТИВНОЙ КОНФИГУРАЦИИ (профиль + overrides + ключ)
# ==================================================================
def resolve_active_config():
if ACTIVE_PROFILE not in PROFILES:
available = ", ".join(PROFILES.keys())
raise SystemExit(
f"Неизвестный ACTIVE_PROFILE='{ACTIVE_PROFILE}'. "
f"Доступные профили: {available}"
)
profile = PROFILES[ACTIVE_PROFILE]
provider_name = PROVIDER_OVERRIDE or profile["provider"]
model_name = MODEL_OVERRIDE or profile["model"]
temperature = TEMPERATURE_OVERRIDE if TEMPERATURE_OVERRIDE is not None else profile["temperature"]
if provider_name not in PROVIDERS:
available = ", ".join(PROVIDERS.keys())
raise SystemExit(
f"Неизвестный провайдер '{provider_name}'. Доступные: {available}"
)
provider = PROVIDERS[provider_name]
api_key = None
if provider["api_key_env"]:
api_key = MANUAL_API_KEYS.get(provider_name) or os.environ.get(provider["api_key_env"])
if not api_key:
raise SystemExit(
f"Для провайдера '{provider_name}' ({provider['label']}) нужен API-ключ.\n"
f"Либо задай переменную окружения {provider['api_key_env']}, "
f"либо впиши ключ в MANUAL_API_KEYS['{provider_name}'] в SETTINGS."
)
preset_hint = provider["presets"].get(model_name, "(модель не из пресетов — используется как есть)")
return {
"provider_name": provider_name,
"provider": provider,
"model": model_name,
"temperature": temperature,
"api_key": api_key,
"preset_hint": preset_hint,
}
# ------------------------------------------------------------------
# SYSTEM PROMPT (стиль LCM v2.0)
# ------------------------------------------------------------------
SYSTEM_PROMPT = """\
You are a precise UI element locator for browser automation.
Your task: find the SINGLE element that best matches the given description,
and return its exact center coordinates from the DOM data.
CRITICAL INSTRUCTION: Your ENTIRE response must be a single valid JSON object.
Start your response with { and end with }.
Do NOT write any text before or after the JSON.
Do NOT use markdown code blocks or ```json fences.
Do NOT explain anything. Output raw JSON only.
OUTPUT SCHEMA (use exactly these keys):
{
"found": true,
"x": 817,
"y": 281,
"label": "exact text or aria-label of the matched element",
"tag": "input",
"reason": "one sentence why this element matches",
"confidence": "high"
}
If the element cannot be found:
{
"found": false,
"x": 0,
"y": 0,
"label": "",
"tag": "",
"reason": "why not found",
"confidence": "low"
}
MATCHING RULES:
1. The DOM is split into two sections: INPUT_FIELDS and CLICKABLE_ELEMENTS.
- If the target is a field to TYPE INTO → search INPUT_FIELDS first.
- If the target is a BUTTON/LINK/TAB → search CLICKABLE_ELEMENTS first.
2. Never confuse a label/heading ABOVE a field with the field itself.
A label says WHAT the field is; the actual INPUT element has tag=input/textarea
and usually has a placeholder attribute.
3. Use x/y coordinates ONLY from the DOM data — never from OCR.
4. OCR visible texts confirm what is rendered on screen; use them to
verify your candidate is actually visible, nothing more.
5. Semantic matching: "chat input" → role=textbox, tag=textarea or input;
"send button" → button with text "Send" or aria-label "Send";
"search field" → input with placeholder containing "search".
6. If multiple candidates match, prefer: interactive=true > role match > tag match.
7. "confidence": "high" if one clear match; "medium" if best guess; "low" if not sure.
"""
# ------------------------------------------------------------------
# DOM PREPROCESSOR (client-side, mirrors AiDomPreprocessor logic)
# ------------------------------------------------------------------
def prepare_dom_split(dom_json: dict) -> dict:
"""
Принимает dom.json из AiDomPreprocessor v8 или v9.
Возвращает словарь с двумя колонками:
{
"input_fields": [...], # tag=input/textarea/select/contenteditable
"clickable_elements": [...], # tag=button/a/label + role=button/link/...
"page": {...}
}
Если dom.json уже содержит поле "split" (от AiDomPreprocessor v9) —
используем его напрямую.
"""
# Формат v9 с готовым split
if "split" in dom_json:
return dom_json["split"]
# Формат v8: достаём elements
if "elements" in dom_json:
raw = dom_json["elements"]
elif isinstance(dom_json, list):
raw = dom_json
else:
raw = []
page_meta = dom_json.get("page", {})
INPUT_TAGS = {"input", "textarea", "select"}
BUTTON_TAGS = {"button", "a"}
INPUT_ROLES = {"textbox", "searchbox", "spinbutton", "combobox"}
BUTTON_ROLES = {"button", "link", "menuitem", "tab", "checkbox",
"radio", "switch", "option", "treeitem"}
input_fields = []
clickable_elements = []
for el in raw:
if not el.get("visible", True):
continue
if not el.get("w") or not el.get("h"):
continue
if el.get("disabled"):
continue
tag = (el.get("tag") or "").lower()
role = (el.get("role") or "").lower()
ce = str(el.get("contenteditable", "")).lower() == "true"
inter = el.get("interactive", False)
# Нормализуем запись для LLM
def _clean(e: dict) -> dict:
out = {
"tag": tag,
"cx": e.get("cx", e.get("x", 0)),
"cy": e.get("cy", e.get("y", 0)),
"w": e.get("w", 0),
"h": e.get("h", 0),
}
if e.get("text"): out["text"] = str(e["text"])[:100]
if e.get("label"): out["label"] = str(e["label"])[:100]
if e.get("ariaLabel"): out["aria_label"] = str(e["ariaLabel"])[:100]
if e.get("ph"): out["placeholder"] = str(e["ph"])[:80]
if e.get("value"): out["value"] = str(e["value"])[:60]
if role: out["role"] = role
if e.get("elId"): out["id"] = str(e["elId"])[:60]
if e.get("href"): out["href"] = str(e["href"])[:120]
if e.get("type"): out["type"] = str(e["type"])
if e.get("readOnly"): out["readOnly"] = True
if e.get("required"): out["required"] = True
return out
is_input = (tag in INPUT_TAGS) or (role in INPUT_ROLES) or ce
is_click = (tag in BUTTON_TAGS) or (role in BUTTON_ROLES) or inter
if is_input:
input_fields.append(_clean(el))
elif is_click:
clickable_elements.append(_clean(el))
log.info(
f"DOM split: {len(input_fields)} input fields, "
f"{len(clickable_elements)} clickable elements"
)
return {
"input_fields": input_fields,
"clickable_elements": clickable_elements,
"page": page_meta,
}
# ------------------------------------------------------------------
# OCR (опциональный, только для visible-confirmation)
# ------------------------------------------------------------------
def get_ocr_visible_texts(image_path: str) -> List[str]:
"""
Возвращает плоский список строк видимых на скриншоте.
Используется только для подтверждения что элемент отрисован.
"""
if not os.path.exists(image_path):
log.warning(f"Скриншот не найден: {image_path}")
return []
log.info("OCR: запускаем тихий анализ скриншота...")
try:
with open(os.devnull, "w") as fnull:
with redirect_stdout(fnull), redirect_stderr(fnull):
import easyocr
reader = easyocr.Reader(["en", "ru"], gpu=False, verbose=False)
with open(image_path, "rb") as f:
img_bytes = f.read()
results = reader.readtext(img_bytes)
texts = []
for (bbox, text, prob) in results:
if prob >= 0.3 and text.strip():
texts.append(text.strip())
log.info(f"OCR: найдено {len(texts)} видимых строк")
return texts
except Exception as e:
log.error(f"OCR ошибка: {e}")
return []
# ------------------------------------------------------------------
# PROMPT BUILDER
# ------------------------------------------------------------------
def build_prompt(target: str, split: dict, ocr_texts: List[str]) -> str:
"""
Строит user-промпт с двумя колонками DOM и OCR-подтверждением.
"""
page = split.get("page", {})
page_info = ""
if page:
page_info = (
f"CURRENT PAGE: {page.get('url','?')} | "
f"Title: {page.get('title','?')}\n\n"
)
input_fields = split.get("input_fields", [])
clickable_elements = split.get("clickable_elements", [])
# Форматируем каждую колонку компактно
def fmt_col(items: List[dict]) -> str:
if not items:
return " (none)\n"
lines = []
for el in items:
cx = el.get("cx", el.get("x", 0))
cy = el.get("cy", el.get("y", 0))
tag = el.get("tag", "?")
# Собираем все доступные идентификаторы
idents = []
if el.get("text"): idents.append(f'text="{el["text"]}"')
if el.get("label"): idents.append(f'label="{el["label"]}"')
if el.get("aria_label"): idents.append(f'aria="{el["aria_label"]}"')
if el.get("placeholder"): idents.append(f'ph="{el["placeholder"]}"')
if el.get("role"): idents.append(f'role={el["role"]}')
if el.get("type"): idents.append(f'type={el["type"]}')
if el.get("id"): idents.append(f'id={el["id"]}')
ident_str = " | ".join(idents) if idents else "(no text)"
w = el.get("w", 0)
h = el.get("h", 0)
lines.append(f" [{tag}] cx={cx} cy={cy} ({w}x{h}) {ident_str}")
return "\n".join(lines) + "\n"
ocr_block = ""
if ocr_texts:
ocr_list = "\n".join(f" - {t}" for t in ocr_texts[:60])
ocr_block = (
f"OCR VISIBLE TEXTS (screen confirmation only, NOT coordinates):\n"
f"{ocr_list}\n\n"
)
prompt = (
f"TARGET ELEMENT TO FIND: \"{target}\"\n\n"
f"{page_info}"
f"=== COLUMN 1: INPUT FIELDS (for typing/selecting) ===\n"
f"{fmt_col(input_fields)}\n"
f"=== COLUMN 2: CLICKABLE ELEMENTS (buttons, links, tabs) ===\n"
f"{fmt_col(clickable_elements)}\n"
f"{ocr_block}"
f"INSTRUCTION: Find the element matching \"{target}\".\n"
f"Return cx as x and cy as y in the JSON output.\n"
f"cx/cy are the CENTER coordinates — use them directly for clicking."
)
return prompt
# ==================================================================
# LLM CALLS — по одному вызывающему на формат API
# ==================================================================
def call_openai_format(cfg: dict, system_prompt: str, user_prompt: str) -> str:
"""Работает с любым OpenAI-совместимым /chat/completions эндпоинтом:
OpenAI, Ollama, Google (openai-compat), Groq, Mistral, DeepSeek,
OpenRouter, xAI, и любой self-hosted сервер того же формата."""
provider = cfg["provider"]
url = provider["api_base"].rstrip("/") + "/chat/completions"
headers = {"Content-Type": "application/json"}
if cfg["api_key"]:
headers["Authorization"] = f"Bearer {cfg['api_key']}"
body = {
"model": cfg["model"],
"temperature": cfg["temperature"],
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
}
# Пытаемся попросить строгий JSON-режим — большинство современных
# OpenAI-совместимых API его поддерживают. Если сервис его не
# поддерживает, повторяем запрос без этого параметра.
try:
resp = requests.post(
url, headers=headers,
json={**body, "response_format": {"type": "json_object"}},
timeout=REQUEST_TIMEOUT,
)
if resp.status_code >= 400:
raise requests.HTTPError(f"{resp.status_code}: {resp.text[:300]}")
except requests.HTTPError:
resp = requests.post(url, headers=headers, json=body, timeout=REQUEST_TIMEOUT)
resp.raise_for_status()
data = resp.json()
content = data["choices"][0]["message"]["content"]
log.info(f"LLM RAW (first 500):\n{content[:500]}")
return content
def call_anthropic_format(cfg: dict, system_prompt: str, user_prompt: str) -> str:
"""Нативный Anthropic Messages API."""
provider = cfg["provider"]
url = provider["api_base"].rstrip("/") + "/messages"
headers = {
"Content-Type": "application/json",
"x-api-key": cfg["api_key"],
"anthropic-version": "2023-06-01",
}
body = {
"model": cfg["model"],
"max_tokens": 1024,
"temperature": cfg["temperature"],
"system": system_prompt,
"messages": [{"role": "user", "content": user_prompt}],
}
resp = requests.post(url, headers=headers, json=body, timeout=REQUEST_TIMEOUT)
resp.raise_for_status()
data = resp.json()
content = "".join(
block.get("text", "") for block in data.get("content", []) if block.get("type") == "text"
)
log.info(f"LLM RAW (first 500):\n{content[:500]}")
return content
def call_llm(cfg: dict, system_prompt: str, user_prompt: str) -> str:
fmt = cfg["provider"]["format"]
if fmt == "openai":
return call_openai_format(cfg, system_prompt, user_prompt)
elif fmt == "anthropic":
return call_anthropic_format(cfg, system_prompt, user_prompt)
else:
raise SystemExit(f"Неизвестный формат API '{fmt}' у провайдера '{cfg['provider_name']}'")
# ------------------------------------------------------------------
# JSON PARSER (балансированный поиск, как в LCM)
# ------------------------------------------------------------------
def parse_result(raw: str) -> Optional[dict]:
"""
Извлекает первый валидный JSON-объект из ответа LLM.
Использует метод счёта скобок — защита от обрезанного JSON.
"""
raw = re.sub(r"```(?:json)?", "", raw).strip()
for m in re.finditer(r"\{", raw):
candidate = raw[m.start():]
depth, end = 0, -1
for i, ch in enumerate(candidate):
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
end = i + 1
break
if end == -1:
continue
try:
data = json.loads(candidate[:end])
if "x" in data and "y" in data:
return data
except json.JSONDecodeError:
continue
log.error("Не удалось извлечь JSON из ответа LLM")
log.error(f"RAW DUMP:\n{raw[:800]}")
return None
# ------------------------------------------------------------------
# MAIN ANALYZER CLASS
# ------------------------------------------------------------------
class ClickAnalyser:
def __init__(
self,
cfg: dict,
dom_file: str = DOM_FILE,
output_file: str = OUTPUT_FILE,
screenshot: str = SCREENSHOT,
use_ocr: bool = True,
):
self.cfg = cfg
self.dom_file = dom_file
self.output_file = output_file
self.screenshot = screenshot
self.use_ocr = use_ocr
log.info(
f"ClickAnalyser v3.0 | сервис={cfg['provider']['label']} "
f"| модель={cfg['model']} ({cfg['preset_hint']}) "
f"| temperature={cfg['temperature']}"
)
def _load_dom(self) -> Optional[dict]:
if not os.path.exists(self.dom_file):
log.error(f"DOM файл не найден: {self.dom_file}")
return None
try:
with open(self.dom_file, "r", encoding="utf-8") as f:
data = json.load(f)
log.info(f"DOM загружен: {self.dom_file}")
return data
except Exception as e:
log.error(f"Ошибка чтения DOM: {e}")
return None
def find(self, target: str) -> Optional[Tuple[int, int]]:
"""
Основной метод. Возвращает (x, y) или None.
ВАЖНО про систему координат:
AiDomMapper считает координаты как:
cx = getBoundingClientRect().left + window.scrollX + width/2
cy = getBoundingClientRect().top + window.scrollY + height/2
То есть cx/cy — АБСОЛЮТНЫЕ координаты страницы (с учётом прокрутки).
ZennoPoster FullEmulationMouseMove принимает координаты ВЬЮПОРТА.
Поэтому нужно вычесть scrollX/scrollY из итоговых координат.
"""
log.info(f"Ищем: \"{target}\"")
# 1. Загружаем DOM
dom_raw = self._load_dom()
if dom_raw is None:
log.error("DOM недоступен. Прерываем.")
return None
# Читаем scroll из метаданных страницы
page_meta = dom_raw.get("page", {})
scroll = page_meta.get("scroll", {})
scroll_x = int(scroll.get("x", 0))
scroll_y = int(scroll.get("y", 0))
if scroll_x != 0 or scroll_y != 0:
log.info(f"Страница прокручена: scrollX={scroll_x}, scrollY={scroll_y} "
f"→ будет вычтено из координат")
# 2. Делаем split на колонки
split = prepare_dom_split(dom_raw)
total_elements = (
len(split.get("input_fields", [])) +
len(split.get("clickable_elements", []))
)
if total_elements == 0:
log.error("DOM пустой после обработки.")
return None
# 3. OCR (опционально)
ocr_texts = []
if self.use_ocr and os.path.exists(self.screenshot):
ocr_texts = get_ocr_visible_texts(self.screenshot)
# 4. Строим промпт
prompt = build_prompt(target, split, ocr_texts)
log.info(
f"Промпт готов: {len(split['input_fields'])} input_fields, "
f"{len(split['clickable_elements'])} clickable | "
f"OCR: {len(ocr_texts)} строк"
)
# 5. Вызов LLM
try:
raw_response = call_llm(self.cfg, SYSTEM_PROMPT, prompt)
except requests.exceptions.RequestException as e:
log.error(f"Ошибка запроса к API: {e}")
return None
except Exception as e:
log.error(f"Ошибка вызова LLM: {e}")
return None
# 6. Парсинг
result = parse_result(raw_response)
if result is None:
return None
if not result.get("found", True):
log.warning(f"LLM: элемент не найден. Reason: {result.get('reason','?')}")
return None
# Поддерживаем cx/cy и x/y
x = result.get("x") or result.get("cx") or 0
y = result.get("y") or result.get("cy") or 0
try:
x, y = int(float(x)), int(float(y))
except (ValueError, TypeError):
log.error(f"Некорректные координаты в ответе: x={x}, y={y}")
return None
if x == 0 and y == 0:
log.error("LLM вернул нулевые координаты.")
return None
log.info(
f"DOM координаты (абсолютные): x={x}, y={y} | "
f"label='{result.get('label','')}' | "
f"tag={result.get('tag','')} | "
f"confidence={result.get('confidence','?')} | "
f"reason: {result.get('reason','')}"
)
# Преобразуем абсолютные координаты страницы -> координаты вьюпорта.
# AiDomMapper пишет cx = rect.left + scrollX + w/2 (абсолютные).
# ZP FullEmulationMouseMove принимает координаты вьюпорта (без scroll).
x_vp = x - scroll_x
y_vp = y - scroll_y
if scroll_x != 0 or scroll_y != 0:
log.info(f"Scroll-коррекция: ({x},{y}) -> viewport ({x_vp},{y_vp})")
else:
log.info(f"НАЙДЕНО (viewport): x={x_vp}, y={y_vp}")
return x_vp, y_vp
def find_and_save(self, target: str) -> bool:
"""
Ищет элемент и сохраняет результат в coordinates.txt.
Координаты в файле — VIEWPORT (уже скорректированы на scroll).
Возвращает True при успехе.
"""
coords = self.find(target)
if coords is None:
with open(self.output_file, "w", encoding="utf-8") as f:
f.write(f"TARGET={target}\nFOUND=false\nX=0\nY=0\n")
log.warning(f"Координаты не найдены для: '{target}'")
return False
x, y = coords
with open(self.output_file, "w", encoding="utf-8") as f:
# X, Y — координаты ВЬЮПОРТА, готовы для FullEmulationMouseMove
f.write(f"TARGET={target}\nFOUND=true\nX={x}\nY={y}\n")
log.info(f"✅ Viewport-координаты сохранены: X={x}, Y={y} → {self.output_file}")
return True
# ------------------------------------------------------------------
# ENTRY POINT
# ------------------------------------------------------------------
if __name__ == "__main__":
# Читаем цель из target.txt если есть, иначе из аргументов
if len(sys.argv) >= 2:
target = " ".join(sys.argv[1:])
elif os.path.exists(TARGET_FILE):
with open(TARGET_FILE, "r", encoding="utf-8") as f:
target = f.read().strip()
else:
log.error(
f"Не задан целевой элемент. "
f"Передайте как аргумент или создайте {TARGET_FILE}"
)
sys.exit(1)
cfg = resolve_active_config()
analyser = ClickAnalyser(
cfg = cfg,
use_ocr = True,
)
success = analyser.find_and_save(target)
sys.exit(0 if success else 1)
5.6. AI DOM Save Solution — запись рецепта в общую память
Финальный и, пожалуй, самый недооценённый по важности блок: превращает разовый ответ ИИ в актив, которым воспользуются все остальные потоки. Обратите внимание на паттерн «запись во временный файл + атомарное переименование» (File.Move) — защита от гонки потоков, если два потока одновременно нашли новый макет и одновременно получили ответ от ИИ. (Блок MiniJson опущен — см. раздел 5.2.)
C#:
// ==============================================================================
// AI DOM Save Solution
// Сохраняет решение ИИ в базу знаний (KB) под текущим fingerprint-хэшем,
// чтобы другие потоки/проекты могли переиспользовать его без повторного вызова ИИ.
// Вход: AiDomFingerprint (тот же, что был на момент cache-miss), AiTaskId,
// AiKbFolder, AiSolutionJson (результат, который вернул ИИ).
// ==============================================================================
string[] required = { "AiDomFingerprint", "AiTaskId", "AiKbFolder", "AiSolutionJson" };
foreach (var v in required)
{
if (!project.Variables.Keys.Contains(v) || string.IsNullOrWhiteSpace(project.Variables[v].Value))
{
project.SendErrorToLog($"Переменная '{v}' не заполнена.", true);
return null;
}
}
try
{
var current = JParse(project.Variables["AiDomFingerprint"].Value) as Dictionary<string, object>;
if (current == null) throw new System.Exception("AiDomFingerprint: не удалось распарсить JSON.");
if (!current.ContainsKey("hash") || !current.ContainsKey("signatures"))
throw new System.Exception("AiDomFingerprint: отсутствуют поля 'hash' или 'signatures'.");
object solutionObj;
try { solutionObj = JParse(project.Variables["AiSolutionJson"].Value); }
catch (System.Exception ex) { throw new System.Exception($"AiSolutionJson: невалидный JSON ({ex.Message})."); }
string taskId = project.Variables["AiTaskId"].Value;
string kbDir = Path.Combine(project.Variables["AiKbFolder"].Value, SafeFolder(taskId));
Directory.CreateDirectory(kbDir);
string hash = SGet(current, "hash");
string filePath = Path.Combine(kbDir, hash + ".json");
if (File.Exists(filePath))
{
project.SendInfoToLog($"ℹ️ Решение для hash={hash} уже есть в KB, повторно не сохраняем.", true);
return "already_exists";
}
var entry = new Dictionary<string, object>
{
["hash"] = current["hash"],
["signatures"] = current["signatures"],
["solution"] = solutionObj,
["createdAt"] = System.DateTime.UtcNow.ToString("o"),
["hits"] = (double)0
};
string tmpPath = filePath + ".tmp_" + System.Guid.NewGuid().ToString("N");
File.WriteAllText(tmpPath, JSerialize(entry));
File.Move(tmpPath, filePath);
project.SendInfoToLog($"✅ Решение сохранено в KB (taskId={taskId}, hash={hash}).", true);
return "saved";
}
catch (System.Exception ex)
{
project.SendErrorToLog($"❌ Ошибка сохранения решения в KB: {ex.Message}", true);
return null;
}
// -------------------- Вспомогательные функции --------------------
string SafeFolder(string name)
{
foreach (var c in Path.GetInvalidFileNameChars()) name = name.Replace(c, '_');
return name;
}
// -------------------- MiniJson + типобезопасные геттеры --------------------
// (общий блок JParse/JSerialize/DGet/LGet/SGet/NGet/BGet/ToStrList/Sha256Hex —
// см. полный листинг один раз в разделе 5.1, все четыре C#-сниппета делят один и тот же код).
// Здесь этот блок нужен для File.Move-паттерна: запись во временный файл и
// атомарное переименование защищает от гонки, если два потока одновременно
// нашли новый макет и одновременно получили ответ от ИИ.[/FONT][/COLOR][/td]
5.7. Клик со сдвигом — финальное действие блока «Выполняем клик»
Простой, но осмысленный приём: вместо мгновенного клика точно в найденных координатах курсор сначала перемещается в точку, делает паузу, затем сдвигается на случайную величину (rndd — переменная со случайным смещением, генерируется где-то раньше по сценарию) и только после этого выполняется клик — траектория движения курсора перестаёт быть идеальным «телепортом», что снижает вероятность срабатывания поведенческих антибот-эвристик.
C#:
// 1. Assign project variable values to local code variables, converting them to int
int startX = int.Parse(project.Variables["click_left"].Value);
int startY = int.Parse(project.Variables["click_top"].Value);
// Define movement parameters
int moveDownPixels = 0; // How many pixels to move down
int moveUpPixels = 0; // How many pixels to move up from the highest point (startY - moveUpPixels)
int moveSidewaysPixels = int.Parse(project.Variables["rndd"].Value); // How many pixels to move left or right
// You can change this to -moveSidewaysPixels for left, or +moveSidewaysPixels for right
// Let's assume you want to move RIGHT by default here.
int sidewaysDirection = 1; // 1 for right, -1 for left
// --- Mouse Movement Sequence ---
// Move to the initial coordinates
instance.ActiveTab.FullEmulationMouseMove(startX, startY);
Thread.Sleep(1000); // Pause for 1 second at the start position
// Move UP from the current position, then Sideways (Right in this example)
int upY = startY - moveUpPixels; // Calculate the "up" position relative to the initial Y
int sidewaysX = startX + (moveSidewaysPixels * sidewaysDirection); // Calculate sideways X
instance.ActiveTab.FullEmulationMouseMove(sidewaysX, upY); // Move to the new Up/Sideways position
Thread.Sleep(1000); // Pause for 1 second
// --- Final Click ---
instance.ActiveTab.FullEmulationMouseClick("left", "click"); // Perform a regular click[/FONT][/COLOR][/td]
6. Математика ROI: считаем деньги
Возьмём условный, но реалистичный проект: 100 параллельных потоков, в сценарии каждого потока — 50 точек (значений AiTaskId), где обычно потребовалось бы распознавание элемента интерфейса. Для наглядности возьмём условную стоимость одного мультимодального вызова (скриншот + DOM + текстовый промпт) в $0.003 — ориентир для бюджетной модели уровня «мини»/flash-класса; для более точных моделей цифра будет выше, для локальной Ollama — нулевая, но с затратами на инфраструктуру.Сценарий А — ИИ на каждом потоке (без оркестратора). Каждый поток независимо вызывает LLM в каждой из 50 точек:
При 20 циклах в сутки (типично для проектов с быстрой ротацией задач) — это $300/день, или ≈$9 000/месяц только на распознавание интерфейса, без учёта содержательных задач вроде генерации текста.100 потоков × 50 точек × $0.003 = $15 за один проход всех потоков
Сценарий Б — Orchestrator Pattern. На первом цикле система ещё «холодная»: если все 50 уникальных состояний интерфейса будут открыты уже первыми несколькими потоками, первый проход потребует максимум 50 вызовов ИИ — по одному на каждое уникальное состояние, а не 5000:
Все последующие проходы обслуживаются практически без обращения к ИИ. Даже закладывая 5% «нового» трафика ежедневно как разумный запас на изменчивость реальных сайтов:50 уникальных состояний × $0.003 = $0.15 за первый проход
100 потоков × 50 точек × 5% × $0.003 = $0.75 за проход
20 проходов/день = $15/день, ≈ $450/месяц
| Метрика | Без оркестратора | С оркестратором (5% cache-miss) | С оркестратором (зрелый KB, 1% miss) |
|---|---|---|---|
| Вызовов ИИ за проход | 5 000 | 250 | 50 |
| Стоимость за проход | $15 | $0.75 | $0.15 |
| Стоимость в день (×20) | $300 | $15 | $3 |
| Стоимость в месяц | ≈$9 000 | ≈$450 | ≈$90 |
| Экономия | — | 95% | 99% |
Помимо прямой экономии, побочный эффект — снятие давления с rate-limit: вместо 5000 одновременных запросов к API получаем 50–250, что на порядок снижает вероятность упереться в лимиты провайдера и, как следствие, увеличивает скорость всего проекта, а не только его экономичность.
7. Как улучшать и применять по-новому
Приложенный проект — рабочий, но сознательно минималистичный шаблон: он показывает принцип, а не исчерпывающую промышленную реализацию. Ниже — направления, в которых его можно развивать; это авторские предложения по расширению паттерна, а не описание того, что уже реализовано в примере.
7.1. Выйти за пределы «клика»
Пара «Compare & Fetch → (ИИ) → Save Solution» ничего не знает о том, что именно делает поток с найденными координатами — блок 7 выполняет клик просто потому, что это самый частый случай. На его месте можно подставить: заполнение поля текстом (тогда solution в KB хранит не только x/y, но и, например, ожидаемый формат значения), выбор пункта в выпадающем списке, drag-and-drop, скролл до появления элемента, или просто «подтвердить, что элемент существует» без клика вообще — сама схема отпечатка и кэша от типа целевого действия не зависит.
7.2. Замкнуть цикл обратной связи: инвалидация протухших решений
Сейчас запись в KB считается верной навсегда. Более устойчивый вариант — фиксировать в solution не только координаты, но и результат последнего использования: если клик по кэшированным координатам не привёл к ожидаемому переходу/изменению DOM, это сигнал, что вёрстка изменилась, и запись стоит принудительно инвалидировать (удалить или пометить stale: true), а не ждать, пока структурный отпечаток случайно перестанет совпадать сам.
7.3. Формализовать блокировку между потоками
Файл-флаг zp_python\очередь многопоток.txt (раздел 4.1 и 4.6) в приложенном виде — простой индикатор, а не строгий мьютекс: он не блокирует запись для других потоков, а лишь оставляет пометку. Для по-настоящему высокой конкурентности (сотни потоков, часто встречающих один и тот же новый макет одновременно) имеет смысл заменить его на файловую блокировку с эксклюзивным доступом (FileStream с FileShare.None) или на именованный системный Mutex — тогда только один поток реально дойдёт до вызова ИИ по конкретному AiTaskId, а остальные дождутся его результата и возьмут уже готовую запись из KB, вместо того чтобы параллельно оплатить один и тот же вызов несколько раз.
7.4. Перерасти файловую KB, когда потоков становится много
Файлы {hash}.json в папке на диске — быстрый и прозрачный вариант, который прекрасно работает для одной машины или нескольких машин с общей сетевой папкой. Когда счёт потоков идёт на сотни и на разные физические серверы без общего диска, естественное развитие — перенести ту же схему (ключ = хэш, значение = solution + метаданные) в Redis или SQLite/Postgres: интерфейс AiDomCompareAndFetch/AiDomSaveSolution остаётся тем же (get by hash, set, перебор для нечёткого поиска), меняется только слой хранения.
7.5. Добавить визуальный слой поверх структурного
Структурный отпечаток отлично ловит изменения вёрстки, но слеп к чисто визуальным различиям — тёмная тема вместо светлой, другая локализация с более длинными подписями, сезонное оформление баннера. Если это критично, к отпечатку можно добавить второй, визуальный ключ (например, perceptual hash скриншота области элемента) и использовать его как дополнительный фильтр при нечётком совпадении — это удерживает кэш от слишком уверенного смешивания визуально разных, но структурно похожих состояний.
7.6. Мультипровайдерный fallback вместо одного ACTIVE_PROFILE
click_analiser.py уже поддерживает десяток провайдеров (раздел 5.5), но переключение между ними в приложенном виде — ручное, через ACTIVE_PROFILE. Логичное развитие — список профилей-кандидатов с автоматическим переходом к следующему при ошибке или превышении лимита текущего (try: profile_A except RateLimitError: profile_B), особенно полезно на пиковых нагрузках, когда быстрый и дешёвый провайдер временно недоступен.
7.7. Не хранить API-ключ в тексте .py на диске
Механизм подстановки {-Variable.agent_api_key-} (раздел 4.6) удобен, но означает, что ключ на короткое время оказывается прямо в файле на диске в открытом виде. Более защищённый вариант — передавать ключ процессу через переменную окружения при запуске (Запуск программы с заданным env), не подставляя его в тело скрипта вообще; click_analiser.py уже умеет читать ключ из os.environ — этот путь достаточно включить вместо текстовой подстановки.
7.8. Телеметрия по самой базе знаний
У каждой записи KB уже есть счётчик hits (раздел 5.6) — этого достаточно, чтобы раз в день считать долю exact/fuzzy/none по логам AiDomCompareAndFetch и строить графики реальной экономии по факту, а не по оценке — и заметить деградацию (резкий рост доли none), если сайт массово поменял вёрстку, раньше, чем это скажется на счёте за API.
8. Итог
Orchestrator Pattern — это не «ИИ вместо логики», а её ровно противоположность: максимум детерминированной, быстрой C#/HTTP/Browser-логики и минимальный, точечный, кэшируемый слой интеллекта, который включается только там, где рутина заканчивается. Каждый доллар, потраченный на LLM в этой архитектуре, покупает не разовое действие, а актив — запись в базе знаний, которой будут пользоваться сотни последующих потоков и любые другие проекты, подключённые к той же папке AiKbFolder.
Приложенный проект специально устроен как шаблон, а не как готовый бот под конкретную задачу: клики в блоке «Начало» — условный пример, но пять шагов вокруг них (собрать DOM → построить отпечаток → сравнить с базой → при необходимости спросить ИИ → сохранить решение) — самостоятельный, переносимый модуль. Внедряется один раз — пять C#-сниппетов плюс внешний Python-скрипт с реестром провайдеров — и после этого подтягивается к любому существующему шаблону ZennoPoster как универсальный защитный слой от сбоев, изменений вёрстки и, самое главное, от расходов на повторные вопросы к ИИ, ответ на которые уже известен.
Приложенный проект специально устроен как шаблон, а не как готовый бот под конкретную задачу: клики в блоке «Начало» — условный пример, но пять шагов вокруг них (собрать DOM → построить отпечаток → сравнить с базой → при необходимости спросить ИИ → сохранить решение) — самостоятельный, переносимый модуль. Внедряется один раз — пять C#-сниппетов плюс внешний Python-скрипт с реестром провайдеров — и после этого подтягивается к любому существующему шаблону ZennoPoster как универсальный защитный слой от сбоев, изменений вёрстки и, самое главное, от расходов на повторные вопросы к ИИ, ответ на которые уже известен.
Вложения
Последнее редактирование:


