1. Зачем это нужно
Моя боль как автора (а я не только программист и к. т. н, но еще и писатель в жанре фантастики) – это отсутствие на сайте автор.тудей полноценного анализа статистики. Вся статистика ограничивается просмотрами, временем и средним временем прочтений по дням и главам, в виде шахматки:

Ну, и еще плюс график времени чтения:

И все! Нет ни воронки, сколько % читателей переходят к следующей главе, ни каких-то других маркетинговых отчетов. А об определении статистической достоверности через вычисление p-Value я вообще молчу. Сидеть и вбивать все эти данные ручками в Excel, а потом получать нужные отчеты – не вариант. Как инженер-программист я просто должен что-то придумать, найти какое-то инженерное решение.
2. Правовая ремарка
Предупреждаю сразу. Приложение выложено на гитхаб в открытый доступ. Но по закону его допускается использовать только для сбора и анализа статистики по своим книгам (произведениям, к которым у вас есть законный доступ как у автора/соавтора). Сбор чужой статистики, обход ограничений доступа и любое иное неправомерное использование запрещены. Всю ответственность за неправомерное использование несёт пользователь. То есть, если используя, данную разработку, вы вдруг скачаете статистику чужих книг (на самом деле не скачаете, но это нюансы) – то это будет ваша ответственность.
3. Обзор решения
Первое, что я попробовал – это прямые GET запросы к странице со статистикой. Не сработало. Сайт не выдает по GET ту же самую страницу, что в браузере. Значит, надо воспользовался эмулятором браузера, например, библиотекой Selenium. Тут, конечно, тоже возникает правовой вопрос. Но так как правила сайта прямо не запрещают парсинг страниц, да и закона нет, который бы запретил сбор из интернета открытых данных, значит, можно.
Парсер находит на странице Kendo Grid (написанный на jQuery и встроенный в сайт автор.тудей) и разбирает его по столбцам и строкам. Сначала он записывает информацию в свою внутреннюю структуру, затем пишет в базу данных MS SQL. Теперь с этими данными можно работать через SQL-запросы.
Другие скрипты (сначала был CLI) извлекают данные из реляционных таблиц, строят отчеты, например, воронку прочтений, или даже сравнивают две воронки и считают p-value, чтобы подтвердить (или опровергнуть), что различия между ними статистически значимы.
Чуть позже был добавлен WEB-интерфейс через streamlit:

4. Почему не «просто API»
Кто-то может заметить, что author.today предоставляет API. Да, но в нем нет доступа к статистике книг. Вот что в нем есть, согласно официальной документации (Общая информация, Author.Today API):
Жанры, метаданные, контент и текст глав, скачивание книги, рекомендации, лайк, соавторство.
Возможность отправить прогресс чтения на сервер.
Каталог книг: справочник, фильтры.
Информация об авторе: логин/регистрация, профиль, библиотека, лайки, метки, прогресс чтения, настройки.
Старт чтения, прогресс, URL для доступа к аудиоверсии.
Главная страница, литмобы, конкурсы
Уведомления
Подписка на автора, скрытие авторов/циклов
Прочее: Жалобы, загрузка картинок, push-токены, heartbeat.
Как видим, доступа к статистике прочтений тут нет.
5. Архитектура
Организована следующим образом:
author.today (отчёт / Kendo)
↓ Selenium
fetch + parse → StatsTable
↓
ReadSnapshot → MS SQL (fetch_runs + chapter_reads)
↓
analyze (воронка / compare / тренд)
↓
CLI или Streamlit (services + UI)
То есть идет загрузка с сайта через Selenium, прописывается в базу данных, на основании этих данных строятся отчеты. Почему так, думаю, понятно. Selenium и парсер хрупкие и долго работающие, они не могут быть источниками истины для построения отчетов. Нужен промежуточный буфер – база данных.
Описание каталогов:
fetch / parse / auth / browser — только загрузка;
storage — сохранение и чтение;
analyze — чистая математика на ReadSnapshot;
services — тонкая обёртка для UI;
Streamlit не пишет SQL и не дергает Selenium напрямую (кроме вкладки загрузки через FetchJob).
Модель данных: Книга → несколько загрузок (fetch_runs, у каждой value_type) → ячейки матрицы (chapter_reads.metric_value). Один run = одна метрика (hit / time / avgTime).
Основные архитектурные принципы:
Protocol репозитория (ReadRepository) — позволяет при необходимости сменить БД;
фоновый FetchJob — UI не блокируется на Selenium;
настройки и секреты только в .env.
6. Парсинг Kendo
Страница со статистикой на Author.Today – это не просто типовая HTML-таблица. Она, конечно, сделана через тэг <table></table>, но не совсем обычным способом, а через Kendo UI Grid:

Что такое Kendo UI Grid? Это такой мощный jQuery-компонент, предназначенный для работы с табличными данными. В нем есть множество функций от простого отображения таблицы до полноценного CRUD. Чтобы не рисовать сотни ячеек сразу, компонент виртуализирует как строки, так и колонки: в один момент времени в DOM попадает лишь видимый кусок, в случае с сайтом Author.Today это примерно полтора десятка глав по вертикали и около десяти дат по горизонтали.
Проблема состоит в том, что Selenium читает только то, что на экране. Отдельно осложняет жизнь split-layout: названия глав в locked-колонке, числа — в прокручиваемой. Если использовать наивный zip, который объединяет два списка строк, и плюс еще служебная строка «Часть», то мы получаем баг – сдвиг метрик на соседние главы. Наивный zip – это конструкция типа такой:
for left_row, right_row in zip(locked_rows, scroll_rows): ...
Вот она и не работает. Как я вышел из положения? Использовал вот такую вот простую схему:
Сначала через execute_script читаем виджет целиком: jQuery(…).data(‘kendoGrid’).dataSource — там уже полная матрица «глава × дата», без прокруток.
Далее. Проверяем случай, когда dataSource недоступен. Это может быть в случае, когда отсутствует jQuery или другая верстка. Тогда мы делаем fallback: вертикальный и горизонтальный scroll по элементу .k-grid-content. Следующим шагом идет склейка видимых срезов. Эта склейка заключается в аккуратном сопоставлении locked/scroll, но на этот раз без слепого zip.
Раньше гоняли оба пути подряд; сейчас dataSource — быстрый основной путь, DOM — запасной.
Какой из всего этого можно извлечь урок? У виртуализированных UI «то, что видно в инспекторе» ≠ «то, что знает приложение». Скрейпить только видимый DOM опасно; ищите модель данных виджета (dataSource и аналоги) и лишь потом эмулируйте прокрутку.
Если подробнее, то работает все это так. Точка входа находится в файле author_today\parse\kendo_grid.py:
def parse_stats_page(driver: WebDriver, timeout: int = 30) -> StatsTable: """Извлечь таблицу прочтений с уже открытой страницы.""" grid = find_best_grid(driver, timeout) table = _extract_kendo_split(grid, driver) if not table.rows: table = _extract_plain_table(grid, table) if not table.rows: raise RuntimeError( "Таблица найдена, но строки с главами не извлечены. " "Проверьте авторизацию и загрузку страницы." ) return table
Сначала мы пытаемся получить dataSource, затем, если не получилось, то DOM-scroll:
def _extract_kendo_split(grid, driver: WebDriver) -> StatsTable: # Быстрый путь: вся матрица из Kendo dataSource без DOM-прокруток. ds_table = _extract_kendo_datasource(driver, grid) if ds_table and ds_table.rows and ds_table.dates: return ds_table # ... if locked and scroll: dom_table = _extract_via_dom_scroll(driver, grid, locked, scroll, header) if dom_table and dom_table.rows: return dom_table
Но есть и быстрый путь, например, JS-скрипт читает виджет целиком:
const el = arguments[0]; if (!el || typeof jQuery === 'undefined') return null; const grid = jQuery(el).data('kendoGrid'); if (!grid || !grid.dataSource) return null; const dateCols = []; let chapterCol = null; for (const col of grid.columns) { const title = String(col.title || '').trim(); if (/^\\d{2}\\.\\d{2}$/.test(title)) { dateCols.push({title: title, field: col.field}); } else if (col.locked && !chapterCol) { chapterCol = col; } } if (!chapterCol) chapterCol = grid.columns[0]; if (!dateCols.length || !chapterCol || !chapterCol.field) return null; const chapterField = chapterCol.field; const dates = dateCols.map((c) => c.title); const rows = []; for (const item of grid.dataSource.data()) { const chapter = String(item[chapterField] ?? '').trim(); if (!chapter || chapter === 'Часть') continue; const values = {}; for (const dc of dateCols) { const raw = item[dc.field]; if (raw === null || raw === undefined || raw === '') { values[dc.title] = null; } else { const n = Number(raw); values[dc.title] = Number.isFinite(n) ? n : null; } } rows.push({chapter, values}); } if (!rows.length) return null; return {dates, rows};
Python же его только запускает через передачу в виде текстовой строки:
def _extract_kendo_datasource(driver: WebDriver, grid) -> StatsTable | None: payload = driver.execute_script(_KENDO_DATASOURCE_JS, grid) # ... table = _stats_table_from_payload(dates, rows) return table if table.rows else None
Далее идет fallback (не наивный индекс):
def iter_scroll_row_indices(locked_labels: list[str]) -> list[tuple[str, int]]: """ Строка «Часть» есть только слева; при zip() она сдвигает значения на одну главу. """ pairs: list[tuple[str, int]] = [] scroll_idx = 0 for label in locked_labels: if not label or label == "Часть": continue # ... pairs.append((label, scroll_idx)) scroll_idx += 1 return pairs …. def _locked_scroll_pairs(locked, scroll) -> list[tuple[str, object]]: # ... for label, scroll_idx in iter_scroll_row_indices(locked_labels): if scroll_idx < len(scroll_rows): pairs.append((label, scroll_rows[scroll_idx])) return pairs
Если все кратко подытожить, то получается вот такая схема: Selenium открывает страницу → ищем .k-grid → почти всегда берём полную матрицу из dataSource → если нет — скролл DOM + сопоставление locked/scroll без zip(locked, scroll).
Надо сказать, что все это я писал не ручками, а через Cursor, именно он предложил такую схему. Я лишь смотрел код и офигевал, сколько я бы мучился, если бы писал все это ручками.
7. Модель данных
Модель данных соответствует сущностям предметной области. Имеются таблицы books (книги) и chapter_reads (прочтения по главам). Между ними таблица fetch_runs. Тут думаю, стоит пояснить, зачем нужна промежуточная таблица и почему бы просто не сделать связь books – chapter_reads без посредников. Дело в том, что если вдруг что-то криво загрузилось, то без fetch_runs нельзя безопасно удалить эту конкретную загрузку, нужно знать какие именно даты мы загрузили. Далее, разные метрики. Сначала были только прочтения (hit), затем добавились время (time) и среднее время чтения (avgTime). Кроме того, fetch_runs хранит метаданные загрузки (например, дату, когда именно происходила загрузка).
Позже была добавлена таблица book_notes для хранения заметок к книге.
Итак, подытожим и дополним по схеме данных:
fetch_runs: Одна загрузка = один снимок: книга + период + метрика + момент fetched_at. chapter_reads — только ячейки этого снимка.
Метрики так же хранятся в fetch_runs, а не висят в chapter_reads. Почему так – потому что сначала требовалось только hit, затем пришла идея грузить остальные метрики. Можно было бы добавить в chapter_reads еще колонок, но тогда пришлось бы догружать их через update, а не через insert, но тогда бы еще возник вопрос, что делать если новая загрузка – это новый fetch_runs с новой датой. Получается колонки так и останутся пустые. Кроме того, для метрики hit/time агрегатная функция – sum, а для avgTime – так делать нельзя.
books появляется при первой успешной загрузке; title правится вручную (с сайта не тянется).
book_notes — дата + текст; на тренде подсвечиваются как события (контекст к графику, не метрика).
Сознательно не стал делать отдельную таблицу глав, хотя по теории о нормальных формах следовало бы. Но, во-первых, главы не стабильны – книги могут переименовываться, вставляться, (например, автор может вставить интерлюдию), удаляться. Учесть все это – необоснованное усложнение. Кроме того, вынос глав в отдельный справочник – это лишний join, что замедляет работу. Короче, выгода от нормализации меньше, чем издержи на эту самую излишнюю нормализацию.
8. Аналитика
На момент написания этой статьи в приложении предусмотрен следующий аналитический функционал:
Воронка прочтений (% от базы и от предыдущей главы).
Сравнение периодов, расчет средних значений за период и среднеквадратичного отклонения, сравнение статистической значимости различий двух разных периодов (pValue).
Тренд прочтений.
Теперь более подробно. Начнем с воронки прочтений. Этот отчет показывает, насколько уменьшаются прочтения от главы к главе, чтобы выявить, на какой главе отваливаются читатели:

По графику видно резкое падение начинается с 9-ой главы. Почему? Потому что с этой главы кончается бесплатный фрагмент. Много отвалов сразу после первой главы, дальше идет пологий спуск. Почему? С первой главы отваливаются те, кому книга не интересна. Заинтересованы где-то 30% – вполне неплохой показатель, но не выдающийся. Есть, как говорится, точки роста.
Сравнение периодов. Этот отчет служит для того, чтобы проверить, принесли ли маркетинговые мероприятия результат. Сравниваем период до мероприятия и после. Смотрим, увеличились ли прочтения, и если да, то на каком этапе воронки. Исключаем случайность – для этого проверяем статистическую значимость – считаем pValue (должно быть меньше 0.05):

Тренд прочтений. Этот отчет показывает динамику процента прочтений по конкретной главе (синяя линия) и количество прочтений в целом (оранжевая линия). Они совмещены на одном графике. Почему? Сначала была только одна линия – процент прочтений. Я смотрю на график. И вижу, что сначала он был неприлично высок (аж 80%), потом почему-то резко упал и потом медленно рос до «средненьких» 30%. Почему? График прочтения ответил на этот вопрос: там где был неприлично высокий процент прочтений, там было неприлично низкое количество прочтений. То есть это были единые случаи, когда кто-то нашел книгу и стал ее читать. Потом я включил рекламу на Яндек.Директ, количество прочтений возросло, а процент упал – увы, но реклама привлекает много нерелевантной аудитории (боль маркетологов):

