This commit is contained in:
knuthtimo-lab 2025-09-04 10:41:27 +02:00
parent b1a2626c8d
commit bdcb9d3b75
22 changed files with 2349 additions and 58 deletions

View File

@ -1,7 +1,8 @@
{
"permissions": {
"allow": [
"Bash(npm run dev:*)"
"Bash(npm run dev:*)",
"Bash(npm install:*)"
],
"deny": [],
"ask": []

12
.env.example Normal file
View File

@ -0,0 +1,12 @@
# Answer Engine Optimization (AEO) Configuration
# IndexNow API key for automatic URL submission to search engines
# Generate a UUID and create a matching key file in /public/
VITE_INDEXNOW_KEY=your-indexnow-key-here
# Primary domain for canonical URLs and IndexNow submissions
VITE_SITE_HOST=energie-profis.de
# Supabase Configuration (existing)
VITE_SUPABASE_URL=your-supabase-url
VITE_SUPABASE_ANON_KEY=your-supabase-anon-key

View File

@ -1,8 +1,90 @@
# Welcome to your Lovable project
# EnergieProfis - Renewable Energy Platform
A React + TypeScript platform connecting energy customers with qualified solar and wind installation professionals in Germany.
## Project info
**URL**: https://lovable.dev/projects/54a0b4f8-e87d-43d2-ac51-69b3e38c23fb
**Domain**: energie-profis.de
## Answer Engine Optimization (AEO) Features
This project implements comprehensive AEO to be cited by ChatGPT, Perplexity, and other AI answer engines:
### ✅ Implemented Features
- **Robots.txt**: Explicitly allows PerplexityBot and GPTBot
- **IndexNow Integration**: Automatic URL submission with queue and retry logic
- **Sitemaps**: XML sitemaps with accurate lastmod dates
- **Canonical URLs**: Single canonical per page for AI indexing
- **JSON-LD Schema**: FAQ, HowTo, Article, Organization markup
- **Content Metadata**: Visible "Zuletzt aktualisiert" and author information
### 🔧 AEO Setup
1. **Environment Variables**
```bash
VITE_INDEXNOW_KEY=your-indexnow-key-here
VITE_SITE_HOST=energie-profis.de
```
2. **Create IndexNow Key File**
```bash
# Create public key file (replace with your actual key)
echo "your-indexnow-key-here" > public/your-indexnow-key-here.txt
```
3. **Usage in Components**
```tsx
import Canonical from '@/components/seo/Canonical';
import JsonLd from '@/components/seo/JsonLd';
import ContentMeta from '@/components/seo/ContentMeta';
import { buildFAQPageJsonLd, exampleFAQs } from '@/lib/schema';
function MyPage() {
return (
<>
<Canonical />
<JsonLd data={buildFAQPageJsonLd(exampleFAQs)} />
<ContentMeta lastUpdated={new Date()} />
</>
);
}
```
4. **Manual IndexNow Ping**
```bash
npm run ping-indexnow https://energie-profis.de/ https://energie-profis.de/solar
```
### 🧪 Testing AEO Features
Run E2E tests to validate AEO implementation:
```bash
npm run test:e2e
```
Tests cover:
- Robots.txt allows AI bots
- Canonical URLs are present and correct
- JSON-LD schema is valid
- Sitemap includes all pages with lastmod
### 📋 Manual Validation
Quick checks to verify AEO compliance:
```bash
# Check robots.txt allows AI bots
curl https://energie-profis.de/robots.txt | grep -E "(PerplexityBot|GPTBot)"
# Validate sitemap structure
curl https://energie-profis.de/sitemap.xml | xmllint --format -
# Check canonical on homepage
curl -s https://energie-profis.de/ | grep -o '<link rel="canonical"[^>]*>'
```
## How can I edit this code?

605
package-lock.json generated
View File

@ -42,6 +42,7 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^3.6.0",
"dotenv": "^17.2.2",
"embla-carousel-react": "^8.6.0",
"input-otp": "^1.4.2",
"lucide-react": "^0.462.0",
@ -61,6 +62,7 @@
},
"devDependencies": {
"@eslint/js": "^9.32.0",
"@playwright/test": "^1.55.0",
"@tailwindcss/typography": "^0.5.16",
"@types/node": "^22.16.5",
"@types/react": "^18.3.23",
@ -74,6 +76,7 @@
"lovable-tagger": "^1.1.9",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.17",
"tsx": "^4.20.5",
"typescript": "^5.8.3",
"typescript-eslint": "^8.38.0",
"vite": "^5.4.19"
@ -507,6 +510,23 @@
"node": ">=12"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.9.tgz",
"integrity": "sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
@ -952,6 +972,22 @@
"node": ">=14"
}
},
"node_modules/@playwright/test": {
"version": "1.55.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.0.tgz",
"integrity": "sha512-04IXzPwHrW69XusN/SIdDdKZBzMfOT9UNT/YiJit/xpy2VuAoB8NHc8Aplb96zsWDddLnbkPL3TsmrS04ZU2xQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.55.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@radix-ui/number": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
@ -3909,6 +3945,18 @@
"csstype": "^3.0.2"
}
},
"node_modules/dotenv": {
"version": "17.2.2",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.2.tgz",
"integrity": "sha512-Sf2LSQP+bOlhKWWyhFsn0UsfdK/kCWRv1iuA2gXAwt3dyNabr6QSj00I2V10pidqz69soatm9ZwZvpQMTIOd5Q==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/eastasianwidth": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
@ -4404,6 +4452,19 @@
"node": ">=6"
}
},
"node_modules/get-tsconfig": {
"version": "4.10.1",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz",
"integrity": "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"resolve-pkg-maps": "^1.0.0"
},
"funding": {
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/glob": {
"version": "10.4.5",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
@ -5550,6 +5611,53 @@
"node": ">= 6"
}
},
"node_modules/playwright": {
"version": "1.55.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.0.tgz",
"integrity": "sha512-sdCWStblvV1YU909Xqx0DhOjPZE4/5lJsIS84IfN9dAZfcl/CIZ5O8l3o0j7hPMjDvqoTF8ZUcc+i/GL5erstA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.55.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.55.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.0.tgz",
"integrity": "sha512-GvZs4vU3U5ro2nZpeiwyb0zuFaqb9sUiAJuyrWpcGouD8y9/HLgGbNRjIph7zU9D3hnPaisMl9zG9CgFi/biIg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
@ -6033,6 +6141,16 @@
"node": ">=4"
}
},
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/reusify": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
@ -6458,6 +6576,493 @@
"integrity": "sha512-jWVzBLplnCmoaTr13V9dYbiQ99wvZRd0vNWaDRg+aVYRcjDF3nDksxFDE/+fkXnKhpnUUkmx5pK/v8mCtLVqZA==",
"license": "0BSD"
},
"node_modules/tsx": {
"version": "4.20.5",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.5.tgz",
"integrity": "sha512-+wKjMNU9w/EaQayHXb7WA7ZaHY6hN8WgfvHNQ3t1PnU91/7O8TcTnIhCDYTZwnt8JsO9IBqZ30Ln1r7pPF52Aw==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.25.0",
"get-tsconfig": "^4.7.5"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/tsx/node_modules/@esbuild/aix-ppc64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz",
"integrity": "sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/android-arm": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.9.tgz",
"integrity": "sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/android-arm64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.9.tgz",
"integrity": "sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/android-x64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.9.tgz",
"integrity": "sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/darwin-arm64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.9.tgz",
"integrity": "sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/darwin-x64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.9.tgz",
"integrity": "sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/freebsd-arm64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.9.tgz",
"integrity": "sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/freebsd-x64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.9.tgz",
"integrity": "sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-arm": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.9.tgz",
"integrity": "sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-arm64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.9.tgz",
"integrity": "sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-ia32": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.9.tgz",
"integrity": "sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-loong64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.9.tgz",
"integrity": "sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-mips64el": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.9.tgz",
"integrity": "sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-ppc64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.9.tgz",
"integrity": "sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-riscv64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.9.tgz",
"integrity": "sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-s390x": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.9.tgz",
"integrity": "sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-x64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.9.tgz",
"integrity": "sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/netbsd-arm64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.9.tgz",
"integrity": "sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/netbsd-x64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.9.tgz",
"integrity": "sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/openbsd-arm64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.9.tgz",
"integrity": "sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/openbsd-x64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.9.tgz",
"integrity": "sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/sunos-x64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.9.tgz",
"integrity": "sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/win32-arm64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.9.tgz",
"integrity": "sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/win32-ia32": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.9.tgz",
"integrity": "sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/win32-x64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.9.tgz",
"integrity": "sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/esbuild": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz",
"integrity": "sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.25.9",
"@esbuild/android-arm": "0.25.9",
"@esbuild/android-arm64": "0.25.9",
"@esbuild/android-x64": "0.25.9",
"@esbuild/darwin-arm64": "0.25.9",
"@esbuild/darwin-x64": "0.25.9",
"@esbuild/freebsd-arm64": "0.25.9",
"@esbuild/freebsd-x64": "0.25.9",
"@esbuild/linux-arm": "0.25.9",
"@esbuild/linux-arm64": "0.25.9",
"@esbuild/linux-ia32": "0.25.9",
"@esbuild/linux-loong64": "0.25.9",
"@esbuild/linux-mips64el": "0.25.9",
"@esbuild/linux-ppc64": "0.25.9",
"@esbuild/linux-riscv64": "0.25.9",
"@esbuild/linux-s390x": "0.25.9",
"@esbuild/linux-x64": "0.25.9",
"@esbuild/netbsd-arm64": "0.25.9",
"@esbuild/netbsd-x64": "0.25.9",
"@esbuild/openbsd-arm64": "0.25.9",
"@esbuild/openbsd-x64": "0.25.9",
"@esbuild/openharmony-arm64": "0.25.9",
"@esbuild/sunos-x64": "0.25.9",
"@esbuild/win32-arm64": "0.25.9",
"@esbuild/win32-ia32": "0.25.9",
"@esbuild/win32-x64": "0.25.9"
}
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",

