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 Any, List BRACKET_PLACEHOLDERS = ( "[Business Name]", "[City]", "[State]", "[Phone]", "[Address]", "[Your", "[INSERT", "[URL]", "[Email]", ) BARE_PLACEHOLDER_RE = re.compile(r"\bREPLACE(?:_[A-Z]+)*\b") # Match every <script ...>...</script> pair, then filter on the type attribute. # The previous pattern required ``type`` to be the first and only attribute, so # blocks carrying a CSP ``nonce``, an ``id`` or ``data-*`` attributes, or an # unquoted type value were skipped without validation. # # The attribute group is a small tokenizer, not a plain ``[^>]*``: it consumes a # double-quoted value, a single-quoted value, or a run of characters that is # neither a quote nor ``>``. A ``[^>]*`` scan ends the tag at the first ``>`` it # sees, quoted or not, so an attribute value containing ``>`` (``data-cond="a>b"``, # a templated nonce) truncated the tag early and fed the remainder of the # attributes plus the real body to the JSON parser as garbage. Treating a quoted # span as atomic keeps an embedded ``>`` from ending the tag prematurely. # The fallback class excludes both quote characters: if it matched an # apostrophe, the alternation would be ambiguous and a tag with many # apostrophes and no closing tag would backtrack exponentially, hanging the # blocking hook. _ATTRS_RE = r'(?:"[^"]*"|\'[^\']*\'|[^"\'>])*' SCRIPT_TAG_RE = re.compile( r"<script\b(" + _ATTRS_RE + r")>(.*?)</script\s*>", re.DOTALL | re.IGNORECASE ) LD_JSON_TYPE_RE = re.compile( r"""(?:^|\s)type\s*=\s*""" r"""(?:"application/ld\+json"|'application/ld\+json'|application/ld\+json(?=\s|$))""", re.IGNORECASE, ) # Server- or client-side template expressions that render JSON-LD at runtime. # The hook runs on .jsx/.tsx/.vue/.svelte/.php/.ejs sources, where the script # body is frequently an expression rather than literal JSON. Those blocks cannot # be validated statically and must not be reported as invalid JSON. SERVER_TEMPLATE_RE = re.compile( r"""^(?: <\?(?:php\b|=) # <?php ... ?> / <?= ... ?> | <% # EJS / ERB )""", re.VERBOSE, ) COMPONENT_EXPRESSION_RE = re.compile( r"""^(?: \{\{ # Vue / Handlebars / Twig | \{@html\b # Svelte | \$\{ # JS template literal | \{\s*[A-Za-z_$][\w$.]* # JSX expression: {schema} / {JSON.stringify(...)} )""", re.VERBOSE, ) COMPONENT_EXTENSIONS = (".jsx", ".tsx", ".vue", ".svelte") SCHEMA_ORG_CONTEXTS = frozenset( {"https://schema.org", "http://schema.org", "https://schema.org/", "http://schema.org/"} ) def _configure_utf8() -> None: """Keep hook diagnostics printable on legacy Windows console encodings.""" for stream in (sys.stdout, sys.stderr): reconfigure = getattr(stream, "reconfigure", None) if reconfigure: reconfigure(encoding="utf-8", errors="replace") def _extract_ld_json_blocks(content: str) -> List[str]: """Return the bodies of every ``<script type="application/ld+json">`` block. Attribute order, extra attributes (``nonce``, ``id``, ``data-*``), tag case and unquoted type values are all accepted; only the type value is decisive. """ blocks = [] for attributes, body in SCRIPT_TAG_RE.findall(content): if LD_JSON_TYPE_RE.search(attributes): blocks.append(body) return blocks def _is_template_expression(block: str, filepath: str = "") -> bool: """True when the script body is rendered at runtime rather than literal JSON. Server-side markers (PHP, EJS) are never valid JSON and are skipped for every file type. Component expressions (JSX, Vue, Svelte, template literals) are only skipped in component sources, so a malformed object literal in a plain ``.html`` file is still reported. """ if SERVER_TEMPLATE_RE.match(block): return True if filepath.lower().endswith(COMPONENT_EXTENSIONS): return bool(COMPONENT_EXPRESSION_RE.match(block)) return False def _is_schema_org_context(value: Any) -> bool: """Accept the schema.org context in its string, list and object forms.""" if isinstance(value, str): return value in SCHEMA_ORG_CONTEXTS if isinstance(value, list): return any(_is_schema_org_context(item) for item in value) if isinstance(value, dict): return _is_schema_org_context(value.get("@vocab")) return False def validate_jsonld(content: str, filepath: str = "") -> List[str]: """Validate JSON-LD blocks in HTML content.""" errors = [] blocks = _extract_ld_json_blocks(content) if not blocks: return [] # No schema found; not an error for i, block in enumerate(blocks, 1): block = block.strip() if _is_template_expression(block, filepath): continue # Rendered at runtime; nothing to validate statically try: data = json.loads(block) except json.JSONDecodeError as e: errors.append(f"Block {i}: Invalid JSON; {e}") continue if isinstance(data, list): f
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
17,484
Stars
2,563
Forks
Active
Maintenance
Python
Language
MIT
License
11d ago
Last commit
7mo ago
Created
Repo: AgriciDaniel/claude-seo

