xk6-sip: нагрузочное тестирование в CI — автоматизация с нуля до отчёта

Разберём, как настроить нагрузочное тестирование с нуля и встроить его в CI на примере xk6-sip — расширения нагрузочного инструмента k6 для VoIP/SIP-телефонии. На каждый push в GitHub Actions идут шесть функциональных звонковых сценариев и двухминутный нагрузочный прогон с порогами качества, а на странице прогона появляется сводка: каждый сценарий и каждый шаг, ключевые метрики нагрузки против порогов. В артефактах остаются JUnit-отчёты, WAV проваленных проверок звука и скриншот дашборда Grafana за окно теста.
Это четвёртая статья серии. В первой мы разобрали, как описывать звонки как код и как устроен движок xk6-sip, во второй — настроили мониторинг нагрузки на Prometheus и Grafana. Здесь объединяем их в CI-пайплайн на GitHub Actions, который проверяет АТС на каждом коммите.
Для VoIP/SIP-телефонии автоматизация тестирования в CI исторически давалась тяжело. Классический SIPp описывает сценарии в XML и не отдаёт ни метрики в Prometheus, ни JUnit-отчёт, поэтому в пайплайне его приходится обвязывать скриптами. В xk6-sip сценарий — обычный скрипт k6, а пороги, JUnit и экспорт метрик — стандартные возможности k6.
Нагрузочное тестирование часто живёт отдельно от разработки: раз в релиз инженер вручную запускает прогон, смотрит на графики и пишет отчёт. Деградация при этом обнаруживается через недели после коммита, который её принёс. Автоматизация нагрузочного тестирования в CI сокращает этот срок до минут: пороги производительности становятся quality gates, как юнит-тесты.
Всё ниже — из реального пайплайна репозитория xk6-sip: функциональные тесты занимают 40 секунд, нагрузка с мониторингом — 3,5 минуты, 401 звонок и 2 005 проверок за прогон.
В статье:
Архитектура пайплайна: что запускается на раннере.
Как настроить нагрузочное тестирование с нуля: три уровня проверок.
Функциональные звонковые тесты на каждый push.
Нагрузочный прогон в CI и пороги как quality gates.
Отчёты: сводка прогона на странице GitHub, JUnit и дашборд за окно прогона.
Грабли, на которые мы наступили.
Что дальше: реальная АТС, запуски CI по расписанию и сравнение с базовой линией.
Архитектура пайплайна
На каждый push и pull request рядом с юнит-тестами, сборкой и линтером параллельно идут два job со звонками на обычных раннерах GitHub Actions, без внешних стендов: всё — и тестируемая АТС, и мониторинг — поднимается на самом раннере и исчезает вместе с ним.

