Централизованный ИИ-Оркестратор: экономия до 90%+ бюджетов на LLM API в многопоточных проектах ZennoPoster

SAT

Client
Регистрация
24.12.2024
Сообщения
72
Реакции
170
Баллы
33
Централизованный ИИ-Оркестратор:
экономия до 90%+ бюджетов на LLM API в многопоточных проектах ZennoPoster


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


Главная обложка статьи (Кейс).jpg



​

1. Парадокс ИИ в многопотоке: почему «умный» скрипт может разорить проект​

У любого, кто пробовал прикрутить LLM к ZennoPoster напрямую — «в лоб», на каждый поток — рано или поздно происходит одно и то же: счёт за API начинает расти быстрее, чем количество успешно обработанных аккаунтов.


Причина проста и почти всегда игнорируется на старте. ZennoPoster — это по определению многопоточная среда, где десятки или сотни независимых потоков выполняют одну и ту же логику, на одинаковых (или почти одинаковых) версиях интерфейса сайта. Если ИИ-агент подключён напрямую к каждому потоку:


  • Поток №1 находит кнопку «Отправить» через LLM — платим за вызов.
  • Поток №2 через секунду сталкивается с той же самой кнопкой на том же макете — снова платим за вызов, хотя ответ будет идентичным.
  • Поток №50 — то же самое. И поток №100. И так на каждом шаге сценария, где применяется распознавание элемента.

Получается математика, которая убивает экономику проекта: N потоков × M точек принятия решений × стоимость одного вызова = кратный, а не линейный рост расходов. При этом мы платим не за интеллект, а за повторение уже известного решения.


К финансовой стороне добавляются ещё три проблемы, знакомые каждому, кто пытался так делать:


  1. Rate-limit и очереди. У большинства провайдеров жёсткие лимиты RPM/TPM. Сто параллельных потоков, одновременно бомбардирующих API одним и тем же вопросом, упираются в лимиты быстрее, чем успевают решить хоть одну реальную задачу.
  2. Задержка. LLM, тем более мультимодальная (со скриншотом), отвечает секундами, а не миллисекундами. Умножьте это на количество точек вызова в сценарии — и скорость всего проекта начинает определяться не логикой ZennoPoster, а очередью к нейросети.
  3. Недетерминированность там, где она не нужна. Задача «найти координаты кнопки на уже знакомом макете» — не творческая, а справочная. Гонять для неё LLM каждый раз — то же самое, что каждый раз заново гуглить то, что уже лежит у вас в закладках.

Главный тезис этой статьи: ИИ-агент должен думать только в двух случаях — когда произошёл сбой сценария или когда поток встретил ранее не виденное состояние интерфейса. Всё остальное — рутинное, уже решённое действие — должно выполняться без единого обращения к LLM, по кэшированному рецепту.

Раздел 1. Парадокс ИИ в многопотоке.jpg

2. Orchestrator Pattern: разделяем «руки» и «голову»​

Решение — архитектурно развести две сущности, которые физически можно упаковать в два разных проекта ZennoPoster:
Основной проект (Worker)ИИ-Оркестратор
РольБыстрое выполнение 95% рутинных действий: клики, ввод текста, навигация по стандартному C#/HTTP/Browser-шаблонуЦентральный «мозг», к которому обращаются только при нештатной ситуации
Частота обращения к LLMПрактически никогдаТолько на cache-miss
Что делает при сбоеОстанавливается на нестандартном элементе, собирает контекстПолучает DOM + скриншот + описание цели, ищет решение
Что делает после решенияЗабирает готовый ответ (координаты/шаблон действия) и продолжает работуЗаписывает найденное решение в общую базу знаний (KB)
Механика цикла выглядит так:
Код:
Развернуть Свернуть Копировать
Поток встречает шаг сценария
        │
        ▼
Строим "снимок" текущего состояния страницы (DOM-карта + структурный отпечаток)
        │
        ▼
Ищем отпечаток в базе знаний (KB) ──── Найден? ──yes──▶ Берём готовый рецепт, кликаем, едем дальше (без ИИ)
        │
        no
        ▼
Обращаемся к ИИ-Оркестратору (передаём DOM + скриншот + описание цели)
        │
        ▼
ИИ находит решение (координаты элемента / шаблон действия)
        │
        ▼
Решение записывается в KB под хэшем текущего состояния
        │
        ▼
