Adopt the agents/ architecture proven on medellin.co (reference impl): - Move the content engine to a top-level agents/ dir: orchestrators, prompts, config, run.sh, admin console, shared libs. All content-pipeline literals repointed (config paths, scripts, admin, LLM-facing prompts/image.md string, configure.mjs, new-site.sh, astroagent tokenFile, .gitignore runtime block). - Every script carries a parseable @agent-manifest header: name, title, class (content|operational|runtime|plumbing), trigger, model, prompts, skills (MCP), tools, reads/writes tables. 5 content agents + 3 plumbing scripts. - New agents/catalog.mjs generates the catalog from the headers: agents/AGENTS.md (human, grouped by class) + agents/agents.json (machine manifest — a clone diffs it against a source to find missing tools/tables/MCP before running). configure.mjs regenerates the catalog on every identity stamp. No DB table, no watcher. - config.json gains paths.stateDir/newsDir; publish-tick, write-daily, and news-radar read them instead of hardcoding. - Full cut: content-pipeline/ deleted (the seed has no live crons, so no hybrid period needed). Docs updated (AGENTS.md structure + pipeline section, README paths). Clones migrating from content-pipeline/: see medellin.co's .memory/handoffs/agents-directory-migration.md for the cutover playbook (one cron set active at a time; migrate drafts/state after repointing cron). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FMQeUnUrAeexcZ7P2Hxa6G
128 lines
5 KiB
JavaScript
128 lines
5 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* @agent-manifest
|
|
* name: seo-review
|
|
* title: SEO Reviewer
|
|
* class: content
|
|
* trigger: manual <slug> (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 <postDir>/seo-review.json. Usage:
|
|
// node scripts/seo-review.mjs <slug> [--published]
|
|
// (default: review the draft in agents/drafts/<slug>; --published reviews the
|
|
// live post in app/src/content/blog/<slug>)
|
|
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 <slug> [--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);
|
|
}
|