Сервис, который пишет мне парсеры по ТЗ. Начиналось как тест на выходные

от автора

Я делаю catalogloader.com, price-matrix.ru и inventorymod.com — всё вокруг товарных данных. Чтобы клиент начал пользоваться, к нему нужно подключить поставщика. Поставщик каждый раз новый: у одного XML по ссылке, у второго REST с OAuth, у третьего личный кабинет с кнопкой «выгрузить Excel», у четвёртого — только сайт с каталогом и больше ничего.

Работа тупая и одинаковая: сходить, забрать, разложить по колонкам. Но её надо делать сорок раз подряд, каждый раз чуть иначе, и каждый раз это вечер: devtools, пагинация, где тут категория, написать выгрузку, отладить, положить в проект.

В конце июня 2026-го, в четверг вечером, я сел проверить, можно ли это отдать модели. Не «пусть подсказывает код в редакторе» — этого мне и так хватало. Хотелось, чтобы она сама запускала то, что написала, видела ответ сервера и правила по факту.

Сейчас оно живёт на script.catalogloader.com, называется AIScript, и подключения поставщиков в price-matrix я через него и делаю. Дальше — как оно устроено и обо что я расшибся по дороге.

Первая версия не работала вообще

Начал я, как все начинают: свободный текст один вызов LLM Python-код запуск в контейнере. На демонстрационных задачах — «сгенерируй табличку», «переложи JSON в xlsx» — всё выглядело прекрасно. На реальном поставщике не заработало ни разу. Ни одного подключения я этой версией не сделал.

Выглядит это так: код красивый, по нему видно, что автор понимает, что делает, эндпоинт правильного вида, пагинация на месте, поля разложены по колонкам. Запускаешь — 404. Или 200 и пустой список, потому что параметр называется иначе. Или всё отработало, а в файле нули, потому что цена лежит на два уровня глубже. Правишь, запускаешь снова — вылезает следующее такое же. Быстрее было написать самому.

Довольно долго я считал, что дело в промпте, и вылизывал формулировки. Не помогло ни разу, потому что проблема была не там.

Через три недели, 17 июля, у меня появилась записка, где я сам себе это сформулировал — три причины, почему one-shot не вывозит именно интеграции:

(A) У модели нет документации API поставщика (она угадывает эндпоинты/поля).
(B) Генерация one-shot, без реального пробного запроса и без цикла «сгенерировал запустил увидел ошибку починил».
(C) Ввод — свободный текст, а у интеграции есть чёткая СТРУКТУРА (база URL, авторизация, сущности, пагинация, формат файла).

Корень — (B). Модель без ответа сервера угадывает, а угадать чужой API с первого раза нельзя: это не задача на знание питона, это задача на факты, которых у неё нет. Никакой промпт этого не чинит — нужен реальный запрос и возможность посмотреть, что вернулось. Пришлось выкидывать one-shot и делать агентный цикл.

Как это работает сейчас

Пользователь пишет задачу текстом. Модель получает пять инструментов и ходит по ним, пока не дойдёт до результата:

Постановка задачи: обычный текст, никакого конструктора и селекторов.

Постановка задачи: обычный текст, никакого конструктора и селекторов.
  • run_code — выполнить Python в песочнице, вернуть настоящие stdout/stderr;

  • read_input_file — поискать по загруженной документации (Swagger/OpenAPI/текст);

  • write_workspace_file / read_workspace_file — состояние между шагами;

  • finish — отдать финальный скрипт и резюме человеку.

Спека run_code (формат Anthropic; для OpenAI Chat Completions, OpenAI Responses и Gemini она транслируется на лету):

'run_code': {    'name': 'run_code',    'description': ('Execute a Python script in an isolated sandbox (Python 3.12; '        'available: requests, pandas, openpyxl, selectolax, beautifulsoup4, lxml, '        'jmespath, price-parser). Configured secrets are available as '        "os.environ['NAME']. For a file result, write to /output/result.* "        '... Use it for API RECONNAISSANCE (print responses) and for the final export. '        'During recon print COMPACTLY: keys(), len(), the first 1-2 elements/a slice - '        'not the whole response, otherwise the important parts get truncated.'),    'input_schema': {        'type': 'object',        'properties': {            'script': {'type': 'string', 'description': 'The full Python script to run.'},        },        'required': ['script'],    },},

