#!/usr/bin/env node /** * @agent-manifest * name: seo-review * title: SEO Reviewer * class: content * trigger: manual (auto-invoked by writer/approve gates) * description: Audit one article for EEAT / spam-policy / on-page SEO / AEO / readability → seo-review.json. * model: reviewer=claude-opus-4-8 * prompt: prompts/seo-review.system.md * skills: - * tools: lib/claude.mjs * reads: - * writes: - * created: 2026-07-04 * @end */ // SEO Specialist agent: audit one article for EEAT / spam-policy / on-page SEO / AEO / readability. // Writes /seo-review.json. Usage: // node scripts/seo-review.mjs [--published] // (default: review the draft in agents/drafts/; --published reviews the // live post in app/src/content/blog/) import { existsSync, readFileSync } from "node:fs"; import { dirname, resolve, join } from "node:path"; import { fileURLToPath } from "node:url"; import { runClaude } from "./lib/claude.mjs"; import { loadJson, readFrontmatter, fmString } from "./lib/util.mjs"; import { readSlugs } from "./lib/blogData.mjs"; const HERE = dirname(fileURLToPath(import.meta.url)); const PIPELINE = resolve(HERE, ".."); const cfg = loadJson(join(PIPELINE, "config.json")); const root = cfg.paths.projectRoot; function postDirFor(slug, published) { const draftDir = join(root, cfg.paths.draftsDir, slug); const liveDir = join(root, cfg.paths.blogContentDir, slug); if (published) return liveDir; if (existsSync(join(draftDir, "index.mdx"))) return draftDir; if (existsSync(join(liveDir, "index.mdx"))) return liveDir; return draftDir; // will fail the existence check below } function primaryKeywordFor(slug, mdxPath) { const calPath = join(root, cfg.paths.calendar); if (existsSync(calPath)) { const entry = loadJson(calPath).find((e) => e.slug === slug); if (entry?.primaryKeyword) return entry.primaryKeyword; } if (existsSync(mdxPath)) { const t = fmString(readFrontmatter(readFileSync(mdxPath, "utf8")), "title"); if (t) return t; } return slug.replace(/-/g, " "); } /** Run the SEO audit for one post. Returns the parsed audit object, or null on failure. */ export async function runSeoReview(slug, { published = false } = {}) { const postDir = postDirFor(slug, published); const mdxPath = join(postDir, "index.mdx"); if (!existsSync(mdxPath)) { console.error(`[seo] no article at ${mdxPath}`); return null; } const keyword = primaryKeywordFor(slug, mdxPath); const calPathForEntry = join(root, cfg.paths.calendar); const calEntry = existsSync(calPathForEntry) ? loadJson(calPathForEntry).find((e) => e.slug === slug) : null; const firsthandNote = calEntry?.authorFirsthand ? `\n\nIMPORTANT — authorFirsthand piece: a real, named author (${cfg.author?.name || "the site author"}) wrote this personally from genuine first-hand experience. First-person voice and subjective opinions are LEGITIMATE Experience (positive E-E-A-T), NOT fabrication. Do not flag the personal voice as an EEAT/spam violation; only require citations for objective, verifiable facts (addresses, prices, dates, hours).` : ""; const blogDir = join(root, cfg.paths.blogContentDir); const categories = [...readSlugs(join(root, cfg.paths.blogDataFile)).categories]; const auditPath = join(postDir, "seo-review.json"); const logFile = join(root, cfg.paths.logsDir, `seo-${slug}.log`); const prompt = `Audit this Comiida article. - Article: ${mdxPath} - Citations file (if present): ${join(postDir, "sources.json")} - Primary keyword to target: ${keyword} - Existing published posts (for internal-link validation): ${blogDir} - Allowed category slugs: ${categories.join(", ")} - Write your audit JSON to: ${auditPath}${firsthandNote} Follow the rubric and output contract in your system prompt exactly.`; await runClaude({ prompt, systemPromptFile: join(PIPELINE, "prompts/seo-review.system.md"), model: cfg.models.reviewer, allowedTools: "Read Glob Grep WebSearch Write", addDirs: [root], cwd: root, logFile, }); if (!existsSync(auditPath)) { console.error(`[seo] audit not written: ${auditPath}`); return null; } try { return loadJson(auditPath); } catch (e) { console.error(`[seo] could not parse audit: ${e.message}`); return null; } } // CLI if (import.meta.url === `file://${process.argv[1]}`) { const slug = process.argv[2]; const published = process.argv.includes("--published"); if (!slug) { console.error("Usage: seo-review.mjs [--published]"); process.exit(1); } const audit = await runSeoReview(slug, { published }); if (!audit) process.exit(1); const nb = (audit.blocking || []).length; const nf = (audit.topFixes || []).length; console.log(`[seo] ${slug}: ${audit.verdict} · ${audit.overall} · ${nb} blocking · ${nf} fixes`); if (audit.summary) console.log(`[seo] ${audit.summary}`); if (nb) console.log("[seo] blocking:\n - " + audit.blocking.join("\n - ")); process.exit(audit.verdict === "pass" ? 0 : audit.verdict === "revise" ? 2 : 3); }