Skip to content

Parsers RU

Алексей . edited this page Jul 26, 2026 · 1 revision

Парсеры

English Русский

Эта страница описывает два публичных парсера, LogsHtmlParser и LogsFilterCatalogParser, которые превращают HTML страницы в типизированные модели без единого HTTP-запроса. Здесь приведены их сигнатуры, разметка, которую каждый из них ищет, и поведение при её отсутствии.

Почему парсеры существуют отдельно

Любая публичная операция библиотеки проходит по одному и тому же конвейеру: клиент строит URI, источник данных возвращает сырую HTML-строку, статический парсер превращает эту строку в записи. Парсеры занимают только последнюю стадию. Это чистые статические функции над string — без HTTP-клиента, без cookie, без аутентификации и без состояния, поэтому тот, кто получил HTML любым другим способом (собственным HttpClient, выгрузкой из браузера, сохранённым на диск снимком, фикстурой в тесте), может разобрать его тем же кодом, который библиотека использует внутри.

Два следствия, о которых стоит знать:

  • Ничто в парсерах не может сломаться по сетевым причинам; единственный вид отказа — «ожидаемой разметки нет».
  • LogsParserClient не добавляет к ним ничего. GetLogsAsync — это LogsHtmlParser.ParseLogs, применённый к HTML, который вернул источник данных; то же верно и для остальных трёх методов.
Метод клиента Вызываемый парсер
GetLogsAsync LogsHtmlParser.ParseLogs
GetAdminActivityAsync LogsHtmlParser.ParseAdminActivity
GetTopOperationsAsync LogsHtmlParser.ParseTopOperations
GetLogsFilterCatalogAsync LogsFilterCatalogParser.Parse

LogsHtmlParser

public static partial class LogsHtmlParser

Пространство имён: LogsParser (корневое — файл лежит в Parsing/, но папка не равна пространству имён).

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

Условие Исключение
html равен null ArgumentNullException (наследник ArgumentException)
html пуст или состоит только из пробельных символов ArgumentException

ParseLogs

public static LogsPage ParseLogs(string html)

Разбирает страницу со списком логов в LogsPage.

Искомая разметка Во что превращается
Показано с N по M из T (произвольные теги между словами допускаются) LogsPage.MetaInfo — LogPageMetaInfo(Start, End, Total)
общий макет страницы (навбар, выбор сервера) LogsPage.Account
первый <tbody> на странице источник строк для LogsPage.Entries
ячейка 1 строки LogEntry.Timestamp
ячейка 2 строки LogEntry.Text, LogEntry.Html, LogEntry.RevealedValues
ячейка 3 строки LogEntry.Sender / LogEntry.Target — Money, Bank, Donate и AdditionalInfo
ячейка 4 строки LogParticipant.LastIp и LogParticipant.RegistrationIp

Подробности разбора строки:

  • Строки, в которых меньше двух ячеек <td>, пропускаются молча.
  • Ячейка действия сохраняет свой сырой внутренний HTML в LogEntry.Html. LogEntry.Text — та же ячейка, но с удалёнными блоками, которые сайт прячет за «глазом» (class="app__hidden"), поэтому Text соответствует тому, что страница действительно показывает. Каждый скрытый блок становится LogRevealedValue, у которого Label — это атрибут data-title блока (пустая строка, если атрибута нет), а Text сохраняет разбиение блока на строки.
  • В ячейке данных маркеры I: и II: определяют отправителя и получателя соответственно. Скрытый информационный блок относится к тому маркеру, за которым он следует, а не сопоставляется по индексу, поэтому строка только с участником II: оставляет Sender равным null. Информационный блок превращается в LogAdditionalInfo только если содержит не меньше десяти значений <code>; иначе он отбрасывается.
  • Если в ячейке данных маркеров I:/II: нет вовсе, первый скрытый блок читается как информация отправителя, а второй — как информация получателя.
  • В ячейке с IP каждому элементу table-ip нужны один <code> (I: или II:) и минимум две ссылки: первая ссылка — последний IP, вторая — регистрационный.
  • Sender и Target равны null, пока не найдено хотя бы одно из трёх: деньги, дополнительная информация или последний IP.

Отсутствие разметки не приводит к исключению:

Ситуация Результат
нет <tbody> LogsPage с пустой коллекцией Entries; MetaInfo и Account всё равно заполняются, если присутствуют
нет строки постраничной навигации MetaInfo равен null
нет разметки аккаунта Account равен null
строки, в которых меньше двух ячеек <td> пропускаются, остальные строки возвращаются
using LogsParser;
using LogsParser.Models;

