Compare commits
14 Commits
v1.8.10
...
b3d089ac6d
| Author | SHA1 | Date | |
|---|---|---|---|
| b3d089ac6d | |||
| baecc9c83c | |||
| d5632b009a | |||
| 90a9e34c7e | |||
| 99f040cfb0 | |||
| 02bffbc67f | |||
| f4507ef121 | |||
| 3a1a88db89 | |||
| a9adb2eff7 | |||
| a50b8d6393 | |||
| 3f1c37813a | |||
| 8f32c80801 | |||
| 67750c886e | |||
| 9fe9a74e71 |
@@ -10,3 +10,5 @@ coverage
|
||||
.turbo
|
||||
*.log
|
||||
.DS_Store
|
||||
.pnpm-store
|
||||
.gitea
|
||||
|
||||
1
.env
1
.env
@@ -3,6 +3,7 @@ IMAGE_TAG=v1.8.10
|
||||
PROJECT_NAME=at-mintel
|
||||
PROJECT_COLOR=#82ed20
|
||||
GITEA_TOKEN=ccce002e30fe16a31a6c9d5a414740af2f72a582
|
||||
OPENROUTER_API_KEY=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
|
||||
|
||||
# Authentication
|
||||
GATEKEEPER_PASSWORD=mintel
|
||||
|
||||
@@ -24,8 +24,8 @@ jobs:
|
||||
|
||||
# Run the prune script on the host
|
||||
# We transfer the script and execute it to ensure it matches the repo version
|
||||
scp packages/infra/scripts/prune-registry.sh root@${{ secrets.SSH_HOST }}:/tmp/prune-registry.sh
|
||||
ssh root@${{ secrets.SSH_HOST }} "bash /tmp/prune-registry.sh && rm /tmp/prune-registry.sh"
|
||||
scp packages/infra/scripts/mintel-optimizer.sh root@${{ secrets.SSH_HOST }}:/tmp/mintel-optimizer.sh
|
||||
ssh root@${{ secrets.SSH_HOST }} "bash /tmp/mintel-optimizer.sh && rm /tmp/mintel-optimizer.sh"
|
||||
|
||||
- name: 🔔 Notification - Success
|
||||
if: success()
|
||||
|
||||
@@ -213,12 +213,11 @@ jobs:
|
||||
file: ${{ matrix.file }}
|
||||
platforms: linux/arm64
|
||||
pull: true
|
||||
provenance: false
|
||||
push: true
|
||||
secrets: |
|
||||
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
||||
tags: |
|
||||
registry.infra.mintel.me/mintel/${{ matrix.image }}:${{ github.ref_name }}
|
||||
registry.infra.mintel.me/mintel/${{ matrix.image }}:latest
|
||||
cache-from: type=registry,ref=registry.infra.mintel.me/mintel/${{ matrix.image }}:buildcache
|
||||
cache-to: type=registry,ref=registry.infra.mintel.me/mintel/${{ matrix.image }}:buildcache,mode=max
|
||||
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -37,3 +37,8 @@ Thumbs.db
|
||||
|
||||
# Changesets
|
||||
.changeset/*.lock
|
||||
directus/extensions/
|
||||
packages/cms-infra/extensions/
|
||||
packages/cms-infra/uploads/
|
||||
|
||||
directus/uploads/directus-health-file
|
||||
@@ -18,6 +18,7 @@
|
||||
"@mintel/next-utils": "workspace:*",
|
||||
"@mintel/observability": "workspace:*",
|
||||
"@mintel/next-observability": "workspace:*",
|
||||
"@mintel/image-processor": "workspace:*",
|
||||
"@sentry/nextjs": "10.38.0",
|
||||
"next": "16.1.6",
|
||||
"next-intl": "^4.8.2",
|
||||
@@ -33,4 +34,4 @@
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"typescript": "^5.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
45
apps/sample-website/src/app/api/image/route.ts
Normal file
45
apps/sample-website/src/app/api/image/route.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { processImageWithSmartCrop } from '@mintel/image-processor';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const url = searchParams.get('url');
|
||||
let width = parseInt(searchParams.get('w') || '800');
|
||||
let height = parseInt(searchParams.get('h') || '600');
|
||||
let q = parseInt(searchParams.get('q') || '80');
|
||||
|
||||
if (!url) {
|
||||
return NextResponse.json({ error: 'Missing url parameter' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Fetch image from original URL
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
return NextResponse.json({ error: 'Failed to fetch original image' }, { status: response.status });
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
// 2. Process image with Face-API and Sharp
|
||||
const processedBuffer = await processImageWithSmartCrop(buffer, {
|
||||
width,
|
||||
height,
|
||||
format: 'webp',
|
||||
quality: q,
|
||||
});
|
||||
|
||||
// 3. Return the processed image
|
||||
return new NextResponse(new Uint8Array(processedBuffer), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'image/webp',
|
||||
'Cache-Control': 'public, max-age=31536000, immutable',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Image Processing Error:', error);
|
||||
return NextResponse.json({ error: 'Failed to process image' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"name": "acquisition-manager",
|
||||
"description": "Custom High-Fidelity Management for Directus",
|
||||
"icon": "extension",
|
||||
"version": "1.8.2",
|
||||
"type": "module",
|
||||
"keywords": [
|
||||
"directus",
|
||||
"directus-extension",
|
||||
"directus-extension-module"
|
||||
],
|
||||
"directus:extension": {
|
||||
"type": "module",
|
||||
"path": "index.js",
|
||||
"source": "src/index.ts",
|
||||
"host": "app",
|
||||
"name": "acquisition manager"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "directus-extension build",
|
||||
"dev": "directus-extension build -w"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@directus/extensions-sdk": "11.0.2",
|
||||
"@mintel/directus-extension-toolkit": "workspace:*",
|
||||
"vue": "^3.4.0"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"name": "acquisition",
|
||||
"version": "1.8.2",
|
||||
"type": "module",
|
||||
"directus:extension": {
|
||||
"type": "endpoint",
|
||||
"path": "index.js",
|
||||
"source": "src/index.ts",
|
||||
"host": "^11.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "node build.mjs",
|
||||
"dev": "node build.mjs --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@directus/extensions-sdk": "11.0.2",
|
||||
"@mintel/pdf": "workspace:*",
|
||||
"@mintel/mail": "workspace:*",
|
||||
"esbuild": "^0.25.0",
|
||||
"typescript": "^5.6.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"jquery": "^3.7.1",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"name": "company-manager",
|
||||
"description": "Custom High-Fidelity Management for Directus",
|
||||
"icon": "extension",
|
||||
"version": "1.8.2",
|
||||
"type": "module",
|
||||
"keywords": [
|
||||
"directus",
|
||||
"directus-extension",
|
||||
"directus-extension-module"
|
||||
],
|
||||
"directus:extension": {
|
||||
"type": "module",
|
||||
"path": "index.js",
|
||||
"source": "src/index.ts",
|
||||
"host": "app",
|
||||
"name": "company manager"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "directus-extension build",
|
||||
"dev": "directus-extension build -w"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@directus/extensions-sdk": "11.0.2",
|
||||
"@mintel/directus-extension-toolkit": "workspace:*",
|
||||
"vue": "^3.4.0"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"name": "customer-manager",
|
||||
"description": "Custom High-Fidelity Management for Directus",
|
||||
"icon": "extension",
|
||||
"version": "1.8.2",
|
||||
"type": "module",
|
||||
"keywords": [
|
||||
"directus",
|
||||
"directus-extension",
|
||||
"directus-extension-module"
|
||||
],
|
||||
"directus:extension": {
|
||||
"type": "module",
|
||||
"path": "index.js",
|
||||
"source": "src/index.ts",
|
||||
"host": "app",
|
||||
"name": "customer manager"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "directus-extension build",
|
||||
"dev": "directus-extension build -w"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@directus/extensions-sdk": "11.0.2",
|
||||
"@mintel/directus-extension-toolkit": "workspace:*",
|
||||
"vue": "^3.4.0"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"name": "feedback-commander",
|
||||
"description": "Custom High-Fidelity Management for Directus",
|
||||
"icon": "extension",
|
||||
"version": "1.8.2",
|
||||
"type": "module",
|
||||
"keywords": [
|
||||
"directus",
|
||||
"directus-extension",
|
||||
"directus-extension-module"
|
||||
],
|
||||
"directus:extension": {
|
||||
"type": "module",
|
||||
"path": "index.js",
|
||||
"source": "src/index.ts",
|
||||
"host": "app",
|
||||
"name": "feedback commander"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "directus-extension build",
|
||||
"dev": "directus-extension build -w"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@directus/extensions-sdk": "11.0.2",
|
||||
"vue": "^3.4.0"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"name": "people-manager",
|
||||
"description": "Custom High-Fidelity Management for Directus",
|
||||
"icon": "extension",
|
||||
"version": "1.8.2",
|
||||
"type": "module",
|
||||
"keywords": [
|
||||
"directus",
|
||||
"directus-extension",
|
||||
"directus-extension-module"
|
||||
],
|
||||
"directus:extension": {
|
||||
"type": "module",
|
||||
"path": "index.js",
|
||||
"source": "src/index.ts",
|
||||
"host": "app",
|
||||
"name": "people manager"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "directus-extension build",
|
||||
"dev": "directus-extension build -w"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@directus/extensions-sdk": "11.0.2",
|
||||
"@mintel/directus-extension-toolkit": "workspace:*",
|
||||
"vue": "^3.4.0"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"name": "unified-dashboard",
|
||||
"description": "Custom High-Fidelity Management for Directus",
|
||||
"icon": "extension",
|
||||
"version": "1.8.2",
|
||||
"type": "module",
|
||||
"keywords": [
|
||||
"directus",
|
||||
"directus-extension",
|
||||
"directus-extension-module"
|
||||
],
|
||||
"directus:extension": {
|
||||
"type": "module",
|
||||
"path": "index.js",
|
||||
"source": "src/index.ts",
|
||||
"host": "app",
|
||||
"name": "unified dashboard"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "directus-extension build",
|
||||
"dev": "directus-extension build -w"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@directus/extensions-sdk": "11.0.2",
|
||||
"vue": "^3.4.0"
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,8 @@ services:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.sample-website.rule=Host(`${TRAEFIK_HOST:-sample-website.localhost}`)"
|
||||
- "traefik.http.services.sample-website.loadbalancer.server.port=3000"
|
||||
- "caddy=http://${TRAEFIK_HOST:-acquisition.localhost}"
|
||||
- "caddy.reverse_proxy={{upstreams 3000}}"
|
||||
|
||||
directus:
|
||||
image: registry.infra.mintel.me/mintel/directus:${IMAGE_TAG:-latest}
|
||||
@@ -58,6 +60,8 @@ services:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.sample-website-directus.rule=Host(`${DIRECTUS_HOST:-cms.sample-website.localhost}`)"
|
||||
- "traefik.http.services.sample-website-directus.loadbalancer.server.port=8055"
|
||||
- "caddy=http://${DIRECTUS_HOST:-cms.at.localhost}"
|
||||
- "caddy.reverse_proxy={{upstreams 8055}}"
|
||||
|
||||
at-mintel-directus-db:
|
||||
image: postgres:15-alpine
|
||||
|
||||
1
models/tiny_face_detector_model-shard1
Normal file
1
models/tiny_face_detector_model-shard1
Normal file
@@ -0,0 +1 @@
|
||||
404: Not Found
|
||||
30
models/tiny_face_detector_model-weights_manifest.json
Normal file
30
models/tiny_face_detector_model-weights_manifest.json
Normal file
@@ -0,0 +1,30 @@
|
||||
[
|
||||
{
|
||||
"weights":
|
||||
[
|
||||
{"name":"conv0/filters","shape":[3,3,3,16],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.009007044399485869,"min":-1.2069439495311063}},
|
||||
{"name":"conv0/bias","shape":[16],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.005263455241334205,"min":-0.9211046672334858}},
|
||||
{"name":"conv1/depthwise_filter","shape":[3,3,16,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.004001977630690033,"min":-0.5042491814669441}},
|
||||
{"name":"conv1/pointwise_filter","shape":[1,1,16,32],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.013836609615999109,"min":-1.411334180831909}},
|
||||
{"name":"conv1/bias","shape":[32],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.0015159862590771096,"min":-0.30926119685173037}},
|
||||
{"name":"conv2/depthwise_filter","shape":[3,3,32,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.002666276225856706,"min":-0.317286870876948}},
|
||||
{"name":"conv2/pointwise_filter","shape":[1,1,32,64],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.015265831292844286,"min":-1.6792414422128714}},
|
||||
{"name":"conv2/bias","shape":[64],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.0020280554598453,"min":-0.37113414915168985}},
|
||||
{"name":"conv3/depthwise_filter","shape":[3,3,64,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.006100742489683862,"min":-0.8907084034938438}},
|
||||
{"name":"conv3/pointwise_filter","shape":[1,1,64,128],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.016276211832083907,"min":-2.0508026908425725}},
|
||||
{"name":"conv3/bias","shape":[128],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.003394414279975143,"min":-0.7637432129944072}},
|
||||
{"name":"conv4/depthwise_filter","shape":[3,3,128,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.006716050119961009,"min":-0.8059260143953211}},
|
||||
{"name":"conv4/pointwise_filter","shape":[1,1,128,256],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.021875603993733724,"min":-2.8875797271728514}},
|
||||
{"name":"conv4/bias","shape":[256],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.0041141652009066415,"min":-0.8187188749804216}},
|
||||
{"name":"conv5/depthwise_filter","shape":[3,3,256,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.008423839597141042,"min":-0.9013508368940915}},
|
||||
{"name":"conv5/pointwise_filter","shape":[1,1,256,512],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.030007277283014035,"min":-3.8709387695088107}},
|
||||
{"name":"conv5/bias","shape":[512],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.008402082966823203,"min":-1.4871686851277068}},
|
||||
{"name":"conv8/filters","shape":[1,1,512,25],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.028336129469030042,"min":-4.675461362389957}},
|
||||
{"name":"conv8/bias","shape":[25],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.002268134028303857,"min":-0.41053225912299807}}
|
||||
],
|
||||
"paths":
|
||||
[
|
||||
"tiny_face_detector_model.bin"
|
||||
]
|
||||
}
|
||||
]
|
||||
15
package.json
15
package.json
@@ -10,15 +10,16 @@
|
||||
"changeset": "changeset",
|
||||
"version-packages": "changeset version",
|
||||
"sync-versions": "tsx scripts/sync-versions.ts --",
|
||||
"cms:push:infra": "./scripts/sync-directus.sh push infra",
|
||||
"cms:pull:infra": "./scripts/sync-directus.sh pull infra",
|
||||
"cms:dev": "pnpm --filter @mintel/cms-infra dev",
|
||||
"cms:up": "pnpm --filter @mintel/cms-infra up",
|
||||
"cms:down": "pnpm --filter @mintel/cms-infra down",
|
||||
"cms:logs": "pnpm --filter @mintel/cms-infra logs",
|
||||
"cms:schema:snapshot": "./scripts/cms-snapshot.sh",
|
||||
"cms:schema:apply": "./scripts/cms-apply.sh local",
|
||||
"cms:schema:apply:infra": "./scripts/cms-apply.sh infra",
|
||||
"cms:up": "cd packages/cms-infra && npm run up -- --force-recreate",
|
||||
"cms:down": "cd packages/cms-infra && npm run down",
|
||||
"cms:logs": "cd packages/cms-infra && npm run logs",
|
||||
"dev:infra": "docker-compose up -d directus directus-db",
|
||||
"cms:sync:push": "./scripts/sync-directus.sh push infra",
|
||||
"cms:sync:pull": "./scripts/sync-directus.sh pull infra",
|
||||
"build:extensions": "./scripts/sync-extensions.sh",
|
||||
"release": "pnpm build && changeset publish",
|
||||
"release:tag": "pnpm build && pnpm -r publish --no-git-checks --access public",
|
||||
"prepare": "husky"
|
||||
@@ -63,4 +64,4 @@
|
||||
"@sentry/nextjs": "10.38.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,9 +20,11 @@
|
||||
"build": "directus-extension build",
|
||||
"dev": "directus-extension build -w"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mintel/directus-extension-toolkit": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@directus/extensions-sdk": "11.0.2",
|
||||
"@mintel/directus-extension-toolkit": "workspace:*",
|
||||
"vue": "^3.4.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,15 @@
|
||||
<v-button v-if="selectedLead?.audit_pdf_path" secondary icon v-tooltip.bottom="'PDF öffnen'" @click="openPdf">
|
||||
<v-icon name="open_in_new" />
|
||||
</v-button>
|
||||
|
||||
<v-button
|
||||
v-if="selectedLead && !isCustomer(selectedLead.company)"
|
||||
secondary
|
||||
@click="linkAsCustomer"
|
||||
>
|
||||
<v-icon name="handshake" left />
|
||||
Kunde verlinken
|
||||
</v-button>
|
||||
|
||||
<v-button
|
||||
v-if="selectedLead?.audit_pdf_path"
|
||||
@@ -182,9 +191,12 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { useApi } from '@directus/extensions-sdk';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { MintelManagerLayout, MintelSelect, MintelStatCard } from '@mintel/directus-extension-toolkit';
|
||||
|
||||
const api = useApi();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const leads = ref<any[]>([]);
|
||||
const selectedLeadId = ref<string | null>(null);
|
||||
const loadingAudit = ref(false);
|
||||
@@ -204,6 +216,7 @@ const newLead = ref({
|
||||
|
||||
const companies = ref<any[]>([]);
|
||||
const people = ref<any[]>([]);
|
||||
const customers = ref<any[]>([]);
|
||||
|
||||
const companyOptions = computed(() =>
|
||||
companies.value.map(c => ({
|
||||
@@ -242,7 +255,7 @@ const selectedLead = computed(() => leads.value.find(l => l.id === selectedLeadI
|
||||
|
||||
async function fetchData() {
|
||||
try {
|
||||
const [leadsResp, peopleResp, companiesResp] = await Promise.all([
|
||||
const [leadsResp, peopleResp, companiesResp, customersResp] = await Promise.all([
|
||||
api.get('/items/leads', {
|
||||
params: {
|
||||
sort: '-date_created',
|
||||
@@ -250,11 +263,13 @@ async function fetchData() {
|
||||
}
|
||||
}),
|
||||
api.get('/items/people', { params: { sort: 'last_name' } }),
|
||||
api.get('/items/companies', { params: { sort: 'name' } })
|
||||
api.get('/items/companies', { params: { sort: 'name' } }),
|
||||
api.get('/items/customers', { params: { fields: ['company'] } })
|
||||
]);
|
||||
leads.value = leadsResp.data.data;
|
||||
people.value = peopleResp.data.data;
|
||||
companies.value = companiesResp.data.data;
|
||||
customers.value = customersResp.data.data;
|
||||
|
||||
if (!selectedLeadId.value && leads.value.length > 0) {
|
||||
selectedLeadId.value = leads.value[0].id;
|
||||
@@ -264,6 +279,33 @@ async function fetchData() {
|
||||
}
|
||||
}
|
||||
|
||||
function isCustomer(companyId: string | any) {
|
||||
if (!companyId) return false;
|
||||
const id = typeof companyId === 'object' ? companyId.id : companyId;
|
||||
return customers.value.some(c => (typeof c.company === 'object' ? c.company.id : c.company) === id);
|
||||
}
|
||||
|
||||
async function linkAsCustomer() {
|
||||
if (!selectedLead.value) return;
|
||||
|
||||
const companyId = selectedLead.value.company
|
||||
? (typeof selectedLead.value.company === 'object' ? selectedLead.value.company.id : selectedLead.value.company)
|
||||
: null;
|
||||
|
||||
const personId = selectedLead.value.contact_person
|
||||
? (typeof selectedLead.value.contact_person === 'object' ? selectedLead.value.contact_person.id : selectedLead.value.contact_person)
|
||||
: null;
|
||||
|
||||
router.push({
|
||||
name: 'module-customer-manager',
|
||||
query: {
|
||||
create: 'true',
|
||||
company: companyId,
|
||||
contact_person: personId
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchLeads() {
|
||||
await fetchData();
|
||||
}
|
||||
@@ -391,7 +433,12 @@ function getStatusColor(status: string) {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchData);
|
||||
onMounted(async () => {
|
||||
await fetchData();
|
||||
if (route.query.create === 'true') {
|
||||
openCreateDrawer();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -11,7 +11,9 @@ const outfile = resolve(__dirname, 'dist/index.js');
|
||||
|
||||
try {
|
||||
mkdirSync(dirname(outfile), { recursive: true });
|
||||
} catch (e) { }
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
console.log(`Building from ${entryPoint} to ${outfile}...`);
|
||||
|
||||
@@ -22,12 +24,16 @@ build({
|
||||
target: 'node18',
|
||||
outfile: outfile,
|
||||
jsx: 'automatic',
|
||||
format: 'esm',
|
||||
// footer: {
|
||||
// js: "module.exports = module.exports.default || module.exports;",
|
||||
// },
|
||||
loader: {
|
||||
'.tsx': 'tsx',
|
||||
'.ts': 'ts',
|
||||
'.js': 'js',
|
||||
},
|
||||
external: ["@react-pdf/renderer", "react", "react-dom", "jsdom", "jsdom/*", "jquery", "jquery/*", "canvas", "fs", "path", "os", "http", "https", "zlib", "stream", "util", "url", "net", "tls", "crypto"],
|
||||
external: ["canvas", "fs", "path", "os", "http", "https", "zlib", "stream", "util", "url", "net", "tls", "crypto"],
|
||||
plugins: [{
|
||||
name: 'mock-canvas',
|
||||
setup(build) {
|
||||
|
||||
@@ -24,4 +24,4 @@
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,176 +1,236 @@
|
||||
import { defineEndpoint } from "@directus/extensions-sdk";
|
||||
import { AcquisitionService, PdfEngine } from "@mintel/pdf/server";
|
||||
import { render, SiteAuditTemplate, ProjectEstimateTemplate } from "@mintel/mail";
|
||||
import {
|
||||
render,
|
||||
SiteAuditTemplate,
|
||||
ProjectEstimateTemplate,
|
||||
} from "@mintel/mail";
|
||||
import { createElement } from "react";
|
||||
import * as path from "path";
|
||||
import * as fs from "fs";
|
||||
|
||||
export default defineEndpoint((router, { services, env }) => {
|
||||
const { ItemsService, MailService } = services;
|
||||
|
||||
router.get("/ping", (req, res) => res.send("pong"));
|
||||
|
||||
router.post("/audit/:id", async (req: any, res: any) => {
|
||||
const { id } = req.params;
|
||||
const leadsService = new ItemsService("leads", {
|
||||
schema: req.schema,
|
||||
accountability: req.accountability,
|
||||
});
|
||||
|
||||
try {
|
||||
const lead = await leadsService.readOne(id);
|
||||
if (!lead) return res.status(404).send({ error: "Lead not found" });
|
||||
|
||||
await leadsService.updateOne(id, { status: "auditing" });
|
||||
|
||||
const acqService = new AcquisitionService(env.OPENROUTER_API_KEY);
|
||||
const result = await acqService.runFullSequence(
|
||||
lead.website_url,
|
||||
lead.briefing,
|
||||
lead.comments,
|
||||
);
|
||||
|
||||
await leadsService.updateOne(id, {
|
||||
status: "audit_ready",
|
||||
ai_state: result.state,
|
||||
audit_context: JSON.stringify(result.usage),
|
||||
});
|
||||
|
||||
res.send({ success: true, result });
|
||||
} catch (error: any) {
|
||||
console.error("Audit failed:", error);
|
||||
await leadsService.updateOne(id, {
|
||||
status: "new",
|
||||
comments: `Audit failed: ${error.message}`,
|
||||
});
|
||||
res.status(500).send({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/audit-email/:id", async (req: any, res: any) => {
|
||||
const { id } = req.params;
|
||||
const { ItemsService, MailService } = services;
|
||||
|
||||
router.get("/ping", (req, res) => res.send("pong"));
|
||||
|
||||
router.post("/audit/:id", async (req: any, res: any) => {
|
||||
const { id } = req.params;
|
||||
const leadsService = new ItemsService("leads", { schema: req.schema, accountability: req.accountability });
|
||||
|
||||
try {
|
||||
const lead = await leadsService.readOne(id);
|
||||
if (!lead) return res.status(404).send({ error: "Lead not found" });
|
||||
|
||||
await leadsService.updateOne(id, { status: "auditing" });
|
||||
|
||||
const acqService = new AcquisitionService(env.OPENROUTER_API_KEY);
|
||||
const result = await acqService.runFullSequence(lead.website_url, lead.briefing, lead.comments);
|
||||
|
||||
await leadsService.updateOne(id, {
|
||||
status: "audit_ready",
|
||||
ai_state: result.state,
|
||||
audit_context: JSON.stringify(result.usage),
|
||||
});
|
||||
|
||||
res.send({ success: true, result });
|
||||
} catch (error: any) {
|
||||
console.error("Audit failed:", error);
|
||||
await leadsService.updateOne(id, { status: "new", comments: `Audit failed: ${error.message}` });
|
||||
res.status(500).send({ error: error.message });
|
||||
}
|
||||
const leadsService = new ItemsService("leads", {
|
||||
schema: req.schema,
|
||||
accountability: req.accountability,
|
||||
});
|
||||
const _peopleService = new ItemsService("people", {
|
||||
schema: req.schema,
|
||||
accountability: req.accountability,
|
||||
});
|
||||
const companiesService = new ItemsService("companies", {
|
||||
schema: req.schema,
|
||||
accountability: req.accountability,
|
||||
});
|
||||
const mailService = new MailService({
|
||||
schema: req.schema,
|
||||
accountability: req.accountability,
|
||||
});
|
||||
|
||||
router.post("/audit-email/:id", async (req: any, res: any) => {
|
||||
const { id } = req.params;
|
||||
const { ItemsService, MailService } = services;
|
||||
const leadsService = new ItemsService("leads", { schema: req.schema, accountability: req.accountability });
|
||||
const peopleService = new ItemsService("people", { schema: req.schema, accountability: req.accountability });
|
||||
const companiesService = new ItemsService("companies", { schema: req.schema, accountability: req.accountability });
|
||||
const mailService = new MailService({ schema: req.schema, accountability: req.accountability });
|
||||
try {
|
||||
const lead = await leadsService.readOne(id, {
|
||||
fields: ["*", "company.*", "contact_person.*"],
|
||||
});
|
||||
if (!lead || !lead.ai_state)
|
||||
return res.status(400).send({ error: "Lead or Audit not ready" });
|
||||
|
||||
try {
|
||||
const lead = await leadsService.readOne(id, { fields: ["*", "company.*", "contact_person.*"] });
|
||||
if (!lead || !lead.ai_state) return res.status(400).send({ error: "Lead or Audit not ready" });
|
||||
let recipientEmail = lead.contact_email;
|
||||
let companyName = lead.company?.name || lead.company_name;
|
||||
|
||||
let recipientEmail = lead.contact_email;
|
||||
let companyName = lead.company?.name || lead.company_name;
|
||||
if (lead.contact_person) {
|
||||
recipientEmail = lead.contact_person.email || recipientEmail;
|
||||
|
||||
if (lead.contact_person) {
|
||||
recipientEmail = lead.contact_person.email || recipientEmail;
|
||||
|
||||
if (lead.contact_person.company) {
|
||||
const personCompany = await companiesService.readOne(lead.contact_person.company);
|
||||
companyName = personCompany?.name || companyName;
|
||||
}
|
||||
}
|
||||
|
||||
if (!recipientEmail) return res.status(400).send({ error: "No recipient email found" });
|
||||
|
||||
const auditHighlights = [
|
||||
`Projekt-Typ: ${lead.ai_state.projectType === "website" ? "Website" : "Web App"}`,
|
||||
...(lead.ai_state.sitemap || []).slice(0, 3).map((item: any) => `Potenzial in: ${item.category}`),
|
||||
];
|
||||
|
||||
const html = await render(createElement(SiteAuditTemplate, {
|
||||
companyName: companyName,
|
||||
websiteUrl: lead.website_url,
|
||||
auditHighlights
|
||||
}));
|
||||
|
||||
await mailService.send({
|
||||
to: recipientEmail,
|
||||
subject: `Analyse Ihrer Webpräsenz: ${companyName}`,
|
||||
html
|
||||
});
|
||||
|
||||
await leadsService.updateOne(id, {
|
||||
status: "contacted",
|
||||
last_contacted_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
res.send({ success: true });
|
||||
} catch (error: any) {
|
||||
console.error("Audit Email failed:", error);
|
||||
res.status(500).send({ error: error.message });
|
||||
if (lead.contact_person.company) {
|
||||
const personCompany = await companiesService.readOne(
|
||||
lead.contact_person.company,
|
||||
);
|
||||
companyName = personCompany?.name || companyName;
|
||||
}
|
||||
}
|
||||
|
||||
if (!recipientEmail)
|
||||
return res.status(400).send({ error: "No recipient email found" });
|
||||
|
||||
const auditHighlights = [
|
||||
`Projekt-Typ: ${lead.ai_state.projectType === "website" ? "Website" : "Web App"}`,
|
||||
...(lead.ai_state.sitemap || [])
|
||||
.slice(0, 3)
|
||||
.map((item: any) => `Potenzial in: ${item.category}`),
|
||||
];
|
||||
|
||||
const html = await render(
|
||||
createElement(SiteAuditTemplate, {
|
||||
companyName: companyName,
|
||||
websiteUrl: lead.website_url,
|
||||
auditHighlights,
|
||||
}),
|
||||
);
|
||||
|
||||
await mailService.send({
|
||||
to: recipientEmail,
|
||||
subject: `Analyse Ihrer Webpräsenz: ${companyName}`,
|
||||
html,
|
||||
});
|
||||
|
||||
await leadsService.updateOne(id, {
|
||||
status: "contacted",
|
||||
last_contacted_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
res.send({ success: true });
|
||||
} catch (error: any) {
|
||||
console.error("Audit Email failed:", error);
|
||||
res.status(500).send({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/estimate/:id", async (req: any, res: any) => {
|
||||
const { id } = req.params;
|
||||
const leadsService = new ItemsService("leads", {
|
||||
schema: req.schema,
|
||||
accountability: req.accountability,
|
||||
});
|
||||
|
||||
router.post("/estimate/:id", async (req: any, res: any) => {
|
||||
const { id } = req.params;
|
||||
const leadsService = new ItemsService("leads", { schema: req.schema, accountability: req.accountability });
|
||||
try {
|
||||
const lead = await leadsService.readOne(id);
|
||||
if (!lead || !lead.ai_state)
|
||||
return res.status(400).send({ error: "Lead or AI state not found" });
|
||||
|
||||
try {
|
||||
const lead = await leadsService.readOne(id);
|
||||
if (!lead || !lead.ai_state) return res.status(400).send({ error: "Lead or AI state not found" });
|
||||
const pdfEngine = new PdfEngine();
|
||||
const filename = `estimate_${id}_${Date.now()}.pdf`;
|
||||
const storageRoot = env.STORAGE_LOCAL_ROOT || "./storage";
|
||||
const outputPath = path.join(storageRoot, filename);
|
||||
|
||||
const pdfEngine = new PdfEngine();
|
||||
const filename = `estimate_${id}_${Date.now()}.pdf`;
|
||||
const storageRoot = env.STORAGE_LOCAL_ROOT || "./storage";
|
||||
const outputPath = path.join(storageRoot, filename);
|
||||
await pdfEngine.generateEstimatePdf(lead.ai_state, outputPath);
|
||||
|
||||
await pdfEngine.generateEstimatePdf(lead.ai_state, outputPath);
|
||||
await leadsService.updateOne(id, {
|
||||
audit_pdf_path: filename,
|
||||
});
|
||||
|
||||
await leadsService.updateOne(id, {
|
||||
audit_pdf_path: filename,
|
||||
});
|
||||
res.send({ success: true, filename });
|
||||
} catch (error: any) {
|
||||
console.error("PDF Generation failed:", error);
|
||||
res.status(500).send({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
res.send({ success: true, filename });
|
||||
} catch (error: any) {
|
||||
console.error("PDF Generation failed:", error);
|
||||
res.status(500).send({ error: error.message });
|
||||
}
|
||||
router.post("/estimate-email/:id", async (req: any, res: any) => {
|
||||
const { id } = req.params;
|
||||
const leadsService = new ItemsService("leads", {
|
||||
schema: req.schema,
|
||||
accountability: req.accountability,
|
||||
});
|
||||
const _peopleService = new ItemsService("people", {
|
||||
schema: req.schema,
|
||||
accountability: req.accountability,
|
||||
});
|
||||
const companiesService = new ItemsService("companies", {
|
||||
schema: req.schema,
|
||||
accountability: req.accountability,
|
||||
});
|
||||
const mailService = new MailService({
|
||||
schema: req.schema,
|
||||
accountability: req.accountability,
|
||||
});
|
||||
|
||||
router.post("/estimate-email/:id", async (req: any, res: any) => {
|
||||
const { id } = req.params;
|
||||
const leadsService = new ItemsService("leads", { schema: req.schema, accountability: req.accountability });
|
||||
const peopleService = new ItemsService("people", { schema: req.schema, accountability: req.accountability });
|
||||
const companiesService = new ItemsService("companies", { schema: req.schema, accountability: req.accountability });
|
||||
const mailService = new MailService({ schema: req.schema, accountability: req.accountability });
|
||||
try {
|
||||
const lead = await leadsService.readOne(id, {
|
||||
fields: ["*", "company.*", "contact_person.*"],
|
||||
});
|
||||
if (!lead || !lead.audit_pdf_path)
|
||||
return res.status(400).send({ error: "PDF not generated" });
|
||||
|
||||
try {
|
||||
const lead = await leadsService.readOne(id, { fields: ["*", "company.*", "contact_person.*"] });
|
||||
if (!lead || !lead.audit_pdf_path) return res.status(400).send({ error: "PDF not generated" });
|
||||
let recipientEmail = lead.contact_email;
|
||||
let companyName = lead.company?.name || lead.company_name;
|
||||
|
||||
let recipientEmail = lead.contact_email;
|
||||
let companyName = lead.company?.name || lead.company_name;
|
||||
if (lead.contact_person) {
|
||||
recipientEmail = lead.contact_person.email || recipientEmail;
|
||||
|
||||
if (lead.contact_person) {
|
||||
recipientEmail = lead.contact_person.email || recipientEmail;
|
||||
|
||||
if (lead.contact_person.company) {
|
||||
const personCompany = await companiesService.readOne(lead.contact_person.company);
|
||||
companyName = personCompany?.name || companyName;
|
||||
}
|
||||
}
|
||||
|
||||
if (!recipientEmail) return res.status(400).send({ error: "No recipient email found" });
|
||||
|
||||
const html = await render(createElement(ProjectEstimateTemplate, {
|
||||
companyName: companyName,
|
||||
}));
|
||||
|
||||
const storageRoot = env.STORAGE_LOCAL_ROOT || "./storage";
|
||||
const attachmentPath = path.join(storageRoot, lead.audit_pdf_path);
|
||||
|
||||
await mailService.send({
|
||||
to: recipientEmail,
|
||||
subject: `Ihre Projekt-Schätzung: ${companyName}`,
|
||||
html,
|
||||
attachments: [
|
||||
{
|
||||
filename: `Angebot_${companyName}.pdf`,
|
||||
content: fs.readFileSync(attachmentPath)
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
await leadsService.updateOne(id, {
|
||||
status: "contacted",
|
||||
last_contacted_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
res.send({ success: true });
|
||||
} catch (error: any) {
|
||||
console.error("Estimate Email failed:", error);
|
||||
res.status(500).send({ error: error.message });
|
||||
if (lead.contact_person.company) {
|
||||
const personCompany = await companiesService.readOne(
|
||||
lead.contact_person.company,
|
||||
);
|
||||
companyName = personCompany?.name || companyName;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!recipientEmail)
|
||||
return res.status(400).send({ error: "No recipient email found" });
|
||||
|
||||
const html = await render(
|
||||
createElement(ProjectEstimateTemplate, {
|
||||
companyName: companyName,
|
||||
}),
|
||||
);
|
||||
|
||||
const storageRoot = env.STORAGE_LOCAL_ROOT || "./storage";
|
||||
const attachmentPath = path.join(storageRoot, lead.audit_pdf_path);
|
||||
|
||||
await mailService.send({
|
||||
to: recipientEmail,
|
||||
subject: `Ihre Projekt-Schätzung: ${companyName}`,
|
||||
html,
|
||||
attachments: [
|
||||
{
|
||||
filename: `Angebot_${companyName}.pdf`,
|
||||
content: fs.readFileSync(attachmentPath),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await leadsService.updateOne(id, {
|
||||
status: "contacted",
|
||||
last_contacted_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
res.send({ success: true });
|
||||
} catch (error: any) {
|
||||
console.error("Estimate Email failed:", error);
|
||||
res.status(500).send({ error: error.message });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,7 +41,7 @@ program
|
||||
`),
|
||||
);
|
||||
execSync(
|
||||
"docker-compose down --remove-orphans && docker-compose up app directus directus-db",
|
||||
"docker compose down --remove-orphans && docker compose up -d app directus at-mintel-directus-db",
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -12,7 +12,9 @@ const entryPoints = [
|
||||
|
||||
try {
|
||||
mkdirSync(resolve(__dirname, 'dist'), { recursive: true });
|
||||
} catch (e) { }
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
console.log(`Building entry point...`);
|
||||
|
||||
|
||||
@@ -3,91 +3,96 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
export interface AssetMap {
|
||||
[originalUrl: string]: string;
|
||||
[originalUrl: string]: string;
|
||||
}
|
||||
|
||||
export class AssetManager {
|
||||
private userAgent: string;
|
||||
private userAgent: string;
|
||||
|
||||
constructor(userAgent: string = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36") {
|
||||
this.userAgent = userAgent;
|
||||
constructor(
|
||||
userAgent: string = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36",
|
||||
) {
|
||||
this.userAgent = userAgent;
|
||||
}
|
||||
|
||||
public sanitizePath(rawPath: string): string {
|
||||
return rawPath
|
||||
.split("/")
|
||||
.map((p) => p.replace(/[^a-z0-9._-]/gi, "_"))
|
||||
.join("/");
|
||||
}
|
||||
|
||||
public async downloadFile(
|
||||
url: string,
|
||||
assetsDir: string,
|
||||
): Promise<string | null> {
|
||||
if (url.startsWith("//")) url = `https:${url}`;
|
||||
if (!url.startsWith("http")) return null;
|
||||
|
||||
try {
|
||||
const u = new URL(url);
|
||||
const relPath = this.sanitizePath(u.hostname + u.pathname);
|
||||
const dest = path.join(assetsDir, relPath);
|
||||
|
||||
if (fs.existsSync(dest)) return `./assets/${relPath}`;
|
||||
|
||||
const res = await axios.get(url, {
|
||||
responseType: "arraybuffer",
|
||||
headers: { "User-Agent": this.userAgent },
|
||||
timeout: 15000,
|
||||
validateStatus: () => true,
|
||||
});
|
||||
|
||||
if (res.status !== 200) return null;
|
||||
|
||||
if (!fs.existsSync(path.dirname(dest)))
|
||||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||
fs.writeFileSync(dest, Buffer.from(res.data));
|
||||
return `./assets/${relPath}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public sanitizePath(rawPath: string): string {
|
||||
return rawPath
|
||||
.split("/")
|
||||
.map((p) => p.replace(/[^a-z0-9._-]/gi, "_"))
|
||||
.join("/");
|
||||
}
|
||||
public async processCssRecursively(
|
||||
cssContent: string,
|
||||
cssUrl: string,
|
||||
assetsDir: string,
|
||||
urlMap: AssetMap,
|
||||
depth = 0,
|
||||
): Promise<string> {
|
||||
if (depth > 5) return cssContent;
|
||||
|
||||
public async downloadFile(url: string, assetsDir: string): Promise<string | null> {
|
||||
if (url.startsWith("//")) url = `https:${url}`;
|
||||
if (!url.startsWith("http")) return null;
|
||||
const urlRegex = /(?:url\(["']?|@import\s+["'])([^"')]*)["']?\)?/gi;
|
||||
let match;
|
||||
let newContent = cssContent;
|
||||
|
||||
try {
|
||||
const u = new URL(url);
|
||||
const relPath = this.sanitizePath(u.hostname + u.pathname);
|
||||
const dest = path.join(assetsDir, relPath);
|
||||
while ((match = urlRegex.exec(cssContent)) !== null) {
|
||||
const originalUrl = match[1];
|
||||
if (originalUrl.startsWith("data:") || originalUrl.startsWith("blob:"))
|
||||
continue;
|
||||
|
||||
if (fs.existsSync(dest)) return `./assets/${relPath}`;
|
||||
try {
|
||||
const absUrl = new URL(originalUrl, cssUrl).href;
|
||||
const local = await this.downloadFile(absUrl, assetsDir);
|
||||
|
||||
const res = await axios.get(url, {
|
||||
responseType: "arraybuffer",
|
||||
headers: { "User-Agent": this.userAgent },
|
||||
timeout: 15000,
|
||||
validateStatus: () => true,
|
||||
});
|
||||
if (local) {
|
||||
const u = new URL(cssUrl);
|
||||
const cssPath = u.hostname + u.pathname;
|
||||
const assetPath = new URL(absUrl).hostname + new URL(absUrl).pathname;
|
||||
|
||||
if (res.status !== 200) return null;
|
||||
const rel = path.relative(
|
||||
path.dirname(this.sanitizePath(cssPath)),
|
||||
this.sanitizePath(assetPath),
|
||||
);
|
||||
|
||||
if (!fs.existsSync(path.dirname(dest)))
|
||||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||
fs.writeFileSync(dest, Buffer.from(res.data));
|
||||
return `./assets/${relPath}`;
|
||||
} catch {
|
||||
return null;
|
||||
newContent = newContent.split(originalUrl).join(rel);
|
||||
urlMap[absUrl] = local;
|
||||
}
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
public async processCssRecursively(
|
||||
cssContent: string,
|
||||
cssUrl: string,
|
||||
assetsDir: string,
|
||||
urlMap: AssetMap,
|
||||
depth = 0,
|
||||
): Promise<string> {
|
||||
if (depth > 5) return cssContent;
|
||||
|
||||
const urlRegex = /(?:url\(["']?|@import\s+["'])([^"'\)]+)["']?\)?/gi;
|
||||
let match;
|
||||
let newContent = cssContent;
|
||||
|
||||
while ((match = urlRegex.exec(cssContent)) !== null) {
|
||||
const originalUrl = match[1];
|
||||
if (originalUrl.startsWith("data:") || originalUrl.startsWith("blob:"))
|
||||
continue;
|
||||
|
||||
try {
|
||||
const absUrl = new URL(originalUrl, cssUrl).href;
|
||||
const local = await this.downloadFile(absUrl, assetsDir);
|
||||
|
||||
if (local) {
|
||||
const u = new URL(cssUrl);
|
||||
const cssPath = u.hostname + u.pathname;
|
||||
const assetPath = new URL(absUrl).hostname + new URL(absUrl).pathname;
|
||||
|
||||
const rel = path.relative(
|
||||
path.dirname(this.sanitizePath(cssPath)),
|
||||
this.sanitizePath(assetPath),
|
||||
);
|
||||
|
||||
newContent = newContent.split(originalUrl).join(rel);
|
||||
urlMap[absUrl] = local;
|
||||
}
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
return newContent;
|
||||
}
|
||||
return newContent;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,184 +1,256 @@
|
||||
import { chromium, Browser, BrowserContext, Page } from "playwright";
|
||||
import { chromium } from "playwright";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import axios from "axios";
|
||||
import { AssetManager, AssetMap } from "./AssetManager.js";
|
||||
|
||||
export interface PageClonerOptions {
|
||||
outputDir: string;
|
||||
userAgent?: string;
|
||||
outputDir: string;
|
||||
userAgent?: string;
|
||||
}
|
||||
|
||||
export class PageCloner {
|
||||
private options: PageClonerOptions;
|
||||
private assetManager: AssetManager;
|
||||
private userAgent: string;
|
||||
private options: PageClonerOptions;
|
||||
private assetManager: AssetManager;
|
||||
private userAgent: string;
|
||||
|
||||
constructor(options: PageClonerOptions) {
|
||||
this.options = options;
|
||||
this.userAgent = options.userAgent || "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36";
|
||||
this.assetManager = new AssetManager(this.userAgent);
|
||||
}
|
||||
constructor(options: PageClonerOptions) {
|
||||
this.options = options;
|
||||
this.userAgent =
|
||||
options.userAgent ||
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36";
|
||||
this.assetManager = new AssetManager(this.userAgent);
|
||||
}
|
||||
|
||||
public async clone(targetUrl: string): Promise<string> {
|
||||
const urlObj = new URL(targetUrl);
|
||||
const domainSlug = urlObj.hostname.replace("www.", "");
|
||||
const domainDir = path.resolve(this.options.outputDir, domainSlug);
|
||||
const assetsDir = path.join(domainDir, "assets");
|
||||
public async clone(targetUrl: string): Promise<string> {
|
||||
const urlObj = new URL(targetUrl);
|
||||
const domainSlug = urlObj.hostname.replace("www.", "");
|
||||
const domainDir = path.resolve(this.options.outputDir, domainSlug);
|
||||
const assetsDir = path.join(domainDir, "assets");
|
||||
|
||||
if (!fs.existsSync(assetsDir)) fs.mkdirSync(assetsDir, { recursive: true });
|
||||
if (!fs.existsSync(assetsDir)) fs.mkdirSync(assetsDir, { recursive: true });
|
||||
|
||||
let pageSlug = urlObj.pathname.split("/").filter(Boolean).join("-");
|
||||
if (!pageSlug) pageSlug = "index";
|
||||
const htmlFilename = `${pageSlug}.html`;
|
||||
let pageSlug = urlObj.pathname.split("/").filter(Boolean).join("-");
|
||||
if (!pageSlug) pageSlug = "index";
|
||||
const htmlFilename = `${pageSlug}.html`;
|
||||
|
||||
console.log(`🚀 INDUSTRIAL CLONE: ${targetUrl}`);
|
||||
console.log(`🚀 INDUSTRIAL CLONE: ${targetUrl}`);
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({
|
||||
userAgent: this.userAgent,
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({
|
||||
userAgent: this.userAgent,
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
const urlMap: AssetMap = {};
|
||||
const foundAssets = new Set<string>();
|
||||
const urlMap: AssetMap = {};
|
||||
const foundAssets = new Set<string>();
|
||||
|
||||
page.on("response", (response) => {
|
||||
if (response.status() === 200) {
|
||||
const url = response.url();
|
||||
if (url.match(/\.(css|js|png|jpg|jpeg|gif|svg|woff2?|ttf|otf|mp4|webm|webp|ico)/i)) {
|
||||
foundAssets.add(url);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await page.goto(targetUrl, { waitUntil: "networkidle", timeout: 90000 });
|
||||
|
||||
// Scroll Wave
|
||||
await page.evaluate(async () => {
|
||||
await new Promise((resolve) => {
|
||||
let totalHeight = 0;
|
||||
const distance = 400;
|
||||
const timer = setInterval(() => {
|
||||
const scrollHeight = document.body.scrollHeight;
|
||||
window.scrollBy(0, distance);
|
||||
totalHeight += distance;
|
||||
if (totalHeight >= scrollHeight) {
|
||||
clearInterval(timer);
|
||||
window.scrollTo(0, 0);
|
||||
resolve(true);
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
});
|
||||
|
||||
const fullHeight = await page.evaluate(() => document.body.scrollHeight);
|
||||
await page.setViewportSize({ width: 1920, height: fullHeight + 1000 });
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// Sanitization
|
||||
await page.evaluate(() => {
|
||||
const assetPattern = /\.(jpg|jpeg|png|gif|svg|webp|mp4|webm|woff2?|ttf|otf)/i;
|
||||
document.querySelectorAll("*").forEach((el) => {
|
||||
if (["META", "LINK", "HEAD", "SCRIPT", "STYLE", "SVG", "PATH"].includes(el.tagName)) return;
|
||||
const htmlEl = el as HTMLElement;
|
||||
const style = window.getComputedStyle(htmlEl);
|
||||
if (style.opacity === "0" || style.visibility === "hidden") {
|
||||
htmlEl.style.setProperty("opacity", "1", "important");
|
||||
htmlEl.style.setProperty("visibility", "visible", "important");
|
||||
}
|
||||
for (const attr of Array.from(el.attributes)) {
|
||||
const name = attr.name.toLowerCase();
|
||||
const val = attr.value;
|
||||
if (assetPattern.test(val) || name.includes("src") || name.includes("image")) {
|
||||
if (el.tagName === "IMG") {
|
||||
const img = el as HTMLImageElement;
|
||||
if (name.includes("srcset")) img.srcset = val;
|
||||
else if (!img.src || img.src.includes("data:")) img.src = val;
|
||||
}
|
||||
if (el.tagName === "SOURCE") (el as HTMLSourceElement).srcset = val;
|
||||
if (el.tagName === "VIDEO" || el.tagName === "AUDIO") (el as HTMLMediaElement).src = val;
|
||||
if (val.match(/^(https?:\/\/|\/\/|\/)/) && !name.includes("href")) {
|
||||
const bg = htmlEl.style.backgroundImage;
|
||||
if (!bg || bg === "none") htmlEl.style.backgroundImage = `url('${val}')`;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
if (document.body) {
|
||||
document.body.style.setProperty("opacity", "1", "important");
|
||||
document.body.style.setProperty("visibility", "visible", "important");
|
||||
}
|
||||
});
|
||||
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
let content = await page.content();
|
||||
const regexPatterns = [
|
||||
/(?:src|href|url|data-[a-z-]+|srcset)=["']([^"'<>\s]+?\.(?:css|js|png|jpg|jpeg|gif|svg|woff2?|ttf|otf|mp4|webm|webp|ico)(?:\?[^"']*)?)["']/gi,
|
||||
/url\(["']?([^"'\)]+)["']?\)/gi,
|
||||
];
|
||||
|
||||
for (const pattern of regexPatterns) {
|
||||
let match;
|
||||
while ((match = pattern.exec(content)) !== null) {
|
||||
try { foundAssets.add(new URL(match[1], targetUrl).href); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
for (const url of foundAssets) {
|
||||
const local = await this.assetManager.downloadFile(url, assetsDir);
|
||||
if (local) {
|
||||
urlMap[url] = local;
|
||||
const clean = url.split("?")[0];
|
||||
urlMap[clean] = local;
|
||||
if (clean.endsWith(".css")) {
|
||||
try {
|
||||
const { data } = await axios.get(url, { headers: { "User-Agent": this.userAgent } });
|
||||
const processedCss = await this.assetManager.processCssRecursively(data, url, assetsDir, urlMap);
|
||||
const relPath = this.assetManager.sanitizePath(new URL(url).hostname + new URL(url).pathname);
|
||||
fs.writeFileSync(path.join(assetsDir, relPath), processedCss);
|
||||
} catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let finalContent = content;
|
||||
const sortedUrls = Object.keys(urlMap).sort((a, b) => b.length - a.length);
|
||||
if (sortedUrls.length > 0) {
|
||||
const escaped = sortedUrls.map((u) => u.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
||||
const masterRegex = new RegExp(`(${escaped.join("|")})`, "g");
|
||||
finalContent = finalContent.replace(masterRegex, (match) => urlMap[match] || match);
|
||||
}
|
||||
|
||||
const commonDirs = ["/wp-content/", "/wp-includes/", "/assets/", "/static/", "/images/"];
|
||||
for (const dir of commonDirs) {
|
||||
const localDir = `./assets/${urlObj.hostname}${dir}`;
|
||||
finalContent = finalContent.split(`"${dir}`).join(`"${localDir}`).split(`'${dir}`).join(`'${localDir}`).split(`(${dir}`).join(`(${localDir}`);
|
||||
}
|
||||
|
||||
const domainPattern = new RegExp(`https?://(www\\.)?${urlObj.hostname.replace(/\./g, "\\.")}[^"']*`, "gi");
|
||||
finalContent = finalContent.replace(domainPattern, () => "./");
|
||||
|
||||
finalContent = finalContent.replace(/<script\b[^>]*>([\s\S]*?)<\/script>/gi, (match, scriptContent) => {
|
||||
const lower = scriptContent.toLowerCase();
|
||||
return (lower.includes("google-analytics") || lower.includes("gtag") || lower.includes("fbq") || lower.includes("lazy") || lower.includes("tracker")) ? "" : match;
|
||||
});
|
||||
|
||||
const headEnd = finalContent.indexOf("</head>");
|
||||
if (headEnd > -1) {
|
||||
const stabilityCss = `\n<style>* { transition: none !important; animation: none !important; scroll-behavior: auto !important; } [data-aos], .reveal, .lazypath, .lazy-load, [data-src] { opacity: 1 !important; visibility: visible !important; transform: none !important; clip-path: none !important; } img, video, iframe { max-width: 100%; display: block; } a { pointer-events: none; cursor: default; } </style>`;
|
||||
finalContent = finalContent.slice(0, headEnd) + stabilityCss + finalContent.slice(headEnd);
|
||||
}
|
||||
|
||||
const finalPath = path.join(domainDir, htmlFilename);
|
||||
fs.writeFileSync(finalPath, finalContent);
|
||||
return finalPath;
|
||||
} finally {
|
||||
await browser.close();
|
||||
page.on("response", (response) => {
|
||||
if (response.status() === 200) {
|
||||
const url = response.url();
|
||||
if (
|
||||
url.match(
|
||||
/\.(css|js|png|jpg|jpeg|gif|svg|woff2?|ttf|otf|mp4|webm|webp|ico)/i,
|
||||
)
|
||||
) {
|
||||
foundAssets.add(url);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await page.goto(targetUrl, { waitUntil: "networkidle", timeout: 90000 });
|
||||
|
||||
// Scroll Wave
|
||||
await page.evaluate(async () => {
|
||||
await new Promise((resolve) => {
|
||||
let totalHeight = 0;
|
||||
const distance = 400;
|
||||
const timer = setInterval(() => {
|
||||
const scrollHeight = document.body.scrollHeight;
|
||||
window.scrollBy(0, distance);
|
||||
totalHeight += distance;
|
||||
if (totalHeight >= scrollHeight) {
|
||||
clearInterval(timer);
|
||||
window.scrollTo(0, 0);
|
||||
resolve(true);
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
});
|
||||
|
||||
const fullHeight = await page.evaluate(() => document.body.scrollHeight);
|
||||
await page.setViewportSize({ width: 1920, height: fullHeight + 1000 });
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// Sanitization
|
||||
await page.evaluate(() => {
|
||||
const assetPattern =
|
||||
/\.(jpg|jpeg|png|gif|svg|webp|mp4|webm|woff2?|ttf|otf)/i;
|
||||
document.querySelectorAll("*").forEach((el) => {
|
||||
if (
|
||||
["META", "LINK", "HEAD", "SCRIPT", "STYLE", "SVG", "PATH"].includes(
|
||||
el.tagName,
|
||||
)
|
||||
)
|
||||
return;
|
||||
const htmlEl = el as HTMLElement;
|
||||
const style = window.getComputedStyle(htmlEl);
|
||||
if (style.opacity === "0" || style.visibility === "hidden") {
|
||||
htmlEl.style.setProperty("opacity", "1", "important");
|
||||
htmlEl.style.setProperty("visibility", "visible", "important");
|
||||
}
|
||||
for (const attr of Array.from(el.attributes)) {
|
||||
const name = attr.name.toLowerCase();
|
||||
const val = attr.value;
|
||||
if (
|
||||
assetPattern.test(val) ||
|
||||
name.includes("src") ||
|
||||
name.includes("image")
|
||||
) {
|
||||
if (el.tagName === "IMG") {
|
||||
const img = el as HTMLImageElement;
|
||||
if (name.includes("srcset")) img.srcset = val;
|
||||
else if (!img.src || img.src.includes("data:")) img.src = val;
|
||||
}
|
||||
if (el.tagName === "SOURCE")
|
||||
(el as HTMLSourceElement).srcset = val;
|
||||
if (el.tagName === "VIDEO" || el.tagName === "AUDIO")
|
||||
(el as HTMLMediaElement).src = val;
|
||||
if (
|
||||
val.match(/^(https?:\/\/|\/\/|\/)/) &&
|
||||
!name.includes("href")
|
||||
) {
|
||||
const bg = htmlEl.style.backgroundImage;
|
||||
if (!bg || bg === "none")
|
||||
htmlEl.style.backgroundImage = `url('${val}')`;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
if (document.body) {
|
||||
document.body.style.setProperty("opacity", "1", "important");
|
||||
document.body.style.setProperty("visibility", "visible", "important");
|
||||
}
|
||||
});
|
||||
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const content = await page.content();
|
||||
const regexPatterns = [
|
||||
/(?:src|href|url|data-[a-z-]+|srcset)=["']([^"'<>\s]+?\.(?:css|js|png|jpg|jpeg|gif|svg|woff2?|ttf|otf|mp4|webm|webp|ico)(?:\?[^"']*)?)["']/gi,
|
||||
/url\(["']?([^"')]*)["']?\)/gi,
|
||||
];
|
||||
|
||||
for (const pattern of regexPatterns) {
|
||||
let match;
|
||||
while ((match = pattern.exec(content)) !== null) {
|
||||
try {
|
||||
foundAssets.add(new URL(match[1], targetUrl).href);
|
||||
} catch {
|
||||
// Ignore invalid URLs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const url of foundAssets) {
|
||||
const local = await this.assetManager.downloadFile(url, assetsDir);
|
||||
if (local) {
|
||||
urlMap[url] = local;
|
||||
const clean = url.split("?")[0];
|
||||
urlMap[clean] = local;
|
||||
if (clean.endsWith(".css")) {
|
||||
try {
|
||||
const { data } = await axios.get(url, {
|
||||
headers: { "User-Agent": this.userAgent },
|
||||
});
|
||||
const processedCss =
|
||||
await this.assetManager.processCssRecursively(
|
||||
data,
|
||||
url,
|
||||
assetsDir,
|
||||
urlMap,
|
||||
);
|
||||
const relPath = this.assetManager.sanitizePath(
|
||||
new URL(url).hostname + new URL(url).pathname,
|
||||
);
|
||||
fs.writeFileSync(path.join(assetsDir, relPath), processedCss);
|
||||
} catch {
|
||||
// Ignore stylesheet download/process failures
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let finalContent = content;
|
||||
const sortedUrls = Object.keys(urlMap).sort(
|
||||
(a, b) => b.length - a.length,
|
||||
);
|
||||
if (sortedUrls.length > 0) {
|
||||
const escaped = sortedUrls.map((u) =>
|
||||
u.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
|
||||
);
|
||||
const masterRegex = new RegExp(`(${escaped.join("|")})`, "g");
|
||||
finalContent = finalContent.replace(
|
||||
masterRegex,
|
||||
(match) => urlMap[match] || match,
|
||||
);
|
||||
}
|
||||
|
||||
const commonDirs = [
|
||||
"/wp-content/",
|
||||
"/wp-includes/",
|
||||
"/assets/",
|
||||
"/static/",
|
||||
"/images/",
|
||||
];
|
||||
for (const dir of commonDirs) {
|
||||
const localDir = `./assets/${urlObj.hostname}${dir}`;
|
||||
finalContent = finalContent
|
||||
.split(`"${dir}`)
|
||||
.join(`"${localDir}`)
|
||||
.split(`'${dir}`)
|
||||
.join(`'${localDir}`)
|
||||
.split(`(${dir}`)
|
||||
.join(`(${localDir}`);
|
||||
}
|
||||
|
||||
const domainPattern = new RegExp(
|
||||
`https?://(www\\.)?${urlObj.hostname.replace(/\./g, "\\.")}[^"']*`,
|
||||
"gi",
|
||||
);
|
||||
finalContent = finalContent.replace(domainPattern, () => "./");
|
||||
|
||||
finalContent = finalContent.replace(
|
||||
/<script\b[^>]*>([\s\S]*?)<\/script>/gi,
|
||||
(match, scriptContent) => {
|
||||
const lower = scriptContent.toLowerCase();
|
||||
return lower.includes("google-analytics") ||
|
||||
lower.includes("gtag") ||
|
||||
lower.includes("fbq") ||
|
||||
lower.includes("lazy") ||
|
||||
lower.includes("tracker")
|
||||
? ""
|
||||
: match;
|
||||
},
|
||||
);
|
||||
|
||||
const headEnd = finalContent.indexOf("</head>");
|
||||
if (headEnd > -1) {
|
||||
const stabilityCss = `\n<style>* { transition: none !important; animation: none !important; scroll-behavior: auto !important; } [data-aos], .reveal, .lazypath, .lazy-load, [data-src] { opacity: 1 !important; visibility: visible !important; transform: none !important; clip-path: none !important; } img, video, iframe { max-width: 100%; display: block; } a { pointer-events: none; cursor: default; } </style>`;
|
||||
finalContent =
|
||||
finalContent.slice(0, headEnd) +
|
||||
stabilityCss +
|
||||
finalContent.slice(headEnd);
|
||||
}
|
||||
|
||||
const finalPath = path.join(domainDir, htmlFilename);
|
||||
fs.writeFileSync(finalPath, finalContent);
|
||||
return finalPath;
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,123 +1,150 @@
|
||||
import { PlaywrightCrawler, RequestQueue } from 'crawlee';
|
||||
import * as path from 'node:path';
|
||||
import * as fs from 'node:fs';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { PlaywrightCrawler, RequestQueue } from "crawlee";
|
||||
import * as path from "node:path";
|
||||
import * as fs from "node:fs";
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
export interface WebsiteClonerOptions {
|
||||
baseOutputDir: string;
|
||||
maxRequestsPerCrawl?: number;
|
||||
maxConcurrency?: number;
|
||||
baseOutputDir: string;
|
||||
maxRequestsPerCrawl?: number;
|
||||
maxConcurrency?: number;
|
||||
}
|
||||
|
||||
export class WebsiteCloner {
|
||||
private options: WebsiteClonerOptions;
|
||||
private options: WebsiteClonerOptions;
|
||||
|
||||
constructor(options: WebsiteClonerOptions) {
|
||||
this.options = {
|
||||
maxRequestsPerCrawl: 100,
|
||||
maxConcurrency: 3,
|
||||
...options
|
||||
};
|
||||
constructor(options: WebsiteClonerOptions) {
|
||||
this.options = {
|
||||
maxRequestsPerCrawl: 100,
|
||||
maxConcurrency: 3,
|
||||
...options,
|
||||
};
|
||||
}
|
||||
|
||||
public async clone(
|
||||
targetUrl: string,
|
||||
outputDirName?: string,
|
||||
): Promise<string> {
|
||||
const urlObj = new URL(targetUrl);
|
||||
const domain = urlObj.hostname;
|
||||
const finalOutputDirName = outputDirName || domain.replace(/\./g, "-");
|
||||
const baseOutputDir = path.resolve(
|
||||
this.options.baseOutputDir,
|
||||
finalOutputDirName,
|
||||
);
|
||||
|
||||
if (fs.existsSync(baseOutputDir)) {
|
||||
fs.rmSync(baseOutputDir, { recursive: true, force: true });
|
||||
}
|
||||
fs.mkdirSync(baseOutputDir, { recursive: true });
|
||||
|
||||
public async clone(targetUrl: string, outputDirName?: string): Promise<string> {
|
||||
const urlObj = new URL(targetUrl);
|
||||
const domain = urlObj.hostname;
|
||||
const finalOutputDirName = outputDirName || domain.replace(/\./g, '-');
|
||||
const baseOutputDir = path.resolve(this.options.baseOutputDir, finalOutputDirName);
|
||||
console.log(`🚀 Starting perfect recursive clone of ${targetUrl}...`);
|
||||
console.log(`📂 Output: ${baseOutputDir}`);
|
||||
|
||||
if (fs.existsSync(baseOutputDir)) {
|
||||
fs.rmSync(baseOutputDir, { recursive: true, force: true });
|
||||
}
|
||||
fs.mkdirSync(baseOutputDir, { recursive: true });
|
||||
const requestQueue = await RequestQueue.open();
|
||||
await requestQueue.addRequest({ url: targetUrl });
|
||||
|
||||
console.log(`🚀 Starting perfect recursive clone of ${targetUrl}...`);
|
||||
console.log(`📂 Output: ${baseOutputDir}`);
|
||||
const crawler = new PlaywrightCrawler({
|
||||
requestQueue,
|
||||
maxRequestsPerCrawl: this.options.maxRequestsPerCrawl,
|
||||
maxConcurrency: this.options.maxConcurrency,
|
||||
|
||||
const requestQueue = await RequestQueue.open();
|
||||
await requestQueue.addRequest({ url: targetUrl });
|
||||
async requestHandler({ request, enqueueLinks, log }) {
|
||||
const url = request.url;
|
||||
log.info(`Capturing ${url}...`);
|
||||
|
||||
const crawler = new PlaywrightCrawler({
|
||||
requestQueue,
|
||||
maxRequestsPerCrawl: this.options.maxRequestsPerCrawl,
|
||||
maxConcurrency: this.options.maxConcurrency,
|
||||
const u = new URL(url);
|
||||
let relPath = u.pathname;
|
||||
if (relPath === "/" || relPath === "") relPath = "/index.html";
|
||||
if (!relPath.endsWith(".html") && !path.extname(relPath))
|
||||
relPath += "/index.html";
|
||||
if (relPath.startsWith("/")) relPath = relPath.substring(1);
|
||||
|
||||
async requestHandler({ request, enqueueLinks, log }) {
|
||||
const url = request.url;
|
||||
log.info(`Capturing ${url}...`);
|
||||
const fullPath = path.join(baseOutputDir, relPath);
|
||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||
|
||||
const u = new URL(url);
|
||||
let relPath = u.pathname;
|
||||
if (relPath === '/' || relPath === '') relPath = '/index.html';
|
||||
if (!relPath.endsWith('.html') && !path.extname(relPath)) relPath += '/index.html';
|
||||
if (relPath.startsWith('/')) relPath = relPath.substring(1);
|
||||
|
||||
const fullPath = path.join(baseOutputDir, relPath);
|
||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||
|
||||
try {
|
||||
// Note: This assumes single-file-cli is available in the environment
|
||||
execSync(`npx single-file-cli "${url}" "${fullPath}" --browser-headless=true --browser-wait-until=networkidle0`, {
|
||||
stdio: 'inherit'
|
||||
});
|
||||
} catch (e) {
|
||||
log.error(`Failed to capture ${url} with SingleFile`);
|
||||
}
|
||||
|
||||
await enqueueLinks({
|
||||
strategy: 'same-domain',
|
||||
transformRequestFunction: (req) => {
|
||||
if (/\.(download|pdf|zip|gz|exe|png|jpg|jpeg|gif|svg|css|js)$/i.test(req.url)) return false;
|
||||
return req;
|
||||
}
|
||||
});
|
||||
try {
|
||||
// Note: This assumes single-file-cli is available in the environment
|
||||
execSync(
|
||||
`npx single-file-cli "${url}" "${fullPath}" --browser-headless=true --browser-wait-until=networkidle0`,
|
||||
{
|
||||
stdio: "inherit",
|
||||
},
|
||||
);
|
||||
} catch (_e) {
|
||||
log.error(`Failed to capture ${url} with SingleFile`);
|
||||
}
|
||||
|
||||
await enqueueLinks({
|
||||
strategy: "same-domain",
|
||||
transformRequestFunction: (req) => {
|
||||
if (
|
||||
/\.(download|pdf|zip|gz|exe|png|jpg|jpeg|gif|svg|css|js)$/i.test(
|
||||
req.url,
|
||||
)
|
||||
)
|
||||
return false;
|
||||
return req;
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
await crawler.run();
|
||||
await crawler.run();
|
||||
|
||||
console.log('🔗 Rewriting internal links for offline navigation...');
|
||||
const allFiles = this.getFiles(baseOutputDir).filter(f => f.endsWith('.html'));
|
||||
console.log("🔗 Rewriting internal links for offline navigation...");
|
||||
const allFiles = this.getFiles(baseOutputDir).filter((f) =>
|
||||
f.endsWith(".html"),
|
||||
);
|
||||
|
||||
for (const file of allFiles) {
|
||||
let content = fs.readFileSync(file, 'utf8');
|
||||
const fileRelToRoot = path.relative(baseOutputDir, file);
|
||||
for (const file of allFiles) {
|
||||
let content = fs.readFileSync(file, "utf8");
|
||||
const fileRelToRoot = path.relative(baseOutputDir, file);
|
||||
|
||||
content = content.replace(/href="([^"]+)"/g, (match, href) => {
|
||||
if (href.startsWith(targetUrl) || href.startsWith('/') || (!href.includes('://') && !href.startsWith('data:'))) {
|
||||
try {
|
||||
const linkUrl = new URL(href, targetUrl);
|
||||
if (linkUrl.hostname === domain) {
|
||||
let linkPath = linkUrl.pathname;
|
||||
if (linkPath === '/' || linkPath === '') linkPath = '/index.html';
|
||||
if (!linkPath.endsWith('.html') && !path.extname(linkPath)) linkPath += '/index.html';
|
||||
if (linkPath.startsWith('/')) linkPath = linkPath.substring(1);
|
||||
content = content.replace(/href="([^"]+)"/g, (match, href) => {
|
||||
if (
|
||||
href.startsWith(targetUrl) ||
|
||||
href.startsWith("/") ||
|
||||
(!href.includes("://") && !href.startsWith("data:"))
|
||||
) {
|
||||
try {
|
||||
const linkUrl = new URL(href, targetUrl);
|
||||
if (linkUrl.hostname === domain) {
|
||||
let linkPath = linkUrl.pathname;
|
||||
if (linkPath === "/" || linkPath === "") linkPath = "/index.html";
|
||||
if (!linkPath.endsWith(".html") && !path.extname(linkPath))
|
||||
linkPath += "/index.html";
|
||||
if (linkPath.startsWith("/")) linkPath = linkPath.substring(1);
|
||||
|
||||
const relativeLink = path.relative(path.dirname(fileRelToRoot), linkPath);
|
||||
return `href="${relativeLink}"`;
|
||||
}
|
||||
} catch (e) { }
|
||||
}
|
||||
return match;
|
||||
});
|
||||
|
||||
fs.writeFileSync(file, content);
|
||||
}
|
||||
|
||||
console.log(`\n✅ Done! Perfect clone complete in: ${baseOutputDir}`);
|
||||
return baseOutputDir;
|
||||
}
|
||||
|
||||
private getFiles(dir: string, fileList: string[] = []) {
|
||||
const files = fs.readdirSync(dir);
|
||||
for (const file of files) {
|
||||
const name = path.join(dir, file);
|
||||
if (fs.statSync(name).isDirectory()) {
|
||||
this.getFiles(name, fileList);
|
||||
} else {
|
||||
fileList.push(name);
|
||||
const relativeLink = path.relative(
|
||||
path.dirname(fileRelToRoot),
|
||||
linkPath,
|
||||
);
|
||||
return `href="${relativeLink}"`;
|
||||
}
|
||||
} catch (_e) {
|
||||
// Ignore link rewriting failures
|
||||
}
|
||||
}
|
||||
return fileList;
|
||||
return match;
|
||||
});
|
||||
|
||||
fs.writeFileSync(file, content);
|
||||
}
|
||||
|
||||
console.log(`\n✅ Done! Perfect clone complete in: ${baseOutputDir}`);
|
||||
return baseOutputDir;
|
||||
}
|
||||
|
||||
private getFiles(dir: string, fileList: string[] = []) {
|
||||
const files = fs.readdirSync(dir);
|
||||
for (const file of files) {
|
||||
const name = path.join(dir, file);
|
||||
if (fs.statSync(name).isDirectory()) {
|
||||
this.getFiles(name, fileList);
|
||||
} else {
|
||||
fileList.push(name);
|
||||
}
|
||||
}
|
||||
return fileList;
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -25,6 +25,8 @@ services:
|
||||
LOG_LEVEL: "debug"
|
||||
SERVE_APP: "true"
|
||||
EXTENSIONS_AUTO_RELOAD: "true"
|
||||
EXTENSIONS_SANDBOX: "false"
|
||||
CONTENT_SECURITY_POLICY: "false"
|
||||
volumes:
|
||||
- ./database:/directus/database
|
||||
- ./uploads:/directus/uploads
|
||||
@@ -37,11 +39,12 @@ services:
|
||||
retries: 5
|
||||
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.at-mintel-infra-cms.rule=Host(`cms.localhost`)"
|
||||
- "traefik.http.services.at-mintel-infra-cms.loadbalancer.server.port=8055"
|
||||
- "traefik.http.services.at-mintel-infra-cms.loadbalancer.healthcheck.path=/server/health"
|
||||
- "traefik.docker.network=infra"
|
||||
traefik.enable: "true"
|
||||
traefik.http.routers.at-mintel-infra-cms.rule: "Host(`cms.localhost`)"
|
||||
traefik.docker.network: "infra"
|
||||
caddy: "http://cms.localhost"
|
||||
caddy.reverse_proxy: "{{upstreams 8055}}"
|
||||
caddy.header.Cache-Control: "no-store, no-cache, must-revalidate, max-age=0"
|
||||
|
||||
networks:
|
||||
default:
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"name": "acquisition-manager",
|
||||
"description": "Custom High-Fidelity Management for Directus",
|
||||
"icon": "extension",
|
||||
"version": "1.8.2",
|
||||
"type": "module",
|
||||
"keywords": [
|
||||
"directus",
|
||||
"directus-extension",
|
||||
"directus-extension-module"
|
||||
],
|
||||
"directus:extension": {
|
||||
"type": "module",
|
||||
"path": "index.js",
|
||||
"source": "src/index.ts",
|
||||
"host": "app",
|
||||
"name": "acquisition manager"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "directus-extension build",
|
||||
"dev": "directus-extension build -w"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@directus/extensions-sdk": "11.0.2",
|
||||
"@mintel/directus-extension-toolkit": "workspace:*",
|
||||
"vue": "^3.4.0"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"name": "acquisition",
|
||||
"version": "1.8.2",
|
||||
"type": "module",
|
||||
"directus:extension": {
|
||||
"type": "endpoint",
|
||||
"path": "index.js",
|
||||
"source": "src/index.ts",
|
||||
"host": "^11.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "node build.mjs",
|
||||
"dev": "node build.mjs --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@directus/extensions-sdk": "11.0.2",
|
||||
"@mintel/pdf": "workspace:*",
|
||||
"@mintel/mail": "workspace:*",
|
||||
"esbuild": "^0.25.0",
|
||||
"typescript": "^5.6.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"jquery": "^3.7.1",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"name": "company-manager",
|
||||
"description": "Custom High-Fidelity Management for Directus",
|
||||
"icon": "extension",
|
||||
"version": "1.8.2",
|
||||
"type": "module",
|
||||
"keywords": [
|
||||
"directus",
|
||||
"directus-extension",
|
||||
"directus-extension-module"
|
||||
],
|
||||
"directus:extension": {
|
||||
"type": "module",
|
||||
"path": "index.js",
|
||||
"source": "src/index.ts",
|
||||
"host": "app",
|
||||
"name": "company manager"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "directus-extension build",
|
||||
"dev": "directus-extension build -w"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@directus/extensions-sdk": "11.0.2",
|
||||
"@mintel/directus-extension-toolkit": "workspace:*",
|
||||
"vue": "^3.4.0"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"name": "customer-manager",
|
||||
"description": "Custom High-Fidelity Management for Directus",
|
||||
"icon": "extension",
|
||||
"version": "1.8.2",
|
||||
"type": "module",
|
||||
"keywords": [
|
||||
"directus",
|
||||
"directus-extension",
|
||||
"directus-extension-module"
|
||||
],
|
||||
"directus:extension": {
|
||||
"type": "module",
|
||||
"path": "index.js",
|
||||
"source": "src/index.ts",
|
||||
"host": "app",
|
||||
"name": "customer manager"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "directus-extension build",
|
||||
"dev": "directus-extension build -w"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@directus/extensions-sdk": "11.0.2",
|
||||
"@mintel/directus-extension-toolkit": "workspace:*",
|
||||
"vue": "^3.4.0"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"name": "feedback-commander",
|
||||
"description": "Custom High-Fidelity Management for Directus",
|
||||
"icon": "extension",
|
||||
"version": "1.8.2",
|
||||
"type": "module",
|
||||
"keywords": [
|
||||
"directus",
|
||||
"directus-extension",
|
||||
"directus-extension-module"
|
||||
],
|
||||
"directus:extension": {
|
||||
"type": "module",
|
||||
"path": "index.js",
|
||||
"source": "src/index.ts",
|
||||
"host": "app",
|
||||
"name": "feedback commander"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "directus-extension build",
|
||||
"dev": "directus-extension build -w"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@directus/extensions-sdk": "11.0.2",
|
||||
"vue": "^3.4.0"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"name": "people-manager",
|
||||
"description": "Custom High-Fidelity Management for Directus",
|
||||
"icon": "extension",
|
||||
"version": "1.8.2",
|
||||
"type": "module",
|
||||
"keywords": [
|
||||
"directus",
|
||||
"directus-extension",
|
||||
"directus-extension-module"
|
||||
],
|
||||
"directus:extension": {
|
||||
"type": "module",
|
||||
"path": "index.js",
|
||||
"source": "src/index.ts",
|
||||
"host": "app",
|
||||
"name": "people manager"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "directus-extension build",
|
||||
"dev": "directus-extension build -w"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@directus/extensions-sdk": "11.0.2",
|
||||
"@mintel/directus-extension-toolkit": "workspace:*",
|
||||
"vue": "^3.4.0"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"name": "unified-dashboard",
|
||||
"description": "Custom High-Fidelity Management for Directus",
|
||||
"icon": "extension",
|
||||
"version": "1.8.2",
|
||||
"type": "module",
|
||||
"keywords": [
|
||||
"directus",
|
||||
"directus-extension",
|
||||
"directus-extension-module"
|
||||
],
|
||||
"directus:extension": {
|
||||
"type": "module",
|
||||
"path": "index.js",
|
||||
"source": "src/index.ts",
|
||||
"host": "app",
|
||||
"name": "unified dashboard"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "directus-extension build",
|
||||
"dev": "directus-extension build -w"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@directus/extensions-sdk": "11.0.2",
|
||||
"vue": "^3.4.0"
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,8 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"up": "npm run build:extensions && docker compose up -d",
|
||||
"dev": "npm run up -- --link",
|
||||
"up": "../../scripts/cms-up.sh",
|
||||
"down": "docker compose down",
|
||||
"logs": "docker compose logs -f",
|
||||
"build:extensions": "../../scripts/sync-extensions.sh",
|
||||
@@ -14,4 +15,4 @@
|
||||
"sync:push": "../../scripts/sync-directus.sh push infra",
|
||||
"sync:pull": "../../scripts/sync-directus.sh pull infra"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
--tVj
|
||||
@@ -20,9 +20,11 @@
|
||||
"build": "directus-extension build",
|
||||
"dev": "directus-extension build -w"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mintel/directus-extension-toolkit": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@directus/extensions-sdk": "11.0.2",
|
||||
"@mintel/directus-extension-toolkit": "workspace:*",
|
||||
"vue": "^3.4.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -104,9 +104,11 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useApi } from '@directus/extensions-sdk';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { MintelManagerLayout } from '@mintel/directus-extension-toolkit';
|
||||
|
||||
const api = useApi();
|
||||
const route = useRoute();
|
||||
const companies = ref([]);
|
||||
const selectedCompany = ref(null);
|
||||
const feedback = ref(null);
|
||||
@@ -201,7 +203,12 @@ async function deleteCompany() {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchData);
|
||||
onMounted(async () => {
|
||||
await fetchData();
|
||||
if (route.query.create === 'true') {
|
||||
openCreateDrawer();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
48
packages/content-engine/examples/generate-post.ts
Normal file
48
packages/content-engine/examples/generate-post.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { ContentGenerator } from "../src/index";
|
||||
import dotenv from "dotenv";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
|
||||
// Load .env from mintel.me (since that's where the key is)
|
||||
dotenv.config({
|
||||
path: path.resolve(__dirname, "../../../../mintel.me/apps/web/.env"),
|
||||
});
|
||||
|
||||
async function main() {
|
||||
const apiKey = process.env.OPENROUTER_API_KEY || process.env.OPENROUTER_KEY;
|
||||
if (!apiKey) {
|
||||
console.error("❌ OPENROUTER_API_KEY not found");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const generator = new ContentGenerator(apiKey);
|
||||
|
||||
const topic = "Why traditional CMSs are dead for developers";
|
||||
console.log(`🚀 Generating post for: "${topic}"`);
|
||||
|
||||
try {
|
||||
const post = await generator.generatePost({
|
||||
topic,
|
||||
includeResearch: true,
|
||||
includeDiagrams: true,
|
||||
includeMemes: true,
|
||||
});
|
||||
|
||||
console.log("\n\n✅ GENERATION COMPLETE");
|
||||
console.log("--------------------------------------------------");
|
||||
console.log(`Title: ${post.title}`);
|
||||
console.log(`Research Points: ${post.research.length}`);
|
||||
console.log(`Memes Generated: ${post.memes.length}`);
|
||||
console.log(`Diagrams Generated: ${post.diagrams.length}`);
|
||||
console.log("--------------------------------------------------");
|
||||
|
||||
// Save to file
|
||||
const outputPath = path.join(__dirname, "output.md");
|
||||
fs.writeFileSync(outputPath, post.content);
|
||||
console.log(`📄 Saved output to: ${outputPath}`);
|
||||
} catch (error) {
|
||||
console.error("❌ Generation failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
58
packages/content-engine/examples/optimize-post.ts
Normal file
58
packages/content-engine/examples/optimize-post.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { ContentGenerator } from "../src/index";
|
||||
import dotenv from "dotenv";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
// Fix __dirname for ESM
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Load .env from mintel.me (since that's where the key is)
|
||||
dotenv.config({
|
||||
path: path.resolve(__dirname, "../../../../mintel.me/apps/web/.env"),
|
||||
});
|
||||
|
||||
async function main() {
|
||||
const apiKey = process.env.OPENROUTER_API_KEY || process.env.OPENROUTER_KEY;
|
||||
if (!apiKey) {
|
||||
console.error("❌ OPENROUTER_API_KEY not found");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const generator = new ContentGenerator(apiKey);
|
||||
|
||||
const draftContent = `# The Case for Static Sites
|
||||
|
||||
Static sites are faster and more secure. They don't have a database to hack.
|
||||
They are also cheaper to host. You can use a CDN to serve them globally.
|
||||
Dynamic sites are complex and prone to errors.`;
|
||||
|
||||
console.log("📄 Original Content:");
|
||||
console.log(draftContent);
|
||||
console.log("\n🚀 Optimizing content...\n");
|
||||
|
||||
try {
|
||||
const post = await generator.optimizePost(draftContent, {
|
||||
enhanceFacts: true,
|
||||
addDiagrams: true,
|
||||
addMemes: true,
|
||||
});
|
||||
|
||||
console.log("\n\n✅ OPTIMIZATION COMPLETE");
|
||||
console.log("--------------------------------------------------");
|
||||
console.log(`Research Points Added: ${post.research.length}`);
|
||||
console.log(`Memes Generated: ${post.memes.length}`);
|
||||
console.log(`Diagrams Generated: ${post.diagrams.length}`);
|
||||
console.log("--------------------------------------------------");
|
||||
|
||||
// Save to file
|
||||
const outputPath = path.join(__dirname, "optimized.md");
|
||||
fs.writeFileSync(outputPath, post.content);
|
||||
console.log(`📄 Saved output to: ${outputPath}`);
|
||||
} catch (error) {
|
||||
console.error("❌ Optimization failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
132
packages/content-engine/examples/optimize-vendor-lockin.ts
Normal file
132
packages/content-engine/examples/optimize-vendor-lockin.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { ContentGenerator, ComponentDefinition } from "../src/index";
|
||||
import dotenv from "dotenv";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
// Fix __dirname for ESM
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Load .env from mintel.me
|
||||
dotenv.config({
|
||||
path: path.resolve(__dirname, "../../../../mintel.me/apps/web/.env"),
|
||||
});
|
||||
|
||||
async function main() {
|
||||
const apiKey = process.env.OPENROUTER_API_KEY || process.env.OPENROUTER_KEY;
|
||||
if (!apiKey) {
|
||||
console.error("❌ OPENROUTER_API_KEY not found");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const generator = new ContentGenerator(apiKey);
|
||||
|
||||
const contentToOptimize = `
|
||||
"Wir können nicht wechseln, das wäre zu teuer."
|
||||
In meiner Arbeit als Digital Architect ist das der Anfang vom Ende jeder technologischen Innovation.
|
||||
Vendor Lock-In ist die digitale Version einer Geiselnahme.
|
||||
Ich zeige Ihnen, wie wir Systeme bauen, die Ihnen jederzeit die volle Freiheit lassen – technologisch und wirtschaftlich.
|
||||
|
||||
Die unsichtbaren Ketten proprietärer Systeme
|
||||
Viele Unternehmen lassen sich von der Bequemlichkeit großer SaaS-Plattformen oder Baukästen blenden.
|
||||
Man bekommt ein schnelles Feature, gibt aber dafür die Kontrolle über seine Daten und seine Codebasis ab.
|
||||
Nach zwei Jahren sind Sie so tief im Ökosystem eines Anbieters verstrickt, dass ein Auszug unmöglich scheint.
|
||||
Der Anbieter weiß das – und diktiert fortan die Preise und das Tempo Ihrer Entwicklung.
|
||||
Ich nenne das technologische Erpressbarkeit.
|
||||
Wahre Unabhängigkeit beginnt bei der strategischen Wahl der Architektur.
|
||||
|
||||
Technologische Souveränität als Asset
|
||||
Software sollte für Sie arbeiten, nicht umgekehrt.
|
||||
Indem wir auf offene Standards und portable Architekturen setzen, verwandeln wir Code in ein echtes Firmen-Asset.
|
||||
Sie können den Cloud-Anbieter wechseln, die Agentur tauschen oder das Team skalieren – ohne jemals bei Null anfangen zu müssen.
|
||||
Das ist das Privileg der technologischen Elite.
|
||||
Portabilität ist kein technisches Gimmick, sondern eine unternehmerische Notwendigkeit.
|
||||
|
||||
Meine Architektur der Ungebundenheit
|
||||
Ich baue keine "Käfige" aus fertigen Plugins.
|
||||
Mein Framework basiert auf Modularität und Klarheit.
|
||||
|
||||
Standard-basiertes Engineering: Wir nutzen Technologien, die weltweit verstanden werden. Keine geheimen "Spezial-Module" eines einzelnen Anbieters.
|
||||
Daten-Portabilität: Ihre Daten gehören Ihnen. Zu jeder Zeit. Wir bauen Schnittstellen, die den Export so einfach machen wie den Import.
|
||||
Cloud-agnostisches Hosting: Wir nutzen Container-Technologie. Ob AWS, Azure oder lokale Anbieter – Ihr Code läuft überall gleich perfekt.
|
||||
|
||||
Der strategische Hebel für langfristige Rendite
|
||||
Systeme ohne Lock-In altern besser.
|
||||
Sie lassen sich schrittweise modernisieren, statt alle fünf Jahre komplett neu gebaut werden zu müssen.
|
||||
Das spart Millionen an Opportunitätskosten und Fehl-Investitionen.
|
||||
Seien Sie der Herr über Ihr digitales Schicksal.
|
||||
Investieren Sie in intelligente Unabhängigkeit.
|
||||
|
||||
Für wen ich 'Freiheits-Systeme' erstelle
|
||||
Ich arbeite für Gründer, die ihr Unternehmen langfristig wertvoll aufstellen wollen.
|
||||
Ist digitale Exzellenz Teil Ihrer Exit-Strategie oder Ihres Erbes? Dann brauchen Sie meine Architektur.
|
||||
Ich baue keine Provisorien, sondern nachhaltige Werte.
|
||||
|
||||
Fazit: Freiheit ist eine Wahl
|
||||
Technologie sollte Ihnen Flügel verleihen, keine Fesseln anlegen.
|
||||
Lassen Sie uns gemeinsam ein System schaffen, das so flexibel ist wie Ihr Business.
|
||||
Werden Sie unersetzbar durch Qualität, nicht durch Abhängigkeit. Ihr Erfolg verdient absolute Freiheit.
|
||||
`;
|
||||
|
||||
// Define components available in mintel.me
|
||||
const availableComponents: ComponentDefinition[] = [
|
||||
{
|
||||
name: "LeadParagraph",
|
||||
description: "Large, introductory text for the beginning of the article.",
|
||||
usageExample: "<LeadParagraph>First meaningful sentence.</LeadParagraph>",
|
||||
},
|
||||
{
|
||||
name: "H2",
|
||||
description: "Section heading.",
|
||||
usageExample: "<H2>Section Title</H2>",
|
||||
},
|
||||
{
|
||||
name: "H3",
|
||||
description: "Subsection heading.",
|
||||
usageExample: "<H3>Subtitle</H3>",
|
||||
},
|
||||
{
|
||||
name: "Paragraph",
|
||||
description: "Standard body text paragraph.",
|
||||
usageExample: "<Paragraph>Some text...</Paragraph>",
|
||||
},
|
||||
{
|
||||
name: "ArticleBlockquote",
|
||||
description: "A prominent quote block for key insights.",
|
||||
usageExample: "<ArticleBlockquote>Important quote</ArticleBlockquote>",
|
||||
},
|
||||
{
|
||||
name: "Marker",
|
||||
description: "Yellow highlighter effect for very important phrases.",
|
||||
usageExample: "<Marker>Highlighted Text</Marker>",
|
||||
},
|
||||
{
|
||||
name: "ComparisonRow",
|
||||
description: "A component comparing a negative vs positive scenario.",
|
||||
usageExample:
|
||||
'<ComparisonRow description="Cost Comparison" negativeLabel="Lock-In" negativeText="High costs" positiveLabel="Open" positiveText="Control" />',
|
||||
},
|
||||
];
|
||||
|
||||
console.log('🚀 Optimizing "Vendor Lock-In" post...');
|
||||
|
||||
try {
|
||||
const post = await generator.optimizePost(contentToOptimize, {
|
||||
enhanceFacts: true,
|
||||
addDiagrams: true,
|
||||
addMemes: true,
|
||||
availableComponents,
|
||||
});
|
||||
|
||||
console.log("\n\n✅ OPTIMIZATION COMPLETE");
|
||||
// Save to a file in the package dir
|
||||
const outputPath = path.join(__dirname, "VendorLockIn_OPTIMIZED.md");
|
||||
fs.writeFileSync(outputPath, post.content);
|
||||
console.log(`📄 Saved output to: ${outputPath}`);
|
||||
} catch (error) {
|
||||
console.error("❌ Optimization failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
71
packages/content-engine/examples/optimize-with-components.ts
Normal file
71
packages/content-engine/examples/optimize-with-components.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { ContentGenerator, ComponentDefinition } from "../src/index";
|
||||
import dotenv from "dotenv";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
// Fix __dirname for ESM
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Load .env from mintel.me (since that's where the key is)
|
||||
dotenv.config({
|
||||
path: path.resolve(__dirname, "../../../../mintel.me/apps/web/.env"),
|
||||
});
|
||||
|
||||
async function main() {
|
||||
const apiKey = process.env.OPENROUTER_API_KEY || process.env.OPENROUTER_KEY;
|
||||
if (!apiKey) {
|
||||
console.error("❌ OPENROUTER_API_KEY not found");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const generator = new ContentGenerator(apiKey);
|
||||
|
||||
const draftContent = `# Improving User Retention
|
||||
|
||||
User retention is key. You need to keep users engaged.
|
||||
Offer them value and they will stay.
|
||||
If they have questions, they should contact support.`;
|
||||
|
||||
const availableComponents: ComponentDefinition[] = [
|
||||
{
|
||||
name: "InfoCard",
|
||||
description: "A colored box to highlight important tips or warnings.",
|
||||
usageExample:
|
||||
'<InfoCard variant="warning" title="Pro Tip">Always measure retention.</InfoCard>',
|
||||
},
|
||||
{
|
||||
name: "CallToAction",
|
||||
description: "A prominent button for conversion.",
|
||||
usageExample: '<CallToAction href="/contact">Get in Touch</CallToAction>',
|
||||
},
|
||||
];
|
||||
|
||||
console.log("📄 Original Content:");
|
||||
console.log(draftContent);
|
||||
console.log("\n🚀 Optimizing content with components...\n");
|
||||
|
||||
try {
|
||||
const post = await generator.optimizePost(draftContent, {
|
||||
enhanceFacts: true,
|
||||
addDiagrams: false, // Skip diagrams for this test to focus on components
|
||||
addMemes: false,
|
||||
availableComponents,
|
||||
});
|
||||
|
||||
console.log("\n\n✅ OPTIMIZATION COMPLETE");
|
||||
console.log("--------------------------------------------------");
|
||||
console.log(post.content);
|
||||
console.log("--------------------------------------------------");
|
||||
|
||||
// Save to file
|
||||
const outputPath = path.join(__dirname, "optimized-components.md");
|
||||
fs.writeFileSync(outputPath, post.content);
|
||||
console.log(`📄 Saved output to: ${outputPath}`);
|
||||
} catch (error) {
|
||||
console.error("❌ Optimization failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
34
packages/content-engine/package.json
Normal file
34
packages/content-engine/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "@mintel/content-engine",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup src/index.ts --format esm --dts --clean",
|
||||
"dev": "tsup src/index.ts --format esm --watch --dts",
|
||||
"lint": "eslint src"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mintel/journaling": "workspace:*",
|
||||
"@mintel/meme-generator": "workspace:*",
|
||||
"@mintel/thumbnail-generator": "workspace:*",
|
||||
"dotenv": "^17.3.1",
|
||||
"openai": "^4.82.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mintel/eslint-config": "workspace:*",
|
||||
"@mintel/tsconfig": "workspace:*",
|
||||
"@types/node": "^20.0.0",
|
||||
"tsup": "^8.3.5",
|
||||
"typescript": "^5.0.0"
|
||||
}
|
||||
}
|
||||
990
packages/content-engine/src/generator.ts
Normal file
990
packages/content-engine/src/generator.ts
Normal file
@@ -0,0 +1,990 @@
|
||||
import OpenAI from "openai";
|
||||
import { ResearchAgent, type Fact, type SocialPost } from "@mintel/journaling";
|
||||
import { MemeGenerator, MemeSuggestion } from "@mintel/meme-generator";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
|
||||
export interface ComponentDefinition {
|
||||
name: string;
|
||||
description: string;
|
||||
usageExample: string;
|
||||
}
|
||||
|
||||
export interface BlogPostOptions {
|
||||
topic: string;
|
||||
tone?: string;
|
||||
targetAudience?: string;
|
||||
includeMemes?: boolean;
|
||||
includeDiagrams?: boolean;
|
||||
includeResearch?: boolean;
|
||||
availableComponents?: ComponentDefinition[];
|
||||
}
|
||||
|
||||
export interface OptimizationOptions {
|
||||
enhanceFacts?: boolean;
|
||||
addMemes?: boolean;
|
||||
addDiagrams?: boolean;
|
||||
availableComponents?: ComponentDefinition[];
|
||||
projectContext?: string;
|
||||
/** Target audience description for all AI prompts */
|
||||
targetAudience?: string;
|
||||
/** Tone/persona description for all AI prompts */
|
||||
tone?: string;
|
||||
/** Prompt for DALL-E 3 style generation */
|
||||
memeStylePrompt?: string;
|
||||
/** Path to the docs folder (e.g. apps/web/docs) for full persona/tone context */
|
||||
docsPath?: string;
|
||||
}
|
||||
|
||||
export interface GeneratedPost {
|
||||
title: string;
|
||||
content: string;
|
||||
research: Fact[];
|
||||
memes: MemeSuggestion[];
|
||||
diagrams: string[];
|
||||
}
|
||||
|
||||
interface Insertion {
|
||||
afterSection: number;
|
||||
content: string;
|
||||
}
|
||||
|
||||
// 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",
|
||||
// Mermaid diagram generation - User requested Pro
|
||||
DIAGRAM: "google/gemini-2.5-pro",
|
||||
} as const;
|
||||
|
||||
/** Strip markdown fences that some models wrap around JSON despite response_format */
|
||||
function safeParseJSON(raw: string, fallback: any = {}): any {
|
||||
let cleaned = raw.trim();
|
||||
// Remove ```json ... ``` or ``` ... ``` wrapping
|
||||
if (cleaned.startsWith("```")) {
|
||||
cleaned = cleaned
|
||||
.replace(/^```(?:json)?\s*\n?/, "")
|
||||
.replace(/\n?```\s*$/, "");
|
||||
}
|
||||
try {
|
||||
return JSON.parse(cleaned);
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
"⚠️ Failed to parse JSON response, using fallback:",
|
||||
(e as Error).message,
|
||||
);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export class ContentGenerator {
|
||||
private openai: OpenAI;
|
||||
private researchAgent: ResearchAgent;
|
||||
private memeGenerator: MemeGenerator;
|
||||
|
||||
constructor(apiKey: string) {
|
||||
this.openai = new OpenAI({
|
||||
apiKey,
|
||||
baseURL: "https://openrouter.ai/api/v1",
|
||||
defaultHeaders: {
|
||||
"HTTP-Referer": "https://mintel.me",
|
||||
"X-Title": "Mintel Content Engine",
|
||||
},
|
||||
});
|
||||
this.researchAgent = new ResearchAgent(apiKey);
|
||||
this.memeGenerator = new MemeGenerator(apiKey);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// generatePost — for new posts (unchanged from original)
|
||||
// =========================================================================
|
||||
async generatePost(options: BlogPostOptions): Promise<GeneratedPost> {
|
||||
const {
|
||||
topic,
|
||||
tone = "professional yet witty",
|
||||
includeResearch = true,
|
||||
availableComponents = [],
|
||||
} = options;
|
||||
console.log(`🚀 Starting content generation for: "${topic}"`);
|
||||
|
||||
let facts: Fact[] = [];
|
||||
if (includeResearch) {
|
||||
console.log("📚 Gathering research...");
|
||||
facts = await this.researchAgent.researchTopic(topic);
|
||||
}
|
||||
|
||||
console.log("📝 Creating outline...");
|
||||
const outline = await this.createOutline(topic, facts, tone);
|
||||
|
||||
console.log("✍️ Drafting content...");
|
||||
let content = await this.draftContent(
|
||||
topic,
|
||||
outline,
|
||||
facts,
|
||||
tone,
|
||||
availableComponents,
|
||||
);
|
||||
|
||||
const diagrams: string[] = [];
|
||||
if (options.includeDiagrams) {
|
||||
content = await this.processDiagramPlaceholders(content, diagrams);
|
||||
}
|
||||
|
||||
const memes: MemeSuggestion[] = [];
|
||||
if (options.includeMemes) {
|
||||
const memeIdeas = await this.memeGenerator.generateMemeIdeas(
|
||||
content.slice(0, 4000),
|
||||
);
|
||||
memes.push(...memeIdeas);
|
||||
}
|
||||
|
||||
return { title: outline.title, content, research: facts, memes, diagrams };
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// generateTldr — Creates a TL;DR block for the given content
|
||||
// =========================================================================
|
||||
async generateTldr(content: string): Promise<string> {
|
||||
const context = content.slice(0, 3000);
|
||||
const response = await this.openai.chat.completions.create({
|
||||
model: MODELS.CONTENT,
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: `Du bist ein kompromissloser Digital Architect.
|
||||
Erstelle ein "TL;DR" für diesen Artikel.
|
||||
|
||||
REGELN:
|
||||
- 3 knackige Bulletpoints
|
||||
- TON: Sarkastisch, direkt, provokant ("Finger in die Wunde")
|
||||
- Fokussiere auf den wirtschaftlichen Schaden von schlechter Tech
|
||||
- Formatiere als MDX-Komponente:
|
||||
<div className="my-8 p-6 bg-slate-50 border-l-4 border-blue-600 rounded-r-xl">
|
||||
<H3>TL;DR: Warum Ihr Geld verbrennt</H3>
|
||||
<ul className="list-disc pl-5 space-y-2 mb-0">
|
||||
<li>Punkt 1</li>
|
||||
<li>Punkt 2</li>
|
||||
<li>Punkt 3</li>
|
||||
</ul>
|
||||
</div>`,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: context,
|
||||
},
|
||||
],
|
||||
});
|
||||
return response.choices[0].message.content?.trim() ?? "";
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// optimizePost — ADDITIVE architecture (never rewrites original content)
|
||||
// =========================================================================
|
||||
async optimizePost(
|
||||
content: string,
|
||||
options: OptimizationOptions,
|
||||
): Promise<GeneratedPost> {
|
||||
console.log("🚀 Optimizing existing content (additive mode)...");
|
||||
|
||||
// Load docs context if provided
|
||||
let docsContext = "";
|
||||
if (options.docsPath) {
|
||||
docsContext = await this.loadDocsContext(options.docsPath);
|
||||
console.log(`📖 Loaded ${docsContext.length} chars of docs context`);
|
||||
}
|
||||
|
||||
const fullContext = [options.projectContext || "", docsContext]
|
||||
.filter(Boolean)
|
||||
.join("\n\n---\n\n");
|
||||
|
||||
// Split content into numbered sections for programmatic insertion
|
||||
const sections = this.splitIntoSections(content);
|
||||
console.log(`📋 Content has ${sections.length} sections`);
|
||||
|
||||
const insertions: Insertion[] = [];
|
||||
const facts: Fact[] = [];
|
||||
const diagrams: string[] = [];
|
||||
const memes: MemeSuggestion[] = [];
|
||||
|
||||
// Build a numbered content map for LLM reference (read-only)
|
||||
const sectionMap = this.buildSectionMap(sections);
|
||||
|
||||
// ----- STEP 1: Research -----
|
||||
if (options.enhanceFacts) {
|
||||
console.log("🔍 Identifying research topics...");
|
||||
const researchTopics = await this.identifyResearchTopics(
|
||||
content,
|
||||
fullContext,
|
||||
);
|
||||
console.log(`📚 Researching: ${researchTopics.join(", ")}`);
|
||||
|
||||
for (const topic of researchTopics) {
|
||||
const topicFacts = await this.researchAgent.researchTopic(topic);
|
||||
facts.push(...topicFacts);
|
||||
}
|
||||
|
||||
if (facts.length > 0) {
|
||||
console.log(`📝 Planning fact insertions for ${facts.length} facts...`);
|
||||
const factInsertions = await this.planFactInsertions(
|
||||
sectionMap,
|
||||
sections,
|
||||
facts,
|
||||
fullContext,
|
||||
);
|
||||
insertions.push(...factInsertions);
|
||||
console.log(` → ${factInsertions.length} fact enrichments planned`);
|
||||
}
|
||||
|
||||
// ----- STEP 1.5: Social Media Extraction (no LLM — regex only) -----
|
||||
console.log("📱 Extracting existing social media embeds...");
|
||||
const socialPosts = this.researchAgent.extractSocialPosts(content);
|
||||
|
||||
// If none exist, fetch real ones via Serper API
|
||||
if (socialPosts.length === 0) {
|
||||
console.log(
|
||||
" → None found. Fetching real social posts via Serper API...",
|
||||
);
|
||||
const newPosts = await this.researchAgent.fetchRealSocialPosts(
|
||||
content.slice(0, 500),
|
||||
);
|
||||
socialPosts.push(...newPosts);
|
||||
}
|
||||
|
||||
if (socialPosts.length > 0) {
|
||||
console.log(
|
||||
`📝 Planning placement for ${socialPosts.length} social media posts...`,
|
||||
);
|
||||
const socialInsertions = await this.planSocialMediaInsertions(
|
||||
sectionMap,
|
||||
sections,
|
||||
socialPosts,
|
||||
fullContext,
|
||||
);
|
||||
insertions.push(...socialInsertions);
|
||||
console.log(
|
||||
` → ${socialInsertions.length} social embeddings planned`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ----- STEP 2: Component suggestions -----
|
||||
if (options.availableComponents && options.availableComponents.length > 0) {
|
||||
console.log("🧩 Planning component additions...");
|
||||
const componentInsertions = await this.planComponentInsertions(
|
||||
sectionMap,
|
||||
sections,
|
||||
options.availableComponents,
|
||||
fullContext,
|
||||
);
|
||||
insertions.push(...componentInsertions);
|
||||
console.log(
|
||||
` → ${componentInsertions.length} component additions planned`,
|
||||
);
|
||||
}
|
||||
|
||||
// ----- STEP 3: Diagram generation -----
|
||||
if (options.addDiagrams) {
|
||||
console.log("📊 Planning diagrams...");
|
||||
const diagramPlans = await this.planDiagramInsertions(
|
||||
sectionMap,
|
||||
sections,
|
||||
fullContext,
|
||||
);
|
||||
|
||||
for (const plan of diagramPlans) {
|
||||
const mermaidCode = await this.generateMermaid(plan.concept);
|
||||
if (!mermaidCode) {
|
||||
console.warn(` ⏭️ Skipping invalid diagram for: "${plan.concept}"`);
|
||||
continue;
|
||||
}
|
||||
diagrams.push(mermaidCode);
|
||||
const diagramId = plan.concept
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/[^a-z0-9-]/g, "")
|
||||
.slice(0, 40);
|
||||
insertions.push({
|
||||
afterSection: plan.afterSection,
|
||||
content: `<div className="my-8">\n <Mermaid id="${diagramId}" title="${plan.concept}" showShare={true}>\n${mermaidCode}\n </Mermaid>\n</div>`,
|
||||
});
|
||||
}
|
||||
console.log(
|
||||
` → ${diagramPlans.length} diagrams planned, ${diagrams.length} valid`,
|
||||
);
|
||||
}
|
||||
|
||||
// ----- STEP 4: Meme placement (memegen.link via ArticleMeme) -----
|
||||
if (options.addMemes) {
|
||||
console.log("✨ Generating meme ideas...");
|
||||
let memeIdeas = await this.memeGenerator.generateMemeIdeas(
|
||||
content.slice(0, 4000),
|
||||
);
|
||||
|
||||
// User requested to explicitly limit memes to max 1 per page to prevent duplication
|
||||
if (memeIdeas.length > 1) {
|
||||
memeIdeas = [memeIdeas[0]];
|
||||
}
|
||||
|
||||
memes.push(...memeIdeas);
|
||||
|
||||
if (memeIdeas.length > 0) {
|
||||
console.log(
|
||||
`🎨 Planning meme placement for ${memeIdeas.length} memes...`,
|
||||
);
|
||||
const memePlacements = await this.planMemePlacements(
|
||||
sectionMap,
|
||||
sections,
|
||||
memeIdeas,
|
||||
);
|
||||
|
||||
for (let i = 0; i < memeIdeas.length; i++) {
|
||||
const meme = memeIdeas[i];
|
||||
if (
|
||||
memePlacements[i] !== undefined &&
|
||||
memePlacements[i] >= 0 &&
|
||||
memePlacements[i] < sections.length
|
||||
) {
|
||||
const captionsStr = meme.captions.join("|");
|
||||
insertions.push({
|
||||
afterSection: memePlacements[i],
|
||||
content: `<div className="my-8">\n <ArticleMeme template="${meme.template}" captions="${captionsStr}" />\n</div>`,
|
||||
});
|
||||
}
|
||||
}
|
||||
console.log(` → ${memeIdeas.length} memes placed`);
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Enforce visual spacing (no consecutive visualizations) -----
|
||||
this.enforceVisualSpacing(insertions, sections);
|
||||
|
||||
// ----- Apply all insertions to original content -----
|
||||
console.log(
|
||||
`\n🔧 Applying ${insertions.length} insertions to original content...`,
|
||||
);
|
||||
let optimizedContent = this.applyInsertions(sections, insertions);
|
||||
|
||||
// ----- FINAL AGENTIC REWRITE (Replaces dumb regex scripts) -----
|
||||
console.log(
|
||||
`\n🧠 Agentic Rewrite: Polishing MDX, fixing syntax, and deduplicating...`,
|
||||
);
|
||||
const finalRewrite = await this.openai.chat.completions.create({
|
||||
model: MODELS.CONTENT,
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: `You are an expert MDX Editor. Your task is to take a draft blog post and output the FINAL, error-free MDX code.
|
||||
|
||||
CRITICAL RULES:
|
||||
1. DEDUPLICATION: Ensure there is MAX ONE <ArticleMeme> in the entire post. Remove any duplicates or outdated memes. Ensure there is MAX ONE TL;DR section. Ensure there are no duplicate components.
|
||||
2. TEXT-TO-COMPONENT RATIO: Ensure there are at least 3-4 paragraphs of normal text between any two visual components (<Mermaid>, <ArticleMeme>, <StatsGrid>, <BoldNumber>, etc.). If they are clumped together, spread them out or delete the less important ones.
|
||||
3. SYNTAX: Fix any broken Mermaid/MDX syntax (e.g. unclosed tags, bad quotes).
|
||||
4. FIDELITY: Preserve the author's original German text, meaning, and tone. Smooth out transitions into the components.
|
||||
5. NO HALLUCINATION: Do not invent new URLs or facts. Keep the data provided in the draft.
|
||||
6. OUTPUT: Return ONLY the raw MDX content. No markdown code blocks (\`\`\`mdx), no preamble. Just the raw code file.`,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: optimizedContent,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
optimizedContent =
|
||||
finalRewrite.choices[0].message.content?.trim() || optimizedContent;
|
||||
|
||||
// Strip any residual markdown formatting fences just in case
|
||||
if (optimizedContent.startsWith("```")) {
|
||||
optimizedContent = optimizedContent
|
||||
.replace(/^```[a-zA-Z]*\n/, "")
|
||||
.replace(/\n```$/, "");
|
||||
}
|
||||
|
||||
return {
|
||||
title: "Optimized Content",
|
||||
content: optimizedContent,
|
||||
research: facts,
|
||||
memes,
|
||||
diagrams,
|
||||
};
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// ADDITIVE HELPERS — these return JSON instructions, never rewrite content
|
||||
// =========================================================================
|
||||
|
||||
private splitIntoSections(content: string): string[] {
|
||||
// Split on double newlines (paragraph/block boundaries in MDX)
|
||||
return content.split(/\n\n+/);
|
||||
}
|
||||
|
||||
private applyInsertions(sections: string[], insertions: Insertion[]): string {
|
||||
// Sort by section index DESCENDING to avoid index shifting
|
||||
const sorted = [...insertions].sort(
|
||||
(a, b) => b.afterSection - a.afterSection,
|
||||
);
|
||||
const result = [...sections];
|
||||
for (const ins of sorted) {
|
||||
const idx = Math.min(ins.afterSection + 1, result.length);
|
||||
result.splice(idx, 0, ins.content);
|
||||
}
|
||||
return result.join("\n\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce visual spacing: visual components must have at least 2 text sections between them.
|
||||
* This prevents walls of visualizations and maintains reading flow.
|
||||
*/
|
||||
private enforceVisualSpacing(
|
||||
insertions: Insertion[],
|
||||
sections: string[],
|
||||
): void {
|
||||
const visualPatterns = [
|
||||
"<Mermaid",
|
||||
"<ArticleMeme",
|
||||
"<StatsGrid",
|
||||
"<StatsDisplay",
|
||||
"<BoldNumber",
|
||||
"<MetricBar",
|
||||
"<ComparisonRow",
|
||||
"<PremiumComparisonChart",
|
||||
"<DiagramFlow",
|
||||
"<DiagramPie",
|
||||
"<DiagramGantt",
|
||||
"<DiagramState",
|
||||
"<DiagramSequence",
|
||||
"<DiagramTimeline",
|
||||
"<Carousel",
|
||||
"<WebVitalsScore",
|
||||
"<WaterfallChart",
|
||||
];
|
||||
const isVisual = (content: string) =>
|
||||
visualPatterns.some((p) => content.includes(p));
|
||||
|
||||
// Sort by section ascending
|
||||
insertions.sort((a, b) => a.afterSection - b.afterSection);
|
||||
|
||||
// Minimum gap of 10 sections between visual components (= ~6-8 text paragraphs)
|
||||
// User requested a better text-to-component ratio (not 1:1)
|
||||
const MIN_VISUAL_GAP = 10;
|
||||
|
||||
for (let i = 1; i < insertions.length; i++) {
|
||||
if (
|
||||
isVisual(insertions[i].content) &&
|
||||
isVisual(insertions[i - 1].content)
|
||||
) {
|
||||
const gap = insertions[i].afterSection - insertions[i - 1].afterSection;
|
||||
if (gap < MIN_VISUAL_GAP) {
|
||||
const newPos = Math.min(
|
||||
insertions[i - 1].afterSection + MIN_VISUAL_GAP,
|
||||
sections.length - 1,
|
||||
);
|
||||
insertions[i].afterSection = newPos;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private buildSectionMap(sections: string[]): string {
|
||||
return sections
|
||||
.map((s, i) => {
|
||||
const preview = s.trim().replace(/\n/g, " ").slice(0, 120);
|
||||
return `[${i}] ${preview}${s.length > 120 ? "…" : ""}`;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
private async loadDocsContext(docsPath: string): Promise<string> {
|
||||
try {
|
||||
const files = await fs.readdir(docsPath);
|
||||
const mdFiles = files.filter((f) => f.endsWith(".md")).sort();
|
||||
const contents: string[] = [];
|
||||
|
||||
for (const file of mdFiles) {
|
||||
const filePath = path.join(docsPath, file);
|
||||
const text = await fs.readFile(filePath, "utf8");
|
||||
contents.push(`=== ${file} ===\n${text.trim()}`);
|
||||
}
|
||||
|
||||
return contents.join("\n\n");
|
||||
} catch (e) {
|
||||
console.warn(`⚠️ Could not load docs from ${docsPath}: ${e}`);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
// --- Fact insertion planning (Claude Sonnet — precise content understanding) ---
|
||||
private async planFactInsertions(
|
||||
sectionMap: string,
|
||||
sections: string[],
|
||||
facts: Fact[],
|
||||
context: string,
|
||||
): Promise<Insertion[]> {
|
||||
const factsText = facts
|
||||
.map((f, i) => `${i + 1}. ${f.statement} [Source: ${f.source}]`)
|
||||
.join("\n");
|
||||
|
||||
const response = await this.openai.chat.completions.create({
|
||||
model: MODELS.CONTENT,
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: `You enrich a German blog post by ADDING new paragraphs with researched facts.
|
||||
|
||||
RULES:
|
||||
- Do NOT rewrite or modify any existing content
|
||||
- Only produce NEW <Paragraph> blocks to INSERT after a specific section number
|
||||
- Maximum 5 insertions (only the most impactful facts)
|
||||
- Match the post's tone and style (see context below)
|
||||
- Use the post's JSX components: <Paragraph>, <Marker> for emphasis
|
||||
- Cite sources using ExternalLink: <ExternalLink href="URL">Source: Name</ExternalLink>
|
||||
- Write in German, active voice, Ich-Form where appropriate
|
||||
|
||||
CONTEXT (tone, style, persona):
|
||||
${context.slice(0, 3000)}
|
||||
|
||||
EXISTING SECTIONS (read-only — do NOT modify these):
|
||||
${sectionMap}
|
||||
|
||||
FACTS TO INTEGRATE:
|
||||
${factsText}
|
||||
|
||||
Return JSON:
|
||||
{ "insertions": [{ "afterSection": 3, "content": "<Paragraph>\\n Fact-enriched paragraph text. [Source: Name]\\n</Paragraph>" }] }
|
||||
Return ONLY the JSON.`,
|
||||
},
|
||||
],
|
||||
response_format: { type: "json_object" },
|
||||
});
|
||||
|
||||
const result = safeParseJSON(
|
||||
response.choices[0].message.content || '{"insertions": []}',
|
||||
{ insertions: [] },
|
||||
);
|
||||
return (result.insertions || []).filter(
|
||||
(i: any) =>
|
||||
typeof i.afterSection === "number" &&
|
||||
i.afterSection >= 0 &&
|
||||
i.afterSection < sections.length &&
|
||||
typeof i.content === "string",
|
||||
);
|
||||
}
|
||||
|
||||
// --- Social Media insertion planning ---
|
||||
private async planSocialMediaInsertions(
|
||||
sectionMap: string,
|
||||
sections: string[],
|
||||
posts: SocialPost[],
|
||||
context: string,
|
||||
): Promise<Insertion[]> {
|
||||
if (!posts || posts.length === 0) return [];
|
||||
|
||||
const postsText = posts
|
||||
.map(
|
||||
(p, i) =>
|
||||
`[${i}] Platform: ${p.platform}, ID: ${p.embedId} (${p.description})`,
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
const response = await this.openai.chat.completions.create({
|
||||
model: MODELS.CONTENT,
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: `You enhance a German blog post by embedding relevant social media posts (YouTube, Twitter, LinkedIn).
|
||||
|
||||
RULES:
|
||||
- Do NOT rewrite any existing content
|
||||
- Return exactly 1 or 2 high-impact insertions
|
||||
- Choose the best fitting post(s) from the provided list
|
||||
- Use the correct component based on the platform:
|
||||
- youtube -> <YouTubeEmbed videoId="ID" />
|
||||
- twitter -> <TwitterEmbed tweetId="ID" theme="light" />
|
||||
- linkedin -> <LinkedInEmbed urn="ID" />
|
||||
- Add a 1-sentence intro paragraph above the embed to contextualize it naturally in the flow of the text (e.g. "Wie Experte XY im folgenden Video detailliert erklärt:"). This context is MANDATORY. Do not just drop the Component without text reference.
|
||||
|
||||
CONTEXT:
|
||||
${context.slice(0, 3000)}
|
||||
|
||||
SOCIAL POSTS AVAILABLE TO EMBED:
|
||||
${postsText}
|
||||
|
||||
EXISTING SECTIONS:
|
||||
${sectionMap}
|
||||
|
||||
Return JSON:
|
||||
{ "insertions": [{ "afterSection": 4, "content": "<Paragraph>Wie Experten passend bemerken:</Paragraph>\\n\\n<TwitterEmbed tweetId=\\"123456\\" theme=\\"light\\" />" }] }
|
||||
Return ONLY the JSON.`,
|
||||
},
|
||||
],
|
||||
response_format: { type: "json_object" },
|
||||
});
|
||||
|
||||
const result = safeParseJSON(
|
||||
response.choices[0].message.content || '{"insertions": []}',
|
||||
{ insertions: [] },
|
||||
);
|
||||
return (result.insertions || []).filter(
|
||||
(i: any) =>
|
||||
typeof i.afterSection === "number" &&
|
||||
i.afterSection >= 0 &&
|
||||
i.afterSection < sections.length &&
|
||||
typeof i.content === "string",
|
||||
);
|
||||
}
|
||||
|
||||
// --- Component insertion planning (Claude Sonnet — understands JSX context) ---
|
||||
private async planComponentInsertions(
|
||||
sectionMap: string,
|
||||
sections: string[],
|
||||
components: ComponentDefinition[],
|
||||
context: string,
|
||||
): Promise<Insertion[]> {
|
||||
const fullContent = sections.join("\n\n");
|
||||
const componentsText = components
|
||||
.map((c) => `<${c.name}>: ${c.description}\n Example: ${c.usageExample}`)
|
||||
.join("\n\n");
|
||||
const usedComponents = components
|
||||
.filter((c) => fullContent.includes(`<${c.name}`))
|
||||
.map((c) => c.name);
|
||||
|
||||
const response = await this.openai.chat.completions.create({
|
||||
model: MODELS.CONTENT,
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: `You enhance a German blog post by ADDING interactive UI components.
|
||||
|
||||
STRICT BALANCE RULES:
|
||||
- Maximum 3–4 component additions total
|
||||
- There MUST be at least 3–4 text paragraphs between any two visual components
|
||||
- Visual components MUST NEVER appear directly after each other
|
||||
- Each unique component type should only appear ONCE (e.g., only one WebVitalsScore, one WaterfallChart)
|
||||
- Multiple MetricBar or ComparisonRow in sequence are OK (they form a group)
|
||||
|
||||
CONTENT RULES:
|
||||
- Do NOT rewrite any existing content — only ADD new component blocks
|
||||
- Do NOT add components already present: ${usedComponents.join(", ") || "none"}
|
||||
- Statistics MUST have comparison context (before/after, competitor vs us) — never standalone numbers
|
||||
- All BoldNumber components MUST include source and sourceUrl props
|
||||
- All ArticleQuote components MUST include source and sourceUrl; add "(übersetzt)" if translated
|
||||
- MetricBar value must be a real number > 0, not placeholder zeros
|
||||
- Carousel items array must have at least 2 items with substantive content
|
||||
- Use exact JSX syntax from the examples
|
||||
|
||||
CONTEXT:
|
||||
${context.slice(0, 3000)}
|
||||
|
||||
EXISTING SECTIONS (read-only):
|
||||
${sectionMap}
|
||||
|
||||
AVAILABLE COMPONENTS:
|
||||
${componentsText}
|
||||
|
||||
Return JSON:
|
||||
{ "insertions": [{ "afterSection": 5, "content": "<StatsDisplay value=\\"100\\" label=\\"PageSpeed Score\\" subtext=\\"Kein Kompromiss.\\" />" }] }
|
||||
Return ONLY the JSON.`,
|
||||
},
|
||||
],
|
||||
response_format: { type: "json_object" },
|
||||
});
|
||||
|
||||
const result = safeParseJSON(
|
||||
response.choices[0].message.content || '{"insertions": []}',
|
||||
{ insertions: [] },
|
||||
);
|
||||
return (result.insertions || []).filter(
|
||||
(i: any) =>
|
||||
typeof i.afterSection === "number" &&
|
||||
i.afterSection >= 0 &&
|
||||
i.afterSection < sections.length &&
|
||||
typeof i.content === "string",
|
||||
);
|
||||
}
|
||||
|
||||
// --- Diagram planning (Gemini Flash — structured output) ---
|
||||
private async planDiagramInsertions(
|
||||
sectionMap: string,
|
||||
sections: string[],
|
||||
context: string,
|
||||
): Promise<{ afterSection: number; concept: string }[]> {
|
||||
const fullContent = sections.join("\n\n");
|
||||
const hasDiagrams =
|
||||
fullContent.includes("<Mermaid") || fullContent.includes("<Diagram");
|
||||
|
||||
const response = await this.openai.chat.completions.create({
|
||||
model: MODELS.STRUCTURED,
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: `Analyze this German blog post and suggest 1-2 Mermaid diagrams.
|
||||
${hasDiagrams ? "The post already has diagrams. Only suggest NEW concepts not already visualized." : ""}
|
||||
${context.slice(0, 1500)}
|
||||
|
||||
SECTIONS:
|
||||
${sectionMap}
|
||||
|
||||
Return JSON:
|
||||
{ "diagrams": [{ "afterSection": 5, "concept": "Descriptive concept name" }] }
|
||||
Maximum 2 diagrams. Return ONLY the JSON.`,
|
||||
},
|
||||
],
|
||||
response_format: { type: "json_object" },
|
||||
});
|
||||
|
||||
const result = safeParseJSON(
|
||||
response.choices[0].message.content || '{"diagrams": []}',
|
||||
{ diagrams: [] },
|
||||
);
|
||||
return (result.diagrams || []).filter(
|
||||
(d: any) =>
|
||||
typeof d.afterSection === "number" &&
|
||||
d.afterSection >= 0 &&
|
||||
d.afterSection < sections.length,
|
||||
);
|
||||
}
|
||||
|
||||
// --- Meme placement planning (Gemini Flash — structural positioning) ---
|
||||
private async planMemePlacements(
|
||||
sectionMap: string,
|
||||
sections: string[],
|
||||
memes: MemeSuggestion[],
|
||||
): Promise<number[]> {
|
||||
const memesText = memes
|
||||
.map((m, i) => `${i}: "${m.template}" — ${m.captions.join(" / ")}`)
|
||||
.join("\n");
|
||||
|
||||
const response = await this.openai.chat.completions.create({
|
||||
model: MODELS.STRUCTURED,
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: `Place ${memes.length} memes at appropriate positions in this blog post.
|
||||
Rules: Space them out evenly, place between thematic sections, never at position 0 (the very start).
|
||||
|
||||
SECTIONS:
|
||||
${sectionMap}
|
||||
|
||||
MEMES:
|
||||
${memesText}
|
||||
|
||||
Return JSON: { "placements": [sectionNumber, sectionNumber, ...] }
|
||||
One section number per meme, in the same order as the memes list. Return ONLY JSON.`,
|
||||
},
|
||||
],
|
||||
response_format: { type: "json_object" },
|
||||
});
|
||||
|
||||
const result = safeParseJSON(
|
||||
response.choices[0].message.content || '{"placements": []}',
|
||||
{ placements: [] },
|
||||
);
|
||||
return result.placements || [];
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// SHARED HELPERS
|
||||
// =========================================================================
|
||||
|
||||
private async createOutline(
|
||||
topic: string,
|
||||
facts: Fact[],
|
||||
tone: string,
|
||||
): Promise<{ title: string; sections: string[] }> {
|
||||
const factsContext = facts
|
||||
.map((f) => `- ${f.statement} (${f.source})`)
|
||||
.join("\n");
|
||||
const response = await this.openai.chat.completions.create({
|
||||
model: MODELS.STRUCTURED,
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: `Create a blog post outline on "${topic}".
|
||||
Tone: ${tone}.
|
||||
Incorporating these facts:
|
||||
${factsContext}
|
||||
|
||||
Return JSON: { "title": "Catchy Title", "sections": ["Introduction", "Section 1", "Conclusion"] }
|
||||
Return ONLY the JSON.`,
|
||||
},
|
||||
],
|
||||
response_format: { type: "json_object" },
|
||||
});
|
||||
return safeParseJSON(
|
||||
response.choices[0].message.content || '{"title": "", "sections": []}',
|
||||
{ title: "", sections: [] },
|
||||
);
|
||||
}
|
||||
|
||||
private async draftContent(
|
||||
topic: string,
|
||||
outline: { title: string; sections: string[] },
|
||||
facts: Fact[],
|
||||
tone: string,
|
||||
components: ComponentDefinition[],
|
||||
): Promise<string> {
|
||||
const factsContext = facts
|
||||
.map((f) => `- ${f.statement} (Source: ${f.source})`)
|
||||
.join("\n");
|
||||
const componentsContext =
|
||||
components.length > 0
|
||||
? `\n\nAvailable Components:\n` +
|
||||
components
|
||||
.map(
|
||||
(c) =>
|
||||
`- <${c.name}>: ${c.description}\n Example: ${c.usageExample}`,
|
||||
)
|
||||
.join("\n")
|
||||
: "";
|
||||
|
||||
const response = await this.openai.chat.completions.create({
|
||||
model: MODELS.CONTENT,
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: `Write a blog post based on this outline:
|
||||
Title: ${outline.title}
|
||||
Sections: ${outline.sections.join(", ")}
|
||||
|
||||
Tone: ${tone}.
|
||||
Facts: ${factsContext}
|
||||
${componentsContext}
|
||||
|
||||
BLOG POST BEST PRACTICES (MANDATORY):
|
||||
- DEVIL'S ADVOCATE: Füge zwingend eine kurze kritische Sektion ein (z.B. mit \`<ComparisonRow>\` oder \`<IconList>\`), in der du offen die Nachteile/Kosten/Haken deiner eigenen Lösung ansprichst ("Der Haken an der Sache...").
|
||||
- FAQ GENERATOR: Am absoluten Ende des Artikels erstellst du zwingend eine Markdown-Liste mit den 3 wichtigsten Fragen (FAQ) und Antworten (jeweils 2 Sätze) für Google Rich Snippets.
|
||||
- Nutze wo passend die obigen React-Komponenten für ein hochwertiges Layout.
|
||||
|
||||
Format as Markdown. Start with # H1.
|
||||
For places where a diagram would help, insert: <!-- DIAGRAM_PLACEHOLDER: Concept Name -->
|
||||
Return ONLY raw content.`,
|
||||
},
|
||||
],
|
||||
});
|
||||
return response.choices[0].message.content || "";
|
||||
}
|
||||
|
||||
private async processDiagramPlaceholders(
|
||||
content: string,
|
||||
diagrams: string[],
|
||||
): Promise<string> {
|
||||
const matches = content.matchAll(/<!-- DIAGRAM_PLACEHOLDER: (.+?) -->/g);
|
||||
let processedContent = content;
|
||||
|
||||
for (const match of Array.from(matches)) {
|
||||
const concept = match[1];
|
||||
const diagram = await this.generateMermaid(concept);
|
||||
diagrams.push(diagram);
|
||||
const diagramId = concept
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/[^a-z0-9-]/g, "")
|
||||
.slice(0, 40);
|
||||
const mermaidJsx = `\n<div className="my-8">\n <Mermaid id="${diagramId}" title="${concept}" showShare={true}>\n${diagram}\n </Mermaid>\n</div>\n`;
|
||||
processedContent = processedContent.replace(
|
||||
`<!-- DIAGRAM_PLACEHOLDER: ${concept} -->`,
|
||||
mermaidJsx,
|
||||
);
|
||||
}
|
||||
return processedContent;
|
||||
}
|
||||
|
||||
private async generateMermaid(concept: string): Promise<string> {
|
||||
const response = await this.openai.chat.completions.create({
|
||||
model: MODELS.DIAGRAM,
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: `Generate a Mermaid.js diagram for: "${concept}".
|
||||
|
||||
RULES:
|
||||
- Use clear labels in German where appropriate
|
||||
- Keep it EXTREMELY SIMPLE AND COMPACT: strictly max 3-4 nodes for a tiny visual footprint.
|
||||
- Prefer vertical layouts (TD) over horizontal (LR) to prevent wide overflowing graphs.
|
||||
- CRITICAL: Generate ONLY ONE single connected graph. Do NOT generate multiple independent graphs or isolated subgraphs in the same Mermaid block.
|
||||
- No nested subgraphs. Keep instructions short.
|
||||
- Use double-quoted labels for nodes: A["Label"]
|
||||
- VERY CRITICAL: DO NOT use curly braces '{}' or brackets '[]' inside labels unless they are wrapped in double quotes (e.g. A["Text {with braces}"]).
|
||||
- VERY CRITICAL: DO NOT use any HTML tags (no <br>, no <br/>, no <b>, etc).
|
||||
- VERY CRITICAL: DO NOT use special characters like '&', '<', '>', or double-quotes inside the label strings. They break the mermaid parser in our environment.
|
||||
- Return ONLY the raw mermaid code. No markdown blocks, no backticks.
|
||||
- The first line MUST be a valid mermaid diagram type: graph, flowchart, sequenceDiagram, pie, gantt, stateDiagram, timeline`,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const code =
|
||||
response.choices[0].message.content
|
||||
?.replace(/```mermaid/g, "")
|
||||
.replace(/```/g, "")
|
||||
.trim() || "";
|
||||
|
||||
// Validate: must start with a valid mermaid keyword
|
||||
const validStarts = [
|
||||
"graph",
|
||||
"flowchart",
|
||||
"sequenceDiagram",
|
||||
"pie",
|
||||
"gantt",
|
||||
"stateDiagram",
|
||||
"timeline",
|
||||
"classDiagram",
|
||||
"erDiagram",
|
||||
];
|
||||
const firstLine = code.split("\n")[0]?.trim().toLowerCase() || "";
|
||||
const isValid = validStarts.some((keyword) =>
|
||||
firstLine.startsWith(keyword),
|
||||
);
|
||||
|
||||
if (!isValid || code.length < 10) {
|
||||
console.warn(
|
||||
`⚠️ Mermaid: Invalid diagram generated for "${concept}", skipping`,
|
||||
);
|
||||
return "";
|
||||
}
|
||||
|
||||
return code;
|
||||
}
|
||||
|
||||
private async identifyResearchTopics(
|
||||
content: string,
|
||||
context: string,
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
console.log("Sending request to OpenRouter...");
|
||||
const response = await this.openai.chat.completions.create({
|
||||
model: MODELS.STRUCTURED,
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: `Analyze the following blog post and identify 3 key topics or claims that would benefit from statistical data or external verification.
|
||||
Return relevant, specific research queries (not too broad).
|
||||
|
||||
Context: ${context.slice(0, 1500)}
|
||||
|
||||
Return JSON: { "topics": ["topic 1", "topic 2", "topic 3"] }
|
||||
Return ONLY the JSON.`,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: content.slice(0, 4000),
|
||||
},
|
||||
],
|
||||
response_format: { type: "json_object" },
|
||||
});
|
||||
console.log("Got response from OpenRouter");
|
||||
const parsed = safeParseJSON(
|
||||
response.choices[0].message.content || '{"topics": []}',
|
||||
{ topics: [] },
|
||||
);
|
||||
return (parsed.topics || []).map((t: any) =>
|
||||
typeof t === "string" ? t : JSON.stringify(t),
|
||||
);
|
||||
} catch (e: any) {
|
||||
console.error("Error in identifyResearchTopics:", e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
2
packages/content-engine/src/index.ts
Normal file
2
packages/content-engine/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./generator";
|
||||
export * from "./orchestrator";
|
||||
661
packages/content-engine/src/orchestrator.ts
Normal file
661
packages/content-engine/src/orchestrator.ts
Normal file
@@ -0,0 +1,661 @@
|
||||
import OpenAI from "openai";
|
||||
import { ResearchAgent, type Fact, type SocialPost } from "@mintel/journaling";
|
||||
import { ThumbnailGenerator } from "@mintel/thumbnail-generator";
|
||||
import { ComponentDefinition } from "./generator";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
|
||||
export interface OrchestratorConfig {
|
||||
apiKey: string;
|
||||
replicateApiKey?: string;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
export interface OptimizationTask {
|
||||
content: string;
|
||||
projectContext: string;
|
||||
availableComponents?: ComponentDefinition[];
|
||||
instructions?: string;
|
||||
internalLinks?: { title: string; slug: string }[];
|
||||
}
|
||||
|
||||
export interface OptimizeFileOptions {
|
||||
contextDir: string;
|
||||
availableComponents?: ComponentDefinition[];
|
||||
shouldRename?: boolean;
|
||||
}
|
||||
|
||||
export class AiBlogPostOrchestrator {
|
||||
private openai: OpenAI;
|
||||
private researchAgent: ResearchAgent;
|
||||
private thumbnailGenerator?: ThumbnailGenerator;
|
||||
private model: string;
|
||||
|
||||
constructor(config: OrchestratorConfig) {
|
||||
this.model = config.model || "google/gemini-3-flash-preview";
|
||||
this.openai = new OpenAI({
|
||||
apiKey: config.apiKey,
|
||||
baseURL: "https://openrouter.ai/api/v1",
|
||||
defaultHeaders: {
|
||||
"HTTP-Referer": "https://mintel.me",
|
||||
"X-Title": "Mintel AI Blog Post Orchestrator",
|
||||
},
|
||||
});
|
||||
this.researchAgent = new ResearchAgent(config.apiKey);
|
||||
if (config.replicateApiKey) {
|
||||
this.thumbnailGenerator = new ThumbnailGenerator({
|
||||
replicateApiKey: config.replicateApiKey,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable context loader. Loads all .md and .txt files from a directory into a single string.
|
||||
*/
|
||||
async loadContext(dirPath: string): Promise<string> {
|
||||
try {
|
||||
const resolvedDir = path.resolve(process.cwd(), dirPath);
|
||||
const files = await fs.readdir(resolvedDir);
|
||||
const textFiles = files.filter((f) => /\.(md|txt)$/i.test(f)).sort();
|
||||
const contents: string[] = [];
|
||||
|
||||
for (const file of textFiles) {
|
||||
const filePath = path.join(resolvedDir, file);
|
||||
const text = await fs.readFile(filePath, "utf8");
|
||||
contents.push(`=== ${file} ===\n${text.trim()}`);
|
||||
}
|
||||
|
||||
return contents.join("\n\n");
|
||||
} catch (e) {
|
||||
console.warn(`⚠️ Could not load context from ${dirPath}: ${e}`);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a file, extracts frontmatter, loads context, optimizes body, and writes it back.
|
||||
*/
|
||||
async optimizeFile(
|
||||
targetFile: string,
|
||||
options: OptimizeFileOptions,
|
||||
): Promise<void> {
|
||||
const absPath = path.isAbsolute(targetFile)
|
||||
? targetFile
|
||||
: path.resolve(process.cwd(), targetFile);
|
||||
console.log(`📄 Processing File: ${path.basename(absPath)}`);
|
||||
|
||||
const content = await fs.readFile(absPath, "utf8");
|
||||
|
||||
// Idea 4: We no longer split frontmatter and body. We pass the whole file
|
||||
// to the LLM so it can optimize the SEO title and description.
|
||||
|
||||
// Idea 1: Build Internal Link Graph
|
||||
const blogDir = path.dirname(absPath);
|
||||
const internalLinks = await this.buildInternalLinkGraph(
|
||||
blogDir,
|
||||
path.basename(absPath),
|
||||
);
|
||||
|
||||
console.log(`📖 Loading context from: ${options.contextDir}`);
|
||||
const projectContext = await this.loadContext(options.contextDir);
|
||||
if (!projectContext) {
|
||||
console.warn(
|
||||
"⚠️ No project context loaded. AI might miss specific guidelines.",
|
||||
);
|
||||
}
|
||||
|
||||
const optimizedContent = await this.optimizeDocument({
|
||||
content: content,
|
||||
projectContext,
|
||||
availableComponents: options.availableComponents,
|
||||
internalLinks: internalLinks, // pass to orchestrator
|
||||
});
|
||||
|
||||
// Idea 4b: Extract the potentially updated title to rename the file (SEO Slug)
|
||||
const newFmMatch = optimizedContent.match(/^---\s*\n([\s\S]*?)\n---/);
|
||||
let finalPath = absPath;
|
||||
let finalSlug = path.basename(absPath, ".mdx");
|
||||
|
||||
if (options.shouldRename && newFmMatch && newFmMatch[1]) {
|
||||
const titleMatch = newFmMatch[1].match(/title:\s*["']([^"']+)["']/);
|
||||
if (titleMatch && titleMatch[1]) {
|
||||
const newTitle = titleMatch[1];
|
||||
// Generate SEO Slug
|
||||
finalSlug = newTitle
|
||||
.toLowerCase()
|
||||
.replace(/ä/g, "ae")
|
||||
.replace(/ö/g, "oe")
|
||||
.replace(/ü/g, "ue")
|
||||
.replace(/ß/g, "ss")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
|
||||
const newAbsPath = path.join(path.dirname(absPath), `${finalSlug}.mdx`);
|
||||
if (newAbsPath !== absPath) {
|
||||
console.log(
|
||||
`🔄 SEO Title changed! Renaming file to: ${finalSlug}.mdx`,
|
||||
);
|
||||
// Delete old file if the title changed significantly
|
||||
try {
|
||||
await fs.unlink(absPath);
|
||||
} catch (_err) {
|
||||
// ignore
|
||||
}
|
||||
finalPath = newAbsPath;
|
||||
}
|
||||
}
|
||||
} else if (newFmMatch && newFmMatch[1]) {
|
||||
console.log(
|
||||
`ℹ️ Rename skipped (permalink stability active). If you want to rename, use --rename.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Idea 5: Automatic Thumbnails
|
||||
let finalContent = optimizedContent;
|
||||
|
||||
// Skip if thumbnail already exists in frontmatter
|
||||
const hasExistingThumbnail = /thumbnail:\s*["'][^"']+["']/.test(
|
||||
finalContent,
|
||||
);
|
||||
|
||||
if (this.thumbnailGenerator && !hasExistingThumbnail) {
|
||||
console.log("🎨 Phase 5: Generating/Linking visual thumbnail...");
|
||||
try {
|
||||
const webPublicDir = path.resolve(process.cwd(), "apps/web/public");
|
||||
const thumbnailRelPath = `/blog/${finalSlug}.png`;
|
||||
const thumbnailAbsPath = path.join(
|
||||
webPublicDir,
|
||||
"blog",
|
||||
`${finalSlug}.png`,
|
||||
);
|
||||
|
||||
// Check if the physical file already exists
|
||||
let physicalFileExists = false;
|
||||
try {
|
||||
await fs.access(thumbnailAbsPath);
|
||||
physicalFileExists = true;
|
||||
} catch (_err) {
|
||||
// File does not exist
|
||||
}
|
||||
|
||||
if (physicalFileExists) {
|
||||
console.log(
|
||||
`⏭️ Thumbnail already exists on disk, skipping generation: ${thumbnailAbsPath}`,
|
||||
);
|
||||
} else {
|
||||
const visualPrompt = await this.generateVisualPrompt(finalContent);
|
||||
await this.thumbnailGenerator.generateImage(
|
||||
visualPrompt,
|
||||
thumbnailAbsPath,
|
||||
);
|
||||
}
|
||||
|
||||
// Update frontmatter with thumbnail
|
||||
if (finalContent.includes("thumbnail:")) {
|
||||
finalContent = finalContent.replace(
|
||||
/thumbnail:\s*["'].*?["']/,
|
||||
`thumbnail: "${thumbnailRelPath}"`,
|
||||
);
|
||||
} else {
|
||||
finalContent = finalContent.replace(
|
||||
/(title:\s*["'].*?["'])/,
|
||||
`$1\nthumbnail: "${thumbnailRelPath}"`,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("⚠️ Thumbnail processing failed, skipping:", e);
|
||||
}
|
||||
}
|
||||
|
||||
await fs.writeFile(finalPath, finalContent);
|
||||
console.log(`✅ Saved optimized file to: ${finalPath}`);
|
||||
}
|
||||
|
||||
private async generateVisualPrompt(content: string): Promise<string> {
|
||||
const response = await this.openai.chat.completions.create({
|
||||
model: this.model,
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: `You are a Visual Discovery Agent for an architectural design system.
|
||||
Review the provided blog post and create a 1-sentence abstract visual description for an image generator (like Flux).
|
||||
|
||||
THEME: Technical blueprint / structural illustration.
|
||||
STYLE: Clean lines, geometric shapes, monochrome base with one highlighter accent color (green, pink, or yellow).
|
||||
NO TEXT. NO PEOPLE. NO REALISTIC PHOTOS.
|
||||
FOCUS: The core metaphor or technical concept of the article.
|
||||
|
||||
Example output: "A complex network of glowing fiber optic nodes forming a recursive pyramid structure, technical blue lineart style."`,
|
||||
},
|
||||
{ role: "user", content: content.slice(0, 5000) },
|
||||
],
|
||||
max_tokens: 100,
|
||||
});
|
||||
return (
|
||||
response.choices[0].message.content ||
|
||||
"Technical architectural blueprint of a digital system"
|
||||
);
|
||||
}
|
||||
|
||||
private async buildInternalLinkGraph(
|
||||
blogDir: string,
|
||||
currentFile: string,
|
||||
): Promise<{ title: string; slug: string }[]> {
|
||||
try {
|
||||
const files = await fs.readdir(blogDir);
|
||||
const mdxFiles = files.filter(
|
||||
(f) => f.endsWith(".mdx") && f !== currentFile,
|
||||
);
|
||||
const graph: { title: string; slug: string }[] = [];
|
||||
|
||||
for (const file of mdxFiles) {
|
||||
const fileContent = await fs.readFile(path.join(blogDir, file), "utf8");
|
||||
const titleMatch = fileContent.match(/title:\s*["']([^"']+)["']/);
|
||||
if (titleMatch && titleMatch[1]) {
|
||||
graph.push({
|
||||
title: titleMatch[1],
|
||||
slug: `/blog/${file.replace(".mdx", "")}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
return graph;
|
||||
} catch (e) {
|
||||
console.warn("Could not build internal link graph", e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the 3-step optimization pipeline:
|
||||
* 1. Fakten recherchieren
|
||||
* 2. Bestehende Social Posts extrahieren (kein LLM — nur Regex)
|
||||
* 3. AI anweisen daraus Artikel zu erstellen
|
||||
*/
|
||||
async optimizeDocument(task: OptimizationTask): Promise<string> {
|
||||
console.log(`🚀 Starting AI Orchestration Pipeline (${this.model})...`);
|
||||
|
||||
// 1. Fakten & Konkurrenz recherchieren
|
||||
console.log("1️⃣ Recherchiere Fakten und analysiere Konkurrenz...");
|
||||
const researchTopics = await this.identifyTopics(task.content);
|
||||
const facts: Fact[] = [];
|
||||
const competitorInsights: string[] = [];
|
||||
|
||||
// Paralellize competitor research and fact research
|
||||
await Promise.all(
|
||||
researchTopics.map(async (topic) => {
|
||||
const [topicFacts, insights] = await Promise.all([
|
||||
this.researchAgent.researchTopic(topic),
|
||||
this.researchAgent.researchCompetitors(topic),
|
||||
]);
|
||||
facts.push(...topicFacts);
|
||||
competitorInsights.push(...insights);
|
||||
}),
|
||||
);
|
||||
|
||||
// 2. Bestehende Social Posts aus dem Content extrahieren (deterministisch, kein LLM)
|
||||
console.log("2️⃣ Extrahiere bestehende Social Media Embeds aus Content...");
|
||||
const socialPosts = this.researchAgent.extractSocialPosts(task.content);
|
||||
|
||||
// Wenn keine vorhanden sind, besorge echte von der Serper API
|
||||
if (socialPosts.length === 0) {
|
||||
console.log(
|
||||
" → Keine bestehenden Posts gefunden. Suche neue über Serper API...",
|
||||
);
|
||||
const realPosts = await this.researchAgent.fetchRealSocialPosts(
|
||||
task.content.slice(0, 500),
|
||||
);
|
||||
socialPosts.push(...realPosts);
|
||||
}
|
||||
|
||||
// 3. AI anweisen daraus Artikel zu erstellen
|
||||
console.log("3️⃣ Erstelle optimierten Artikel (Agentic Rewrite)...");
|
||||
return await this.compileArticle(
|
||||
task,
|
||||
facts,
|
||||
competitorInsights,
|
||||
socialPosts,
|
||||
task.internalLinks || [],
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: `Analyze the following blog post and identify 1 to 2 key topics or claims that would benefit from statistical data or external verification.
|
||||
Return JSON: { "topics": ["topic 1", "topic 2"] }
|
||||
Return ONLY the JSON.`,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: content.slice(0, 4000),
|
||||
},
|
||||
],
|
||||
response_format: { type: "json_object" },
|
||||
});
|
||||
|
||||
try {
|
||||
const raw = response.choices[0].message.content || '{"topics": []}';
|
||||
const cleaned = raw
|
||||
.trim()
|
||||
.replace(/^```(?:json)?\s*\n?/, "")
|
||||
.replace(/\n?```\s*$/, "");
|
||||
const parsed = JSON.parse(cleaned);
|
||||
return parsed.topics || [];
|
||||
} catch (e) {
|
||||
console.warn("⚠️ Failed to parse research topics", e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async compileArticle(
|
||||
task: OptimizationTask,
|
||||
facts: Fact[],
|
||||
competitorInsights: string[],
|
||||
socialPosts: SocialPost[],
|
||||
internalLinks: { title: string; slug: string }[],
|
||||
retryCount = 0,
|
||||
): Promise<string> {
|
||||
const factsText = facts
|
||||
.map((f, i) => `${i + 1}. ${f.statement} [Source: ${f.source}]`)
|
||||
.join("\n");
|
||||
|
||||
let socialText = `CRITICAL RULE: NO VERIFIED SOCIAL MEDIA POSTS FOUND. You MUST NOT use <YouTubeEmbed />, <TwitterEmbed />, or <LinkedInEmbed /> under ANY circumstances in this article. DO NOT hallucinate IDs.`;
|
||||
|
||||
if (socialPosts.length > 0) {
|
||||
const allowedTags: string[] = [];
|
||||
if (socialPosts.some((p) => p.platform === "youtube"))
|
||||
allowedTags.push('<YouTubeEmbed videoId="..." />');
|
||||
if (socialPosts.some((p) => p.platform === "twitter"))
|
||||
allowedTags.push('<TwitterEmbed tweetId="..." />');
|
||||
if (socialPosts.some((p) => p.platform === "linkedin"))
|
||||
allowedTags.push('<LinkedInEmbed url="..." />');
|
||||
|
||||
socialText = `Social Media Posts to embed (use ONLY these tags, do not use others: ${allowedTags.join(", ")}):\n${socialPosts.map((p) => `Platform: ${p.platform}, ID: ${p.embedId} (${p.description})`).join("\n")}\nCRITICAL: Do not invent any IDs that are not explicitly listed in the list above.`;
|
||||
}
|
||||
|
||||
const componentsText = (task.availableComponents || [])
|
||||
.filter((c) => {
|
||||
if (
|
||||
c.name === "YouTubeEmbed" &&
|
||||
!socialPosts.some((p) => p.platform === "youtube")
|
||||
)
|
||||
return false;
|
||||
if (
|
||||
c.name === "TwitterEmbed" &&
|
||||
!socialPosts.some((p) => p.platform === "twitter")
|
||||
)
|
||||
return false;
|
||||
if (
|
||||
c.name === "LinkedInEmbed" &&
|
||||
!socialPosts.some((p) => p.platform === "linkedin")
|
||||
)
|
||||
return false;
|
||||
return true;
|
||||
})
|
||||
.map((c) => {
|
||||
// Ensure LinkedInEmbed usage example consistently uses 'url'
|
||||
if (c.name === "LinkedInEmbed") {
|
||||
return `<${c.name}>: ${c.description}\n Example: <LinkedInEmbed url="https://www.linkedin.com/posts/..." />`;
|
||||
}
|
||||
return `<${c.name}>: ${c.description}\n Example: ${c.usageExample}`;
|
||||
})
|
||||
.join("\n\n");
|
||||
|
||||
const memeTemplates = [
|
||||
"db", // Distracted Boyfriend
|
||||
"gb", // Galaxy Brain
|
||||
"fine", // This is Fine
|
||||
"ds", // Daily Struggle
|
||||
"gru", // Gru's Plan
|
||||
"cmm", // Change My Mind
|
||||
"astronaut", // Always Has Been (ahb)
|
||||
"disastergirl",
|
||||
"pigeon", // Is this a pigeon?
|
||||
"rollsafe",
|
||||
"slap", // Will Smith
|
||||
"exit", // Left Exit 12
|
||||
"mordor",
|
||||
"panik-kalm-panik",
|
||||
"woman-cat", // Woman yelling at cat
|
||||
"grumpycat",
|
||||
"sadfrog",
|
||||
"stonks",
|
||||
"same", // They're the same picture
|
||||
"spongebob",
|
||||
];
|
||||
const forcedMeme =
|
||||
memeTemplates[Math.floor(Math.random() * memeTemplates.length)];
|
||||
|
||||
const response = await this.openai.chat.completions.create({
|
||||
model: this.model,
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: `You are an expert MDX Editor and Digital Architect.
|
||||
|
||||
YOUR TASK:
|
||||
Take the given draft blog post and rewrite/enhance it into a final, error-free MDX file. Maintain the author's original German text, meaning, and tone, but enrich it gracefully.
|
||||
|
||||
CONTEXT & RULES:
|
||||
Project Context / Tone:
|
||||
${task.projectContext}
|
||||
|
||||
FACTS TO INTEGRATE:
|
||||
${factsText || "No new facts needed."}
|
||||
|
||||
COMPETITOR BENCHMARK (TOP RANKING ARTICLES):
|
||||
Here are snippets from the top 5 ranking Google articles for this topic. Read them carefully and ensure our article covers these topics but is fundamentally BETTER, deeper, and more authoritative:
|
||||
${competitorInsights.length > 0 ? competitorInsights.join("\n") : "No competitor insights found."}
|
||||
|
||||
AVAILABLE UI COMPONENTS:
|
||||
${componentsText}
|
||||
|
||||
SOCIAL MEDIA POSTS:
|
||||
${socialText}
|
||||
|
||||
INTERNAL LINKING GRAPH:
|
||||
Hier sind unsere existierenden Blog-Posts (Titel und URL-Slug). Finde 2-3 passende Stellen im Text, um organisch mit regulärem Markdown (\`[passender Text]([slug])\`) auf diese Posts zu verlinken. Nutze KEIN <ExternalLink> für B2B-interne Links.
|
||||
${internalLinks.length > 0 ? internalLinks.map((l) => `- "${l.title}" -> ${l.slug}`).join("\n") : "Keine internen Links verfügbar."}
|
||||
|
||||
Special Instructions from User:
|
||||
${task.instructions || "None"}
|
||||
|
||||
BLOG POST BEST PRACTICES (MANDATORY):
|
||||
- DEVIL'S ADVOCATE: Füge zwingend eine kurze kritische Sektion ein (z.B. mit \`<ComparisonRow>\` oder \`<IconList>\`), in der du offen die Nachteile/Kosten/Haken deiner eigenen Lösung ansprichst ("Der Haken an der Sache..."). Das baut Vertrauen bei B2B Entscheidenden auf.
|
||||
- FAQ GENERATOR: Am absoluten Ende des Artikels erstellst du zwingend eine Markdown-Liste mit den 3 wichtigsten Fragen (FAQ) und Antworten (jeweils 2 Sätze) für Google Rich Snippets. Nutze dazu das FAQSection Component oder normales Markdown.
|
||||
- SUBTLE CTAs: Webe 1-2 subtile CTAs für High-End Website Entwicklung ein. Nutze ZWINGEND die Komponente [LeadMagnet] für diese Zwecke anstelle von einfachen Buttons. [LeadMagnet] bietet mehr Kontext und Vertrauen. Beispiel: <LeadMagnet title="Performance-Check anfragen" description="Wir analysieren Ihre Core Web Vitals und decken Umsatzpotenziale auf." buttonText="Jetzt analysieren lassen" href="/contact" variant="performance" />. Die Texte im LeadMagnet müssen absolut überzeugend, hochprofessionell und B2B-fokussiert sein (KEIN Robotik-Marketing-Sprech).
|
||||
- MEME DIVERSITY: Du MUSST ZWINGEND für jedes Meme (sofern passend) abwechslungsreiche Templates nutzen. Um dies zu garantieren, wurde für diesen Artikel das folgende Template ausgewählt: '${forcedMeme}'. Du MUSST EXAKT DIESES TEMPLATE NUTZEN. Versuche nicht, es durch ein Standard-Template wie 'drake' zu ersetzen!
|
||||
- Zitat-Varianten: Wenn du Organisationen oder Studien zitierst, nutze ArticleQuote (mit isCompany=true für Firmen). Für Personen lass isCompany weg.
|
||||
- Füge zwingend ein prägnantes 'TL;DR' ganz am Anfang ein.
|
||||
- Füge ein sauberes TableOfContents ein.
|
||||
- Verwende unsere Komponenten stilvoll für Visualisierungen.
|
||||
- Agiere als hochprofessioneller Digital Architect und entferne alte MDX-Metadaten im Body.
|
||||
- Fazit: Schließe JEDEN Artikel ZWINGEND mit einem starken, klaren 'Fazit' ab.
|
||||
- ORIGINAL LANGUAGE QUOTES: Übersetze NIEMALS Zitate (z.B. in ArticleQuote). Behalte das Original (z.B. Englisch), wenn du Studien von Deloitte, McKinsey oder Aussagen von CEOs zitierst. Das erhöht die Authentizität im B2B-Mittelstand.
|
||||
- CONTENT PRUNING: Wenn das dir übergebene MDX bereits interaktive Komponenten (z.B. \`<YouTubeEmbed>\`) enthält, die **nicht** oder **nicht mehr** zum inhaltlichen Fokus passen (z.B. irrelevante Videos oder platzhalter-ähnliche Snippets), MUSST du diese radikal **entfernen**. Behalte keine halluzinierten oder unpassenden Medien, nur weil sie schon da waren.
|
||||
|
||||
STRICT MDX OUTPUT RULES:
|
||||
1. ONLY use the exact components defined above.
|
||||
2. For Social Media Embeds, you MUST ONLY use the EXACT IDs provided in the list above. Do NOT invent IDs.
|
||||
3. If ANY verified social media posts are provided, you MUST integrate at least one naturally with a contextual sentence.
|
||||
4. Keep the original content blocks and headings as much as possible, just improve flow.
|
||||
5. FRONTMATTER SEO (Idea 4): Ich übergebe dir die KOMPLETTE Datei inklusive Markdown-Frontmatter (--- ... ---). Du MUSST das Frontmatter ebenfalls zurückgeben! Optimiere darin den \`title\` und die \`description\` maximal für B2B SEO. Lasse die anderen Keys im Frontmatter (date, tags) unangetastet.
|
||||
|
||||
CRITICAL GUIDELINES (NEVER BREAK THESE):
|
||||
1. THE OUTPUT MUST START WITH YAML FRONTMATTER AND END WITH THE MDX BODY.
|
||||
2. DO NOT INCLUDE MARKDOWN WRAPPERS (do not wrap in \`\`\`mdx ... \`\`\`).
|
||||
5. Be clean. Do NOT clump all components together. Provide 3-4 paragraphs of normal text between visual items.
|
||||
6. If you insert components, ensure their syntax is 100% valid JSX/MDX.
|
||||
7. CRITICAL MERMAID RULE: If you use <Mermaid>, the inner content MUST be 100% valid Mermaid.js syntax. NO HTML inside labels. NO quotes inside brackets without valid syntax.
|
||||
8. Do NOT hallucinate links or facts. Use only what is provided.`,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: task.content,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let rawContent = response.choices[0].message.content || task.content;
|
||||
rawContent = this.cleanResponse(rawContent, socialPosts);
|
||||
|
||||
// --- Autonomous Validation Layer ---
|
||||
let hasError = false;
|
||||
let errorFeedback = "";
|
||||
|
||||
// 1. Validate Meme Templates
|
||||
const memeRegex = /<ArticleMeme[^>]+template=["']([^"']+)["'][^>]*>/g;
|
||||
let memeMatch;
|
||||
const invalidMemes: string[] = [];
|
||||
while ((memeMatch = memeRegex.exec(rawContent)) !== null) {
|
||||
if (!memeTemplates.includes(memeMatch[1])) {
|
||||
invalidMemes.push(memeMatch[1]);
|
||||
}
|
||||
}
|
||||
if (invalidMemes.length > 0) {
|
||||
hasError = true;
|
||||
errorFeedback += `\n- You hallucinated invalid meme templates: ${invalidMemes.join(", ")}. You MUST ONLY use templates from this exact list: ${memeTemplates.join(", ")}. DO NOT INVENT TEMPLATES.\n`;
|
||||
}
|
||||
|
||||
// 2. Validate Mermaid Syntax
|
||||
if (rawContent.includes("<Mermaid>")) {
|
||||
console.log("🔍 Validating Mermaid syntax in AI response...");
|
||||
const mermaidBlocks = this.extractMermaidBlocks(rawContent);
|
||||
|
||||
for (const block of mermaidBlocks) {
|
||||
const validationResult = await this.validateMermaidSyntax(block);
|
||||
if (!validationResult.valid) {
|
||||
hasError = true;
|
||||
errorFeedback += `\n- Invalid Mermaid block:\n${block}\nError context: ${validationResult.error}\n`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasError && retryCount < 3) {
|
||||
console.log(
|
||||
`❌ Validation errors detected. Retrying compilation (Attempt ${retryCount + 1}/3)...`,
|
||||
);
|
||||
return this.compileArticle(
|
||||
{
|
||||
...task,
|
||||
content: `CRITICAL ERROR IN PREVIOUS ATTEMPT:\nYour generated MDX contained the following errors that MUST be fixed:\n${errorFeedback}\n\nPlease rewrite the MDX and FIX these errors. Pay strict attention to the rules.\n\nOriginal Draft:\n${task.content}`,
|
||||
},
|
||||
facts,
|
||||
competitorInsights,
|
||||
socialPosts,
|
||||
internalLinks,
|
||||
retryCount + 1,
|
||||
);
|
||||
}
|
||||
|
||||
return rawContent;
|
||||
}
|
||||
|
||||
private extractMermaidBlocks(content: string): string[] {
|
||||
const blocks: string[] = [];
|
||||
// Regex to match <Mermaid>...</Mermaid> blocks across multiple lines
|
||||
const regex = /<Mermaid>([\s\S]*?)<\/Mermaid>/g;
|
||||
let match;
|
||||
while ((match = regex.exec(content)) !== null) {
|
||||
if (match[1]) {
|
||||
blocks.push(match[1].trim());
|
||||
}
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
private async validateMermaidSyntax(
|
||||
graph: string,
|
||||
): Promise<{ valid: boolean; error?: string }> {
|
||||
// Fast LLM validation to catch common syntax errors like unbalanced quotes or HTML entities
|
||||
try {
|
||||
const validationResponse = await this.openai.chat.completions.create({
|
||||
model: "google/gemini-3-flash-preview", // Switch from gpt-4o-mini to user requested model
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content:
|
||||
'You are a strict Mermaid.js compiler. Analyze the given Mermaid syntax. If it is 100% valid and will render without exceptions, reply ONLY with "VALID". If it has syntax errors (e.g., HTML inside labels, unescaped quotes, unclosed brackets), reply ONLY with "INVALID" followed by a short explanation of the exact error.',
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: graph,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const reply =
|
||||
validationResponse.choices[0].message.content?.trim() || "VALID";
|
||||
if (reply.startsWith("INVALID")) {
|
||||
return { valid: false, error: reply };
|
||||
}
|
||||
return { valid: true };
|
||||
} catch (e) {
|
||||
console.error("Syntax validation LLM call failed, passing through:", e);
|
||||
return { valid: true }; // Fallback to passing if validator fails
|
||||
}
|
||||
}
|
||||
|
||||
private cleanResponse(content: string, socialPosts: SocialPost[]): string {
|
||||
let cleaned = content.trim();
|
||||
|
||||
// 1. Strip Markdown Wrappers (e.g. ```mdx ... ```)
|
||||
if (cleaned.startsWith("```")) {
|
||||
cleaned = cleaned
|
||||
.replace(/^```[a-zA-Z]*\n?/, "")
|
||||
.replace(/\n?```\s*$/, "");
|
||||
}
|
||||
|
||||
// 2. We NO LONGER strip redundant frontmatter, because we requested the LLM to output it.
|
||||
// Ensure the output actually has frontmatter, if not, something went wrong, but we just pass it along.
|
||||
|
||||
// 3. Strip any social embeds the AI hallucinated (IDs not in our extracted set)
|
||||
const knownYtIds = new Set(
|
||||
socialPosts.filter((p) => p.platform === "youtube").map((p) => p.embedId),
|
||||
);
|
||||
const knownTwIds = new Set(
|
||||
socialPosts.filter((p) => p.platform === "twitter").map((p) => p.embedId),
|
||||
);
|
||||
const knownLiIds = new Set(
|
||||
socialPosts
|
||||
.filter((p) => p.platform === "linkedin")
|
||||
.map((p) => p.embedId),
|
||||
);
|
||||
|
||||
cleaned = cleaned.replace(
|
||||
/<YouTubeEmbed[^>]*videoId="([^"]+)"[^>]*\/>/gi,
|
||||
(tag, id) => {
|
||||
if (knownYtIds.has(id)) return tag;
|
||||
console.log(
|
||||
`🛑 Stripped hallucinated YouTubeEmbed with videoId="${id}"`,
|
||||
);
|
||||
return "";
|
||||
},
|
||||
);
|
||||
|
||||
cleaned = cleaned.replace(
|
||||
/<TwitterEmbed[^>]*tweetId="([^"]+)"[^>]*\/>/gi,
|
||||
(tag, id) => {
|
||||
if (knownTwIds.has(id)) return tag;
|
||||
console.log(
|
||||
`🛑 Stripped hallucinated TwitterEmbed with tweetId="${id}"`,
|
||||
);
|
||||
return "";
|
||||
},
|
||||
);
|
||||
|
||||
cleaned = cleaned.replace(
|
||||
/<LinkedInEmbed[^>]*(?:url|urn)="([^"]+)"[^>]*\/>/gi,
|
||||
(tag, id) => {
|
||||
if (knownLiIds.has(id)) return tag;
|
||||
console.log(`🛑 Stripped hallucinated LinkedInEmbed with id="${id}"`);
|
||||
return "";
|
||||
},
|
||||
);
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
}
|
||||
11
packages/content-engine/tsconfig.json
Normal file
11
packages/content-engine/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "@mintel/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"module": "ESNext",
|
||||
"target": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -20,9 +20,11 @@
|
||||
"build": "directus-extension build",
|
||||
"dev": "directus-extension build -w"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mintel/directus-extension-toolkit": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@directus/extensions-sdk": "11.0.2",
|
||||
"@mintel/directus-extension-toolkit": "workspace:*",
|
||||
"vue": "^3.4.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,14 +45,19 @@
|
||||
<v-button secondary rounded icon v-tooltip.bottom="'Kunden-Verlinkung bearbeiten'" @click="openEditDrawer">
|
||||
<v-icon name="edit" />
|
||||
</v-button>
|
||||
<v-button primary @click="openCreateClientUser">
|
||||
Portal-Nutzer hinzufügen
|
||||
</v-button>
|
||||
<div @click="onDebugClick" style="display: inline-block; border: 2px solid lime;">
|
||||
<v-button primary @click="openCreateClientUser">
|
||||
Portal-Nutzer hinzufügen
|
||||
</v-button>
|
||||
</div>
|
||||
<button style="background: red; color: white; padding: 4px 8px; border-radius: 4px; border: none; cursor: pointer; margin-left: 10px;" @click="onDebugClick">DEBUG</button>
|
||||
<button style="background: blue; color: white; padding: 8px 16px; border-radius: 4px; border: none; cursor: pointer; margin-left: 10px;" @click="openCreateClientUser">NATIVE: Portal-Nutzer</button>
|
||||
</template>
|
||||
|
||||
<template #empty-state>
|
||||
Wähle einen Kunden aus der Liste oder
|
||||
<v-button x-small @click="openCreateDrawer">verlinke eine neue Firma</v-button>.
|
||||
<button id="debug-click-test" style="background: red; color: white; padding: 4px 8px; border-radius: 4px; border: none; cursor: pointer; margin-left: 10px;" @click="onDebugClick">DEBUG CLICK</button>
|
||||
</template>
|
||||
|
||||
<!-- Main Content: Client Users Table -->
|
||||
@@ -193,15 +198,74 @@
|
||||
</div>
|
||||
</div>
|
||||
</v-drawer>
|
||||
|
||||
<!-- Drawer: Quick Add Company -->
|
||||
<v-drawer
|
||||
v-model="quickAddCompanyActive"
|
||||
title="Firma schnell anlegen"
|
||||
icon="business"
|
||||
@cancel="quickAddCompanyActive = false"
|
||||
>
|
||||
<div class="drawer-content">
|
||||
<div class="form-section">
|
||||
<div class="field">
|
||||
<span class="label">Firmenname</span>
|
||||
<v-input v-model="quickCompanyForm.name" placeholder="z.B. Schmidt GmbH" autofocus />
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="label">Domain / Website</span>
|
||||
<v-input v-model="quickCompanyForm.domain" placeholder="example.com" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="drawer-actions">
|
||||
<v-button primary block :loading="savingQuick" @click="saveQuickCompany">Firma speichern</v-button>
|
||||
</div>
|
||||
</div>
|
||||
</v-drawer>
|
||||
|
||||
<!-- Drawer: Quick Add Person -->
|
||||
<v-drawer
|
||||
v-model="quickAddPersonActive"
|
||||
title="Person schnell anlegen"
|
||||
icon="person"
|
||||
@cancel="quickAddPersonActive = false"
|
||||
>
|
||||
<div class="drawer-content">
|
||||
<div class="form-section">
|
||||
<div class="field">
|
||||
<span class="label">Vorname</span>
|
||||
<v-input v-model="quickPersonForm.first_name" placeholder="Vorname" autofocus />
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="label">Nachname</span>
|
||||
<v-input v-model="quickPersonForm.last_name" placeholder="Nachname" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="label">E-Mail</span>
|
||||
<v-input v-model="quickPersonForm.email" placeholder="email@example.com" type="email" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="drawer-actions">
|
||||
<v-button primary block :loading="savingQuick" @click="saveQuickPerson">Person speichern</v-button>
|
||||
</div>
|
||||
</div>
|
||||
</v-drawer>
|
||||
</MintelManagerLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, nextTick, computed } from 'vue';
|
||||
import { ref, onMounted, nextTick, computed, watch } from 'vue';
|
||||
import { useApi } from '@directus/extensions-sdk';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { MintelManagerLayout, MintelSelect } from '@mintel/directus-extension-toolkit';
|
||||
|
||||
const api = useApi();
|
||||
const route = useRoute();
|
||||
|
||||
function onDebugClick() {
|
||||
console.log("=== [Customer Manager] DEBUG CLICK TRAPPED ===");
|
||||
alert("Interactivity OK!");
|
||||
}
|
||||
|
||||
const items = ref<any[]>([]);
|
||||
const selectedItem = ref<any>(null);
|
||||
@@ -222,6 +286,12 @@ const drawerUserActive = ref(false);
|
||||
const isEditingUser = ref(false);
|
||||
const userForm = ref({ id: '', first_name: '', last_name: '', email: '', person: null, temporary_password: '' });
|
||||
|
||||
const quickAddCompanyActive = ref(false);
|
||||
const quickAddPersonActive = ref(false);
|
||||
const savingQuick = ref(false);
|
||||
const quickCompanyForm = ref({ name: '', domain: '' });
|
||||
const quickPersonForm = ref({ first_name: '', last_name: '', email: '' });
|
||||
|
||||
const tableHeaders = [
|
||||
{ text: 'Name', value: 'name', sortable: true },
|
||||
{ text: 'E-Mail', value: 'email', sortable: true },
|
||||
@@ -284,7 +354,10 @@ function openEditDrawer() {
|
||||
}
|
||||
|
||||
async function saveItem() {
|
||||
if (!form.value.company) return;
|
||||
if (!form.value.company) {
|
||||
notice.value = { type: 'danger', message: 'Bitte wählen Sie eine Firma aus.' };
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
if (isEditing.value) {
|
||||
@@ -301,7 +374,10 @@ async function saveItem() {
|
||||
if (updated) selectItem(updated);
|
||||
}
|
||||
} catch (e: any) {
|
||||
notice.value = { type: 'danger', message: e.message };
|
||||
notice.value = {
|
||||
type: 'danger',
|
||||
message: e.response?.data?.errors?.[0]?.message || e.message || 'Speichern fehlgeschlagen'
|
||||
};
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
@@ -365,9 +441,45 @@ async function inviteUser(user: any) {
|
||||
}
|
||||
|
||||
function openQuickAdd(type: 'company' | 'person') {
|
||||
// Quick add logic can involve opening another drawer or navigating
|
||||
// For now, we'll just show a notice
|
||||
notice.value = { type: 'info', message: `${type === 'company' ? 'Firma' : 'Person'} im jeweiligen Manager anlegen.` };
|
||||
if (type === 'company') {
|
||||
quickCompanyForm.value = { name: '', domain: '' };
|
||||
quickAddCompanyActive.value = true;
|
||||
} else {
|
||||
quickPersonForm.value = { first_name: '', last_name: '', email: '' };
|
||||
quickAddPersonActive.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveQuickCompany() {
|
||||
if (!quickCompanyForm.value.name) return;
|
||||
savingQuick.value = true;
|
||||
try {
|
||||
const res = await api.post('/items/companies', quickCompanyForm.value);
|
||||
await fetchData();
|
||||
form.value.company = res.data.data.id;
|
||||
quickAddCompanyActive.value = false;
|
||||
notice.value = { type: 'success', message: 'Firma angelegt und ausgewählt.' };
|
||||
} catch (e: any) {
|
||||
notice.value = { type: 'danger', message: e.message };
|
||||
} finally {
|
||||
savingQuick.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveQuickPerson() {
|
||||
if (!quickPersonForm.value.first_name || !quickPersonForm.value.last_name) return;
|
||||
savingQuick.value = true;
|
||||
try {
|
||||
const res = await api.post('/items/people', quickPersonForm.value);
|
||||
await fetchData();
|
||||
form.value.contact_person = res.data.data.id;
|
||||
quickAddPersonActive.value = false;
|
||||
notice.value = { type: 'success', message: 'Person angelegt und ausgewählt.' };
|
||||
} catch (e: any) {
|
||||
notice.value = { type: 'danger', message: e.message };
|
||||
} finally {
|
||||
savingQuick.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
@@ -377,7 +489,30 @@ function formatDate(dateStr: string) {
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(fetchData);
|
||||
function handleDeepLink() {
|
||||
if (route.query.create === 'true') {
|
||||
// Only open if not already open to avoid resetting form if user is already typing
|
||||
if (!drawerActive.value) {
|
||||
openCreateDrawer();
|
||||
}
|
||||
|
||||
if (route.query.company) {
|
||||
form.value.company = route.query.company as any;
|
||||
}
|
||||
if (route.query.contact_person) {
|
||||
form.value.contact_person = route.query.contact_person as any;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => route.query.create, (newVal) => {
|
||||
if (newVal === 'true') handleDeepLink();
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchData();
|
||||
handleDeepLink();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -2,40 +2,37 @@ import nextPlugin from "@next/eslint-plugin-next";
|
||||
import reactPlugin from "eslint-plugin-react";
|
||||
import hooksPlugin from "eslint-plugin-react-hooks";
|
||||
import tseslint from "typescript-eslint";
|
||||
import js from "@eslint/js";
|
||||
|
||||
/**
|
||||
* Mintel Next.js ESLint Configuration (Flat Config)
|
||||
*
|
||||
*
|
||||
* This configuration replaces the legacy 'eslint-config-next' which
|
||||
* relies on @rushstack/eslint-patch and causes issues in ESLint 9.
|
||||
*/
|
||||
export const nextConfig = tseslint.config(
|
||||
{
|
||||
plugins: {
|
||||
"react": reactPlugin,
|
||||
"react-hooks": hooksPlugin,
|
||||
"@next/next": nextPlugin,
|
||||
export const nextConfig = tseslint.config({
|
||||
plugins: {
|
||||
react: reactPlugin,
|
||||
"react-hooks": hooksPlugin,
|
||||
"@next/next": nextPlugin,
|
||||
},
|
||||
languageOptions: {
|
||||
globals: {
|
||||
// Add common browser/node globals if needed,
|
||||
// though usually handled by base configs
|
||||
},
|
||||
languageOptions: {
|
||||
globals: {
|
||||
// Add common browser/node globals if needed,
|
||||
// though usually handled by base configs
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
...reactPlugin.configs.recommended.rules,
|
||||
...hooksPlugin.configs.recommended.rules,
|
||||
...nextPlugin.configs.recommended.rules,
|
||||
...nextPlugin.configs["core-web-vitals"].rules,
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"react/no-unescaped-entities": "off",
|
||||
"@next/next/no-img-element": "warn",
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
version: "detect",
|
||||
},
|
||||
rules: {
|
||||
...reactPlugin.configs.recommended.rules,
|
||||
...hooksPlugin.configs.recommended.rules,
|
||||
...nextPlugin.configs.recommended.rules,
|
||||
...nextPlugin.configs["core-web-vitals"].rules,
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"react/no-unescaped-entities": "off",
|
||||
"@next/next/no-img-element": "warn",
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
version: "detect",
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -24,4 +24,4 @@
|
||||
"@directus/extensions-sdk": "11.0.2",
|
||||
"vue": "^3.4.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
/* global module */
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import path from "node:path";
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* global process */
|
||||
import path from "node:path";
|
||||
|
||||
const buildLintCommand = (filenames) => {
|
||||
|
||||
32
packages/image-processor/package.json
Normal file
32
packages/image-processor/package.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "@mintel/image-processor",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup src/index.ts --format esm --dts --clean",
|
||||
"dev": "tsup src/index.ts --format esm --watch --dts",
|
||||
"lint": "eslint src"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vladmandic/face-api": "^1.7.13",
|
||||
"canvas": "^2.11.2",
|
||||
"sharp": "^0.33.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mintel/eslint-config": "workspace:*",
|
||||
"@mintel/tsconfig": "workspace:*",
|
||||
"@types/node": "^20.0.0",
|
||||
"tsup": "^8.3.5",
|
||||
"typescript": "^5.0.0"
|
||||
}
|
||||
}
|
||||
48
packages/image-processor/scripts/download-models.ts
Normal file
48
packages/image-processor/scripts/download-models.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as https from 'node:https';
|
||||
|
||||
const MODELS_DIR = path.join(process.cwd(), 'models');
|
||||
const BASE_URL = 'https://raw.githubusercontent.com/vladmandic/face-api/master/model/';
|
||||
|
||||
const models = [
|
||||
'tiny_face_detector_model-weights_manifest.json',
|
||||
'tiny_face_detector_model-shard1'
|
||||
];
|
||||
|
||||
async function downloadModel(filename: string) {
|
||||
const destPath = path.join(MODELS_DIR, filename);
|
||||
if (fs.existsSync(destPath)) {
|
||||
console.log(`Model ${filename} already exists.`);
|
||||
return;
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
console.log(`Downloading ${filename}...`);
|
||||
const file = fs.createWriteStream(destPath);
|
||||
https.get(BASE_URL + filename, (response) => {
|
||||
response.pipe(file);
|
||||
file.on('finish', () => {
|
||||
file.close();
|
||||
resolve(true);
|
||||
});
|
||||
}).on('error', (err) => {
|
||||
fs.unlinkSync(destPath);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!fs.existsSync(MODELS_DIR)) {
|
||||
fs.mkdirSync(MODELS_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
for (const model of models) {
|
||||
await downloadModel(model);
|
||||
}
|
||||
|
||||
console.log('All models downloaded successfully!');
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
1
packages/image-processor/src/index.ts
Normal file
1
packages/image-processor/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './processor.js';
|
||||
139
packages/image-processor/src/processor.ts
Normal file
139
packages/image-processor/src/processor.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import * as faceapi from '@vladmandic/face-api';
|
||||
// Provide Canvas fallback for face-api in Node.js
|
||||
import { Canvas, Image, ImageData } from 'canvas';
|
||||
import sharp from 'sharp';
|
||||
import * as path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
// @ts-ignore
|
||||
faceapi.env.monkeyPatch({ Canvas, Image, ImageData });
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Path to the downloaded models
|
||||
const MODELS_PATH = path.join(__dirname, '..', 'models');
|
||||
|
||||
let isModelsLoaded = false;
|
||||
|
||||
async function loadModels() {
|
||||
if (isModelsLoaded) return;
|
||||
await faceapi.nets.tinyFaceDetector.loadFromDisk(MODELS_PATH);
|
||||
isModelsLoaded = true;
|
||||
}
|
||||
|
||||
export interface ProcessImageOptions {
|
||||
width: number;
|
||||
height: number;
|
||||
format?: 'webp' | 'jpeg' | 'png' | 'avif';
|
||||
quality?: number;
|
||||
}
|
||||
|
||||
export async function processImageWithSmartCrop(
|
||||
inputBuffer: Buffer,
|
||||
options: ProcessImageOptions
|
||||
): Promise<Buffer> {
|
||||
await loadModels();
|
||||
|
||||
// Load image via Canvas for face-api
|
||||
const img = new Image();
|
||||
img.src = inputBuffer;
|
||||
|
||||
// Detect faces
|
||||
const detections = await faceapi.detectAllFaces(
|
||||
// @ts-ignore
|
||||
img,
|
||||
new faceapi.TinyFaceDetectorOptions()
|
||||
);
|
||||
|
||||
const sharpImage = sharp(inputBuffer);
|
||||
const metadata = await sharpImage.metadata();
|
||||
|
||||
if (!metadata.width || !metadata.height) {
|
||||
throw new Error('Could not read image metadata');
|
||||
}
|
||||
|
||||
// If faces are found, calculate the bounding box containing all faces
|
||||
if (detections.length > 0) {
|
||||
let minX = metadata.width;
|
||||
let minY = metadata.height;
|
||||
let maxX = 0;
|
||||
let maxY = 0;
|
||||
|
||||
for (const det of detections) {
|
||||
const { x, y, width, height } = det.box;
|
||||
if (x < minX) minX = Math.max(0, x);
|
||||
if (y < minY) minY = Math.max(0, y);
|
||||
if (x + width > maxX) maxX = Math.min(metadata.width, x + width);
|
||||
if (y + height > maxY) maxY = Math.min(metadata.height, y + height);
|
||||
}
|
||||
|
||||
const faceBoxWidth = maxX - minX;
|
||||
const faceBoxHeight = maxY - minY;
|
||||
|
||||
// Calculate center of the faces
|
||||
const centerX = Math.floor(minX + faceBoxWidth / 2);
|
||||
const centerY = Math.floor(minY + faceBoxHeight / 2);
|
||||
|
||||
// Provide this as a focus point for sharp's extract or resize
|
||||
// We can use sharp's resize with `position` focusing on crop options,
|
||||
// or calculate an exact bounding box. However, extracting an exact bounding box
|
||||
// and then resizing usually yields the best results when focusing on a specific coordinate.
|
||||
|
||||
// A simpler approach is to crop a rectangle with the target aspect ratio
|
||||
// centered on the faces, then resize. Let's calculate the crop box.
|
||||
|
||||
const targetRatio = options.width / options.height;
|
||||
const currentRatio = metadata.width / metadata.height;
|
||||
|
||||
let cropWidth = metadata.width;
|
||||
let cropHeight = metadata.height;
|
||||
|
||||
if (currentRatio > targetRatio) {
|
||||
// Image is wider than target, calculate new width
|
||||
cropWidth = Math.floor(metadata.height * targetRatio);
|
||||
} else {
|
||||
// Image is taller than target, calculate new height
|
||||
cropHeight = Math.floor(metadata.width / targetRatio);
|
||||
}
|
||||
|
||||
// Try to center the crop box around the faces
|
||||
let cropX = Math.floor(centerX - cropWidth / 2);
|
||||
let cropY = Math.floor(centerY - cropHeight / 2);
|
||||
|
||||
// Keep crop box within image bounds
|
||||
if (cropX < 0) cropX = 0;
|
||||
if (cropY < 0) cropY = 0;
|
||||
if (cropX + cropWidth > metadata.width) cropX = metadata.width - cropWidth;
|
||||
if (cropY + cropHeight > metadata.height) cropY = metadata.height - cropHeight;
|
||||
|
||||
sharpImage.extract({
|
||||
left: cropX,
|
||||
top: cropY,
|
||||
width: cropWidth,
|
||||
height: cropHeight
|
||||
});
|
||||
}
|
||||
|
||||
// Finally, resize to the requested dimensions and format
|
||||
let finalImage = sharpImage.resize(options.width, options.height, {
|
||||
// If faces weren't found, default to entropy/attention based cropping as fallback
|
||||
fit: 'cover',
|
||||
position: detections.length > 0 ? 'center' : 'attention'
|
||||
});
|
||||
|
||||
const format = options.format || 'webp';
|
||||
const quality = options.quality || 80;
|
||||
|
||||
if (format === 'webp') {
|
||||
finalImage = finalImage.webp({ quality });
|
||||
} else if (format === 'jpeg') {
|
||||
finalImage = finalImage.jpeg({ quality });
|
||||
} else if (format === 'png') {
|
||||
finalImage = finalImage.png({ quality });
|
||||
} else if (format === 'avif') {
|
||||
finalImage = finalImage.avif({ quality });
|
||||
}
|
||||
|
||||
return finalImage.toBuffer();
|
||||
}
|
||||
19
packages/image-processor/tsconfig.json
Normal file
19
packages/image-processor/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"extends": "@mintel/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"allowJs": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext"
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"**/*.test.ts"
|
||||
]
|
||||
}
|
||||
@@ -191,6 +191,7 @@ jobs:
|
||||
file: packages/infra/docker/Dockerfile.nextjs
|
||||
platforms: linux/arm64
|
||||
pull: true
|
||||
provenance: false
|
||||
build-args: |
|
||||
NEXT_PUBLIC_BASE_URL=${{ needs.prepare.outputs.next_public_base_url }}
|
||||
NEXT_PUBLIC_TARGET=${{ needs.prepare.outputs.target }}
|
||||
@@ -198,8 +199,6 @@ jobs:
|
||||
secrets: |
|
||||
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
||||
tags: registry.infra.mintel.me/mintel/${{ github.event.repository.name }}:${{ needs.prepare.outputs.image_tag }}
|
||||
cache-from: type=registry,ref=registry.infra.mintel.me/mintel/${{ github.event.repository.name }}:buildcache
|
||||
cache-to: type=registry,ref=registry.infra.mintel.me/mintel/${{ github.event.repository.name }}:buildcache,mode=max
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# JOB 4: Deploy
|
||||
|
||||
50
packages/infra/scripts/mintel-optimizer.sh
Normal file
50
packages/infra/scripts/mintel-optimizer.sh
Normal file
@@ -0,0 +1,50 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Configuration
|
||||
REGISTRY_DATA="/mnt/HC_Volume_104575103/registry-data/docker/registry/v2"
|
||||
KEEP_TAGS=3
|
||||
|
||||
echo "🏥 Starting Aggressive Mintel Infrastructure Optimization..."
|
||||
|
||||
# 1. Prune Registry Tags (Filesystem level)
|
||||
if [ -d "$REGISTRY_DATA" ]; then
|
||||
echo "🔍 Processing Registry tags..."
|
||||
for repo_dir in "$REGISTRY_DATA/repositories/mintel/"*; do
|
||||
[ -e "$repo_dir" ] || continue
|
||||
repo_name=$(basename "$repo_dir")
|
||||
|
||||
# EXCLUDE base images from pruning to prevent breaking downstream builds
|
||||
if [[ "$repo_name" == "runtime" || "$repo_name" == "nextjs" || "$repo_name" == "gatekeeper" ]]; then
|
||||
echo " 🛡️ Skipping protected repository: mintel/$repo_name"
|
||||
continue
|
||||
fi
|
||||
|
||||
tags_dir="$repo_dir/_manifests/tags"
|
||||
|
||||
if [ -d "$tags_dir" ]; then
|
||||
echo " 📦 Pruning mintel/$repo_name..."
|
||||
# Note: keeping latest and up to KEEP_TAGS of each pattern
|
||||
PATTERNS=("main-*" "testing-*" "branch-*" "v*" "rc*" "[0-9a-f]*")
|
||||
for pattern in "${PATTERNS[@]}"; do
|
||||
find "$tags_dir" -maxdepth 1 -name "$pattern" -print0 2>/dev/null | xargs -0 ls -dt 2>/dev/null | tail -n +$((KEEP_TAGS + 1)) | xargs rm -rf 2>/dev/null || true
|
||||
done
|
||||
rm -rf "$tags_dir/buildcache"* 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# 2. Registry Garbage Collection
|
||||
REGISTRY_CONTAINER=$(docker ps --format "{{.Names}}" | grep registry | head -1 || true)
|
||||
if [ -n "$REGISTRY_CONTAINER" ]; then
|
||||
echo "♻️ Running Registry GC..."
|
||||
docker exec "$REGISTRY_CONTAINER" bin/registry garbage-collect /etc/docker/registry/config.yml --delete-untagged || true
|
||||
fi
|
||||
|
||||
# 3. Global Docker Pruning
|
||||
echo "🧹 Pruning Docker resources..."
|
||||
docker system prune -af --filter "until=24h"
|
||||
docker volume prune -f
|
||||
|
||||
echo "✅ Optimization complete!"
|
||||
df -h /mnt/HC_Volume_104575103
|
||||
@@ -35,9 +35,9 @@ DB_USER="directus"
|
||||
DB_NAME="directus"
|
||||
|
||||
echo "🔍 Detecting local database..."
|
||||
LOCAL_DB_CONTAINER=$(docker compose ps -q directus-db)
|
||||
LOCAL_DB_CONTAINER=$(docker compose ps -q at-mintel-directus-db)
|
||||
if [ -z "$LOCAL_DB_CONTAINER" ]; then
|
||||
echo "❌ Local directus-db container not found. Running?"
|
||||
echo "❌ Local at-mintel-directus-db container not found. Running?"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
32
packages/journaling/package.json
Normal file
32
packages/journaling/package.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "@mintel/journaling",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup src/index.ts --format esm --dts --clean",
|
||||
"dev": "tsup src/index.ts --format esm --watch --dts",
|
||||
"lint": "eslint src"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.6.0",
|
||||
"google-trends-api": "^4.9.2",
|
||||
"openai": "^4.82.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mintel/eslint-config": "workspace:*",
|
||||
"@mintel/tsconfig": "workspace:*",
|
||||
"@types/node": "^20.0.0",
|
||||
"tsup": "^8.3.5",
|
||||
"typescript": "^5.0.0"
|
||||
}
|
||||
}
|
||||
420
packages/journaling/src/agent.ts
Normal file
420
packages/journaling/src/agent.ts
Normal file
@@ -0,0 +1,420 @@
|
||||
import OpenAI from "openai";
|
||||
import { DataCommonsClient } from "./clients/data-commons";
|
||||
import { TrendsClient } from "./clients/trends";
|
||||
import { SerperClient, type SerperVideoResult } from "./clients/serper";
|
||||
|
||||
export interface Fact {
|
||||
statement: string;
|
||||
source: string;
|
||||
url?: string;
|
||||
confidence: "high" | "medium" | "low";
|
||||
data?: any;
|
||||
}
|
||||
|
||||
export interface SocialPost {
|
||||
platform: "youtube" | "twitter" | "linkedin";
|
||||
embedId: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export class ResearchAgent {
|
||||
private openai: OpenAI;
|
||||
private dcClient: DataCommonsClient;
|
||||
private trendsClient: TrendsClient;
|
||||
private serperClient: SerperClient;
|
||||
|
||||
constructor(apiKey: string) {
|
||||
this.openai = new OpenAI({
|
||||
apiKey,
|
||||
baseURL: "https://openrouter.ai/api/v1",
|
||||
defaultHeaders: {
|
||||
"HTTP-Referer": "https://mintel.me",
|
||||
"X-Title": "Mintel Journaling Agent",
|
||||
},
|
||||
});
|
||||
this.dcClient = new DataCommonsClient();
|
||||
this.trendsClient = new TrendsClient(apiKey);
|
||||
this.serperClient = new SerperClient(process.env.SERPER_API_KEY);
|
||||
}
|
||||
|
||||
async researchTopic(topic: string): Promise<Fact[]> {
|
||||
console.log(`🔎 Researching: ${topic}`);
|
||||
|
||||
// 1. Plan Research
|
||||
const plan = await this.planResearch(topic);
|
||||
console.log(`📋 Research Plan:`, plan);
|
||||
|
||||
const facts: Fact[] = [];
|
||||
|
||||
// 2. Execute Plan
|
||||
// Google Trends
|
||||
for (const kw of plan.trendsKeywords) {
|
||||
try {
|
||||
const data = await this.trendsClient.getInterestOverTime(kw);
|
||||
if (data.length > 0) {
|
||||
// Analyze trend
|
||||
const latest = data[data.length - 1];
|
||||
const max = Math.max(...data.map((d) => d.value));
|
||||
facts.push({
|
||||
statement: `Interest in "${kw}" is currently at ${latest.value}% of peak popularity.`,
|
||||
source: "Google Trends",
|
||||
confidence: "high",
|
||||
data: data.slice(-5), // Last 5 points
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Error fetching trends for ${kw}`, e);
|
||||
}
|
||||
}
|
||||
|
||||
// Data Commons
|
||||
// We need DCIDs. LLM should have provided them or we need a search.
|
||||
// For this POC, let's assume the LLM provides plausible DCIDs or we skip deep DC integration for now
|
||||
// and rely on the LLM's own knowledge + the verified trends.
|
||||
// However, if the plan has dcVariables, let's try.
|
||||
|
||||
// 3. Synthesize & Verify
|
||||
// Ask LLM to verify its own knowledge against the data we found (if any) or just use its training data
|
||||
// but formatted as "facts".
|
||||
|
||||
const synthesis = await this.openai.chat.completions.create({
|
||||
model: "google/gemini-2.0-flash-001",
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: `You are a professional digital researcher and fact-checker.
|
||||
Topic: "${topic}"
|
||||
|
||||
Your Goal: Provide 5-7 concrete, verifiable, statistical facts.
|
||||
Constraint 1: Cite real sources (e.g. "Google Developers", "HTTP Archive", "Deloitte", "Nielsen Norman Group").
|
||||
Constraint 2: DO NOT cite "General Knowledge".
|
||||
Constraint 3: CRITICAL MANDATE - NEVER generate or guess URLs. You must hallucinate NO links. Use ONLY the Organization's Name as the "source" field.
|
||||
|
||||
Return JSON: { "facts": [ { "statement": "...", "source": "Organization Name Only", "confidence": "high" } ] }`,
|
||||
},
|
||||
{ role: "user", content: "Extract facts." },
|
||||
],
|
||||
response_format: { type: "json_object" },
|
||||
});
|
||||
|
||||
if (
|
||||
!synthesis.choices ||
|
||||
synthesis.choices.length === 0 ||
|
||||
!synthesis.choices[0].message
|
||||
) {
|
||||
console.warn(`⚠️ Research synthesis failed for concept: "${topic}"`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const result = JSON.parse(synthesis.choices[0].message.content || "{}");
|
||||
return result.facts || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts existing social media embeds from MDX content via regex.
|
||||
* No LLM involved — purely deterministic parsing.
|
||||
* Only returns posts that are already present in the article.
|
||||
*/
|
||||
extractSocialPosts(content: string): SocialPost[] {
|
||||
const posts: SocialPost[] = [];
|
||||
|
||||
// YouTube: <YouTubeEmbed videoId="..." />
|
||||
const ytMatches = [
|
||||
...content.matchAll(/<YouTubeEmbed[^>]*videoId="([^"]+)"[^>]*\/>/gi),
|
||||
];
|
||||
for (const match of ytMatches) {
|
||||
if (!posts.some((p) => p.embedId === match[1])) {
|
||||
posts.push({
|
||||
platform: "youtube",
|
||||
embedId: match[1],
|
||||
description: "Existing YouTube embed",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Twitter/X: <TwitterEmbed tweetId="..." />
|
||||
const twMatches = [
|
||||
...content.matchAll(/<TwitterEmbed[^>]*tweetId="([^"]+)"[^>]*\/>/gi),
|
||||
];
|
||||
for (const match of twMatches) {
|
||||
if (!posts.some((p) => p.embedId === match[1])) {
|
||||
posts.push({
|
||||
platform: "twitter",
|
||||
embedId: match[1],
|
||||
description: "Existing Twitter/X embed",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// LinkedIn: <LinkedInEmbed url="..." /> or <LinkedInEmbed urn="..." />
|
||||
const liMatches = [
|
||||
...content.matchAll(/<LinkedInEmbed[^>]*(?:url|urn)="([^"]+)"[^>]*\/>/gi),
|
||||
];
|
||||
for (const match of liMatches) {
|
||||
if (!posts.some((p) => p.embedId === match[1])) {
|
||||
posts.push({
|
||||
platform: "linkedin",
|
||||
embedId: match[1],
|
||||
description: "Existing LinkedIn embed",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (posts.length > 0) {
|
||||
console.log(
|
||||
`📱 Extracted ${posts.length} existing social media embed(s) from content`,
|
||||
);
|
||||
} else {
|
||||
console.log(`📱 No existing social media embeds found in content`);
|
||||
}
|
||||
|
||||
return posts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches real, verified social media posts using the Serper API (Google Video Search).
|
||||
* This completely prevents hallucinations as it relies on actual search results.
|
||||
*/
|
||||
async fetchRealSocialPosts(
|
||||
topic: string,
|
||||
retries = 1,
|
||||
): Promise<SocialPost[]> {
|
||||
console.log(
|
||||
`🌐 [Serper] Fetching real social media posts for topic: "${topic}"...`,
|
||||
);
|
||||
|
||||
// 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",
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: `You generate ultra-short, highly relevant YouTube search queries based on a given text context.
|
||||
|
||||
RULES:
|
||||
1. Extract only the 2-4 most important technical or business keywords from the provided text.
|
||||
2. Ignore all markdown syntax, frontmatter (---), titles, and descriptions.
|
||||
3. Keep the query generic enough to find popular educational tech videos, BUT ensure it specifically targets the core technical subject. Append "tutorial" or "b2b explanation" if necessary to find high-quality content.
|
||||
4. DO NOT append specific channel names (e.g., "Fireship", "Vercel") to the query.
|
||||
5. DO NOT USE QUOTES IN THE QUERY.
|
||||
|
||||
Return a JSON object with a single string field "query". Example: {"query": "core web vitals performance tutorial"}`,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: `CONTEXT: ${topic}`,
|
||||
},
|
||||
],
|
||||
response_format: { type: "json_object" },
|
||||
});
|
||||
|
||||
try {
|
||||
let queryStr = "";
|
||||
const parsed = JSON.parse(
|
||||
queryGen.choices[0].message.content || '{"query": ""}',
|
||||
);
|
||||
queryStr = parsed.query || `${topic} tutorial explanation`;
|
||||
|
||||
// Step 2: Search via Serper Video Search
|
||||
const videos = await this.serperClient.searchVideos(queryStr);
|
||||
|
||||
if (!videos || videos.length === 0) {
|
||||
console.warn(`⚠️ [Serper] No videos found for query: "${queryStr}"`);
|
||||
if (retries > 0) return this.fetchRealSocialPosts(topic, retries - 1);
|
||||
return [];
|
||||
}
|
||||
|
||||
// Filter for youtube results
|
||||
const ytVideos = videos
|
||||
.filter(
|
||||
(v) =>
|
||||
v.link &&
|
||||
v.link.includes("youtube.com/watch") &&
|
||||
v.title &&
|
||||
v.channel,
|
||||
)
|
||||
.slice(0, 5); // Take top 5 for evaluation
|
||||
|
||||
if (ytVideos.length === 0) {
|
||||
console.warn(`⚠️ [Serper] No YouTube videos in search results.`);
|
||||
if (retries > 0) return this.fetchRealSocialPosts(topic, retries - 1);
|
||||
return [];
|
||||
}
|
||||
|
||||
// Step 3: Ask the LLM to evaluate the relevance of the found videos
|
||||
const evalPrompt = `You are a strict technical evaluator. You must select the MOST RELEVANT educational tech video from the list below based on this core article context: "${topic.slice(0, 800)}..."
|
||||
|
||||
Videos:
|
||||
${ytVideos.map((v, i) => `[ID: ${i}] Title: "${v.title}" | Channel: "${v.channel}" | Snippet: "${v.snippet || 'none'}"`).join("\n")}
|
||||
|
||||
RULES:
|
||||
1. The video MUST be highly relevant to the EXACT technical topic of the context.
|
||||
2. The channel SHOULD be a high-quality tech, development, or professional B2B channel (e.g., Google Developers, Vercel, Theo - t3.gg, Fireship, Syntax, ByteByteGo, IBM Technology, McKinsey, Gartner, Deloitte). AVOID gaming, generic vlogs, clickbait, off-topic podcasts, or unrelated topics.
|
||||
3. If none of the videos are strictly relevant to the core technical or business subject (e.g. they are just casually mentioning the word), YOU MUST RETURN -1. Be extremely critical. Do not just pick the "best of the worst".
|
||||
4. If one is highly relevant, return its ID number.
|
||||
|
||||
Return ONLY a JSON object: {"bestVideoId": number}`;
|
||||
|
||||
const evalResponse = await this.openai.chat.completions.create({
|
||||
model: "google/gemini-2.5-flash",
|
||||
messages: [{ role: "system", content: evalPrompt }],
|
||||
response_format: { type: "json_object" },
|
||||
});
|
||||
|
||||
let bestIdx = -1;
|
||||
try {
|
||||
const evalParsed = JSON.parse(
|
||||
evalResponse.choices[0].message.content || '{"bestVideoId": -1}',
|
||||
);
|
||||
bestIdx = evalParsed.bestVideoId;
|
||||
} catch (e) {
|
||||
console.warn("Failed to parse video evaluation response");
|
||||
}
|
||||
|
||||
if (bestIdx < 0 || bestIdx >= ytVideos.length) {
|
||||
console.warn(`⚠️ [Serper] LLM rejected all videos as irrelevant.`);
|
||||
if (retries > 0) return this.fetchRealSocialPosts(topic, retries - 1);
|
||||
return [];
|
||||
}
|
||||
|
||||
const bestVideo = ytVideos[bestIdx];
|
||||
console.log(
|
||||
`✅ [Serper] AI selected video: ${bestVideo.title} (Channel: ${bestVideo.channel})`,
|
||||
);
|
||||
|
||||
// Extract the 11-char video ID from the link (e.g., https://www.youtube.com/watch?v=dQw4w9WgXcQ)
|
||||
const urlObj = new URL(bestVideo.link);
|
||||
const videoId = urlObj.searchParams.get("v");
|
||||
|
||||
if (!videoId) {
|
||||
console.warn(
|
||||
`⚠️ [Serper] Could not extract video ID from: ${bestVideo.link}`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
console.log(
|
||||
`✅ [Serper] Found valid YouTube Video: ${videoId} ("${bestVideo.title}")`,
|
||||
);
|
||||
|
||||
return [
|
||||
{
|
||||
platform: "youtube",
|
||||
embedId: videoId,
|
||||
description: bestVideo.title || "YouTube Video",
|
||||
},
|
||||
];
|
||||
} catch (e) {
|
||||
console.error("❌ Failed to fetch real social posts:", e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async planResearch(
|
||||
topic: string,
|
||||
): Promise<{ trendsKeywords: string[]; dcVariables: string[] }> {
|
||||
const response = await this.openai.chat.completions.create({
|
||||
model: "google/gemini-2.0-flash-001",
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: `Plan research for: "${topic}".
|
||||
Return JSON:
|
||||
{
|
||||
"trendsKeywords": ["list", "of", "max", "2", "keywords"],
|
||||
"dcVariables": ["StatisticalVariables", "if", "known", "otherwise", "empty"]
|
||||
}
|
||||
CRITICAL: Do NOT provide more than 2 trendsKeywords. Keep it extremely focused.`,
|
||||
},
|
||||
],
|
||||
response_format: { type: "json_object" },
|
||||
});
|
||||
|
||||
if (
|
||||
!response.choices ||
|
||||
response.choices.length === 0 ||
|
||||
!response.choices[0].message
|
||||
) {
|
||||
console.warn(`⚠️ Research planning failed for concept: "${topic}"`);
|
||||
return { trendsKeywords: [], dcVariables: [] };
|
||||
}
|
||||
|
||||
try {
|
||||
let parsed = JSON.parse(
|
||||
response.choices[0].message.content ||
|
||||
'{"trendsKeywords": [], "dcVariables": []}',
|
||||
);
|
||||
if (Array.isArray(parsed)) {
|
||||
parsed = parsed[0] || { trendsKeywords: [], dcVariables: [] };
|
||||
}
|
||||
return {
|
||||
trendsKeywords: Array.isArray(parsed.trendsKeywords)
|
||||
? parsed.trendsKeywords
|
||||
: [],
|
||||
dcVariables: Array.isArray(parsed.dcVariables)
|
||||
? parsed.dcVariables
|
||||
: [],
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("Failed to parse research plan JSON", e);
|
||||
return { trendsKeywords: [], dcVariables: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Researches the top-ranking competitors on Google for a given topic.
|
||||
* Extracts their titles and snippets to guide the LLM to write better content.
|
||||
*/
|
||||
async researchCompetitors(topic: string, retries = 1): Promise<string[]> {
|
||||
console.log(
|
||||
`🔍 [Competitor Research] Fetching top ranking web pages for topic: "${topic.slice(0, 50)}..."`,
|
||||
);
|
||||
|
||||
// Step 1: LLM generates the optimal Google Search query
|
||||
const queryGen = await this.openai.chat.completions.create({
|
||||
model: "google/gemini-2.5-flash",
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: `Generate a Google Search query that a B2B decision maker would use to research the following topic: "${topic}".
|
||||
Focus on intent-driven keywords.
|
||||
Return a JSON object with a single string field "query". Example: {"query": "Next.js performance optimization agency"}.
|
||||
DO NOT USE QUOTES IN THE QUERY ITSELF.`,
|
||||
},
|
||||
],
|
||||
response_format: { type: "json_object" },
|
||||
});
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(
|
||||
queryGen.choices[0].message.content || '{"query": ""}',
|
||||
);
|
||||
const queryStr = parsed.query || topic;
|
||||
|
||||
// Step 2: Search via Serper Web Search
|
||||
const organicResults = await this.serperClient.searchWeb(queryStr, 5);
|
||||
|
||||
if (!organicResults || organicResults.length === 0) {
|
||||
console.warn(
|
||||
`⚠️ [Competitor Research] No web results found for query: "${queryStr}"`,
|
||||
);
|
||||
if (retries > 0) return this.researchCompetitors(topic, retries - 1);
|
||||
return [];
|
||||
}
|
||||
|
||||
// Map to structured insights string
|
||||
const insights = organicResults.map((result, i) => {
|
||||
return `[Rank #${i + 1}] Title: "${result.title}" | Snippet: "${result.snippet}"`;
|
||||
});
|
||||
|
||||
console.log(
|
||||
`✅ [Competitor Research] Analyzed top ${insights.length} competitor articles.`,
|
||||
);
|
||||
return insights;
|
||||
} catch (e) {
|
||||
console.error("❌ Failed to fetch competitor research:", e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
52
packages/journaling/src/clients/data-commons.ts
Normal file
52
packages/journaling/src/clients/data-commons.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import axios from "axios";
|
||||
|
||||
export interface DataPoint {
|
||||
date: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export class DataCommonsClient {
|
||||
private baseUrl = "https://api.datacommons.org";
|
||||
|
||||
/**
|
||||
* Fetches statistical series for a specific variable and place.
|
||||
* @param placeId DCID of the place (e.g., 'country/DEU' for Germany)
|
||||
* @param variable DCID of the statistical variable (e.g., 'Count_Person')
|
||||
*/
|
||||
async getStatSeries(placeId: string, variable: string): Promise<DataPoint[]> {
|
||||
try {
|
||||
// https://docs.datacommons.org/api/rest/v2/stat_series
|
||||
const response = await axios.get(`${this.baseUrl}/v2/stat/series`, {
|
||||
params: {
|
||||
place: placeId,
|
||||
stat_var: variable,
|
||||
},
|
||||
});
|
||||
|
||||
// Response format: { "series": { "country/DEU": { "Count_Person": { "val": { "2020": 83166711, ... } } } } }
|
||||
const seriesData = response.data?.series?.[placeId]?.[variable]?.val;
|
||||
|
||||
if (!seriesData) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object.entries(seriesData)
|
||||
.map(([date, value]) => ({ date, value: Number(value) }))
|
||||
.sort((a, b) => a.date.localeCompare(b.date));
|
||||
} catch (error) {
|
||||
console.error(`DataCommons Error (${placeId}, ${variable}):`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for entities (places, etc.)
|
||||
*/
|
||||
async resolveEntity(name: string): Promise<string | null> {
|
||||
// Search API or simple mapping for now.
|
||||
// DC doesn't have a simple "search" endpoint in v2 public API easily accessible without key sometimes?
|
||||
// Let's rely on LLM to provide DCIDs for now, or implement a naive search if needed.
|
||||
// For now, return null to force LLM to guess/know DCIDs.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
128
packages/journaling/src/clients/serper.ts
Normal file
128
packages/journaling/src/clients/serper.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
export interface SerperVideoResult {
|
||||
title: string;
|
||||
link: string;
|
||||
snippet?: string;
|
||||
date?: string;
|
||||
duration?: string;
|
||||
channel?: string;
|
||||
}
|
||||
|
||||
export interface SerperVideoResponse {
|
||||
searchParameters: any;
|
||||
videos: SerperVideoResult[];
|
||||
}
|
||||
|
||||
export interface SerperWebResult {
|
||||
title: string;
|
||||
link: string;
|
||||
snippet: string;
|
||||
date?: string;
|
||||
sitelinks?: any[];
|
||||
position: number;
|
||||
}
|
||||
|
||||
export interface SerperWebResponse {
|
||||
searchParameters: any;
|
||||
organic: SerperWebResult[];
|
||||
}
|
||||
|
||||
export class SerperClient {
|
||||
private apiKey: string;
|
||||
|
||||
constructor(apiKey?: string) {
|
||||
const key = apiKey || process.env.SERPER_API_KEY;
|
||||
if (!key) {
|
||||
console.warn("⚠️ SERPER_API_KEY is not defined. SerperClient will fail.");
|
||||
}
|
||||
this.apiKey = key || "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs a video search via Serper (Google Video Search).
|
||||
* Great for finding relevant YouTube videos.
|
||||
*/
|
||||
async searchVideos(
|
||||
query: string,
|
||||
num: number = 5,
|
||||
): Promise<SerperVideoResult[]> {
|
||||
if (!this.apiKey) {
|
||||
console.error("❌ SERPER_API_KEY missing - cannot execute search.");
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`🔍 [Serper] Searching videos for: "${query}"`);
|
||||
const response = await fetch("https://google.serper.dev/videos", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-API-KEY": this.apiKey,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
q: query,
|
||||
num: num,
|
||||
gl: "de", // Germany for localized results
|
||||
hl: "de", // German language
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(
|
||||
`❌ [Serper] API Error: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
const text = await response.text();
|
||||
console.error(text);
|
||||
return [];
|
||||
}
|
||||
|
||||
const data = (await response.json()) as SerperVideoResponse;
|
||||
return data.videos || [];
|
||||
} catch (e) {
|
||||
console.error("❌ [Serper] Request failed", e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs a standard web search via Serper.
|
||||
* Crucial for B2B competitor analysis and context gathering.
|
||||
*/
|
||||
async searchWeb(query: string, num: number = 5): Promise<SerperWebResult[]> {
|
||||
if (!this.apiKey) {
|
||||
console.error("❌ SERPER_API_KEY missing - cannot execute web search.");
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`🔍 [Serper] Web Search for Competitor Insights: "${query}"`);
|
||||
const response = await fetch("https://google.serper.dev/search", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-API-KEY": this.apiKey,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
q: query,
|
||||
num: num,
|
||||
gl: "de", // Germany for localized results
|
||||
hl: "de", // German language
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(
|
||||
`❌ [Serper] API Error: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
const text = await response.text();
|
||||
console.error(text);
|
||||
return [];
|
||||
}
|
||||
|
||||
const data = (await response.json()) as SerperWebResponse;
|
||||
return data.organic || [];
|
||||
} catch (e) {
|
||||
console.error("❌ [Serper] Web Request failed", e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
79
packages/journaling/src/clients/trends.ts
Normal file
79
packages/journaling/src/clients/trends.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import OpenAI from "openai";
|
||||
|
||||
export interface TrendPoint {
|
||||
date: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export class TrendsClient {
|
||||
private openai: OpenAI;
|
||||
|
||||
constructor(apiKey?: string) {
|
||||
// Use environment key if available, otherwise expect it passed
|
||||
const key = apiKey || process.env.OPENROUTER_KEY || "dummy";
|
||||
this.openai = new OpenAI({
|
||||
apiKey: key,
|
||||
baseURL: "https://openrouter.ai/api/v1",
|
||||
defaultHeaders: {
|
||||
"HTTP-Referer": "https://mintel.me",
|
||||
"X-Title": "Mintel Trends Engine",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulates interest over time using LLM knowledge to avoid flaky scraping.
|
||||
* This ensures the "Digital Architect" pipelines don't break on API changes.
|
||||
*/
|
||||
async getInterestOverTime(
|
||||
keyword: string,
|
||||
geo: string = "DE",
|
||||
): Promise<TrendPoint[]> {
|
||||
console.log(
|
||||
`📈 Simuliere Suchvolumen-Trend (AI-basiert) für: "${keyword}" (Region: ${geo})...`,
|
||||
);
|
||||
try {
|
||||
const response = await this.openai.chat.completions.create({
|
||||
model: "google/gemini-2.5-flash",
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: `You are a data simulator. Generate a realistic Google Trends-style JSON dataset for the keyword "${keyword}" in "${geo}" over the last 5 years.
|
||||
Rules:
|
||||
- 12 data points (approx one every 6 months or represent key moments).
|
||||
- Values between 0-100.
|
||||
- JSON format: { "timeline": [{ "date": "YYYY-MM", "value": 50 }] }
|
||||
- Return ONLY JSON.`,
|
||||
},
|
||||
],
|
||||
response_format: { type: "json_object" },
|
||||
});
|
||||
|
||||
const body = response.choices[0].message.content || "{}";
|
||||
const parsed = JSON.parse(body);
|
||||
return parsed.timeline || [];
|
||||
} catch (error) {
|
||||
console.warn(`Simulated Trend Error (${keyword}):`, error);
|
||||
// Fallback mock data
|
||||
return [
|
||||
{ date: "2020-01", value: 20 },
|
||||
{ date: "2021-01", value: 35 },
|
||||
{ date: "2022-01", value: 50 },
|
||||
{ date: "2023-01", value: 75 },
|
||||
{ date: "2024-01", value: 95 },
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
async getRelatedQueries(
|
||||
keyword: string,
|
||||
geo: string = "DE",
|
||||
): Promise<string[]> {
|
||||
// Simple mock to avoid API calls
|
||||
return [
|
||||
`${keyword} optimization`,
|
||||
`${keyword} tutorial`,
|
||||
`${keyword} best practices`,
|
||||
];
|
||||
}
|
||||
}
|
||||
4
packages/journaling/src/index.ts
Normal file
4
packages/journaling/src/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./clients/data-commons";
|
||||
export * from "./clients/trends";
|
||||
export * from "./clients/serper";
|
||||
export * from "./agent";
|
||||
17
packages/journaling/src/types/google-trends-api.d.ts
vendored
Normal file
17
packages/journaling/src/types/google-trends-api.d.ts
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
declare module "google-trends-api" {
|
||||
export function interestOverTime(options: {
|
||||
keyword: string | string[];
|
||||
startTime?: Date;
|
||||
endTime?: Date;
|
||||
geo?: string;
|
||||
hl?: string;
|
||||
timezone?: number;
|
||||
category?: number;
|
||||
}): Promise<string>;
|
||||
|
||||
export function interestByRegion(options: any): Promise<string>;
|
||||
export function relatedQueries(options: any): Promise<string>;
|
||||
export function relatedTopics(options: any): Promise<string>;
|
||||
export function dailyTrends(options: any): Promise<string>;
|
||||
export function realTimeTrends(options: any): Promise<string>;
|
||||
}
|
||||
11
packages/journaling/tsconfig.json
Normal file
11
packages/journaling/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "@mintel/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"module": "ESNext",
|
||||
"target": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
29
packages/meme-generator/package.json
Normal file
29
packages/meme-generator/package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "@mintel/meme-generator",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup src/index.ts --format esm --dts --clean",
|
||||
"dev": "tsup src/index.ts --format esm --watch --dts",
|
||||
"lint": "eslint src"
|
||||
},
|
||||
"dependencies": {
|
||||
"openai": "^4.82.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mintel/eslint-config": "workspace:*",
|
||||
"@mintel/tsconfig": "workspace:*",
|
||||
"tsup": "^8.3.5",
|
||||
"typescript": "^5.0.0"
|
||||
}
|
||||
}
|
||||
141
packages/meme-generator/src/index.ts
Normal file
141
packages/meme-generator/src/index.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import OpenAI from "openai";
|
||||
|
||||
export interface MemeSuggestion {
|
||||
template: string;
|
||||
captions: string[];
|
||||
explanation: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mapping of common meme names to memegen.link template IDs.
|
||||
* See https://api.memegen.link/templates for the full list.
|
||||
*/
|
||||
export const MEMEGEN_TEMPLATES: Record<string, string> = {
|
||||
drake: "drake",
|
||||
"drake hotline bling": "drake",
|
||||
"distracted boyfriend": "db",
|
||||
distracted: "db",
|
||||
"expanding brain": "brain",
|
||||
expanding: "brain",
|
||||
"this is fine": "fine",
|
||||
fine: "fine",
|
||||
clown: "clown-applying-makeup",
|
||||
"clown applying makeup": "clown-applying-makeup",
|
||||
"two buttons": "daily-struggle",
|
||||
"daily struggle": "daily-struggle",
|
||||
ds: "daily-struggle",
|
||||
gru: "gru",
|
||||
"change my mind": "cmm",
|
||||
"always has been": "ahb",
|
||||
"uno reverse": "uno",
|
||||
"disaster girl": "disastergirl",
|
||||
"is this a pigeon": "pigeon",
|
||||
"roll safe": "rollsafe",
|
||||
rollsafe: "rollsafe",
|
||||
"surprised pikachu": "pikachu",
|
||||
"batman slapping robin": "slap",
|
||||
"left exit 12": "exit",
|
||||
"one does not simply": "mordor",
|
||||
"panik kalm panik": "panik",
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve a human-readable meme name to a memegen.link template ID.
|
||||
* Falls back to slugified version of the name.
|
||||
*/
|
||||
export function resolveTemplateId(name: string): string {
|
||||
if (!name) return "drake";
|
||||
const normalized = name.toLowerCase().trim();
|
||||
|
||||
// Check if it's already a valid memegen ID
|
||||
const validIds = new Set(Object.values(MEMEGEN_TEMPLATES));
|
||||
if (validIds.has(normalized)) return normalized;
|
||||
|
||||
// Check mapping
|
||||
if (MEMEGEN_TEMPLATES[normalized]) return MEMEGEN_TEMPLATES[normalized];
|
||||
|
||||
// STRICT FALLBACK: Prevent 404 image errors on the frontend
|
||||
return "drake";
|
||||
}
|
||||
|
||||
export class MemeGenerator {
|
||||
private openai: OpenAI;
|
||||
|
||||
constructor(
|
||||
apiKey: string,
|
||||
baseUrl: string = "https://openrouter.ai/api/v1",
|
||||
) {
|
||||
this.openai = new OpenAI({
|
||||
apiKey,
|
||||
baseURL: baseUrl,
|
||||
defaultHeaders: {
|
||||
"HTTP-Referer": "https://mintel.me",
|
||||
"X-Title": "Mintel AI Meme Generator",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async generateMemeIdeas(content: string): Promise<MemeSuggestion[]> {
|
||||
const templateList = Object.keys(MEMEGEN_TEMPLATES)
|
||||
.filter((k, i, arr) => arr.indexOf(k) === i)
|
||||
.slice(0, 20)
|
||||
.join(", ");
|
||||
|
||||
const response = await this.openai.chat.completions.create({
|
||||
model: "google/gemini-2.5-flash",
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: `You are a high-end Meme Architect for "Mintel.me", a boutique digital architecture studio.
|
||||
Your persona is Marc Mintel: a technical expert, performance-obsessed, and "no-BS" digital architect.
|
||||
|
||||
Your Goal: Analyze the blog post content and suggest 3 high-fidelity, highly sarcastic, and provocative technical memes that would appeal to (and trigger) CEOs, CTOs, and high-level marketing engineers.
|
||||
|
||||
Meme Guidelines:
|
||||
1. Tone: Extremely sarcastic, provocative, and "triggering". It must mock typical B2B SaaS/Agency mediocrity. Pure sarcasm that forces people to share it because it hurts (e.g. throwing 20k ads at an 8-second loading page, blaming weather for bounce rates).
|
||||
2. Language: Use German for the captions. Use biting technical/business terms (e.g., "ROI-Killer", "Tracking-Müll", "WordPress-Hölle", "Marketing-Budget verbrennen").
|
||||
3. Quality: Must be ruthless. Avoid generic "Low Effort" memes. The humor should stem from the painful reality of bad tech decisions.
|
||||
|
||||
IMPORTANT: Use ONLY template IDs from this list for the "template" field:
|
||||
${templateList}
|
||||
|
||||
Return ONLY a JSON object:
|
||||
{
|
||||
"memes": [
|
||||
{
|
||||
"template": "memegen_template_id",
|
||||
"captions": ["Top caption", "Bottom caption"],
|
||||
"explanation": "Brief context on why this fits the strategy"
|
||||
}
|
||||
]
|
||||
}
|
||||
IMPORTANT: Return ONLY the JSON object. No markdown wrappers.`,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content,
|
||||
},
|
||||
],
|
||||
response_format: { type: "json_object" },
|
||||
});
|
||||
|
||||
const body = response.choices[0].message.content || '{"memes": []}';
|
||||
let result;
|
||||
try {
|
||||
result = JSON.parse(body);
|
||||
} catch (e) {
|
||||
console.error("Failed to parse AI response", body);
|
||||
return [];
|
||||
}
|
||||
|
||||
// Normalize template IDs
|
||||
const memes: MemeSuggestion[] = (result.memes || []).map(
|
||||
(m: MemeSuggestion) => ({
|
||||
...m,
|
||||
template: resolveTemplateId(m.template),
|
||||
}),
|
||||
);
|
||||
|
||||
return memes;
|
||||
}
|
||||
}
|
||||
14
packages/meme-generator/src/placeholder.ts
Normal file
14
packages/meme-generator/src/placeholder.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export function getPlaceholderImage(
|
||||
width: number,
|
||||
height: number,
|
||||
text: string,
|
||||
): string {
|
||||
// Generate a simple SVG placeholder as base64
|
||||
const svg = `
|
||||
<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="100%" height="100%" fill="#1e293b"/>
|
||||
<text x="50%" y="50%" font-family="monospace" font-size="24" fill="#64748b" text-anchor="middle" dy=".3em">${text}</text>
|
||||
</svg>
|
||||
`.trim();
|
||||
return Buffer.from(svg).toString("base64");
|
||||
}
|
||||
11
packages/meme-generator/tsconfig.json
Normal file
11
packages/meme-generator/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "@mintel/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"module": "ESNext",
|
||||
"target": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -20,9 +20,11 @@
|
||||
"build": "directus-extension build",
|
||||
"dev": "directus-extension build -w"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mintel/directus-extension-toolkit": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@directus/extensions-sdk": "11.0.2",
|
||||
"@mintel/directus-extension-toolkit": "workspace:*",
|
||||
"vue": "^3.4.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -126,9 +126,11 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed, nextTick } from 'vue';
|
||||
import { useApi } from '@directus/extensions-sdk';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { MintelManagerLayout, MintelSelect } from '@mintel/directus-extension-toolkit';
|
||||
|
||||
const api = useApi();
|
||||
const route = useRoute();
|
||||
const people = ref([]);
|
||||
const companies = ref([]);
|
||||
const selectedPerson = ref(null);
|
||||
@@ -256,7 +258,12 @@ function openQuickAdd(type: string) {
|
||||
feedback.value = { type: 'info', message: `Firma im Company Manager anlegen.` };
|
||||
}
|
||||
|
||||
onMounted(fetchData);
|
||||
onMounted(async () => {
|
||||
await fetchData();
|
||||
if (route.query.create === 'true') {
|
||||
openCreateDrawer();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
30
packages/thumbnail-generator/package.json
Normal file
30
packages/thumbnail-generator/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@mintel/thumbnail-generator",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup src/index.ts --format esm --dts --clean",
|
||||
"dev": "tsup src/index.ts --format esm --watch --dts",
|
||||
"lint": "eslint src"
|
||||
},
|
||||
"dependencies": {
|
||||
"replicate": "^1.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mintel/eslint-config": "workspace:*",
|
||||
"@mintel/tsconfig": "workspace:*",
|
||||
"@types/node": "^20.0.0",
|
||||
"tsup": "^8.3.5",
|
||||
"typescript": "^5.0.0"
|
||||
}
|
||||
}
|
||||
112
packages/thumbnail-generator/src/generator.ts
Normal file
112
packages/thumbnail-generator/src/generator.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import Replicate from "replicate";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
|
||||
export interface ThumbnailGeneratorConfig {
|
||||
replicateApiKey: string;
|
||||
}
|
||||
|
||||
export interface ThumbnailGenerateOptions {
|
||||
model?: string;
|
||||
systemPrompt?: string;
|
||||
imagePrompt?: string; // Path to local reference image
|
||||
}
|
||||
|
||||
export class ThumbnailGenerator {
|
||||
private replicate: Replicate;
|
||||
|
||||
constructor(config: ThumbnailGeneratorConfig) {
|
||||
this.replicate = new Replicate({
|
||||
auth: config.replicateApiKey,
|
||||
});
|
||||
}
|
||||
|
||||
public async generateImage(
|
||||
topic: string,
|
||||
outputPath: string,
|
||||
options?: ThumbnailGenerateOptions,
|
||||
): Promise<string> {
|
||||
const defaultSystemPrompt = `A highly polished, ultra-minimalist conceptual illustration. Style: high-end tech agency, clean modern 3D or flat vector art, extensive use of negative space, elegant monochrome palette (whites, light grays) with a single vibrant accent color (neon green or electric blue). Extremely clean and precise geometry. Absolutely no text, no photorealism, no chaotic lines, no messy sketches, no people.`;
|
||||
|
||||
const systemPrompt = options?.systemPrompt || defaultSystemPrompt;
|
||||
const prompt = `${systemPrompt}\n\nTopic to illustrate abstractly: ${topic}`;
|
||||
|
||||
console.log(`🎨 Generating thumbnail for topic: "${topic}"...`);
|
||||
|
||||
const inputPayload: any = {
|
||||
prompt,
|
||||
aspect_ratio: "16:9",
|
||||
output_format: "png",
|
||||
output_quality: 90,
|
||||
prompt_upsampling: false,
|
||||
};
|
||||
|
||||
if (options?.imagePrompt) {
|
||||
console.log(`🖼️ Using image style reference: ${options.imagePrompt}`);
|
||||
try {
|
||||
const absImgPath = path.isAbsolute(options.imagePrompt)
|
||||
? options.imagePrompt
|
||||
: path.resolve(process.cwd(), options.imagePrompt);
|
||||
const imgBuffer = await fs.readFile(absImgPath);
|
||||
const base64 = imgBuffer.toString("base64");
|
||||
// Replicate models usually expect a data URI for image_prompt
|
||||
inputPayload.image_prompt = `data:image/png;base64,${base64}`;
|
||||
} catch (err) {
|
||||
console.warn(`⚠️ Could not load image prompt: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Default to the requested nano-banana-pro model unless explicitly provided
|
||||
const model = options?.model || "google/nano-banana-pro";
|
||||
|
||||
const output = await this.replicate.run(model as `${string}/${string}`, {
|
||||
input: inputPayload,
|
||||
});
|
||||
|
||||
// Replicate returns a ReadableStream for the output of flux-1.1-pro in newer Node SDKs
|
||||
// Or a string URL in older ones. We handle both.
|
||||
let buffer: Buffer;
|
||||
|
||||
if (output instanceof ReadableStream) {
|
||||
console.log(`⬇️ Downloading generated stream from Replicate...`);
|
||||
const chunks: Uint8Array[] = [];
|
||||
const reader = output.getReader();
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (value) chunks.push(value);
|
||||
}
|
||||
buffer = Buffer.concat(chunks);
|
||||
} else if (
|
||||
typeof output === "string" ||
|
||||
(Array.isArray(output) && typeof output[0] === "string")
|
||||
) {
|
||||
const imageUrl = Array.isArray(output) ? output[0] : output;
|
||||
console.log(
|
||||
`⬇️ Downloading generated image from URL: ${imageUrl.substring(0, 50)}...`,
|
||||
);
|
||||
const response = await fetch(imageUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download image: ${response.statusText}`);
|
||||
}
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
buffer = Buffer.from(arrayBuffer);
|
||||
} else if (Buffer.isBuffer(output)) {
|
||||
buffer = output;
|
||||
} else if (typeof output === "object") {
|
||||
console.log("Raw output object:", output);
|
||||
throw new Error("Unexpected output format from Replicate.");
|
||||
} else {
|
||||
throw new Error("Unknown output format from Replicate.");
|
||||
}
|
||||
|
||||
const absPath = path.isAbsolute(outputPath)
|
||||
? outputPath
|
||||
: path.resolve(process.cwd(), outputPath);
|
||||
await fs.mkdir(path.dirname(absPath), { recursive: true });
|
||||
await fs.writeFile(absPath, buffer);
|
||||
|
||||
console.log(`✅ Saved thumbnail to: ${absPath}`);
|
||||
return absPath;
|
||||
}
|
||||
}
|
||||
1
packages/thumbnail-generator/src/index.ts
Normal file
1
packages/thumbnail-generator/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./generator";
|
||||
8
packages/thumbnail-generator/tsconfig.json
Normal file
8
packages/thumbnail-generator/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@mintel/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -24,4 +24,4 @@
|
||||
"@directus/extensions-sdk": "11.0.2",
|
||||
"vue": "^3.4.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
</header>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card" @click="navigateTo('/company-manager')">
|
||||
<div class="stat-card" @click="navigateTo('company-manager')">
|
||||
<div class="stat-icon"><v-icon name="business" large /></div>
|
||||
<div class="stat-content">
|
||||
<span class="stat-label">Firmen</span>
|
||||
@@ -16,7 +16,7 @@
|
||||
<v-icon name="chevron_right" class="arrow" />
|
||||
</div>
|
||||
|
||||
<div class="stat-card" @click="navigateTo('/people-manager')">
|
||||
<div class="stat-card" @click="navigateTo('people-manager')">
|
||||
<div class="stat-icon"><v-icon name="person" large /></div>
|
||||
<div class="stat-content">
|
||||
<span class="stat-label">Personen</span>
|
||||
@@ -25,7 +25,7 @@
|
||||
<v-icon name="chevron_right" class="arrow" />
|
||||
</div>
|
||||
|
||||
<div class="stat-card" @click="navigateTo('/acquisition-manager')">
|
||||
<div class="stat-card" @click="navigateTo('acquisition-manager')">
|
||||
<div class="stat-icon"><v-icon name="auto_awesome" large /></div>
|
||||
<div class="stat-content">
|
||||
<span class="stat-label">Leads</span>
|
||||
@@ -38,14 +38,18 @@
|
||||
<div class="recent-activity">
|
||||
<h2 class="section-title">Schnellzugriff</h2>
|
||||
<div class="action-grid">
|
||||
<v-button secondary block @click="navigateTo('/people-manager?create=true')">
|
||||
<v-button secondary block @click="navigateTo('people-manager', { create: 'true' })">
|
||||
<v-icon name="person_add" left />
|
||||
Neue Person anlegen
|
||||
</v-button>
|
||||
<v-button secondary block @click="navigateTo('/acquisition-manager?create=true')">
|
||||
<v-button secondary block @click="navigateTo('acquisition-manager', { create: 'true' })">
|
||||
<v-icon name="add_link" left />
|
||||
Neuen Lead registrieren
|
||||
</v-button>
|
||||
<v-button secondary block @click="navigateTo('customer-manager', { create: 'true' })">
|
||||
<v-icon name="handshake" left />
|
||||
Kunden verlinken
|
||||
</v-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -83,8 +87,9 @@ async function fetchStats() {
|
||||
}
|
||||
}
|
||||
|
||||
function navigateTo(path: string) {
|
||||
router.push(path);
|
||||
function navigateTo(id: string, query?: any) {
|
||||
console.log(`[Unified Dashboard] Navigating to ${id}...`);
|
||||
router.push({ name: id, query });
|
||||
}
|
||||
|
||||
onMounted(fetchStats);
|
||||
|
||||
787
pnpm-lock.yaml
generated
787
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user