Все остальные потоки (и даже другие проекты), встретив тот же макет,
находят готовый ответ в KB и больше никогда не платят за этот шаг
Ключевая идея, которая делает паттерн действительно рабочим — это не кэширование по URL или по имени задачи, а кэширование по структурному отпечатку интерфейса. Если бы кэш строился по конкретному тексту на странице (имя пользователя, число лайков, текущая дата), он бы «протухал» на каждом новом аккаунте. Поэтому в реализации ниже отпечаток намеренно строится так, чтобы игнорировать волатильный контент и опираться только на устойчивую структуру: теги, роли, координатную сетку, размерный класс элемента.
Это шаблон, а не готовый бот
Проект, разобранный в этой статье, — это эталонный шаблон-паттерн, приложенный как демонстрация принципа: как связать ZennoPoster, файловую базу знаний и внешний ИИ-скрипт так, чтобы обращение к LLM происходило только на cache-miss. Конкретное действие, которое выполняет поток после того, как координаты найдены (в приложенном примере — клик по полю ввода и по полю поиска), — это условный демонстрационный шаг, а не факт, что шаблон умеет только это. Сама связка Preднастройка → сбор DOM → отпечаток → сравнение с KB → (опционально) вызов ИИ → клик → сохранение решения — не завязана на конкретный сайт, конкретную задачу или конкретное действие: вместо клика на её месте может быть ввод текста, выбор из выпадающего списка, свайп, проверка появления элемента — что угодно, для чего нужно определить координаты или состояние экрана.

Раздел 2. Orchestrator Pattern разделяем «руки» и «голову».jpg

​

3. Практическая универсальность: паттерн, а не «костыль под один проект»​

Важный момент, который стоит подчеркнуть отдельно: описанная связка «Мэппер → Отпечаток → Сравнение с KB → (опционально) ИИ → Сохранение» — это не набор функций, зашитых под конкретный сценарий, а самостоятельный переиспользуемый модуль.


Он оперирует универсальными сущностями — «страница», «интерактивный элемент», «структурный отпечаток», «база знаний по задаче» — и не знает ничего о бизнес-логике конкретного проекта. Благодаря этому один и тот же комплект C#-сниппетов:


  • подключается к любому существующему шаблону ZennoPoster простым добавлением блоков в точку, где сценарий может столкнуться с нестандартным элементом;
  • масштабируется на любое число параллельных проектов — они делят одну и ту же папку AiKbFolder на диске (или сетевой ресурс), поэтому решение, найденное в одном проекте, становится доступно любому другому, если там встретится тот же макет интерфейса;
  • разделяется по задачам через AiTaskId — у каждого логического шага сценария («найти поле логина», «найти кнопку публикации») своя изолированная «полка» в базе знаний, что исключает коллизии между непохожими задачами с похожей геометрией.

Иными словами, инфраструктуру внедряют один раз, а дальше просто «подтягивают» её к новым ботам — это готовый защитный слой на все случаи, когда сайт меняет вёрстку, добавляет A/B-тест или показывает нестандартный попап.

Раздел 3. Практическая универсальность паттерна.jpg

4. Пошаговый разбор эталонного проекта — от Start до следующего действия​


Раздел 4. Пошаговый разбор эталонного проекта (Блоки 1–8).jpg
​

Ниже — разбор реального проекта ZennoPoster, приложенного как рабочий шаблон. Он состоит из восьми именованных блоков, пронумерованных на холсте самим автором проекта (1–8). Каждый блок разобран по фактическому содержимому скриншотов — без домысливания того, чего не видно.​
1789067928220.png

Общий вид блоков 1–5: Преднастройка → Начало → Обработка при ошибках → Строит стабильный «отпечаток» → Сравнивает текущий AiDomFingerprint с базой знаний​

