SLOPSHOPPER

client-pulse

Shows a client's live Search Console, rank tracking and AI visibility numbers from DataWise above the prompt.

newbandcommandtimer
★ 1v0.1.0MITupdated 2026-10-12NicoSKOOL/seo-mods/client-pulse
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · client-pulse
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /pulse ⎿ client-pulse: Client Pulse: no client here. Add .seo-client.json with {"domain": "example.com"} or run /pulse example.com ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
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.tsx 391 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Pulse, PulseStatus } from '../types'
5
6// The DataWise connector's name as /mcp lists it. A project can override it
7// with "datawiseServer" in .seo-client.json.
8const DEFAULT_SERVER = 'claude.ai DataWise SEO'
9const CONFIG_FILE = '.seo-client.json'
10const MAX_AGE_MS = 6 * 60 * 60 * 1000
11
12const status = atom({ plugin: 'client-pulse', key: 'status' } as const, { kind: 'idle' } as PulseStatus)
13const isHidden = atom({ plugin: 'client-pulse', key: 'isHidden' } as const, false)
14
15type Config = { domain: string; server: string }
16type Row = Record<string, any>
17
18export function bareDomain(url: string): string {
19  return url
20    .toLowerCase()
21    .replace(/^sc-domain:/, '')
22    .replace(/^https?:\/\//, '')
23    .replace(/^www\./, '')
24    .replace(/[/?#].*$/, '')
25}
26
27export function percentChange(current: number, previous: number): number | null {
28  return previous > 0 ? Math.round(((current - previous) / previous) * 100) : null
29}
30
31/** Last 7 days of the daily trend against the 7 before them. */
32export function weekOverWeek(daily: { date: string; clicks: number; impressions: number }[]) {
33  const sorted = [...daily].sort((a, b) => a.date.localeCompare(b.date))
34  const last = sorted.slice(-7)
35  const prev = sorted.slice(-14, -7)
36  const sum = (rows: typeof sorted, field: 'clicks' | 'impressions') =>
37    rows.reduce((total, row) => total + (row[field] ?? 0), 0)
38
39  return {
40    syncedTo: last.at(-1)?.date ?? null,
41    clicksDaily: sorted.slice(-14).map(row => row.clicks ?? 0),
42    impressionsDaily: sorted.slice(-14).map(row => row.impressions ?? 0),
43    clicks7: sum(last, 'clicks'),
44    clicksDelta: prev.length === 7 ? percentChange(sum(last, 'clicks'), sum(prev, 'clicks')) : null,
45    impressions7: sum(last, 'impressions'),
46    impressionsDelta:
47      prev.length === 7 ? percentChange(sum(last, 'impressions'), sum(prev, 'impressions')) : null,
48  }
49}
50
51export function trackedMovement(keywords: Row[]) {
52  const moved = (row: Row, isWorse: boolean) => {
53    if (row.prev_position == null) return false
54    if (row.position == null) return isWorse
55    return isWorse ? row.position > row.prev_position : row.position < row.prev_position
56  }
57
58  return {
59    ranking: keywords.filter(row => row.position != null).length,
60    total: keywords.length,
61    up: keywords.filter(row => moved(row, false)).length,
62    down: keywords.filter(row => moved(row, true)).length,
63  }
64}
65
66/** Citations across engines on the most recent check date. */
67export function latestAiCitations(trend: Row[]) {
68  const latest = trend.map(row => String(row.date)).sort().at(-1)
69  if (latest === undefined) return null
70  const rows = trend.filter(row => row.date === latest)
71
72  return {
73    cited: rows.reduce((total, row) => total + (row.cited ?? 0), 0),
74    total: rows.reduce((total, row) => total + (row.total ?? 0), 0),
75  }
76}
77
78/**
79 * The Search Console property to read for a domain: a domain property
80 * (sc-domain:) covers every protocol and subdomain, so it beats any URL-prefix
81 * one; then enabled before disabled, then the most recently synced.
82 */
83export function pickProperty(properties: Row[], domain: string): Row | undefined {
84  const rank = (row: Row) => [String(row.site_url).startsWith('sc-domain:') ? 1 : 0, Number(row.is_enabled) ? 1 : 0]
85
86  return properties
87    .filter(row => row.kind === 'gsc' && bareDomain(row.site_url) === domain)
88    .sort((a, b) => {
89      const [aDomain, aEnabled] = rank(a)
90      const [bDomain, bEnabled] = rank(b)
91      return bDomain! - aDomain! || bEnabled! - aEnabled! || String(b.last_synced_at ?? '').localeCompare(String(a.last_synced_at ?? ''))
92    })[0]
93}
94
95async function datawise($: EngineInterface, server: string, tool: string, args: Row): Promise<Row> {
96  const result = await $.mcp.call(server, tool, args)
97  const text = result.content.find(block => block.type === 'text')?.text ?? ''
98  if (result.isError) throw new Error(`${tool}: ${text.slice(0, 200)}`)
99
100  return JSON.parse(text)
101}
102
103async function loadConfig($: EngineInterface): Promise<Config | null> {
104  const path = `${await $.session.cwd()}/${CONFIG_FILE}`
105  if (!(await $.fs.exists(path))) return null
106  const raw = JSON.parse(await $.fs.read(path))
107  if (typeof raw.domain !== 'string' || raw.domain === '') return null
108
109  return { domain: bareDomain(raw.domain), server: raw.datawiseServer ?? DEFAULT_SERVER }
110}
111
112async function fetchPulse($: EngineInterface, config: Config): Promise<Pulse> {
113  const { domain, server } = config
114  const { properties = [] } = await datawise($, server, 'datawise_search_console', {
115    action: 'list_properties',
116  })
117  const property = pickProperty(properties as Row[], domain)
118  if (property === undefined) throw new Error(`no Search Console property for ${domain} in DataWise`)
119
120  const { overview } = await datawise($, server, 'datawise_search_console', {
121    action: 'overview',
122    property_id: property.id,
123    range_days: 30,
124    response_format: 'detailed',
125  })
126  const week = weekOverWeek(overview.daily_trend ?? [])
127  const opportunity = (overview.opportunities ?? overview.range?.opportunities ?? [])[0]
128
129  let tracked: Pulse['tracked'] = null
130  let aiCited: Pulse['aiCited'] = null
131  try {
132    const { projects = [] } = await datawise($, server, 'datawise_rank_tracking', { action: 'list_projects' })
133    const project = (projects as Row[])
134      .filter(row => bareDomain(row.domain) === domain)
135      .sort((a, b) => b.keyword_count - a.keyword_count)[0]
136    if (project !== undefined) {
137      const { keywords = [] } = await datawise($, server, 'datawise_rank_tracking', {
138        action: 'project_keywords',
139        project_id: project.id,
140      })
141      tracked = keywords.length > 0 ? trackedMovement(keywords) : null
142      const { trend = [] } = await datawise($, server, 'datawise_ai_visibility', {
143        project_id: project.id,
144        period: 30,
145      })
146      aiCited = latestAiCitations(trend)
147    }
148  } catch {
149    // Rank tracking and AI visibility are extras; the band still shows Search Console.
150  }
151
152  return {
153    domain,
154    ...week,
155    avgPosition: overview.summary?.last_30_days?.avg_position ?? null,
156    tracked,
157    aiCited,
158    topOpportunity: opportunity
159      ? { query: opportunity.query, impressions: opportunity.impressions, position: opportunity.avg_position }
160      : null,
161    fetchedAt: await $.clock.now(),
162  }
163}
164
165async function refresh($: EngineInterface, config: Config, isForced: boolean): Promise<PulseStatus> {
166  const cacheKey = `pulse:v3:${config.domain}`
167  const cached = (await $.store.get(cacheKey)) as Pulse | undefined
168  const isFresh = cached !== undefined && (await $.clock.now()) - cached.fetchedAt < MAX_AGE_MS
169
170  if (cached !== undefined) await update($, status, (): PulseStatus => ({ kind: 'ready', pulse: cached }))
171  if (isFresh && !isForced) return { kind: 'ready', pulse: cached }
172  if (cached === undefined) await update($, status, (): PulseStatus => ({ kind: 'loading', domain: config.domain }))
173
174  try {
175    const pulse = await fetchPulse($, config)
176    await $.store.set(cacheKey, pulse)
177    const next: PulseStatus = { kind: 'ready', pulse }
178    await update($, status, () => next)
179    return next
180  } catch (error) {
181    const next: PulseStatus = { kind: 'error', domain: config.domain, message: String((error as Error).message ?? error) }
182    if (cached === undefined) await update($, status, () => next)
183    return next
184  }
185}
186
187const SPARK = '▁▂▃▄▅▆▇█'
188
189/** A one-line bar chart of a series, scaled between its own min and max. */
190export function sparkline(values: number[] | undefined): string {
191  if (!values || values.length === 0) return ''
192  const min = Math.min(...values)
193  const span = Math.max(...values) - min
194  return values.map(v => SPARK[span === 0 ? 3 : Math.round(((v - min) / span) * (SPARK.length - 1))]).join('')
195}
196
197const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
198const shortDate = (iso: string) => {
199  const [, month, day] = iso.split('-').map(Number)
200  return month && day ? `${MONTHS[month - 1]} ${day}` : iso
201}
202
203const signed = (n: number | null) => (n === null ? '' : ` (${n > 0 ? '+' : ''}${n}%)`)
204const compact = (n: number) => (n >= 10000 ? `${(n / 1000).toFixed(1)}K` : n.toLocaleString('en-US'))
205
206export function summarize(pulse: Pulse): string {
207  const parts = [
208    `${pulse.domain}`,
209    `clicks 7d ${compact(pulse.clicks7)}${signed(pulse.clicksDelta)}`,
210    `impressions 7d ${compact(pulse.impressions7)}${signed(pulse.impressionsDelta)}`,
211  ]
212  if (pulse.avgPosition !== null) parts.push(`avg position ${pulse.avgPosition}`)
213  if (pulse.tracked) {
214    parts.push(`tracked ${pulse.tracked.ranking}/${pulse.tracked.total} ranking (${pulse.tracked.up} up, ${pulse.tracked.down} down)`)
215  }
216  if (pulse.aiCited) parts.push(`AI citations ${pulse.aiCited.cited}/${pulse.aiCited.total}`)
217  if (pulse.topOpportunity) {
218    parts.push(`top opportunity "${pulse.topOpportunity.query}" (${pulse.topOpportunity.impressions} impressions, position ${pulse.topOpportunity.position})`)
219  }
220  if (pulse.syncedTo) parts.push(`data through ${pulse.syncedTo}`)
221
222  return parts.join(' · ')
223}
224
225export const register: Register = on => {
226  let config: Config | null = null
227
228  on('session.start', async ($, e, next) => {
229    await $.command.register({
230      name: 'pulse',
231      description: 'Client Pulse: refresh the DataWise numbers, or switch client with /pulse example.com',
232      argumentHint: '[domain]',
233    })
234    config = await loadConfig($).catch(() => null)
235    const startConfig = config
236    if (startConfig !== null) $.clock.after(1, () => void refresh($, startConfig, false))
237
238    return next(e)
239  })
240
241  on('command.run', { command: 'pulse' }, async ($, e) => {
242    const domain = e.args.trim()
243    if (domain !== '') {
244      config = { domain: bareDomain(domain), server: config?.server ?? DEFAULT_SERVER }
245    }
246    if (config === null) {
247      return { text: `Client Pulse: no client here. Add ${CONFIG_FILE} with {"domain": "example.com"} or run /pulse example.com` }
248    }
249    await update($, isHidden, () => false)
250    const result = await refresh($, config, true)
251
252    return result.kind === 'ready'
253      ? { text: `Client Pulse: ${summarize(result.pulse)}` }
254      : { text: `Client Pulse: could not load ${config.domain}: ${result.kind === 'error' ? result.message : 'still loading'}` }
255  })
256
257  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
258    const current = await read($, status)
259    if (e.props.hasSurvey || current.kind === 'idle' || (await read($, isHidden))) return next(e)
260
261    const { Box, Button, Text } = $.ui.resolve(e)
262    const isWide = e.props.bodyColumns >= 96 && e.props.maxRows >= 7
263    const reload = config
264    const controls = (
265      <Box gap={1}>
266        {reload !== null && (
267          <Button key="refresh" plain label="↻" onPress={() => void refresh($, reload, true)} />
268        )}
269        <Button key="hide" plain role="dismiss" label="×" onPress={() => update($, isHidden, () => true)} />
270      </Box>
271    )
272    const header = (domain: string, note: string) => (
273      <Box justifyContent="space-between">
274        <Text>
275          <Text color="claude">◆ </Text>
276          <Text bold>{domain}</Text>
277        </Text>
278        <Box gap={2}>
279          <Text color="subtle">{note}</Text>
280          {controls}
281        </Box>
282      </Box>
283    )
284    const card = (children: unknown) => (
285      <Box flexDirection="column" borderStyle="round" borderColor="promptBorder" paddingX={1}>
286        {children as never}
287      </Box>
288    )
289
290    if (current.kind === 'loading') {
291      return card(header(current.domain, 'loading from DataWise…'))
292    }
293    if (current.kind === 'error') {
294      return card(
295        <Box flexDirection="column">
296          {header(current.domain, 'DataWise')}
297          <Text color="error">{current.message}</Text>
298        </Box>,
299      )
300    }
301
302    const pulse = current.pulse
303    const note = `Search Console via DataWise${pulse.syncedTo ? ` · data to ${shortDate(pulse.syncedTo)}` : ''}`
304    const delta = (n: number | null, isInverted = false) => {
305      if (n === null) return null
306      const isGood = isInverted ? n < 0 : n > 0
307      return (
308        <Text color={n === 0 ? 'subtle' : isGood ? 'success' : 'error'}>
309          {' '}
310          {n > 0 ? '▲' : n < 0 ? '▼' : '•'} {Math.abs(n)}%
311        </Text>
312      )
313    }
314    const opportunity = pulse.topOpportunity && (
315      <Text wrap="truncate-end">
316        <Text color="suggestion">→ </Text>
317        <Text color="subtle">Top opportunity </Text>
318        <Text bold>"{pulse.topOpportunity.query}"</Text>
319        <Text color="subtle">
320          {' '}
321          ranks #{Math.round(pulse.topOpportunity.position)} with {compact(pulse.topOpportunity.impressions)} impressions
322        </Text>
323      </Text>
324    )
325
326    if (!isWide) {
327      return (
328        <Box flexDirection="column">
329          <Box justifyContent="space-between">
330            <Text wrap="truncate-end">
331              <Text color="claude">◆ </Text>
332              <Text bold>{pulse.domain}</Text>
333              <Text color="subtle">  clicks </Text>
334              <Text bold>{compact(pulse.clicks7)}</Text>
335              {delta(pulse.clicksDelta)}
336              <Text color="subtle">  impr </Text>
337              <Text bold>{compact(pulse.impressions7)}</Text>
338              {delta(pulse.impressionsDelta)}
339            </Text>
340            {controls}
341          </Box>
342          {opportunity}
343        </Box>
344      )
345    }
346
347    const stat = (key: string, label: string, value: string, extra: unknown, line?: string) => (
348      <Box key={key} flexDirection="column" minWidth={16}>
349        <Text color="subtle">{label}</Text>
350        <Text>
351          <Text bold>{value}</Text>
352          {extra as never}
353        </Text>
354        <Text color="suggestion">{line ?? ' '}</Text>
355      </Box>
356    )
357    const ratio = (part: number, whole: number) => (whole > 0 ? Math.round((part / whole) * 100) : 0)
358
359    return card(
360      <Box flexDirection="column">
361        {header(pulse.domain, note)}
362        <Box gap={3} marginTop={1}>
363          {stat('clicks', 'CLICKS · 7D', compact(pulse.clicks7), delta(pulse.clicksDelta), sparkline(pulse.clicksDaily))}
364          {stat('impressions', 'IMPRESSIONS · 7D', compact(pulse.impressions7), delta(pulse.impressionsDelta), sparkline(pulse.impressionsDaily))}
365          {pulse.avgPosition !== null && stat('position', 'AVG POSITION', String(pulse.avgPosition), null, 'last 30 days')}
366          {pulse.tracked &&
367            stat(
368              'tracked',
369              'TRACKED KEYWORDS',
370              `${pulse.tracked.ranking}/${pulse.tracked.total}`,
371              <Text>
372                {pulse.tracked.up > 0 && <Text color="success"> ▲{pulse.tracked.up}</Text>}
373                {pulse.tracked.down > 0 && <Text color="error"> ▼{pulse.tracked.down}</Text>}
374              </Text>,
375              'ranking in top 100',
376            )}
377          {pulse.aiCited &&
378            stat(
379              'ai',
380              'AI CITATIONS',
381              `${pulse.aiCited.cited}/${pulse.aiCited.total}`,
382              <Text color="subtle"> {ratio(pulse.aiCited.cited, pulse.aiCited.total)}%</Text>,
383              'latest check',
384            )}
385        </Box>
386        {opportunity}
387      </Box>,
388    )
389  })
390}
391
types/index.d.ts 30 lines
1export type Pulse = {
2  domain: string
3  /** Last date Search Console data covers (YYYY-MM-DD). */
4  syncedTo: string | null
5  clicks7: number
6  /** Daily clicks and impressions, oldest first, for the sparklines (up to 14 days). */
7  clicksDaily: number[]
8  impressionsDaily: number[]
9  clicksDelta: number | null
10  impressions7: number
11  impressionsDelta: number | null
12  avgPosition: number | null
13  tracked: { ranking: number; total: number; up: number; down: number } | null
14  aiCited: { cited: number; total: number } | null
15  topOpportunity: { query: string; impressions: number; position: number } | null
16  fetchedAt: number
17}
18
19export type PulseStatus =
20  | { kind: 'idle' }
21  | { kind: 'loading'; domain: string }
22  | { kind: 'ready'; pulse: Pulse }
23  | { kind: 'error'; domain: string; message: string }
24
25declare module 'claude-code' {
26  interface PluginState {
27    'client-pulse': { status: PulseStatus; isHidden: boolean }
28  }
29}
30