View File

@ -8,7 +8,11 @@
"build": "vite build",
"build:dev": "vite build --mode development",
"lint": "eslint .",
"preview": "vite preview"
"preview": "vite preview",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:headed": "playwright test --headed",
"ping-indexnow": "tsx scripts/ping-indexnow.ts"
},
"dependencies": {
"@hookform/resolvers": "^3.10.0",
@ -45,6 +49,7 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^3.6.0",
"dotenv": "^17.2.2",
"embla-carousel-react": "^8.6.0",
"input-otp": "^1.4.2",
"lucide-react": "^0.462.0",
@ -64,6 +69,7 @@
},
"devDependencies": {
"@eslint/js": "^9.32.0",
"@playwright/test": "^1.55.0",
"@tailwindcss/typography": "^0.5.16",
"@types/node": "^22.16.5",
"@types/react": "^18.3.23",
@ -77,6 +83,7 @@
"lovable-tagger": "^1.1.9",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.17",
"tsx": "^4.20.5",
"typescript": "^5.8.3",
"typescript-eslint": "^8.38.0",
"vite": "^5.4.19"

61
playwright.config.ts Normal file
View File

@ -0,0 +1,61 @@
import { defineConfig, devices } from '@playwright/test';
/**
* Playwright configuration for AEO (Answer Engine Optimization) testing
* @see https://playwright.dev/docs/test-configuration
*/
export default defineConfig({
testDir: './tests/e2e',
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: 'http://localhost:8080',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: { ...devices['Pixel 5'] },
// },
// {
// name: 'Mobile Safari',
// use: { ...devices['iPhone 12'] },
// },
],
/* Run your local dev server before starting the tests */
webServer: {
command: 'npm run dev',
url: 'http://localhost:8080',
reuseExistingServer: !process.env.CI,
},
});

View File

@ -10,5 +10,13 @@ Allow: /
User-agent: facebookexternalhit
Allow: /
User-agent: PerplexityBot
Allow: /
User-agent: GPTBot
Allow: /
User-agent: *
Allow: /
Sitemap: https://energie-profis.de/sitemap.xml

46
public/sitemap.xml Normal file
View File

@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://energie-profis.de/</loc>
<lastmod>2025-01-28</lastmod>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://energie-profis.de/solar</loc>
<lastmod>2025-01-28</lastmod>
<changefreq>weekly</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>https://energie-profis.de/wind</loc>
<lastmod>2025-01-28</lastmod>
<changefreq>weekly</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>https://energie-profis.de/installateur-finden</loc>
<lastmod>2025-01-04</lastmod>
<changefreq>daily</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://energie-profis.de/kostenlose-beratung</loc>
<lastmod>2024-12-05</lastmod>
<changefreq>monthly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://energie-profis.de/unternehmen-listen</loc>
<lastmod>2025-01-04</lastmod>
<changefreq>daily</changefreq>
<priority>0.6</priority>
</url>
</urlset>

37
scripts/ping-indexnow.ts Normal file
View File

@ -0,0 +1,37 @@
#!/usr/bin/env tsx
/**
* Manual IndexNow ping script for CI/CD and manual testing
* Usage: npm run ping-indexnow <url1> <url2> ...
*/
import { pingIndexNowManually } from '../src/lib/indexnow.js';
async function main() {
const urls = process.argv.slice(2);
if (urls.length === 0) {
console.error('Usage: npm run ping-indexnow <url1> <url2> ...');
console.error('Example: npm run ping-indexnow https://energie-profis.de/ https://energie-profis.de/solar');
process.exit(1);
}
console.log(`Pinging IndexNow for ${urls.length} URLs...`);
console.log('URLs:', urls.join(', '));
try {
const success = await pingIndexNowManually(urls);
if (success) {
console.log('✅ IndexNow ping completed successfully');
process.exit(0);
} else {
console.error('❌ IndexNow ping failed or queued for retry');
process.exit(1);
}
} catch (error) {
console.error('❌ IndexNow ping error:', error);
process.exit(1);
}
}
main().catch(console.error);

View File

@ -74,20 +74,6 @@ const HeroSection = () => {
</Button>
</div>
</div>
{/* Clean CTA buttons */}
<div className="flex flex-col sm:flex-row gap-4">
<Button
variant="outline"
size="xl"
className="border-2 border-white/40 text-white hover:bg-white hover:text-primary bg-white/10 backdrop-blur-sm hover:shadow-lg transition-all duration-300"
>
<Link to="/installateur-finden" className="flex items-center">
Alle Installateure Ansehen
<ArrowRight className="w-5 h-5 ml-2 group-hover:translate-x-1 transition-transform" />
</Link>
</Button>
</div>
</div>
</div>
@ -133,6 +119,20 @@ const HeroSection = () => {
</div>
</div>
</div>
{/* Moved "Alle Installateure Ansehen" button to upper right corner */}
<div className="absolute top-8 right-8 z-20">
<Button
variant="outline"
size="lg"
className="border-2 border-white/40 text-white hover:bg-white hover:text-primary bg-white/10 backdrop-blur-sm hover:shadow-lg transition-all duration-300"
>
<Link to="/installateur-finden" className="flex items-center">
Alle Installateure Ansehen
<ArrowRight className="w-5 h-5 ml-2 group-hover:translate-x-1 transition-transform" />
</Link>
</Button>
</div>
</section>
);
};

