|
| 1 | +""" |
| 2 | +QRG-aligned content quality detector for OpenSEO. |
| 3 | +
|
| 4 | +Ported from claude-seo's content_quality.py script. |
| 5 | +Scores text against Google's January 23, 2025 Quality Rater Guidelines update: |
| 6 | + - §4.6.5 Scaled content abuse (using low-effort templates or AI generators) |
| 7 | + - §4.6.6 MC (Main Content) copied or AI-generated without value |
| 8 | + - §4.6 Filler content (padding phrases, low information density) |
| 9 | +""" |
| 10 | + |
| 11 | +from __future__ import annotations |
| 12 | + |
| 13 | +import re |
| 14 | +from collections import Counter |
| 15 | +from typing import Iterable, TypedDict |
| 16 | + |
| 17 | + |
| 18 | +class QRGAnalysisResult(TypedDict): |
| 19 | + filler_score: int |
| 20 | + ai_pattern_score: int |
| 21 | + information_density: float |
| 22 | + repetition_score: int |
| 23 | + overall_quality: int |
| 24 | + flags: list[str] |
| 25 | + matches: dict[str, list[str]] |
| 26 | + tokens: int |
| 27 | + unique_tokens: int |
| 28 | + |
| 29 | + |
| 30 | +# Padding / filler phrases that QRG §4.6 flags as "little-to-no value". |
| 31 | +FILLER_PHRASES: tuple[str, ...] = ( |
| 32 | + "it's important to note that", |
| 33 | + "in this article, we'll explore", |
| 34 | + "in this article we will explore", |
| 35 | + "in today's fast-paced world", |
| 36 | + "in today's digital age", |
| 37 | + "in today's competitive landscape", |
| 38 | + "needless to say", |
| 39 | + "at the end of the day", |
| 40 | + "when it comes to", |
| 41 | + "when all is said and done", |
| 42 | + "in the realm of", |
| 43 | + "in the world of", |
| 44 | + "the bottom line is", |
| 45 | + "without further ado", |
| 46 | + "first and foremost", |
| 47 | + "last but not least", |
| 48 | + "for what it's worth", |
| 49 | + "it goes without saying", |
| 50 | + "as we all know", |
| 51 | + "the truth is that", |
| 52 | + "the fact of the matter is", |
| 53 | + "more often than not", |
| 54 | + "let's dive in", |
| 55 | + "let's dive into", |
| 56 | + "let's take a closer look", |
| 57 | + "let's take a deeper look", |
| 58 | +) |
| 59 | + |
| 60 | + |
| 61 | +# LLM-typical phrasings (Wikipedia AI Cleanup catalogue, CC BY-SA 4.0; |
| 62 | +# also used by ivankuznetsov/claude-seo, MIT). |
| 63 | +AI_PATTERNS: tuple[str, ...] = ( |
| 64 | + "delve into", |
| 65 | + "delve deeper into", |
| 66 | + "in the ever-evolving", |
| 67 | + "ever-evolving landscape", |
| 68 | + "ever-changing landscape", |
| 69 | + "in the dynamic landscape", |
| 70 | + "navigating the", |
| 71 | + "navigate the complexities", |
| 72 | + "tapestry of", |
| 73 | + "rich tapestry", |
| 74 | + "intricate tapestry", |
| 75 | + "embark on a journey", |
| 76 | + "embarking on this", |
| 77 | + "a testament to", |
| 78 | + "a beacon of", |
| 79 | + "the cornerstone of", |
| 80 | + "a cornerstone of", |
| 81 | + "at the heart of", |
| 82 | + "at its core", |
| 83 | + "in essence,", |
| 84 | + "in conclusion,", |
| 85 | + "ultimately,", |
| 86 | + "moreover,", |
| 87 | + "furthermore,", |
| 88 | + "however, it's worth noting", |
| 89 | + "it's worth noting that", |
| 90 | + "by leveraging", |
| 91 | + "leverage the power of", |
| 92 | + "leveraging the power of", |
| 93 | + "harness the power of", |
| 94 | + "unlock the potential", |
| 95 | + "unlock the full potential", |
| 96 | + "the realm of possibilities", |
| 97 | + "open up a world of", |
| 98 | + "a world of possibilities", |
| 99 | + "elevate your", |
| 100 | + "transform your", |
| 101 | + "revolutionize the way", |
| 102 | + "game-changer", |
| 103 | + "game-changing", |
| 104 | + "cutting-edge", |
| 105 | + "state-of-the-art", |
| 106 | + "in summary,", |
| 107 | + "to summarize,", |
| 108 | + "to put it simply,", |
| 109 | + "in a nutshell,", |
| 110 | +) |
| 111 | + |
| 112 | + |
| 113 | +TOKEN_RE = re.compile(r"[A-Za-z][A-Za-z'\-]*") |
| 114 | +NUMBER_RE = re.compile(r"\b\d+(?:[.,]\d+)?(?:%|st|nd|rd|th)?\b") |
| 115 | +# Capitalised multi-word names: rough proper-noun heuristic. Two or more |
| 116 | +# capitalised tokens in a row count as one entity. |
| 117 | +ENTITY_RE = re.compile(r"\b(?:[A-Z][a-z]+(?:\s+[A-Z][a-z]+)+)\b") |
| 118 | + |
| 119 | + |
| 120 | +def _count_phrase_hits(text: str, patterns: Iterable[str]) -> list[str]: |
| 121 | + """Return the patterns that appear at least once in text (case-insensitive).""" |
| 122 | + lowered = text.lower() |
| 123 | + return [p for p in patterns if p in lowered] |
| 124 | + |
| 125 | + |
| 126 | +def _repetition_score(tokens: list[str]) -> float: |
| 127 | + """Bigram repetition: fraction of bigrams that recur more than once.""" |
| 128 | + if len(tokens) < 4: |
| 129 | + return 0.0 |
| 130 | + bigrams = [f"{tokens[i]} {tokens[i+1]}" for i in range(len(tokens) - 1)] |
| 131 | + counts = Counter(bigrams) |
| 132 | + repeated = sum(1 for v in counts.values() if v > 1) |
| 133 | + return repeated / max(1, len(counts)) |
| 134 | + |
| 135 | + |
| 136 | +def analyze_qrg(text: str) -> QRGAnalysisResult: |
| 137 | + """Score a body of text against the QRG quality heuristics.""" |
| 138 | + if not text or not text.strip(): |
| 139 | + return { |
| 140 | + "filler_score": 0, |
| 141 | + "ai_pattern_score": 0, |
| 142 | + "information_density": 0.0, |
| 143 | + "repetition_score": 0, |
| 144 | + "overall_quality": 0, |
| 145 | + "flags": ["empty-input"], |
| 146 | + "matches": {"filler": [], "ai_patterns": []}, |
| 147 | + "tokens": 0, |
| 148 | + "unique_tokens": 0, |
| 149 | + } |
| 150 | + |
| 151 | + tokens = [t.lower() for t in TOKEN_RE.findall(text)] |
| 152 | + n_tokens = len(tokens) |
| 153 | + unique = len(set(tokens)) |
| 154 | + |
| 155 | + filler_hits = _count_phrase_hits(text, FILLER_PHRASES) |
| 156 | + ai_hits = _count_phrase_hits(text, AI_PATTERNS) |
| 157 | + |
| 158 | + # Density: entities + numbers per 100 tokens. A typical high-density |
| 159 | + # article lands at ~5+; a generic filler post lands at <2. |
| 160 | + entities = len(ENTITY_RE.findall(text)) |
| 161 | + numbers = len(NUMBER_RE.findall(text)) |
| 162 | + density_per_100 = (entities + numbers) * 100.0 / max(1, n_tokens) |
| 163 | + information_density = min(1.0, density_per_100 / 10.0) |
| 164 | + |
| 165 | + rep = _repetition_score(tokens) |
| 166 | + rep_score = int(round(rep * 100)) |
| 167 | + |
| 168 | + # Scale to per-1000 tokens so the score is comparable across page lengths. |
| 169 | + scale = max(1.0, n_tokens / 1000.0) |
| 170 | + filler_per_kt = len(filler_hits) / scale |
| 171 | + ai_per_kt = len(ai_hits) / scale |
| 172 | + |
| 173 | + filler_score = min(100, int(round(filler_per_kt * 25))) |
| 174 | + ai_pattern_score = min(100, int(round(ai_per_kt * 15))) |
| 175 | + |
| 176 | + flags: list[str] = [] |
| 177 | + if filler_score >= 50: |
| 178 | + flags.append("filler") |
| 179 | + if ai_pattern_score >= 40: |
| 180 | + flags.append("ai-patterns") |
| 181 | + if information_density < 0.20: |
| 182 | + flags.append("low-density") |
| 183 | + if rep_score >= 30: |
| 184 | + flags.append("repetitive") |
| 185 | + if n_tokens < 300: |
| 186 | + flags.append("thin-content") |
| 187 | + |
| 188 | + # Composite: invert penalty signals, weight by impact. |
| 189 | + overall = ( |
| 190 | + (100 - filler_score) * 0.25 |
| 191 | + + (100 - ai_pattern_score) * 0.25 |
| 192 | + + information_density * 100 * 0.25 |
| 193 | + + (100 - rep_score) * 0.15 |
| 194 | + + min(100, n_tokens / 10.0) * 0.10 # length bonus capped at 1000 tokens |
| 195 | + ) |
| 196 | + |
| 197 | + return { |
| 198 | + "filler_score": filler_score, |
| 199 | + "ai_pattern_score": ai_pattern_score, |
| 200 | + "information_density": round(information_density, 3), |
| 201 | + "repetition_score": rep_score, |
| 202 | + "overall_quality": int(round(overall)), |
| 203 | + "flags": flags, |
| 204 | + "matches": {"filler": filler_hits, "ai_patterns": ai_hits}, |
| 205 | + "tokens": n_tokens, |
| 206 | + "unique_tokens": unique, |
| 207 | + } |
0 commit comments