<?xml version="1.0" encoding="utf-8" ?><rss version="2.0" xmlns:tt="http://teletype.in/" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:media="http://search.yahoo.com/mrss/"><channel><title>@gringo342</title><generator>teletype.in</generator><description><![CDATA[@gringo342]]></description><link>https://teletype.in/@gringo342?utm_source=teletype&amp;utm_medium=feed_rss&amp;utm_campaign=gringo342</link><atom:link rel="self" type="application/rss+xml" href="https://teletype.in/rss/gringo342?offset=0"></atom:link><atom:link rel="next" type="application/rss+xml" href="https://teletype.in/rss/gringo342?offset=10"></atom:link><atom:link rel="search" type="application/opensearchdescription+xml" title="Teletype" href="https://teletype.in/opensearch.xml"></atom:link><pubDate>Wed, 11 Nov 2026 18:26:57 GMT</pubDate><lastBuildDate>Wed, 11 Nov 2026 18:26:57 GMT</lastBuildDate><item><guid isPermaLink="true">https://teletype.in/@gringo342/6Cg0mXj-IUd</guid><link>https://teletype.in/@gringo342/6Cg0mXj-IUd?utm_source=teletype&amp;utm_medium=feed_rss&amp;utm_campaign=gringo342</link><comments>https://teletype.in/@gringo342/6Cg0mXj-IUd?utm_source=teletype&amp;utm_medium=feed_rss&amp;utm_campaign=gringo342#comments</comments><dc:creator>gringo342</dc:creator><title>Свой маркиратор для Честного знака на картридже HP45 и ESP32: от одной точки до рабочего прототипа в производстве</title><pubDate>Wed, 11 Nov 2026 16:55:08 GMT</pubDate><description><![CDATA[<img src="https://img3.teletype.in/files/af/e2/afe2e7ec-451e-4764-ae23-c013da088646.png"></img>Всем привет! Меня зовут Семён, я инженер‑программист и занимаюсь разработкой промышленного оборудования. В нашем цеху на часть оборудования установлены китайские ручные маркираторы с картриджем типа HP45.]]></description><content:encoded><![CDATA[
  <h3 id="m9SV">Вступление</h3>
  <p id="12WR">Всем привет! Меня зовут Семён, я инженер‑программист и занимаюсь разработкой промышленного оборудования. В нашем цеху на часть оборудования установлены китайские ручные маркираторы с картриджем типа HP45.</p>
  <p id="yizQ">Всё началось с вопроса директора: «Можем ли мы сами управлять картриджем и печатать им что угодно, в том числе коды Честного знака? Так, чтобы его можно было без проблем встроить в любой проект и чтобы это было дешевле готовых решений на рынке».</p>
  <p id="aaTM">Задача звучала амбициозно. На старте у меня был один уставший картридж HP45, несколько маркираторов на производстве для примера, полное отсутствие документации и огромное желание заставить эту штуку печатать.</p>
  <p id="OH7P">Первым делом я изучил тему. Искать пришлось на английском, и информации оказалось очень мало. В итоге я наткнулся на энтузиаста, который уже провёл реверс‑инжиниринг картриджа, — его сайт. Оттуда же нашлась ссылка на патент HP US5946012. Благодаря этому автору я разобрался, как работает картридж, за что отвечают его контакты и что такое адреса и примитивы. Поначалу было вообще непонятно, как всё устроено, но со временем информация уложилась в голове и превратилась в ценный опыт.</p>
  <h3 id="8EFI">Теория</h3>
  <p id="CRf7">HP45 — это <strong>термоструйный</strong> (TIJ, thermal inkjet) картридж. Он состоит из резервуара для чернил, контактной площадки на задней стороне и двух рядов сопел на нижней. Резервуар объёмом 42 мл удерживается под небольшим отрицательным давлением с помощью пружины — так чернила не вытекают из сопел самопроизвольно. Картридж имеет 300 сопел, которые в сумме дают разрешение 600 DPI, работает от 12 В и имеет 52 контакта.</p>
  <p id="aAP3">Каждое сопло — это нагревательный элемент сопротивлением около 30 Ом и полевой транзистор. Затвор транзистора подключён к адресной линии, нагреватель — к линии примитива, исток — к GND.</p>
  <figure id="d9E2" class="m_original">
    <img src="https://img3.teletype.in/files/af/e2/afe2e7ec-451e-4764-ae23-c013da088646.png" width="438" />
    <figcaption>Схема сопла картриджа HP45 (источник: ytec3d.com )</figcaption>
  </figure>
  <p id="wOUZ"><em>Схема сопла картриджа HP45 (источник:</em></p>
  <p id="cj2E"><em>)</em></p>
  <p id="wtYj">Когда сопло должно выбросить каплю, нагреватель кратковременно включается и мгновенно испаряет небольшое количество чернил в камере. Образовавшийся пузырь пара выталкивает остальные чернила наружу. После этого нагреватель остывает, и камера снова заполняется чернилами.</p>
  <p id="vBq9">Сопла объединены в матрицу, благодаря чему 300 сопел управляются всего 36 линиями: 22 адресами и 14 примитивами. Адресу достаточно открыть затвор транзистора, поэтому большой ток ему не нужен — основной ток течёт через примитивы. На схеме земля показана отдельными линиями, но внутри печатающей головки все они соединены. Другие компоненты, например датчик температуры, тоже заземлены через эти выводы.</p>
  <h3 id="WGwd">Что такое адрес и примитив</h3>
  <p id="ESuN">Адрес открывает затворы транзисторов у группы сопел, а примитив подаёт ток на нагреватели. Сопло срабатывает только тогда, когда одновременно активны его адрес и его примитив.</p>
  <figure id="Cp7R" class="m_original">
    <img src="https://img4.teletype.in/files/3f/b1/3fb1b3f8-b224-4428-9e0a-3a66a2701af3.png" width="1560" />
    <figcaption>Схема матрицы сопел (источник: ytec3d.com )</figcaption>
  </figure>
  <p id="6MLb"><em>Схема матрицы сопел (источник:</em></p>
  <p id="dswQ"><em>)</em></p>
  <p id="4tQb">Алгоритм выстрела выглядит так:</p>
  <ul>
    <li>Подаём 12 В на адрес, чтобы открыть затворы.</li>
    <li>Ждём около 2 мкс, пока затворы откроются.</li>
    <li>Подаём от 9 до 12 В на нужные примитивы (на все или на часть) на 2 мкс.</li>
    <li>Снимаем напряжение с примитивов.</li>
    <li>Через 3 мкс закрываем адрес.</li>
    <li>Переходим к следующему адресу по порядку — от 1 до 22 или от 22 до 1.</li>
  </ul>
  <blockquote id="a1V1"><strong>Предупреждения:</strong> Адрес нельзя закрывать, пока примитив ещё включён: полевой транзистор будет повреждён или уничтожен. Примитив всегда должен закрываться раньше адреса.  Импульс примитива длиннее 2 мкс может повредить сопло.  Стрельба соплами без чернил тоже может их повредить.</blockquote>
  <p id="BOIF">По опыту скажу, что картридж обычно живучее, чем кажется. Перед тем как выйти из строя, он начинает с писком проливать под себя много чернил, а иногда пускает предсмертный белый дымок. Пока это происходит, обычно успеваешь отключить питание и спасти картридж.</p>
  <figure id="AOmc" class="m_original">
    <img src="https://img2.teletype.in/files/54/5f/545f22e1-2d69-4680-9f93-113a1828c309.png" width="1560" />
    <figcaption>Диаграмма синхронизации сопла</figcaption>
  </figure>
  <p id="kARY"><em>Диаграмма синхронизации сопла</em></p>
  <figure id="hc22" class="m_original">
    <img src="https://img2.teletype.in/files/17/9e/179e0179-cd75-4fc6-93c0-85dc77bbbbf5.png" width="1313" />
    <figcaption>Диаграмма синхронизации адресов. Адреса каскадом переключаются от 1 до 22, затем снова с 1 (или в обратном порядке)</figcaption>
  </figure>
  <p id="GY3s"><em>Диаграмма синхронизации адресов. Адреса каскадом переключаются от 1 до 22, затем снова с 1 (или в обратном порядке)</em></p>
  <p id="iPAt">Сопла разделены на две группы — чётную и нечётную. Чётные примитивы управляют чётным рядом сопел, нечётные — нечётным. Адресные линии общие для обоих рядов. Расположение контактов показано ниже.</p>
  <figure id="uQav" class="m_original">
    <img src="https://img2.teletype.in/files/18/19/181909c9-640e-4bc3-8b5c-679a0aabc5a9.png" width="1379" />
    <figcaption>Фактическое расположение контактов на печатающей головке HP45</figcaption>
  </figure>
  <p id="hAzP"><em>Фактическое расположение контактов на печатающей головке HP45</em></p>
  <h3 id="SXmp">Как узнать температуру картриджа</h3>
  <p id="EBMG">На картридже есть два вывода для измерения температуры:</p>
  <ul>
    <li><strong>R10X</strong> — опорный резистор, сопротивление которого примерно в 10 раз больше сопротивления нагревателя, то есть около 300 Ом. С ним сравнивается сопротивление датчика.</li>
    <li><strong>TSR</strong> (thermal sense resistor) — терморезистор с номинальным сопротивлением около 300 Ом. При нагреве его сопротивление растёт, при охлаждении — падает.</li>
  </ul>
  <p id="rb5w">Сравнивая сопротивление TSR с опорным R10X, можно оценить температуру головки.</p>
  <p id="p4dn">Чтобы было нагляднее, мы с командой вскрыли один сломанный картридж и посмотрели, как он устроен внутри.</p>
  <figure id="Z22s" class="m_original">
    <img src="https://img1.teletype.in/files/4a/b4/4ab4a385-2221-474e-9ec1-845dd52d7493.jpeg" width="960" />
  </figure>
  <figure id="w3AD" class="m_original">
    <img src="https://img2.teletype.in/files/9f/50/9f50d3dc-561e-474d-893a-2257c888cb5d.jpeg" width="960" />
  </figure>
  <figure id="kPBk" class="m_original">
    <img src="https://img3.teletype.in/files/29/e2/29e2914f-ed06-45d7-9959-a5ffe2a50abb.jpeg" width="960" />
    <figcaption>Пружинная пластина, которая создаёт отрицательное давление в картридже</figcaption>
  </figure>
  <p id="g537"><em>Пружинная пластина, которая создаёт отрицательное давление в картридже</em></p>
  <h3 id="NYym">Первый импульс</h3>
  <p id="GK2J">Поначалу никакой контактной площадки для картриджа у меня не было, поэтому для первого подключения я просто припаялся к его контактам. Картриджу нужно 12, а микроконтроллер работает с логикой 3,3, поэтому понадобилось согласование уровней. Я набросал схему ключа на N и P‑канальных MOSFET в симуляторе, получил в нём рабочий результат и перешёл к сборке.</p>
  <p id="NMDV">К собранной схеме я подключил ESP32, быстро написал тестовый скетч и начал проверять. Не с первой попытки, но на бумаге появилась одна маленькая точка. Это стало стартом для первого прототипа — маленькая победа.</p>
  <h3 id="saMe">Первый прототип</h3>
  <h3 id="Dir7">Схема</h3>
  <p id="6HUj">Дальше я взялся за схему уже под весь картридж, попутно исправив ошибки первого опыта. Проектировал на SMD‑компонентах, чтобы с обратной стороны платы ничего не торчало и не мешало прижимать картридж к контактной площадке. Получился каскад ключей, и стало ясно, что MOSFET‑ов потребуется больше 50.</p>
  <p id="WtN4">У ytec3d уже была своя готовая схема, но тогда я не понимал, как работают используемые им микросхемы. Поэтому пошёл своим путём: хотелось собрать то, что понимаю сам, и, возможно, найти решение проще, надёжнее или дешевле.</p>
  <h3 id="gAIm">Плата</h3>
  <p id="4ze8">Платы заказали в NextPCB без монтажа. Партия пришла с косяками, и виноват в этом был я: вместо резисторов 0603 поставил в проекте 0201 (паять их вручную очень тяжело), а для MOSFET выбрал не тот футпринт, из‑за чего выводы оказались перепутаны. Эти платы отправились в мусор. Урок усвоен. Я перепроектировал плату, перезаказал, и на этот раз пришло ровно то, что нужно.</p>
  <figure id="vwC7" class="m_original">
    <img src="https://img3.teletype.in/files/22/07/2207e509-dbb6-428e-9cad-f6731cf1f2b4.jpeg" width="960" />
    <figcaption>Плата, подключённая к микроконтроллеру</figcaption>
  </figure>
  <p id="RmYS"><em>Плата, подключённая к микроконтроллеру</em></p>
  <h3 id="oMLd">Прошивка</h3>
  <p id="WROD">Плату я тестировал с осциллографом и выяснил, что тайминги неправильные — с такими можно было повредить картридж. Почему, если в коде стоит 2 мкс?</p>
  <p id="BmJM">Проект под ESP32 я писал в PlatformIO на фреймворке Arduino, и главным пожирателем времени оказались как раз функции Arduino. Один вызов</p>
  <pre id="Q4Pj">digitalWrite()</pre>
  <p id="BsUU">занимал больше 1 мкс. Функция ESP‑IDF</p>
  <pre id="CH62">gpio_set_level()</pre>
  <p id="BDSu">работала быстрее, но тоже заметно тормозила. Самым быстрым вариантом стала прямая запись в регистры GPIO ESP32 (</p>
  <pre id="KdlK">GPIO.out_w1ts</pre>
  <p id="cXZf">/</p>
  <pre id="TjX3">GPIO.out_w1tc</pre>
  <p id="furU">) через макросы — переключение вывода сократилось с микросекунд до наносекунд.</p>
  <pre id="tU2F">      
#define SET_HIGH(Pin) ((Pin) &gt;= 32 ? (GPIO.out1_w1ts.val = (1 &lt;&lt; ((Pin) - 32))) : (GPIO.out_w1ts = (1 &lt;&lt; (Pin))))
#define SET_LOW(Pin)  ((Pin) &gt;= 32 ? (GPIO.out1_w1tc.val = (1 &lt;&lt; ((Pin) - 32))) : (GPIO.out_w1tc = (1 &lt;&lt; (Pin))))

    </pre>
  <p id="TD5Z">Вторым сюрпризом стала</p>
  <pre id="vDpg">delayMicroseconds()</pre>
  <p id="0ThI">: она давала задержку чуть больше заданных 2 мкс. Поэтому вместо неё я, как и ytec3d в своём коде, использовал пустые инструкции процессора (NOP):</p>
  <pre id="Jwfg">      
for (uint8_t d = 0; d &lt; 13; d++) { // NOP    
  __asm__("nop\n\t""nop\n\t""nop\n\t""nop\n\t""nop\n\t""nop\n\t""nop\n\t""nop\n\t");
}

    </pre>
  <p id="ETPk">У меня такой цикл даёт примерно 2 мкс, но число итераций зависит от частоты процессора и подбирается по осциллографу. После оптимизации я получил на осциллографе чистые 2 мкс.</p>
  <p id="Ck7C">Отдельный момент — прерывания. Если во время импульса примитива сработает прерывание или проснётся Wi‑Fi‑стек, импульс растянется, а для нагревателя это опасно. Поэтому критичный участок генерации импульсов нужно выполнять с запрещёнными прерываниями и желательно на отдельном ядре.</p>
  <p id="1zJy">Затем я написал функцию выстрела всеми соплами одного ряда (чётного или нечётного). Каждый примитив управлялся своим GPIO, и тут проявилась проблема схемы. Я включал GPIO по очереди, ждал 2 мкс и по очереди же их выключал. Первые включившиеся примитивы ждали, пока переключатся остальные, задержки складывались, и вместо 2 мкс получалось около 4 мкс. Чтобы уложиться в адекватные тайминги, я просто убрал явную задержку: переключение всей линии примитивов само по себе занимало около 2 мкс, и для экспериментов этого хватало.</p>
  <p id="eOXi">Ещё я написал функцию выстрела одним соплом, чтобы потом прорисовывать каждую точку по отдельности. Ей тоже можно выстрелить всеми соплами, но это займёт больше времени.</p>
  <h3 id="eepI">Первая линия</h3>
  <p id="ZhOm">Запаяв всё, я протестировал плату и, к своему удивлению, смог стрельнуть большей частью сопел. Правда, подключение было очень неудобным. Что‑то в линию напечатать получилось, но на готовый результат это ещё не походило.</p>
  <figure id="MuWM" class="m_original">
    <img src="https://img4.teletype.in/files/76/75/7675cb14-c291-4be4-a8b0-ef44c4284bd5.jpeg" width="960" />
    <figcaption>Пропуски — это мёртвые примитивы в правом ряду сопел</figcaption>
  </figure>
  <p id="wnUx"><em>Пропуски — это мёртвые примитивы в правом ряду сопел</em></p>
  <h3 id="pJnv">Библиотека DataMatrix для печати кодов Честного знака</h3>
  <p id="Jfdo">Готовых библиотек DataMatrix для ESP32 я не нашёл. Зато нашлась ZXing‑C++, не адаптированная под микроконтроллеры. Я успешно встроил её в проект: подтянул нужные зависимости и вырезал всё лишнее, чтобы библиотека не занимала много места.</p>
  <p id="e8Lq">Для печати кодов, картинок и вообще чего угодно нужен буфер, куда складываются все точки. Чтобы он занимал поменьше памяти, я использовал двумерный массив</p>
  <pre id="bxgc">uint8_t</pre>
  <p id="W4n8">, где каждый байт хранит 8 точек.</p>
  <p id="BaVT">Первый напечатанный DataMatrix успешно отсканировался, но приложение Честного знака его ещё не принимало: для маркировки нужен GS1 DataMatrix со служебными символами. На следующих этапах я добавил поддержку GS1 в интегрированную библиотеку, и после этого коды стали нормально считываться приложением Честного знака.</p>
  <p id="3yru"><em>Картридж я двигал руками, поэтому коды получались растянутыми</em></p>
  <p id="nzTT"><em>Напечатанный Qr‑code</em></p>
  <h3 id="0vGb">Промежуточный прототип</h3>
  <h3 id="TpIR">Схема</h3>
  <p id="lwEs">Мне не давало покоя количество MOSFET‑ов и занятых выводов контроллера. Только на управление картриджем уходило 36 линий (22 адреса и 14 примитивов), и это съедало почти все свободные GPIO ESP32. А в конечном устройстве нужны выводы ещё и под датчики и интерфейсы связи.</p>
  <p id="5mzv">Прежде чем проектировать следующую версию, я основательно разобрался в работе MOSFET и подобрал микросхемы, которые позволяли сделать схему компактнее:</p>
  <ul>
    <li><strong>74HC595</strong> — сдвиговые регистры для расширения портов;</li>
    <li><strong>TLC59213</strong> — 8-битный драйвер с параллельным входом для коммутации 12 В на примитивы;</li>
    <li><strong>4017</strong> — десятичные счётчики для адресов, соединённые каскадом через логику «И» <strong>4081</strong> со сбросом;</li>
    <li>питание — на линейном стабилизаторе, у каждой микросхемы керамический конденсатор 0,1 мкФ, плюс электролит 100 мкФ для чистоты питания.</li>
  </ul>
  <p id="davF">В этот раз мы решили изготовить платы сами: это дешевле, и не нужно ждать доставку.</p>
  <h3 id="dtjR">Самодельные платы</h3>
  <h3 id="w38B">Подготовка</h3>
  <p id="UYIJ">Для самодельной двухслойной платы понадобятся:</p>
  <ul>
    <li>двусторонний фольгированный текстолит, нарезанный по размеру;</li>
    <li>проект платы;</li>
    <li>негативный плёночный фоторезист;</li>
    <li>ламинатор для накатки фоторезиста;</li>
    <li>фотополимерный 3D‑принтер;</li>
    <li>мини‑дрель с цангой под свёрла меньше 1 мм.</li>
  </ul>
  <p id="VG8T">Плату я проектировал в Fusion 360. Открываем её в 3D‑виде, добавляем рамки для совмещения, отключаем всё лишнее и экспортируем верхний и нижний слои в два STL‑файла. Затем с помощью ламинатора накатываем фоторезист на текстолит с обеих сторон.</p>
  <h3 id="tJAN">Засветка</h3>
  <p id="TL7n">Для засветки мы использовали фотополимерный принтер Elegoo Mars 2 Pro: снимаем с него ванночку и всё лишнее, нужен только экран. Он засвечивает фоторезист ультрафиолетом и позволяет получать очень тонкие дорожки. Задание для принтера я подготовил в слайсере CHITUBOX.</p>
  <p id="I7Zt"><strong>Важно:</strong> плату нужно максимально точно отцентрировать на экране, иначе слои не совпадут, и придётся смывать фоторезист и наносить его заново.</p>
  <p id="KGeW">После засветки проверяем, что отверстия на обеих сторонах совпадают, проявляем плату в слабом растворе кальцинированной соды и травим в хлорном железе по стандартной технологии. Работали в перчатках, очках и халатах.</p>
  <p id="PDWi">Для маленьких переходных отверстий мы сделали стойку под мини‑дрель, чтобы она опускалась строго вертикально. Тонкие свёрла очень хрупкие: чуть неправильно надавишь или уведёшь в сторону — и сверло сломано.</p>
  <p id="Wly3">Просверлив отверстия, переходим к лужению. Чтобы полигоны GND и дорожки получились ровными, мы наносили паяльную пасту тонким слоем по всей поверхности пластиковой карточкой. Самым сложным оказалась пайка переходных отверстий — это требует усидчивости и большого терпения. После этого плату можно тестировать.</p>
  <p id="D2tk"><em>Вариантов плат было много. На фото — версия для быстрого теста, где полигоны ради экономии времени не лудили</em></p>
  <p id="DBxD"><strong>Если самодельная плата пойдёт в реальное устройство, лудить её нужно обязательно целиком, иначе медь окислится.</strong></p>
  <h3 id="SMB0">Прошивка</h3>
  <p id="4krZ">В прошивке появилась передача данных по SPI в регистры 74HC595. Регистров в цепочке два, поэтому за одну передачу уходит 16 бит, и все нужные выходы выставляются разом. Проблема с задержками ушла: нужные примитивы теперь включаются одновременно.</p>
  <p id="cPvq">С драйвером TLC59213 всё довольно просто. Один вывод (RST) мгновенно сбрасывает все выходы в низкий уровень, а на вывод CLK я подаю импульс по таймингам из даташита, чтобы защёлкнуть новое состояние выходов.</p>
  <p id="0wDr">Адреса теперь переключает каскад счётчиков — по порядку от 1 до 22. В конце обязательно нужен сброс, иначе счётчик зациклится на последней микросхеме в цепочке.</p>
  <p id="3QIp">Кроме кодов Честного знака нужно печатать дату и номер партии. Для этого я подключил готовый растровый шрифт. Дату напечатать получилось, но работать так неудобно: под каждый новый шрифт нужно искать или делать его растровую версию.</p>
  <p id="YkH4">Хорошо печаталась только верхняя дата. Причина была в прошивке: я неправильно составил таблицу соответствия сопел адресам и примитивам. При неверной адресации что‑то печатается, но результат плохой. Сверив свою таблицу с таблицей ytec3d, я нашёл ошибки и переписал её. Теперь каждую дату уже можно было хотя бы прочитать:</p>
  <p id="vVVP">Для печати на русском нужны шрифты с кириллицей. Тут мой взгляд упал на популярную библиотеку для дисплеев <strong>u8g2</strong>: в ней уже есть буфер, работа со шрифтами и много документации.</p>
  <p id="EirA">Запустить её без дисплея можно тремя способами:</p>
  <ul>
    <li>Написать драйвер своего «виртуального» дисплея.</li>
    <li>Взять из имеющихся в библиотеке дисплей с наиболее подходящим разрешением.</li>
    <li>Вообще не использовать сущность дисплея и задать размеры буфера самостоятельно.</li>
  </ul>
  <p id="RsTf">Сначала я пошёл вторым путём, потому что ещё не знал про третий: взял подходящий по размеру дисплей 160×512 и смог рисовать дату и вообще всё, что умеет библиотека. Позже нейросеть подсказала третий вариант — он оказался самым правильным, и я переделал на него.</p>
  <p id="6u25">На тестах я успешно печатал тексты, картинки и коды Честного знака. Самодельную тестовую плату никуда не встроишь, поэтому дальше я начал трассировать итоговую плату для заказа в Китае.</p>
  <h3 id="EaSM">Итоговый прототип</h3>
  <blockquote id="GcuP">Схему итоговой платы и исходники прошивки я не публикую: это коммерческая разработка компании. Но общий подход и архитектура описаны ниже, а принципы работы с картриджем — в разделах выше.</blockquote>
  <h3 id="Tf1P">Схема</h3>
  <p id="bqSt">В итоговой версии я разделил электронику на две платы:</p>
  <ul>
    <li>маленькую плату‑коннектор, к которой подключается картридж и на которой стоит только FFC‑разъём для шлейфа;</li>
    <li>основную плату со всей остальной электроникой.</li>
  </ul>
  <p id="c2Vu">Так печатающая голова получается компактной и не громоздкой.</p>
  <p id="8lG9">После трассировки я несколько раз перепроверил схему: каждая ошибка на этом этапе стоит денег. Платы заказали со сборкой в Китае через AliExpress, общались с производителем по почте на английском. Вывод на будущее: если заказываете сборку у китайцев, сразу проектируйте в EasyEDA — там подключена библиотека компонентов LCSC, и вам не придётся согласовывать кучу правок, когда что‑то не сходится по размерам или чего‑то нет в наличии.</p>
  <h3 id="1qE4">Плата</h3>
  <p id="S9lu">Через месяц платы пришли. Перед запуском я шаг за шагом всё прозвонил и убедился, что нет замыканий. Потом подал питание и проверил, что напряжения приходят куда нужно. На последнем этапе подключил микроконтроллер и проверил работу всех микросхем.</p>
  <h3 id="Ujf2">Архитектура и прошивка</h3>
  <p id="5R4y">На этом этапе я в основном занимался протоколами связи, командами печати и доводкой основного алгоритма. Прежде чем писать код, я набросал на бумаге архитектуру системы и в итоге отказался от u8g2: генерация DataMatrix осталась на ESP32, а всю растеризацию я отдал хосту.</p>
  <p id="X5ZU">Общая схема получилась такой:</p>
  <p id="PZYL">Плюсы такого разделения:</p>
  <ul>
    <li>у хоста гораздо больше возможностей для растеризации любой сложности;</li>
    <li>контроллер маркиратора занимается только управлением картриджем: ему всё равно, что печатать, главное — правильно загрузить данные в буфер. Прошивка становится проще и компактнее.</li>
  </ul>
  <p id="6KW4">Основной протокол связи работает по UART. Дополнительно я сделал HTTP API по Wi‑Fi, но заранее закладывал переход на Ethernet: на производстве Wi‑Fi ненадёжен из‑за помех от высокочастотного оборудования.</p>
  <p id="6s9B">Через команды можно менять параметры маркиратора, запускать печать и загружать данные в буфер. Целостность загружаемого буфера проверяется по CRC-16/CCITT‑FALSE. Маркиратор сообщает хосту об ошибках, а все вводимые параметры проверяются на допустимый диапазон.Все команды я задокументировал в инструкции для разработчиков хоста.</p>
  <p id="JEKA">Чтобы уместить Wi‑Fi‑стек, я использовал схему разметки флеш‑памяти</p>
  <pre id="qTQN">min_spiffs.csv</pre>
  <p id="OcGm">. С ней не получится обновлять прошивку по воздуху, но для наших задач это и не нужно.</p>
  <p id="i3ZC">В прошивке реализовано измерение температуры по разнице сопротивлений опорного резистора R10X и терморезистора TSR. Можно задать максимальную температуру: если картридж перегреется сверх неё, печать блокируется. Параметр необязательный, без него маркиратор тоже работает.</p>
  <h3 id="hjyI">Настоящие 600 DPI</h3>
  <p id="AZmL">Отдельно я реализовал печать в 600 DPI. По ходу выяснилось, что на ручных китайских маркираторах режим 600 DPI неполноценный. Дело в том, что каждый ряд сопел HP45 сам по себе даёт только 300 DPI, а честные 600 DPI получаются, только когда оба ряда работают синхронно и их точки ложатся между собой.</p>
  <p id="w7E4">Реализовать это можно двумя способами:</p>
  <ul>
    <li><strong>Простой.</strong> Печатать одним рядом в 300 DPI и делать несколько выстрелов в одну точку. Отпечаток выходит жирнее и визуально похож на 600 DPI, но реального прироста разрешения нет.</li>
    <li><strong>Сложный.</strong> Организовать синхронную работу обоих рядов сопел.</li>
  </ul>
  <p id="S85q">Я выбрал второй вариант. Для него понадобился параметр сдвига точек, который отвечает за совмещение двух рядов. Расстояние между рядами +-4.1 мм исходя из данных патента, но параметр я оставил настраиваемым, чтобы его можно было откалибровать на конкретном оборудовании.</p>
  <p id="l3SJ">В режиме 600 DPI критически важно направление печати. Сдвиг задерживает выстрелы одного ряда относительно другого с расчётом на определённое направление движения. Если картридж движется в обратную сторону, ряды не сойдутся ни при каком значении сдвига.</p>
  <h3 id="aAgR">Хост</h3>
  <p id="7t56">На производстве у меня два основных сценария интеграции:</p>
  <ul>
    <li>машина для оклейки — печать даты и партии (этикетировщик);</li>
    <li>конвейерная линия — печать кодов Честного знака.</li>
  </ul>
  <p id="xmWa">Для первого сценария хост у меня уже был: веб‑панель на Python Flask, которая управляет ЧПУ и системой компьютерного зрения. Сервером служит Orange Pi, объединяющий станок, камеру, а теперь и маркиратор. В панель я добавил блок настроек маркиратора:</p>
  <ul>
    <li>ввод даты и партии;</li>
    <li>размер текста;</li>
    <li>положение на холсте и предпросмотр того, что уйдёт в буфер;</li>
    <li>сверка буфера — проверка, что сейчас лежит в памяти устройства.</li>
  </ul>
  <p id="k57e">Текст на холсте рисуется библиотекой Pillow. Для маркиратора я добавил отдельное подключение по UART и реализовал команды по своей документации.</p>
  <p id="WFVq"><em>Блок настроек маркиратора на рабочей панели</em></p>
  <p id="YfHP">Для второго сценария я решил сделать полноценное универсальное приложение для ПК. Выбирал между Qt и Tauri (Rust) и остановился на Qt: это самый популярный фреймворк для подобных приложений, и мне хотелось получить опыт работы с ним.</p>
  <p id="ZncM">Главная задача этого хоста — потоковая отправка кодов Честного знака на печать. Приложение читает CSV‑файлы с кодами, выгруженные из Честного знака. Если код не удалось прочитать или маркиратор не смог его сгенерировать, код отбраковывается, хост уведомляет об этом и переходит к следующему. В будущем я подключу к системе сканер кодов с отбраковщиком, чтобы снимать с линии продукцию с нечитаемыми кодами. У нашей компании уже есть такой опыт — мы собирали на аутсорс линию со сканером и отбраковкой, так что проблем здесь не ожидаю.</p>
  <p id="Zl41"><em>Готовая панель на Qt</em></p>
  <p id="kiFt"><em>Готовая панель на Qt</em></p>
  <h3 id="7hHb">Итоги</h3>
  <p id="5cus">Сейчас маркиратор уже работает на машине для оклейки и почти установлен на линию, где будет печатать дату, партию и коды Честного знака одновременно. Устройство будет проходить испытания в реальных производственных условиях.</p>
  <p id="fBe8">Собственный маркиратор обходится компании дешевле китайских аналогов. Кроме того, у него есть потенциал как полностью русифицированного продукта с полной поддержкой под российский рынок. А главное — теперь мы можем делать с картриджем что угодно, и в схемотехнике ещё много возможностей для улучшений, которые сделают устройство дешевле и технологичнее в производстве.</p>
  <p id="Bxdw">Этот проект дал мне очень много опыта и знаний, которые точно пригодятся. Начинать было тяжело, но сейчас, оглядываясь назад, я вижу проделанный путь и горжусь им. Спасибо за внимание!</p>
  <hr />
  <p id="pPI9"><strong>Кстати</strong>, нашли тут ещё один классный VPS, который работает прямо в центре города. Уже сами протестировали - всё отлично! Оставлю ссылку:<a href="https://moremore.pro/?ref=7776677766" target="_blank"> moremore.pro</a></p>

]]></content:encoded></item><item><guid isPermaLink="true">https://teletype.in/@gringo342/oVsdqrQX8uP</guid><link>https://teletype.in/@gringo342/oVsdqrQX8uP?utm_source=teletype&amp;utm_medium=feed_rss&amp;utm_campaign=gringo342</link><comments>https://teletype.in/@gringo342/oVsdqrQX8uP?utm_source=teletype&amp;utm_medium=feed_rss&amp;utm_campaign=gringo342#comments</comments><dc:creator>gringo342</dc:creator><title>Как ломается модульный монолит и как с этим бороться</title><pubDate>Wed, 11 Nov 2026 15:51:12 GMT</pubDate><description><![CDATA[<img src="https://img3.teletype.in/files/a2/be/a2be3319-2ca0-4906-9736-1f9e0e5334f8.png"></img>В прошлой статье я сделал проверку зависимостей между модулями при запуске. Только срабатывает она уже на проде, а ловить такие ошибки хочется раньше.]]></description><content:encoded><![CDATA[
  <figure id="DLA6" class="m_original">
    <img src="https://img3.teletype.in/files/a2/be/a2be3319-2ca0-4906-9736-1f9e0e5334f8.png" width="1200" />
  </figure>
  <p id="TMBH">В прошлой статье я сделал проверку зависимостей между модулями при запуске. Только срабатывает она уже на проде, а ловить такие ошибки хочется раньше.</p>
  <p id="r2ub">В конце той статьи я привёл список поломок, которые проходят сборку, а иногда и локальную отладку. Здесь покажу, как превратить их в <strong>ошибки компиляции и красные тесты</strong>.</p>
  <p id="WbYI"><em>Это четвёртая статья по мотивам моего доклада </em> <em>«Модульность без микросервисов в ASP.NET из коробки»</em> <em> на DotNext 2026:</em></p>
  <ul>
    <li><em>Модульность без микросервисов: почему она того стоит.</em></li>
    <li><em>Модульный монолит в ASP.NET из коробки.</em></li>
    <li><em>Модульная модель данных в EF Core.</em></li>
    <li><em>Как ломается модульный монолит и как с этим бороться (вы здесь).</em></li>
    <li><em>eShop: запилить микросервисы в модульный монолит.</em></li>
  </ul>
  <h2 id="6Mv1">TL;DR</h2>
  <ul>
    <li>Тест поднимает топологию той же переменной HOSTINGSTARTUPASSEMBLIES, что и деплой, и проверяет её таблицы, маршруты и сервисы.</li>
    <li>Топологии в одном тестовом процессе делят модель EF Core, пока в ключе кэша модели нет набора модулей.</li>
    <li>Анализаторы Roslyn запрещают модулю публичные типы, хосту и библиотекам использовать модули, а классу модуля обходиться без ModuleBase и атрибута.</li>
    <li>Список модулей для design-time фабрики пишет генератор, и тот же список проверяет переменную на старте.</li>
    <li>Генератор в модулях и анализатор в хосте вместе следят, чтобы у таблицы и сущности был один модуль-владелец.</li>
  </ul>
  <p id="cxgg">Для примеров тестов я взял TUnit. У него есть свой API для веб-тестов, но я им не пользуюсь: хочу, чтобы код переносился между тест-фреймворками без переделок.</p>
  <h2 id="EAZl">Тесты топологий</h2>
  <p id="7T78">Чтобы проверить, что топология не упадёт на проде и получит доступ ко всем нужным данным, можно запустить модульный хост прямо в тесте.</p>
  <pre id="ZUUC">      
