C.E.'s answer is probably the best, relying as it does on a well-constructed Java library with serious people behind it, but I thought it'd be nice to have a pure Mathematica solution as well, so I sunk some time into building an XMLGraph object which I put here.
The basic idea is to store the two pieces of data I talked about before, a Graph that holds all of the structural relationships, and an Association encoding all of the data as a flat data structure.
Each node then gets a name, e.g. "head:ee1d9544" which goes into the Graph and an entry in the Association like:
<| "Parent" -> "html:80da223b", "Type" -> "head", "Name" -> "head:ee1d9544", "Meta" -> <||>, "Children" -> {} |>
This way it's easy to mutate the object by changing entries in the flat association and it's easy to query the Graph to get out structure.
I then used this to implement conversion from XMLElement and CSS selectors, so here's a little rundown of what we can do (although development work isn't finished):
Get["https://github.com/b3m2a1/mathematica-tools/raw/master/XMLGraph.m"]; testXML = Import["https://developer.github.com/apps/building-oauth-apps/understanding-scopes-for-oauth-apps/#available-scopes", {"HTML", "XMLObject"} ]; g = XMLGraph[testXML]

Then we can query, say, all the code elements that follow a td element (this was what originally spurred me to make this). The Mathematica code for this query in the standard way looks like:
t1 = Cases[testXML, XMLElement["td", _, code_] :> FirstCase[code, XMLElement["code", _, {s_, ___}] :> s, Nothing, \[Infinity]], \[Infinity]];
The graph query looks like:
t2 = First /@ Values@ g@"Children"[g@"Select"["td code"]]; DeleteDuplicates@Sort@t1 == DeleteDuplicates@Sort@t2 True
Performance-wise we're a bit slower:
t1 = Cases[testXML, XMLElement["td", _, code_] :> FirstCase[code, XMLElement["code", _, {s_, ___}] :> s, Nothing, \[Infinity]], \[Infinity]]; // RepeatedTiming {0.00045, Null} t2 = First /@ Values@ g@"Children"[g@"Select"["td code"]]; // RepeatedTiming {0.0091, Null}
But other things like property look-ups are so much faster like this. Also we can do queries over the Association instead of the graph (when possible) which is competitive with Cases:
Cases[testXML, XMLElement["code", _, _], \[Infinity]]; // RepeatedTiming {0.00015, Null} nodes = g@"Select"["code"]; // RepeatedTiming {0.00077, Null}
And once we have this set of nodes it's easy and fast to get all sorts of information out:
g@"Children"[nodes]; // RepeatedTiming {0.00012, Null} g@"Parent"[nodes]; // RepeatedTiming {0.000082, Null} g@"Attribute"[nodes, "colspan"]; // RepeatedTiming {0.000084, Null}
This is rather more challenging to do well with XMLElement.
We can also do easy modifications like this:
newG = g@"ModifyNodes"[ Thread[nodes -> <|"colspan" -> 2|>] ]; g@"Attribute"[nodes, "colspan"] // Take[#, 5] & <|"td:ae4c93a0" -> "1", "td:c3fbb1ce" -> "1", "td:77adbfa1" -> "1", "td:3efd9c29" -> "1", "td:69452f14" -> "1"|> newG@"Attribute"[nodes, "colspan"] // Take[#, 5] & <|"td:ae4c93a0" -> 2, "td:c3fbb1ce" -> 2, "td:77adbfa1" -> 2, "td:3efd9c29" -> 2, "td:69452f14" -> 2|>
Or we can change some types and dump to XML:
mods = {newG["Root"] -> <|"Type" -> "xml"|>}; newG@"ModifyNodes"[mods]@"XML" // Shallow XMLElement["xml", <|"version" -> "-//W3C//DTD HTML 4.01 Transitional//EN", "lang" -> "en", "prefix" -> "og: http://ogp.me/ns#", {"http://www.w3.org/2000/xmlns/", "xmlns"} -> "http://www.w3.org/1999/xhtml"|>, {XMLElement[<<3>>], XMLElement[<<3>>]}]
The full list of supported methods is somewhat in flux, but it'll be exposed eventually and every method has a Symbol backing it (e.g. XMLGraph`Package`SelectNodes for "Select") so that's always possible to look at.
As a final usage note, with the structure as it's set-up the storage can always be handled by as HashTable which would allow this XMLGraph to be a truly mutable object in the way the object-oriented interface suggests it could be.
Hopefully this gives a sense of how this may be done and why this type of structure is useful. And of course, this is a preliminary package, so performance enhancements are always possible.