View File

@ -0,0 +1,45 @@
/**
* Canonical URL component for Answer Engine Optimization (AEO)
* Ensures single canonical URL per page for better AI indexing
*/
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
interface CanonicalProps {
url?: string;
baseUrl?: string;
}
const Canonical: React.FC<CanonicalProps> = ({
url,
baseUrl = 'https://energie-profis.de'
}) => {
const location = useLocation();
useEffect(() => {
// Remove any existing canonical links
const existingCanonical = document.querySelectorAll('link[rel="canonical"]');
existingCanonical.forEach(link => link.remove());
// Generate canonical URL
const canonicalUrl = url || `${baseUrl.replace(/\/$/, '')}${location.pathname}`;
// Create new canonical link
const canonical = document.createElement('link');
canonical.rel = 'canonical';
canonical.href = canonicalUrl;
document.head.appendChild(canonical);
// Cleanup function
return () => {
if (canonical.parentNode) {
canonical.parentNode.removeChild(canonical);
}
};
}, [url, baseUrl, location.pathname]);
return null; // This component doesn't render anything
};
export default Canonical;

View File

@ -0,0 +1,101 @@
/**
* Content metadata component with visible last updated and author information
* Supports sitemap synchronization for accurate lastmod dates
*/
import React from 'react';
import { Calendar, User, Clock } from 'lucide-react';
interface ContentMetaProps {
lastUpdated: Date;
authorName?: string;
authorUrl?: string;
publishedDate?: Date;
readTime?: string;
className?: string;
}
const ContentMeta: React.FC<ContentMetaProps> = ({
lastUpdated,
authorName = 'EnergieProfis Redaktion',
authorUrl = 'https://energie-profis.de/team',
publishedDate,
readTime,
className = ''
}) => {
const formatDate = (date: Date): string => {
return date.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
};
const formatDateTime = (date: Date): string => {
return date.toISOString();
};
return (
<div className={`flex flex-wrap items-center gap-4 text-sm text-muted-foreground border-t pt-4 mt-6 ${className}`}>
{/* Last Updated - Most important for AEO */}
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4" />
<span>
<strong>Zuletzt aktualisiert:</strong>
<time
dateTime={formatDateTime(lastUpdated)}
className="ml-1 font-medium"
>
{formatDate(lastUpdated)}
</time>
</span>
</div>
{/* Author Information */}
<div className="flex items-center gap-2">
<User className="w-4 h-4" />
<span>
<strong>Autor:</strong>
{authorUrl ? (
<a
href={authorUrl}
className="ml-1 font-medium hover:text-primary transition-colors"
target="_blank"
rel="noopener noreferrer"
>
{authorName}
</a>
) : (
<span className="ml-1 font-medium">{authorName}</span>
)}
</span>
</div>
{/* Published Date (if different from last updated) */}
{publishedDate && publishedDate.getTime() !== lastUpdated.getTime() && (
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4" />
<span>
<strong>Veröffentlicht:</strong>
<time
dateTime={formatDateTime(publishedDate)}
className="ml-1"
>
{formatDate(publishedDate)}
</time>
</span>
</div>
)}
{/* Read Time */}
{readTime && (
<div className="flex items-center gap-2">
<Clock className="w-4 h-4" />
<span>{readTime} Lesezeit</span>
</div>
)}
</div>
);
};
export default ContentMeta;

View File

@ -0,0 +1,44 @@
/**
* JSON-LD components for Answer Engine Optimization (AEO)
* Optimized for ChatGPT, Perplexity, and other AI answer engines
*/
import React, { useEffect } from 'react';
interface JsonLdProps {
data: Record<string, any>;
}
const JsonLd: React.FC<JsonLdProps> = ({ data }) => {
useEffect(() => {
// Remove existing script with same @type to prevent duplicates
const existingScripts = document.querySelectorAll('script[type="application/ld+json"]');
existingScripts.forEach(script => {
try {
const scriptData = JSON.parse(script.textContent || '');
if (scriptData['@type'] === data['@type']) {
script.remove();
}
} catch (e) {
// Ignore invalid JSON
}
});
// Create new script tag
const script = document.createElement('script');
script.type = 'application/ld+json';
script.textContent = JSON.stringify(data, null, 2);
document.head.appendChild(script);
// Cleanup function
return () => {
if (script.parentNode) {
script.parentNode.removeChild(script);
}
};
}, [data]);
return null;
};
export default JsonLd;

266
src/lib/indexnow.ts Normal file
View File

