#!/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); });