Marketing
Hook
Hooks
What claude-seo runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.
Install
> /plugin marketplace add AgriciDaniel/claude-seo > /plugin install claude-seo@agricidaniel-claude-seo
Ships with claude-seo. Installing the plugin gets these hooks.
Where it lives
- hooks/run-python-hook.jsGitHub
Read the script
#!/usr/bin/env node "use strict"; const { spawnSync } = require("child_process"); function stripWrappingQuotes(value) { return value.replace(/^["']|["']$/g, ""); } function pythonCandidates() { const candidates = []; if (process.env.CLAUDE_SEO_PYTHON) { candidates.push({ label: "CLAUDE_SEO_PYTHON", exe: stripWrappingQuotes(process.env.CLAUDE_SEO_PYTHON), args: [], }); } candidates.push( { label: "py -3", exe: "py", args: ["-3"] }, { label: "python3", exe: "python3", args: [] }, { label: "python", exe: "python", args: [] }, ); return candidates; } function isStoreStubOutput(text) { return /Microsoft Store|WindowsApps|App execution alias|was not found/i.test(text); } function probe(candidate) { const script = "import sys; print(sys.executable); print(sys.version.split()[0])"; const result = spawnSync(candidate.exe, [...candidate.args, "-c", script], { encoding: "utf8", }); const output = `${result.stdout || ""}\n${result.stderr || ""}`; return result.status === 0 && Boolean((result.stdout || "").trim()) && !isStoreStubOutput(output); } function main() { const [, , hookScript, ...hookArgs] = process.argv; if (!hookScript) { process.exit(0); } for (const candidate of pythonCandidates()) { if (!probe(candidate)) { continue; } const result = spawnSync(candidate.exe, [...candidate.args, hookScript, ...hookArgs], { stdio: "inherit", }); if (result.error) { continue; } process.exit(result.status === null ? 1 : result.status); } console.error( "Claude SEO hook could not find Python. Tried CLAUDE_SEO_PYTHON, py -3, python3, python.", ); process.exit(1); } main(); - hooks/validate-schema.pyGitHub
Read the script
#!/usr/bin/env python3 """Post-edit schema validation hook for Claude Code. Validates JSON-LD schema after file edits. Returns exit code 2 to block if critical validation errors found. Hook configuration in ~/.claude/settings.json: { "hooks": { "PostToolUse": [ { "matcher": "Edit|Write", "hooks": [ { "type": "command", "command": "node", "args": [ "${CLAUDE_PLUGIN_ROOT}/hooks/run-python-hook.js", "${CLAUDE_PLUGIN_ROOT}/hooks/validate-schema.py", "${tool_input.file_path}" ] } ] } ] } } Note: matcher filters by tool name only (Edit, Write). The script itself checks if the file contains schema markup before validating. """ import json import os import re import sys from typing import List def validate_jsonld(content: str) -> List[str]: """Validate JSON-LD blocks in HTML content.""" errors = [] pattern = r'<script\s+type=["\']application/ld\+json["\']\s*>(.*?)</script>' blocks = re.findall(pattern, content, re.DOTALL | re.IGNORECASE) if not blocks: return [] # No schema found; not an error for i, block in enumerate(blocks, 1): block = block.strip() try: data = json.loads(block) except json.JSONDecodeError as e: errors.append(f"Block {i}: Invalid JSON; {e}") continue if isinstance(data, list): for item in data: errors.extend(_validate_schema_object(item, i)) elif isinstance(data, dict): errors.extend(_validate_schema_object(data, i)) return errors def _validate_schema_object(obj: dict, block_num: int) -> List[str]: """Validate a single schema object.""" errors = [] prefix = f"Block {block_num}" # Check @context if "@context" not in obj: errors.append(f"{prefix}: Missing @context") elif obj["@context"] not in ("https://schema.org", "http://schema.org"): errors.append(f"{prefix}: @context should be 'https://schema.org'") # Check @type if "@type" not in obj: errors.append(f"{prefix}: Missing @type") # Check for placeholder text placeholders = [ "[Business Name]", "[City]", "[State]", "[Phone]", "[Address]", "[Your", "[INSERT", "REPLACE", "[URL]", "[Email]", ] text = json.dumps(obj) for p in placeholders: if p.lower() in text.lower(): errors.append(f"{prefix}: Contains placeholder text: {p}") # Check for deprecated types schema_type = obj.get("@type", "") deprecated = { "HowTo": "deprecated September 2023", "SpecialAnnouncement": "deprecated July 31, 2025", "CourseInfo": "retired June 2025", "EstimatedSalary": "retired June 2025", "LearningVideo": "retired June 2025", "ClaimReview": "retired June 2025; fact-check rich results discontinued", "VehicleListing": "retired June 2025; vehicle listing structured data discontinued", } if schema_type in deprecated: errors.append(f"{prefix}: @type '{schema_type}' is {deprecated[schema_type]}") # Check for restricted types used incorrectly. # FAQPage is intentionally NOT flagged: Google retired FAQ rich results for # all sites (May 7, 2026), but FAQPage remains a valid Schema.org type. # This project makes no claim of a confirmed AI or ranking benefit. restricted: dict = {} if schema_type in restricted: errors.append(f"{prefix}: @type '{schema_type}' is {restricted[schema_type]}; verify site qualifies") return errors def _resolve_filepath(): """File path from argv (exec-form template) or the stdin hook-event JSON. Claude Code's documented hook contract delivers the event as JSON on stdin; the argv template is kept for harnesses that substitute it. Whichever yields an existing file wins. """ if len(sys.argv) > 1 and os.path.isfile(sys.argv[1]): return sys.argv[1] try: if not sys.stdin.isatty(): raw = sys.stdin.read() if raw.strip(): event = json.loads(raw) fp = (event.get("tool_input") or {}).get("file_path") if fp and os.path.isfile(fp): return fp except (OSError, ValueError): pass return None def main(): filepath = _resolve_filepath() if not filepath: sys.exit(0) # Only validate HTML-like files valid_extensions = (".html", ".htm", ".jsx", ".tsx", ".vue", ".svelte", ".php", ".ejs") if not filepath.lower().endswith(valid_extensions): sys.exit(0) # File-size guard: skip files >10MB to bound memory + hook latency. # Real source files almost never exceed this; bigger inputs are typically # generated, minified bundles or accidental binary writes. MAX_FILE_BYTES = 10 * 1024 * 1024 # 10 MiB try: if os.path.getsize(filepath) > MAX_FILE_BYTES: sys.exit(0) except OSError: sys.exit(0) try: with open(filepath, "r", encoding="utf-8", errors="ignore") as f: content = f.read() except (OSError, IOError): sys.exit(0) errors = validate_jsonld(content) if not errors: sys.exit(0) # Categorize errors critical_keywords = ["placeholder", "deprecated", "retired"] critical = [e for e in errors if any(kw in e.lower() for kw in critical_keywords)] warnings = [e for e in errors if e not in critical] if warnings: print("⚠️ Schema validation warnings:") for w in warnings: print(f" - {w}") if critical: print("🛑 Schema validation ERRORS (blocking):") for e in critical: print(f" - {e}") sys.exit(2) # Block the edit sys.exit(1) # Warnings only; proceed if __name__ == "__main__":
Read the script before you install anything that runs on your machine. This is the one part of a plugin that acts without being asked.
Ships withclaude-seo
Claude SEO is an open-source SEO analysis plugin for Claude Code. It runs 25 sub-skills and 18 specialist agents in parallel across technical SEO, content quality (E-E-A-T), Schema.org markup, AI search optimization (GEO), local SEO, e-commerce, and
Get the whole plugin
Stats
13,818
Stars
2,014
Forks
Active
Maintenance
Python
Language
MIT
License
13d ago
Last commit
6mo ago
Created
Repo: AgriciDaniel/claude-seo