Нормальная сессия выглядит так: один-два разведочных прогона («напечатай статус и первые два элемента»), потом маленький образец на три товара, который уже создаёт /output/result.xlsx, потом finish с полным скриптом без тестовых ограничений. Полную выгрузку по всему каталогу запускает уже пользователь — кнопкой или по расписанию.

Границы, до которых я дошёл методом проб: один run_code — 120 секунд, вся задача — 900, потолок итераций сквозь возобновления — 45, порог обрезки истории — 200 000 символов. Последнее число я поднимал дважды: начинал с 60k, и для окон современных моделей это оказалось смешно мало.

Половина ценности оказалась не в генерации

Я затевал всё ради качества кода, а самый большой выигрыш получил в двух местах, про которые вообще не думал: на старте и после.

Старт. Чтобы написать тот же парсер руками, нужен поставленный питон нужной версии, редактор с настроенным окружением, виртуалка под проект, доставленные библиотеки и оплаченный доступ к модели, если хочется помощи. Ничего сложного, но это полчаса до первой строчки — и полчаса каждый раз, когда садишься за это не на своей машине. В браузере ничего этого нет: открыл вкладку, написал задачу. Библиотеки уже в образе песочницы, модель уже подключена.

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

Здесь скрипт получает дом в момент сохранения, и это ровно то, что я имею в виду под «мгновенным хостингом для питон-скриптов»:

  • Расписание — галочка в диалоге: каждые N часов, ежедневно в HH:MM или по дням недели. Дальше это забота демона, а не человека.

  • Внешний API — ключ создаётся автоматически вместе с конфигурацией. GET /api/v1/export?key=... отдаёт файлы последней выгрузки, а с run_and_wait_timeout=120 запускает скрипт прямо сейчас, ждёт и отдаёт файл именно этого прогона (не успел — 504, второй параллельный запуск той же конфигурации — 409).

  • История прогонов с логами и файлами, чистится сама: храним три последних.

  • Живой лог и предпросмотр файла прямо во время работы — видно, как результат растёт.

  • Письма о том, что скрипт готов, что длинный ручной прогон закончился и что прогон прервался.

  • Автоотключение расписания, если за N дней никто ни разу не дёрнул API конфигурации. Чтобы не молотить годами выгрузку, которая давно никому не нужна, — с письмом владельцу и баннером в интерфейсе.

Расписание    галочка и время: ни сервера, ни крона, ни присмотра за ними.

Расписание — галочка и время: ни сервера, ни крона, ни присмотра за ними.
API-ключ создаётся вместе с задачей: выгрузку можно забирать из чужого кода.

API-ключ создаётся вместе с задачей: выгрузку можно забирать из чужого кода.

У меня на ноутбуке до сих пор стоит всё нужное, чтобы сделать это руками, и я всё равно иду в браузер. Не потому, что не осилю написать парсер, а потому, что не хочу после этого заводить ему сервер.

Песочница

Код, написанный моделью, лезет в интернет с чужими учётками. Каждый run_code — отдельный контейнер:

'--memory=256m','--cpus=0.5',f'--env=SANDBOX_TIMEOUT_SECONDS={_eff}',

Внутри python:3.12-slim, непривилегированный uid 1000 и заранее поставленные библиотеки, чтобы модель не тратила шаги на pip install:

RUN pip install --no-cache-dir \        requests pandas openpyxl \        selectolax beautifulsoup4 lxml jmespath price-parser \        curl_cffi \        playwrightENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwrightRUN playwright install --with-deps chromium \    && chmod -R a+rx /ms-playwright

Playwright с headless Chromium и curl_cffi приехали в образ в один день, 10 августа, с разницей в пару часов. Парсингом мы занимаемся постоянно, и обе категории сайтов у нас встречаются регулярно: там, где каталог рисуется джаваскриптом, модель честно забирает HTML — а товаров в этом HTML нет вообще; там, где стоит защита по TLS-отпечатку, всё непохожее на настоящий браузер получает 403 независимо от заголовков. Раньше я обходил и то и другое руками в каждом проекте по отдельности, и в какой-то момент стало очевидно, что оба инструмента должны просто лежать в образе — чтобы модель могла ими воспользоваться сама, без моего участия.

Секреты пользователя лежат зашифрованными Fernet, в контейнер уезжают через --env-file, в логах маскируются. В генерируемом коде их значений быть не должно — только os.environ['NAME'] без дефолта. Это отдельным абзацем в системном промпте, потому что модель по доброте душевной подставляет значение прямо в исходник, чтобы «скрипт запускался как есть». Для настроенных секретов это плохо, а для логина-пароля, который человек сам вписал в ТЗ, — наоборот правильно. Про это разделение будет отдельно ниже, я на нём знатно обжёгся.

