Initial commit: Astro scaffold, review template, voorstelformulier, screenshot/AI-automatisering
This commit is contained in:
212
scripts/new-review.mjs
Normal file
212
scripts/new-review.mjs
Normal file
@@ -0,0 +1,212 @@
|
||||
#!/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
|
||||
* 2. Titel/meta/zichtbare tekst van de site scrapen
|
||||
* 3. Claude (Haiku) laten samenvatten tot titel/bedrijfsnaam/categorie/doelgroep/prijsindicatie
|
||||
* — met expliciete instructie om niets te verzinnen, onbekend = leeg veld
|
||||
* 4. Nieuw src/content/reviews/<slug>.md wegschrijven vanuit _template.md, met die velden ingevuld
|
||||
*
|
||||
* Wat het BEWUST NIET automatisch invult:
|
||||
* score, verdict, 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 krijgen een "# AI-concept, controleren!"-comment: 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!`);
|
||||
}
|
||||
|
||||
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: 1024,
|
||||
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: "Korte Nederlandse categorie, bv. 'Klantenservice', 'Boekhouding', 'Marketing'",
|
||||
},
|
||||
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",
|
||||
},
|
||||
},
|
||||
required: ["title", "company", "location", "category", "for_who", "price_from", "hosting"],
|
||||
},
|
||||
},
|
||||
],
|
||||
tool_choice: { type: "tool", name: "record_tool_info" },
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content:
|
||||
"Hier is de inhoud van een bedrijfswebsite. Haal er alleen op wat er ECHT letterlijk staat, " +
|
||||
"verzin nooit iets. Onbekende/niet-vermelde velden: lege string, niet gokken.\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 de regels helemaal weghalen, zodat het veld echt afwezig is en de
|
||||
// .optional() in het schema z'n werk kan doen.
|
||||
content = content.replace(/^date: ""\s*#.*\n/m, "");
|
||||
content = content.replace(/^score: 0\s*#.*\n/m, "");
|
||||
|
||||
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);
|
||||
|
||||
const companyForAlt = info.company || slug;
|
||||
content = content.replace(
|
||||
"## Wat is het\n\n<!--",
|
||||
`## Wat is het\n\n\n\n<!--`,
|
||||
);
|
||||
|
||||
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 naast lezen, en zelf score/verdict/hands-on indruk schrijven na het testen.",
|
||||
);
|
||||
|
||||
// enige stdout-regel, bedoeld om door scripts (bv. de Makefile) opgepakt te worden
|
||||
console.log(contentPath);
|
||||
}
|
||||
|
||||
main();
|
||||
Reference in New Issue
Block a user