Skip to content

Parsers

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

Parsers

English Русский

This page covers the two public parsers, LogsHtmlParser and LogsFilterCatalogParser, which turn a page's HTML into typed models without performing any HTTP request. It documents their signatures, the markup each one needs to find, and what happens when that markup is absent.

Why the parsers are standalone

Every public operation of the library follows the same pipeline: the client builds a URI, the data source returns a raw HTML string, and a static parser converts that string into records. The parsers occupy the last stage only. They are pure static functions over a string — no HTTP client, no cookies, no authentication, no state — so a caller who obtains the HTML by other means (an own HttpClient, a browser export, an on-disk capture, a fixture in a test) can parse it with exactly the same code path the library uses internally.

Two consequences worth knowing:

  • Nothing in the parsers can fail for network reasons; the only failures are "the expected markup is not there".
  • LogsParserClient adds nothing on top of them. GetLogsAsync is LogsHtmlParser.ParseLogs applied to the HTML the data source returned, and the same holds for the other three methods.
Client method Parser it calls
GetLogsAsync LogsHtmlParser.ParseLogs
GetAdminActivityAsync LogsHtmlParser.ParseAdminActivity
GetTopOperationsAsync LogsHtmlParser.ParseTopOperations
GetLogsFilterCatalogAsync LogsFilterCatalogParser.Parse

LogsHtmlParser

public static partial class LogsHtmlParser

Namespace: LogsParser (the root namespace — the file lives in Parsing/, but the folder is not the namespace).

All three methods validate their argument first and throw before touching the markup.

Condition Exception
html is null ArgumentNullException (derives from ArgumentException)
html is empty or whitespace only ArgumentException

ParseLogs

public static LogsPage ParseLogs(string html)

Parses a log listing page into a LogsPage.

Markup it looks for Maps to
Показано с N по M из T (arbitrary tags between the words are tolerated) LogsPage.MetaInfo — LogPageMetaInfo(Start, End, Total)
the page layout (navbar, server select) LogsPage.Account
the first <tbody> on the page the row source for LogsPage.Entries
cell 1 of a row LogEntry.Timestamp
cell 2 of a row LogEntry.Text, LogEntry.Html, LogEntry.RevealedValues
cell 3 of a row LogEntry.Sender / LogEntry.Target — Money, Bank, Donate and AdditionalInfo
cell 4 of a row LogParticipant.LastIp and LogParticipant.RegistrationIp

Details of the row mapping:

  • Rows with fewer than two <td> cells are skipped silently.
  • The action cell keeps its raw inner HTML in LogEntry.Html. LogEntry.Text is the same cell with the blocks the site hides behind an eye toggle (class="app__hidden") removed, so Text mirrors what the page actually shows. Each hidden block becomes a LogRevealedValue whose Label is the block's data-title attribute (empty string when the attribute is missing) and whose Text keeps the block's line structure.
  • In the data cell, the I: and II: markers select the sender and the target respectively. A hidden information block is attributed to the marker it follows, not paired by index, so a row that carries only a II: participant leaves Sender null. An information block yields a LogAdditionalInfo only when it contains at least ten <code> values; otherwise it is dropped.
  • When the data cell has no I:/II: markers at all, the first hidden block is read as the sender's information and the second as the target's.
  • In the IP cell, each table-ip element needs one <code> (I: or II:) and at least two links; the first link is the last IP, the second the registration IP.
  • Sender and Target are null unless at least one of money, additional information or last IP was found.

Missing markup does not throw:

Situation Result
no <tbody> LogsPage with an empty Entries collection; MetaInfo and Account are still filled if present
no pagination line MetaInfo is null
no account markup Account is null
rows with fewer than two <td> cells skipped, the remaining rows are returned
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}");
    }
}

Throws

Exception When
ArgumentNullException html is null
ArgumentException html is empty or whitespace
FormatException a row's timestamp or a numeric cell cannot be parsed — values are read with DateTime.Parse, int.Parse, long.Parse under CultureInfo.InvariantCulture