Что сломалось: бесконечная разведка

Главная головная боль, и промптом она не лечится. Модель заходит на сайт, печатает структуру, решает проверить ещё категорию, потом пагинацию, потом «а посмотрим карточку товара» — и упирается в потолок шагов, ни разу не создав файл результата. Строчка «не исследуй долго» в промпте не даёт вообще ничего: модель искренне считает, что вот этот, последний прогон точно нужен для дела.

Что сработало — вклиниваться в диалог. С третьего разведочного прогона без результата на каждом шаге подмешивается user-сообщение:

def _steer_message(recon_steps: int, have_output: bool):    if recon_steps < _STEER_START:        return None    if have_output:        body = ('Stop. You ALREADY have a successful run with a non-empty '            '/output/result.* on a sample. Reconnaissance is over - on the NEXT step '            'call finish and pass the FULL final script (without test limits) in the '            'code field. Do not investigate anything else.')    else:        body = (f'Stop. You have made {recon_steps} reconnaissance runs but still have '            'NOT created the /output/result.* file. ... take ONE category, the first 3 '            'product cards, parse the fields required by the task, ALWAYS write the rows '            'to /output/result.xlsx and print the row count. ...')    return {'role': 'user', 'content': body}

Порог я потом опустил с четырёх до трёх. Смешное наблюдение: пере-разведкой грешат не слабые модели, а как раз сильные — у gpt-5.6-luna в моих прогонах стабильно выходило по четыре холостых запуска без записи result.*, то есть она догорала ровно на границе. Развернул на шаг раньше — стало заметно лучше.

Что сломалось: история

Один шаг агента — это скрипт плюс stdout, а stdout легко бывает в десятки килобайт, если модель напечатала весь ответ целиком (за что её отдельно ругает описание инструмента, см. выше). Пять шагов — и контекст кончился.

Просто выкидывать старое нельзя: модель тут же идёт проверять эндпоинт, который двумя шагами раньше отдал 404. Поэтому вытесненные пары «вызов результат» уходят в отдельный дешёвый вызов модели и возвращаются одним сжатым сообщением с маркером:

_BRIEF_PREFIX = ('[SUMMARY OF EARLIER, EVICTED STEPS - what was already tried, so you '    'do not repeat yourself or re-fetch what is already known]\n')

Маркер сделан префиксом прямо в тексте сообщения. Хотелось служебным ключом в dict, но провайдеры по-разному переваривают незнакомые поля в messages, и я решил не выяснять это в проде.

Дешевле — значит больнее

К этому моменту агентный цикл работал. На claude-opus-4.8 и на codex-5.5 задачи доходили до finish в общем-то неплохо: модель делала разведку, писала образец, отдавала полный скрипт. Проблема была не в качестве, а в счёте. Одна задача — это десяток вызовов модели, и в каждом едет вся история с килобайтами stdout от предыдущих прогонов. Для сервиса, где человек за месячную подписку делает не одну задачу, такая себестоимость генерации не сходилась.

29 июля я полез смотреть, что будет на дешёвой модели, — взял gemini-3-6-flash через сторонний шлюз. Следующие два дня я занимался исключительно тем, что чинил последствия этого решения. Зато набор костылей получился поучительный, и он весь до сих пор в коде.

Модель проговаривает вызов словами

Главная беда: просишь tool_choice = ANY, а в ответ приходит текст, в котором вызов функции аккуратно описан прозой:

Function call requested: run_codeArguments: {"script": "..."}

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

Первая линия обороны — nudge, короткое «продолжай через инструменты, текстом отвечать нельзя». Помогает, но не всегда, и заодно сжигает ход. Поэтому появился salvage — попытка вытащить вызов из текста парсером. Наивная версия («найди JSON после Arguments») прожила примерно один живой прогон.

Дальше начался цирк с экранированием

