initial commit

This commit is contained in:
fox
2026-06-24 14:25:32 +01:00
commit 9788479ae7
109 changed files with 8666 additions and 0 deletions
@@ -0,0 +1,13 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.DependencyInjection;
namespace Solicitors.HtmlParsing;
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static class DIExtensions
{
public static IServiceCollection AddHtmlParsing(this IServiceCollection services)
{
return services.AddSingleton<IHtmlParser, HtmlParser>();
}
}
@@ -0,0 +1,130 @@
namespace Solicitors.HtmlParsing;
using Models;
internal class HtmlParser : IHtmlParser
{
private readonly string[] _voidElements =
[
"area",
"base",
"br",
"col",
"embed",
"hr",
"img",
"input",
"link",
"meta",
"source",
"track",
"wbr"
];
public IEnumerable<IHtmlNode> ParseHtml(string html)
{
// wanted to do this in one pass but was quite time-consuming
// so taking a little lesson from compiler development
// and lex the text into tokens before parsing :)
// deferred execution might help it still be "one pass"?
var stringEnumerator = html.GetEnumerator();
var tokens = LexHtml(stringEnumerator);
var tokenEnumerator = tokens.GetEnumerator();
return ParseHtml(tokenEnumerator);
}
private IEnumerable<string> LexHtml(CharEnumerator content)
{
var work = "";
while (content.MoveNext())
{
if (content.Current == '<' && !string.IsNullOrWhiteSpace(work))
{
yield return work.Trim();
work = "";
}
work += content.Current;
if (content.Current == '>' && !string.IsNullOrWhiteSpace(work))
{
yield return work.Trim();
work = "";
}
}
}
private IEnumerable<IHtmlNode> ParseHtml(IEnumerator<string> tokens)
{
while (tokens.MoveNext())
{
var token = tokens.Current;
if (!token.StartsWith('<'))
{
yield return new StringNode(token);
continue;
}
var noBraces = token.Substring(1, token.Length - 2);
var split = noBraces.Split(' ');
IHtmlAttribute[] attributes = [];
if (split.Length > 1)
{
attributes = ParseAttributes(noBraces).ToArray();
}
if (IsVoidOrSelfClosingElement(token))
yield return new HtmlNode(split.First(), attributes);
else if (token.StartsWith("</"))
yield break;
else
{
var children = ParseHtml(tokens).ToArray();
yield return new ParentHtmlNode(split.First(), attributes, children);
}
}
}
private bool IsVoidOrSelfClosingElement(string token)
{
if (token.StartsWith("<!") || token.EndsWith("/>"))
return true;
var tagName = token.Split(' ').First()[1..];
if (tagName.EndsWith('>'))
tagName = tagName[..^1];
return _voidElements.Contains(tagName);
}
private IEnumerable<IHtmlAttribute> ParseAttributes(string tokenNoBraces)
{
var attributesString = string.Join(' ', tokenNoBraces.Split(' ').Skip(1));
var attString = "";
var inQuote = false;
foreach (var c in attributesString)
{
if (!inQuote && c == ' ')
{
yield return ParseAttribute(attString);
attString = "";
}
else
{
attString += c;
if (c == '\"')
inQuote = !inQuote;
}
}
yield return ParseAttribute(attString);
}
private IHtmlAttribute ParseAttribute(string attString)
{
var split = attString.Split('=');
if (split.Length > 1)
return new HtmlValueAttribute(split[0], string.Join('=', split.Skip(1)));
else
return new HtmlAttribute(attString);
}
}
@@ -0,0 +1,8 @@
namespace Solicitors.HtmlParsing;
using Models;
public interface IHtmlParser
{
IEnumerable<IHtmlNode> ParseHtml(string html);
}
@@ -0,0 +1,6 @@
namespace Solicitors.HtmlParsing.Models;
internal class HtmlAttribute(string name) : IHtmlAttribute
{
public string Name { get; } = name;
}
@@ -0,0 +1,92 @@
using System.Diagnostics.CodeAnalysis;
namespace Solicitors.HtmlParsing.Models;
internal class HtmlNode(string name, IHtmlAttribute[] attributes)
: IHtmlNode
{
public virtual bool TryGetByTagName(
string tagName,
[NotNullWhen(true)] out IHtmlNode? node,
bool noChildren = false)
{
node = null;
if (tagName == name)
node = this;
return node is not null;
}
private string[] Classes
{
get
{
var classAttribute = attributes
.Where(a => a is HtmlValueAttribute)
.Select(a => (a as HtmlValueAttribute)!)
.FirstOrDefault(a => a.Name.Equals("class", StringComparison.CurrentCultureIgnoreCase));
return classAttribute is not null
? classAttribute.Value.Substring(1, classAttribute.Value.Length - 2).Split(' ')
: [];
}
}
public virtual bool TryGetByClass(
string className,
[NotNullWhen(true)] out IHtmlNode? node,
bool noChildren = false)
{
node = null;
if (Classes.Contains(className, StringComparer.InvariantCultureIgnoreCase))
node = this;
return node is not null;
}
public virtual bool TryGetByTagNameAndClass(
string tagName,
string className,
[NotNullWhen(true)] out IHtmlNode? node,
bool noChildren = false)
{
node = null;
if (tagName == name && Classes.Contains(className, StringComparer.InvariantCultureIgnoreCase))
node = this;
return node is not null;
}
public virtual bool TryGetText([NotNullWhen(true)] out string? text)
{
text = null;
return false;
}
public virtual bool TryGetChildren([NotNullWhen(true)] out IEnumerable<IHtmlNode>? children)
{
children = null;
return false;
}
public bool HasAttribute(string attributeName)
{
return attributes
.Any(a => a.Name.Equals(attributeName, StringComparison.InvariantCultureIgnoreCase));
}
public bool TryGetAttributeValue(string attributeName, [NotNullWhen(true)] out string? attributeValue)
{
attributeValue = null;
var matchingAttribute = attributes.FirstOrDefault(
a => a.Name.Equals(attributeName, StringComparison.InvariantCultureIgnoreCase)
);
if (matchingAttribute is HtmlValueAttribute valueAttribute)
attributeValue = valueAttribute.Value;
return attributeValue is not null;
}
}
@@ -0,0 +1,6 @@
namespace Solicitors.HtmlParsing.Models;
internal class HtmlValueAttribute(string name, string value) : HtmlAttribute(name)
{
public string Value { get; } = value;
}
@@ -0,0 +1,6 @@
namespace Solicitors.HtmlParsing.Models;
internal interface IHtmlAttribute
{
string Name { get; }
}
@@ -0,0 +1,64 @@
using System.Diagnostics.CodeAnalysis;
namespace Solicitors.HtmlParsing.Models;
public interface IHtmlNode
{
/// <summary>
/// Searches this node and its direct children for a matching tag name.
/// </summary>
/// <param name="tagName">The tag name to match.</param>
/// <param name="node">If found, the node that matches the tag name.</param>
/// <param name="noChildren">If true, only check this node, not its children.</param>
/// <returns>True if this node, or any of its direct children, have a matching tag name; false otherwise.</returns>
bool TryGetByTagName(
string tagName,
[NotNullWhen(true)] out IHtmlNode? node,
bool noChildren = false);
/// <summary>
/// Searches this node and its direct children for a matching class.
/// </summary>
/// <param name="className">The class name to match.</param>
/// <param name="node">If found, the node that matches the class name.</param>
/// <param name="noChildren">If true, only check this node, not its children.</param>
/// <returns>True if this node, or any of its direct children, have a matching class name; false otherwise.</returns>
bool TryGetByClass(
string className,
[NotNullWhen(true)] out IHtmlNode? node,
bool noChildren = false);
/// <summary>
/// Searches this node and its direct children for a matching tag name and matching class on the same node.
/// </summary>
/// <param name="tagName">The tag name to match.</param>
/// <param name="className">The class name to match.</param>
/// <param name="node">If found, the node that matches the tag name and class name.</param>
/// <param name="noChildren">If true, only check this node, not its children.</param>
/// <returns>True if this node, or any of its direct children, have a matching tag name and class name; false otherwise.</returns>
bool TryGetByTagNameAndClass(
string tagName,
string className,
[NotNullWhen(true)] out IHtmlNode? node,
bool noChildren = false);
/// <summary>
/// Checks this node (and *not* its children) to see if it is text content.
/// </summary>
/// <param name="text">If found, the text content.</param>
/// <returns>True if the node is a text node, false otherwise.</returns>
bool TryGetText([NotNullWhen(true)] out string? text);
/// <summary>
/// Checks this node to see if it has child elements.
/// </summary>
/// <param name="children">If found, the child elements.</param>
/// <returns>True if the node has child elements, false otherwise.</returns>
bool TryGetChildren([NotNullWhen(true)] out IEnumerable<IHtmlNode>? children);
bool HasAttribute(string attributeName);
bool TryGetAttributeValue(
string attributeName,
[NotNullWhen(true)] out string? attributeValue);
}
@@ -0,0 +1,92 @@
using System.Diagnostics.CodeAnalysis;
namespace Solicitors.HtmlParsing.Models;
internal class ParentHtmlNode(string tagName, IHtmlAttribute[] attributes, IHtmlNode[] children)
: HtmlNode(tagName, attributes)
{
public override bool TryGetByTagName(
string tagName,
[NotNullWhen(true)] out IHtmlNode? node,
bool noChildren = false)
{
node = null;
if (!base.TryGetByTagName(tagName, out node, noChildren) && !noChildren)
{
var nodesToCheck = new Queue<IHtmlNode>(children);
while (nodesToCheck.TryDequeue(out var child))
{
if (child.TryGetByTagName(tagName, out var childNode, true))
{
node = childNode;
break;
}
if (child.TryGetChildren(out var grandChildren))
foreach (var grandChild in grandChildren)
nodesToCheck.Enqueue(grandChild);
}
}
return node is not null;
}
public override bool TryGetByClass(
string className,
[NotNullWhen(true)] out IHtmlNode? node,
bool noChildren = false)
{
node = null;
if (!base.TryGetByClass(className, out node, noChildren) && !noChildren)
{
var nodesToCheck = new Queue<IHtmlNode>(children);
while (nodesToCheck.TryDequeue(out var child))
{
if (child.TryGetByClass(className, out var childNode, true))
{
node = childNode;
break;
}
if (child.TryGetChildren(out var grandChildren))
foreach (var grandChild in grandChildren)
nodesToCheck.Enqueue(grandChild);
}
}
return node is not null;
}
public override bool TryGetByTagNameAndClass(
string tagName,
string className,
[NotNullWhen(true)] out IHtmlNode? node,
bool noChildren = false)
{
node = null;
if (!base.TryGetByTagNameAndClass(tagName, className, out node, noChildren) && !noChildren)
{
var nodesToCheck = new Queue<IHtmlNode>(children);
while (nodesToCheck.TryDequeue(out var child))
{
if (child.TryGetByTagNameAndClass(tagName, className, out var childNode, true))
{
node = childNode;
break;
}
if (child.TryGetChildren(out var grandChildren))
foreach (var grandChild in grandChildren)
nodesToCheck.Enqueue(grandChild);
}
}
return node is not null;
}
public override bool TryGetChildren([NotNullWhen(true)] out IEnumerable<IHtmlNode>? children1)
{
children1 = children;
return children.Length != 0;
}
}
@@ -0,0 +1,57 @@
using System.Diagnostics.CodeAnalysis;
namespace Solicitors.HtmlParsing.Models;
internal class StringNode(string content)
: IHtmlNode
{
public bool TryGetByTagName(
string tagName,
[NotNullWhen(true)] out IHtmlNode? node,
bool noChildren = false)
{
node = null;
return false;
}
public bool TryGetByClass(
string className,
[NotNullWhen(true)] out IHtmlNode? node,
bool noChildren = false)
{
node = null;
return false;
}
public bool TryGetByTagNameAndClass(
string tagName,
string className,
[NotNullWhen(true)] out IHtmlNode? node,
bool noChildren = false)
{
node = null;
return false;
}
public bool TryGetText([NotNullWhen(true)] out string? text)
{
text = content;
return !string.IsNullOrEmpty(text);
}
public bool TryGetChildren([NotNullWhen(true)] out IEnumerable<IHtmlNode>? children)
{
children = null;
return false;
}
public bool HasAttribute(string attributeName) => false;
public bool TryGetAttributeValue(
string attributeName,
[NotNullWhen(true)] out string? attributeValue)
{
attributeValue = null;
return false;
}
}
@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.9" />
</ItemGroup>
</Project>