LogsPage page = LogsHtmlParser.ParseLogs(html);

Console.WriteLine($"{page.Entries.Count} entries of {page.MetaInfo?.Total} total");

foreach (LogEntry entry in page.Entries)
{
    Console.WriteLine($"{entry.Timestamp:u} {entry.Text}");

    foreach (LogRevealedValue value in entry.RevealedValues)
    {
        Console.WriteLine($"  [{value.Label}] {value.Text}");
    }

    if (entry.Sender is { } sender)
    {
        Console.WriteLine($"  sender: {sender.Money} / {sender.Bank} / {sender.Donate} from {sender.LastIp}");
    }
}

Исключения

Исключение Когда
ArgumentNullException html равен null
ArgumentException html пуст или состоит из пробельных символов
FormatException не удалось разобрать отметку времени строки или числовую ячейку — значения читаются через DateTime.Parse, int.Parse, long.Parse с CultureInfo.InvariantCulture

ParseAdminActivity

public static AdminActivityReport ParseAdminActivity(string html)

Разбирает отчёт об активности администраторов в AdminActivityReport.

Обязательная разметка Во что превращается
<input name="min_period" value="…"> и <input name="max_period" value="…"> AdminActivityReport.Period (From, To)
первый <tbody> на странице AdminActivityReport.Entries
блоки app__hidden внутри этого <tbody> AdminActivityEntry.Details — разбивка по дням

Подробности разбора:

  • Скрытые блоки удаляются из таблицы до чтения строк, после чего n-й скрытый блок сопоставляется с n-й оставшейся строкой.
  • Строке нужно минимум десять ячеек <td>, иначе она пропускается.
  • Внутри скрытого блока первая строка считается заголовком и пропускается; каждой следующей строке нужно минимум семь ячеек <th>, чтобы стать AdminActivityDay.
  • AdminActivityEntry.TotalOnline — сумма значений Online по дням, поэтому она равна TimeSpan.Zero, если у строки нет блока с деталями.
  • AdminActivityMetaInfo вычисляется, а не берётся из разметки: AdminCount — количество записей, PeriodDays — разница периода в днях, ограниченная снизу нулём, TotalReports и TotalBans — суммы по записям.
using LogsParser;
using LogsParser.Exceptions;
using LogsParser.Models;

try
{
    AdminActivityReport report = LogsHtmlParser.ParseAdminActivity(html);

    Console.WriteLine($"{report.Period.From:yyyy-MM-dd} — {report.Period.To:yyyy-MM-dd}");
    Console.WriteLine($"{report.MetaInfo.AdminCount} admins, {report.MetaInfo.TotalBans} bans");

    foreach (AdminActivityEntry entry in report.Entries)
    {
        Console.WriteLine($"{entry.Nickname} [{entry.Id}] online {entry.TotalOnline} over {entry.Details.Count} days");
    }
}
catch (HtmlParsingException ex)
{
    Console.Error.WriteLine($"unexpected markup: {ex.Message}");
}

Исключения

Исключение Когда
ArgumentNullException html равен null
ArgumentException html пуст или состоит из пробельных символов
HtmlParsingException отсутствует один из инпутов периода — сообщение Admin activity period was not found.
HtmlParsingException нет <tbody> — сообщение Admin activity table was not found.
FormatException не удалось разобрать значение периода, дату или числовую ячейку

ParseTopOperations

public static TopOperationsReport ParseTopOperations(string html)

Разбирает отчёт по топу операций в TopOperationsReport.

Обязательная разметка Во что превращается
Данные за: yyyy-MM-dd TopOperationsReport.Date
первый <tbody> на странице TopOperationsReport.Entries

Подробности разбора:

  • HTML декодируется перед поиском даты, поэтому подпись, экранированная HTML-сущностями, всё равно находится.
  • Строке нужно минимум шесть ячеек <td>, иначе она пропускается.
  • Ячейки 3 и 4 — IP-адреса. Значение, которое IPAddress.TryParse отвергает, записывается в лог как предупреждение и заменяется на IPAddress.Loopback, а не приводит к исключению.
  • TopOperationsMetaInfo вычисляется по записям: PlayerCount, сумма TotalTransactions и сумма Sum типа ulong.
using LogsParser;
using LogsParser.Exceptions;
using LogsParser.Models;

