seedproject-web/content-pipeline/scripts/write-daily.mjs
Carlos Arias 1559ce017d chore: scaffold SeedProject base (Phase 1)
Clean-room copy of the reusable engines from comiida, with all
instance data, secrets, dependencies, and build output excluded:
- app/         Astro theme skeleton (no comiida blog posts; hero image -> placeholder)
- api/         SeedProject PHP framework (no vendor/.env/config.php)
- content-pipeline/  engine only (scripts/admin/prompts; empty runtime state)
- astroagent.config.json + app/.astroagent/skills

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SYHWLHihq3v9nxNwoPCKSn
2026-07-04 22:53:10 +00:00

199 lines
8.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
// Daily writer: draft one article (MDX + cover.jpg + sources.json) into drafts/<slug>/.
// Selection order: today's news (news radar) first → else the next evergreen calendar topic.
// Refills the evergreen backlog on demand when it runs low. All calendar writes are atomic.
// Usage: node content-pipeline/scripts/write-daily.mjs [slug]
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { dirname, resolve, join } from "node:path";
import { fileURLToPath } from "node:url";
import { runClaude } from "./lib/claude.mjs";
import { loadJson, todayInTz } from "./lib/util.mjs";
import { readCalendar, updateEntry, addEntry } from "./lib/calendar.mjs";
import { runSeoReview } from "./seo-review.mjs";
import { runRevise } from "./revise.mjs";
import { runNewsRadar, pickFreshNews, markNewsUsed } from "./news-radar.mjs";
import { runResearch } from "./research.mjs";
const HERE = dirname(fileURLToPath(import.meta.url));
const PIPELINE = resolve(HERE, "..");
const cfg = loadJson(join(PIPELINE, "config.json"));
const root = cfg.paths.projectRoot;
if (cfg.author.slug === "REPLACE_ME") {
console.error("[write-daily] config.author is not set. Add the real author first.");
process.exit(1);
}
const calendarPath = join(root, cfg.paths.calendar);
const today = todayInTz(cfg.editorial.timezone);
const stateDir = join(root, "content-pipeline", "state");
const argSlug = process.argv[2];
let entry;
if (argSlug) {
entry = readCalendar(calendarPath).find((e) => e.slug === argSlug);
if (!entry) {
console.error(`[write-daily] slug not found in calendar: ${argSlug}`);
process.exit(1);
}
} else {
// 0) User suggestions (from the admin dashboard) take top priority.
const suggestion = readCalendar(calendarPath)
.filter((e) => e.suggested && e.status === "planned")
.sort((a, b) => (a.date || "").localeCompare(b.date || ""))[0];
if (suggestion) {
entry = suggestion;
console.log(`[write-daily] suggestion-first: "${entry.workingTitle}"`);
} else {
mkdirSync(stateDir, { recursive: true });
// 1) Ensure today's news scan ran (guarded once/day so the cadence doesn't matter).
if (cfg.news?.enabled) {
const newsStatePath = join(stateDir, `news-${today}.json`);
if (!existsSync(newsStatePath)) {
try {
await runNewsRadar();
} catch (e) {
console.log(`[write-daily] news radar error (continuing): ${e.message}`);
}
writeFileSync(newsStatePath, JSON.stringify({ scanned: today }, null, 2));
}
}
// 2) Demand-driven evergreen refill when the backlog is low (guarded once/day).
const threshold = cfg.editorial.refillThreshold ?? 7;
const plannedCount = readCalendar(calendarPath).filter((e) => e.status === "planned").length;
const refillStatePath = join(stateDir, `research-${today}.json`);
if (plannedCount < threshold && !existsSync(refillStatePath)) {
console.log(`[write-daily] evergreen backlog low (${plannedCount} < ${threshold}) — refilling…`);
try {
const added = await runResearch({ count: cfg.editorial.refillCount ?? 14 });
console.log(`[write-daily] refill added ${added} topics.`);
} catch (e) {
console.log(`[write-daily] refill error (continuing): ${e.message}`);
}
writeFileSync(refillStatePath, JSON.stringify({ refilled: today }, null, 2));
}
// 3) News-first selection; inject the chosen news item as a calendar entry. Else evergreen.
const news = pickFreshNews(cfg);
if (news) {
entry = {
date: today,
slug: news.slug,
workingTitle: news.workingTitle,
type: news.type || "news-roundup",
primaryKeyword: news.primaryKeyword,
secondaryKeywords: news.secondaryKeywords || [],
searchIntent: news.searchIntent || "informational",
audienceAngle: news.newsHook || "",
sourceHints: news.sourceLinks || [],
eeatAngle: "Timely, sourced news — cite every claim.",
news: true,
freshnessScore: news.freshnessScore ?? 3,
status: "planned",
};
await addEntry(calendarPath, entry);
markNewsUsed(news.slug);
console.log(`[write-daily] news-first: "${entry.workingTitle}" (freshness ${entry.freshnessScore})`);
} else {
const cal = readCalendar(calendarPath);
const planned = cal.filter((e) => e.status === "planned");
const due = planned.filter((e) => e.date <= today).sort((a, b) => a.date.localeCompare(b.date));
entry = due[0] || planned.sort((a, b) => a.date.localeCompare(b.date))[0];
}
}
}
if (!entry) {
console.log("[write-daily] Nothing left to write — no fresh news and no planned topics.");
process.exit(0);
}
const draftDir = join(root, cfg.paths.draftsDir, entry.slug);
mkdirSync(draftDir, { recursive: true });
const [wMin, wMax] = cfg.editorial.wordCounts[entry.type] || [800, 1200];
const blogDir = join(root, cfg.paths.blogContentDir);
const logFile = join(root, cfg.paths.logsDir, `write-${entry.slug}.log`);
const prompt = `Write today's Comiida article from this calendar entry:
${JSON.stringify({ ...entry, draft: undefined, instructions: undefined }, null, 2)}
- Today's date: ${today}
- Author slug to byline (exact): ${cfg.author.slug}
- Word count range for type "${entry.type}": ${wMin}${wMax} words
- Draft output directory (write index.mdx, cover.jpg, sources.json here): ${draftDir}
- Existing published posts to read for internal links: ${blogDir}
- Image: use the Higgsfield MCP (preferred model "${cfg.image.preferredModel}", aspect ratio
~${cfg.image.aspectRatio}). Read content-pipeline/prompts/image.md and follow that flow
(recommend model → generate_image → job_status sync → curl the result URL).
Save the final image to: ${join(draftDir, "cover.jpg")}.
${
entry.authorFirsthand
? `\nAUTHOR FIRST-HAND PIECE: this is ${cfg.author.name}'s own draft, written from genuine first-hand experience. Follow the "Author first-hand pieces" EXCEPTION in your system prompt — keep the FIRST-PERSON voice and the author's honest opinions (do not neutralize them), and only fact-check/cite objective specifics (addresses, opening dates, prices, hours). SCOPE: the article is about the venues and points the author actually names — cover those; do NOT pad the piece with generic restaurants the author didn't mention just to hit a word count. Aim for the LOWER end of the word-count range; a tight, genuine ~8001000 words beats bloated filler.\n`
: ""
}${
entry.instructions
? `\nEDITOR INSTRUCTIONS (from the person who suggested this — follow them carefully):\n${entry.instructions}\n`
: ""
}${
entry.draft
? `\nEDITOR-PROVIDED DRAFT — use this as the basis for the article. Keep its intent and key points, but fact-check every claim, add real citations, improve structure/SEO, and expand it to meet the contract:\n"""\n${entry.draft}\n"""\n`
: ""
}
Follow the EEAT/SEO contract in your system prompt exactly.`;
await updateEntry(calendarPath, entry.slug, { status: "drafting" });
console.log(`[write-daily] drafting "${entry.workingTitle}" (${entry.type}) → ${draftDir}`);
const res = await runClaude({
prompt,
systemPromptFile: join(PIPELINE, "prompts/writer.system.md"),
model: cfg.models.writer,
// Higgsfield is configured globally via claude.ai (reachable headless) — no local --mcp-config.
allowedTools: "Read Write Edit Glob Grep WebSearch Bash mcp__claude_ai_Higgsfield",
addDirs: [root],
cwd: root,
logFile,
});
console.log(`[write-daily] agent: ${res.result || "(no text)"}`);
const hasMdx = existsSync(join(draftDir, "index.mdx"));
const hasCover = existsSync(join(draftDir, "cover.jpg"));
const finalStatus = hasMdx ? (hasCover ? "drafted" : "drafted-no-image") : "draft-failed";
await updateEntry(calendarPath, entry.slug, { status: finalStatus });
console.log(
`[write-daily] status=${finalStatus} mdx=${hasMdx} cover=${hasCover}\n` +
`[write-daily] review: ${draftDir} | log: ${logFile}`
);
if (!hasMdx) process.exit(1);
// SEO Specialist pass + auto-revise loop, so the approve/publish gate has a verdict.
try {
let audit = await runSeoReview(entry.slug, { published: false });
const minScore = cfg.seo?.minScore ?? 85;
const fails = (a) => !a || a.verdict === "fail" || (a.overall ?? 0) < minScore;
if (audit && cfg.seo?.autoRevise && fails(audit)) {
console.log(`[write-daily] SEO ${audit.verdict} ${audit.overall} < ${minScore} — auto-revising…`);
const { audit: revised, attempts } = await runRevise(entry.slug, {});
if (revised) audit = revised;
console.log(`[write-daily] auto-revise done after ${attempts} attempt(s): ${audit?.verdict} ${audit?.overall}`);
}
if (audit) {
await updateEntry(calendarPath, entry.slug, { seo: { verdict: audit.verdict, overall: audit.overall } });
console.log(
`[write-daily] SEO: ${audit.verdict} · ${audit.overall} · ${(audit.blocking || []).length} blocking`
);
} else {
console.log("[write-daily] SEO: audit could not be generated (continuing).");
}
} catch (e) {
console.log(`[write-daily] SEO review error (continuing): ${e.message}`);
}