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

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