316 lines
9.8 KiB
JavaScript
316 lines
9.8 KiB
JavaScript
#!/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);
|
||
});
|