public class TopologyFactory(params string[] modules) : WebApplicationFactory&lt;Program&gt;
{
    protected override void ConfigureWebHost(IWebHostBuilder builder) =&gt; builder
        .UseSetting(WebHostDefaults.HostingStartupAssembliesKey, string.Join(';', modules))
        // База не нужна: модель EF Core строится без подключения, а миграции тесту ни к чему
        .UseSetting("ConnectionStrings:Postgres", "Host=localhost;Database=topology")
        .UseSetting("Database:Migrate", "false");

    // Таблицы модели: "схема.таблица"
    public string[] Tables() =&gt;
        [.. Model().GetEntityTypes().Select(e =&gt; $"{e.GetSchema()}.{e.GetTableName()}")];

    // Внешние ключи: "откуда → куда"
    public string[] ForeignKeys() =&gt;
    [
        .. from entity in Model().GetEntityTypes()
           from key in entity.GetForeignKeys()
           select $"{entity.GetTableName()} → {key.PrincipalEntityType.GetTableName()}"
    ];

    // Маршруты: "[http_method] [route_pattern]", по строке на каждый метод
    public string[] Routes() =&gt;
    [
        .. from endpoint in Services.GetRequiredService&lt;EndpointDataSource&gt;().Endpoints.OfType&lt;RouteEndpoint&gt;()
           from method in endpoint.Metadata.GetMetadata&lt;IHttpMethodMetadata&gt;()?.HttpMethods ?? ["ANY"]
           select $"{method} {endpoint.RoutePattern.RawText}"
    ];

    // Контекст берём как DbContext, так же, как его берут модули
    IModel Model()
    {
        using var scope = Services.CreateScope();
        return scope.ServiceProvider.GetRequiredService&lt;DbContext&gt;().Model;
    }
}


    </pre>
  <p id="SO6f">На каждую топологию по одному тесту:</p>
  <pre id="RZ16">      
public class TopologyTests
{
    static readonly string[] Monolith = ["Orders.Entities", "Customers.Entities", "Monolith"];
    static readonly string[] Orders = ["Orders.Entities", "OrdersMicroservice"];
    static readonly string[] Customers = ["Customers.Entities", "CustomersMicroservice"];

    [Test]
    public async Task Monolith_HasBothTables_AndTheForeignKey()
    {
        await using var factory = new TopologyFactory(Monolith);

        await Assert.That(factory.Tables()).IsEquivalentTo(new[] { "Marketing.Customers", "Sales.Orders" });
        await Assert.That(factory.ForeignKeys()).IsEquivalentTo(new[] { "Orders → Customers" });
    }

    [Test]
    public async Task Orders_HasOnlyItsTable_AndItsRoutes()
    {
        await using var factory = new TopologyFactory(Orders);

        await Assert.That(factory.Tables()).IsEquivalentTo(new[] { "Sales.Orders" });
        await Assert.That(factory.ForeignKeys()).IsEmpty();
        await Assert.That(factory.Routes()).IsEquivalentTo(new[] { "GET /orders/unpaid" });
    }
}


    </pre>
  <p id="9CFR">Тест сравнивает строки, поэтому ему не нужен ни один тип из модулей, а</p>
  <pre id="06as">IsEquivalentTo</pre>
  <p id="md2L">не смотрит на порядок.</p>
  <p id="qVVu">Если при старте вызывается</p>
  <pre id="xt9C">MigrateAsync</pre>
  <p id="Tv7x">, его надо уметь выключать. Я завёл для этого параметр конфигурации</p>
  <pre id="ZTa3">Database:Migrate</pre>
  <p id="k2M2">, фабрика выше ставит его в</p>
  <pre id="BjLG">false</pre>
  <p id="Hsbs">:</p>
  <pre id="xQLS">      
var app = builder.Build();

if (app.Configuration.GetValue("Database:Migrate", true))
{
    await using var scope = app.Services.CreateAsyncScope();
    await scope.ServiceProvider.GetRequiredService&lt;AppDbContext&gt;().Database.MigrateAsync();
}


    </pre>
  <p id="sKlX">Сравнение целиком заодно работает как негативный тест. Маршрут</p>
  <pre id="wUfi">POST /seed</pre>
  <p id="LZqP">и таблица клиентов в образе есть, но в топологии заказов их быть не должно, и</p>
  <pre id="HiXi">Orders_HasOnlyItsTable_AndItsRoutes</pre>
  <p id="sAXU">их не пропустит.</p>
  <p id="GpvD">С сервисами то же самое. Вот тест для калькулятора из второй статьи, у которого четыре транспорта в одном образе. Хост у калькулятора другой, из нагрузочного теста, поэтому и фабрика для этого теста своя, над его</p>
  <pre id="u1EX">Program</pre>
  <p id="B0Be">, а устроена она так же.</p>
  <pre id="tO7s">RemoteRmq</pre>
  <p id="Hvo4">в тесте нет: его hosted-сервис на EasyNetQ при старте фабрики полезет к RabbitMQ.</p>
  <pre id="62Sr">      
[Test]
[Arguments("Local")]
[Arguments("RemoteRest")]
[Arguments("RemoteGrpc")]
public async Task Calculator_ComesFromThisTopology(string module)
{
    await using var factory = new TopologyFactory(module);

    // Зависимости контроллеров контейнер на старте не проверяет:
    // без транспорта топология стартует и падает на первом запросе
    var isService = factory.Services.GetRequiredService&lt;IServiceProviderIsService&gt;();
    await Assert.That(isService.IsService(typeof(ICalculator))).IsTrue();

    // Два модуля с одним интерфейсом не падают, побеждает последний. Проверяем, чей
    await using var scope = factory.Services.CreateAsyncScope();
    var calculator = scope.ServiceProvider.GetRequiredService&lt;ICalculator&gt;();
    await Assert.That(calculator.GetType().Assembly.GetName().Name).IsEqualTo(module);
}


    </pre>
  <p id="HSfx">Ещё я бы сравнивал с ожидаемым список</p>
  <pre id="Fpsj">IHostedService</pre>
  <p id="8Qjh">:</p>
  <pre id="C3FV">AddHostedService</pre>
  <p id="ngyo">в модуле запускает воркер в каждой реплике каждой топологии, где этот модуль загружен.</p>
  <h3 id="0gSE">Несколько топологий в одном процессе</h3>
  <p id="QQHg">Можно написать тест моделей данных:</p>
  <pre id="XwlV">      
[Test]
public async Task ThreeTopologiesInOneProcess_EachHasItsOwnModel()
{
    await using var monolith = new TopologyFactory(Monolith);
    await using var orders = new TopologyFactory(Orders);
    await using var customers = new TopologyFactory(Customers);

    await Assert.That(monolith.Tables()).IsEquivalentTo(new[] { "Marketing.Customers", "Sales.Orders" });
    await Assert.That(orders.Tables()).IsEquivalentTo(new[] { "Sales.Orders" });
    await Assert.That(customers.Tables()).IsEquivalentTo(new[] { "Marketing.Customers" });
}


    </pre>
  <p id="IW8u">Без доработок этот тест будет красным.</p>
  <p id="anJO">EF Core кэширует модель по типу контекста, и второй хост получает модель первого. Ничего не падает, просто в модели не те таблицы. Лечится своим ключом кэша модели:</p>
  <pre id="5k9i">      
class AppDbContext(DbContextOptions options, IConfiguration configuration) : DbContext(options)
{
    // Модули, из которых собрана модель
    IReadOnlyList&lt;Assembly&gt; Modules { get; } = ModuleBase.GetLoadedModules(configuration);

    // Состав модулей для ключа кэша модели
    public string ModuleFingerprint =&gt; string.Join(';', Modules.Select(a =&gt; a.GetName().Name));

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        foreach (var assembly in Modules)
            modelBuilder.ApplyConfigurationsFromAssembly(assembly);
    }
}

class ModuleAwareModelCacheKeyFactory : IModelCacheKeyFactory
{
    public object Create(DbContext context, bool designTime) =&gt; context is AppDbContext app
        ? (typeof(AppDbContext), app.ModuleFingerprint, designTime)
        : (object)(context.GetType(), designTime);
}

builder.Services.AddDbContext&lt;AppDbContext&gt;(b =&gt; b
    .UseNpgsql(builder.Configuration.GetConnectionString("Postgres"))
    .ConfigureWarnings(o =&gt; o.Log(RelationalEventId.PendingModelChangesWarning))
    .ReplaceService&lt;IModelCacheKeyFactory, ModuleAwareModelCacheKeyFactory&gt;());


    </pre>
  <p id="c3Z2">Список модулей я читаю один раз в свойство</p>
  <pre id="KfBU">Modules</pre>
  <p id="VVm3">. Если звать</p>
  <pre id="yB5n">GetLoadedModules</pre>
  <p id="Ga8m">и в инициализаторе, и в</p>
  <pre id="cL5X">OnModelCreating</pre>
  <p id="Rt5h">, параметр первичного конструктора захватывается дважды, и компилятор ругается предупреждением CS9124.</p>
  <p id="SD6Y">Набор модулей</p>
  <pre id="QvMu">GetLoadedModules</pre>
  <p id="FNdt">берёт из реестра в конфигурации хоста. Брать загруженные сборки из</p>
  <pre id="e9ix">AppDomain</pre>
  <p id="jSiN">нельзя: в тестовом процессе загружены сборки всех топологий, которые уже поднимались.</p>
  <p id="QeLC">Без</p>
  <pre id="K8dG">ReplaceService</pre>
  <p id="XMK7">падает ещё и</p>
  <pre id="Ozue">Orders_HasOnlyItsTable_AndItsRoutes</pre>
  <p id="tNs9">. Кэш модели общий на весь процесс, и модель того теста, который запустился первым, уже лежит в нём. Если тест проходит в одиночку и падает в общем прогоне, первым делом проверьте ключ кэша.</p>
  <h2 id="fiPy">Контроль модульности при компиляции</h2>
  <p id="C4on">Тесты проверяют поведение. Публичный тип в модуле они не заметят, пока на него кто-нибудь не сошлётся. Обычно такое закрывают «архитектурными тестами», но правильнее для этого сделать анализатор.</p>
  <p id="ogVp">Вот что хочется получить:</p>
  <pre id="NRUg">      
[assembly: HostingStartup(typeof(Module))]

class Module : ModuleBase { }

public class Leak { }                                // ошибка компиляции
public record struct AddRequest(int A, int B);       // ошибка. Моя первая версия смотрела только на классы и это пропустила
public class Outer { public class Nested { } }       // ошибка только на Outer

public class Order { public int Id { get; set; } }   // можно: это сущность
class OrderConfiguration : IEntityTypeConfiguration&lt;Order&gt; { public void Configure(EntityTypeBuilder&lt;Order&gt; b) { } }


    </pre>
  <p id="H8GD">Сущность узнаётся по</p>
  <pre id="epHd">IEntityTypeConfiguration&lt;T&gt;</pre>
  <p id="mPeL">в той же сборке. Остальные типы, которые нужны больше чем одному модулю, переезжают в contracts-сборку без</p>
  <pre id="99Ra">[HostingStartup]</pre>
  <p id="BsWC">.</p>
  <p id="Ha3t">Анализатор - библиотека под</p>
  <pre id="WTON">netstandard2.0</pre>
  <p id="srxV">:</p>
  <pre id="qkZg">      
&lt;Project Sdk="Microsoft.NET.Sdk"&gt;
  &lt;PropertyGroup&gt;
    &lt;TargetFramework&gt;netstandard2.0&lt;/TargetFramework&gt;
    &lt;LangVersion&gt;latest&lt;/LangVersion&gt;
    &lt;EnforceExtendedAnalyzerRules&gt;true&lt;/EnforceExtendedAnalyzerRules&gt;
  &lt;/PropertyGroup&gt;
  &lt;ItemGroup&gt;
    &lt;PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="5.3.0" /&gt;
    &lt;PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="5.3.0" PrivateAssets="all" /&gt;
  &lt;/ItemGroup&gt;
&lt;/Project&gt;


    </pre>
  <p id="fTFf">Версия</p>
  <pre id="aAuf">Microsoft.CodeAnalysis.CSharp</pre>
  <p id="UCoP">задаёт минимальный компилятор: анализатор, собранный под 5.3.0, не загрузится в SDK или Visual Studio со старым Roslyn (будет CS8032). Пока анализатор живёт проектом в своём решении, это неважно. Для пакета я бы ставил минимально нужную версию.</p>
  <p id="Emz1">Подключается анализатор ко всем проектам сразу, в</p>
  <pre id="7BSW">Directory.Build.props</pre>
  <p id="d019">:</p>
  <pre id="LUil">      
&lt;ItemGroup&gt;
  &lt;ProjectReference Include="$(MSBuildThisFileDirectory)../modular-analyzer/modular-analyzer.csproj"
                    ReferenceOutputAssembly="false"
                    OutputItemType="Analyzer" /&gt;
  &lt;!-- анализатору нужно отличать хост от тестового проекта: точка входа есть у обоих --&gt;
  &lt;CompilerVisibleProperty Include="IsTestProject" /&gt;
&lt;/ItemGroup&gt;


    </pre>
  <p id="gw99">Отдельно помечать модули не нужно. Анализатор сам определяет: модуль - сборка с</p>
  <pre id="dXpI">[assembly: HostingStartup]</pre>
  <p id="uml6">, хост - сборка с точкой входа.</p>
  <p id="Ox9g">Код анализатора:</p>
  <pre id="0nC1">      
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class ModuleAnalyzer : DiagnosticAnalyzer
{
    static readonly DiagnosticDescriptor PublicType = new(
        "MOD0001", "Модуль не отдаёт публичных типов",
        "Тип '{0}' публичный, а сборка является модулем. Сделайте его internal или перенесите в contracts-сборку.",
        "Design", DiagnosticSeverity.Error, isEnabledByDefault: true);

    public override ImmutableArray&lt;DiagnosticDescriptor&gt; SupportedDiagnostics =&gt; [PublicType];

    public override void Initialize(AnalysisContext context)
    {
        context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
        context.EnableConcurrentExecution();
        context.RegisterCompilationStartAction(start =&gt;
        {
            var compilation = start.Compilation;

            // Модуль - сборка с [assembly: HostingStartup] и без точки входа
            if (!HasHostingStartup(compilation.Assembly) ||
                compilation.GetEntryPoint(start.CancellationToken) is not null)
                return;

            // Сущности - типы T, для которых в этой же сборке есть IEntityTypeConfiguration&lt;T&gt;
            var configuration = compilation.GetTypeByMetadataName(
                "Microsoft.EntityFrameworkCore.IEntityTypeConfiguration`1");
            var entities = compilation.GetSymbolsWithName(_ =&gt; true, SymbolFilter.Type, start.CancellationToken)
                .OfType&lt;INamedTypeSymbol&gt;()
                .SelectMany(type =&gt; type.AllInterfaces)
                .Where(i =&gt; SymbolEqualityComparer.Default.Equals(i.OriginalDefinition, configuration))
                .Select(i =&gt; i.TypeArguments[0])
                .ToImmutableHashSet&lt;ITypeSymbol&gt;(SymbolEqualityComparer.Default);

            start.RegisterSymbolAction(ctx =&gt;
            {
                var type = (INamedTypeSymbol)ctx.Symbol;
                // Вложенный тип виден снаружи только вместе с внешним, поэтому хватает типов верхнего уровня
                if (type is { DeclaredAccessibility: Accessibility.Public, ContainingType: null } &amp;&amp;
                    !entities.Contains(type))
                    ctx.ReportDiagnostic(Diagnostic.Create(PublicType, type.Locations[0], type.Name));
            }, SymbolKind.NamedType);
        });
    }

    internal static bool HasHostingStartup(IAssemblySymbol assembly) =&gt;
        assembly.GetAttributes().Any(a =&gt;
            a.AttributeClass?.ToDisplayString() == "Microsoft.AspNetCore.Hosting.HostingStartupAttribute");
}


    </pre>
  <h3 id="79dH">Контроллеры тоже internal</h3>
  <p id="g27f">Первая версия правила, та, что в репозитории доклада, разрешала публичные контроллеры: ASP.NET MVC по умолчанию находит только их. Но публичный контроллер тянет за собой наружу всё, что принимает:</p>
  <pre id="Gtq7">      
public class HomeController(IOrderQueries orders) : Controller   // CS0051: IOrderQueries internal
{
    public async Task&lt;IActionResult&gt; Index() =&gt; View(await orders.Unpaid());
}


    </pre>
  <p id="cD70">Компилятор заставит сделать публичным</p>
  <pre id="aogw">IOrderQueries</pre>
  <p id="4v2v">, за ним его DTO, и в итоге правило формально соблюдено, а публичной стала половина модуля. Проще научить MVC находить</p>
  <pre id="b5be">internal</pre>
  <p id="RR7r">-контроллеры. Для этого нужен свой провайдер:</p>
  <pre id="uYg3">      
sealed class InternalControllerProvider : IApplicationFeatureProvider&lt;ControllerFeature&gt;
{
    public void PopulateFeature(IEnumerable&lt;ApplicationPart&gt; parts, ControllerFeature feature)
    {
        var types = parts.OfType&lt;IApplicationPartTypeProvider&gt;().SelectMany(part =&gt; part.Types);
        foreach (var type in types.Where(IsInternalController))
            if (!feature.Controllers.Contains(type))
                feature.Controllers.Add(type);
    }

    // Условия стандартного провайдера, кроме публичности. IsNotPublic бывает только
    // у типов верхнего уровня, так что вложенный класс контроллером по-прежнему не станет
    static bool IsInternalController(TypeInfo type) =&gt;
        type is { IsNotPublic: true, IsClass: true, IsAbstract: false, ContainsGenericParameters: false } &amp;&amp;
        !type.IsDefined(typeof(NonControllerAttribute)) &amp;&amp;
        (type.Name.EndsWith("Controller", StringComparison.OrdinalIgnoreCase) ||
         type.IsDefined(typeof(ControllerAttribute)));
}

public static class InternalControllers
{
    public static IMvcBuilder AllowInternalControllers(this IMvcBuilder mvc) =&gt;
        mvc.ConfigureApplicationPartManager(manager =&gt;
        {
            // Контроллеры могут быть у нескольких модулей, а провайдер нужен один
            if (!manager.FeatureProviders.OfType&lt;InternalControllerProvider&gt;().Any())
                manager.FeatureProviders.Add(new InternalControllerProvider());
        });
}


    </pre>
  <p id="C6Bt">В модуле добавляется одна строка, и контроллер становится</p>
  <pre id="rBRN">internal</pre>
  <p id="BB5Z">:</p>
  <pre id="l246">      
protected override void ConfigureServices(WebHostBuilderContext ctx, IServiceCollection services)
{
    services.AddControllersWithViews()
            .AddApplicationPart(typeof(Module).Assembly)
            .AllowInternalControllers();
}

[Area(Module.AreaName)]
class HomeController(IOrderQueries orders) : Controller   // internal, как и IOrderQueries
{
    public async Task&lt;IActionResult&gt; Index() =&gt; View(await orders.Unpaid());
}


    </pre>
  <p id="DDsn">Без</p>
  <pre id="Hj9j">AllowInternalControllers()</pre>
  <p id="7CcR">такой контроллер компилируется и отдаёт 404.</p>
  <p id="Wmkn">Есть и обратная сторона: любой вспомогательный класс с именем на</p>
  <pre id="xi4A">Controller</pre>
  <p id="KAmN">теперь тоже станет контроллером, и его надо пометить</p>
  <pre id="iNXR">[NonController]</pre>
  <p id="RKnZ">.</p>
  <p id="cCCE">Код здесь упрощён, чтобы уместиться в статью. Кроме контроллеров, публичными в модуле должны оставаться тег-хелперы (internal-хелпер Razor молча выведет обычным HTML) и ViewComponent. Для Web API показанного хватает.</p>
  <h2 id="JySw">Связи между сборками</h2>
  <p id="CbYP">Хост ссылается на модули, чтобы они попали в образ, но использовать их не должен, иначе модуль станет обязательным в любом деплое:</p>
  <pre id="0RAC">      
// Program.cs хоста
using Customers;                                    // можно: импорт ещё не использование

var app = WebApplication.CreateBuilder(args).Build();
var type = typeof(Customer);                        // ошибка: хост использует модуль Customers.Entities
app.Run();

/// &lt;summary&gt;См. &lt;see cref="Customer"/&gt;&lt;/summary&gt;   // можно: комментарий модуль в деплой не тянет
static class Docs { }


    </pre>
  <p id="k9xe">Обычной библиотеке тоже нельзя ссылаться на модуль: она затащит сборку модуля туда, где его</p>
  <pre id="UCgC">IHostingStartup</pre>
  <p id="zlLG">не выполнялся.</p>
  <p id="b0Kv">Компилятор знает, какие ссылки он использовал: GetUsedAssemblyReferences.</p>
  <p id="tPCs">Моя первая версия верила ему на слово и ругалась на</p>
  <pre id="6HPr">using</pre>
  <p id="0CzA">, на</p>
  <pre id="KOlR">cref</pre>
  <p id="2Gym">с</p>
  <pre id="6D8F">/doc</pre>
  <p id="665q">и на каждый модуль в сломанной компиляции. Теперь его ответ для меня только подозрение, а подтверждает его поиск конкретной строки:</p>
  <pre id="i0ia">      
context.RegisterCompilationAction(ctx =&gt;
{
    var compilation = ctx.Compilation;
    if (IsTestProject(ctx.Options)) return;   // тестам ссылаться можно

    var isHost = compilation.GetEntryPoint(ctx.CancellationToken) is not null;
    if (!isHost &amp;&amp; ModuleAnalyzer.HasHostingStartup(compilation.Assembly))
        return;   // модулю ссылаться на модули можно: это сверяет ValidateReferences на старте

    // Первый вопрос, дешёвый: какие сборки-модули компилятор считает использованными
    var suspects = compilation.GetUsedAssemblyReferences(ctx.CancellationToken)
        .Select(compilation.GetAssemblyOrModuleSymbol)
        .OfType&lt;IAssemblySymbol&gt;()
        .Where(a =&gt; !a.Name.StartsWith("Microsoft.AspNetCore."))   // IIS Integration тоже hosting startup
        .Where(ModuleAnalyzer.HasHostingStartup);

    // Второй, точный: где именно
    foreach (var module in suspects)
        if (FindUse(compilation, module, ctx.CancellationToken) is { } location)
            ctx.ReportDiagnostic(Diagnostic.Create(
                isHost ? HostUsesModule : LibraryUsesModule,   // MOD0004 : MOD0005
                location, module.Name));
});

// Первый узел кода, который обращается к символу из сборки. DescendantNodes не заходит в trivia,
// поэтому &lt;see cref&gt; из doc-комментария не в счёт. using пропускаем сами: импорт ещё не использование
static Location? FindUse(Compilation compilation, IAssemblySymbol assembly, CancellationToken ct)
{
    bool FromAssembly(ISymbol? symbol) =&gt;
        SymbolEqualityComparer.Default.Equals(symbol?.ContainingAssembly, assembly);

    foreach (var tree in compilation.SyntaxTrees)
    {
#pragma warning disable RS1030   // только в конце компиляции и только для подозреваемых сборок
        var model = compilation.GetSemanticModel(tree);
#pragma warning restore RS1030
        foreach (var node in tree.GetRoot(ct).DescendantNodes(n =&gt; n is not UsingDirectiveSyntax))
            if (FromAssembly(model.GetSymbolInfo(node, ct).Symbol) ||
                FromAssembly(model.GetTypeInfo(node, ct).Type))
                return node.GetLocation();
    }
    return null;
}

static bool IsTestProject(AnalyzerOptions options) =&gt;
    options.AnalyzerConfigOptionsProvider.GlobalOptions
        .TryGetValue("build_property.IsTestProject", out var value) &amp;&amp;
    string.Equals(value, "true", StringComparison.OrdinalIgnoreCase);


    </pre>
  <pre id="ux8F">GetSemanticModel</pre>
  <p id="Iqtl">в анализаторе дорогой, о чём и предупреждает RS1030. Здесь он вызывается только для подозреваемых модулей, то есть в чистом проекте почти никогда. Если хост использует библиотеку, а та модуль, ошибку получит библиотека: исправлять надо её строку.</p>
  <h3 id="CJBj">ModuleBase и атрибут</h3>
  <pre id="IINy">      
[assembly: HostingStartup(typeof(PlainStartup))]

class PlainStartup : IHostingStartup { ... }    // ошибка: не попадёт в реестр, сущностей не будет в модели
class Forgotten : ModuleBase { }                // ошибка: нет атрибута, сборку загрузят, а модуль пропустят
abstract class CompanyModule : ModuleBase { }   // можно: общая база сама не модуль


    </pre>
  <p id="vk6d">Хуже всего</p>
  <pre id="O56J">Forgotten</pre>
  <p id="rFQ5">: приложение стартует, health-check зелёный, а модуль отвечает 404. Оба случая ловит одна проверка классов, которые реализуют</p>
  <pre id="MJUk">IHostingStartup</pre>
  <p id="A4yv">:</p>
  <pre id="THA4">      
context.RegisterCompilationStartAction(start =&gt;
{
    var compilation = start.Compilation;
    var hostingStartup = compilation.GetTypeByMetadataName("Microsoft.AspNetCore.Hosting.IHostingStartup");
    var moduleBase = compilation.GetTypeByMetadataName("ModuleBase");
    if (hostingStartup is null) return;

    // Классы, названные в [assembly: HostingStartup(typeof(...))]
    var named = compilation.Assembly.GetAttributes()
        .Where(a =&gt; a.AttributeClass?.ToDisplayString() == "Microsoft.AspNetCore.Hosting.HostingStartupAttribute")
        .SelectMany(a =&gt; a.ConstructorArguments)   // пусто, если атрибут написан с ошибкой
        .Select(argument =&gt; argument.Value)
        .OfType&lt;INamedTypeSymbol&gt;()
        .ToImmutableHashSet&lt;INamedTypeSymbol&gt;(SymbolEqualityComparer.Default);

    start.RegisterSymbolAction(ctx =&gt;
    {
        var type = (INamedTypeSymbol)ctx.Symbol;
        if (type.IsAbstract || !type.AllInterfaces.Contains(hostingStartup, SymbolEqualityComparer.Default))
            return;

        if (!named.Contains(type))
            ctx.ReportDiagnostic(Diagnostic.Create(NotNamed, type.Locations[0], type.Name));        // MOD0003
        else if (moduleBase is null || !DerivesFrom(type, moduleBase))
            ctx.ReportDiagnostic(Diagnostic.Create(NotModuleBase, type.Locations[0], type.Name));   // MOD0002
    }, SymbolKind.NamedType);
});

static bool DerivesFrom(INamedTypeSymbol type, INamedTypeSymbol baseType) =&gt;
    type.BaseType is { } parent &amp;&amp;
    (SymbolEqualityComparer.Default.Equals(parent, baseType) || DerivesFrom(parent, baseType));


    </pre>
  <h3 id="OOL5">Что ещё проверять при сборке</h3>
  <p id="sepw">Этот список у меня растёт из реальных отказов:</p>
  <ul>
    <li>AddSingleton&lt;IStartupFilter&gt;(this) в модуле: ModuleBase это уже сделал, и повторный маршрут уронит старт.</li>
    <li>Забытый параметр GenerateMvcApplicationPartsAssemblyAttributes в хосте притащит контроллеры всех модулей во все топологии. Анализатором надо ловить ApplicationPartAttribute в сборке хоста.</li>
    <li>CreateSlimBuilder и CreateEmptyBuilder игнорируют HOSTINGSTARTUPASSEMBLIES, и модули молча не загрузятся.</li>
    <li>Hub&lt;TClient&gt; с internal-интерфейсом клиента падает с TypeLoadException на первом клиенте. Нужен InternalsVisibleTo для динамической сборки SignalR.</li>
    <li>Assembly.GetEntryAssembly() в модуле вернёт сборку хоста. Это ломает код, который добавляет типы в DI, когда такой код переезжает в отдельный модуль.</li>
  </ul>
  <p id="fPjZ">Анализаторы для паттернов, которые недопустимы именно в вашем коде, вы можете написать сами. Главное, не подавляйте эти правила через</p>
  <pre id="HhcW">NoWarn</pre>
  <p id="5hnZ">. Для случаев «так и задумано» есть</p>
  <pre id="MCTl">#pragma</pre>
  <p id="XDZA">с комментарием у конкретной строки.</p>
  <h2 id="ygxx">Улучшаем модульность с помощью Source Generator</h2>
  <p id="wZS2">Design-time фабрика для EF из прошлой статьи перечисляет модули руками:</p>
  <pre id="xenU">      