try
{
    TopOperationsReport report = LogsHtmlParser.ParseTopOperations(html);

    Console.WriteLine($"{report.Date:yyyy-MM-dd}: {report.MetaInfo.PlayerCount} players, {report.MetaInfo.TotalSum} total");

    foreach (TopOperationsEntry entry in report.Entries)
    {
        Console.WriteLine($"{entry.Nickname} [{entry.Id}] {entry.TotalTransactions} ops, {entry.Sum} from {entry.Ip}");
    }
}
catch (HtmlParsingException ex)
{
    Console.Error.WriteLine($"unexpected markup: {ex.Message}");
}

Исключения

Исключение Когда
ArgumentNullException html равен null
ArgumentException html пуст или состоит из пробельных символов
HtmlParsingException отсутствует подпись с датой — сообщение Top operations date was not found.
HtmlParsingException нет <tbody> — сообщение Top operations table was not found.
FormatException не удалось разобрать дату или числовую ячейку

Асимметрия между тремя методами

ParseLogs деградирует, ParseAdminActivity и ParseTopOperations выбрасывают исключение. Это сделано намеренно, а не по недосмотру.

Список логов вполне законно может прийти пустым — фильтр, не совпавший ни с одним событием, даёт страницу без строк, — поэтому пустая LogsPage является корректным ответом, а информация о постраничной навигации и контекст аккаунта всё равно возвращаются, если страница их содержит. У двух отчётов, наоборот, всегда есть период (или дата) и таблица, раз они вообще отрисовались; их отсутствие означает, что ответ — не та страница, которую запрашивали, и HtmlParsingException сообщает об этом вместо отчёта с нулём администраторов.

LogsFilterCatalogParser

public static partial class LogsFilterCatalogParser

Пространство имён: LogsParser.Parsing.

Parse

public static LogsFilterCatalog Parse(string html)

Разбирает каталог фильтров с любой страницы, отрисовывающей форму поиска — в собственном конвейере библиотеки это корень сайта /. Возвращает LogsFilterCatalog.

Искомая разметка Во что превращается
опции <select name="type[]"> LogsFilterCatalog.Filters — по одному LogsFilterDefinition на опцию
атрибут value опции LogsFilterDefinition.Code
текст опции LogsFilterDefinition.Name
блоки <div class="… js_component_filter_item …" data-filter-type="CODE"> LogsFilterDefinition.AdditionalParameters
<label> внутри такого блока LogsFilterAdditionalParameter.Label
атрибут name у <input>, <select> или <textarea> внутри такого блока LogsFilterAdditionalParameter.QueryKey (ключ dynamic[n])
общий макет страницы (навбар, выбор сервера) LogsFilterCatalog.Account

Подробности разбора:

  • Опции с пустым или отсутствующим value отбрасываются, поэтому опция-заполнитель реальной формы не становится фильтром.
  • Блоки динамических параметров группируются по data-filter-type и дедуплицируются по QueryKey с помощью StringComparer.Ordinal.
  • Фильтр, для которого блока не нашлось, получает пустую коллекцию AdditionalParameters, но никогда не null.
  • Если селект type[] отсутствует, каталог возвращается с пустой коллекцией Filters — этот метод никогда не выбрасывает HtmlParsingException. Account при этом всё равно заполняется, если макет присутствует.

Значения QueryKey — это ровно те ключи, которые принимает LogsQuery.AdditionalParameters, поэтому каталог и является штатным способом их узнать; см. Запросы и построитель URI.

using LogsParser.Models;
using LogsParser.Parsing;

LogsFilterCatalog catalog = LogsFilterCatalogParser.Parse(html);

foreach (LogsFilterDefinition filter in catalog.Filters)
{
    Console.WriteLine($"{filter.Code} — {filter.Name}");

    foreach (LogsFilterAdditionalParameter parameter in filter.AdditionalParameters)
    {
        Console.WriteLine($"    {parameter.QueryKey}: {parameter.Label}");
    }
}

Console.WriteLine($"signed in as {catalog.Account?.Nickname}");

Исключения

Исключение Когда
ArgumentNullException html равен null
ArgumentException html пуст или состоит из пробельных символов

Информация об аккаунте в общем макете

LogsPage.Account и LogsFilterCatalog.Account имеют тип LogsAccount?, и оба заполняются внутренним парсером аккаунта, который читает макет, общий для всех аутентифицированных страниц: ссылку с ником в навбаре, бейджи рядом с ней и селект server_number. Именно поэтому запрос логов и запрос каталога возвращают один и тот же контекст аккаунта без отдельного вызова «кто я».

