1use quarb::{AstAdapter, NodeId, Value};
23use scraper::{ElementRef, Html};
24use std::collections::HashMap;
25
26struct Node {
27 tag: Option<String>,
29 attrs: Vec<(String, String)>,
30 text: String,
32 parent: Option<NodeId>,
33 children: Vec<NodeId>,
34}
35
36impl Node {
37 fn attr(&self, name: &str) -> Option<&str> {
38 self.attrs
39 .iter()
40 .find(|(k, _)| k == name)
41 .map(|(_, v)| v.as_str())
42 }
43}
44
45pub struct HtmlAdapter {
47 nodes: Vec<Node>,
48 ids: HashMap<String, NodeId>,
50 root: NodeId,
51 document_url: Option<url::Url>,
56}
57
58impl HtmlAdapter {
59 fn rel_ok(&self, node: NodeId, hint: Option<&str>) -> bool {
66 match hint {
67 None | Some("*") => true,
68 Some(h) => self.nodes[node.0 as usize]
69 .attr("rel")
70 .is_some_and(|rel| rel.split_whitespace().any(|w| w.eq_ignore_ascii_case(h))),
71 }
72 }
73
74 pub fn set_document_url(&mut self, url: &str) {
77 self.document_url = url::Url::parse(url).ok();
78 }
79
80 pub fn parse(html: &str) -> Self {
82 let document = Html::parse_document(html);
83 let mut nodes = vec![Node {
84 tag: None,
85 attrs: Vec::new(),
86 text: String::new(),
87 parent: None,
88 children: Vec::new(),
89 }];
90 let mut ids = HashMap::new();
91 let root = NodeId(0);
92
93 let html_node = build(document.root_element(), Some(root), &mut nodes, &mut ids);
94 nodes[0].text = nodes[html_node.0 as usize].text.clone();
95 nodes[0].children = vec![html_node];
96
97 HtmlAdapter {
98 nodes,
99 ids,
100 root,
101 document_url: None,
102 }
103 }
104
105 pub fn locator(&self, node: NodeId) -> String {
109 let mut segments = Vec::new();
110 let mut cur = Some(node);
111 while let Some(id) = cur {
112 let n = &self.nodes[id.0 as usize];
113 if let Some(tag) = &n.tag {
114 segments.push(self.segment(id, tag));
115 }
116 cur = n.parent;
117 }
118 segments.reverse();
119 format!("/{}", segments.join("/"))
120 }
121
122 fn segment(&self, node: NodeId, tag: &str) -> String {
123 let Some(parent) = self.nodes[node.0 as usize].parent else {
124 return tag.to_string();
125 };
126 let siblings = &self.nodes[parent.0 as usize].children;
127 let same_tag: Vec<NodeId> = siblings
128 .iter()
129 .copied()
130 .filter(|&s| self.nodes[s.0 as usize].tag.as_deref() == Some(tag))
131 .collect();
132 if same_tag.len() > 1 {
133 let n = same_tag.iter().position(|&s| s == node).unwrap() + 1;
134 format!("{tag}[{n}]")
135 } else {
136 tag.to_string()
137 }
138 }
139}
140
141fn build(
152 el: ElementRef,
153 parent: Option<NodeId>,
154 nodes: &mut Vec<Node>,
155 ids: &mut HashMap<String, NodeId>,
156) -> NodeId {
157 let root_this = NodeId(nodes.len() as u64);
158 let mut stack = vec![(el, parent)];
160
161 while let Some((el, parent)) = stack.pop() {
162 let this = NodeId(nodes.len() as u64);
163
164 let tag = el.value().name().to_string();
165 let attrs: Vec<(String, String)> = el
166 .value()
167 .attrs()
168 .map(|(k, v)| (k.to_string(), v.to_string()))
169 .collect();
170 let text: String = el.text().collect();
171
172 if let Some(id) = el.value().id() {
173 ids.entry(id.to_string()).or_insert(this);
174 }
175
176 nodes.push(Node {
177 tag: Some(tag),
178 attrs,
179 text,
180 parent,
181 children: Vec::new(),
182 });
183
184 if let Some(p) = parent {
188 nodes[p.0 as usize].children.push(this);
189 }
190
191 let children: Vec<ElementRef> = el.children().filter_map(ElementRef::wrap).collect();
194 for child in children.into_iter().rev() {
195 stack.push((child, Some(this)));
196 }
197 }
198
199 root_this
200}
201
202const BLOCK: &[&str] = &[
204 "address",
205 "article",
206 "aside",
207 "blockquote",
208 "body",
209 "details",
210 "dialog",
211 "dd",
212 "div",
213 "dl",
214 "dt",
215 "fieldset",
216 "figcaption",
217 "figure",
218 "footer",
219 "form",
220 "h1",
221 "h2",
222 "h3",
223 "h4",
224 "h5",
225 "h6",
226 "header",
227 "hgroup",
228 "hr",
229 "html",
230 "li",
231 "main",
232 "nav",
233 "ol",
234 "p",
235 "pre",
236 "section",
237 "table",
238 "ul",
239];
240
241fn is_heading(tag: &str) -> bool {
242 matches!(tag, "h1" | "h2" | "h3" | "h4" | "h5" | "h6")
243}
244
245impl AstAdapter for HtmlAdapter {
246 fn root(&self) -> NodeId {
247 self.root
248 }
249
250 fn children(&self, node: NodeId) -> Vec<NodeId> {
251 self.nodes[node.0 as usize].children.clone()
252 }
253
254 fn name(&self, node: NodeId) -> Option<String> {
255 self.nodes[node.0 as usize].tag.clone()
256 }
257
258 fn parent(&self, node: NodeId) -> Option<NodeId> {
259 self.nodes[node.0 as usize].parent
260 }
261
262 fn traits(&self, node: NodeId) -> Vec<String> {
265 let n = &self.nodes[node.0 as usize];
266 let Some(tag) = &n.tag else {
267 return Vec::new();
268 };
269 let mut out = Vec::new();
270 if BLOCK.contains(&tag.as_str()) {
271 out.push("block".to_string());
272 } else {
273 out.push("inline".to_string());
274 }
275 if is_heading(tag) {
276 out.push("heading".to_string());
277 }
278 if tag == "a" && n.attr("href").is_some() {
279 out.push("link".to_string());
280 }
281 out
282 }
283
284 fn property(&self, node: NodeId, name: &str) -> Option<Value> {
287 self.nodes[node.0 as usize]
288 .attr(name)
289 .map(|v| Value::Str(v.to_string()))
290 }
291
292 fn default_value(&self, node: NodeId) -> Option<Value> {
294 Some(Value::Str(self.nodes[node.0 as usize].text.clone()))
295 }
296
297 fn metadata(&self, node: NodeId, key: &str) -> Option<Value> {
305 let n = &self.nodes[node.0 as usize];
306 match key {
307 "tag" => n.tag.clone().map(Value::Str),
308 "classes" => n.attr("class").map(|c| {
309 Value::list(
310 c.split_whitespace()
311 .map(|s| Value::Str(s.to_string()))
312 .collect(),
313 )
314 }),
315 "attrs" => {
316 let mut names: Vec<&str> = n.attrs.iter().map(|(k, _)| k.as_str()).collect();
317 names.sort_unstable();
318 Some(Value::list(
319 names
320 .into_iter()
321 .map(|k| Value::Str(k.to_string()))
322 .collect(),
323 ))
324 }
325 "n-attrs" => Some(Value::Int(n.attrs.len() as i64)),
326 _ => None,
327 }
328 }
329
330 fn resolve(&self, node: NodeId, property: &str, hint: Option<&str>) -> Option<NodeId> {
333 if property == "href" && !self.rel_ok(node, hint) {
334 return None;
335 }
336 let value = self.nodes[node.0 as usize].attr(property)?;
337 let fragment = value.strip_prefix('#')?;
338 self.ids.get(fragment).copied()
339 }
340
341 fn external_ref(&self, node: NodeId, property: &str, hint: Option<&str>) -> Option<String> {
351 if property == "href" && !self.rel_ok(node, hint) {
352 return None;
353 }
354 let value = self.nodes[node.0 as usize].attr(property)?;
355 let t = value.trim();
356 if t.is_empty() || t.starts_with('#') {
357 return None;
358 }
359 let u = match url::Url::parse(t) {
360 Ok(u) => u,
361 Err(url::ParseError::RelativeUrlWithoutBase) => {
362 self.document_url.as_ref()?.join(t).ok()?
363 }
364 Err(_) => return None,
365 };
366 if u.scheme() != "http" && u.scheme() != "https" {
367 return None;
368 }
369 Some(u.into())
370 }
371
372 fn resolve_fragment(&self, _node: NodeId, fragment: &str) -> Option<NodeId> {
375 self.ids.get(fragment).copied()
376 }
377
378 fn ref_property(&self, node: NodeId) -> Option<String> {
381 let n = &self.nodes[node.0 as usize];
382 let (prop, tags): (&str, &[&str]) = match n.tag.as_deref() {
383 Some(t) if ["a", "link", "area"].contains(&t) => ("href", &["a", "link", "area"]),
384 Some(t) if ["iframe", "frame"].contains(&t) => ("src", &["iframe", "frame"]),
385 _ => return None,
386 };
387 let _ = tags;
388 n.attr(prop).map(|_| prop.to_string())
389 }
390
391 fn ref_label(&self, node: NodeId, property: &str) -> Option<String> {
396 if property != "href" {
397 return None;
398 }
399 let rel = self.nodes[node.0 as usize].attr("rel")?;
400 let rel = rel.trim();
401 (!rel.is_empty()).then(|| rel.to_string())
402 }
403}