static readonly string[] AllModules = ["Customers.Entities", "Orders.Entities", "Monolith"];


    </pre>
  <p id="bqYF">Добавили новый модуль с моделью для EF и забыли вписать его сюда - миграция для него не создастся. А если несколько веток правят этот список параллельно, будут ещё и конфликты.</p>
  <p id="RDLC">Этот список можно генерировать для хоста с помощью Source Generator:</p>
  <pre id="V7jp">      
[Generator(LanguageNames.CSharp)]
public sealed class KnownModulesGenerator : IIncrementalGenerator
{
    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        // Список нужен только запускаемым сборкам: хосту и тестам. Результат - строка,
        // а строки сравниваются по значению: правка, не меняющая набор модулей, файл не перегенерирует
        var names = context.CompilationProvider.Select(static (compilation, _) =&gt;
            compilation.Options.OutputKind == OutputKind.DynamicallyLinkedLibrary
                ? null
                : string.Join(", ", ModuleNames(compilation).Select(name =&gt; $"\"{name}\"")));

        context.RegisterSourceOutput(names, static (spc, names) =&gt;
        {
            if (names is null) return;
            spc.AddSource("KnownModules.g.cs", $$"""
                // &lt;auto-generated/&gt;
                internal static class KnownModules
                {
                    public static readonly string[] Names = [{{names}}];
                }
                """);
        });
    }

    // Сборки среди ссылок проекта, помеченные [assembly: HostingStartup]
    static IEnumerable&lt;string&gt; ModuleNames(Compilation compilation) =&gt; compilation.References
        .Select(compilation.GetAssemblyOrModuleSymbol)
        .OfType&lt;IAssemblySymbol&gt;()
        .Where(a =&gt; !a.Name.StartsWith("Microsoft.AspNetCore."))   // IIS Integration тоже hosting startup
        .Where(ModuleAnalyzer.HasHostingStartup)
        .Select(a =&gt; a.Name)
        .OrderBy(name =&gt; name, StringComparer.Ordinal);
}


    </pre>
  <p id="r1Rl">Фильтр</p>
  <pre id="mqsy">Microsoft.AspNetCore.</pre>
  <p id="RBlO">я дописал после первого запуска, когда в списке оказался IIS Integration. Фабрика больше не знает имён модулей:</p>
  <pre id="mjyi">      
var configuration = new ConfigurationBuilder()
    .AddJsonFile("appsettings.json", optional: true)
    .AddEnvironmentVariables()
    .AddInMemoryCollection([new(WebHostDefaults.HostingStartupAssembliesKey, string.Join(';', KnownModules.Names))])
    .AddInMemoryCollection(ModuleBase.CreateModuleRegistry(KnownModules.Names))
    .Build();


    </pre>
  <p id="VazP">Генератор работает и в тестовом проекте. Тестовый проект тоже exe, анализатор подключён к нему через</p>
  <pre id="IwXG">Directory.Build.props</pre>
  <p id="9W6L">, и там появляется свой</p>
  <pre id="VMdL">KnownModules</pre>
  <p id="vYZ4">, собранный из транзитивных ссылок. Раз тесты ссылаются на хост, список совпадёт с хостовым. Если хост открыл тестам internals через</p>
  <pre id="eLqx">InternalsVisibleTo</pre>
  <p id="Y2CD">, компилятор предупредит о двух одинаковых типах (CS0436) и возьмёт свой.</p>
  <h3 id="CzIs">Порядок модулей</h3>
  <p id="LyrB">Генератор отдаёт имена по алфавиту, и</p>
  <pre id="NZMi">Monolith</pre>
  <p id="2qez">оказывается раньше</p>
  <pre id="g0l4">Orders.Entities</pre>
  <p id="6gTm">.</p>
  <pre id="Sggh">      
// Monolith
class OrderEntityConfiguration : IEntityTypeConfiguration&lt;Order&gt;   // Order в сигнатуре: ссылка на Orders.Entities есть в reference-сборке
{
    public void Configure(EntityTypeBuilder&lt;Order&gt; entity)
    {
        entity.HasOne&lt;Customer&gt;()                                  // Customer только в теле метода: ссылки на Customers.Entities там нет
              .WithMany()
              .HasForeignKey(e =&gt; e.CustomerId);
    }
}


    </pre>
  <p id="iBZc">При таком порядке создание внешнего ключа в</p>
  <pre id="LeyO">Monolith</pre>
  <p id="PWoF">упадёт с ошибкой. Нужна топологическая сортировка модулей, но в генераторе её не сделать: компилятор видит соседние проекты как reference-сборки без тел методов и зависимостей.</p>
  <p id="lHwv">В третьей статье порядок задавала переменная, и</p>
  <pre id="KlzO">Monolith</pre>
  <p id="xSZq">надо было ставить в ней последним. Теперь порядок строит</p>
  <pre id="uid7">GetLoadedModules</pre>
  <p id="6q1w">во время работы, по настоящим сборкам, и порядок в переменной больше не важен:</p>
  <pre id="bCYW">      
public static IReadOnlyList&lt;Assembly&gt; GetLoadedModules(IConfiguration configuration)
{
    var order = (configuration[WebHostDefaults.HostingStartupAssembliesKey] ?? string.Empty)
        .Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);

    var modules = configuration.GetSection(ModulesSection).GetChildren()
        .ToDictionary(m =&gt; m.Key, m =&gt; Assembly.Load(m.Key), StringComparer.OrdinalIgnoreCase);

    int Depth(Assembly module) =&gt; module.GetReferencedAssemblies()
        .Where(r =&gt; modules.ContainsKey(r.Name!))
        .Select(r =&gt; Depth(modules[r.Name!]) + 1)
        .DefaultIfEmpty(0)
        .Max();

    // Сначала то, на что ссылаются, при равной глубине по порядку переменной.
    // Сборки приложения в переменной нет, её индекс −1, и она остаётся первой
    return [.. modules.Values
        .OrderBy(Depth)
        .ThenBy(m =&gt; Array.FindIndex(order, n =&gt;
            string.Equals(n, m.GetName().Name, StringComparison.OrdinalIgnoreCase)))];
}


    </pre>
  <pre id="r0gT">Depth</pre>
  <p id="TDGp">- длина самой длинной цепочки ссылок от модуля до модулей без зависимостей. В design-time фабрике в реестре все модули проекта, поэтому и порядок там такой же, как в любой топологии с теми же модулями.</p>
  <p id="qFFV">С переменной</p>
  <pre id="f4c5">Monolith;Customers.Entities;Orders.Entities</pre>
  <p id="6GBT">модель собирается в порядке</p>
  <pre id="wIjB">Customers.Entities;Orders.Entities;Monolith</pre>
  <p id="A15k">, и внешний ключ на месте.</p>
  <h3 id="pkZO">Опечатка в переменной HOSTINGSTARTUPASSEMBLIES</h3>
  <p id="Vrnc">Если указать имя несуществующего модуля, ASP.NET Core пишет в лог с уровнем</p>
  <pre id="fYeR">crit</pre>
  <p id="3pZ3">и стартует дальше. Список от генератора позволяет это поймать:</p>
  <pre id="fKFI">      
public static void ValidateModules(IConfiguration configuration, IEnumerable&lt;string&gt; modules)
{
    var registry = configuration.GetSection(ModulesSection);
    var requested = configuration[WebHostDefaults.HostingStartupAssembliesKey] ?? string.Empty;

    // Модуль хоста обязан оказаться в реестре, чужому hosting startup достаточно загрузиться
    var broken = requested.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
        .Where(name =&gt; registry[name] is null &amp;&amp;
                       (modules.Contains(name, StringComparer.OrdinalIgnoreCase) || !CanLoad(name)))
        .ToArray();

    if (broken.Length &gt; 0)
        throw new InvalidOperationException(
            $"HOSTINGSTARTUPASSEMBLIES = '{requested}': не активированы {string.Join(", ", broken)}");
}

static bool CanLoad(string name)
{
    try { Assembly.Load(name); return true; }
    catch (IOException) { return false; }   // FileNotFoundException, FileLoadException
}


    </pre>
  <p id="rhlQ">Хост вызывает проверку сразу после создания билдера:</p>
  <pre id="4oCQ">      
var builder = WebApplication.CreateBuilder(args);
ModuleBase.ValidateModules(builder.Configuration, KnownModules.Names);


    </pre>
  <p id="VmJ3">Чужой hosting startup вроде Application Insights в реестр не пишет, от него требуется только загрузиться. Тест:</p>
  <pre id="jRS5">      
[Test]
public async Task Typo_FailsStartup()
{
    await using var factory = new TopologyFactory("Orders.Entitys", "OrdersMicroservice");
    await Assert.That(() =&gt; factory.Services).Throws&lt;InvalidOperationException&gt;()
        .WithMessageContaining("Orders.Entitys");
}


    </pre>
  <p id="WqnW">Текст сообщения я проверяю не просто так.</p>
  <pre id="OQUw">InvalidOperationException</pre>
  <p id="ggSR">фабрика бросает и сама, например когда хост вообще не построился, и без проверки сообщения тест прошёл бы не по той причине.</p>
  <h2 id="Kj7A">Бонус: проверка владения сущностью и таблицей</h2>
  <p id="qnfQ">Генератор и анализатор хороши поодиночке, но интереснее всего они работают в паре. В комментариях под прошлой статьёй заметили, что модульная модель позволяет описать одну и ту же таблицу в разных модулях.</p>
  <p id="dwex">Например, новому модулю срочно нужны заказы. Ссылаться на</p>
  <pre id="074w">Orders</pre>
  <p id="5J0C">нельзя: это поменяет топологию, и упадут тесты.</p>
  <p id="7iwC">Зато можно так:</p>
  <pre id="iMcM">      
// Модуль Billing
class Order { public int Id { get; set; } public decimal Amount { get; set; } }   // своя копия заказа

class OrderConfiguration : IEntityTypeConfiguration&lt;Order&gt;
{
    public void Configure(EntityTypeBuilder&lt;Order&gt; entity) =&gt; entity.ToTable("Orders", "Sales");   // таблица модуля Orders
}


    </pre>
  <p id="yVFs">Ссылок между сборками нет, компилятор доволен, всё работает, пока</p>
  <pre id="6WaD">Orders</pre>
  <p id="R76P">не поменяет схему. Такая же протечка бывает через общую сборку</p>
  <pre id="B3hJ">Shared</pre>
  <p id="Js2Y">: два модуля настраивают один класс сущности, и EF Core молча берёт конфигурацию того, кто применился последним.</p>
  <p id="Plf1">Правило простое: у таблицы и у сущности один модуль-владелец, тот, кто вызывает</p>
  <pre id="IRxg">ToTable</pre>
  <p id="FOMb">. Связывающий модуль</p>
  <pre id="4id7">Monolith</pre>
  <p id="4tbB">из демо настраивает</p>
  <pre id="xke6">Order</pre>
  <p id="xlLi">, но</p>
  <pre id="0Ryo">ToTable</pre>
  <p id="hCfB">не вызывает, поэтому владельцем не считается.</p>
  <p id="v2HK">Проверять правило надо в хосте, только он видит все модули сразу. Но</p>
  <pre id="6WRX">ToTable</pre>
  <p id="kd4b">вызывается в теле метода, а хост видит модули как reference-сборки без тел.</p>
  <p id="7sl9">Поэтому работу надо разделить: генератор в каждом модуле записывает в атрибуты сборки, чем модуль владеет, а анализатор хоста эти атрибуты читает. Атрибуты сборки в reference-сборке остаются, и анализатор их видит.</p>
  <h3 id="7LyB">Очень много кода</h3>
  <pre id="Mqle">      
[Generator(LanguageNames.CSharp)]
public sealed class OwnedTablesGenerator : IIncrementalGenerator
{
    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        // Вызовы entity.ToTable("Orders", "Sales") с константными аргументами
        var owned = context.SyntaxProvider.CreateSyntaxProvider(
                static (node, _) =&gt; node is InvocationExpressionSyntax
                {
                    Expression: MemberAccessExpressionSyntax { Name.Identifier.ValueText: "ToTable" }
                },
                static (ctx, ct) =&gt; Owned(ctx, ct))
            .Where(static line =&gt; line is not null)
            .Collect()
            .Select(static (lines, _) =&gt; string.Join("\n", lines.Distinct().OrderBy(l =&gt; l, StringComparer.Ordinal)));

        var isModule = context.CompilationProvider
            .Select(static (compilation, _) =&gt; ModuleAnalyzer.HasHostingStartup(compilation.Assembly));

        context.RegisterSourceOutput(owned.Combine(isModule), static (spc, input) =&gt;
        {
            var (lines, isModule) = input;
            if (!isModule || lines.Length == 0) return;
            spc.AddSource("OwnedTables.g.cs", $$"""
                // &lt;auto-generated/&gt;
                {{lines}}

                [System.AttributeUsage(System.AttributeTargets.Assembly, AllowMultiple = true)]
                internal sealed class OwnsTableAttribute(string table, string entity) : System.Attribute { }
                """);
        });
    }

    static string? Owned(GeneratorSyntaxContext ctx, CancellationToken ct)
    {
        var call = (InvocationExpressionSyntax)ctx.Node;
        var builder = ((MemberAccessExpressionSyntax)call.Expression).Expression;

        // entity имеет тип EntityTypeBuilder&lt;Order&gt;, отсюда тип сущности
        if (ctx.SemanticModel.GetTypeInfo(builder, ct).Type is not INamedTypeSymbol
            {
                Name: "EntityTypeBuilder", TypeArguments.Length: 1
            } builderType)
            return null;
        var entity = builderType.TypeArguments[0];

        var args = call.ArgumentList.Arguments
            .Select(a =&gt; ctx.SemanticModel.GetConstantValue(a.Expression, ct))
            .ToArray();
        if (args.Length == 0 || args[0].Value is not string table) return null;   // имя не константа: не угадываем
        var schema = args.Length &gt; 1 &amp;&amp; args[1].Value is string s ? s + "." : "";

        return $"[assembly: OwnsTable(\"{schema}{table}\", \"{entity.ToDisplayString()}\")]";
    }
}


    </pre>
  <p id="wAWu">В сборке</p>
  <pre id="sCiw">Orders.Entities</pre>
  <p id="uGwM">появляется файл:</p>
  <pre id="xeD6">      
// &lt;auto-generated/&gt;
[assembly: OwnsTable("Sales.Orders", "Orders.Order")]

[System.AttributeUsage(System.AttributeTargets.Assembly, AllowMultiple = true)]
internal sealed class OwnsTableAttribute(string table, string entity) : System.Attribute { }


    </pre>
  <p id="ztZA">Атрибут internal, поэтому публичных типов у модуля не прибавилось, и правило про них молчит. Анализатор хоста собирает атрибуты со всех модулей. Таблица и сущность для него одинаковые ключи владения:</p>
  <pre id="0fq8">      
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class OwnedTablesAnalyzer : DiagnosticAnalyzer
{
    static readonly DiagnosticDescriptor TwoOwners = new(
        "OWN0001", "У таблицы и сущности один владелец",
        "'{0}' владеют модули {1}",
        "Design", DiagnosticSeverity.Error, isEnabledByDefault: true,
        customTags: WellKnownDiagnosticTags.CompilationEnd);

    public override ImmutableArray&lt;DiagnosticDescriptor&gt; SupportedDiagnostics =&gt; [TwoOwners];

    public override void Initialize(AnalysisContext context)
    {
        context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
        context.EnableConcurrentExecution();
        context.RegisterCompilationAction(ctx =&gt;
        {
            var compilation = ctx.Compilation;
            if (compilation.GetEntryPoint(ctx.CancellationToken) is null) return;   // сверяет хост

            // Каждый модуль сам записал, чем владеет
            var owners = compilation.References
                .Select(compilation.GetAssemblyOrModuleSymbol)
                .OfType&lt;IAssemblySymbol&gt;()
                .Where(ModuleAnalyzer.HasHostingStartup)
                .SelectMany(module =&gt; module.GetAttributes()
                    .Where(a =&gt; a.AttributeClass?.Name == "OwnsTableAttribute")
                    .SelectMany(a =&gt; a.ConstructorArguments)          // и таблица, и сущность
                    .Select(argument =&gt; (Key: (string)argument.Value!, Module: module.Name)));

            foreach (var key in owners.GroupBy(o =&gt; o.Key))
            {
                var modules = key.Select(o =&gt; o.Module).Distinct().OrderBy(m =&gt; m).ToArray();
                if (modules.Length &gt; 1)
                    ctx.ReportDiagnostic(Diagnostic.Create(TwoOwners, Location.None, key.Key, string.Join(", ", modules)));
            }
        });
    }
}


    </pre>
  <p id="7QOm">Хост с модулем Billing из примера больше не собирается:</p>
  <pre id="rkgJ">      
error OWN0001: 'Sales.Orders' владеют модули Billing, Orders.Entities


    </pre>
  <p id="7K4W">Если Billing вместо копии настроит чужой</p>
  <pre id="MR6c">Orders.Order</pre>
  <p id="HSww">под свою таблицу, ошибка будет про</p>
  <pre id="HGsi">'Orders.Order'</pre>
  <p id="crMN">.</p>
  <p id="n0ty">Генератор видит только</p>
  <pre id="PBk6">ToTable</pre>
  <p id="7A6Z">с константами. Таблицу, названную по соглашению из</p>
  <pre id="BO8n">DbSet</pre>
  <p id="URiQ">, или имя из конфигурации он пропустит, и это честная дыра. Закрыть её можно проверкой при старте или в тесте: у таблицы нет владельца - ошибка.</p>
  <p id="yXBP">Проверка идёт по всем модулям хоста сразу, поэтому она строже любой топологии. Два модуля-альтернативы, которые никогда не грузятся вместе, но владеют одной таблицей, она тоже не пропустит. Явно разрешить такой случай пока нечем. Диагностика выдаётся без места в коде (</p>
  <pre id="2sXO">Location.None</pre>
  <p id="ithW">),</p>
  <pre id="FHYW">#pragma</pre>
  <p id="a6s0">к ней не приложить, и остаётся только</p>
  <pre id="MRKj">NoWarn</pre>
  <p id="HXkM">на весь хост, от которого я выше отговаривал.</p>
  <p id="LQtN">Тот же приём годится для всего, что живёт в телах методов и нужно хосту: регистрации сервисов, маршруты, имена очередей.</p>
  <h2 id="aoQQ">Выводы</h2>
  <p id="F7k9">Всё, что показано в статье, собрано из стандартных деталей: ASP.NET, EF Core,</p>
  <pre id="mx8M">WebApplicationFactory</pre>
  <p id="64tu">и Roslyn. Проверки тестами и компилятором стоит добавить, даже если модульность у вас держится на папках, договорённостях или любом модульном фреймворке.</p>
  <p id="mV9u">Есть популярное возражение:</p>
  <blockquote id="sPpA">В эпоху ИИ архитектура больше не важна. Говоришь клоду «сделай хорошо», он сделает, а значит, и модульность не нужна, клод разберётся.</blockquote>
  <p id="bSxM"><em>Вместо “хорошо” было другое слово, но цензура.</em></p>
  <p id="I29w">Агент, конечно, сделает <em>«хорошо»</em>. Сделает для той задачи, которую ему дали: тест зелёный, фича работает. О том, что модуль Orders перестал запускаться отдельно, он не узнает, потому что такую топологию никто не запускал. Архитектуру агент не отменяет. Он просто ломает её с той же скоростью, с какой пишет код.</p>
  <p id="Jhmu">Агент пишет код быстрее человека и очень любит срезать углы. Нужный класс</p>
  <pre id="QUif">internal</pre>
  <p id="QV9L">- проще сделать его публичным. Нужны данные соседа - проще замапить его таблицу. Каждый такой шаг на ревью не отловишь. А ошибка компиляции или красный тест останавливают агента сразу, и он вынужден исправлять код ещё до того, как кожаный увидит дифф.</p>
  <p id="Zcsg">При разделении монолита на модули я бы начал с тестов топологий: их проще всего прикрутить к существующему проекту, и ловят они больше всего. Анализатор публичных типов и ссылок я бы прикрутил к любому решению, где вы хотите модульности, особенно если код пишет ИИ. Остальное можно добавлять по мере того, как вылезают грабли. Руками это всё писать, конечно, не нужно: весь код анализатора и генераторов в этой статье написал ИИ.</p>
  <p id="8gB7">Анализаторы лучше сразу упаковать в отдельный NuGet-пакет. Если анализатор лежит проектом в решении, агент его видит и может поправить правило вместо своего кода, и вам придётся уговаривать его так не делать. Из пакета агенту видна только диагностика, а само правило не поменять. Заодно появляется стимул делать анализаторы универсальными, чтобы они подходили к разным проектам.</p>
  <p id="mLnz">Я всё это уже проделал и собрал в библиотеку.</p>
  <h2 id="QPrJ">FusionModules</h2>
  <p id="iPuA">FusionModules - открытая библиотека под MIT для модульных приложений на ASP.NET Core. В ней базовый класс модуля, проверки топологии на старте, пакет для тестов топологий и анализаторы MOD0001-MOD0005 из этой статьи, с исправлениями. Есть и правила, которых в статье нет: MOD0011 ловит контроллеры модуля без application part, а MOD0014 - два модуля, которые зарегистрировали один интерфейс. Проверки владения таблицей из бонуса в пакете пока нет.</p>
  <pre id="hWSZ">      
dotnet add package FusionModules


    </pre>
  <p id="xBzl">В следующей статье я пошагово покажу, как переделать микросервисный eShop от Microsoft в модульный монолит с помощью FusionModules.</p>
  <h2 id="2qdQ">Материалы</h2>
  <ul>
    <li>Доклад: Модульность без микросервисов в ASP.NET из коробки, DotNext 2026</li>
    <li>Репозиторий доклада:. Демо, на которых выросли примеры из статьи: modular-data, modular-endpoints, modular-analyzer, load-test; в ветке fusionmodules те же демо на FusionModules 0.3</li>
    <li>Библиотека: FusionModules на GitHub</li>
    <li>Тесты: TUnit, интеграционные тесты в ASP.NET Core, IModelCacheKeyFactory</li>
    <li>MVC: application parts и feature providers</li>
    <li>Roslyn: написание анализаторов, генераторы кода, Compilation.GetUsedAssemblyReferences</li>
  </ul>
  <hr />
  <p id="AAUY"><strong>Кстати</strong>, есть ещё одна интересная находка - VPS в самом центре города. Мы его уже проверили, можем рекомендовать! Ссылка: <a href="https://moremore.pro/?ref=7776677766" target="_blank">moremore.pro</a></p>

]]></content:encoded></item><item><guid isPermaLink="true">https://teletype.in/@gringo342/0wcizyyDcbz</guid><link>https://teletype.in/@gringo342/0wcizyyDcbz?utm_source=teletype&amp;utm_medium=feed_rss&amp;utm_campaign=gringo342</link><comments>https://teletype.in/@gringo342/0wcizyyDcbz?utm_source=teletype&amp;utm_medium=feed_rss&amp;utm_campaign=gringo342#comments</comments><dc:creator>gringo342</dc:creator><title>От COM-интеграций к централизованной схеме: как проверить новую интеграционную архитектуру до промышленного внедрения</title><pubDate>Wed, 11 Nov 2026 14:48:50 GMT</pubDate><description><![CDATA[<img src="https://img4.teletype.in/files/76/3c/763c4d80-684c-42c0-bbb4-0e15d6294a33.png"></img>На связи Анна Астахова, директор по развитию ИТ-интегратора «Белый код». Почти всегда тем, кто обращается к нам с вопросом о выборе шины, мы говорим, что надо проверять на реальной задаче. Пилотный проект — хорошая…]]></description><content:encoded><![CDATA[
  <figure id="QPrB" class="m_original">
    <img src="https://img4.teletype.in/files/76/3c/763c4d80-684c-42c0-bbb4-0e15d6294a33.png" width="1560" />
  </figure>
  <p id="P9Qr"><em>На связи Анна Астахова, директор по развитию ИТ-интегратора «Белый код». Почти всегда тем, кто обращается к нам с вопросом о выборе шины, мы говорим, что надо проверять на реальной задаче. Пилотный проект — хорошая возможность оценить, насколько вашей архитектуре и вашим требованиям подходит конкретная интеграционная платформа. Расскажу на примере. Для одного из заказчиков мы реализовали три интеграционных потока, и это помогло увидеть реальную пользу DATAREON Platform.</em></p>
  <p id="VvZ4"><strong>Заказчик:</strong> инжиниринговый холдинг.</p>
  <p id="KwXu"><strong>Задача:</strong> проверить на пилотном контуре централизованную схему интеграции, в которой «1С:Документооборот» становится единым источником данных по объекту «Проект», а обмен между системами выполняется через DATAREON Platform.</p>
  <h3 id="E7V0">О компании</h3>
  <p id="Am0E">Один из российских инжиниринговых холдингов. Компания строит промышленные объекты и имеет подтвержденные положительные результаты работы действующих производств.</p>
  <h3 id="1Puk">С чем обратился заказчик</h3>
  <p id="z0Ef">На момент старта проекта обмен данными между системами был построен через COM-взаимодействие.</p>
  <p id="zz0R">Процесс работы с объектом «Проект» выглядел так:</p>
  <ul>
    <li>проект создавался в «1С:Документооборот»;</li>
    <li>затем через COM передавался в «БИТ.ФИНАНС»;</li>
    <li>в «1С БИТ» создавалась связанная номенклатурная группа;</li>
    <li>после этого проект и номенклатурная группа передавались в «1С:ЗУП» также через COM.</li>
  </ul>
  <p id="2AO5">Основная сложность этой схемы заключалась в том, что «1С:ЗУП» получала данные о проекте не напрямую из исходной системы, а через «БИТ.ФИНАНС». Из-за этого целевой обмен зависел от промежуточной системы, а происхождение данных было менее прозрачным.</p>
  <p id="2z4o">Заказчик хотел проверить другую модель: определить «1С:Документооборот» как единый источник данных по проектам и организовать передачу информации через централизованный интеграционный слой DATAREON Platform. В интеграционном контуре пилотного проекта использовались три системы: «1С:Документооборот», «БИТ.ФИНАНС» и «1С:ЗУП».</p>
  <h3 id="1VRQ">Что сделали</h3>
  <p id="YtPF">В рамках пилота мы реализовали три интеграционных потока:</p>
  <ul>
    <li>«1С:Документооборот» → «БИТ.ФИНАНС» — передача справочника «Проекты».</li>
    <li>«1С:Документооборот» → «1С:ЗУП» — передача справочника «Проекты».</li>
    <li>«БИТ.ФИНАНС» → «1С:ЗУП» — передача справочника «Номенклатурные группы».</li>
  </ul>
  <p id="6pm6">Ключевое изменение по сравнению с исходной схемой — проект стал поступать в «1С:ЗУП» напрямую из «1С:Документооборот», а не через «БИТ.ФИНАНС».</p>
  <p id="7GEL">При этом роль «БИТ.ФИНАНС» сохранилась: после получения проекта в системе формируется связанная номенклатурная группа, которая затем передается в «1С:ЗУП».</p>
  <p id="qFIM">DATAREON Platform использовалась как единый интеграционный слой пилотного решения.</p>
  <figure id="vHOg" class="m_original">
    <img src="https://img2.teletype.in/files/98/ce/98cef2f6-754d-44c5-8e1e-38f422030af5.png" width="1560" />
  </figure>
  <p id="tQC2">Дополнительно объекты «Проект» и «Номенклатурная группа» в процессе обмена записывались в Банк данных DATAREON. Отдельного создания объектов внутри Банка данных не предусматривалось: объект проходит через него, а в Банке данных сохраняется его текущая версия. История изменений и версионирование в границы пилота не входили.</p>
  <p id="HGEp">Сам пилот был намеренно ограничен по объему. В него не входили:</p>
  <ul>
    <li>передача табличных частей;</li>
    <li>перенос потоков на промышленный контур;</li>
    <li>дополнительные справочники, документы и бизнес-процессы;</li>
    <li>промышленная эксплуатация и регламентное сопровождение;</li>
    <li>тиражирование решения за пределы пилотного контура.</li>
  </ul>
  <p id="dqNO">Такой подход позволил проверить саму архитектурную модель, не расширяя проект на смежные процессы.</p>
  <h3 id="QGyY">Результат</h3>
  <p id="251S">По итогам пилота удалось проверить работу централизованной схемы обмена через DATAREON Platform.</p>
  <p id="L8Wi">В пилотном контуре:</p>
  <ul>
    <li>реализованы и настроены три целевых интеграционных потока;</li>
    <li>«1С:Документооборот» выступает единым источником данных по объекту «Проект»;</li>
    <li>проект передается из «1С:Документооборот» в «БИТ.ФИНАНС»;</li>
    <li>проект передается из «1С:Документооборот» напрямую в «1С:ЗУП»;</li>
    <li>номенклатурная группа передается из «БИТ.ФИНАНС» в «1С:ЗУП»;</li>
    <li>проверена передача объектов по каждому потоку;</li>
    <li>подтверждено соблюдение ограничений пилота, в том числе исключение табличных частей из обмена.</li>
  </ul>
  <p id="M603">Заказчик получил возможность оценить целевую архитектуру до принятия решения о дальнейшем развитии и переносе интеграций в промышленную среду.</p>
  <hr />
  <p id="rMVH"><strong>А ещё</strong>, кстати, наткнулись на интересный VPS который работает прямо в центре города. Решили проверить сами - результат порадовал. Вот ссылка, вдруг пригодится: <a href="https://moremore.pro/" target="_blank">moremore.pro</a></p>

]]></content:encoded></item><item><guid isPermaLink="true">https://teletype.in/@gringo342/3mfelfteXug</guid><link>https://teletype.in/@gringo342/3mfelfteXug?utm_source=teletype&amp;utm_medium=feed_rss&amp;utm_campaign=gringo342</link><comments>https://teletype.in/@gringo342/3mfelfteXug?utm_source=teletype&amp;utm_medium=feed_rss&amp;utm_campaign=gringo342#comments</comments><dc:creator>gringo342</dc:creator><title>OpenAPI спецификация как инструмент frontend-разработчика: практическое применение и типичные проблемы</title><pubDate>Wed, 11 Nov 2026 13:41:28 GMT</pubDate><description><![CDATA[<img src="https://img1.teletype.in/files/4c/08/4c0827a5-e038-412d-b80b-fdb6d45e5d67.png"></img>OpenAPI — это спецификация для документирования API в формате YAML или JSON. Она позволяет стандартизированно описывать REST API.]]></description><content:encoded><![CDATA[
  <h2 id="xJQ0">Спецификация OpenAPI</h2>
  <p id="Mkar">OpenAPI — это спецификация для документирования API в формате YAML или JSON. Она позволяет стандартизированно описывать REST API.</p>
  <p id="Oy8C">Swagger UI и ReDoc — это популярные инструменты, которые преобразуют спецификацию OpenAPI в интерактивную документацию (UI для спецификации OpenAPI).</p>
  <p id="vvME">Как найти исходную спецификацию OpenAPI по Swagger UI или ReDoc? Открыв документацию Swagger UI или ReDoc в браузере, в консоли разработчика можно найти сетевые запросы к файлу спецификации (формата *.json, *.yaml или *.yml и содержимое будет начинаться с версии спецификации OpenAPI).</p>
  <figure id="UlRS" class="m_original">
    <img src="https://img1.teletype.in/files/4c/08/4c0827a5-e038-412d-b80b-fdb6d45e5d67.png" width="1560" />
    <figcaption>Как найти исходную спецификацию OpenAPI по Swagger UI или ReDoc?</figcaption>
  </figure>
  <h2 id="dUFB">Как можно применить спецификацию OpenAPI?</h2>
  <h3 id="mzf1">1. Генерация типов TypeScript</h3>
  <p id="f1Be">Одно из главных преимуществ — возможность генерировать TypeScript типы (для всех эндпоинтов, request/response моделей) из спецификации. Особенно полезно в небольших стартапах или на начальных этапах разработки проекта, когда api быстро меняется, а изменения не всегда обратно совместимы, и вручную уследить за всеми изменениями сложно.</p>
  <p id="so8a">Примеры инструментов:</p>
  <ul>
    <li>openapi-typescript - генерирует чистые TypeScript-типы (paths, components) из OpenAPI 3.0/3.1 без runtime-кода, быстро и с поддержкой YAML/JSON (локально/удалённо);</li>
    <li>dtsgenerator - Создаёт.d.ts файлы из JSON Schema или OpenAPI (v2/v3), с CLI, плагинами и поддержкой stdin/url;</li>
    <li>openapi-generator - Универсальный генератор (CLI /openapi-generator-cli) для клиентов, серверов, документации в 50+ языках, включая TypeScript-варианты (axios, fetch);</li>
  </ul>
  <h3 id="drlw">1.1. Пример генерации типов для RTK</h3>
  <p id="QLWS">Полный код рабочего примера на github</p>
  <p id="KBLB">Как это работает:</p>
  <p id="ulNo">1.1.1. Генерируем типы из OpenAPI спецификации через npm команду</p>
  <pre id="bOuY">      
