1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use thiserror::Error;
4
5#[derive(Debug, Error)]
10pub enum ParserError {
11 #[error("Failed to parse HTML: {0}")]
12 ParseError(String),
13
14 #[error("无效 CSS 选择器: {0}")]
15 SelectorError(String),
16
17 #[error("URL 错误: {0}")]
18 UrlError(#[from] url::ParseError),
19
20 #[error("IO 错误: {0}")]
21 IoError(#[from] std::io::Error),
22
23 #[error("编码错误: {0}")]
24 EncodingError(String),
25
26 #[error("配置错误: {0}")]
27 ConfigError(String),
28}
29
30pub type ParserResult<T> = Result<T, ParserError>;
31
32#[derive(Debug, Clone, Serialize, Deserialize, Default)]
37pub struct TextContent {
38 pub raw_text: String,
39 pub cleaned_text: String,
40 pub word_count: usize,
41 pub char_count: usize,
42 pub language: Option<String>,
43 pub readability_score: Option<f64>,
44 pub reading_time_minutes: Option<f64>,
45}
46
47impl TextContent {
48 pub fn from_raw(raw: &str) -> Self {
49 let cleaned = normalize_whitespace(raw);
50 let word_count = cleaned.split_whitespace().count();
51 let char_count = cleaned.chars().count();
52 let reading_time = if word_count > 0 {
53 Some(word_count as f64 / 225.0)
54 } else {
55 None
56 };
57 Self {
58 raw_text: raw.to_string(),
59 cleaned_text: cleaned,
60 word_count,
61 char_count,
62 language: None,
63 readability_score: None,
64 reading_time_minutes: reading_time,
65 }
66 }
67
68 pub fn is_empty(&self) -> bool {
69 self.word_count == 0
70 }
71
72 pub fn is_substantial(&self) -> bool {
73 self.word_count >= 50
74 }
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct Heading {
83 pub level: u8,
84 pub text: String,
85 pub id: Option<String>,
86 pub classes: Vec<String>,
87}
88
89impl Heading {
90 pub fn new(level: u8, text: impl Into<String>) -> Self {
91 Self {
92 level: level.clamp(1, 6),
93 text: text.into(),
94 id: None,
95 classes: Vec::new(),
96 }
97 }
98
99 pub fn with_id(mut self, id: impl Into<String>) -> Self {
100 self.id = Some(id.into());
101 self
102 }
103}
104
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
110#[serde(rename_all = "snake_case")]
111pub enum LinkRel {
112 Follow,
113 NoFollow,
114 Ugc,
115 Sponsored,
116 External,
117 NoOpener,
118 NoReferrer,
119 Other,
120}
121
122#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
123#[serde(rename_all = "snake_case")]
124pub enum LinkType {
125 Internal,
126 External,
127 Unknown,
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct Link {
132 pub href: String,
133 pub url: Option<String>,
134 pub text: String,
135 pub title: Option<String>,
136 pub rel: Vec<LinkRel>,
137 pub link_type: LinkType,
138 pub is_nofollow: bool,
139 pub target: Option<String>,
140 pub hreflang: Option<String>,
141}
142
143impl Link {
144 pub fn new(href: impl Into<String>, text: impl Into<String>) -> Self {
145 Self {
146 href: href.into(),
147 url: None,
148 text: text.into(),
149 title: None,
150 rel: Vec::new(),
151 link_type: LinkType::Unknown,
152 is_nofollow: false,
153 target: None,
154 hreflang: None,
155 }
156 }
157
158 pub fn should_follow(&self) -> bool {
159 !self.is_nofollow
160 && !self.rel.contains(&LinkRel::Sponsored)
161 && !self.rel.contains(&LinkRel::Ugc)
162 }
163
164 pub fn opens_new_tab(&self) -> bool {
165 self.target.as_deref() == Some("_blank")
166 }
167}
168
169#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
174#[serde(rename_all = "snake_case")]
175pub enum ImageLoading {
176 #[default]
177 Eager,
178 Lazy,
179}
180
181#[derive(Debug, Clone, Serialize, Deserialize)]
182pub struct Image {
183 pub src: String,
184 pub url: Option<String>,
185 pub alt: String,
186 pub title: Option<String>,
187 pub width: Option<u32>,
188 pub height: Option<u32>,
189 pub srcset: Option<String>,
190 pub sizes: Option<String>,
191 pub loading: ImageLoading,
192 pub is_decorative: bool,
193}
194
195impl Image {
196 pub fn new(src: impl Into<String>, alt: impl Into<String>) -> Self {
197 let alt_str = alt.into();
198 let is_decorative = alt_str.is_empty();
199 Self {
200 src: src.into(),
201 url: None,
202 alt: alt_str,
203 title: None,
204 width: None,
205 height: None,
206 srcset: None,
207 sizes: None,
208 loading: ImageLoading::default(),
209 is_decorative,
210 }
211 }
212
213 pub fn is_responsive(&self) -> bool {
214 self.srcset.is_some()
215 }
216}
217
218#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
223#[serde(rename_all = "snake_case")]
224pub enum ListType {
225 Ordered,
226 Unordered,
227 Definition,
228}
229
230#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct ListItem {
232 pub text: String,
233 pub nested: Option<Box<ListContent>>,
234}
235
236impl ListItem {
237 pub fn new(text: impl Into<String>) -> Self {
238 Self {
239 text: text.into(),
240 nested: None,
241 }
242 }
243
244 pub fn with_nested(text: impl Into<String>, nested: ListContent) -> Self {
245 Self {
246 text: text.into(),
247 nested: Some(Box::new(nested)),
248 }
249 }
250}
251
252#[derive(Debug, Clone, Serialize, Deserialize)]
253pub struct ListContent {
254 pub list_type: ListType,
255 pub items: Vec<ListItem>,
256 pub total_items: usize,
257}
258
259impl ListContent {
260 pub fn new(list_type: ListType) -> Self {
261 Self {
262 list_type,
263 items: Vec::new(),
264 total_items: 0,
265 }
266 }
267
268 pub fn add_item(&mut self, item: ListItem) {
269 self.total_items += 1;
270 if let Some(ref nested) = item.nested {
271 self.total_items += nested.total_items;
272 }
273 self.items.push(item);
274 }
275
276 pub fn is_empty(&self) -> bool {
277 self.items.is_empty()
278 }
279}
280
281#[derive(Debug, Clone, Serialize, Deserialize)]
286pub struct TableCell {
287 pub content: String,
288 pub is_header: bool,
289 pub colspan: u32,
290 pub rowspan: u32,
291}
292
293impl TableCell {
294 pub fn data(content: impl Into<String>) -> Self {
295 Self {
296 content: content.into(),
297 is_header: false,
298 colspan: 1,
299 rowspan: 1,
300 }
301 }
302
303 pub fn header(content: impl Into<String>) -> Self {
304 Self {
305 content: content.into(),
306 is_header: true,
307 colspan: 1,
308 rowspan: 1,
309 }
310 }
311}
312
313#[derive(Debug, Clone, Serialize, Deserialize)]
314pub struct TableRow {
315 pub cells: Vec<TableCell>,
316 pub is_header_row: bool,
317}
318
319impl TableRow {
320 pub fn new(cells: Vec<TableCell>) -> Self {
321 let is_header = cells.iter().all(|c| c.is_header);
322 Self {
323 cells,
324 is_header_row: is_header,
325 }
326 }
327}
328
329#[derive(Debug, Clone, Serialize, Deserialize)]
330pub struct TableContent {
331 pub caption: Option<String>,
332 pub headers: Vec<TableRow>,
333 pub rows: Vec<TableRow>,
334 pub column_count: usize,
335 pub summary: Option<String>,
336}
337
338impl TableContent {
339 pub fn new() -> Self {
340 Self {
341 caption: None,
342 headers: Vec::new(),
343 rows: Vec::new(),
344 column_count: 0,
345 summary: None,
346 }
347 }
348
349 pub fn is_empty(&self) -> bool {
350 self.headers.is_empty() && self.rows.is_empty()
351 }
352
353 pub fn row_count(&self) -> usize {
354 self.headers.len() + self.rows.len()
355 }
356}
357
358impl Default for TableContent {
359 fn default() -> Self {
360 Self::new()
361 }
362}
363
364#[derive(Debug, Clone, Serialize, Deserialize)]
369pub struct CodeBlock {
370 pub code: String,
371 pub language: Option<String>,
372 pub line_count: usize,
373 pub is_inline: bool,
374 pub filename: Option<String>,
375}
376
377impl CodeBlock {
378 pub fn new(code: impl Into<String>) -> Self {
379 let code_str = code.into();
380 let line_count = code_str.lines().count();
381 Self {
382 code: code_str,
383 language: None,
384 line_count,
385 is_inline: false,
386 filename: None,
387 }
388 }
389
390 pub fn with_language(mut self, lang: impl Into<String>) -> Self {
391 self.language = Some(lang.into());
392 self
393 }
394
395 pub fn inline(mut self) -> Self {
396 self.is_inline = true;
397 self
398 }
399}
400
401#[derive(Debug, Clone, Serialize, Deserialize)]
406pub struct Quote {
407 pub text: String,
408 pub cite: Option<String>,
409 pub cite_url: Option<String>,
410}
411
412impl Quote {
413 pub fn new(text: impl Into<String>) -> Self {
414 Self {
415 text: text.into(),
416 cite: None,
417 cite_url: None,
418 }
419 }
420
421 pub fn with_cite(mut self, cite: impl Into<String>) -> Self {
422 self.cite = Some(cite.into());
423 self
424 }
425}
426
427#[derive(Debug, Clone, Default, Serialize, Deserialize)]
432pub struct OpenGraph {
433 pub title: Option<String>,
434 pub og_type: Option<String>,
435 pub url: Option<String>,
436 pub image: Option<String>,
437 pub description: Option<String>,
438 pub site_name: Option<String>,
439 pub locale: Option<String>,
440 pub video: Option<String>,
441 pub audio: Option<String>,
442 pub extra: HashMap<String, String>,
443}
444
445impl OpenGraph {
446 pub fn is_present(&self) -> bool {
447 self.title.is_some() || self.og_type.is_some() || self.url.is_some()
448 }
449}
450
451#[derive(Debug, Clone, Default, Serialize, Deserialize)]
452pub struct TwitterCard {
453 pub card: Option<String>,
454 pub site: Option<String>,
455 pub creator: Option<String>,
456 pub title: Option<String>,
457 pub description: Option<String>,
458 pub image: Option<String>,
459 pub extra: HashMap<String, String>,
460}
461
462impl TwitterCard {
463 pub fn is_present(&self) -> bool {
464 self.card.is_some() || self.site.is_some()
465 }
466}
467
468#[derive(Debug, Clone, Default, Serialize, Deserialize)]
469pub struct RobotsMeta {
470 pub index: bool,
471 pub follow: bool,
472 pub archive: bool,
473 pub cache: bool,
474 pub snippet: bool,
475 pub max_snippet: i32,
476 pub max_image_preview: Option<String>,
477 pub max_video_preview: i32,
478 pub raw: Option<String>,
479}
480
481impl RobotsMeta {
482 pub fn allowed() -> Self {
483 Self {
484 index: true,
485 follow: true,
486 archive: true,
487 cache: true,
488 snippet: true,
489 max_snippet: -1,
490 max_image_preview: Some("large".to_string()),
491 max_video_preview: -1,
492 raw: None,
493 }
494 }
495
496 pub fn noindex_nofollow() -> Self {
497 Self {
498 index: false,
499 follow: false,
500 ..Self::allowed()
501 }
502 }
503}
504
505#[derive(Debug, Clone, Serialize, Deserialize)]
506pub struct AlternateLink {
507 pub hreflang: String,
508 pub href: String,
509}
510
511#[derive(Debug, Clone, Default, Serialize, Deserialize)]
512pub struct PageMetadata {
513 pub title: Option<String>,
514 pub description: Option<String>,
515 pub keywords: Vec<String>,
516 pub author: Option<String>,
517 pub generator: Option<String>,
518 pub canonical: Option<String>,
519 pub base_url: Option<String>,
520 pub language: Option<String>,
521 pub charset: Option<String>,
522 pub viewport: Option<String>,
523 pub robots: RobotsMeta,
524 pub opengraph: OpenGraph,
525 pub twitter: TwitterCard,
526 pub alternates: Vec<AlternateLink>,
527 pub favicon: Option<String>,
528 pub apple_touch_icon: Option<String>,
529 pub theme_color: Option<String>,
530 pub published_date: Option<String>,
531 pub modified_date: Option<String>,
532 pub schema_type: Option<String>,
533 pub custom: HashMap<String, String>,
534}
535
536impl PageMetadata {
537 pub fn effective_title(&self) -> Option<&str> {
538 self.opengraph.title.as_deref()
539 .or(self.twitter.title.as_deref())
540 .or(self.title.as_deref())
541 }
542
543 pub fn effective_description(&self) -> Option<&str> {
544 self.opengraph.description.as_deref()
545 .or(self.twitter.description.as_deref())
546 .or(self.description.as_deref())
547 }
548
549 pub fn effective_image(&self) -> Option<&str> {
550 self.opengraph.image.as_deref()
551 .or(self.twitter.image.as_deref())
552 }
553
554 pub fn should_index(&self) -> bool {
555 self.robots.index
556 }
557
558 pub fn should_follow(&self) -> bool {
559 self.robots.follow
560 }
561}
562
563#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
568#[serde(rename_all = "snake_case")]
569pub enum StructuredDataFormat {
570 JsonLd,
571 Microdata,
572 Rdfa,
573}
574
575#[derive(Debug, Clone, Serialize, Deserialize)]
576pub struct StructuredData {
577 pub format: StructuredDataFormat,
578 pub schema_type: Option<String>,
579 pub raw_json: Option<String>,
580 pub properties: HashMap<String, serde_json::Value>,
581}
582
583impl StructuredData {
584 pub fn json_ld(raw: impl Into<String>) -> Self {
585 Self {
586 format: StructuredDataFormat::JsonLd,
587 schema_type: None,
588 raw_json: Some(raw.into()),
589 properties: HashMap::new(),
590 }
591 }
592
593 pub fn microdata(schema_type: impl Into<String>) -> Self {
594 Self {
595 format: StructuredDataFormat::Microdata,
596 schema_type: Some(schema_type.into()),
597 raw_json: None,
598 properties: HashMap::new(),
599 }
600 }
601}
602
603#[derive(Debug, Clone, Default, Serialize, Deserialize)]
608pub struct ParsedContent {
609 pub metadata: PageMetadata,
610 pub text: TextContent,
611 pub headings: Vec<Heading>,
612 pub paragraphs: Vec<String>,
613 pub links: Vec<Link>,
614 pub images: Vec<Image>,
615 pub lists: Vec<ListContent>,
616 pub tables: Vec<TableContent>,
617 pub code_blocks: Vec<CodeBlock>,
618 pub quotes: Vec<Quote>,
619 pub structured_data: Vec<StructuredData>,
620 pub stats: ParseStats,
621}
622
623impl ParsedContent {
624 pub fn internal_links(&self) -> Vec<&Link> {
625 self.links.iter().filter(|l| l.link_type == LinkType::Internal).collect()
626 }
627
628 pub fn external_links(&self) -> Vec<&Link> {
629 self.links.iter().filter(|l| l.link_type == LinkType::External).collect()
630 }
631
632 pub fn followable_links(&self) -> Vec<&Link> {
633 self.links.iter().filter(|l| l.should_follow()).collect()
634 }
635
636 pub fn has_structured_data(&self) -> bool {
637 !self.structured_data.is_empty()
638 }
639}
640
641#[derive(Debug, Clone, Default, Serialize, Deserialize)]
642pub struct ParseStats {
643 pub html_size: usize,
644 pub parse_time_us: u64,
645 pub node_count: usize,
646 pub element_count: usize,
647 pub text_node_count: usize,
648 pub comment_count: usize,
649 pub errors: Vec<String>,
650 pub warnings: Vec<String>,
651}
652
653impl ParseStats {
654 pub fn has_errors(&self) -> bool {
655 !self.errors.is_empty()
656 }
657
658 pub fn has_warnings(&self) -> bool {
659 !self.warnings.is_empty()
660 }
661}
662
663#[derive(Debug, Clone)]
668pub struct ParserConfig {
669 pub base_url: Option<url::Url>,
670 pub max_text_length: usize,
671 pub extract_images: bool,
672 pub extract_links: bool,
673 pub extract_tables: bool,
674 pub extract_code_blocks: bool,
675 pub extract_structured_data: bool,
676 pub compute_readability: bool,
677 pub min_paragraph_length: usize,
678 pub content_selectors: Vec<String>,
679 pub remove_selectors: Vec<String>,
680 pub preserve_whitespace: bool,
681}
682
683impl Default for ParserConfig {
684 fn default() -> Self {
685 Self {
686 base_url: None,
687 max_text_length: 1_000_000,
688 extract_images: true,
689 extract_links: true,
690 extract_tables: true,
691 extract_code_blocks: true,
692 extract_structured_data: true,
693 compute_readability: false,
694 min_paragraph_length: 20,
695 content_selectors: vec![
696 "article".to_string(),
697 "main".to_string(),
698 "[role=main]".to_string(),
699 ".content".to_string(),
700 ".post-content".to_string(),
701 ".entry-content".to_string(),
702 ],
703 remove_selectors: vec![
704 "script".to_string(),
705 "style".to_string(),
706 "noscript".to_string(),
707 "nav".to_string(),
708 "header".to_string(),
709 "footer".to_string(),
710 "aside".to_string(),
711 ".sidebar".to_string(),
712 ".advertisement".to_string(),
713 ".ad".to_string(),
714 ".ads".to_string(),
715 "[role=navigation]".to_string(),
716 "[role=banner]".to_string(),
717 "[role=contentinfo]".to_string(),
718 ],
719 preserve_whitespace: false,
720 }
721 }
722}
723
724impl ParserConfig {
725 pub fn with_base_url(url: impl AsRef<str>) -> Result<Self, url::ParseError> {
726 Ok(Self {
727 base_url: Some(url::Url::parse(url.as_ref())?),
728 ..Default::default()
729 })
730 }
731
732 pub fn minimal() -> Self {
733 Self {
734 extract_images: false,
735 extract_tables: false,
736 extract_code_blocks: false,
737 extract_structured_data: false,
738 compute_readability: false,
739 ..Default::default()
740 }
741 }
742
743 pub fn full() -> Self {
744 Self {
745 compute_readability: true,
746 ..Default::default()
747 }
748 }
749
750 pub fn base_url(mut self, url: url::Url) -> Self {
751 self.base_url = Some(url);
752 self
753 }
754
755 pub fn add_content_selector(mut self, selector: impl Into<String>) -> Self {
756 self.content_selectors.push(selector.into());
757 self
758 }
759
760 pub fn add_remove_selector(mut self, selector: impl Into<String>) -> Self {
761 self.remove_selectors.push(selector.into());
762 self
763 }
764}
765
766pub fn normalize_whitespace(text: &str) -> String {
771 let mut result = String::with_capacity(text.len());
772 let mut prev_ws = false;
773 for c in text.chars() {
774 if c.is_whitespace() {
775 if !prev_ws {
776 result.push(' ');
777 prev_ws = true;
778 }
779 } else {
780 result.push(c);
781 prev_ws = false;
782 }
783 }
784 result.trim().to_string()
785}
786
787pub fn clean_text(text: &str) -> String {
788 text.chars()
789 .filter(|c| !c.is_control() || c.is_whitespace())
790 .collect()
791}
792
793pub fn truncate_text(text: &str, max_len: usize) -> String {
794 if text.len() <= max_len {
795 text.to_string()
796 } else {
797 let mut truncated: String = text.chars().take(max_len - 3).collect();
798 truncated.push_str("...");
799 truncated
800 }
801}
802
803#[cfg(test)]
804mod tests {
805 use super::*;
806
807 #[test]
808 fn test_text_content_creation() {
809 let text = TextContent::from_raw("Hello world, this is a test.");
810 assert_eq!(text.cleaned_text, "Hello world, this is a test.");
811 assert_eq!(text.word_count, 6);
812 assert!(!text.is_empty());
813 }
814
815 #[test]
816 fn test_heading_creation() {
817 let h1 = Heading::new(1, "Main Title").with_id("main");
818 assert_eq!(h1.level, 1);
819 assert_eq!(h1.id, Some("main".to_string()));
820 }
821
822 #[test]
823 fn test_link_creation() {
824 let link = Link::new("https://example.com", "Example");
825 assert!(!link.is_nofollow);
826 assert!(link.should_follow());
827 }
828
829 #[test]
830 fn test_image_creation() {
831 let img = Image::new("/img/photo.jpg", "A photo");
832 assert!(!img.is_decorative);
833 let decorative = Image::new("/img/spacer.gif", "");
834 assert!(decorative.is_decorative);
835 }
836
837 #[test]
838 fn test_opengraph() {
839 let og = OpenGraph::default();
840 assert!(!og.is_present());
841 let og2 = OpenGraph {
842 title: Some("Test".to_string()),
843 ..Default::default()
844 };
845 assert!(og2.is_present());
846 }
847
848 #[test]
849 fn test_parser_config() {
850 let config = ParserConfig::default();
851 assert!(config.extract_images);
852 let minimal = ParserConfig::minimal();
853 assert!(!minimal.extract_images);
854 }
855
856 #[test]
857 fn test_normalize_whitespace() {
858 assert_eq!(normalize_whitespace(" hello world "), "hello world");
859 assert_eq!(normalize_whitespace(" "), "");
860 }
861
862 #[test]
863 fn test_truncate_text() {
864 assert_eq!(truncate_text("Hello", 10), "Hello");
865 assert_eq!(truncate_text("Hello World", 8), "Hello...");
866 }
867}