Files
QR-master/seo-tracker/track.mjs
2026-07-28 13:29:35 +02:00

316 lines
9.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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);
});