Skip to content

Commit 574a4fd

Browse files
committed
feat: implement seo drift monitoring system for baseline capture and change detection
1 parent 89b5a05 commit 574a4fd

10 files changed

Lines changed: 1736 additions & 7 deletions

File tree

HOW_TO_USE.md

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
# OpenSEO CLI Documentation & Usage Guide 🔍
2+
3+
OpenSEO is a production-quality, provider-agnostic command-line tool built to help developers, marketers, and SEO professionals audit, analyze, and monitor search engine visibility using Large Language Models (LLMs) and local diagnostic checks.
4+
5+
---
6+
7+
## 🚀 Installation & Local Setup
8+
9+
### 1. Standard Virtual Environment setup
10+
If you are developing locally from the source, create and activate a virtual environment:
11+
12+
```powershell
13+
# Create environment
14+
python -m venv .venv
15+
16+
# Activate environment (Windows PowerShell)
17+
.venv\Scripts\Activate.ps1
18+
19+
# Activate environment (macOS/Linux)
20+
source .venv/bin/activate
21+
```
22+
23+
### 2. Install OpenSEO in Editable Mode (Required for development)
24+
To ensure the `seo` CLI command references your local workspace changes (`src/` directory), install the package in **editable mode**:
25+
26+
```bash
27+
pip install -e ".[all]"
28+
```
29+
*(Optionally run `playwright install chromium` if you wish to use javascript rendering features)*
30+
31+
---
32+
33+
## 🛠 Command Reference & Usage
34+
35+
Run `seo --help` to see all available commands. Below is a detailed breakdown of each command and how to use it:
36+
37+
---
38+
39+
### 1. Setup & Diagnostics
40+
41+
#### `seo init`
42+
Initializes OpenSEO with an interactive setup wizard that guides you through selecting default providers, models, caches, and output directory paths. Configurations are stored globally in `~/.openseo/config.json`.
43+
44+
```bash
45+
seo init
46+
```
47+
48+
#### `seo doctor`
49+
Runs a health check on your environment to verify API keys, cache database status, playwritght dependencies, and internet connectivity.
50+
51+
```bash
52+
seo doctor
53+
```
54+
55+
#### `seo provider`
56+
Lists available LLM providers, sets API credentials, and overrides default models.
57+
58+
```bash
59+
# List all providers
60+
seo provider list
61+
62+
# Set API key for a provider
63+
seo provider set-key openai sk-proj-...
64+
seo provider set-key gemini AIzaSy...
65+
66+
# Set active provider and model
67+
seo provider use gemini --model gemini/gemini-1.5-flash
68+
```
69+
70+
---
71+
72+
### 2. Analysis & Auditing
73+
74+
#### `seo audit`
75+
Crawls a website and runs rule-based technical checks combined with LLM analysis.
76+
77+
```bash
78+
# Run a quick audit on a single URL
79+
seo audit https://example.com
80+
81+
# Audit pages found in the sitemap index with custom depth
82+
seo audit https://example.com --sitemap-only --max-pages 20
83+
84+
# Generate a complete PDF scorecard report inside the results/ folder
85+
seo audit https://example.com --report
86+
```
87+
88+
#### `seo content`
89+
Analyzes content relevance against target keywords, checks search experience guidelines, and performs NLP/QRG diagnostics.
90+
91+
```bash
92+
# Run LLM-based content keyword gap audit
93+
seo content https://example.com/blog/python-tutorial --keyword "python tutorial"
94+
95+
# Run local Quality Rater Guidelines (QRG) checks (AI pattern, filler, repetition)
96+
seo content https://example.com/blog/python-tutorial --quality
97+
```
98+
99+
#### `seo schema`
100+
Generates structured Schema.org JSON-LD blocks for search eligibility.
101+
102+
```bash
103+
# Generate schema recommendations using page crawling + LLM analysis
104+
seo schema https://example.com/blog/post --type article
105+
106+
# Interactively generate predefined high-leverage schema templates
107+
seo schema --template profile
108+
seo schema --template discussion
109+
seo schema --template order
110+
seo schema --template reservation
111+
```
112+
113+
---
114+
115+
### 3. SEO Drift & Regression Monitoring (`seo drift`)
116+
117+
Allows capturing a technical baseline state of page structures and comparing current states to check for unintended deployments/code regressions (e.g., losing noindex directives, removing canonical tags, or deleting Schema).
118+
119+
#### Capture a Baseline Snapshot
120+
```bash
121+
seo drift baseline https://example.com/pricing
122+
```
123+
124+
#### Compare Current State to Baseline
125+
```bash
126+
# Compare against the latest captured baseline
127+
seo drift compare https://example.com/pricing
128+
129+
# Compare against a specific baseline ID
130+
seo drift compare https://example.com/pricing --baseline-id 4
131+
132+
# Output diff results as raw JSON
133+
seo drift compare https://example.com/pricing -o json
134+
```
135+
136+
#### View Drift History
137+
```bash
138+
seo drift history https://example.com/pricing
139+
```
140+
141+
#### Generate HTML Drift Reports
142+
```bash
143+
# Creates a self-contained color-coded HTML report (seo-drift-report.html)
144+
seo drift report https://example.com/pricing
145+
```
146+
147+
---
148+
149+
### 4. Utilities
150+
151+
- `seo sitemap <url>`: Fetches and parses standard XML sitemaps to check schema compliance.
152+
- `seo robots <url>`: Audits site `robots.txt` configuration and maps user-agent blocks.
153+
- `seo keywords <topic>`: Generates keyword clustering and search-intent outlines.

src/openseo/analyzers/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from openseo.analyzers.technical import analyze_page_technical, analyze_site_technical
1111
from openseo.analyzers.duplicates import detect_duplicate_clusters
1212
from openseo.analyzers.link_graph import build_link_graph
13+
from openseo.analyzers.qrg import analyze_qrg
1314

1415
__all__ = [
1516
"analyze_title",
@@ -23,4 +24,5 @@
2324
"analyze_site_technical",
2425
"detect_duplicate_clusters",
2526
"build_link_graph",
27+
"analyze_qrg",
2628
]

src/openseo/analyzers/qrg.py

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
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+
}

src/openseo/app.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ def create_app() -> typer.Typer:
5959
)
6060

6161
# ── Register Built-in Commands ────────────────────────────────────────────
62-
from openseo.commands import audit, config_cmd, content, doctor, init, keywords, provider, robots, schema, sitemap
62+
from openseo.commands import audit, config_cmd, content, doctor, init, keywords, provider, robots, schema, sitemap, drift
6363

6464
init.register(app)
6565
config_cmd.register(app)
@@ -71,6 +71,7 @@ def create_app() -> typer.Typer:
7171
doctor.register(app)
7272
sitemap.register(app)
7373
robots.register(app)
74+
drift.register(app)
7475

7576
# ── Version Command ───────────────────────────────────────────────────────
7677
@app.command("version")

0 commit comments

Comments
 (0)