По порядку, как оно ломалось на живых запусках. Каждый пункт — отдельный вечер и отдельный коммит:

  • Хвостовой мусор. Шлюз дописывал после JSON что-нибудь своё, вроде ... Retention limit: 100 lines}. Обычный json.loads на этом падает.

  • Настоящие переводы строк внутри строкового аргумента. Python-скрипт приезжал в значении не как \n, а живыми переносами — то есть это уже не JSON.

  • Экранированные кавычки внутри кода плюс тот же мусор в хвосте: одной стратегией «обрезать хвост» такое не вытаскивается.

  • Легитимный \n в исходнике. Пятый по счёту сорванный прогон: в скрипте было честное split('\n'). Мой код по привычке раскодировал escape-последовательности, превратил их в настоящий перенос строки, сломал строковый литерал — и отверг совершенно рабочий скрипт.

  • Совсем другой формат. Иногда шлюзвыдавал вообще не JSON: action:default_api:run_code{script:...сырой питон...}.

Схема, к которой я пришёл, простая, и нравится мне больше любой попытки угадать правило. Не выяснять, как именно всё закодировано, а перебрать варианты и взять тот, что даёт валидный Python:

текст ответа       формат A: "Function call requested: NAME / Arguments: {...}"         пробуем raw_decode (терпим мусор в хвосте)   получилось? берём         не вышло  достаём значение ключа вручную:              срезы:    S1 обрезать хвостовой мусор                        S2 до первой НЕэкранированной кавычки              варианты: как есть  /  с раскодировкой escape               4 кандидата, берём ПЕРВЫЙ, который парсится как Python       формат B: "action:default_api:NAME{script:...}"  сырой питон до финальной }

В коде это выглядит так:

bases = [tail.rstrip(' \t\r\n"\'}')]               # S1: срезать хвостmq = re.search(r'(?<!\\)"', tail)                 # S2: до 1-й НЕэкранир. кавычкиif mq:    bases.append(tail[:mq.start()])candidates = []for base in bases:    for val in (base, _json_unescape(base)):        if val not in candidates:            candidates.append(val)chosen = Nonefor val in candidates:    if not val.strip():        continue    if key != 'script' or check_python_syntax(val)[0]:        chosen = val        break

Ключевая деталь — check_python_syntax как критерий отбора. Спасённый вызов принимается, только если внутри действительно компилируемый Python; иначе лучше потратить ход на nudge, чем гонять в песочнице заведомо битый (обычно обрезанный) скрипт. И raw пробуется раньше раскодированного — именно из-за истории с split('\n').

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

Подпись мыслей, которую нельзя терять

Отдельная засада, на которую ушло больше всего времени, потому что симптом выглядел как предыдущая проблема. У Gemini 3 с включённым thinking к каждому functionCall прицеплена thoughtSignature, и её надо возвращать обратно в истории. Я её выбрасывал при разборе ответа — и через пару ходов модель теряла собственную цепочку рассуждений и начинала выдавать вызовы текстом. Внешне тот же самый «нет tool call», а причина совсем другая: я сам ломал ей контекст.

Пришлось протаскивать подпись через весь цикл: ловить при разборе, хранить во внутреннем ключе блока tool_use и приклеивать обратно при сборке запроса. Ключ появляется только при AI_API_FORMAT=gemini, конвертеры остальных провайдеров его игнорируют.

А закончилось всё тем, что thinking в агентном пути я по умолчанию выключил:

GEMINI_AGENT_THINKING = False   # слать ли thinkingConfig в агентном пути Gemini

Логика такая: thinking + function calling через шлюз ломают структурные вызовы, а пошаговое рассуждение в агентном цикле и так есть — оно и есть сам цикл, только с реальными результатами вместо размышлений. В одношаговой генерации thinking остался, а round-trip подписи никуда не делся и работает страховкой.

Мелочи, которые добили

Пришлось поднять два порога. MAX_TEXT_ONLY с 2 до 5 — сколько подряд текстовых ответов терпим, прежде чем признать задачу сорванной: nudge обычно возвращает модель в колею за один-два хода, а порог 2 убивал задачи в шаге от готового результата. И бюджет токенов на задачу со 120k до 250k — флаки-модели нужно место, чтобы оправиться и всё-таки сойтись.

Отдельно веселил судья — модель, которая проверяет, годится ли полученный образец. На тестовой выборке из трёх товаров он честно писал «данные неполные, категорий мало» и отклонял нормальный результат. Пришлось и промпт судьи править (на ТЕСТОВОМ образце полнота не важна), и добавить детерминированный обход: если NOTOK выставлен только за полноту, а на руках непустая таблица — принимаем. Структурные претензии по-прежнему отклоняют.

Что в итоге

