Blocks AI slop in website copy: scores every page Claude writes for AI tells and SEO cliches (English and Spanish) and makes Claude rewrite it.

Three Claude Code mods for SEO work: live rankings while Claude works, a guard against keyword cannibalization, and a filter that keeps AI slop off your pages.
Client Pulse and Cannibalization Guard read their data from DataWise SEO through its Claude connector.
claude update).In a Claude Code terminal session:
/plugin install client-pulse --marketplace NicoSKOOL/seo-mods
/plugin install cannibal-guard --marketplace NicoSKOOL/seo-mods
/plugin install slop-filter --marketplace NicoSKOOL/seo-mods
Answer y to add the marketplace, then choose the user scope.
Put a .seo-client.json in the root of each client's website project:
{ "domain": "example.com" }
Optional fields:
| Field | What it does |
|---|---|
locationCode, languageCode | DataForSEO market for ranked keywords (for example 2152 and es for Chile) |
contentDirs | Folders holding your pages (default: src/content, src/pages, content, pages) |
cannibalization.minImpressions | Search Console impressions before a keyword counts as taken (default 20) |
cannibalization.rankedKeywords | false to skip the DataForSEO lookup |
datawiseServer | The DataWise connector's name if /mcp shows a different one (default claude.ai DataWise SEO) |
Open Claude Code in the client project and the band appears. Data is cached for 6 hours.
/pulse refreshes the numbers and shares them with Claude./pulse otherclient.com switches client.× hides the band.The Guard checks new pages that declare a target keyword, in any of these forms:
---
keyword: emergency dentist austin
---
const keyword = "emergency dentist austin" in an Astro file, or <!-- keyword: emergency dentist austin --> in HTML.
Add this line to your project's CLAUDE.md so every new page gets one:
Every new page gets a
keyword:field in its frontmatter with its primary target keyword.
When the Guard blocks a page, Claude explains what already covers the keyword. If you still want the new page, tell Claude, and it adds cannibalization: ok to the frontmatter.
It works out of the box. Every time Claude writes or edits website copy (.astro, .html, .mdx, and .md inside your content folders), the filter scores the new text from 0 to 100:
oaicite, utm_source=chatgpt.com) and unfilled placeholders like [Your Phone Number].When it blocks, Claude gets the list of tells with examples and rewrites the copy. The status line shows each page's score. If Claude cannot get a page under the bar in two tries, the third write goes through with a warning, so it never loops.
Tune it under slop in .seo-client.json:
{
"slop": {
"threshold": 60,
"minTells": 3,
"mode": "block",
"banned": ["leading provider", "state-of-the-art"],
"allow": ["Heart of Texas"],
"emDash": "block"
}
}
| Field | What it does |
|---|---|
threshold | Score below which a page is blocked (default 60) |
minTells | Different tells needed before a block (default 3) |
mode | warn writes the page and only flags it to Claude |
banned | Your own phrases that always block |
allow | Names or phrases never to flag (a business name like "Vibrant Smiles") |
emDash | block, count (default) or off |
To publish a page anyway, tell Claude, and it adds slop: ok to the frontmatter.
Patterns draw on Wikipedia's Signs of AI writing, slop-score and slop-guard.
hooks/register.ts 81 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { explain, scan, verdict } from './slop.ts'
4import type { SlopOptions } from './slop.ts'
5
6const CONFIG_FILE = '.seo-client.json'
7const PAGE_FILE = /\.(md|mdx|astro|html|htm)$/i
8const MARKUP_FILE = /\.(mdx|astro|html|htm)$/i
9// Markdown is only website copy inside one of these folders (plans, notes and
10// READMEs elsewhere are left alone). "contentDirs" in .seo-client.json replaces them.
11const DEFAULT_CONTENT_DIRS = ['src/content', 'src/pages', 'content', 'pages', 'blog', 'posts']
12// After this many blocks on one file in a session, the next write goes
13// through with a warning, so a stubborn page never loops forever.
14const MAX_BLOCKS_PER_FILE = 2
15
16type Config = SlopOptions & { threshold: number; minTells: number; mode: 'block' | 'warn'; contentDirs: string[] }
17
18async function loadConfig($: EngineInterface, root: string): Promise<Config> {
19 const path = `${root}/${CONFIG_FILE}`
20 const file = (await $.fs.exists(path)) ? JSON.parse(await $.fs.read(path)) : {}
21 const raw = file.slop ?? {}
22
23 return {
24 contentDirs: Array.isArray(file.contentDirs) ? file.contentDirs : DEFAULT_CONTENT_DIRS,
25 threshold: raw.threshold ?? 60,
26 minTells: raw.minTells ?? 3,
27 mode: raw.mode === 'warn' ? 'warn' : 'block',
28 banned: Array.isArray(raw.banned) ? raw.banned : [],
29 allow: Array.isArray(raw.allow) ? raw.allow : [],
30 emDash: raw.emDash === 'block' || raw.emDash === 'off' ? raw.emDash : 'count',
31 }
32}
33
34export function isWebCopy(path: string, root: string, contentDirs: string[]): boolean {
35 if (!PAGE_FILE.test(path)) return false
36 if (MARKUP_FILE.test(path)) return true
37 const relative = `/${(path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path).toLowerCase()}`
38
39 return contentDirs.some(dir => relative.includes(`/${dir.toLowerCase().replace(/^\/|\/$/g, '')}/`))
40}
41
42const isOverridden = (content: string) => /\bslop\s*:\s*["']?ok\b/i.test(content.slice(0, 4000))
43
44export const register: Register = on => {
45 const blocks = new Map<string, number>()
46
47 on('tool.call', async ($, e, next) => {
48 if (e.tool !== 'Write' && e.tool !== 'Edit') return next(e)
49 const path = e.file_path
50 const text = e.tool === 'Write' ? e.content : e.new_string
51 if (!PAGE_FILE.test(path) || isOverridden(text)) return next(e)
52
53 const root = await $.session.cwd()
54 const config = await loadConfig($, root).catch(() => null)
55 if (config === null || !isWebCopy(path, root, config.contentDirs)) return next(e)
56 const report = scan(text, path, config)
57 const name = path.split('/').slice(-2).join('/')
58
59 if (verdict(report, config.threshold, config.minTells) === 'pass') {
60 blocks.delete(path)
61 $.ui.status(`Slop Filter: ${name} ${report.score}/100 clean`)
62 return next(e)
63 }
64
65 const count = (blocks.get(path) ?? 0) + 1
66 const message = explain(report, path)
67 if (config.mode === 'warn' || count > MAX_BLOCKS_PER_FILE) {
68 $.ui.status(`Slop Filter: ${name} ${report.score}/100 (written with a warning)`)
69 const result = await next(e)
70 if ('deny' in result && result.deny !== undefined) return result
71 return { ...result, context: [...(result.context ?? []), message.replace('stopped this write to', 'flagged')] }
72 }
73
74 blocks.set(path, count)
75 $.ui.status(`Slop Filter: ${name} ${report.score}/100 blocked`)
76 $.ui.toast(`Slop Filter blocked ${name}: score ${report.score}/100, ${report.hits.length} tells. Claude is rewriting it.`)
77
78 return { deny: message }
79 })
80}
81hooks/slop.ts 200 lines1// The Slop Filter's scorer: plain functions with no engine calls, so it can be
2// tested and calibrated on its own.
3//
4// Pattern sources: Wikipedia "Signs of AI writing" (WikiProject AI Cleanup),
5// sam-paech/slop-score and eric-tramel/slop-guard (MIT), plus SEO service-page
6// cliches and Spanish tells collected for this mod.
7
8export type Tier = 'hard' | 'high' | 'low'
9
10export type Pattern = {
11 id: string
12 label: string
13 tier: Tier
14 regex: RegExp
15 /** Points per hit (hard-tier patterns block on their own and score nothing). */
16 weight: number
17 /** Hits counted at most this many times. */
18 cap?: number
19 /** Only applied to text in this language. */
20 lang?: 'en' | 'es'
21 /** Matched only outside quotes and blockquotes (where a page quotes a template or prompt). */
22 isProseOnly?: boolean
23}
24
25export type Hit = { id: string; label: string; tier: Tier; count: number; example: string; points: number; phrases: string[] }
26
27export type Report = {
28 words: number
29 lang: 'en' | 'es'
30 points: number
31 score: number
32 hits: Hit[]
33 hard: Hit[]
34 /** Distinct phrases among the high and low tells found. */
35 distinct: number
36}
37
38export type SlopOptions = {
39 /** Extra phrases that always block (case-insensitive, matched as words). */
40 banned?: string[]
41 /** Pattern ids or phrases to ignore (a business or place name, say). */
42 allow?: string[]
43 /** 'block' makes any em dash a hard stop, 'count' scores them, 'off' ignores them. */
44 emDash?: 'block' | 'count' | 'off'
45}
46
47const words = (list: string) => list.split('|').join('|')
48
49const PATTERNS: Pattern[] = [
50 // Hard: text a person never publishes on a web page.
51 { id: 'chatbot-citation', label: 'chatbot citation artifact', tier: 'hard', weight: 0, regex: /\b(?:oaicite|contentReference|turn\d+search\d+)\b|\[cite:\s*\d+\]|utm_source=chatgpt\.com/gi },
52 { id: 'chat-leftover', label: 'leftover chat reply', tier: 'hard', weight: 0, regex: /\bas an ai (?:language model|assistant|model)\b|\blet me know if you(?:'d| would) like (?:me to|any)\b|\bhere(?:'s| is) (?:a|the) (?:revised|rewritten|updated|polished) version\b|^\s*(?:certainly|absolutely)! (?:here|below)\b/gim },
53 { id: 'cutoff', label: 'knowledge cutoff disclaimer', tier: 'hard', weight: 0, regex: /\bas of my (?:last|latest) (?:update|knowledge|training)\b|\bknowledge cutoff\b/gi },
54 { id: 'placeholder', label: 'unfilled placeholder', tier: 'hard', weight: 0, isProseOnly: true, regex: /\[(?:your|insert|city|company|business name|phone|address|tu |nombre|ciudad)[^\]\n]{0,30}\](?!\()|\blorem ipsum\b/gi },
55
56 // High: words and phrases far more common in AI text than human text.
57 { id: 'ai-vocabulary', label: 'AI vocabulary', tier: 'high', weight: 3, cap: 6, regex: new RegExp(`\\b(?:${words('delve[sd]?|delving|tapestry|testament to|intricate|multifaceted|realm|pivotal|underscores?|underscored|showcasing|garner(?:s|ed)?|bustling|vibrant|boasts|nestled|indelible|ever-evolving|evolving landscape|embark(?:s|ing)? on|beacon of|meticulous(?:ly)?|a myriad of|plethora of|navigate the complexities|in the heart of')})\\b`, 'gi') },
58 { id: 'stock-opener', label: 'stock opener', tier: 'high', weight: 4, regex: /\bin today'?s (?:fast-paced|digital|modern|ever-changing|competitive|dynamic)? ?(?:world|landscape|age|era|market|environment)\b|\bin the (?:ever-evolving|dynamic|fast-paced|ever-changing) (?:world|realm|landscape) of\b/gi },
59 { id: 'stock-closer', label: 'stock filler phrase', tier: 'high', weight: 3, regex: /\bcannot be overstated\b|\bit(?:'s| is) (?:worth noting|important to note)\b|\bplays? an? (?:crucial|pivotal|vital|key) role\b|\bwhether you(?:'re| are) an? [^.,;\n]{2,40} or an? \b/gi },
60 { id: 'seo-cliche', label: 'SEO copy cliche', tier: 'high', weight: 3, regex: /\blook no further\b|\bunlock(?:ing)? the (?:power|potential|secrets?)\b|\byour trusted partner\b|\belevate your\b|\btake your [^.,;\n]{1,30} to the next level\b|\bseamless(?:ly)?\b|\bgame[- ]changer\b|\bone-stop shop\b|\bwe pride ourselves\b|\bsecond to none\b|\btailored to (?:meet )?your (?:unique |specific )?needs\b/gi },
61 { id: 'negative-parallelism', label: '"it\'s not just X, it\'s Y"', tier: 'high', weight: 3, regex: /\b(?:it'?s|this is|that'?s|it is)\s+not\s+(?:just|only|merely|simply|about)\b[^.!?\n]{1,80}?[,;:—–-]\s*(?:it'?s|it is|but|this is)\b/gi },
62 { id: 'not-only', label: '"not only... but also"', tier: 'high', weight: 2, regex: /\bnot only\b[^.!?\n]{1,80}\bbut also\b/gi },
63 { id: 'copula-avoidance', label: '"serves as / stands as"', tier: 'high', weight: 2, regex: /\b(?:serves|stands|functions) as (?:a|an|the)\b/gi },
64 { id: 'significance-clause', label: 'trailing "-ing" significance clause', tier: 'high', weight: 2, regex: /,\s(?:highlighting|underscoring|showcasing|reflecting|ensuring|fostering|emphasizing|solidifying|cementing)\s(?:the|its|their|our|your|a|an)\b/gi },
65 { id: 'chat-signoff', label: 'chatbot sign-off', tier: 'high', weight: 2, cap: 1, regex: /\bi hope this (?:helps|was helpful)\b|\bgreat question\b/gi },
66 { id: 'hype-hook', label: 'hype hook ("Here\'s the thing")', tier: 'high', weight: 3, regex: /\bhere'?s the (?:thing|kicker|catch)\b|\blet'?s dive in\b|\bthe best part\?|\bbut here'?s the\b/gi },
67 { id: 'conclusion', label: '"In conclusion / In summary"', tier: 'high', weight: 2, regex: /^#{0,6}\s*\**(?:in conclusion|in summary|to sum up|final thoughts)\b/gim },
68
69 // Spanish high tier.
70 { id: 'es-connector', label: 'conector de IA', tier: 'high', weight: 3, lang: 'es', regex: /\bcabe (?:destacar|mencionar|señalar|resaltar)\b|\bes importante (?:destacar|mencionar|señalar|recordar|tener en cuenta)\b|\ben el mundo actual\b|\ben la era digital\b|\ben el (?:dinámico|cambiante|competitivo) mundo de\b/gi },
71 { id: 'es-vocabulary', label: 'vocabulario de IA', tier: 'high', weight: 3, lang: 'es', regex: /\bun tapiz de\b|\btestamento de\b|\bjuega(?:n)? un papel (?:crucial|clave|fundamental|vital)\b|\bsumérgete\b|\bprofundizar en\b|\bno busques más\b|\btu socio de confianza\b|\bal siguiente nivel\b|\bdesbloquea(?:r)? (?:el|todo (?:el|su|tu)) (?:poder|potencial)\b|\bsin lugar a dudas\b/gi },
72 { id: 'es-conclusion', label: '"En conclusión / En resumen"', tier: 'high', weight: 2, lang: 'es', regex: /^#{0,6}\s*\**(?:en conclusión|en resumen|para concluir)\b/gim },
73 { id: 'es-title-case', label: 'título con Mayúsculas en Cada Palabra', tier: 'high', weight: 2, lang: 'es', regex: /^#{1,6}\s+(?:[A-ZÁÉÍÓÚÑ][\wáéíóúñ]+\s+(?:(?:de|la|el|en|y|para|los|las|del|con|por|a)\s+)?){2,}[A-ZÁÉÍÓÚÑ][\wáéíóúñ]+\s*$/gm },
74
75 // Low: common in human writing too, so they only count toward density.
76 { id: 'filler-words', label: 'filler transition words', tier: 'low', weight: 1, cap: 5, regex: /\b(?:additionally|moreover|furthermore|crucial|comprehensive|robust|leverag(?:e|es|ing)|streamline[sd]?|empower(?:s|ing)?|enhance[sd]?|ensuring)\b/gi },
77 { id: 'es-filler', label: 'muletillas', tier: 'low', weight: 1, cap: 5, lang: 'es', regex: /\b(?:fundamental|esencial|además|por ende|en este sentido|destacar|resaltar|subrayar|potenciar|optimizar)\b/gi },
78 { id: 'soft-cta', label: 'stock CTA', tier: 'low', weight: 1, cap: 2, regex: /\bpeace of mind\b|\ba wide range of\b|\bdon'?t wait\b|\bcontact us today to\b/gi },
79 { id: 'vague-attribution', label: 'unsourced "studies show"', tier: 'low', weight: 2, cap: 3, regex: /\b(?:studies|research) (?:show|shows|suggest|suggests|has shown)\b|\bexperts (?:agree|say|believe)\b/gi },
80 { id: 'bold-label-bullets', label: 'bullets with bold inline labels', tier: 'low', weight: 1, cap: 4, regex: /^\s*[-*]\s+\*\*[^*\n]{1,40}\*\*\s*:/gm },
81 { id: 'emoji-bullets', label: 'emoji bullets or headings', tier: 'low', weight: 1, cap: 4, regex: /^\s*(?:[-*]\s*|#{1,6}\s*)?\p{Extended_Pictographic}/gmu },
82]
83
84const EM_DASH = /—/g
85
86const SPANISH_MARKERS = /\b(?:de|la|que|el|en|los|las|del|para|con|una|por|es|su|se)\b/gi
87const ENGLISH_MARKERS = /\b(?:the|and|of|to|is|for|with|you|your|that|are|on|this)\b/gi
88
89export function detectLanguage(text: string): 'en' | 'es' {
90 const es = text.match(SPANISH_MARKERS)?.length ?? 0
91 const en = text.match(ENGLISH_MARKERS)?.length ?? 0
92
93 return es > en ? 'es' : 'en'
94}
95
96/** The words a reader sees: no code, scripts, styles, JSON-LD, tag markup or Astro frontmatter. */
97export function readableText(source: string, path = ''): string {
98 let text = source
99 if (/\.astro$/i.test(path)) text = text.replace(/^---[\s\S]*?\n---/, '')
100 return text
101 .replace(/```[\s\S]*?```/g, ' ')
102 .replace(/`[^`\n]*`/g, ' ')
103 .replace(/<(script|style)\b[\s\S]*?<\/\1>/gi, ' ')
104 .replace(/<!--[\s\S]*?-->/g, ' ')
105 .replace(/\{[^{}\n]*\}/g, ' ')
106 .replace(/<\/?[a-zA-Z][^>]*>/g, ' ')
107 .replace(/^\s*(?:import|export) .*$/gm, ' ')
108}
109
110const escape = (phrase: string) => phrase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
111
112function snippet(text: string, index: number, length: number): string {
113 const start = Math.max(0, index - 30)
114 const end = Math.min(text.length, index + length + 30)
115 return `${start > 0 ? '…' : ''}${text.slice(start, end).replace(/\s+/g, ' ').trim()}${end < text.length ? '…' : ''}`
116}
117
118export function scan(source: string, path = '', options: SlopOptions = {}): Report {
119 const text = readableText(source, path)
120 const lang = detectLanguage(text)
121 const wordCount = text.split(/\s+/).filter(word => /\p{L}/u.test(word)).length
122 const allow = (options.allow ?? []).map(entry => entry.toLowerCase())
123 const isAllowed = (pattern: Pattern, match: string) =>
124 allow.includes(pattern.id) || allow.some(entry => match.toLowerCase().includes(entry))
125
126 const patterns: Pattern[] = [...PATTERNS]
127 if (options.banned?.length) {
128 patterns.push({
129 id: 'banned',
130 label: 'banned by your rules',
131 tier: 'hard',
132 weight: 0,
133 regex: new RegExp(`(?<![\\p{L}])(?:${options.banned.map(escape).join('|')})(?![\\p{L}])`, 'giu'),
134 })
135 }
136 const emDash = options.emDash ?? 'count'
137 if (emDash !== 'off') {
138 patterns.push(
139 emDash === 'block'
140 ? { id: 'em-dash', label: 'em dash (banned by your rules)', tier: 'hard', weight: 0, regex: EM_DASH }
141 : { id: 'em-dash', label: 'em dashes', tier: 'low', weight: 0.5, cap: 10, regex: EM_DASH },
142 )
143 }
144
145 const prose = text.replace(/^\s*>.*$/gm, ' ').replace(/"[^"\n]{0,300}"|“[^”\n]{0,300}”/g, ' ')
146 const hits: Hit[] = []
147 for (const pattern of patterns) {
148 if (pattern.lang !== undefined && pattern.lang !== lang) continue
149 const matches = [...(pattern.isProseOnly ? prose : text).matchAll(pattern.regex)].filter(match => !isAllowed(pattern, match[0]))
150 if (matches.length === 0) continue
151 const first = matches[0]!
152 const counted = Math.min(matches.length, pattern.cap ?? 3)
153 hits.push({
154 id: pattern.id,
155 label: pattern.label,
156 tier: pattern.tier,
157 count: matches.length,
158 example: snippet(text, first.index ?? 0, first[0].length),
159 points: counted * pattern.weight,
160 phrases: [...new Set(matches.map(match => match[0].toLowerCase().trim()))],
161 })
162 }
163
164 const points = hits.reduce((total, hit) => total + hit.points, 0)
165 // Short texts (an Edit's new lines) are scored as if 150 words long, so one
166 // phrase in a sentence does not read as saturation.
167 const density = (points / Math.max(wordCount, 150)) * 1000
168
169 return {
170 words: wordCount,
171 lang,
172 points,
173 score: Math.round(100 * Math.exp(-density / 40)),
174 hits: hits.sort((a, b) => b.points - a.points),
175 hard: hits.filter(hit => hit.tier === 'hard'),
176 distinct: new Set(hits.filter(hit => hit.tier !== 'hard').flatMap(hit => hit.phrases.map(phrase => `${hit.id}:${phrase}`))).size,
177 }
178}
179
180export type Verdict = 'pass' | 'block'
181
182export function verdict(report: Report, threshold: number, minTells: number): Verdict {
183 if (report.hard.length > 0) return 'block'
184 return report.score < threshold && report.distinct >= minTells ? 'block' : 'pass'
185}
186
187export function explain(report: Report, path: string): string {
188 const lines = report.hits.map(hit => {
189 const times = hit.count > 1 ? ` (${hit.count}x)` : ''
190 const tag = hit.tier === 'hard' ? ' [always blocked]' : ''
191 return `- ${hit.label}${times}${tag}: "${hit.example}"`
192 })
193
194 return [
195 `Slop Filter stopped this write to ${path}: slop score ${report.score}/100 (${report.lang === 'es' ? 'Spanish' : 'English'}, ${report.words} words).`,
196 ...lines,
197 'Rewrite the copy in plain, specific language: say the concrete fact, number or example instead of the stock phrase, drop the flagged words, and keep every fact, keyword and link. Then write it again.',
198 ].join('\n')
199}
200