diff --git a/scripts/changed-urls-2026-07-27.json b/scripts/changed-urls-2026-07-27.json new file mode 100644 index 0000000..8a0cd9b --- /dev/null +++ b/scripts/changed-urls-2026-07-27.json @@ -0,0 +1,42 @@ +{ + "host": "www.qrmaster.net", + "generatedAt": "2026-07-27", + "reason": "Commits 62ac1ad, 033bc7e, 70d97aa, 90dfedf, ab63d4b. Nur Seiten mit echten Title/Description/Content-Aenderungen. Die reine Gedankenstrich-Normalisierung (— -> -) aus 70d97aa ist bewusst NICHT enthalten.", + "groups": { + "titleAndDescriptionRewritten": [ + "https://www.qrmaster.net/", + "https://www.qrmaster.net/bulk-qr-code-generator", + "https://www.qrmaster.net/qr-code-tracking", + "https://www.qrmaster.net/custom-qr-code-generator", + "https://www.qrmaster.net/dynamic-qr-code-generator", + "https://www.qrmaster.net/learn" + ], + "toolPages": [ + "https://www.qrmaster.net/tools/url-qr-code", + "https://www.qrmaster.net/tools/vcard-qr-code", + "https://www.qrmaster.net/tools/google-review-qr-code", + "https://www.qrmaster.net/tools/barcode-generator", + "https://www.qrmaster.net/tools/instagram-qr-code", + "https://www.qrmaster.net/tools/tiktok-qr-code", + "https://www.qrmaster.net/tools/twitter-qr-code", + "https://www.qrmaster.net/tools/facebook-qr-code", + "https://www.qrmaster.net/tools/teams-qr-code", + "https://www.qrmaster.net/tools/zoom-qr-code", + "https://www.qrmaster.net/tools/geolocation-qr-code", + "https://www.qrmaster.net/tools/crypto-qr-code" + ], + "bulkLimitCorrection": [ + "https://www.qrmaster.net/pricing", + "https://www.qrmaster.net/faq", + "https://www.qrmaster.net/alternatives/beaconstac", + "https://www.qrmaster.net/alternatives/bitly", + "https://www.qrmaster.net/alternatives/flowcode", + "https://www.qrmaster.net/alternatives/qr-code-generator", + "https://www.qrmaster.net/vs/beaconstac" + ], + "single": [ + "https://www.qrmaster.net/blog/microsoft-teams-qr-code", + "https://www.qrmaster.net/qr-code-for/barbershops" + ] + } +} diff --git a/scripts/submit-changed-urls.mjs b/scripts/submit-changed-urls.mjs new file mode 100644 index 0000000..c793f83 --- /dev/null +++ b/scripts/submit-changed-urls.mjs @@ -0,0 +1,329 @@ +#!/usr/bin/env node +/** + * Reicht eine kuratierte URL-Liste bei IndexNow (Bing, Yandex, Seznam, Naver) + * und optional bei der Google Indexing API ein. + * + * Keine npm-Dependencies. Braucht Node 18+ (globales fetch). + * + * node scripts/submit-changed-urls.mjs --dry-run + * node scripts/submit-changed-urls.mjs + * node scripts/submit-changed-urls.mjs --google-only + * node scripts/submit-changed-urls.mjs --urls scripts/changed-urls-2026-07-27.json + * + * Konfiguration (.env oder Umgebung): + * INDEXNOW_KEY IndexNow-Key, muss als .txt live erreichbar sein + * GOOGLE_SERVICE_ACCOUNT Pfad zur Service-Account-JSON (Default: ./service_account.json) + * + * Hinweis zur Google Indexing API: offiziell unterstuetzt Google damit nur + * JobPosting und BroadcastEvent. Fuer normale Seiten funktioniert es in der + * Praxis oft, ist aber nicht zugesichert. Das Tageslimit liegt bei 200 URLs. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(__dirname, '..'); + +// ---------------------------------------------------------------- CLI + env + +const argv = process.argv.slice(2); +const hasFlag = (name) => argv.includes(`--${name}`); +const getArg = (name, fallback) => { + const i = argv.indexOf(`--${name}`); + return i !== -1 && argv[i + 1] ? argv[i + 1] : fallback; +}; + +const DRY_RUN = hasFlag('dry-run'); +const SKIP_PREFLIGHT = hasFlag('skip-preflight'); +const GOOGLE_ONLY = hasFlag('google-only'); +const INDEXNOW_ONLY = hasFlag('indexnow-only'); +const DO_INDEXNOW = !GOOGLE_ONLY; +const DO_GOOGLE = !INDEXNOW_ONLY; + +loadDotEnv(path.join(repoRoot, '.env')); +loadDotEnv(path.join(repoRoot, '.env.local')); + +function loadDotEnv(file) { + if (!fs.existsSync(file)) return; + for (const line of fs.readFileSync(file, 'utf8').split('\n')) { + const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/i); + if (!m) continue; + const key = m[1]; + if (process.env[key] !== undefined) continue; + process.env[key] = m[2].replace(/^["']|["']$/g, ''); + } +} + +// ---------------------------------------------------------------- URL-Liste + +const urlsFile = path.resolve( + repoRoot, + getArg('urls', 'scripts/changed-urls-2026-07-27.json') +); + +if (!fs.existsSync(urlsFile)) { + fail(`URL-Datei nicht gefunden: ${urlsFile}`); +} + +const config = JSON.parse(fs.readFileSync(urlsFile, 'utf8')); +const groups = config.groups ?? {}; +const urls = [...new Set(Object.values(groups).flat())]; + +if (urls.length === 0) fail('Die URL-Liste ist leer.'); + +console.log(`\n Quelle ${path.relative(repoRoot, urlsFile)}`); +console.log(` Host ${config.host}`); +console.log(` URLs ${urls.length}`); +for (const [name, list] of Object.entries(groups)) { + console.log(` ${String(list.length).padStart(3)} ${name}`); +} +console.log(` Modus ${DRY_RUN ? 'DRY RUN (es wird nichts gesendet)' : 'LIVE'}`); +console.log( + ` Kanaele ${[DO_INDEXNOW && 'IndexNow', DO_GOOGLE && 'Google Indexing API'] + .filter(Boolean) + .join(' + ') || 'keine'}\n` +); + +// alle URLs muessen zum konfigurierten Host gehoeren +const foreign = urls.filter((u) => new URL(u).host !== config.host); +if (foreign.length) { + fail(`Diese URLs passen nicht zu host="${config.host}":\n ${foreign.join('\n ')}`); +} + +// ---------------------------------------------------------------- Preflight + +if (!SKIP_PREFLIGHT) { + console.log(' Preflight: pruefe, ob jede URL live 200 liefert ...'); + const bad = []; + for (const url of urls) { + const status = await statusOf(url); + if (status !== 200) bad.push(`${status} ${url}`); + } + if (bad.length) { + console.error('\n Diese URLs antworten nicht mit 200:'); + for (const b of bad) console.error(` ${b}`); + fail( + 'Abbruch. Eine URL einzureichen, die 404 oder 500 liefert, schadet mehr als sie nutzt.\n' + + ' Deploy pruefen, dann erneut ausfuehren (oder --skip-preflight setzen).' + ); + } + console.log(` Preflight OK: alle ${urls.length} URLs liefern 200.\n`); +} + +// ---------------------------------------------------------------- IndexNow + +if (DO_INDEXNOW) { + const key = process.env.INDEXNOW_KEY || detectKeyInPublicDir(); + if (!key) { + fail( + 'Kein IndexNow-Key gefunden.\n' + + ' Entweder INDEXNOW_KEY in .env setzen, oder eine .txt in public/\n' + + ' ablegen, die genau den Key als Inhalt hat (bing.com/indexnow).' + ); + } + if (!process.env.INDEXNOW_KEY) { + console.log(` IndexNow-Key aus public/ erkannt: ${key}`); + } + + const keyLocation = `https://${config.host}/${key}.txt`; + + if (!SKIP_PREFLIGHT) { + const res = await fetch(keyLocation).catch(() => null); + const body = res && res.ok ? (await res.text()).trim().replace(/^/, '') : null; + if (!res || !res.ok) { + fail(`IndexNow-Key-Datei nicht erreichbar: ${keyLocation}`); + } + if (body !== key) { + fail( + `IndexNow-Key-Datei enthaelt nicht den erwarteten Key.\n` + + ` ${keyLocation}\n erwartet: ${key}\n gefunden: ${JSON.stringify(body)}` + ); + } + console.log(` IndexNow-Key verifiziert: ${keyLocation}`); + } + + const payload = { + host: config.host, + key, + keyLocation, + urlList: urls, + }; + + if (DRY_RUN) { + console.log(` [dry-run] POST https://api.indexnow.org/indexnow (${urls.length} URLs)\n`); + } else { + const res = await fetch('https://api.indexnow.org/indexnow', { + method: 'POST', + headers: { 'Content-Type': 'application/json; charset=utf-8' }, + body: JSON.stringify(payload), + }); + // 200 = angenommen, 202 = angenommen, Key wird noch geprueft + if (res.status === 200 || res.status === 202) { + console.log(` IndexNow OK (${res.status}): ${urls.length} URLs uebermittelt.\n`); + } else { + console.error(` IndexNow fehlgeschlagen (${res.status}): ${await res.text()}\n`); + process.exitCode = 1; + } + } +} + +// ------------------------------------------------------- Google Indexing API + +if (DO_GOOGLE) { + const saPath = path.resolve( + repoRoot, + process.env.GOOGLE_SERVICE_ACCOUNT || 'service_account.json' + ); + + if (!fs.existsSync(saPath)) { + console.warn( + ` Google Indexing API uebersprungen: ${path.relative(repoRoot, saPath)} nicht gefunden.\n` + + ` Pfad ueber GOOGLE_SERVICE_ACCOUNT setzen oder --indexnow-only nutzen.\n` + ); + } else if (urls.length > 200) { + fail(`Die Google Indexing API erlaubt 200 URLs pro Tag, die Liste hat ${urls.length}.`); + } else { + const sa = JSON.parse(fs.readFileSync(saPath, 'utf8')); + console.log(` Google Indexing API als ${sa.client_email}`); + + if (DRY_RUN) { + console.log(` [dry-run] ${urls.length}x urlNotifications:publish (URL_UPDATED)\n`); + } else { + const token = await getAccessToken(sa); + let ok = 0; + const failures = []; + + for (const url of urls) { + const res = await fetch( + 'https://indexing.googleapis.com/v3/urlNotifications:publish', + { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ url, type: 'URL_UPDATED' }), + } + ); + + if (res.ok) { + ok++; + console.log(` ok ${url}`); + } else { + const text = await res.text(); + failures.push(`${res.status} ${url}\n ${text.slice(0, 200)}`); + console.log(` FEHL ${res.status} ${url}`); + } + await sleep(120); // bleibt unter dem Minutenlimit + } + + console.log(`\n Google: ${ok}/${urls.length} akzeptiert.`); + if (failures.length) { + console.error('\n Fehlgeschlagen:'); + for (const f of failures) console.error(` ${f}`); + process.exitCode = 1; + } + console.log(); + } + } +} + +console.log(' Fertig.\n'); +if (!DRY_RUN) { + console.log(' Denk dran: Google Search Console hat keine API fuer "Indexierung'); + console.log(' beantragen". Die wichtigsten Seiten dort weiterhin manuell anstossen.\n'); +} + +// ---------------------------------------------------------------- Helpers + +/** + * Sucht in public/ nach einer .txt, deren Inhalt exakt dem Dateinamen + * entspricht. Genau so verlangt IndexNow die Key-Datei. + */ +function detectKeyInPublicDir() { + const publicDir = path.join(repoRoot, 'public'); + if (!fs.existsSync(publicDir)) return null; + + const candidates = []; + for (const file of fs.readdirSync(publicDir)) { + if (!file.endsWith('.txt')) continue; + const name = file.slice(0, -4); + if (!/^[a-f0-9]{8,128}$/i.test(name)) continue; + let content; + try { + content = fs.readFileSync(path.join(publicDir, file), 'utf8'); + } catch { + continue; + } + // BOM und Null-Bytes aus Windows-Editoren wegräumen + const normalised = content.replace(//g, '').replace(/^/, '').trim(); + if (normalised === name) candidates.push(name); + } + + if (candidates.length > 1) { + console.warn( + ` Mehrere gueltige IndexNow-Keys in public/: ${candidates.join(', ')}\n` + + ` Es wird ${candidates[0]} genutzt. Fuer Eindeutigkeit INDEXNOW_KEY setzen.` + ); + } + return candidates[0] ?? null; +} + +async function statusOf(url) { + try { + let res = await fetch(url, { method: 'HEAD', redirect: 'follow' }); + // manche Hosts mögen HEAD nicht + if (res.status === 405 || res.status === 501) { + res = await fetch(url, { method: 'GET', redirect: 'follow' }); + } + return res.status; + } catch (err) { + return `ERR ${err.message}`; + } +} + +/** OAuth2 Access Token per signiertem JWT, ohne googleapis-Dependency. */ +async function getAccessToken(sa) { + const now = Math.floor(Date.now() / 1000); + const header = { alg: 'RS256', typ: 'JWT' }; + const claim = { + iss: sa.client_email, + scope: 'https://www.googleapis.com/auth/indexing', + aud: 'https://oauth2.googleapis.com/token', + exp: now + 3600, + iat: now, + }; + + const b64 = (obj) => Buffer.from(JSON.stringify(obj)).toString('base64url'); + const unsigned = `${b64(header)}.${b64(claim)}`; + const signature = crypto + .createSign('RSA-SHA256') + .update(unsigned) + .sign(sa.private_key) + .toString('base64url'); + + const res = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', + assertion: `${unsigned}.${signature}`, + }), + }); + + const json = await res.json(); + if (!res.ok) fail(`Google-Auth fehlgeschlagen: ${JSON.stringify(json)}`); + return json.access_token; +} + +function sleep(ms) { + return new Promise((r) => setTimeout(r, ms)); +} + +function fail(msg) { + console.error(`\n ${msg}\n`); + process.exit(1); +} diff --git a/seo-tracker/.env.example b/seo-tracker/.env.example new file mode 100644 index 0000000..2a013ce --- /dev/null +++ b/seo-tracker/.env.example @@ -0,0 +1,16 @@ +# Serper.dev API Key – https://serper.dev → API keys +SERPER_API_KEY= + +# Eigene Domain (ohne www, ohne Protokoll) +TARGET_DOMAIN=qrmaster.net + +# Standardland für Keywords ohne "| xx" Suffix +DEFAULT_COUNTRY=us + +# Wie tief geprüft wird. 100 = Top 100 (~10 Credits/Keyword), +# 20 = Top 20 (~2 Credits/Keyword). Zum Credits-Sparen niedriger setzen. +RESULTS_PER_QUERY=100 + +# Parallele Requests und Pause dazwischen +CONCURRENCY=4 +DELAY_MS=250 diff --git a/seo-tracker/.gitignore b/seo-tracker/.gitignore new file mode 100644 index 0000000..d6d010a --- /dev/null +++ b/seo-tracker/.gitignore @@ -0,0 +1,5 @@ +.env +results.csv +test.csv +test.txt +tracker.log diff --git a/seo-tracker/README.md b/seo-tracker/README.md new file mode 100644 index 0000000..2a8f3fb --- /dev/null +++ b/seo-tracker/README.md @@ -0,0 +1,94 @@ +# SEO Rank Tracker (standalone) + +Eigenständiger Rank-Tracker für `qrmaster.net` über die [Serper.dev](https://serper.dev) +Google-SERP-API. Läuft komplett außerhalb der Next.js-App, greift auf keine +Datenbank zu und hat keine npm-Dependencies — nur **Node 18+**. + +Ersetzt kostenpflichtige Rank-Tracker (Wincher, SerpRobot, ~10–25 €/Monat) für +ein paar Cent pro Lauf. + +## Setup + +```bash +cd seo-tracker +cp .env.example .env +# SERPER_API_KEY in .env eintragen +``` + +## Nutzung + +```bash +node track.mjs --dry-run # zeigt Keywords + Credit-Schätzung, ohne API-Calls +node track.mjs --limit 5 --num 20 # kleiner Testlauf (~10 Credits) +node track.mjs # voller Lauf +node track.mjs --num 20 # nur Top 20 prüfen — spart 80% Credits + +# eigene Liste / eigene Ausgabedatei (z. B. für Ad-hoc-Checks) +node track.mjs --keywords test.txt --out test.csv --num 20 +``` + +Ergebnisse werden an `results.csv` angehängt (eine Zeile pro Keyword pro Lauf). +Beim zweiten Lauf zeigt die Konsole automatisch die Δ-Veränderung gegenüber dem +vorherigen Durchlauf. + +## Keywords pflegen + +`keywords.txt`, eine Zeile pro Keyword. `#` = Kommentar. +Land optional per Pipe anhängen: + +``` +qr code generator +qr code generator kostenlos | de +``` + +## Credits im Blick behalten + +Serper rechnet **1 Credit pro 10 Ergebnisse** ab: + +| Tiefe (`--num`) | Credits/Keyword | 43 Keywords | Läufe mit 2.500 Credits | +|---|---|---|---| +| 100 | 10 | 430 | ~5 | +| 20 | 2 | 86 | ~29 | +| 10 | 1 | 43 | ~58 | + +**Empfehlung:** `RESULTS_PER_QUERY=20` in der `.env` und wöchentlich laufen lassen. +Damit reicht das kostenlose Guthaben gut ein halbes Jahr, und Positionen jenseits +von Platz 20 sind ohnehin kaum handlungsrelevant. + +Für die tiefe Sicht (Top 100) einmal im Quartal `node track.mjs --num 100` laufen lassen. + +## Automatisierung + +Wöchentlich, montags 6 Uhr: + +**Windows (Aufgabenplanung)** +``` +schtasks /create /tn "QR Master Rank Tracker" /tr "node C:\Users\timo\Documents\qrmaster\QR-master\seo-tracker\track.mjs" /sc weekly /d MON /st 06:00 +``` + +**Linux/Server (crontab)** +``` +0 6 * * 1 cd /pfad/zu/seo-tracker && node track.mjs >> tracker.log 2>&1 +``` + +## Ausgabe-Spalten + +| Spalte | Bedeutung | +|---|---| +| `date` | Datum des Laufs | +| `keyword` / `country` | Abgefragte Query und Land | +| `position` | Position von qrmaster.net (leer = nicht in Top N) | +| `url` | Welche URL rankt | +| `top_competitor` | Domain auf Platz 1 (ausser eigener) | +| `ai_overview` | 1 = Google zeigte AI Overview / Answer Box | +| `error` | Fehlermeldung, falls der Call fehlschlug | + +Die Spalte `ai_overview` ist der Einstieg ins AEO-Tracking: Keywords, bei denen +Google eine AI-Antwort ausspielt, verlieren organische Klicks — die gehören +priorisiert in die AEO-Backlog-Liste in `CLAUDE.md`. + +## Blinder Fleck + +Der Tracker sieht nur, was Serper sieht — Google-Rankings. Für **echte** +Impressions/Klicks empfiehlt sich zusätzlich die Google-Search-Console-API +(kostenlos, offizielle Daten). diff --git a/seo-tracker/keywords.txt b/seo-tracker/keywords.txt new file mode 100644 index 0000000..1eedc04 --- /dev/null +++ b/seo-tracker/keywords.txt @@ -0,0 +1,63 @@ +# QR Master – Rank-Tracking Keywords +# Eine Zeile = ein Keyword. Zeilen mit # werden ignoriert. +# Optional pro Zeile ein Land anhängen: keyword | de +# Ohne Angabe wird DEFAULT_COUNTRY aus .env verwendet (Standard: us) + +# --- Core / Head Terms --- +qr code generator +free qr code generator +dynamic qr code generator +best qr code generator +best qr code generator 2026 +custom qr code generator + +# --- Feature / Intent --- +qr code generator with analytics +qr code generator with tracking +trackable qr code +editable qr code +bulk qr code generator +qr code generator api +qr code with logo +qr code generator no sign up +free qr code generator no expiration + +# --- Audience --- +qr code generator for business +qr code generator for small business +qr code generator for agencies +white label qr code generator + +# --- Content Types --- +vcard qr code generator +wifi qr code generator +pdf qr code generator +whatsapp qr code generator +menu qr code generator +google review qr code + +# --- Barcode Cluster (bestätigter AEO-Win) --- +dynamic barcode generator +barcode generator +ean 13 generator + +# --- Informational / Blog --- +qr code analytics +qr code tracking +dynamic vs static qr codes +qr code scan statistics + +# --- Competitor / Alternatives (aktuell größte Lücke) --- +qr tiger alternative +uniqode alternative +bitly qr code alternative +beaconstac alternative +flowcode alternative +hovercode alternative + +# --- DE-Markt --- +qr code generator kostenlos | de +qr code erstellen | de +dynamischer qr code | de +qr code mit logo | de +qr code generator mit statistik | de diff --git a/seo-tracker/track.mjs b/seo-tracker/track.mjs new file mode 100644 index 0000000..3921341 --- /dev/null +++ b/seo-tracker/track.mjs @@ -0,0 +1,315 @@ +#!/usr/bin/env node +/** + * QR Master – Standalone Rank Tracker (Serper.dev) + * + * Liest keywords.txt, fragt für jedes Keyword die Google-SERP über Serper ab, + * findet die Position der eigenen Domain und schreibt das Ergebnis nach results.csv. + * + * Keine Dependencies – braucht nur Node 18+ (globales fetch). + * + * node track.mjs # normaler Lauf + * node track.mjs --dry-run # nichts abfragen, nur zeigen was passieren würde + * node track.mjs --limit 5 # nur die ersten 5 Keywords (zum Testen) + * node track.mjs --num 20 # nur Top-20 statt Top-100 prüfen (spart Credits) + * node track.mjs --keywords test.txt --out test.csv # andere Listen/Ausgabe + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// ---------------------------------------------------------------- config ---- + +loadDotEnv(path.join(__dirname, '.env')); + +const args = parseArgs(process.argv.slice(2)); + +const CONFIG = { + apiKey: process.env.SERPER_API_KEY, + domain: (process.env.TARGET_DOMAIN || 'qrmaster.net').replace(/^www\./, ''), + defaultCountry: (process.env.DEFAULT_COUNTRY || 'us').toLowerCase(), + num: Number(args.num || process.env.RESULTS_PER_QUERY || 100), + concurrency: Number(process.env.CONCURRENCY || 4), + delayMs: Number(process.env.DELAY_MS || 250), + keywordsFile: args.keywords + ? path.resolve(process.cwd(), String(args.keywords)) + : path.join(__dirname, 'keywords.txt'), + csvFile: args.out + ? path.resolve(process.cwd(), String(args.out)) + : path.join(__dirname, 'results.csv'), + dryRun: Boolean(args['dry-run']), + limit: args.limit ? Number(args.limit) : null, +}; + +const LANG_BY_COUNTRY = { de: 'de', at: 'de', ch: 'de', fr: 'fr', es: 'es', it: 'it', nl: 'nl' }; + +// ------------------------------------------------------------------ main ---- + +async function main() { + const keywords = readKeywords(CONFIG.keywordsFile, CONFIG.defaultCountry); + const targets = CONFIG.limit ? keywords.slice(0, CONFIG.limit) : keywords; + + if (targets.length === 0) { + console.error('Keine Keywords in keywords.txt gefunden.'); + process.exit(1); + } + + console.log(`\n Domain ${CONFIG.domain}`); + console.log(` Keywords ${targets.length}`); + console.log(` Tiefe Top ${CONFIG.num}`); + console.log(` Credits ~${estimateCredits(targets.length, CONFIG.num)}\n`); + + if (CONFIG.dryRun) { + for (const k of targets) console.log(` [dry] ${k.country} ${k.keyword}`); + console.log('\nDry run – keine API-Calls abgesetzt.\n'); + return; + } + + if (!CONFIG.apiKey) { + console.error('SERPER_API_KEY fehlt. Lege eine .env an (siehe .env.example).'); + process.exit(1); + } + + const previous = readPreviousRun(CONFIG.csvFile); + const runDate = new Date().toISOString().slice(0, 10); + const rows = []; + let done = 0; + + await pool(targets, CONFIG.concurrency, async (kw) => { + const row = await trackKeyword(kw, runDate); + rows.push(row); + done += 1; + if (process.stdout.isTTY) process.stdout.write(`\r ${done}/${targets.length} abgefragt…`); + await sleep(CONFIG.delayMs); + }); + + if (process.stdout.isTTY) process.stdout.write('\r' + ' '.repeat(40) + '\r'); + console.log(''); + + rows.sort((a, b) => a.keyword.localeCompare(b.keyword)); + appendCsv(CONFIG.csvFile, rows); + printReport(rows, previous); + + console.log(`\n Gespeichert: ${path.relative(process.cwd(), CONFIG.csvFile)}\n`); +} + +// ----------------------------------------------------------- tracking ------- + +async function trackKeyword({ keyword, country }, runDate) { + const base = { + date: runDate, + keyword, + country, + position: null, + url: '', + top_competitor: '', + ai_overview: false, + error: '', + }; + + try { + const data = await serperSearch(keyword, country); + const organic = Array.isArray(data.organic) ? data.organic : []; + + const hit = organic.find((r) => hostOf(r.link) === CONFIG.domain); + if (hit) { + base.position = hit.position ?? organic.indexOf(hit) + 1; + base.url = hit.link || ''; + } + + const firstOther = organic.find((r) => hostOf(r.link) !== CONFIG.domain); + base.top_competitor = firstOther ? hostOf(firstOther.link) : ''; + base.ai_overview = Boolean(data.answerBox || data.aiOverview); + } catch (err) { + base.error = String(err.message || err).slice(0, 200); + } + + return base; +} + +async function serperSearch(keyword, country, attempt = 1) { + const body = { + q: keyword, + gl: country, + hl: LANG_BY_COUNTRY[country] || 'en', + num: CONFIG.num, + }; + + const res = await fetch('https://google.serper.dev/search', { + method: 'POST', + headers: { 'X-API-KEY': CONFIG.apiKey, 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + if (res.status === 429 || res.status >= 500) { + if (attempt < 3) { + await sleep(1000 * attempt); + return serperSearch(keyword, country, attempt + 1); + } + } + + if (!res.ok) { + throw new Error(`Serper ${res.status}: ${(await res.text()).slice(0, 120)}`); + } + + return res.json(); +} + +// -------------------------------------------------------------- reporting --- + +function printReport(rows, previous) { + const ranked = rows.filter((r) => r.position !== null); + const top10 = ranked.filter((r) => r.position <= 10); + const errors = rows.filter((r) => r.error); + + console.log(' KEYWORD LAND POS Δ'); + console.log(' ' + '-'.repeat(64)); + + for (const r of rows) { + const prev = previous.get(`${r.keyword}|${r.country}`); + const pos = r.position === null ? ' –' : String(r.position).padStart(3); + const delta = formatDelta(r.position, prev); + const name = r.keyword.length > 42 ? r.keyword.slice(0, 39) + '…' : r.keyword; + console.log(` ${name.padEnd(42)} ${r.country.padEnd(5)} ${pos} ${delta}`); + } + + console.log(' ' + '-'.repeat(64)); + console.log(` Ranked: ${ranked.length}/${rows.length} Top 10: ${top10.length}` + + (errors.length ? ` Fehler: ${errors.length}` : '')); + + if (errors.length) { + console.log('\n Fehler:'); + for (const e of errors) console.log(` ${e.keyword} – ${e.error}`); + } +} + +function formatDelta(current, prev) { + if (prev === undefined) return 'neu'; + if (current === null && prev === null) return ''; + if (current === null) return 'raus'; + if (prev === null) return 'rein'; + const diff = prev - current; // positiv = verbessert + if (diff === 0) return '='; + return diff > 0 ? `+${diff}` : String(diff); +} + +// -------------------------------------------------------------------- io ---- + +function readKeywords(file, defaultCountry) { + if (!fs.existsSync(file)) return []; + return fs + .readFileSync(file, 'utf8') + .split(/\r?\n/) + .map((l) => l.trim()) + .filter((l) => l && !l.startsWith('#')) + .map((line) => { + const [kw, country] = line.split('|').map((s) => s.trim()); + return { keyword: kw, country: (country || defaultCountry).toLowerCase() }; + }); +} + +const CSV_HEADER = 'date,keyword,country,position,url,top_competitor,ai_overview,error'; + +function appendCsv(file, rows) { + const exists = fs.existsSync(file); + const lines = rows.map((r) => + [ + r.date, r.keyword, r.country, + r.position === null ? '' : r.position, + r.url, r.top_competitor, r.ai_overview ? '1' : '0', r.error, + ].map(csvEscape).join(',') + ); + fs.appendFileSync(file, (exists ? '' : CSV_HEADER + '\n') + lines.join('\n') + '\n', 'utf8'); +} + +/** Liest die zuletzt geschriebene Run-Zeile pro Keyword für den Δ-Vergleich. */ +function readPreviousRun(file) { + const map = new Map(); + if (!fs.existsSync(file)) return map; + + const lines = fs.readFileSync(file, 'utf8').split(/\r?\n/).filter(Boolean).slice(1); + if (lines.length === 0) return map; + + const parsed = lines.map(parseCsvLine); + const lastDate = parsed[parsed.length - 1][0]; + + for (const cols of parsed) { + if (cols[0] === lastDate) { + map.set(`${cols[1]}|${cols[2]}`, cols[3] === '' ? null : Number(cols[3])); + } + } + return map; +} + +function csvEscape(value) { + const s = String(value ?? ''); + return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; +} + +function parseCsvLine(line) { + const out = []; + let cur = '', inQuotes = false; + for (let i = 0; i < line.length; i++) { + const c = line[i]; + if (inQuotes) { + if (c === '"' && line[i + 1] === '"') { cur += '"'; i++; } + else if (c === '"') inQuotes = false; + else cur += c; + } else if (c === '"') inQuotes = true; + else if (c === ',') { out.push(cur); cur = ''; } + else cur += c; + } + out.push(cur); + return out; +} + +// ----------------------------------------------------------------- utils ---- + +function hostOf(url) { + try { return new URL(url).hostname.replace(/^www\./, '').toLowerCase(); } + catch { return ''; } +} + +/** Serper: 1 Credit pro 10 Ergebnisse (num=100 → ~10 Credits). */ +function estimateCredits(count, num) { + return count * Math.max(1, Math.ceil(num / 10)); +} + +async function pool(items, size, worker) { + const queue = [...items]; + const runners = Array.from({ length: Math.min(size, queue.length) }, async () => { + while (queue.length) await worker(queue.shift()); + }); + await Promise.all(runners); +} + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +function parseArgs(argv) { + const out = {}; + for (let i = 0; i < argv.length; i++) { + if (!argv[i].startsWith('--')) continue; + const key = argv[i].slice(2); + const next = argv[i + 1]; + if (next && !next.startsWith('--')) { out[key] = next; i++; } + else out[key] = true; + } + return out; +} + +function loadDotEnv(file) { + if (!fs.existsSync(file)) return; + for (const line of fs.readFileSync(file, 'utf8').split(/\r?\n/)) { + const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/i); + if (m && !process.env[m[1]]) { + process.env[m[1]] = m[2].replace(/^["']|["']$/g, ''); + } + } +} + +main().catch((err) => { + console.error('\nAbbruch:', err); + process.exit(1); +}); diff --git a/src/app/(main)/(marketing)/unsubscribe/page.tsx b/src/app/(main)/(marketing)/unsubscribe/page.tsx new file mode 100644 index 0000000..be60d3d --- /dev/null +++ b/src/app/(main)/(marketing)/unsubscribe/page.tsx @@ -0,0 +1,55 @@ +'use client'; + +import { useSearchParams } from 'next/navigation'; +import { useState } from 'react'; + +export default function UnsubscribePage() { + const searchParams = useSearchParams(); + const token = searchParams.get('token'); + const [status, setStatus] = useState<'idle' | 'saving' | 'success' | 'error'>('idle'); + + async function unsubscribe() { + setStatus('saving'); + + const response = await fetch('/api/marketing/unsubscribe', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token }), + }); + + setStatus(response.ok ? 'success' : 'error'); + } + + return ( +
+
+ {status === 'success' ? ( + <> +

You’re unsubscribed.

+

+ You will no longer receive QR Master product and upgrade emails. +

+ + ) : ( + <> +

Unsubscribe from product updates?

+

+ You will still receive essential account, billing, and security emails. +

+ + {status === 'error' && ( +

This link is invalid or has expired.

+ )} + + )} +
+
+ ); +} diff --git a/src/app/(main)/api/marketing/designer-broadcast/route.ts b/src/app/(main)/api/marketing/designer-broadcast/route.ts new file mode 100644 index 0000000..5df33ae --- /dev/null +++ b/src/app/(main)/api/marketing/designer-broadcast/route.ts @@ -0,0 +1,51 @@ +import { NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { db } from '@/lib/db'; +import { sendDesignerAnnouncementEmail } from '@/lib/email'; +import { createMarketingUnsubscribeUrl } from '@/lib/marketingEmail'; + +export const maxDuration = 300; + +/** Sends the Designer announcement to account holders who have not opted out. */ +export async function POST() { + if (cookies().get('newsletter-admin')?.value !== 'authenticated') { + return NextResponse.json({ error: 'Unauthorized.' }, { status: 401 }); + } + + try { + const suppressions = await db.newsletterSubscription.findMany({ + where: { status: 'unsubscribed' }, + select: { email: true }, + }); + const suppressedEmails = new Set(suppressions.map((entry) => entry.email.toLowerCase())); + const accountUsers = await db.user.findMany({ + select: { email: true }, + orderBy: { createdAt: 'asc' }, + }); + const recipients = accountUsers.filter( + (recipient) => !suppressedEmails.has(recipient.email.toLowerCase()) + ); + + let sent = 0; + const failed: string[] = []; + + // Sequential sending respects the configured Resend free-tier rate limit. + for (const recipient of recipients) { + try { + await sendDesignerAnnouncementEmail( + recipient.email, + createMarketingUnsubscribeUrl(recipient.email) + ); + sent++; + } catch (error) { + failed.push(recipient.email); + console.error('Designer announcement failed:', error); + } + } + + return NextResponse.json({ sent, failed: failed.length, total: recipients.length }); + } catch (error) { + console.error('Designer announcement broadcast error:', error); + return NextResponse.json({ error: 'Unable to send Designer announcement.' }, { status: 500 }); + } +} diff --git a/src/app/(main)/api/marketing/unsubscribe/route.ts b/src/app/(main)/api/marketing/unsubscribe/route.ts new file mode 100644 index 0000000..a207185 --- /dev/null +++ b/src/app/(main)/api/marketing/unsubscribe/route.ts @@ -0,0 +1,25 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getUnsubscribeEmail } from '@/lib/marketingEmail'; + +export async function POST(request: NextRequest) { + try { + const { token } = await request.json(); + const email = getUnsubscribeEmail(typeof token === 'string' ? token : null); + + if (!email) { + return NextResponse.json({ error: 'This unsubscribe link is invalid or expired.' }, { status: 400 }); + } + + await db.newsletterSubscription.upsert({ + where: { email }, + update: { status: 'unsubscribed', source: 'marketing-unsubscribe' }, + create: { email, status: 'unsubscribed', source: 'marketing-unsubscribe' }, + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Marketing unsubscribe error:', error); + return NextResponse.json({ error: 'Unable to update your email preferences.' }, { status: 500 }); + } +} diff --git a/src/app/(main)/api/newsletter/subscribe/route.ts b/src/app/(main)/api/newsletter/subscribe/route.ts index cbcaa24..c3879b7 100644 --- a/src/app/(main)/api/newsletter/subscribe/route.ts +++ b/src/app/(main)/api/newsletter/subscribe/route.ts @@ -50,7 +50,7 @@ export async function POST(request: NextRequest) { where: { email }, }); - if (existing) { + if (existing?.status === 'subscribed') { // If already subscribed, return success (idempotent) // Don't reveal if email exists for privacy return NextResponse.json({ @@ -58,7 +58,20 @@ export async function POST(request: NextRequest) { message: 'Successfully subscribed to AI features newsletter!', alreadySubscribed: true, }); - } + } + + if (existing?.status === 'unsubscribed') { + await db.newsletterSubscription.update({ + where: { email }, + data: { status: 'subscribed', source: 'ai-coming-soon' }, + }); + + return NextResponse.json({ + success: true, + message: 'Successfully subscribed to AI features newsletter!', + alreadySubscribed: false, + }); + } // Create new subscription await db.newsletterSubscription.create({ diff --git a/src/lib/email.ts b/src/lib/email.ts index 577227d..477b1e9 100644 --- a/src/lib/email.ts +++ b/src/lib/email.ts @@ -559,7 +559,48 @@ function createSmtpTransport() { }); } -const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://www.qrmaster.net'; +const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://www.qrmaster.net'; + +/** Marketing announcement with a per-recipient, functional unsubscribe link. */ +export async function sendDesignerAnnouncementEmail(email: string, unsubscribeUrl: string) { + await waitForRateLimit(); + + const createUrl = `${appUrl}/create`; + + await resend.emails.send({ + from: 'Timo from QR Master ', + replyTo: 'support@qrmaster.net', + to: email, + subject: 'Your QR codes can now look like your brand', + html: ` + + +
+ + + + +
QR MASTER / DESIGNER UPDATE
+
+

Your QR codes can now look like your brand.

+

Your QR codes do not have to look generic. The new QR Master Designer gives you more control over how every code looks while keeping it easy to create QR codes that scan reliably.

+
+

DESIGNER TIERS

+

Free: custom colours, plus SVG and PNG downloads.
Pro (EUR 9/mo): 4 module shapes, custom eye styles, and your logo.
Business (EUR 29/mo): 11 module shapes, colour gradients, and saved presets for bulk uploads.

+
+

For packaging, flyers, menus, labels, or campaigns, a consistent design makes every scan feel like part of your brand. With Business, you can save a design once and apply it across an entire bulk upload.

+ DESIGN YOUR QR CODE +

Best, Timo

+
+ Your existing QR codes will stay active exactly as they are.
+ Unsubscribe from product updates +
+
+ + `, + text: `Your QR codes can now look like your brand. Design your QR code: ${createUrl}\n\nUnsubscribe from product updates: ${unsubscribeUrl}`, + }); +} // --------------------------------------------------------------------------- // Shared design tokens (email-safe inline styles) diff --git a/src/lib/marketingEmail.ts b/src/lib/marketingEmail.ts new file mode 100644 index 0000000..f33b4d5 --- /dev/null +++ b/src/lib/marketingEmail.ts @@ -0,0 +1,72 @@ +import 'server-only'; + +import crypto from 'crypto'; + +const TOKEN_TTL_MS = 1000 * 60 * 60 * 24 * 365; + +function getSigningSecret(): string { + const secret = process.env.EMAIL_UNSUBSCRIBE_SECRET || process.env.NEXTAUTH_SECRET; + + if (!secret) { + throw new Error('Set EMAIL_UNSUBSCRIBE_SECRET before sending marketing email.'); + } + + return secret; +} + +function sign(payload: string): string { + return crypto.createHmac('sha256', getSigningSecret()).update(payload).digest('base64url'); +} + +function normalizeEmail(email: string): string { + return email.trim().toLowerCase(); +} + +export function createMarketingUnsubscribeUrl(email: string): string { + const payload = Buffer.from( + JSON.stringify({ email: normalizeEmail(email), expiresAt: Date.now() + TOKEN_TTL_MS }) + ).toString('base64url'); + const token = `${payload}.${sign(payload)}`; + const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://www.qrmaster.net'; + + return `${appUrl}/unsubscribe?token=${encodeURIComponent(token)}`; +} + +export function getUnsubscribeEmail(token: string | null | undefined): string | null { + if (!token) return null; + + const separator = token.lastIndexOf('.'); + if (separator <= 0 || separator === token.length - 1) return null; + + const payload = token.slice(0, separator); + const providedSignature = token.slice(separator + 1); + const expectedSignature = sign(payload); + const providedBuffer = Buffer.from(providedSignature); + const expectedBuffer = Buffer.from(expectedSignature); + + if ( + providedBuffer.length !== expectedBuffer.length || + !crypto.timingSafeEqual(providedBuffer, expectedBuffer) + ) { + return null; + } + + try { + const decoded = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as { + email?: unknown; + expiresAt?: unknown; + }; + + if ( + typeof decoded.email !== 'string' || + typeof decoded.expiresAt !== 'number' || + decoded.expiresAt < Date.now() + ) { + return null; + } + + return normalizeEmail(decoded.email); + } catch { + return null; + } +}