Новостной конвейер на PowerShell: три минуты от ленты источников до готовой страницы. DevOps.. DevOps. Natural Language Processing.. DevOps. Natural Language Processing. powershell.. DevOps. Natural Language Processing. powershell. искусственный интеллект.. DevOps. Natural Language Processing. powershell. искусственный интеллект. Поисковая оптимизация.. DevOps. Natural Language Processing. powershell. искусственный интеллект. Поисковая оптимизация. Проектирование API.

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

Сейчас в журнале конвейера 61 карточка, из них 56 стали страницами на сайте, семь дней подряд собирается вечерняя сводка, а сам прогон от поиска до готовой страницы занимает три минуты, из которых языковая модель работает меньше минуты. Ниже — как это устроено, что пришлось переделывать и на чём PowerShell с LLM мстят друг другу.

Что было в требованиях

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

Всё это живёт на обычном хостинге с FTP, без серверной части и без базы данных: сайт статический, состоящий из HTML-файлов. Управляющая часть — PowerShell 5.1 на моей машине, 47 скриптов, около 9,7 тысяч строк.

Журнал как единственный источник истины

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

Вот как выглядит карточка в середине обработки (поля сокращены, «…» — вырезанные источники):

{
  "заголовок": "Agent4Sci: смогут ли ИИ-агенты улучшать научные модели?",
  "url": "https://arxiv.org/abs/2610.28763",
  "рубрика": "релизы",
  "дата": "2026-10-22",
  "язык": "en",
  "лид": "Новый тестбенч Agent4Sci проверяет, способны ли агенты улучшать модели…",
  "статус": "готово",
  "тема": 169,
  "источники": [{ "url": "https://arxiv.org/abs/2610.28763", "название": "arXiv cs.AI" }, "…"],
  "факты": [["Что случилось", "…"], ["Цифры", "…"]],
  "вывод": "…",
  "полезно": [["цена/доступность", "…"], ["что нужно из железа", "…"]],
  "фактЧек": "ок",
  "faq": [{ "в": "…", "о": "…" }]
}

Постоянный адрес страницы считается из данных, а не назначается вручную: дата плюс первые восемь шестнадцатеричных знаков SHA-1 от URL первоисточника. Получается 2026-10-22-2ea2fcfc.html — коротко, стабильно, без транслита и без коллизий в пределах дня.

Ключевой приём всей схемы — гейт публикации проверяет файл, а не флаг. Материал считается опубликованным тогда и только тогда, когда на диске существует сайтnews<slug>.html. Из этого автоматически следуют приятные вещи: сводки и ленты строятся только по существующим страницам, битых ссылок не бывает, а любая авария в середине цепочки просто оставляет карточку без страницы, что видно по счётчику, но не ломает сайт.

Цепочка одного материала: от ленты до страницы за три минуты

Каждый шаг — отдельный процесс powershell.exe -File. Это дороже по запуску, зато падение шага не убивает прогон: конвейер пишет его в список упавших и идёт дальше, а материал добирается следующим запуском. Реальные секунды сняты с лога прогона 22 октября:

Шаг

Что делает

Секунды

collect-news

опрос лент, отбор кандидатов

29 (17 кандидатов)

consolidate-sources

поиск дополнительных источников по сырому заголовку

3

adapt-ru

заголовок 45–65 знаков и лид 200–450 знаков

6

register-topics

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

3

enrich-anchors

факт в 8–14 слов на каждый источник (анкор вместо голой ссылки)

17

enrich-facts

таблица фактов, «что это значит», «что из этого полезно вам»

17

check-facts

проверка утверждений по тексту первоисточника

14

fact-heal

автопочинка неудачных проверок до публикации

15

make-news-img

картинка к материалу в локальной модели

68

faq-gen

четыре вопроса-ответа для FAQ-разметки

6

feed-gen

сборка страницы, лента, карта сайта, RSS

2

Итого: старт в 17:00:02, материал зафиксирован в 17:03:04. Дольше всего идёт не языковая модель, а картинка — 68 секунд на локальной видеокарте. Сами LLM-шаги вместе дают 46 секунд.

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

Четыре обращения к модели и их контракты

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

Вызов

Задача

Лимит токенов

Что вернулось на замере

adapt-ru

заголовок и лид из англоязычного материала

900

~950 токенов, 3–4 с

enrich-anchors

по факту на каждый источник

300

177 токенов, 1,9 с

enrich-facts

факты, вывод, «полезно» (300–500 слов)

2 600

1 054 токена, 5,7 с

faq-gen

четыре пары «вопрос-ответ»

1 200

286 токенов, 2,7 с