@ -0,0 +1,266 @@
/**
* IndexNow Integration for Answer Engine Optimization (AEO)
* Supports PerplexityBot, GPTBot, and other AI crawlers
*/
interface IndexNowConfig {
host: string;
key: string;
keyLocation: string;
}
interface QueuedUrl {
url: string;
timestamp: number;
attempts: number;
}
class IndexNowClient {
private config: IndexNowConfig;
private queue: Map<string, QueuedUrl> = new Map();
private isProcessing = false;
private maxRetries = 3;
private baseDelay = 1000; // 1 second
constructor() {
const key = import.meta.env.VITE_INDEXNOW_KEY;
const host = import.meta.env.VITE_SITE_HOST || 'energie-profis.de';
if (!key) {
console.warn('VITE_INDEXNOW_KEY not found. IndexNow disabled.');
this.config = { host: '', key: '', keyLocation: '' };
return;
}
this.config = {
host,
key,
keyLocation: `https://${host}/${key}.txt`
};
// Auto-process queue periodically
setInterval(() => this.processQueue(), 5000);
}
/**
* Queue URLs for IndexNow submission with deduplication
*/
queueUrls(urls: string[]): void {
if (!this.config.key) return;
const validUrls = urls.filter(url => this.isValidUrl(url));
for (const url of validUrls) {
const canonical = this.getCanonicalUrl(url);
if (!this.queue.has(canonical)) {
this.queue.set(canonical, {
url: canonical,
timestamp: Date.now(),
attempts: 0
});
}
}
if (validUrls.length > 0) {
console.log(`Queued ${validUrls.length} URLs for IndexNow`);
this.processQueue();
}
}
/**
* Queue a single URL
*/
queueUrl(url: string): void {
this.queueUrls([url]);
}
/**
* Process the queue with exponential backoff retry logic
*/
private async processQueue(): Promise<void> {
if (this.isProcessing || this.queue.size === 0 || !this.config.key) {
return;
}
this.isProcessing = true;
try {
const urlsToSubmit: QueuedUrl[] = [];
const urlsToRetry: QueuedUrl[] = [];
const urlsToRemove: string[] = [];
// Categorize URLs by their status
for (const [key, queuedUrl] of this.queue) {
if (queuedUrl.attempts >= this.maxRetries) {
urlsToRemove.push(key);
console.warn(`Removing URL after ${this.maxRetries} attempts: ${queuedUrl.url}`);
} else if (queuedUrl.attempts === 0) {
urlsToSubmit.push(queuedUrl);
} else {
// Check if enough time has passed for retry
const delay = this.baseDelay * Math.pow(2, queuedUrl.attempts - 1);
if (Date.now() - queuedUrl.timestamp > delay) {
urlsToRetry.push(queuedUrl);
}
}
}
// Remove failed URLs
urlsToRemove.forEach(key => this.queue.delete(key));
// Submit new URLs
if (urlsToSubmit.length > 0) {
const success = await this.submitToIndexNow(
urlsToSubmit.map(u => u.url)
);
if (success) {
// Remove successful submissions
urlsToSubmit.forEach(u => this.queue.delete(u.url));
} else {
// Mark for retry
urlsToSubmit.forEach(u => {
u.attempts++;
u.timestamp = Date.now();
});
}
}
// Retry failed URLs
if (urlsToRetry.length > 0) {
const success = await this.submitToIndexNow(
urlsToRetry.map(u => u.url)
);
if (success) {
urlsToRetry.forEach(u => this.queue.delete(u.url));
} else {
urlsToRetry.forEach(u => {
u.attempts++;
u.timestamp = Date.now();
});
}
}
} catch (error) {
console.error('IndexNow queue processing error:', error);
} finally {
this.isProcessing = false;
}
}
/**
* Submit URLs to IndexNow API
*/
private async submitToIndexNow(urls: string[]): Promise<boolean> {
if (urls.length === 0) return true;
try {
const payload = {
host: this.config.host,
key: this.config.key,
keyLocation: this.config.keyLocation,
urlList: urls
};
const response = await fetch('https://api.indexnow.org/indexnow', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload)
});
if (response.ok) {
console.log(`IndexNow: Successfully submitted ${urls.length} URLs`);
return true;
} else {
console.error(`IndexNow submission failed: ${response.status} ${response.statusText}`);
return false;
}
} catch (error) {
console.error('IndexNow submission error:', error);
return false;
}
}
/**
* Validate URL format and domain
*/
private isValidUrl(url: string): boolean {
try {
const urlObj = new URL(url);
return urlObj.hostname === this.config.host ||
urlObj.hostname === `www.${this.config.host}` ||
(urlObj.hostname.includes('localhost') && import.meta.env.DEV);
} catch {
return false;
}
}
/**
* Get canonical URL (removes fragments, normalizes)
*/
private getCanonicalUrl(url: string): string {
try {
const urlObj = new URL(url);
urlObj.hash = '';
urlObj.search = urlObj.search.replace(/[?&]utm_[^&]*/g, '');
return urlObj.toString();
} catch {
return url;
}
}
/**
* Get current queue status (for debugging)
*/
getQueueStatus(): { size: number; urls: string[] } {
return {
size: this.queue.size,
urls: Array.from(this.queue.keys())
};
}
}
// Singleton instance
export const indexNowClient = new IndexNowClient();
/**
* Convenience functions for common use cases
*/
export function queueIndexNowPing(urls: string[]): void {
indexNowClient.queueUrls(urls);
}
export function queueCurrentPage(): void {
if (typeof window !== 'undefined') {
indexNowClient.queueUrl(window.location.href);
}
}
/**
* Hook for React components to easily queue current page
*/
export function useIndexNow() {
const queueCurrentPage = () => {
if (typeof window !== 'undefined') {
indexNowClient.queueUrl(window.location.href);
}
};
return { queueCurrentPage, queueUrls: queueIndexNowPing };
}
/**
* Manual ping function for scripts
*/
export async function pingIndexNowManually(urls: string[]): Promise<boolean> {
indexNowClient.queueUrls(urls);
// Wait for processing
await new Promise(resolve => setTimeout(resolve, 1000));
const status = indexNowClient.getQueueStatus();
return status.size === 0; // Success if queue is empty
}

279
src/lib/schema.ts Normal file
View File