// ...
"scripts": {
  //...
  "generate-types": "dtsgen --out src/api/types/generated-counter-api-types.d.ts specs/prepared-counter-api.yaml",
  //...
}
// ...


    </pre>
  <p id="mOdz">1.1.2. Генерируются файлы с API типами</p>
  <pre id="Fdfq">      
// ...

declare namespace Components {
    namespace Schemas {
        export interface CounterResponse {
            value: number; // int32
        }
        export interface CounterRequest {
            value: number; // int32
        }
    }
}

declare namespace Paths {
    namespace GetCounter {
        export interface QueryParameters {
            version?: string;
        }
        namespace Responses {
            export type $200 = Components.Schemas.CounterResponse;
        }
    }
    namespace SaveCounter {
        export type RequestBody = Components.Schemas.CounterRequest;
        namespace Responses {
            export type $200 = Components.Schemas.CounterResponse;
        }
    }
}

// ...


    </pre>
  <p id="mBUs">1.1.3. Создаём RTK Query API с использованием сгенерированных типов файл</p>
  <pre id="BTqG">      
/// &lt;reference path="../../api/types/generated-counter-api-types.d.ts" /&gt;

// ...

export const counterApi = createApi({
  // ...
  endpoints: (builder) =&gt; ({
    getCounter: builder.query&lt;Paths.GetCounter.Responses.$200, Paths.GetCounter.QueryParameters&gt;({
      // ...
    }),
    saveCounter: builder.mutation&lt;
      Paths.SaveCounter.Responses.$200,
      Paths.SaveCounter.RequestBody
    &gt;({
      // ...
    }),
  }),
});

// ...


    </pre>
  <p id="7ENt">1.1.4. Используем типобезопасные хуки в React компонентах файл</p>
  <pre id="KqAK">      
// ...

const { data, isLoading, error } = useGetCounterQuery({ version });
const [saveCounter] = useSaveCounterMutation();

// data имеет тип Paths.GetCounter.Responses.$200
// saveCounter принимает параметры типа Paths.SaveCounter.RequestBody

// ...


    </pre>
  <h3 id="Phc4">1.2. Пример генерации типов для react-query</h3>
  <p id="mL5O">Полный код рабочего примера на github</p>
  <p id="SrmN">Как это работает детально:</p>
  <p id="AphN">1.2.1. Генерируем типы из OpenAPI спецификации через npm команду</p>
  <pre id="kHaT">      
// ...
"scripts": {
  // ...
  "generate-types": "dtsgen --out src/api/types/generated-api-types.d.ts specs/prepared-openapi.json"
  // ...
}
// ...


    </pre>
  <p id="G4Io">1.2.2. Генерируются файлы с API типами</p>
  <pre id="Ux9s">      
declare namespace Components {
    namespace Schemas {
        export interface Pet {
            id?: number; // int64
            name: string;
            photoUrls: string[];
            status?: "available" | "pending" | "sold";
            category?: Category;
            tags?: Tag[];
        }
    }
}

declare namespace Paths {
    namespace GetPetById {
        export interface PathParameters {
            petId: number; // int64
        }
        namespace Responses {
            export type $200 = Components.Schemas.Pet;
        }
    }
}


    </pre>
  <p id="i82X">1.2.3. Создаём API-функции с использованием сгенерированных типов</p>
  <pre id="VbYI">      
/// &lt;reference path="./types/generated-api-types.d.ts" /&gt;

export async function fetchPetById(petId: number): Promise&lt;Paths.GetPetById.Responses.$200&gt; {
  const res = await fetch(`http://localhost:4010/api/v3/pet/${petId}`);
  if (!res.ok) {
    throw new Error('Pet is not found');
  }
  return res.json();
}


    </pre>
  <p id="uUVT">1.2.4. Используем типобезопасные хуки React Query в компонентах</p>
  <pre id="UM5c">      
import { useQuery } from '@tanstack/react-query';
import { fetchPetById } from './api/fetchPetById';

// ...

function App() {
  // ...
  const { isPending, error, data } = useQuery({
    queryKey: ['pet', petId],
    queryFn: () =&gt; fetchPetById(petId),
  });
  // ...
}


    </pre>
  <h3 id="LoNg">1.3. Пример генерации типов для нативного fetch</h3>
  <p id="Gjr7">Полный код рабочего примера на github</p>
  <p id="VJ3f">Как это работает детально:</p>
  <p id="5KIH">1.3.1. Генерируем типы из OpenAPI спецификации через npm команду</p>
  <pre id="y96R">      
// ...
"scripts": {
  //...
  "generate-types": "dtsgen --out src/api/types/generated-api-types.d.ts specs/prepared-openapi.json",
  //...
}
// ...


    </pre>
  <p id="we6s">1.3.2. Генерируются файлы с API типами</p>
  <pre id="K0H8">      
// ...

declare namespace Components {
    namespace Schemas {
        export interface Pet {
            id?: number; // int64
            name: string;
            photoUrls: string[];
            status?: "available" | "pending" | "sold";
            category?: Category;
            tags?: Tag[];
        }
    }
}

declare namespace Paths {
    namespace GetPetById {
        export interface PathParameters {
            petId: number; // int64
        }
        namespace Responses {
            export type $200 = Components.Schemas.Pet;
            export interface $404 {}
        }
    }
}

// ...


    </pre>
  <p id="aEkP">1.3.3. Создаём типобезопасную обёртку над нативным fetch с использованием сгенерированных типов файл</p>
  <pre id="8OCs">      
/// &lt;reference path="./types/generated-api-types.d.ts" /&gt;

export class ApiClient {
  private baseUrl: string;

  constructor(baseUrl: string) {
    this.baseUrl = baseUrl;
  }

  async get&lt;T, E = unknown&gt;(path: string, options?: RequestInit): Promise&lt;ApiResult&lt;T, E&gt;&gt; {
    const url = `${this.baseUrl}${path}`;
    const res = await fetch(url, { ...options, method: 'GET' });
    if (!res.ok) {
      const error = new ApiError(res.statusText, res.status);
      return [error, null];
    }
    const data = (await res.json()) as T;
    return [null, data];
  }

  // Специфичный метод с использованием сгенерированных типов
  async fetchPetByIdSafe(petId: number): Promise&lt;ApiResult&lt;Paths.GetPetById.Responses.$200, Paths.GetPetById.Responses.$404&gt;&gt; {
    return this.get&lt;Paths.GetPetById.Responses.$200, Paths.GetPetById.Responses.$404&gt;(`/api/v3/pet/${petId}`);
  }
}


    </pre>
  <p id="7IRz">1.3.4. Используем типобезопасный API клиент в React компонентах файл</p>
  <pre id="yBtf">      
import { ApiPet, ApiTag } from './api/types/models';
import { ApiClient, ApiError } from './api/client';

// ...
const apiClient = new ApiClient('http://localhost:4010');
// ...

function App() {
  // ...

  const [data, setData] = useState&lt;ApiPet | null&gt;(null);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState&lt;string | null&gt;(null);

  // ...
 
  useEffect(() =&gt; {
    const fetchPet = async () =&gt; {
      setIsLoading(true);
      setError(null);
      const [err, pet] = await apiClient.fetchPetByIdSafe(petId);
      if (err) {
        setError(err.message);
      } else {
        setData(pet);
      }
      setIsLoading(false);
    };

    fetchPet();
  }, [petId]);

  // ...
}


    </pre>
  <h3 id="atiX">2. Генерация API-клиентов</h3>
  <p id="TNmW">Из OpenAPI спецификации можно генерировать готовые API-клиенты.</p>
  <p id="FMQD">Не нужно вручную писать fetch/axios запросы, автоматическая обработка headers, query параметров.</p>
  <p id="xmBy">Примеры инструментов:</p>
  <ul>
    <li><strong>orval</strong> - Генерирует React Query/TanStack Query, Axios/Fetch клиенты с типами из OpenAPI v3/Swagger v2.</li>
    <li><strong>Hey API (@hey-api/openapi-ts)</strong> - CLI для клиентов (Fetch, Axios, Angular, Next/Nuxt), SDK, валидаторов; OpenAPI 2/3/3.1 с плагинами.</li>
    <li><strong>openapi-typescript-codegen</strong> - Генерирует Axios/Fetch клиенты с типами, поддержка JSON/YAML, external refs</li>
    <li><strong>swagger-typescript-api</strong> - CLI для Fetch/Axios API клиентов из Swagger/OpenAPI</li>
    <li><strong>@openapitools</strong><strong>/openapi-generator-cli</strong> - Универсальный CLI для SDK в 50+ языках/фреймворках (TS-axios, fetch и др.).</li>
    <li><strong>@rtk-query</strong><strong>/codegen-openapi</strong> - Генерирует RTK Query (Redux Toolkit) хуки/endpoint’ы из OpenAPI.</li>
    <li><strong>openapi-fetch</strong> - Легкий (6kb) type-safe Fetch-клиент на runtime (с типами из openapi-typescript)</li>
  </ul>
  <h3 id="sI0r">2.1. Пример сгенерированного клиента с использованием orval</h3>
  <p id="suFX">Полный код рабочего примера на github</p>
  <p id="PXVF">Как это работает детально:</p>
  <p id="EBj0">2.1.1. Пишем конфигурацию</p>
  <pre id="csZX">      
export default defineConfig({
  api: {
    input: {
      target: './specs/prepared-openapi.json',
    },
    output: {
      target: './src/api/generated/api.ts',
      client: 'fetch',
      mode: 'tags',
      baseUrl: 'http://localhost:4010',
    },
  },
});


    </pre>
  <p id="OSYR">2.1.2. Запускаем генерацию через npm комманду</p>
  <pre id="V9eA">      
// ...

"scripts": {
  //...
  "generate-types": "orval",
  //...
}

// ...


    </pre>
  <p id="jVLo">2.1.3. Генерируются файлы с api клиентами</p>
  <pre id="JoV5">      
// ...

export const getPetById = async (petId: number, options?: RequestInit): Promise&lt;getPetByIdResponse&gt; =&gt; {
   // ...
   const res = await fetch(getGetPetByIdUrl(petId));
   // ...
}

// ...


    </pre>
  <p id="6dUT">2.1.4. Генерируются файлы с api типами</p>
  <pre id="p1v8">      
// ...

export interface Pet {
  category?: Category;
  id?: number;
  name: string;
  photoUrls: string[];
  /** pet status in the store */
  status?: PetStatus;
  tags?: Tag[];
}

// ...


    </pre>
  <h3 id="2H5G">3. Типизация endpoints в BFF-прокси на Node.js с Express</h3>
  <p id="csdD">Автоматическая типизация запросов/ответов (генерируя общие TypeScript-типы из API-контрактов из OpenAPI спецификации), исключает ошибки парсинга данных на фронте и упрощает интеграцию с React/TypeScript без ручного описания интерфейсов. Фронтенд получает “глупый” клиент с полной автодополняемостью и валидацией на этапе компиляции, снижая время на отладку API-интеграций. Это особенно полезно в микросервисах, где BFF адаптирует backend-API под нужды UI.</p>
  <h3 id="UnTM">3.1. Пример типизации express endpoints через общие TypeScript-типы</h3>
  <p id="ozlS">Полный код рабочего примера на github</p>
  <p id="8Alu">Как это работает:</p>
  <p id="uMUf">3.1.1. Генерируем типы из OpenAPI спецификации через npm команду</p>
  <pre id="Tpfe">      
"scripts": {
  //...
  "generate-types": "dtsgen --out src/services/petstore/generated-api-types.d.ts specs/prepared-petstore.json",
  //...
}


    </pre>
  <p id="4MOF">3.1.2. Генерируются файлы с API типами</p>
  <pre id="4RFn">      
// ...

declare namespace PetstoreComponents {
    namespace Schemas {
    	// ...
    	export interface Order {
            complete?: boolean;
            /**
             * example:
             * 10
             */
            id?: number; // int64
            /**
             * example:
             * 198772
             */
            petId?: number; // int64
            /**
             * example:
             * 7
             */
            quantity?: number; // int32
            shipDate?: string; // date-time
            /**
             * Order Status
             * example:
             * approved
             */
            status?: "placed" | "approved" | "delivered";
        }
    	// ...
    }
}

declare namespace PetstorePaths {
	// ...
    namespace StoreOrder$OrderId {
        namespace Get {
            namespace Parameters {
                export type OrderId = number; // int64
            }
            export interface PathParameters {
                orderId: Parameters.OrderId /* int64 */;
            }
            namespace Responses {
                export type $200 = PetstoreComponents.Schemas.Order;
                export interface $400 {
                    id?: number; // int64
                }
                export interface $404 {
                    message?: string;
                }
            }
        }
    }
    // ...
}


    </pre>
  <p id="Vf03">3.1.3. Определяем типобезопасный контроллер через интерфейс</p>
  <pre id="ko1Y">TypedController</pre>
  <pre id="Ymqe">      
// ...
export type TypedController&lt;T extends ExpressRouteShape&gt; = (
    request: TypedRequest&lt;T&gt;,
    response: TypedResponse&lt;T&gt;,
    next: TypedNext&lt;T&gt;,
) =&gt; Promise&lt;void&gt; | void;
// ...


    </pre>
  <p id="IjVX">3.1.4. Создаём типобезопасные контроллеры с использованием сгенерированных типов</p>
  <pre id="AELM">      
// Определяем тип маршрута с параметрами запроса и возможными ответами

type GetOrderByIdRoute = {
  request: {
    params: {
      orderId: PetstorePaths.StoreOrder$OrderId.Get.Parameters.OrderId;
    };
  };
  responses: {
    success: PetstorePaths.StoreOrder$OrderId.Get.Responses.$200;
    error:
        | PetstorePaths.StoreOrder$OrderId.Get.Responses.$400
        | PetstorePaths.StoreOrder$OrderId.Get.Responses.$404; 
  };
}

// ...

// Типизированный контроллер для маршрута GetOrderById
const getOrderByIdRouteTypedController: TypedController&lt;GetOrderByIdRoute&gt; = async (request, response, next) =&gt; {
	// ...

	const responseMock = await getMockFromOpenApi&lt;PetstorePaths.StoreOrder.Post.Responses.$200&gt;('post', '/store/order', '200');
    
	// ...
    response.result = responseMock;
    next();

    // ...

    // Example: not valid response body
    // Intentionally assign an invalid object to demonstrate TypeScript error checking
    // @ts-expect-error: TS2322: Type '{ foo: string; }' is not assignable to type 'Order'. Object literal may only specify known properties, and 'foo' does not exist in type 'Order'.
    response.result = { foo: 'bar'};
}

// Регистрируем маршрут с типобезопасным контроллером
app.get('/store/order/:orderId', getOrderByIdRouteTypedController);


    </pre>
  <p id="xTFj">3.1.5. Примеры типобезопасных контроллеров для разных типов запросов:</p>
  <p id="yE5l"><strong>POST запрос с телом:</strong></p>
  <pre id="8Qgf">      
type PlaceOrderRoute = {
    request: {
        body: PetstorePaths.StoreOrder.Post.RequestBody;
    };
    responses: {
        success: PetstorePaths.StoreOrder.Post.Responses.$200;
        error:
            | PetstorePaths.StoreOrder.Post.Responses.$400
            | PetstorePaths.StoreOrder.Post.Responses.$422;
    };
}

const placeOrderRouteTypedController: TypedController&lt;PlaceOrderRoute&gt; = async (request, response, next) =&gt; {
    const order = request.body; // order типизирован как PetstoreComponents.Schemas.Order
    // ...
}


    </pre>
  <p id="xEyL"><strong>GET запрос с query параметрами:</strong></p>
  <pre id="DQ52">      
type LoginUserRoute = {
    request: {
        query: PetstorePaths.UserLogin.Get.QueryParameters;
    };
    responses: {
        success: PetstorePaths.UserLogin.Get.Responses.$200;
        error: PetstorePaths.UserLogin.Get.Responses.$400;
    };
}

const loginUserRouteTypedController: TypedController&lt;LoginUserRoute&gt; = async (request, response, next) =&gt; {
    const username = request.query.username; // типизирован как string | undefined
    const password = request.query.password; // типизирован как string | undefined
    // ...
}


    </pre>
  <p id="OgoM">Примеры других инструментов:</p>
  <ul>
    <li><strong>tRPC</strong> - Полная end-to-end типизация без схем; интегрируется с Express, идеален для React+BFF; типы генерируются автоматически из роутеров</li>
    <li><strong>routing-controllers</strong> - Декораторы для классов-контроллеров в Express; типизирует handlers с @Get/@Post, поддерживает валидацию</li>
  </ul>
  <h3 id="BCok">4. Валидация в обработчиках (handlers) Express</h3>
  <p id="aNDn">OpenAPI спецификация значительно упрощает валидацию в обработчиках (handlers) Express, обеспечивая автоматическую проверку запросов и ответов на соответствие заранее определённым схемам.</p>
  <p id="PPpw">Примеры библиотек:</p>
  <ul>
    <li><strong>express-openapi-validator</strong> - предоставляет Express middleware для автоматической валидации запросов, ответов (JSON) и security по OpenAPI 3.0/3.1</li>
    <li><strong>openapi-request-validator</strong> - валидирует свойства запросов (query, body, headers, params) против OpenAPI схем, работает standalone</li>
    <li><strong>openapi-response-validator</strong> - проверяет HTTP-ответы по responses схемам OpenAPI, standalone для тестирования или runtime</li>
  </ul>
  <h3 id="U57H">4.1. Пример валидации запросов</h3>
  <p id="gbWX">Полный код рабочего примера на github</p>
  <p id="4Y1R">Как это работает:</p>
  <pre id="Ew0V">      
app.use(
  OpenApiValidator.middleware({
    apiSpec: openApiSpec,
    validateRequests: true,
    validateResponses: true,
  }),
);


    </pre>
  <h3 id="TEho">5. Генерация моковых/sample данных/сущностей для разработки и интеграционных тестов</h3>
  <p id="TDBJ">OpenApi спецификация может использоваться для автоматической генерации сущностей и моделей данных, которые можно использовать в интеграционных тестах, обеспечивая их согласованность с контрактом API и ускоряя QA-процессы.</p>
  <p id="zZQW">Примеры инструментов:</p>
  <ul>
    <li><strong>openapi-sampler</strong> - генерирует детерминированные примеры JSON из OpenAPI Schema Objects (3.0/3.1), учитывая examples, enum, default, constraints и compound keywords как allOf/oneOf/anyOf</li>
    <li><strong>@openapi-contrib</strong><strong>/openapi-schema-to-json-schema </strong> + <strong>json-schema-faker</strong> - @openapi-contrib/openapi-schema-to-json-schema конвертирует OpenAPI Schema/Parameter в JSON Schema Draft 4 (удаляет nullable → type: [‘null’], поддержка nested allOf), для совместимости с JSON Schema инструментами. json-schema-faker (jsf) генерирует случайные (не детерминированные) данные из JSON Schema Draft 4+.</li>
  </ul>
  <h3 id="KY5q">5.1. Пример генерации моковых данных</h3>
  <p id="AcFC">Полный код рабочего примера на github</p>
  <p id="i8bM">Генерация моковых/sample данных основана на конвертации OpenAPI схем в JSON Schema и использовании <strong>json-schema-faker</strong></p>
  <ul>
    <li><strong>@openapi-contrib</strong><strong>/openapi-schema-to-json-schema</strong> – конвертирует OpenAPI схемы в JSON Schema</li>
    <li><strong>@apidevtools</strong><strong>/json-schema-ref-parser</strong> – разрешает все $ref ссылки в OpenAPI спецификации</li>
    <li><strong>json-schema-faker</strong> – генерирует валидные данные на основе JSON Schema</li>
  </ul>
  <p id="h9VP">Как это работает детально:</p>
  <pre id="AbJ1">      
// Утилита для генерации sample объекта для сущности из OpenAPI спецификации
// ...
async function createEntitySampleFromOpenApi&lt;T&gt;(entityName: string): Promise&lt;T&gt; {
  // Загружаем OpenAPI спецификацию
  const specPath = path.join(__dirname, '../specs/petstore.json');
  const spec = JSON.parse(fs.readFileSync(specPath, 'utf-8'));
  // Разрешаем все $ref ссылки
  const dereferenced: any = await RefParser.dereference(spec);
  // Находим схему для указанной сущности/компонента
  const entitySchema = dereferenced.components.schemas[entityName];
  if (!entitySchema) throw new Error('Component not found');
  // Конвертируем OpenAPI схему в JSON Schema
  const jsonSchema = openapiSchemaToJsonSchema(entitySchema);
  // Генерируем sample объект, соответствующий схеме
  return jsf.generate(jsonSchema) as T;
}

// Использование в тестах
it('should generate a valid Pet object', async () =&gt; {
  // Генерируем sample объект для сущности 'Pet', определённой в OpenAPI спецификации
  const sample = await createEntitySampleFromOpenApi&lt;PetstoreComponents.Schemas.Pet&gt;('Pet');
  // sample типизирован как PetstoreComponents.Schemas.Pet
  expect(sample).toBeTruthy();
});


    </pre>
  <pre id="eAZx">      
// Утилита для генерации sample ответа для endpoint'а из OpenAPI спецификации
async function createEndpointResponseSampleFromOpenApi&lt;T&gt;(method: string, endpointPath: string, statusCode: string): Promise&lt;T&gt; {
  // Загружаем OpenAPI спецификацию
  const specPath = path.join(__dirname, '../specs/petstore.json');
  const spec = JSON.parse(fs.readFileSync(specPath, 'utf-8'));
  // Разрешаем все $ref ссылки
  const dereferenced: any = await RefParser.dereference(spec);
  // Находим указанный endpoint
  const pathItem = dereferenced.paths[endpointPath];
  if (!pathItem) throw new Error('Path not found');
  // Находим указанный HTTP метод
  const op = pathItem[method.toLowerCase()];
  if (!op) throw new Error('Method not found');
  // Извлекаем ответ для указанного статус кода
  const resp = op.responses &amp;&amp; op.responses[statusCode];
  if (!resp) throw new Error('Status code not found');
  // Получаем JSON схему для ответа
  const content = resp.content &amp;&amp; resp.content['application/json'];
  if (!content || !content.schema) throw new Error('Response schema not found');
  // Конвертируем OpenAPI схему в JSON Schema
  const jsonSchema = openapiSchemaToJsonSchema(content.schema);
  // Генерируем sample объект, соответствующий схеме
  return jsf.generate(jsonSchema) as T;
}

// Использование в тестах
it('should generate a valid endpoint response', async () =&gt; {
  // Генерируем sample ответ для GET /pet/{petId} endpoint со статусом 200
  const sampleResponse = await createEndpointResponseSampleFromOpenApi&lt;PetstorePaths.Pet$PetId.Get.Responses.$200&gt;(
    'get', 
    '/pet/{petId}', 
    '200'
  );
  // sampleResponse типизирован как PetstorePaths.Pet$PetId.Get.Responses.$200
  expect(sampleResponse).toBeTruthy();
});


    </pre>
  <pre id="dset">      
// Утилита для генерации моковых данных из OpenAPI (используется в контроллерах)
async function getMockFromOpenApi&lt;T&gt;(method: string, endpointPath: string, statusCode: string): Promise&lt;T&gt; {
  const specPath = path.join(__dirname, '../specs/petstore.json');
  const spec = JSON.parse(fs.readFileSync(specPath, 'utf-8'));
  const dereferenced: any = await RefParser.dereference(spec);
  const pathItem = dereferenced.paths[endpointPath];
  const op = pathItem[method.toLowerCase()];
  const resp = op.responses &amp;&amp; op.responses[statusCode];
  const content = resp.content &amp;&amp; resp.content['application/json'];
  const jsonSchema = openapiSchemaToJsonSchema(content.schema);
  return jsf.generate(jsonSchema) as T;
}

// Использование в контроллере
const getOrderByIdRouteTypedController: TypedController&lt;GetOrderByIdRoute&gt; = async (request, response, next) =&gt; {
    // ...

  // Генерируем моковый ответ для успешного ответа 200
  const responseMock = await getMockFromOpenApi&lt;PetstorePaths.StoreOrder$OrderId.Get.Responses.$200&gt;(
    'get', 
    '/store/order/{orderId}', 
    '200'
  );
  response.result = responseMock; // типобезопасно
  next();

// ...

  // Example 4: valid error body
  // Generate a mock error response for a 400 error using OpenAPI types
  const responseErrorMock = await getMockFromOpenApi&lt;PetstorePaths.StoreOrder$OrderId.Get.Responses.$400&gt;('post', '/store/order/{orderId}', '400');
    next(
        new ServiceResponseError({
            statusCode: 400,
            body: responseErrorMock
        }),
    );

    // ...
};




    </pre>
  <h3 id="e5GT">6. Создание mock-серверов</h3>
  <p id="xioi">OpenAPI спецификация значительно упрощает создание mock-серверов, позволяя автоматически генерировать имитацию API на основе описанных эндпоинтов, схем данных и примеров ответов. Инструменты вроде MockServer, Smogger или Swagger Mock загружают спецификацию (в формате YAML/JSON) и динамически создают сервер, который возвращает реалистичные мок-ответы с рандомными данными по схемам, без необходимости писать код вручную. Это ускоряет frontend-разработку, тестирование и интеграцию, пока реальный бэкенд не готов, обеспечивая валидацию запросов и поддержку всех HTTP-методов из спецификации.</p>
  <p id="M1FW">Примеры инструментов:</p>
  <ul>
    <li><strong>Prism (/prism-cli)</strong> запускает полноценный HTTP mock/proxy-сервер из OpenAPI 2/3 или Postman, с динамическими ответами, валидацией и transformations</li>
    <li><strong>msw</strong> + <strong>msw-auto-mock</strong> - перехватывает запросы в браузере/Node (Service Worker), генерируя TS-моки из OpenAPI с faker.js данными для dev-тестов</li>
    <li><strong>json-server</strong> - создаёт REST API из JSON-файла (db.json), с CRUD, auth-плагинами (json-server-auth), но без native OpenAPI — нужны кастомные плагины для схем</li>
  </ul>
  <h3 id="KQqv">6.1. Пример запуска mock-сервера</h3>
  <p id="b7dG">Полный код рабочего примера на github</p>
  <p id="eKR6">Как это работает детально:</p>
  <p id="w9qg">Запускаем mock-сервер с помощью <strong>Prism CLI</strong></p>
  <pre id="u323">      
"scripts": {
  //...
  "mock:api": "prism mock specs/prepared-openapi.json -h 0.0.0.0 -p 4010 --cors",
  //...
}

// npm run mock:api


    </pre>
  <p id="mtl2">Mock-сервер:</p>
  <ul>
    <li>Читает OpenAPI спецификацию из specs/prepared-openapi.json</li>
    <li>Автоматически генерирует валидные ответы на основе схем из спецификации</li>
    <li>Запускается на</li>
    <li>Поддерживает CORS для работы с фронтенд-приложением</li>
    <li>Эмулирует все endpoint’ы, определённые в OpenAPI спецификации</li>
  </ul>
  <h2 id="qMU3">Возникающие типичные проблемы и как их решить</h2>
  <p id="tPDc">Фронтендеры часто сталкиваются с тем, что <strong>OpenAPI документация от backend-команды неидеальна</strong> и мешает эффективной работе:</p>
  <ul>
    <li>допускаются банальные опечатки, если бэкендеры пишут спецификацию вручную;</li>
    <li>стандарт OpenAPI формальный, из-за этого иногда спецификацию пишут так, что ломаются интеграции с инструментами (например, из-за особенностей написания спецификации перестаёт работать генерация типов);</li>
    <li>если бэкендеры генерируют спецификацию из кода, она может формироваться так, что ломаются интеграции с инструментами работы со спецификацией — возникает дилемма: либо дорабатывать фронтенд-инструменты, либо исправлять генерацию спецификации на бэкенде.</li>
  </ul>
  <p id="hnE1">В результате часть автоматизации теряет смысл, требуя ручных правок и уточнений.</p>
  <p id="3eOL">Есть несколько способов решить эту проблему:</p>
  <h3 id="iLPg">1. Валидировать OpenApi спецификацию</h3>
  <p id="yQiE">Инструменты для валидации OpenApi спецификации:</p>
  <ul>
    <li>Redocly CLI выполняет linting с рекомендациями по улучшению, поддерживает OpenAPI 3.x/2.0, bundling, документацию и API contract testing через Respect.</li>
    <li>Spectral — это линтер на основе правил (rulesets/functions), ориентированный на OpenAPI, AsyncAPI и другие JSON/YAML, с высокой кастомизацией.</li>
    <li>Swagger CLI в основном валидирует схему OpenAPI 3.0/Swagger 2.0 плюс базовые проверки (дубликаты, MIME-типы), но устарел и не развивается</li>
  </ul>
  <p id="Xpaj">При помощи валидации спецификации можно вовремя заметить ломающие изменения, но это не решает проблему неидеальности OpenAPI-спецификации.</p>
  <h3 id="1NCq">2. Подготавливать OpenApi спецификацию</h3>
  <p id="Ui7F">У Redocly CLI для модификации спецификации используется функция decorators, которая позволяет трансформировать документ (добавлять примеры, описания или изменять URL).</p>
  <p id="iq7Q">Decorators подходят для простых трансформаций (удаление эндпоинтов, добавление примеров), но для сложных случаев требуют custom plugins на JS, что усложняет поддержку.</p>
  <p id="VBAQ">Для упрощения подготовки OpenAPI-спецификации можно использовать openapi-modifier — он содержит широкий набор простых правил для трансформаций, которые применяются последовательно (собираются в pipeline). Таким образом можно строить сложные трансформации.</p>
  <pre id="Q0fL">      