9. Streamlit как UI
Способ реализации UI я выбирал из двух: Django и FastAPI. Почему? Ну потому что хорошо знаю Django и FastAPI. Но на всякий случай решил спросить у ИИ. А агент посоветовал Streamlit, потому что это легкий UI-движок, для моей задачи его возможностей более чем достаточно. Django – это овер-инжиниринг, он тянет за собой ORM, админку, шаблоны. Все это лишнее для такой простой программы. Кроме того, когда я решил сделать UI, у меня уже было полноценное CLI приложение с pyodbc и ручным SQL, что вступает в конфликт с архитектурой Django, это, по сути, надо все полностью переписывать.
FastAPI был отвергнут по аналогичной причине: поверх него пришлось бы писать фронт из HTML/React/Vue.js – тоже овер-инжиниринг, к тому же FastAPI больше предназначен для написания микросервисов, а не UI.
В общем, решил последовать совету ИИ, так как он правильный и плюс возможность освоить новую библиотеку, расширить свой стек.
10. Грабли
Ох, куда же без них, без граблей. Здесь главные грабли – это повторная загрузка одного и того же периода. В этом случае будут неверные отчеты, так как агрегатные функции посчитают дублирующие загрузки. Сделал временное решение – возможность кнопочка удаления загрузки: чтобы не лазить в базу данных и не удалять ручками:

Ну, и конечно же, запланировать в будущем сделать контроль на повторную загрузку.
11. Как запустить
Скачиваем или клонируем репозиторий по ссылке megabax/BookReadingAnalyzer. Создаем окружение, устанавливаем зависимости.
python -m venv venv venv\Scripts\activate.bat pip install -e ".[dev]"
Для UI:
pip install -e ".[ui]"
или
pip install -r requirements-ui.txt
Настраиваем .env. Запускаем командой:
streamlit run streamlit_app.py
И пользуемся:

12. Что дальше
Разумеется, в будущем проект будет развиваться. Планируется:
Календарь покрытия (удобная таблица/график за какие периоды загружены данные).
Нормальные сообщения об ошибках при попытке загрузить чужую книгу. Сайт author.today выдает ошибки 403/404, но сейчас они не анализируются, просто программа падает по таймауту.
Селектор метрик в отчете (кроме просмотров, времени и среднего времени).
Ну, и конечно же, планирую прикрутить искусственный интеллект, как же сейчас без этого.
Конкретных сроков не обещаю (проект некоммерческий, open-source).
13. Заключение
Проект бесплатный (при самостоятельной установке), берите и пользуйтесь. Если пригодилось: поставьте, пожалуйста, звезду. Если вам нужно помочь с внедрением / дообработать / проконсультировать – это можно обсудить индивидуально, контакты ниже (но тут уже бесплатно ничего не обещаю).
Информация об авторе:
Имя · Шуравин Александр
Роль · Python developer
О проекте · личный инструмент для анализа статистики прочтений на author.today (Selenium → MS SQL → отчёты / Streamlit)
Контакты
Telegram |
|
GitHub |
|
TenChat |
|
Habr |
Для работодателя / рекрутера
Кратко о стеке этого репозитория: Python, Selenium, MS SQL (pyodbc), pandas, Streamlit, pytest; CLI + фоновые jobs; разделение domain / storage / analyze / UI.
Резюме (PDF) |
|
Резюме (HH) |
|
Готовность к работе |
удалённо (предпочтительно); гибрид / офис — Ижевск |
Целевая роль |
ML / AI Engineer (LLM, CV, R&D); дата-сайентист / разработчик |
Стек (из опыта, кратко):
Область |
Технологии |
LLM / AI |
OpenAI-compatible API, DeepSeek, LangChain, RAG, embeddings (sentence-transformers, BGE), Ollama (Llama/Mistral), промпт-инжиниринг, privacy / 152-ФЗ (Natasha NER) |
Computer Vision |
PyTorch, OpenCV, YOLO, ONNX, OCR (Tesseract), Librosa |
Backend / API |
Python, FastAPI, asyncio, C#, REST, RabbitMQ |
Данные |
PostgreSQL, MS SQL, SQLite, Qdrant, MinIO / S3, pandas, NumPy |
ETL / MLOps |
Airflow, Docker, Linux, Prometheus, Grafana, n8n, MLFlow |
Качество кода |
Git, Pytest, UML / ООП / Solid |