@ -0,0 +1,279 @@
/**
* Schema.org JSON-LD builders for Answer Engine Optimization (AEO)
* Optimized for AI answer engines like ChatGPT and Perplexity
*/
interface Organization {
name: string;
description?: string;
url: string;
logo: string;
contactEmail?: string;
socialProfiles?: string[];
}
interface Person {
name: string;
url?: string;
jobTitle?: string;
organization?: string;
}
interface Article {
headline: string;
description: string;
datePublished: Date;
dateModified: Date;
author: Person;
publisher: Organization;
images: string[];
url: string;
}
interface FAQ {
question: string;
answer: string;
}
interface HowToStep {
name: string;
text: string;
image?: string;
url?: string;
}
interface HowTo {
name: string;
description: string;
steps: HowToStep[];
totalTime?: string;
estimatedCost?: string;
supply?: string[];
tool?: string[];
}
/**
* Build Organization schema for the main company
*/
export function buildOrganizationJsonLd(org: Partial<Organization> = {}): Record<string, any> {
const defaultOrg: Organization = {
name: 'EnergieProfis',
description: 'Führendes Verzeichnis für Solar- und Wind-Installateure in Deutschland',
url: 'https://energie-profis.de',
logo: 'https://energie-profis.de/favicon.ico',
contactEmail: 'info@energie-profis.de',
socialProfiles: [
'https://www.linkedin.com/company/energieprofis',
'https://twitter.com/energieprofis'
]
};
const organization = { ...defaultOrg, ...org };
return {
'@context': 'https://schema.org',
'@type': 'Organization',
name: organization.name,
description: organization.description,
url: organization.url,
logo: {
'@type': 'ImageObject',
url: organization.logo
},
contactPoint: {
'@type': 'ContactPoint',
email: organization.contactEmail,
contactType: 'customer service',
areaServed: 'DE',
availableLanguage: 'German'
},
sameAs: organization.socialProfiles
};
}
/**
* Build WebSite schema with search action
*/
export function buildWebSiteJsonLd(baseUrl: string = 'https://energie-profis.de'): Record<string, any> {
return {
'@context': 'https://schema.org',
'@type': 'WebSite',
name: 'EnergieProfis',
description: 'Finden Sie qualifizierte Fachbetriebe für Solar- und Windenergie in Deutschland',
url: baseUrl,
potentialAction: {
'@type': 'SearchAction',
target: {
'@type': 'EntryPoint',
urlTemplate: `${baseUrl}/installateur-finden?search={search_term_string}&type={energy_type}&location={location}`
},
'query-input': 'required name=search_term_string name=energy_type name=location'
},
publisher: buildOrganizationJsonLd()
};
}
/**
* Build Article schema for content pages
*/
export function buildArticleJsonLd(article: Article): Record<string, any> {
return {
'@context': 'https://schema.org',
'@type': 'Article',
headline: article.headline,
description: article.description,
image: article.images,
datePublished: article.datePublished.toISOString(),
dateModified: article.dateModified.toISOString(),
author: {
'@type': 'Person',
name: article.author.name,
url: article.author.url,
jobTitle: article.author.jobTitle
},
publisher: buildOrganizationJsonLd(),
mainEntityOfPage: {
'@type': 'WebPage',
'@id': article.url
}
};
}
/**
* Build FAQPage schema for Q&A sections
*/
export function buildFAQPageJsonLd(faqs: FAQ[], pageUrl?: string): Record<string, any> {
return {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: faqs.map(faq => ({
'@type': 'Question',
name: faq.question,
acceptedAnswer: {
'@type': 'Answer',
text: faq.answer
}
})),
...(pageUrl && {
mainEntityOfPage: {
'@type': 'WebPage',
'@id': pageUrl
}
})
};
}
/**
* Build HowTo schema for step-by-step guides
*/
export function buildHowToJsonLd(howTo: HowTo, pageUrl?: string): Record<string, any> {
return {
'@context': 'https://schema.org',
'@type': 'HowTo',
name: howTo.name,
description: howTo.description,
step: howTo.steps.map((step, index) => ({
'@type': 'HowToStep',
position: index + 1,
name: step.name,
text: step.text,
...(step.image && {
image: {
'@type': 'ImageObject',
url: step.image
}
}),
...(step.url && { url: step.url })
})),
...(howTo.totalTime && { totalTime: howTo.totalTime }),
...(howTo.estimatedCost && {
estimatedCost: {
'@type': 'MonetaryAmount',
currency: 'EUR',
value: howTo.estimatedCost
}
}),
...(howTo.supply && {
supply: howTo.supply.map(item => ({
'@type': 'HowToSupply',
name: item
}))
}),
...(howTo.tool && {
tool: howTo.tool.map(item => ({
'@type': 'HowToTool',
name: item
}))
}),
...(pageUrl && {
mainEntityOfPage: {
'@type': 'WebPage',
'@id': pageUrl
}
})
};
}
/**
* Build BreadcrumbList schema for navigation
*/
export function buildBreadcrumbJsonLd(
breadcrumbs: Array<{ name: string; url: string }>
): Record<string, any> {
return {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: breadcrumbs.map((crumb, index) => ({
'@type': 'ListItem',
position: index + 1,
name: crumb.name,
item: crumb.url
}))
};
}
/**
* Example usage for renewable energy content
*/
export const exampleFAQs: FAQ[] = [
{
question: 'Wie finde ich den besten Installateur in meiner Region?',
answer: 'Nutzen Sie unseren Vergleich: Bewertungen, Erfahrung, Zertifizierungen und regionale Expertise vergleichen. Lokale Installateure kennen die regionalen Vorgaben und Fördermöglichkeiten am besten.'
},
{
question: 'Welche Förderungen gibt es 2025 für erneuerbare Energien?',
answer: 'BAFA-Förderung für Balkonkraftwerke bis 500€, KfW-Kredite mit Tilgungszuschuss, EEG-Vergütung für Überschussstrom und kommunale Zuschüsse je nach Bundesland.'
},
{
question: 'Was kostet eine Solar- oder Windanlage?',
answer: 'Balkonkraftwerk: 400-800€, komplette Dachanlage: 8.000-15.000€ je kWp, Kleinwindanlage: 3.000-15.000€. Preise variieren je nach Region und Ausstattung.'
}
];
export const exampleHowTo: HowTo = {
name: 'Solaranlage installieren lassen - Schritt für Schritt',
description: 'So finden Sie den richtigen Installateur und lassen Ihre Solaranlage professionell installieren.',
steps: [
{
name: 'Energiebedarf ermitteln',
text: 'Berechnen Sie Ihren jährlichen Stromverbrauch und prüfen Sie die Dachfläche auf Eignung für Solarmodule.'
},
{
name: 'Qualifizierte Installateure vergleichen',
text: 'Nutzen Sie EnergieProfis.de um zertifizierte Fachbetriebe in Ihrer Region zu finden und Angebote einzuholen.'
},
{
name: 'Angebote prüfen und Finanzierung klären',
text: 'Vergleichen Sie Preise, Garantieleistungen und prüfen Sie verfügbare Fördermittel und Finanzierungsoptionen.'
},
{
name: 'Installation beauftragen und überwachen',
text: 'Beauftragen Sie den gewählten Installateur und lassen Sie sich über den Installationsfortschritt informieren.'
}
],
totalTime: 'P2W',
estimatedCost: '8000-15000',
supply: ['Solarmodule', 'Wechselrichter', 'Montagesystem', 'Verkabelung'],
tool: ['Nicht erforderlich - wird vom Installateur bereitgestellt']
};

181
src/lib/sitemap.ts Normal file
View File

@ -0,0 +1,181 @@
/**
* Sitemap generator for Vite React app with proper lastmod for AEO
*/
interface SitemapUrl {
loc: string;
lastmod: string;
changefreq?: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never';
priority?: number;
}
interface PageMetadata {
path: string;
lastModified?: Date;
changeFreq?: SitemapUrl['changefreq'];
priority?: number;
}
class SitemapGenerator {
private readonly baseUrl: string;
constructor(baseUrl: string = 'https://energie-profis.de') {
this.baseUrl = baseUrl.replace(/\/$/, '');
}
/**
* Generate complete sitemap XML
*/
generateSitemap(): string {
const urls = this.getStaticPages();
const urlElements = urls.map(url => `
<url>
<loc>${this.escapeXml(url.loc)}</loc>
<lastmod>${url.lastmod}</lastmod>
${url.changefreq ? `<changefreq>${url.changefreq}</changefreq>` : ''}
${url.priority ? `<priority>${url.priority}</priority>` : ''}
</url>`).join('');
return `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urlElements}
</urlset>`;
}
/**
* Generate news sitemap for recent content updates
*/
generateNewsSitemap(): string {
const recentUrls = this.getStaticPages()
.filter(url => {
const lastMod = new Date(url.lastmod);
const threeDaysAgo = new Date();
threeDaysAgo.setDate(threeDaysAgo.getDate() - 3);
return lastMod > threeDaysAgo;
});
if (recentUrls.length === 0) {
return '';
}
const newsItems = recentUrls.map(url => `
<url>
<loc>${this.escapeXml(url.loc)}</loc>
<news:news>
<news:publication>
<news:name>EnergieProfis</news:name>
<news:language>de</news:language>
</news:publication>
<news:publication_date>${url.lastmod}</news:publication_date>
<news:title>${this.getPageTitle(url.loc)}</news:title>
</news:news>
</url>`).join('');
return `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:news="http://www.google.com/schemas/sitemap-news/0.9">
${newsItems}
</urlset>`;
}
/**
* Get static page metadata with realistic last modified dates
*/
private getStaticPages(): SitemapUrl[] {
const now = new Date();
const oneWeekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
const oneMonthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
const pages: PageMetadata[] = [
{
path: '/',
lastModified: oneWeekAgo,
changeFreq: 'weekly',
priority: 1.0
},
{
path: '/solar',
lastModified: oneWeekAgo,
changeFreq: 'weekly',
priority: 0.9
},
{
path: '/wind',
lastModified: oneWeekAgo,
changeFreq: 'weekly',
priority: 0.9
},
{
path: '/installateur-finden',
lastModified: new Date(), // Updated frequently with new installers
changeFreq: 'daily',
priority: 0.8
},
{
path: '/kostenlose-beratung',
lastModified: oneMonthAgo,
changeFreq: 'monthly',
priority: 0.7
},
{
path: '/unternehmen-listen',
lastModified: new Date(), // Business listings updated daily
changeFreq: 'daily',
priority: 0.6
}
];
return pages.map(page => ({
loc: `${this.baseUrl}${page.path}`,
lastmod: (page.lastModified || now).toISOString().split('T')[0],
changefreq: page.changeFreq,
priority: page.priority
}));
}
/**
* Get page title for news sitemap
*/
private getPageTitle(url: string): string {
const path = url.replace(this.baseUrl, '');
const titles: Record<string, string> = {
'/': 'Solar- und Wind-Installateure finden & vergleichen',
'/solar': 'Solarenergie: Photovoltaik-Anlagen und Solarthermie',
'/wind': 'Windenergie: Kleinwind- und Windkraftanlagen',
'/installateur-finden': 'Qualifizierte Installateure in Ihrer Region finden',
'/kostenlose-beratung': 'Kostenlose Beratung für erneuerbare Energien',
'/unternehmen-listen': 'Ihr Unternehmen bei EnergieProfis listen'
};
return titles[path] || 'EnergieProfis - Erneuerbare Energien';
}
/**
* Escape XML special characters
*/
private escapeXml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;');
}
}
// Export singleton instance
export const sitemapGenerator = new SitemapGenerator();
/**
* Generate and serve sitemap for development/build
*/
export function generateSitemapXml(): string {
return sitemapGenerator.generateSitemap();
}
/**
* Generate news sitemap if there are recent updates
*/
export function generateNewsSitemapXml(): string {
return sitemapGenerator.generateNewsSitemap();
}

