cli to detect tropes in prose
rust cli ai toml aho-corasick llm slop
0

Configure Feed

Select the types of activity you want to include in your feed.

feat: repetition detectors and span abstraction

+424 -72
+33 -6
README.md
··· 10 10 ```text 11 11 Text 12 12 13 - Normalize text 14 - 15 13 Aho-Corasick phrase matcher 16 14 17 - Regex / parser structural rules 15 + Structural, repetition, and character-class detectors 18 16 19 - Feature scoring 20 - 21 - Report 17 + Findings report 18 + ``` 19 + 20 + ## Usage 21 + 22 + Scan text from stdin: 23 + 24 + ```sh 25 + printf 'Let us delve into this robust ecosystem.' | cargo run -q -p tropius-cli 26 + ``` 27 + 28 + Scan article text extracted from a live URL with [lectito](https://lectito.stormlightlabs.org/): 29 + 30 + ```sh 31 + lectito 'https://www.solo.io/blog/what-is-agent-identity-human-workload-a-new-layer' \ 32 + --format text \ 33 + | cargo run -q -p tropius-cli 22 34 ``` 35 + 36 + The CLI exits `0` when no findings are found and `1` when it finds trope signals. 37 + 38 + It exits `2` for usage or configuration errors. 39 + 40 + Color output respects [`NO_COLOR`](https://no-color.org/). 41 + 42 + ## Coverage 43 + 44 + Current coverage includes: 45 + 46 + - phrase patterns for 22 of 33 trope.fyi sections 47 + - structural detectors for sentence and paragraph shape 48 + - repetition detectors for repeated metaphor terms and duplicated content 49 + - character-class detection for Unicode decoration 23 50 24 51 ## Inspiration 25 52
+2 -2
crates/cli/src/main.rs
··· 75 75 finding 76 76 .rule_id 77 77 .if_supports_color(Stream::Stdout, |text| text.bold()), 78 - finding.start, 79 - finding.end, 78 + finding.span.start(), 79 + finding.span.end(), 80 80 finding 81 81 .matched 82 82 .if_supports_color(Stream::Stdout, |text| text.yellow())
+42 -12
crates/core/src/detector.rs
··· 57 57 let mut findings = self.scan_phrases(text); 58 58 findings.extend(char_class::scan_unicode_decoration(text)); 59 59 findings.extend(structural::scan_structural(text)); 60 - findings.sort_by_key(|finding| finding.start); 60 + findings.extend(repetition::scan_repetition(text)); 61 + findings.sort_by_key(|finding| finding.span.start()); 61 62 findings 62 63 } 63 64 ··· 74 75 severity: pattern.severity, 75 76 kind: FindingKind::Phrase, 76 77 matched: text[mat.start()..mat.end()].to_owned(), 77 - start: mat.start(), 78 - end: mat.end(), 78 + span: Span(mat.start(), mat.end()), 79 79 } 80 80 }) 81 81 .collect() ··· 95 95 pub kind: FindingKind, 96 96 /// Matched text slice. 97 97 pub matched: String, 98 - /// Start byte offset. 99 - pub start: usize, 100 - /// End byte offset. 101 - pub end: usize, 98 + /// Start & end byte offset. 99 + pub span: Span, 102 100 } 103 101 104 102 impl Finding { ··· 107 105 rule_name: &str, 108 106 severity: Severity, 109 107 text: &str, 110 - start: usize, 111 - end: usize, 108 + span: Span, 112 109 ) -> Finding { 113 110 Finding { 114 111 rule_id: rule_id.to_owned(), 115 112 rule_name: rule_name.to_owned(), 116 113 severity, 117 114 kind: FindingKind::Structural, 118 - matched: text[start..end].to_owned(), 119 - start, 120 - end, 115 + matched: text[span.start()..span.end()].to_owned(), 116 + span, 117 + } 118 + } 119 + 120 + /// Builds a repetition finding from a byte range in the scanned text. 121 + pub fn repetition( 122 + rule_id: &str, 123 + rule_name: &str, 124 + severity: Severity, 125 + text: &str, 126 + span: Span, 127 + ) -> Finding { 128 + Finding { 129 + rule_id: rule_id.to_owned(), 130 + rule_name: rule_name.to_owned(), 131 + severity, 132 + kind: FindingKind::Repetition, 133 + matched: text[span.start()..span.end()].to_owned(), 134 + span, 121 135 } 122 136 } 123 137 } ··· 131 145 CharacterClass, 132 146 /// Document or sentence structure matched by heuristic detectors. 133 147 Structural, 148 + /// Repeated document content matched by repetition detectors. 149 + Repetition, 134 150 } 135 151 136 152 impl Display for FindingKind { ··· 139 155 FindingKind::Phrase => "phrase", 140 156 FindingKind::CharacterClass => "char", 141 157 FindingKind::Structural => "struct", 158 + FindingKind::Repetition => "repeat", 142 159 }) 143 160 } 144 161 } ··· 146 163 impl FindingKind { 147 164 pub fn label(self) -> String { 148 165 self.to_string() 166 + } 167 + } 168 + 169 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 170 + pub struct Span(pub usize, pub usize); 171 + 172 + impl Span { 173 + pub fn start(&self) -> usize { 174 + self.0 175 + } 176 + 177 + pub fn end(&self) -> usize { 178 + self.1 149 179 } 150 180 } 151 181
+3 -4
crates/core/src/detector/char_class.rs
··· 21 21 severity: Severity::Low, 22 22 kind: FindingKind::CharacterClass, 23 23 matched: character.to_string(), 24 - start, 25 - end: start + character.len_utf8(), 24 + span: super::Span(start, start + character.len_utf8()), 26 25 }) 27 26 .collect() 28 27 } ··· 37 36 38 37 assert_eq!(findings.len(), 1); 39 38 assert_eq!(findings[0].matched, "→"); 40 - assert_eq!(findings[0].start, 6); 41 - assert_eq!(findings[0].end, 9); 39 + assert_eq!(findings[0].span.start(), 6); 40 + assert_eq!(findings[0].span.end(), 9); 42 41 } 43 42 44 43 #[test]
+312
crates/core/src/detector/repetition.rs
··· 1 + //! Repetition detectors for document-level trope signals. 2 + 3 + use std::collections::HashMap; 4 + 5 + use crate::patterns::Severity; 6 + 7 + use super::Finding; 8 + 9 + const DEAD_METAPHOR_TERMS: &[&str] = &[ 10 + "ecosystem", 11 + "ecosystems", 12 + "wall", 13 + "walls", 14 + "door", 15 + "doors", 16 + "primitive", 17 + "primitives", 18 + "tapestry", 19 + "landscape", 20 + ]; 21 + 22 + const STOP_WORDS: &[&str] = &[ 23 + "about", "after", "again", "also", "because", "before", "being", "between", "could", "every", 24 + "from", "have", "into", "more", "much", "over", "same", "should", "that", "their", "there", 25 + "these", "they", "this", "through", "what", "when", "where", "which", "while", "with", "would", 26 + "your", 27 + ]; 28 + 29 + /// Finds repetition-based trope signals in text. 30 + pub fn scan_repetition(text: &str) -> Vec<Finding> { 31 + let paragraphs = paragraph_spans(text); 32 + let sentences = sentence_spans(text); 33 + 34 + let mut findings = Vec::new(); 35 + findings.extend(scan_dead_metaphor(text)); 36 + findings.extend(scan_one_point_dilution(text, &paragraphs)); 37 + findings.extend(scan_content_duplication(text, &paragraphs, &sentences)); 38 + findings 39 + } 40 + 41 + fn scan_dead_metaphor(text: &str) -> Vec<Finding> { 42 + let mut hits: HashMap<&str, Vec<super::Span>> = HashMap::new(); 43 + 44 + for span in word_spans(text) { 45 + let word = text[span.start()..span.end()].to_ascii_lowercase(); 46 + 47 + if let Some(term) = DEAD_METAPHOR_TERMS 48 + .iter() 49 + .find(|term| **term == word.as_str()) 50 + { 51 + hits.entry(term).or_default().push(span); 52 + } 53 + } 54 + 55 + hits.into_values() 56 + .filter(|spans| spans.len() >= 5) 57 + .map(|spans| { 58 + Finding::repetition( 59 + "composition.dead_metaphor", 60 + "The Dead Metaphor", 61 + Severity::Medium, 62 + text, 63 + super::Span(spans[0].start(), spans[spans.len() - 1].end()), 64 + ) 65 + }) 66 + .collect() 67 + } 68 + 69 + fn scan_one_point_dilution(text: &str, paragraphs: &[super::Span]) -> Vec<Finding> { 70 + let paragraph_terms: Vec<_> = paragraphs 71 + .iter() 72 + .map(|paragraph| { 73 + ( 74 + *paragraph, 75 + top_terms(&text[paragraph.start()..paragraph.end()]), 76 + ) 77 + }) 78 + .filter(|(_, terms)| terms.len() >= 3) 79 + .collect(); 80 + 81 + paragraph_terms 82 + .windows(3) 83 + .filter(|window| { 84 + let shared = shared_terms(&window[0].1, &window[1].1, &window[2].1); 85 + shared >= 3 86 + }) 87 + .map(|window| { 88 + Finding::repetition( 89 + "composition.one_point_dilution", 90 + "One-Point Dilution", 91 + Severity::Medium, 92 + text, 93 + super::Span(window[0].0.start(), window[2].0.end()), 94 + ) 95 + }) 96 + .collect() 97 + } 98 + 99 + fn scan_content_duplication( 100 + text: &str, 101 + paragraphs: &[super::Span], 102 + sentences: &[super::Span], 103 + ) -> Vec<Finding> { 104 + let mut findings = duplicate_normalized_spans( 105 + text, 106 + paragraphs, 107 + "composition.content_duplication", 108 + "Content Duplication", 109 + ); 110 + 111 + findings.extend(duplicate_normalized_spans( 112 + text, 113 + sentences, 114 + "composition.content_duplication", 115 + "Content Duplication", 116 + )); 117 + 118 + findings 119 + } 120 + 121 + fn duplicate_normalized_spans( 122 + text: &str, 123 + spans: &[super::Span], 124 + rule_id: &str, 125 + rule_name: &str, 126 + ) -> Vec<Finding> { 127 + let mut seen: HashMap<String, super::Span> = HashMap::new(); 128 + let mut findings = Vec::new(); 129 + 130 + for span in spans { 131 + let value = &text[span.start()..span.end()]; 132 + 133 + if value.len() < 40 { 134 + continue; 135 + } 136 + 137 + let normalized = normalize_text(value); 138 + 139 + if normalized.len() < 40 { 140 + continue; 141 + } 142 + 143 + if let Some(previous) = seen.get(&normalized) { 144 + findings.push(Finding::repetition( 145 + rule_id, 146 + rule_name, 147 + Severity::High, 148 + text, 149 + super::Span(previous.start(), span.end()), 150 + )); 151 + } else { 152 + seen.insert(normalized, *span); 153 + } 154 + } 155 + 156 + findings 157 + } 158 + 159 + fn paragraph_spans(text: &str) -> Vec<super::Span> { 160 + split_spans(text, "\n\n") 161 + } 162 + 163 + fn sentence_spans(text: &str) -> Vec<super::Span> { 164 + let mut spans = Vec::new(); 165 + let mut start = 0; 166 + 167 + for (index, character) in text.char_indices() { 168 + if matches!(character, '.' | '!' | '?') { 169 + push_trimmed_span(text, &mut spans, start, index + character.len_utf8()); 170 + start = index + character.len_utf8(); 171 + } 172 + } 173 + 174 + push_trimmed_span(text, &mut spans, start, text.len()); 175 + spans 176 + } 177 + 178 + fn split_spans(text: &str, separator: &str) -> Vec<super::Span> { 179 + let mut spans = Vec::new(); 180 + let mut start = 0; 181 + 182 + for (index, _) in text.match_indices(separator) { 183 + push_trimmed_span(text, &mut spans, start, index); 184 + start = index + separator.len(); 185 + } 186 + 187 + push_trimmed_span(text, &mut spans, start, text.len()); 188 + spans 189 + } 190 + 191 + fn push_trimmed_span(text: &str, spans: &mut Vec<super::Span>, start: usize, end: usize) { 192 + let value = &text[start..end]; 193 + let trimmed = value.trim(); 194 + 195 + if trimmed.is_empty() { 196 + return; 197 + } 198 + 199 + let leading = value.len() - value.trim_start().len(); 200 + let trailing = value.len() - value.trim_end().len(); 201 + 202 + spans.push(super::Span(start + leading, end - trailing)); 203 + } 204 + 205 + fn word_spans(text: &str) -> Vec<super::Span> { 206 + let mut spans = Vec::new(); 207 + let mut start = None; 208 + 209 + for (index, character) in text.char_indices() { 210 + if character.is_ascii_alphanumeric() || character == '\'' { 211 + start.get_or_insert(index); 212 + } else if let Some(word_start) = start.take() { 213 + spans.push(super::Span(word_start, index)); 214 + } 215 + } 216 + 217 + if let Some(word_start) = start { 218 + spans.push(super::Span(word_start, text.len())); 219 + } 220 + 221 + spans 222 + } 223 + 224 + fn top_terms(text: &str) -> Vec<String> { 225 + let mut counts: HashMap<String, usize> = HashMap::new(); 226 + 227 + for span in word_spans(text) { 228 + let word = text[span.start()..span.end()].to_ascii_lowercase(); 229 + 230 + if word.len() < 5 || STOP_WORDS.contains(&word.as_str()) { 231 + continue; 232 + } 233 + 234 + *counts.entry(word).or_default() += 1; 235 + } 236 + 237 + let mut counts: Vec<_> = counts.into_iter().collect(); 238 + counts.sort_by(|left, right| right.1.cmp(&left.1).then_with(|| left.0.cmp(&right.0))); 239 + counts.into_iter().take(5).map(|(word, _)| word).collect() 240 + } 241 + 242 + fn shared_terms(first: &[String], second: &[String], third: &[String]) -> usize { 243 + first 244 + .iter() 245 + .filter(|term| second.contains(term) && third.contains(term)) 246 + .count() 247 + } 248 + 249 + fn normalize_text(text: &str) -> String { 250 + word_spans(text) 251 + .into_iter() 252 + .map(|span| text[span.start()..span.end()].to_ascii_lowercase()) 253 + .collect::<Vec<_>>() 254 + .join(" ") 255 + } 256 + 257 + #[cfg(test)] 258 + mod tests { 259 + use super::*; 260 + 261 + #[test] 262 + fn detects_dead_metaphor() { 263 + let findings = scan_repetition( 264 + "The ecosystem needs ecosystem value. This ecosystem has ecosystem tools for the ecosystem.", 265 + ); 266 + 267 + assert!( 268 + findings 269 + .iter() 270 + .any(|finding| finding.rule_id == "composition.dead_metaphor") 271 + ); 272 + } 273 + 274 + #[test] 275 + fn detects_one_point_dilution() { 276 + let findings = scan_repetition( 277 + "Platform access pricing blocks builders and adoption.\n\nPlatform access pricing slows builders and adoption.\n\nPlatform access pricing confuses builders and adoption.", 278 + ); 279 + 280 + assert!( 281 + findings 282 + .iter() 283 + .any(|finding| finding.rule_id == "composition.one_point_dilution") 284 + ); 285 + } 286 + 287 + #[test] 288 + fn detects_duplicate_paragraphs() { 289 + let findings = scan_repetition( 290 + "This paragraph repeats the same exact claim about adoption and access.\n\nThis paragraph repeats the same exact claim about adoption and access.", 291 + ); 292 + 293 + assert!( 294 + findings 295 + .iter() 296 + .any(|finding| finding.rule_id == "composition.content_duplication") 297 + ); 298 + } 299 + 300 + #[test] 301 + fn detects_duplicate_sentences() { 302 + let findings = scan_repetition( 303 + "This sentence repeats the same exact claim about adoption and access. Something else happens. This sentence repeats the same exact claim about adoption and access.", 304 + ); 305 + 306 + assert!( 307 + findings 308 + .iter() 309 + .any(|finding| finding.rule_id == "composition.content_duplication") 310 + ); 311 + } 312 + }
+26 -43
crates/core/src/detector/structural.rs
··· 4 4 5 5 use super::Finding; 6 6 7 - #[derive(Debug, Clone, Copy, PartialEq, Eq)] 8 - struct Span { 9 - start: usize, 10 - end: usize, 11 - } 12 - 13 7 /// Finds structural trope signals in text. 14 8 pub fn scan_structural(text: &str) -> Vec<Finding> { 15 9 let sentences = sentence_spans(text); ··· 25 19 findings 26 20 } 27 21 28 - fn scan_anaphora(text: &str, sentences: &[Span]) -> Vec<Finding> { 22 + fn scan_anaphora(text: &str, sentences: &[super::Span]) -> Vec<Finding> { 29 23 let starts: Vec<_> = sentences 30 24 .iter() 31 25 .filter_map(|sentence| sentence_start_key(text, *sentence).map(|key| (*sentence, key))) ··· 40 34 "Anaphora Abuse", 41 35 Severity::Medium, 42 36 text, 43 - window[0].0.start, 44 - window[2].0.end, 37 + super::Span(window[0].0.start(), window[2].0.end()), 45 38 ) 46 39 }) 47 40 .collect() 48 41 } 49 42 50 - fn scan_tricolon(text: &str, sentences: &[Span]) -> Vec<Finding> { 43 + fn scan_tricolon(text: &str, sentences: &[super::Span]) -> Vec<Finding> { 51 44 sentences 52 45 .iter() 53 46 .filter(|sentence| { 54 - let value = &text[sentence.start..sentence.end]; 47 + let value = &text[sentence.start()..sentence.end()]; 55 48 let separators = value.matches(',').count() + value.matches(';').count(); 56 49 separators >= 2 && repeated_clause_starts(value) >= 2 57 50 }) ··· 61 54 "Tricolon Abuse", 62 55 Severity::Medium, 63 56 text, 64 - sentence.start, 65 - sentence.end, 57 + super::Span(sentence.start(), sentence.end()), 66 58 ) 67 59 }) 68 60 .collect() 69 61 } 70 62 71 - fn scan_short_punchy_fragments(text: &str, sentences: &[Span]) -> Vec<Finding> { 63 + fn scan_short_punchy_fragments(text: &str, sentences: &[super::Span]) -> Vec<Finding> { 72 64 let mut findings = Vec::new(); 73 65 let mut run_start = None; 74 66 let mut run_end = 0; 75 67 let mut run_len = 0; 76 68 77 69 for sentence in sentences { 78 - if word_count(&text[sentence.start..sentence.end]) <= 4 { 79 - run_start.get_or_insert(sentence.start); 80 - run_end = sentence.end; 70 + if word_count(&text[sentence.start()..sentence.end()]) <= 4 { 71 + run_start.get_or_insert(sentence.start()); 72 + run_end = sentence.end(); 81 73 run_len += 1; 82 74 } else { 83 75 if run_len >= 3 { ··· 86 78 "Short Punchy Fragments", 87 79 Severity::Medium, 88 80 text, 89 - run_start.unwrap(), 90 - run_end, 81 + super::Span(run_start.unwrap(), run_end), 91 82 )); 92 83 } 93 84 run_start = None; ··· 102 93 "Short Punchy Fragments", 103 94 Severity::Medium, 104 95 text, 105 - run_start.unwrap(), 106 - run_end, 96 + super::Span(run_start.unwrap(), run_end), 107 97 )); 108 98 } 109 99 110 100 findings 111 101 } 112 102 113 - fn scan_listicle_in_trench_coat(text: &str, paragraphs: &[Span]) -> Vec<Finding> { 103 + fn scan_listicle_in_trench_coat(text: &str, paragraphs: &[super::Span]) -> Vec<Finding> { 114 104 let mut ordinal_hits = Vec::new(); 115 105 116 106 for paragraph in paragraphs { 117 - if paragraph_starts_with_ordinal(&text[paragraph.start..paragraph.end]) { 107 + if paragraph_starts_with_ordinal(&text[paragraph.start()..paragraph.end()]) { 118 108 ordinal_hits.push(*paragraph); 119 109 } 120 110 } ··· 127 117 "Listicle in a Trench Coat", 128 118 Severity::Medium, 129 119 text, 130 - window[0].start, 131 - window[2].end, 120 + super::Span(window[0].start(), window[2].end()), 132 121 ) 133 122 }) 134 123 .collect() 135 124 } 136 125 137 - fn scan_fractal_summaries(text: &str, paragraphs: &[Span]) -> Vec<Finding> { 126 + fn scan_fractal_summaries(text: &str, paragraphs: &[super::Span]) -> Vec<Finding> { 138 127 let mut hits = Vec::new(); 139 128 140 129 for paragraph in paragraphs { 141 - let value = text[paragraph.start..paragraph.end].trim_start(); 130 + let value = text[paragraph.start()..paragraph.end()].trim_start(); 142 131 if starts_with_any_ci( 143 132 value, 144 133 &[ ··· 163 152 "Fractal Summaries", 164 153 Severity::Medium, 165 154 text, 166 - hits[0].start, 167 - hits[hits.len() - 1].end, 155 + super::Span(hits[0].start(), hits[hits.len() - 1].end()), 168 156 )] 169 157 } 170 158 171 - fn scan_historical_analogy_stacking(text: &str, sentences: &[Span]) -> Vec<Finding> { 159 + fn scan_historical_analogy_stacking(text: &str, sentences: &[super::Span]) -> Vec<Finding> { 172 160 sentences 173 161 .windows(3) 174 162 .filter(|window| { 175 163 window 176 164 .iter() 177 - .all(|sentence| has_analogy_marker(&text[sentence.start..sentence.end])) 165 + .all(|sentence| has_analogy_marker(&text[sentence.start()..sentence.end()])) 178 166 }) 179 167 .map(|window| { 180 168 Finding::structural( ··· 182 170 "Historical Analogy Stacking", 183 171 Severity::Medium, 184 172 text, 185 - window[0].start, 186 - window[2].end, 173 + super::Span(window[0].start(), window[2].end()), 187 174 ) 188 175 }) 189 176 .collect() 190 177 } 191 178 192 - fn sentence_spans(text: &str) -> Vec<Span> { 179 + fn sentence_spans(text: &str) -> Vec<super::Span> { 193 180 let mut spans = Vec::new(); 194 181 let mut start = 0; 195 182 ··· 204 191 spans 205 192 } 206 193 207 - fn paragraph_spans(text: &str) -> Vec<Span> { 194 + fn paragraph_spans(text: &str) -> Vec<super::Span> { 208 195 let mut spans = Vec::new(); 209 196 let mut start = 0; 210 197 ··· 217 204 spans 218 205 } 219 206 220 - fn push_trimmed_span(text: &str, spans: &mut Vec<Span>, start: usize, end: usize) { 207 + fn push_trimmed_span(text: &str, spans: &mut Vec<super::Span>, start: usize, end: usize) { 221 208 let value = &text[start..end]; 222 209 let trimmed = value.trim(); 223 210 ··· 227 214 228 215 let leading = value.len() - value.trim_start().len(); 229 216 let trailing = value.len() - value.trim_end().len(); 230 - 231 - spans.push(Span { 232 - start: start + leading, 233 - end: end - trailing, 234 - }); 217 + spans.push(super::Span(start + leading, end - trailing)); 235 218 } 236 219 237 - fn sentence_start_key(text: &str, sentence: Span) -> Option<String> { 238 - let words = words(&text[sentence.start..sentence.end]); 220 + fn sentence_start_key(text: &str, sentence: super::Span) -> Option<String> { 221 + let words = words(&text[sentence.start()..sentence.end()]); 239 222 240 223 if words.is_empty() { 241 224 None
+1
meta/examples.txt
··· 13 13 https://biagibros.com/blog/technology-3pl/enhancing-efficiency-and-growth-the-benefits-of-third-party-logistics-3pl-in-the-food-and-beverage-industry/ 14 14 https://convenienturgent.com/blogs/how-a-walk-in-clinic-can-be-faster-than-a-doctors-office/ 15 15 https://reveald.com/blog/understanding-the-cybersecurity-landscape-of-2023 16 + https://www.solo.io/blog/what-is-agent-identity-human-workload-a-new-layer
+5 -5
todo.md
··· 41 41 ## Trope Coverage Checklist 42 42 43 43 Current phrase coverage: 22 of 33 source sections. 44 - Implemented non-Aho detectors: 7. 44 + Implemented non-Aho detectors: 10. 45 45 46 46 - [x] Quietly and Other Magic Adverbs 47 47 - [x] Delve and Friends ··· 70 70 - [ ] Bold-First Bullets - markdown-aware detector 71 71 - [x] Unicode Decoration - character-class detector 72 72 - [x] Fractal Summaries - structural detector 73 - - [ ] The Dead Metaphor - repetition detector 73 + - [x] The Dead Metaphor - repetition detector 74 74 - [x] Historical Analogy Stacking - structural detector 75 - - [ ] One-Point Dilution - repetition detector (or semantic) 76 - - [ ] Content Duplication - repetition detector 75 + - [x] One-Point Dilution - repetition detector 76 + - [x] Content Duplication - repetition detector 77 77 - [x] The Signposted Conclusion 78 78 - [x] Despite Its Challenges 79 79 ··· 128 128 Use the `lectito` CLI to extract article text into fixtures when useful: 129 129 130 130 ```text 131 - lectito inspect <url> --text > meta/examples/clean/example.txt 131 + lectito --format text <url> > meta/examples/clean/example.txt 132 132 ``` 133 133 134 134 ### Unit Tests