Skip to main content

crawlkit_parser/
selector.rs

1//! CSS 选择器工具模块
2//!
3//! 提供预编译的选择器缓存、动态选择器创建、选择器构建函数等功能。
4//! 基于 scraper crate 实现,适配自 halldyll-parser。
5
6use lazy_static::lazy_static;
7use scraper::Selector;
8use std::collections::{HashMap, HashSet};
9use std::sync::RwLock;
10
11use crate::types::{ParserError, ParserResult};
12
13/// 预编译的 CSS 选择器集合
14///
15/// 通过 `lazy_static` 的 `SELECTORS` 全局实例访问。
16/// 所有选择器在首次访问时编译一次,后续复用。
17pub struct CachedSelectors {
18    /// `title` 元素选择器
19    pub title: Selector,
20    /// `meta` 元素选择器
21    pub meta: Selector,
22    /// `link` 元素选择器
23    pub link: Selector,
24    /// `base` 元素选择器
25    pub base: Selector,
26    /// `html` 元素选择器
27    pub html: Selector,
28    /// `body` 元素选择器
29    pub body: Selector,
30    /// `article` 元素选择器
31    pub article: Selector,
32    /// `main` 元素选择器
33    pub main: Selector,
34    /// `[role=main]` 选择器
35    pub main_role: Selector,
36    /// 标题元素集合选择器:h1, h2, h3, h4, h5, h6
37    pub headings: Selector,
38    /// `p` 元素选择器
39    pub p: Selector,
40    /// `blockquote` 元素选择器
41    pub blockquote: Selector,
42    /// `pre` 元素选择器
43    pub pre: Selector,
44    /// `pre code` 后代选择器(代码块)
45    pub pre_code: Selector,
46    /// `code` 元素选择器
47    pub code: Selector,
48    /// `ul` 元素选择器
49    pub ul: Selector,
50    /// `ol` 元素选择器
51    pub ol: Selector,
52    /// `li` 元素选择器
53    pub li: Selector,
54    /// `dl` 元素选择器
55    pub dl: Selector,
56    /// `dt` 元素选择器
57    pub dt: Selector,
58    /// `dd` 元素选择器
59    pub dd: Selector,
60    /// `table` 元素选择器
61    pub table: Selector,
62    /// `thead` 元素选择器
63    pub thead: Selector,
64    /// `tbody` 元素选择器
65    pub tbody: Selector,
66    /// `tfoot` 元素选择器
67    pub tfoot: Selector,
68    /// `tr` 元素选择器
69    pub tr: Selector,
70    /// `th` 元素选择器
71    pub th: Selector,
72    /// `td` 元素选择器
73    pub td: Selector,
74    /// `caption` 元素选择器
75    pub caption: Selector,
76    /// `a` 元素选择器
77    pub a: Selector,
78    /// `img` 元素选择器
79    pub img: Selector,
80    /// `picture` 元素选择器
81    pub picture: Selector,
82    /// `source` 元素选择器
83    pub source: Selector,
84    /// `figure` 元素选择器
85    pub figure: Selector,
86    /// `figcaption` 元素选择器
87    pub figcaption: Selector,
88    /// `script` 元素选择器
89    pub script: Selector,
90    /// `style` 元素选择器
91    pub style: Selector,
92    /// `noscript` 元素选择器
93    pub noscript: Selector,
94    /// `nav` 元素选择器
95    pub nav: Selector,
96    /// `header` 元素选择器
97    pub header: Selector,
98    /// `footer` 元素选择器
99    pub footer: Selector,
100    /// `aside` 元素选择器
101    pub aside: Selector,
102    /// `script[type="application/ld+json"]` 选择器(JSON-LD 结构化数据)
103    pub json_ld: Selector,
104    /// `[itemscope][itemtype]` 选择器(Microdata 结构化数据)
105    pub microdata: Selector,
106}
107
108lazy_static! {
109    /// 预编译 CSS 选择器全局实例
110    ///
111    /// 所有内置选择器在此处一次性编译,避免重复解析。
112    pub static ref SELECTORS: CachedSelectors = CachedSelectors {
113        title: sel("title"),
114        meta: sel("meta"),
115        link: sel("link"),
116        base: sel("base"),
117        html: sel("html"),
118        body: sel("body"),
119        article: sel("article"),
120        main: sel("main"),
121        main_role: sel("[role=main]"),
122        headings: sel("h1, h2, h3, h4, h5, h6"),
123        p: sel("p"),
124        blockquote: sel("blockquote"),
125        pre: sel("pre"),
126        pre_code: sel("pre code"),
127        code: sel("code"),
128        ul: sel("ul"),
129        ol: sel("ol"),
130        li: sel("li"),
131        dl: sel("dl"),
132        dt: sel("dt"),
133        dd: sel("dd"),
134        table: sel("table"),
135        thead: sel("thead"),
136        tbody: sel("tbody"),
137        tfoot: sel("tfoot"),
138        tr: sel("tr"),
139        th: sel("th"),
140        td: sel("td"),
141        caption: sel("caption"),
142        a: sel("a"),
143        img: sel("img"),
144        picture: sel("picture"),
145        source: sel("source"),
146        figure: sel("figure"),
147        figcaption: sel("figcaption"),
148        script: sel("script"),
149        style: sel("style"),
150        noscript: sel("noscript"),
151        nav: sel("nav"),
152        header: sel("header"),
153        footer: sel("footer"),
154        aside: sel("aside"),
155        json_ld: sel(r#"script[type="application/ld+json"]"#),
156        microdata: sel("[itemscope][itemtype]"),
157    };
158
159    /// 动态选择器缓存
160    ///
161    /// 用于缓存运行时动态创建的选择器,避免重复解析。
162    /// 使用 `RwLock` 保证线程安全。
163    static ref SELECTOR_CACHE: RwLock<HashMap<String, Selector>> = RwLock::new(HashMap::new());
164}
165
166/// 编译 CSS 选择器,失败时 panic
167///
168/// 仅用于预编译已知合法的选择器。
169fn sel(css: &str) -> Selector {
170    Selector::parse(css).unwrap_or_else(|e| {
171        panic!("无效的 CSS 选择器 '{css}': {e}")
172    })
173}
174
175/// 获取或创建动态选择器(带缓存)
176///
177/// 先从缓存查找,如果不存在则编译并存入缓存。
178/// 如果选择器字符串无效,返回 `ParserError::SelectorError`。
179pub fn get_or_create_selector(css: &str) -> ParserResult<Selector> {
180    {
181        let cache = SELECTOR_CACHE.read().map_err(|e| {
182            ParserError::ConfigError(format!("选择器缓存读取锁失效: {e}"))
183        })?;
184        if let Some(sel) = cache.get(css) {
185            return Ok(sel.clone());
186        }
187    }
188
189    let selector = Selector::parse(css).map_err(|e| {
190        ParserError::SelectorError(format!("无法解析选择器 '{css}': {e}"))
191    })?;
192
193    let mut cache = SELECTOR_CACHE.write().map_err(|e| {
194        ParserError::ConfigError(format!("选择器缓存写入锁失效: {e}"))
195    })?;
196    cache.insert(css.to_string(), selector.clone());
197    Ok(selector)
198}
199
200/// 解析 CSS 选择器,失败时 panic
201///
202/// 不经过缓存,每次都重新解析。
203/// 适用于脚本阶段就知道合法的选择器。
204pub fn parse_selector(css: &str) -> Selector {
205    Selector::parse(css).unwrap_or_else(|e| {
206        panic!("无效的 CSS 选择器 '{css}': {e}")
207    })
208}
209
210/// 尝试解析 CSS 选择器,失败时返回 `None`
211///
212/// 不经过缓存,每次都重新解析。
213pub fn try_parse_selector(css: &str) -> Option<Selector> {
214    Selector::parse(css).ok()
215}
216
217/// 返回匹配所有标题元素(h1-h6)的 CSS 选择器字符串
218///
219/// 等同于 `"h1, h2, h3, h4, h5, h6"`。
220pub fn heading_selector() -> &'static str {
221    "h1, h2, h3, h4, h5, h6"
222}
223
224// ─── 常用选择器集合 ─────────────────────────────────────────
225
226/// 内容区域选择器列表
227///
228/// 用于定位页面的主要内容区域。
229pub const CONTENT_SELECTORS: &[&str] = &[
230    "article",
231    "main",
232    "[role=main]",
233    ".content",
234    ".post-content",
235    ".entry-content",
236    ".article-content",
237    ".post-body",
238    ".article-body",
239    "#content",
240    "#main-content",
241    ".main-content",
242];
243
244lazy_static! {
245    /// 样板(非内容)区域选择器集合
246    ///
247    /// 用于移除页面中的导航、广告、侧边栏等非核心内容。
248    /// 使用 `HashSet` 以便快速查找和克隆。
249    pub static ref BOILERPLATE_SELECTORS: HashSet<&'static str> = {
250        let mut set = HashSet::new();
251        set.insert("script");
252        set.insert("style");
253        set.insert("noscript");
254        set.insert("nav");
255        set.insert("header");
256        set.insert("footer");
257        set.insert("aside");
258        set.insert(".sidebar");
259        set.insert(".advertisement");
260        set.insert(".ad");
261        set.insert(".ads");
262        set.insert(".menu");
263        set.insert(".navigation");
264        set.insert(".nav");
265        set.insert(".footer");
266        set.insert(".header");
267        set.insert("[role=navigation]");
268        set.insert("[role=banner]");
269        set.insert("[role=contentinfo]");
270        set
271    };
272}
273
274/// 内联元素名列表
275///
276/// 这些元素通常不会产生换行,在文本提取时应作为内联处理。
277pub const INLINE_ELEMENTS: &[&str] = &[
278    "a", "abbr", "acronym", "b", "bdo", "big", "br", "button", "cite",
279    "code", "dfn", "em", "i", "img", "input", "kbd", "label", "map",
280    "object", "q", "samp", "script", "select", "small", "span", "strong",
281    "sub", "sup", "textarea", "time", "tt", "var",
282];
283
284/// 块级元素名列表
285///
286/// 这些元素通常会换行并占据整行空间,在文本提取时应作为块级处理。
287pub const BLOCK_ELEMENTS: &[&str] = &[
288    "address", "article", "aside", "blockquote", "canvas", "dd", "div",
289    "dl", "dt", "fieldset", "figcaption", "figure", "footer", "form",
290    "h1", "h2", "h3", "h4", "h5", "h6", "header", "hgroup", "hr", "li",
291    "main", "nav", "noscript", "ol", "output", "p", "pre", "section",
292    "table", "tfoot", "ul", "video",
293];
294
295// ─── 选择器构建函数 ─────────────────────────────────────────
296
297/// 构建属性选择器:`[attr]`
298///
299/// 匹配所有包含指定属性的元素,无论属性值是什么。
300pub fn attr_selector(attr: &str) -> String {
301    format!("[{attr}]")
302}
303
304/// 构建属性包含选择器:`[attr*=value]`
305///
306/// 匹配属性值中包含指定子串的元素。
307pub fn attr_contains_selector(attr: &str, value: &str) -> String {
308    format!(r#"[{attr}*="{value}"]"#)
309}
310
311/// 构建属性前缀选择器:`[attr^=value]`
312///
313/// 匹配属性值以指定字符串开头的元素。
314pub fn attr_starts_with_selector(attr: &str, value: &str) -> String {
315    format!(r#"[{attr}^="{value}"]"#)
316}
317
318/// 构建 class 选择器:`.class`
319///
320/// 匹配所有包含指定 class 的元素。
321pub fn class_selector(class: &str) -> String {
322    format!(".{class}")
323}
324
325/// 构建 ID 选择器:`#id`
326pub fn id_selector(id: &str) -> String {
327    format!("#{id}")
328}
329
330/// 构建后代选择器:`ancestor descendant`
331///
332/// 匹配 ancestor 元素下的所有 descendant 元素。
333pub fn descendant_selector(ancestor: &str, descendant: &str) -> String {
334    format!("{ancestor} {descendant}")
335}
336
337/// 构建子选择器:`parent > child`
338///
339/// 仅匹配 parent 的直接子元素 child。
340pub fn child_selector(parent: &str, child: &str) -> String {
341    format!("{parent} > {child}")
342}
343
344/// 构建多重选择器:`sel1, sel2, sel3, ...`
345///
346/// 将多个选择器合并为一个逗号分隔的组合选择器。
347pub fn multi_selector(selectors: &[&str]) -> String {
348    selectors.join(", ")
349}
350
351// ─── Meta 标签选择器 ────────────────────────────────────────
352
353/// 构建 `meta[name="..."]` 选择器
354///
355/// 用于匹配特定 name 属性的 `<meta>` 标签。
356pub fn meta_name_selector(name: &str) -> String {
357    format!(r#"meta[name="{name}"]"#)
358}
359
360/// 构建 `meta[property="..."]` 选择器
361///
362/// 用于匹配特定 property 属性的 `<meta>` 标签(Open Graph 等)。
363pub fn meta_property_selector(property: &str) -> String {
364    format!(r#"meta[property="{property}"]"#)
365}
366
367/// 构建 `link[rel="..."]` 选择器
368///
369/// 用于匹配特定 rel 属性的 `<link>` 标签。
370pub fn link_rel_selector(rel: &str) -> String {
371    format!(r#"link[rel="{rel}"]"#)
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377    use scraper::Html;
378
379    /// 辅助:计算 HTML 片段中匹配选择器的元素数量
380    fn count_matches(html: &str, selector: &str) -> usize {
381        let doc = Html::parse_fragment(html);
382        let sel = Selector::parse(selector).unwrap();
383        doc.select(&sel).count()
384    }
385
386    /// 辅助:在文档模式下计算匹配数
387    fn count_matches_doc(html: &str, selector: &Selector) -> usize {
388        let doc = Html::parse_document(html);
389        doc.select(selector).count()
390    }
391
392    // ─── 预编译选择器测试 ──────────────────────────────────
393
394    #[test]
395    fn test_precompiled_selectors_match_expected_elements() {
396        let html = r##"
397            <html><head><title>测试</title></head><body>
398                <h1>标题1</h1><h2>标题2</h2><h6>标题6</h6>
399                <p>段落</p>
400                <a href="#">链接</a>
401                <img src="test.jpg" />
402                <ul><li>列表项</li></ul>
403                <table><thead><tr><th>表头</th></tr></thead>
404                       <tbody><tr><td>单元格</td></tr></tbody>
405                       <tfoot><tr><td>页脚</td></tr></tfoot>
406                       <caption>表格标题</caption></table>
407                <article>文章</article>
408                <main>主体</main>
409                <div role="main">角色主体</div>
410                <nav>导航</nav>
411                <footer>页脚</footer>
412                <header>页眉</header>
413                <aside>侧边栏</aside>
414                <blockquote>引用</blockquote>
415                <pre><code>代码</code></pre>
416                <code>内联代码</code>
417                <dl><dt>术语</dt><dd>定义</dd></dl>
418                <ol><li>有序项</li></ol>
419                <picture><source srcset="img.webp" /><img src="img.jpg" /></picture>
420                <figure><figcaption>图注</figcaption></figure>
421                <script>js</script>
422                <style>css</style>
423                <noscript>备选</noscript>
424                <script type="application/ld+json">{}</script>
425                <div itemscope itemtype="https://example.org/Article">微数据</div>
426            </body></html>
427        "##;
428
429        assert_eq!(count_matches_doc(html, &SELECTORS.title), 1);
430        assert_eq!(count_matches_doc(html, &SELECTORS.meta), 0);
431        assert_eq!(count_matches_doc(html, &SELECTORS.html), 1);
432        assert_eq!(count_matches_doc(html, &SELECTORS.body), 1);
433        assert_eq!(count_matches_doc(html, &SELECTORS.headings), 3);
434        assert_eq!(count_matches_doc(html, &SELECTORS.p), 1);
435        assert_eq!(count_matches_doc(html, &SELECTORS.a), 1);
436        assert_eq!(count_matches_doc(html, &SELECTORS.img), 2);
437        assert_eq!(count_matches_doc(html, &SELECTORS.ul), 1);
438        assert_eq!(count_matches_doc(html, &SELECTORS.ol), 1);
439        assert_eq!(count_matches_doc(html, &SELECTORS.li), 2);
440        assert_eq!(count_matches_doc(html, &SELECTORS.table), 1);
441        assert_eq!(count_matches_doc(html, &SELECTORS.thead), 1);
442        assert_eq!(count_matches_doc(html, &SELECTORS.tbody), 1);
443        assert_eq!(count_matches_doc(html, &SELECTORS.tfoot), 1);
444        assert_eq!(count_matches_doc(html, &SELECTORS.tr), 3);
445        assert_eq!(count_matches_doc(html, &SELECTORS.th), 1);
446        assert_eq!(count_matches_doc(html, &SELECTORS.td), 2);
447        assert_eq!(count_matches_doc(html, &SELECTORS.caption), 1);
448        assert_eq!(count_matches_doc(html, &SELECTORS.article), 1);
449        assert_eq!(count_matches_doc(html, &SELECTORS.main), 1);
450        assert_eq!(count_matches_doc(html, &SELECTORS.main_role), 1);
451        assert_eq!(count_matches_doc(html, &SELECTORS.nav), 1);
452        assert_eq!(count_matches_doc(html, &SELECTORS.footer), 1);
453        assert_eq!(count_matches_doc(html, &SELECTORS.header), 1);
454        assert_eq!(count_matches_doc(html, &SELECTORS.aside), 1);
455        assert_eq!(count_matches_doc(html, &SELECTORS.blockquote), 1);
456        assert_eq!(count_matches_doc(html, &SELECTORS.pre), 1);
457        assert_eq!(count_matches_doc(html, &SELECTORS.pre_code), 1);
458        assert_eq!(count_matches_doc(html, &SELECTORS.code), 2);
459        assert_eq!(count_matches_doc(html, &SELECTORS.dl), 1);
460        assert_eq!(count_matches_doc(html, &SELECTORS.dt), 1);
461        assert_eq!(count_matches_doc(html, &SELECTORS.dd), 1);
462        assert_eq!(count_matches_doc(html, &SELECTORS.picture), 1);
463        assert_eq!(count_matches_doc(html, &SELECTORS.source), 1);
464        assert_eq!(count_matches_doc(html, &SELECTORS.figure), 1);
465        assert_eq!(count_matches_doc(html, &SELECTORS.figcaption), 1);
466        assert_eq!(count_matches_doc(html, &SELECTORS.script), 2);
467        assert_eq!(count_matches_doc(html, &SELECTORS.style), 1);
468        assert_eq!(count_matches_doc(html, &SELECTORS.noscript), 1);
469        assert_eq!(count_matches_doc(html, &SELECTORS.json_ld), 1);
470        assert_eq!(count_matches_doc(html, &SELECTORS.microdata), 1);
471    }
472
473    #[test]
474    fn test_precompiled_link_base_selectors() {
475        let html = r#"
476            <html><head>
477                <base href="https://example.com/" />
478                <link rel="stylesheet" href="style.css" />
479            </head></html>
480        "#;
481        assert_eq!(count_matches_doc(html, &SELECTORS.link), 1);
482        assert_eq!(count_matches_doc(html, &SELECTORS.base), 1);
483    }
484
485    // ─── 动态选择器缓存测试 ────────────────────────────────
486
487    #[test]
488    fn test_get_or_create_selector_creates_and_caches() {
489        let sel1 = get_or_create_selector("div.content").unwrap();
490        let sel2 = get_or_create_selector("div.content").unwrap();
491
492        // 验证两个选择器行为一致
493        let html = r#"<div class="content">内容</div>"#;
494        let doc = Html::parse_fragment(html);
495        assert_eq!(doc.select(&sel1).count(), doc.select(&sel2).count());
496        assert_eq!(doc.select(&sel1).count(), 1);
497    }
498
499    #[test]
500    fn test_get_or_create_selector_returns_error_for_invalid() {
501        let result = get_or_create_selector("!!!invalid");
502        assert!(result.is_err());
503        match result {
504            Err(ParserError::SelectorError(_)) => {}
505            _ => panic!("期望 SelectorError 变体"),
506        }
507    }
508
509    #[test]
510    fn test_try_parse_selector_returns_none_for_invalid() {
511        let result = try_parse_selector("!!!invalid");
512        assert!(result.is_none());
513    }
514
515    #[test]
516    fn test_try_parse_selector_returns_some_for_valid() {
517        let result = try_parse_selector("div");
518        assert!(result.is_some());
519    }
520
521    // ─── heading_selector 测试 ─────────────────────────────
522
523    #[test]
524    fn test_heading_selector_format() {
525        assert_eq!(heading_selector(), "h1, h2, h3, h4, h5, h6");
526    }
527
528    #[test]
529    fn test_heading_selector_matches_all_levels() {
530        let html = r"
531            <h1>一级</h1>
532            <h2>二级</h2>
533            <h3>三级</h3>
534            <h4>四级</h4>
535            <h5>五级</h5>
536            <h6>六级</h6>
537        ";
538        assert_eq!(count_matches(html, heading_selector()), 6);
539    }
540
541    // ─── 选择器常量测试 ────────────────────────────────────
542
543    #[test]
544    fn test_content_selectors_contains_core_selectors() {
545        assert!(CONTENT_SELECTORS.contains(&"article"));
546        assert!(CONTENT_SELECTORS.contains(&"main"));
547        assert!(CONTENT_SELECTORS.contains(&"[role=main]"));
548        assert!(!CONTENT_SELECTORS.is_empty());
549    }
550
551    #[test]
552    fn test_boilerplate_selectors_contains_core_selectors() {
553        assert!(BOILERPLATE_SELECTORS.contains(&"nav"));
554        assert!(BOILERPLATE_SELECTORS.contains(&"footer"));
555        assert!(BOILERPLATE_SELECTORS.contains(&"aside"));
556        assert!(!BOILERPLATE_SELECTORS.is_empty());
557    }
558
559    #[test]
560    fn test_inline_and_block_elements_are_disjoint() {
561        for elem in INLINE_ELEMENTS {
562            assert!(
563                !BLOCK_ELEMENTS.contains(elem),
564                "元素 {elem} 同时出现在 INLINE 和 BLOCK 中"
565            );
566        }
567        assert!(INLINE_ELEMENTS.contains(&"span"));
568        assert!(BLOCK_ELEMENTS.contains(&"div"));
569    }
570
571    // ─── 选择器构建函数测试 ──────────────────────────────────
572
573    #[test]
574    fn test_attr_selector_builders() {
575        assert_eq!(attr_selector("href"), "[href]");
576        assert_eq!(attr_contains_selector("class", "active"), r#"[class*="active"]"#);
577        assert_eq!(attr_starts_with_selector("href", "/"), r#"[href^="/"]"#);
578    }
579
580    #[test]
581    fn test_class_and_id_selector_builders() {
582        assert_eq!(class_selector("container"), ".container");
583        assert_eq!(id_selector("main"), "#main");
584    }
585
586    #[test]
587    fn test_combinator_selector_builders() {
588        assert_eq!(descendant_selector("div", "p"), "div p");
589        assert_eq!(child_selector("ul", "li"), "ul > li");
590    }
591
592    #[test]
593    fn test_multi_selector_builder() {
594        assert_eq!(multi_selector(&["h1", "h2", "h3"]), "h1, h2, h3");
595        assert_eq!(multi_selector(&[]), "");
596    }
597
598    // ─── Meta 选择器测试 ──────────────────────────────────────
599
600    #[test]
601    fn test_meta_name_selector_matches_meta_name() {
602        let sel = meta_name_selector("description");
603        assert_eq!(sel, r#"meta[name="description"]"#);
604        assert_eq!(count_matches(r#"<meta name="description" content="d">"#, &sel), 1);
605        assert_eq!(count_matches(r#"<meta name="keywords" content="k">"#, &sel), 0);
606    }
607
608    #[test]
609    fn test_meta_property_selector_matches_meta_property() {
610        let sel = meta_property_selector("og:title");
611        assert_eq!(sel, r#"meta[property="og:title"]"#);
612        assert_eq!(count_matches(r#"<meta property="og:title" content="标题">"#, &sel), 1);
613    }
614
615    #[test]
616    fn test_link_rel_selector_matches_link_rel() {
617        let sel = link_rel_selector("canonical");
618        assert_eq!(sel, r#"link[rel="canonical"]"#);
619        assert_eq!(count_matches(r#"<link rel="canonical" href="https://ex.com">"#, &sel), 1);
620        assert_eq!(count_matches(r#"<link rel="stylesheet" href="s.css">"#, &sel), 0);
621    }
622
623    // ─── 集成测试 ──────────────────────────────────────────
624
625    #[test]
626    fn test_builder_functions_compose_correctly() {
627        let composed = descendant_selector("article", &class_selector("post-content"));
628        assert_eq!(composed, "article .post-content");
629
630        let html = r#"<article><div class="post-content"><p>正文</p></div></article>"#;
631        assert_eq!(count_matches(html, &composed), 1);
632    }
633
634    #[test]
635    fn test_cached_selector_matches_raw_selector() {
636        let html = r#"<div id="unique-id">唯一元素</div>"#;
637
638        let raw = Selector::parse("div#unique-id").unwrap();
639        let cached = get_or_create_selector("div#unique-id").unwrap();
640
641        let doc = Html::parse_fragment(html);
642        assert_eq!(doc.select(&raw).count(), doc.select(&cached).count());
643    }
644}