Оно поехало. Живой прогон на реальной задаче выгрузки стабильно доходит до finish=success, и это на модели, которая дешевле сильных в разы. Побочный эффект — поддержка четырёх форматов вызова инструментов: Anthropic Messages, OpenAI Chat Completions, OpenAI Responses и Gemini. Не от любви к абстракциям, а потому что провайдера хочется менять строчкой в конфиге.

Сейчас в дефолте gpt-5-6-sol с reasoning effort low, рядом закомментированы профили под claude-sonnet-5 и gemini-3-6-flash. Слабой модели, кстати, high заметно помогает — в отличие от сильных, где разницы почти не видно.

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

Уточняющие вопросы перед генерацией

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

Зато перед запуском человек сидит перед экраном. Один короткий вызов модели (25 секунд, не ответил — молча идём генерировать как есть) возвращает строгий JSON:

{  "plan": ["short line", "short line"],  "questions": [    {"id": "format", "text": "question in plain words",     "options": ["option 1", "option 2"], "default": "option 1"}  ]}

План человеческими словами плюс максимум три вопроса, и обязательно с готовыми вариантами — печатать ничего не надо, надо ткнуть. Расчёт на нетехническую аудиторию: узнать ошибку в готовом плане намного проще, чем сформулировать недостающее с нуля. Человек, который не напишет ТЗ, отлично видит, что в плане написано «выгрузим все категории», а ему нужна одна.

Reasoning у этого шага прибит на low независимо от общего профиля. Разбор одной фразы — не то место, где нужны рассуждения, и если кто-то поднимет общий профиль ради качества кода, экран перед генерацией не должен от этого тормозить.

Неожиданное: им пользуются не программисты

Я строил инструмент под себя и был уверен, что аудитория — такие же разработчики. Промахнулся. Первым же посторонним человеком, который что-то через него выгрузил, оказался менеджер, никогда не писавший кода. Он не открывал сгенерированный скрипт, ему это и не нужно: есть поле для ТЗ, есть план с вопросами, где надо выбрать вариант, и есть файл на выходе.

Отдельно интересно про тех, кто уже пробовал решать такое через чат с ИИ. У них обычно есть опыт вида «модель написала код, а дальше что». Дальше — ставить питон, разбираться с ошибкой в консоли, идти обратно в чат с этой ошибкой, и так по кругу; на третьем витке человек бросает. Здесь этот круг проходит сам агент, внутри, и наружу выходит уже то, что реально запустилось.

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

Доработка словами: человек пишет, что поправить, и видит историю прежних запросов. В код заходить не нужно.

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

Как я запретил модели работать

Свежая история, чинил 7 сентября. Человек написал ТЗ, честно вписал туда логин и пароль от личного кабинета поставщика — и получил скрипт, который отказывается запускаться, пока не настроишь секреты в интерфейсе. Формально всё правильно: во всех промптах у меня было написано «секреты только через os.environ, значения в код не писать», а для случая «секреты не настроены» — прямым текстом: сообщи об этом и заканчивай. Модель послушалась. Человек, который уже дал доступы и хотел рабочий скрипт, упёрся в стену.

Ошибка была в том, что я свалил в одну кучу две разные вещи. Секрет, который пользователь завёл в хранилище, — это одно. Логин, который он только что своими руками вписал в текст задачи, — совсем другое, и требовать после этого «настройте окружение» просто издевательство.

Теперь во всех движках генерации просьба одна: собрать в начале скрипта блок НАСТРОЕК — логин, пароль, токен, адрес, категории, лимиты, имя выходного файла, заглавными именами. Доступы из текста задачи уезжают туда значениями по умолчанию:

LOGIN = os.environ.get('SITE_LOGIN', 'из-задачи')

Скрипт после этого работает как есть у человека, который вообще ничего не настраивал; всё, что хочется покрутить, лежит в одном месте в начале файла; а если те же секреты потом завести в хранилище — они переопределят умолчания, и код трогать не придётся. Для уже настроенных секретов строгое правило осталось прежним: os.environ['ИМЯ'] без дефолта, в код не писать, в лог не печатать.

Тот самый блок НАСТРОЕК в начале сгенерированного скрипта: всё, что можно покрутить, собрано в одном месте.

Тот самый блок НАСТРОЕК в начале сгенерированного скрипта: всё, что можно покрутить, собрано в одном месте.