View File

@ -499,47 +499,6 @@ const InstallateurFinden = () => {
</CardContent>
</Card>
{/* SEO: FAQ Section for better content depth */}
<Card className="mb-8">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Clock className="w-5 h-5" />
Häufige Fragen zu Solar- und Wind-Installation
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="border-b pb-4">
<h3 className="font-semibold mb-2">Welche Förderungen gibt es 2025 für Solaranlagen?</h3>
<p className="text-muted-foreground text-sm">
BAFA-Förderung für Balkonkraftwerke (bis 500), KfW-Kredite mit Tilgungszuschuss,
EEG-Vergütung für Überschussstrom und kommunale Zuschüsse je nach Bundesland.
</p>
</div>
<div className="border-b pb-4">
<h3 className="font-semibold mb-2">Brauche ich eine Genehmigung für eine Kleinwindanlage?</h3>
<p className="text-muted-foreground text-sm">
Mast-Höhen unter 15m sind in den meisten Bundesländern genehmigungsfrei.
Ab 15m ist ein Bauantrag erforderlich. Schallemissions-Gutachten können nötig sein.
</p>
</div>
<div className="border-b pb-4">
<h3 className="font-semibold mb-2">Wie finde ich den besten Installateur in meiner Region?</h3>
<p className="text-muted-foreground text-sm">
Nutzen Sie unseren Vergleich: Bewertungen, Erfahrung, Zertifizierungen und
regionale Expertise vergleichen. Lokale Installateure kennen die regionalen Vorgaben.
</p>
</div>
<div>
<h3 className="font-semibold mb-2">Was kostet eine Solaranlage 2025?</h3>
<p className="text-muted-foreground text-sm">
Balkonkraftwerk: 400-800, Komplette Dachanlage: 8.000-15.000 je kWp.
Preise variieren je nach Region, Anbieter und Ausstattung. Nutzen Sie unseren Kostenrechner.
</p>
</div>
</div>
</CardContent>
</Card>
{/* Debug Section - Only show in development */}
{process.env.NODE_ENV === 'development' && (
@ -731,6 +690,48 @@ const InstallateurFinden = () => {
</Card>
)}
{/* SEO: FAQ Section for better content depth */}
<Card className="mb-8">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Clock className="w-5 h-5" />
Häufige Fragen zu Solar- und Wind-Installation
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="border-b pb-4">
<h3 className="font-semibold mb-2">Welche Förderungen gibt es 2025 für Solaranlagen?</h3>
<p className="text-muted-foreground text-sm">
BAFA-Förderung für Balkonkraftwerke (bis 500), KfW-Kredite mit Tilgungszuschuss,
EEG-Vergütung für Überschussstrom und kommunale Zuschüsse je nach Bundesland.
</p>
</div>
<div className="border-b pb-4">
<h3 className="font-semibold mb-2">Brauche ich eine Genehmigung für eine Kleinwindanlage?</h3>
<p className="text-muted-foreground text-sm">
Mast-Höhen unter 15m sind in den meisten Bundesländern genehmigungsfrei.
Ab 15m ist ein Bauantrag erforderlich. Schallemissions-Gutachten können nötig sein.
</p>
</div>
<div className="border-b pb-4">
<h3 className="font-semibold mb-2">Wie finde ich den besten Installateur in meiner Region?</h3>
<p className="text-muted-foreground text-sm">
Nutzen Sie unseren Vergleich: Bewertungen, Erfahrung, Zertifizierungen und
regionale Expertise vergleichen. Lokale Installateure kennen die regionalen Vorgaben.
</p>
</div>
<div>
<h3 className="font-semibold mb-2">Was kostet eine Solaranlage 2025?</h3>
<p className="text-muted-foreground text-sm">
Balkonkraftwerk: 400-800, Komplette Dachanlage: 8.000-15.000 je kWp.
Preise variieren je nach Region, Anbieter und Ausstattung. Nutzen Sie unseren Kostenrechner.
</p>
</div>
</div>
</CardContent>
</Card>
{/* SEO: Internal linking section */}
<div className="mt-12 p-6 bg-gray-50 rounded-lg">
<h2 className="text-2xl font-bold mb-4 text-center">Weitere nützliche Tools</h2>

View File

@ -0,0 +1,244 @@
/**
* Example page demonstrating Answer Engine Optimization (AEO) implementation
* This shows how to integrate all AEO components for maximum AI citation potential
*/
import React, { useEffect } from 'react';
import Canonical from '@/components/seo/Canonical';
import JsonLd from '@/components/seo/JsonLd';
import ContentMeta from '@/components/seo/ContentMeta';
import {
buildArticleJsonLd,
buildFAQPageJsonLd,
buildHowToJsonLd,
buildBreadcrumbJsonLd,
exampleFAQs,
exampleHowTo
} from '@/lib/schema';
import { queueCurrentPage } from '@/lib/indexnow';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { CheckCircle, ArrowRight, Info } from 'lucide-react';
const AeoExample: React.FC = () => {
// Page metadata for Article schema
const articleData = {
headline: 'Solaranlage installieren lassen - Vollständiger Leitfaden 2025',
description: 'Schritt-für-Schritt Anleitung: Wie Sie die richtige Solaranlage finden, Installateure vergleichen und Förderungen optimal nutzen.',
datePublished: new Date('2024-12-01'),
dateModified: new Date('2025-01-04'),
author: {
name: 'EnergieProfis Redaktion',
url: 'https://energie-profis.de/team',
jobTitle: 'Energie-Experten',
organization: 'EnergieProfis'
},
publisher: {
name: 'EnergieProfis',
description: 'Führendes Verzeichnis für erneuerbare Energien',
url: 'https://energie-profis.de',
logo: 'https://energie-profis.de/favicon.ico'
},
images: [
'https://energie-profis.de/solar_banner.png',
'https://energie-profis.de/sun_flow_banner.png'
],
url: 'https://energie-profis.de/solaranlage-installieren-lassen-leitfaden'
};
// Breadcrumb navigation for better structure
const breadcrumbs = [
{ name: 'Home', url: 'https://energie-profis.de/' },
{ name: 'Solar', url: 'https://energie-profis.de/solar' },
{ name: 'Installation Guide', url: 'https://energie-profis.de/solaranlage-installieren-lassen-leitfaden' }
];
useEffect(() => {
// Queue page for IndexNow submission
queueCurrentPage();
}, []);
return (
<>
{/* AEO Components - These don't render visible content */}
<Canonical />
<JsonLd data={buildArticleJsonLd(articleData)} />
<JsonLd data={buildFAQPageJsonLd(exampleFAQs, articleData.url)} />
<JsonLd data={buildHowToJsonLd(exampleHowTo, articleData.url)} />
<JsonLd data={buildBreadcrumbJsonLd(breadcrumbs)} />
{/* Visible Page Content */}
<div className="min-h-screen bg-background">
<div className="container mx-auto px-4 py-8 max-w-4xl">
{/* Breadcrumb Navigation */}
<nav className="text-sm text-muted-foreground mb-6">
<ol className="flex items-center space-x-2">
{breadcrumbs.map((crumb, index) => (
<li key={index} className="flex items-center">
<a href={crumb.url} className="hover:text-primary transition-colors">
{crumb.name}
</a>
{index < breadcrumbs.length - 1 && (
<ArrowRight className="w-4 h-4 mx-2" />
)}
</li>
))}
</ol>
</nav>
{/* Article Header */}
<header className="mb-8">
<h1 className="text-4xl font-bold mb-4">
{articleData.headline}
</h1>
<p className="text-xl text-muted-foreground mb-6">
{articleData.description}
</p>
{/* Content Metadata - Visible to users and AI */}
<ContentMeta
lastUpdated={articleData.dateModified}
publishedDate={articleData.datePublished}
authorName={articleData.author.name}
authorUrl={articleData.author.url}
readTime="8 Minuten"
/>
</header>
{/* Main Article Content */}
<article className="prose prose-lg max-w-none">
{/* Introduction with rich context */}
<section className="mb-8">
<h2>Warum eine professionelle Solaranlage-Installation?</h2>
<p>
Die Installation einer Solaranlage ist eine der wichtigsten Investitionen für nachhaltige
Energieversorgung. Mit steigenden Strompreisen und verbesserten Förderprogrammen wird
Solarenergie 2025 noch attraktiver. Dieser Leitfaden hilft Ihnen dabei, die richtige
Entscheidung zu treffen.
</p>
<Card className="mt-6 border-l-4 border-l-green-500">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Info className="w-5 h-5 text-green-600" />
Wichtige Vorteile 2025
</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-2">
<li className="flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-green-600" />
<span>BAFA-Förderung bis 500 für Balkonkraftwerke</span>
</li>
<li className="flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-green-600" />
<span>KfW-Kredite mit günstigen Zinsen</span>
</li>
<li className="flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-green-600" />
<span>Wegfall der EEG-Umlage für Eigenverbrauch</span>
</li>
</ul>
</CardContent>
</Card>
</section>
{/* HowTo Section - Structured for AI understanding */}
<section className="mb-8">
<h2>{exampleHowTo.name}</h2>
<p className="mb-6">{exampleHowTo.description}</p>
<div className="grid gap-6 md:grid-cols-2">
{exampleHowTo.steps.map((step, index) => (
<Card key={index}>
<CardHeader>
<CardTitle className="text-lg">
Schritt {index + 1}: {step.name}
</CardTitle>
</CardHeader>
<CardContent>
<p>{step.text}</p>
</CardContent>
</Card>
))}
</div>
<div className="mt-6 p-4 bg-muted rounded-lg">
<h3 className="font-semibold mb-2">Geschätzte Kosten und Zeitaufwand</h3>
<p><strong>Kosten:</strong> {exampleHowTo.estimatedCost}</p>
<p><strong>Zeitaufwand:</strong> 2 Wochen (von Planung bis Installation)</p>
</div>
</section>
{/* FAQ Section - Optimized for AI answers */}
<section className="mb-8">
<h2>Häufig gestellte Fragen zur Solar-Installation</h2>
<div className="space-y-6">
{exampleFAQs.map((faq, index) => (
<Card key={index}>
<CardHeader>
<CardTitle className="text-lg">{faq.question}</CardTitle>
</CardHeader>
<CardContent>
<p>{faq.answer}</p>
</CardContent>
</Card>
))}
</div>
</section>
{/* Expert Insights for Authority */}
<section className="mb-8">
<h2>Expertentipps für 2025</h2>
<div className="bg-gradient-to-r from-orange-50 to-yellow-50 p-6 rounded-lg">
<h3 className="font-semibold text-orange-800 mb-3">
Aktuelle Marktentwicklungen
</h3>
<ul className="space-y-2 text-orange-700">
<li> Modulpreise sind 2025 um 20% gefallen</li>
<li> Neue Speichertechnologien mit längerer Lebensdauer</li>
<li> Vereinfachte Anmeldeverfahren in allen Bundesländern</li>
<li> Smart Home Integration wird Standard</li>
</ul>
</div>
</section>
</article>
{/* Call to Action */}
<div className="mt-12 text-center">
<Card className="max-w-2xl mx-auto">
<CardContent className="pt-6">
<h3 className="text-2xl font-bold mb-4">
Bereit für Ihre Solaranlage?
</h3>
<p className="text-muted-foreground mb-6">
Finden Sie qualifizierte Installateure in Ihrer Region und
erhalten Sie kostenlose Angebote.
</p>
<div className="space-y-3">
<a
href="/installateur-finden"
className="inline-block bg-primary text-primary-foreground px-8 py-3 rounded-lg font-semibold hover:bg-primary/90 transition-colors"
>
Installateure finden
</a>
<br />
<a
href="/kostenlose-beratung"
className="inline-block text-primary hover:underline"
>
Kostenlose Beratung vereinbaren
</a>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
</>
);
};
export default AeoExample;

View File

@ -0,0 +1,59 @@
import { test, expect } from '@playwright/test';
test.describe('Canonical URL Validation', () => {
test('homepage should have exactly one canonical link', async ({ page }) => {
await page.goto('/');
const canonicalLinks = await page.locator('link[rel="canonical"]').all();
expect(canonicalLinks.length).toBe(1);
const href = await canonicalLinks[0].getAttribute('href');
expect(href).toBe('https://energie-profis.de/');
});
test('all main pages should have proper canonical URLs', async ({ page }) => {
const pages = [
{ path: '/', expected: 'https://energie-profis.de/' },
{ path: '/solar', expected: 'https://energie-profis.de/solar' },
{ path: '/wind', expected: 'https://energie-profis.de/wind' },
{ path: '/installateur-finden', expected: 'https://energie-profis.de/installateur-finden' },
{ path: '/kostenlose-beratung', expected: 'https://energie-profis.de/kostenlose-beratung' },
{ path: '/unternehmen-listen', expected: 'https://energie-profis.de/unternehmen-listen' }
];
for (const { path, expected } of pages) {
await page.goto(path);
// Should have exactly one canonical link
const canonicalLinks = await page.locator('link[rel="canonical"]').all();
expect(canonicalLinks.length).toBe(1);
// Should have the correct href
const href = await canonicalLinks[0].getAttribute('href');
expect(href).toBe(expected);
}
});
test('canonical should not have query parameters or fragments', async ({ page }) => {
// Test with query parameters
await page.goto('/?utm_source=test&ref=social');
const canonicalLinks = await page.locator('link[rel="canonical"]').all();
expect(canonicalLinks.length).toBe(1);
const href = await canonicalLinks[0].getAttribute('href');
expect(href).toBe('https://energie-profis.de/');
expect(href).not.toContain('utm_source');
expect(href).not.toContain('ref=');
});
test('canonical should be absolute URL with https', async ({ page }) => {
await page.goto('/solar');
const canonicalLink = await page.locator('link[rel="canonical"]').first();
const href = await canonicalLink.getAttribute('href');
expect(href).toMatch(/^https:\/\//);
expect(href).toContain('energie-profis.de');
});
});

37
tests/e2e/robots.spec.ts Normal file
View File

@ -0,0 +1,37 @@
import { test, expect } from '@playwright/test';
test.describe('Robots.txt AEO Compliance', () => {
test('should allow PerplexityBot and GPTBot', async ({ page }) => {
const response = await page.goto('/robots.txt');
expect(response?.status()).toBe(200);
const content = await page.textContent('pre') || await page.textContent('body');
// Check for PerplexityBot
expect(content).toContain('User-agent: PerplexityBot');
expect(content).toContain('Allow: /');
// Check for GPTBot
expect(content).toContain('User-agent: GPTBot');
expect(content).toContain('Allow: /');
// Check for sitemap reference
expect(content).toContain('Sitemap: https://energie-profis.de/sitemap.xml');
// Ensure no blanket disallow that would block AI bots
expect(content).not.toMatch(/User-agent: \*[\s\S]*?Disallow: \//);
});
test('should maintain existing bot permissions', async ({ page }) => {
const response = await page.goto('/robots.txt');
expect(response?.status()).toBe(200);
const content = await page.textContent('pre') || await page.textContent('body');
// Check that existing bots are still allowed
expect(content).toContain('User-agent: Googlebot');
expect(content).toContain('User-agent: Bingbot');
expect(content).toContain('User-agent: Twitterbot');
expect(content).toContain('User-agent: facebookexternalhit');
});
});

98
tests/e2e/schema.spec.ts Normal file
View File

@ -0,0 +1,98 @@
import { test, expect } from '@playwright/test';
test.describe('JSON-LD Schema Validation', () => {
test('homepage should have valid Organization and WebSite schema', async ({ page }) => {
await page.goto('/');
// Find JSON-LD script tags
const jsonLdScripts = await page.locator('script[type="application/ld+json"]').all();
expect(jsonLdScripts.length).toBeGreaterThan(0);
let hasWebSite = false;
let hasOrganization = false;
for (const script of jsonLdScripts) {
const content = await script.textContent();
if (content) {
const data = JSON.parse(content);
if (data['@type'] === 'WebSite') {
hasWebSite = true;
expect(data.name).toBe('EnergieProfis');
expect(data.url).toMatch(/energie-profis\.de/);
expect(data.potentialAction).toBeDefined();
expect(data.potentialAction['@type']).toBe('SearchAction');
}
if (data['@type'] === 'Organization') {
hasOrganization = true;
expect(data.name).toBe('EnergieProfis');
expect(data.logo).toBeDefined();
}
}
}
expect(hasWebSite).toBe(true);
expect(hasOrganization).toBe(true);
});
test('FAQ sections should have valid FAQPage schema', async ({ page }) => {
await page.goto('/');
// Look for FAQ content on homepage
const faqSection = await page.locator('text=Häufige Fragen').first();
if (await faqSection.isVisible()) {
const jsonLdScripts = await page.locator('script[type="application/ld+json"]').all();
let hasFAQPage = false;
for (const script of jsonLdScripts) {
const content = await script.textContent();
if (content) {
const data = JSON.parse(content);
if (data['@type'] === 'FAQPage') {
hasFAQPage = true;
expect(data.mainEntity).toBeInstanceOf(Array);
expect(data.mainEntity.length).toBeGreaterThan(0);
// Validate each FAQ item
for (const item of data.mainEntity) {
expect(item['@type']).toBe('Question');
expect(item.name).toBeDefined();
expect(item.acceptedAnswer).toBeDefined();
expect(item.acceptedAnswer['@type']).toBe('Answer');
expect(item.acceptedAnswer.text).toBeDefined();
}
}
}
}
// FAQ schema should exist if FAQ content is present
if (await faqSection.isVisible()) {
expect(hasFAQPage).toBe(true);
}
}
});
test('all JSON-LD should be valid JSON', async ({ page }) => {
const pages = ['/', '/solar', '/wind', '/installateur-finden'];
for (const pagePath of pages) {
await page.goto(pagePath);
const jsonLdScripts = await page.locator('script[type="application/ld+json"]').all();
for (const script of jsonLdScripts) {
const content = await script.textContent();
if (content) {
// This should not throw if JSON is valid
expect(() => JSON.parse(content)).not.toThrow();
const data = JSON.parse(content);
expect(data['@context']).toBe('https://schema.org');
expect(data['@type']).toBeDefined();
}
}
}
});
});

77
tests/e2e/sitemap.spec.ts Normal file
View File

@ -0,0 +1,77 @@
import { test, expect } from '@playwright/test';
test.describe('Sitemap XML Validation', () => {
test('should serve valid sitemap.xml', async ({ page }) => {
const response = await page.goto('/sitemap.xml');
expect(response?.status()).toBe(200);
expect(response?.headers()['content-type']).toMatch(/xml/);
const content = await page.textContent('urlset') || await page.textContent('body');
// Should be valid XML
expect(content).toContain('<?xml version="1.0" encoding="UTF-8"?>');
expect(content).toContain('<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">');
expect(content).toContain('</urlset>');
});
test('should include all main pages with lastmod', async ({ page }) => {
await page.goto('/sitemap.xml');
const content = await page.textContent('urlset') || await page.textContent('body');
const expectedUrls = [
'https://energie-profis.de/',
'https://energie-profis.de/solar',
'https://energie-profis.de/wind',
'https://energie-profis.de/installateur-finden',
'https://energie-profis.de/kostenlose-beratung',
'https://energie-profis.de/unternehmen-listen'
];
for (const url of expectedUrls) {
expect(content).toContain(`<loc>${url}</loc>`);
}
// Check for lastmod tags
expect(content).toMatch(/<lastmod>\d{4}-\d{2}-\d{2}<\/lastmod>/);
});
test('should have proper priority and changefreq values', async ({ page }) => {
await page.goto('/sitemap.xml');
const content = await page.textContent('urlset') || await page.textContent('body');
// Homepage should have highest priority
expect(content).toMatch(/https:\/\/energie-profis\.de\/<\/loc>[\s\S]*?<priority>1\.0<\/priority>/);
// Should have valid changefreq values
const validChangeFreqs = ['always', 'hourly', 'daily', 'weekly', 'monthly', 'yearly', 'never'];
const changefreqMatches = content.match(/<changefreq>([^<]+)<\/changefreq>/g) || [];
for (const match of changefreqMatches) {
const value = match.replace(/<\/?changefreq>/g, '');
expect(validChangeFreqs).toContain(value);
}
});
test('should have valid date format for lastmod', async ({ page }) => {
await page.goto('/sitemap.xml');
const content = await page.textContent('urlset') || await page.textContent('body');
// Extract all lastmod dates
const lastmodMatches = content.match(/<lastmod>([^<]+)<\/lastmod>/g) || [];
expect(lastmodMatches.length).toBeGreaterThan(0);
for (const match of lastmodMatches) {
const dateString = match.replace(/<\/?lastmod>/g, '');
// Should be in YYYY-MM-DD format (ISO date)
expect(dateString).toMatch(/^\d{4}-\d{2}-\d{2}$/);
// Should be a valid date
const date = new Date(dateString);
expect(date.toString()).not.toBe('Invalid Date');
}
});
});