diff --git a/.claude/memory/journal.md b/.claude/memory/journal.md index 7461677..3e554b3 100644 --- a/.claude/memory/journal.md +++ b/.claude/memory/journal.md @@ -396,6 +396,8 @@ rules: - BDR-068 (close-auto-persist) MERGED to develop + pushed. Then cut + pushed **v1.1.0** (minor, that feature). Standard forward bump → sonnet release-executor ran BOTH spans (prep + finish+tag); lineage continued 1.0.0→1.1.0 not 5.x (validates [[BDR-067]]). origin: main=2f8dc6b, develop=21b1e21, tags v1.0.0 + v1.1.0. WATCH-ITEM: a stale local tag `v4.0.0` reappeared during the release — NOT from origin (origin never regained it; `push.followTags` off; its commit unreachable from develop/main). Inert (push targeted main/develop/v1.1.0 explicitly + deleted the local copy; origin verified clean). Mechanism unexplained — if `v4.0.0` resurfaces locally after a `gitflow` op, trace the release lib (gitflow.sh / release-executor) for stray tag re-creation. ## 2026-07-17 +- content_quality verb shipped via /feat (2nd cherry-pick, stacked on feature/seo-data-cherry-picks): deterministic filler/AI-slop signal (QRG list intact, no LLM), advisory-not-verdict wired into geo STEP 8. GATE 1 CONFORME 10/10 both verbs, seo-data 190→210. Two easy claude-seo picks DONE; url_safety (DNS-rebinding) still deferred pending threat-model. Branch carries 2 feat + 1 journal commit, UNMERGED (human gate). +- Gap-revisit claude-seo after the 21-commit build: remaining cherry-pick value narrowed to 2 clean stdlib picks + url_safety (DNS-rebinding, deferred on threat-model). schema_gen verb shipped via /feat (honors [[BDR-070]] adapt-not-copy): generates JSON-LD (Reservation/OrderAction/DiscussionForumPosting/ProfilePage), the system only audited before. GATE 1 CONFORME 10/10, seo-data 167→190 pass. content_quality next (same /feat, stacked — shares fetch.sh/test/README). - seo/geo parity vs github.com/AgriciDaniel/claude-seo (11.5k★, MIT): full 20-point plan built from a 3-subagent inventory, then executed. Verdict cherry-pick-never-install ([[BDR-070]]). 21 commits: Phase 1 (I1-I8 integrity, markdown specs) MERGED to develop (02c7a6f, 8 commits); Phases 2-7 on bugfix/seo-geo-integrity UNMERGED (13 commits, human gate). `fetch.sh` 5→11 verbs (richresults via inspect, sitemap, rendercheck, linkgraph, cannibal, drift, score); seo-data test suite 85→167 pass, 0 fail. Dogfooded on 2 live sites (zenquality Astro + lavageangels356 native PHP) — the second caught 2 bugs Astro hid (image:loc counted as page, flat-URL family heuristic). - 4 features KILLED at measurement, not built: B1/B2 (Common Crawl edges = 17.3 GB, ref impl reads 2.9% and calls it a profile — [[BDR-071]]), B3 (GSC Links API doesn't exist), W2 (Bing OAuth swamp — [[BLK-017]]). 30/70 similarity refused (needs content extraction), Playwright refused (R2 [[BDR-072]]), defusedxml refused (DTD-reject keeps stdlib-only). The most trustworthy output was the code NOT written ([[EVAL-025]]). - BDR-070/071/072/073 + LRN-131/132/133 + BLK-017 + EVAL-025 capitalized; checked 14 TODO done (I1-I5,W1,W3,C1-C3,B3,R2,H1,H2), W2+R1 left unchecked (deferred/rejected). 2 learnings dropped as dup of [[LRN-074]] (grep/find gitignore + detector-proof). Red thread [[LRN-133]]: an omission must stay legible. Verification discipline [[LRN-131]]/[[LRN-132]]: WebSearch ≠ verification, subagent summary = claim not fact (7 disproven, 3 self-reproduced). diff --git a/agents/geo-analyzer.md b/agents/geo-analyzer.md index 66f845c..5d5cd77 100644 --- a/agents/geo-analyzer.md +++ b/agents/geo-analyzer.md @@ -551,6 +551,13 @@ sample of a 300-page site says nothing about the other 294. pronouns? 8. **Lists/tables vs prose** — structured where possible? 9. **30/70 rule** (if city/service variants exist) — ≥70% unique? +10. **Filler/AI-slop signal (deterministic)** — feed each sampled page's + body text to `fetch.sh content_quality`. It is a DETERMINISTIC input + that INFORMS checks 1-9 (word-list/density heuristics, no LLM call); + it never replaces your read of them. A low `overall_quality` or a + `filler`/`ai-patterns` flag is a candidate for human review, not an + automatic finding — do not let the number become the verdict, and do + not claim a page "is AI-written" from it. ### Sampling command @@ -561,6 +568,11 @@ for f in index.html $(find . "${FEXCL[@]}" -maxdepth 3 \( -name "*.astro" -o -na echo "=== $f ===" grep -oE '<(h1|h2|h3)[^>]*>[^<]+|^#{1,3} .+' "$f" 2>/dev/null | head -20 done + +# Filler/AI-slop signal (Check 10) — strip markup to plain body text, then +# score it. Advisory only: pair the number with your own read of Checks 1-9. +sed -e 's/<[^>]*>//g' index.html | \ + bash ~/.claude/lib/seo-data/fetch.sh content_quality ``` ### Findings @@ -576,6 +588,9 @@ CITED STATISTICS : FRESHNESS VISIBLE : PRONOUN-HEAVY : 30/70 RULE : pass | fail | N/A +FILLER/AI-SLOP SIGNAL : /100, flags: + (deterministic, advisory — informs checks 1-9, never + a verdict, never scored on its own) PRIORITY ACTIONS : ``` @@ -813,7 +828,14 @@ to act without your audit context. Embed per item: - **Templates + context** — G2/G6 paste the expected JSON-LD from `geo-schemas.md` + business context (entity name, sameAs, @id canonical) + framework note. G4 follows `llms-txt-template.md` exactly. G1 pastes - the correct variant from `ai-crawlers-2026.md`. + the correct variant from `ai-crawlers-2026.md`. When a G2 item needs a + `Reservation`/`OrderAction`/`DiscussionForumPosting`/`ProfilePage` block, + generate the skeleton via `fetch.sh schema_gen + [flags]` + (`~/.claude/lib/seo-data/fetch.sh`) and fill in the real values, rather + than hand-writing that markup. The data-integrity rule still applies on + top of it: `schema_gen` only generates STRUCTURE — unknown field values + stay `[À COMPLÉTER]`, never invented to fill a flag the verb needs. - **PERMISSIVE default** on G1 unless the client flagged premium/regulated. ### Output shape diff --git a/lib/seo-data/README.md b/lib/seo-data/README.md index 747a244..1f277c5 100644 --- a/lib/seo-data/README.md +++ b/lib/seo-data/README.md @@ -215,6 +215,74 @@ fetch.sh score --findings • Malformed input is an error, never a silently wrong number — unlike the fetch verbs, a degrade here would mean bad input, not a network fact. +fetch.sh schema_gen [flags] [--script-tag] + → {"status":"ok","source":"schema_gen","type":"<@type>","jsonld":{…}} + → {"status":"error","reason":"bad_usage"} # a REQUIRED flag omitted + → {"status":"degraded","reason":"…"} # a required flag given, empty + + fetch.sh schema_gen reservation --provider "Marea NYC" \ + --start 2026-06-04T19:30:00-04:00 --party-size 4 + fetch.sh schema_gen order --merchant "Acme Pizza" --order-url https://acme.example/order + fetch.sh schema_gen discussion --headline "…" --author "Sara Park" \ + --url https://forum.example.com/t/123 --date 2026-05-12T14:00:00Z + fetch.sh schema_gen profile --name "Daniel Agrici" --url https://agricidaniel.com/about \ + --same-as https://github.com/AgriciDaniel --knows-about "SEO" "Schema markup" + + Adapted from claude-seo's `schema_generate.py` (MIT) into this contract. + Our system only AUDITS existing markup elsewhere; this is the one verb + that GENERATES it — deterministic JSON-LD skeletons for the four v2 + high-leverage Schema.org types, so geo-analyzer's G2 batch stops + hand-writing markup by hand. It only generates STRUCTURE: unknown field + VALUES are the caller's job, `[À COMPLÉTER]` for anything unconfirmed — + this verb never invents a sameAs, an email, or a business name. + • Stdlib only, no network, no auth — runs even without the venv. + • `--script-tag` wraps the cleaned jsonld in + `` under a `script` key, + still inside the `ok` envelope. It must be given AFTER the type + (`schema_gen reservation … --script-tag`, not before) — argparse + subcommand flags only parse after their subcommand. + • Never emits a JSON `null`: fields left unset are omitted from the + `jsonld` object entirely rather than serialised as `null`. + • A REQUIRED flag omitted → `{"status":"error","reason":"bad_usage"}`, + exit 2 (bad usage, like every other verb). A required flag GIVEN but + empty (argparse cannot catch that) → `{"status":"degraded",...}`, + exit 0 — fail-open, never a traceback. + +fetch.sh content_quality [--file ] < text_on_stdin + → {"status":"ok","source":"content_quality","filler_score":0,"ai_pattern_score":0, + "information_density":1.0,"overall_quality":90,"flags":[], + "matches":{"filler":[],"ai_patterns":[]}} + → {"status":"degraded","reason":"empty_input"|""} + + fetch.sh content_quality --file article.txt + printf '%s' "$BODY_TEXT" | fetch.sh content_quality + + Adapted from claude-seo's `content_quality.py` (MIT) into this contract. + 100% deterministic — regex/word-lists (QRG §4.6 filler phrases + a + Wikipedia "AI Cleanup" catalogue of LLM-typical phrasings, CC BY-SA 4.0), + no LLM call, no network. Reads the text to score from `--file ` or, + when `--file` is `-` or omitted, from stdin — the same idiom `score.py` + uses for `--findings`. + • **ADVISORY, NOT A VERDICT.** The output never claims "this text is + AI-written" — modern generative tools can pass every heuristic here, + and human writers use some of these phrases too. `flags` are + candidates for HUMAN REVIEW, never an automatic finding. geo-analyzer + STEP 8 (Content Shape for AI) treats `overall_quality`/`flags` as ONE + measured input that INFORMS the axis; the axis itself stays an LLM + judgement (30/70, Definition Lead), never replaced by this score. + • `filler_score`/`ai_pattern_score` (0-100, higher = worse) count + phrase-list hits scaled per 1000 tokens; `information_density` + (0.0-1.0) is entities + numbers per 100 tokens; `overall_quality` + (0-100, higher is better) is the weighted composite (also folds in a + bigram-repetition penalty even though that score isn't itself a + top-level field). `flags` fires at fixed thresholds: `filler`, + `ai-patterns`, `low-density`, `repetitive`. + • Stdlib only (argparse/json/re/sys/collections/typing) — runs even + without the venv. Empty/whitespace-only input degrades rather than + returning a false zero-value "ok": an empty analysis is not a result. + • This is filler/AI-pattern SHAPE, not fact-checking — a text can be + dense and well-cited yet still wrong; that stays a human/LLM call. + fetch.sh drift --url https://ex.com/sitemap.xml [--max 500] → {"status":"ok","baseline":true,"captured":"…","pages":24,"store":"…"} → {"status":"ok","baseline":false,"since":"…","gone":[…],"new":[…], diff --git a/lib/seo-data/content_quality.py b/lib/seo-data/content_quality.py new file mode 100644 index 0000000..3ea73bd --- /dev/null +++ b/lib/seo-data/content_quality.py @@ -0,0 +1,242 @@ +#!/usr/bin/env python3 +"""Deterministic filler / AI-slop content-quality scorer. Stdlib only. + +Adapted from claude-seo (github.com/AgriciDaniel/claude-seo, MIT), +content_quality.py — rewritten to the lib/seo-data fail-open contract. + +Scores a block of text against three regex/word-list heuristics: padding +"filler" phrases (QRG §4.6), LLM-typical phrasings ("AI-pattern" list), +and a measured information density (entities + numbers per token). 100% +deterministic — no LLM call, no network. + +ADVISORY, NOT A VERDICT. This never claims "this text is AI-written" — +modern generative tools can pass every heuristic here, and human writers +use some of these phrases too. A low overall_quality or a filler/ +ai-patterns flag is a candidate for human review, nothing more. In +geo-analyzer's STEP 8 (Content Shape for AI) it is ONE measured input +that INFORMS the axis, which stays an LLM judgement (30/70, Definition +Lead) — never a replacement for it, and never auto-filed as a finding on +its own. + +Attribution: the AI-pattern list draws from the Wikipedia "AI Cleanup" +project's catalogue of LLM-typical phrasings (CC BY-SA 4.0), the same +list claude-seo cites. + +Envelope (see `_cli`):: + + {"status": "ok", "source": "content_quality", + "filler_score": 0..100, # higher = more filler-like + "ai_pattern_score": 0..100, # higher = more AI-pattern hits + "information_density": 0.0..1.0, + "overall_quality": 0..100, # composite, higher is better + "flags": ["filler", "ai-patterns", "low-density", "repetitive"], + "matches": {"filler": [...], "ai_patterns": [...]}} + {"status": "degraded", "reason": "empty_input" | ""} +""" +import argparse, json, re, sys +from collections import Counter +from typing import Iterable + +# Padding / filler phrases QRG §4.6 flags as "little-to-no value". The +# lists are the value of this module — kept intact from the source, not +# trimmed. +_FILLER_PHRASES = ( + "it's important to note that", + "in this article, we'll explore", + "in this article we will explore", + "in today's fast-paced world", + "in today's digital age", + "in today's competitive landscape", + "needless to say", + "at the end of the day", + "when it comes to", + "when all is said and done", + "in the realm of", + "in the world of", + "the bottom line is", + "without further ado", + "first and foremost", + "last but not least", + "for what it's worth", + "it goes without saying", + "as we all know", + "the truth is that", + "the fact of the matter is", + "more often than not", + "let's dive in", + "let's dive into", + "let's take a closer look", + "let's take a deeper look", +) + +# LLM-typical phrasings (Wikipedia AI Cleanup catalogue, CC BY-SA 4.0; +# also used by claude-seo, MIT). Conservative: only phrases that +# disproportionately appear in LLM output. Adding to this list should +# require corpus evidence, not intuition. +_AI_PATTERNS = ( + "delve into", + "delve deeper into", + "in the ever-evolving", + "ever-evolving landscape", + "ever-changing landscape", + "in the dynamic landscape", + "navigating the", + "navigate the complexities", + "tapestry of", + "rich tapestry", + "intricate tapestry", + "embark on a journey", + "embarking on this", + "a testament to", + "a beacon of", + "the cornerstone of", + "a cornerstone of", + "at the heart of", + "at its core", + "in essence,", + "in conclusion,", + "ultimately,", + "moreover,", + "furthermore,", + "however, it's worth noting", + "it's worth noting that", + "by leveraging", + "leverage the power of", + "leveraging the power of", + "harness the power of", + "unlock the potential", + "unlock the full potential", + "the realm of possibilities", + "open up a world of", + "a world of possibilities", + "elevate your", + "transform your", + "revolutionize the way", + "game-changer", + "game-changing", + "cutting-edge", + "state-of-the-art", + "in summary,", + "to summarize,", + "to put it simply,", + "in a nutshell,", +) + +_TOKEN_RE = re.compile(r"[A-Za-z][A-Za-z'\-]*") +_NUMBER_RE = re.compile(r"\b\d+(?:[.,]\d+)?(?:%|st|nd|rd|th)?\b") +# Capitalised multi-word names: rough proper-noun heuristic. Two or more +# capitalised tokens in a row count as one entity. +_ENTITY_RE = re.compile(r"\b(?:[A-Z][a-z]+(?:\s+[A-Z][a-z]+)+)\b") + + +def _count_phrase_hits(text: str, patterns: Iterable[str]) -> list: + """Patterns that appear at least once in text (case-insensitive).""" + lowered = text.lower() + return [p for p in patterns if p in lowered] + + +def _repetition_score(tokens): + """Bigram repetition: fraction of bigrams that recur more than once.""" + if len(tokens) < 4: + return 0.0 + bigrams = [tokens[i] + " " + tokens[i + 1] for i in range(len(tokens) - 1)] + counts = Counter(bigrams) + repeated = sum(1 for v in counts.values() if v > 1) + return repeated / max(1, len(counts)) + + +def analyse(text): + """Score text against the filler / AI-pattern / density / repetition + heuristics. Advisory only — see module docstring.""" + tokens = [t.lower() for t in _TOKEN_RE.findall(text)] + n_tokens = len(tokens) + + filler_hits = _count_phrase_hits(text, _FILLER_PHRASES) + ai_hits = _count_phrase_hits(text, _AI_PATTERNS) + + # Density: entities + numbers per 100 tokens. A high-density article + # (case studies, data journalism) lands at ~5+; generic filler <2. + entities = len(_ENTITY_RE.findall(text)) + numbers = len(_NUMBER_RE.findall(text)) + density_per_100 = (entities + numbers) * 100.0 / max(1, n_tokens) + information_density = min(1.0, density_per_100 / 10.0) + + rep_score = int(round(_repetition_score(tokens) * 100)) + + # Scale to per-1000 tokens so the score is comparable across lengths. + scale = max(1.0, n_tokens / 1000.0) + filler_score = min(100, int(round(len(filler_hits) / scale * 25))) + ai_pattern_score = min(100, int(round(len(ai_hits) / scale * 15))) + + flags = [] + if filler_score >= 50: + flags.append("filler") + if ai_pattern_score >= 40: + flags.append("ai-patterns") + if information_density < 0.20: + flags.append("low-density") + if rep_score >= 30: + flags.append("repetitive") + + # Composite: invert penalty signals, weight by impact. Same weights + # as the source — the length bonus caps at 1000 tokens. + overall = ( + (100 - filler_score) * 0.25 + + (100 - ai_pattern_score) * 0.25 + + information_density * 100 * 0.25 + + (100 - rep_score) * 0.15 + + min(100, n_tokens / 10.0) * 0.10 + ) + + return { + "filler_score": filler_score, + "ai_pattern_score": ai_pattern_score, + "information_density": round(information_density, 3), + "overall_quality": int(round(overall)), + "flags": flags, + "matches": {"filler": filler_hits, "ai_patterns": ai_hits}, + } + + +def _build_parser(): + p = argparse.ArgumentParser( + description="Deterministic filler / AI-slop content-quality scorer." + ) + p.add_argument("--store", default=None) # accepted+ignored (dispatch) + p.add_argument( + "--file", default="-", + help="Path to a text file, or - for stdin (default -).", + ) + return p + + +def _read_input(path): + """Read the analysis target from stdin ('-'/omitted) or a plain file. + Plain `open()` only — no pathlib, to stay stdlib-minimal per contract.""" + if path in (None, "-"): + return sys.stdin.read() + return open(path, encoding="utf-8", errors="replace").read() + + +def _cli(): + try: + args = _build_parser().parse_args() + text = _read_input(args.file) + if not text or not text.strip(): + print(json.dumps({"status": "degraded", "reason": "empty_input"})) + return + envelope = {"status": "ok", "source": "content_quality"} + envelope.update(analyse(text)) + print(json.dumps(envelope, indent=2)) + except SystemExit as e: + if e.code not in (0, None): + print(json.dumps({"status": "error", "reason": "bad_usage"})) + raise + except Exception as e: + # Fail-open: a missing --file, an unreadable/binary file, or any + # other unexpected error degrades rather than crashing the caller. + print(json.dumps({"status": "degraded", "reason": str(e)})) + + +if __name__ == "__main__": + _cli() diff --git a/lib/seo-data/fetch.sh b/lib/seo-data/fetch.sh index e2291be..37d9a5e 100644 --- a/lib/seo-data/fetch.sh +++ b/lib/seo-data/fetch.sh @@ -34,6 +34,10 @@ case "$cmd" in exec "$PY" "$HERE/sitemap.py" --store "$STORE" "$@" ;; score) exec "$PY" "$HERE/score.py" --store "$STORE" "$@" ;; + schema_gen) + exec "$PY" "$HERE/schema_gen.py" --store "$STORE" "$@" ;; + content_quality) + exec "$PY" "$HERE/content_quality.py" --store "$STORE" "$@" ;; drift) exec "$PY" "$HERE/drift.py" --store "$STORE" "$@" ;; rendercheck) @@ -52,6 +56,6 @@ case "$cmd" in fi echo '{"status":"error","reason":"usage: fetch.sh forget {--label