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

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 251 lines1import 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