На материал уходит 2,5–4,5 тысячи токенов — это доли цента при текущих тарифах, так что узкое место конвейера не деньги, а качество контракта. Поэтому факты принимаются только в виде [["Что случилось","…"],["Цифры","…"]], а если модель вернула набор без обязательных полей, шаг не пропускает его дальше, а переспрашивает — три попытки с паузой.

Вызов модели — обычный HTTP из PowerShell, без SDK и без внешних библиотек:

$тело = @{
  model       = $Модель
  messages    = @(@{ role = "user"; content = $промт })
  temperature = 0.4
  max_tokens  = 2600
}
if ($Рез.JsonMode)      { $тело.response_format  = @{ type = 'json_object' } }
if ($Рез.ReasoningNone) { $тело.reasoning_effort = 'none' }
$body = $тело | ConvertTo-Json -Depth 6 -Compress

$req = [System.Net.HttpWebRequest]::Create($Рез.Url)
$req.Method = "POST"; $req.ContentType = "application/json"; $req.Timeout = 120000
$req.Headers.Add("Authorization", "Bearer $Ключ")
$bytes = [Text.Encoding]::UTF8.GetBytes($body)
$req.ContentLength = $bytes.Length
$rs = $req.GetRequestStream(); $rs.Write($bytes, 0, $bytes.Length); $rs.Close()
$sr = New-Object IO.StreamReader($req.GetResponse().GetResponseStream(), [Text.Encoding]::UTF8)
$ответ = $sr.ReadToEnd() | ConvertFrom-Json

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

Модель — не константа в пяти скриптах

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

Сейчас есть один резолвер. Он читает настройки сессии, находит там провайдера и модель, проверяет, что модель вообще есть в списке /v1/models этого провайдера, берёт имя переменной с ключом из настроек и возвращает готовый набор: адрес, модель, ключ, а заодно два флага режима.

. (Join-Path $PSScriptRoot 'модель-сессии.ps1')
$Рез = Get-МодельСессии
$Модель = $Рез.Модель     # если не задана вручную параметром -Модель
# $Рез.Url, $Рез.КлючИмя, $Рез.JsonMode, $Рез.ReasoningNone

Внутри — кэш на десять минут, инвалидация по времени изменения файла настроек и фолбэк на провайдера по умолчанию, если в настройках пусто или модель не найдена в каталоге. Я поменял модель в настройках один раз и получил смену автора всех материалов без правки скриптов; параметр -Модель остался как ручное переопределение на один запуск.

Пять грабель, которые стоили вечера

Рассказывать про работающий код скучно, поэтому вот места, где я реально потерял время. Все они из этой системы, и все уже вылечены.

1. Reasoning-модель съедает лимит и возвращает пустоту

Модель deepseek-v4.1-flash на шлюзе B.AI — рассуждающая: кроме content она возвращает reasoning_content, и этот блок считается внутри max_tokens. При лимите 70 токенов ответ пришёл с finish_reason: length, content пустой, а в reasoning — 287 знаков размышлений про то, как надо ответить. При 400 токенах картина та же: 1090 знаков размышлений и снова пустой ответ. Со стороны это выглядит как «модель сломалась», хотя она честно отработала лимит.

Лечится флагом reasoning_effort: 'none' — режим отключает блок размышлений, ответ приходит за 2–6 секунд, и лимит снова принадлежит содержанию. Значение low не помогает: размышления всё равно едят бюджет. Вывод для любых конвейеров: если провайдер отдаёт reasoning-блок, сначала посмотрите, входит ли он в лимит, и только потом увеличивайте max_tokens — иначе будете платить за размышления, которые не попадают в результат.

2. PowerShell не различает регистр в именах переменных

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

$S = "E:...скрипты"                  # папка скриптов
foreach ($s in @($карточка.источники)) { $путь = Join-Path $S $s.url }

