using System.Globalization;
using System.Text.RegularExpressions;
using Blog.Models;
namespace Blog.Services;
///
/// Turns warframe.com/droptables into .
///
///
/// The page is one 4 MB machine-generated document: twenty <h3 id> sections, each a
/// single flat table whose rows carry no classes or ids. Three row grammars cover all twenty:
///
///
/// - Two-column reward tables (missions, keys, dynamic locations, sorties, relics): a
/// full-width header names the place, an optional Rotation X header splits it, and every
/// other row is item | rarity (chance).
/// - Three-column bounty tables: as above, plus an indented header per stage and a leading
/// spacer cell on every row.
/// - Three-column "by source" tables (mods, blueprints, resources, relics by enemy): a header
/// naming the enemy and its overall drop chance, then that table's contents.
///
///
/// Rows that match no grammar are skipped rather than thrown over, so a section DE add later costs
/// coverage instead of the whole page.
///
public static partial class DropTableParser
{
[GeneratedRegex("""
]*>""")]
private static partial Regex SectionHeading { get; }
[GeneratedRegex("""[^>]*)>(?.*?)
""", RegexOptions.Singleline)]
private static partial Regex RowPattern { get; }
[GeneratedRegex("""<(?th|td)(?[^>]*)>(?.*?)\k>""", RegexOptions.Singleline)]
private static partial Regex CellPattern { get; }
/// DE write every chance as Rare (7.69%).
[GeneratedRegex(@"^(?[A-Za-z ]+?)\s*\((?[0-9.]+)%\)$")]
private static partial Regex ChancePattern { get; }
/// The "by source" headers all end in … Drop Chance: 3.00%.
[GeneratedRegex(@"Drop Chance:\s*(?[0-9.]+)%")]
private static partial Regex SourceChancePattern { get; }
[GeneratedRegex(@"Last Update:\s*(?[^<\r\n]+)")]
private static partial Regex LastUpdatePattern { get; }
/// A relic appears four times, once per refinement: Axi A1 Relic (Intact).
[GeneratedRegex(@"^(?.+? Relic) \((?Intact|Exceptional|Flawless|Radiant)\)$")]
private static partial Regex RelicHeaderPattern { get; }
/// Which grammar each section is written in, and what its sources should be called.
private static readonly Dictionary RewardSections = new(StringComparer.Ordinal)
{
["missionRewards"] = DropCategory.Mission,
["keyRewards"] = DropCategory.Key,
["transientRewards"] = DropCategory.Dynamic,
["sortieRewards"] = DropCategory.Sortie
};
private static readonly string[] BountySections =
[
"cetusRewards", "solarisRewards", "deimosRewards",
"zarimanRewards", "entratiLabRewards", "hexRewards"
];
private static readonly string[] SourceSections =
[
"modByAvatar", "blueprintByAvatar", "resourceByAvatar",
"sigilByAvatar", "additionalItemByAvatar", "relicByAvatar"
];
public static DropTables Parse(string html, DateTimeOffset fetchedAt)
{
var builder = new Builder();
foreach (var (id, section) in Sections(html))
{
if (RewardSections.TryGetValue(id, out var category))
{
ParseRewards(section, category, builder);
}
else if (BountySections.Contains(id, StringComparer.Ordinal))
{
ParseBounties(section, builder);
}
else if (SourceSections.Contains(id, StringComparer.Ordinal))
{
ParseBySource(section, builder);
}
else if (id == "relicRewards")
{
ParseRelics(section, builder);
}
// modByDrop, blueprintByDrop and resourceByDrop hold the *ByAvatar data indexed the
// other way round. Builder indexes both ways, so reading them would only duplicate.
}
return builder.Build(LastUpdate(html), fetchedAt);
}
private static string LastUpdate(string html)
{
var match = LastUpdatePattern.Match(html);
return match.Success ? match.Groups["date"].Value.Trim() : "unknown";
}
/// Each <h3 id> and everything up to the next one.
private static IEnumerable<(string Id, string Html)> Sections(string html)
{
var headings = SectionHeading.Matches(html);
for (var i = 0; i < headings.Count; i++)
{
var start = headings[i].Index;
var end = i + 1 < headings.Count ? headings[i + 1].Index : html.Length;
yield return (headings[i].Groups["id"].Value, html[start..end]);
}
}
/// Missions, keys, dynamic locations and sorties: a place, maybe a rotation, then drops.
private static void ParseRewards(string section, DropCategory category, Builder builder)
{
string? location = null;
string? rotation = null;
foreach (var row in Rows(section))
{
switch (row)
{
// `Rotation A` splits the place above it; anything else full-width is a new place.
case [{ Header: true, FullWidth: true, Text: var header }]:
if (IsRotation(header)) rotation = header;
else (location, rotation) = (header, null);
break;
// Dynamic locations write their rotations as a header and an empty cell instead.
case [{ Header: true, Text: var header }, { Text: "" }]:
rotation = IsRotation(header) ? header : rotation;
break;
case [{ Header: false, Text: var item }, { Header: false, Text: var chance }]
when location is not null && Chance(chance) is (var rarity, var pct):
builder.AddSource(item, new DropSource(category, location, rotation, rarity, pct));
break;
}
}
}
/// Open-world bounties: a bounty, a rotation and a stage, then drops behind a spacer cell.
private static void ParseBounties(string section, Builder builder)
{
string? bounty = null;
string? rotation = null;
string? stage = null;
foreach (var row in Rows(section))
{
switch (row)
{
case [{ Header: true, FullWidth: true, Text: var header }]:
if (IsRotation(header)) rotation = header;
else (bounty, rotation, stage) = (header, null, null);
break;
case [{ Spacer: true }, { Header: true, Text: var header }]:
stage = header;
break;
case [_, { Header: false, Text: var item }, { Header: false, Text: var chance }]
when bounty is not null && Chance(chance) is (var rarity, var pct):
builder.AddSource(item, new DropSource(DropCategory.Bounty, bounty, Join(rotation, stage), rarity, pct));
break;
}
}
}
///
/// Drops by enemy. The header carries how often the enemy drops from its table at all, so a
/// Very Common (100%) line off a 3% table is a 3% kill.
///
private static void ParseBySource(string section, Builder builder)
{
string? source = null;
var sourceChance = 100.0;
foreach (var row in Rows(section))
{
switch (row)
{
case [{ Header: true, Text: var name }, { Header: true, Text: var chance }]:
source = name;
var match = SourceChancePattern.Match(chance);
sourceChance = match.Success ? Percent(match.Groups["pct"].Value) : 100.0;
break;
case [_, { Header: false, Text: var item }, { Header: false, Text: var chance }]
when source is not null && Chance(chance) is (var rarity, var pct):
builder.AddSource(item,
new DropSource(DropCategory.Enemy, source, null, rarity, pct * sourceChance / 100.0));
break;
}
}
}
///
/// Relic contents. Every relic is listed once per refinement; only Intact and Radiant are kept.
///
private static void ParseRelics(string section, Builder builder)
{
string? relic = null;
string? refinement = null;
foreach (var row in Rows(section))
{
switch (row)
{
case [{ Header: true, FullWidth: true, Text: var header }]:
var match = RelicHeaderPattern.Match(header);
(relic, refinement) = match.Success
? (match.Groups["relic"].Value, match.Groups["refinement"].Value)
: (null, null);
break;
case [{ Header: false, Text: var item }, { Header: false, Text: var chance }]
when relic is not null && Chance(chance) is (var rarity, var pct):
builder.AddRelicLine(relic, item, rarity, pct, refinement == "Radiant");
break;
}
}
}
private static bool IsRotation(string header) =>
header.StartsWith("Rotation", StringComparison.OrdinalIgnoreCase);
private static string? Join(string? rotation, string? stage) =>
(rotation, stage) switch
{
(null, null) => null,
(null, var s) => s,
(var r, null) => r,
var (r, s) => $"{r} · {s}"
};
private static (string Rarity, double Percent)? Chance(string text)
{
var match = ChancePattern.Match(text);
return match.Success
? (match.Groups["rarity"].Value, Percent(match.Groups["pct"].Value))
: null;
}
private static double Percent(string text) =>
double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var value) ? value : 0;
/// One cell: what it says, and the two attributes the grammars branch on.
private readonly record struct Cell(string Text, bool Header, bool FullWidth, bool Spacer);
private static IEnumerable Rows(string section)
{
foreach (Match row in RowPattern.Matches(section))
{
var cells = CellPattern.Matches(row.Groups["body"].Value);
if (cells.Count == 0) continue;
var parsed = new Cell[cells.Count];
for (var i = 0; i < cells.Count; i++)
{
var attrs = cells[i].Groups["attrs"].Value;
parsed[i] = new Cell(
Text: cells[i].Groups["text"].Value.Trim(),
Header: cells[i].Groups["tag"].Value == "th",
FullWidth: attrs.Contains("colspan", StringComparison.Ordinal),
Spacer: attrs.Contains("pad-cell", StringComparison.Ordinal));
}
yield return parsed;
}
}
/// Collects rows as they are read, then sorts and indexes them.
private sealed class Builder
{
private readonly Dictionary> _sources = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<(string Relic, string Item), RelicLine> _relicLines = new();
public void AddSource(string item, DropSource source)
{
if (item.Length == 0) return;
if (!_sources.TryGetValue(item, out var list))
{
_sources[item] = list = [];
}
list.Add(source);
}
public void AddRelicLine(string relic, string item, string rarity, double chance, bool radiant)
{
if (item.Length == 0) return;
var existing = _relicLines.GetValueOrDefault((relic, item))
?? new RelicLine(relic, item, rarity, Intact: 0, Radiant: 0);
_relicLines[(relic, item)] = radiant
? existing with { Radiant = chance }
: existing with { Rarity = rarity, Intact = chance };
}
public DropTables Build(string lastUpdate, DateTimeOffset fetchedAt)
{
var byRelic = _relicLines.Values
.GroupBy(line => line.Relic, StringComparer.OrdinalIgnoreCase)
.ToDictionary(
group => group.Key,
group => (IReadOnlyList)group.OrderByDescending(line => line.Intact).ToArray(),
StringComparer.OrdinalIgnoreCase);
var byItem = _relicLines.Values
.GroupBy(line => line.Item, StringComparer.OrdinalIgnoreCase)
.ToDictionary(
group => group.Key,
group => (IReadOnlyList)group.OrderByDescending(line => line.Intact).ToArray(),
StringComparer.OrdinalIgnoreCase);
// A relic is both an item you farm and a container of items, so both indexes and the
// drop sources meet under one name.
var names = _sources.Keys
.Concat(byRelic.Keys)
.Concat(byItem.Keys)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(name => name, StringComparer.OrdinalIgnoreCase)
.ToArray();
var items = names.ToDictionary(
name => name,
name => new ItemDrops
{
Name = name,
Sources = _sources.TryGetValue(name, out var sources)
? sources.OrderByDescending(source => source.Chance).ToArray()
: [],
InRelics = byItem.GetValueOrDefault(name, []),
Contents = byRelic.GetValueOrDefault(name, [])
},
StringComparer.OrdinalIgnoreCase);
return new DropTables
{
LastUpdate = lastUpdate,
FetchedAt = fetchedAt,
Items = items,
Names = names
};
}
}
}
|