module.exports = {
  input: "./openapi.yaml",
  output: "./openapi.mod.yaml",
  logger: { verbose: true },
  pipeline: [
    // JIRA-232: добавляем ручки новой фичи
    { rule: "merge-openapi-spec", config: { path: "docs/feature-JIRA-232.yaml" } },

    // JIRA-8752: исправляем тип id, сохраняя остальные поля Pet
    { rule: "patch-component-schema", config: { descriptor: "Pet.id", patchMethod: "merge", schemaDiff: { type: "string", format: "uuid" } } },

    // Нормализуем форматы перед изменением JSON-ответа
    { rule: "change-content-type", config: { map: { "*/*": "application/json" } } },
    { rule: "filter-by-content-type", config: { disabled: ["multipart/form-data"] } },

    // Исправляем статусы в ответе — массиве объектов без $ref
    { rule: "patch-endpoint-response-schema", config: { endpointDescriptor: "GET /v1/pets", code: "200", contentType: "application/json", correction: "[].status", patchMethod: "merge", schemaDiff: { enum: ["active", "archived"] } } },

    // Нормализация под генераторы
    { rule: "remove-operation-id" },
    { rule: "remove-min-items" },

    // Убираем deprecated ручки
    { rule: "remove-deprecated", config: { showDeprecatedDescriptions: true } },
    // Убираем внутренние ручки
    { rule: "filter-endpoints", config: { disabledPathRegExp: [/^\/internal(?:\/|$)/] } },
    // Удаляем неиспользуемые компоненты
    { rule: "remove-unused-components", config: { printDeletedComponents: true } },
  ],
};


    </pre>
  <p id="rfwC">openapi-modifier позволяет держать спецификацию в пригодном для кодогенерации состоянии независимо от темпа и качества исходной документации, а знания об исправлениях хранить декларативно, прозрачно и воспроизводимо.</p>
  <h3 id="bx2M">Типичные ситуации и как их решить:</h3>
  <p id="Qe8G">Ниже — независимые примеры конфигураций. Имена моделей, полей и пути условные; замените их на свои. Входной и выходной файлы можно передать через CLI:</p>
  <pre id="nvuH">npx openapi-modifier --input openapi.yaml --output prepared-openapi.yaml --config openapi-modifier.config.js</pre>
  <p id="RIVj">.</p>
  <ul>
    <li>Бэкенд указал неверный тип int вместо integer, что ломает кодогенерацию TS-типов или API-клиентов Пример конфигурации:</li>
  </ul>
  <pre id="fCnB">      
module.exports = {
  pipeline: [{
    rule: 'patch-component-schema',
    config: {
      // Исправляем тип поля id в модели Pet.
      descriptor: 'Pet.id',
      patchMethod: 'merge',
      schemaDiff: { type: 'integer' },
    },
  }],
};


    </pre>
  <ul>
    <li>Бэкенд допустил опечатку в формате поля, например указал datetime вместо date-time Пример конфигурации:</li>
  </ul>
  <pre id="j6U3">      
module.exports = {
  pipeline: [{
    rule: 'patch-component-schema',
    config: {
      // Исправляем формат даты в поле createdAt.
      descriptor: 'Pet.createdAt',
      patchMethod: 'merge',
      schemaDiff: { type: 'string', format: 'date-time' },
    },
  }],
};


    </pre>
  <ul>
    <li>Бэкенд забыл указать "type": "object" в схеме модели, что ломает кодогенерацию TS-типов или API-клиентов Пример конфигурации:</li>
  </ul>
  <pre id="22Jv">      
module.exports = {
  pipeline: [{
    rule: 'patch-component-schema',
    config: {
      // Указываем пропущенный тип модели Pet.
      descriptor: 'Pet',
      patchMethod: 'merge',
      schemaDiff: { type: 'object' },
    },
  }],
};


    </pre>
  <ul>
    <li>Бэкенд на проде находится под другой прокси, и нужно поменять basepath для всех endpoint в спецификации Пример конфигурации:</li>
  </ul>
  <pre id="w1Bv">      
module.exports = {
  pipeline: [{
    rule: 'change-endpoints-basepath',
    // /pets → /api/v1/pets; меняются ключи paths, а не servers.
    config: { map: { '/': '/api/v1/' } },
  }],
};


    </pre>
  <ul>
    <li>Мешают minItems и maxItems для генерации типов или API-клиентов Пример конфигурации:</li>
  </ul>
  <pre id="Twz9">      
module.exports = {
  pipeline: [
    // Убираем ограничение на минимальную длину массивов.
    { rule: 'remove-min-items' },
    // Убираем ограничение на максимальную длину массивов.
    { rule: 'remove-max-items' },
  ],
};


    </pre>
  <ul>
    <li>Мешает operationId для генерации типов или API-клиентов Пример конфигурации:</li>
  </ul>
  <pre id="8is9">      
module.exports = {
  pipeline: [
    // Удаляем operationId у всех операций.
    { rule: 'remove-operation-id' },
  ],
};


    </pre>
  <ul>
    <li>Нужно убрать лишние варианты ответов application/xml и переписать <em>/</em> на application/json для генерации типов или API-клиентов Пример конфигурации:</li>
  </ul>
  <pre id="vAUv">      
module.exports = {
  pipeline: [
    {
      // Убираем XML из запросов и ответов.
      rule: 'filter-by-content-type',
      config: { disabled: ['application/xml'] },
    },
    {
      // Заменяем */* на application/json.
      rule: 'change-content-type',
      config: { map: { '*/*': 'application/json' } },
    },
  ],
};


    </pre>
  <ul>
    <li>Не нужны все ручки из OpenAPI-спецификации, к примеру, нужно убрать /private/* ручки из документации для фронтенда. Это не ограничивает доступ к реальным эндпоинтам Пример конфигурации:</li>
  </ul>
  <pre id="8edt">      
module.exports = {
  pipeline: [{
    // Исключаем /private и все пути внутри /private/.
    rule: 'filter-endpoints',
    config: { disabledPathRegExp: [/^\/private(?:\/|$)/] },
  }],
};


    </pre>
  <ul>
    <li>Параметр или свойство метода скоро исчезнет (оно еще не помечено как deprecated в документации), и нужно отредактировать спецификацию, чтобы после перегенерации типов или API-клиентов TypeScript подсказал, где нужно убрать его использование, и дополнительно не допустить повторного его использования Пример конфигурации:</li>
  </ul>
  <pre id="Mefr">      
module.exports = {
  pipeline: [{
    // Удаляем query-параметр version у GET /pets.
    rule: 'remove-parameter',
    config: {
      endpointDescriptor: 'GET /pets',
      parameterDescriptor: { name: 'version', in: 'query' },
    },
  }],
};


    </pre>
  <ul>
    <li>Параметр или свойство метода скоро появится, и нужно добавить его, но в спецификации бэкенда его еще нет, а разработку фронтенда нужно начинать — нужно отредактировать спецификацию, чтобы после перегенерации типов или API-клиентов новый параметр появился в них Пример конфигурации:</li>
  </ul>
  <pre id="3dpL">      
module.exports = {
  pipeline: [{
    rule: 'patch-component-schema',
    config: {
      descriptor: 'Pet',
      patchMethod: 'deepmerge',
      schemaDiff: {
        // Добавляем необязательное поле color, сохраняя остальные поля.
        properties: { color: { type: 'string' } },
      },
    },
  }],
};


    </pre>
  <p id="YEzf">Помимо данных правил, есть еще правила:</p>
  <ul>
    <li>удаление deprecated компонентов, эндпоинтов, параметров и свойств (см. правило remove-deprecated) - позволяет вовремя замечать устаревания используемых элементов спецификации, например, бекендеры помечают элементы deprecated флагом - фронтендеры, замечая, что они пропадают с openapi вовремя замечают и съезжают с них</li>
    <li>удаляющее неиспользуемые компоненты/cхемы, на которые нет ссылок или ref’ов (см. правило remove-unused-components) - позволяет очистить спецификацию от неиспользуемых элементов спецификации, к примеру, если были отфильтрованы ручки и остались неиспользуемые компоненты</li>
    <li>объединение нескольких спецификаций (см. правило merge-openapi-spec) - полезно в ситуации, когда появляется новая фича, документация для ручек которой еще не готова для влития в основную, а параллельную разработку можно начинать.</li>
  </ul>
  <ul>
    <li><strong>Автоматизация</strong> — не нужно вручную править OpenAPI-файлы после каждого обновления</li>
    <li><strong>Исправление опечаток</strong> — быстрое исправление банальных ошибок без ожидания фикса от бэкенда</li>
    <li><strong>Документирование причин</strong> — каждое правило можно снабдить комментарием с номером задачи</li>
    <li><strong>Улучшение коммуникации в команде</strong> — единый источник правды</li>
    <li><strong>Ускорение онбординга новых разработчиков</strong> — самодокументируемый код, все причины правок документированы</li>
    <li><strong>Pipeline-подход</strong> — правила применяются последовательно, можно строить сложные трансформации. Любое правило можно применять несколько раз — дробите фиксы на мелкие осмысленные блоки с комментариями и ссылками на тикеты.</li>
  </ul>
  <hr />
  <p id="pANq"><strong>Кстати</strong>, есть ещё одна интересная находка - VPS в самом центре города. Мы его уже проверили, можем рекомендовать! Ссылка: <a href="https://moremore.pro/?ref=7776677766" target="_blank">moremore.pro</a></p>

]]></content:encoded></item><item><guid isPermaLink="true">https://teletype.in/@gringo342/nt5B2mMS1Jr</guid><link>https://teletype.in/@gringo342/nt5B2mMS1Jr?utm_source=teletype&amp;utm_medium=feed_rss&amp;utm_campaign=gringo342</link><comments>https://teletype.in/@gringo342/nt5B2mMS1Jr?utm_source=teletype&amp;utm_medium=feed_rss&amp;utm_campaign=gringo342#comments</comments><dc:creator>gringo342</dc:creator><title>Модернизация JavaScript-проекта для моделирования физических экспериментов и роль ИИ в этом</title><pubDate>Wed, 11 Nov 2026 12:41:18 GMT</pubDate><description><![CDATA[<img src="https://img2.teletype.in/files/59/74/5974f9e6-143b-46ed-b7ad-e2683c752e44.png"></img>Несколько лет назад с целью изучения языка JavaScript мной была выбрана тема, связанная с симуляцией физических явлений. Поскольку основной задачей было как можно более полное понимание самого языка программирования,…]]></description><content:encoded><![CDATA[
  <h2 id="Abg2">Оглавление</h2>
  <ul>
    <li>Введение и описание</li>
    <li>Описание демонстраций и теория по физике</li>
    <li>Модернизация проекта и использование ИИ Исходное состояние   Переход от модульного паттерна к использованию классов   Создание языковой локализации   Избавление от setTimeout и переход на механизм requestAnimationFrame для создания анимаций   Адаптация под смартфоны и различные разрешения экрана   Создание боковых, сворачивающихся панелей   Создание новой демонстрации по теме “Затухающие колебания пружин”</li>
    <li>Результаты, выводы</li>
  </ul>
  <h2 id="fTCu">Введение и описание</h2>
  <p id="RTYZ">Несколько лет назад с целью изучения языка JavaScript мной была выбрана тема, связанная с симуляцией физических явлений. Поскольку основной задачей было как можно более полное понимание самого языка программирования, было решено отказаться от использования библиотек и фреймворков. Сам проект включал в себя симуляции физических приборов, механических процессов и экспериментов с жидкостями. Подобные явления рассматривались многими на лабораторных работах в средней школе.</p>
  <p id="qBcD">Старый программный код можно найти по ссылке. Предыдущая статья доступна здесь. Также доступен работающий сайт со старыми демонстрациями по этому адресу.</p>
  <figure id="rxlu" class="m_original">
    <img src="https://img2.teletype.in/files/59/74/5974f9e6-143b-46ed-b7ad-e2683c752e44.png" width="900" />
    <figcaption>Скриншот предыдущего проекта</figcaption>
  </figure>
  <p id="xpdn">Позже я вернулся к развитию своего проекта. Моей целью было добавление новых возможностей, новых симуляций, изменение структуры программы с учетом новых возможностей JavaScript, адаптация под смартфоны, а также улучшение интерфейса. Для модернизации проекта и добавления новых функций требовалось не только сохранить поведение моделей, но и улучшить программную архитектуру.</p>
  <p id="NX6Q">Подобные изменения при работе в одиночку занимают массу времени. В подобной ситуации пригодились инструменты искусственного интеллекта, которые смогли помочь отрефакторить код и модернизировать проект за гораздо более короткий промежуток времени.</p>
  <p id="o5m2">В данной статье рассказано, как менялся проект, как использовался ИИ ChatGPT и Cursor и с какими проблемами пришлось столкнуться.</p>
  <h2 id="o3bj">Описание демонстраций и теория по физике</h2>
  <h3 id="nhP1">Нынешняя версия проекта</h3>
  <p id="NrTH">С обновленным проектом можно ознакомиться здесь. Веб-сайт с демонстрациями доступен по этой ссылке.</p>
  <p id="vXbO">Проект представляет собой классическое веб-приложение, в котором используются HTML– страницы, Javascript и CSS файлы без подключения каких-либо сторонних библиотек.</p>
  <p id="Pg59">На панели слева можно выбрать интересующую демонстрацию. Посередине находится пространство для сцен, где отображается физический эксперимент. Здесь пользователь имеет возможность наблюдать за поведением объектов или приборов. Можно также влиять на реакцию моделей, например, перетаскивать и подвешивать физические тела. Сами сцены в программе отображаются на холсте Canvas при помощи JavaScript. На панели справа пользователь может менять физические величины и параметры, применяемые при расчетах в формулах.</p>
  <p id="1eBu">Каждая сцена для демонстрации физических явлений относится к конкретному разделу физики. В программе представлены эксперименты из разделов: механика, механические колебания, гидростатика.</p>
  <h3 id="ChRc">Закон сложения сил под углом к другу</h3>
  <p id="3AKM">Сила, воздействующая на тело, является векторной величиной и измеряется в ньютонах. Когда на тело действуют две силы под углом, их результирующая сила вычисляется по формуле:</p>
  <p id="jTU3">Пользователь демонстрации в проекте может изменять массы грузов и угол между динамометрами для измерения силы. После перетаскивания и подвешивания тела к динамометрам рассчитывается равнодействующая сила и демонстрируется анимация стрелок динамометров.</p>
  <figure id="WDHf" class="m_original">
    <img src="https://img2.teletype.in/files/95/5c/955c2237-2f23-4f18-b67c-f0a4df74f964.png" width="900" />
    <figcaption>Скриншот физической сцены “Сложение сил, направленных под углом друг к другу”</figcaption>
  </figure>
  <h3 id="eVuj">Закон гидростатики и аэростатики</h3>
  <figure id="kwnU" class="m_original">
    <img src="https://img3.teletype.in/files/ad/66/ad66f82a-23d4-49dc-8aaf-56d64edcb36a.png" width="108" />
    <figcaption>Иллюстрация закона Архимеда</figcaption>
  </figure>
  <p id="zl3I">Принцип Архимеда: на тело, погружённое в жидкость или газ, действует выталкивающая сила, равная весу объёма жидкости или газа, вытесненного этим телом. Сила Архимеда высчитывается по формуле:</p>
  <p id="2VI3">Пользователь в проекте может изменять:</p>
  <p id="t0pb">(ро) — плотность жидкости или газа (</p>
  <p id="HgRI">),</p>
  <p id="MPjq">— объём погружённого тела (</p>
  <p id="nEFi">) и вес тела. После перетаскивания и подвеса тела к пружинному динамометру подсчитываются объём тела, сила Архимеда, сила тяжести, а также демонстрируется анимация динамометра.</p>
  <figure id="hFj1" class="m_original">
    <img src="https://img4.teletype.in/files/f2/aa/f2aa78f1-c0e7-4a6c-83a7-a59f3fa42fff.png" width="1008" />
    <figcaption>Скриншот физической сцены “Закон Архимеда”</figcaption>
  </figure>
  <h3 id="PYYk">Математический маятник</h3>
  <figure id="G6IF" class="m_original">
    <img src="https://img2.teletype.in/files/55/d8/55d8b6c3-9b61-4c6d-aa2f-6af0c793abba.png" width="305" />
    <figcaption>Иллюстрация математического маятника</figcaption>
  </figure>
  <p id="pwLr">Математический маятник — это материальная точка, подвешенная на невесомой нерастяжимой нити (идеализированная система). Период колебаний — время, за которое маятник совершает одно полное колебание. Вычисляется по формуле:</p>
  <p id="BNlh">Пользователь в проекте может изменять</p>
  <p id="YMl0">— длину нити и начальный угол колебания, а также видеть результаты подсчёта периода и частоты колебаний.</p>
  <figure id="DLzB" class="m_original">
    <img src="https://img1.teletype.in/files/03/af/03af7438-c53a-4128-becc-15dec79f0ea2.png" width="1009" />
    <figcaption>Скриншот физической сцены “Математический маятник”</figcaption>
  </figure>
  <h3 id="kJnG">Затухающие колебания пружин</h3>
  <figure id="OdXE" class="m_original">
    <img src="https://img4.teletype.in/files/f5/0f/f50f8e98-672d-4e25-8fd2-e852f5462fac.png" width="369" />
    <figcaption>Иллюстрация колебания пружин</figcaption>
  </figure>
  <p id="yofj">Затухающими колебаниями пружин называют свободные колебания, энергия которых под воздействием сил сопротивления уменьшается с течением времени. Для подсчета частоты затухающих колебаний применяется формула:</p>
  <p id="Qq7N">Частота измеряется в герцах (</p>
  <p id="Ta6T">) и используется только для отображения информации пользователю. Для реальной реализации затухающих колебательных движений в коде применяется дифференциальное уравнение движения.</p>
  <p id="7QTq">Пользователь сайта может изменять:</p>
  <p id="1fKn">— жёсткость пружины(</p>
  <p id="QOY8">),</p>
  <p id="iq6s">— массу груза (</p>
  <p id="gVoL">),</p>
  <p id="m8mW">— коэффициент затухания(</p>
  <p id="7K62">). После перетаскивания и подвешивания тела к пружине подсчитывается частота колебаний, а также демонстрируется анимация затухающих колебаний пружины.</p>
  <figure id="AUSI" class="m_original">
    <img src="https://img1.teletype.in/files/88/64/8864a0f6-67b6-48da-940f-de8c78669ca3.png" width="1009" />
    <figcaption>Скриншот физической сцены “Затухающие колебания”</figcaption>
  </figure>
  <h2 id="lOfE">Модернизация проекта и использование ИИ</h2>
  <h3 id="AjDn">Исходное состояние</h3>
  <p id="JQnX">Центральной страницей проекта является</p>
  <pre id="e0EF">index.html</pre>
  <p id="1ZQf">В предыдущей версии</p>
  <pre id="zrY9">index.html </pre>
  <p id="BZAm">подключал объекты JS - кода и скрипты напрямую через</p>
  <pre id="gZUv">&lt;script src&gt;</pre>
  <p id="tfeI">без использования модулей.</p>
  <figure id="aqBJ" class="m_original">
    <img src="https://img3.teletype.in/files/a7/f7/a7f77be5-c985-487c-8e4a-0e5491aae5f5.png" width="758" />
    <figcaption>Общая схема старого проекта</figcaption>
  </figure>
  <p id="vjMq">В старой версии проекта для организации архитектуры использовался классический <em>модульный паттерн</em>, который разбивал js-код на объекты</p>
  <pre id="coMF">const </pre>
  <p id="RNv3">и группировал функции, другие элементы по назначению.</p>
  <p id="nWTA">Логика экспериментов по необходимой физической теме осуществлялась в следующих объектах:</p>
  <pre id="y4JP">appliancesDemo</pre>
  <p id="ALjr"><em>, </em></p>
  <pre id="TPDi">archimedesPrincipleDemo</pre>
  <p id="OLGo"><em>, </em></p>
  <pre id="FKUF">forcesbyAngleDemo</pre>
  <p id="8deV"><em>, </em></p>
  <pre id="cI1n">pendulumDemo </pre>
  <p id="js7J">(например,</p>
  <pre id="hvBd">const archimedesPrincipleDemo = { ... }</pre>
  <p id="sP2o">,</p>
  <pre id="TRa3">const pendulumDemo = { ... } </pre>
  <p id="qAHL">и. т. д.). Внутри этих const-объектов происходила обработка параметров, поступающих от пользователя, последующая компоновка приборов, группировка функций и отрисовка сцены на холсте canvas.</p>
  <p id="QIrt">За группировку логики по перетаскиванию по экрану и группировку функций общего назначения отвечали объекты:</p>
  <pre id="5HnC">dragRendering</pre>
  <p id="TIca"><em>, </em></p>
  <pre id="80Aa">contextLayout</pre>
  <p id="a2Yy">.</p>
  <p id="JoQm">При таком подходе требуется подключение используемых js-файлов в HTML-коде через</p>
  <pre id="n0E3">script src</pre>
  <p id="K8Nj"><em>. </em>Например, приходилось подключать все используемые js-файлы в центральном месте</p>
  <pre id="Bq4D">index.html</pre>
  <p id="0sML"><em>, </em>чтотребовало соблюдения порядка последовательности файлов:</p>
  <pre id="XEm3">      
    &lt;script src="./js/appMethods.js"&gt;&lt;/script&gt;

    &lt;script src="./js/dragging.js"&gt;&lt;/script&gt;

    &lt;script src="./js/topicsRender/forcesbyAngleRender.js"&gt;&lt;/script&gt;

    </pre>
  <h3 id="1sns">Переход от модульного паттерна к использованию классов</h3>
  <p id="4zlx">Предыдущий подход разбиения архитектуры на блоки</p>
  <pre id="T7Xv">const</pre>
  <p id="rbcq">, с сгруппированным функционалом является устаревшим. Это создавало неудобства при поддержке, при внесении изменений или расширении проекта. Кроме того, при применении объектов</p>
  <pre id="vYqs">const </pre>
  <p id="lGyX">неудобно создавать приватные методы, поля для скрытия данных от внешнего использования.</p>
  <p id="7W4R">Для визуализации и создания физических приборов (динамометров, линеек, пружин и.т.д.), которые могут применяться многократно, но с разными параметрами использовался метод <em>функций – конструкторов</em><strong>. </strong>Например, абстрактное устройство для визуализации циферблата со стрелкой без чисел:</p>
  <pre id="CELf">      
function Appliance(options = {}) {
 // Значения по умолчанию 
 Object.assign(this, {
…
  }, options);
…
}

    </pre>
  <p id="viF8">Прибор для измерения силы — динамометр — строился на основании этого абстрактного устройства с добавлением новых свойств. Для этого применялось <em>прототипное наследование</em>. Пример создания стрелочного динамометра и наследование от</p>
  <pre id="omiA">Appliance</pre>
  <p id="jONC">:</p>
  <pre id="uwng">      
function Dynamometer(options) {
  Appliance.call(this, options); // вызов базового конструктора 
 ...
}
Dynamometer.prototype = Object.create(Appliance.prototype); // организация цепочки прототипов
Dynamometer.prototype.constructor = Dynamometer;

Dynamometer.prototype = {
  draw: function () { ... );
}

    </pre>
  <p id="Zssm">В современном JavaScript чаще используется подход использования модулей (</p>
  <pre id="il6w">import/export</pre>
  <p id="d3aE">). Это позволяет экспортировать классы, функции для использования в других JS – файлах. В классах можно организовать код более структурировано и понятно, организовать поддержку приватных полей с помощью встроенного синтаксиса. Для использования экспортированных объектов достаточно прописать , что происходит их импорт на уровне JavaScript кода.</p>
  <p id="k4lE">Иерархия наследования при использовании классов решается более понятным способом, чем с использованием функций-конструкторов. Для этого применяется ключевое слово</p>
  <pre id="4jgz">extends.</pre>
  <p id="xxfO">А чтобы получить доступ к конструктору родителя и его свойствам в классах используется простое использование конструкции</p>
  <pre id="BWlw">super</pre>
  <p id="O1IC">. К примеру, создание объекта</p>
  <pre id="GPkx">Appliance </pre>
  <p id="TGzA">и его наследника</p>
  <pre id="6zG8">Dynamometer</pre>
  <p id="pzo4"><em>, </em>которые были описаны выше, реализуется следующим образом:</p>
  <pre id="GCPk">      
export class Appliance {
  #fieldsAppl; // приватные свойства
  constructor(options = {}) {
    // Свойства значения по умолчанию
    Object.assign(
      this,
      {
        centerX: 0,
        centerY: 0,
        radius: 50,
      },
      options,
    );
  }
  ...
}

// Dynamometer наследует Appliance
export class Dynamometer extends Appliance {
  constructor(options = {}) {
    // наследование конструктора от Appliance
    super(options);
    // Свойства значения по умолчанию для Dynamometer
    Object.assign(
      this,
      {
        maxValue: 10,
      },
      options,
    );
  }
  ...
}

    </pre>
  <p id="EPTp">На этом этапе для решения задачи по переходу на</p>
  <pre id="zxBZ"> import/export</pre>
  <p id="Zweo">использовался <em>ChatGPT </em>вместе с <em>Cursor</em>. <em>Cursor </em>способен исследовать и переделать весь проект целиком. Однако такой объем изменений сложно будет отследить и позже переработать самостоятельно. Поэтому сначала объекты переводились в классы постепенно через <em>ChatGPT</em>.</p>
  <p id="uzpb">Первая проблема, которая возникает, — не получается сделать модульными и настроить <em>import/export </em>объектов в проекте поочередно. Например, для начала, переводится в класс, прописывается <em>export </em>объекта</p>
  <pre id="1dlV">Spring </pre>
  <p id="Kejb">(функционал для отображения пружины из файла <em>"</em><em>Tools/spring.js</em><em>"</em> ). Если использовать</p>
  <pre id="XNkl">Spring </pre>
  <p id="qpOG">внутри другого объекта, который не является модульным, например внутри файла</p>
  <pre id="BiXK">springDynamometer.js</pre>
  <p id="Deov"><em> , </em>топроявляетсяошибка<em> “</em> <em>Cannot use import statement outside a module”. </em></p>
  <p id="gLZQ">То есть нельзя сделать модульным</p>
  <pre id="B5ZU">spring.js</pre>
  <p id="TSOc">, если</p>
  <pre id="o0v2">springDynamometer.js </pre>
  <p id="UGHr">подключается как обычный</p>
  <pre id="ycea">&lt;script src&gt;</pre>
  <p id="tfXN"><em>. </em>Придется переводить все объекты проекта в классы для экспорта одновременно<em>. ChatGPT </em>навел на мысль, что для начала объект можно перевести в класс и при этом сохранить глобальные объекты через</p>
  <pre id="87tW">window</pre>
  <p id="54fs">. Иными словами сначала создается</p>
  <pre id="WOdX">class Spring</pre>
  <p id="BKpM">, после его описания прописывается</p>
  <pre id="6kbo">window.Spring = Spring</pre>
  <p id="9ERN"><em>.</em>При этом нет необходимости использовать</p>
  <pre id="GgZz">export </pre>
  <p id="mlvD">и ломать структуру проекта.</p>
  <p id="bmPV">Такой подход позволил переписать постепенно все объекты из каталога</p>
  <pre id="Jz1r"> js/Tools </pre>
  <p id="tF9w">на классы и привязать к</p>
  <pre id="f1b9">window</pre>
  <p id="9mh3">. После этого все объекты, отвечающие за реализацию логики обработки демонстраций из папки</p>
  <pre id="mZ1H">js/TopicsRender</pre>
  <p id="f2iG"><em>. </em></p>
  <p id="o42Q">Затем, используя <em>Cursor</em>, удалось завершить работу и добиться работающего функционала.</p>
  <h3 id="3E8S">Создание языковой локализации</h3>
  <p id="RZci">Изначально сайт содержал строго закодированные в HTML текстовые значения надписей, заголовков меню, блочных элементов на английском языке. Это усложняло расширение и внесение изменений в структуру HTML, например при смене языка веб-сайта.</p>
  <p id="u80y">Для разрешения возникшей проблемы также использовался <em>ChatGPT </em>одновременно с <em>Cursor</em>.</p>
  <p id="69xe">Чтобы собрать надписи в одном месте, потребовалось создать файлы ресурсов для разных языков. В качестве файла ресурсов можно выбрать JSON-формат. Однако <em>ChatGPT </em>подсказал, что более удобно использовать просто JavaScript-объекты без необходимости асинхронной загрузки JSON – данных.</p>
  <p id="BS5D">Пример части файла</p>
  <pre id="75Az">strings.ru.js</pre>
  <p id="V4GN"><em>:</em></p>
  <pre id="FXzS">      
export default  {
    Title : "Физика",
    SubTitle : "Моделирование",
...
};

    </pre>
  <p id="K9ND">В HTML-коде элементам, которые должны содержать текст из файла ресурсов, должен присваиваться пользовательский атрибут</p>
  <pre id="12hr">data-resource</pre>
  <p id="0svF">(например,</p>
  <pre id="KZKU">&lt;p data-resource="SubTitle"&gt;&lt;/p&gt;</pre>
  <p id="u6qO">). Впоследствии JS-код находит через атрибут значение в JS-объекте. Например, ресурс</p>
  <pre id="1yqf">Panel.Title</pre>
  <p id="IcLy"><em>:</em></p>
  <pre id="xRDT">      
import ru from "../resources/strings.ru.js";
const attribute = "data-resource";

document.querySelectorAll(`[${attribute}]`).forEach((elem) =&gt; {
    elem.innerHTML = ru.Panel.Title;
});

    </pre>
  <p id="TwPC">Итоговая функция, чтобы найти любое значение из файла ресурсов:</p>
  <pre id="ja7I">      
static get(key) {
    const parts = key.split(".");
    let result = Resources.currentLang;
    for (const part of parts) {
      result = result[part];

      if (result === undefined) {
        return key;
      }
    }
    return result;
}

    </pre>
  <p id="gIBM">Дальше через запрос к <em>Cursor </em>были присвоены атрибуты ресурсов во всем проекте и создана локализация.</p>
  <h3 id="ZFZI">Избавление от setTimeout и переход на механизм requestAnimationFrame для создания анимаций</h3>
  <p id="CA7W">Демонстрации подразумевают наблюдение вживую за физическими явлениями и поведением приборов, что требует создание различных анимаций. Например, возможна анимация движения стрелки динамометра или сжатие/разжатие пружины.</p>
  <p id="nlyK">В старой версии приложения уже было предусмотрено использование анимаций с помощью общего механизма для всего проекта</p>
  <pre id="z5Fu">setTimeout</pre>
  <p id="QgZR">. Данная функция, как известно, выполняет какую-либо другую функцию только один раз через заданный интервал времени. В предыдущем проекте использовался рекурсивный</p>
  <pre id="DLP0">setTimeout </pre>
  <p id="IPX6">с асинхронным вызовом через</p>
  <pre id="ox8Z">async/await</pre>
  <p id="bRXu">.</p>
  <p id="j6mQ">Пример анимации стрелки динамометра через</p>
  <pre id="gaHQ">setTimeout</pre>
  <p id="BeZH">:</p>
  <pre id="vMHK">      
const application = {
  timeout: (ms) =&gt; new Promise((resolve) =&gt; setTimeout(resolve, ms)),
}

...

setValue: async function (valuePointer = 0) {
    ...
    async function tick() {
      face(); // отрисовка циферблата
      ...
      // draws value pointer according to condition
      async function setTimer(condition) {
        if (condition) {
          value(currentValue);
          await application.timeout(1); // ожидание 1 мс
          return await tick();
        }
        else {
          redraw(valuePointer);
          value(valuePointer);
          return;
        }
      } 

      if (initValue &lt;= valuePointer) {
        currentValue = application.roundTwoDigits(currentValue + rotateSpeed);
        return await setTimer(currentValue &lt;= valuePointer);
      }
    ...

    } // tick

    return tick(); 
} // setValue

    </pre>
  <p id="lZpo">Если требовалась отмена анимации, могла применяться функция, которая создавала</p>
  <pre id="m5jD">Promise</pre>
  <p id="Je5J">на основе</p>
  <pre id="DW1g">setTimeout</pre>
  <p id="kQAk">и возвращала объект:</p>
  <pre id="zglG">      
timeoutPromise: (ms) =&gt; {
    let timer = 0;
    const promise = new Promise((resolve) =&gt; {
      timer = setTimeout(resolve, ms);
    });
    return { promise, timer };
}
...
let timeout = application.timeoutPromise(1);
if (this.cancelTimer == true) {
    clearTimeout(timeout.timer);
}

    </pre>
  <p id="SiYY">Такой предыдущий подход через</p>
  <pre id="SZ7E">setTimeout</pre>
  <p id="RQAG">имеет ряд недостатков. Анимация получается не привязана к частоте обновления экрана. Это означает, что может возникнуть рассохранизация между тем, как код обновляется на основании таймера, и как отрисовываются кадры на экране монитора. Также скорость анимации может зависеть от браузера: к примеру,</p>
  <pre id="PuiA">setTimeout(1)</pre>
  <p id="nABi">после нескольких вызовов вложенных таймеров может задерживаться до нескольких миллисекунд. Следовательно, такой механизм может использоваться для создания только простых задач по анимации.</p>
  <p id="QEug">Для реализации более плавного анимирования, без задержек и “дерганий”, в данном проекте было решено перейти на современные возможности <em>JavaScript </em>посредством использования</p>
  <pre id="P0eE">requestAnimationFrame</pre>
  <p id="NbUn">.</p>
  <p id="FTCr">Данный метод позволяет более эффективно реализовать анимацию и в качестве параметра принимает функцию (<em>callback</em>), которая вызовется перед следующим вызовом фрейма перерисовки. В отличие от</p>
  <pre id="8a4W">setTimeout() </pre>
  <p id="tMkn">и</p>
  <pre id="d9rh">setInterval()</pre>
  <p id="jhSz"><em>, </em>для</p>
  <pre id="fpCg">requestAnimationFrame </pre>
  <p id="WDxQ">не требуется указыватьвремя, через которое запустится функция. Данный метод возвращает</p>
  <pre id="gRFt">requestID</pre>
  <p id="mKUu"><em>, </em>который служит для отмены анимации через</p>
  <pre id="ZSz1">window.cancelAnimationFrame()</pre>
  <p id="OigO"><em>.</em></p>
  <p id="lLeF">Самой простой идеей по переводу функции</p>
  <pre id="eynM">setValue</pre>
  <p id="91fq">, приведенной выше, к использованию описанного механизмаявляется применение</p>
  <pre id="5qFU">Promise</pre>
  <p id="3tap">, внутри которого применяется</p>
  <pre id="LfxA">requestAnimationFrame</pre>
  <p id="ZNCa">. При достижении необходимых условий</p>
  <pre id="zAWb">Promise </pre>
  <p id="BKfD">возвращает результат и анимация завершается. Однако</p>
  <pre id="egJ9">requestAnimationFrame </pre>
  <p id="MyEL">строго привязывается к частоте экрана, которая отличается на разных устройствах (на смартфонах, на компьютерах).</p>
  <p id="oVf9">Например:</p>
  <pre id="wlhx">      
const tick = () =&gt; {
  currentValue += 1; // всегда прибавляем 1 за кадр
  requestAnimationFrame(tick);
};

    </pre>
  <p id="lJrW">Это означает, что на мониторе с частотой 60 Гц стрелка будет двигаться со скоростью 60 пикселей в секунду, а при частоте 100 Гц – 100 пикселей в секунду.</p>
  <p id="mZlg">Во избежание этого <em>ChatGPT </em>предложил ввести некий коэффициент для гарантии того, что анимация будет длиться всегда одно и то же время, независимо от частоты экрана.</p>
  <p id="rc1p">Итоговая функция по анимированию стрелки динамометра с учетом частоты монитора:</p>
  <pre id="1gaq">      
setValue(valuePointer = 0) {
    const rotateSpeed = this.propsDynam.getRotateStep();
    let currentValue = this.value;
    let previousTime = null;
    ...
    return new Promise((resolve) =&gt; {
      const tick = (currentTime) =&gt; {
        if (previousTime === null) previousTime = currentTime;
        // время между кадрами
        const deltaTime = currentTime - previousTime;
        previousTime = currentTime;

        // Нормирование шага анимации для различной частоты экрана
        const step = rotateSpeed * deltaTime / Application.frameTime; // frameTime = 1000 / 60

        // движение к цели, currentValue += step;
        ...

        // достигли цели
        if (currentValue === valuePointer) {
          this.#animationFrame = null;
          resolve();
          return;
        }

        // следующий кадр
        this.#animationFrame = requestAnimationFrame(tick);
      };

      this.#animationFrame = requestAnimationFrame(tick);
    });
} // setValue

    </pre>
  <p id="qKE8">Через запросы <em>Cursor </em>аналогичный приницип был применен для изменения принципа создания анимации во всем проекте.</p>
  <h3 id="oS6u">Адаптация под смартфоны и различные разрешения экрана</h3>
  <p id="mQVE">В первоначальной версии веб-сайта адаптивный дизайн отсутствовал, и демонстрации отображались корректно только на компьютерных мониторах и ноутбуках. Сайт невозможно было использовать на экранах с маленьким разрешением, например на смартфонах. Чтобы корректно отображать физические демонстрации на различных устройствах, потребовалось не только изменить CSS-дизайн, но и переработать JavaScript-код для создания масштабирования сцен с демонстрациями.</p>
  <h3 id="oocZ">HTML, CSS адаптивность</h3>
  <p id="34Gn">Компоновка элементов на странице реализована в виде таблицы через</p>
  <pre id="gLwk">CSS Grid Layout</pre>
  <p id="uSPb">. Это позволяет работать с раскладкой в виде колонок и рядов. Сам <em>Grid </em>отображается в файле</p>
  <pre id="ofOz">index.html </pre>
  <p id="6a2r">, а его раскладка прописана в файле</p>
  <pre id="Rvqh">main.css.</pre>
  <p id="19Pn">Изначальная верстка предыдущего проекта</p>
  <pre id="uHBw">Grid </pre>
  <p id="jyQb">выглядела следующим образом:</p>
  <pre id="l1mp">      
.grid-container {
  height: 100vh;
  display: grid;
  grid-template-areas:
    ". . headerArea headerArea headerArea . ."
    ". . . . . . ."
    ". menuArea . contentArea . sidebarArea ."
    ". . . . . . ."
    ". footerArea footerArea footerArea footerArea footerArea .";
  grid-template-columns: 10px 14% 15px 1fr 15px 25% 10px;
  grid-template-rows: 55px 5px 1fr 5px 30px;
}

    </pre>
  <p id="efW5">В</p>
  <pre id="IUnq">.grid-container </pre>
  <p id="GUCt">строки такие: строка под заголовок, пустая строка-отступ, пространство под сцену и панели, строка-отступ, нижний блок пространства. Колонки, где происходит взаимодействие с демонстрациями: отступ слева, колонка с меню, пустая колонка, колонка, содержащая</p>
  <pre id="iuk0">Canvas </pre>
  <p id="c42I">для отрисовки, пустая колонка, колонка, содержащая</p>
  <pre id="UF17">iframe </pre>
  <p id="61eO">с параметрами для каждой сцены, отступ справа. Заданные настройки дизайна слабо подходят под адаптивность.</p>
  <p id="YXTH">Чтобы</p>
  <pre id="2ulm">Grid</pre>
  <p id="qDsq">с содержимым подстраивался под разные разрешения, необходимо, чтобы колонки и строки имели не фиксированные размеры, а динамические - в процентах. Также можно задавать настройку дизайна контейнера для разных разрешений экрана и для разных устройств с помощью директивы</p>
  <pre id="56zr">@media</pre>
  <p id="cVqI">. Поведение шрифтов можно настроить через относительные единицы (</p>
  <pre id="Qj0l">vw, vh</pre>
  <p id="D6vk">) и</p>
  <pre id="T5sH">clamp</pre>
  <p id="YMZk">, которая задаёт диапазон значений между нижней и верхней границами. Одной из важных особенностей адаптивной верстки является использование единиц измерения</p>
  <pre id="s0dr">vh </pre>
  <p id="FM1z">и</p>
  <pre id="FbAO">dvh</pre>
  <p id="EX99">. С помощью</p>
  <pre id="UnKi">dvh </pre>
  <p id="KGAu">возможно учитывать высоту в зависимости от того, скрыта или видима адресная строка на смартфонах.</p>
  <p id="HO3d">Зная эти особенности и возможности CSS и используя <em>Cursor </em>удалось добиться корректного отображения веб-сайта на различных устройствах . Чтобы контролировать процесс и понять, какие изменения были внесены, пришлось делать запросы к <em>Cursor </em>поэтапно.</p>
  <p id="9P6q">Вначале через запрос к ИИ было изменено визуальное поведения Grid-контейнера. Чтобы код не разрастался и не создавались множественные CSS-файлы пришлось запросить конкретно: доработать существующий интерфейс минимальными изменениями, не переписывать всю вёрстку и не менять существующий дизайн, по возможности использовать</p>
  <pre id="nk7z">@media</pre>
  <p id="IBFu">для адаптивности.</p>
  <p id="yhJH">Следующим этапом была адаптация самих шрифтов. На этом этапе в запросе было указано: по возможности необходимо использовать</p>
  <pre id="eTdy">clamp</pre>
  <p id="FqRj">, нельзя менять существующие шрифты и оформление, а также избегать появления горизонтальной прокрутки.</p>
  <p id="9oHL">Последним этапом была проверка и корректировка, так как некоторые элементы (например, заголовки, кнопки или текстбоксы) могли съезжать или иметь неправильный размер.</p>
  <p id="eBeu">По итогу, после изменений макет</p>
  <pre id="wrtM">Grid </pre>
  <p id="D2LU">остался прежним (</p>
  <pre id="Wznx">menuArea, headerArea, contentArea</pre>
  <p id="6VRN">…) , но теперь прописывались не фиксированные размеры, а размеры через CSS-переменные:</p>
  <pre id="yjMG">      
grid-template-columns:
    var(--gutter-outer)
    var(--menu-width)
…
grid-template-rows:
    var(--header-height)
    var(--grid-gap)
    var(--stage-height)
…;

    </pre>
  <p id="NMeg">Переменные прописались в</p>
  <pre id="qOXa">@media</pre>
  <p id="fObC">блоке:</p>
  <pre id="Wxvv">      
/* Планшеты  */
@media (max-width: 1024px) {
  :root {
    --menu-width: max(9.5rem, 14%);
    --controls-width: max(12rem, 22%);
...
  }
}

