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

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