initial commit
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user