/* смартфоны, повернутые горизонтально  */
@media (orientation: landscape) and (max-height: 500px) {
  :root {
    --footer-height: 12px;
    --grid-gap: 4px;
}

    </pre>
  <p id="hlVu">Пример CSS-переменны<em>х</em> для дизайна шрифтов, кнопок:</p>
  <pre id="ChBW">      
--font-title: clamp(22px, 3.2vw, 40px);
--button-width: clamp(5.5rem, 36vw, 120px);

    </pre>
  <p id="Dt3e">В итоге, сайт корректно показывается на различных устройствах с разным разрешением.</p>
  <h3 id="algF">Адаптивность JavaScript демонстраций</h3>
  <p id="CE7z">Другой проблемой сайта было то, что JavaScript-cцены с демонстрациями экспериментов всегда отображались без учета изменения размеров разрешения экрана и, соответственно, размеров холста с отображенными сценами. Размеры физических приборов, координаты сброса грузов, координаты анимаций — все это имело фиксированные величины и координаты.</p>
  <p id="wqhv">Для подсчета коэффициента масштабирования, который применяется для того, чтобы объекты и демонстрации подстраивались под разные размеры экрана, можно использовать простую функцию:</p>
  <pre id="KinI">      
static applyScale(scene) {
    const scaleX = scene.canvas.width / scene.baseWidth;
    const scaleY = scene.canvas.height / scene.baseHeight;
    scene.stageScale = Math.min(scaleX, scaleY);
    return scene.stageScale;
}

    </pre>
  <p id="Erwk">Впоследствии, применяя Cursor, удалось настроить все демонстрации на использование этого метода.</p>
  <p id="1QEf">Первоначальный размер холста со сценой до применения масштабирования, определяется следующим образом:</p>
  <pre id="0ASu">      
static setBaseSize(scene, baseWidth, baseHeight) {
    scene.baseWidth = baseWidth;
    scene.baseHeight = baseHeight;
    scene.stageScale = 1;
}

    </pre>
  <p id="tmZ6">При начальной загрузке (например,</p>
  <p id="V10x">) каждой сцены применяется <em>init</em></p>
  <pre id="Iwgp">setBaseSize</pre>
  <p id="HwQH">. А при любом изменении размеров сцены уже высчитывается коэффициент масштабирования через</p>
  <pre id="SvM2">applyScale </pre>
  <p id="F2dx">и применяются размеры или настройки:</p>
  <pre id="b4Hl">      
init () {
        CanvasUtils.setBaseSize(
            this,
            900,
            700,
        );
…
resizeAppliances(){
        CanvasUtils.applyScale(this);
        let cst = this.constants;
        this.resizeSpringDynamometer(cst);
        this.resizeRuler(cst);
}
...
resizeRuler(cst) {
        this.ruler.startX = cst.ruler.startX * this.stageScale;
        this.ruler.length = cst.ruler.length  * this.stageScale;
…
}
...

    </pre>
  <h3 id="8z8x">Создание боковых, сворачивающихся панелей</h3>
  <figure id="8mUg" class="m_original">
    <img src="https://img2.teletype.in/files/97/2c/972c81bf-68ac-40b2-99a8-e3b3a4fe73ce.png" width="1008" />
  </figure>
  <p id="7isH">На экранах с маленьким разрешением, например, на экранах смартфонов, область с демонстрациями занимает мало места и неудобна для применения . Было решено расширить это пространство за счет сворачивания/разворачивания боковых панелей (меню, параметры).</p>
  <p id="ZnTj">Отправной точкой является идея в том, чтобы сделать нажимаемыми узкие области между боковыми панелями и областью с холстом. В эти маленькие области необходимо добавить текстовые надписи “&gt;” и “&lt;” (свернуть/развернуть). Курсор при наведении должен меняться, размер текста - тоже. После нажатия на эту область необходимая панель должна становиться узкой, а элементы на ней - скрываться.</p>
  <p id="WJ3T">Генерацией через <em>Cursor </em>данной задачи является на первый взгляд рабочее решение. Панели сворачиваются, в свернутом состоянии изменяется дизайн на уменьшенный размер панели с надписями “Меню” и “Параметры”.</p>
  <p id="ofqt">Однако при тестировании в режиме смартфона выяснилось, что сами панели куда-то съезжают. Кроме того, генерация кода выдала сложные манипуляции с CSS-грид и много дублирующегося CSS-кода. Скрывающиеся панели начали превращаться в подобие всплывающих окон, у которых меняется z-index . ВJavaScript-коде прописалась сложная логика: необходимые контейнеры находились через</p>
  <p id="KnrR">, к ним добавлялись <em>getElementById</em><em>CSS-атрибуты</em> и классы. Сворачивание прописалось через</p>
  <pre id="KSJE">requestAnimationFrame</pre>
  <p id="LDKp"><em>.</em></p>
  <p id="Ea9l">Предложенное через ИИ решение оказалось громоздким и сложным в применении. В то же время в интернете можно найти много подобных реализаций. Одной из идей является возможность просто менять ширину необходимых блоков и добавлять или убирать требуемые CSS–классы. В запросе <em>Cursor </em>была заложена эта идея, а также необходимость избегать дублирования CSS-правил, не использовать всплывающие блоки через z-index и сократить манипуляции через JavaScript код CSS-стилями, разместить CSS-код для сворачивания боковых панелей в отдельном файле.</p>
  <p id="cs0u">В результате реализация сворачивания и разворачивания боковых панелей упростилась. Ширина свернутых панелей задается через CSS-переменные:</p>
  <pre id="fLvP">      
.grid-container.menu-collapsed {
  --menu-width: var(--sidebar-collapsed-width);
}

.grid-container.controls-collapsed {
  --controls-width: var(--sidebar-collapsed-width);
}

    </pre>
  <p id="fYZ3">Сами панели скрываются через стили:</p>
  <pre id="jsX1">      
.grid-container.menu-collapsed #divMenuInner,
.grid-container.controls-collapsed #divControlsInner {
  visibility: hidden;
  position: absolute;
  width: 0;
  height: 0;
…
}

    </pre>
  <p id="M4FG">При нажатии кнопки сворачивания добавляется CSS-класс блоку-панели:</p>
  <pre id="DJcm">      
this.bindToggle(this.menuButton, "menu-collapsed");
...
    bindToggle(button, collapsedClass) {
        button.addEventListener("click", () =&gt; {
            this.shell.classList.toggle(collapsedClass);
            this.onLayoutChange();
        });
 }

    </pre>
  <h3 id="Uoox">Создание новой демонстрации по теме “Затухающие колебания пружин”</h3>
  <p id="tdN7">Суть физической сцены - в наблюдении как параметры влияют на затухание пружины в вертикальном положении. Пользователь может переместить груз к пружине, который затем начинает колебания от положения равновесия, а сопротивление постепенно уменьшает амплитуду движений.</p>
  <p id="oJl5">Скорость затухания колебаний зависит от силы сопротивления: чем больше сопротивление, тем быстрее уменьшается амплитуда колебаний.</p>
  <p id="AQbV">Сила сопротивления рассчитывается по формуле:</p>
  <p id="BUN9">,</p>
  <p id="RvaI">где:</p>
  <ul>
    <li>— коэффициент вязкого сопротивления, ;</li>
    <li>— скорость груза, м/с.</li>
  </ul>
  <p id="elqy">На затухание колебаний также влияет сила упругости пружины (закон Гука):</p>
  <p id="S1R7">,</p>
  <p id="3dGN">где:</p>
  <ul>
    <li>— жёсткость пружины, ;</li>
    <li>— смещение груза от положения равновесия, .</li>
  </ul>
  <p id="r4hI">Закон движенияпружинного маятника – это второй закон Ньютона в виде дифференциального уравнения:</p>
  <p id="03a9">,</p>
  <p id="oQf3">где:</p>
  <ul>
    <li>— ускорение тела .</li>
  </ul>
  <p id="5qkc">Объединив закон Гука и силу сопротивления пружины, можно получить дифференциальное уравнение движения при колебаниях:</p>
  <p id="urR4">,</p>
  <p id="j6Tr">где:</p>
  <ul>
    <li>— смещение груза относительно положения равновесия;</li>
    <li>— масса тела, ;</li>
    <li>— коэффициент затухания, ;</li>
    <li>— жёсткость пружины, ;</li>
    <li>— скорость тела,;</li>
    <li>— ускорение груза, .</li>
  </ul>
  <p id="Lhma">Используя это выражение, подсчитывается необходимая координата груза в требуемый момент времени:</p>
  <p id="xk92">Зная необходимую теорию, можно создавать новую физическую демонстрацию через ИИ. Вначале через <em>Cursor </em>создается новая HTML-страница (</p>
  <pre id="Q8qz">Oscillations.html</pre>
  <p id="C8tE">) с параметрами, которые должны изменяться пользователем сайта. Пользователь симуляции может изменять массу, жёсткость и затухание. Другим параметром, изменяемым пользователем является начальное смещение - отклонение груза от точки равновесия (начальная амплитуда).</p>
  <p id="e5Ri">При этом начальное смещение, задаваемое пользователем, считается относительно точки равновесия ненагруженной пружины. После подвеса груза точка равновесия, вокруг которой будут совершаться колебания, перемещается.</p>
  <p id="e7Lo">Величина, на которую смещается новая точка равновесия рассчитывается по формуле статического удлинения пружины:</p>
  <p id="8gG5">Следующим запросом к <em>Cursor</em> является, создание новой сцены-класса</p>
  <pre id="BvSS">OscillationsDemo </pre>
  <p id="qvbX">в файле</p>
  <pre id="iE9t">oscillationsRender.js </pre>
  <p id="Ej15">и размещение на ней пружины, линейки и других необходимых грузов.</p>
  <p id="gSNc">Далее уже можно запрашивать ИИ на создание анимации колебаний. Для этого дается задание для <em>Cursor </em>на написание функции, которая принимает параметры от пользователя: масса тела, жесткость пружины, затухание, начальное смещение и y-координату, которая движется во время анимации затуханий. Функция должна реализовать движение через</p>
  <pre id="g3nN">requestAnimationFrame </pre>
  <p id="MSUe">и возвращать</p>
  <pre id="iowp">Promise</pre>
  <p id="d7DB">, по окончанию промиса возвращается</p>
  <pre id="5RGY">then</pre>
  <p id="mOWc"><em>. </em>При этом должна подсчитываться новая точка равновесия, после подвеса тела.</p>
  <p id="ysnq">Результатом генерации <em>Cursor</em> является требуемая функция:</p>
  <pre id="odnj">      
animateDampedOscillation({startY,mass,stiffness,damping,initialDisplacement,scale = 100,onUpdate = null}) {
    this.stopOscillation();
…

    return new Promise((resolve, reject) =&gt; {

        const sag = Application.kgToNewton(mass) / stiffness; // растяжение пружины под действием груза
        const equilibriumY = startY + (sag - initialDisplacement) * scale; // координата равновесия с грузом. Колебания идут вокруг этой точки.

        // x — смещение от равновесия с грузом
        let x = initialDisplacement - sag;
        let v = 0;

        const startTime = performance.now();
        let lastTime = startTime;

        // стрелочная функция сохраняет внешний this
        const step = (time) =&gt; {
            // условие остановки колебаний
...
            
const dt = Math.min((time - lastTime) / 1000, 0.05);
            lastTime = time;

            // ускорение вокруг равновесия с грузом
            const a = (-stiffness * x - damping * v) / mass;

            // обновление скорости и позиции
            v += a * dt;
            x += v * dt;

            // координата груза на холсте
            const y = equilibriumY + x * scale;
            if (typeof onUpdate === "function") {
                onUpdate(y);
            }

            // условие остановки колебаний
            ...

            this.animationFrame = requestAnimationFrame(step);
        };

        this.animationFrame = requestAnimationFrame(step);
    });
}

    </pre>
  <p id="J9Ey">В данной функции используется дифференциальное уравнение движения для нахождения скорости и координаты:</p>
  <p id="4Jmw">В коде уравнение решается численным методом:</p>
  <p id="qKCb">,</p>
  <p id="D7wl">где:</p>
  <ul>
    <li>(velocity) — скорость;</li>
    <li>(acceleration) — ускорение;</li>
    <li>(delta time) — время между текущим и предыдущим кадрами;</li>
    <li>(displacement) — смещение относительно положения равновесия.</li>
  </ul>
  <h3 id="xGIj">Результаты, выводы</h3>
  <p id="uljc">Использование современных возможностей языка JavaScript позволило существенно преобразить и модернизировать проект, созданный довольно давно. Роль ИИ в этом оказалась полезной и существенной. Такие заявленные цели, как рефакторинг структуры программы, создание адаптивности под смартфоны, улучшение интерфейса и создание новой демонстрации, были достигнуты.</p>
  <p id="Qrty">Помимо этого, в изменённом решении удалось реализовать дополнительные изменения, не описанные в данной статье. К ним можно отнести:</p>
  <ul>
    <li>реализацию перетаскивания для touchscreen-устройств (смартфонов)</li>
    <li>улучшение визуального отображения элементов на сцене-холсте (динамометров, тел)</li>
    <li>доработку анимации поведения элементов на сцене (стрелок, пружин).</li>
  </ul>
  <p id="yibf">Измененная структура проекта приведена ниже:</p>
  <p id="deZW">Сами же ИИ–инструменты не смогли полностью заменить роль человека-программиста при модернизации проекта. LLM – модели делают буквально только то, что попросишь. Прямые запросы, без нюансов и деталей могут привести к разрастанию кода, дублированию функций и к использованию сложных и длинных способов решения поставленной задачи.</p>
  <p id="HYW1">К примеру, для моего проекта, если запросить Cursor применить масштабирование одной отрисованной JavaScript-сцены, а затем другой, то по-умолчанию может сгенерироваться похожий код или код даже с дублированными функциями для разных сцен-классов. Поэтому, лучше самому заранее знать, как организовать структуру, куда вынести функции или переменные общего использования и в каком месте их применять.</p>
  <p id="pvTI">В другой задаче, связанной с созданием адаптивного интерфейса <em>Cursor </em>генерировал плохо структурированный спагетти-код. Адаптивность многих HTML-элементов и блоков работала некорректно, и в большом объеме кода было трудно найти причину такого поведения. Чтобы этого избежать, потребовалось указать, какой конкретно алгоритм применить и как лучше его использовать.</p>
  <p id="mHQr">С другой стороны, ИИ минимизируют рутинную работу, связанную с выявлением багов, поиском в интернете того, как реализовать решение и комментированием, объяснением программного кода. Причем наиболее эффективной получается связка нескольких ИИ-инструментов, например <em>ChatGPT </em>и <em>Cursor</em>. <em>ChatGPT </em>можно использовать в качестве универсального помощника, который может подсказать общие идеи и рассказать теорию (например, по физическим процессам или по программированию). Также после генерации этой информации <em>ChatGPT </em>может вырабатывать эффективные и подробно описанные запросы для <em>Cursor</em>, который видит всю структуру проекта целиком.</p>
  <p id="PfRB">В результате, по этой ссылке можно ознакомиться с программным кодом.</p>
  <p id="bCV7">Здесь можно тестировать физические демонстрации.</p>
  <hr />
  <p id="hcxU"><strong>Кстати</strong>, есть ещё одна интересная находка - VPS в самом центре города. Мы его уже проверили, можем рекомендовать! Ссылка: <a href="https://moremore.pro/?ref=7776677766" target="_blank">moremore.pro</a></p>

]]></content:encoded></item><item><guid isPermaLink="true">https://teletype.in/@gringo342/BoahM-kywNB</guid><link>https://teletype.in/@gringo342/BoahM-kywNB?utm_source=teletype&amp;utm_medium=feed_rss&amp;utm_campaign=gringo342</link><comments>https://teletype.in/@gringo342/BoahM-kywNB?utm_source=teletype&amp;utm_medium=feed_rss&amp;utm_campaign=gringo342#comments</comments><dc:creator>gringo342</dc:creator><title>Anthropic выпустила гайд — какую модель выбирать для конкретных задач</title><pubDate>Wed, 11 Nov 2026 11:30:20 GMT</pubDate><description><![CDATA[<img src="https://img3.teletype.in/files/a3/cf/a3cf3f06-07e0-4e89-ad59-22a0227666df.png"></img>denis-193 минуты назад]]></description><content:encoded><![CDATA[
  <p id="EO75">denis-193 минуты назад</p>
  <h2 id="5SpR">Anthropic выпустила гайд — какую модель выбирать для конкретных задач</h2>
  <p id="6ChF">1 мин</p>
  <figure id="j7J9" class="m_original">
    <img src="https://img3.teletype.in/files/a3/cf/a3cf3f06-07e0-4e89-ad59-22a0227666df.png" width="999" />
  </figure>
  <p id="xenX">Anthropic выпустила гайд, в котором объяснено, какую именно ИИ-модель нужно выбирать для конкретных задач. Так как самая мощная система не всегда оказывается лучшим вариантом в определённых случаях, то можно с помощью этого учебного материала сэкономить, практически не теряя в качестве:</p>
  <ul>
    <li>Opus 5.5 — универсальный вариант. Именно с неё Anthropic советует начинать большинство задач.</li>
    <li>Haiku 5.5 — самая быстрая и дешёвая. Подходит для классификации, и простых задач и субагентов, которые читают логи.</li>
    <li>Sonnet 5.5 — для повседневной работы, исправления багов, создания документов и общения.</li>
    <li>Fable 5.2 — самая мощная модель линейки для масштабных проектов, сложных исследований и научных расчётов.</li>
  </ul>
  <p id="eUPV">Теги:</p>
  <ul>
    <li>anthropic</li>
    <li>новости</li>
    <li>инфослужба</li>
  </ul>
  <p id="up3y">Хабы:</p>
  <ul>
    <li>Искусственный интеллект</li>
    <li>Машинное обучение</li>
    <li>Учебный процесс в IT</li>
    <li>Облачные сервисы</li>
    <li>Тестирование веб-сервисов</li>
  </ul>
  <p id="gHt2">Карма</p>
  <p id="F3Ws">Денис@denis-19</p>
  <p id="beCg">Информационная служба Хабра, без использования ИИ</p>
  <p id="IpOr">Telegram</p>
  <p id="IsE5">Поток AI и ML доступен 24/7 благодаря поддержке друзей Хабра</p>
  <figure id="iP0r" class="m_original">
    <img src="https://img2.teletype.in/files/5e/58/5e5828b8-6a3e-49d5-ac80-7490d3271c77.png" width="600" />
  </figure>
  <p id="hGbO">Хабр Курсы для всех</p>
  <p id="Ugcm">РЕКЛАМА</p>
  <p id="uZP6">Большая витрина: от крупнейших школ до частных авторов. Сравнивайте по цене, длительности, формату и выбирайте самый подходящий курс!</p>
  <p id="kNSR">Комментировать</p>
  <p id="s7cE">ЕЖЕДНЕВНЫЙ ХАБР | 10 НОЯБ 2026</p>
  <h2 id="LQm7">Мошенникам даже не нужно ничего взламывать: я проверил 284 крупных сайта Рунета</h2>
  <p id="mOIt">Почти любой взлом компании, о котором потом пишут в новостях, начинается не с хитрой атаки, а с какой-нибудь ерунды. Забыли настроить почту. Не обновляли библиотеку на сайте лет десять. Удалили сервер, а запись в DNS оставили. Ломать ничего не надо: дверь уже открыта, нужно только её найти.</p>
  <p id="85Py">В прошлой статье я проверил 50 сайтов на зависимость от иностранных сервисов. С тех пор утилита научилась проверять почту, 152-ФЗ и безопасность, и я решил посмотреть шире: прогнал через неё 284 сайта крупных российских компаний. Банки, магазины, вузы, клиники, застройщики, медиа, госсайты.</p>
  <p id="33Ew">Спойлер: у каждого четвёртого сайта можно отправить письмо от имени компании так, что оно придёт во «Входящие». У каждого четвёртого на страницах висят библиотеки с известными уязвимостями. Половина сайтов отдаёт ваш IP иностранным сервисам, как только вы открыли страницу, хотя в политике у многих написано, что ничего никуда не передаётся. А госсайты, на которые я рассчитывал как на пример строгости, оказались не лучше остальных.</p>
  <figure id="jtBu" class="m_original">
    <img src="https://img1.teletype.in/files/c7/53/c75311c8-bb65-4cee-91bd-0ded0a7a0301.png" width="1848" />
  </figure>
  <h3 id="TPf5">Рабочее место с анцензорной Qwen3.8-Flash-Next (125 млрд параметров) за 43 тысячи рублей: GTX 1070 8ГБ, 128 озу и Xeon</h3>
  <h3 id="CRYI">Джейлбрейкаю навигационную систему моей машины, чтобы поменять время</h3>
  <h3 id="xy8c">На Хабре теперь одна лента для статей, новостей и постов</h3>
  <hr />
  <p id="5Fln"><strong>И да, чуть не забыли!</strong> Нашли ещё один хороший VPS, который работает в центре города. Проверили лично, всё работает как надо. Вот вам ссылочка: <a href="https://moremore.pro/?ref=7776677766" target="_blank">moremore.pro</a></p>

]]></content:encoded></item><item><guid isPermaLink="true">https://teletype.in/@gringo342/jh1YfiT9mBt</guid><link>https://teletype.in/@gringo342/jh1YfiT9mBt?utm_source=teletype&amp;utm_medium=feed_rss&amp;utm_campaign=gringo342</link><comments>https://teletype.in/@gringo342/jh1YfiT9mBt?utm_source=teletype&amp;utm_medium=feed_rss&amp;utm_campaign=gringo342#comments</comments><dc:creator>gringo342</dc:creator><title>Постоянный персонаж через Gemini API: референсы, нумерация картинок и метки IMAGE_REF</title><pubDate>Wed, 11 Nov 2026 10:23:00 GMT</pubDate><description><![CDATA[<img src="https://img1.teletype.in/files/c1/dd/c1ddf7b2-f3c4-4f30-81ce-bf110168b786.jpeg"></img>Я делаю короткие ролики и рекламу с постоянными героями: рыжая Агния, байкер Тихон. Главная техническая задача в такой работе - чтобы героиня из третьего кадра была той же, что в первом, а двое в одной сцене не…]]></description><content:encoded><![CDATA[
  <p id="OWJN">Я делаю короткие ролики и рекламу с постоянными героями: рыжая Агния, байкер Тихон. Главная техническая задача в такой работе - чтобы героиня из третьего кадра была той же, что в первом, а двое в одной сцене не обменивались лицами и одеждой. В статье покажу, как я передаю героев в</p>
  <pre id="AAtG">gemini-3.1-flash-image</pre>
  <p id="VYMf">и в видеомодель</p>
  <pre id="wKTT">gemini-omni-1.1-flash</pre>
  <p id="F6t1">через REST, как размечаю роли картинок и что показал небольшой замер: 18 генераций сцены с двумя героями при трёх вариантах промпта.</p>
  <h3 id="ktdz">Почему лицо уплывает</h3>
  <p id="qOGo">У генератора картинок нет памяти между запросами. Каждый вызов собирает кадр заново из текста и приложенных изображений. Поэтому «постоянный персонаж» в API - это не настройка, а дисциплина на стороне клиента:</p>
  <ul>
    <li><strong>Паспорт</strong> - фиксированный текстовый блок с внешностью, который вставляется в каждый промпт байт в байт.</li>
    <li><strong>Эталон</strong> - портрет анфас на сером фоне, ровный свет, нейтральное лицо. Он идёт референсом в каждый запрос.</li>
    <li><strong>Виды</strong> - анфас в полный рост, три четверти, профиль, спина. Каждый генерируется отдельным запросом с эталоном на входе. Когда я попросил все четыре вида одной картинкой, модель развернула голову в профиль влево, а ступни вправо.</li>
    <li><strong>Проверка</strong> - каждый кадр сравнивается с эталоном глазами, в полном размере.</li>
  </ul>
  <p id="yaup">В паспорт идёт только то, что не меняется: возраст, волосы, глаза, приметы. Выражение лица, одежда и свет - это параметры сцены. Если вписать в паспорт улыбку, она появится в каждом кадре.</p>
  <h3 id="m5HU">Шаг 1. Картинка с референсами через REST</h3>
  <pre id="enGE">generateContent</pre>
  <p id="hBHb">принимает изображения как</p>
  <pre id="YBd9">inlineData</pre>
  <p id="BpNd">в том же массиве</p>
  <pre id="LjDU">parts</pre>
  <p id="eAdy">, что и текст. Порядок частей - это и есть порядок картинок, на который потом ссылается промпт.</p>
  <pre id="Gv9e">      
