Я делаю 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 конфигурации. Чтобы не молотить годами выгрузку, которая давно никому не нужна, - с письмом владельцу и баннером в интерфейсе.


У меня на ноутбуке до сих пор стоит всё нужное, чтобы сделать это руками, и я всё равно иду в браузер. Не потому, что не осилю написать парсер, а потому, что не хочу после этого заводить ему сервер.
Код, написанный моделью, лезет в интернет с чужими учётками. Каждый 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 \
playwright
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
RUN 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_code
Arguments: {"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 = None
for 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.