Результат сборки решают коды выхода k6: проваленная проверка или порог делают job красным, а артефакты сохраняются в любом случае.
Как настроить нагрузочное тестирование с нуля
Нагрузочное тестирование в CI — это не один большой прогон, а три уровня проверок с разной частотой и ценой. Чем дороже проверка, тем реже она запускается.
Уровень | Когда | Длится | Что ловит |
|---|---|---|---|
Функциональные сценарии звонков | каждый push и PR | секунды | сломанные call flow: удержание, переводы, отказы, звук |
Короткая нагрузка (smoke-performance) | каждый push и PR | минуты | грубую деградацию, утечки, рассинхрон сигнализации и медиа |
Полноценная нагрузка: поиск предела, стабильность | по расписанию или перед релизом | часы | потолок CAPS, деградацию между версиями АТС |
Первые два уровня живут в обычном CI на общих раннерах. Третий требует выделенного генератора и стенда, и о нём — в конце. Для старта нужны четыре вещи:
Генератор нагрузки, который собирается и запускается одной командой. Здесь это k6 с расширением xk6-sip:
xk6 buildсобирает один бинарник без внешних зависимостей.Тестируемая система внутри CI. Для расширения это тестовая АТС testpbx: регистратор и B2BUA на Go с digest-авторизацией, удержанием и переводами. Для настоящей АТС — её стенд или контейнер (Asterisk, FreeSWITCH).
Пороги в скрипте. k6 проверяет их сам и выходит с ненулевым кодом, если порог нарушен. Это и есть quality gate: никакой отдельной проверки поверх не нужно.
Мониторинг и отчёт. Prometheus и Grafana в Docker прямо на раннере и скрипт, который после прогона снимает дашборд за окно теста. Без отчёта красная сборка говорит «стало хуже», но не говорит, что и когда.
Функциональные звонковые тесты на каждый push
Шесть сценариев проходят за 40 секунд и покрывают основные call flow АТС. Каждый — один VU и одна итерация, потому что здесь проверяется логика, а не объём.
Сценарий | Что проверяет |
|---|---|
| звонок, гудки, ответ, звук в обе стороны, DTMF, кто положил трубку |
| удержание и возврат re-INVITE: звук пропадает и возвращается |
| слепой перевод REFER |
| сопровождаемый перевод с Replaces |
| каждая сторона чисто слышит фразу собеседника и не слышит себя |
| отказ (486), отмена во время вызова (487), несуществующий номер (404) |
Сценарий — это обычный скрипт k6, который читается как тест-кейс. Вот отказ и отмена целиком:
export default function () {
// B занят
let out = step('A calls B', A.call({ callee: B }));
let inc = step('B gets the call', B.expectCall({ caller: A, timeout: '10s' }));
inc.reject(486, 'Busy Here');
step('A: busy call ended', out.expectDisconnected('10s'));
step(`A gets 486 (got ${out.status()})`, out.status() === 486);
// A кладёт трубку, пока у B звонит
out = step('A calls B again', A.call({ callee: B }));
inc = step('B gets the second call', B.expectCall({ caller: A, timeout: '10s' }));
step('A hears ringing', out.expectRinging('10s'));
out.hangup();
step('B: call cancelled', inc.expectDisconnected('10s'));
step(`B: ended with 487 (got ${inc.status()})`, inc.status() === 487);
// номера не существует
out = step('A calls 1999', A.call({ callee: '1999' }));
step('A: call to unknown number ended', out.expectDisconnected('10s'));
step(`A gets 404 (got ${out.status()})`, out.status() === 404);
}Три принципа, на которых это держится:
step()останавливает сценарий на первом провале. Если B не получил звонок, проверять статус бессмысленно, и отчёт показывает одну настоящую причину вместо каскада вторичных ошибок.Фактическое значение — в имени проверки. В JUnit и в итогах k6 видно «A gets 404 (got 480)», и лог открывать не нужно.
Порог
checks: ['rate==1']делает любую проваленную проверку падением k6 с ненулевым кодом, аhandleSummary()пишет JUnit-отчёт для CI.
Job в GitHub Actions собирает k6 и testpbx, поднимает АТС и гоняет сценарии по очереди. Один упавший сценарий не останавливает остальные: в конце видно все сломанные сразу.
functional:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version: stable
- name: build k6 and testpbx
run: |
go install go.k6.io/xk6@latest
mkdir -p bin
xk6 build v2.3.0 --with github.com/Dmitry-Fedotov-Dev/xk6-sip=. --output bin/k6
go build -o bin/testpbx ./cmd/testpbx
- name: run scenarios
run: |
./bin/testpbx -addr 127.0.0.1:5070 -users 3 > testpbx.log 2>&1 &
sleep 1
mkdir -p reports
cd examples/functional
failed=0
for s in basic-call hold blind-transfer attended-transfer audio-quality reject-cancel; do
echo "::group::$s"
if ! ../../bin/k6 run -q -e JUNIT="../../reports/$s.xml" -e RECORD_DIR=../../reports/recordings "$s.js"; then
echo "::error title=functional::$s failed"
failed=1
fi
echo "::endgroup::"
done
exit $failed
- name: keep reports
if: always()
uses: actions/upload-artifact@v7
with:
name: functional-reports
path: |
reports/
testpbx.logВ артефактах остаются JUnit по каждому сценарию, лог АТС и WAV-записи звонков, где провалилась проверка звука: их можно послушать.
Нагрузочный прогон в CI
На каждый push идёт двухминутная нагрузка: 20 виртуальных пользователей, каждый ведёт пару абонентов и звонит по кругу с разговором 5–6 секунд. Профиль намеренно маленький: задача этого уровня — поймать грубую деградацию и рассинхрон, а не найти потолок.
Каждый звонок проверяется полностью: B получил звонок с верным АОН, соединение установилось, звук идёт, B слышит фразу A без искажений (compareAudio()), звонок завершился. Над этим — пороги, которые и решают судьбу сборки:
export const options = {
scenarios: { calls: { executor: 'constant-vus', vus: 20, duration: '2m' } },
thresholds: {
sip_call_success: ['rate>0.99'], // звонки соединяются
sip_call_setup_time: ['p(95)<500'], // и быстро
rtp_audio_heard: ['rate>0.99'], // без one-way audio
rtp_audio_score: ['p(95)>0.9'], // и без искажений
},
};Результаты последнего прогона на общем раннере GitHub Actions:
Метрика | Значение | Порог |
|---|---|---|
Звонков | 401, в среднем 3,15 CAPS | — |
Успешных | 100% | > 99% |
Setup time p95 | 1,05 мс | < 500 мс |
Post-dial delay p95 | 0,90 мс | — |
Первый ответ на INVITE p95 | 0,35 мс | — |
Джиттер p95 | 0,52 мс | — |
RTP-пакетов принято / потеряно | 224 107 / 0 | — |
Оценка звука p95 | 0,993 | > 0,9 |
Проверок | 2 005 из 2 005 | — |
Сверка по закону Литтла L = λW: 20 VU, одна итерация — это установление звонка, 5,6 с разговора и 0,5 с паузы, около 6,3 с. Отсюда 20 / 6,3 ≈ 3,2 CAPS против измеренных 3,15: генератор дал ровно ту нагрузку, которую заказали.
Почему порог setup time — 500 мс при фактических 1 мс. У общих раннеров CI шумные соседи: виртуальная машина делит CPU с чужими сборками, и тайминги плавают в разы. Порог на уровне таймера SIP T1 = 500 мс ловит настоящую поломку (АТС отвечает так медленно, что клиент уже повторяет запрос) и не краснеет от случайных скачков. Точные сравнения миллисекунд — дело выделенного стенда. Правило: на общих раннерах пороги проверяют корректность и грубую деградацию, а не точные цифры.
В job нагрузки три шага: поднять мониторинг и АТС, прогнать k6, снять отчёт:
- name: start monitoring and the test PBX
run: |
docker compose -f monitoring/docker-compose.yml --profile linux-host up -d
./bin/testpbx -addr 127.0.0.1:5070 -users 200 -csv examples/subscribers.csv > reports/testpbx.log 2>&1 &
timeout 60 bash -c 'until curl -sf http://localhost:3001/api/health >/dev/null; do sleep 2; done'
- name: load test
env:
K6_PROMETHEUS_RW_SERVER_URL: http://localhost:9091/api/v1/write
K6_FEATURES: native-histograms
run: |
echo "START=$(date +%s)" >> "$GITHUB_ENV"
./bin/k6 run -o experimental-prometheus-rw --tag testid="$TESTID" --tag pbx_version=testpbx \
-e VUS=20 -e DURATION=2m -e HOLD=5 -e AUDIO=1 -e SIP_METRICS_ADDR=0.0.0.0:6566 \
--summary-export reports/summary.json examples/call.js | tee reports/k6.txt
- name: dashboard report
if: always()
run: |
sleep 15 # the last remote write and scrape
bash monitoring/report.sh "$TESTID" $((START - 30)) $(($(date +%s) - 10)) reports/dashboard.pngTESTID равен ci-<номер сборки>: по нему прогон находится на дашборде, а если мониторинг общий для нескольких сборок, прогоны не смешиваются. Метрики теста k6 отдаёт через remote write, а метрики процесса k6 и машины Prometheus забирает сам; как это устроено, подробно разобрано в статье про мониторинг.
Отчёты: сводка прогона, JUnit и дашборд
Так выглядит зелёный прогон в GitHub Actions: пять job за 3,5 минуты, нагрузочный — самый долгий. Отчёты лежат в двух артефактах сборки: functional-reports и load-report.