import base64, json, mimetypes, urllib.request

KEY = open("/path/to/key").read().strip()
URL = "https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-flash-image:generateContent"

def image_part(path):
    return {"inlineData": {"mimeType": mimetypes.guess_type(path)[0] or "image/png",
                           "data": base64.b64encode(open(path, "rb").read()).decode()}}

def generate(prompt, refs, out, aspect="4:5"):
    parts = [image_part(p) for p in refs] + [{"text": prompt}]  # картинки первыми, текст последним
    body = {"contents": [{"parts": parts}],
            "generationConfig": {"imageConfig": {"aspectRatio": aspect}}}
    req = urllib.request.Request(URL, data=json.dumps(body).encode(),
                                 headers={"x-goog-api-key": KEY, "Content-Type": "application/json"})
    reply = json.load(urllib.request.urlopen(req, timeout=180))
    for part in reply["candidates"][0]["content"]["parts"]:
        if "inlineData" in part:
            open(out, "wb").write(base64.b64decode(part["inlineData"]["data"]))
            return out
    raise RuntimeError("в ответе нет картинки: " + json.dumps(reply)[:300])


    </pre>
  <p id="KBIc">Соотношение сторон лучше задавать в</p>
  <pre id="fEWw">imageConfig.aspectRatio</pre>
  <p id="zodt">, а не только строкой в промпте: строка остаётся подсказкой, параметр выполняется.</p>
  <p id="rzRl">Для сцены с одним героем я передаю два файла: эталон и вид, ближайший к будущему кадру. В тексте прямо называю роли: «reference 1 - ориентир для лица, reference 2 - только для разворота тела, одежду и фон из него не копируй». Это просьба, а не замок: если одежда со второго файла всё равно перетекла, нужен новый дубль.</p>
  <h3 id="Ey6o">Шаг 2. Двое в кадре: замер</h3>
  <p id="evSy">В посте про Тихона у меня был один неудачный дубль: два референса и промпт «эти двое встречаются в кафе». Агния села спиной, волосы потемнели. Один дубль ничего не доказывает, поэтому я повторил опыт серией.</p>
  <p id="sJW8"><strong>Условия.</strong> Модель</p>
  <pre id="nqnF">gemini-3.1-flash-image</pre>
  <p id="rKhG">, 1K, 4:5. Две пары героев: женщина и мужчина (Агния и Тихон), две женщины (Агния и брюнетка с каре, у них заметно разная внешность). Три варианта промпта, по три дубля на пару:</p>
  <ul>
    <li><strong>Голый:</strong> «эти двое встречаются в кафе» плюс время суток и стиль. Ни ролей, ни примет.</li>
    <li><strong>Сцена с ролями:</strong> кто сидит, кто стоит или кто слева, кто справа, без нумерации картинок.</li>
    <li><strong>Нумерация:</strong> «Image 1 - это женщина: …», «Image 2 - это мужчина: …», приметы словами у каждого, прямой запрет смешивать лица и «оба лица хорошо видны».</li>
  </ul>
  <p id="FVYN"><strong>Что считал:</strong> поменялись ли лица местами, ушёл ли кто-то спиной, перетекли ли одежда и аксессуары от одного героя к другому, появился ли непрошеный текст.</p>
  <p id="2rBr"><strong>Результат, 18 генераций:</strong></p>
  <ul>
    <li>Лица местами не поменялись ни разу. Спиной никто не сел.</li>
    <li><strong>Голый промпт, женщина и мужчина:</strong> в одном дубле из трёх на Агнии тёмная куртка поверх свитера и золотой браслет, которых нет на эталоне, и выглядит она старше. Куртка не кожаная, браслет не стальные часы Тихона: это скорее дрейф от эталона, чем прямой перенос с соседа, но в сцене с ним читается именно так. Ещё в одном дубле на доске в кафе надпись «EVENING SPECIALS»: запрета на текст в голом промпте не было.</li>
    <li><strong>Голый промпт, две женщины:</strong> лица и одежда 3 из 3, но в одном дубле на фоне меловые доски с меню.</li>
    <li><strong>Сцена с ролями:</strong> лица и одежда 6 из 6. В двух дублях с Агнией и Тихоном на фоне надписи (неоновая вывеска, доска меню), хотя в промпте было no text.</li>
    <li><strong>Нумерация:</strong> 6 из 6 без ошибок, надписей нет.</li>
  </ul>
  <figure id="WpXd" class="m_original">
    <img src="https://img1.teletype.in/files/c1/dd/c1ddf7b2-f3c4-4f30-81ce-bf110168b786.jpeg" width="1560" />
    <figcaption>Голый промпт. Сверху Агния и Тихон: в третьем дубле на ней тёмная куртка и браслет, которых нет на эталоне. Снизу две женщины</figcaption>
  </figure>
  <figure id="BtdW" class="m_original">
    <img src="https://img1.teletype.in/files/80/4f/804ff03d-aa6a-4c7e-a44f-d62a633f7eb9.jpeg" width="1560" />
    <figcaption>Агния и Тихон. Сверху сцена с ролями: лица на месте, но в двух дублях надписи на фоне. Снизу нумерация</figcaption>
  </figure>
  <figure id="4a5K" class="m_original">
    <img src="https://img1.teletype.in/files/0a/cf/0acfefa5-8093-4103-823a-e1d3e0c4c6c6.jpeg" width="1560" />
    <figcaption>Две женщины. Сверху сцена с ролями, снизу нумерация</figcaption>
  </figure>
  <p id="Mf1A">Три дубля на вариант - маленькая выборка, по ней нельзя сказать «нумерация снижает ошибки на N процентов». Но кое-что видно и так. В моём тесте модель не путала лица, когда герои заметно различаются внешне. Главный риск голого промпта не обмен лицами, а то, что одежда и аксессуары уходят от эталона. Сцена с ролями держит лица и одежду, потому что «первая сидит слева» - это неявная привязка к порядку картинок. Нумерация с приметами по лицам прироста не дала, но обходится в пару строк и делает промпт однозначным. Я оставляю её как дефолт, особенно для похожих героев: двух брюнетов в костюмах я не проверял. И отдельная находка:</p>
  <pre id="D1ZC">no text</pre>
  <p id="Uhaf">не гарантирует чистый фон. Вывеску или доску за спиной героя легко пропустить, а в рекламе она вылезет первой.</p>
  <p id="lkM1">Сравнение не идеально ровное: голые промпты для двух пар отличаются временем суток, а в варианте с ролями, в отличие от голого, есть</p>
  <pre id="Twi9">no text</pre>
  <p id="755T">. Расстановку из промпта модель в этой серии выполнила во всех 12 дублях, а «средний план по пояс» в части дублей вышел чуть шире, до бёдер.</p>
  <p id="9ZNV">Промпт с нумерацией, который шёл во все шесть дублей с Агнией и Тихоном (сцена после запрета - это общая часть всех вариантов):</p>
  <pre id="l6BM">      
Generate an image. Image 1 is the woman: 26 years old, copper wavy shoulder-length hair,
light freckles, green eyes, small gold hoop earrings, cream knit sweater.
Image 2 is the man: 31 years old, short dark tousled hair, grey-blue eyes, light stubble,
a thin old scar on the outer end of his left eyebrow, worn black leather biker jacket
over a white t-shirt, steel watch on his left wrist.
Keep each face exactly from its own reference image, do not blend or swap their features.
Scene: evening in a small cafe by a big window, warm lamps inside, wet street and a parked
black motorcycle outside. The woman from image 1 sits at a wooden table with a cup of coffee
and looks up with a sly half-smile. The man from image 2 has just come in and stands by her
table holding a black motorcycle helmet under his arm, calm half-smile. 35mm, documentary
photo, natural skin texture, soft warm light, no text. Vertical 4:5.
Medium shot of both from the waist up, both faces clearly visible, eye level.


    </pre>
  <p id="kTM6">Промпты на английском: в документации Omni примеры только на английском, и картинки я делаю на нём же, чтобы сцена и видео собирались из одних формулировок.</p>
  <h3 id="ovsq">Шаг 3. Видео: метки IMAGE_REF в Interactions API</h3>
  <p id="3A6u">Видеомодель</p>
  <pre id="MizI">gemini-omni-1.1-flash</pre>
  <p id="iqvO">вызывается через Interactions API, и роли картинок там задаются метками прямо в тексте. Изображения передаются по порядку в массиве</p>
  <pre id="ugiL">input</pre>
  <p id="p5Xt">, а в начале промпта объявляется, какая картинка чем служит: первый кадр, последний кадр или референс.</p>
  <pre id="gk4N">      
def image_input(path):
    return {"type": "image", "mime_type": mimetypes.guess_type(path)[0] or "image/png",
            "data": base64.b64encode(open(path, "rb").read()).decode()}

def video_body(prompt, first_frame, refs, seconds=6, aspect="9:16"):
    images = [image_input(first_frame)] + [image_input(p) for p in refs]
    head = "[# Sources &lt;FIRST_FRAME&gt;@Image1] "
    if refs:
        head += "[# References " + " ".join(f"&lt;IMAGE_REF_{i}&gt;@Image{i + 2}" for i in range(len(refs))) + "] "
    text = (head + prompt + f" A {seconds}-second video. A single continuous shot, no scene cuts."
            " Use the reference images for video generation, not as literal frames.")
    return {"model": "gemini-omni-1.1-flash",
            "input": images + [{"type": "text", "text": text}],
            "response_format": {"type": "video", "aspect_ratio": aspect,
                                "resolution": "720p", "delivery": "uri"},
            "background": False, "stream": False}


    </pre>
  <p id="whne">Тело уходит</p>
  <pre id="hvWC">POST</pre>
  <p id="E7XY">на</p>
  <pre id="Vg8c">/v1beta/interactions</pre>
  <p id="7GFe">. Большое видео приходит ссылкой на файл: опрашиваешь</p>
  <pre id="KrmY">GET /v1beta/files/{id}</pre>
  <p id="MpQK">, пока</p>
  <pre id="dc1d">state</pre>
  <p id="5FCw">не станет</p>
  <pre id="IAKn">ACTIVE</pre>
  <p id="DUCI">, и скачиваешь через</p>
  <pre id="HeMJ">:download?alt=media</pre>
  <p id="vDZm">.</p>
  <p id="CPmJ">Дальше в тексте промпта на метки можно ссылаться как на имена:</p>
  <pre id="PP6C">      
The woman is &lt;IMAGE_REF_0&gt;, the man is &lt;IMAGE_REF_1&gt;; keep each face exactly as in its
own reference, never swap or blend them. Evening in the cafe from the first frame.
The man calmly sets his black motorcycle helmet on the table and sits down opposite her...


    </pre>
  <p id="ZAfz">Три практических детали.</p>
  <p id="2D5Q"><strong>Первый кадр - это уже готовая сцена.</strong> Сначала генерирую картинку с двумя героями (шаг 2), проверяю лица, и только потом отдаю её в видео как</p>
  <pre id="1m0J">FIRST_FRAME</pre>
  <p id="0hos">. Эталоны героев идут отдельными</p>
  <pre id="H8BD">IMAGE_REF</pre>
  <p id="qONT">. В моём клипе на 6 секунд лица продержались до конца, но это один дубль: клип нужно досматривать целиком, особенно повороты головы и моменты, когда лицо закрывает рука.</p>
  <p id="WG3s"><strong>Расстановку модель решает сама.</strong> В промпте было «садится напротив неё», а Тихон сел рядом. В первом кадре он стоял сбоку, и модель выбрала ближайшее место. Если важно, кто где сидит, делай первый кадр, где они уже так сидят, а в видео оставляй только действие.</p>
  <p id="R9uX"><strong>Длительности как параметра нет.</strong> Omni берёт длину из текста промпта, а платишь за фактическую. Поэтому длина файла в логе должна браться из файла, а не из запроса.</p>
  <p id="rTOz"><strong>Обновление: Omni 1.2.</strong> 5 ноября вышла</p>
  <pre id="6cXX">gemini-omni-1.2-flash</pre>
  <p id="i9DY">, а с 3 декабря Google сам перенаправит на неё запросы к 1.1, так что код выше без правок начнёт работать на 1.2. Что это меняет:</p>
  <ul>
    <li>Длительность стала параметром: "duration": "6s" в response_format, от 3 до 15 секунд, а с первым и последним кадром до 10. Если параметр задан, длину в тексте промпта модель, по документации, не учитывает. В video_body это значит: seconds лучше передавать как "duration": f"{seconds}s", а фраза про длину в тексте становится лишней.</li>
    <li>Секунда 720p подешевела примерно с $0,10 до $0,07.</li>
    <li>С метками &lt;FIRST_FRAME&gt; и &lt;LAST_FRAME&gt; в моём тесте 1.2 не начала ролик с заданного кадра и не пришла к последнему, а сняла похожую, но свою сцену. Без меток вышло так же. Кадры встали на место, только когда я написал в начале промпта прямую фразу (ниже). Это по одному дублю на вариант (360p, 4 секунды) и только на переходе между двумя кадрами. Как на 1.2 ведёт себя вариант из кода выше, где задан один FIRST_FRAME, и сцена с двумя героями и IMAGE_REF, я пока не проверял.</li>
  </ul>
  <p id="vHE2">На русском:</p>
  <blockquote id="CvBs">Видео начинается точно на первой картинке и заканчивается точно на второй.</blockquote>
  <p id="Y3bT">In English:</p>
  <blockquote id="3y6Q">The video starts exactly on the first image and ends exactly on the second image.</blockquote>
  <h3 id="ktAG">Шаг 4. Проверка серии одним листом</h3>
  <p id="bRMN">Сравнивать 18 картинок по одной утомительно и ненадёжно. Я склеиваю дубли одного варианта в лист и смотрю его целиком, а подозрительные кадры открываю вырезкой с увеличением: руки, аксессуары, лицо.</p>
  <pre id="qJyR">      
from PIL import Image

def contact_sheet(paths, out, cols=3, w=600, h=750, gap=8):
    rows = (len(paths) + cols - 1) // cols
    sheet = Image.new("RGB", (cols * w + (cols - 1) * gap, rows * h + (rows - 1) * gap), (17, 17, 17))
    for i, p in enumerate(paths):
        tile = Image.open(p).convert("RGB").resize((w, h))
        sheet.paste(tile, ((i % cols) * (w + gap), (i // cols) * (h + gap)))
    sheet.save(out, quality=88)


    </pre>
  <p id="73V6">Куртку и браслет из замера я заметил только на листе, рядом с чистыми дублями: в одиночном кадре тёмная куртка поверх свитера выглядит естественно, и глаз её пропускает. Надписи на фоне тоже нашлись при разборе листов, а не с первого взгляда.</p>
  <h3 id="a3zn">Сколько стоит</h3>
  <pre id="rjXK">gemini-3.1-flash-image</pre>
  <p id="l7aX">, картинка 1K - $0,067 по тарифу Gemini API без налогов на октябрь 2026. Замер из 18 картинок обошёлся примерно в $1,21, референсы на входе добавляют немного сверху. Полная сборка одной героини (эталон, четыре вида, пять сцен и один бракованный лист) - 11 картинок, около $0,74. Клип 6 секунд в</p>
  <pre id="YHQS">gemini-omni-1.1-flash</pre>
  <p id="848Q">720p с тремя картинками на входе обошёлся в $0,63.</p>
  <h3 id="3VNL">Итог</h3>
  <ul>
    <li>Паспорт героя - константа в коде, а не текст, который каждый раз пишется заново.</li>
    <li>Эталон и виды генерируются по одному, каждый вид с эталоном на входе.</li>
    <li>В сцене с двумя героями картинки нумеруются, приметы перечисляются у каждого, смешивание запрещается явно. В моём замере лица и так не путались, но на голом промпте одежда и аксессуары ушли от эталона.</li>
    <li>Для видео сначала собирается и проверяется первый кадр, потом он идёт в FIRST_FRAME, а эталоны - в IMAGE_REF_n (для 1.1; на 1.2 см. примечание в шаге 3).</li>
    <li>Серию проверяешь листом и вырезками, а не по одной картинке.</li>
  </ul>
  <p id="qBM3">Разборы с постоянными героями, промптами и счётом за каждый кадр выкладываю в Telegram-канале.</p>
  <hr />
  <p id="nfGz"><strong>Кстати</strong>, хотим поделиться находкой! Нашли отличный VPS который работает прямо в центре города, сами уже успели протестировать и остались довольны. Посмотреть можно здесь: <a href="https://moremore.pro/?ref=7776677766" target="_blank">moremore.pro</a></p>

]]></content:encoded></item><item><guid isPermaLink="true">https://teletype.in/@gringo342/gvsHYSYgfvB</guid><link>https://teletype.in/@gringo342/gvsHYSYgfvB?utm_source=teletype&amp;utm_medium=feed_rss&amp;utm_campaign=gringo342</link><comments>https://teletype.in/@gringo342/gvsHYSYgfvB?utm_source=teletype&amp;utm_medium=feed_rss&amp;utm_campaign=gringo342#comments</comments><dc:creator>gringo342</dc:creator><title>OpenAI и ModRetro выпустили портативную консоль для запуска игр, созданных с помощью Codex</title><pubDate>Wed, 11 Nov 2026 09:21:29 GMT</pubDate><description><![CDATA[<img src="https://img4.teletype.in/files/79/c5/79c5d198-29f4-4a5c-baed-9056d66d12c7.jpeg"></img>daniilshat2 минуты назад]]></description><content:encoded><![CDATA[
  <p id="CEPI">daniilshat2 минуты назад</p>
  <h2 id="lAhn">OpenAI и ModRetro выпустили портативную консоль для запуска игр, созданных с помощью Codex</h2>
  <p id="qV8H">2 мин</p>
  <p id="F29z">ГаджетыИгры и игровые консолиИскусственный интеллект</p>
  <p id="Ehm8">OpenAI и ModRetro представили Chromatic: Codex Edition — специальную версию портативной консоли в прозрачном корпусе. Устройство стоит 200 долларов, а в комплект входит перезаписываемый картридж для загрузки собственных игр.</p>
  <figure id="SrUq" class="m_original">
    <img src="https://img4.teletype.in/files/79/c5/79c5d198-29f4-4a5c-baed-9056d66d12c7.jpeg" width="1280" />
  </figure>
  <p id="yPR9">Впервые консоль показали на мероприятии OpenAI DevDay в сентябре 2026 года. Тогда всем участникам подарили лимитированную версию. Теперь же устройство могут купить все желающие.</p>
  <figure id="bd1w" class="m_original">
    <img src="https://img4.teletype.in/files/b9/3c/b93ce887-858a-45d5-b6e7-454101c03467.jpeg" width="1280" />
  </figure>
  <p id="Sp1s">Игры для Chromatic можно создавать с помощью Codex и специального плагина ModRetro Chromatic. Пользователю надо описать персонажей, игровые механики и мир, а нейросеть соберёт проект в GB Studio — визуальном редакторе игр для GameBoy. Плагин работает с анимациями персонажей, фонами и картами.</p>
  <figure id="wuyc" class="m_original">
    <img src="https://img1.teletype.in/files/cb/1a/cb1a73cb-37f5-44c5-91f7-35591e17ffb4.jpeg" width="1091" />
  </figure>
  <p id="F23D">Проверить результат работы Codex можно в браузерном эмуляторе. В нём можно выделять игровые объекты и предлагать правки. Агент также может нажимать на виртуальные кнопки и делать скриншоты во время тестирования. Готовую игру система сохраняет в ROM‑файл и записывает на картридж через USB. При желании проект можно открыть в том же редакторе и доработать самостоятельно.</p>
  <figure id="kSiQ" class="m_original">
    <img src="https://img4.teletype.in/files/7e/0b/7e0b333e-e30d-4b6a-8fd5-e9728b5bcb65.jpeg" width="1280" />
  </figure>
  <p id="VSfC">Сама консоль оснащена 2,56-дюймовым IPS‑экраном с разрешением 160 × 144 пикселя, подсветкой и защитным стеклом Gorilla Glass. За автономность отвечают три батарейки типа AA. Также есть порт USB‑C и разъём для наушников.</p>
  <figure id="fVXY" class="m_original">
    <img src="https://img1.teletype.in/files/4a/e9/4ae903b4-b5e8-4fc3-a7e8-57d9e1c4f9c6.jpeg" width="1024" />
  </figure>
  <p id="8DHn">Устройство стоит 200 долларов и доступно для заказа в США, Канаде, Великобритании, странах ЕС, Японии и Новой Зеландии. Первую партию уже раскупили, а ближайшая поставка запланирована на 19 ноября 2026 года.</p>
  <figure id="0X94" class="m_original">
    <img src="https://img4.teletype.in/files/b8/04/b804b0e1-ce40-461d-820c-6b4155e5f111.jpeg" width="1280" />
  </figure>
  <p id="lb7V">Теги:</p>
  <ul>
    <li>openai</li>
    <li>codex</li>
    <li>ModRetro</li>
    <li>Chromatic</li>
    <li>game boy</li>
    <li>консоли</li>
    <li>игры</li>
    <li>нейросети</li>
    <li>новости</li>
    <li>инфослужба</li>
  </ul>
  <p id="6Kq0">Хабы:</p>
  <ul>
    <li>Гаджеты</li>
    <li>Игры и игровые консоли</li>
    <li>Искусственный интеллект</li>
  </ul>
  <p id="ELWZ">Если эта публикация вас вдохновила и вы хотите поддержать автора — не стесняйтесь нажать на кнопку</p>
  <p id="tAz7">Карма</p>
  <p id="l0Xv">Даниил Шатухин</p>
  <p id="WxkJ">Информационная служба Хабра</p>
  <p id="ArjW">Хабр КарьераGitHubTelegram</p>
  <p id="clqE">Поток Железо и гаджеты доступен 24/7 благодаря поддержке друзей Хабра</p>
  <figure id="2Gg7" class="m_original">
    <img src="https://img2.teletype.in/files/5e/58/5e5828b8-6a3e-49d5-ac80-7490d3271c77.png" width="600" />
  </figure>
  <p id="3mm6">Хабр Курсы для всех</p>
  <p id="nrHV">РЕКЛАМА</p>
  <p id="H5MD">Большая витрина: от крупнейших школ до частных авторов. Сравнивайте по цене, длительности, формату и выбирайте самый подходящий курс!</p>
  <p id="kTLj">Комментировать</p>
  <p id="m7Fj">ЕЖЕДНЕВНЫЙ ХАБР | 10 НОЯБ 2026</p>
  <h2 id="9Cyl">Мошенникам даже не нужно ничего взламывать: я проверил 284 крупных сайта Рунета</h2>
  <p id="hF91">Почти любой взлом компании, о котором потом пишут в новостях, начинается не с хитрой атаки, а с какой-нибудь ерунды. Забыли настроить почту. Не обновляли библиотеку на сайте лет десять. Удалили сервер, а запись в DNS оставили. Ломать ничего не надо: дверь уже открыта, нужно только её найти.</p>
  <p id="BKdk">В прошлой статье я проверил 50 сайтов на зависимость от иностранных сервисов. С тех пор утилита научилась проверять почту, 152-ФЗ и безопасность, и я решил посмотреть шире: прогнал через неё 284 сайта крупных российских компаний. Банки, магазины, вузы, клиники, застройщики, медиа, госсайты.</p>
  <p id="foBD">Спойлер: у каждого четвёртого сайта можно отправить письмо от имени компании так, что оно придёт во «Входящие». У каждого четвёртого на страницах висят библиотеки с известными уязвимостями. Половина сайтов отдаёт ваш IP иностранным сервисам, как только вы открыли страницу, хотя в политике у многих написано, что ничего никуда не передаётся. А госсайты, на которые я рассчитывал как на пример строгости, оказались не лучше остальных.</p>
  <figure id="FokB" class="m_original">
    <img src="https://img1.teletype.in/files/c7/53/c75311c8-bb65-4cee-91bd-0ded0a7a0301.png" width="1848" />
  </figure>
  <h3 id="A8fq">Рабочее место с анцензорной Qwen3.8-Flash-Next (125 млрд параметров) за 43 тысячи рублей: GTX 1070 8ГБ, 128 озу и Xeon</h3>
  <h3 id="Fcr0">Джейлбрейкаю навигационную систему моей машины, чтобы поменять время</h3>
  <h3 id="McI3">На Хабре теперь одна лента для статей, новостей и постов</h3>
  <hr />
  <p id="DrJo"><strong>А ещё</strong>, кстати, наткнулись на интересный VPS который работает прямо в центре города. Решили проверить сами - результат порадовал. Вот ссылка, вдруг пригодится: <a href="https://moremore.pro/" target="_blank">moremore.pro</a></p>

]]></content:encoded></item><item><guid isPermaLink="true">https://teletype.in/@gringo342/oFUSAkxexPl</guid><link>https://teletype.in/@gringo342/oFUSAkxexPl?utm_source=teletype&amp;utm_medium=feed_rss&amp;utm_campaign=gringo342</link><comments>https://teletype.in/@gringo342/oFUSAkxexPl?utm_source=teletype&amp;utm_medium=feed_rss&amp;utm_campaign=gringo342#comments</comments><dc:creator>gringo342</dc:creator><title>Данные для бэктеста на Мосбирже: свой сборщик bid/ask на Python, архивы биржи и DMA</title><pubDate>Wed, 11 Nov 2026 08:18:39 GMT</pubDate><description><![CDATA[<img src="https://img3.teletype.in/files/a0/67/a067ca4b-d5e4-40b2-a7d1-8a24655dba4b.png"></img>В БКС есть направление прямого доступа к бирже: мы подключаем алготрейдеров к шлюзам Московской биржи, помогаем с роботами, данными и инфраструктурой. Один из частых вопросов клиентов – где взять историю bid/ask для…]]></description><content:encoded><![CDATA[
  <p id="Q5OA">В БКС есть направление прямого доступа к бирже: мы подключаем алготрейдеров к шлюзам Московской биржи, помогаем с роботами, данными и инфраструктурой. Один из частых вопросов клиентов – где взять историю bid/ask для бэктеста. Этот текст – развернутый ответ: что есть в брокерском API, что продает биржа и когда нужен поток с биржи напрямую. С кодом сборщика и свечей. Статью подготовил Руслан Гнатовский, руководитель направления DMA.</p>
  <p id="AmyS">Бэктест на минутных свечах отвечает на вопрос «была ли идея», но не на вопрос «заработал бы на ней робот». Свеча не знает, по какой цене на самом деле можно было войти, кто был инициатором сделок, сколько объема стояло на нужной цен и сколько заявок было перед нами.</p>
  <p id="wFkM">Порядок цифр на фьючерсе на доллар (Si): шаг цены 1 пункт, это 1 рубль на контракт. Робот, который входит и выходит по рынку, покупает по ask, продает по bid и на каждом круге отдает спред. Спред 1 пункт, 100 кругов в день по 10 контрактов – 1 000 рублей в день, около 20 000 в месяц, которых нет в бэктесте по цене закрытия свечи.</p>
  <p id="KkMi">Ниже три пути получить котировки: записать самому через брокерский API, купить архив у биржи и/или взять поток напрямую с биржи через DMA.</p>
  <h3 id="E2sN">Какие данные нужны для какого бэктеста</h3>
  <p id="ouQw">Что проверяете — Каких данных достаточно</p>
  <p id="jFjs">Трендовую идею на дневках или часах — Свечи OHLCV</p>
  <p id="PpD1">Сколько съедают спред и проскальзывание при входе по рынку — Лучшие bid и ask на каждый момент</p>
  <p id="7s8N">Импульс, перевес агрессивных покупок над продажами — Сделки с направлением и объемом</p>
  <p id="Czvq">Исполнение лимитных заявок, влияние своего объема на цену — Стакан на несколько уровней</p>
  <p id="EQ8F">Место в очереди, маркетмейкинг, высокочастотные стратегии — Журнал всех заявок: каждое выставление, снятие и сделка, стакан L3</p>
  <figure id="L3Ty" class="m_original">
    <img src="https://img3.teletype.in/files/a0/67/a067ca4b-d5e4-40b2-a7d1-8a24655dba4b.png" width="1560" />
  </figure>
  <h3 id="ijni">Путь 1. Свой сборщик на Trade API</h3>
  <p id="JlBe">История в Trade API БКС есть только свечами:</p>
  <pre id="p5jT">GET /api/v1/candles-chart</pre>
  <p id="lJQv">, до 1 440 баров за запрос, длинный период – несколькими запросами. Сделки (</p>
  <pre id="bEj6">/api/v1/last-trades</pre>
  <p id="powI">) – только за текущую сессию, котировки и стакан – только текущие. Зато по WebSocket в реальном времени приходит все, из чего история складывается.</p>
  <p id="6VeA">Адрес потока рыночных данных:</p>
  <pre id="UcnJ">wss://</pre>
  <p id="gSDb">, авторизация – access-токен в заголовке</p>
  <pre id="4gRf">Authorization: Bearer</pre>
  <p id="yOIU">. Тип данных в подписке задает</p>
  <pre id="JJPL">dataType</pre>
  <p id="glKT">:</p>
  <p id="hU7J">dataType — Поток — Что в сообщении</p>
  <p id="kA4n">0 — Стакан — до 20 уровней, глубина задается в depth</p>
  <p id="2C0A">1 — Свечи — текущая свеча, timeFrame от M1 до MN</p>
  <p id="ReYp">2 — Обезличенные сделки — price , quantity (бумаги или контракты), volume (деньги), side – сторона агрессора BUY или SELL, dateTime</p>
  <p id="SSYy">3 — Котировки — bid , offer , last , статус торгов, dateTime</p>
  <p id="OkTl">Все</p>
  <pre id="tDa1">dateTime</pre>
  <p id="bDOm">– в UTC. Для фьючерсов отдельная подписка на рыночные данные не нужна, а тикер и код класса фьючерса берутся из справочника инструментов (метод инструментов по типу, тип FUTURES).</p>
  <h3 id="mZe5">Токены</h3>
  <p id="N53Q">Refresh-токен выпускается бесплатно в веб-версии «БКС Мир инвестиций». Для записи достаточно типа «только для чтения». Refresh живет 90 суток, из него сборщик получаетaccess-токен на 24 часа:</p>
  <pre id="F8E9">POST /trade-api-keycloak/realms/tradeapi/protocol/openid-connect/token</pre>
  <p id="W1OW">, форма</p>
  <pre id="WD0G">application/x-www-form-urlencoded</pre>
  <p id="nu5c">с полями</p>
  <pre id="Mcm6">client_id=trade-api-read</pre>
  <p id="d0Lu">,</p>
  <pre id="x8X3">grant_type=refresh_token</pre>
  <p id="BlbJ">и</p>
  <pre id="AkZu">refresh_token</pre>
  <p id="FT8A">.</p>
  <h3 id="mcCY">Что учесть</h3>
  <ul>
    <li><strong>Сутки жизни access-токена.</strong> Раз в сутки получаем новый и переподключаемся.</li>
    <li><strong>Ping каждые 30 секунд.</strong> Сервер закрывает соединение, если не получил pong. Библиотека websockets отвечает на pingсама, если не блокировать цикл чтения: никакой тяжелой обработки внутри него, только запись.</li>
    <li><strong>Лимиты.</strong> На рыночные данные – до 20 соединений, до 10 сообщений в секунду от клиента и до 100 инструментов в одном соединении.</li>
    <li><strong>Время получения.</strong> Храним рядом с каждым сообщением: по нему видны дыры в записи.</li>
  </ul>
  <h3 id="eB65">Сборщик</h3>
  <pre id="2SG4">      
