email marketing

This commit is contained in:
2026-07-28 13:29:35 +02:00
parent ab63d4b916
commit e1b6d5fcc1
13 changed files with 1124 additions and 3 deletions

View File

@@ -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"
]
}
}

View File

@@ -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 <key>.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 <key>.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 <key>.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);
}

16
seo-tracker/.env.example Normal file
View File

@@ -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

5
seo-tracker/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
.env
results.csv
test.csv
test.txt
tracker.log

94
seo-tracker/README.md Normal file
View File

@@ -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, ~1025 €/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).

63
seo-tracker/keywords.txt Normal file
View File

@@ -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

315
seo-tracker/track.mjs Normal file
View File

@@ -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);
});

View File

@@ -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 (
<main className="mx-auto flex min-h-[60vh] max-w-xl items-center px-6 py-20">
<section className="w-full border border-slate-200 bg-white p-8 text-center shadow-sm">
{status === 'success' ? (
<>
<h1 className="text-3xl font-semibold text-slate-900">Youre unsubscribed.</h1>
<p className="mt-4 text-slate-600">
You will no longer receive QR Master product and upgrade emails.
</p>
</>
) : (
<>
<h1 className="text-3xl font-semibold text-slate-900">Unsubscribe from product updates?</h1>
<p className="mt-4 text-slate-600">
You will still receive essential account, billing, and security emails.
</p>
<button
type="button"
onClick={unsubscribe}
disabled={!token || status === 'saving'}
className="mt-8 bg-slate-900 px-5 py-3 font-medium text-white disabled:cursor-not-allowed disabled:opacity-50"
>
{status === 'saving' ? 'Unsubscribing…' : 'Unsubscribe'}
</button>
{status === 'error' && (
<p className="mt-4 text-sm text-red-700">This link is invalid or has expired.</p>
)}
</>
)}
</section>
</main>
);
}

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -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({

View File

@@ -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 <timo@qrmaster.net>',
replyTo: 'support@qrmaster.net',
to: email,
subject: 'Your QR codes can now look like your brand',
html: `
<!doctype html>
<html><body style="margin:0;background:#f5f4ef;color:#1b1c19;font-family:Arial,sans-serif;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f5f4ef;"><tr><td align="center" style="padding:32px 12px;">
<table role="presentation" width="600" cellspacing="0" cellpadding="0" style="width:100%;max-width:600px;background:#fff;">
<tr><td style="padding:20px 32px;border-bottom:1px solid #c4c7c7;font-size:11px;font-weight:bold;letter-spacing:2px;">QR MASTER / DESIGNER UPDATE</td></tr>
<tr><td style="padding:42px 48px;">
<div style="width:42px;height:1px;background:#1b1c19;margin-bottom:24px;"></div>
<h1 style="margin:0 0 28px;font-family:Georgia,serif;font-size:38px;font-weight:normal;line-height:1.15;">Your QR codes can now look like your brand.</h1>
<p style="font-size:16px;line-height:1.65;">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.</p>
<div style="border-top:1px solid #e3e3de;border-bottom:1px solid #e3e3de;padding:22px 0;margin:28px 0;">
<p style="font-size:11px;font-weight:bold;letter-spacing:2px;">DESIGNER TIERS</p>
<p style="font-size:14px;line-height:1.6;"><strong>Free:</strong> custom colours, plus SVG and PNG downloads.<br><strong>Pro (EUR 9/mo):</strong> 4 module shapes, custom eye styles, and your logo.<br><strong>Business (EUR 29/mo):</strong> 11 module shapes, colour gradients, and saved presets for bulk uploads.</p>
</div>
<p style="font-size:16px;line-height:1.65;">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.</p>
<a href="${createUrl}" style="display:inline-block;background:#0047ff;color:#fff;padding:15px 24px;text-decoration:none;font-size:11px;font-weight:bold;letter-spacing:1.5px;">DESIGN YOUR QR CODE</a>
<p style="margin:30px 0 0;font-family:Georgia,serif;font-size:17px;font-style:italic;">Best, Timo</p>
</td></tr>
<tr><td style="padding:20px 48px 36px;border-top:1px solid #e3e3de;text-align:center;color:#747878;font-size:11px;line-height:1.6;">
Your existing QR codes will stay active exactly as they are.<br>
<a href="${unsubscribeUrl}" style="color:#747878;text-decoration:underline;">Unsubscribe from product updates</a>
</td></tr>
</table>
</td></tr></table>
</body></html>
`,
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)

72
src/lib/marketingEmail.ts Normal file
View File

@@ -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;
}
}