feat: migrate npm registry from Verdaccio to Gitea Packages
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 1s
Monorepo Pipeline / 🧹 Lint (push) Failing after 35s
Monorepo Pipeline / 🧪 Test (push) Failing after 35s
Monorepo Pipeline / 🏗️ Build (push) Failing after 12s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build Image Processor (push) Has been skipped
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been skipped
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been skipped
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 1s
Monorepo Pipeline / 🧹 Lint (push) Failing after 35s
Monorepo Pipeline / 🧪 Test (push) Failing after 35s
Monorepo Pipeline / 🏗️ Build (push) Failing after 12s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build Image Processor (push) Has been skipped
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been skipped
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been skipped
This commit is contained in:
6
.env
6
.env
@@ -4,6 +4,12 @@ PROJECT_NAME=at-mintel
|
||||
PROJECT_COLOR=#82ed20
|
||||
GITEA_TOKEN=ccce002e30fe16a31a6c9d5a414740af2f72a582
|
||||
OPENROUTER_API_KEY=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
|
||||
ZYTE_API_KEY=1f0f74726f044f55aaafc7ead32cd489
|
||||
REPLICATE_API_KEY=r8_W3grtpXMRfi0u3AM9VdkKbuWdZMmhwU2Tn0yt
|
||||
SERPER_API_KEY=02f69a8db9578c41fb1c8ed9f7a999302da644ff
|
||||
DATA_FOR_SEO_API_KEY=bWFyY0BtaW50ZWwubWU6MjQ0YjBjZmIzOGY3NTIzZA==
|
||||
DATA_FOR_SEO_LOGIN=marc@mintel.me
|
||||
DATA_FOR_SEO_PASSWORD=244b0cfb38f7523d
|
||||
|
||||
# Authentication
|
||||
GATEKEEPER_PASSWORD=mintel
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -42,3 +42,8 @@ packages/cms-infra/extensions/
|
||||
packages/cms-infra/uploads/
|
||||
|
||||
directus/uploads/directus-health-file
|
||||
|
||||
# Estimation Engine Data
|
||||
data/crawls/
|
||||
packages/estimation-engine/out/
|
||||
apps/web/out/estimations/
|
||||
6
.npmrc
6
.npmrc
@@ -1,6 +1,6 @@
|
||||
@mintel:registry=https://npm.infra.mintel.me/
|
||||
registry=https://npm.infra.mintel.me/
|
||||
//npm.infra.mintel.me/:_authToken=${NPM_TOKEN}
|
||||
@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm/
|
||||
registry=https://git.infra.mintel.me/api/packages/mmintel/npm/
|
||||
//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${NPM_TOKEN}
|
||||
always-auth=true
|
||||
|
||||
public-hoist-pattern[]=*
|
||||
|
||||
246
data/briefings/etib.txt
Normal file
246
data/briefings/etib.txt
Normal file
@@ -0,0 +1,246 @@
|
||||
Hallo Marc,
|
||||
|
||||
eine harte Deadline gibt es nicht – Was denkst du ist realistisch? Ich habe als Ziel so
|
||||
April / Mai im Kopf -> dann aber schon zu 95 % fertig. Viele Grüße
|
||||
|
||||
Mit freundlichen Grüßen
|
||||
|
||||
Danny Joseph
|
||||
Geschäftsführer
|
||||
|
||||
E-TIB GmbH
|
||||
Gewerbestraße 22
|
||||
D-03172 Guben
|
||||
|
||||
Mobil +49 15207230518
|
||||
E-Mail d.joseph@e-tib.com
|
||||
Web www.e-tib.com
|
||||
|
||||
--------------------------------------------------------------------------------------------------
|
||||
|
||||
Hey,
|
||||
|
||||
ich würde wie bei https://www.schleicher-gruppe.de/ ein Video auf der Startseite
|
||||
haben wollen. Da ginge sicherlich was vom bisherigen Messevideo. Liebe Grüße.
|
||||
|
||||
Mit freundlichen Grüßen
|
||||
|
||||
Danny Joseph
|
||||
Geschäftsführer
|
||||
|
||||
E-TIB GmbH
|
||||
Gewerbestraße 22
|
||||
D-03172 Guben
|
||||
|
||||
Mobil +49 15207230518
|
||||
E-Mail d.joseph@e-tib.com
|
||||
Web www.e-tib.com
|
||||
|
||||
|
||||
--------------------------------------------------------------------------------------------------
|
||||
Geschäftsführung: Danny Joseph
|
||||
Handelsregister: Amtsgericht Cottbus
|
||||
HRB: 12403 CB
|
||||
USt. ID-Nr.: DE304799919
|
||||
--------------------------------------------------------------------------------------------------
|
||||
|
||||
Von: Frieder Helmich <f.helmich@etib-ing.com>
|
||||
Gesendet: Donnerstag, 29. Januar 2026 08:49
|
||||
An: Marc Mintel <marc@cablecreations.de>; Danny Joseph <d.joseph@e-tib.com>
|
||||
Betreff: AW: Homepage E-TIB
|
||||
|
||||
Hi Marc,
|
||||
|
||||
brauchst du nur Fotos oder bindest du auch videos ein? Wir haben sehr viel Videomaterial. Wir haben auch einen kleinen Film den wir auf der Messe laufen lassen haben.
|
||||
|
||||
Mit freundlichen Grüßen
|
||||
|
||||
i.A. Frieder Helmich
|
||||
|
||||
E-TIB Ingenieurgesellschaft mbH
|
||||
Kampstraße 3
|
||||
D-27412 Bülstedt
|
||||
|
||||
Tel +49 4283 6979923
|
||||
Mobil +49 173 6560514
|
||||
Fax +49 4283 6084091
|
||||
|
||||
E-Mail f.helmich@etib-ing.com
|
||||
Web www.etib-ing.com
|
||||
|
||||
ETIB_Ing_logo_mk
|
||||
Datenschutzhinweise: www.etib-ing.com/datenschutz
|
||||
-----------------------------------------------------------------------------------------------
|
||||
Geschäftsführung: Julian Helmich
|
||||
Handelsregister: Amtsgericht Tostedt
|
||||
HRB: 207158
|
||||
-----------------------------------------------------------------------------------------------
|
||||
|
||||
Von: Marc Mintel <marc@cablecreations.de>
|
||||
Gesendet: Mittwoch, 28. Januar 2026 18:10
|
||||
An: Danny Joseph <d.joseph@e-tib.com>
|
||||
Cc: Frieder Helmich <f.helmich@etib-ing.com>
|
||||
Betreff: Re: Homepage E-TIB
|
||||
|
||||
Hallo Danny,
|
||||
|
||||
Vielen Dank für die schnelle Rückmeldung.
|
||||
Wie gesprochen werde ich mir die Unterlagen und Webseiten im Detail anschauen und mich dann noch einmal bei dir melden.
|
||||
|
||||
Gibt es eigentlich eine Deadline oder einen zeitlichen Rahmen, wo ihr mit der neuen Webseite rechnen möchtet?
|
||||
Je nach dem könnte man auch Features priorisieren, so dass der Kern der Seite schnellstmöglich modernisiert online geht und der Rest im Nachgang.
|
||||
|
||||
Das Foto-Material würde ich auch gerne sichten, dann kann man schon sehen, wie viel sich damit arbeiten lässt.
|
||||
|
||||
Viele Grüße
|
||||
|
||||
|
||||
From: Danny Joseph <d.joseph@e-tib.com>
|
||||
Organization: E-TIB GmbH
|
||||
Date: Wednesday, 28. January 2026 at 16:16
|
||||
To: Marc Mintel <marc@cablecreations.de>
|
||||
Cc: 'Frieder Helmich' <f.helmich@etib-ing.com>
|
||||
Subject: Homepage E-TIB
|
||||
|
||||
Hallo Marc,
|
||||
|
||||
wie telefonisch besprochen erste wirre Gedanken:
|
||||
|
||||
Wir möchten eine minimalistische, hochwertige Homepage die sowohl am PV, als auch
|
||||
Auf Smartphone / Tablet etc. vernünftig ausschaut.
|
||||
|
||||
Bisher war unser Aufhänger:
|
||||
DIE EXPERTEN FÜR KABELTIEFBAU …
|
||||
|
||||
Alles nur Ideen: …
|
||||
|
||||
# Schaltflächen ähnlich: https://www.schleicher-gruppe.de/
|
||||
|
||||
E-TIB GmbH
|
||||
E-TIB Verwaltung GmbH
|
||||
E-TIB Ingenieurgesellschaft mbH
|
||||
E-TIB Bohrtechnik GmbH
|
||||
|
||||
# Schaltflächen ähnlich: https://www.schleicher-gruppe.de/
|
||||
(ehemals Kompetenzen www.e-tib.com)
|
||||
|
||||
Kabelbau
|
||||
Kabelpflugarbeiten
|
||||
Horizontalspülbohrungen
|
||||
Elektromontagen bis 110 kV
|
||||
Glasfaser-Kabelmontagen
|
||||
Wartung & Störungsdienst
|
||||
Genehmigungs- und Ausführungsplanung
|
||||
Komplexe Querung (Bahn, Autobahn, Gewässer)
|
||||
Elektro- und Netzanschlussplanung
|
||||
Vermessung & Dokumentation
|
||||
|
||||
Input für Über uns: Grid … Timeline?
|
||||
Gründung E-TIB GmbH: 16.12.2015
|
||||
Kabelbau
|
||||
Kabelpflugarbeiten
|
||||
Horizontalspülbohrungen
|
||||
Elektromontagen bis 110 kV
|
||||
Glasfaser-Kabelmontagen
|
||||
Wartung & Störungsdienst
|
||||
Elektro- und Netzanschlussplanung
|
||||
Vermessung & Dokumentation
|
||||
|
||||
Gründung E-TIB Verwaltung GmbH: 14.11.2019
|
||||
Der Erwerb, die Vermietung, Verpachtung und Verwaltung
|
||||
von Immobilien, Grundstücken, Maschinen und Geräten.
|
||||
|
||||
Gründung E-TIB Ingenieurgesellschaft mbH: 04.02.2019
|
||||
Genehmigungs- und Ausführungsplanung
|
||||
Komplexe Querung (Bahn, Autobahn, Gewässer)
|
||||
Elektro- und Netzanschlussplanung
|
||||
|
||||
Gründung E-TIB Bohrtechnik GmbH: 21.10.2025
|
||||
Horizontalspülbohrungen in allen Bodenklassen
|
||||
|
||||
Gruppen‑Kacheln (Beispieltexte) ...
|
||||
|
||||
E‑TIB GmbH – Ausführung elektrischer Infrastrukturprojekte
|
||||
E‑TIB Bohrtechnik GmbH – Präzise Horizontalbohrungen in allen Bodenklassen
|
||||
E‑TIB Verwaltung GmbH – Zentrale Dienste, Einkauf, Finanzen
|
||||
E‑TIB Ingenieurgesellschaft mbH – Planung, Projektierung, Dokumentation
|
||||
|
||||
Kontaktseite siehe: www.e-tib.com
|
||||
|
||||
Karriere: ...
|
||||
|
||||
Messen: wo wir dieses Jahr einen Stand haben: Intersolar München, Windenergietage Linstow, Kabelwerkstatt Wiesbaden
|
||||
|
||||
Referenzen: … müsste ich dir zur Verfügung stellen
|
||||
|
||||
Pflichtseiten
|
||||
Impressum (vollständig, Verantwortliche, Registernummer, USt‑ID).
|
||||
Datenschutz (Verarbeitungen, Rechtsgrundlagen, AVV, Cookie‑Gruppen, Löschfristen, Rechte).
|
||||
Cookie‑Einstellungen (Consent Manager: ...)
|
||||
|
||||
www.e-tib.com
|
||||
www.etib-ing.com
|
||||
|
||||
Hier mein instagram account:
|
||||
me.and.eloise
|
||||
Verstehst du mich vielleicht ein kleines Stück mehr…
|
||||
|
||||
Unser Frieder Helmich kann erstes Foto-/Videomaterial zur Verfügung stellen:
|
||||
f.helmich@etib-ing.com
|
||||
|
||||
Lass mir mal eine Idee vom Stundenaufwand / Kosten pro Stunde für Erstellung zukommen,
|
||||
damit wir eine Vertragsgrundlage haben. Danach lass uns loslegen.
|
||||
|
||||
Besten Dank dir.
|
||||
|
||||
Mit freundlichen Grüßen
|
||||
|
||||
Danny Joseph
|
||||
Geschäftsführer
|
||||
|
||||
E-TIB GmbH
|
||||
Gewerbestraße 22
|
||||
D-03172 Guben
|
||||
|
||||
Mobil +49 15207230518
|
||||
E-Mail d.joseph@e-tib.com
|
||||
Web www.e-tib.com
|
||||
|
||||
|
||||
--------------------------------------------------------------------------------------------------
|
||||
Geschäftsführung: Danny Joseph
|
||||
Handelsregister: Amtsgericht Cottbus
|
||||
HRB: 12403 CB
|
||||
USt. ID-Nr.: DE304799919
|
||||
--------------------------------------------------------------------------------------------------
|
||||
|
||||
Von: Marc Mintel <marc@cablecreations.de>
|
||||
Gesendet: Donnerstag, 13. November 2025 16:30
|
||||
An: d.joseph@e-tib.com
|
||||
Betreff: Homepage
|
||||
|
||||
Hi Danny,
|
||||
|
||||
mein Vater meinte, ich könnte mich mal bei dir melden, weil ihr jemanden für eure Website sucht.
|
||||
|
||||
Kurz zu mir: Ich habe über 10 Jahre in der Webentwicklung gearbeitet. Inzwischen liegt mein Schwerpunkt zwar im 3D-Bereich (u. a. cablecreations.de), aber ich betreue weiterhin Websites für Firmen, die das Ganze unkompliziert abgegeben haben möchten. Unter anderem betreue ich auch die Seite von KLZ (klz-cables.com). Der Ablauf ist bei mir recht einfach: Wenn ihr etwas braucht, reicht in der Regel eine kurze Mail – Anpassungen, Inhalte oder technische Themen erledige ich dann im Hintergrund. Dadurch spart ihr euch Schulungen, Zugänge oder lange Meetings, wie man sie oft mit Agenturen hat.
|
||||
|
||||
Wichtig ist: Eine Website braucht auch nach dem Aufbau regelmäßige Pflege, damit Technik und Sicherheit sauber laufen – das übernehme ich dann ebenfalls, damit ihr im Alltag keinen Aufwand damit habt.
|
||||
|
||||
Um einschätzen zu können, ob und wie ich euch unterstützen kann, wäre es gut zu wissen, was ihr mit der Website vorhabt und was an der aktuellen Seite nicht mehr passt. Wenn du magst, können wir dazu auch kurz telefonieren.
|
||||
|
||||
Viele Grüße
|
||||
Marc
|
||||
|
||||
Marc Mintel
|
||||
Founder & 3D Artist
|
||||
marc@cablecreations.de
|
||||
|
||||
Cable Creations
|
||||
www.cablecreations.de
|
||||
info@cablecreations.de
|
||||
VAT: DE367588065
|
||||
|
||||
Georg-Meistermann-Straße 7
|
||||
54586 Schüller
|
||||
Germany
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "1.8.21",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://npm.infra.mintel.me"
|
||||
"registry": "https://git.infra.mintel.me/api/packages/mmintel/npm"
|
||||
},
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
||||
40
packages/concept-engine/src/_test_pipeline.ts
Normal file
40
packages/concept-engine/src/_test_pipeline.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { config as dotenvConfig } from 'dotenv';
|
||||
import * as path from 'node:path';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import { EstimationPipeline } from './pipeline.js';
|
||||
|
||||
dotenvConfig({ path: path.resolve(process.cwd(), '../../.env') });
|
||||
|
||||
const briefing = await fs.readFile(
|
||||
path.resolve(process.cwd(), '../../data/briefings/etib.txt'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
console.log(`Briefing loaded: ${briefing.length} chars`);
|
||||
|
||||
const pipeline = new EstimationPipeline(
|
||||
{
|
||||
openrouterKey: process.env.OPENROUTER_API_KEY || '',
|
||||
zyteApiKey: process.env.ZYTE_API_KEY,
|
||||
outputDir: path.resolve(process.cwd(), '../../out/estimations'),
|
||||
crawlDir: path.resolve(process.cwd(), '../../data/crawls'),
|
||||
},
|
||||
{
|
||||
onStepStart: (id, name) => console.log(`[CB] Starting: ${id}`),
|
||||
onStepComplete: (id) => console.log(`[CB] Done: ${id}`),
|
||||
onStepError: (id, err) => console.error(`[CB] Error in ${id}: ${err}`),
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await pipeline.run({
|
||||
briefing,
|
||||
url: 'https://www.e-tib.com',
|
||||
});
|
||||
|
||||
console.log('\n✨ Pipeline complete!');
|
||||
console.log('Validation:', result.validationResult?.passed ? 'PASSED' : 'FAILED');
|
||||
} catch (err: any) {
|
||||
console.error('\n❌ Pipeline failed:', err.message);
|
||||
console.error(err.stack);
|
||||
}
|
||||
334
packages/concept-engine/src/analyzer.ts
Normal file
334
packages/concept-engine/src/analyzer.ts
Normal file
@@ -0,0 +1,334 @@
|
||||
// ============================================================================
|
||||
// Analyzer — Deterministic Site Analysis (NO LLM!)
|
||||
// Builds a SiteProfile from crawled pages using pure code logic.
|
||||
// This is the core fix against hallucinated page structures.
|
||||
// ============================================================================
|
||||
|
||||
import type {
|
||||
CrawledPage,
|
||||
SiteProfile,
|
||||
NavItem,
|
||||
CompanyInfo,
|
||||
PageInventoryItem,
|
||||
} from "./types.js";
|
||||
|
||||
/**
|
||||
* Build a complete SiteProfile from an array of crawled pages.
|
||||
* This is 100% deterministic — no LLM calls involved.
|
||||
*/
|
||||
export function analyzeSite(pages: CrawledPage[], domain: string): SiteProfile {
|
||||
const navigation = extractNavigation(pages);
|
||||
const existingFeatures = extractExistingFeatures(pages);
|
||||
const services = extractAllServices(pages);
|
||||
const companyInfo = extractCompanyInfo(pages);
|
||||
const colors = extractColors(pages);
|
||||
const socialLinks = extractSocialLinks(pages);
|
||||
const externalDomains = extractExternalDomains(pages, domain);
|
||||
const images = extractAllImages(pages);
|
||||
const employeeCount = extractEmployeeCount(pages);
|
||||
const pageInventory = buildPageInventory(pages);
|
||||
|
||||
return {
|
||||
domain,
|
||||
crawledAt: new Date().toISOString(),
|
||||
totalPages: pages.filter((p) => p.type !== "legal").length,
|
||||
navigation,
|
||||
existingFeatures,
|
||||
services,
|
||||
companyInfo,
|
||||
pageInventory,
|
||||
colors,
|
||||
socialLinks,
|
||||
externalDomains,
|
||||
images,
|
||||
employeeCount,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the site's main navigation structure from <nav> elements.
|
||||
* Uses the HOME page's nav as the canonical source.
|
||||
*/
|
||||
function extractNavigation(pages: CrawledPage[]): NavItem[] {
|
||||
// Prefer the home page's nav
|
||||
const homePage = pages.find((p) => p.type === "home");
|
||||
const sourcePage = homePage || pages[0];
|
||||
if (!sourcePage) return [];
|
||||
|
||||
// Deduplicate nav items
|
||||
const seen = new Set<string>();
|
||||
const navItems: NavItem[] = [];
|
||||
|
||||
for (const label of sourcePage.navItems) {
|
||||
const normalized = label.toLowerCase().trim();
|
||||
if (seen.has(normalized)) continue;
|
||||
if (normalized.length < 2) continue;
|
||||
seen.add(normalized);
|
||||
navItems.push({ label, href: "" });
|
||||
}
|
||||
|
||||
return navItems;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate all detected interactive features across all pages.
|
||||
*/
|
||||
function extractExistingFeatures(pages: CrawledPage[]): string[] {
|
||||
const allFeatures = new Set<string>();
|
||||
for (const page of pages) {
|
||||
for (const feature of page.features) {
|
||||
allFeatures.add(feature);
|
||||
}
|
||||
}
|
||||
return [...allFeatures];
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate all images found across all pages.
|
||||
*/
|
||||
function extractAllImages(pages: CrawledPage[]): string[] {
|
||||
const allImages = new Set<string>();
|
||||
for (const page of pages) {
|
||||
if (!page.images) continue;
|
||||
for (const img of page.images) {
|
||||
allImages.add(img);
|
||||
}
|
||||
}
|
||||
return [...allImages];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract employee count from page text.
|
||||
* Looks for patterns like "über 50 Mitarbeitern", "200 Mitarbeiter", "50+ employees".
|
||||
*/
|
||||
function extractEmployeeCount(pages: CrawledPage[]): string | null {
|
||||
const allText = pages.map((p) => p.text).join(" ");
|
||||
|
||||
// German patterns: 'über 50 Mitarbeitern', '120 Beschäftigte', '+200 MA'
|
||||
const patterns = [
|
||||
/(über|ca\.?|rund|mehr als|\+)?\s*(\d{1,4})\s*(Mitarbeiter(?:innen)?|Beschäftigte|MA|Fachkräfte)\b/gi,
|
||||
/(\d{1,4})\+?\s*(employees|team members)/gi,
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = allText.match(pattern);
|
||||
if (match && match[0]) {
|
||||
const num = match[0].match(/(\d{1,4})/)?.[1];
|
||||
const prefix = match[0].match(/über|ca\.?|rund|mehr als/i)?.[0];
|
||||
if (num) return prefix ? `${prefix} ${num}` : num;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract services/competencies from service-type pages.
|
||||
* Focuses on H2-H3 headings and list items on service pages.
|
||||
*/
|
||||
function extractAllServices(pages: CrawledPage[]): string[] {
|
||||
const servicePages = pages.filter(
|
||||
(p) => p.type === "service" || p.pathname.includes("kompetenz"),
|
||||
);
|
||||
|
||||
const services = new Set<string>();
|
||||
for (const page of servicePages) {
|
||||
// Use headings as primary service indicators
|
||||
for (const heading of page.headings) {
|
||||
const clean = heading.trim();
|
||||
if (clean.length > 3 && clean.length < 100) {
|
||||
// Skip generic headings
|
||||
if (/^(home|kontakt|impressum|datenschutz|menü|navigation|suche)/i.test(clean)) continue;
|
||||
services.add(clean);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no service pages found, look at the home page headings too
|
||||
if (services.size === 0) {
|
||||
const homePage = pages.find((p) => p.type === "home");
|
||||
if (homePage) {
|
||||
for (const heading of homePage.headings) {
|
||||
const clean = heading.trim();
|
||||
if (clean.length > 3 && clean.length < 80) {
|
||||
services.add(clean);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...services];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract company information from Impressum / footer content.
|
||||
*/
|
||||
function extractCompanyInfo(pages: CrawledPage[]): CompanyInfo {
|
||||
const info: CompanyInfo = {};
|
||||
|
||||
// Find Impressum or legal page
|
||||
const legalPage = pages.find(
|
||||
(p) =>
|
||||
p.type === "legal" &&
|
||||
(p.pathname.includes("impressum") || p.title.toLowerCase().includes("impressum")),
|
||||
);
|
||||
|
||||
const sourceText = legalPage?.text || pages.find((p) => p.type === "home")?.text || "";
|
||||
|
||||
// USt-ID
|
||||
const taxMatch = sourceText.match(/USt[.\s-]*(?:ID[.\s-]*Nr\.?|IdNr\.?)[:\s]*([A-Z]{2}\d{9,11})/i);
|
||||
if (taxMatch) info.taxId = taxMatch[1];
|
||||
|
||||
// HRB number
|
||||
const hrbMatch = sourceText.match(/HRB[:\s]*(\d+\s*[A-Z]*)/i);
|
||||
if (hrbMatch) info.registerNumber = `HRB ${hrbMatch[1].trim()}`;
|
||||
|
||||
// Phone
|
||||
const phoneMatch = sourceText.match(/(?:Tel|Telefon|Fon)[.:\s]*([+\d\s()/-]{10,20})/i);
|
||||
if (phoneMatch) info.phone = phoneMatch[1].trim();
|
||||
|
||||
// Email
|
||||
const emailMatch = sourceText.match(/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/);
|
||||
if (emailMatch) info.email = emailMatch[0];
|
||||
|
||||
// Address (look for German postal code pattern)
|
||||
const addressMatch = sourceText.match(
|
||||
/(?:[\w\s.-]+(?:straße|str\.|weg|platz|ring|allee|gasse)\s*\d+[a-z]?\s*,?\s*)?(?:D-)?(\d{5})\s+\w+/i,
|
||||
);
|
||||
if (addressMatch) info.address = addressMatch[0].trim();
|
||||
|
||||
// GF / Geschäftsführer
|
||||
const gfMatch = sourceText.match(
|
||||
/Geschäftsführ(?:er|ung)[:\s]*([A-ZÄÖÜ][a-zäöüß]+(?:\s+[A-ZÄÖÜ][a-zäöüß]+){1,3})/,
|
||||
);
|
||||
if (gfMatch) info.managingDirector = gfMatch[1].trim();
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract brand colors from HTML (inline styles, CSS variables).
|
||||
*/
|
||||
function extractColors(pages: CrawledPage[]): string[] {
|
||||
const colors = new Set<string>();
|
||||
const homePage = pages.find((p) => p.type === "home");
|
||||
if (!homePage) return [];
|
||||
|
||||
const hexMatches = homePage.html.match(/#(?:[0-9a-fA-F]{3}){1,2}\b/g) || [];
|
||||
for (const hex of hexMatches) {
|
||||
colors.add(hex.toLowerCase());
|
||||
if (colors.size >= 8) break;
|
||||
}
|
||||
|
||||
return [...colors];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract social media links from footers / headers.
|
||||
*/
|
||||
function extractSocialLinks(pages: CrawledPage[]): Record<string, string> {
|
||||
const socials: Record<string, string> = {};
|
||||
const platforms = [
|
||||
{ key: "linkedin", patterns: ["linkedin.com"] },
|
||||
{ key: "instagram", patterns: ["instagram.com"] },
|
||||
{ key: "facebook", patterns: ["facebook.com", "fb.com"] },
|
||||
{ key: "youtube", patterns: ["youtube.com", "youtu.be"] },
|
||||
{ key: "twitter", patterns: ["twitter.com", "x.com"] },
|
||||
{ key: "xing", patterns: ["xing.com"] },
|
||||
];
|
||||
|
||||
const homePage = pages.find((p) => p.type === "home");
|
||||
if (!homePage) return socials;
|
||||
|
||||
const urlMatches = homePage.html.match(/https?:\/\/[^\s"'<>]+/g) || [];
|
||||
for (const url of urlMatches) {
|
||||
for (const platform of platforms) {
|
||||
if (platform.patterns.some((p) => url.includes(p)) && !socials[platform.key]) {
|
||||
socials[platform.key] = url;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return socials;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find domains that are linked but separate from the main domain.
|
||||
* Critical for detecting sister companies with own websites (e.g. etib-ing.com).
|
||||
*/
|
||||
function extractExternalDomains(pages: CrawledPage[], mainDomain: string): string[] {
|
||||
const externalDomains = new Set<string>();
|
||||
const cleanMain = mainDomain.replace(/^www\./, "");
|
||||
// Extract meaningful base parts: "e-tib.com" → ["e", "tib", "etib"]
|
||||
const mainParts = cleanMain.split(".")[0].toLowerCase().split(/[-_]/).filter(p => p.length > 1);
|
||||
const mainJoined = mainParts.join(""); // "etib"
|
||||
|
||||
for (const page of pages) {
|
||||
const linkMatches = page.html.match(/https?:\/\/[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g) || [];
|
||||
for (const url of linkMatches) {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
const domain = urlObj.hostname.replace(/^www\./, "");
|
||||
// Skip same domain
|
||||
if (domain === cleanMain) continue;
|
||||
// Skip common third-party services
|
||||
if (
|
||||
domain.includes("google") ||
|
||||
domain.includes("facebook") ||
|
||||
domain.includes("twitter") ||
|
||||
domain.includes("linkedin") ||
|
||||
domain.includes("instagram") ||
|
||||
domain.includes("youtube") ||
|
||||
domain.includes("cookie") ||
|
||||
domain.includes("analytics") ||
|
||||
domain.includes("cdn") ||
|
||||
domain.includes("cloudflare") ||
|
||||
domain.includes("fonts") ||
|
||||
domain.includes("jquery") ||
|
||||
domain.includes("bootstrap") ||
|
||||
domain.includes("wordpress") ||
|
||||
domain.includes("jimdo") ||
|
||||
domain.includes("wix")
|
||||
)
|
||||
continue;
|
||||
|
||||
// Fuzzy match: check if the domain contains any base part of the main domain
|
||||
// e.g. main="e-tib.com" → mainParts=["e","tib"], mainJoined="etib"
|
||||
// target="etib-ing.com" → domainBase="etib-ing", domainJoined="etibing"
|
||||
const domainBase = domain.split(".")[0].toLowerCase();
|
||||
const domainJoined = domainBase.replace(/[-_]/g, "");
|
||||
|
||||
const isRelated =
|
||||
domainJoined.includes(mainJoined) ||
|
||||
mainJoined.includes(domainJoined) ||
|
||||
mainParts.some(part => part.length > 2 && domainBase.includes(part));
|
||||
|
||||
if (isRelated) {
|
||||
externalDomains.add(domain);
|
||||
}
|
||||
} catch {
|
||||
// Invalid URL
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...externalDomains];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a structured inventory of all pages.
|
||||
*/
|
||||
function buildPageInventory(pages: CrawledPage[]): PageInventoryItem[] {
|
||||
return pages.map((page) => ({
|
||||
url: page.url,
|
||||
pathname: page.pathname,
|
||||
title: page.title,
|
||||
type: page.type,
|
||||
headings: page.headings.slice(0, 10),
|
||||
services: page.type === "service" ? page.headings.filter((h) => h.length > 3 && h.length < 80) : [],
|
||||
hasSearch: page.features.includes("search"),
|
||||
hasForms: page.features.includes("forms"),
|
||||
hasMap: page.features.includes("maps"),
|
||||
hasVideo: page.features.includes("video"),
|
||||
contentSummary: page.text.substring(0, 500),
|
||||
}));
|
||||
}
|
||||
149
packages/concept-engine/src/cli.ts
Normal file
149
packages/concept-engine/src/cli.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
#!/usr/bin/env node
|
||||
// ============================================================================
|
||||
// @mintel/concept-engine — CLI Entry Point
|
||||
// Simple commander-based CLI for concept generation.
|
||||
// ============================================================================
|
||||
|
||||
import { Command } from "commander";
|
||||
import * as path from "node:path";
|
||||
import * as fs from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { config as dotenvConfig } from "dotenv";
|
||||
import { ConceptPipeline } from "./pipeline.js";
|
||||
|
||||
// Load .env from monorepo root
|
||||
dotenvConfig({ path: path.resolve(process.cwd(), "../../.env") });
|
||||
dotenvConfig({ path: path.resolve(process.cwd(), ".env") });
|
||||
|
||||
const program = new Command();
|
||||
|
||||
program
|
||||
.name("concept")
|
||||
.description("AI-powered project concept generator")
|
||||
.version("1.0.0");
|
||||
|
||||
program
|
||||
.command("run")
|
||||
.description("Run the full concept pipeline")
|
||||
.argument("[briefing]", "Briefing text or @path/to/file.txt")
|
||||
.option("--url <url>", "Target website URL")
|
||||
.option("--comments <comments>", "Additional notes")
|
||||
.option("--clear-cache", "Clear crawl cache and re-crawl")
|
||||
.option("--output <dir>", "Output directory", "../../out/concepts")
|
||||
.option("--crawl-dir <dir>", "Crawl data directory", "../../data/crawls")
|
||||
.action(async (briefingArg: string | undefined, options: any) => {
|
||||
const openrouterKey = process.env.OPENROUTER_API_KEY || process.env.OPENROUTER_KEY;
|
||||
if (!openrouterKey) {
|
||||
console.error("❌ OPENROUTER_API_KEY not found in environment.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let briefing = briefingArg || "";
|
||||
|
||||
// Handle @file references
|
||||
if (briefing.startsWith("@")) {
|
||||
const rawPath = briefing.substring(1);
|
||||
const filePath = rawPath.startsWith("/")
|
||||
? rawPath
|
||||
: path.resolve(process.cwd(), rawPath);
|
||||
if (!existsSync(filePath)) {
|
||||
console.error(`❌ Briefing file not found: ${filePath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
briefing = await fs.readFile(filePath, "utf8");
|
||||
console.log(`📄 Loaded briefing from: ${filePath}`);
|
||||
}
|
||||
|
||||
// Auto-discover URL from briefing
|
||||
let url = options.url;
|
||||
if (!url && briefing) {
|
||||
const urlMatch = briefing.match(/https?:\/\/[^\s]+/);
|
||||
if (urlMatch) {
|
||||
url = urlMatch[0];
|
||||
console.log(`🔗 Discovered URL in briefing: ${url}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!briefing && !url) {
|
||||
console.error("❌ Provide a briefing text or --url");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const pipeline = new ConceptPipeline(
|
||||
{
|
||||
openrouterKey,
|
||||
zyteApiKey: process.env.ZYTE_API_KEY,
|
||||
outputDir: path.resolve(process.cwd(), options.output),
|
||||
crawlDir: path.resolve(process.cwd(), options.crawlDir),
|
||||
},
|
||||
{
|
||||
onStepStart: (id, name) => {
|
||||
// Will be enhanced with Ink spinner later
|
||||
},
|
||||
onStepComplete: (id, result) => {
|
||||
// Will be enhanced with Ink UI later
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
await pipeline.run({
|
||||
briefing,
|
||||
url,
|
||||
comments: options.comments,
|
||||
clearCache: options.clearCache,
|
||||
});
|
||||
|
||||
console.log("\n✨ Concept generation complete!");
|
||||
} catch (err) {
|
||||
console.error(`\n❌ Pipeline failed: ${(err as Error).message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command("analyze")
|
||||
.description("Only crawl and analyze a website (no LLM)")
|
||||
.argument("<url>", "Website URL to analyze")
|
||||
.option("--crawl-dir <dir>", "Crawl data directory", "../../data/crawls")
|
||||
.option("--clear-cache", "Clear existing crawl cache")
|
||||
.action(async (url: string, options: any) => {
|
||||
const { crawlSite } = await import("./scraper.js");
|
||||
const { analyzeSite } = await import("./analyzer.js");
|
||||
|
||||
if (options.clearCache) {
|
||||
const { clearCrawlCache } = await import("./scraper.js");
|
||||
const domain = new URL(url).hostname;
|
||||
await clearCrawlCache(path.resolve(process.cwd(), options.crawlDir), domain);
|
||||
}
|
||||
|
||||
const pages = await crawlSite(url, {
|
||||
zyteApiKey: process.env.ZYTE_API_KEY,
|
||||
crawlDir: path.resolve(process.cwd(), options.crawlDir),
|
||||
});
|
||||
|
||||
const domain = new URL(url).hostname;
|
||||
const profile = analyzeSite(pages, domain);
|
||||
|
||||
console.log("\n📊 Site Profile:");
|
||||
console.log(` Domain: ${profile.domain}`);
|
||||
console.log(` Total Pages: ${profile.totalPages}`);
|
||||
console.log(` Navigation: ${profile.navigation.map((n) => n.label).join(", ")}`);
|
||||
console.log(` Features: ${profile.existingFeatures.join(", ") || "none"}`);
|
||||
console.log(` Services: ${profile.services.join(", ") || "none"}`);
|
||||
console.log(` External Domains: ${profile.externalDomains.join(", ") || "none"}`);
|
||||
console.log(` Company: ${profile.companyInfo.name || "unbekannt"}`);
|
||||
console.log(` Tax ID: ${profile.companyInfo.taxId || "unbekannt"}`);
|
||||
console.log(` Colors: ${profile.colors.join(", ")}`);
|
||||
console.log(` Images Found: ${profile.images.length}`);
|
||||
console.log(` Social: ${Object.entries(profile.socialLinks).map(([k, v]) => `${k}`).join(", ") || "none"}`);
|
||||
|
||||
const outputPath = path.join(
|
||||
path.resolve(process.cwd(), options.crawlDir),
|
||||
domain.replace(/\./g, "-"),
|
||||
"_site_profile.json",
|
||||
);
|
||||
console.log(`\n📦 Full profile saved to: ${outputPath}`);
|
||||
});
|
||||
|
||||
program.parse();
|
||||
10
packages/concept-engine/src/index.ts
Normal file
10
packages/concept-engine/src/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// ============================================================================
|
||||
// @mintel/concept-engine — Public API
|
||||
// ============================================================================
|
||||
|
||||
export { ConceptPipeline } from "./pipeline.js";
|
||||
export type { PipelineCallbacks } from "./pipeline.js";
|
||||
export { crawlSite, clearCrawlCache } from "./scraper.js";
|
||||
export { analyzeSite } from "./analyzer.js";
|
||||
export { llmRequest, llmJsonRequest, cleanJson } from "./llm-client.js";
|
||||
export * from "./types.js";
|
||||
133
packages/concept-engine/src/llm-client.ts
Normal file
133
packages/concept-engine/src/llm-client.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
// ============================================================================
|
||||
// LLM Client — Unified interface with model routing via OpenRouter
|
||||
// ============================================================================
|
||||
|
||||
import axios from "axios";
|
||||
|
||||
interface LLMRequestOptions {
|
||||
model: string;
|
||||
systemPrompt: string;
|
||||
userPrompt: string;
|
||||
jsonMode?: boolean;
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
interface LLMResponse {
|
||||
content: string;
|
||||
usage: {
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
cost: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean raw LLM output to parseable JSON.
|
||||
* Handles markdown fences, control chars, trailing commas.
|
||||
*/
|
||||
export function cleanJson(str: string): string {
|
||||
let cleaned = str.replace(/```json\n?|```/g, "").trim();
|
||||
cleaned = cleaned.replace(
|
||||
/[\u0000-\u0009\u000B\u000C\u000E-\u001F\u007F-\u009F]/g,
|
||||
" ",
|
||||
);
|
||||
cleaned = cleaned.replace(/,\s*([\]}])/g, "$1");
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a request to an LLM via OpenRouter.
|
||||
*/
|
||||
export async function llmRequest(options: LLMRequestOptions): Promise<LLMResponse> {
|
||||
const { model, systemPrompt, userPrompt, jsonMode = true, apiKey } = options;
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
const resp = await axios.post(
|
||||
"https://openrouter.ai/api/v1/chat/completions",
|
||||
{
|
||||
model,
|
||||
messages: [
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "user", content: userPrompt },
|
||||
],
|
||||
...(jsonMode ? { response_format: { type: "json_object" } } : {}),
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout: 120000,
|
||||
},
|
||||
).catch(err => {
|
||||
if (err.response) {
|
||||
console.error("OpenRouter API Error:", JSON.stringify(err.response.data, null, 2));
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
|
||||
const content = resp.data.choices?.[0]?.message?.content;
|
||||
if (!content) {
|
||||
throw new Error(`LLM returned no content. Model: ${model}`);
|
||||
}
|
||||
|
||||
let cost = 0;
|
||||
const usage = resp.data.usage || {};
|
||||
if (usage.cost !== undefined) {
|
||||
cost = usage.cost;
|
||||
} else {
|
||||
// Fallback estimation
|
||||
cost =
|
||||
(usage.prompt_tokens || 0) * (0.1 / 1_000_000) +
|
||||
(usage.completion_tokens || 0) * (0.4 / 1_000_000);
|
||||
}
|
||||
|
||||
return {
|
||||
content,
|
||||
usage: {
|
||||
promptTokens: usage.prompt_tokens || 0,
|
||||
completionTokens: usage.completion_tokens || 0,
|
||||
cost,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a request and parse the response as JSON.
|
||||
*/
|
||||
export async function llmJsonRequest<T = any>(
|
||||
options: LLMRequestOptions,
|
||||
): Promise<{ data: T; usage: LLMResponse["usage"] }> {
|
||||
const response = await llmRequest({ ...options, jsonMode: true });
|
||||
const cleaned = cleanJson(response.content);
|
||||
|
||||
let parsed: T;
|
||||
try {
|
||||
parsed = JSON.parse(cleaned);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`Failed to parse LLM JSON response: ${(e as Error).message}\nRaw: ${cleaned.substring(0, 500)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Unwrap common LLM artifacts: {"0": {...}}, {"state": {...}}, etc.
|
||||
const unwrapped = unwrapResponse(parsed);
|
||||
|
||||
return { data: unwrapped as T, usage: response.usage };
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively unwrap common LLM wrapping patterns.
|
||||
*/
|
||||
function unwrapResponse(obj: any): any {
|
||||
if (!obj || typeof obj !== "object" || Array.isArray(obj)) return obj;
|
||||
const keys = Object.keys(obj);
|
||||
if (keys.length === 1) {
|
||||
const key = keys[0];
|
||||
if (key === "0" || key === "state" || key === "facts" || key === "result" || key === "data") {
|
||||
return unwrapResponse(obj[key]);
|
||||
}
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
257
packages/concept-engine/src/pipeline.ts
Normal file
257
packages/concept-engine/src/pipeline.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
// ============================================================================
|
||||
// Pipeline Orchestrator
|
||||
// Runs all steps sequentially, tracks state, supports re-running individual steps.
|
||||
// ============================================================================
|
||||
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
import { crawlSite, clearCrawlCache } from "./scraper.js";
|
||||
import { analyzeSite } from "./analyzer.js";
|
||||
import { executeResearch } from "./steps/00b-research.js";
|
||||
import { executeExtract } from "./steps/01-extract.js";
|
||||
import { executeSiteAudit } from "./steps/00a-site-audit.js";
|
||||
import { executeAudit } from "./steps/02-audit.js";
|
||||
import { executeStrategize } from "./steps/03-strategize.js";
|
||||
import { executeArchitect } from "./steps/04-architect.js";
|
||||
import type {
|
||||
PipelineConfig,
|
||||
PipelineInput,
|
||||
ConceptState,
|
||||
ProjectConcept,
|
||||
StepResult,
|
||||
StepUsage,
|
||||
} from "./types.js";
|
||||
|
||||
export interface PipelineCallbacks {
|
||||
onStepStart?: (stepId: string, stepName: string) => void;
|
||||
onStepComplete?: (stepId: string, result: StepResult) => void;
|
||||
onStepError?: (stepId: string, error: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The main concept pipeline orchestrator.
|
||||
* Runs conceptual steps sequentially and builds the ProjectConcept.
|
||||
*/
|
||||
export class ConceptPipeline {
|
||||
private config: PipelineConfig;
|
||||
private state: ConceptState;
|
||||
private callbacks: PipelineCallbacks;
|
||||
|
||||
constructor(config: PipelineConfig, callbacks: PipelineCallbacks = {}) {
|
||||
this.config = config;
|
||||
this.callbacks = callbacks;
|
||||
this.state = this.createInitialState();
|
||||
}
|
||||
|
||||
private createInitialState(): ConceptState {
|
||||
return {
|
||||
briefing: "",
|
||||
usage: {
|
||||
totalPromptTokens: 0,
|
||||
totalCompletionTokens: 0,
|
||||
totalCost: 0,
|
||||
perStep: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the full concept pipeline from scratch.
|
||||
*/
|
||||
async run(input: PipelineInput): Promise<ProjectConcept> {
|
||||
this.state.briefing = input.briefing;
|
||||
this.state.url = input.url;
|
||||
this.state.comments = input.comments;
|
||||
|
||||
// Ensure output directories
|
||||
await fs.mkdir(this.config.outputDir, { recursive: true });
|
||||
await fs.mkdir(this.config.crawlDir, { recursive: true });
|
||||
|
||||
// Step 0: Scrape & Analyze (deterministic)
|
||||
if (input.url) {
|
||||
if (input.clearCache) {
|
||||
const domain = new URL(input.url).hostname;
|
||||
await clearCrawlCache(this.config.crawlDir, domain);
|
||||
}
|
||||
await this.runStep("00-scrape", "Scraping & Analyzing Website", async () => {
|
||||
const pages = await crawlSite(input.url!, {
|
||||
zyteApiKey: this.config.zyteApiKey,
|
||||
crawlDir: this.config.crawlDir,
|
||||
});
|
||||
const domain = new URL(input.url!).hostname;
|
||||
const siteProfile = analyzeSite(pages, domain);
|
||||
this.state.siteProfile = siteProfile;
|
||||
this.state.crawlDir = path.join(this.config.crawlDir, domain.replace(/\./g, "-"));
|
||||
|
||||
// Save site profile
|
||||
await fs.writeFile(
|
||||
path.join(this.state.crawlDir!, "_site_profile.json"),
|
||||
JSON.stringify(siteProfile, null, 2),
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: siteProfile,
|
||||
usage: { step: "00-scrape", model: "none", promptTokens: 0, completionTokens: 0, cost: 0, durationMs: 0 },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Step 00a: Site Audit (DataForSEO)
|
||||
await this.runStep("00a-site-audit", "IST-Analysis (DataForSEO)", async () => {
|
||||
const result = await executeSiteAudit(this.state, this.config);
|
||||
if (result.success && result.data) {
|
||||
this.state.siteAudit = result.data;
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
// Step 00b: Research (real web data via journaling)
|
||||
await this.runStep("00b-research", "Industry & Company Research", async () => {
|
||||
const result = await executeResearch(this.state);
|
||||
if (result.success && result.data) {
|
||||
this.state.researchData = result.data;
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
// Step 1: Extract facts
|
||||
await this.runStep("01-extract", "Extracting Facts from Briefing", async () => {
|
||||
const result = await executeExtract(this.state, this.config);
|
||||
if (result.success) this.state.facts = result.data;
|
||||
return result;
|
||||
});
|
||||
|
||||
// Step 2: Audit features
|
||||
await this.runStep("02-audit", "Auditing Features (Skeptical Review)", async () => {
|
||||
const result = await executeAudit(this.state, this.config);
|
||||
if (result.success) this.state.auditedFacts = result.data;
|
||||
return result;
|
||||
});
|
||||
|
||||
// Step 3: Strategic analysis
|
||||
await this.runStep("03-strategize", "Strategic Analysis", async () => {
|
||||
const result = await executeStrategize(this.state, this.config);
|
||||
if (result.success) {
|
||||
this.state.briefingSummary = result.data.briefingSummary;
|
||||
this.state.designVision = result.data.designVision;
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
// Step 4: Sitemap architecture
|
||||
await this.runStep("04-architect", "Information Architecture", async () => {
|
||||
const result = await executeArchitect(this.state, this.config);
|
||||
if (result.success) {
|
||||
this.state.sitemap = result.data.sitemap;
|
||||
this.state.websiteTopic = result.data.websiteTopic;
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
const projectConcept = this.buildProjectConcept();
|
||||
await this.saveState(projectConcept);
|
||||
|
||||
return projectConcept;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a single step with callbacks and error handling.
|
||||
*/
|
||||
private async runStep(
|
||||
stepId: string,
|
||||
stepName: string,
|
||||
executor: () => Promise<StepResult>,
|
||||
): Promise<void> {
|
||||
this.callbacks.onStepStart?.(stepId, stepName);
|
||||
console.log(`\n📍 ${stepName}...`);
|
||||
|
||||
try {
|
||||
const result = await executor();
|
||||
if (result.usage) {
|
||||
this.state.usage.perStep.push(result.usage);
|
||||
this.state.usage.totalPromptTokens += result.usage.promptTokens;
|
||||
this.state.usage.totalCompletionTokens += result.usage.completionTokens;
|
||||
this.state.usage.totalCost += result.usage.cost;
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
const cost = result.usage?.cost ? ` ($${result.usage.cost.toFixed(4)})` : "";
|
||||
const duration = result.usage?.durationMs ? ` [${(result.usage.durationMs / 1000).toFixed(1)}s]` : "";
|
||||
console.log(` ✅ ${stepName} complete${cost}${duration}`);
|
||||
this.callbacks.onStepComplete?.(stepId, result);
|
||||
} else {
|
||||
console.error(` ❌ ${stepName} failed: ${result.error}`);
|
||||
this.callbacks.onStepError?.(stepId, result.error || "Unknown error");
|
||||
throw new Error(result.error);
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMsg = (err as Error).message;
|
||||
this.callbacks.onStepError?.(stepId, errorMsg);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the final Concept object.
|
||||
*/
|
||||
private buildProjectConcept(): ProjectConcept {
|
||||
return {
|
||||
domain: this.state.siteProfile?.domain || "unknown",
|
||||
timestamp: new Date().toISOString(),
|
||||
briefing: this.state.briefing,
|
||||
auditedFacts: this.state.auditedFacts || {},
|
||||
siteProfile: this.state.siteProfile,
|
||||
siteAudit: this.state.siteAudit,
|
||||
researchData: this.state.researchData,
|
||||
strategy: {
|
||||
briefingSummary: this.state.briefingSummary || "",
|
||||
designVision: this.state.designVision || "",
|
||||
},
|
||||
architecture: {
|
||||
websiteTopic: this.state.websiteTopic || "",
|
||||
sitemap: this.state.sitemap || [],
|
||||
},
|
||||
usage: this.state.usage,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the full concept generated state to disk.
|
||||
*/
|
||||
private async saveState(concept: ProjectConcept): Promise<void> {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const companyName = this.state.auditedFacts?.companyName || "unknown";
|
||||
|
||||
const stateDir = path.join(this.config.outputDir, "concepts");
|
||||
await fs.mkdir(stateDir, { recursive: true });
|
||||
|
||||
const statePath = path.join(stateDir, `${companyName}_${timestamp}.json`);
|
||||
await fs.writeFile(statePath, JSON.stringify(concept, null, 2));
|
||||
console.log(`\n📦 Saved Project Concept to: ${statePath}`);
|
||||
|
||||
// Save debug trace
|
||||
const debugPath = path.join(stateDir, `${companyName}_${timestamp}_debug.json`);
|
||||
await fs.writeFile(debugPath, JSON.stringify(this.state, null, 2));
|
||||
|
||||
// Print usage summary
|
||||
console.log("\n──────────────────────────────────────────────");
|
||||
console.log("📊 PIPELINE USAGE SUMMARY");
|
||||
console.log("──────────────────────────────────────────────");
|
||||
for (const step of this.state.usage.perStep) {
|
||||
if (step.cost > 0) {
|
||||
console.log(` ${step.step}: ${step.model} — $${step.cost.toFixed(6)} (${(step.durationMs / 1000).toFixed(1)}s)`);
|
||||
}
|
||||
}
|
||||
console.log("──────────────────────────────────────────────");
|
||||
console.log(` TOTAL: $${this.state.usage.totalCost.toFixed(6)}`);
|
||||
console.log(` Tokens: ${(this.state.usage.totalPromptTokens + this.state.usage.totalCompletionTokens).toLocaleString()}`);
|
||||
console.log("──────────────────────────────────────────────\n");
|
||||
}
|
||||
|
||||
/** Get the current internal state (for CLI inspection). */
|
||||
getState(): ConceptState {
|
||||
return this.state;
|
||||
}
|
||||
}
|
||||
432
packages/concept-engine/src/scraper.ts
Normal file
432
packages/concept-engine/src/scraper.ts
Normal file
@@ -0,0 +1,432 @@
|
||||
// ============================================================================
|
||||
// Scraper — Zyte API + Local Persistence
|
||||
// Crawls all pages of a website, stores them locally for reuse.
|
||||
// ============================================================================
|
||||
|
||||
import axios from "axios";
|
||||
import * as cheerio from "cheerio";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
import type { CrawledPage, PageType } from "./types.js";
|
||||
|
||||
interface ScraperConfig {
|
||||
zyteApiKey?: string;
|
||||
crawlDir: string;
|
||||
maxPages?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a URL pathname into a page type.
|
||||
*/
|
||||
function classifyPage(pathname: string): PageType {
|
||||
const p = pathname.toLowerCase();
|
||||
if (p === "/" || p === "" || p === "/index.html") return "home";
|
||||
if (p.includes("service") || p.includes("leistung") || p.includes("kompetenz"))
|
||||
return "service";
|
||||
if (p.includes("about") || p.includes("ueber") || p.includes("über") || p.includes("unternehmen"))
|
||||
return "about";
|
||||
if (p.includes("contact") || p.includes("kontakt")) return "contact";
|
||||
if (p.includes("job") || p.includes("karriere") || p.includes("career") || p.includes("human-resources"))
|
||||
return "career";
|
||||
if (p.includes("portfolio") || p.includes("referenz") || p.includes("projekt") || p.includes("case-study"))
|
||||
return "portfolio";
|
||||
if (p.includes("blog") || p.includes("news") || p.includes("aktuelles") || p.includes("magazin"))
|
||||
return "blog";
|
||||
if (p.includes("legal") || p.includes("impressum") || p.includes("datenschutz") || p.includes("privacy") || p.includes("agb"))
|
||||
return "legal";
|
||||
return "other";
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect interactive features present on a page.
|
||||
*/
|
||||
function detectFeatures($: cheerio.CheerioAPI): string[] {
|
||||
const features: string[] = [];
|
||||
|
||||
// Search
|
||||
if (
|
||||
$('input[type="search"]').length > 0 ||
|
||||
$('form[role="search"]').length > 0 ||
|
||||
$(".search-form, .search-box, #search, .searchbar").length > 0 ||
|
||||
$('input[name="q"], input[name="s"], input[name="search"]').length > 0
|
||||
) {
|
||||
features.push("search");
|
||||
}
|
||||
|
||||
// Forms (beyond search)
|
||||
const formCount = $("form").length;
|
||||
const searchForms = $('form[role="search"], .search-form').length;
|
||||
if (formCount > searchForms) {
|
||||
features.push("forms");
|
||||
}
|
||||
|
||||
// Maps
|
||||
if (
|
||||
$('iframe[src*="google.com/maps"], iframe[src*="openstreetmap"], .map-container, #map, [data-map]').length > 0
|
||||
) {
|
||||
features.push("maps");
|
||||
}
|
||||
|
||||
// Video
|
||||
if (
|
||||
$("video, iframe[src*='youtube'], iframe[src*='vimeo'], .video-container").length > 0
|
||||
) {
|
||||
features.push("video");
|
||||
}
|
||||
|
||||
// Calendar / Events
|
||||
if ($(".calendar, .event, [data-calendar]").length > 0) {
|
||||
features.push("calendar");
|
||||
}
|
||||
|
||||
// Cookie consent
|
||||
if ($(".cookie-banner, .cookie-consent, #cookie-notice, [data-cookie]").length > 0) {
|
||||
features.push("cookie-consent");
|
||||
}
|
||||
|
||||
return features;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all internal links from a page.
|
||||
*/
|
||||
function extractInternalLinks($: cheerio.CheerioAPI, origin: string): string[] {
|
||||
const links: string[] = [];
|
||||
$("a[href]").each((_, el) => {
|
||||
const href = $(el).attr("href");
|
||||
if (!href) return;
|
||||
try {
|
||||
const url = new URL(href, origin);
|
||||
if (url.origin === origin) {
|
||||
// Skip assets
|
||||
if (/\.(pdf|zip|jpg|jpeg|png|svg|webp|gif|css|js|ico|woff|woff2|ttf|eot)$/i.test(url.pathname)) return;
|
||||
// Skip anchors-only
|
||||
if (url.pathname === "/" && url.hash) return;
|
||||
links.push(url.pathname);
|
||||
}
|
||||
} catch {
|
||||
// Invalid URL, skip
|
||||
}
|
||||
});
|
||||
return [...new Set(links)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all images from a page.
|
||||
*/
|
||||
function extractImages($: cheerio.CheerioAPI, origin: string): string[] {
|
||||
const images: string[] = [];
|
||||
|
||||
// Regular img tags
|
||||
$("img[src]").each((_, el) => {
|
||||
const src = $(el).attr("src");
|
||||
if (src) images.push(src);
|
||||
});
|
||||
|
||||
// CSS background images (inline styles)
|
||||
$("[style*='background-image']").each((_, el) => {
|
||||
const style = $(el).attr("style");
|
||||
const match = style?.match(/url\(['"]?(.*?)['"]?\)/);
|
||||
if (match && match[1]) {
|
||||
images.push(match[1]);
|
||||
}
|
||||
});
|
||||
|
||||
// Resolve URLs to absolute
|
||||
const absoluteImages: string[] = [];
|
||||
for (const img of images) {
|
||||
if (img.startsWith("data:image")) continue; // Skip inline base64
|
||||
try {
|
||||
const url = new URL(img, origin);
|
||||
// Ignore small tracking pixels or generic vectors
|
||||
if (url.pathname.endsWith(".svg") && !url.pathname.includes("logo")) continue;
|
||||
absoluteImages.push(url.href);
|
||||
} catch {
|
||||
// Invalid URL
|
||||
}
|
||||
}
|
||||
|
||||
return [...new Set(absoluteImages)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract services/competencies from text content.
|
||||
*/
|
||||
function extractServices(text: string): string[] {
|
||||
const services: string[] = [];
|
||||
// Common pattern: bulleted or newline-separated service lists
|
||||
const lines = text.split(/\n/).map((l) => l.trim()).filter((l) => l.length > 3 && l.length < 100);
|
||||
for (const line of lines) {
|
||||
// Skip generic boilerplate
|
||||
if (/cookie|datenschutz|impressum|copyright|©/i.test(line)) continue;
|
||||
if (/^(tel|fax|e-mail|mobil|web|http)/i.test(line)) continue;
|
||||
services.push(line);
|
||||
}
|
||||
return services;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a page via Zyte API with browser rendering.
|
||||
*/
|
||||
async function fetchWithZyte(url: string, apiKey: string): Promise<string> {
|
||||
try {
|
||||
const resp = await axios.post(
|
||||
"https://api.zyte.com/v1/extract",
|
||||
{
|
||||
url,
|
||||
browserHtml: true,
|
||||
},
|
||||
{
|
||||
auth: { username: apiKey, password: "" },
|
||||
timeout: 60000,
|
||||
},
|
||||
);
|
||||
const html = resp.data.browserHtml || "";
|
||||
if (!html) {
|
||||
console.warn(` ⚠️ Zyte returned empty browserHtml for ${url}`);
|
||||
}
|
||||
return html;
|
||||
} catch (err: any) {
|
||||
if (err.response) {
|
||||
console.error(` ❌ Zyte API error ${err.response.status} for ${url}: ${err.response.data?.detail || err.response.statusText}`);
|
||||
// Rate limited — wait and retry once
|
||||
if (err.response.status === 429) {
|
||||
console.log(" ⏳ Rate limited, waiting 5s and retrying...");
|
||||
await new Promise((r) => setTimeout(r, 5000));
|
||||
return fetchWithZyte(url, apiKey);
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a page via simple HTTP GET (fallback).
|
||||
*/
|
||||
async function fetchDirect(url: string): Promise<string> {
|
||||
const resp = await axios.get(url, {
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
|
||||
},
|
||||
});
|
||||
return typeof resp.data === "string" ? resp.data : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an HTML string into a CrawledPage.
|
||||
*/
|
||||
function parsePage(html: string, url: string): CrawledPage {
|
||||
const $ = cheerio.load(html);
|
||||
const urlObj = new URL(url);
|
||||
|
||||
const title = $("title").text().trim();
|
||||
const headings = $("h1, h2, h3")
|
||||
.map((_, el) => $(el).text().trim())
|
||||
.get()
|
||||
.filter((h) => h.length > 0);
|
||||
|
||||
const navItems = $("nav a")
|
||||
.map((_, el) => $(el).text().trim())
|
||||
.get()
|
||||
.filter((t) => t.length > 0 && t.length < 100);
|
||||
|
||||
const bodyText = $("body")
|
||||
.text()
|
||||
.replace(/\s+/g, " ")
|
||||
.substring(0, 50000)
|
||||
.trim();
|
||||
|
||||
const features = detectFeatures($);
|
||||
const links = extractInternalLinks($, urlObj.origin);
|
||||
const images = extractImages($, urlObj.origin);
|
||||
|
||||
const description = $('meta[name="description"]').attr("content") || undefined;
|
||||
const ogTitle = $('meta[property="og:title"]').attr("content") || undefined;
|
||||
const ogImage = $('meta[property="og:image"]').attr("content") || undefined;
|
||||
|
||||
return {
|
||||
url,
|
||||
pathname: urlObj.pathname,
|
||||
title,
|
||||
html,
|
||||
text: bodyText,
|
||||
headings,
|
||||
navItems,
|
||||
features,
|
||||
type: classifyPage(urlObj.pathname),
|
||||
links,
|
||||
images,
|
||||
meta: { description, ogTitle, ogImage },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Crawl a website and persist all pages locally.
|
||||
*
|
||||
* Returns an array of CrawledPage objects.
|
||||
*/
|
||||
export async function crawlSite(
|
||||
targetUrl: string,
|
||||
config: ScraperConfig,
|
||||
): Promise<CrawledPage[]> {
|
||||
const urlObj = new URL(targetUrl);
|
||||
const origin = urlObj.origin;
|
||||
const domain = urlObj.hostname;
|
||||
const domainDir = path.join(config.crawlDir, domain.replace(/\./g, "-"));
|
||||
|
||||
// Check for existing crawl
|
||||
const metaFile = path.join(domainDir, "_crawl_meta.json");
|
||||
if (existsSync(metaFile)) {
|
||||
console.log(`📦 Found existing crawl for ${domain}. Loading from disk...`);
|
||||
return loadCrawlFromDisk(domainDir);
|
||||
}
|
||||
|
||||
console.log(`🔍 Crawling ${targetUrl} via ${config.zyteApiKey ? "Zyte API" : "direct HTTP"}...`);
|
||||
|
||||
// Ensure output dir
|
||||
await fs.mkdir(domainDir, { recursive: true });
|
||||
|
||||
const maxPages = config.maxPages || 30;
|
||||
const visited = new Set<string>();
|
||||
const queue: string[] = [targetUrl];
|
||||
const pages: CrawledPage[] = [];
|
||||
|
||||
while (queue.length > 0 && visited.size < maxPages) {
|
||||
const url = queue.shift()!;
|
||||
const urlPath = new URL(url).pathname;
|
||||
|
||||
if (visited.has(urlPath)) continue;
|
||||
visited.add(urlPath);
|
||||
|
||||
try {
|
||||
console.log(` ↳ Fetching ${url} (${visited.size}/${maxPages})...`);
|
||||
|
||||
let html: string;
|
||||
if (config.zyteApiKey) {
|
||||
html = await fetchWithZyte(url, config.zyteApiKey);
|
||||
} else {
|
||||
html = await fetchDirect(url);
|
||||
}
|
||||
|
||||
if (!html || html.length < 100) {
|
||||
console.warn(` ⚠️ Empty/tiny response for ${url}, skipping.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const page = parsePage(html, url);
|
||||
pages.push(page);
|
||||
|
||||
// Save HTML + metadata to disk
|
||||
const safeName = urlPath === "/" ? "index" : urlPath.replace(/\//g, "_").replace(/^_/, "");
|
||||
await fs.writeFile(path.join(domainDir, `${safeName}.html`), html);
|
||||
await fs.writeFile(
|
||||
path.join(domainDir, `${safeName}.meta.json`),
|
||||
JSON.stringify(
|
||||
{
|
||||
url: page.url,
|
||||
pathname: page.pathname,
|
||||
title: page.title,
|
||||
type: page.type,
|
||||
headings: page.headings,
|
||||
navItems: page.navItems,
|
||||
features: page.features,
|
||||
links: page.links,
|
||||
images: page.images,
|
||||
meta: page.meta,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
// Discover new links
|
||||
for (const link of page.links) {
|
||||
if (!visited.has(link)) {
|
||||
const fullUrl = `${origin}${link}`;
|
||||
queue.push(fullUrl);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(` ⚠️ Failed to fetch ${url}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Save crawl metadata
|
||||
await fs.writeFile(
|
||||
metaFile,
|
||||
JSON.stringify(
|
||||
{
|
||||
domain,
|
||||
crawledAt: new Date().toISOString(),
|
||||
totalPages: pages.length,
|
||||
urls: pages.map((p) => p.url),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
console.log(`✅ Crawled ${pages.length} pages for ${domain}. Saved to ${domainDir}`);
|
||||
return pages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a previously crawled site from disk.
|
||||
*/
|
||||
async function loadCrawlFromDisk(domainDir: string): Promise<CrawledPage[]> {
|
||||
const files = await fs.readdir(domainDir);
|
||||
const metaFiles = files.filter((f) => f.endsWith(".meta.json") && f !== "_crawl_meta.json");
|
||||
|
||||
const pages: CrawledPage[] = [];
|
||||
for (const metaFile of metaFiles) {
|
||||
const baseName = metaFile.replace(".meta.json", "");
|
||||
const htmlFile = `${baseName}.html`;
|
||||
|
||||
const meta = JSON.parse(await fs.readFile(path.join(domainDir, metaFile), "utf8"));
|
||||
let html = "";
|
||||
if (files.includes(htmlFile)) {
|
||||
html = await fs.readFile(path.join(domainDir, htmlFile), "utf8");
|
||||
}
|
||||
|
||||
const text = html
|
||||
? cheerio
|
||||
.load(html)("body")
|
||||
.text()
|
||||
.replace(/\s+/g, " ")
|
||||
.substring(0, 50000)
|
||||
.trim()
|
||||
: "";
|
||||
|
||||
pages.push({
|
||||
url: meta.url,
|
||||
pathname: meta.pathname,
|
||||
title: meta.title,
|
||||
html,
|
||||
text,
|
||||
headings: meta.headings || [],
|
||||
navItems: meta.navItems || [],
|
||||
features: meta.features || [],
|
||||
type: meta.type || "other",
|
||||
links: meta.links || [],
|
||||
images: meta.images || [],
|
||||
meta: meta.meta || {},
|
||||
});
|
||||
}
|
||||
|
||||
console.log(` 📂 Loaded ${pages.length} cached pages from disk.`);
|
||||
return pages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a cached crawl to force re-crawl.
|
||||
*/
|
||||
export async function clearCrawlCache(crawlDir: string, domain: string): Promise<void> {
|
||||
const domainDir = path.join(crawlDir, domain.replace(/\./g, "-"));
|
||||
if (existsSync(domainDir)) {
|
||||
await fs.rm(domainDir, { recursive: true, force: true });
|
||||
console.log(`🧹 Cleared crawl cache for ${domain}`);
|
||||
}
|
||||
}
|
||||
65
packages/concept-engine/src/steps/00a-site-audit.ts
Normal file
65
packages/concept-engine/src/steps/00a-site-audit.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
// ============================================================================
|
||||
// Step 00a: Site Audit (DataForSEO + AI)
|
||||
// ============================================================================
|
||||
|
||||
import { PageAuditor } from "@mintel/page-audit";
|
||||
import type { ConceptState, StepResult, PipelineConfig } from "../types.js";
|
||||
|
||||
export async function executeSiteAudit(
|
||||
state: ConceptState,
|
||||
config: PipelineConfig,
|
||||
): Promise<StepResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
if (!state.url) {
|
||||
return {
|
||||
success: true,
|
||||
data: null,
|
||||
usage: { step: "00a-site-audit", model: "none", promptTokens: 0, completionTokens: 0, cost: 0, durationMs: Date.now() - startTime },
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const login = process.env.DATA_FOR_SEO_LOGIN || process.env.DATA_FOR_SEO_API_KEY?.split(":")?.[0];
|
||||
const password = process.env.DATA_FOR_SEO_PASSWORD || process.env.DATA_FOR_SEO_API_KEY?.split(":")?.slice(1)?.join(":");
|
||||
|
||||
if (!login || !password) {
|
||||
console.warn(" ⚠️ Site Audit skipped: DataForSEO credentials missing from environment.");
|
||||
return {
|
||||
success: true,
|
||||
data: null,
|
||||
usage: { step: "00a-site-audit", model: "none", promptTokens: 0, completionTokens: 0, cost: 0, durationMs: Date.now() - startTime },
|
||||
};
|
||||
}
|
||||
|
||||
const auditor = new PageAuditor({
|
||||
dataForSeoLogin: login,
|
||||
dataForSeoPassword: password,
|
||||
openrouterKey: config.openrouterKey,
|
||||
outputDir: config.outputDir ? `${config.outputDir}/audits` : undefined,
|
||||
});
|
||||
|
||||
// Run audit (max 20 pages for the estimation phase to keep it fast)
|
||||
const result = await auditor.audit(state.url, { maxPages: 20 });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result,
|
||||
usage: {
|
||||
step: "00a-site-audit",
|
||||
model: "dataforseo",
|
||||
cost: 0, // DataForSEO cost tracking could be added later
|
||||
promptTokens: 0,
|
||||
completionTokens: 0,
|
||||
durationMs: Date.now() - startTime,
|
||||
},
|
||||
};
|
||||
} catch (err: any) {
|
||||
console.warn(` ⚠️ Site Audit failed, skipping: ${err.message}`);
|
||||
return {
|
||||
success: true,
|
||||
data: null,
|
||||
usage: { step: "00a-site-audit", model: "none", promptTokens: 0, completionTokens: 0, cost: 0, durationMs: Date.now() - startTime },
|
||||
};
|
||||
}
|
||||
}
|
||||
121
packages/concept-engine/src/steps/00b-research.ts
Normal file
121
packages/concept-engine/src/steps/00b-research.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
// ============================================================================
|
||||
// Step 00b: Research — Industry Research via @mintel/journaling (No LLM hallus)
|
||||
// Uses Serper API for real web search results about the industry/company.
|
||||
// ============================================================================
|
||||
|
||||
import type { ConceptState, StepResult } from "../types.js";
|
||||
|
||||
interface ResearchResult {
|
||||
companyContext: string[];
|
||||
industryInsights: string[];
|
||||
competitorInfo: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Research the company and industry using real web search data.
|
||||
* Uses @mintel/journaling's ResearchAgent — results are grounded in real sources.
|
||||
*
|
||||
* NOTE: The journaling package can cause unhandled rejections that crash the process.
|
||||
* We wrap each call in an additional safety layer.
|
||||
*/
|
||||
export async function executeResearch(
|
||||
state: ConceptState,
|
||||
): Promise<StepResult<ResearchResult>> {
|
||||
const startTime = Date.now();
|
||||
|
||||
const companyName = state.siteProfile?.companyInfo?.name || "";
|
||||
const websiteTopic = state.siteProfile?.services?.slice(0, 3).join(", ") || "";
|
||||
const domain = state.siteProfile?.domain || "";
|
||||
|
||||
if (!companyName && !websiteTopic && !domain) {
|
||||
return {
|
||||
success: true,
|
||||
data: { companyContext: [], industryInsights: [], competitorInfo: [] },
|
||||
usage: { step: "00b-research", model: "none", promptTokens: 0, completionTokens: 0, cost: 0, durationMs: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
// Safety wrapper: catch ANY unhandled rejections during this step
|
||||
const safeCall = <T>(fn: () => Promise<T>, fallback: T): Promise<T> => {
|
||||
return new Promise<T>((resolve) => {
|
||||
const handler = (err: any) => {
|
||||
console.warn(` ⚠️ Unhandled rejection caught in research: ${err?.message || err}`);
|
||||
process.removeListener("unhandledRejection", handler);
|
||||
resolve(fallback);
|
||||
};
|
||||
process.on("unhandledRejection", handler);
|
||||
|
||||
fn()
|
||||
.then((result) => {
|
||||
process.removeListener("unhandledRejection", handler);
|
||||
resolve(result);
|
||||
})
|
||||
.catch((err) => {
|
||||
process.removeListener("unhandledRejection", handler);
|
||||
console.warn(` ⚠️ Research call failed: ${err?.message || err}`);
|
||||
resolve(fallback);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
const { ResearchAgent } = await import("@mintel/journaling");
|
||||
const agent = new ResearchAgent(process.env.OPENROUTER_API_KEY || "");
|
||||
|
||||
const results: ResearchResult = {
|
||||
companyContext: [],
|
||||
industryInsights: [],
|
||||
competitorInfo: [],
|
||||
};
|
||||
|
||||
// 1. Research the company itself
|
||||
if (companyName || domain) {
|
||||
const searchQuery = companyName
|
||||
? `${companyName} ${websiteTopic} Unternehmen`
|
||||
: `site:${domain}`;
|
||||
|
||||
console.log(` 🔍 Researching: "${searchQuery}"...`);
|
||||
const facts = await safeCall(
|
||||
() => agent.researchTopic(searchQuery),
|
||||
[] as any[],
|
||||
);
|
||||
results.companyContext = (facts || [])
|
||||
.filter((f: any) => f?.fact || f?.value || f?.text || f?.statement)
|
||||
.map((f: any) => f.fact || f.value || f.text || f.statement)
|
||||
.slice(0, 5);
|
||||
}
|
||||
|
||||
// 2. Industry research
|
||||
if (websiteTopic) {
|
||||
console.log(` 🔍 Researching industry: "${websiteTopic}"...`);
|
||||
const insights = await safeCall(
|
||||
() => agent.researchCompetitors(websiteTopic),
|
||||
[] as any[],
|
||||
);
|
||||
results.industryInsights = (insights || []).slice(0, 5);
|
||||
}
|
||||
|
||||
const totalFacts = results.companyContext.length + results.industryInsights.length + results.competitorInfo.length;
|
||||
console.log(` 📊 Research found ${totalFacts} data points.`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: results,
|
||||
usage: {
|
||||
step: "00b-research",
|
||||
model: "serper/datacommons",
|
||||
promptTokens: 0,
|
||||
completionTokens: 0,
|
||||
cost: 0,
|
||||
durationMs: Date.now() - startTime,
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
console.warn(` ⚠️ Research step skipped: ${(err as Error).message}`);
|
||||
return {
|
||||
success: true,
|
||||
data: { companyContext: [], industryInsights: [], competitorInfo: [] },
|
||||
usage: { step: "00b-research", model: "none", promptTokens: 0, completionTokens: 0, cost: 0, durationMs: Date.now() - startTime },
|
||||
};
|
||||
}
|
||||
}
|
||||
108
packages/concept-engine/src/steps/01-extract.ts
Normal file
108
packages/concept-engine/src/steps/01-extract.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
// ============================================================================
|
||||
// Step 01: Extract — Briefing Fact Extraction (Gemini Flash)
|
||||
// ============================================================================
|
||||
|
||||
import { llmJsonRequest } from "../llm-client.js";
|
||||
import type { ConceptState, StepResult, PipelineConfig } from "../types.js";
|
||||
import { DEFAULT_MODELS } from "../types.js";
|
||||
|
||||
export async function executeExtract(
|
||||
state: ConceptState,
|
||||
config: PipelineConfig,
|
||||
): Promise<StepResult> {
|
||||
const models = { ...DEFAULT_MODELS, ...config.modelsOverride };
|
||||
const startTime = Date.now();
|
||||
|
||||
// Build site context from the deterministic analyzer
|
||||
const siteContext = state.siteProfile
|
||||
? `
|
||||
EXISTING WEBSITE ANALYSIS (FACTS — verifiably crawled, NOT guessed):
|
||||
- Domain: ${state.siteProfile.domain}
|
||||
- Total pages crawled: ${state.siteProfile.totalPages}
|
||||
- Navigation items: ${state.siteProfile.navigation.map((n) => n.label).join(", ") || "nicht erkannt"}
|
||||
- Existing features: ${state.siteProfile.existingFeatures.join(", ") || "keine"}
|
||||
- Services / Kompetenzen: ${state.siteProfile.services.join(" | ") || "keine"}
|
||||
- Employee count (from website text): ${(state.siteProfile as any).employeeCount || "nicht genannt"}
|
||||
- Company name: ${state.siteProfile.companyInfo.name || "unbekannt"}
|
||||
- Address: ${state.siteProfile.companyInfo.address || "unbekannt"}
|
||||
- Tax ID (USt-ID): ${state.siteProfile.companyInfo.taxId || "unbekannt"}
|
||||
- HRB: ${state.siteProfile.companyInfo.registerNumber || "unbekannt"}
|
||||
- Managing Director: ${state.siteProfile.companyInfo.managingDirector || "unbekannt"}
|
||||
- External related domains (HAVE OWN WEBSITES — DO NOT include as sub-pages!): ${state.siteProfile.externalDomains.join(", ") || "keine"}
|
||||
- Social links: ${Object.entries(state.siteProfile.socialLinks).map(([k, v]) => `${k}: ${v}`).join(", ") || "keine"}
|
||||
`
|
||||
: "No existing website data available.";
|
||||
|
||||
const systemPrompt = `
|
||||
You are a precision fact extractor. Your only job: extract verifiable facts from the BRIEFING.
|
||||
Output language: GERMAN (strict).
|
||||
Output format: flat JSON at root level. No nesting except arrays.
|
||||
|
||||
### CRITICAL RULES:
|
||||
1. "employeeCount": take from SITE ANALYSIS if available. Only override if briefing states something more specific.
|
||||
2. External domains (e.g. "etib-ing.com") have their OWN website. NEVER include them as sub-pages.
|
||||
3. Videos (Messefilm, Imagefilm) are CONTENT ASSETS, not pages.
|
||||
4. If existing site already has search, include "search" in functions.
|
||||
5. DO NOT invent pages not mentioned in briefing or existing navigation.
|
||||
|
||||
### CONSERVATIVE RULE:
|
||||
- simple lists (Jobs, Referenzen, Messen) = pages, NOT features
|
||||
- Assume "page" as default. Only add "feature" for complex interactive systems.
|
||||
|
||||
### OUTPUT FORMAT:
|
||||
{
|
||||
"companyName": string,
|
||||
"companyAddress": string,
|
||||
"personName": string,
|
||||
"email": string,
|
||||
"existingWebsite": string,
|
||||
"websiteTopic": string, // MAX 3 words
|
||||
"isRelaunch": boolean,
|
||||
"employeeCount": string, // from site analysis, e.g. "über 50"
|
||||
"pages": string[], // ALL pages: ["Startseite", "Über Uns", "Leistungen", ...]
|
||||
"functions": string[], // search, forms, maps, video, cookie_consent, etc.
|
||||
"assets": string[], // existing_website, logo, media, photos, videos
|
||||
"deadline": string,
|
||||
"targetAudience": string,
|
||||
"cmsSetup": boolean,
|
||||
"multilang": boolean
|
||||
}
|
||||
|
||||
BANNED OUTPUT KEYS: "selectedPages", "otherPages", "features", "apiSystems" — use pages[] and functions[] ONLY.
|
||||
`;
|
||||
|
||||
const userPrompt = `BRIEFING (TRUTH SOURCE):
|
||||
${state.briefing}
|
||||
|
||||
COMMENTS:
|
||||
${state.comments || "keine"}
|
||||
|
||||
${siteContext}`;
|
||||
|
||||
try {
|
||||
const { data, usage } = await llmJsonRequest({
|
||||
model: models.flash,
|
||||
systemPrompt,
|
||||
userPrompt,
|
||||
apiKey: config.openrouterKey,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data,
|
||||
usage: {
|
||||
step: "01-extract",
|
||||
model: models.flash,
|
||||
promptTokens: usage.promptTokens,
|
||||
completionTokens: usage.completionTokens,
|
||||
cost: usage.cost,
|
||||
durationMs: Date.now() - startTime,
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Extract step failed: ${(err as Error).message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
110
packages/concept-engine/src/steps/02-audit.ts
Normal file
110
packages/concept-engine/src/steps/02-audit.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
// ============================================================================
|
||||
// Step 02: Audit — Feature Auditor + Skeptical Review (Gemini Flash)
|
||||
// ============================================================================
|
||||
|
||||
import { llmJsonRequest } from "../llm-client.js";
|
||||
import type { ConceptState, StepResult, PipelineConfig } from "../types.js";
|
||||
import { DEFAULT_MODELS } from "../types.js";
|
||||
|
||||
export async function executeAudit(
|
||||
state: ConceptState,
|
||||
config: PipelineConfig,
|
||||
): Promise<StepResult> {
|
||||
const models = { ...DEFAULT_MODELS, ...config.modelsOverride };
|
||||
const startTime = Date.now();
|
||||
|
||||
if (!state.facts) {
|
||||
return { success: false, error: "No facts from Step 01 available." };
|
||||
}
|
||||
|
||||
const systemPrompt = `
|
||||
You are a "Strict Cost Controller". Your mission is to prevent over-billing.
|
||||
Review the extracted FEATURES against the BRIEFING and the EXISTING SITE ANALYSIS.
|
||||
|
||||
### RULE OF THUMB:
|
||||
- A "Feature" (1.500 €) is ONLY justified for complex, dynamic systems (logic, database, CMS-driven management, advanced filtering).
|
||||
- Simple lists, information sections, or static descriptions (e.g., "Messen", "Team", "Historie", "Jobs" as mere text) are ALWAYS "Pages" (600 €).
|
||||
- If the briefing doesn't explicitly mention "Management System", "Filterable Database", or "Client Login", it is a PAGE.
|
||||
|
||||
### ADDITIONAL CHECKS:
|
||||
1. If any feature maps to an entity that has its own external website (listed in EXTERNAL_DOMAINS), remove it entirely — it's out of scope.
|
||||
2. Videos are ASSETS not pages. Remove any video-related entries from pages.
|
||||
3. If the existing site has features (search, forms, etc.), ensure they are in the functions list.
|
||||
|
||||
### MISSION:
|
||||
Return the corrected 'features', 'otherPages', and 'functions' arrays.
|
||||
|
||||
### OUTPUT FORMAT:
|
||||
{
|
||||
"features": string[],
|
||||
"otherPages": string[],
|
||||
"functions": string[],
|
||||
"removedItems": [{ "item": string, "reason": string }],
|
||||
"addedItems": [{ "item": string, "reason": string }]
|
||||
}
|
||||
`;
|
||||
|
||||
const userPrompt = `
|
||||
EXTRACTED FACTS:
|
||||
${JSON.stringify(state.facts, null, 2)}
|
||||
|
||||
BRIEFING:
|
||||
${state.briefing}
|
||||
|
||||
EXTERNAL DOMAINS (have own websites, OUT OF SCOPE):
|
||||
${state.siteProfile?.externalDomains?.join(", ") || "none"}
|
||||
|
||||
EXISTING FEATURES ON CURRENT SITE:
|
||||
${state.siteProfile?.existingFeatures?.join(", ") || "none"}
|
||||
`;
|
||||
|
||||
try {
|
||||
const { data, usage } = await llmJsonRequest({
|
||||
model: models.flash,
|
||||
systemPrompt,
|
||||
userPrompt,
|
||||
apiKey: config.openrouterKey,
|
||||
});
|
||||
|
||||
// Apply audit results to facts
|
||||
const auditedFacts = { ...state.facts };
|
||||
auditedFacts.features = data.features || [];
|
||||
auditedFacts.otherPages = [
|
||||
...new Set([...(auditedFacts.otherPages || []), ...(data.otherPages || [])]),
|
||||
];
|
||||
if (data.functions) {
|
||||
auditedFacts.functions = [
|
||||
...new Set([...(auditedFacts.functions || []), ...data.functions]),
|
||||
];
|
||||
}
|
||||
|
||||
// Log changes
|
||||
if (data.removedItems?.length) {
|
||||
console.log(" 📉 Audit removed:");
|
||||
for (const item of data.removedItems) {
|
||||
console.log(` - ${item.item}: ${item.reason}`);
|
||||
}
|
||||
}
|
||||
if (data.addedItems?.length) {
|
||||
console.log(" 📈 Audit added:");
|
||||
for (const item of data.addedItems) {
|
||||
console.log(` + ${item.item}: ${item.reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: auditedFacts,
|
||||
usage: {
|
||||
step: "02-audit",
|
||||
model: models.flash,
|
||||
promptTokens: usage.promptTokens,
|
||||
completionTokens: usage.completionTokens,
|
||||
cost: usage.cost,
|
||||
durationMs: Date.now() - startTime,
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
return { success: false, error: `Audit step failed: ${(err as Error).message}` };
|
||||
}
|
||||
}
|
||||
99
packages/concept-engine/src/steps/03-strategize.ts
Normal file
99
packages/concept-engine/src/steps/03-strategize.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
// ============================================================================
|
||||
// Step 03: Strategize — Briefing Summary + Design Vision (Gemini Pro)
|
||||
// ============================================================================
|
||||
|
||||
import { llmJsonRequest } from "../llm-client.js";
|
||||
import type { ConceptState, StepResult, PipelineConfig } from "../types.js";
|
||||
import { DEFAULT_MODELS } from "../types.js";
|
||||
|
||||
export async function executeStrategize(
|
||||
state: ConceptState,
|
||||
config: PipelineConfig,
|
||||
): Promise<StepResult> {
|
||||
const models = { ...DEFAULT_MODELS, ...config.modelsOverride };
|
||||
const startTime = Date.now();
|
||||
|
||||
if (!state.auditedFacts) {
|
||||
return { success: false, error: "No audited facts from Step 02 available." };
|
||||
}
|
||||
|
||||
const systemPrompt = `
|
||||
You are a high-end Digital Architect. Your goal is to make the CUSTOMER feel 100% understood.
|
||||
Analyze the BRIEFING and the EXISTING WEBSITE context.
|
||||
|
||||
### OBJECTIVE:
|
||||
1. **briefingSummary**: Ein sachlicher, tiefgehender Überblick der Unternehmenslage.
|
||||
- STIL: Keine Ich-Form. Keine Marketing-Floskeln. Nutze präzise Fachbegriffe. Sei prägnant.
|
||||
- FORM: EXAKT ZWEI ABSÄTZE. Insgesamt ca. 6 Sätze.
|
||||
- INHALT: Status Quo, was der Kunde will, welcher Sprung notwendig ist.
|
||||
- ABSOLUTE REGEL: Keine Halluzinationen. Keine namentlichen Nennungen von Personen.
|
||||
- RELAUNCH-REGEL: Wenn isRelaunch=true, NICHT sagen "keine digitale Präsenz". Es GIBT eine Seite.
|
||||
- SORGLOS BETRIEB: MUSS erwähnt werden als Teil des Gesamtpakets.
|
||||
|
||||
2. **designVision**: Ein abstraktes, strategisches Konzept.
|
||||
- STIL: Rein konzeptionell. Keine Umsetzungsschritte. Keine Ich-Form. Sei prägnant.
|
||||
- FORM: EXAKT ZWEI ABSÄTZE. Insgesamt ca. 4 Sätze.
|
||||
- DATENSCHUTZ: KEINERLEI namentliche Nennungen.
|
||||
- FOKUS: Welche strategische Wirkung soll erzielt werden?
|
||||
|
||||
### RULES:
|
||||
- NO "wir/unser". NO "Ich/Mein". Objective, fact-oriented narrative.
|
||||
- NO marketing lingo. NO "innovativ", "revolutionär", "state-of-the-art".
|
||||
- NO hallucinations about features not in the briefing.
|
||||
- NO "SEO-Standards zur Fachkräftesicherung" or "B2B-Nutzerströme" — das ist Schwachsinn.
|
||||
Use specific industry terms from the briefing (e.g. "Kabeltiefbau", "HDD-Bohrverfahren").
|
||||
- LANGUAGE: Professional German. Simple but expert-level.
|
||||
|
||||
### OUTPUT FORMAT:
|
||||
{
|
||||
"briefingSummary": string,
|
||||
"designVision": string
|
||||
}
|
||||
`;
|
||||
|
||||
const userPrompt = `
|
||||
BRIEFING (TRUTH SOURCE):
|
||||
${state.briefing}
|
||||
|
||||
EXISTING WEBSITE DATA:
|
||||
- Services: ${state.siteProfile?.services?.join(", ") || "unbekannt"}
|
||||
- Navigation: ${state.siteProfile?.navigation?.map((n) => n.label).join(", ") || "unbekannt"}
|
||||
- Company: ${state.auditedFacts.companyName || "unbekannt"}
|
||||
|
||||
EXTRACTED & AUDITED FACTS:
|
||||
${JSON.stringify(state.auditedFacts, null, 2)}
|
||||
|
||||
${state.siteAudit?.report ? `
|
||||
TECHNICAL SITE AUDIT (IST-Analyse):
|
||||
Health: ${state.siteAudit.report.overallHealth} (SEO: ${state.siteAudit.report.seoScore}, UX: ${state.siteAudit.report.uxScore}, Perf: ${state.siteAudit.report.performanceScore})
|
||||
- Executive Summary: ${state.siteAudit.report.executiveSummary}
|
||||
- Strengths: ${state.siteAudit.report.strengths.join(", ")}
|
||||
- Critical Issues: ${state.siteAudit.report.criticalIssues.join(", ")}
|
||||
- Quick Wins: ${state.siteAudit.report.quickWins.join(", ")}
|
||||
` : ""}
|
||||
`;
|
||||
|
||||
try {
|
||||
const { data, usage } = await llmJsonRequest({
|
||||
model: models.pro,
|
||||
systemPrompt,
|
||||
userPrompt,
|
||||
apiKey: config.openrouterKey,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data,
|
||||
usage: {
|
||||
step: "03-strategize",
|
||||
model: models.pro,
|
||||
promptTokens: usage.promptTokens,
|
||||
completionTokens: usage.completionTokens,
|
||||
cost: usage.cost,
|
||||
durationMs: Date.now() - startTime,
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
return { success: false, error: `Strategize step failed: ${(err as Error).message}` };
|
||||
}
|
||||
}
|
||||
133
packages/concept-engine/src/steps/04-architect.ts
Normal file
133
packages/concept-engine/src/steps/04-architect.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
// ============================================================================
|
||||
// Step 04: Architect — Sitemap & Information Architecture (Gemini Pro)
|
||||
// ============================================================================
|
||||
|
||||
import { llmJsonRequest } from "../llm-client.js";
|
||||
import type { ConceptState, StepResult, PipelineConfig } from "../types.js";
|
||||
import { DEFAULT_MODELS } from "../types.js";
|
||||
|
||||
export async function executeArchitect(
|
||||
state: ConceptState,
|
||||
config: PipelineConfig,
|
||||
): Promise<StepResult> {
|
||||
const models = { ...DEFAULT_MODELS, ...config.modelsOverride };
|
||||
const startTime = Date.now();
|
||||
|
||||
if (!state.auditedFacts) {
|
||||
return { success: false, error: "No audited facts available." };
|
||||
}
|
||||
|
||||
// Build navigation constraint from the real site
|
||||
const existingNav = state.siteProfile?.navigation?.map((n) => n.label).join(", ") || "unbekannt";
|
||||
const existingServices = state.siteProfile?.services?.join(", ") || "unbekannt";
|
||||
const externalDomains = state.siteProfile?.externalDomains?.join(", ") || "keine";
|
||||
|
||||
const systemPrompt = `
|
||||
Du bist ein Senior UX Architekt. Erstelle einen ECHTEN SEITENBAUM für die neue Website.
|
||||
Regelwerk für den Output:
|
||||
|
||||
### SEITENBAUM-REGELN:
|
||||
1. KEIN MARKETINGSPRECH als Kategoriename. Gültige Kategorien sind nur die echten Navigationspunkte der Website.
|
||||
ERLAUBT: "Startseite", "Leistungen", "Über uns", "Karriere", "Referenzen", "Kontakt", "Rechtliches"
|
||||
VERBOTEN: "Kern-Präsenz", "Vertrauen", "Business Areas", "Digitaler Auftritt"
|
||||
|
||||
2. LEISTUNGEN muss in ECHTE UNTERSEITEN aufgeteilt werden — nicht eine einzige "Leistungen"-Seite.
|
||||
Jede Kompetenz aus dem existierenden Leistungsspektrum = eine eigene Seite.
|
||||
Beispiel statt:
|
||||
{ category: "Leistungen", pages: [{ title: "Leistungen", desc: "..." }] }
|
||||
So:
|
||||
{ category: "Leistungen", pages: [
|
||||
{ title: "Kabeltiefbau", desc: "Mittelspannung, Niederspannung, Kabelpflugarbeiten..." },
|
||||
{ title: "Horizontalspülbohrungen", desc: "HDD in allen Bodenklassen..." },
|
||||
{ title: "Elektromontagen", desc: "Bis 110 kV, Glasfaserkabelmontagen..." },
|
||||
{ title: "Planung & Dokumentation", desc: "Genehmigungs- und Ausführungsplanung, Vermessung..." }
|
||||
]}
|
||||
|
||||
3. SEITENTITEL: Kurz, klar, faktisch. Kein Werbejargon.
|
||||
ERLAUBT: "Kabeltiefbau", "Über uns", "Karriere"
|
||||
VERBOTEN: "Unsere Expertise", "Kompetenzspektrum", "Community"
|
||||
|
||||
4. Gruppe die Leistungen nach dem ECHTEN Kompetenzkatalog der bestehenden Site — nicht erfinden.
|
||||
|
||||
5. Keine doppelten Seiten. Keine Phantomseiten.
|
||||
|
||||
6. Videos = Content-Assets, keine eigene Seite.
|
||||
|
||||
7. Entitäten mit eigener Domain (${externalDomains}) = NICHT als Seite. Nur als Teaser/Link wenn nötig.
|
||||
|
||||
### KONTEXT:
|
||||
Bestehende Navigation: ${existingNav}
|
||||
Bestehende Services: ${existingServices}
|
||||
Externe Domains (haben eigene Website): ${externalDomains}
|
||||
Angeforderte zusätzliche Seiten aus Briefing: ${(state.auditedFacts as any)?.pages?.join(", ") || "keine spezifischen"}
|
||||
|
||||
### OUTPUT FORMAT (JSON):
|
||||
{
|
||||
"websiteTopic": string, // MAX 3 Wörter, beschreibend
|
||||
"sitemap": [
|
||||
{
|
||||
"category": string, // Echter Nav-Eintrag. KEIN Marketingsprech.
|
||||
"pages": [
|
||||
{ "title": string, "desc": string } // Echte Unterseite, 1-2 Sätze Zweck
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
`;
|
||||
|
||||
const userPrompt = `
|
||||
BRIEFING:
|
||||
${state.briefing}
|
||||
|
||||
FAKTEN (aus Extraktion):
|
||||
${JSON.stringify({ facts: state.auditedFacts, strategy: { briefingSummary: state.briefingSummary } }, null, 2)}
|
||||
|
||||
Erstelle den Seitenbaum. Baue die Leistungen DETAILLIERT aus — echte Unterseiten pro Kompetenzbereich.
|
||||
`;
|
||||
|
||||
try {
|
||||
const { data, usage } = await llmJsonRequest({
|
||||
model: models.pro,
|
||||
systemPrompt,
|
||||
userPrompt,
|
||||
apiKey: config.openrouterKey,
|
||||
});
|
||||
|
||||
// Normalize sitemap structure
|
||||
let sitemap = data.sitemap;
|
||||
if (sitemap && !Array.isArray(sitemap)) {
|
||||
if (sitemap.categories) sitemap = sitemap.categories;
|
||||
else {
|
||||
const entries = Object.entries(sitemap);
|
||||
if (entries.every(([, v]) => Array.isArray(v))) {
|
||||
sitemap = entries.map(([category, pages]) => ({ category, pages }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(sitemap)) {
|
||||
sitemap = sitemap.map((cat: any) => ({
|
||||
category: cat.category || cat.kategorie || cat.Kategorie || "Allgemein",
|
||||
pages: (cat.pages || cat.seiten || []).map((page: any) => ({
|
||||
title: page.title || page.titel || "Seite",
|
||||
desc: page.desc || page.beschreibung || page.description || "",
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { websiteTopic: data.websiteTopic, sitemap },
|
||||
usage: {
|
||||
step: "04-architect",
|
||||
model: models.pro,
|
||||
promptTokens: usage.promptTokens,
|
||||
completionTokens: usage.completionTokens,
|
||||
cost: usage.cost,
|
||||
durationMs: Date.now() - startTime,
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
return { success: false, error: `Architect step failed: ${(err as Error).message}` };
|
||||
}
|
||||
}
|
||||
233
packages/concept-engine/src/types.ts
Normal file
233
packages/concept-engine/src/types.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
// ============================================================================
|
||||
// @mintel/concept-engine — Core Type Definitions
|
||||
// ============================================================================
|
||||
|
||||
/** Page types recognized during crawling */
|
||||
export type PageType =
|
||||
| "home"
|
||||
| "service"
|
||||
| "about"
|
||||
| "contact"
|
||||
| "career"
|
||||
| "portfolio"
|
||||
| "blog"
|
||||
| "legal"
|
||||
| "other";
|
||||
|
||||
/** A single crawled page with extracted metadata */
|
||||
export interface CrawledPage {
|
||||
url: string;
|
||||
pathname: string;
|
||||
title: string;
|
||||
html: string;
|
||||
text: string;
|
||||
headings: string[];
|
||||
navItems: string[];
|
||||
features: string[];
|
||||
type: PageType;
|
||||
links: string[];
|
||||
images: string[];
|
||||
meta: {
|
||||
description?: string;
|
||||
ogTitle?: string;
|
||||
ogImage?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/** Navigation item extracted from <nav> elements */
|
||||
export interface NavItem {
|
||||
label: string;
|
||||
href: string;
|
||||
children?: NavItem[];
|
||||
}
|
||||
|
||||
/** Company info extracted from Impressum / footer */
|
||||
export interface CompanyInfo {
|
||||
name?: string;
|
||||
address?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
taxId?: string;
|
||||
registerNumber?: string;
|
||||
managingDirector?: string;
|
||||
}
|
||||
|
||||
/** A page in the site inventory */
|
||||
export interface PageInventoryItem {
|
||||
url: string;
|
||||
pathname: string;
|
||||
title: string;
|
||||
type: PageType;
|
||||
headings: string[];
|
||||
services: string[];
|
||||
hasSearch: boolean;
|
||||
hasForms: boolean;
|
||||
hasMap: boolean;
|
||||
hasVideo: boolean;
|
||||
contentSummary: string;
|
||||
}
|
||||
|
||||
/** Full site profile — deterministic, no LLM involved */
|
||||
export interface SiteProfile {
|
||||
domain: string;
|
||||
crawledAt: string;
|
||||
totalPages: number;
|
||||
navigation: NavItem[];
|
||||
existingFeatures: string[];
|
||||
services: string[];
|
||||
companyInfo: CompanyInfo;
|
||||
pageInventory: PageInventoryItem[];
|
||||
colors: string[];
|
||||
socialLinks: Record<string, string>;
|
||||
externalDomains: string[];
|
||||
images: string[];
|
||||
employeeCount: string | null;
|
||||
}
|
||||
|
||||
/** Configuration for the estimation pipeline */
|
||||
export interface PipelineConfig {
|
||||
openrouterKey: string;
|
||||
zyteApiKey?: string;
|
||||
outputDir: string;
|
||||
crawlDir: string;
|
||||
modelsOverride?: Partial<ModelConfig>;
|
||||
}
|
||||
|
||||
/** Model routing configuration */
|
||||
export interface ModelConfig {
|
||||
flash: string;
|
||||
pro: string;
|
||||
opus: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_MODELS: ModelConfig = {
|
||||
flash: "google/gemini-3-flash-preview",
|
||||
pro: "google/gemini-3.1-pro-preview",
|
||||
opus: "anthropic/claude-opus-4-6",
|
||||
};
|
||||
|
||||
/** Input for a pipeline run */
|
||||
export interface PipelineInput {
|
||||
briefing: string;
|
||||
url?: string;
|
||||
budget?: string;
|
||||
comments?: string;
|
||||
clearCache?: boolean;
|
||||
}
|
||||
|
||||
/** State that flows through all concept pipeline steps */
|
||||
export interface ConceptState {
|
||||
// Input
|
||||
briefing: string;
|
||||
url?: string;
|
||||
comments?: string;
|
||||
|
||||
// Output: Scrape & Analyze
|
||||
siteProfile?: SiteProfile;
|
||||
crawlDir?: string;
|
||||
|
||||
// Output: Site Audit
|
||||
siteAudit?: any;
|
||||
|
||||
// Output: Research
|
||||
researchData?: any;
|
||||
|
||||
// Output: Extract
|
||||
facts?: Record<string, any>;
|
||||
|
||||
// Output: Audit
|
||||
auditedFacts?: Record<string, any>;
|
||||
|
||||
// Output: Strategy
|
||||
briefingSummary?: string;
|
||||
designVision?: string;
|
||||
|
||||
// Output: Architecture
|
||||
sitemap?: SitemapCategory[];
|
||||
websiteTopic?: string;
|
||||
|
||||
// Cost tracking
|
||||
usage: UsageStats;
|
||||
}
|
||||
|
||||
/** Final output of the Concept Engine */
|
||||
export interface ProjectConcept {
|
||||
domain: string;
|
||||
timestamp: string;
|
||||
briefing: string;
|
||||
auditedFacts: Record<string, any>;
|
||||
siteProfile?: SiteProfile;
|
||||
siteAudit?: any;
|
||||
researchData?: any;
|
||||
strategy: {
|
||||
briefingSummary: string;
|
||||
designVision: string;
|
||||
};
|
||||
architecture: {
|
||||
websiteTopic: string;
|
||||
sitemap: SitemapCategory[];
|
||||
};
|
||||
usage: UsageStats;
|
||||
}
|
||||
|
||||
export interface SitemapCategory {
|
||||
category: string;
|
||||
pages: { title: string; desc: string }[];
|
||||
}
|
||||
|
||||
export interface UsageStats {
|
||||
totalPromptTokens: number;
|
||||
totalCompletionTokens: number;
|
||||
totalCost: number;
|
||||
perStep: StepUsage[];
|
||||
}
|
||||
|
||||
export interface StepUsage {
|
||||
step: string;
|
||||
model: string;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
cost: number;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
/** Result of a single pipeline step */
|
||||
export interface StepResult<T = any> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
usage?: StepUsage;
|
||||
}
|
||||
|
||||
/** Validation result from the deterministic validator */
|
||||
export interface ValidationResult {
|
||||
passed: boolean;
|
||||
errors: ValidationError[];
|
||||
warnings: ValidationWarning[];
|
||||
}
|
||||
|
||||
export interface ValidationError {
|
||||
code: string;
|
||||
message: string;
|
||||
field?: string;
|
||||
expected?: any;
|
||||
actual?: any;
|
||||
}
|
||||
|
||||
export interface ValidationWarning {
|
||||
code: string;
|
||||
message: string;
|
||||
suggestion?: string;
|
||||
}
|
||||
|
||||
/** Step definition for the concept pipeline */
|
||||
export interface PipelineStep {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
model: "flash" | "pro" | "opus" | "none";
|
||||
execute: (
|
||||
state: ConceptState,
|
||||
config: PipelineConfig,
|
||||
) => Promise<StepResult>;
|
||||
}
|
||||
28
packages/concept-engine/tsconfig.json
Normal file
28
packages/concept-engine/tsconfig.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"target": "ES2022",
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM"
|
||||
],
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"**/*.test.ts"
|
||||
]
|
||||
}
|
||||
9
packages/concept-engine/tsup.config.ts
Normal file
9
packages/concept-engine/tsup.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from "tsup";
|
||||
|
||||
export default defineConfig({
|
||||
entry: ["src/index.ts", "src/cli.ts"],
|
||||
format: ["esm"],
|
||||
dts: true,
|
||||
clean: true,
|
||||
target: "es2022",
|
||||
});
|
||||
@@ -52,11 +52,11 @@ interface Insertion {
|
||||
// Model configuration: specialized models for different tasks
|
||||
const MODELS = {
|
||||
// Structured JSON output, research planning, diagram models: {
|
||||
STRUCTURED: "google/gemini-2.5-flash",
|
||||
ROUTING: "google/gemini-2.5-flash",
|
||||
CONTENT: "google/gemini-2.5-pro",
|
||||
STRUCTURED: "google/gemini-3-flash-preview",
|
||||
ROUTING: "google/gemini-3-flash-preview",
|
||||
CONTENT: "google/gemini-3.1-pro-preview",
|
||||
// Mermaid diagram generation - User requested Pro
|
||||
DIAGRAM: "google/gemini-2.5-pro",
|
||||
DIAGRAM: "google/gemini-3.1-pro-preview",
|
||||
} as const;
|
||||
|
||||
/** Strip markdown fences that some models wrap around JSON despite response_format */
|
||||
|
||||
@@ -214,7 +214,7 @@ export class AiBlogPostOrchestrator {
|
||||
|
||||
async generateSlug(content: string, title?: string, instructions?: string): Promise<string> {
|
||||
const response = await this.openai.chat.completions.create({
|
||||
model: "google/gemini-2.5-flash",
|
||||
model: "google/gemini-3-flash-preview",
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
@@ -347,7 +347,7 @@ Example output: "A complex network of glowing fiber optic nodes forming a recurs
|
||||
|
||||
private async identifyTopics(content: string): Promise<string[]> {
|
||||
const response = await this.openai.chat.completions.create({
|
||||
model: "google/gemini-2.5-flash", // fast structured model for topic extraction
|
||||
model: "google/gemini-3-flash-preview", // fast structured model for topic extraction
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "1.8.21",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://npm.infra.mintel.me"
|
||||
"registry": "https://git.infra.mintel.me/api/packages/mmintel/npm"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "index.js",
|
||||
|
||||
40
packages/estimation-engine/src/_test_pipeline.ts
Normal file
40
packages/estimation-engine/src/_test_pipeline.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { config as dotenvConfig } from 'dotenv';
|
||||
import * as path from 'node:path';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import { EstimationPipeline } from './pipeline.js';
|
||||
|
||||
dotenvConfig({ path: path.resolve(process.cwd(), '../../.env') });
|
||||
|
||||
const briefing = await fs.readFile(
|
||||
path.resolve(process.cwd(), '../../data/briefings/etib.txt'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
console.log(`Briefing loaded: ${briefing.length} chars`);
|
||||
|
||||
const pipeline = new EstimationPipeline(
|
||||
{
|
||||
openrouterKey: process.env.OPENROUTER_API_KEY || '',
|
||||
zyteApiKey: process.env.ZYTE_API_KEY,
|
||||
outputDir: path.resolve(process.cwd(), '../../out/estimations'),
|
||||
crawlDir: path.resolve(process.cwd(), '../../data/crawls'),
|
||||
},
|
||||
{
|
||||
onStepStart: (id, name) => console.log(`[CB] Starting: ${id}`),
|
||||
onStepComplete: (id) => console.log(`[CB] Done: ${id}`),
|
||||
onStepError: (id, err) => console.error(`[CB] Error in ${id}: ${err}`),
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await pipeline.run({
|
||||
briefing,
|
||||
url: 'https://www.e-tib.com',
|
||||
});
|
||||
|
||||
console.log('\n✨ Pipeline complete!');
|
||||
console.log('Validation:', result.validationResult?.passed ? 'PASSED' : 'FAILED');
|
||||
} catch (err: any) {
|
||||
console.error('\n❌ Pipeline failed:', err.message);
|
||||
console.error(err.stack);
|
||||
}
|
||||
78
packages/estimation-engine/src/cli.ts
Normal file
78
packages/estimation-engine/src/cli.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env node
|
||||
// ============================================================================
|
||||
// @mintel/estimation-engine — CLI Entry Point
|
||||
// ============================================================================
|
||||
|
||||
import { Command } from "commander";
|
||||
import * as path from "node:path";
|
||||
import * as fs from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { config as dotenvConfig } from "dotenv";
|
||||
import { EstimationPipeline } from "./pipeline.js";
|
||||
import type { ProjectConcept } from "@mintel/concept-engine";
|
||||
|
||||
// Load .env from monorepo root
|
||||
dotenvConfig({ path: path.resolve(process.cwd(), "../../.env") });
|
||||
dotenvConfig({ path: path.resolve(process.cwd(), ".env") });
|
||||
|
||||
const program = new Command();
|
||||
|
||||
program
|
||||
.name("estimate")
|
||||
.description("AI-powered project estimation engine")
|
||||
.version("1.0.0");
|
||||
|
||||
program
|
||||
.command("run")
|
||||
.description("Run the financial estimation pipeline from a concept file")
|
||||
.argument("<concept-file>", "Path to the ProjectConcept JSON file")
|
||||
.option("--budget <budget>", "Budget constraint (e.g. '15.000 €')")
|
||||
.option("--output <dir>", "Output directory", "../../out/estimations")
|
||||
.action(async (conceptFile: string, options: any) => {
|
||||
const openrouterKey = process.env.OPENROUTER_API_KEY || process.env.OPENROUTER_KEY;
|
||||
if (!openrouterKey) {
|
||||
console.error("❌ OPENROUTER_API_KEY not found in environment.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const filePath = path.resolve(process.cwd(), conceptFile);
|
||||
if (!existsSync(filePath)) {
|
||||
console.error(`❌ Concept file not found: ${filePath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`📄 Loading concept from: ${filePath}`);
|
||||
const rawConcept = await fs.readFile(filePath, "utf8");
|
||||
const concept = JSON.parse(rawConcept) as ProjectConcept;
|
||||
|
||||
const pipeline = new EstimationPipeline(
|
||||
{
|
||||
openrouterKey,
|
||||
outputDir: path.resolve(process.cwd(), options.output),
|
||||
crawlDir: "" // No longer needed here
|
||||
},
|
||||
{
|
||||
onStepStart: (id, name) => { },
|
||||
onStepComplete: (id, result) => { },
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await pipeline.run({
|
||||
concept,
|
||||
budget: options.budget,
|
||||
});
|
||||
|
||||
console.log("\n✨ Estimation complete!");
|
||||
|
||||
if (result.validationResult && !result.validationResult.passed) {
|
||||
console.log(`\n⚠️ ${result.validationResult.errors.length} validation issues found.`);
|
||||
console.log(" Review the output JSON and re-run problematic steps.");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`\n❌ Pipeline failed: ${(err as Error).message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
program.parse();
|
||||
9
packages/estimation-engine/src/index.ts
Normal file
9
packages/estimation-engine/src/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
// ============================================================================
|
||||
// @mintel/estimation-engine — Public API
|
||||
// ============================================================================
|
||||
|
||||
export { EstimationPipeline } from "./pipeline.js";
|
||||
export type { PipelineCallbacks } from "./pipeline.js";
|
||||
export { validateEstimation } from "./validators.js";
|
||||
export { llmRequest, llmJsonRequest, cleanJson } from "./llm-client.js";
|
||||
export * from "./types.js";
|
||||
128
packages/estimation-engine/src/llm-client.ts
Normal file
128
packages/estimation-engine/src/llm-client.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
// ============================================================================
|
||||
// LLM Client — Unified interface with model routing via OpenRouter
|
||||
// ============================================================================
|
||||
|
||||
import axios from "axios";
|
||||
|
||||
interface LLMRequestOptions {
|
||||
model: string;
|
||||
systemPrompt: string;
|
||||
userPrompt: string;
|
||||
jsonMode?: boolean;
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
interface LLMResponse {
|
||||
content: string;
|
||||
usage: {
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
cost: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean raw LLM output to parseable JSON.
|
||||
* Handles markdown fences, control chars, trailing commas.
|
||||
*/
|
||||
export function cleanJson(str: string): string {
|
||||
let cleaned = str.replace(/```json\n?|```/g, "").trim();
|
||||
cleaned = cleaned.replace(
|
||||
/[\u0000-\u0009\u000B\u000C\u000E-\u001F\u007F-\u009F]/g,
|
||||
" ",
|
||||
);
|
||||
cleaned = cleaned.replace(/,\s*([\]}])/g, "$1");
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a request to an LLM via OpenRouter.
|
||||
*/
|
||||
export async function llmRequest(options: LLMRequestOptions): Promise<LLMResponse> {
|
||||
const { model, systemPrompt, userPrompt, jsonMode = true, apiKey } = options;
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
const resp = await axios.post(
|
||||
"https://openrouter.ai/api/v1/chat/completions",
|
||||
{
|
||||
model,
|
||||
messages: [
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "user", content: userPrompt },
|
||||
],
|
||||
...(jsonMode ? { response_format: { type: "json_object" } } : {}),
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout: 120000,
|
||||
},
|
||||
);
|
||||
|
||||
const content = resp.data.choices?.[0]?.message?.content;
|
||||
if (!content) {
|
||||
throw new Error(`LLM returned no content. Model: ${model}`);
|
||||
}
|
||||
|
||||
let cost = 0;
|
||||
const usage = resp.data.usage || {};
|
||||
if (usage.cost !== undefined) {
|
||||
cost = usage.cost;
|
||||
} else {
|
||||
// Fallback estimation
|
||||
cost =
|
||||
(usage.prompt_tokens || 0) * (0.1 / 1_000_000) +
|
||||
(usage.completion_tokens || 0) * (0.4 / 1_000_000);
|
||||
}
|
||||
|
||||
return {
|
||||
content,
|
||||
usage: {
|
||||
promptTokens: usage.prompt_tokens || 0,
|
||||
completionTokens: usage.completion_tokens || 0,
|
||||
cost,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a request and parse the response as JSON.
|
||||
*/
|
||||
export async function llmJsonRequest<T = any>(
|
||||
options: LLMRequestOptions,
|
||||
): Promise<{ data: T; usage: LLMResponse["usage"] }> {
|
||||
const response = await llmRequest({ ...options, jsonMode: true });
|
||||
const cleaned = cleanJson(response.content);
|
||||
|
||||
let parsed: T;
|
||||
try {
|
||||
parsed = JSON.parse(cleaned);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`Failed to parse LLM JSON response: ${(e as Error).message}\nRaw: ${cleaned.substring(0, 500)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Unwrap common LLM artifacts: {"0": {...}}, {"state": {...}}, etc.
|
||||
const unwrapped = unwrapResponse(parsed);
|
||||
|
||||
return { data: unwrapped as T, usage: response.usage };
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively unwrap common LLM wrapping patterns.
|
||||
*/
|
||||
function unwrapResponse(obj: any): any {
|
||||
if (!obj || typeof obj !== "object" || Array.isArray(obj)) return obj;
|
||||
const keys = Object.keys(obj);
|
||||
if (keys.length === 1) {
|
||||
const key = keys[0];
|
||||
if (key === "0" || key === "state" || key === "facts" || key === "result" || key === "data") {
|
||||
return unwrapResponse(obj[key]);
|
||||
}
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
228
packages/estimation-engine/src/pipeline.ts
Normal file
228
packages/estimation-engine/src/pipeline.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
// ============================================================================
|
||||
// Pipeline Orchestrator
|
||||
// Runs all steps sequentially, tracks state, supports re-running individual steps.
|
||||
// ============================================================================
|
||||
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
import { validateEstimation } from "./validators.js";
|
||||
import { executeSynthesize } from "./steps/05-synthesize.js";
|
||||
import { executeCritique } from "./steps/06-critique.js";
|
||||
import type {
|
||||
PipelineConfig,
|
||||
PipelineInput,
|
||||
EstimationState,
|
||||
StepResult,
|
||||
StepUsage,
|
||||
} from "./types.js";
|
||||
|
||||
export interface PipelineCallbacks {
|
||||
onStepStart?: (stepId: string, stepName: string) => void;
|
||||
onStepComplete?: (stepId: string, result: StepResult) => void;
|
||||
onStepError?: (stepId: string, error: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The main estimation pipeline orchestrator.
|
||||
* Runs steps sequentially, persists state between steps, supports re-entry.
|
||||
*/
|
||||
export class EstimationPipeline {
|
||||
private config: PipelineConfig;
|
||||
private state: EstimationState;
|
||||
private callbacks: PipelineCallbacks;
|
||||
|
||||
constructor(config: PipelineConfig, callbacks: PipelineCallbacks = {}) {
|
||||
this.config = config;
|
||||
this.callbacks = callbacks;
|
||||
this.state = this.createInitialState();
|
||||
}
|
||||
|
||||
private createInitialState(): EstimationState {
|
||||
return {
|
||||
concept: null as any, // Will be set in run()
|
||||
usage: {
|
||||
totalPromptTokens: 0,
|
||||
totalCompletionTokens: 0,
|
||||
totalCost: 0,
|
||||
perStep: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the full estimation pipeline from a completed project concept.
|
||||
*/
|
||||
async run(input: PipelineInput): Promise<EstimationState> {
|
||||
this.state.concept = input.concept;
|
||||
this.state.budget = input.budget;
|
||||
|
||||
// Ensure output directories
|
||||
await fs.mkdir(this.config.outputDir, { recursive: true });
|
||||
|
||||
// Step 5: Position synthesis
|
||||
await this.runStep("05-synthesize", "Position Descriptions", async () => {
|
||||
const result = await executeSynthesize(this.state, this.config);
|
||||
if (result.success) this.state.positionDescriptions = result.data;
|
||||
return result;
|
||||
});
|
||||
|
||||
// Step 6: Quality critique
|
||||
await this.runStep("06-critique", "Quality Gate (Industrial Critic)", async () => {
|
||||
const result = await executeCritique(this.state, this.config);
|
||||
if (result.success) {
|
||||
this.state.critiquePassed = result.data.passed;
|
||||
this.state.critiqueErrors = result.data.errors?.map((e: any) => `${e.field}: ${e.issue}`) || [];
|
||||
|
||||
// Apply corrections
|
||||
if (result.data.corrections) {
|
||||
const corrections = result.data.corrections;
|
||||
// Note: We only correct the positionDescriptions since briefing/design/sitemap are locked in the concept phase.
|
||||
// If the critique suggests changes to those, it should be a warning or failure.
|
||||
if (corrections.positionDescriptions) {
|
||||
this.state.positionDescriptions = {
|
||||
...this.state.positionDescriptions,
|
||||
...corrections.positionDescriptions,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
// Step 7: Deterministic validation
|
||||
await this.runStep("07-validate", "Deterministic Validation", async () => {
|
||||
// Build the merged form state first
|
||||
this.state.formState = this.buildFormState();
|
||||
const validationResult = validateEstimation(this.state);
|
||||
this.state.validationResult = validationResult;
|
||||
|
||||
if (!validationResult.passed) {
|
||||
console.log("\n⚠️ Validation Issues:");
|
||||
for (const error of validationResult.errors) {
|
||||
console.log(` ❌ [${error.code}] ${error.message}`);
|
||||
}
|
||||
}
|
||||
if (validationResult.warnings.length > 0) {
|
||||
console.log("\n⚡ Warnings:");
|
||||
for (const warning of validationResult.warnings) {
|
||||
console.log(` ⚡ [${warning.code}] ${warning.message}`);
|
||||
if (warning.suggestion) console.log(` → ${warning.suggestion}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: validationResult,
|
||||
usage: { step: "07-validate", model: "none", promptTokens: 0, completionTokens: 0, cost: 0, durationMs: 0 },
|
||||
};
|
||||
});
|
||||
|
||||
// Save final state
|
||||
await this.saveState();
|
||||
|
||||
return this.state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a single step with callbacks and error handling.
|
||||
*/
|
||||
private async runStep(
|
||||
stepId: string,
|
||||
stepName: string,
|
||||
executor: () => Promise<StepResult>,
|
||||
): Promise<void> {
|
||||
this.callbacks.onStepStart?.(stepId, stepName);
|
||||
console.log(`\n📍 ${stepName}...`);
|
||||
|
||||
try {
|
||||
const result = await executor();
|
||||
if (result.usage) {
|
||||
this.state.usage.perStep.push(result.usage);
|
||||
this.state.usage.totalPromptTokens += result.usage.promptTokens;
|
||||
this.state.usage.totalCompletionTokens += result.usage.completionTokens;
|
||||
this.state.usage.totalCost += result.usage.cost;
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
const cost = result.usage?.cost ? ` ($${result.usage.cost.toFixed(4)})` : "";
|
||||
const duration = result.usage?.durationMs ? ` [${(result.usage.durationMs / 1000).toFixed(1)}s]` : "";
|
||||
console.log(` ✅ ${stepName} complete${cost}${duration}`);
|
||||
this.callbacks.onStepComplete?.(stepId, result);
|
||||
} else {
|
||||
console.error(` ❌ ${stepName} failed: ${result.error}`);
|
||||
this.callbacks.onStepError?.(stepId, result.error || "Unknown error");
|
||||
throw new Error(result.error);
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMsg = (err as Error).message;
|
||||
this.callbacks.onStepError?.(stepId, errorMsg);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the final FormState compatible with @mintel/pdf.
|
||||
*/
|
||||
private buildFormState(): Record<string, any> {
|
||||
const facts = this.state.concept.auditedFacts || {};
|
||||
return {
|
||||
projectType: "website",
|
||||
...facts,
|
||||
briefingSummary: this.state.concept.strategy.briefingSummary || "",
|
||||
designVision: this.state.concept.strategy.designVision || "",
|
||||
sitemap: this.state.concept.architecture.sitemap || [],
|
||||
positionDescriptions: this.state.positionDescriptions || {},
|
||||
websiteTopic: this.state.concept.architecture.websiteTopic || facts.websiteTopic || "",
|
||||
statusQuo: facts.isRelaunch ? "Relaunch" : "Neuentwicklung",
|
||||
name: facts.personName || "",
|
||||
email: facts.email || "",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the full state to disk for later re-use.
|
||||
*/
|
||||
private async saveState(): Promise<void> {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const companyName = this.state.concept.auditedFacts?.companyName || "unknown";
|
||||
|
||||
// Save full state
|
||||
const stateDir = path.join(this.config.outputDir, "json");
|
||||
await fs.mkdir(stateDir, { recursive: true });
|
||||
|
||||
const statePath = path.join(stateDir, `${companyName}_${timestamp}.json`);
|
||||
await fs.writeFile(statePath, JSON.stringify(this.state.formState, null, 2));
|
||||
console.log(`\n📦 Saved state to: ${statePath}`);
|
||||
|
||||
// Save full pipeline state (for debugging / re-entry)
|
||||
const debugPath = path.join(stateDir, `${companyName}_${timestamp}_debug.json`);
|
||||
await fs.writeFile(debugPath, JSON.stringify(this.state, null, 2));
|
||||
|
||||
// Print usage summary
|
||||
console.log("\n──────────────────────────────────────────────");
|
||||
console.log("📊 PIPELINE USAGE SUMMARY");
|
||||
console.log("──────────────────────────────────────────────");
|
||||
for (const step of this.state.usage.perStep) {
|
||||
if (step.cost > 0) {
|
||||
console.log(` ${step.step}: ${step.model} — $${step.cost.toFixed(6)} (${(step.durationMs / 1000).toFixed(1)}s)`);
|
||||
}
|
||||
}
|
||||
console.log("──────────────────────────────────────────────");
|
||||
console.log(` TOTAL: $${this.state.usage.totalCost.toFixed(6)}`);
|
||||
console.log(` Tokens: ${(this.state.usage.totalPromptTokens + this.state.usage.totalCompletionTokens).toLocaleString()}`);
|
||||
console.log("──────────────────────────────────────────────\n");
|
||||
}
|
||||
|
||||
/** Get the current state (for CLI inspection). */
|
||||
getState(): EstimationState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
/** Load a saved state from JSON. */
|
||||
async loadState(jsonPath: string): Promise<void> {
|
||||
const raw = await fs.readFile(jsonPath, "utf8");
|
||||
const formState = JSON.parse(raw);
|
||||
this.state.formState = formState;
|
||||
}
|
||||
}
|
||||
95
packages/estimation-engine/src/steps/05-synthesize.ts
Normal file
95
packages/estimation-engine/src/steps/05-synthesize.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
// ============================================================================
|
||||
// Step 05: Synthesize — Position Descriptions (Gemini Pro)
|
||||
// ============================================================================
|
||||
|
||||
import { llmJsonRequest } from "../llm-client.js";
|
||||
import type { EstimationState, StepResult, PipelineConfig } from "../types.js";
|
||||
import { DEFAULT_MODELS } from "../types.js";
|
||||
|
||||
export async function executeSynthesize(
|
||||
state: EstimationState,
|
||||
config: PipelineConfig,
|
||||
): Promise<StepResult> {
|
||||
const models = { ...DEFAULT_MODELS, ...config.modelsOverride };
|
||||
const startTime = Date.now();
|
||||
|
||||
if (!state.concept?.auditedFacts || !state.concept?.architecture?.sitemap) {
|
||||
return { success: false, error: "Missing audited facts or sitemap." };
|
||||
}
|
||||
|
||||
const facts = state.concept.auditedFacts;
|
||||
|
||||
// Determine which positions are required
|
||||
const requiredPositions = [
|
||||
"Das technische Fundament",
|
||||
(facts.selectedPages?.length || 0) + (facts.otherPages?.length || 0) > 0
|
||||
? "Individuelle Seiten"
|
||||
: null,
|
||||
facts.features?.length > 0 ? "System-Module (Features)" : null,
|
||||
facts.functions?.length > 0 ? "Logik-Funktionen" : null,
|
||||
facts.apiSystems?.length > 0 ? "Schnittstellen (API)" : null,
|
||||
facts.cmsSetup ? "Inhalts-Verwaltung" : null,
|
||||
facts.multilang ? "Mehrsprachigkeit" : null,
|
||||
"Inhaltliche Initial-Pflege",
|
||||
"Sorglos Betrieb",
|
||||
].filter(Boolean);
|
||||
|
||||
const systemPrompt = `
|
||||
You are a Senior Solution Architect. Write position descriptions for a professional B2B quote.
|
||||
|
||||
### REQUIRED POSITIONS (STRICT — ONLY DESCRIBE THESE):
|
||||
${requiredPositions.map((p) => `"${p}"`).join(", ")}
|
||||
|
||||
### RULES (STRICT):
|
||||
1. NO FIRST PERSON: NEVER "Ich", "Mein", "Wir", "Unser". Lead with nouns or passive verbs.
|
||||
2. QUANTITY PARITY: Description MUST list EXACTLY the number of items matching 'qty'.
|
||||
3. CMS GUARD: If cmsSetup=false, do NOT mention "CMS", "Inhaltsverwaltung". Use "Plattform-Struktur".
|
||||
4. TONE: "Erstellung von...", "Anbindung der...", "Bereitstellung von...". Technical, high-density.
|
||||
5. PAGES: List actual page names. NO implementation notes in parentheses.
|
||||
6. HARD SPECIFICS: Use industry terms from the briefing (e.g. "Kabeltiefbau", "110 kV").
|
||||
7. KEYS: Return EXACTLY the keys from REQUIRED POSITIONS.
|
||||
8. NO AGB: NEVER mention "AGB" or "Geschäftsbedingungen".
|
||||
9. Sorglos Betrieb: "Inklusive 1 Jahr technischer Betrieb, Hosting, SSL, Sicherheits-Updates, Monitoring und techn. Support."
|
||||
10. Inhaltliche Initial-Pflege: Refers to DATENSÄTZE (datasets like products, references), NOT Seiten.
|
||||
Use "Datensätze" in the description, not "Seiten".
|
||||
11. Mehrsprachigkeit: This is a +20% markup on the subtotal. NOT an API. NOT a Schnittstelle.
|
||||
|
||||
### EXAMPLES:
|
||||
- GOOD: "Erstellung der Seiten: Startseite, Über uns, Leistungen, Kontakt."
|
||||
- GOOD: "Native API-Anbindung an Google Maps mit individueller Standort-Visualisierung."
|
||||
- BAD: "Ich richte dir das CMS ein."
|
||||
- BAD: "Verschiedene Funktionen" (too generic — name the things!)
|
||||
|
||||
### DATA CONTEXT:
|
||||
${JSON.stringify({ facts, sitemap: state.concept.architecture.sitemap, strategy: { briefingSummary: state.concept.strategy.briefingSummary } }, null, 2)}
|
||||
|
||||
### OUTPUT FORMAT:
|
||||
{
|
||||
"positionDescriptions": { "Das technische Fundament": string, ... }
|
||||
}
|
||||
`;
|
||||
|
||||
try {
|
||||
const { data, usage } = await llmJsonRequest({
|
||||
model: models.pro,
|
||||
systemPrompt,
|
||||
userPrompt: state.concept.briefing,
|
||||
apiKey: config.openrouterKey,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: data.positionDescriptions || data,
|
||||
usage: {
|
||||
step: "05-synthesize",
|
||||
model: models.pro,
|
||||
promptTokens: usage.promptTokens,
|
||||
completionTokens: usage.completionTokens,
|
||||
cost: usage.cost,
|
||||
durationMs: Date.now() - startTime,
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
return { success: false, error: `Synthesize step failed: ${(err as Error).message}` };
|
||||
}
|
||||
}
|
||||
99
packages/estimation-engine/src/steps/06-critique.ts
Normal file
99
packages/estimation-engine/src/steps/06-critique.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
// ============================================================================
|
||||
// Step 06: Critique — Industrial Critic Quality Gate (Claude Opus)
|
||||
// ============================================================================
|
||||
|
||||
import { llmJsonRequest } from "../llm-client.js";
|
||||
import type { EstimationState, StepResult, PipelineConfig } from "../types.js";
|
||||
import { DEFAULT_MODELS } from "../types.js";
|
||||
|
||||
export async function executeCritique(
|
||||
state: EstimationState,
|
||||
config: PipelineConfig,
|
||||
): Promise<StepResult> {
|
||||
const models = { ...DEFAULT_MODELS, ...config.modelsOverride };
|
||||
const startTime = Date.now();
|
||||
|
||||
const currentState = {
|
||||
facts: state.concept?.auditedFacts,
|
||||
briefingSummary: state.concept?.strategy?.briefingSummary,
|
||||
designVision: state.concept?.strategy?.designVision,
|
||||
sitemap: state.concept?.architecture?.sitemap,
|
||||
positionDescriptions: state.positionDescriptions,
|
||||
siteProfile: state.concept?.siteProfile
|
||||
? {
|
||||
existingFeatures: state.concept.siteProfile.existingFeatures,
|
||||
services: state.concept.siteProfile.services,
|
||||
externalDomains: state.concept.siteProfile.externalDomains,
|
||||
navigation: state.concept.siteProfile.navigation,
|
||||
totalPages: state.concept.siteProfile.totalPages,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
|
||||
const systemPrompt = `
|
||||
You are the "Industrial Critic" — the final quality gate for a professional B2B estimation.
|
||||
Your job is to find EVERY error, hallucination, and inconsistency before this goes to the client.
|
||||
|
||||
### CRITICAL ERROR CHECKLIST (FAIL IF ANY FOUND):
|
||||
1. HALLUCINATION: FAIL if names, software versions, or details not in the BRIEFING are used.
|
||||
- "Sie", "Ansprechpartner" for personName when an actual name exists = FAIL.
|
||||
2. LOGIC CONFLICT: FAIL if isRelaunch=true but text claims "no website exists".
|
||||
3. IMPLEMENTATION FLUFF: FAIL if "React", "Next.js", "TypeScript", "Tailwind" are mentioned.
|
||||
4. GENERICISM: FAIL if text could apply to ANY company. Must use specific industry terms.
|
||||
5. NAMEN-VERBOT: FAIL if personal names in briefingSummary or designVision.
|
||||
6. CMS-LEAKAGE: FAIL if cmsSetup=false but descriptions mention "CMS", "Inhaltsverwaltung".
|
||||
7. AGB BAN: FAIL if "AGB" or "Geschäftsbedingungen" appear anywhere.
|
||||
8. LENGTH: briefingSummary ~6 sentences, designVision ~4 sentences. Shorten if too wordy.
|
||||
9. LEGAL SAFETY: FAIL if "rechtssicher" is used. Use "Standard-konform" instead.
|
||||
10. BULLSHIT DETECTOR: FAIL if jargon like "SEO-Standards zur Fachkräftesicherung",
|
||||
"B2B-Nutzerströme", "Digitale Konvergenzstrategie" or similar meaningless buzzwords are used.
|
||||
The text must make SENSE to a construction industry CEO.
|
||||
11. PAGE STRUCTURE: FAIL if the sitemap contains:
|
||||
- Videos as pages (Messefilm, Imagefilm)
|
||||
- Internal functions as pages (Verwaltung)
|
||||
- Entities with their own domains as sub-pages (check externalDomains!)
|
||||
12. SORGLOS-BETRIEB: FAIL if not mentioned in the summary or position descriptions.
|
||||
13. TONE: FAIL if "wir/unser" or "Ich/Mein" in position descriptions. FAIL if marketing fluff.
|
||||
14. MULTILANG: FAIL if Mehrsprachigkeit is described as an API or Schnittstelle.
|
||||
15. INITIAL-PFLEGE: FAIL if described in terms of "Seiten" instead of "Datensätze".
|
||||
|
||||
### MISSION:
|
||||
Return corrected fields ONLY for fields with issues. If everything passes, return empty corrections.
|
||||
|
||||
### OUTPUT FORMAT:
|
||||
{
|
||||
"passed": boolean,
|
||||
"errors": [{ "field": string, "issue": string, "severity": "critical" | "warning" }],
|
||||
"corrections": {
|
||||
"briefingSummary"?: string,
|
||||
"designVision"?: string,
|
||||
"positionDescriptions"?: Record<string, string>,
|
||||
"sitemap"?: array
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
try {
|
||||
const { data, usage } = await llmJsonRequest({
|
||||
model: models.opus,
|
||||
systemPrompt,
|
||||
userPrompt: `BRIEFING_TRUTH:\n${state.concept?.briefing}\n\nCURRENT_STATE:\n${JSON.stringify(currentState, null, 2)}`,
|
||||
apiKey: config.openrouterKey,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data,
|
||||
usage: {
|
||||
step: "06-critique",
|
||||
model: models.opus,
|
||||
promptTokens: usage.promptTokens,
|
||||
completionTokens: usage.completionTokens,
|
||||
cost: usage.cost,
|
||||
durationMs: Date.now() - startTime,
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
return { success: false, error: `Critique step failed: ${(err as Error).message}` };
|
||||
}
|
||||
}
|
||||
113
packages/estimation-engine/src/types.ts
Normal file
113
packages/estimation-engine/src/types.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
// ============================================================================
|
||||
// @mintel/estimation-engine — Core Type Definitions
|
||||
// ============================================================================
|
||||
|
||||
import type { ProjectConcept, SitemapCategory } from "@mintel/concept-engine";
|
||||
|
||||
/** Configuration for the estimation pipeline */
|
||||
export interface PipelineConfig {
|
||||
openrouterKey: string;
|
||||
zyteApiKey?: string;
|
||||
outputDir: string;
|
||||
crawlDir: string;
|
||||
modelsOverride?: Partial<ModelConfig>;
|
||||
}
|
||||
|
||||
/** Model routing configuration */
|
||||
export interface ModelConfig {
|
||||
flash: string;
|
||||
pro: string;
|
||||
opus: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_MODELS: ModelConfig = {
|
||||
flash: "google/gemini-3-flash-preview",
|
||||
pro: "google/gemini-3.1-pro-preview",
|
||||
opus: "anthropic/claude-opus-4-6",
|
||||
};
|
||||
|
||||
/** Input for the estimation pipeline */
|
||||
export interface PipelineInput {
|
||||
concept: ProjectConcept;
|
||||
budget?: string;
|
||||
}
|
||||
|
||||
/** State that flows through all pipeline steps */
|
||||
export interface EstimationState {
|
||||
// Input
|
||||
concept: ProjectConcept;
|
||||
budget?: string;
|
||||
|
||||
// Step 5 output: Position Synthesis
|
||||
positionDescriptions?: Record<string, string>;
|
||||
|
||||
// Step 6 output: Critique
|
||||
critiquePassed?: boolean;
|
||||
critiqueErrors?: string[];
|
||||
|
||||
// Step 7 output: Validation
|
||||
validationResult?: ValidationResult;
|
||||
|
||||
// Final merged form state for PDF generation
|
||||
formState?: Record<string, any>;
|
||||
|
||||
// Cost tracking
|
||||
usage: UsageStats;
|
||||
}
|
||||
|
||||
export interface UsageStats {
|
||||
totalPromptTokens: number;
|
||||
totalCompletionTokens: number;
|
||||
totalCost: number;
|
||||
perStep: StepUsage[];
|
||||
}
|
||||
|
||||
export interface StepUsage {
|
||||
step: string;
|
||||
model: string;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
cost: number;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
/** Result of a single pipeline step */
|
||||
export interface StepResult<T = any> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
usage?: StepUsage;
|
||||
}
|
||||
|
||||
/** Validation result from the deterministic validator */
|
||||
export interface ValidationResult {
|
||||
passed: boolean;
|
||||
errors: ValidationError[];
|
||||
warnings: ValidationWarning[];
|
||||
}
|
||||
|
||||
export interface ValidationError {
|
||||
code: string;
|
||||
message: string;
|
||||
field?: string;
|
||||
expected?: any;
|
||||
actual?: any;
|
||||
}
|
||||
|
||||
export interface ValidationWarning {
|
||||
code: string;
|
||||
message: string;
|
||||
suggestion?: string;
|
||||
}
|
||||
|
||||
/** Step definition for the pipeline */
|
||||
export interface PipelineStep {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
model: "flash" | "pro" | "opus" | "none";
|
||||
execute: (
|
||||
state: EstimationState,
|
||||
config: PipelineConfig,
|
||||
) => Promise<StepResult>;
|
||||
}
|
||||
380
packages/estimation-engine/src/validators.ts
Normal file
380
packages/estimation-engine/src/validators.ts
Normal file
@@ -0,0 +1,380 @@
|
||||
// ============================================================================
|
||||
// Validators — Deterministic Math & Logic Checks (NO LLM!)
|
||||
// Catches all the issues reported by the user programmatically.
|
||||
// ============================================================================
|
||||
|
||||
import type { EstimationState, ValidationResult, ValidationError, ValidationWarning } from "./types.js";
|
||||
|
||||
/**
|
||||
* Run all deterministic validation checks on the final estimation state.
|
||||
*/
|
||||
export function validateEstimation(state: EstimationState): ValidationResult {
|
||||
const errors: ValidationError[] = [];
|
||||
const warnings: ValidationWarning[] = [];
|
||||
|
||||
if (!state.formState) {
|
||||
return { passed: false, errors: [{ code: "NO_FORM_STATE", message: "No form state available for validation." }], warnings: [] };
|
||||
}
|
||||
|
||||
const fs = state.formState;
|
||||
|
||||
// 1. PAGE COUNT PARITY
|
||||
validatePageCountParity(fs, errors);
|
||||
|
||||
// 2. SORGLOS-BETRIEB IN SUMMARY
|
||||
validateSorglosBetrieb(fs, errors, warnings);
|
||||
|
||||
// 3. NO VIDEOS AS PAGES
|
||||
validateNoVideosAsPages(fs, errors);
|
||||
|
||||
// 4. EXTERNAL DOMAINS NOT AS PAGES
|
||||
validateExternalDomains(fs, state.concept?.siteProfile, errors);
|
||||
|
||||
// 5. SERVICE COVERAGE
|
||||
validateServiceCoverage(fs, state.concept?.siteProfile, warnings);
|
||||
|
||||
// 6. EXISTING FEATURE DETECTION
|
||||
validateExistingFeatures(fs, state.concept?.siteProfile, warnings);
|
||||
|
||||
// 7. MULTILANG LABEL CORRECTNESS
|
||||
validateMultilangLabeling(fs, errors);
|
||||
|
||||
// 8. INITIAL-PFLEGE UNITS
|
||||
validateInitialPflegeUnits(fs, warnings);
|
||||
|
||||
// 9. SITEMAP vs PAGE LIST CONSISTENCY
|
||||
validateSitemapConsistency(fs, errors);
|
||||
|
||||
return {
|
||||
passed: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Page count: the "Individuelle Seiten" position description must mention
|
||||
* roughly the same number of pages as the sitemap contains.
|
||||
* "er berechnet 15 Seiten nennt aber nur 11"
|
||||
*
|
||||
* NOTE: fs.pages (from auditedFacts) is a conceptual list of page groups
|
||||
* (e.g. "Leistungen") while the sitemap expands those into sub-pages.
|
||||
* Therefore we do NOT compare fs.pages.length to the sitemap count.
|
||||
* Instead, we verify that the position description text lists the right count.
|
||||
*/
|
||||
function validatePageCountParity(
|
||||
fs: Record<string, any>,
|
||||
errors: ValidationError[],
|
||||
): void {
|
||||
// Count pages listed in the sitemap (the source of truth)
|
||||
let sitemapPageCount = 0;
|
||||
if (Array.isArray(fs.sitemap)) {
|
||||
for (const cat of fs.sitemap) {
|
||||
sitemapPageCount += (cat.pages || []).length;
|
||||
}
|
||||
}
|
||||
if (sitemapPageCount === 0) return;
|
||||
|
||||
// Extract page names mentioned in the "Individuelle Seiten" position description
|
||||
const positions = fs.positionDescriptions || {};
|
||||
const pagesDesc = positions["Individuelle Seiten"] || positions["2. Individuelle Seiten"] || "";
|
||||
if (!pagesDesc) return;
|
||||
|
||||
const descStr = typeof pagesDesc === "string" ? pagesDesc : "";
|
||||
|
||||
// Count distinct page names mentioned (split by comma)
|
||||
// We avoid splitting by "&" or "und" because actual page names like
|
||||
// "Wartung & Störungsdienst" or "Genehmigungs- und Ausführungsplanung" contain them.
|
||||
const afterColon = descStr.includes(":") ? descStr.split(":").slice(1).join(":") : descStr;
|
||||
const segments = afterColon
|
||||
.split(/,/)
|
||||
.map((s: string) => s.replace(/\.$/, "").trim())
|
||||
.filter((s: string) => s.length > 2);
|
||||
|
||||
// Handle consolidated references like "Leistungen (6 Unterseiten)" or "(inkl. Messen)"
|
||||
let mentionedCount = 0;
|
||||
for (const seg of segments) {
|
||||
const subPageMatch = seg.match(/\((\d+)\s+(?:Unter)?[Ss]eiten?\)/);
|
||||
if (subPageMatch) {
|
||||
mentionedCount += parseInt(subPageMatch[1], 10);
|
||||
} else if (seg.match(/\(inkl\.\s+/)) {
|
||||
// "Unternehmen (inkl. Messen)" = 2 pages
|
||||
mentionedCount += 2;
|
||||
} else {
|
||||
mentionedCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (mentionedCount > 0 && Math.abs(mentionedCount - sitemapPageCount) > 2) {
|
||||
errors.push({
|
||||
code: "PAGE_COUNT_MISMATCH",
|
||||
message: `Seiten-Beschreibung nennt ~${mentionedCount} Seiten, aber ${sitemapPageCount} Seiten in der Sitemap.`,
|
||||
field: "positionDescriptions.Individuelle Seiten",
|
||||
expected: sitemapPageCount,
|
||||
actual: mentionedCount,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 2. Sorglos-Betrieb must be included in summary.
|
||||
* "Zusammenfassung der Schätzung hat Sorglos-Betrieb nicht miteingenommen"
|
||||
*/
|
||||
function validateSorglosBetrieb(
|
||||
fs: Record<string, any>,
|
||||
errors: ValidationError[],
|
||||
warnings: ValidationWarning[],
|
||||
): void {
|
||||
const positions = fs.positionDescriptions || {};
|
||||
const hasPosition = Object.keys(positions).some(
|
||||
(k) =>
|
||||
k.toLowerCase().includes("sorglos") ||
|
||||
k.toLowerCase().includes("betrieb") ||
|
||||
k.toLowerCase().includes("pflege"),
|
||||
);
|
||||
|
||||
if (!hasPosition) {
|
||||
errors.push({
|
||||
code: "MISSING_SORGLOS_BETRIEB",
|
||||
message: "Der Sorglos-Betrieb fehlt in den Position-Beschreibungen.",
|
||||
field: "positionDescriptions",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 3. Videos must not be treated as separate pages.
|
||||
* "Er hat Videos als eigene Seite aufgenommen"
|
||||
*/
|
||||
function validateNoVideosAsPages(
|
||||
fs: Record<string, any>,
|
||||
errors: ValidationError[],
|
||||
): void {
|
||||
const allPages = [...(fs.selectedPages || []), ...(fs.otherPages || [])];
|
||||
const sitemapPages = Array.isArray(fs.sitemap)
|
||||
? fs.sitemap.flatMap((cat: any) => (cat.pages || []).map((p: any) => p.title))
|
||||
: [];
|
||||
|
||||
const allPageNames = [...allPages, ...sitemapPages];
|
||||
const videoKeywords = ["video", "film", "messefilm", "imagefilm", "clip"];
|
||||
|
||||
for (const pageName of allPageNames) {
|
||||
const lower = (typeof pageName === "string" ? pageName : "").toLowerCase();
|
||||
if (videoKeywords.some((kw) => lower.includes(kw) && !lower.includes("leistung"))) {
|
||||
errors.push({
|
||||
code: "VIDEO_AS_PAGE",
|
||||
message: `"${pageName}" ist ein Video-Asset, keine eigene Seite.`,
|
||||
field: "sitemap",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 4. External sister-company domains must not be proposed as sub-pages.
|
||||
* "er hat ingenieursgesellschaft als seite integriert, die haben aber eine eigene website"
|
||||
*/
|
||||
function validateExternalDomains(
|
||||
fs: Record<string, any>,
|
||||
siteProfile: any,
|
||||
errors: ValidationError[],
|
||||
): void {
|
||||
if (!siteProfile?.externalDomains?.length) return;
|
||||
|
||||
const sitemapPages = Array.isArray(fs.sitemap)
|
||||
? fs.sitemap.flatMap((cat: any) => (cat.pages || []).map((p: any) => p.title || ""))
|
||||
: [];
|
||||
|
||||
for (const extDomain of siteProfile.externalDomains) {
|
||||
// Extract base name (e.g. "etib-ing" from "etib-ing.com")
|
||||
const baseName = extDomain.replace(/^www\./, "").split(".")[0].toLowerCase();
|
||||
|
||||
for (const pageTitle of sitemapPages) {
|
||||
const lower = pageTitle.toLowerCase();
|
||||
// Check if the page title references the external company
|
||||
if (lower.includes(baseName) || (lower.includes("ingenieur") && extDomain.includes("ing"))) {
|
||||
errors.push({
|
||||
code: "EXTERNAL_DOMAIN_AS_PAGE",
|
||||
message: `"${pageTitle}" hat eine eigene Website (${extDomain}) und darf nicht als Unterseite vorgeschlagen werden.`,
|
||||
field: "sitemap",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 5. Services from the existing site should be covered.
|
||||
* "er hat leistungen ausgelassen die ganz klar auf der kompetenz seite genannt werden"
|
||||
*/
|
||||
function validateServiceCoverage(
|
||||
fs: Record<string, any>,
|
||||
siteProfile: any,
|
||||
warnings: ValidationWarning[],
|
||||
): void {
|
||||
if (!siteProfile?.services?.length) return;
|
||||
|
||||
const allContent = JSON.stringify(fs).toLowerCase();
|
||||
|
||||
for (const service of siteProfile.services) {
|
||||
const keywords = service
|
||||
.toLowerCase()
|
||||
.split(/[\s,&-]+/)
|
||||
.filter((w: string) => w.length > 4);
|
||||
|
||||
const isCovered = keywords.some((kw: string) => allContent.includes(kw));
|
||||
|
||||
if (!isCovered && service.length > 5) {
|
||||
warnings.push({
|
||||
code: "MISSING_SERVICE",
|
||||
message: `Bestehende Leistung "${service}" ist nicht in der Schätzung berücksichtigt.`,
|
||||
suggestion: `Prüfen ob "${service}" im Briefing gewünscht ist und ggf. in die Seitenstruktur aufnehmen.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 6. Existing features (search, forms) must be acknowledged.
|
||||
* "er hat die suchfunktion nicht bemerkt, die gibts schon auf der seite"
|
||||
*/
|
||||
function validateExistingFeatures(
|
||||
fs: Record<string, any>,
|
||||
siteProfile: any,
|
||||
warnings: ValidationWarning[],
|
||||
): void {
|
||||
if (!siteProfile?.existingFeatures?.length) return;
|
||||
|
||||
const functions = fs.functions || [];
|
||||
const features = fs.features || [];
|
||||
const allSelected = [...functions, ...features];
|
||||
|
||||
for (const existingFeature of siteProfile.existingFeatures) {
|
||||
if (existingFeature === "cookie-consent") continue; // Standard, don't flag
|
||||
if (existingFeature === "video") continue; // Usually an asset, not a feature
|
||||
|
||||
const isMapped = allSelected.some(
|
||||
(f: string) => f.toLowerCase() === existingFeature.toLowerCase(),
|
||||
);
|
||||
|
||||
if (!isMapped) {
|
||||
warnings.push({
|
||||
code: "EXISTING_FEATURE_IGNORED",
|
||||
message: `Die bestehende Suchfunktion/Feature "${existingFeature}" wurde auf der aktuellen Website erkannt, aber nicht in der Schätzung berücksichtigt.`,
|
||||
suggestion: `"${existingFeature}" als Function oder Feature aufnehmen, da es bereits existiert und der Kunde es erwartet.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 7. Multilang +20% must not be labeled as API.
|
||||
* "die +20% beziehen sich nicht auf API"
|
||||
*/
|
||||
function validateMultilangLabeling(
|
||||
fs: Record<string, any>,
|
||||
errors: ValidationError[],
|
||||
): void {
|
||||
const positions = fs.positionDescriptions || {};
|
||||
|
||||
for (const [key, desc] of Object.entries(positions)) {
|
||||
if (key.toLowerCase().includes("api") || key.toLowerCase().includes("schnittstelle")) {
|
||||
const descStr = typeof desc === "string" ? desc : "";
|
||||
if (
|
||||
descStr.toLowerCase().includes("mehrsprach") ||
|
||||
descStr.toLowerCase().includes("multilang") ||
|
||||
descStr.toLowerCase().includes("20%")
|
||||
) {
|
||||
errors.push({
|
||||
code: "MULTILANG_WRONG_POSITION",
|
||||
message: `Mehrsprachigkeit (+20%) ist unter "${key}" eingeordnet, gehört aber nicht zu API/Schnittstellen.`,
|
||||
field: key,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 8. Initial-Pflege should refer to "Datensätze" not "Seiten".
|
||||
* "Initialpflege => 100€/Stk => damit sind keine Seiten sondern Datensätze"
|
||||
*/
|
||||
function validateInitialPflegeUnits(
|
||||
fs: Record<string, any>,
|
||||
warnings: ValidationWarning[],
|
||||
): void {
|
||||
const positions = fs.positionDescriptions || {};
|
||||
|
||||
for (const [key, desc] of Object.entries(positions)) {
|
||||
if (key.toLowerCase().includes("pflege") || key.toLowerCase().includes("initial")) {
|
||||
const descStr = typeof desc === "string" ? desc : "";
|
||||
if (descStr.toLowerCase().includes("seiten") && !descStr.toLowerCase().includes("datensätz")) {
|
||||
warnings.push({
|
||||
code: "INITIALPFLEGE_WRONG_UNIT",
|
||||
message: `"${key}" spricht von "Seiten", aber gemeint sind Datensätze (z.B. Produkte, Referenzen).`,
|
||||
suggestion: `Beschreibung auf "Datensätze" statt "Seiten" ändern.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 9. Position descriptions must match calculated quantities.
|
||||
*/
|
||||
function validatePositionDescriptionsMath(
|
||||
fs: Record<string, any>,
|
||||
errors: ValidationError[],
|
||||
): void {
|
||||
const positions = fs.positionDescriptions || {};
|
||||
|
||||
// Check pages description mentions correct count
|
||||
const pagesDesc = positions["Individuelle Seiten"] || positions["2. Individuelle Seiten"] || "";
|
||||
if (pagesDesc) {
|
||||
// Use the sitemap as the authoritative source of truth for page count
|
||||
let sitemapPageCount = 0;
|
||||
if (Array.isArray(fs.sitemap)) {
|
||||
for (const cat of fs.sitemap) {
|
||||
sitemapPageCount += (cat.pages || []).length;
|
||||
}
|
||||
}
|
||||
|
||||
// Count how many page names are mentioned in the description
|
||||
const descStr = typeof pagesDesc === "string" ? pagesDesc : "";
|
||||
const mentionedPages = descStr.split(/,|und|&/).filter((s: string) => s.trim().length > 2);
|
||||
|
||||
if (sitemapPageCount > 0 && mentionedPages.length > 0 && Math.abs(mentionedPages.length - sitemapPageCount) > 2) {
|
||||
errors.push({
|
||||
code: "PAGES_DESC_COUNT_MISMATCH",
|
||||
message: `Seiten-Beschreibung nennt ~${mentionedPages.length} Seiten, aber ${sitemapPageCount} in der Sitemap.`,
|
||||
field: "positionDescriptions.Individuelle Seiten",
|
||||
expected: sitemapPageCount,
|
||||
actual: mentionedPages.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 10. Sitemap categories should be consistent with selected pages/features.
|
||||
*/
|
||||
function validateSitemapConsistency(
|
||||
fs: Record<string, any>,
|
||||
errors: ValidationError[],
|
||||
): void {
|
||||
if (!Array.isArray(fs.sitemap)) return;
|
||||
|
||||
const sitemapTitles = fs.sitemap
|
||||
.flatMap((cat: any) => (cat.pages || []).map((p: any) => (p.title || "").toLowerCase()));
|
||||
|
||||
// Check for "Verwaltung" page (hallucinated management page)
|
||||
for (const title of sitemapTitles) {
|
||||
if (title.includes("verwaltung") && !title.includes("inhalt")) {
|
||||
errors.push({
|
||||
code: "HALLUCINATED_MANAGEMENT_PAGE",
|
||||
message: `"Verwaltung" als Seite ist vermutlich halluziniert. Verwaltung ist typischerweise eine interne Funktion, keine öffentliche Webseite.`,
|
||||
field: "sitemap",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
14
packages/estimation-engine/tsconfig.json
Normal file
14
packages/estimation-engine/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "@mintel/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"module": "ESNext",
|
||||
"target": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
basePath: '/gatekeeper',
|
||||
output: 'standalone',
|
||||
};
|
||||
|
||||
export default mintelNextConfig(nextConfig);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "1.8.21",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://npm.infra.mintel.me"
|
||||
"registry": "https://git.infra.mintel.me/api/packages/mmintel/npm"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "index.js",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "1.8.21",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://npm.infra.mintel.me"
|
||||
"registry": "https://git.infra.mintel.me/api/packages/mmintel/npm"
|
||||
},
|
||||
"files": [
|
||||
"docker",
|
||||
|
||||
@@ -77,7 +77,7 @@ export class ResearchAgent {
|
||||
// but formatted as "facts".
|
||||
|
||||
const synthesis = await this.openai.chat.completions.create({
|
||||
model: "google/gemini-2.0-flash-001",
|
||||
model: "google/gemini-3-flash-preview",
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
@@ -186,7 +186,7 @@ Return JSON: { "facts": [ { "statement": "...", "source": "Organization Name Onl
|
||||
// Step 1: Ask the LLM to generate a highly specific YouTube search query
|
||||
// We want tutorials, explanations, or deep dives.
|
||||
const queryGen = await this.openai.chat.completions.create({
|
||||
model: "google/gemini-2.5-flash",
|
||||
model: "google/gemini-3-flash-preview",
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
@@ -262,7 +262,7 @@ RULES:
|
||||
Return ONLY a JSON object: {"bestVideoId": number}`;
|
||||
|
||||
const evalResponse = await this.openai.chat.completions.create({
|
||||
model: "google/gemini-2.5-flash",
|
||||
model: "google/gemini-3-flash-preview",
|
||||
messages: [{ role: "system", content: evalPrompt }],
|
||||
response_format: { type: "json_object" },
|
||||
});
|
||||
@@ -320,7 +320,7 @@ Return ONLY a JSON object: {"bestVideoId": number}`;
|
||||
topic: string,
|
||||
): Promise<{ trendsKeywords: string[]; dcVariables: string[] }> {
|
||||
const response = await this.openai.chat.completions.create({
|
||||
model: "google/gemini-2.0-flash-001",
|
||||
model: "google/gemini-3-flash-preview",
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
@@ -378,7 +378,7 @@ CRITICAL: Do NOT provide more than 2 trendsKeywords. Keep it extremely focused.`
|
||||
|
||||
// Step 1: LLM generates the optimal Google Search query
|
||||
const queryGen = await this.openai.chat.completions.create({
|
||||
model: "google/gemini-2.5-flash",
|
||||
model: "google/gemini-3-flash-preview",
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
|
||||
@@ -34,7 +34,7 @@ export class TrendsClient {
|
||||
);
|
||||
try {
|
||||
const response = await this.openai.chat.completions.create({
|
||||
model: "google/gemini-2.5-flash",
|
||||
model: "google/gemini-3-flash-preview",
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"private": false,
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://npm.infra.mintel.me"
|
||||
"registry": "https://git.infra.mintel.me/api/packages/mmintel/npm"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -82,7 +82,7 @@ export class MemeGenerator {
|
||||
.join(", ");
|
||||
|
||||
const response = await this.openai.chat.completions.create({
|
||||
model: "google/gemini-2.5-flash",
|
||||
model: "google/gemini-3-flash-preview",
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "1.8.21",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://npm.infra.mintel.me"
|
||||
"registry": "https://git.infra.mintel.me/api/packages/mmintel/npm"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "index.js",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "1.8.21",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://npm.infra.mintel.me"
|
||||
"registry": "https://git.infra.mintel.me/api/packages/mmintel/npm"
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.mjs",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "1.8.21",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://npm.infra.mintel.me"
|
||||
"registry": "https://git.infra.mintel.me/api/packages/mmintel/npm"
|
||||
},
|
||||
"main": "./dist/index.cjs",
|
||||
"module": "./dist/index.js",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "1.8.21",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://npm.infra.mintel.me"
|
||||
"registry": "https://git.infra.mintel.me/api/packages/mmintel/npm"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "1.8.21",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://npm.infra.mintel.me"
|
||||
"registry": "https://git.infra.mintel.me/api/packages/mmintel/npm"
|
||||
},
|
||||
"main": "./dist/index.cjs",
|
||||
"module": "./dist/index.js",
|
||||
|
||||
85
packages/page-audit/README.md
Normal file
85
packages/page-audit/README.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# @mintel/page-audit
|
||||
|
||||
AI-powered website IST-analysis — combines DataForSEO On-Page crawl data with Gemini Pro to generate a comprehensive German-language audit report.
|
||||
|
||||
## Setup
|
||||
|
||||
Add the following to your `.env`:
|
||||
|
||||
```env
|
||||
# DataForSEO — get credentials at https://dataforseo.com
|
||||
# Format: your login email + API password from the dashboard
|
||||
DATA_FOR_SEO_LOGIN=yourlogin@example.com
|
||||
DATA_FOR_SEO_PASSWORD=your_api_password
|
||||
|
||||
# Or as a single key (login:password)
|
||||
DATA_FOR_SEO_API_KEY=yourlogin@example.com:your_api_password
|
||||
|
||||
# OpenRouter (for AI report)
|
||||
OPENROUTER_API_KEY=sk-or-...
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### CLI
|
||||
|
||||
```bash
|
||||
# Full audit with AI report
|
||||
npx tsx src/cli.ts run https://www.e-tib.com
|
||||
|
||||
# Faster: skip AI report (data only)
|
||||
npx tsx src/cli.ts run https://www.e-tib.com --light
|
||||
|
||||
# Custom max pages and output dir
|
||||
npx tsx src/cli.ts run https://www.e-tib.com --max-pages 100 --output ./out/audits
|
||||
```
|
||||
|
||||
### Programmatic
|
||||
|
||||
```typescript
|
||||
import { PageAuditor } from '@mintel/page-audit';
|
||||
|
||||
const auditor = new PageAuditor({
|
||||
dataForSeoLogin: process.env.DATA_FOR_SEO_LOGIN!,
|
||||
dataForSeoPassword: process.env.DATA_FOR_SEO_PASSWORD!,
|
||||
openrouterKey: process.env.OPENROUTER_API_KEY,
|
||||
outputDir: './out/page-audits',
|
||||
});
|
||||
|
||||
const result = await auditor.audit('https://www.e-tib.com', {
|
||||
maxPages: 50,
|
||||
onProgress: (msg) => console.log(msg),
|
||||
});
|
||||
|
||||
console.log(result.report?.executiveSummary);
|
||||
console.log(result.report?.seoScore); // 0-100
|
||||
```
|
||||
|
||||
## What it checks
|
||||
|
||||
### DataForSEO On-Page (deterministic)
|
||||
- HTTP status codes, broken pages (4xx/5xx)
|
||||
- Title tags, meta descriptions, H1 presence
|
||||
- Image alt attributes
|
||||
- Internal/external link counts
|
||||
- Core Web Vitals: LCP, CLS, TTFB
|
||||
- Viewport meta, canonical tags
|
||||
- Indexability
|
||||
|
||||
### AI Report (Gemini Pro)
|
||||
- Executive summary of current state
|
||||
- Strengths (what's working)
|
||||
- Critical issues (urgent fixes)
|
||||
- Quick wins (high impact, low effort)
|
||||
- Strategic recommendations
|
||||
- Scores: SEO (0-100), UX (0-100), Performance (0-100)
|
||||
- Overall health: `critical` | `needs-work` | `good` | `excellent`
|
||||
|
||||
## Output
|
||||
|
||||
Results are saved as JSON to `out/page-audits/{domain}_{timestamp}.json`.
|
||||
|
||||
## Integration in Estimation Engine
|
||||
|
||||
`@mintel/page-audit` can be used as an optional pre-step in the `@mintel/estimation-engine` pipeline
|
||||
to enrich the site analysis with real SEO metrics from DataForSEO before the AI estimation runs.
|
||||
31
packages/page-audit/_debug_vitals.ts
Normal file
31
packages/page-audit/_debug_vitals.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { config } from "dotenv";
|
||||
import * as path from "node:path";
|
||||
|
||||
config({ path: path.resolve(process.cwd(), "../../.env") });
|
||||
|
||||
const login = process.env.DATA_FOR_SEO_LOGIN || "";
|
||||
const password = process.env.DATA_FOR_SEO_PASSWORD || "";
|
||||
const BASE = "https://api.dataforseo.com/v3";
|
||||
const auth = Buffer.from(`${login}:${password}`).toString("base64");
|
||||
const headers: Record<string, string> = { Authorization: `Basic ${auth}`, "Content-Type": "application/json" };
|
||||
|
||||
async function apiPost(path: string, body: any) {
|
||||
const r = await fetch(`${BASE}${path}`, { method: "POST", headers, body: JSON.stringify(body) });
|
||||
return r.json();
|
||||
}
|
||||
|
||||
async function run() {
|
||||
console.log("Starting test crawl with enable_browser_rendering = true...");
|
||||
const req = await apiPost("/on_page/task_post", [{
|
||||
target: "e-tib.com",
|
||||
max_crawl_pages: 1,
|
||||
load_resources: true,
|
||||
enable_javascript: true,
|
||||
enable_browser_rendering: true,
|
||||
check_spell: false,
|
||||
}]);
|
||||
|
||||
console.log(JSON.stringify(req?.tasks?.[0] || req, null, 2));
|
||||
}
|
||||
|
||||
run();
|
||||
152
packages/page-audit/src/auditor.ts
Normal file
152
packages/page-audit/src/auditor.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
// ============================================================================
|
||||
// @mintel/page-audit — Auditor Orchestrator
|
||||
// Main entry point: runs the full audit pipeline for a domain.
|
||||
// ============================================================================
|
||||
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import { DataForSeoClient, normalizePage } from "./dataforseo.js";
|
||||
import { generateAuditReport } from "./report.js";
|
||||
import type { AuditConfig, AuditIssue, DomainAuditResult, PageAuditData } from "./types.js";
|
||||
|
||||
export class PageAuditor {
|
||||
private client: DataForSeoClient;
|
||||
|
||||
constructor(private config: AuditConfig) {
|
||||
this.client = new DataForSeoClient(
|
||||
config.dataForSeoLogin,
|
||||
config.dataForSeoPassword,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a full audit for a domain.
|
||||
* Steps:
|
||||
* 1. Start DataForSEO On-Page crawl task
|
||||
* 2. Wait for completion
|
||||
* 3. Fetch page results + broken resources
|
||||
* 4. Normalize and aggregate issues
|
||||
* 5. Generate AI report (unless lightMode)
|
||||
* 6. Save to disk
|
||||
*/
|
||||
async audit(domain: string, opts?: { maxPages?: number; onProgress?: (msg: string) => void }): Promise<DomainAuditResult> {
|
||||
const log = opts?.onProgress || console.log;
|
||||
const cleanDomain = domain.replace(/^https?:\/\//, "").replace(/\/$/, "");
|
||||
|
||||
log(`🔍 Starting audit for ${cleanDomain}...`);
|
||||
|
||||
// Step 1: Start crawl
|
||||
const taskId = await this.client.startCrawl(domain, opts?.maxPages || 50);
|
||||
log(`📋 DataForSEO task started: ${taskId}`);
|
||||
|
||||
// Step 2: Wait for completion
|
||||
log("⏳ Waiting for crawl to complete...");
|
||||
await this.client.waitForTask(taskId, 300_000);
|
||||
log("✅ Crawl complete!");
|
||||
|
||||
// Step 3: Fetch results
|
||||
log("📥 Fetching page data...");
|
||||
const [rawPages, brokenResources] = await Promise.all([
|
||||
this.client.getPages(taskId, 100, 0),
|
||||
this.client.getBrokenResources(taskId),
|
||||
]);
|
||||
|
||||
// Step 4: Normalize pages
|
||||
const pages: PageAuditData[] = rawPages.map(normalizePage);
|
||||
|
||||
// Count broken links per page
|
||||
for (const broken of brokenResources) {
|
||||
const sourceUrl = broken.source_url;
|
||||
const sourcePage = pages.find((p) => p.url === sourceUrl);
|
||||
if (sourcePage) sourcePage.links.broken++;
|
||||
}
|
||||
|
||||
// Aggregate top issues
|
||||
const issueMap = new Map<string, AuditIssue & { count: number }>();
|
||||
for (const page of pages) {
|
||||
for (const issue of page.issues) {
|
||||
const existing = issueMap.get(issue.code);
|
||||
if (existing) {
|
||||
existing.count++;
|
||||
} else {
|
||||
issueMap.set(issue.code, { ...issue, count: 1 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const topIssues = Array.from(issueMap.values())
|
||||
.sort((a, b) => {
|
||||
const severityOrder = { critical: 0, warning: 1, info: 2 };
|
||||
return severityOrder[a.severity] - severityOrder[b.severity] || b.count - a.count;
|
||||
})
|
||||
.slice(0, 20);
|
||||
|
||||
const result: DomainAuditResult = {
|
||||
domain: cleanDomain,
|
||||
auditedAt: new Date().toISOString(),
|
||||
totalPages: pages.length,
|
||||
pages,
|
||||
topIssues,
|
||||
report: null,
|
||||
dataForSeoTaskId: taskId,
|
||||
};
|
||||
|
||||
// Step 5: AI Report
|
||||
if (!this.config.lightMode && this.config.openrouterKey) {
|
||||
log("🤖 Generating AI analysis...");
|
||||
try {
|
||||
result.report = await generateAuditReport(result, this.config.openrouterKey);
|
||||
log("✅ AI report generated!");
|
||||
} catch (err: any) {
|
||||
console.warn(`⚠️ AI report failed (audit data still saved): ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 6: Save
|
||||
await this.saveResult(result);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async saveResult(result: DomainAuditResult): Promise<void> {
|
||||
const outputDir = this.config.outputDir || "./out/page-audits";
|
||||
await fs.mkdir(outputDir, { recursive: true });
|
||||
|
||||
const slug = result.domain.replace(/\./g, "-");
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
||||
const outputPath = path.join(outputDir, `${slug}_${timestamp}.json`);
|
||||
|
||||
await fs.writeFile(outputPath, JSON.stringify(result, null, 2));
|
||||
console.log(`\n📦 Audit saved to: ${outputPath}`);
|
||||
|
||||
// Print summary
|
||||
console.log("\n" + "─".repeat(50));
|
||||
console.log("📊 AUDIT SUMMARY — " + result.domain);
|
||||
console.log("─".repeat(50));
|
||||
console.log(` Pages audited: ${result.totalPages}`);
|
||||
console.log(` Critical issues: ${result.topIssues.filter((i) => i.severity === "critical").length}`);
|
||||
console.log(` Warnings: ${result.topIssues.filter((i) => i.severity === "warning").length}`);
|
||||
|
||||
if (result.report) {
|
||||
console.log("\n🤖 AI REPORT:");
|
||||
console.log(` Overall Health: ${result.report.overallHealth.toUpperCase()}`);
|
||||
console.log(` SEO Score: ${result.report.seoScore}/100`);
|
||||
console.log(` UX Score: ${result.report.uxScore}/100`);
|
||||
console.log(` Performance Score: ${result.report.performanceScore}/100`);
|
||||
console.log(`\n Summary: ${result.report.executiveSummary}`);
|
||||
if (result.report.criticalIssues.length > 0) {
|
||||
console.log("\n 🔴 Critical Issues:");
|
||||
for (const issue of result.report.criticalIssues) {
|
||||
console.log(` - ${issue}`);
|
||||
}
|
||||
}
|
||||
if (result.report.quickWins.length > 0) {
|
||||
console.log("\n 🟢 Quick Wins:");
|
||||
for (const win of result.report.quickWins) {
|
||||
console.log(` - ${win}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log("─".repeat(50));
|
||||
}
|
||||
}
|
||||
70
packages/page-audit/src/cli.ts
Normal file
70
packages/page-audit/src/cli.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env node
|
||||
// ============================================================================
|
||||
// @mintel/page-audit — CLI
|
||||
// ============================================================================
|
||||
|
||||
import { Command } from "commander";
|
||||
import * as path from "node:path";
|
||||
import { config as dotenvConfig } from "dotenv";
|
||||
import { PageAuditor } from "./auditor.js";
|
||||
|
||||
dotenvConfig({ path: path.resolve(process.cwd(), "../../.env") });
|
||||
dotenvConfig({ path: path.resolve(process.cwd(), ".env") });
|
||||
|
||||
const program = new Command();
|
||||
|
||||
program
|
||||
.name("page-audit")
|
||||
.description("AI-powered website IST-analysis using DataForSEO + Gemini")
|
||||
.version("1.0.0");
|
||||
|
||||
program
|
||||
.command("run")
|
||||
.description("Run a full SEO and UX audit for a website")
|
||||
.argument("<url>", "Website URL or domain to audit")
|
||||
.option("--max-pages <n>", "Maximum pages to crawl", "50")
|
||||
.option("--output <dir>", "Output directory", "../../out/page-audits")
|
||||
.option("--light", "Skip AI report (faster)")
|
||||
.action(async (url: string, options: any) => {
|
||||
// Support both DATA_FOR_SEO_API_KEY (login:password) and separate vars
|
||||
let login = process.env.DATA_FOR_SEO_LOGIN;
|
||||
let password = process.env.DATA_FOR_SEO_PASSWORD;
|
||||
|
||||
if (!login && process.env.DATA_FOR_SEO_API_KEY) {
|
||||
const parts = process.env.DATA_FOR_SEO_API_KEY.split(":");
|
||||
login = parts[0];
|
||||
password = parts.slice(1).join(":"); // passwords may contain colons
|
||||
}
|
||||
|
||||
if (!login || !password) {
|
||||
console.error("\n❌ DataForSEO credentials not found.");
|
||||
console.error(" Set in .env:");
|
||||
console.error(" DATA_FOR_SEO_LOGIN=yourlogin@example.com");
|
||||
console.error(" DATA_FOR_SEO_PASSWORD=your_api_password");
|
||||
console.error(" OR: DATA_FOR_SEO_API_KEY=email:password");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const auditor = new PageAuditor({
|
||||
dataForSeoLogin: login,
|
||||
dataForSeoPassword: password,
|
||||
openrouterKey: process.env.OPENROUTER_API_KEY,
|
||||
outputDir: path.resolve(process.cwd(), options.output),
|
||||
lightMode: options.light || false,
|
||||
});
|
||||
|
||||
try {
|
||||
await auditor.audit(url, {
|
||||
maxPages: parseInt(options.maxPages, 10),
|
||||
onProgress: (msg) => console.log(msg),
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error(`\n❌ Audit failed: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
program.parseAsync(process.argv).catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
212
packages/page-audit/src/dataforseo.ts
Normal file
212
packages/page-audit/src/dataforseo.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
// ============================================================================
|
||||
// @mintel/page-audit — DataForSEO API Client
|
||||
// Uses native fetch (no axios) to avoid Node event loop exit during polling.
|
||||
// Docs: https://docs.dataforseo.com/v3/on_page/
|
||||
// ============================================================================
|
||||
|
||||
import type { PageAuditData, AuditIssue } from "./types.js";
|
||||
|
||||
const API_BASE = "https://api.dataforseo.com/v3";
|
||||
|
||||
/** Authenticated DataForSEO client */
|
||||
export class DataForSeoClient {
|
||||
private auth: string;
|
||||
|
||||
constructor(login: string, password: string) {
|
||||
this.auth = Buffer.from(`${login}:${password}`).toString("base64");
|
||||
}
|
||||
|
||||
private get headers(): Record<string, string> {
|
||||
return {
|
||||
Authorization: `Basic ${this.auth}`,
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
}
|
||||
|
||||
private async apiGet(path: string): Promise<any> {
|
||||
const resp = await fetch(`${API_BASE}${path}`, { headers: this.headers });
|
||||
if (!resp.ok) throw new Error(`DataForSEO GET ${path} failed: ${resp.status}`);
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
private async apiPost(path: string, body: any): Promise<any> {
|
||||
const resp = await fetch(`${API_BASE}${path}`, {
|
||||
method: "POST",
|
||||
headers: this.headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`DataForSEO POST ${path} failed: ${resp.status}`);
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start an On-Page crawl for a domain and return the task ID.
|
||||
*/
|
||||
async startCrawl(domain: string, maxCrawlPages = 50): Promise<string> {
|
||||
const url = domain.startsWith("http") ? domain : `https://${domain}`;
|
||||
|
||||
const data = await this.apiPost("/on_page/task_post", [
|
||||
{
|
||||
target: url,
|
||||
max_crawl_pages: maxCrawlPages,
|
||||
load_resources: true,
|
||||
enable_javascript: true,
|
||||
enable_browser_rendering: true,
|
||||
check_spell: false,
|
||||
calculate_keyword_density: false,
|
||||
},
|
||||
]);
|
||||
|
||||
const task = data?.tasks?.[0];
|
||||
if (!task?.id) {
|
||||
throw new Error(`DataForSEO task creation failed: ${JSON.stringify(task?.status_message || "unknown")}`);
|
||||
}
|
||||
|
||||
return task.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a task is ready via the tasks_ready endpoint.
|
||||
*/
|
||||
async isTaskReady(taskId: string): Promise<boolean> {
|
||||
const data = await this.apiGet("/on_page/tasks_ready");
|
||||
const readyTasks: string[] = data?.tasks?.[0]?.result?.map((t: any) => t.id) || [];
|
||||
return readyTasks.includes(taskId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll for task completion using tasks_ready endpoint.
|
||||
* DataForSEO crawls can take 2-5 minutes.
|
||||
*/
|
||||
async waitForTask(taskId: string, timeoutMs = 300_000): Promise<void> {
|
||||
const start = Date.now();
|
||||
let delay = 15_000;
|
||||
let pollCount = 0;
|
||||
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
await this.sleep(delay);
|
||||
pollCount++;
|
||||
|
||||
const ready = await this.isTaskReady(taskId);
|
||||
const elapsed = Math.round((Date.now() - start) / 1000);
|
||||
console.log(` 📊 Poll #${pollCount}: ${ready ? "READY ✅" : "not ready"} (${elapsed}s elapsed)`);
|
||||
|
||||
if (ready) {
|
||||
// Short grace period so the pages endpoint settles
|
||||
await this.sleep(5_000);
|
||||
return;
|
||||
}
|
||||
|
||||
delay = Math.min(delay * 1.3, 30_000);
|
||||
}
|
||||
|
||||
throw new Error(`DataForSEO task ${taskId} timed out after ${timeoutMs / 1000}s`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sleep that keeps the Node event loop alive.
|
||||
*/
|
||||
private sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(resolve, ms);
|
||||
// Explicitly ref the timer to prevent Node from exiting
|
||||
if (timer && typeof timer === "object" && "ref" in timer) {
|
||||
(timer as NodeJS.Timeout).ref();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the crawl summary.
|
||||
*/
|
||||
async getCrawlSummary(taskId: string): Promise<any> {
|
||||
const data = await this.apiGet(`/on_page/summary/${taskId}`);
|
||||
return data?.tasks?.[0]?.result?.[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all page-level results.
|
||||
*/
|
||||
async getPages(taskId: string, limit = 100, offset = 0): Promise<any[]> {
|
||||
const data = await this.apiPost("/on_page/pages", [
|
||||
{ id: taskId, limit, offset },
|
||||
]);
|
||||
return data?.tasks?.[0]?.result?.[0]?.items || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch non-indexable pages.
|
||||
*/
|
||||
async getNonIndexable(taskId: string): Promise<any[]> {
|
||||
const data = await this.apiPost("/on_page/non_indexable", [
|
||||
{ id: taskId, limit: 100, offset: 0 },
|
||||
]);
|
||||
return data?.tasks?.[0]?.result?.[0]?.items || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch broken resources (404s, timeouts, etc.)
|
||||
*/
|
||||
async getBrokenResources(taskId: string): Promise<any[]> {
|
||||
const data = await this.apiPost("/on_page/resources", [
|
||||
{ id: taskId, limit: 100, filters: [["status_code", ">", "399"]] },
|
||||
]);
|
||||
return data?.tasks?.[0]?.result?.[0]?.items || [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a DataForSEO raw page result into our PageAuditData type.
|
||||
*/
|
||||
export function normalizePage(raw: any): PageAuditData {
|
||||
const issues: AuditIssue[] = [];
|
||||
|
||||
const checks = raw.checks || {};
|
||||
|
||||
if (checks.no_title) issues.push({ code: "NO_TITLE", severity: "critical", message: "Seite hat keinen <title> Tag" });
|
||||
if (checks.title_too_long) issues.push({ code: "TITLE_TOO_LONG", severity: "warning", message: `Title zu lang (${raw.meta?.title?.length || "?"} Zeichen, max 60)` });
|
||||
if (checks.no_description) issues.push({ code: "NO_META_DESCRIPTION", severity: "warning", message: "Keine Meta-Description" });
|
||||
if (checks.description_too_long) issues.push({ code: "META_DESC_TOO_LONG", severity: "info", message: "Meta-Description zu lang (max 160)" });
|
||||
if (checks.no_h1_tag) issues.push({ code: "NO_H1", severity: "critical", message: "Kein H1-Tag auf der Seite" });
|
||||
if (checks.duplicate_h1_tag) issues.push({ code: "DUPLICATE_H1", severity: "warning", message: "Mehrere H1-Tags gefunden" });
|
||||
if (checks.is_broken) issues.push({ code: "PAGE_BROKEN", severity: "critical", message: `HTTP ${raw.status_code}: Seite nicht erreichbar` });
|
||||
if (checks.low_content_rate) issues.push({ code: "THIN_CONTENT", severity: "warning", message: "Zu wenig Content (dünne Seite)" });
|
||||
if (checks.has_render_blocking_resources) issues.push({ code: "RENDER_BLOCKING", severity: "warning", message: "Render-blockierende Ressourcen gefunden" });
|
||||
if (checks.image_not_optimized) issues.push({ code: "UNOPTIMIZED_IMAGES", severity: "info", message: "Nicht-optimierte Bilder vorhanden" });
|
||||
|
||||
const imagesWithoutAlt = raw.checks?.no_image_alt ? (raw.meta?.images_count || 0) : 0;
|
||||
|
||||
return {
|
||||
url: raw.url,
|
||||
statusCode: raw.status_code,
|
||||
pageTitle: raw.meta?.title || null,
|
||||
metaDescription: raw.meta?.description || null,
|
||||
h1: raw.meta?.htags?.h1?.[0] || null,
|
||||
wordCount: raw.meta?.content?.words_count || 0,
|
||||
loadTime: raw.page_timing?.time_to_interactive || null,
|
||||
links: {
|
||||
internal: raw.meta?.internal_links_count || 0,
|
||||
external: raw.meta?.external_links_count || 0,
|
||||
broken: 0,
|
||||
},
|
||||
images: {
|
||||
total: raw.meta?.images_count || 0,
|
||||
missingAlt: imagesWithoutAlt,
|
||||
},
|
||||
seo: {
|
||||
hasViewport: !raw.checks?.no_viewport_tag,
|
||||
hasCanonical: !!raw.meta?.canonical,
|
||||
isIndexable: !raw.checks?.is_4xx_code && !raw.checks?.is_5xx_code,
|
||||
robotsTxt: raw.meta?.robots || null,
|
||||
ogTitle: raw.meta?.social_media_tags?.og_title || null,
|
||||
ogDescription: raw.meta?.social_media_tags?.og_description || null,
|
||||
},
|
||||
performance: {
|
||||
cls: raw.page_timing?.cumulative_layout_shift || null,
|
||||
lcp: raw.page_timing?.largest_contentful_paint || null,
|
||||
fid: raw.page_timing?.first_input_delay || null,
|
||||
ttfb: raw.page_timing?.waiting_time || null,
|
||||
},
|
||||
issues,
|
||||
};
|
||||
}
|
||||
14
packages/page-audit/src/index.ts
Normal file
14
packages/page-audit/src/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
// ============================================================================
|
||||
// @mintel/page-audit — Public API
|
||||
// ============================================================================
|
||||
|
||||
export { PageAuditor } from "./auditor.js";
|
||||
export { DataForSeoClient, normalizePage } from "./dataforseo.js";
|
||||
export { generateAuditReport } from "./report.js";
|
||||
export type {
|
||||
PageAuditData,
|
||||
AuditIssue,
|
||||
DomainAuditResult,
|
||||
AuditReport,
|
||||
AuditConfig,
|
||||
} from "./types.js";
|
||||
149
packages/page-audit/src/report.ts
Normal file
149
packages/page-audit/src/report.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
// ============================================================================
|
||||
// @mintel/page-audit — AI Report Generator
|
||||
// Uses Gemini Pro (via OpenRouter) to synthesize DataForSEO data into
|
||||
// a structured IST-analysis report in German.
|
||||
// ============================================================================
|
||||
|
||||
import type { DomainAuditResult, AuditReport, PageAuditData, AuditIssue } from "./types.js";
|
||||
|
||||
const OPENROUTER_BASE = "https://openrouter.ai/api/v1";
|
||||
const REPORT_MODEL = "google/gemini-3.1-pro-preview";
|
||||
|
||||
/**
|
||||
* Generate an AI-powered IST-analysis report from audit data.
|
||||
*/
|
||||
export async function generateAuditReport(
|
||||
audit: DomainAuditResult,
|
||||
openrouterKey: string,
|
||||
): Promise<AuditReport> {
|
||||
const summary = buildAuditSummary(audit);
|
||||
|
||||
const systemPrompt = `
|
||||
Du bist ein Senior SEO- und UX-Stratege. Analysiere die technischen Audit-Daten einer Website
|
||||
und erstelle einen präzisen IST-Analyse-Bericht auf DEUTSCH.
|
||||
|
||||
Stil:
|
||||
- Faktisch, direkt, kein Bullshit
|
||||
- Konkrete Handlungsempfehlungen, keine vagen Floskeln
|
||||
- Technik-verständlich für Entscheider (nicht für Entwickler)
|
||||
|
||||
Output: JSON (kein Markdown drumherum)
|
||||
`;
|
||||
|
||||
const userPrompt = `
|
||||
Website: ${audit.domain}
|
||||
Seiten gecrawlt: ${audit.totalPages}
|
||||
Audit-Datum: ${audit.auditedAt}
|
||||
|
||||
=== TECHNISCHE AUSWERTUNG ===
|
||||
${summary}
|
||||
|
||||
=== TOP-ISSUES ===
|
||||
${audit.topIssues.map((i) => `[${i.severity.toUpperCase()}] ${i.message}${i.count ? ` (${i.count}x)` : ""}`).join("\n")}
|
||||
|
||||
Erstelle jetzt den IST-Analyse-Report als JSON:
|
||||
{
|
||||
"executiveSummary": string (2-3 Sätze über den aktuellen Zustand der Website),
|
||||
"strengths": string[] (max 4, was gut läuft),
|
||||
"criticalIssues": string[] (max 5, sofort zu beheben),
|
||||
"quickWins": string[] (max 4, einfach umzusetzen mit großer Wirkung),
|
||||
"strategicRecommendations": string[] (max 4, mittel-/langfristig),
|
||||
"seoScore": number (0-100, realistisch),
|
||||
"uxScore": number (0-100, realistisch),
|
||||
"performanceScore": number (0-100, realistisch),
|
||||
"overallHealth": "critical" | "needs-work" | "good" | "excellent"
|
||||
}
|
||||
`;
|
||||
|
||||
const response = await fetch(`${OPENROUTER_BASE}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${openrouterKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: REPORT_MODEL,
|
||||
messages: [
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "user", content: userPrompt },
|
||||
],
|
||||
response_format: { type: "json_object" },
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`LLM request failed: ${response.status} ${await response.text()}`);
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
const content = json.choices?.[0]?.message?.content || "{}";
|
||||
|
||||
// Clean up markdown JSON wrappers if present
|
||||
const cleaned = content.replace(/^```(?:json)?\n?/m, "").replace(/```$/m, "").trim();
|
||||
|
||||
try {
|
||||
return JSON.parse(cleaned) as AuditReport;
|
||||
} catch {
|
||||
throw new Error(`Could not parse AI report: ${cleaned.slice(0, 200)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a human-readable text summary of the audit data for the LLM prompt.
|
||||
*/
|
||||
function buildAuditSummary(audit: DomainAuditResult): string {
|
||||
const pages = audit.pages;
|
||||
const brokenPages = pages.filter((p) => p.statusCode >= 400);
|
||||
const noTitle = pages.filter((p) => !p.pageTitle);
|
||||
const noDesc = pages.filter((p) => !p.metaDescription);
|
||||
const noH1 = pages.filter((p) => !p.h1);
|
||||
const notIndexable = pages.filter((p) => !p.seo.isIndexable);
|
||||
const noViewport = pages.filter((p) => !p.seo.hasViewport);
|
||||
const slowPages = pages.filter((p) => p.loadTime !== null && p.loadTime > 3000);
|
||||
const imagesWithoutAlt = pages.reduce((sum, p) => sum + p.images.missingAlt, 0);
|
||||
const totalImages = pages.reduce((sum, p) => sum + p.images.total, 0);
|
||||
|
||||
const avgLoad = pages
|
||||
.filter((p) => p.loadTime !== null)
|
||||
.reduce((sum, p, _, arr) => sum + (p.loadTime || 0) / arr.length, 0);
|
||||
|
||||
const lines = [
|
||||
`Seiten gesamt: ${pages.length}`,
|
||||
`Seiten mit Fehler (4xx/5xx): ${brokenPages.length}`,
|
||||
`Seiten ohne <title>: ${noTitle.length}`,
|
||||
`Seiten ohne Meta-Description: ${noDesc.length}`,
|
||||
`Seiten ohne H1: ${noH1.length}`,
|
||||
`Nicht-indexierbare Seiten: ${notIndexable.length}`,
|
||||
`Seiten ohne Viewport-Meta: ${noViewport.length}`,
|
||||
`Bilder gesamt: ${totalImages}, davon ohne alt-Attribut: ${imagesWithoutAlt}`,
|
||||
`Langsame Seiten (>3s): ${slowPages.length}`,
|
||||
`Ø Ladezeit: ${avgLoad > 0 ? `${(avgLoad / 1000).toFixed(1)}s` : "unbekannt"}`,
|
||||
];
|
||||
|
||||
// Core Web Vitals (from first valid page)
|
||||
const lcpPages = pages.filter((p) => p.performance.lcp !== null);
|
||||
if (lcpPages.length > 0) {
|
||||
const avgLcp = lcpPages.reduce((s, p) => s + (p.performance.lcp || 0), 0) / lcpPages.length;
|
||||
lines.push(`Ø LCP: ${(avgLcp / 1000).toFixed(1)}s (Ziel: <2.5s)`);
|
||||
}
|
||||
|
||||
const clsPages = pages.filter((p) => p.performance.cls !== null);
|
||||
if (clsPages.length > 0) {
|
||||
const avgCls = clsPages.reduce((s, p) => s + (p.performance.cls || 0), 0) / clsPages.length;
|
||||
lines.push(`Ø CLS: ${avgCls.toFixed(3)} (Ziel: <0.1)`);
|
||||
}
|
||||
|
||||
// Top pages by issues
|
||||
const worstPages = [...pages]
|
||||
.sort((a, b) => b.issues.length - a.issues.length)
|
||||
.slice(0, 5);
|
||||
|
||||
if (worstPages.length > 0) {
|
||||
lines.push("\nSeiten mit den meisten Problemen:");
|
||||
for (const page of worstPages) {
|
||||
lines.push(` ${page.url}: ${page.issues.length} Issues (${page.issues.map((i) => i.code).join(", ")})`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
83
packages/page-audit/src/types.ts
Normal file
83
packages/page-audit/src/types.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
// ============================================================================
|
||||
// @mintel/page-audit — Types
|
||||
// ============================================================================
|
||||
|
||||
/** DataForSEO On-Page audit result for a single page */
|
||||
export interface PageAuditData {
|
||||
url: string;
|
||||
statusCode: number;
|
||||
pageTitle: string | null;
|
||||
metaDescription: string | null;
|
||||
h1: string | null;
|
||||
wordCount: number;
|
||||
loadTime: number | null; // ms
|
||||
links: {
|
||||
internal: number;
|
||||
external: number;
|
||||
broken: number;
|
||||
};
|
||||
images: {
|
||||
total: number;
|
||||
missingAlt: number;
|
||||
};
|
||||
seo: {
|
||||
hasViewport: boolean;
|
||||
hasCanonical: boolean;
|
||||
isIndexable: boolean;
|
||||
robotsTxt: string | null;
|
||||
ogTitle: string | null;
|
||||
ogDescription: string | null;
|
||||
};
|
||||
performance: {
|
||||
cls: number | null; // Cumulative Layout Shift
|
||||
lcp: number | null; // Largest Contentful Paint (ms)
|
||||
fid: number | null; // First Input Delay (ms)
|
||||
ttfb: number | null; // Time to First Byte (ms)
|
||||
};
|
||||
issues: AuditIssue[];
|
||||
}
|
||||
|
||||
/** A single issue found during audit */
|
||||
export interface AuditIssue {
|
||||
code: string;
|
||||
severity: "critical" | "warning" | "info";
|
||||
message: string;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
/** Full crawled audit result for a domain */
|
||||
export interface DomainAuditResult {
|
||||
domain: string;
|
||||
auditedAt: string;
|
||||
totalPages: number;
|
||||
pages: PageAuditData[];
|
||||
/** Aggregated issues sorted by severity */
|
||||
topIssues: AuditIssue[];
|
||||
/** AI-generated analysis */
|
||||
report: AuditReport | null;
|
||||
/** Raw DataForSEO task ID for reference */
|
||||
dataForSeoTaskId?: string;
|
||||
}
|
||||
|
||||
/** AI-generated IST analysis report */
|
||||
export interface AuditReport {
|
||||
executiveSummary: string;
|
||||
strengths: string[];
|
||||
criticalIssues: string[];
|
||||
quickWins: string[];
|
||||
strategicRecommendations: string[];
|
||||
seoScore: number; // 0-100
|
||||
uxScore: number; // 0-100
|
||||
performanceScore: number; // 0-100
|
||||
overallHealth: "critical" | "needs-work" | "good" | "excellent";
|
||||
}
|
||||
|
||||
/** Config for running an audit */
|
||||
export interface AuditConfig {
|
||||
dataForSeoLogin: string;
|
||||
dataForSeoPassword: string;
|
||||
openrouterKey?: string;
|
||||
outputDir?: string;
|
||||
/** If true, only analyze — no AI report */
|
||||
lightMode?: boolean;
|
||||
}
|
||||
13
packages/page-audit/tsconfig.json
Normal file
13
packages/page-audit/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "@mintel/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"module": "ESNext",
|
||||
"target": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
70
packages/pdf-library/src/components/ConceptPDF.tsx
Normal file
70
packages/pdf-library/src/components/ConceptPDF.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Page as PDFPage, Document as PDFDocument } from "@react-pdf/renderer";
|
||||
import { pdfStyles } from "./pdf/SharedUI.js";
|
||||
import { SimpleLayout } from "./pdf/SimpleLayout.js";
|
||||
|
||||
// Modules
|
||||
import { FrontPageModule } from "./pdf/modules/FrontPageModule.js";
|
||||
import { BriefingModule } from "./pdf/modules/BriefingModule.js";
|
||||
import { SitemapModule } from "./pdf/modules/SitemapModule.js";
|
||||
import { ClosingModule } from "./pdf/modules/CommonModules.js";
|
||||
|
||||
export const ConceptPDF = ({
|
||||
concept,
|
||||
headerIcon,
|
||||
footerLogo,
|
||||
}: any) => {
|
||||
const date = new Date().toLocaleDateString("de-DE", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
// Flatten the ProjectConcept to match what the legacy modules expect
|
||||
const flatState = {
|
||||
...concept.auditedFacts,
|
||||
briefingSummary: concept.strategy?.briefingSummary || "",
|
||||
designVision: concept.strategy?.designVision || "",
|
||||
sitemap: concept.architecture?.sitemap || [],
|
||||
websiteTopic: concept.architecture?.websiteTopic || concept.auditedFacts?.websiteTopic || "",
|
||||
};
|
||||
|
||||
const companyData = {
|
||||
name: "Marc Mintel",
|
||||
address1: "Georg-Meistermann-Straße 7",
|
||||
address2: "54586 Schüller",
|
||||
ustId: "DE367588065",
|
||||
};
|
||||
|
||||
const commonProps = {
|
||||
state: flatState,
|
||||
date,
|
||||
headerIcon,
|
||||
footerLogo,
|
||||
companyData,
|
||||
};
|
||||
|
||||
return (
|
||||
<PDFDocument title={`Projektkonzept - ${flatState.companyName || "Projekt"}`}>
|
||||
<PDFPage size="A4" style={pdfStyles.titlePage}>
|
||||
<FrontPageModule state={flatState} headerIcon={headerIcon} date={date} />
|
||||
</PDFPage>
|
||||
|
||||
<SimpleLayout {...commonProps}>
|
||||
<BriefingModule state={flatState} />
|
||||
</SimpleLayout>
|
||||
|
||||
{flatState.sitemap && flatState.sitemap.length > 0 && (
|
||||
<SimpleLayout {...commonProps}>
|
||||
<SitemapModule state={flatState} />
|
||||
</SimpleLayout>
|
||||
)}
|
||||
|
||||
<SimpleLayout {...commonProps}>
|
||||
<ClosingModule />
|
||||
</SimpleLayout>
|
||||
</PDFDocument>
|
||||
);
|
||||
};
|
||||
@@ -7,8 +7,6 @@ import { SimpleLayout } from "./pdf/SimpleLayout.js";
|
||||
|
||||
// Modules
|
||||
import { FrontPageModule } from "./pdf/modules/FrontPageModule.js";
|
||||
import { BriefingModule } from "./pdf/modules/BriefingModule.js";
|
||||
import { SitemapModule } from "./pdf/modules/SitemapModule.js";
|
||||
import { EstimationModule } from "./pdf/modules/EstimationModule.js";
|
||||
import { TransparenzModule } from "./pdf/modules/TransparenzModule.js";
|
||||
import { ClosingModule } from "./pdf/modules/CommonModules.js";
|
||||
@@ -64,16 +62,6 @@ export const EstimationPDF = ({
|
||||
<FrontPageModule state={state} headerIcon={headerIcon} date={date} />
|
||||
</PDFPage>
|
||||
|
||||
<SimpleLayout {...commonProps}>
|
||||
<BriefingModule state={state} />
|
||||
</SimpleLayout>
|
||||
|
||||
{state.sitemap && state.sitemap.length > 0 && (
|
||||
<SimpleLayout {...commonProps}>
|
||||
<SitemapModule state={state} />
|
||||
</SimpleLayout>
|
||||
)}
|
||||
|
||||
<SimpleLayout {...commonProps}>
|
||||
<EstimationModule
|
||||
state={state}
|
||||
|
||||
172
packages/pdf-library/src/components/InfoPDF.tsx
Normal file
172
packages/pdf-library/src/components/InfoPDF.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
Page as PDFPage,
|
||||
Text as PDFText,
|
||||
View as PDFView,
|
||||
StyleSheet as PDFStyleSheet,
|
||||
Document as PDFDocument,
|
||||
} from "@react-pdf/renderer";
|
||||
import {
|
||||
pdfStyles,
|
||||
DocumentTitle,
|
||||
COLORS,
|
||||
FONT_SIZES,
|
||||
Divider,
|
||||
} from "./pdf/SharedUI.js";
|
||||
import { SimpleLayout } from "./pdf/SimpleLayout.js";
|
||||
|
||||
const styles = PDFStyleSheet.create({
|
||||
section: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
textLead: {
|
||||
fontSize: FONT_SIZES.BODY,
|
||||
color: COLORS.TEXT_MAIN,
|
||||
lineHeight: 1.6,
|
||||
marginBottom: 16,
|
||||
},
|
||||
textRegular: {
|
||||
fontSize: FONT_SIZES.BODY,
|
||||
color: COLORS.TEXT_DIM,
|
||||
lineHeight: 1.6,
|
||||
marginBottom: 12,
|
||||
},
|
||||
bulletPoint: {
|
||||
flexDirection: "row",
|
||||
marginBottom: 6,
|
||||
paddingLeft: 10,
|
||||
},
|
||||
bullet: {
|
||||
width: 15,
|
||||
fontSize: FONT_SIZES.BODY,
|
||||
color: COLORS.TEXT_LIGHT,
|
||||
},
|
||||
bulletText: {
|
||||
flex: 1,
|
||||
fontSize: FONT_SIZES.BODY,
|
||||
color: COLORS.TEXT_DIM,
|
||||
lineHeight: 1.6,
|
||||
},
|
||||
quoteBox: {
|
||||
marginTop: 20,
|
||||
marginBottom: 20,
|
||||
padding: 20,
|
||||
backgroundColor: COLORS.GRID,
|
||||
borderLeftWidth: 3,
|
||||
borderLeftColor: COLORS.CHARCOAL,
|
||||
},
|
||||
quoteText: {
|
||||
fontSize: FONT_SIZES.LABEL,
|
||||
fontWeight: "bold",
|
||||
color: COLORS.CHARCOAL,
|
||||
lineHeight: 1.4,
|
||||
},
|
||||
headingSmall: {
|
||||
fontSize: FONT_SIZES.LABEL,
|
||||
fontWeight: "bold",
|
||||
color: COLORS.CHARCOAL,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 1,
|
||||
marginBottom: 12,
|
||||
marginTop: 8,
|
||||
},
|
||||
});
|
||||
|
||||
export const InfoPDF = ({ headerIcon, footerLogo }: { headerIcon?: string; footerLogo?: string }) => {
|
||||
const companyData = {
|
||||
name: "Marc Mintel",
|
||||
address1: "Georg-Meistermann-Straße 7",
|
||||
address2: "54586 Schüller",
|
||||
ustId: "DE367588065",
|
||||
};
|
||||
|
||||
const bankData = {
|
||||
name: "N26",
|
||||
bic: "NTSBDEB1XXX",
|
||||
iban: "DE50 1001 1001 2620 4328 65",
|
||||
};
|
||||
|
||||
const content = (
|
||||
<PDFView>
|
||||
<DocumentTitle
|
||||
title="Arbeitsweise & Philosophie"
|
||||
subLines={["Digital Architect — Marc Mintel"]}
|
||||
isHero={true}
|
||||
/>
|
||||
|
||||
<PDFView style={styles.section}>
|
||||
<PDFText style={styles.headingSmall}>Hintergrund & Motivation</PDFText>
|
||||
<PDFText style={styles.textLead}>
|
||||
Ich baue Websites und Systeme seit über 15 Jahren. Nicht weil ich Websites so liebe – sondern weil ich es hasse, wenn Dinge nicht funktionieren.
|
||||
</PDFText>
|
||||
<PDFText style={styles.textRegular}>
|
||||
In diesen 15 Jahren habe ich Agenturen von innen gesehen, Konzerne erlebt, Startups aufgebaut und gelernt, wie man Dinge baut, die einfach laufen. Heute mache ich das ohne Agentur-Zwischenschichten: Direkt. Sauber. Verantwortlich.
|
||||
</PDFText>
|
||||
</PDFView>
|
||||
|
||||
<PDFView style={styles.quoteBox}>
|
||||
<PDFText style={styles.quoteText}>
|
||||
"Das Problem ist selten Technik. Es ist immer Zuständigkeit. Wenn keiner verantwortlich ist, passiert nichts."
|
||||
</PDFText>
|
||||
</PDFView>
|
||||
|
||||
<PDFView style={styles.section}>
|
||||
<PDFText style={styles.headingSmall}>System-Architektur statt Baukasten</PDFText>
|
||||
<PDFText style={styles.textRegular}>
|
||||
Als Senior Developer in Umgebungen mit Millionenumsätzen habe ich gelernt: Performance ist nicht optional, Sicherheit kein Nice-to-Have. Deshalb sind meine Lösungen:
|
||||
</PDFText>
|
||||
<PDFView style={styles.bulletPoint}>
|
||||
<PDFText style={styles.bullet}>•</PDFText>
|
||||
<PDFText style={styles.bulletText}>Schnell, stabil und "boring" (im besten Sinne).</PDFText>
|
||||
</PDFView>
|
||||
<PDFView style={styles.bulletPoint}>
|
||||
<PDFText style={styles.bullet}>•</PDFText>
|
||||
<PDFText style={styles.bulletText}>Wartungsarm und unabhängig von Plugins oder Agenturen.</PDFText>
|
||||
</PDFView>
|
||||
<PDFView style={styles.bulletPoint}>
|
||||
<PDFText style={styles.bullet}>•</PDFText>
|
||||
<PDFText style={styles.bulletText}>Technologisch auf Augenhöhe mit Konzern-Standards, ohne bürokratischen Overhead.</PDFText>
|
||||
</PDFView>
|
||||
</PDFView>
|
||||
|
||||
<PDFView style={styles.section}>
|
||||
<PDFText style={styles.headingSmall}>Ihre Vorteile</PDFText>
|
||||
<PDFText style={styles.textRegular}>
|
||||
Sie bekommen keinen Projektmanager, keinen starren Prozess und kein CMS-Drama.
|
||||
</PDFText>
|
||||
<PDFView style={{ flexDirection: 'row', marginTop: 10 }}>
|
||||
<PDFView style={{ flex: 1, paddingRight: 20 }}>
|
||||
<PDFText style={{ fontSize: FONT_SIZES.TINY, color: COLORS.TEXT_LIGHT, marginBottom: 4 }}>KOMMUNIKATION</PDFText>
|
||||
<PDFText style={styles.textRegular}>Ein Ansprechpartner. Eine kurze Mail reicht oft aus. Keine endlosen Meetings.</PDFText>
|
||||
</PDFView>
|
||||
<PDFView style={{ flex: 1 }}>
|
||||
<PDFText style={{ fontSize: FONT_SIZES.TINY, color: COLORS.TEXT_LIGHT, marginBottom: 4 }}>VERANTWORTUNG</PDFText>
|
||||
<PDFText style={styles.textRegular}>Ich übernehme das Thema komplett, damit es für Sie kein Thema mehr ist.</PDFText>
|
||||
</PDFView>
|
||||
</PDFView>
|
||||
</PDFView>
|
||||
|
||||
<Divider style={{ marginTop: 20, marginBottom: 20, backgroundColor: COLORS.GRID }} />
|
||||
|
||||
<PDFText style={[styles.textRegular, { fontSize: FONT_SIZES.SMALL, textAlign: 'center', color: COLORS.TEXT_LIGHT }]}>
|
||||
Marc Mintel — Digital Architect & Senior Software Developer
|
||||
</PDFText>
|
||||
</PDFView>
|
||||
);
|
||||
|
||||
return (
|
||||
<PDFDocument title="Marc Mintel - Arbeitsweise">
|
||||
<SimpleLayout
|
||||
companyData={companyData}
|
||||
bankData={bankData}
|
||||
headerIcon={headerIcon}
|
||||
footerLogo={footerLogo}
|
||||
showPageNumber={false}
|
||||
>
|
||||
{content}
|
||||
</SimpleLayout>
|
||||
</PDFDocument>
|
||||
);
|
||||
};
|
||||
@@ -24,9 +24,10 @@ const styles = StyleSheet.create({
|
||||
borderBottomColor: COLORS.GRID,
|
||||
alignItems: "flex-start",
|
||||
},
|
||||
colPos: { width: "8%" },
|
||||
colDesc: { width: "62%" },
|
||||
colQty: { width: "10%", textAlign: "center" },
|
||||
colPos: { width: "6%" },
|
||||
colDesc: { width: "46%", paddingRight: 10 },
|
||||
colQty: { width: "8%", textAlign: "center" },
|
||||
colUnitPrice: { width: "20%", textAlign: "right", paddingRight: 10 },
|
||||
colPrice: { width: "20%", textAlign: "right" },
|
||||
headerText: {
|
||||
fontSize: FONT_SIZES.TINY,
|
||||
@@ -111,7 +112,8 @@ export const EstimationModule = ({
|
||||
Beschreibung
|
||||
</PDFText>
|
||||
<PDFText style={[styles.headerText, styles.colQty]}>Menge</PDFText>
|
||||
<PDFText style={[styles.headerText, styles.colPrice]}>Betrag</PDFText>
|
||||
<PDFText style={[styles.headerText, styles.colUnitPrice]}>E-Preis</PDFText>
|
||||
<PDFText style={[styles.headerText, styles.colPrice]}>Gesamt</PDFText>
|
||||
</PDFView>
|
||||
{positions.map((item: any, i: number) => (
|
||||
<PDFView key={i} style={styles.tableRow} wrap={false}>
|
||||
@@ -125,6 +127,11 @@ export const EstimationModule = ({
|
||||
</PDFText>
|
||||
</PDFView>
|
||||
<PDFText style={[styles.posText, styles.colQty]}>{item.qty}</PDFText>
|
||||
<PDFText style={[styles.priceText, styles.colUnitPrice, { fontSize: FONT_SIZES.SMALL, color: COLORS.TEXT_MAIN, fontWeight: "normal" }]}>
|
||||
{item.price > 0 && item.qty > 0
|
||||
? `${(item.price / item.qty).toLocaleString("de-DE")} €`
|
||||
: "n. A."}
|
||||
</PDFText>
|
||||
<PDFText style={[styles.priceText, styles.colPrice]}>
|
||||
{item.price > 0
|
||||
? `${item.price.toLocaleString("de-DE")} €`
|
||||
|
||||
@@ -17,64 +17,61 @@ const styles = StyleSheet.create({
|
||||
marginBottom: 24,
|
||||
textAlign: "justify",
|
||||
},
|
||||
sitemapTree: { marginTop: 8 },
|
||||
rootNode: {
|
||||
padding: 12,
|
||||
backgroundColor: COLORS.GRID,
|
||||
marginBottom: 20,
|
||||
borderLeftWidth: 2,
|
||||
borderLeftColor: COLORS.CHARCOAL,
|
||||
sitemapTree: {
|
||||
marginTop: 8,
|
||||
borderLeftWidth: 1,
|
||||
borderLeftColor: COLORS.GRID,
|
||||
marginLeft: 4,
|
||||
paddingLeft: 16,
|
||||
},
|
||||
rootTitle: {
|
||||
fontSize: FONT_SIZES.HEADING,
|
||||
fontSize: FONT_SIZES.LABEL,
|
||||
fontWeight: "bold",
|
||||
color: COLORS.CHARCOAL,
|
||||
letterSpacing: 0.5,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 1,
|
||||
marginBottom: 16,
|
||||
marginLeft: -16, // offset the padding
|
||||
},
|
||||
categorySection: { marginBottom: 20 },
|
||||
categorySection: { marginBottom: 16 },
|
||||
categoryHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingBottom: 6,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: COLORS.BLUEPRINT,
|
||||
marginBottom: 10,
|
||||
marginBottom: 8,
|
||||
},
|
||||
categoryIcon: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
backgroundColor: COLORS.GRID,
|
||||
borderInlineWidth: 1,
|
||||
borderColor: COLORS.DIVIDER,
|
||||
marginRight: 10,
|
||||
width: 6,
|
||||
height: 6,
|
||||
backgroundColor: COLORS.CHARCOAL,
|
||||
marginRight: 8,
|
||||
},
|
||||
categoryTitle: {
|
||||
fontSize: FONT_SIZES.BODY,
|
||||
fontWeight: "bold",
|
||||
color: COLORS.CHARCOAL,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 1,
|
||||
},
|
||||
pagesGrid: { flexDirection: "row", flexWrap: "wrap" },
|
||||
pageCard: {
|
||||
width: "48%",
|
||||
marginRight: "2%",
|
||||
marginBottom: 12,
|
||||
padding: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: COLORS.GRID,
|
||||
backgroundColor: "#fafafa",
|
||||
pageRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
marginBottom: 6,
|
||||
paddingLeft: 14,
|
||||
},
|
||||
pageBullet: {
|
||||
fontSize: FONT_SIZES.BODY,
|
||||
color: COLORS.TEXT_LIGHT,
|
||||
marginRight: 8,
|
||||
width: 10,
|
||||
},
|
||||
pageTitle: {
|
||||
fontSize: FONT_SIZES.BODY,
|
||||
fontWeight: "bold",
|
||||
color: COLORS.TEXT_MAIN,
|
||||
marginBottom: 4,
|
||||
fontWeight: "bold",
|
||||
},
|
||||
pageDesc: {
|
||||
fontSize: FONT_SIZES.TINY,
|
||||
fontSize: FONT_SIZES.SMALL,
|
||||
color: COLORS.TEXT_DIM,
|
||||
lineHeight: 1.3,
|
||||
marginLeft: 6,
|
||||
marginTop: 1,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -83,16 +80,13 @@ export const SitemapModule = ({ state }: any) => (
|
||||
<DocumentTitle title="Informationsarchitektur" isHero={true} />
|
||||
<PDFView style={styles.section}>
|
||||
<PDFText style={styles.intro}>
|
||||
Die folgende Struktur definiert die logische Hierarchie und
|
||||
Benutzerführung. Sie dient als Bauplan für die technische Umsetzung und
|
||||
stellt sicher, dass alle relevanten Geschäftsbereiche intuitiv
|
||||
auffindbar sind.
|
||||
Die folgende Baumstruktur definiert die logische Hierarchie und
|
||||
Benutzerführung. Sie dient als kompakter Bauplan für die technische
|
||||
Umsetzung aller relevanten Geschäftsbereiche.
|
||||
</PDFText>
|
||||
|
||||
<PDFView style={styles.sitemapTree}>
|
||||
<PDFView style={styles.rootNode}>
|
||||
<PDFText style={styles.rootTitle}>Seitenstruktur</PDFText>
|
||||
</PDFView>
|
||||
<PDFText style={styles.rootTitle}>/ Root (Startseite)</PDFText>
|
||||
|
||||
{state.sitemap?.map((cat: any, i: number) => (
|
||||
<PDFView key={i} style={styles.categorySection} wrap={false}>
|
||||
@@ -101,18 +95,13 @@ export const SitemapModule = ({ state }: any) => (
|
||||
<PDFText style={styles.categoryTitle}>{cat.category}</PDFText>
|
||||
</PDFView>
|
||||
|
||||
<PDFView style={styles.pagesGrid}>
|
||||
<PDFView>
|
||||
{cat.pages.map((p: any, j: number) => (
|
||||
<PDFView
|
||||
key={j}
|
||||
style={[
|
||||
styles.pageCard,
|
||||
j % 2 === 1 ? { marginRight: 0 } : {},
|
||||
]}
|
||||
>
|
||||
<PDFView key={j} style={styles.pageRow}>
|
||||
<PDFText style={styles.pageBullet}>↳</PDFText>
|
||||
<PDFText style={styles.pageTitle}>{p.title}</PDFText>
|
||||
{p.desc && (
|
||||
<PDFText style={styles.pageDesc}>{p.desc}</PDFText>
|
||||
<PDFText style={styles.pageDesc}> – {p.desc}</PDFText>
|
||||
)}
|
||||
</PDFView>
|
||||
))}
|
||||
|
||||
@@ -81,7 +81,7 @@ export const TransparenzModule = ({ pricing }: any) => {
|
||||
},
|
||||
{
|
||||
l: "Sprachversionen",
|
||||
d: "Skalierung der System-Architektur auf Zweit-Sprachen.",
|
||||
d: "Skalierung der Architektur für weitere Sprachen (+20% Aufschlag auf die Zwischensumme aller vorherigen Positionen).",
|
||||
p: "+20%",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -2,6 +2,8 @@ export * from "./logic/pricing/types.js";
|
||||
export * from "./logic/pricing/constants.js";
|
||||
export * from "./logic/pricing/calculator.js";
|
||||
export * from "./components/EstimationPDF.js";
|
||||
export * from "./components/ConceptPDF.js";
|
||||
export * from "./components/InfoPDF.js";
|
||||
export * from "./components/pdf/SimpleLayout.js";
|
||||
export * from "./components/pdf/SharedUI.js";
|
||||
export * from "./components/pdf/modules/FrontPageModule.js";
|
||||
@@ -12,4 +14,5 @@ export * from "./components/pdf/modules/CommonModules.js";
|
||||
export * from "./components/pdf/modules/BrandingModules.js";
|
||||
export * from "./components/pdf/modules/TransparenzModule.js";
|
||||
export * from "./components/AgbsPDF.js";
|
||||
export * from "./components/InfoPDF.js";
|
||||
export * from "./components/CombinedQuotePDF.js";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { FormState } from "./types.js";
|
||||
|
||||
export const PRICING = {
|
||||
BASE_WEBSITE: 5440, // Updated to match AI prompt requirement in Pass 1
|
||||
BASE_WEBSITE: 4000, // Foundation server infrastructure setup
|
||||
PAGE: 600,
|
||||
FEATURE: 1500,
|
||||
FUNCTION: 800,
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
export * from "./index.js";
|
||||
export * from "./services/AcquisitionService.js";
|
||||
export * from "./services/PdfEngine.js";
|
||||
|
||||
@@ -1,6 +1,27 @@
|
||||
import { renderToFile } from "@react-pdf/renderer";
|
||||
import { renderToFile, Font } from "@react-pdf/renderer";
|
||||
import { createElement } from "react";
|
||||
|
||||
// Standard Font Registrations to prevent crashes when PDFs use custom web fonts
|
||||
Font.register({
|
||||
family: 'Outfit',
|
||||
fonts: [
|
||||
{ src: 'Helvetica' },
|
||||
{ src: 'Helvetica-Bold', fontWeight: 'bold' },
|
||||
],
|
||||
});
|
||||
|
||||
Font.register({
|
||||
family: 'Inter',
|
||||
fonts: [
|
||||
{ src: 'Helvetica' },
|
||||
{ src: 'Helvetica-Bold', fontWeight: 'bold' },
|
||||
],
|
||||
});
|
||||
|
||||
import { EstimationPDF } from "../components/EstimationPDF.js";
|
||||
import { ConceptPDF } from "../components/ConceptPDF.js";
|
||||
import { InfoPDF } from "../components/InfoPDF.js";
|
||||
import { AgbsPDF } from "../components/AgbsPDF.js";
|
||||
import { PRICING } from "../logic/pricing/constants.js";
|
||||
import { calculateTotals } from "../logic/pricing/calculator.js";
|
||||
|
||||
@@ -21,4 +42,33 @@ export class PdfEngine {
|
||||
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
async generateConceptPdf(concept: any, outputPath: string): Promise<string> {
|
||||
await renderToFile(
|
||||
createElement(ConceptPDF as any, {
|
||||
concept,
|
||||
} as any) as any,
|
||||
outputPath
|
||||
);
|
||||
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
async generateInfoPdf(outputPath: string, options: { headerIcon?: string; footerLogo?: string } = {}): Promise<string> {
|
||||
await renderToFile(
|
||||
createElement(InfoPDF as any, options as any) as any,
|
||||
outputPath
|
||||
);
|
||||
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
async generateAgbsPdf(outputPath: string, options: { headerIcon?: string; footerLogo?: string; mode?: "estimation" | "full" } = {}): Promise<string> {
|
||||
await renderToFile(
|
||||
createElement(AgbsPDF as any, options as any) as any,
|
||||
outputPath
|
||||
);
|
||||
|
||||
return outputPath;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "1.8.21",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://npm.infra.mintel.me"
|
||||
"registry": "https://git.infra.mintel.me/api/packages/mmintel/npm"
|
||||
},
|
||||
"files": [
|
||||
"base.json",
|
||||
|
||||
495
pnpm-lock.yaml
generated
495
pnpm-lock.yaml
generated
@@ -290,6 +290,49 @@ importers:
|
||||
specifier: ^3.4.0
|
||||
version: 3.5.28(typescript@5.9.3)
|
||||
|
||||
packages/concept-engine:
|
||||
dependencies:
|
||||
'@crawlee/cheerio':
|
||||
specifier: ^3.11.2
|
||||
version: 3.16.0
|
||||
'@mintel/journaling':
|
||||
specifier: workspace:*
|
||||
version: link:../journaling
|
||||
'@mintel/page-audit':
|
||||
specifier: workspace:*
|
||||
version: link:../page-audit
|
||||
axios:
|
||||
specifier: ^1.7.9
|
||||
version: 1.13.5
|
||||
cheerio:
|
||||
specifier: 1.0.0-rc.12
|
||||
version: 1.0.0-rc.12
|
||||
commander:
|
||||
specifier: ^13.1.0
|
||||
version: 13.1.0
|
||||
dotenv:
|
||||
specifier: ^16.4.7
|
||||
version: 16.6.1
|
||||
zod:
|
||||
specifier: ^3.24.2
|
||||
version: 3.25.76
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^20.17.17
|
||||
version: 20.19.33
|
||||
tsup:
|
||||
specifier: ^8.3.6
|
||||
version: 8.5.1(@swc/core@1.15.11(@swc/helpers@0.5.18))(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)
|
||||
tsx:
|
||||
specifier: ^4.19.2
|
||||
version: 4.21.0
|
||||
typescript:
|
||||
specifier: ^5.7.3
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^3.0.5
|
||||
version: 3.2.4(@types/debug@4.1.12)(@types/node@20.19.33)(@vitest/ui@4.0.18)(happy-dom@20.5.3)(jsdom@27.4.0(canvas@3.2.1))(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0)
|
||||
|
||||
packages/content-engine:
|
||||
dependencies:
|
||||
'@mintel/journaling':
|
||||
@@ -379,6 +422,64 @@ importers:
|
||||
specifier: ^8.54.0
|
||||
version: 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
|
||||
packages/estimation-engine:
|
||||
dependencies:
|
||||
'@mintel/concept-engine':
|
||||
specifier: workspace:*
|
||||
version: link:../concept-engine
|
||||
axios:
|
||||
specifier: ^1.6.0
|
||||
version: 1.13.5
|
||||
chalk:
|
||||
specifier: ^5.3.0
|
||||
version: 5.6.2
|
||||
commander:
|
||||
specifier: ^12.0.0
|
||||
version: 12.1.0
|
||||
dotenv:
|
||||
specifier: ^17.3.1
|
||||
version: 17.3.1
|
||||
ink:
|
||||
specifier: ^5.1.0
|
||||
version: 5.2.1(@types/react@18.3.28)(react@18.3.1)
|
||||
ink-select-input:
|
||||
specifier: ^6.0.0
|
||||
version: 6.2.0(ink@5.2.1(@types/react@18.3.28)(react@18.3.1))(react@18.3.1)
|
||||
ink-spinner:
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.0(ink@5.2.1(@types/react@18.3.28)(react@18.3.1))(react@18.3.1)
|
||||
ink-text-input:
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0(ink@5.2.1(@types/react@18.3.28)(react@18.3.1))(react@18.3.1)
|
||||
openai:
|
||||
specifier: ^4.82.0
|
||||
version: 4.104.0(ws@8.19.0)(zod@3.25.76)
|
||||
react:
|
||||
specifier: ^18.2.0
|
||||
version: 18.3.1
|
||||
devDependencies:
|
||||
'@mintel/eslint-config':
|
||||
specifier: workspace:*
|
||||
version: link:../eslint-config
|
||||
'@mintel/tsconfig':
|
||||
specifier: workspace:*
|
||||
version: link:../tsconfig
|
||||
'@types/node':
|
||||
specifier: ^20.0.0
|
||||
version: 20.19.33
|
||||
'@types/react':
|
||||
specifier: ^18.2.0
|
||||
version: 18.3.28
|
||||
tsup:
|
||||
specifier: ^8.3.5
|
||||
version: 8.5.1(@swc/core@1.15.11(@swc/helpers@0.5.18))(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)
|
||||
tsx:
|
||||
specifier: ^4.7.0
|
||||
version: 4.21.0
|
||||
typescript:
|
||||
specifier: ^5.0.0
|
||||
version: 5.9.3
|
||||
|
||||
packages/feedback-commander:
|
||||
devDependencies:
|
||||
'@directus/extensions-sdk':
|
||||
@@ -747,6 +848,43 @@ importers:
|
||||
specifier: ^2.0.0
|
||||
version: 2.1.9(@types/node@22.19.10)(@vitest/ui@4.0.18)(happy-dom@20.5.3)(jsdom@27.4.0(canvas@3.2.1))(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0)
|
||||
|
||||
packages/page-audit:
|
||||
dependencies:
|
||||
chalk:
|
||||
specifier: ^5.3.0
|
||||
version: 5.6.2
|
||||
cheerio:
|
||||
specifier: ^1.0.0
|
||||
version: 1.2.0
|
||||
commander:
|
||||
specifier: ^12.0.0
|
||||
version: 12.1.0
|
||||
dotenv:
|
||||
specifier: ^17.3.1
|
||||
version: 17.3.1
|
||||
openai:
|
||||
specifier: ^4.82.0
|
||||
version: 4.104.0(ws@8.19.0)(zod@3.25.76)
|
||||
devDependencies:
|
||||
'@mintel/eslint-config':
|
||||
specifier: workspace:*
|
||||
version: link:../eslint-config
|
||||
'@mintel/tsconfig':
|
||||
specifier: workspace:*
|
||||
version: link:../tsconfig
|
||||
'@types/node':
|
||||
specifier: ^20.0.0
|
||||
version: 20.19.33
|
||||
tsup:
|
||||
specifier: ^8.3.5
|
||||
version: 8.5.1(@swc/core@1.15.11(@swc/helpers@0.5.18))(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)
|
||||
tsx:
|
||||
specifier: ^4.7.0
|
||||
version: 4.21.0
|
||||
typescript:
|
||||
specifier: ^5.0.0
|
||||
version: 5.9.3
|
||||
|
||||
packages/pdf-library:
|
||||
dependencies:
|
||||
'@crawlee/cheerio':
|
||||
@@ -835,6 +973,10 @@ packages:
|
||||
'@adobe/css-tools@4.4.4':
|
||||
resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==}
|
||||
|
||||
'@alcalzone/ansi-tokenize@0.1.3':
|
||||
resolution: {integrity: sha512-3yWxPTq3UQ/FY9p1ErPxIyfT64elWaMvM9lIHnaqpyft63tkxodF5aUElYHrdisWve5cETkh1+KBw1yJuW0aRw==}
|
||||
engines: {node: '>=14.13.1'}
|
||||
|
||||
'@alloc/quick-lru@5.2.0':
|
||||
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -3616,6 +3758,9 @@ packages:
|
||||
'@types/prompts@2.4.9':
|
||||
resolution: {integrity: sha512-qTxFi6Buiu8+50/+3DGIWLHM6QuWsEKugJnnP6iv2Mc4ncxE4A/OJkjuVOA+5X0X1S/nq5VJRa8Lu+nwcvbrKA==}
|
||||
|
||||
'@types/prop-types@15.7.15':
|
||||
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
|
||||
|
||||
'@types/qs@6.14.0':
|
||||
resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==}
|
||||
|
||||
@@ -3627,6 +3772,9 @@ packages:
|
||||
peerDependencies:
|
||||
'@types/react': ^19.2.0
|
||||
|
||||
'@types/react@18.3.28':
|
||||
resolution: {integrity: sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==}
|
||||
|
||||
'@types/react@19.2.13':
|
||||
resolution: {integrity: sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ==}
|
||||
|
||||
@@ -4254,6 +4402,10 @@ packages:
|
||||
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
auto-bind@5.0.1:
|
||||
resolution: {integrity: sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
|
||||
autoprefixer@10.4.24:
|
||||
resolution: {integrity: sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
@@ -4477,6 +4629,10 @@ packages:
|
||||
cjs-module-lexer@2.2.0:
|
||||
resolution: {integrity: sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==}
|
||||
|
||||
cli-boxes@3.0.0:
|
||||
resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
cli-cursor@3.1.0:
|
||||
resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -4493,6 +4649,10 @@ packages:
|
||||
resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
cli-truncate@4.0.0:
|
||||
resolution: {integrity: sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
cli-truncate@5.1.1:
|
||||
resolution: {integrity: sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A==}
|
||||
engines: {node: '>=20'}
|
||||
@@ -4527,6 +4687,10 @@ packages:
|
||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
code-excerpt@4.0.0:
|
||||
resolution: {integrity: sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
|
||||
color-convert@2.0.1:
|
||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||
engines: {node: '>=7.0.0'}
|
||||
@@ -4562,6 +4726,14 @@ packages:
|
||||
resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
commander@12.1.0:
|
||||
resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
commander@13.1.0:
|
||||
resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
commander@14.0.3:
|
||||
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
|
||||
engines: {node: '>=20'}
|
||||
@@ -4613,6 +4785,10 @@ packages:
|
||||
convert-source-map@2.0.0:
|
||||
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
||||
|
||||
convert-to-spaces@2.0.1:
|
||||
resolution: {integrity: sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
|
||||
cookie@0.7.2:
|
||||
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
|
||||
engines: {node: '>= 0.6'}
|
||||
@@ -5030,6 +5206,9 @@ packages:
|
||||
resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
es-toolkit@1.44.0:
|
||||
resolution: {integrity: sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==}
|
||||
|
||||
esbuild@0.17.19:
|
||||
resolution: {integrity: sha512-XQ0jAPFkK/u3LcVRcvVHQcTIqD6E2H1fvZMA5dQPSOWb3suUbWbfbRf94pjc0bNzRYLfIrDRQXr7X+LHIm5oHw==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -5063,6 +5242,10 @@ packages:
|
||||
resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==}
|
||||
engines: {node: '>=0.8.0'}
|
||||
|
||||
escape-string-regexp@2.0.0:
|
||||
resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
escape-string-regexp@4.0.0:
|
||||
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -5313,6 +5496,10 @@ packages:
|
||||
resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
figures@6.1.0:
|
||||
resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
file-entry-cache@8.0.0:
|
||||
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
@@ -5785,6 +5972,10 @@ packages:
|
||||
resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
indent-string@5.0.0:
|
||||
resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
inflight@1.0.6:
|
||||
resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==}
|
||||
deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.
|
||||
@@ -5799,6 +5990,40 @@ packages:
|
||||
resolution: {integrity: sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==}
|
||||
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
|
||||
|
||||
ink-select-input@6.2.0:
|
||||
resolution: {integrity: sha512-304fZXxkpYxJ9si5lxRCaX01GNlmPBgOZumXXRnPYbHW/iI31cgQynqk2tRypGLOF1cMIwPUzL2LSm6q4I5rQQ==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
ink: '>=5.0.0'
|
||||
react: '>=18.0.0'
|
||||
|
||||
ink-spinner@5.0.0:
|
||||
resolution: {integrity: sha512-EYEasbEjkqLGyPOUc8hBJZNuC5GvXGMLu0w5gdTNskPc7Izc5vO3tdQEYnzvshucyGCBXc86ig0ujXPMWaQCdA==}
|
||||
engines: {node: '>=14.16'}
|
||||
peerDependencies:
|
||||
ink: '>=4.0.0'
|
||||
react: '>=18.0.0'
|
||||
|
||||
ink-text-input@6.0.0:
|
||||
resolution: {integrity: sha512-Fw64n7Yha5deb1rHY137zHTAbSTNelUKuB5Kkk2HACXEtwIHBCf9OH2tP/LQ9fRYTl1F0dZgbW0zPnZk6FA9Lw==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
ink: '>=5'
|
||||
react: '>=18'
|
||||
|
||||
ink@5.2.1:
|
||||
resolution: {integrity: sha512-BqcUyWrG9zq5HIwW6JcfFHsIYebJkWWb4fczNah1goUO0vv5vneIlfwuS85twyJ5hYR/y18FlAYUxrO9ChIWVg==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
'@types/react': '>=18.0.0'
|
||||
react: '>=18.0.0'
|
||||
react-devtools-core: ^4.19.1
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
react-devtools-core:
|
||||
optional: true
|
||||
|
||||
inquirer@8.2.7:
|
||||
resolution: {integrity: sha512-UjOaSel/iddGZJ5xP/Eixh6dY1XghiBw4XK13rCCIJcJfyhhoul/7KhLLUGtebEj6GDYM6Vnx/mVsjx2L/mFIA==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
@@ -5890,6 +6115,10 @@ packages:
|
||||
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
is-fullwidth-code-point@4.0.0:
|
||||
resolution: {integrity: sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
is-fullwidth-code-point@5.1.0:
|
||||
resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -5902,6 +6131,11 @@ packages:
|
||||
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
is-in-ci@1.0.0:
|
||||
resolution: {integrity: sha512-eUuAjybVTHMYWm/U+vBO1sY/JOCgoPCXRxzdju0K+K0BiGW0SChEL1MLC0PoCIR1OlPo5YAp8HuQoUlsWEICwg==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
is-interactive@1.0.0:
|
||||
resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -5987,6 +6221,10 @@ packages:
|
||||
resolution: {integrity: sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
is-unicode-supported@2.1.0:
|
||||
resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
is-url@1.2.4:
|
||||
resolution: {integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==}
|
||||
|
||||
@@ -6859,6 +7097,10 @@ packages:
|
||||
parseley@0.12.1:
|
||||
resolution: {integrity: sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==}
|
||||
|
||||
patch-console@2.0.0:
|
||||
resolution: {integrity: sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
|
||||
path-exists@4.0.0:
|
||||
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -7374,10 +7616,20 @@ packages:
|
||||
react-promise-suspense@0.3.4:
|
||||
resolution: {integrity: sha512-I42jl7L3Ze6kZaq+7zXWSunBa3b1on5yfvUW6Eo/3fFOj6dZ5Bqmcd264nJbTK/gn1HjjILAjSwnZbV4RpSaNQ==}
|
||||
|
||||
react-reconciler@0.29.2:
|
||||
resolution: {integrity: sha512-zZQqIiYgDCTP/f1N/mAR10nJGrPD2ZR+jDSEsKWJHYC7Cm2wodlwbR3upZRdC3cjIjSlTLNVyO7Iu0Yy7t2AYg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
peerDependencies:
|
||||
react: ^18.3.1
|
||||
|
||||
react-refresh@0.18.0:
|
||||
resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
react@18.3.1:
|
||||
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
react@19.2.4:
|
||||
resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -7587,6 +7839,9 @@ packages:
|
||||
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
|
||||
engines: {node: '>=v12.22.7'}
|
||||
|
||||
scheduler@0.23.2:
|
||||
resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==}
|
||||
|
||||
scheduler@0.25.0-rc-603e6108-20241029:
|
||||
resolution: {integrity: sha512-pFwF6H1XrSdYYNLfOcGlM28/j8CGLu8IvdrxqhjWULe2bPcKiKW4CV+OWqR/9fT52mywx65l7ysNkjLKBda7eA==}
|
||||
|
||||
@@ -7698,6 +7953,10 @@ packages:
|
||||
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
slice-ansi@5.0.0:
|
||||
resolution: {integrity: sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
slice-ansi@7.1.2:
|
||||
resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -7759,6 +8018,10 @@ packages:
|
||||
resolution: {integrity: sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==}
|
||||
deprecated: 'Modern JS already guarantees Array#sort() is a stable sort, so this library is deprecated. See the compatibility table on MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#browser_compatibility'
|
||||
|
||||
stack-utils@2.0.6:
|
||||
resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
stackback@0.0.2:
|
||||
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
||||
|
||||
@@ -8072,6 +8335,10 @@ packages:
|
||||
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
|
||||
engines: {node: '>=8.0'}
|
||||
|
||||
to-rotated@1.0.0:
|
||||
resolution: {integrity: sha512-KsEID8AfgUy+pxVRLsWp0VzCa69wxzUDZnzGbyIST/bcgcrMvTYoFBX/QORH4YApoD89EDuUovx4BTdpOn319Q==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
toad-cache@3.7.0:
|
||||
resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -8596,6 +8863,10 @@ packages:
|
||||
engines: {node: '>=8'}
|
||||
hasBin: true
|
||||
|
||||
widest-line@5.0.0:
|
||||
resolution: {integrity: sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
word-wrap@1.2.5:
|
||||
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -8699,15 +8970,17 @@ packages:
|
||||
zod@3.25.76:
|
||||
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
|
||||
|
||||
zod@4.3.6:
|
||||
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@acemir/cssom@0.9.31': {}
|
||||
|
||||
'@adobe/css-tools@4.4.4': {}
|
||||
|
||||
'@alcalzone/ansi-tokenize@0.1.3':
|
||||
dependencies:
|
||||
ansi-styles: 6.2.3
|
||||
is-fullwidth-code-point: 4.0.0
|
||||
|
||||
'@alloc/quick-lru@5.2.0': {}
|
||||
|
||||
'@apify/consts@2.51.0': {}
|
||||
@@ -11726,6 +11999,8 @@ snapshots:
|
||||
'@types/node': 20.19.33
|
||||
kleur: 3.0.3
|
||||
|
||||
'@types/prop-types@15.7.15': {}
|
||||
|
||||
'@types/qs@6.14.0': {}
|
||||
|
||||
'@types/range-parser@1.2.7': {}
|
||||
@@ -11734,6 +12009,11 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/react': 19.2.13
|
||||
|
||||
'@types/react@18.3.28':
|
||||
dependencies:
|
||||
'@types/prop-types': 15.7.15
|
||||
csstype: 3.2.3
|
||||
|
||||
'@types/react@19.2.13':
|
||||
dependencies:
|
||||
csstype: 3.2.3
|
||||
@@ -12003,6 +12283,14 @@ snapshots:
|
||||
optionalDependencies:
|
||||
vite: 5.4.21(@types/node@22.19.10)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0)
|
||||
|
||||
'@vitest/mocker@3.2.4(vite@5.4.21(@types/node@20.19.33)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0))':
|
||||
dependencies:
|
||||
'@vitest/spy': 3.2.4
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
vite: 5.4.21(@types/node@20.19.33)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0)
|
||||
|
||||
'@vitest/mocker@3.2.4(vite@5.4.21(@types/node@22.19.10)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0))':
|
||||
dependencies:
|
||||
'@vitest/spy': 3.2.4
|
||||
@@ -12490,6 +12778,8 @@ snapshots:
|
||||
|
||||
atomic-sleep@1.0.0: {}
|
||||
|
||||
auto-bind@5.0.1: {}
|
||||
|
||||
autoprefixer@10.4.24(postcss@8.5.6):
|
||||
dependencies:
|
||||
browserslist: 4.28.1
|
||||
@@ -12753,6 +13043,8 @@ snapshots:
|
||||
|
||||
cjs-module-lexer@2.2.0: {}
|
||||
|
||||
cli-boxes@3.0.0: {}
|
||||
|
||||
cli-cursor@3.1.0:
|
||||
dependencies:
|
||||
restore-cursor: 3.1.0
|
||||
@@ -12767,6 +13059,11 @@ snapshots:
|
||||
|
||||
cli-spinners@2.9.2: {}
|
||||
|
||||
cli-truncate@4.0.0:
|
||||
dependencies:
|
||||
slice-ansi: 5.0.0
|
||||
string-width: 7.2.0
|
||||
|
||||
cli-truncate@5.1.1:
|
||||
dependencies:
|
||||
slice-ansi: 7.1.2
|
||||
@@ -12796,6 +13093,10 @@ snapshots:
|
||||
|
||||
clsx@2.1.1: {}
|
||||
|
||||
code-excerpt@4.0.0:
|
||||
dependencies:
|
||||
convert-to-spaces: 2.0.1
|
||||
|
||||
color-convert@2.0.1:
|
||||
dependencies:
|
||||
color-name: 1.1.4
|
||||
@@ -12826,6 +13127,10 @@ snapshots:
|
||||
|
||||
commander@11.1.0: {}
|
||||
|
||||
commander@12.1.0: {}
|
||||
|
||||
commander@13.1.0: {}
|
||||
|
||||
commander@14.0.3: {}
|
||||
|
||||
commander@2.20.3: {}
|
||||
@@ -12863,6 +13168,8 @@ snapshots:
|
||||
|
||||
convert-source-map@2.0.0: {}
|
||||
|
||||
convert-to-spaces@2.0.1: {}
|
||||
|
||||
cookie@0.7.2: {}
|
||||
|
||||
core-js@3.29.1: {}
|
||||
@@ -13354,6 +13661,8 @@ snapshots:
|
||||
is-date-object: 1.1.0
|
||||
is-symbol: 1.1.1
|
||||
|
||||
es-toolkit@1.44.0: {}
|
||||
|
||||
esbuild@0.17.19:
|
||||
optionalDependencies:
|
||||
'@esbuild/android-arm': 0.17.19
|
||||
@@ -13492,6 +13801,8 @@ snapshots:
|
||||
|
||||
escape-string-regexp@1.0.5: {}
|
||||
|
||||
escape-string-regexp@2.0.0: {}
|
||||
|
||||
escape-string-regexp@4.0.0: {}
|
||||
|
||||
eslint-config-next@16.1.6(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3):
|
||||
@@ -13602,8 +13913,8 @@ snapshots:
|
||||
'@babel/parser': 7.29.0
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
hermes-parser: 0.25.1
|
||||
zod: 4.3.6
|
||||
zod-validation-error: 4.0.2(zod@4.3.6)
|
||||
zod: 3.25.76
|
||||
zod-validation-error: 4.0.2(zod@3.25.76)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -13843,6 +14154,10 @@ snapshots:
|
||||
dependencies:
|
||||
escape-string-regexp: 1.0.5
|
||||
|
||||
figures@6.1.0:
|
||||
dependencies:
|
||||
is-unicode-supported: 2.1.0
|
||||
|
||||
file-entry-cache@8.0.0:
|
||||
dependencies:
|
||||
flat-cache: 4.0.1
|
||||
@@ -14364,6 +14679,8 @@ snapshots:
|
||||
|
||||
indent-string@4.0.0: {}
|
||||
|
||||
indent-string@5.0.0: {}
|
||||
|
||||
inflight@1.0.6:
|
||||
dependencies:
|
||||
once: 1.4.0
|
||||
@@ -14375,6 +14692,59 @@ snapshots:
|
||||
|
||||
ini@4.1.1: {}
|
||||
|
||||
ink-select-input@6.2.0(ink@5.2.1(@types/react@18.3.28)(react@18.3.1))(react@18.3.1):
|
||||
dependencies:
|
||||
figures: 6.1.0
|
||||
ink: 5.2.1(@types/react@18.3.28)(react@18.3.1)
|
||||
react: 18.3.1
|
||||
to-rotated: 1.0.0
|
||||
|
||||
ink-spinner@5.0.0(ink@5.2.1(@types/react@18.3.28)(react@18.3.1))(react@18.3.1):
|
||||
dependencies:
|
||||
cli-spinners: 2.9.2
|
||||
ink: 5.2.1(@types/react@18.3.28)(react@18.3.1)
|
||||
react: 18.3.1
|
||||
|
||||
ink-text-input@6.0.0(ink@5.2.1(@types/react@18.3.28)(react@18.3.1))(react@18.3.1):
|
||||
dependencies:
|
||||
chalk: 5.6.2
|
||||
ink: 5.2.1(@types/react@18.3.28)(react@18.3.1)
|
||||
react: 18.3.1
|
||||
type-fest: 4.41.0
|
||||
|
||||
ink@5.2.1(@types/react@18.3.28)(react@18.3.1):
|
||||
dependencies:
|
||||
'@alcalzone/ansi-tokenize': 0.1.3
|
||||
ansi-escapes: 7.3.0
|
||||
ansi-styles: 6.2.3
|
||||
auto-bind: 5.0.1
|
||||
chalk: 5.6.2
|
||||
cli-boxes: 3.0.0
|
||||
cli-cursor: 4.0.0
|
||||
cli-truncate: 4.0.0
|
||||
code-excerpt: 4.0.0
|
||||
es-toolkit: 1.44.0
|
||||
indent-string: 5.0.0
|
||||
is-in-ci: 1.0.0
|
||||
patch-console: 2.0.0
|
||||
react: 18.3.1
|
||||
react-reconciler: 0.29.2(react@18.3.1)
|
||||
scheduler: 0.23.2
|
||||
signal-exit: 3.0.7
|
||||
slice-ansi: 7.1.2
|
||||
stack-utils: 2.0.6
|
||||
string-width: 7.2.0
|
||||
type-fest: 4.41.0
|
||||
widest-line: 5.0.0
|
||||
wrap-ansi: 9.0.2
|
||||
ws: 8.19.0
|
||||
yoga-layout: 3.2.1
|
||||
optionalDependencies:
|
||||
'@types/react': 18.3.28
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- utf-8-validate
|
||||
|
||||
inquirer@8.2.7(@types/node@22.19.10):
|
||||
dependencies:
|
||||
'@inquirer/external-editor': 1.0.3(@types/node@22.19.10)
|
||||
@@ -14498,6 +14868,8 @@ snapshots:
|
||||
|
||||
is-fullwidth-code-point@3.0.0: {}
|
||||
|
||||
is-fullwidth-code-point@4.0.0: {}
|
||||
|
||||
is-fullwidth-code-point@5.1.0:
|
||||
dependencies:
|
||||
get-east-asian-width: 1.4.0
|
||||
@@ -14514,6 +14886,8 @@ snapshots:
|
||||
dependencies:
|
||||
is-extglob: 2.1.1
|
||||
|
||||
is-in-ci@1.0.0: {}
|
||||
|
||||
is-interactive@1.0.0: {}
|
||||
|
||||
is-interactive@2.0.0: {}
|
||||
@@ -14581,6 +14955,8 @@ snapshots:
|
||||
|
||||
is-unicode-supported@1.3.0: {}
|
||||
|
||||
is-unicode-supported@2.1.0: {}
|
||||
|
||||
is-url@1.2.4: {}
|
||||
|
||||
is-weakmap@2.0.2: {}
|
||||
@@ -15434,6 +15810,8 @@ snapshots:
|
||||
leac: 0.6.0
|
||||
peberminta: 0.9.0
|
||||
|
||||
patch-console@2.0.0: {}
|
||||
|
||||
path-exists@4.0.0: {}
|
||||
|
||||
path-key@3.1.1: {}
|
||||
@@ -15952,8 +16330,18 @@ snapshots:
|
||||
dependencies:
|
||||
fast-deep-equal: 2.0.1
|
||||
|
||||
react-reconciler@0.29.2(react@18.3.1):
|
||||
dependencies:
|
||||
loose-envify: 1.4.0
|
||||
react: 18.3.1
|
||||
scheduler: 0.23.2
|
||||
|
||||
react-refresh@0.18.0: {}
|
||||
|
||||
react@18.3.1:
|
||||
dependencies:
|
||||
loose-envify: 1.4.0
|
||||
|
||||
react@19.2.4: {}
|
||||
|
||||
read-cache@1.0.0:
|
||||
@@ -16218,6 +16606,10 @@ snapshots:
|
||||
dependencies:
|
||||
xmlchars: 2.2.0
|
||||
|
||||
scheduler@0.23.2:
|
||||
dependencies:
|
||||
loose-envify: 1.4.0
|
||||
|
||||
scheduler@0.25.0-rc-603e6108-20241029: {}
|
||||
|
||||
scheduler@0.27.0: {}
|
||||
@@ -16392,6 +16784,11 @@ snapshots:
|
||||
|
||||
slash@3.0.0: {}
|
||||
|
||||
slice-ansi@5.0.0:
|
||||
dependencies:
|
||||
ansi-styles: 6.2.3
|
||||
is-fullwidth-code-point: 4.0.0
|
||||
|
||||
slice-ansi@7.1.2:
|
||||
dependencies:
|
||||
ansi-styles: 6.2.3
|
||||
@@ -16448,6 +16845,10 @@ snapshots:
|
||||
|
||||
stable@0.1.8: {}
|
||||
|
||||
stack-utils@2.0.6:
|
||||
dependencies:
|
||||
escape-string-regexp: 2.0.0
|
||||
|
||||
stackback@0.0.2: {}
|
||||
|
||||
stacktrace-parser@0.1.11:
|
||||
@@ -16790,6 +17191,8 @@ snapshots:
|
||||
dependencies:
|
||||
is-number: 7.0.0
|
||||
|
||||
to-rotated@1.0.0: {}
|
||||
|
||||
toad-cache@3.7.0: {}
|
||||
|
||||
token-types@6.1.2:
|
||||
@@ -17057,6 +17460,24 @@ snapshots:
|
||||
- supports-color
|
||||
- terser
|
||||
|
||||
vite-node@3.2.4(@types/node@20.19.33)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0):
|
||||
dependencies:
|
||||
cac: 6.7.14
|
||||
debug: 4.4.3
|
||||
es-module-lexer: 1.7.0
|
||||
pathe: 2.0.3
|
||||
vite: 5.4.21(@types/node@20.19.33)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0)
|
||||
transitivePeerDependencies:
|
||||
- '@types/node'
|
||||
- less
|
||||
- lightningcss
|
||||
- sass
|
||||
- sass-embedded
|
||||
- stylus
|
||||
- sugarss
|
||||
- supports-color
|
||||
- terser
|
||||
|
||||
vite-node@3.2.4(@types/node@22.19.10)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0):
|
||||
dependencies:
|
||||
cac: 6.7.14
|
||||
@@ -17087,6 +17508,18 @@ snapshots:
|
||||
sass: 1.97.3
|
||||
terser: 5.46.0
|
||||
|
||||
vite@5.4.21(@types/node@20.19.33)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0):
|
||||
dependencies:
|
||||
esbuild: 0.21.5
|
||||
postcss: 8.5.6
|
||||
rollup: 4.57.1
|
||||
optionalDependencies:
|
||||
'@types/node': 20.19.33
|
||||
fsevents: 2.3.3
|
||||
lightningcss: 1.30.2
|
||||
sass: 1.97.3
|
||||
terser: 5.46.0
|
||||
|
||||
vite@5.4.21(@types/node@22.19.10)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0):
|
||||
dependencies:
|
||||
esbuild: 0.21.5
|
||||
@@ -17155,6 +17588,48 @@ snapshots:
|
||||
- supports-color
|
||||
- terser
|
||||
|
||||
vitest@3.2.4(@types/debug@4.1.12)(@types/node@20.19.33)(@vitest/ui@4.0.18)(happy-dom@20.5.3)(jsdom@27.4.0(canvas@3.2.1))(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0):
|
||||
dependencies:
|
||||
'@types/chai': 5.2.3
|
||||
'@vitest/expect': 3.2.4
|
||||
'@vitest/mocker': 3.2.4(vite@5.4.21(@types/node@20.19.33)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0))
|
||||
'@vitest/pretty-format': 3.2.4
|
||||
'@vitest/runner': 3.2.4
|
||||
'@vitest/snapshot': 3.2.4
|
||||
'@vitest/spy': 3.2.4
|
||||
'@vitest/utils': 3.2.4
|
||||
chai: 5.3.3
|
||||
debug: 4.4.3
|
||||
expect-type: 1.3.0
|
||||
magic-string: 0.30.21
|
||||
pathe: 2.0.3
|
||||
picomatch: 4.0.3
|
||||
std-env: 3.10.0
|
||||
tinybench: 2.9.0
|
||||
tinyexec: 0.3.2
|
||||
tinyglobby: 0.2.15
|
||||
tinypool: 1.1.1
|
||||
tinyrainbow: 2.0.0
|
||||
vite: 5.4.21(@types/node@20.19.33)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0)
|
||||
vite-node: 3.2.4(@types/node@20.19.33)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0)
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/debug': 4.1.12
|
||||
'@types/node': 20.19.33
|
||||
'@vitest/ui': 4.0.18(vitest@4.0.18)
|
||||
happy-dom: 20.5.3
|
||||
jsdom: 27.4.0(canvas@3.2.1)
|
||||
transitivePeerDependencies:
|
||||
- less
|
||||
- lightningcss
|
||||
- msw
|
||||
- sass
|
||||
- sass-embedded
|
||||
- stylus
|
||||
- sugarss
|
||||
- supports-color
|
||||
- terser
|
||||
|
||||
vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.10)(@vitest/ui@4.0.18)(happy-dom@20.5.3)(jsdom@27.4.0(canvas@3.2.1))(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0):
|
||||
dependencies:
|
||||
'@types/chai': 5.2.3
|
||||
@@ -17430,6 +17905,10 @@ snapshots:
|
||||
siginfo: 2.0.0
|
||||
stackback: 0.0.2
|
||||
|
||||
widest-line@5.0.0:
|
||||
dependencies:
|
||||
string-width: 7.2.0
|
||||
|
||||
word-wrap@1.2.5: {}
|
||||
|
||||
wrap-ansi@6.2.0:
|
||||
@@ -17510,12 +17989,10 @@ snapshots:
|
||||
|
||||
zhead@2.2.4: {}
|
||||
|
||||
zod-validation-error@4.0.2(zod@4.3.6):
|
||||
zod-validation-error@4.0.2(zod@3.25.76):
|
||||
dependencies:
|
||||
zod: 4.3.6
|
||||
zod: 3.25.76
|
||||
|
||||
zod@3.22.4: {}
|
||||
|
||||
zod@3.25.76: {}
|
||||
|
||||
zod@4.3.6: {}
|
||||
|
||||
6
tmp-patch-v3.sh
Normal file
6
tmp-patch-v3.sh
Normal file
@@ -0,0 +1,6 @@
|
||||
#!/bin/bash
|
||||
sed -i 's/^TRAEFIK_HOST_RULE=.*/TRAEFIK_HOST_RULE=Host(`testing.klz-cables.com`)/g' /home/deploy/sites/testing.klz-cables.com/.env
|
||||
cd /home/deploy/sites/testing.klz-cables.com && docker compose --env-file .env up -d
|
||||
|
||||
sed -i 's/^TRAEFIK_RULE=.*/TRAEFIK_RULE=Host(`testing.mb-grid-solutions.com`)/g' /home/deploy/sites/mb-grid-solutions.com/.env.testing
|
||||
cd /home/deploy/sites/mb-grid-solutions.com && docker compose --env-file .env.testing -p mb-grid-solutions-testing up -d
|
||||
10
tmp-patch.sh
Normal file
10
tmp-patch.sh
Normal file
@@ -0,0 +1,10 @@
|
||||
#!/bin/bash
|
||||
sed -i 's/traefik:v2.11/traefik:v3.3/g' /opt/alpha/traefik/docker-compose.yml
|
||||
cd /opt/alpha/traefik && docker compose up -d
|
||||
|
||||
sed -i 's/^GATEKEEPER_HOST.*/GATEKEEPER_HOST=testing.klz-cables.com/g' /home/deploy/sites/testing.klz-cables.com/.env
|
||||
cd /home/deploy/sites/testing.klz-cables.com && docker compose --env-file .env up -d
|
||||
|
||||
sed -i 's/^TRAEFIK_RULE=.*/TRAEFIK_RULE=Host(`testing.mb-grid-solutions.com`) \&\& !PathPrefix(`\/gatekeeper`)/g' /home/deploy/sites/mb-grid-solutions.com/.env.testing
|
||||
sed -i 's/^TRAEFIK_HOST=.*/TRAEFIK_HOST=testing.mb-grid-solutions.com/g' /home/deploy/sites/mb-grid-solutions.com/.env.testing
|
||||
cd /home/deploy/sites/mb-grid-solutions.com && docker compose --env-file .env.testing -p mb-grid-solutions-testing up -d
|
||||
Reference in New Issue
Block a user