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 { nodes, ids, root, document_url: None }
98 }
99
100 pub fn locator(&self, node: NodeId) -> String {
104 let mut segments = Vec::new();
105 let mut cur = Some(node);
106 while let Some(id) = cur {
107 let n = &self.nodes[id.0 as usize];
108 if let Some(tag) = &n.tag {
109 segments.push(self.segment(id, tag));
110 }
111 cur = n.parent;
112 }
113 segments.reverse();
114 format!("/{}", segments.join("/"))
115 }
116
117 fn segment(&self, node: NodeId, tag: &str) -> String {
118 let Some(parent) = self.nodes[node.0 as usize].parent else {
119 return tag.to_string();
120 };
121 let siblings = &self.nodes[parent.0 as usize].children;
122 let same_tag: Vec<NodeId> = siblings
123 .iter()
124 .copied()
125 .filter(|&s| self.nodes[s.0 as usize].tag.as_deref() == Some(tag))
126 .collect();
127 if same_tag.len() > 1 {
128 let n = same_tag.iter().position(|&s| s == node).unwrap() + 1;
129 format!("{tag}[{n}]")
130 } else {
131 tag.to_string()
132 }
133 }
134}
135
136fn build(
147 el: ElementRef,
148 parent: Option<NodeId>,
149 nodes: &mut Vec<Node>,
150 ids: &mut HashMap<String, NodeId>,
151) -> NodeId {
152 let root_this = NodeId(nodes.len() as u64);
153 let mut stack = vec![(el, parent)];
155
156 while let Some((el, parent)) = stack.pop() {
157 let this = NodeId(nodes.len() as u64);
158
159 let tag = el.value().name().to_string();
160 let attrs: Vec<(String, String)> = el
161 .value()
162 .attrs()
163 .map(|(k, v)| (k.to_string(), v.to_string()))
164 .collect();
165 let text: String = el.text().collect();
166
167 if let Some(id) = el.value().id() {
168 ids.entry(id.to_string()).or_insert(this);
169 }
170
171 nodes.push(Node {
172 tag: Some(tag),
173 attrs,
174 text,
175 parent,
176 children: Vec::new(),
177 });
178
179 if let Some(p) = parent {
183 nodes[p.0 as usize].children.push(this);
184 }
185
186 let children: Vec<ElementRef> = el.children().filter_map(ElementRef::wrap).collect();
189 for child in children.into_iter().rev() {
190 stack.push((child, Some(this)));
191 }
192 }
193
194 root_this
195}
196
197const BLOCK: &[&str] = &[
199 "address",
200 "article",
201 "aside",
202 "blockquote",
203 "body",
204 "details",
205 "dialog",
206 "dd",
207 "div",
208 "dl",
209 "dt",
210 "fieldset",
211 "figcaption",
212 "figure",
213 "footer",
214 "form",
215 "h1",
216 "h2",
217 "h3",
218 "h4",
219 "h5",
220 "h6",
221 "header",
222 "hgroup",
223 "hr",
224 "html",
225 "li",
226 "main",
227 "nav",
228 "ol",
229 "p",
230 "pre",
231 "section",
232 "table",
233 "ul",
234];
235
236fn is_heading(tag: &str) -> bool {
237 matches!(tag, "h1" | "h2" | "h3" | "h4" | "h5" | "h6")
238}
239
240impl AstAdapter for HtmlAdapter {
241 fn root(&self) -> NodeId {
242 self.root
243 }
244
245 fn children(&self, node: NodeId) -> Vec<NodeId> {
246 self.nodes[node.0 as usize].children.clone()
247 }
248
249 fn name(&self, node: NodeId) -> Option<String> {
250 self.nodes[node.0 as usize].tag.clone()
251 }
252
253 fn parent(&self, node: NodeId) -> Option<NodeId> {
254 self.nodes[node.0 as usize].parent
255 }
256
257 fn traits(&self, node: NodeId) -> Vec<String> {
260 let n = &self.nodes[node.0 as usize];
261 let Some(tag) = &n.tag else {
262 return Vec::new();
263 };
264 let mut out = Vec::new();
265 if BLOCK.contains(&tag.as_str()) {
266 out.push("block".to_string());
267 } else {
268 out.push("inline".to_string());
269 }
270 if is_heading(tag) {
271 out.push("heading".to_string());
272 }
273 if tag == "a" && n.attr("href").is_some() {
274 out.push("link".to_string());
275 }
276 out
277 }
278
279 fn property(&self, node: NodeId, name: &str) -> Option<Value> {
282 self.nodes[node.0 as usize]
283 .attr(name)
284 .map(|v| Value::Str(v.to_string()))
285 }
286
287 fn default_value(&self, node: NodeId) -> Option<Value> {
289 Some(Value::Str(self.nodes[node.0 as usize].text.clone()))
290 }
291
292 fn metadata(&self, node: NodeId, key: &str) -> Option<Value> {
300 let n = &self.nodes[node.0 as usize];
301 match key {
302 "tag" => n.tag.clone().map(Value::Str),
303 "classes" => n.attr("class").map(|c| {
304 Value::List(
305 c.split_whitespace()
306 .map(|s| Value::Str(s.to_string()))
307 .collect(),
308 )
309 }),
310 "attrs" => {
311 let mut names: Vec<&str> = n.attrs.iter().map(|(k, _)| k.as_str()).collect();
312 names.sort_unstable();
313 Some(Value::List(
314 names
315 .into_iter()
316 .map(|k| Value::Str(k.to_string()))
317 .collect(),
318 ))
319 }
320 "n-attrs" => Some(Value::Int(n.attrs.len() as i64)),
321 _ => None,
322 }
323 }
324
325 fn resolve(&self, node: NodeId, property: &str, hint: Option<&str>) -> Option<NodeId> {
328 if property == "href" && !self.rel_ok(node, hint) {
329 return None;
330 }
331 let value = self.nodes[node.0 as usize].attr(property)?;
332 let fragment = value.strip_prefix('#')?;
333 self.ids.get(fragment).copied()
334 }
335
336 fn external_ref(&self, node: NodeId, property: &str, hint: Option<&str>) -> Option<String> {
346 if property == "href" && !self.rel_ok(node, hint) {
347 return None;
348 }
349 let value = self.nodes[node.0 as usize].attr(property)?;
350 let t = value.trim();
351 if t.is_empty() || t.starts_with('#') {
352 return None;
353 }
354 let u = match url::Url::parse(t) {
355 Ok(u) => u,
356 Err(url::ParseError::RelativeUrlWithoutBase) => {
357 self.document_url.as_ref()?.join(t).ok()?
358 }
359 Err(_) => return None,
360 };
361 if u.scheme() != "http" && u.scheme() != "https" {
362 return None;
363 }
364 Some(u.into())
365 }
366
367 fn resolve_fragment(&self, _node: NodeId, fragment: &str) -> Option<NodeId> {
370 self.ids.get(fragment).copied()
371 }
372
373 fn ref_property(&self, node: NodeId) -> Option<String> {
376 let n = &self.nodes[node.0 as usize];
377 let (prop, tags): (&str, &[&str]) = match n.tag.as_deref() {
378 Some(t) if ["a", "link", "area"].contains(&t) => ("href", &["a", "link", "area"]),
379 Some(t) if ["iframe", "frame"].contains(&t) => ("src", &["iframe", "frame"]),
380 _ => return None,
381 };
382 let _ = tags;
383 n.attr(prop).map(|_| prop.to_string())
384 }
385
386 fn ref_label(&self, node: NodeId, property: &str) -> Option<String> {
391 if property != "href" {
392 return None;
393 }
394 let rel = self.nodes[node.0 as usize].attr("rel")?;
395 let rel = rel.trim();
396 (!rel.is_empty()).then(|| rel.to_string())
397 }
398}