Отдельным пунктом пришлось запретить выдумывать доступы, если их нет вообще нигде: пустое значение с комментарием и сообщение в лог — но не отказ работать. Модель, которой нечего подставить, охотно пишет PASSWORD = 'your_password_here' и делает вид, что всё в порядке.

Мораль, которую я вынес: осторожные формулировки в промпте («никогда», «обязательно», «иначе прекрати») модель исполняет буквально и до конца. Запрет, написанный ради безопасности, тихо ломал у меня ровно тот сценарий, ради которого сервис менеджеру и нужен: вписал всё в описание один раз — получил файл.

Скучное, которое видно только на проде

Воркер умирает. Деплой, ребут, OOM — задача остаётся в GENERATING навсегда. Сначала я чинил это в вебе: фронт поллит /status, мы замечаем мёртвый воркер, поднимаем заново. Работало ровно до первого человека, который закрыл вкладку и ушёл. Восстановление переехало в демон планировщика и стало проактивным, веб — read-only. Там же чинятся зависшие docker-прогоны: пока такой висит в RUNNING, приложение отдаёт 409 на «Запуск», и скрипт заблокирован намертво.

Файл должен расти на глазах. Полная выгрузка каталога — это десятки минут, и если писать файл в конце, то прогон, срезанный таймаутом, не оставляет ничего. В промпте отдельным пунктом: дописывать строки по ходу и делать flush() после каждой пачки, предпочитая CSV; xlsx собирать в конце, если он заказан явно. Побочно это оказалось важным психологически — человек смотрит, как файл растёт, и не дёргает меня вопросом «оно вообще работает?».

Лог прогона и файл результата: видно, сколько строк собрано по каждой категории.

Лог прогона и файл результата: видно, сколько строк собрано по каждой категории.
Предпросмотр выгрузки прямо в браузере  -  скачивать файл, чтобы проверить результат, не нужно.

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

Вежливость к источнику. Не остановишь — модель напишет многопоточный обход всех ссылок на странице. Пришлось прописать явно: только категории и пагинация, последовательно, time.sleep(0.2-0.5) между страницами, ошибки глотать и продолжать. И всегда создавать result.*, даже если данных нет — хотя бы с заголовками колонок.

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

Чего до сих пор нет

Честный список, чтобы не выглядело глянцем.

Нет нормального ретрая частично сломанного скрипта: если полная выгрузка упала на 8000-й позиции, её надо запускать заново с начала (состояние в /workspace/state/ для инкрементальных выгрузок есть, но модель пользуется им, только если попросить прямо в ТЗ). Нет диффа между версиями скрипта — видно, что новая версия хуже старой, а чем именно, приходится сравнивать глазами.

Самое обидное из ненаписанного — сравнение выходных файлов между запусками. Для парсинга это больное место: скрипт, поставленный на расписание, не ломается громко. Поставщик поменял вёрстку или закрыл часть каталога — скрипт отработает со статусом «успех», просто в файле будет не 12 000 строк, а 300. Или 12 000, но пустых в половине колонок. Формально всё хорошо, прогон зелёный, и узнаёшь ты об этом от клиента.

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

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

Зато из недавно доделанного — тот самый синхронный запуск через API, про который выше. Пока его не было, наружу отдавалась только последняя готовая выгрузка, и чтобы получить свежую, надо было руками идти в интерфейс и жать кнопку. Мелочь на день работы, а без неё сервис нельзя было честно дёргать из чужого кода.

Итог

Список задач: расписание, последний запуск, статус и файлы  -  всё в одной таблице.

Список задач: расписание, последний запуск, статус и файлы — всё в одной таблице.

Технически получился обычный веб-сервис: Flask + peewee, MySQL на проде, отдельный демон под расписания и восстановление, свои зашифрованные секреты, двуязычный интерфейс, сохранённый скрипт как «конфигурация», которую можно перезапускать руками или по расписанию.

Практически — я перестал писать подключения поставщиков руками. Цифры пока скромные и я не буду делать вид, что это иначе: через сервис прошло шесть настоящих рабочих задач, четыре скрипта уехали в проект без единой правки, два я открывал и допиливал. Но и два допиленных — это правка уже написанного, а не старт с пустого файла, и по времени это несопоставимо.

Первый коммит — 25 июня. Затевалось как проверка гипотезы на пару вечеров, а стало тем, куда я иду по умолчанию, когда появляется новый поставщик. Посмотреть можно тут: script.catalogloader.com.

ссылка на оригинал статьи https://habr.com/ru/articles/1080166/