Files
aitestbank/scripts/new-review.mjs
Menno 96c51baba0 Categorieënpagina, screenshot-hero, emoji/vlaggen, auteur-byline en publicatie-planning
- Categorie automatisch gekoppeld aan een vaste dekkleur (geen coverHue-veld meer nodig), /categorieen groepeert alle gepubliceerde reviews en categorieën zijn overal klikbaar
- Screenshot verschijnt als gelinkte hero tussen specs en body i.p.v. los in de Markdown-tekst; afbeeldingen in de body breken nu uit de tekstkolom
- :shortcode:-emoji en :flag_xx:-landvlaggen werken zowel in de Markdown-body als in frontmatter-tekstvelden
- Zichtbare "Door Menno"-byline + auteurs-metatag, adres via src/lib/site.ts
- Reviews met draft: false en een datum in de toekomst blijven volledig verborgen (ook niet als placeholder) tot na een build ná die datum; reviewpagina krijgt dan noindex
- make deploy-target (build + rsync naar de server)
- CGI-formulier gebruikt nu het net opgezette aitestbank.nl-maildomein i.p.v. een tijdelijk adres
2026-07-24 16:04:10 +02:00

284 lines
12 KiB
JavaScript

#!/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 -- <url> [slug]
* (of: ANTHROPIC_API_KEY in .env zetten — zie .env.example — en dan gewoon
* `npm run new-review -- <url> [slug]`, of `make review URL=<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/<slug>/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/<slug>.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 } 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, "");
}
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<!-- ${placeholderComment} -->`;
const replacement = `## ${heading}\n\n${prose}\n\n<!-- AI-concept, controleren! -->`;
return template.replace(search, replacement);
}
async function main() {
const [, , urlArg, slugArg] = process.argv;
if (!urlArg) {
console.error("Gebruik: npm run new-review -- <url> [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/, "");
// date/score horen pas een echte waarde te krijgen bij het publiceren. Het
// template zet ze op respectievelijk "" en 0 als leesbaar voorbeeld voor de
// handmatige workflow, maar allebei zijn dat *ongeldige* waarden voor het
// Zod-schema (date: "" faalt de date-coercion, score: 0 faalt min(1)) — dus
// hier uitcommentariëren i.p.v. laten staan, zodat het veld echt afwezig is
// (de .optional() in het schema kan er dan mee uit de voeten) maar er wél
// zichtbaar blijft staan HOE je 'm moet invullen zodra je gaat publiceren.
content = content.replace(
/^date: ""(\s*#.*)?$/m,
"# date: 2026-01-01 # invullen zodra je draft: false zet",
);
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/<slug>/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();