diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs
new file mode 100644
index 00000000..ca6466df
--- /dev/null
+++ b/.git-blame-ignore-revs
@@ -0,0 +1,2 @@
+# Bulk reformats. `git config blame.ignoreRevsFile .git-blame-ignore-revs`
+255c58d0436bf36964f8e58ceeebdb062c5e10cf # prettier, 399 files
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index a6464165..554fa373 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -11,7 +11,7 @@
version: 2
updates:
- package-ecosystem: npm
- directory: "/"
+ directory: '/'
schedule:
interval: weekly
open-pull-requests-limit: 5
@@ -20,7 +20,7 @@ updates:
update-types: [minor, patch]
- package-ecosystem: github-actions
- directory: "/"
+ directory: '/'
schedule:
interval: weekly
open-pull-requests-limit: 3
diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 00000000..1ed5ea51
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1,21 @@
+# Build output and vendored trees — formatting these is noise.
+node_modules
+.next
+dist
+build
+out
+coverage
+.turbo
+.vercel
+*.min.js
+*.min.css
+
+# Lockfiles are generated; prettier would rewrite them wholesale.
+package-lock.json
+pnpm-lock.yaml
+yarn.lock
+
+# Markdown is deliberately out of scope for now. Prettier rewraps prose, which
+# is where it is most opinionated and least useful, and it would bury the real
+# diff. Remove this line when you want docs formatted too.
+*.md
diff --git a/.prettierrc b/.prettierrc
new file mode 100644
index 00000000..616247af
--- /dev/null
+++ b/.prettierrc
@@ -0,0 +1,9 @@
+{
+ "semi": true,
+ "singleQuote": true,
+ "printWidth": 100,
+ "tabWidth": 2,
+ "trailingComma": "all",
+ "arrowParens": "always",
+ "endOfLine": "lf"
+}
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 3fa50943..8e6a71fb 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -1,43 +1,46 @@
-import { defineConfig, globalIgnores } from "eslint/config";
-import nextVitals from "eslint-config-next/core-web-vitals";
-import nextTs from "eslint-config-next/typescript";
+import { defineConfig, globalIgnores } from 'eslint/config';
+import nextVitals from 'eslint-config-next/core-web-vitals';
+import nextTs from 'eslint-config-next/typescript';
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
{
rules: {
- "@typescript-eslint/no-unused-vars": ["warn", {
- argsIgnorePattern: "^_",
- varsIgnorePattern: "^_",
- destructuredArrayIgnorePattern: "^_",
- }],
+ '@typescript-eslint/no-unused-vars': [
+ 'warn',
+ {
+ argsIgnorePattern: '^_',
+ varsIgnorePattern: '^_',
+ destructuredArrayIgnorePattern: '^_',
+ },
+ ],
},
},
// Pipeline/migration scripts — relax any-type rule.
// These one-off scripts manipulate JSONB blobs where exact types aren't needed.
{
- files: ["scripts/**/*.ts", "src/scripts/**/*.ts"],
+ files: ['scripts/**/*.ts', 'src/scripts/**/*.ts'],
rules: {
- "@typescript-eslint/no-explicit-any": "off",
+ '@typescript-eslint/no-explicit-any': 'off',
},
},
// Legacy JS scripts using CommonJS require() — exclude from ESM import rule.
{
- files: ["src/scripts/**/*.js"],
+ files: ['src/scripts/**/*.js'],
rules: {
- "@typescript-eslint/no-require-imports": "off",
+ '@typescript-eslint/no-require-imports': 'off',
},
},
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
- ".next/**",
- "out/**",
- "build/**",
- "next-env.d.ts",
+ '.next/**',
+ 'out/**',
+ 'build/**',
+ 'next-env.d.ts',
// Auto-generated file — never lint
- "src/lib/config/foundations/stiftungen-generated.ts",
+ 'src/lib/config/foundations/stiftungen-generated.ts',
]),
]);
diff --git a/messages/de.json b/messages/de.json
index c8036d02..a7abb565 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -14,30 +14,72 @@
},
"nav": {
"ueberUns": { "label": "Über uns" },
- "strategie": { "label": "Mission & Strategie", "desc": "Warum wir existieren & was wir erreichen wollen" },
+ "strategie": {
+ "label": "Mission & Strategie",
+ "desc": "Warum wir existieren & was wir erreichen wollen"
+ },
"team": { "label": "Team", "desc": "Wer wir sind & wie wir arbeiten" },
- "wieWirArbeiten": { "label": "Wie wir arbeiten", "desc": "Die Wertschöpfungskaskade — maximale Wertschöpfung pro Gerät" },
- "finanzen": { "label": "Finanzen {range}", "desc": "Transparente Einnahmen & Ausgaben ({label})" },
- "wirkung": { "label": "Wirkung & Impact", "desc": "CO₂ eingespart, Geräte refurbiert, Menschen erreicht" },
- "preismodell": { "label": "Preismodell", "desc": "Solidarische Preise (4 Stufen nach Einkommen)" },
+ "wieWirArbeiten": {
+ "label": "Wie wir arbeiten",
+ "desc": "Die Wertschöpfungskaskade — maximale Wertschöpfung pro Gerät"
+ },
+ "finanzen": {
+ "label": "Finanzen {range}",
+ "desc": "Transparente Einnahmen & Ausgaben ({label})"
+ },
+ "wirkung": {
+ "label": "Wirkung & Impact",
+ "desc": "CO₂ eingespart, Geräte refurbiert, Menschen erreicht"
+ },
+ "preismodell": {
+ "label": "Preismodell",
+ "desc": "Solidarische Preise (4 Stufen nach Einkommen)"
+ },
"methodik": { "label": "Methodik", "desc": "Wie wir messen & berechnen (Transparenzreport)" },
"zukunft": { "label": "Zukunft 2030" },
"vision": { "label": "Vision" },
- "revamp2030": { "label": "Revamp 2030 — Die Vision", "desc": "Wo wir 2030 stehen wollen: Hub, Bildung, Impact" },
+ "revamp2030": {
+ "label": "Revamp 2030 — Die Vision",
+ "desc": "Wo wir 2030 stehen wollen: Hub, Bildung, Impact"
+ },
"projekte": { "label": "Konkrete Projekte (2026–2028)" },
"hub": { "label": "Hub {space}", "desc": "🏢 Werkstatt + AI Lab + Hackerspace + Kulturraum" },
- "bildung": { "label": "Bildungsprogramm", "desc": "🎓 2× Programmleiter für Train-the-Trainer & Workshops" },
+ "bildung": {
+ "label": "Bildungsprogramm",
+ "desc": "🎓 2× Programmleiter für Train-the-Trainer & Workshops"
+ },
"fundraising": { "label": "Fundraising" },
"budgetStatus": { "label": "Budget & Status" },
- "plan": { "label": "Fundraising-Plan", "desc": "📊 Budget-Übersicht, Finanzierungsbedarf, Timeline" },
- "dashboard": { "label": "Pipeline-Dashboard", "desc": "📈 KPI-Übersicht: Eingereicht, Zugesagt, Erfolgsquote" },
+ "plan": {
+ "label": "Fundraising-Plan",
+ "desc": "📊 Budget-Übersicht, Finanzierungsbedarf, Timeline"
+ },
+ "dashboard": {
+ "label": "Pipeline-Dashboard",
+ "desc": "📈 KPI-Übersicht: Eingereicht, Zugesagt, Erfolgsquote"
+ },
"stiftungenSection": { "label": "Stiftungen" },
- "stiftungen": { "label": "{count} Stiftungen", "desc": "🔍 Mit Fit-Score, Themen, Deadlines, Beträgen" },
- "vorlagen": { "label": "Gesuch-Vorlagen", "desc": "📝 {count} Referenz-Vorlagen nach Typ (A/B/C/D)" },
+ "stiftungen": {
+ "label": "{count} Stiftungen",
+ "desc": "🔍 Mit Fit-Score, Themen, Deadlines, Beträgen"
+ },
+ "vorlagen": {
+ "label": "Gesuch-Vorlagen",
+ "desc": "📝 {count} Referenz-Vorlagen nach Typ (A/B/C/D)"
+ },
"gesuche": { "label": "Meine Gesuche", "desc": "📝 Bearbeitete Gesuch-Entwürfe im Überblick" },
- "pipeline": { "label": "Gesuch-Pipeline", "desc": "📋 Stiftungsgesuche verwalten und nachverfolgen" },
- "scoring": { "label": "Scoring-Methodik", "desc": "⚙️ Wie Fit, Bereitschaft und Priorität berechnet werden" },
- "pipelineMethodik": { "label": "Pipeline-Methodik", "desc": "🔬 Wie wir aus {universe} Stiftungen die richtigen finden" },
+ "pipeline": {
+ "label": "Gesuch-Pipeline",
+ "desc": "📋 Stiftungsgesuche verwalten und nachverfolgen"
+ },
+ "scoring": {
+ "label": "Scoring-Methodik",
+ "desc": "⚙️ Wie Fit, Bereitschaft und Priorität berechnet werden"
+ },
+ "pipelineMethodik": {
+ "label": "Pipeline-Methodik",
+ "desc": "🔬 Wie wir aus {universe} Stiftungen die richtigen finden"
+ },
"dokumente": { "label": "Dokumente" },
"plattform": { "label": "Plattform" }
}
diff --git a/messages/en.json b/messages/en.json
index 4e134412..f731e4f7 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -14,30 +14,66 @@
},
"nav": {
"ueberUns": { "label": "About us" },
- "strategie": { "label": "Mission & strategy", "desc": "Why we exist & what we want to achieve" },
+ "strategie": {
+ "label": "Mission & strategy",
+ "desc": "Why we exist & what we want to achieve"
+ },
"team": { "label": "Team", "desc": "Who we are & how we work" },
- "wieWirArbeiten": { "label": "How we work", "desc": "The value cascade — maximum value per device" },
+ "wieWirArbeiten": {
+ "label": "How we work",
+ "desc": "The value cascade — maximum value per device"
+ },
"finanzen": { "label": "Finances {range}", "desc": "Transparent income & expenses ({label})" },
"wirkung": { "label": "Impact", "desc": "CO₂ saved, devices refurbished, people reached" },
"preismodell": { "label": "Pricing model", "desc": "Solidarity pricing (4 tiers by income)" },
- "methodik": { "label": "Methodology", "desc": "How we measure & calculate (transparency report)" },
+ "methodik": {
+ "label": "Methodology",
+ "desc": "How we measure & calculate (transparency report)"
+ },
"zukunft": { "label": "Future 2030" },
"vision": { "label": "Vision" },
- "revamp2030": { "label": "Revamp 2030 — the vision", "desc": "Where we want to be in 2030: hub, education, impact" },
+ "revamp2030": {
+ "label": "Revamp 2030 — the vision",
+ "desc": "Where we want to be in 2030: hub, education, impact"
+ },
"projekte": { "label": "Concrete projects (2026–2028)" },
- "hub": { "label": "Hub {space}", "desc": "🏢 Workshop + AI lab + hackerspace + cultural space" },
- "bildung": { "label": "Education program", "desc": "🎓 2× program leads for train-the-trainer & workshops" },
+ "hub": {
+ "label": "Hub {space}",
+ "desc": "🏢 Workshop + AI lab + hackerspace + cultural space"
+ },
+ "bildung": {
+ "label": "Education program",
+ "desc": "🎓 2× program leads for train-the-trainer & workshops"
+ },
"fundraising": { "label": "Fundraising" },
"budgetStatus": { "label": "Budget & status" },
"plan": { "label": "Fundraising plan", "desc": "📊 Budget overview, funding needs, timeline" },
- "dashboard": { "label": "Pipeline dashboard", "desc": "📈 KPI overview: submitted, awarded, success rate" },
+ "dashboard": {
+ "label": "Pipeline dashboard",
+ "desc": "📈 KPI overview: submitted, awarded, success rate"
+ },
"stiftungenSection": { "label": "Foundations" },
- "stiftungen": { "label": "{count} foundations", "desc": "🔍 With fit scores, themes, deadlines, amounts" },
- "vorlagen": { "label": "Application templates", "desc": "📝 {count} reference templates by type (A/B/C/D)" },
+ "stiftungen": {
+ "label": "{count} foundations",
+ "desc": "🔍 With fit scores, themes, deadlines, amounts"
+ },
+ "vorlagen": {
+ "label": "Application templates",
+ "desc": "📝 {count} reference templates by type (A/B/C/D)"
+ },
"gesuche": { "label": "My applications", "desc": "📝 Edited application drafts at a glance" },
- "pipeline": { "label": "Application pipeline", "desc": "📋 Manage and track foundation applications" },
- "scoring": { "label": "Scoring methodology", "desc": "⚙️ How fit, readiness, and priority are computed" },
- "pipelineMethodik": { "label": "Pipeline methodology", "desc": "🔬 How we find the right ones among {universe} foundations" },
+ "pipeline": {
+ "label": "Application pipeline",
+ "desc": "📋 Manage and track foundation applications"
+ },
+ "scoring": {
+ "label": "Scoring methodology",
+ "desc": "⚙️ How fit, readiness, and priority are computed"
+ },
+ "pipelineMethodik": {
+ "label": "Pipeline methodology",
+ "desc": "🔬 How we find the right ones among {universe} foundations"
+ },
"dokumente": { "label": "Documents" },
"plattform": { "label": "Platform" }
}
diff --git a/messages/fr.json b/messages/fr.json
index 83d228f1..4ff602dc 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -14,30 +14,78 @@
},
"nav": {
"ueberUns": { "label": "À propos" },
- "strategie": { "label": "Mission & stratégie", "desc": "Pourquoi nous existons & ce que nous voulons accomplir" },
+ "strategie": {
+ "label": "Mission & stratégie",
+ "desc": "Pourquoi nous existons & ce que nous voulons accomplir"
+ },
"team": { "label": "Équipe", "desc": "Qui nous sommes & comment nous travaillons" },
- "wieWirArbeiten": { "label": "Notre façon de travailler", "desc": "La cascade de valeur — valorisation maximale par appareil" },
- "finanzen": { "label": "Finances {range}", "desc": "Recettes & dépenses transparentes ({label})" },
- "wirkung": { "label": "Impact", "desc": "CO₂ économisé, appareils reconditionnés, personnes touchées" },
- "preismodell": { "label": "Modèle de prix", "desc": "Prix solidaires (4 niveaux selon le revenu)" },
- "methodik": { "label": "Méthodologie", "desc": "Comment nous mesurons & calculons (rapport de transparence)" },
+ "wieWirArbeiten": {
+ "label": "Notre façon de travailler",
+ "desc": "La cascade de valeur — valorisation maximale par appareil"
+ },
+ "finanzen": {
+ "label": "Finances {range}",
+ "desc": "Recettes & dépenses transparentes ({label})"
+ },
+ "wirkung": {
+ "label": "Impact",
+ "desc": "CO₂ économisé, appareils reconditionnés, personnes touchées"
+ },
+ "preismodell": {
+ "label": "Modèle de prix",
+ "desc": "Prix solidaires (4 niveaux selon le revenu)"
+ },
+ "methodik": {
+ "label": "Méthodologie",
+ "desc": "Comment nous mesurons & calculons (rapport de transparence)"
+ },
"zukunft": { "label": "Avenir 2030" },
"vision": { "label": "Vision" },
- "revamp2030": { "label": "Revamp 2030 — la vision", "desc": "Où nous voulons être en 2030 : hub, formation, impact" },
+ "revamp2030": {
+ "label": "Revamp 2030 — la vision",
+ "desc": "Où nous voulons être en 2030 : hub, formation, impact"
+ },
"projekte": { "label": "Projets concrets (2026–2028)" },
- "hub": { "label": "Hub {space}", "desc": "🏢 Atelier + AI lab + hackerspace + espace culturel" },
- "bildung": { "label": "Programme de formation", "desc": "🎓 2× responsables pour train-the-trainer & ateliers" },
+ "hub": {
+ "label": "Hub {space}",
+ "desc": "🏢 Atelier + AI lab + hackerspace + espace culturel"
+ },
+ "bildung": {
+ "label": "Programme de formation",
+ "desc": "🎓 2× responsables pour train-the-trainer & ateliers"
+ },
"fundraising": { "label": "Levée de fonds" },
"budgetStatus": { "label": "Budget & statut" },
- "plan": { "label": "Plan de levée de fonds", "desc": "📊 Vue d'ensemble du budget, besoins, calendrier" },
- "dashboard": { "label": "Tableau de bord", "desc": "📈 Vue KPI : soumis, accordés, taux de succès" },
+ "plan": {
+ "label": "Plan de levée de fonds",
+ "desc": "📊 Vue d'ensemble du budget, besoins, calendrier"
+ },
+ "dashboard": {
+ "label": "Tableau de bord",
+ "desc": "📈 Vue KPI : soumis, accordés, taux de succès"
+ },
"stiftungenSection": { "label": "Fondations" },
- "stiftungen": { "label": "{count} fondations", "desc": "🔍 Avec scores de fit, thèmes, délais, montants" },
- "vorlagen": { "label": "Modèles de demande", "desc": "📝 {count} modèles de référence par type (A/B/C/D)" },
+ "stiftungen": {
+ "label": "{count} fondations",
+ "desc": "🔍 Avec scores de fit, thèmes, délais, montants"
+ },
+ "vorlagen": {
+ "label": "Modèles de demande",
+ "desc": "📝 {count} modèles de référence par type (A/B/C/D)"
+ },
"gesuche": { "label": "Mes demandes", "desc": "📝 Brouillons de demandes en un coup d'œil" },
- "pipeline": { "label": "Pipeline des demandes", "desc": "📋 Gérer et suivre les demandes aux fondations" },
- "scoring": { "label": "Méthodologie de scoring", "desc": "⚙️ Comment fit, préparation et priorité sont calculés" },
- "pipelineMethodik": { "label": "Méthodologie du pipeline", "desc": "🔬 Comment trouver les bonnes parmi {universe} fondations" },
+ "pipeline": {
+ "label": "Pipeline des demandes",
+ "desc": "📋 Gérer et suivre les demandes aux fondations"
+ },
+ "scoring": {
+ "label": "Méthodologie de scoring",
+ "desc": "⚙️ Comment fit, préparation et priorité sont calculés"
+ },
+ "pipelineMethodik": {
+ "label": "Méthodologie du pipeline",
+ "desc": "🔬 Comment trouver les bonnes parmi {universe} fondations"
+ },
"dokumente": { "label": "Documents" },
"plattform": { "label": "Plateforme" }
}
diff --git a/next-env.d.ts b/next-env.d.ts
index ce4e94a6..644e5801 100644
--- a/next-env.d.ts
+++ b/next-env.d.ts
@@ -1,7 +1,7 @@
///
///
-import "./.next/types/routes.d.ts";
-import "./.next/types/root-params.d.ts";
+import './.next/types/routes.d.ts';
+import './.next/types/root-params.d.ts';
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/next.config.ts b/next.config.ts
index 1a215e8c..b3b5a297 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -1,46 +1,46 @@
-import type { NextConfig } from "next";
-import createNextIntlPlugin from "next-intl/plugin";
+import type { NextConfig } from 'next';
+import createNextIntlPlugin from 'next-intl/plugin';
-const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
+const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts');
// Security headers for every response (formerly in vercel.json — the app is
// self-hosted now, so Next.js itself must apply them; Caddy only proxies).
const SECURITY_HEADERS = [
- { key: "X-Content-Type-Options", value: "nosniff" },
- { key: "X-Frame-Options", value: "DENY" },
- { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
- { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
- { key: "X-DNS-Prefetch-Control", value: "on" },
+ { key: 'X-Content-Type-Options', value: 'nosniff' },
+ { key: 'X-Frame-Options', value: 'DENY' },
+ { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
+ { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
+ { key: 'X-DNS-Prefetch-Control', value: 'on' },
];
// Legacy /pages/* URL shapes from the pre-App-Router site (formerly vercel.json).
const LEGACY_REDIRECTS = [
- { source: "/pages/finanzen", destination: "/finanzen" },
- { source: "/pages/kennzahlen", destination: "/wirkung" },
- { source: "/kennzahlen", destination: "/wirkung" },
- { source: "/pages/wirkung", destination: "/wirkung" },
- { source: "/pages/methodik", destination: "/methodik" },
- { source: "/pages/transparenz", destination: "/methodik" },
- { source: "/transparenz", destination: "/methodik" },
- { source: "/pages/preismodell", destination: "/preismodell" },
- { source: "/pages/strategie", destination: "/strategie" },
- { source: "/pages/team", destination: "/team" },
- { source: "/pages/operations", destination: "/operations" },
- { source: "/pages/dokumente", destination: "/dokumente" },
- { source: "/pages/fundraising", destination: "/fundraising" },
- { source: "/pages/fundraising/stiftungen", destination: "/fundraising/stiftungen" },
- { source: "/pages/fundraising/stiftungen/:slug", destination: "/fundraising/stiftungen/:slug" },
- { source: "/en/platform", destination: "/plattform" },
+ { source: '/pages/finanzen', destination: '/finanzen' },
+ { source: '/pages/kennzahlen', destination: '/wirkung' },
+ { source: '/kennzahlen', destination: '/wirkung' },
+ { source: '/pages/wirkung', destination: '/wirkung' },
+ { source: '/pages/methodik', destination: '/methodik' },
+ { source: '/pages/transparenz', destination: '/methodik' },
+ { source: '/transparenz', destination: '/methodik' },
+ { source: '/pages/preismodell', destination: '/preismodell' },
+ { source: '/pages/strategie', destination: '/strategie' },
+ { source: '/pages/team', destination: '/team' },
+ { source: '/pages/operations', destination: '/operations' },
+ { source: '/pages/dokumente', destination: '/dokumente' },
+ { source: '/pages/fundraising', destination: '/fundraising' },
+ { source: '/pages/fundraising/stiftungen', destination: '/fundraising/stiftungen' },
+ { source: '/pages/fundraising/stiftungen/:slug', destination: '/fundraising/stiftungen/:slug' },
+ { source: '/en/platform', destination: '/plattform' },
].map((r) => ({ ...r, permanent: true }));
const nextConfig: NextConfig = {
- output: "standalone",
- serverExternalPackages: ["pg"],
+ output: 'standalone',
+ serverExternalPackages: ['pg'],
turbopack: {
root: __dirname,
},
async headers() {
- return [{ source: "/(.*)", headers: SECURITY_HEADERS }];
+ return [{ source: '/(.*)', headers: SECURITY_HEADERS }];
},
async redirects() {
return LEGACY_REDIRECTS;
diff --git a/package-lock.json b/package-lock.json
index 3b9e739f..87092681 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -38,6 +38,7 @@
"eslint": "^9",
"eslint-config-next": "16.3.3",
"pg": "^8.23.0",
+ "prettier": "3.9.6",
"tailwindcss": "^4",
"tsx": "^4.23.12",
"typescript": "^5",
@@ -8731,6 +8732,22 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/prettier": {
+ "version": "3.9.6",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz",
+ "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "prettier": "bin/prettier.cjs"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/prettier/prettier?sponsor=1"
+ }
+ },
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
diff --git a/package.json b/package.json
index 9de1e687..cda956c4 100644
--- a/package.json
+++ b/package.json
@@ -12,7 +12,7 @@
"test:watch": "vitest",
"lint": "eslint",
"typecheck": "tsc --noEmit",
- "verify": "npm run lint && npm run lint:umlauts && npm run typecheck && npm run test",
+ "verify": "npm run format:check && npm run lint && npm run lint:umlauts && npm run typecheck && npm run test",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push",
@@ -32,7 +32,9 @@
"pipeline:upsert": "tsx scripts/foundation-upsert.ts",
"zefix:download": "tsx scripts/zefix-download.ts",
"zefix:ingest": "tsx scripts/zefix-ingest.ts",
- "enrich:bulk": "tsx scripts/bulk-enrich.ts"
+ "enrich:bulk": "tsx scripts/bulk-enrich.ts",
+ "format": "prettier --write .",
+ "format:check": "prettier --check ."
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
@@ -65,6 +67,7 @@
"eslint": "^9",
"eslint-config-next": "16.3.3",
"pg": "^8.23.0",
+ "prettier": "3.9.6",
"tailwindcss": "^4",
"tsx": "^4.23.12",
"typescript": "^5",
diff --git a/postcss.config.mjs b/postcss.config.mjs
index 61e36849..297374d8 100644
--- a/postcss.config.mjs
+++ b/postcss.config.mjs
@@ -1,6 +1,6 @@
const config = {
plugins: {
- "@tailwindcss/postcss": {},
+ '@tailwindcss/postcss': {},
},
};
diff --git a/scripts/audit-pipeline.ts b/scripts/audit-pipeline.ts
index 4caebcca..624ad541 100644
--- a/scripts/audit-pipeline.ts
+++ b/scripts/audit-pipeline.ts
@@ -7,7 +7,8 @@
* This is the SSOT for all pipeline stats cited in CLAUDE.md and docs.
* Run it after any sync/rescore and copy the output to update those docs.
*/
-import { config } from 'dotenv'; config({ path: '.env.local' });
+import { config } from 'dotenv';
+config({ path: '.env.local' });
import { sql } from './lib/db';
import { getAllFoundations } from './lib/foundations';
import { hasGesuchPage } from '../src/lib/domain/foundation-helpers';
@@ -39,7 +40,13 @@ async function main() {
`;
// ── 2. Priority breakdown (sync-eligible only — matches generated file) ──
- const prios = await sql<{ priority: number; total: string | number; with_app_url: string | number; with_web_url: string | number; with_email: string | number }>`
+ const prios = await sql<{
+ priority: number;
+ total: string | number;
+ with_app_url: string | number;
+ with_web_url: string | number;
+ with_email: string | number;
+ }>`
SELECT
priority,
COUNT(*) AS total,
@@ -109,25 +116,53 @@ async function main() {
console.log(` deep (human-verified) .......... ${totals.depth_deep}`);
console.log(``);
- const p1 = prios.find(r => r.priority === 1) ?? { priority: 1, total: 0, with_app_url: 0, with_web_url: 0, with_email: 0 };
- const p2 = prios.find(r => r.priority === 2) ?? { priority: 2, total: 0, with_app_url: 0, with_web_url: 0, with_email: 0 };
- const p3 = prios.find(r => r.priority === 3) ?? { priority: 3, total: 0, with_app_url: 0, with_web_url: 0, with_email: 0 };
- const p4 = prios.find(r => r.priority === 4) ?? { priority: 4, total: 0, with_app_url: 0, with_web_url: 0, with_email: 0 };
+ const p1 = prios.find((r) => r.priority === 1) ?? {
+ priority: 1,
+ total: 0,
+ with_app_url: 0,
+ with_web_url: 0,
+ with_email: 0,
+ };
+ const p2 = prios.find((r) => r.priority === 2) ?? {
+ priority: 2,
+ total: 0,
+ with_app_url: 0,
+ with_web_url: 0,
+ with_email: 0,
+ };
+ const p3 = prios.find((r) => r.priority === 3) ?? {
+ priority: 3,
+ total: 0,
+ with_app_url: 0,
+ with_web_url: 0,
+ with_email: 0,
+ };
+ const p4 = prios.find((r) => r.priority === 4) ?? {
+ priority: 4,
+ total: 0,
+ with_app_url: 0,
+ with_web_url: 0,
+ with_email: 0,
+ };
const p13total = Number(p1.total) + Number(p2.total) + Number(p3.total);
const pct = (n: number | string, d: number | string) =>
- d === 0 ? '—' : `${Math.round(Number(n)/Number(d)*100)}%`;
+ d === 0 ? '—' : `${Math.round((Number(n) / Number(d)) * 100)}%`;
console.log(`PRIORITY BREAKDOWN (sync-eligible, non-archived)`);
- console.log(` ${''.padEnd(4)} ${'total'.padEnd(6)} ${'appUrl'.padEnd(12)} ${'webUrl'.padEnd(12)} ${'email'.padEnd(10)}`);
- console.log(` ${''.padEnd(4)} ${'-----'.padEnd(6)} ${'------'.padEnd(12)} ${'------'.padEnd(12)} ${'-----'.padEnd(10)}`);
- for (const r of [p1,p2,p3,p4]) {
- const label = `P${prios.indexOf(r)+1}`;
+ console.log(
+ ` ${''.padEnd(4)} ${'total'.padEnd(6)} ${'appUrl'.padEnd(12)} ${'webUrl'.padEnd(12)} ${'email'.padEnd(10)}`,
+ );
+ console.log(
+ ` ${''.padEnd(4)} ${'-----'.padEnd(6)} ${'------'.padEnd(12)} ${'------'.padEnd(12)} ${'-----'.padEnd(10)}`,
+ );
+ for (const r of [p1, p2, p3, p4]) {
+ const label = `P${prios.indexOf(r) + 1}`;
console.log(
` ${label.padEnd(4)} ${String(r.total).padEnd(6)}` +
- ` ${String(r.with_app_url).padEnd(4)} (${pct(r.with_app_url, r.total).padEnd(4)})` +
- ` ${String(r.with_web_url).padEnd(4)} (${pct(r.with_web_url, r.total).padEnd(4)})` +
- ` ${String(r.with_email).padEnd(4)} (${pct(r.with_email, r.total)})`
+ ` ${String(r.with_app_url).padEnd(4)} (${pct(r.with_app_url, r.total).padEnd(4)})` +
+ ` ${String(r.with_web_url).padEnd(4)} (${pct(r.with_web_url, r.total).padEnd(4)})` +
+ ` ${String(r.with_email).padEnd(4)} (${pct(r.with_email, r.total)})`,
);
}
console.log(` P1-P3 total: ${p13total}`);
@@ -137,7 +172,9 @@ async function main() {
if (emailGaps.length === 0) {
console.log(`EMAIL GAPS (truly unreachable) — none ✓`);
} else {
- console.log(`EMAIL GAPS (no email AND no appUrl) — ${emailGaps.length} P1-P3 truly unreachable`);
+ console.log(
+ `EMAIL GAPS (no email AND no appUrl) — ${emailGaps.length} P1-P3 truly unreachable`,
+ );
console.log(` Note: foundations with appUrl/online-form are reachable; not listed here.`);
for (const r of emailGaps) {
console.log(` P${r.priority} fit=${r.fit_score} | ${r.id} (method: ${r.method ?? '—'})`);
@@ -152,13 +189,17 @@ async function main() {
console.log(` ${''.padEnd(4)} ${'id'.padEnd(40)} ${'method'.padEnd(12)} website`);
console.log(` ${''.padEnd(4)} ${'--'.padEnd(40)} ${'------'.padEnd(12)} -------`);
for (const r of gaps) {
- const web = r.website ? (r.website as string).replace(/^https?:\/\//, '').slice(0, 40) : '(none)';
- console.log(` P${r.priority} ${String(r.id).padEnd(40)} ${String(r.method ?? '—').padEnd(12)} ${web}`);
+ const web = r.website
+ ? (r.website as string).replace(/^https?:\/\//, '').slice(0, 40)
+ : '(none)';
+ console.log(
+ ` P${r.priority} ${String(r.id).padEnd(40)} ${String(r.method ?? '—').padEnd(12)} ${web}`,
+ );
}
}
// ── 5. Gesuch quality ─────────────────────────────────────────────────────
- const gesuchFoundations = foundations.filter(f => hasGesuchPage(f));
+ const gesuchFoundations = foundations.filter((f) => hasGesuchPage(f));
let gesuchPerfect = 0;
const gesuchIssues: { p: number; issues: string[] }[] = [];
for (const f of gesuchFoundations) {
@@ -170,22 +211,24 @@ async function main() {
if (issues.length === 0) gesuchPerfect++;
else gesuchIssues.push({ p: computePriorityScore(f).level, issues });
}
- const gByP = [1,2,3].map(p => {
- const items = gesuchIssues.filter(i => i.p === p);
- const total = gesuchFoundations.filter(f => computePriorityScore(f).level === p).length;
+ const gByP = [1, 2, 3].map((p) => {
+ const items = gesuchIssues.filter((i) => i.p === p);
+ const total = gesuchFoundations.filter((f) => computePriorityScore(f).level === p).length;
return { p, issues: items.length, total };
});
console.log(`GESUCH QUALITY (from generated file)`);
- console.log(` Total gesuch pages: ${gesuchFoundations.length} (perfect: ${gesuchPerfect}, with issues: ${gesuchIssues.length})`);
+ console.log(
+ ` Total gesuch pages: ${gesuchFoundations.length} (perfect: ${gesuchPerfect}, with issues: ${gesuchIssues.length})`,
+ );
for (const g of gByP) {
const perfect = g.total - g.issues;
- const pct = g.total > 0 ? `${Math.round(perfect/g.total*100)}%` : '—';
+ const pct = g.total > 0 ? `${Math.round((perfect / g.total) * 100)}%` : '—';
console.log(` P${g.p}: ${perfect}/${g.total} perfect (${pct})`);
}
- const issueTypes = ['no-contact','thin-notes','thin-purpose','no-website'];
+ const issueTypes = ['no-contact', 'thin-notes', 'thin-purpose', 'no-website'];
for (const t of issueTypes) {
- const n = gesuchIssues.filter(i => i.issues.includes(t)).length;
+ const n = gesuchIssues.filter((i) => i.issues.includes(t)).length;
if (n > 0) console.log(` └─ ${t}: ${n}`);
}
console.log(``);
diff --git a/scripts/audit-themes.ts b/scripts/audit-themes.ts
index 1cef23e7..58eaaa46 100644
--- a/scripts/audit-themes.ts
+++ b/scripts/audit-themes.ts
@@ -24,7 +24,15 @@ interface ThemeDefinition {
const THEME_DEFINITIONS: ThemeDefinition[] = [
{
id: 'klima',
- keywords: ['klima', 'klimaschutz', 'co2', 'emission', 'treibhausgas', 'nachhaltigkeit', 'energie'],
+ keywords: [
+ 'klima',
+ 'klimaschutz',
+ 'co2',
+ 'emission',
+ 'treibhausgas',
+ 'nachhaltigkeit',
+ 'energie',
+ ],
excludeKeywords: [],
},
{
@@ -35,12 +43,30 @@ const THEME_DEFINITIONS: ThemeDefinition[] = [
{
id: 'soziale-integration',
// partizipation = direct synonym for Teilhabe in German social-integration contexts
- keywords: ['integration', 'sozial', 'benachteiligt', 'inklusion', 'gemeinschaft', 'teilhabe', 'zusammenleben', 'partizipation'],
+ keywords: [
+ 'integration',
+ 'sozial',
+ 'benachteiligt',
+ 'inklusion',
+ 'gemeinschaft',
+ 'teilhabe',
+ 'zusammenleben',
+ 'partizipation',
+ ],
excludeKeywords: [],
},
{
id: 'digitale-bildung',
- keywords: ['digital', 'bildung', 'schul', 'ausbildung', 'lern', 'pädagog', 'unterricht', 'medien'],
+ keywords: [
+ 'digital',
+ 'bildung',
+ 'schul',
+ 'ausbildung',
+ 'lern',
+ 'pädagog',
+ 'unterricht',
+ 'medien',
+ ],
excludeKeywords: ['medizinstudium', 'medizinische ausbildung'], // avoid medical school false positives; 'medizin' alone is too broad (fires on velux "medizinische Gebiete" which correctly has digitale-bildung)
},
{
@@ -57,7 +83,16 @@ const THEME_DEFINITIONS: ThemeDefinition[] = [
},
{
id: 'arbeitsintegration',
- keywords: ['arbeit', 'beschäftig', 'arbeitslos', 'beruf', 'job', 'arbeitsvermittl', 'arbeitseinglieder', 'arbeitsplätz'],
+ keywords: [
+ 'arbeit',
+ 'beschäftig',
+ 'arbeitslos',
+ 'beruf',
+ 'job',
+ 'arbeitsvermittl',
+ 'arbeitseinglieder',
+ 'arbeitsplätz',
+ ],
excludeKeywords: [],
},
];
@@ -73,7 +108,7 @@ interface SuspiciousAssignment {
}
function checkThemeMatch(theme: string, purpose: string): { matches: boolean; reason: string } {
- const themeDef = THEME_DEFINITIONS.find(t => t.id === theme);
+ const themeDef = THEME_DEFINITIONS.find((t) => t.id === theme);
if (!themeDef) {
return { matches: true, reason: 'Unknown theme (skipped)' };
}
@@ -84,34 +119,31 @@ function checkThemeMatch(theme: string, purpose: string): { matches: boolean; re
if (themeDef.excludeKeywords) {
for (const exclude of themeDef.excludeKeywords) {
if (purposeLower.includes(exclude.toLowerCase())) {
- return {
- matches: false,
- reason: `Contains excluded keyword '${exclude}'`
+ return {
+ matches: false,
+ reason: `Contains excluded keyword '${exclude}'`,
};
}
}
}
// Check if ANY keyword matches
- const matchedKeywords = themeDef.keywords.filter(kw =>
- purposeLower.includes(kw.toLowerCase())
- );
+ const matchedKeywords = themeDef.keywords.filter((kw) => purposeLower.includes(kw.toLowerCase()));
if (matchedKeywords.length === 0) {
- return {
- matches: false,
- reason: `No matching keywords (expected: ${themeDef.keywords.join(', ')})`
+ return {
+ matches: false,
+ reason: `No matching keywords (expected: ${themeDef.keywords.join(', ')})`,
};
}
- return {
- matches: true,
- reason: `Matched: ${matchedKeywords.join(', ')}`
+ return {
+ matches: true,
+ reason: `Matched: ${matchedKeywords.join(', ')}`,
};
}
async function auditThemes() {
-
console.log('\n📊 Auditing theme assignments for P1+P2 foundations...\n');
// Fetch P1 and P2 foundations with themes
@@ -162,7 +194,8 @@ async function auditThemes() {
const purposeSummary = (row.config_data?.purposeSummary ?? '') as string;
const researchNotes = (row.config_data?.researchNotes ?? '') as string;
const region = (row.config_data?.region ?? '') as string;
- const address = ((row.config_data?.contact as { address?: string } | null)?.address ?? '') as string;
+ const address = ((row.config_data?.contact as { address?: string } | null)?.address ??
+ '') as string;
const haystack = [officialPurpose, purposeSummary, researchNotes, region, address]
.filter(Boolean)
.join(' ');
@@ -183,8 +216,9 @@ async function auditThemes() {
priority: row.priority ?? 0,
theme,
reason: check.reason,
- officialPurpose: (purposeSummary || officialPurpose).slice(0, 200)
- + ((purposeSummary || officialPurpose).length > 200 ? '...' : ''),
+ officialPurpose:
+ (purposeSummary || officialPurpose).slice(0, 200) +
+ ((purposeSummary || officialPurpose).length > 200 ? '...' : ''),
allThemes: themes,
});
totalSuspicious++;
@@ -195,7 +229,9 @@ async function auditThemes() {
console.log(`✅ Audit complete:`);
console.log(` Total theme assignments: ${totalThemeAssignments}`);
console.log(` Suspicious assignments: ${totalSuspicious}`);
- console.log(` Suspicious rate: ${((totalSuspicious / totalThemeAssignments) * 100).toFixed(1)}%\n`);
+ console.log(
+ ` Suspicious rate: ${((totalSuspicious / totalThemeAssignments) * 100).toFixed(1)}%\n`,
+ );
if (suspicious.length === 0) {
console.log('🎉 No suspicious theme assignments found!\n');
@@ -214,7 +250,8 @@ async function auditThemes() {
console.log('📋 Suspicious assignments by theme:\n');
for (const [theme, items] of byTheme.entries()) {
console.log(`\n🔴 ${theme} (${items.length} suspicious):\n`);
- for (const item of items.slice(0, 3)) { // Show first 3 per theme
+ for (const item of items.slice(0, 3)) {
+ // Show first 3 per theme
console.log(` ${item.foundationName} (P${item.priority})`);
console.log(` ID: ${item.foundationId}`);
console.log(` All themes: ${item.allThemes.join(', ')}`);
diff --git a/scripts/auto-research.ts b/scripts/auto-research.ts
index f2f9574c..d5ea6f5a 100644
--- a/scripts/auto-research.ts
+++ b/scripts/auto-research.ts
@@ -34,7 +34,7 @@ import { ThemeId } from '../src/lib/schemas/foundation';
type ThemeIdType = z.infer;
const VALID_THEME_IDS: Set = new Set(ThemeId.options);
function filterValidThemes(ids: string[]): ThemeIdType[] {
- return ids.filter(t => VALID_THEME_IDS.has(t)) as ThemeIdType[];
+ return ids.filter((t) => VALID_THEME_IDS.has(t)) as ThemeIdType[];
}
// ============================================================================
@@ -42,15 +42,18 @@ function filterValidThemes(ids: string[]): ThemeIdType[] {
// ============================================================================
const args = process.argv.slice(2);
-const inputFile = args.find(a => !a.startsWith('--'));
+const inputFile = args.find((a) => !a.startsWith('--'));
const DRY_RUN = args.includes('--dry-run');
-const LIMIT = parseInt(args.find(a => a.startsWith('--limit='))?.split('=')[1] || '0', 10) || Infinity;
-const OFFSET = parseInt(args.find(a => a.startsWith('--offset='))?.split('=')[1] || '0', 10) || 0;
-const DELAY_MS = parseInt(args.find(a => a.startsWith('--delay='))?.split('=')[1] || '2000', 10);
-const MODEL_OVERRIDE = args.find(a => a.startsWith('--model='))?.split('=')[1];
+const LIMIT =
+ parseInt(args.find((a) => a.startsWith('--limit='))?.split('=')[1] || '0', 10) || Infinity;
+const OFFSET = parseInt(args.find((a) => a.startsWith('--offset='))?.split('=')[1] || '0', 10) || 0;
+const DELAY_MS = parseInt(args.find((a) => a.startsWith('--delay='))?.split('=')[1] || '2000', 10);
+const MODEL_OVERRIDE = args.find((a) => a.startsWith('--model='))?.split('=')[1];
if (!inputFile) {
- console.error('Usage: npx tsx scripts/auto-research.ts [--dry-run] [--limit=N] [--offset=N] [--model=]');
+ console.error(
+ 'Usage: npx tsx scripts/auto-research.ts [--dry-run] [--limit=N] [--offset=N] [--model=]',
+ );
process.exit(1);
}
@@ -83,7 +86,7 @@ interface TriageOutput {
// ============================================================================
const AVAILABLE_THEMES = Object.values(THEMES)
- .map(t => ` "${t.id}": ${t.label} — ${t.description}`)
+ .map((t) => ` "${t.id}": ${t.label} — ${t.description}`)
.join('\n');
const SYSTEM_PROMPT = `Du bist ein Schweizer Stiftungsexperte. Du analysierst Stiftungen und bewertest deren Passung für Revamp-IT, ein Sozialunternehmen in Zürich.
@@ -160,7 +163,11 @@ function buildUserPrompt(item: TriageItem, websiteContent: string | null): strin
// Draft builder — maps Groq output to ResearchDraft (compatible with upsert)
// ============================================================================
-function buildDraft(item: TriageItem, analysis: ResearchAnalysis, websiteContent: string | null): ResearchDraft {
+function buildDraft(
+ item: TriageItem,
+ analysis: ResearchAnalysis,
+ websiteContent: string | null,
+): ResearchDraft {
return {
slug: item.slug,
name: item.name,
@@ -209,7 +216,9 @@ async function main() {
const triage: TriageOutput = JSON.parse(fs.readFileSync(fullPath, 'utf-8'));
const allItems = triage.items.slice(OFFSET, OFFSET + LIMIT);
- console.log(`Auto-Research: ${allItems.length} foundations (offset=${OFFSET}, limit=${LIMIT === Infinity ? 'all' : LIMIT})`);
+ console.log(
+ `Auto-Research: ${allItems.length} foundations (offset=${OFFSET}, limit=${LIMIT === Infinity ? 'all' : LIMIT})`,
+ );
if (DRY_RUN) console.log(' DRY RUN — will process first 3 only, no files written');
const items = DRY_RUN ? allItems.slice(0, 3) : allItems;
@@ -231,7 +240,11 @@ async function main() {
const progress = `[${i + 1}/${items.length}]`;
// Skip if no website (can't fetch content, LLM has nothing to work with beyond ESA purpose)
- if (!item.websiteUrl || item.websiteUrl.includes('zefix.ch') || item.websiteUrl.includes('uid.admin.ch')) {
+ if (
+ !item.websiteUrl ||
+ item.websiteUrl.includes('zefix.ch') ||
+ item.websiteUrl.includes('uid.admin.ch')
+ ) {
if (item.officialPurpose && item.officialPurpose.length > 100) {
// ESA purpose is substantial enough to analyze without website
console.log(` ${progress} ${item.name} — no website, using ESA purpose only`);
@@ -283,7 +296,7 @@ async function main() {
// Parse and validate against ResearchAnalysisSchema
const parsed = ResearchAnalysisSchema.safeParse(cleaned);
if (!parsed.success) {
- const issues = parsed.error.issues.map(i => `${i.path.join('.')}: ${i.message}`).join('; ');
+ const issues = parsed.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; ');
console.error(` ${progress} ${item.name} — validation error: ${issues}`);
// Try to salvage: fill in missing fields with defaults
@@ -293,7 +306,12 @@ async function main() {
const draft = buildDraft(item, salvaged, websiteContent);
writeDraft(outDir, draft, DRY_RUN, progress);
success++;
- results.push({ slug: item.slug, status: 'salvaged', fitScore: salvaged.suggestedFit, priority: salvaged.suggestedPriority });
+ results.push({
+ slug: item.slug,
+ status: 'salvaged',
+ fitScore: salvaged.suggestedFit,
+ priority: salvaged.suggestedPriority,
+ });
} else {
errors++;
results.push({ slug: item.slug, status: `validation-error: ${issues}` });
@@ -307,11 +325,18 @@ async function main() {
writeDraft(outDir, draft, DRY_RUN, progress);
success++;
- results.push({ slug: item.slug, status: 'ok', fitScore: analysis.suggestedFit, priority: analysis.suggestedPriority });
+ results.push({
+ slug: item.slug,
+ status: 'ok',
+ fitScore: analysis.suggestedFit,
+ priority: analysis.suggestedPriority,
+ });
if (groqResult.usage) {
const tokens = groqResult.usage.prompt_tokens + groqResult.usage.completion_tokens;
- console.log(` → isFunder=${analysis.isFunder}, themes=[${analysis.themes.join(',')}], fit=${analysis.suggestedFit}, P${analysis.suggestedPriority} (${tokens} tokens)`);
+ console.log(
+ ` → isFunder=${analysis.isFunder}, themes=[${analysis.themes.join(',')}], fit=${analysis.suggestedFit}, P${analysis.suggestedPriority} (${tokens} tokens)`,
+ );
}
// Rate limit: don't hammer Groq
@@ -330,15 +355,22 @@ async function main() {
// Write summary file
const summaryFile = path.join(outDir, '_summary.json');
- fs.writeFileSync(summaryFile, JSON.stringify({
- date: today,
- source: inputFile,
- total: items.length,
- success,
- errors,
- skipped,
- results,
- }, null, 2));
+ fs.writeFileSync(
+ summaryFile,
+ JSON.stringify(
+ {
+ date: today,
+ source: inputFile,
+ total: items.length,
+ success,
+ errors,
+ skipped,
+ results,
+ },
+ null,
+ 2,
+ ),
+ );
}
}
@@ -365,7 +397,9 @@ function stripNulls(obj: Record): Record {
function writeDraft(outDir: string, draft: ResearchDraft, dryRun: boolean, progress: string): void {
if (dryRun) {
console.log(` ${progress} ${draft.name} — DRY RUN (would write ${draft.slug}.json)`);
- console.log(` Analysis: isFunder=${draft.analysis.isFunder}, themes=[${draft.analysis.themes.join(',')}]`);
+ console.log(
+ ` Analysis: isFunder=${draft.analysis.isFunder}, themes=[${draft.analysis.themes.join(',')}]`,
+ );
console.log(` Purpose: ${draft.analysis.purposeSummary.substring(0, 80)}...`);
return;
}
@@ -386,12 +420,19 @@ function salvageAnalysis(raw: Record, item: TriageItem): Resear
funderConfidence: (raw.funderConfidence as 'high' | 'medium' | 'low') || 'low',
reasoning: (raw.reasoning as string) || 'Auto-salvaged from partial Groq response',
themes: filterValidThemes(Array.isArray(raw.themes) ? raw.themes : item.themes),
- suggestedType: (['A', 'B', 'C', 'D', 'network'].includes(raw.suggestedType as string) ? raw.suggestedType : 'C') as ResearchAnalysis['suggestedType'],
- suggestedFit: typeof raw.suggestedFit === 'number' ? Math.min(3, Math.max(0, raw.suggestedFit)) : 1,
- suggestedPriority: typeof raw.suggestedPriority === 'number' ? Math.min(4, Math.max(1, raw.suggestedPriority)) : item.priority,
+ suggestedType: (['A', 'B', 'C', 'D', 'network'].includes(raw.suggestedType as string)
+ ? raw.suggestedType
+ : 'C') as ResearchAnalysis['suggestedType'],
+ suggestedFit:
+ typeof raw.suggestedFit === 'number' ? Math.min(3, Math.max(0, raw.suggestedFit)) : 1,
+ suggestedPriority:
+ typeof raw.suggestedPriority === 'number'
+ ? Math.min(4, Math.max(1, raw.suggestedPriority))
+ : item.priority,
purposeSummary: (raw.purposeSummary as string) || '',
researchNotes: (raw.researchNotes as string) || '',
- applicationMethod: (raw.applicationMethod as ResearchAnalysis['applicationMethod']) || 'unknown',
+ applicationMethod:
+ (raw.applicationMethod as ResearchAnalysis['applicationMethod']) || 'unknown',
contactInfo: {
email: (raw.contactInfo as Record)?.email || item.contactEmail || undefined,
phone: (raw.contactInfo as Record)?.phone || item.contactPhone || undefined,
@@ -402,7 +443,9 @@ function salvageAnalysis(raw: Record, item: TriageItem): Resear
max: (raw.grantRange as Record)?.max || undefined,
typical: (raw.grantRange as Record)?.typical || undefined,
},
- warnings: Array.isArray(raw.warnings) ? raw.warnings.filter((w): w is string => typeof w === 'string') : [],
+ warnings: Array.isArray(raw.warnings)
+ ? raw.warnings.filter((w): w is string => typeof w === 'string')
+ : [],
};
// Must have purposeSummary to be useful
@@ -416,7 +459,7 @@ function salvageAnalysis(raw: Record, item: TriageItem): Resear
}
}
-main().catch(err => {
+main().catch((err) => {
console.error('Auto-research failed:', err);
process.exit(1);
});
diff --git a/scripts/bulk-enrich.ts b/scripts/bulk-enrich.ts
index 88500071..7a7ec5ef 100644
--- a/scripts/bulk-enrich.ts
+++ b/scripts/bulk-enrich.ts
@@ -48,12 +48,32 @@ const FUNDRAISO_PATH = path.join(process.cwd(), 'research', 'fundraiso-discovery
// Canton full names for address building
const CANTON_NAMES: Record = {
- ZH: 'Zürich', BE: 'Bern', LU: 'Luzern', UR: 'Uri', SZ: 'Schwyz',
- OW: 'Obwalden', NW: 'Nidwalden', GL: 'Glarus', ZG: 'Zug', FR: 'Freiburg',
- SO: 'Solothurn', BS: 'Basel-Stadt', BL: 'Basel-Landschaft', SH: 'Schaffhausen',
- AR: 'Appenzell Ausserrhoden', AI: 'Appenzell Innerrhoden', SG: 'St. Gallen',
- GR: 'Graubünden', AG: 'Aargau', TG: 'Thurgau', TI: 'Tessin', VD: 'Waadt',
- VS: 'Wallis', NE: 'Neuenburg', GE: 'Genf', JU: 'Jura',
+ ZH: 'Zürich',
+ BE: 'Bern',
+ LU: 'Luzern',
+ UR: 'Uri',
+ SZ: 'Schwyz',
+ OW: 'Obwalden',
+ NW: 'Nidwalden',
+ GL: 'Glarus',
+ ZG: 'Zug',
+ FR: 'Freiburg',
+ SO: 'Solothurn',
+ BS: 'Basel-Stadt',
+ BL: 'Basel-Landschaft',
+ SH: 'Schaffhausen',
+ AR: 'Appenzell Ausserrhoden',
+ AI: 'Appenzell Innerrhoden',
+ SG: 'St. Gallen',
+ GR: 'Graubünden',
+ AG: 'Aargau',
+ TG: 'Thurgau',
+ TI: 'Tessin',
+ VD: 'Waadt',
+ VS: 'Wallis',
+ NE: 'Neuenburg',
+ GE: 'Genf',
+ JU: 'Jura',
};
// ============================================================================
@@ -134,7 +154,9 @@ function normalizeName(name: string): string {
.replace(/[ìíîï]/g, 'i')
.replace(/[òóôõöø]/g, 'o')
.replace(/[ùúûü]/g, 'u')
- .replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue')
+ .replace(/ä/g, 'ae')
+ .replace(/ö/g, 'oe')
+ .replace(/ü/g, 'ue')
.replace(/ß/g, 'ss')
.replace(/[^a-z0-9]/g, '');
}
@@ -157,7 +179,13 @@ function getQualityTier(f: Foundation): string {
}
function computeTierCounts(foundations: Foundation[]): TierCounts {
- const counts: TierCounts = { verzeichnet: 0, erfasst: 0, profiliert: 0, recherchiert: 0, anwendungsbereit: 0 };
+ const counts: TierCounts = {
+ verzeichnet: 0,
+ erfasst: 0,
+ profiliert: 0,
+ recherchiert: 0,
+ anwendungsbereit: 0,
+ };
for (const f of foundations) {
const tier = getQualityTier(f) as keyof TierCounts;
counts[tier]++;
@@ -185,7 +213,10 @@ function loadESA(): Map {
return byUid;
}
-function loadFundraiso(): { byName: Map; bySlug: Map } {
+function loadFundraiso(): {
+ byName: Map;
+ bySlug: Map;
+} {
if (!fs.existsSync(FUNDRAISO_PATH)) {
console.log(' Fundraiso discovery not found, skipping Fundraiso matching');
return { byName: new Map(), bySlug: new Map() };
@@ -243,7 +274,8 @@ function enrichFoundation(
if (!enriched.officialPurpose && esa?.purpose) {
enriched.officialPurpose = esa.purpose;
changes.push({
- slug: f.slug, name: f.name,
+ slug: f.slug,
+ name: f.name,
field: 'officialPurpose',
from: '(empty)',
to: `ESA purpose (${esa.purpose.length} chars)`,
@@ -257,7 +289,8 @@ function enrichFoundation(
if (classified.length > 0) {
enriched.themes = classified;
changes.push({
- slug: f.slug, name: f.name,
+ slug: f.slug,
+ name: f.name,
field: 'themes',
from: '[]',
to: JSON.stringify(classified),
@@ -271,7 +304,8 @@ function enrichFoundation(
enriched.contact = enriched.contact || {};
enriched.contact.address = address;
changes.push({
- slug: f.slug, name: f.name,
+ slug: f.slug,
+ name: f.name,
field: 'contact.address',
from: '(empty)',
to: address,
@@ -282,7 +316,8 @@ function enrichFoundation(
if ((!enriched.region || enriched.region === 'Schweiz') && esa?.city) {
enriched.region = esa.city;
changes.push({
- slug: f.slug, name: f.name,
+ slug: f.slug,
+ name: f.name,
field: 'region',
from: enriched.region || 'Schweiz',
to: esa.city,
@@ -296,7 +331,8 @@ function enrichFoundation(
enriched.isOperative = isOperative;
if (isOperative) {
changes.push({
- slug: f.slug, name: f.name,
+ slug: f.slug,
+ name: f.name,
field: 'isOperative',
from: 'undefined',
to: `true (funder=${scores.funder}, operator=${scores.operator})`,
@@ -310,7 +346,8 @@ function enrichFoundation(
const newType = classifyType(purposeText, enriched.name, scores.funder, scores.operator);
if (newType !== 'C') {
changes.push({
- slug: f.slug, name: f.name,
+ slug: f.slug,
+ name: f.name,
field: 'type',
from: enriched.type,
to: newType,
@@ -322,10 +359,12 @@ function enrichFoundation(
// --- 4. Fundraiso Website URL Matching ---
if (isRegistryUrl(enriched.websiteUrl ?? '')) {
const normalizedName = normalizeName(enriched.name);
- const fundraisoMatch = fundraisoBySlug.get(enriched.slug) || fundraisoByName.get(normalizedName);
+ const fundraisoMatch =
+ fundraisoBySlug.get(enriched.slug) || fundraisoByName.get(normalizedName);
if (fundraisoMatch?.url) {
changes.push({
- slug: f.slug, name: f.name,
+ slug: f.slug,
+ name: f.name,
field: 'websiteUrl',
from: enriched.websiteUrl || '(empty)',
to: `Fundraiso: ${fundraisoMatch.url}`,
@@ -335,7 +374,7 @@ function enrichFoundation(
}
// --- Recompute fit score if themes changed ---
- if (changes.some(c => c.field === 'themes')) {
+ if (changes.some((c) => c.field === 'themes')) {
const esa2 = f.uid ? esaByUid.get(f.uid) : undefined;
const { fitScore } = computeFitScore({
themes: enriched.themes as string[],
@@ -401,12 +440,17 @@ function buildDraft(enriched: Foundation, esa: ESAEntry | undefined) {
reasoning: 'Bulk enrichment from ESA register + Fundraiso cross-reference',
themes: enriched.themes,
suggestedType: enriched.type,
- suggestedFit: fitScoreToDisplay(enriched.fitScore || 0, (enriched.researchDepth || 'rapid') === 'rapid'),
+ suggestedFit: fitScoreToDisplay(
+ enriched.fitScore || 0,
+ (enriched.researchDepth || 'rapid') === 'rapid',
+ ),
suggestedPriority: enriched.priority,
purposeSummary: enriched.purposeSummary || buildMinimalPurposeSummary(enriched, esa),
researchNotes: enriched.researchNotes || buildMinimalResearchNotes(enriched, esa),
- applicationMethod: enriched.applicationMethod === 'post' ? 'unknown'
- : (enriched.applicationMethod as 'online' | 'email' | 'unknown') || 'unknown',
+ applicationMethod:
+ enriched.applicationMethod === 'post'
+ ? 'unknown'
+ : (enriched.applicationMethod as 'online' | 'email' | 'unknown') || 'unknown',
contactInfo: {
email: enriched.contact?.email,
phone: enriched.contact?.phone,
@@ -429,9 +473,8 @@ function buildMinimalPurposeSummary(f: Foundation, esa?: ESAEntry): string {
const city = esa?.city || f.region || 'Schweiz';
const purpose = f.officialPurpose || '';
// Truncate purpose to first 200 chars for summary
- const excerpt = purpose.length > 200
- ? purpose.substring(0, 197).replace(/\s+\S*$/, '') + '...'
- : purpose;
+ const excerpt =
+ purpose.length > 200 ? purpose.substring(0, 197).replace(/\s+\S*$/, '') + '...' : purpose;
return `${f.name} (${city}): ${excerpt || 'Keine detaillierte Zweckbeschreibung verfügbar. Weitere Recherche empfohlen.'}`;
}
@@ -464,7 +507,7 @@ async function main() {
const dbRows = await loadFoundationsFromDB();
console.log(` Database: ${dbRows.length} foundations`);
- const foundations = dbRows.map(r => r.config_data);
+ const foundations = dbRows.map((r) => r.config_data);
const esaByUid = loadESA();
const { byName: fundraisoByName, bySlug: fundraisoBySlug } = loadFundraiso();
@@ -510,13 +553,15 @@ async function main() {
processed++;
if (processed % 5000 === 0) {
- console.log(` Processed ${processed.toLocaleString()}/${foundations.length.toLocaleString()}...`);
+ console.log(
+ ` Processed ${processed.toLocaleString()}/${foundations.length.toLocaleString()}...`,
+ );
}
}
// Compute after-enrichment tier counts
- const afterFoundations = foundations.map(f => {
- const enriched = enrichedFoundations.find(e => e.slug === f.slug);
+ const afterFoundations = foundations.map((f) => {
+ const enriched = enrichedFoundations.find((e) => e.slug === f.slug);
return enriched || f;
});
const afterCounts = computeTierCounts(afterFoundations);
@@ -536,18 +581,26 @@ async function main() {
}
console.log('\n Tier Distribution (Before → After):');
- const tiers = ['verzeichnet', 'erfasst', 'profiliert', 'recherchiert', 'anwendungsbereit'] as const;
+ const tiers = [
+ 'verzeichnet',
+ 'erfasst',
+ 'profiliert',
+ 'recherchiert',
+ 'anwendungsbereit',
+ ] as const;
for (const tier of tiers) {
const before = beforeCounts[tier];
const after = afterCounts[tier];
const delta = after - before;
const deltaStr = delta > 0 ? ` (+${delta})` : delta < 0 ? ` (${delta})` : '';
- console.log(` ${tier.padEnd(20)} ${before.toLocaleString().padStart(7)} → ${after.toLocaleString().padStart(7)}${deltaStr}`);
+ console.log(
+ ` ${tier.padEnd(20)} ${before.toLocaleString().padStart(7)} → ${after.toLocaleString().padStart(7)}${deltaStr}`,
+ );
}
// Count funder/operator classification
- const operativeCount = enrichedFoundations.filter(f => f.isOperative === true).length;
- const funderCount = enrichedFoundations.filter(f => f.isOperative === false).length;
+ const operativeCount = enrichedFoundations.filter((f) => f.isOperative === true).length;
+ const funderCount = enrichedFoundations.filter((f) => f.isOperative === false).length;
console.log(`\n Funder/Operator: ${funderCount} funders, ${operativeCount} operatives detected`);
// Write drafts (unless dry run)
@@ -573,7 +626,9 @@ async function main() {
if (allChanges.length > 0) {
console.log('\n Sample changes (first 20):');
for (const c of allChanges.slice(0, 20)) {
- console.log(` ${c.name.substring(0, 40).padEnd(42)} ${c.field.padEnd(20)} ${c.from.substring(0, 20)} → ${c.to.substring(0, 40)}`);
+ console.log(
+ ` ${c.name.substring(0, 40).padEnd(42)} ${c.field.padEnd(20)} ${c.from.substring(0, 20)} → ${c.to.substring(0, 40)}`,
+ );
}
if (allChanges.length > 20) {
console.log(` ... and ${allChanges.length - 20} more changes`);
@@ -586,7 +641,7 @@ async function main() {
console.log('\nDone.\n');
}
-main().catch(err => {
+main().catch((err) => {
console.error('Error:', err);
process.exit(1);
});
diff --git a/scripts/bulk-import-batches.ts b/scripts/bulk-import-batches.ts
index e114e25a..dd87d25b 100644
--- a/scripts/bulk-import-batches.ts
+++ b/scripts/bulk-import-batches.ts
@@ -3,7 +3,8 @@
* The import script is idempotent — already-imported records are no-ops.
* Runs batches 39-94 (claude-agent results from 2026-04-22).
*/
-import { config } from 'dotenv'; config({ path: '.env.local' });
+import { config } from 'dotenv';
+config({ path: '.env.local' });
import { sql } from './lib/db';
import { readdirSync, readFileSync } from 'fs';
import { resolve } from 'path';
@@ -15,17 +16,24 @@ const DRY_RUN = process.argv.includes('--dry-run');
const RESULTS_DIR = resolve(__dirname, '../research/chatgpt-results');
-async function importFile(file: string): Promise<{ written: number; skipped: number; notFound: number }> {
+async function importFile(
+ file: string,
+): Promise<{ written: number; skipped: number; notFound: number }> {
const raw = JSON.parse(readFileSync(file, 'utf8'));
- const records: Record[] = Array.isArray(raw) ? raw : raw.items ?? [];
- let written = 0, skipped = 0, notFound = 0;
+ const records: Record[] = Array.isArray(raw) ? raw : (raw.items ?? []);
+ let written = 0,
+ skipped = 0,
+ notFound = 0;
for (const rec of records) {
const slug = rec.slug as string;
if (!slug) continue;
const [row] = await sql`SELECT id, config_data FROM fundraising_foundations WHERE id = ${slug}`;
- if (!row) { notFound++; continue; }
+ if (!row) {
+ notFound++;
+ continue;
+ }
const cd = row.config_data as Record;
const fields: string[] = [];
@@ -33,7 +41,10 @@ async function importFile(file: string): Promise<{ written: number; skipped: num
// Never overwrite existing non-empty values (except researchDepth upgrade)
const setIfEmpty = (key: string, val: unknown) => {
if (val == null || val === '') return;
- if (cd[key] == null || cd[key] === '') { cd[key] = val; fields.push(key); }
+ if (cd[key] == null || cd[key] === '') {
+ cd[key] = val;
+ fields.push(key);
+ }
};
// Website URL (skip registry/directory URLs)
@@ -58,10 +69,22 @@ async function importFile(file: string): Promise<{ written: number; skipped: num
// Contact (nested)
const contact = (cd.contact as Record | null) ?? {};
let contactChanged = false;
- if (rec.email && !contact.email) { contact.email = rec.email; contactChanged = true; }
- if (rec.phone && !contact.phone) { contact.phone = rec.phone; contactChanged = true; }
- if (rec.address && !contact.address) { contact.address = rec.address; contactChanged = true; }
- if (contactChanged) { cd.contact = contact; fields.push('contact'); }
+ if (rec.email && !contact.email) {
+ contact.email = rec.email;
+ contactChanged = true;
+ }
+ if (rec.phone && !contact.phone) {
+ contact.phone = rec.phone;
+ contactChanged = true;
+ }
+ if (rec.address && !contact.address) {
+ contact.address = rec.address;
+ contactChanged = true;
+ }
+ if (contactChanged) {
+ cd.contact = contact;
+ fields.push('contact');
+ }
// About
setIfEmpty('purposeSummary', rec.purposeSummary);
@@ -73,10 +96,12 @@ async function importFile(file: string): Promise<{ written: number; skipped: num
// Application info
if (rec.applicationMethod && !cd.applicationMethod) {
- cd.applicationMethod = rec.applicationMethod; fields.push('applicationMethod');
+ cd.applicationMethod = rec.applicationMethod;
+ fields.push('applicationMethod');
}
if (rec.acceptsApplications && !cd.acceptsApplications) {
- cd.acceptsApplications = rec.acceptsApplications; fields.push('acceptsApplications');
+ cd.acceptsApplications = rec.acceptsApplications;
+ fields.push('acceptsApplications');
}
setIfEmpty('deadlineText', rec.deadlineText);
setIfEmpty('applicationProcess', rec.applicationProcess);
@@ -84,15 +109,27 @@ async function importFile(file: string): Promise<{ written: number; skipped: num
// Amount
const amount = (cd.amount as Record | null) ?? {};
let amountChanged = false;
- if (rec.amountMin != null && !amount.min) { amount.min = rec.amountMin; amountChanged = true; }
- if (rec.amountMax != null && !amount.max) { amount.max = rec.amountMax; amountChanged = true; }
- if (rec.amountText && !amount.text) { amount.text = rec.amountText; amountChanged = true; }
- if (amountChanged) { cd.amount = amount; fields.push('amount'); }
+ if (rec.amountMin != null && !amount.min) {
+ amount.min = rec.amountMin;
+ amountChanged = true;
+ }
+ if (rec.amountMax != null && !amount.max) {
+ amount.max = rec.amountMax;
+ amountChanged = true;
+ }
+ if (rec.amountText && !amount.text) {
+ amount.text = rec.amountText;
+ amountChanged = true;
+ }
+ if (amountChanged) {
+ cd.amount = amount;
+ fields.push('amount');
+ }
// Themes — append new ones, never shrink
if (Array.isArray(rec.themes) && rec.themes.length > 0) {
const existing = new Set((cd.themes as string[] | null) ?? []);
- const newThemes = (rec.themes as string[]).filter(t => !existing.has(t));
+ const newThemes = (rec.themes as string[]).filter((t) => !existing.has(t));
if (newThemes.length > 0) {
cd.themes = [...existing, ...newThemes];
fields.push('themes');
@@ -101,7 +138,8 @@ async function importFile(file: string): Promise<{ written: number; skipped: num
// fitScore — only upgrade, never downgrade
if (typeof rec.fitScore === 'number' && rec.fitScore > ((cd.fitScore as number) ?? 0)) {
- cd.fitScore = rec.fitScore; fields.push('fitScore');
+ cd.fitScore = rec.fitScore;
+ fields.push('fitScore');
}
// Mark research method (upgrade only)
@@ -114,8 +152,14 @@ async function importFile(file: string): Promise<{ written: number; skipped: num
fields.push('applicationResearchMethod');
}
- if (fields.length === 0) { skipped++; continue; }
- if (DRY_RUN) { written++; continue; }
+ if (fields.length === 0) {
+ skipped++;
+ continue;
+ }
+ if (DRY_RUN) {
+ written++;
+ continue;
+ }
await sql`
UPDATE fundraising_foundations
@@ -130,21 +174,30 @@ async function importFile(file: string): Promise<{ written: number; skipped: num
async function main() {
const files = readdirSync(RESULTS_DIR)
- .filter(f => f.match(/^batch\d+-claude-agent-2026-04-22\.json$/))
+ .filter((f) => f.match(/^batch\d+-claude-agent-2026-04-22\.json$/))
.sort()
- .map(f => resolve(RESULTS_DIR, f));
+ .map((f) => resolve(RESULTS_DIR, f));
- console.log(`${DRY_RUN ? '[DRY RUN] ' : ''}Processing ${files.length} claude-agent batch files...\n`);
+ console.log(
+ `${DRY_RUN ? '[DRY RUN] ' : ''}Processing ${files.length} claude-agent batch files...\n`,
+ );
- let totalWritten = 0, totalSkipped = 0, totalNotFound = 0;
+ let totalWritten = 0,
+ totalSkipped = 0,
+ totalNotFound = 0;
for (const file of files) {
const { written, skipped, notFound } = await importFile(file);
totalWritten += written;
totalSkipped += skipped;
totalNotFound += notFound;
- if (written > 0) process.stdout.write(` ✅ ${file.split('/').pop()} — ${written} written, ${skipped} skipped\n`);
+ if (written > 0)
+ process.stdout.write(
+ ` ✅ ${file.split('/').pop()} — ${written} written, ${skipped} skipped\n`,
+ );
}
- console.log(`\nTotal: ${totalWritten} written, ${totalSkipped} skipped, ${totalNotFound} not found`);
+ console.log(
+ `\nTotal: ${totalWritten} written, ${totalSkipped} skipped, ${totalNotFound} not found`,
+ );
}
main().catch(console.error);
diff --git a/scripts/dedupe-by-uid.ts b/scripts/dedupe-by-uid.ts
index 4b4420b9..3e62800f 100644
--- a/scripts/dedupe-by-uid.ts
+++ b/scripts/dedupe-by-uid.ts
@@ -22,7 +22,8 @@
* npx tsx scripts/dedupe-by-uid.ts --dry-run
* npx tsx scripts/dedupe-by-uid.ts
*/
-import { config } from 'dotenv'; config({ path: '.env.local' });
+import { config } from 'dotenv';
+config({ path: '.env.local' });
import { sql } from './lib/db';
const DRY_RUN = process.argv.includes('--dry-run');
@@ -42,8 +43,8 @@ function isRegistryUrl(url: string | undefined | null): boolean {
function dataScore(f: Foundation): number {
// Higher score = better keeper candidate.
const cd = f.config_data;
- const purposeLen = (cd.purposeSummary as string | null ?? '').length;
- const notesLen = (cd.researchNotes as string | null ?? '').length;
+ const purposeLen = ((cd.purposeSummary as string | null) ?? '').length;
+ const notesLen = ((cd.researchNotes as string | null) ?? '').length;
const hasRealWeb = !isRegistryUrl(cd.websiteUrl as string | null);
const contact = cd.contact as Record | null;
const hasEmail = !!contact?.email;
@@ -78,7 +79,12 @@ function mergeConfig(
const sl = typeof sv === 'string' ? sv.length : 0;
const dl = typeof dv === 'string' ? dv.length : 0;
if (dl < sl) merged[k] = sv;
- } else if (dv == null || dv === '' || dv === undefined || (Array.isArray(dv) && dv.length === 0)) {
+ } else if (
+ dv == null ||
+ dv === '' ||
+ dv === undefined ||
+ (Array.isArray(dv) && dv.length === 0)
+ ) {
merged[k] = sv;
}
}
@@ -117,34 +123,40 @@ async function main() {
`;
console.log(`${DRY_RUN ? '[DRY RUN] ' : ''}Processing ${clusters.length} UID clusters...`);
- let merged = 0, archived = 0, errors = 0;
+ let merged = 0,
+ archived = 0,
+ errors = 0;
for (const cluster of clusters) {
const ids = cluster.ids as string[];
if (ids.length !== 2) {
console.log(` SKIP cluster ${cluster.uid}: size ${ids.length}`);
continue;
}
- const rows = await sql`
+ const rows = (await sql`
SELECT id, config_data, fit_score, priority
FROM fundraising_foundations WHERE id IN (${ids[0]}, ${ids[1]})
- ` as Foundation[];
- if (rows.length !== 2) { errors++; continue; }
+ `) as Foundation[];
+ if (rows.length !== 2) {
+ errors++;
+ continue;
+ }
const [a, b] = rows;
- const aScore = dataScore(a), bScore = dataScore(b);
+ const aScore = dataScore(a),
+ bScore = dataScore(b);
const keeper = aScore >= bScore ? a : b;
const loser = aScore >= bScore ? b : a;
const newConfig = mergeConfig(keeper.config_data, loser.config_data);
if (DRY_RUN) {
- const purposeBefore = (keeper.config_data.purposeSummary as string ?? '').length;
- const purposeAfter = (newConfig.purposeSummary as string ?? '').length;
- const notesBefore = (keeper.config_data.researchNotes as string ?? '').length;
- const notesAfter = (newConfig.researchNotes as string ?? '').length;
+ const purposeBefore = ((keeper.config_data.purposeSummary as string) ?? '').length;
+ const purposeAfter = ((newConfig.purposeSummary as string) ?? '').length;
+ const notesBefore = ((keeper.config_data.researchNotes as string) ?? '').length;
+ const notesAfter = ((newConfig.researchNotes as string) ?? '').length;
console.log(
` ${keeper.id} ← ${loser.id}` +
- (purposeBefore !== purposeAfter ? ` purpose ${purposeBefore}→${purposeAfter}` : '') +
- (notesBefore !== notesAfter ? ` notes ${notesBefore}→${notesAfter}` : ''),
+ (purposeBefore !== purposeAfter ? ` purpose ${purposeBefore}→${purposeAfter}` : '') +
+ (notesBefore !== notesAfter ? ` notes ${notesBefore}→${notesAfter}` : ''),
);
} else {
await sql`
@@ -161,7 +173,9 @@ async function main() {
merged++;
archived++;
}
- console.log(`\n${DRY_RUN ? '[DRY RUN] ' : ''}Done — ${merged} clusters merged, ${archived} entries archived, ${errors} errors`);
+ console.log(
+ `\n${DRY_RUN ? '[DRY RUN] ' : ''}Done — ${merged} clusters merged, ${archived} entries archived, ${errors} errors`,
+ );
}
main().catch(console.error);
diff --git a/scripts/esa-download-and-parse.ts b/scripts/esa-download-and-parse.ts
index f061e0cf..7f77c821 100644
--- a/scripts/esa-download-and-parse.ts
+++ b/scripts/esa-download-and-parse.ts
@@ -27,7 +27,8 @@ import * as XLSX from 'xlsx';
// ESA Foundation Registry - updated 14.01.2026
// Note: This URL may change when they publish a new version. Check https://www.esa.admin.ch/de/stiftungsverzeichnis for updates.
-const ESA_EXCEL_URL = 'https://backend.esa.admin.ch/fileservice/sdweb-docs-prod-esaadminch-files/files/2026/01/20/ca6bcb70-475e-445c-8cae-898ae8768858.xlsx';
+const ESA_EXCEL_URL =
+ 'https://backend.esa.admin.ch/fileservice/sdweb-docs-prod-esaadminch-files/files/2026/01/20/ca6bcb70-475e-445c-8cae-898ae8768858.xlsx';
const OUTPUT_DIR = path.join(process.cwd(), 'research');
const TEMP_FILE = path.join(OUTPUT_DIR, 'esa-temp.xlsx');
@@ -48,32 +49,34 @@ function downloadFile(url: string, dest: string): Promise {
return new Promise((resolve, reject) => {
const file = fs.createWriteStream(dest);
- https.get(url, (response) => {
- if (response.statusCode === 302 || response.statusCode === 301) {
- // Follow redirect
- const redirectUrl = response.headers.location;
- if (redirectUrl) {
- console.log(`Following redirect to: ${redirectUrl}`);
- downloadFile(redirectUrl, dest).then(resolve).catch(reject);
- return;
+ https
+ .get(url, (response) => {
+ if (response.statusCode === 302 || response.statusCode === 301) {
+ // Follow redirect
+ const redirectUrl = response.headers.location;
+ if (redirectUrl) {
+ console.log(`Following redirect to: ${redirectUrl}`);
+ downloadFile(redirectUrl, dest).then(resolve).catch(reject);
+ return;
+ }
}
- }
- if (response.statusCode !== 200) {
- reject(new Error(`Failed to download: HTTP ${response.statusCode}`));
- return;
- }
+ if (response.statusCode !== 200) {
+ reject(new Error(`Failed to download: HTTP ${response.statusCode}`));
+ return;
+ }
- response.pipe(file);
+ response.pipe(file);
- file.on('finish', () => {
- file.close();
- resolve();
+ file.on('finish', () => {
+ file.close();
+ resolve();
+ });
+ })
+ .on('error', (err) => {
+ fs.unlink(dest, () => {}); // Delete partial file
+ reject(err);
});
- }).on('error', (err) => {
- fs.unlink(dest, () => {}); // Delete partial file
- reject(err);
- });
});
}
@@ -100,7 +103,10 @@ function parseExcel(filePath: string): ESAFoundation[] {
for (const row of rawData) {
// Try to identify columns by common headers
// Note: Exact header names may vary - adjust based on actual file
- const uid = (row['UID'] || row['UID-Nummer'] || row['Unternehmens-Identifikationsnummer'] || '') as string;
+ const uid = (row['UID'] ||
+ row['UID-Nummer'] ||
+ row['Unternehmens-Identifikationsnummer'] ||
+ '') as string;
const name = (row['Name'] || row['Stiftungsname'] || row['Stiftung'] || '') as string;
const purpose = (row['Zweck'] || row['Stiftungszweck'] || row['Purpose'] || '') as string;
const canton = (row['Kanton'] || row['Canton'] || '') as string;
diff --git a/scripts/foundation-add.ts b/scripts/foundation-add.ts
index 5d0e481d..7d78dec7 100644
--- a/scripts/foundation-add.ts
+++ b/scripts/foundation-add.ts
@@ -21,7 +21,14 @@
import * as readline from 'readline';
import { getAllFoundations } from './lib/foundations';
import { slugify } from './lib/utilities';
-import type { Foundation, FoundationType, FoundationStatus, SourceId, ThemeId, ApplicationMethod } from '../src/lib/schemas/foundation';
+import type {
+ Foundation,
+ FoundationType,
+ FoundationStatus,
+ SourceId,
+ ThemeId,
+ ApplicationMethod,
+} from '../src/lib/schemas/foundation';
const rl = readline.createInterface({
input: process.stdin,
@@ -120,7 +127,10 @@ async function promptForEntry(foundations: Foundation[]): Promise t.trim()).filter(Boolean);
+ const themes = themesInput
+ .split(',')
+ .map((t) => t.trim())
+ .filter(Boolean);
console.log('\nApplication Method:');
console.log(' online, post, email, contact, direct, personal, partnership,');
@@ -132,7 +142,7 @@ async function promptForEntry(foundations: Foundation[]): Promise
- f.location?.includes('Zürich') ||
- f.location?.includes('Winterthur') ||
- (f.foundVia && f.foundVia.length >= 2) // Multi-match signal
+ .filter(
+ (f: Candidate) =>
+ f.location?.includes('Zürich') ||
+ f.location?.includes('Winterthur') ||
+ (f.foundVia && f.foundVia.length >= 2), // Multi-match signal
)
.slice(0, 20); // Limit to top 20 for manageable batch size
}
@@ -229,7 +230,13 @@ function processBatch(candidates: Candidate[]): BatchJob {
// Generate prompt for each candidate
candidates.forEach((candidate, index) => {
const prompt = generatePromptForCandidate(candidate, index + 1);
- const fileName = `${String(index + 1).padStart(2, '0')}-${candidate.slug || candidate.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').substring(0, 30)}.md`;
+ const fileName = `${String(index + 1).padStart(2, '0')}-${
+ candidate.slug ||
+ candidate.name
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .substring(0, 30)
+ }.md`;
const filePath = path.join(outputDir, fileName);
fs.writeFileSync(filePath, prompt, 'utf-8');
diff --git a/scripts/foundation-research-assistant.ts b/scripts/foundation-research-assistant.ts
index c9b03dbb..afe49ff0 100644
--- a/scripts/foundation-research-assistant.ts
+++ b/scripts/foundation-research-assistant.ts
@@ -74,8 +74,9 @@ interface ResearchOutput {
function loadESARegister(): ESARegister | null {
const researchDir = path.join(process.cwd(), 'research');
- const files = fs.readdirSync(researchDir)
- .filter(f => f.startsWith('esa-register-') && f.endsWith('.json'))
+ const files = fs
+ .readdirSync(researchDir)
+ .filter((f) => f.startsWith('esa-register-') && f.endsWith('.json'))
.sort()
.reverse();
@@ -92,12 +93,14 @@ function loadESARegister(): ESARegister | null {
function findInESA(register: ESARegister | null, name: string): ESAFoundation | null {
if (!register) return null;
- const normalized = name.toLowerCase()
+ const normalized = name
+ .toLowerCase()
.replace(/stiftung|foundation|fondation|fondazione/gi, '')
.replace(/[^a-z0-9]/g, '');
for (const foundation of register.foundations) {
- const esaNormalized = foundation.name.toLowerCase()
+ const esaNormalized = foundation.name
+ .toLowerCase()
.replace(/stiftung|foundation|fondation|fondazione/gi, '')
.replace(/[^a-z0-9]/g, '');
@@ -115,24 +118,26 @@ function findInESA(register: ESARegister | null, name: string): ESAFoundation |
function fetchWebsite(url: string): Promise {
return new Promise((resolve, reject) => {
- https.get(url, (response) => {
- if (response.statusCode === 302 || response.statusCode === 301) {
- const redirectUrl = response.headers.location;
- if (redirectUrl) {
- fetchWebsite(redirectUrl).then(resolve).catch(reject);
- return;
+ https
+ .get(url, (response) => {
+ if (response.statusCode === 302 || response.statusCode === 301) {
+ const redirectUrl = response.headers.location;
+ if (redirectUrl) {
+ fetchWebsite(redirectUrl).then(resolve).catch(reject);
+ return;
+ }
}
- }
- if (response.statusCode !== 200) {
- reject(new Error(`HTTP ${response.statusCode}`));
- return;
- }
+ if (response.statusCode !== 200) {
+ reject(new Error(`HTTP ${response.statusCode}`));
+ return;
+ }
- let data = '';
- response.on('data', (chunk) => data += chunk);
- response.on('end', () => resolve(data));
- }).on('error', reject);
+ let data = '';
+ response.on('data', (chunk) => (data += chunk));
+ response.on('end', () => resolve(data));
+ })
+ .on('error', reject);
});
}
@@ -140,8 +145,14 @@ function fetchWebsite(url: string): Promise {
// ANALYSIS PROMPT TEMPLATES
// ============================================================================
-function buildAnalysisPrompt(input: ResearchInput, esa: ESAFoundation | null, websiteContent?: string): string {
- const availableThemes = Object.values(THEMES).map(t => `${t.id}: ${t.label} - ${t.description}`).join('\n');
+function buildAnalysisPrompt(
+ input: ResearchInput,
+ esa: ESAFoundation | null,
+ websiteContent?: string,
+): string {
+ const availableThemes = Object.values(THEMES)
+ .map((t) => `${t.id}: ${t.label} - ${t.description}`)
+ .join('\n');
return `# Foundation Research Analysis Task
@@ -159,18 +170,26 @@ You are analyzing a Swiss foundation to determine if it's a potential funder for
${input.websiteUrl ? `**Website**: ${input.websiteUrl}` : ''}
${input.fundraisoUrl ? `**Fundraiso**: ${input.fundraisoUrl}` : ''}
-${esa ? `### ESA Official Data
+${
+ esa
+ ? `### ESA Official Data
**UID**: ${esa.uid}
**Official Name**: ${esa.name}
**City**: ${esa.city}
**Status**: ${esa.status}
**Official Purpose (Stiftungszweck)**:
${esa.purpose}
-` : '**ESA Data**: Not found in federal register (may be under cantonal supervision or not a formal Stiftung)'}
+`
+ : '**ESA Data**: Not found in federal register (may be under cantonal supervision or not a formal Stiftung)'
+}
-${websiteContent ? `### Website Content (First 3000 chars)
+${
+ websiteContent
+ ? `### Website Content (First 3000 chars)
${websiteContent.substring(0, 3000)}...
-` : '**Website**: Not available'}
+`
+ : '**Website**: Not available'
+}
## Available Themes
${availableThemes}
@@ -266,7 +285,7 @@ Please provide your analysis in a structured format following the sections above
async function analyzeFundation(
input: ResearchInput,
esa: ESAFoundation | null,
- websiteContent?: string
+ websiteContent?: string,
): Promise {
const prompt = buildAnalysisPrompt(input, esa, websiteContent);
@@ -357,7 +376,9 @@ async function main() {
}
if (!name && !url) {
- console.error('❌ Usage: npm run research:foundation -- --name="Foundation Name" --url=https://...\n');
+ console.error(
+ '❌ Usage: npm run research:foundation -- --name="Foundation Name" --url=https://...\n',
+ );
process.exit(1);
}
@@ -379,7 +400,8 @@ async function main() {
fs.mkdirSync(outputDir, { recursive: true });
}
- const slug = name!.toLowerCase()
+ const slug = name!
+ .toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
const outputPath = path.join(outputDir, `${slug}.json`);
diff --git a/scripts/foundation-research-queue.ts b/scripts/foundation-research-queue.ts
index 969fdfe7..6f599d0b 100644
--- a/scripts/foundation-research-queue.ts
+++ b/scripts/foundation-research-queue.ts
@@ -52,8 +52,39 @@ const THEME_WEIGHTS: Record = {
};
// Zürich-area cities for geographic bonus
-const ZURICH_AREA = ['zürich', 'zurich', 'winterthur', 'dietikon', 'dübendorf', 'uster', 'wädenswil', 'kloten', 'opfikon', 'bülach'];
-const GERMAN_CH_CANTONS = ['ZH', 'BE', 'LU', 'ZG', 'SZ', 'AG', 'SG', 'TG', 'SH', 'GL', 'BS', 'BL', 'SO', 'NW', 'OW', 'UR', 'GR', 'AR', 'AI'];
+const ZURICH_AREA = [
+ 'zürich',
+ 'zurich',
+ 'winterthur',
+ 'dietikon',
+ 'dübendorf',
+ 'uster',
+ 'wädenswil',
+ 'kloten',
+ 'opfikon',
+ 'bülach',
+];
+const GERMAN_CH_CANTONS = [
+ 'ZH',
+ 'BE',
+ 'LU',
+ 'ZG',
+ 'SZ',
+ 'AG',
+ 'SG',
+ 'TG',
+ 'SH',
+ 'GL',
+ 'BS',
+ 'BL',
+ 'SO',
+ 'NW',
+ 'OW',
+ 'UR',
+ 'GR',
+ 'AR',
+ 'AI',
+];
// ============================================================================
// TYPES
@@ -105,7 +136,7 @@ function computeThemeScore(themes: string[]): number {
function computeGeoScore(city: string, canton: string): number {
const cityLower = city.toLowerCase();
- if (ZURICH_AREA.some(c => cityLower.includes(c))) return 2.0;
+ if (ZURICH_AREA.some((c) => cityLower.includes(c))) return 2.0;
if (canton === 'ZH') return 1.8;
if (GERMAN_CH_CANTONS.includes(canton)) return 1.0;
// French/Italian Swiss — still valid but lower priority
@@ -180,7 +211,11 @@ function missingForNextTier(f: Foundation): string[] {
return missing;
}
-function computeEV(f: Foundation, city: string, canton: string): {
+function computeEV(
+ f: Foundation,
+ city: string,
+ canton: string,
+): {
ev: number;
themeScore: number;
geoScore: number;
@@ -213,7 +248,14 @@ function computeEV(f: Foundation, city: string, canton: string): {
// ESA LOOKUP (for city/canton when not in config_data)
// ============================================================================
-interface ESAEntry { uid: string; name: string; purpose: string; canton: string; city: string; status: string; }
+interface ESAEntry {
+ uid: string;
+ name: string;
+ purpose: string;
+ canton: string;
+ city: string;
+ status: string;
+}
function loadESAIndex(): Map {
const esaPath = path.join(process.cwd(), 'research', 'esa-register-2026-02-16.json');
@@ -232,9 +274,9 @@ function loadESAIndex(): Map {
async function main() {
const args = process.argv.slice(2);
- const limitArg = args.find(a => a.startsWith('--limit='));
+ const limitArg = args.find((a) => a.startsWith('--limit='));
const limit = limitArg ? parseInt(limitArg.split('=')[1], 10) : 500;
- const minEvArg = args.find(a => a.startsWith('--min-ev='));
+ const minEvArg = args.find((a) => a.startsWith('--min-ev='));
const minEv = minEvArg ? parseFloat(minEvArg.split('=')[1]) : 0.5;
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
@@ -356,8 +398,10 @@ async function main() {
// EV distribution
if (queue.length > 0) {
- const evValues = queue.map(q => q.evScore);
- console.log(`\n EV range: ${evValues[evValues.length - 1].toFixed(2)} — ${evValues[0].toFixed(2)}`);
+ const evValues = queue.map((q) => q.evScore);
+ console.log(
+ `\n EV range: ${evValues[evValues.length - 1].toFixed(2)} — ${evValues[0].toFixed(2)}`,
+ );
console.log(` EV median: ${evValues[Math.floor(evValues.length / 2)].toFixed(2)}`);
}
@@ -368,10 +412,10 @@ async function main() {
const themes = item.themes.slice(0, 3).join(', ');
console.log(
` ${String(i + 1).padStart(3)}. [EV ${item.evScore.toFixed(2).padStart(5)}] ` +
- `[${item.currentTier.padEnd(12)}] ` +
- `${item.name.substring(0, 45).padEnd(47)} ` +
- `${item.city.substring(0, 15).padEnd(17)} ` +
- `{${themes}}`
+ `[${item.currentTier.padEnd(12)}] ` +
+ `${item.name.substring(0, 45).padEnd(47)} ` +
+ `${item.city.substring(0, 15).padEnd(17)} ` +
+ `{${themes}}`,
);
}
@@ -393,7 +437,7 @@ async function main() {
console.log('📋 Next: Use foundation-batch-research.ts with this queue\n');
}
-main().catch(err => {
+main().catch((err) => {
console.error('Error:', err);
process.exit(1);
});
diff --git a/scripts/foundation-screen.ts b/scripts/foundation-screen.ts
index a14b9651..a450865d 100644
--- a/scripts/foundation-screen.ts
+++ b/scripts/foundation-screen.ts
@@ -96,8 +96,9 @@ interface ScreeningReport {
function loadESARegister(): ESARegister | null {
const researchDir = path.join(process.cwd(), 'research');
- const files = fs.readdirSync(researchDir)
- .filter(f => f.startsWith('esa-register-') && f.endsWith('.json'))
+ const files = fs
+ .readdirSync(researchDir)
+ .filter((f) => f.startsWith('esa-register-') && f.endsWith('.json'))
.sort()
.reverse();
@@ -128,14 +129,18 @@ function loadCandidates(filePath: string): Candidate[] {
// ============================================================================
function normalizeName(name: string): string {
- return name
- .toLowerCase()
- // Convert umlauts to ae/oe/ue BEFORE stripping non-ASCII, so both
- // 'Unterstützungs' and 'Unterstuetzungs' normalize identically.
- .replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue')
- .replace(/stiftung|foundation|fondation|fondazione/gi, '')
- .replace(/[^a-z0-9]/g, '')
- .trim();
+ return (
+ name
+ .toLowerCase()
+ // Convert umlauts to ae/oe/ue BEFORE stripping non-ASCII, so both
+ // 'Unterstützungs' and 'Unterstuetzungs' normalize identically.
+ .replace(/ä/g, 'ae')
+ .replace(/ö/g, 'oe')
+ .replace(/ü/g, 'ue')
+ .replace(/stiftung|foundation|fondation|fondazione/gi, '')
+ .replace(/[^a-z0-9]/g, '')
+ .trim()
+ );
}
function levenshteinDistance(a: string, b: string): number {
@@ -157,7 +162,7 @@ function levenshteinDistance(a: string, b: string): number {
matrix[i][j] = Math.min(
matrix[i - 1][j - 1] + 1,
matrix[i - 1][j] + 1,
- matrix[i][j - 1] + 1
+ matrix[i][j - 1] + 1,
);
}
}
@@ -229,9 +234,11 @@ function findInESA(register: ESARegister, name: string): ESAFoundation | null {
for (const foundation of register.foundations) {
const esaNormalized = normalizeName(foundation.name);
- if (esaNormalized === normalized ||
- foundation.name.toLowerCase() === name.toLowerCase() ||
- levenshteinDistance(normalized, esaNormalized) <= 2) {
+ if (
+ esaNormalized === normalized ||
+ foundation.name.toLowerCase() === name.toLowerCase() ||
+ levenshteinDistance(normalized, esaNormalized) <= 2
+ ) {
return foundation;
}
}
@@ -250,7 +257,8 @@ function hasInternationalDevelopmentFocus(purpose: string, _city: string): boole
'entwicklungsländer',
'entwicklungszusammenarbeit',
'ländern des südens',
- 'dritte welt', 'drittwelt', // NEW v3.1 - old German term for "Third World"
+ 'dritte welt',
+ 'drittwelt', // NEW v3.1 - old German term for "Third World"
'global south',
'developing countries',
'emerging countries',
@@ -281,8 +289,8 @@ function hasInternationalDevelopmentFocus(purpose: string, _city: string): boole
'basel',
];
- const hasInternationalKeywords = internationalKeywords.some(kw => purposeLower.includes(kw));
- const hasSwissFocus = swissFocusKeywords.some(kw => purposeLower.includes(kw));
+ const hasInternationalKeywords = internationalKeywords.some((kw) => purposeLower.includes(kw));
+ const hasSwissFocus = swissFocusKeywords.some((kw) => purposeLower.includes(kw));
// Only exclude if strong international focus and NO Swiss mention
return hasInternationalKeywords && !hasSwissFocus;
@@ -301,22 +309,37 @@ function scoreFunderOperator(purpose: string): {
// Funder keywords (German, French, Italian)
const funderKeywords = [
- 'fördert', 'unterstützt projekte', 'vergibt beiträge',
- 'projektförderung', 'gesuchsverfahren', 'antragsformular',
- 'finanzielle unterstützung', 'zuwendungen', 'finanziert',
+ 'fördert',
+ 'unterstützt projekte',
+ 'vergibt beiträge',
+ 'projektförderung',
+ 'gesuchsverfahren',
+ 'antragsformular',
+ 'finanzielle unterstützung',
+ 'zuwendungen',
+ 'finanziert',
'unterstützung und förderung', // Fondation Assura pattern
// French
- 'soutient des projets', 'octroie des contributions',
- 'soutien financier', 'finance',
+ 'soutient des projets',
+ 'octroie des contributions',
+ 'soutien financier',
+ 'finance',
// Italian
- 'sostiene progetti', 'contributi finanziari',
+ 'sostiene progetti',
+ 'contributi finanziari',
];
// Operator keywords (German, French, Italian) - ENHANCED in v3.1
const operatorKeywords = [
- 'betreibt', 'führt durch', 'bietet an', 'dienstleister',
- 'eigene programme', 'direkt tätig', 'selbst tätig',
- 'angebote', 'leistungen erbringen',
+ 'betreibt',
+ 'führt durch',
+ 'bietet an',
+ 'dienstleister',
+ 'eigene programme',
+ 'direkt tätig',
+ 'selbst tätig',
+ 'angebote',
+ 'leistungen erbringen',
// Strong operator signals from Phase 3 v2.0 analysis
'erbringung von dienstleistungen',
'geführten betriebe',
@@ -332,28 +355,48 @@ function scoreFunderOperator(purpose: string): {
'durchführung', // Execution/carrying out
'serviceleistung', // Services
// NEW from Phase 3 final 8 analysis (v3.1)
- 'trägerin', 'träger', // Operator/carrier of institution
+ 'trägerin',
+ 'träger', // Operator/carrier of institution
'informationsaustausch', // Information exchange (platforms)
- 'plattform', 'portal', // Platform/portal
- 'wohnheim', 'heim', // Residential home
+ 'plattform',
+ 'portal', // Platform/portal
+ 'wohnheim',
+ 'heim', // Residential home
'wohn- und begegnungsort', // Residential facility
// French
- 'exploite', 'offre des services', 'propres programmes',
- 'prestation de services', 'organise', 'plateforme',
+ 'exploite',
+ 'offre des services',
+ 'propres programmes',
+ 'prestation de services',
+ 'organise',
+ 'plateforme',
// Italian
- 'gestisce', 'offre servizi', 'programmi propri',
- 'erogazione di servizi', 'organizza', 'piattaforma',
+ 'gestisce',
+ 'offre servizi',
+ 'programmi propri',
+ 'erogazione di servizi',
+ 'organizza',
+ 'piattaforma',
];
// Social dimension keywords (NEW in v3.0)
const socialKeywords = [
- 'sozial', 'social', 'sociale',
- 'integration', 'integrazione',
+ 'sozial',
+ 'social',
+ 'sociale',
+ 'integration',
+ 'integrazione',
'arbeitsintegration',
- 'bildung', 'éducation', 'educazione',
- 'armut', 'pauvreté', 'povertà',
- 'benachteiligt', 'défavorisé',
- 'vulnerable', 'vulnerabile',
+ 'bildung',
+ 'éducation',
+ 'educazione',
+ 'armut',
+ 'pauvreté',
+ 'povertà',
+ 'benachteiligt',
+ 'défavorisé',
+ 'vulnerable',
+ 'vulnerabile',
'chancengleichheit',
];
@@ -397,19 +440,24 @@ function scoreFunderOperator(purpose: string): {
function isFrenchItalianOnly(city: string, purpose: string): boolean {
// FIXED: Added more Geneva variants, ensure case-insensitive matching
const frenchCities = [
- 'genève', 'geneva', 'geneve', 'canton de genève',
- 'lausanne', 'neuchâtel', 'fribourg', 'meyrin',
- ];
- const italianCities = [
- 'lugano', 'locarno', 'bellinzona', 'mendrisio',
+ 'genève',
+ 'geneva',
+ 'geneve',
+ 'canton de genève',
+ 'lausanne',
+ 'neuchâtel',
+ 'fribourg',
+ 'meyrin',
];
+ const italianCities = ['lugano', 'locarno', 'bellinzona', 'mendrisio'];
const cityLower = city.toLowerCase();
const purposeLower = purpose.toLowerCase();
// Check if city is French/Italian
- const isFrenchItalianCity = frenchCities.some(fc => cityLower.includes(fc)) ||
- italianCities.some(ic => cityLower.includes(ic));
+ const isFrenchItalianCity =
+ frenchCities.some((fc) => cityLower.includes(fc)) ||
+ italianCities.some((ic) => cityLower.includes(ic));
if (!isFrenchItalianCity) {
return false;
@@ -432,13 +480,21 @@ function hasSocialDimension(purpose: string, socialScore: number): boolean {
// Climate/environment keywords
const climateKeywords = [
- 'klima', 'climate', 'climat',
- 'umwelt', 'environment', 'environnement',
- 'energie', 'energy', 'énergie',
- 'natur', 'nature', 'natura',
+ 'klima',
+ 'climate',
+ 'climat',
+ 'umwelt',
+ 'environment',
+ 'environnement',
+ 'energie',
+ 'energy',
+ 'énergie',
+ 'natur',
+ 'nature',
+ 'natura',
];
- const isClimateOnly = climateKeywords.some(kw => purposeLower.includes(kw));
+ const isClimateOnly = climateKeywords.some((kw) => purposeLower.includes(kw));
// If climate-focused, require social dimension
if (isClimateOnly && socialScore === 0) {
@@ -464,7 +520,7 @@ function isHealthcareOnly(purpose: string): boolean {
'gesundheitsförderung',
];
- const hasHealthcare = healthcareKeywords.some(kw => purposeLower.includes(kw));
+ const hasHealthcare = healthcareKeywords.some((kw) => purposeLower.includes(kw));
if (!hasHealthcare) {
return false; // Not healthcare-focused
@@ -472,13 +528,22 @@ function isHealthcareOnly(purpose: string): boolean {
// Check if it also has our core themes
const coreThemeKeywords = [
- 'arbeitsintegration', 'soziale integration', 'berufliche integration',
- 'kreislaufwirtschaft', 'recycling', 'wiederverwendung', 'secondhand',
- 'digitale bildung', 'digitale kompetenz', 'informatik',
- 'bildung', 'ausbildung', 'erziehung', // Education (broader)
+ 'arbeitsintegration',
+ 'soziale integration',
+ 'berufliche integration',
+ 'kreislaufwirtschaft',
+ 'recycling',
+ 'wiederverwendung',
+ 'secondhand',
+ 'digitale bildung',
+ 'digitale kompetenz',
+ 'informatik',
+ 'bildung',
+ 'ausbildung',
+ 'erziehung', // Education (broader)
];
- const hasCoreTheme = coreThemeKeywords.some(kw => purposeLower.includes(kw));
+ const hasCoreTheme = coreThemeKeywords.some((kw) => purposeLower.includes(kw));
// Healthcare-only if has healthcare keywords but NO core themes
return hasHealthcare && !hasCoreTheme;
@@ -501,7 +566,7 @@ function getCoreThemeScore(purpose: string): number {
'benachteiligte',
'chancengleichheit',
];
- if (socialIntegrationKeywords.some(kw => purposeLower.includes(kw))) {
+ if (socialIntegrationKeywords.some((kw) => purposeLower.includes(kw))) {
score += 3; // High value
}
@@ -513,23 +578,19 @@ function getCoreThemeScore(purpose: string): number {
'secondhand',
'upcycling',
];
- if (circularEconomyKeywords.some(kw => purposeLower.includes(kw))) {
+ if (circularEconomyKeywords.some((kw) => purposeLower.includes(kw))) {
score += 3; // High value
}
// Digital literacy
- const digitalKeywords = [
- 'digitale bildung',
- 'digitale kompetenz',
- 'informatik',
- ];
- if (digitalKeywords.some(kw => purposeLower.includes(kw))) {
+ const digitalKeywords = ['digitale bildung', 'digitale kompetenz', 'informatik'];
+ if (digitalKeywords.some((kw) => purposeLower.includes(kw))) {
score += 3; // High value
}
// Education (broader, lower value)
const educationKeywords = ['bildung', 'ausbildung', 'erziehung'];
- if (educationKeywords.some(kw => purposeLower.includes(kw)) && score === 0) {
+ if (educationKeywords.some((kw) => purposeLower.includes(kw)) && score === 0) {
score += 1; // Some value if no stronger match
}
@@ -557,7 +618,7 @@ function getThematicFocusScore(purpose: string): number {
let sectorCount = 0;
for (const sectorKeywords of sectors) {
- if (sectorKeywords.some(kw => purposeLower.includes(kw))) {
+ if (sectorKeywords.some((kw) => purposeLower.includes(kw))) {
sectorCount++;
}
}
@@ -572,11 +633,7 @@ function getThematicFocusScore(purpose: string): number {
// MAIN SCREENING LOGIC
// ============================================================================
-function screenCandidate(
- candidate: Candidate,
- esaRegister: ESARegister
-): ScreeningResult {
-
+function screenCandidate(candidate: Candidate, esaRegister: ESARegister): ScreeningResult {
// Filter 1: Duplicate in the foundation DB
if (isDuplicateInDB(candidate)) {
return {
@@ -604,13 +661,16 @@ function screenCandidate(
// They need research but should NOT be excluded — ESA only covers federal foundations
if (!esaMatch) {
const candidateCity = candidate.location?.replace(/, ?CH$/, '').toLowerCase() || '';
- const isZurichArea = candidateCity.includes('zürich') || candidateCity.includes('winterthur')
- || candidateCity.includes('schlieren') || candidateCity.includes('maur');
+ const isZurichArea =
+ candidateCity.includes('zürich') ||
+ candidateCity.includes('winterthur') ||
+ candidateCity.includes('schlieren') ||
+ candidateCity.includes('maur');
return {
candidate,
decision: 'pass' as const,
- tier: isZurichArea ? 3 as const : 4 as const,
+ tier: isZurichArea ? (3 as const) : (4 as const),
details: `Not in ESA (cantonal/other supervision) - ${isZurichArea ? 'ZH area' : 'needs location check'}`,
scores: { funderScore: 0, operatorScore: 0, socialScore: 0, confidence: 'low' as const },
};
@@ -688,7 +748,7 @@ function screenCandidate(
const focusScore = getThematicFocusScore(esaMatch.purpose);
const isZurichBased = esaMatch.city.toLowerCase().includes('zürich');
const isNearZurich = ['winterthur', 'zug', 'baden', 'aarau', 'freienbach', 'wetzikon'].some(
- city => esaMatch.city.toLowerCase().includes(city)
+ (city) => esaMatch.city.toLowerCase().includes(city),
);
// Start with base tier
@@ -760,19 +820,21 @@ function processBatch(candidates: Candidate[], esaRegister: ESARegister): Screen
}
}
- const passed = results.filter(r => r.decision === 'pass').length;
- const excluded = results.filter(r => r.decision === 'exclude').length;
+ const passed = results.filter((r) => r.decision === 'pass').length;
+ const excluded = results.filter((r) => r.decision === 'exclude').length;
console.log(`\n📊 Screening complete!`);
- console.log(` ✅ Passed: ${passed} (${Math.round(passed / candidates.length * 100)}%)`);
- console.log(` ❌ Excluded: ${excluded} (${Math.round(excluded / candidates.length * 100)}%)\n`);
+ console.log(` ✅ Passed: ${passed} (${Math.round((passed / candidates.length) * 100)}%)`);
+ console.log(
+ ` ❌ Excluded: ${excluded} (${Math.round((excluded / candidates.length) * 100)}%)\n`,
+ );
console.log(`📈 Exclusion breakdown:`);
Object.entries(exclusionBreakdown)
.filter(([, count]) => count > 0)
.sort(([, a], [, b]) => b - a)
.forEach(([reason, count]) => {
- console.log(` ${reason}: ${count} (${Math.round(count / candidates.length * 100)}%)`);
+ console.log(` ${reason}: ${count} (${Math.round((count / candidates.length) * 100)}%)`);
});
console.log(`\n🎯 Tier breakdown:`);
@@ -821,7 +883,9 @@ async function main() {
process.exit(1);
}
- console.log(`\n📚 Loaded ESA register: ${esaRegister.count} foundations (${esaRegister.downloadDate})`);
+ console.log(
+ `\n📚 Loaded ESA register: ${esaRegister.count} foundations (${esaRegister.downloadDate})`,
+ );
// Load candidates
const args = process.argv.slice(2);
@@ -845,11 +909,7 @@ async function main() {
const report = processBatch(candidates, esaRegister);
// Save report
- const outputPath = path.join(
- process.cwd(),
- 'research',
- `screening-v3.1-${report.date}.json`
- );
+ const outputPath = path.join(process.cwd(), 'research', `screening-v3.1-${report.date}.json`);
fs.writeFileSync(outputPath, JSON.stringify(report, null, 2), 'utf-8');
diff --git a/scripts/foundation-upsert.ts b/scripts/foundation-upsert.ts
index fd7d9d07..89bc1f05 100644
--- a/scripts/foundation-upsert.ts
+++ b/scripts/foundation-upsert.ts
@@ -24,7 +24,11 @@ import * as fs from 'fs';
import * as path from 'path';
import { sql } from './lib/db';
import { ResearchDraftSchema } from './lib/research-types';
-import { foundationSchema, type Foundation, type FoundationRegistry } from '../src/lib/schemas/foundation';
+import {
+ foundationSchema,
+ type Foundation,
+ type FoundationRegistry,
+} from '../src/lib/schemas/foundation';
import { computeFitScore } from '../src/lib/domain/fit-scoring';
import { computePriorityScore } from '../src/lib/domain/foundation-scores';
@@ -46,12 +50,17 @@ async function main() {
// Support directory argument: expand to all .json files in it
if (files.length === 1 && fs.existsSync(files[0]) && fs.statSync(files[0]).isDirectory()) {
const dir = path.resolve(files[0]);
- files = fs.readdirSync(dir).filter(f => f.endsWith('.json')).map(f => path.join(dir, f));
+ files = fs
+ .readdirSync(dir)
+ .filter((f) => f.endsWith('.json'))
+ .map((f) => path.join(dir, f));
console.log(` Expanding directory: ${files.length} JSON files`);
}
if (files.length === 0) {
- console.error('Usage: npx tsx scripts/foundation-upsert.ts [dir/] [more-files...]');
+ console.error(
+ 'Usage: npx tsx scripts/foundation-upsert.ts [dir/] [more-files...]',
+ );
process.exit(1);
}
@@ -88,10 +97,12 @@ async function main() {
const today = now.split('T')[0];
// Read ZHAW metadata if present (from zhaw-crossref.ts)
- const zhaw = (raw as Record)._zhaw as {
- einreichungstermin?: string;
- zielgruppe?: string;
- } | undefined;
+ const zhaw = (raw as Record)._zhaw as
+ | {
+ einreichungstermin?: string;
+ zielgruppe?: string;
+ }
+ | undefined;
// --- Compute deadline text ---
const deadlineText = zhaw?.einreichungstermin || 'Unbekannt';
@@ -110,9 +121,10 @@ async function main() {
address: a.contactInfo.address,
},
acceptsApplications: undefined,
- applicationMethod: a.applicationMethod === 'invitation'
- ? 'contact'
- : a.applicationMethod as FoundationRegistry['applicationMethod'],
+ applicationMethod:
+ a.applicationMethod === 'invitation'
+ ? 'contact'
+ : (a.applicationMethod as FoundationRegistry['applicationMethod']),
isOperative: !a.isFunder,
status: 'rolling',
deadlineText,
@@ -131,14 +143,18 @@ async function main() {
// --- Compute researchDepth ---
// Note: hasWebsite intentionally includes Zefix/registry URLs — the strict
// check (requires real website + email/phone for 'standard') is below.
- const hasWebsite = !!(draft.queueItem.websiteUrl);
+ const hasWebsite = !!draft.queueItem.websiteUrl;
const hasRealWebsite = hasWebsite && !isZefixUrl(draft.queueItem.websiteUrl || '');
const hasEmail = !!a.contactInfo.email;
const hasPhone = !!a.contactInfo.phone;
const hasDeadline = !!(zhaw?.einreichungstermin && zhaw.einreichungstermin !== 'Unbekannt');
const hasGrantRange = !!(a.grantRange.min || a.grantRange.max);
const researchDepth = computeResearchDepth({
- hasRealWebsite, hasEmail, hasPhone, hasDeadline, hasGrantRange,
+ hasRealWebsite,
+ hasEmail,
+ hasPhone,
+ hasDeadline,
+ hasGrantRange,
});
depthCounts[researchDepth]++;
@@ -221,7 +237,9 @@ async function main() {
}
}
- console.log(` ${draft.name} → DB (newFit=${fitScore}, newP=${finalPriority}, depth=${researchDepth})`);
+ console.log(
+ ` ${draft.name} → DB (newFit=${fitScore}, newP=${finalPriority}, depth=${researchDepth})`,
+ );
success++;
} catch (err) {
console.error(` ${draft.name}: ${err instanceof Error ? err.message : err}`);
@@ -230,7 +248,9 @@ async function main() {
}
console.log(`\nDone: ${success} upserted, ${errors} errors`);
- console.log(` Research depth: rapid=${depthCounts.rapid}, standard=${depthCounts.standard}, deep=${depthCounts.deep}`);
+ console.log(
+ ` Research depth: rapid=${depthCounts.rapid}, standard=${depthCounts.standard}, deep=${depthCounts.deep}`,
+ );
if (success > 0) {
console.log('Next: npm run sync && npm run build');
}
diff --git a/scripts/foundation-validate.ts b/scripts/foundation-validate.ts
index 4cbd963e..00b436b2 100644
--- a/scripts/foundation-validate.ts
+++ b/scripts/foundation-validate.ts
@@ -155,7 +155,7 @@ function levenshteinDistance(a: string, b: string): number {
matrix[i][j] = Math.min(
matrix[i - 1][j - 1] + 1,
matrix[i][j - 1] + 1,
- matrix[i - 1][j] + 1
+ matrix[i - 1][j] + 1,
);
}
}
@@ -231,7 +231,7 @@ function validateDuplicates(foundations: Foundation[]) {
// receive its UID)
if (distance > 0) {
if (foundation.uid && existing.uid && foundation.uid !== existing.uid) {
- continue; // different UIDs = different foundations
+ continue; // different UIDs = different foundations
}
}
addIssue({
diff --git a/scripts/gesuch-audit.ts b/scripts/gesuch-audit.ts
index 6f569e29..34f0b8ab 100644
--- a/scripts/gesuch-audit.ts
+++ b/scripts/gesuch-audit.ts
@@ -27,7 +27,8 @@ import { isRegistryUrl } from '../src/lib/config/registry-domains';
// ============================================================================
const args = process.argv.slice(2);
-const PRIORITY_FILTER = parseInt(args.find(a => a.startsWith('--priority='))?.split('=')[1] || '0', 10) || 0;
+const PRIORITY_FILTER =
+ parseInt(args.find((a) => a.startsWith('--priority='))?.split('=')[1] || '0', 10) || 0;
const JSON_OUTPUT = args.includes('--json');
// ============================================================================
@@ -48,7 +49,7 @@ interface AuditIssue {
async function main() {
const foundations = await getAllFoundations();
- const gesuchFoundations = foundations.filter(f => {
+ const gesuchFoundations = foundations.filter((f) => {
if (!hasGesuchPage(f)) return false;
if (PRIORITY_FILTER > 0) {
const computed = computePriorityScore(f);
@@ -77,8 +78,11 @@ async function main() {
// Check 2: Does foundationBridge mention the foundation name?
if (composed.ready) {
const bridge = composed.foundationBridge.toLowerCase();
- const nameParts = foundation.name.toLowerCase().split(/[\s-]+/).filter(p => p.length > 3);
- const mentionsFoundation = nameParts.some(part => bridge.includes(part));
+ const nameParts = foundation.name
+ .toLowerCase()
+ .split(/[\s-]+/)
+ .filter((p) => p.length > 3);
+ const mentionsFoundation = nameParts.some((part) => bridge.includes(part));
if (!mentionsFoundation) {
issues.push(`Bridge text does not mention foundation name (generic bridge)`);
}
@@ -107,12 +111,16 @@ async function main() {
// Check 5: Research notes quality
if (!foundation.researchNotes || foundation.researchNotes.length < 250) {
- issues.push(`Research notes thin (${foundation.researchNotes?.length || 0} chars, need 250+)`);
+ issues.push(
+ `Research notes thin (${foundation.researchNotes?.length || 0} chars, need 250+)`,
+ );
}
// Check 6: Purpose summary quality
if (!foundation.purposeSummary || foundation.purposeSummary.length < 150) {
- issues.push(`Purpose summary thin (${foundation.purposeSummary?.length || 0} chars, need 150+)`);
+ issues.push(
+ `Purpose summary thin (${foundation.purposeSummary?.length || 0} chars, need 150+)`,
+ );
}
// Check 7: Website URL present and not a registry/directory site
@@ -123,11 +131,11 @@ async function main() {
// Compute quality score (100 = perfect, 0 = terrible)
let score = 100;
if (!composed.ready) score -= 50;
- if (issues.some(i => i.includes('Bridge text'))) score -= 15;
- if (issues.some(i => i.includes('No contact'))) score -= 10;
- if (issues.some(i => i.includes('Research notes thin'))) score -= 10;
- if (issues.some(i => i.includes('Purpose summary thin'))) score -= 10;
- if (issues.some(i => i.includes('No real website'))) score -= 5;
+ if (issues.some((i) => i.includes('Bridge text'))) score -= 15;
+ if (issues.some((i) => i.includes('No contact'))) score -= 10;
+ if (issues.some((i) => i.includes('Research notes thin'))) score -= 10;
+ if (issues.some((i) => i.includes('Purpose summary thin'))) score -= 10;
+ if (issues.some((i) => i.includes('No real website'))) score -= 5;
score = Math.max(0, score);
if (issues.length > 0) {
@@ -149,13 +157,20 @@ async function main() {
const outDir = path.resolve('research/audit');
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
const outFile = path.join(outDir, `${today}.json`);
- fs.writeFileSync(outFile, JSON.stringify({
- date: today,
- totalChecked: gesuchFoundations.length,
- totalIssues: auditResults.length,
- perfect: gesuchFoundations.length - auditResults.length,
- results: auditResults,
- }, null, 2));
+ fs.writeFileSync(
+ outFile,
+ JSON.stringify(
+ {
+ date: today,
+ totalChecked: gesuchFoundations.length,
+ totalIssues: auditResults.length,
+ perfect: gesuchFoundations.length - auditResults.length,
+ results: auditResults,
+ },
+ null,
+ 2,
+ ),
+ );
console.log(`Audit written to: ${outFile}`);
return;
}
@@ -166,7 +181,7 @@ async function main() {
// Group by priority
for (const p of [1, 2, 3]) {
- const items = auditResults.filter(r => r.priority === p);
+ const items = auditResults.filter((r) => r.priority === p);
if (items.length === 0) continue;
console.log(`--- P${p} (${items.length} with issues) ---`);
@@ -181,7 +196,7 @@ async function main() {
// Customization candidates (P1/P2 foundations with generic bridges)
const needsCustomization = auditResults.filter(
- r => r.priority <= 2 && r.issues.some(i => i.includes('Bridge text'))
+ (r) => r.priority <= 2 && r.issues.some((i) => i.includes('Bridge text')),
);
if (needsCustomization.length > 0) {
console.log(`=== CUSTOMIZATION NEEDED (${needsCustomization.length} P1/P2 foundations) ===`);
diff --git a/scripts/ingest-triage.ts b/scripts/ingest-triage.ts
index 2276ba1c..8c5339a4 100644
--- a/scripts/ingest-triage.ts
+++ b/scripts/ingest-triage.ts
@@ -14,7 +14,7 @@ import { computeFitScore } from '../src/lib/domain/fit-scoring.js';
import { ThemeId } from '../src/lib/schemas/foundation.js';
const DRY_RUN = process.argv.includes('--dry-run');
-const INPUT_FILE = process.argv.find(a => !a.startsWith('-') && a.endsWith('.json'));
+const INPUT_FILE = process.argv.find((a) => !a.startsWith('-') && a.endsWith('.json'));
const VALID_THEME_IDS: Set = new Set(ThemeId.options);
@@ -56,7 +56,7 @@ async function main() {
for (const entry of entries) {
// Validate themes
- const validThemes = entry.themes.filter(t => VALID_THEME_IDS.has(t));
+ const validThemes = entry.themes.filter((t) => VALID_THEME_IDS.has(t));
// Skip entries without useful data
if (!entry.purposeSummary || entry.purposeSummary.length < 30) {
@@ -148,12 +148,18 @@ async function main() {
const existing = JSON.parse(fs.readFileSync(resultsFile, 'utf-8'));
const existingSlugs = new Set(existing.items.map((r: any) => r.slug));
const newItems = entries
- .filter(e => !existingSlugs.has(e.slug) && e.purposeSummary?.length >= 30)
- .map(e => ({
+ .filter((e) => !existingSlugs.has(e.slug) && e.purposeSummary?.length >= 30)
+ .map((e) => ({
slug: e.slug,
name: e.name,
- fitScore: computeFitScore({ themes: e.themes.filter(t => VALID_THEME_IDS.has(t)), canton: '', city: '', applicationMethod: e.applicationMethod || 'unknown', isFunder: e.isFunder }).fitScore,
- themes: e.themes.filter(t => VALID_THEME_IDS.has(t)),
+ fitScore: computeFitScore({
+ themes: e.themes.filter((t) => VALID_THEME_IDS.has(t)),
+ canton: '',
+ city: '',
+ applicationMethod: e.applicationMethod || 'unknown',
+ isFunder: e.isFunder,
+ }).fitScore,
+ themes: e.themes.filter((t) => VALID_THEME_IDS.has(t)),
purposeSummary: e.purposeSummary,
researchNotes: e.researchNotes || '',
isFunder: e.isFunder,
@@ -166,4 +172,7 @@ async function main() {
console.log(`\nDone: ${upserted} upserted, ${skipped} skipped, ${errors} errors`);
}
-main().catch(err => { console.error(err); process.exit(1); });
+main().catch((err) => {
+ console.error(err);
+ process.exit(1);
+});
diff --git a/scripts/lib/contact-extractor.ts b/scripts/lib/contact-extractor.ts
index 3149fd38..2a035f27 100644
--- a/scripts/lib/contact-extractor.ts
+++ b/scripts/lib/contact-extractor.ts
@@ -42,8 +42,8 @@ export function extractEmails(text: string): string[] {
const matches = text.match(EMAIL_REGEX) || [];
// Deduplicate and filter out blacklisted patterns
- const unique = Array.from(new Set(matches.map(e => e.toLowerCase())));
- return unique.filter(email => {
+ const unique = Array.from(new Set(matches.map((e) => e.toLowerCase())));
+ return unique.filter((email) => {
// Filter out image/file extensions mistakenly captured
if (/\.(png|jpg|jpeg|gif|svg|css|js|pdf)$/i.test(email)) return false;
// Filter out blacklisted prefixes
@@ -62,19 +62,22 @@ export function extractEmails(text: string): string[] {
// +41 XX XXX XX XX, +41 (0)XX XXX XX XX
// 0XX XXX XX XX, 0XX/XXX XX XX
// Also accepts dots and dashes as separators
-const SWISS_PHONE_REGEX = /(?:\+41|0041)[\s.]?\(?\d?\)?[\s./\-]?\d{2}[\s./\-]?\d{3}[\s./\-]?\d{2}[\s./\-]?\d{2}|0\d{2}[\s./\-]?\d{3}[\s./\-]?\d{2}[\s./\-]?\d{2}/g;
+const SWISS_PHONE_REGEX =
+ /(?:\+41|0041)[\s.]?\(?\d?\)?[\s./\-]?\d{2}[\s./\-]?\d{3}[\s./\-]?\d{2}[\s./\-]?\d{2}|0\d{2}[\s./\-]?\d{3}[\s./\-]?\d{2}[\s./\-]?\d{2}/g;
export function extractPhones(text: string): string[] {
const matches = text.match(SWISS_PHONE_REGEX) || [];
// Normalize: collapse whitespace, standardize format
- const normalized = matches.map(p => {
- // Remove all whitespace/separators to get digits
- const digits = p.replace(/[^\d+]/g, '');
- // Skip if too short (false positive)
- if (digits.replace('+', '').length < 10) return null;
- return p.trim();
- }).filter(Boolean) as string[];
+ const normalized = matches
+ .map((p) => {
+ // Remove all whitespace/separators to get digits
+ const digits = p.replace(/[^\d+]/g, '');
+ // Skip if too short (false positive)
+ if (digits.replace('+', '').length < 10) return null;
+ return p.trim();
+ })
+ .filter(Boolean) as string[];
return Array.from(new Set(normalized));
}
@@ -124,7 +127,9 @@ const EMAIL_METHOD_KEYWORDS = [
'par courriel',
];
-export function detectApplicationMethod(text: string): 'online' | 'email' | 'invitation' | 'unknown' {
+export function detectApplicationMethod(
+ text: string,
+): 'online' | 'email' | 'invitation' | 'unknown' {
const lower = text.toLowerCase();
// Check invitation first — it's the strongest signal
@@ -156,8 +161,8 @@ function parseSwissAmount(raw: string): number | null {
// Extract just the numeric part
const numStr = raw
.replace(/CHF|Fr\.?|Franken/gi, '')
- .replace(/['\s]/g, '') // Swiss thousand separator is apostrophe
- .replace(/,(\d{2})$/, '.$1') // Convert comma decimal to dot
+ .replace(/['\s]/g, '') // Swiss thousand separator is apostrophe
+ .replace(/,(\d{2})$/, '.$1') // Convert comma decimal to dot
.trim();
const num = parseFloat(numStr);
@@ -169,10 +174,21 @@ export function extractGrantRange(text: string): { min?: number; max?: number }
// Only look near funding-related context
const fundingContext = text.toLowerCase();
const hasFundingKeywords = [
- 'beitrag', 'beiträge', 'förder', 'unterstütz', 'zuwendung',
- 'grant', 'contribution', 'subvention', 'betrag',
- 'minimum', 'maximum', 'höchstens', 'mindestens', 'bis zu',
- ].some(kw => fundingContext.includes(kw));
+ 'beitrag',
+ 'beiträge',
+ 'förder',
+ 'unterstütz',
+ 'zuwendung',
+ 'grant',
+ 'contribution',
+ 'subvention',
+ 'betrag',
+ 'minimum',
+ 'maximum',
+ 'höchstens',
+ 'mindestens',
+ 'bis zu',
+ ].some((kw) => fundingContext.includes(kw));
if (!hasFundingKeywords) return {};
diff --git a/scripts/lib/directory-fetcher.ts b/scripts/lib/directory-fetcher.ts
index de99d6e1..5f041c65 100644
--- a/scripts/lib/directory-fetcher.ts
+++ b/scripts/lib/directory-fetcher.ts
@@ -12,12 +12,21 @@ const SPHERIQ_BASE = 'https://app.spheriq.ch/organisation';
const SPHERIQ_PHONE = '+41 61 278 93 83'; // Spheriq's own phone, filter it
const NOISE_EMAILS = new Set([
- 'office@spheriq.ch', 'support@spheriq.ch', 'kontakt@freihandlabor.com',
+ 'office@spheriq.ch',
+ 'support@spheriq.ch',
+ 'kontakt@freihandlabor.com',
]);
const NOISE_DOMAINS = new Set([
- 'spheriq.ch', 'stiftungschweiz.ch', 'google.com', 'facebook.com',
- 'twitter.com', 'linkedin.com', 'sentry.io', 'wixpress.com',
- 'example.com', 'wordpress.com',
+ 'spheriq.ch',
+ 'stiftungschweiz.ch',
+ 'google.com',
+ 'facebook.com',
+ 'twitter.com',
+ 'linkedin.com',
+ 'sentry.io',
+ 'wixpress.com',
+ 'example.com',
+ 'wordpress.com',
]);
export interface DirectoryResult {
@@ -57,14 +66,21 @@ function parseSpheriqHtml(html: string): Omit p.trim()))].filter(p => p !== SPHERIQ_PHONE);
+ const phoneMatches =
+ html.match(
+ /(?:\+41|0041)[\s.]?\(?\d?\)?[\s./\-]?\d{2}[\s./\-]?\d{3}[\s./\-]?\d{2}[\s./\-]?\d{2}/g,
+ ) || [];
+ const unique = [...new Set(phoneMatches.map((p) => p.trim()))].filter((p) => p !== SPHERIQ_PHONE);
if (unique.length > 0) result.phone = unique[0];
return result;
@@ -87,7 +103,10 @@ async function fetchWithTimeout(url: string, timeoutMs = 8000): Promise {
+export async function fetchSpheriqProfile(
+ slug: string,
+ uid?: string,
+): Promise {
// Try direct slug match
const directRes = await fetchWithTimeout(`${SPHERIQ_BASE}/${slug}`);
if (directRes?.ok) {
@@ -106,7 +125,9 @@ export async function fetchSpheriqProfile(slug: string, uid?: string): Promise callGroqOnce(link.model, apiKey, systemPrompt, userPrompt, callOnceOptions),
+ attempt: (link) =>
+ callGroqOnce(link.model, apiKey, systemPrompt, userPrompt, callOnceOptions),
});
return { ok: true, content, usage };
} catch (err) {
diff --git a/scripts/lib/research-types.ts b/scripts/lib/research-types.ts
index 522f0662..81039d86 100644
--- a/scripts/lib/research-types.ts
+++ b/scripts/lib/research-types.ts
@@ -25,8 +25,22 @@ export const ResearchAnalysisSchema = z.object({
suggestedFit: z.number().int().min(0).max(3), // 0 = not assessed (bulk enrichment)
suggestedPriority: z.number().int().min(1).max(4),
purposeSummary: z.string().min(20), // relaxed for auto-generated summaries
- researchNotes: z.string().min(20), // relaxed for auto-generated notes
- applicationMethod: z.enum(['online', 'email', 'invitation', 'unknown', 'post', 'contact', 'direct', 'personal', 'partnership', 'via_partner', 'membership', 'contract', 'none']),
+ researchNotes: z.string().min(20), // relaxed for auto-generated notes
+ applicationMethod: z.enum([
+ 'online',
+ 'email',
+ 'invitation',
+ 'unknown',
+ 'post',
+ 'contact',
+ 'direct',
+ 'personal',
+ 'partnership',
+ 'via_partner',
+ 'membership',
+ 'contract',
+ 'none',
+ ]),
contactInfo: z.object({
email: z.string().optional(),
phone: z.string().optional(),
@@ -67,12 +81,14 @@ export const ResearchQueueItemSchema = z.object({
foundVia: z.array(z.string()).optional(),
url: z.string().optional(),
}),
- scores: z.object({
- funderScore: z.number(),
- operatorScore: z.number(),
- socialScore: z.number(),
- confidence: z.enum(['high', 'medium', 'low']),
- }).optional(),
+ scores: z
+ .object({
+ funderScore: z.number(),
+ operatorScore: z.number(),
+ socialScore: z.number(),
+ confidence: z.enum(['high', 'medium', 'low']),
+ })
+ .optional(),
});
export type ResearchQueueItem = z.infer;
@@ -81,14 +97,16 @@ export type ResearchQueueItem = z.infer;
// RESEARCH DRAFT — Full draft ready for human review
// ============================================================================
-export const ResearchDraftSchema = z.object({
- slug: z.string(),
- name: z.string(),
- timestamp: z.string(),
- queueItem: ResearchQueueItemSchema,
- websiteContent: z.string().optional(),
- analysis: ResearchAnalysisSchema,
-}).passthrough(); // Allow _meta from batch-generated drafts
+export const ResearchDraftSchema = z
+ .object({
+ slug: z.string(),
+ name: z.string(),
+ timestamp: z.string(),
+ queueItem: ResearchQueueItemSchema,
+ websiteContent: z.string().optional(),
+ analysis: ResearchAnalysisSchema,
+ })
+ .passthrough(); // Allow _meta from batch-generated drafts
export type ResearchDraft = z.infer;
diff --git a/scripts/lib/source-reconciler.ts b/scripts/lib/source-reconciler.ts
index 5a15ed19..b192a77f 100644
--- a/scripts/lib/source-reconciler.ts
+++ b/scripts/lib/source-reconciler.ts
@@ -8,8 +8,8 @@
export interface ContactCandidate {
email?: string;
phone?: string;
- source: string; // e.g., 'spheriq', 'website:/kontakt', 'website:/impressum'
- sourceUrl: string; // the URL where this was found
+ source: string; // e.g., 'spheriq', 'website:/kontakt', 'website:/impressum'
+ sourceUrl: string; // the URL where this was found
}
export type Confidence = 'high' | 'medium' | 'low';
@@ -22,13 +22,21 @@ export interface ReconciledContact {
phoneSource?: string;
phoneConfidence?: Confidence;
allSources: ContactCandidate[];
- conflicts: string[]; // descriptions of disagreements
+ conflicts: string[]; // descriptions of disagreements
}
// Known garbage patterns
const GARBAGE_EMAIL = /sentry|wixpress|gta-design|\.png|\.jpg|\.webp|\.css|\.js|[{}\\]/i;
const GARBAGE_PHONE = /\+41 000|\+41 123456|00000|99999/;
-const PREFERRED_PREFIXES = ['info@', 'kontakt@', 'contact@', 'stiftung@', 'sekretariat@', 'office@', 'mail@'];
+const PREFERRED_PREFIXES = [
+ 'info@',
+ 'kontakt@',
+ 'contact@',
+ 'stiftung@',
+ 'sekretariat@',
+ 'office@',
+ 'mail@',
+];
function isGarbageEmail(email: string): boolean {
return GARBAGE_EMAIL.test(email) || email.length < 5 || email.length > 60 || !email.includes('@');
@@ -41,7 +49,7 @@ function isGarbagePhone(phone: string): boolean {
/** Score an email for preference (lower = better) */
function emailPreferenceScore(email: string): number {
const lower = email.toLowerCase();
- const idx = PREFERRED_PREFIXES.findIndex(p => lower.startsWith(p));
+ const idx = PREFERRED_PREFIXES.findIndex((p) => lower.startsWith(p));
return idx >= 0 ? idx : PREFERRED_PREFIXES.length;
}
@@ -59,30 +67,32 @@ export function reconcileContacts(candidates: ContactCandidate[]): ReconciledCon
// Collect all non-garbage emails with their sources
const emails = candidates
- .filter(c => c.email && !isGarbageEmail(c.email))
- .map(c => ({ email: c.email!.toLowerCase(), source: c.source, sourceUrl: c.sourceUrl }));
+ .filter((c) => c.email && !isGarbageEmail(c.email))
+ .map((c) => ({ email: c.email!.toLowerCase(), source: c.source, sourceUrl: c.sourceUrl }));
// Collect all non-garbage phones
const phones = candidates
- .filter(c => c.phone && !isGarbagePhone(c.phone))
- .map(c => ({ phone: c.phone!, source: c.source, sourceUrl: c.sourceUrl }));
+ .filter((c) => c.phone && !isGarbagePhone(c.phone))
+ .map((c) => ({ phone: c.phone!, source: c.source, sourceUrl: c.sourceUrl }));
// Reconcile emails
if (emails.length > 0) {
- const uniqueEmails = [...new Set(emails.map(e => e.email))];
+ const uniqueEmails = [...new Set(emails.map((e) => e.email))];
if (uniqueEmails.length === 1) {
// All sources agree
result.email = uniqueEmails[0];
- result.emailSource = emails.map(e => e.source).join('+');
+ result.emailSource = emails.map((e) => e.source).join('+');
result.emailConfidence = emails.length > 1 ? 'high' : 'medium';
} else {
// Sources disagree — pick best, flag conflict
result.conflicts.push(
- `Email conflict: ${uniqueEmails.map((e, _i) => `${e} (${emails.find(x => x.email === e)!.source})`).join(' vs ')}`
+ `Email conflict: ${uniqueEmails.map((e, _i) => `${e} (${emails.find((x) => x.email === e)!.source})`).join(' vs ')}`,
);
// Pick by preference (info@ > kontakt@ > first found)
- const sorted = emails.sort((a, b) => emailPreferenceScore(a.email) - emailPreferenceScore(b.email));
+ const sorted = emails.sort(
+ (a, b) => emailPreferenceScore(a.email) - emailPreferenceScore(b.email),
+ );
result.email = sorted[0].email;
result.emailSource = sorted[0].source;
result.emailConfidence = 'low';
@@ -93,15 +103,15 @@ export function reconcileContacts(candidates: ContactCandidate[]): ReconciledCon
if (phones.length > 0) {
// Normalize for comparison
const normalize = (p: string) => p.replace(/[\s.\-()]/g, '');
- const uniquePhones = [...new Set(phones.map(p => normalize(p.phone)))];
+ const uniquePhones = [...new Set(phones.map((p) => normalize(p.phone)))];
if (uniquePhones.length === 1) {
result.phone = phones[0].phone;
- result.phoneSource = phones.map(p => p.source).join('+');
+ result.phoneSource = phones.map((p) => p.source).join('+');
result.phoneConfidence = phones.length > 1 ? 'high' : 'medium';
} else {
result.conflicts.push(
- `Phone conflict: ${phones.map(p => `${p.phone} (${p.source})`).join(' vs ')}`
+ `Phone conflict: ${phones.map((p) => `${p.phone} (${p.source})`).join(' vs ')}`,
);
result.phone = phones[0].phone;
result.phoneSource = phones[0].source;
diff --git a/scripts/lib/theme-classifier.ts b/scripts/lib/theme-classifier.ts
index 2e36995f..58a59473 100644
--- a/scripts/lib/theme-classifier.ts
+++ b/scripts/lib/theme-classifier.ts
@@ -16,52 +16,122 @@ import type { ThemeId } from '../../src/lib/schemas/foundation';
export const THEME_RULES: Record = {
arbeitsintegration: {
- keywords: ['arbeitsintegration', 'berufliche integration', 'arbeitsmarkt',
- 'wiedereingliederung', 'beschäftigungsprogramm', 'arbeitstraining',
- 'langzeitarbeitslos', 'sozialfirma', 'sozialer betrieb',
- 'berufseinstieg', 'lehrstelle', 'berufsbildung', 'berufsausbildung',
- 'umschulung', 'weiterbildung', 'qualifizierung',
- 'sozialer arbeitsmarkt', 'beschäftigungsmassnahme',
- 'sozialunternehmen', 'werkstätte'],
+ keywords: [
+ 'arbeitsintegration',
+ 'berufliche integration',
+ 'arbeitsmarkt',
+ 'wiedereingliederung',
+ 'beschäftigungsprogramm',
+ 'arbeitstraining',
+ 'langzeitarbeitslos',
+ 'sozialfirma',
+ 'sozialer betrieb',
+ 'berufseinstieg',
+ 'lehrstelle',
+ 'berufsbildung',
+ 'berufsausbildung',
+ 'umschulung',
+ 'weiterbildung',
+ 'qualifizierung',
+ 'sozialer arbeitsmarkt',
+ 'beschäftigungsmassnahme',
+ 'sozialunternehmen',
+ 'werkstätte',
+ ],
weight: 3,
},
kreislaufwirtschaft: {
- keywords: ['kreislaufwirtschaft', 'recycling', 'wiederverwend', 'reparatur',
- 'refurbish', 'ressourcenschon', 'zirkulär', 'elektroschrott', 'e-waste',
- 'secondhand', 'second-hand', 'wiederaufbereitung', 'abfallvermeidung',
- 'zero waste', 'materialkreislauf'],
+ keywords: [
+ 'kreislaufwirtschaft',
+ 'recycling',
+ 'wiederverwend',
+ 'reparatur',
+ 'refurbish',
+ 'ressourcenschon',
+ 'zirkulär',
+ 'elektroschrott',
+ 'e-waste',
+ 'secondhand',
+ 'second-hand',
+ 'wiederaufbereitung',
+ 'abfallvermeidung',
+ 'zero waste',
+ 'materialkreislauf',
+ ],
weight: 3,
},
'soziale-integration': {
- keywords: ['soziale integration', 'sozial benachteiligt',
- 'migrant', 'migration', 'flüchtling', 'asyl',
- 'marginalisiert', 'randgruppen',
- 'existenzsicher', 'sozialhilfeempfänger',
- 'arbeitsintegration', 'berufliche integration',
- 'soziale teilhabe', 'chancengleichheit',
- 'inklusion', 'teilhabe', 'benachteiligt',
- 'armut', 'existenzsicherung'],
+ keywords: [
+ 'soziale integration',
+ 'sozial benachteiligt',
+ 'migrant',
+ 'migration',
+ 'flüchtling',
+ 'asyl',
+ 'marginalisiert',
+ 'randgruppen',
+ 'existenzsicher',
+ 'sozialhilfeempfänger',
+ 'arbeitsintegration',
+ 'berufliche integration',
+ 'soziale teilhabe',
+ 'chancengleichheit',
+ 'inklusion',
+ 'teilhabe',
+ 'benachteiligt',
+ 'armut',
+ 'existenzsicherung',
+ ],
weight: 2,
},
'digitale-bildung': {
- keywords: ['informatik', 'programmier', 'software',
- 'medienkompetenz', 'it-kompetenz', 'digital literacy',
- 'digitale kompetenz', 'digitale bildung',
- 'ict', 'informationstechnologie', 'mint', 'stem',
- 'digitale teilhabe', 'it-bildung', 'it-ausbildung'],
+ keywords: [
+ 'informatik',
+ 'programmier',
+ 'software',
+ 'medienkompetenz',
+ 'it-kompetenz',
+ 'digital literacy',
+ 'digitale kompetenz',
+ 'digitale bildung',
+ 'ict',
+ 'informationstechnologie',
+ 'mint',
+ 'stem',
+ 'digitale teilhabe',
+ 'it-bildung',
+ 'it-ausbildung',
+ ],
weight: 2,
},
'digitale-souveraenitaet': {
- keywords: ['open source', 'quelloffen', 'linux', 'datensouveränität',
- 'digitale selbstbestimmung', 'freie software', 'open data',
- 'datensicher', 'souverän'],
+ keywords: [
+ 'open source',
+ 'quelloffen',
+ 'linux',
+ 'datensouveränität',
+ 'digitale selbstbestimmung',
+ 'freie software',
+ 'open data',
+ 'datensicher',
+ 'souverän',
+ ],
weight: 3,
},
klima: {
- keywords: ['klima', 'klimaschutz', 'klimawandel',
- 'co2', 'kohlendioxid', 'treibhausgas', 'emission',
- 'erneuerbar', 'erneuerbare energie',
- 'dekarbonisier', 'energiewende'],
+ keywords: [
+ 'klima',
+ 'klimaschutz',
+ 'klimawandel',
+ 'co2',
+ 'kohlendioxid',
+ 'treibhausgas',
+ 'emission',
+ 'erneuerbar',
+ 'erneuerbare energie',
+ 'dekarbonisier',
+ 'energiewende',
+ ],
weight: 1,
},
zuerich: {
@@ -79,36 +149,51 @@ export function classifyThemes(purposeLower: string, nameLower: string): ThemeId
const fullText = `${nameLower} ${purposeLower}`;
for (const [theme, rule] of Object.entries(THEME_RULES)) {
- if (rule.keywords.some(kw => fullText.includes(kw))) {
+ if (rule.keywords.some((kw) => fullText.includes(kw))) {
themes.push(theme);
}
}
// Compound: education + digital → digitale-bildung (STRICT: must be IT/tech education)
if (!themes.includes('digitale-bildung')) {
- const hasEdu = ['bildung', 'ausbildung', 'weiterbildung', 'berufsbildung', 'schulung'].some(kw => fullText.includes(kw));
- const hasDig = ['informatik', 'computer', 'it-', 'software', 'programmier', 'coding'].some(kw => fullText.includes(kw));
+ const hasEdu = ['bildung', 'ausbildung', 'weiterbildung', 'berufsbildung', 'schulung'].some(
+ (kw) => fullText.includes(kw),
+ );
+ const hasDig = ['informatik', 'computer', 'it-', 'software', 'programmier', 'coding'].some(
+ (kw) => fullText.includes(kw),
+ );
if (hasEdu && hasDig) themes.push('digitale-bildung');
}
// Compound: work + integration → arbeitsintegration (STRICT: must mention integration/reintegration)
if (!themes.includes('arbeitsintegration')) {
- const hasWork = ['beruf', 'arbeit', 'erwerbstätig', 'beschäftig'].some(kw => fullText.includes(kw));
- const hasInt = ['arbeitsintegration', 'berufliche integration', 'eingliederung', 'wiedereingliederung'].some(kw => fullText.includes(kw));
+ const hasWork = ['beruf', 'arbeit', 'erwerbstätig', 'beschäftig'].some((kw) =>
+ fullText.includes(kw),
+ );
+ const hasInt = [
+ 'arbeitsintegration',
+ 'berufliche integration',
+ 'eingliederung',
+ 'wiedereingliederung',
+ ].some((kw) => fullText.includes(kw));
if (hasWork && hasInt) themes.push('arbeitsintegration');
}
// Compound: beschäftigung + integration → arbeitsintegration (STRICT)
if (!themes.includes('arbeitsintegration')) {
const hasBeschaeftigung = fullText.includes('beschäftigung');
- const hasInt = ['integration', 'eingliederung', 'massnahme'].some(kw => fullText.includes(kw));
+ const hasInt = ['integration', 'eingliederung', 'massnahme'].some((kw) =>
+ fullText.includes(kw),
+ );
if (hasBeschaeftigung && hasInt) themes.push('arbeitsintegration');
}
// Compound: nachhaltigkeit + consumption/resource signal → kreislaufwirtschaft
if (!themes.includes('kreislaufwirtschaft')) {
const hasNachhaltigkeit = fullText.includes('nachhaltigkeit');
- const hasResourceSignal = ['konsum', 'ressource', 'produkt', 'kreislauf'].some(kw => fullText.includes(kw));
+ const hasResourceSignal = ['konsum', 'ressource', 'produkt', 'kreislauf'].some((kw) =>
+ fullText.includes(kw),
+ );
if (hasNachhaltigkeit && hasResourceSignal) themes.push('kreislaufwirtschaft');
}
@@ -124,27 +209,56 @@ export function classifyThemes(purposeLower: string, nameLower: string): ThemeId
// ============================================================================
const FUNDER_KW = [
- 'fördert', 'förderung', 'unterstützt', 'unterstützung',
- 'beiträge', 'zuwendungen', 'zuschüsse', 'finanzielle hilfe',
- 'projektförderung', 'stipend', 'ausricht',
- 'gewährt', 'vergab', 'vergibt', 'zusprech',
- 'finanziell', 'gemeinnützig',
+ 'fördert',
+ 'förderung',
+ 'unterstützt',
+ 'unterstützung',
+ 'beiträge',
+ 'zuwendungen',
+ 'zuschüsse',
+ 'finanzielle hilfe',
+ 'projektförderung',
+ 'stipend',
+ 'ausricht',
+ 'gewährt',
+ 'vergab',
+ 'vergibt',
+ 'zusprech',
+ 'finanziell',
+ 'gemeinnützig',
];
const OPERATOR_KW = [
- 'betreibt', 'führt', 'unterhält', 'verwaltet',
- 'betrieb von', 'heim', 'klinik', 'spital',
- 'pflegeheim', 'altersheim', 'werkstatt', 'wohnheim',
- 'kinderheim', 'tagesstätte', 'krippe', 'trägerin',
- 'museum', 'theater',
+ 'betreibt',
+ 'führt',
+ 'unterhält',
+ 'verwaltet',
+ 'betrieb von',
+ 'heim',
+ 'klinik',
+ 'spital',
+ 'pflegeheim',
+ 'altersheim',
+ 'werkstatt',
+ 'wohnheim',
+ 'kinderheim',
+ 'tagesstätte',
+ 'krippe',
+ 'trägerin',
+ 'museum',
+ 'theater',
];
export function scoreFunderOperator(purpose: string): { funder: number; operator: number } {
const p = purpose.toLowerCase();
let funder = 0;
let operator = 0;
- for (const kw of FUNDER_KW) { if (p.includes(kw)) funder++; }
- for (const kw of OPERATOR_KW) { if (p.includes(kw)) operator++; }
+ for (const kw of FUNDER_KW) {
+ if (p.includes(kw)) funder++;
+ }
+ for (const kw of OPERATOR_KW) {
+ if (p.includes(kw)) operator++;
+ }
return { funder, operator };
}
@@ -155,7 +269,9 @@ export function scoreFunderOperator(purpose: string): { funder: number; operator
export function toSlug(name: string): string {
return name
.toLowerCase()
- .replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue')
+ .replace(/ä/g, 'ae')
+ .replace(/ö/g, 'oe')
+ .replace(/ü/g, 'ue')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.substring(0, 60);
@@ -165,11 +281,29 @@ export function toSlug(name: string): string {
// APPLICATION METHOD DETECTION
// ============================================================================
-export function detectApplicationMethod(purpose: string): 'online' | 'email' | 'invitation' | 'unknown' {
+export function detectApplicationMethod(
+ purpose: string,
+): 'online' | 'email' | 'invitation' | 'unknown' {
const p = purpose.toLowerCase();
- if (p.includes('online') && (p.includes('gesuch') || p.includes('antrag') || p.includes('bewerbung'))) return 'online';
- if (p.includes('auf einladung') || p.includes('nur auf') || p.includes('kein gesuch') || p.includes('keine bewerbung')) return 'invitation';
- if (p.includes('schriftlich') || p.includes('per mail') || p.includes('per post') || p.includes('per e-mail')) return 'email';
+ if (
+ p.includes('online') &&
+ (p.includes('gesuch') || p.includes('antrag') || p.includes('bewerbung'))
+ )
+ return 'online';
+ if (
+ p.includes('auf einladung') ||
+ p.includes('nur auf') ||
+ p.includes('kein gesuch') ||
+ p.includes('keine bewerbung')
+ )
+ return 'invitation';
+ if (
+ p.includes('schriftlich') ||
+ p.includes('per mail') ||
+ p.includes('per post') ||
+ p.includes('per e-mail')
+ )
+ return 'email';
return 'unknown';
}
@@ -186,18 +320,36 @@ export function classifyType(
const p = purpose.toLowerCase();
const n = name.toLowerCase();
- if (n.includes('ag') || n.includes('group') || n.includes('corporate') || n.includes('company')) return 'D';
- if (n.includes('verband') || n.includes('verein') || n.includes('netzwerk') || n.includes('dachverband')) return 'network';
+ if (n.includes('ag') || n.includes('group') || n.includes('corporate') || n.includes('company'))
+ return 'D';
+ if (
+ n.includes('verband') ||
+ n.includes('verein') ||
+ n.includes('netzwerk') ||
+ n.includes('dachverband')
+ )
+ return 'network';
- if (funderScore >= 3 && purpose.length > 200 && (
- p.includes('förderrichtlinien') || p.includes('programm') ||
- p.includes('bewerbungsverfahren') || p.includes('ausschreibung') || p.includes('gesuche')
- )) return 'A';
+ if (
+ funderScore >= 3 &&
+ purpose.length > 200 &&
+ (p.includes('förderrichtlinien') ||
+ p.includes('programm') ||
+ p.includes('bewerbungsverfahren') ||
+ p.includes('ausschreibung') ||
+ p.includes('gesuche'))
+ )
+ return 'A';
- if (funderScore >= 2 && purpose.length > 100 && (
- p.includes('famili') || n.includes('famili') ||
- /\b[A-Z][a-z]+-[A-Z][a-z]+\b/.test(name) || n.includes('-stiftung')
- )) return 'B';
+ if (
+ funderScore >= 2 &&
+ purpose.length > 100 &&
+ (p.includes('famili') ||
+ n.includes('famili') ||
+ /\b[A-Z][a-z]+-[A-Z][a-z]+\b/.test(name) ||
+ n.includes('-stiftung'))
+ )
+ return 'B';
if (operatorScore >= 3) return 'network';
return purpose.length > 200 && funderScore >= 2 ? 'B' : 'C';
diff --git a/scripts/lib/web-extract.ts b/scripts/lib/web-extract.ts
index 51ecab34..c2de90b1 100644
--- a/scripts/lib/web-extract.ts
+++ b/scripts/lib/web-extract.ts
@@ -45,7 +45,9 @@ export function fetchRaw(url: string, redirectCount = 0): Promise {
const absolute = redirectUrl.startsWith('http')
? redirectUrl
: new URL(redirectUrl, url).href;
- fetchRaw(absolute, redirectCount + 1).then(resolve).catch(reject);
+ fetchRaw(absolute, redirectCount + 1)
+ .then(resolve)
+ .catch(reject);
return;
}
}
@@ -56,7 +58,9 @@ export function fetchRaw(url: string, redirectCount = 0): Promise {
}
let data = '';
- response.on('data', (chunk: Buffer) => { data += chunk; });
+ response.on('data', (chunk: Buffer) => {
+ data += chunk;
+ });
response.on('end', () => resolve(data));
});
@@ -72,24 +76,26 @@ export function fetchRaw(url: string, redirectCount = 0): Promise {
* Strip HTML tags, scripts, styles, and normalize whitespace.
*/
export function stripHtml(html: string): string {
- return html
- // Remove script/style blocks entirely
- .replace(/