Элемент макета Во что превращается
ссылка выпадающего меню в навбаре LogsAccount.Nickname
span-бейджи в правом списке навбара LogsAccount.Badges — с дедупликацией, порядковое сравнение
опции <select name="server_number"> LogsAccount.AvailableServers
числовой value опции LogsAccountServer.Id
текст опции LogsAccountServer.DisplayName; Name — тот же текст без ведущего префикса [id]
атрибут selected LogsAccountServer.IsSelected

Если не найдено ни одного из трёх — ни ника, ни бейджей, ни серверов, — Account равен null. Поэтому неаутентифицированная страница даёт null, а не пустой LogsAccount. Сам парсер аккаунта не входит в публичный API.

Использование без HTTP

Разбор HTML-файла с диска

using LogsParser;
using LogsParser.Models;

string html = await File.ReadAllTextAsync(@"C:\captures\logs-2026-07-26.html");
LogsPage page = LogsHtmlParser.ParseLogs(html);

Console.WriteLine($"{page.Entries.Count} entries, account {page.Account?.Nickname ?? "(anonymous)"}");

Разбор HTML, полученного где-то ещё

Подходит любая строка — тело ответа, полученное обычным HttpClient, фикстура в тесте, сохранённая браузером страница. Ничто в парсерах не требует, чтобы HTML пришёл через транспорт библиотеки.

using LogsParser;
using LogsParser.Models;
using LogsParser.Parsing;

using var http = new HttpClient { BaseAddress = new Uri("https://arizonarp.logsparser.info") };

// Сессионная cookie, полученная другим способом; сами парсеры cookie не читают и не устанавливают.
http.DefaultRequestHeaders.Add("Cookie", "arizonarp_session=…");

string root = await http.GetStringAsync("/");
LogsFilterCatalog catalog = LogsFilterCatalogParser.Parse(root);

string listing = await http.GetStringAsync("/?server_number=201&type%5B%5D=warn");
LogsPage page = LogsHtmlParser.ParseLogs(listing);

Если нужна полноценная сессия — вход, подтверждение двухфакторной аутентификации, обход anti-DDoS, повторные попытки и учёт лимитов — используйте LogsParserClient поверх LogsParserHttpDataSource вместо самостоятельных запросов. Парсеры остаются доступны для случаев, когда HTML уже есть на руках.

Диагностика

LogsHtmlParser пишет в фасад логирования библиотеки под категорией LogsHtmlParser: Debug — итоговые счётчики по вызову, Trace — пропущенные строки и извлечённые скрытые блоки, Warning — отсутствие обязательной подписи или откат IP-адреса на loopback. LogsFilterCatalogParser не логирует. О подключении фабрики логирования см. Логирование.

Замечания о реализации

Всё в этом разделе — внутренние детали, а не публичный API; они могут измениться в любом релизе.

  • Разбор выполняется самописными регулярными выражениями поверх внутреннего HtmlFragmentReader. Он даёт извлечение сбалансированных блоков с учётом вложенности (парный закрывающий тег ищется по глубине вложенности, а не ленивым .*?), нормализацию текста со снятием тегов и схлопыванием пробелов, а также многострочный вариант нормализации, сохраняющий переносы строк внутри раскрываемых значений.
  • Шаблоны строк и ячеек намеренно «нестрогие»: строка матчится до следующего <tr> или до конца секции таблицы, поэтому отсутствующий </tr> — который реальный сайт действительно отдаёт — не поглощает остаток таблицы.
  • Ряд русских литералов является несущей конструкцией, так как повторяет разметку реального сайта: Показано с … по … из для информации о постраничной навигации и Данные за: для даты топа операций. То же относится к именам классов и атрибутов app__hidden, data-title, table-ip, js_component_filter_item, data-filter-type и к именам элементов формы type[] и server_number.
  • Шаблон постраничной навигации допускает произвольную разметку между ключевыми словами и числами, потому что сайт оборачивает счётчики в теги <strong>, а декодирование HTML теги не удаляет.
  • Некоторые шаблоны по построению чувствительны к порядку: блок динамического параметра распознаётся только тогда, когда class идёт до data-filter-type в <div>, а <label> — до элемента ввода.
  • Новые регулярные выражения оформляются как private static partial Regex с атрибутом [GeneratedRegex], из-за чего классы парсеров объявлены partial.
  • Сторонний HTML-парсер не используется. Это осознанное проектное решение, и именно поэтому у пакета всего две зависимости: Microsoft.Extensions.DependencyInjection и Microsoft.Extensions.Logging.Abstractions.

См. также

Clone this wiki locally