SLOPSHOPPER

slop-filter

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.

newguardtoaststatus
★ 1v0.1.0MITupdated 2026-10-12NicoSKOOL/seo-mods/slop-filter
A shopper browsing a rack in a slop shop
README

SEO Mods for Claude Code

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: a band above the prompt showing the client's Search Console clicks and impressions (week over week), tracked keyword movement, AI citations and the top opportunity query.
  • Cannibalization Guard: stops Claude from creating a new page for a keyword your site already covers or ranks for, and tells it to improve the existing page instead.
  • Slop Filter: scores every page Claude writes for AI tells and SEO cliches ("look no further", "nestled in the heart of", "it's not just X, it's Y"), in English and Spanish, and makes Claude rewrite anything that reads like AI. No data source needed, no tokens used.

Client Pulse and Cannibalization Guard read their data from DataWise SEO through its Claude connector.

Requirements

  • Claude Code: a recent version (mods are new; run claude update).
  • DataWise SEO: connected in Claude, with your client's Search Console property synced.
  • DataForSEO (optional): the Guard's "which URL already ranks" lookup uses ranked keywords through DataWise, about one call per domain per day. Get DataForSEO here.

Install

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.

Set up a client project

Put a .seo-client.json in the root of each client's website project:

{ "domain": "example.com" }

Optional fields:

FieldWhat it does
locationCode, languageCodeDataForSEO market for ranked keywords (for example 2152 and es for Chile)
contentDirsFolders holding your pages (default: src/content, src/pages, content, pages)
cannibalization.minImpressionsSearch Console impressions before a keyword counts as taken (default 20)
cannibalization.rankedKeywordsfalse to skip the DataForSEO lookup
datawiseServerThe DataWise connector's name if /mcp shows a different one (default claude.ai DataWise SEO)

Using Client Pulse

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.

Using Cannibalization Guard

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.

Using Slop Filter

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:

  • Always blocked: leftover chatbot text ("Here's the revised version"), citation junk (oaicite, utm_source=chatgpt.com) and unfilled placeholders like [Your Phone Number].
  • Blocked when it piles up: AI vocabulary (delve, tapestry, nestled, vibrant), stock openers ("In today's fast-paced world"), SEO cliches ("look no further", "your trusted partner", "seamless"), "it's not just X, it's Y", "In conclusion" sections, and in Spanish "cabe destacar", "juega un papel fundamental" and Title Case Headings. One "crucial" proves nothing, so a page is only blocked when the score drops under 60 and at least 3 different tells show up.

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"
  }
}
FieldWhat it does
thresholdScore below which a page is blocked (default 60)
minTellsDifferent tells needed before a block (default 3)
modewarn writes the page and only flags it to Claude
bannedYour own phrases that always block
allowNames or phrases never to flag (a business name like "Vibrant Smiles")
emDashblock, 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.

Source 2 files
hooks/register.ts 81 lines
1import 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}
81
hooks/slop.ts 200 lines
1// 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