Files
KantSites/scripts/link-check.mjs
T
kantarciandClaude Opus 5 074ef0e7e5
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Eigener Suchtitel, Schriften lokal, Bilder in Anzeigegroesse
Getrennter Titel fuer Suchergebnisse je Seite und Beitrag (Migration
20260913000100): auf der Seite steht "Leistungen", im Suchergebnis darf
der lange Titel stehen. Leer heisst weiterhin: der Titel der Seite.

Schriften kommen jetzt vom eigenen Server. Ein Knopf im Designbereich
oder cms:fonts:fetch holt die gewaehlten Google-Schnitte als WOFF2 nach
public/assets/fonts. Das spart zwei fremde Verbindungen nacheinander
(gemessen 820 ms) und die IP-Weitergabe an Google. Ohne Dateien bleibt
es beim bisherigen Verweis - eine Seite ohne Schrift waere schlimmer.

Ein Stylesheet statt vier, mit Pruefsumme im Namen und ohne Kommentare,
dazu eine .htaccess mit Kompression, Cache und Schutz interner Dateien.
Der Entwicklungsserver sendet dieselben Kopfzeilen, sonst misst man
lokal etwas, das es im Betrieb nicht gibt.

Bilder in der Groesse, in der sie erscheinen: ein nur fuer Desktop
gesetztes Hintergrundbild gilt auf dem Telefon in Telefongroesse
(189 KB -> 40 KB), img nennt seine feste Breite oder sizes="auto", und
fetchpriority liegt auf dem ersten Bild des Inhalts statt auf dem Logo.

Neu: npm run test:links prueft jeden internen Verweis auf seine
Antwort. Lighthouse gegen den Nachbau: Barrierefreiheit, Best
Practices und SEO 100 auf allen vier geprueften Adressen, Performance
100 ausser auf der bilderreichen Startseite (98 mobil, 96 Desktop).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 15:11:46 +02:00

103 lines
4.5 KiB
JavaScript

/*
* Prüft jeden internen Verweis einer Website auf seine Antwort.
*
* Ein Link, der ins Leere zeigt, ist der Fehler, den niemand bemerkt: Tests prüfen, was der Code
* erzeugt, nicht ob die Adresse dahinter existiert. Im Nachbau von kant-it.de zeigten vier
* Verweise im Hero-Slider auf Adressen, die es nie gab - gefunden hat das kein Test, sondern ein
* Durchlauf wie dieser.
*
* Ausgangspunkt ist die Sitemap, danach wird jeder Seite gefolgt, die von dort erreichbar ist.
* Geprüft werden Seiten, Bilder, Stylesheets und Skripte; externe Adressen werden gezählt, aber
* nicht abgerufen - ob fremde Server antworten, ist nicht die Frage dieser Prüfung.
*
* CMS_URL=http://127.0.0.1:8080 npm run test:links
*/
import { chromium } from 'playwright';
import assert from 'node:assert/strict';
import { mkdir, writeFile } from 'node:fs/promises';
const origin = (process.env.CMS_URL || 'http://127.0.0.1:8080').replace(/\/$/, '');
const browser = await chromium.launch();
const geprueft = new Map();
const extern = new Set();
const kaputt = [];
const quellen = new Map();
try {
const context = await browser.newContext();
const page = await context.newPage();
const antwort = async (url) => {
if (!geprueft.has(url)) {
const response = await context.request.get(url, { maxRedirects: 5 });
geprueft.set(url, response.status());
}
return geprueft.get(url);
};
const sitemap = await context.request.get(origin + '/sitemap.xml');
assert.equal(sitemap.status(), 200, 'Ohne Sitemap fehlt der Ausgangspunkt.');
const seiten = [...(await sitemap.text()).matchAll(/<loc>([^<]+)<\/loc>/g)]
.map(treffer => new URL(treffer[1]).pathname);
console.log(`Sitemap: ${seiten.length} Adresse(n).`);
const besucht = new Set();
const warteschlange = ['/', ...seiten];
while (warteschlange.length > 0) {
const pfad = warteschlange.shift();
if (besucht.has(pfad)) continue;
besucht.add(pfad);
const response = await page.goto(origin + pfad, { waitUntil: 'domcontentloaded' });
if (200 !== response.status()) {
kaputt.push({ status: response.status(), url: pfad, von: 'Sitemap' });
continue;
}
// Verweise und eingebundene Dateien derselben Website.
const verweise = await page.evaluate(() => ({
links: [...document.querySelectorAll('a[href]')].map(a => a.getAttribute('href')),
dateien: [
...[...document.querySelectorAll('img[src]')].map(i => i.getAttribute('src')),
...[...document.querySelectorAll('img[srcset]')].flatMap(i => i.getAttribute('srcset').split(',').map(teil => teil.trim().split(/\s+/)[0])),
...[...document.querySelectorAll('link[rel="stylesheet"], link[rel="preload"]')].map(l => l.getAttribute('href')),
...[...document.querySelectorAll('script[src]')].map(s => s.getAttribute('src')),
...[...document.querySelectorAll('[style*="url("]')].flatMap(e => [...e.getAttribute('style').matchAll(/url\(["']?([^"')]+)/g)].map(m => m[1])),
],
stile: [...document.querySelectorAll('style')].flatMap(s => [...s.textContent.matchAll(/url\(["']?([^"')]+)/g)].map(m => m[1])),
}));
for (const roh of [...verweise.links, ...verweise.dateien, ...verweise.stile]) {
if (!roh || roh.startsWith('data:') || roh.startsWith('#') || roh.startsWith('mailto:') || roh.startsWith('tel:')) continue;
const ziel = new URL(roh, origin + pfad);
if (ziel.origin !== origin) {
extern.add(ziel.origin);
continue;
}
const adresse = ziel.pathname + ziel.search;
const status = await antwort(origin + adresse);
if (status >= 400) {
kaputt.push({ status, url: adresse, von: pfad });
}
// Eigenen Seiten folgen wir weiter; Dateien nicht.
if (verweise.links.includes(roh) && !/\.[a-z0-9]{2,5}(\?|$)/i.test(ziel.pathname) && !besucht.has(ziel.pathname)) {
warteschlange.push(ziel.pathname);
quellen.set(ziel.pathname, pfad);
}
}
}
await mkdir('reports', { recursive: true });
await writeFile('reports/links.json', JSON.stringify({ origin, geprueft: geprueft.size, seiten: besucht.size, extern: [...extern], kaputt }, null, 2));
console.log(`${besucht.size} Seite(n), ${geprueft.size} Adresse(n) geprüft, ${extern.size} fremde Quelle(n): ${[...extern].join(', ') || 'keine'}`);
if (kaputt.length > 0) {
for (const eintrag of kaputt) {
console.error(` ${eintrag.status} ${eintrag.url} (verlinkt auf ${eintrag.von})`);
}
throw new Error(`${kaputt.length} Verweis(e) zeigen ins Leere.`);
}
console.log('Kein toter Verweis.');
} finally {
await browser.close();
}