ParseAdminActivity

public static AdminActivityReport ParseAdminActivity(string html)

Parses the administrator activity report into an AdminActivityReport.

Markup it requires Maps to
<input name="min_period" value="…"> and <input name="max_period" value="…"> AdminActivityReport.Period (From, To)
the first <tbody> on the page AdminActivityReport.Entries
app__hidden blocks inside that <tbody> AdminActivityEntry.Details — the per-day breakdown

Details of the mapping:

  • The hidden blocks are removed from the table before the rows are read, and the n-th hidden block is matched to the n-th remaining row.
  • A row needs at least ten <td> cells, otherwise it is skipped.
  • Inside a hidden block, the first row is treated as a header and skipped; every following row needs at least seven <th> cells to become an AdminActivityDay.
  • AdminActivityEntry.TotalOnline is the sum of the days' Online values, so it is TimeSpan.Zero when a row carries no detail block.
  • AdminActivityMetaInfo is computed, not scraped: AdminCount is the number of entries, PeriodDays is the day difference of the period clamped at zero, TotalReports and TotalBans are the sums over the entries.
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}");
}

Throws

Exception When
ArgumentNullException html is null
ArgumentException html is empty or whitespace
HtmlParsingException either period input is missing — message Admin activity period was not found.
HtmlParsingException no <tbody> — message Admin activity table was not found.
FormatException a period value, date or numeric cell cannot be parsed

ParseTopOperations

public static TopOperationsReport ParseTopOperations(string html)

Parses the top operations report into a TopOperationsReport.

Markup it requires Maps to
Данные за: yyyy-MM-dd TopOperationsReport.Date
the first <tbody> on the page TopOperationsReport.Entries

Details of the mapping:

  • The HTML is decoded before the date is matched, so an entity-escaped label is still found.
  • A row needs at least six <td> cells, otherwise it is skipped.
  • Cells 3 and 4 are IP addresses. A value that IPAddress.TryParse rejects is logged as a warning and replaced with IPAddress.Loopback rather than throwing.
  • TopOperationsMetaInfo is computed from the entries: PlayerCount, the sum of TotalTransactions and the ulong sum of Sum.
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}");
}

Throws

Exception When
ArgumentNullException html is null
ArgumentException html is empty or whitespace
HtmlParsingException the date label is missing — message Top operations date was not found.
HtmlParsingException no <tbody> — message Top operations table was not found.
FormatException the date or a numeric cell cannot be parsed

The asymmetry between the three

ParseLogs degrades, ParseAdminActivity and ParseTopOperations throw. This is deliberate, not an oversight.

A log listing legitimately comes back with nothing in it — a filter that matched no events produces a page with no rows — so an empty LogsPage is a valid answer, and the pagination meta info and account context are still returned when the page carries them. The two reports, by contrast, always have a period (or a date) and a table when they render at all; their absence means the response was not the page that was asked for, and HtmlParsingException says so instead of returning a report of zero administrators.

LogsFilterCatalogParser

public static partial class LogsFilterCatalogParser

Namespace: LogsParser.Parsing.

Parse

public static LogsFilterCatalog Parse(string html)

Parses the filter catalogue out of any page that renders the search form — in the library's own pipeline this is the site root /. Returns a LogsFilterCatalog.

Markup it looks for Maps to
<select name="type[]"> options LogsFilterCatalog.Filters — one LogsFilterDefinition per option
an option's value attribute LogsFilterDefinition.Code
an option's text LogsFilterDefinition.Name
<div class="… js_component_filter_item …" data-filter-type="CODE"> blocks LogsFilterDefinition.AdditionalParameters
the <label> inside such a block LogsFilterAdditionalParameter.Label
the name of the <input>, <select> or <textarea> inside such a block LogsFilterAdditionalParameter.QueryKey (the dynamic[n] key)
the page layout (navbar, server select) LogsFilterCatalog.Account