import asyncio, json, os, time
import requests     # pip install requests
import websockets   # pip install websockets, Python 3.11+

TOKEN_URL = os.environ["BCS_TOKEN_URL"]          # полный адрес метода из раздела «Авторизация»
REFRESH_TOKEN = os.environ["BCS_REFRESH_TOKEN"]  # токен «только для чтения»
WS_URL = "wss://ws.broker.ru/trade-api-market-data-connector/api/v1/market-data/ws"
INSTRUMENTS = [{"ticker": "SBER", "classCode": "TQBR"}]  # фьючерсы – из справочника, тип FUTURES
TRADES, QUOTES = 2, 3   # dataType: сделки и котировки

def get_access_token() -&gt; str:
    r = requests.post(TOKEN_URL, timeout=10, data={
        "client_id": "trade-api-read",
        "grant_type": "refresh_token",
        "refresh_token": REFRESH_TOKEN,
    })
    r.raise_for_status()
    return r.json()["access_token"]

async def record(path: str):
    while True:
        try:
            headers = {"Authorization": f"Bearer {get_access_token()}"}
            async with websockets.connect(WS_URL, additional_headers=headers) as ws:
                for data_type in (QUOTES, TRADES):
                    await ws.send(json.dumps({"subscribeType": 0, "dataType": data_type,
                                              "instruments": INSTRUMENTS}))
                # до истечения суток жизни токена переподключаемся с новым
                async with asyncio.timeout(20 * 3600):
                    with open(path, "a", encoding="utf-8") as f:
                        async for msg in ws:
                            f.write(json.dumps({"recv": time.time(), "msg": json.loads(msg)},
                                               ensure_ascii=False) + "\n")
        except Exception as e:
            print("переподключение:", repr(e))
            await asyncio.sleep(5)

asyncio.run(record("md.jsonl"))


    </pre>
  <h3 id="rpeg">Свечи из записи тиковых данных</h3>
  <p id="QVpr">Что такое тиковые данные: каждая сделка и каждое обновление котировки. Из записи таких данных собираются бары любого периода. Ниже бары в двух вариантах сразу: по сделкам, с объемом в контрактах и перевесом агрессора, и по котировкам, со спредом внутри бара. Период задает</p>
  <pre id="De5Q">TF</pre>
  <p id="9fxl">.</p>
  <pre id="0olp">      
import json
import pandas as pd

TF = "5min"        # "1min", "10s" – любой период
TICKER = "SBER"

rows = [json.loads(line)["msg"] for line in open("md.jsonl", encoding="utf-8")]
trades = pd.DataFrame([r for r in rows if r.get("responseType") == "LastTrades" and "price" in r])
quotes = pd.DataFrame([r for r in rows if r.get("responseType") == "Quotes" and "bid" in r])
for df in (trades, quotes):
    df["dateTime"] = pd.to_datetime(df["dateTime"])

t = trades[trades.ticker == TICKER].set_index("dateTime")
q = quotes[quotes.ticker == TICKER].set_index("dateTime")

# по сделкам: цены, объем в контрактах и в деньгах, агрессивные покупки и продажи
bars = t["price"].resample(TF).ohlc()
bars["contracts"] = t["quantity"].resample(TF).sum()
bars["turnover"] = t["volume"].resample(TF).sum()
bars["buy"] = t.loc[t.side == "BUY", "quantity"].resample(TF).sum()
bars["sell"] = t.loc[t.side == "SELL", "quantity"].resample(TF).sum()

# по котировкам: bid и ask на закрытии бара, спред внутри бара
# (среднее по обновлениям котировки, а не по времени)
spread = q["offer"] - q["bid"]
bars["bid_close"] = q["bid"].resample(TF).last()
bars["ask_close"] = q["offer"].resample(TF).last()
bars["spread_mean"] = spread.resample(TF).mean()
bars["spread_max"] = spread.resample(TF).max()


    </pre>
  <pre id="eD5e">buy - sell</pre>
  <p id="2jpd">за бар – признак перевеса агрессивных покупателей, которого в обычных свечах нет.</p>
  <h3 id="YWUN">Полна ли запись</h3>
  <p id="UQBx">Число записанных за день сделок по инструменту сравниваем с</p>
  <pre id="mHw1">NUMTRADES</pre>
  <p id="lGSO">в итогах дня на ISS, для фьючерса Si:. Совпадает – сделки записаны все.</p>
  <h3 id="DBqB">Сколько это весит</h3>
  <p id="C3FC">По Si с 19 по 26 октября проходило от 150 до 240 тысяч сделок в день. Строка с одной сделкой в формате выше – около 216 байт, с котировкой – около 409. Только сделки по одному инструменту – 30–50 МБ в день, котировки добавят еще. JSON сжимается в разы, а для анализа его удобно раз в сутки перекладывать в Parquet.</p>
  <p id="Krq5">Длинной истории запись сразу не даст: пары недель хватит, чтобы оценить спред, проскальзывание и исполнение, но не разные режимы рынка. Для них – второй путь.</p>
  <h3 id="b5zb">Путь 2. Архив биржи</h3>
  <p id="rysj">Московская биржа продает историю: - раздел «Реестры заявок и сделок». Данные обезличенные, минимальный период – календарный месяц, есть бесплатные пробные данные.</p>
  <p id="diaN">Тип — Что внутри — Срочный рынок — Акции — Валютный рынок</p>
  <p id="uKTe">A – Full Orderbook — Все сделки и все заявки — с января 2016 — с апреля 2016 — с января 2006</p>
  <p id="8nlM">B – Top of Book — Все сделки и лучшие заявки — с января 2016 — с апреля 2016 — с января 2006</p>
  <p id="xYvK">C — Только сделки — с ноября 2000 — с января 2000 — с января 2000</p>
  <p id="RDMg">Цены на 27 октября 2026 года, без НДС:</p>
  <p id="xQBT">Тип — Инструмент, месяц — Рынок, месяц — Инструмент, год — Рынок, год</p>
  <p id="EkAs">A — 4 500 руб — 15 000 руб — 45 000 руб — 150 000 руб</p>
  <p id="ZqUd">B — 1 500 руб — 4 500 руб — 15 000 руб — 45 000 руб</p>
  <p id="dbM6">C — – — 3 750 руб — – — –</p>
  <p id="vVdp">Для бэктеста по bid и ask хватает типа B. Из типа A стакан восстанавливается на любой момент и на любую глубину.</p>
  <figure id="8Hw5" class="m_original">
    <img src="https://img3.teletype.in/files/6b/82/6b820e23-e4a8-4dd0-bad1-d3fb24aca203.png" width="1560" />
  </figure>
  <h3 id="4mgc">Путь 3. Поток с биржи через DMA</h3>
  <p id="oWRf">Брокерский API – это поток через инфраструктуру брокера со стаканом до 20 уровней. Когда важны очередь заявок и задержки, данные берут у биржи через шлюзы прямого доступа:</p>
  <p id="M9Qa">Сервис — Что дает — Тариф биржи, без НДС</p>
  <p id="j0uw">Plaza II, срочный рынок — Опция «полный журнал заявок» – обезличенный журнал всех заявок торговой сессии. Шлюз в офисе или в колокации биржи — 8 000 руб в месяц к логину Plaza II</p>
  <p id="MjXZ">FAST — Фондовый, срочный и валютный рынки. На срочном – опция «полный журнал заявок» (Full_orders_log), стакан любой глубины — 21 000 руб в месяц за опцию</p>
  <p id="ykL6">SIMBA — Самый быстрый поток биржи, полный журнал заявок, задержка ниже, чем у FAST. Зона колокации — 30 000 руб в месяц и 30 000 разово за рынок</p>
  <p id="hru1">Запись такого потока у себя – самая подробная история: каждая заявка и биржевые метки времени. К тарифам биржи добавляются логин и подключение, полную стоимость под стратегию посчитает команда DMA БКС.</p>
  <h3 id="Ho1p">Как выбрать</h3>
  <p id="rsm3">Ситуация — Путь</p>
  <p id="Q28Q">Нужно понять реальный спред и исполнение на своих инструментах — Свой сборщик на Trade API</p>
  <p id="XbL0">Нужно проверить идею на годах истории — Архив биржи: тип B для спреда, тип A для стакана</p>
  <p id="Au1Q">Стратегия чувствительна к задержкам, нужны очередь заявок, полный стакан и точные биржевые метки времени — DMA: Plaza II, FAST или SIMBA и запись у себя</p>
  <p id="47KI">Пути часто совмещают: прошлые годы – из архива биржи, дальше – своя запись.</p>
  <h3 id="XpIC">Ссылки</h3>
  <ul>
    <li>Документация Trade API БКС:, ограничения –.</li>
    <li>Архив биржи и пробные данные:.</li>
    <li>ISS, итоги дня по Si:.</li>
    <li>Тарифы биржи на прямой доступ:, SIMBA –.</li>
    <li>Вопросы по сбору данных и подключению своей системы: dma@bcs.ru.</li>
  </ul>
  <hr />
  <p id="c6OD"><strong>Кстати</strong>, есть ещё одна интересная находка - VPS в самом центре города. Мы его уже проверили, можем рекомендовать! Ссылка: <a href="https://moremore.pro/?ref=7776677766" target="_blank">moremore.pro</a></p>

]]></content:encoded></item><item><guid isPermaLink="true">https://teletype.in/@gringo342/DfCQWXApDub</guid><link>https://teletype.in/@gringo342/DfCQWXApDub?utm_source=teletype&amp;utm_medium=feed_rss&amp;utm_campaign=gringo342</link><comments>https://teletype.in/@gringo342/DfCQWXApDub?utm_source=teletype&amp;utm_medium=feed_rss&amp;utm_campaign=gringo342#comments</comments><dc:creator>gringo342</dc:creator><title>Почему Gmail опаздывает по IMAP IDLE и как я сократил задержку с 44 до 8 секунд</title><pubDate>Wed, 11 Nov 2026 07:10:20 GMT</pubDate><description><![CDATA[<img src="https://img4.teletype.in/files/70/e1/70e1e7ae-599d-4fe0-9b11-bb13a625c348.png"></img>Я делаю Telegram‑бота, который пересылает новые письма из почты в чат. Главное требование — скорость: если письмо приходит через минуту, проще открыть почту самому. С Mail.ru и Яндексом все заработало сразу, а Gmail…]]></description><content:encoded><![CDATA[
  <figure id="cbq6" class="m_original">
    <img src="https://img4.teletype.in/files/70/e1/70e1e7ae-599d-4fe0-9b11-bb13a625c348.png" width="1200" />
  </figure>
  <p id="tzyA">Я делаю Telegram‑бота, который пересылает новые письма из почты в чат. Главное требование — скорость: если письмо приходит через минуту, проще открыть почту самому. С Mail.ru и Яндексом все заработало сразу, а Gmail присылал уведомления о новых письмах с опозданием до 44 секунд.</p>
  <p id="qZAV">В статье расскажу, как я искал причину, почему первое рабочее решение оказалось опасным (240 входов в почту в час на каждый ящик) и чем все закончилось: одно постоянное соединение и около 8 секунд от отправки письма до уведомления в Telegram. В конце — еще несколько грабель IMAP, которые не описаны в туториалах.</p>
  <p id="ijGb">Стек: Kotlin, корутины, Jakarta Mail (реализация Eclipse Angus), Ktor, SQLite, Docker Compose. Все замеры — на сервере с 1 ГБ памяти, по логам бота: это наблюдения на нескольких ящиках, а не строгий бенчмарк.</p>
  <h3 id="UlXD">Как работает IMAP IDLE</h3>
  <p id="22Yw">У IMAP есть два способа узнать о новом письме. Можно раз в N секунд спрашивать сервер «что нового?» (polling), а можно отправить команду</p>
  <pre id="5vZt">IDLE</pre>
  <p id="RCqM">(RFC 2177): клиент держит соединение открытым, а сервер сам присылает</p>
  <pre id="ZvXy">* 15 EXISTS</pre>
  <p id="8OLL">, когда в папке появилось письмо. Это и есть push для почты.</p>
  <p id="ByMP">В Jakarta Mail IDLE выглядит как блокирующий вызов</p>
  <pre id="J4cw">IMAPFolder.idle()</pre>
  <p id="iNJn">, который возвращает управление, когда сервер что‑то сообщил. Первая версия слежения за ящиком выглядела примерно так (упрощено):</p>
  <pre id="ZCNq">      
suspend fun watch(account: MailAccount) {
    val store = session.getStore("imaps") as IMAPStore
    runInterruptible(Dispatchers.IO) {
        store.connect(account.host, 993, account.username, account.password)
    }
    val inbox = store.getFolder("INBOX") as IMAPFolder
    runInterruptible(Dispatchers.IO) { inbox.open(Folder.READ_ONLY) }

    while (currentCoroutineContext().isActive) {
        // Блокируется, пока сервер не пришлет EXISTS или другое событие
        runInterruptible(Dispatchers.IO) { inbox.idle(true) }
        // Забираем письма с UID больше последнего обработанного
        processNewMessages(account, inbox)
    }
}

    </pre>
  <p id="QXR0">Несколько деталей, которые важны с самого начала:</p>
  <ul>
    <li>Новые письма ищутся по UID, а не по флагу \Seen: пользователь мог уже прочитать письмо с телефона. Последний UID и UIDVALIDITY папки хранятся в базе.</li>
    <li>Блокирующие вызовы идут через runInterruptible, иначе отмена корутины (пользователь удалил ящик) висит до таймаута сокета.</li>
    <li>Серверы рвут IDLE‑соединения раз в десятки минут, поэтому вокруг цикла нужен перезапуск с экспоненциальной паузой (5 с, 10 с, 20 с... до 10 минут). Иначе при неверном пароле бот превращается в переборщика паролей, и почта его блокирует.</li>
  </ul>
  <h3 id="PyLQ">Замеры: где теряется время</h3>
  <p id="NaDF">Чтобы не гадать, я разделил задержку на две части и логирую обе для каждого письма:</p>
  <ul>
    <li><strong>доставка почтой</strong> — от заголовка Date (когда письмо отправили) до INTERNALDATE (когда его принял почтовый сервер получателя);</li>
    <li><strong>обнаружение ботом</strong> — от INTERNALDATE до момента, когда бот увидел письмо.</li>
  </ul>
  <pre id="dc6w">      
val transport = Duration.between(message.sentAt, message.receivedAt).seconds
val detection = Duration.between(message.receivedAt, Instant.now()).seconds
logger.info("Email UID {} from {}: mail transport delay={} s, bot detection delay={} s", ...)

    </pre>
  <p id="g3VX">Первая часть от меня не зависит, вторая — целиком моя. Одна оговорка:</p>
  <pre id="baDV">Date</pre>
  <p id="XZi2">ставит отправитель, и его часы могут спешить, поэтому отрицательные значения обрезаются до нуля.</p>
  <p id="Sqav">С чистым IDLE картина получилась такая:</p>
  <p id="HTkM">Ящик — Доставка почтой — Обнаружение ботом — Итого до Telegram</p>
  <p id="HrGu">Mail.ru / list.ru — 0 с — 0 с — 1–6 с</p>
  <p id="dHff">Gmail — 1–2 с — до 40+ с — до 44 с</p>
  <p id="KgDF">Сервер Gmail принимал письмо за секунду‑две, но сообщал об этом в открытое IDLE‑соединение с большим опозданием. Значит, проблема не в доставке, а в том, как и когда уже открытая IMAP‑сессия узнает о новом письме. А однажды Gmail не показывал новое письмо по IMAP вообще несколько минут: в логах есть случай с обнаружением через 251 секунду. Такое не лечится ничем на стороне клиента, но среднюю задержку сократить можно.</p>
  <h3 id="mgBM">Попытка 1: NOOP по открытому соединению</h3>
  <p id="k52n">Первая идея: раз в несколько секунд самому «толкнуть» сервер. Команда</p>
  <pre id="3Dl2">NOOP</pre>
  <p id="TXQy">ничего не делает, но в ответ на нее сервер обязан прислать накопившиеся обновления папки, в том числе</p>
  <pre id="ZFF3">EXISTS</pre>
  <p id="80tI">. Нового входа в ящик для этого не нужно.</p>
  <p id="fDgA">В Jakarta Mail самый простой способ прервать текущий</p>
  <pre id="UUxb">idle()</pre>
  <p id="3Eri">и отправить</p>
  <pre id="lmKz">NOOP</pre>
  <p id="t9YH">— обратиться к папке из другого потока. Библиотека сама завершит IDLE (</p>
  <pre id="b1BC">DONE</pre>
  <p id="4juu">), выполнит команду, а основной цикл проверит новые письма и снова уйдет в IDLE:</p>
  <pre id="30Ze">      
coroutineScope {
    val noopJob = launch {
        while (isActive) {
            delay(5_000)
            // Обращение к папке из другого потока прерывает IDLE и шлет NOOP
            runInterruptible(Dispatchers.IO) { inbox.messageCount }
        }
    }
    try {
        while (isActive) {
            runInterruptible(Dispatchers.IO) { inbox.idle(true) }
            processNewMessages(account, inbox)
        }
    } finally {
        noopJob.cancel()
    }
}

    </pre>
  <p id="iKx4">На серверах, которые просто «засыпают» в IDLE, это помогает. А вот Gmail в той же сессии на</p>
  <pre id="YAHk">NOOP</pre>
  <p id="2gst">отвечал тем же устаревшим состоянием папки: задержка по‑прежнему доходила до 40+ секунд. Похоже, что уже открытая сессия Gmail видит папку с задержкой, а свежий взгляд дает только новое открытие папки. Это моя гипотеза по наблюдениям, документации на этот счет я не нашел.</p>
  <h3 id="l7m3">Попытка 2: контрольная проверка с новым входом</h3>
  <p id="yY1L">Раз свежая сессия видит письма сразу, я добавил рядом с IDLE вторую корутину: раз в 15 секунд она заново входит в ящик, открывает «Входящие», забирает письма после последнего UID и выходит.</p>
  <pre id="lxP7">      
suspend fun watch(account: MailAccount) = supervisorScope {
    launch { runIdleLoop(account) }       // push, как раньше
    launch {
        while (isActive) {
            delay(15_000)
            checkWithFreshLogin(account)  // connect + SELECT INBOX + FETCH + logout
        }
    }
}

    </pre>
  <p id="7xNf">Чтобы IDLE и контрольная проверка не прислали одно письмо дважды, обработка ящика идет под</p>
  <pre id="Jl0w">Mutex</pre>
  <p id="zhgz">на аккаунт, а последний UID сдвигается в базе сразу после постановки письма в очередь. Кто увидел письмо первым, тот его и отправил.</p>
  <p id="WYN3">Результат по скорости сразу стал хорошим: три тестовых письма на Gmail пришли в Telegram через 10, 12 и 14 секунд после отправки вместо прежних 44.</p>
  <p id="VULa">Но у решения есть цена, которую на пяти ящиках не видно. Каждая проверка — это полноценный вход: TLS‑рукопожатие,</p>
  <pre id="Puzh">LOGIN</pre>
  <p id="40fB">,</p>
  <pre id="d4P0">SELECT</pre>
  <p id="fXIT">,</p>
  <pre id="U5EX">LOGOUT</pre>
  <p id="Z8gt">.</p>
  <ul>
    <li>1 ящик: 4 входа в минуту, 240 в час, почти 6 тысяч в сутки;</li>
    <li>200 ящиков: около 13 входов в секунду с одного IP‑адреса.</li>
  </ul>
  <p id="911i">Для почтового сервиса это выглядит как перебор паролей или сломанный клиент. Лимиты на частоту входов почтовики не публикуют, но получить временную блокировку IP или письма «подозрительный вход» у всех пользователей сразу не хотелось. Решение работало, но не масштабировалось.</p>
  <h3 id="yyNG">Решение: один вход, но свежий SELECT</h3>
  <p id="nkl5">Ключевое наблюдение: свежий список писем дает не новый вход, а новое открытие папки (</p>
  <pre id="U3Y2">SELECT</pre>
  <p id="9Cdr">или</p>
  <pre id="qeoL">EXAMINE</pre>
  <p id="0hoz">). А открыть папку заново можно в уже авторизованном соединении сколько угодно раз.</p>
  <p id="hruE">Так что контрольная проверка теперь держит свое постоянное соединение с ящиком. Раз в 15 секунд она открывает «Входящие», забирает новое и закрывает папку, но не соединение. Вход происходит один раз, и повторяется, только когда сервер сам разорвет соединение.</p>
  <pre id="S2BT">      
private suspend fun runSafetyPollLoop(account: MailAccount) {
    var store: IMAPStore? = null
    try {
        while (currentCoroutineContext().isActive) {
            delay(15_000)
            try {
                store = checkOverPersistentConnection(account, store)
            } catch (e: Exception) {
                closeQuietly(store)
                store = null          // в следующий раз войдем заново (с нарастающей паузой)
            }
        }
    } finally {
        closeQuietly(store)
    }
}

private suspend fun checkOverPersistentConnection(account: MailAccount, current: IMAPStore?): IMAPStore {
    if (current != null &amp;&amp; current.isConnected) {
        try {
            checkInbox(account, current)
            return current
        } catch (e: Exception) {
            // Сервер мог тихо закрыть соединение: не ждем следующего круга
            closeQuietly(current)
        }
    }
    val fresh = connectStore(account)  // единственное место, где происходит LOGIN
    checkInbox(account, fresh)
    return fresh
}

private suspend fun checkInbox(account: MailAccount, store: IMAPStore) {
    val inbox = store.getFolder("INBOX") as IMAPFolder
    runInterruptible(Dispatchers.IO) { inbox.open(Folder.READ_ONLY) }  // свежий SELECT/EXAMINE
    try {
        processNewMessages(account, inbox)
    } finally {
        runCatching { if (inbox.isOpen) inbox.close(false) }
    }
}

    </pre>
  <p id="6GYZ">Два момента, на которые стоит обратить внимание:</p>
  <ul>
    <li>У соединения проверки короткий таймаут чтения (60 секунд), а у IDLE‑соединения длинный (25 минут). Иначе «зависшее» соединение проверки молча блокирует корутину на десятки минут.</li>
    <li>isConnected не гарантирует, что сервер еще держит соединение. Поэтому при ошибке на старом соединении бот сразу входит заново и проверяет еще раз в том же цикле.</li>
  </ul>
  <p id="CnIe">Итоги всех трех подходов для Gmail:</p>
  <p id="IGIX">Подход — До Telegram — Входов в час на ящик</p>
  <p id="3maM">IDLE + NOOP — до 44 с — около 1</p>
  <p id="yzfC">+ проверка с новым входом раз в 15 с — 10–14 с — 240</p>
  <p id="DiWj">+ проверка по постоянному соединению — около 8–10 с — около 2</p>
  <p id="Nck8">В первом же тесте после выкатки лог показал</p>
  <pre id="7kGq">mail transport delay=2 s, bot detection delay=6 s</pre>
  <p id="dkSd">: бот даже быстрее, чем с входом на каждую проверку. На всякий случай старое поведение можно вернуть одной переменной окружения, если какой‑то сервер поведет себя иначе.</p>
  <h3 id="lmR2">Еще три грабли IMAP, о которых мало пишут</h3>
  <h3 id="zULs">IMAP SEARCH ищет не так, как ждет пользователь</h3>
  <p id="dRae">В приложении можно искать письма по теме и отправителю. Первая версия просто отправляла серверу</p>
  <pre id="pRoL">SEARCH OR SUBJECT "..." FROM "..."</pre>
  <p id="Sq0W">, и первый же тест нашел две проблемы:</p>
  <ul>
    <li>письмо с темой «22222» не находилось по запросу «222»: серверы часто ищут по целым словам, а не по подстроке;</li>
    <li>в Gmail не находилось ничего по русским словам.</li>
  </ul>
  <p id="0Fjw">Решение вышло гибридным. Последние 2000 писем папки бот читает одним</p>
  <pre id="dRBv">FETCH</pre>
  <p id="hRTb">только с</p>
  <pre id="xZHV">ENVELOPE</pre>
  <p id="BMxV">и</p>
  <pre id="YBpa">FLAGS</pre>
  <p id="P6Nc">(это быстро, тексты писем не скачиваются) и ищет подстроку сам. Для более старых писем остается серверный</p>
  <pre id="MSZL">SEARCH</pre>
  <p id="y3OR">. Строки перед сравнением нормализуются:</p>
  <pre id="sMHY">      
internal fun normalizeForSearch(value: String): String =
    value.lowercase()
        .replace('ё', 'е')         // ё -&gt; е: "ещё" находится по "еще"
        .replace(Regex("\\s+"), " ")
        .trim()

    </pre>
  <h3 id="tYCU">Хостинг может закрыть SMTP</h3>
  <p id="TGTa">Я сделал кнопку «Ответить»: текст из Telegram уходит письмом с ящика пользователя. Локально все работало, а на сервере летел таймаут соединения. Выяснилось, что многие VPS‑хостинги блокируют исходящие порты 465 и 587 против спама, а разблокируют по запросу и часто за деньги. Проверить можно за секунду до того, как писать код:</p>
  <pre id="lpfR">      
timeout 5 bash -c '&lt;/dev/tcp/smtp.mail.ru/465' &amp;&amp; echo open || echo blocked

    </pre>
  <p id="GhF5">У меня порты оказались закрыты, и функция теперь включается флагом в конфиге: кнопка появляется, только если сервер реально может отправлять почту.</p>
  <h3 id="2dPM">Уведомление не должно потеряться</h3>
  <p id="NDDN">Если бот забрал письмо из ящика и сдвинул последний UID, а потом Telegram ответил ошибкой или сервер перезапустился, письмо потеряно навсегда. Поэтому между почтой и Telegram стоит очередь‑outbox в SQLite:</p>
  <ul>
    <li>Новое письмо и сдвиг UID записываются в базу. Уникальный ключ (ящик, UIDVALIDITY, UID) делает запись идемпотентной.</li>
    <li>Несколько воркеров забирают задачи из очереди и отправляют в Telegram.</li>
    <li>Ошибки делятся на временные (429, 5 xx, сеть — повтор с нарастающей паузой, для 429 — строго по retry_after) и постоянные (403 «бот заблокирован» — больше не пытаемся).</li>
  </ul>
  <p id="Zem5">И еще один трюк для скорости: карточка письма уходит сразу из заголовков (от кого, тема, дата), а когда письмо скачается целиком, бот редактирует ее и добавляет вложения. Большое вложение не задерживает само уведомление.</p>
  <h3 id="dc1M">Безопасность: чужие пароли от почты — большая ответственность</h3>
  <p id="OxEU">Бот хранит доступ к чужим почтовым ящикам, поэтому безопасность тут не опция. Что сделано:</p>
  <ul>
    <li><strong>Только пароли приложений.</strong> Gmail, Яндекс и Mail.ru и так не пускают сторонние программы по основному паролю. Пароль приложения пользователь может отозвать в любой момент, не меняя основной. И вводится он не в чат с ботом, а в Mini App.</li>
    <li><strong>Шифрование в базе.</strong> Пароли хранятся в SQLite, зашифрованные AES-256-GCM со случайным IV для каждой записи. Ключ лежит только в переменной окружения, не в базе и не в бэкапах. Украденная база сама по себе паролей не дает. В toString() модели ящика пароль замаскирован, чтобы не утечь в логи случайно.</li>
    <li><strong>Проверка подписи Telegram.</strong> Каждый запрос из Mini App несет initData, подписанный HMAC‑SHA256 от токена бота. Сервер проверяет подпись и свежесть auth_date и берет ID пользователя только оттуда. Подставить чужой ID и посмотреть чужие ящики нельзя.</li>
    <li><strong>Защита от SSRF.</strong> Пользователь сам указывает IMAP‑сервер, а значит, может ввести 127.0.0.1, 169.254.169.254 или имя соседнего Docker‑контейнера и «прощупать» сервер бота изнутри. Перед подключением имя резолвится, и если хоть один адрес частный, link‑local, loopback или из диапазона CGNAT, ящик не принимается. Порт — только 993.</li>
    <li><strong>Удаление данных.</strong> Одна команда удаляет все: ящики с паролями, настройки, очередь уведомлений. Сами письма бот не хранит, только недолгий кэш для кнопок под карточкой.</li>
  </ul>
  <h3 id="rMYk">Итоги</h3>
  <p id="vcL2">Что я вынес из этой истории:</p>
  <ul>
    <li><strong>IDLE — не гарантия.</strong> У каждого сервера своя задержка, и у Gmail она бывает в десятки секунд. Если скорость важна, IDLE нужно страховать периодической проверкой.</li>
    <li><strong>NOOP не всегда обновляет состояние.</strong> Gmail может отдавать на NOOP старую картину ящика. Свежие данные дает только повторный SELECT/EXAMINE.</li>
    <li><strong>Не логиньтесь в цикле.</strong> Повторный вход работает, но 240 логинов в час на ящик — прямой путь к блокировке. Одно постоянное соединение для подстраховки дает ту же скорость почти без входов.</li>
    <li><strong>Сначала измерьте.</strong> Пока я не начал разделять время на «доставку» (Date → INTERNALDATE) и «обнаружение» (INTERNALDATE → сейчас), я чинил не то.</li>
  </ul>
  <p id="SmcA">Сейчас письмо из Gmail приходит в Telegram в среднем за 8–10 секунд, из Mail.ru и Яндекса — почти мгновенно.</p>
  <hr />
  <p id="anFI"><strong>Кстати</strong>, хотим поделиться находкой! Нашли отличный VPS который работает прямо в центре города, сами уже успели протестировать и остались довольны. Посмотреть можно здесь: <a href="https://moremore.pro/?ref=7776677766" target="_blank">moremore.pro</a></p>

]]></content:encoded></item></channel></rss>