Потому что $S и $s для PowerShell — одна и та же переменная. Внутри цикла папка скриптов превратилась в объект-ссылку, и Join-Path упал с сообщением про диск с именем @{url=https.

Второй раз — тише и потому дороже. Скрипт вечерней сводки держал путь к своему кэшу в переменной $ФайлКэша. Резолвер модели, который подключается в том же файле через точку (dot-sourcing), объявляет свою переменную с тем же именем для кэша выбора модели. Подключение переписало путь, и текст сводки бодро записался в файл кэша модели, затерев его содержимое. Ошибки не было ни одной: запись прошла успешно, просто не туда. Нашлось это только после того, как я поставил в код временную печать пути — она показала имя файла модели вместо ожидаемого.

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

3. Файл .ps1 без BOM читается как CP1251

Запуск powershell.exe -File script.ps1 для файла без BOM в UTF-8 означает чтение в системной кодировке. В русской локали это CP1251, и весь текст с кириллицей внутри скрипта превращается в мусор — вплоть до ParserError на строке с сообщением. Причём интерактивный запуск того же файла работает: там кодировка определяется иначе. Я лечу это записью BOM после любой правки скрипта, и в половине случаев именно забытый BOM — причина «мистического» падения по расписанию, когда руками всё отлично.

4. Строка “$переменная:” не парсится

Сообщение Variable reference is not valid. ':' was not followed by a valid variable name — про то, что двоеточие после имени переменной в двойных кавычках трактуется как квалификатор диска. Строка вида "Итоги за $дата:" не компилируется; надо ${дата}:. Мелочь, но она прилетает именно в шаблонах сообщений, то есть в самом конце работы, когда всё уже почти готово.

5. Публикация без картинки — это тихая дыра

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

Согласование за десять минут, или Где всё-таки нужен человек

Автоматическая публикация «в интернет без спроса» — то, чего я не хотел даже в собственном проекте. Поэтому у конвейера есть ворота: готовая карточка получает номер темы, попадает в hold-список, из которого страница на сервер не выгружается, и одновременно я получаю письмо и событие в журнал Windows. Дальше работает сторож: он ждёт решение десять минут, и если решение «да» — снимает тему с hold, выгружает сайт, обновляет карту и отправляет оповещение поисковикам. Если решения нет — тема остаётся в холде до следующего раза, а прогон живёт дальше.

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

Выгрузка, карта сайта и поисковики

Деплой — FTP со сверкой MD5 по каждому файлу: сравнивать размеры бесполезно, правка вида ?v= в ссылке на стили не меняет длину файла, зато меняет содержимое. Перед выгрузкой сайт пересобирается целиком: лента, страницы, карта сайта, RSS, отдельная карта для новостей.

С поисковиками три канала, и все три идут после успешной выгрузки: карта сайта отправляется через API Search Console, отдельные адреса — через Indexing API (с паузой два дня на адрес, иначе квота сгорает на повторах), плюс IndexNow для Яндекса и Bing. Сбой любого из каналов не ломает деплой: уведомления складываются в очередь и отправляются следующим прогоном. Это не теоретическая аккуратность: API Вебмастера Яндекса лежит у меня тринадцатый день, очередь терпеливо копится, а сайт от этого не останавливается.

Отдельный урок про FTP-хостинг: листинг не показывает дотфайлы. .htaccess с правилами редиректов и заголовками безопасности в листинге не виден вообще — ни в коротком, ни в длинном формате, при том что файл существует и работает. Вывод: наличие скрытых файлов на таком хостинге проверяется запросом файла, а не листингом. Иначе однажды выгрузите сайт «как есть» и потеряете редиректы, не заметив этого.

Что показали цифры за две недели

Показатель

Значение

Опубликовано материалов

56 (из 61 карточки журнала)

Вечерних сводок

7, каждая со своим разбором дня

Скриптов в конвейере / строк

47 / ~9 700

Время прогона одного материала

~3 минуты

Расход токенов на материал

2,5–4,5 тыс.

Адресов в карте сайта / в индексе Google (23.10)

76 / 54 (71 %)

Оповещений IndexNow отправлено

45

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

Что бы я сделал иначе

Вынести модель в резолвер сразу, а не после того, как пять скриптов разошлись в поведении. Сделать все шаги идемпотентными с кэшем результата с первого дня: я пришёл к этому только на сводках, когда понял, что повторный прогон за прошедшую дату переписывает уже опубликованный текст, и любая отладка дёргает страницу, которую поисковик только что проиндексировал. И третье: не отдавать код 200 на */index.html. Внутренние ссылки шаблона вели на файл, а канонический адрес был без файла — через месяц поисковик насчитал тринадцать «страниц-вариантов» и прислал письмо о проблемах индексации. Лечится одной строкой в .htaccess, но обнаруживается поздно.

Итог

Конвейер из десяти маленьких скриптов, файла-журнала и четырёх узких запросов к модели выпускает материал за три минуты и оставляет человеку ровно одно решение. Всё остальное — дедупликация, факты, картинка, разметка, карта сайта, оповещение поисковиков — считается само. Если вы делаете похожую штуку на PowerShell, три вещи сэкономят вам вечер: проверяйте, входит ли reasoning-блок в max_tokens; не называйте переменные в подключаемых скриптах общими словами; и держите кэш для всего, что дороже секунды, — включая текст, который пишет модель.

Автор: Iskander_L

Источник