Details of the mapping:

  • Options with an empty or missing value are dropped, so the placeholder option of the real form does not become a filter.
  • Dynamic parameter blocks are grouped by their data-filter-type and deduplicated by QueryKey with StringComparer.Ordinal.
  • A filter with no matching block gets an empty AdditionalParameters collection, never null.
  • When the type[] select is absent the catalogue comes back with an empty Filters collection — this method never throws HtmlParsingException. Account is still filled if the layout is present.

The QueryKey values are exactly the keys accepted by LogsQuery.AdditionalParameters, which is why the catalogue is the supported way to discover them — see Requests and URI Builder.

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}");

Throws

Exception When
ArgumentNullException html is null
ArgumentException html is empty or whitespace

Account information in the shared layout

LogsPage.Account and LogsFilterCatalog.Account are both LogsAccount? and both are filled by an internal account parser that reads the layout every authenticated page shares: the nickname link in the navbar, the badges next to it, and the server_number select. That is why a log request and a catalogue request return the same account context without a dedicated "who am I" call.

Layout element Maps to
the navbar dropdown link LogsAccount.Nickname
badge spans in the right-hand navbar list LogsAccount.Badges — deduplicated, ordinal
<select name="server_number"> options LogsAccount.AvailableServers
an option's numeric value LogsAccountServer.Id
an option's text LogsAccountServer.DisplayName; Name is the same text with a leading [id] prefix stripped
the selected attribute LogsAccountServer.IsSelected

When none of the three is found — no nickname, no badges, no servers — Account is null. An unauthenticated page therefore yields null rather than an empty LogsAccount. The account parser itself is not part of the public API.

Offline usage

Parsing an HTML file from disk

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)"}");

Parsing HTML captured elsewhere

Any string works — a response body fetched with a plain HttpClient, a fixture embedded in a test, a page saved by a browser. Nothing in the parsers requires the HTML to have come from the library's transport.

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

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

// A session cookie obtained elsewhere; the parsers themselves neither read nor set cookies.
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);

If the goal is a full session — login, two-factor confirmation, the anti-DDoS challenge, retries and rate limiting — use LogsParserClient over LogsParserHttpDataSource instead of hand-rolling the requests. The parsers stay available for the cases where the HTML is already in hand.

Diagnostics

LogsHtmlParser writes to the library's logging facade under the category LogsHtmlParser: Debug for the per-call result counts, Trace for skipped rows and extracted hidden blocks, Warning when a required label is missing or an IP address falls back to loopback. LogsFilterCatalogParser does not log. See Logging for how to attach a factory.

Implementation notes

Everything in this section is internal detail, not public API, and may change in any release.

  • Parsing is hand-rolled regex over an internal HtmlFragmentReader. It provides depth-aware balanced-block extraction (finding the matching close tag by tracking nesting depth rather than by a lazy .*?), plus text normalisation that strips tags and collapses whitespace, and a multiline variant that preserves line breaks inside revealed values.
  • Row and cell patterns are deliberately loose: a row is matched up to the next <tr> or the end of the table section, so a missing </tr> — which the real site emits — does not swallow the rest of the table.
  • Several Russian literals are load-bearing because they mirror the real site's markup: Показано с … по … из for the pagination meta info and Данные за: for the top operations date. So are the class and attribute names app__hidden, data-title, table-ip, js_component_filter_item, data-filter-type, and the form control names type[] and server_number.
  • The pagination pattern allows arbitrary markup between the keywords and the numbers, because the site wraps the counts in <strong> tags and HTML decoding does not remove tags.
  • Some patterns are order-sensitive by construction: a dynamic parameter block is only recognised when class precedes data-filter-type on the <div> and the <label> precedes the control.
  • New regexes live in [GeneratedRegex]-annotated private static partial Regex members, which is why the parser classes are declared partial.
  • No third-party HTML parser is used. That is a deliberate design decision, and it is the reason the package carries only two dependencies, Microsoft.Extensions.DependencyInjection and Microsoft.Extensions.Logging.Abstractions.

See also

Clone this wiki locally