#!/usr/bin/env node /** * Zet een nieuw review-conceptbestand + screenshot klaar op basis van alleen een URL. * * Gebruik: * ANTHROPIC_API_KEY=... npm run new-review -- [slug] * (of: ANTHROPIC_API_KEY in .env zetten — zie .env.example — en dan gewoon * `npm run new-review -- [slug]`, of `make review URL= [SLUG=slug]` * om na afloop meteen vim te openen op het net aangemaakte bestand) * * Wat het doet: * 1. Screenshot van de site (Playwright) -> src/assets/reviews//screenshot.png * (de reviewpagina toont deze automatisch als hero bovenaan, gelinkt naar de * website, zodra het bestand bestaat — zie reviews/[slug].astro) * 2. Titel/meta/zichtbare tekst van de site scrapen * 3. Claude (Haiku) laten samenvatten tot titel/bedrijfsnaam/categorie (één woord, * bepaalt ook de coverkleur en de sectie op /categorieen)/doelgroep/prijsindicatie * — met expliciete instructie om niets te verzinnen, onbekend = leeg veld * 4. Claude laat ook een concepttekst schrijven voor de secties "Voor wie", * "Wie zit erachter" en "Prijs" (gebaseerd op de site-tekst, gemarkeerd * als AI-concept) * 5. Nieuw src/content/reviews/.md wegschrijven vanuit _template.md, met die velden ingevuld * * Wat het BEWUST NIET automatisch invult: * score, verdict, "Wat is het" en "Hands-on indruk" — dat is jouw eigen testervaring * na het zelf proberen, niet iets wat een script (of AI die de website leest) kan * beoordelen. Alle AI-ingevulde velden/secties krijgen een "AI-concept, controleren!"- * markering: check ze na. */ import { chromium } from "playwright"; import Anthropic from "@anthropic-ai/sdk"; import { mkdir, readFile, writeFile, access, readdir } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const projectRoot = path.resolve(__dirname, ".."); function slugify(hostname) { return hostname .replace(/^www\./, "") .replace(/\.(com|nl|ai|io|app|co)$/i, "") .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/(^-|-$)/g, ""); } /** YYYY-MM-DD in lokale tijd (middaguur om DST-randen te vermijden). */ function formatYmd(d) { const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, "0"); const day = String(d.getDate()).padStart(2, "0"); return `${y}-${m}-${day}`; } function parseYmd(s) { const [y, m, d] = s.split("-").map(Number); return new Date(y, m - 1, d, 12, 0, 0, 0); } /** Schuif door tot de eerstvolgende vrijdag (blijft staan als 't al vrijdag is). */ function toFriday(d) { const out = new Date(d); const day = out.getDay(); // 0 zo … 5 vr if (day !== 5) out.setDate(out.getDate() + ((5 - day + 7) % 7)); return out; } /** * Publicatiedatum voor een nieuw concept: laatste reviewdatum + 1 week, * afgerond naar vrijdag (wekelijkse cadans). Geen eerdere datum? → eerstvolgende vrijdag. */ async function nextPublishDate() { const dir = path.join(projectRoot, "src", "content", "reviews"); const files = await readdir(dir); let latest = null; for (const file of files) { if (!file.endsWith(".md") || file.startsWith("_")) continue; const text = await readFile(path.join(dir, file), "utf-8"); // Alleen een echte frontmatter-date: "date: 2026-07-31" (geen commentaarregel) const match = text.match(/^---\r?\n[\s\S]*?^date:\s*(\d{4}-\d{2}-\d{2})\b/m); if (!match) continue; if (!latest || match[1] > latest) latest = match[1]; } if (latest) { const d = parseYmd(latest); d.setDate(d.getDate() + 7); return formatYmd(toFriday(d)); } return formatYmd(toFriday(new Date())); } function fillField(template, field, value) { // Laat de regel (met het voorbeeld in de comment) ongewijzigd als er niets gevonden is, // zodat de gebruiker nog steeds ziet wat er verwacht wordt en het zelf kan invullen. if (!value) return template; const re = new RegExp(`^${field}: ""(\\s*#.*)?$`, "m"); return template.replace(re, `${field}: ${JSON.stringify(value)} # AI-concept, controleren!`); } function fillSection(template, heading, placeholderComment, prose) { // Zelfde idee als fillField, maar dan voor een hele body-sectie: als er niets // gevonden is, blijft de originele hint-comment gewoon staan voor handmatig invullen. if (!prose) return template; const search = `## ${heading}\n\n`; const replacement = `## ${heading}\n\n${prose}\n\n`; return template.replace(search, replacement); } async function main() { const [, , urlArg, slugArg] = process.argv; if (!urlArg) { console.error("Gebruik: npm run new-review -- [slug]"); process.exit(1); } let url; try { url = new URL(urlArg).toString(); } catch { console.error(`Ongeldige URL: ${urlArg}`); process.exit(1); } const slug = slugArg || slugify(new URL(url).hostname); const contentPath = path.join(projectRoot, "src", "content", "reviews", `${slug}.md`); try { await access(contentPath); console.error( `Bestaat al: ${path.relative(projectRoot, contentPath)} — kies een andere slug of verwijder 'm eerst.`, ); process.exit(1); } catch { // bestaat nog niet, prima } if (!process.env.ANTHROPIC_API_KEY) { console.error("ANTHROPIC_API_KEY niet gezet. Voorbeeld:"); console.error(` ANTHROPIC_API_KEY=sk-ant-... npm run new-review -- ${urlArg}`); process.exit(1); } console.error(`Site laden: ${url}`); const browser = await chromium.launch(); const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); try { await page.goto(url, { waitUntil: "networkidle", timeout: 30_000 }); } catch { await page.goto(url, { waitUntil: "load", timeout: 30_000 }); } const outDir = path.join(projectRoot, "src", "assets", "reviews", slug); await mkdir(outDir, { recursive: true }); const screenshotPath = path.join(outDir, "screenshot.png"); await page.screenshot({ path: screenshotPath }); console.error(`Screenshot: ${path.relative(projectRoot, screenshotPath)}`); const pageTitle = await page.title(); const metaDescription = await page .locator('meta[name="description"]') .first() .getAttribute("content") .catch(() => null); const bodyText = (await page.locator("body").innerText()).slice(0, 8000); await browser.close(); console.error("Claude laten meekijken voor de basisinfo..."); const anthropic = new Anthropic(); const extraction = await anthropic.messages.create({ model: "claude-haiku-4-5-20251001", max_tokens: 2048, tools: [ { name: "record_tool_info", description: "Leg de basisinformatie van deze tool vast voor een reviewtemplate.", input_schema: { type: "object", properties: { title: { type: "string", description: "Nederlandse review-titel in de stijl 'Bedrijfsnaam: wat het doet in één zin'", }, company: { type: "string", description: "Naam van het bedrijf/product" }, location: { type: "string", description: "Vestigingsplaats, ALLEEN als die letterlijk ergens op de site staat, anders lege string", }, category: { type: "string", description: "Categorie in ÉÉN Nederlands woord (bepaalt ook de sectie op de categorieënpagina), bv. 'Klantenservice', 'Boekhouding', 'Marketing', 'Juridisch'. Geen meerwoordige omschrijvingen.", }, for_who: { type: "string", description: "Korte typering van de doelgroep, gebaseerd op de site-tekst" }, price_from: { type: "string", description: "Laagste genoemde prijs incl. periode, bv. '€39 /maand', ALLEEN als een bedrag op de site staat, anders lege string", }, hosting: { type: "string", description: "Korte typering van hosting/privacy ALLEEN als expliciet vermeld (bv. 'EU, GDPR-verklaring'), anders lege string", }, for_who_prose: { type: "string", description: "1-3 zinnen Nederlandse lopende tekst voor de sectie 'Voor wie': welk type gebruiker/bedrijf dit relevant vindt, en voor wie juist niet. Gebaseerd op de site-tekst, niet verzinnen. Lege string als er te weinig te zeggen valt.", }, company_info_prose: { type: "string", description: "1-3 zinnen Nederlandse lopende tekst voor de sectie 'Wie zit erachter': team/locatie/funding/omzet ALLEEN als dat ergens op de site staat. Lege string als er niets over te vinden is (niet gokken of aanvullen met algemene kennis).", }, price_prose: { type: "string", description: "1-3 zinnen Nederlandse lopende tekst voor de sectie 'Prijs': iets uitgebreider dan een los bedrag, bv. tiers, gratis laag, wat er wel/niet in zit — ALLEEN gebaseerd op wat er echt op de site staat. Lege string als er geen prijsinformatie te vinden is.", }, }, required: [ "title", "company", "location", "category", "for_who", "price_from", "hosting", "for_who_prose", "company_info_prose", "price_prose", ], }, }, ], tool_choice: { type: "tool", name: "record_tool_info" }, messages: [ { role: "user", content: "Hier is de inhoud van een bedrijfswebsite, voor een conceptreview op een AI-tool-blog. " + "Haal er alleen op wat er ECHT letterlijk staat, verzin nooit feiten (prijzen, locatie, " + "team). Onbekende/niet-vermelde velden: lege string, niet gokken. Voor de lopende-tekst-" + "velden (for_who_prose/company_info_prose/price_prose) mag je de site-tekst wel in eigen " + "woorden samenvatten, in de rustige/nuchtere toon van de rest van de site — maar blijf bij " + "wat de site zelf claimt, voeg geen eigen mening of hands-on-oordeel toe.\n\n" + `Paginatitel: ${pageTitle}\n` + `Meta-omschrijving: ${metaDescription ?? ""}\n\n` + `Zichtbare tekst van de pagina:\n${bodyText}`, }, ], }); const toolUse = extraction.content.find((block) => block.type === "tool_use"); if (!toolUse) { console.error("Kon geen structured info uit het Claude-antwoord halen."); process.exit(1); } const info = toolUse.input; const templatePath = path.join(projectRoot, "src", "content", "reviews", "_template.md"); let content = await readFile(templatePath, "utf-8"); // instructie-comment bovenaan het template hoort niet in een echt reviewbestand content = content.replace(/# Kopieer dit bestand[\s\S]*?koppeltekens\.\n\n/, ""); // Publicatiedatum: laatste bestaande review + 1 week, op vrijdag (cadans). // draft blijft true tot je zelf klaar bent; de datum staat al klaar voor planning. const publishDate = await nextPublishDate(); content = content.replace( /^date: ""(\s*#.*)?$/m, `date: ${publishDate} # auto: laatste review + 1 week (vrijdag)`, ); console.error(`Publicatiedatum: ${publishDate} (laatste review + 1 week, vrijdag)`); // score: 0 is ongeldig voor het Zod-schema (min(1)) — uitcommentariëren zodat // het veld afwezig is (optional zolang draft) maar de invulinstructie blijft. content = content.replace( /^score: 0(\s*#.*)?$/m, "# score: 3 # geheel getal 1-5 (aantal robots) — invullen vóórdat je draft: false zet", ); content = content.replace(/^website: ""(\s*#.*)?$/m, `website: ${JSON.stringify(url)}`); content = fillField(content, "title", info.title); content = fillField(content, "company", info.company); content = fillField(content, "location", info.location); content = fillField(content, "category", info.category); content = fillField(content, "for_who", info.for_who); content = fillField(content, "price_from", info.price_from); content = fillField(content, "hosting", info.hosting); content = fillSection( content, "Voor wie", "Voor welk type gebruiker/bedrijf is dit relevant, en voor wie juist niet.", info.for_who_prose, ); content = fillSection( content, "Wie zit erachter", "Team, locatie, funding/omzet indien bekend. Onbekend? Dat gewoon zo benoemen.", info.company_info_prose, ); content = fillSection( content, "Prijs", "Prijsmodel iets uitgebreider dan het specs-balkje: tiers, gratis laag, addertjes onder het gras.", info.price_prose, ); // Geen screenshot-markdown meer in de body: de reviewpagina toont 'm zelf al // automatisch als hero bovenaan (gelinkt naar de website) zodra // src/assets/reviews//screenshot.png bestaat, zie reviews/[slug].astro. await writeFile(contentPath, content, "utf-8"); console.error(`Concept aangemaakt: ${path.relative(projectRoot, contentPath)}`); console.error( "Nog met de hand te doen: elk 'AI-concept, controleren!'-veld/sectie naast lezen, en zelf 'Wat is het', score, verdict en hands-on indruk schrijven na het testen.", ); // enige stdout-regel, bedoeld om door scripts (bv. de Makefile) opgepakt te worden console.log(contentPath); } main();