Сводка на странице прогона
Первое, что видно после сборки, — не артефакты, а сводка прямо на странице прогона. Скачивать zip, чтобы узнать «что упало», — лишний шаг, и его никто не делает. GitHub Actions даёт для этого файл $GITHUB_STEP_SUMMARY: всё, что job записал туда в Markdown, показывается на вкладке Summary.
У обоих job со звонками последний шаг — summary с if: always(): он работает и на красной сборке, когда сводка нужнее всего. Короткий скрипт на Python без зависимостей, .github/scripts/summary.py, собирает её из того, что k6 уже написал: из JUnit-отчётов сценариев и из summary.json нагрузки.
Функциональные тесты — одна строка на сценарий: результат, число шагов и упавший шаг.

Под таблицей — шаги каждого сценария в свёрнутых блоках <details>. У прошедшего сценария блок свёрнут, у упавшего раскрыт сразу и показывает, что успело пройти до поломки и где именно сломалось. Вот как выглядит сводка, если задрать порог оценки звука до 0,999 (вывод скрипта на настоящем провале):
Scenario | Result | Steps | Failed step |
|---|---|---|---|
basic-call | ✅ | 9 | |
audio-quality | ❌ | 6 of 7 passed | B hears A's phrase clearly (score 0.993 >= 0.999) |
Step | |
|---|---|
✅ | A calls B |
✅ | B gets the call from A |
✅ | A connected |
✅ | B hears A |
✅ | A hears B |
✅ | B audio compared |
❌ | B hears A's phrase clearly (score 0.993 >= 0.999) |
❌ | threshold checks: rate==1 |
Звонок соединился, звук идёт в обе стороны, а фраза A дошла с оценкой 0,993 при требовании 0,999. Всё это видно без лога и без артефактов. В логе job печатается та же сводка, но с шагами только упавших сценариев, чтобы она не тонула в зелёных строках.
Код шага в job функциональных тестов — три строки:
- name: summary
if: always()
run: |
python3 .github/scripts/summary.py functional reports $SCENARIOS
python3 .github/scripts/summary.py functional --all-steps reports $SCENARIOS > reports/summary.md
cat reports/summary.md >> "$GITHUB_STEP_SUMMARY"Первая строка печатает короткую сводку в лог, вторая сохраняет полную в артефакт, третья выводит её на страницу прогона.
Нагрузка. Здесь сводка отвечает на два вопроса сразу: прошли ли пороги и насколько далеко метрики от них. Скрипт берёт цифры из summary.json, который k6 пишет по флагу --summary-export, и ставит рядом с каждой метрикой её порог из скрипта. Заголовок — итог одной строкой: «✅ Load: 4 thresholds passed» или «❌ Load: 1 of 4 thresholds crossed».
В таблице два вида строк:
С порогом — quality gate сборки: успешные звонки, setup time p95, доля плеч, услышавших звук, оценка звука p95. Рядом ✅ или ❌.
Без порога — контекст, который объясняет красную строку: число звонков и CAPS, post-dial delay, первый ответ на INVITE, джиттер, принятые и потерянные RTP-пакеты, проверки. Если упал порог оценки звука, строка «RTP packets received / lost» сразу покажет, потери это или что-то другое.
Так сводка выглядит на последнем прогоне:

Первая строка — ещё и проверка самого теста: 3,18 CAPS сходятся с расчётными 3,2 по закону Литтла, значит генератор дал заказанную нагрузку. Если бы общий раннер захлебнулся, CAPS упал бы раньше, чем покраснели пороги. Любой порог, которого нет в списке метрик, скрипт добавит строкой сам: нарушенный порог не потеряется, даже если его добавили позже. Строка в конце сводки указывает на dashboard.png в артефакте: сводка говорит «что», дашборд — «когда и как».
Шаг в job нагрузки — такой же, с проверкой, что k6 вообще успел написать итоги:
- name: summary
if: always()
run: |
if [ -f reports/summary.json ]; then
python3 .github/scripts/summary.py load reports/summary.json \
"20 VUs for 2 minutes against the test PBX, audio compared on every call." > reports/summary.md
cat reports/summary.md
cat reports/summary.md >> "$GITHUB_STEP_SUMMARY"
fiJUnit: при чём он здесь
JUnit XML — формат отчёта о тестах, который понимает практически любая CI и система тест-менеджмента. Название осталось от Java-фреймворка, но сам формат давно общий: набор тестов (testsuite), в нём тест-кейсы (testcase), у упавшего — failure с причиной. Именно из JUnit скрипт строит сводку функциональных тестов, и именно его заберут другие системы, если ваш CI — не GitHub.
В нашем отчёте каждый step() сценария — отдельный тест-кейс, с фактическим значением в имени:
<testsuite name="reject-cancel" tests="14" failures="0">
<testcase name="A calls B" classname="reject-cancel"/>
<testcase name="B gets the call" classname="reject-cancel"/>
<testcase name="A gets 486 (got 486)" classname="reject-cancel"/>
...
<testcase name="A gets 404 (got 404)" classname="reject-cancel"/>
<testcase name="threshold checks: rate==1" classname="reject-cancel"/>
</testsuite>Ловушка стандартной функции. В первой версии handleSummary() звал jUnit() из официальной библиотеки k6-summary, и отчёты выглядели так:
<testsuite name="k6 thresholds" tests="1" failures="0">
<testcase name="checks - rate==1" classname="Unnamed folder" />
</testsuite>Один кейс на весь сценарий: jUnit() делает тест-кейсы из порогов, а не из проверок. Если бы такой отчёт упал, он сказал бы только «что-то не так». Заметили это не сразу: пока всё зелёное, файл никто не открывает. Теперь JUnit пишет сам lib.js, примерно 30 строк: обходит data.root_group.checks из итогов k6 в порядке выполнения и добавляет по кейсу на каждый порог.
Кто ещё покажет этот отчёт. Сводка — это наш вариант для GitHub. Другие системы читают тот же XML сами, и каждый шаг звонка там становится отдельным тестом:
Где | Как показывает | Что нужно |
|---|---|---|
GitLab CI | вкладка Tests в пайплайне и виджет в merge request |
|
Jenkins | история каждого кейса по сборкам, график «прошло / упало» | плагин JUnit: |
GitHub, с аннотациями в PR | упавшие шаги отдельным отчётом проверки | сторонний action ( |
TestRail, Xray, Zephyr | прогон в системе тест-менеджмента | импорт JUnit XML |
Именно поэтому фактическое значение стоит писать в имя шага. В GitLab или TestRail лога k6 не будет, а строка «A gets 404 (got 480)» объясняет провал сама.
Дашборд за окно прогона
Пороги отвечают на вопрос «прошло или нет», а дашборд — на вопрос «что происходило». Но Prometheus и Grafana на раннере исчезают вместе с ним, поэтому дашборд нужно успеть сохранить. Это делает скрипт monitoring/report.sh: он открывает дашборд в headless Chrome с нужным testid и окном времени и снимает его целиком одним изображением.
# высота страницы — из разметки дашборда: клетка сетки Grafana 30 px + 8 px отступ
height=$(python3 -c 'import json,sys
d = json.load(open(sys.argv[1], encoding="utf-8"))
print(max(p["gridPos"]["y"] + p["gridPos"]["h"] for p in d["panels"]) * 38 + 80)' "$dashboard")
# kiosk — без меню; testid и from/to (в миллисекундах) выбирают прогон и его окно
url="$grafana/d/xk6-sip/xk6-sip?orgId=1&kiosk&var-testid=$testid&var-window=30s&from=${from}000&to=${to}000"
# --window-size: окно высотой во весь дашборд — Grafana рисует только видимые панели
# --run-all-compositor-stages-before-draw: снимок только полностью отрисованной страницы
# --virtual-time-budget=60000: до 60 с виртуального времени на запросы к Prometheus;
# без него Chrome снимает сразу после загрузки, и панели оказываются пустыми
"$chrome" --headless=new --disable-gpu --hide-scrollbars --window-size="1600,$height" \
--run-all-compositor-stages-before-draw --virtual-time-budget=60000 \
--screenshot="$out" "$url"Две детали, без которых скриншот получается пустым. Grafana дорисовывает панели лениво, только в видимой части окна, поэтому окно браузера должно быть высотой во весь дашборд. А --virtual-time-budget даёт запросам к Prometheus время завершиться до снимка. Окно времени берётся от момента запуска k6 минус 30 секунд до конца прогона, с паузой 15 секунд на последнюю отправку метрик.
Итог — картинка 1600 × 3956 со всеми 39 панелями в артефактах сборки. Разберём два блока из неё.

Верхний блок — 12 индикаторов за последнее окно: ASR и SEER 100%, one-way audio 0%, setup p95 1,1 мс, все звонки в SLA, ретрансмиссий нет. «Calls in progress 0» и CAPS 2,3 вместо 3,15 — не ошибка: плитки показывают самую свежую точку, а она приходится на завершение теста. Средние за весь прогон — в итогах k6.

Нижний блок отвечает на главный вопрос нагрузки на общем раннере: не упёрся ли сам тест. Dropped iterations — 0, длительность итерации ровная, 6–6,5 с. Процесс k6 занял около 6% одного ядра и 70 МБ памяти, куча Go идёт пилой без роста, горутин около 125. CPU машины после старта — несколько процентов; всплеск до 70% в начале — это запуск контейнеров и k6. Значит, цифрам сигнализации и звука можно верить.
Сеть машины при этом показывает килобайты, а сеть процесса k6 — 310 кБ/с RTP. Тестовая АТС работает на той же машине, и звонки идут через loopback, который не проходит через сетевую карту. Счётчики на сокетах xk6-sip видят его, а экспортёр машины — нет.
И целиком — так отчёт выглядит в артефактах сборки:

Грабли
Настройка заняла шесть прогонов CI. Ни одна ошибка не была в самих тестах — все в окружении вокруг них.
Симптом | Причина | Исправление |
|---|---|---|
Все шесть сценариев падают: REGISTER без ответа, 10 ретрансмиссий, |
| бинарники в |
|
|
|
| скрипт закоммичен с Windows без бита исполняемости |
|
Панели процесса k6 пустые на Linux, хотя на Windows работают | Prometheus в Docker ходит к k6 через шлюз хоста, а эндпоинт на |
|
Панель доли ошибок с осью до 10 000% | если все значения нулевые, Grafana сама выбирает диапазон оси | мягкий максимум оси ( |
Первая строка — самая поучительная. Симптомы выглядели как сетевая проблема или ошибка SIP-стека, а причина нашлась в первой строке лога АТС, который мы сохраняем в артефактах: ./testpbx: Is a directory. Правило: логи тестируемой системы идут в артефакты с if: always() с первого дня. И стоит проверять, что система поднялась, прежде чем давать на неё нагрузку: для Grafana это уже сделано через /api/health, а для АТС пока стоит sleep 1.
Ещё одна тонкость относится не к CI, а к метрикам: нативные гистограммы Prometheus искажают величины, которые скапливаются у единицы. Оценка звука 0,993 превращалась на дашборде в 0,958. Подробно — в статье про проверку качества звука.
Что дальше
То, что описано выше, закрывает первые два уровня проверок. Дальше — то, что превращает CI с нагрузкой в полноценный процесс нагрузочного тестирования:
Настоящая АТС вместо тестовой. Сценарии не меняются: абоненты передаются через
-e REGISTRAR=... -e A_USER=...или CSV. АТС можно поднять контейнером прямо в CI (Asterisk, FreeSWITCH) или гонять тесты против стенда с self-hosted раннера в его сети.Длинные прогоны по расписанию. Ночной job (
on: schedule) на выделенном генераторе: поиск предела открытой моделью нагрузки (ramping-arrival-rateступенями) и soak-тесты на часы. Закрытая модель с фиксированным числом VU, как в CI, потолок не находит: замедление АТС само снижает CAPS.Сравнение с базовой линией. Сейчас пороги абсолютные. Следующий шаг — сравнивать
summary.jsonс прогоном основной ветки и валить PR, если setup p95 или оценка звука ухудшились сверх допуска. На выделенном стенде с постоянным Prometheus то же делает дашборд: выбор несколькихpbx_versionи сравнение версий на одном графике.Результат прямо в PR. Комментарий с таблицей ключевых метрик и превью отчёта, чтобы ревьюер видел влияние изменения на производительность, не скачивая артефакты.
Всё описанное лежит в репозитории github.com/Dmitry-Fedotov-Dev/xk6-sip: *
скрипт отчёта и дашборд Grafana.
Предыдущие статьи серии:
xk6-sip: мониторинг нагрузочного тестирования VoIP/SIP‑звонков
xk6-sip: проверка качества звука в нагрузочных и автоматизированных функциональных тестах VoIP/SIP
Если вы тестируете свою АТС — Asterisk, FreeSWITCH, Kamailio, OpenSIPS или собственную, — попробуйте эти сценарии на ней: для старта достаточно двух абонентов и адреса регистратора. Результаты, падения и нехватающие сценарии жду в issues репозитория. А если подход пригодился — поставьте звезду на GitHub: так инструмент быстрее найдут те, кому он нужен.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.