SLOPSHOPPER

cannibal-guard

Stops Claude from creating a new page whose target keyword an existing page already covers or ranks for (local pages + DataWise).

newguardtoaststatus
★ 1v0.1.0MITupdated 2026-10-12NicoSKOOL/seo-mods/cannibal-guard
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 1 files
hooks/register.ts 251 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3// The DataWise connector's name as /mcp lists it. A project can override it
4// with "datawiseServer" in .seo-client.json.
5const DEFAULT_SERVER = 'claude.ai DataWise SEO'
6const CONFIG_FILE = '.seo-client.json'
7const DEFAULT_CONTENT_DIRS = ['src/content', 'src/pages', 'content', 'pages']
8const PAGE_FILE = /\.(md|mdx|astro|html)$/i
9const SKIP_DIRS = new Set(['node_modules', 'dist', 'build', '.git', '.astro', '.next', '.wrangler'])
10const MAX_FILES = 2000
11const DAY_MS = 24 * 60 * 60 * 1000
12const SIMILARITY = 0.6
13
14type Row = Record<string, any>
15type Config = {
16  domain: string | null
17  server: string
18  contentDirs: string[]
19  minImpressions: number
20  useRankedKeywords: boolean
21  locationCode?: number
22  languageCode?: string
23}
24type LocalPage = { path: string; keyword: string }
25type Clash =
26  | { source: 'local'; path: string; keyword: string }
27  | { source: 'search-console'; query: string; impressions: number; clicks: number; position: number }
28  | { source: 'ranked'; keyword: string; url: string; position: number; volume: number }
29
30const STOPWORDS = new Set(
31  'a an the in on at for of to and or near me best top my your with by from de la el los las en y o para del al con por cerca mejor'.split(' '),
32)
33
34export function tokens(text: string): Set<string> {
35  const words = text
36    .toLowerCase()
37    .normalize('NFD')
38    .replace(/[̀-ͯ]/g, '')
39    .split(/[^a-z0-9]+/)
40    .filter(word => word.length > 0 && !STOPWORDS.has(word))
41
42  return new Set(words)
43}
44
45/** Share of words two keywords have in common (Jaccard over their meaningful words). */
46export function similarity(a: string, b: string): number {
47  const left = tokens(a)
48  const right = tokens(b)
49  if (left.size === 0 || right.size === 0) return 0
50  const shared = [...left].filter(word => right.has(word)).length
51
52  return shared / (left.size + right.size - shared)
53}
54
55/** The target keyword a page declares: frontmatter, an Astro const, or an HTML comment. */
56export function targetKeyword(content: string): string | null {
57  const head = content.slice(0, 4000)
58  const match = head.match(
59    /\b(?:target_?keyword|targetKeyword|primary_?keyword|primaryKeyword|focus_?keyword|focusKeyword|keyword)\b\s*[:=]\s*["'`]?([^"'`\n]+?)["'`]?\s*(?:;|,|-->|$)/im,
60  )
61  const keyword = match?.[1]?.trim()
62
63  return keyword ? keyword : null
64}
65
66export const isOverridden = (content: string) => /\bcannibali[sz]ation\s*:\s*["']?ok\b/i.test(content.slice(0, 4000))
67
68const bareDomain = (url: string) =>
69  url.toLowerCase().replace(/^sc-domain:/, '').replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/[/?#].*$/, '')
70
71async function datawise($: EngineInterface, server: string, tool: string, args: Row): Promise<Row> {
72  const result = await $.mcp.call(server, tool, args)
73  const text = result.content.find(block => block.type === 'text')?.text ?? ''
74  if (result.isError) throw new Error(`${tool}: ${text.slice(0, 200)}`)
75
76  return JSON.parse(text)
77}
78
79async function loadConfig($: EngineInterface, root: string): Promise<Config> {
80  const path = `${root}/${CONFIG_FILE}`
81  const raw: Row = (await $.fs.exists(path)) ? JSON.parse(await $.fs.read(path)) : {}
82
83  return {
84    domain: typeof raw.domain === 'string' && raw.domain !== '' ? bareDomain(raw.domain) : null,
85    server: raw.datawiseServer ?? DEFAULT_SERVER,
86    contentDirs: Array.isArray(raw.contentDirs) ? raw.contentDirs : DEFAULT_CONTENT_DIRS,
87    minImpressions: raw.cannibalization?.minImpressions ?? 20,
88    useRankedKeywords: raw.cannibalization?.rankedKeywords ?? true,
89    locationCode: raw.locationCode,
90    languageCode: raw.languageCode,
91  }
92}
93
94async function walk($: EngineInterface, dir: string, found: string[], depth = 0): Promise<void> {
95  if (depth > 8 || found.length >= MAX_FILES) return
96  const entries = await $.fs.list(dir).catch(() => [])
97  for (const entry of entries) {
98    if (found.length >= MAX_FILES) return
99    const path = `${dir}/${entry.name}`
100    if (entry.kind === 'dir' && !SKIP_DIRS.has(entry.name)) await walk($, path, found, depth + 1)
101    else if (entry.kind === 'file' && PAGE_FILE.test(entry.name)) found.push(path)
102  }
103}
104
105async function indexLocalPages($: EngineInterface, root: string, dirs: string[]): Promise<LocalPage[]> {
106  const files: string[] = []
107  for (const dir of dirs) await walk($, `${root}/${dir}`, files)
108  const pages: LocalPage[] = []
109  for (const path of files) {
110    const keyword = targetKeyword(await $.fs.read(path).catch(() => ''))
111    if (keyword !== null) pages.push({ path, keyword })
112  }
113
114  return pages
115}
116
117/** Search Console queries and DataForSEO ranked keywords, cached once a day per domain. */
118async function searchData($: EngineInterface, config: Config): Promise<{ queries: Row[]; ranked: Row[] }> {
119  const domain = config.domain
120  if (domain === null) return { queries: [], ranked: [] }
121  const cacheKey = `search:v2:${domain}`
122  const cached = (await $.store.get(cacheKey)) as { at: number; queries: Row[]; ranked: Row[] } | undefined
123  if (cached !== undefined && (await $.clock.now()) - cached.at < DAY_MS) return cached
124
125  let queries: Row[] = []
126  try {
127    const { properties = [] } = await datawise($, config.server, 'datawise_search_console', { action: 'list_properties' })
128    // A domain property (sc-domain:) covers every version of the site, so it wins.
129    const property = (properties as Row[])
130      .filter(row => row.kind === 'gsc' && row.is_enabled && bareDomain(row.site_url) === domain)
131      .sort((a, b) => Number(String(b.site_url).startsWith('sc-domain:')) - Number(String(a.site_url).startsWith('sc-domain:')))[0]
132    if (property !== undefined) {
133      const result = await datawise($, config.server, 'datawise_search_console', {
134        action: 'queries',
135        property_id: property.id,
136        sort: 'impressions',
137        limit: 200,
138      })
139      queries = result.queries?.rows ?? []
140    }
141  } catch {
142    // No Search Console data: the local check and ranked keywords still run.
143  }
144
145  let ranked: Row[] = []
146  if (config.useRankedKeywords) {
147    try {
148      const result = await datawise($, config.server, 'datawise_ranked_keywords', {
149        domain,
150        limit: 100,
151        ...(config.locationCode ? { location_code: config.locationCode } : {}),
152        ...(config.languageCode ? { language_code: config.languageCode } : {}),
153      })
154      ranked = result.keywords ?? []
155    } catch {
156      // DataForSEO lookup is optional.
157    }
158  }
159
160  const fresh = { at: await $.clock.now(), queries, ranked }
161  await $.store.set(cacheKey, fresh)
162
163  return fresh
164}
165
166export function findClashes(
167  keyword: string,
168  newPath: string,
169  pages: LocalPage[],
170  queries: Row[],
171  ranked: Row[],
172  minImpressions: number,
173): Clash[] {
174  const local: Clash[] = pages
175    .filter(page => page.path !== newPath && similarity(keyword, page.keyword) >= SIMILARITY)
176    .map(page => ({ source: 'local', path: page.path, keyword: page.keyword }))
177
178  const matchedQueries = queries.filter(row => similarity(keyword, row.query) >= SIMILARITY)
179  const totalImpressions = matchedQueries.reduce((total, row) => total + (row.impressions ?? 0), 0)
180  const console: Clash[] =
181    totalImpressions >= minImpressions
182      ? matchedQueries
183          .sort((a, b) => b.impressions - a.impressions)
184          .slice(0, 3)
185          .map(row => ({
186            source: 'search-console',
187            query: row.query,
188            impressions: row.impressions,
189            clicks: row.clicks,
190            position: row.avg_position,
191          }))
192      : []
193
194  const rankedClashes: Clash[] = ranked
195    .filter(row => row.url && similarity(keyword, row.keyword) >= SIMILARITY)
196    .sort((a, b) => a.position - b.position)
197    .slice(0, 3)
198    .map(row => ({ source: 'ranked', keyword: row.keyword, url: row.url, position: row.position, volume: row.search_volume }))
199
200  return [...local, ...console, ...rankedClashes]
201}
202
203export function explain(keyword: string, newPath: string, clashes: Clash[], root: string): string {
204  const relative = (path: string) => (path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path)
205  const lines = clashes.map(clash => {
206    if (clash.source === 'local') return `- Existing page ${relative(clash.path)} already targets "${clash.keyword}".`
207    if (clash.source === 'search-console') {
208      return `- Google already shows this site for "${clash.query}": ${clash.impressions} impressions, ${clash.clicks} clicks, average position ${clash.position} (Search Console via DataWise).`
209    }
210    return `- ${clash.url} already ranks #${clash.position} for "${clash.keyword}" (${clash.volume}/mo).`
211  })
212
213  return [
214    `Cannibalization Guard stopped this new page: ${relative(newPath)} targets "${keyword}", which the site already covers.`,
215    ...lines,
216    'Two pages on one keyword split rankings. Improve the existing page instead (add the new content there, or make the new page target a different keyword).',
217    'Tell the user what was found. Only if the user confirms a separate page is intended, add `cannibalization: ok` to its frontmatter and write it again.',
218  ].join('\n')
219}
220
221export const register: Register = on => {
222  let localIndex: { root: string; pages: LocalPage[] } | null = null
223
224  on('tool.call', { tool: 'Write' }, async ($, e, next) => {
225    const path = e.file_path
226    if (!PAGE_FILE.test(path) || isOverridden(e.content)) return next(e)
227    const keyword = targetKeyword(e.content)
228    if (keyword === null || (await $.fs.exists(path))) return next(e)
229
230    const root = await $.session.cwd()
231    const config = await loadConfig($, root)
232    if (localIndex === null || localIndex.root !== root) {
233      localIndex = { root, pages: await indexLocalPages($, root, config.contentDirs) }
234    }
235    $.ui.status(`Cannibalization Guard: checking "${keyword}"`)
236    const { queries, ranked } = await searchData($, config)
237    $.ui.status(undefined)
238
239    const clashes = findClashes(keyword, path, localIndex.pages, queries, ranked, config.minImpressions)
240    if (clashes.length > 0) {
241      $.ui.toast(`Cannibalization Guard: "${keyword}" is already covered. Claude was told to improve the existing page.`)
242      return { deny: explain(keyword, path, clashes, root) }
243    }
244
245    const result = await next(e)
246    if (!('deny' in result && result.deny !== undefined)) localIndex.pages.push({ path, keyword })
247
248    return result
249  })
250}
251