4.1. Блок 1 — Преднастройка​

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

  1. «очередь многопоток очистка» — файловая операция записи в служебный файл zp_python\очередь многопоток.txt. Судя по месту в цепочке (самый первый шаг, до старта цикла) и по аналогичным блокам дальше по сценарию (см. блоки 6 и 7), это сброс общего файла-флага в исходное состояние — механизм, которым разные потоки координируют доступ к разделяемому ресурсу (ниже, в блоке 6, видно, как этот же файл выставляется в «1» перед обращением к ИИ).
  2. «установка разрешения экрана» (C#) — фиксирует размер окна/вьюпорта браузера. Это важно именно для данного паттерна: координаты в базе знаний считаются относительно вьюпорта (см. AI DOM Fingerprint Builder, сетка 24×24 строится от vw/vh), поэтому если разные потоки будут работать с разным разрешением окна, один и тот же макет интерфейса будет давать разные ячейки сетки — и попадать в кэш будет хуже. Фиксация разрешения — простой способ держать эту переменную постоянной.
  3. Переход по стартовому URL (https://www.google.com/ в примере) — в приложенном шаблоне это условный, демонстрационный адрес; в реальном проекте здесь стоит ваш собственный стартовый URL.
  4. «Пауза 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)​

Самый насыщенный блок всего проекта — то немногое, что действительно стоит денег, и то, что происходит только тогда, когда ни точное, ни нечёткое совпадение с базой знаний не нашлось.​
1789067945327.png

Блок 6 «Обработка с ии»: от установки лок-файла до опроса результата click_analiser.py​
По шагам, слева направо и сверху вниз:
  1. «очередь многопоток 1 в файл» — запись «Записать текст» в тот же файл zp_python\очередь многопоток.txt, но теперь со значением 1 (подтверждено скриншотом свойств действия — путь к файлу, содержимое «1», без дозаписи). По месту в сценарии — прямо перед обращением к ИИ — это, судя по всему, отметка «сейчас идёт обращение к ИИ», использующая простую файловую переменную как индикатор состояния между потоками.
  2. «вызов ии» — уведомление/лог-маркер о том, что сценарий заходит в ветку ИИ.
  3. «Сохранение изображения» + «Записать в файл» — сохраняется скриншот активной вкладки (screenshot.png — именно этот путь ожидает click_analiser.py) и DOM-карта в файл (dom.json).
  4. «Записать в файл» — формируется target.txt с текстовым описанием того, что нужно найти на экране (этот файл читает click_analiser.py, если не передан аргумент командной строки).
  5. Проверка {agent_api_key}=="" — если ключ API не заполнен, срабатывает блок-стоп «Заполните переменную с api ключом ({-Variable.agent_api_key-})» — сценарий не пытается выполнить запрос без ключа, а сразу и явно останавливается с понятным сообщением.
  6. Если ключ заполнен (пунктирная ветка вниз) — группа из трёх действий: «Установить значение openrouter_gemini переменной» (пишет строку openrouter_gemini в переменную ACTIVE_PROFILE — подтверждено отдельным скриншотом свойств действия), «Click analiser» (записывает содержимое click_analiser.py — целиком, включая заголовок «CLICK ANALYSER v3.0» — в переменную prv, что подтверждено скриншотом свойств: в поле «Значение» виден полный текст скрипта) и «Запуск программы» (исполняет сохранённый на диск файл интерпретатором Python).
  7. Правая группа: «Click analiser» / «Click analiser_check» / {prv}=='{prv_1}' — судя по названиям и положению рядом с «Запуск программы», это опрос завершения внешнего процесса: сравнение содержимого переменных prv/prv_1 — обычный в ZennoPoster приём поллинга, когда нет прямого события «процесс завершился», и вместо этого сравнивается состояние до/после с паузой между проверками.
  8. «Прочитать файл» → «ToLower» → «Regex» × 2 — после того как click_analiser.py записал coordinates.txt, ZennoPoster читает его, приводит к нижнему регистру (чтобы X=/x= не имели значения для парсинга) и двумя регулярными выражениями вытаскивает числовые значения X и Y.
1789068076941.png

Свойства действия «Установить значение openrouter_gemini переменной» — подтверждает, что `ACTIVE_PROFILE` в этом примере жёстко выставлен в `openrouter_gemini` (см. раздел 5.5, тот же ключ есть в `PROFILES`)
1789068084373.png

Свойства действия «Click analiser» — всё содержимое `click_analiser.py` v3.0 хранится и подставляется как текстовая переменная `prv` перед записью на диск и запуском
1789068090238.png

Свойства действия «очередь многопоток 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 — Выполняем клик​

1789068101066.png

Блок 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. Полный код всех компонентов — по полочкам​


Раздел 5. Полный код всех компонентов.jpg
​

Вся связка состоит из шести файлов: пяти скриптов, которые исполняются внутри 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 точек:
100 потоков × 50 точек × $0.003 = $15 за один проход всех потоков
При 20 циклах в сутки (типично для проектов с быстрой ротацией задач) — это $300/день, или ≈$9 000/месяц только на распознавание интерфейса, без учёта содержательных задач вроде генерации текста.
Сценарий Б — Orchestrator Pattern. На первом цикле система ещё «холодная»: если все 50 уникальных состояний интерфейса будут открыты уже первыми несколькими потоками, первый проход потребует максимум 50 вызовов ИИ — по одному на каждое уникальное состояние, а не 5000:
50 уникальных состояний × $0.003 = $0.15 за первый проход
Все последующие проходы обслуживаются практически без обращения к ИИ. Даже закладывая 5% «нового» трафика ежедневно как разумный запас на изменчивость реальных сайтов:
100 потоков × 50 точек × 5% × $0.003 = $0.75 за проход
20 проходов/день = $15/день, ≈ $450/месяц
МетрикаБез оркестратораС оркестратором (5% cache-miss)С оркестратором (зрелый KB, 1% miss)
Вызовов ИИ за проход5 00025050
Стоимость за проход$15$0.75$0.15
Стоимость в день (×20)$300$15$3
Стоимость в месяц≈$9 000≈$450≈$90
Экономия—95%99%
Все стоимостные величины — иллюстративные; фактическая цена зависит от выбранного провайдера/модели из реестра PROVIDERS (раздел 5.5) и от реального числа точек принятия решений в вашем сценарии. Ключевой вывод не в конкретных цифрах, а в характере зависимости: без кэша расход растёт линейно с числом потоков, с кэшем — расход определяется только числом уникальных состояний интерфейса, которое почти не зависит от количества потоков.
Помимо прямой экономии, побочный эффект — снятие давления с 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 как универсальный защитный слой от сбоев, изменений вёрстки и, самое главное, от расходов на повторные вопросы к ИИ, ответ на которые уже известен.​
 

Вложения

Последнее редактирование:

Кто просматривает тему: (Всего: 8, Пользователи: 4, Гости: 4)