Country-tier targeting for campaigns: DB-IP lite lookup, tier lists in Settings, Show-to tiers on every format, geo-restricted ads kept off the network, per-country serve stats

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-11 12:02:40 -05:00
parent 0f855292f5
commit cd26161db5
7 changed files with 158 additions and 9 deletions
+120
View File
@@ -0,0 +1,120 @@
'use strict';
// Country lookup for ad targeting, zero dependencies. Data: DB-IP "IP to Country
// Lite" (CC BY 4.0, attribution "IP Geolocation by DB-IP" is shown on the site).
// The server downloads the current month's CSV once, keeps it in DATA_DIR/geo/,
// loads it into sorted range tables (IPv4 as uint32, IPv6 as BigInt) and answers
// countryOf(ip) by binary search. Refreshes monthly.
const fs = require('fs');
const path = require('path');
const https = require('https');
const zlib = require('zlib');
let DATA_DIR = '.';
let v4 = { start: [], end: [], cc: [] }, v6 = { start: [], end: [], cc: [] };
let loaded = false, loadedFile = '';
// Tier lists (affiliate-marketing convention). Editable in Admin > Settings as
// comma-separated ISO codes; tier 3 = everything else.
const DEFAULT_TIER1 = 'US,CA,GB,AU,NZ,IE,DE,FR,NL,SE,NO,DK,FI,CH,AT,BE';
const DEFAULT_TIER2 = 'IT,ES,PT,PL,CZ,HU,GR,RO,SK,SI,HR,BG,EE,LV,LT,LU,IS,MT,CY,JP,KR,SG,HK,TW,IL,AE,SA,QA,KW,BH,OM,ZA,BR,MX,AR,CL,CO,UY,CR,PA,MY,TH,TR,RU,UA,KZ';
function ip4(s) { const p = s.split('.'); if (p.length !== 4) return null; let n = 0; for (const x of p) { const v = Number(x); if (!/^\d{1,3}$/.test(x) || v > 255) return null; n = n * 256 + v; } return n; }
function ip6(s) {
try {
let [head, tail] = s.split('::');
const h = head ? head.split(':') : [], t = tail ? tail.split(':') : [];
if (s.includes('::')) { while (h.length + t.length < 8) h.push('0'); }
const parts = h.concat(t); if (parts.length !== 8) return null;
let n = 0n; for (const p of parts) { if (!/^[0-9a-f]{0,4}$/i.test(p)) return null; n = (n << 16n) + BigInt(parseInt(p || '0', 16)); }
return n;
} catch (e) { return null; }
}
function find(tbl, x) {
let lo = 0, hi = tbl.start.length - 1;
while (lo <= hi) { const mid = (lo + hi) >> 1; if (tbl.start[mid] <= x) { if (x <= tbl.end[mid]) return tbl.cc[mid]; lo = mid + 1; } else hi = mid - 1; }
return null;
}
// ISO-2 country code or null (private ranges, unknown, no data yet)
function countryOf(ip) {
if (!loaded || !ip) return null;
let s = String(ip).trim().replace(/^::ffff:/i, '');
let cc = null;
if (s.includes('.')) { const n = ip4(s); cc = n == null ? null : find(v4, n); }
else { const n = ip6(s); cc = n == null ? null : find(v6, n); }
return cc && cc !== 'ZZ' ? cc : null; // ZZ = private/reserved: unknown
}
function tierLists(cfg) {
const parse = (v, def) => new Set(String(v || def).toUpperCase().split(/[\s,]+/).filter(Boolean));
return { t1: parse(cfg && cfg.geoTier1, DEFAULT_TIER1), t2: parse(cfg && cfg.geoTier2, DEFAULT_TIER2) };
}
// '1' | '2' | '3' | null (unknown country never matches a restricted campaign)
function tierOf(cc, cfg) {
if (!cc) return null;
const { t1, t2 } = tierLists(cfg);
return t1.has(cc) ? '1' : t2.has(cc) ? '2' : '3';
}
// ── data file: DATA_DIR/geo/dbip-country-lite-YYYY-MM.csv.gz ──
function monthKey(d) { d = d || new Date(); return d.getUTCFullYear() + '-' + String(d.getUTCMonth() + 1).padStart(2, '0'); }
function fileFor(mk) { return path.join(DATA_DIR, 'geo', 'dbip-country-lite-' + mk + '.csv.gz'); }
function download(mk) {
return new Promise((resolve, reject) => {
fs.mkdirSync(path.join(DATA_DIR, 'geo'), { recursive: true });
const tmp = fileFor(mk) + '.part';
const req = https.get('https://download.db-ip.com/free/dbip-country-lite-' + mk + '.csv.gz', { headers: { 'User-Agent': 'InstantAdPay/1.0 (+https://instantadpay.com)' }, timeout: 60000 }, res => {
if (res.statusCode !== 200) { res.resume(); return reject(new Error('HTTP ' + res.statusCode)); }
const out = fs.createWriteStream(tmp);
res.pipe(out); out.on('finish', () => { fs.renameSync(tmp, fileFor(mk)); resolve(fileFor(mk)); }); out.on('error', reject);
});
req.on('error', reject); req.on('timeout', () => { req.destroy(new Error('timeout')); });
});
}
function loadFile(file) {
const raw = zlib.gunzipSync(fs.readFileSync(file)).toString('utf8');
const a4 = { start: [], end: [], cc: [] }, a6 = { start: [], end: [], cc: [] };
for (const line of raw.split('\n')) {
if (!line) continue;
const c = line.indexOf(','), c2 = line.indexOf(',', c + 1);
if (c < 0 || c2 < 0) continue;
const s = line.slice(0, c), e = line.slice(c + 1, c2), cc = line.slice(c2 + 1).trim().toUpperCase();
if (cc.length !== 2) continue;
if (s.includes('.')) { const a = ip4(s), b = ip4(e); if (a != null && b != null) { a4.start.push(a); a4.end.push(b); a4.cc.push(cc); } }
else { const a = ip6(s), b = ip6(e); if (a != null && b != null) { a6.start.push(a); a6.end.push(b); a6.cc.push(cc); } }
}
// DB-IP ships sorted; guard anyway
const order = t => { const idx = t.start.map((_, i) => i).sort((i, j) => (t.start[i] < t.start[j] ? -1 : t.start[i] > t.start[j] ? 1 : 0)); return { start: idx.map(i => t.start[i]), end: idx.map(i => t.end[i]), cc: idx.map(i => t.cc[i]) }; };
v4 = order(a4); v6 = order(a6); loaded = true; loadedFile = file;
return { v4: v4.start.length, v6: v6.start.length };
}
// load the newest local file now; fetch this month's if missing (async, non-blocking)
async function init(opts) {
DATA_DIR = opts.dataDir;
try {
const dir = path.join(DATA_DIR, 'geo');
const files = fs.existsSync(dir) ? fs.readdirSync(dir).filter(f => /^dbip-country-lite-\d{4}-\d{2}\.csv\.gz$/.test(f)).sort() : [];
if (files.length) { const n = loadFile(path.join(dir, files[files.length - 1])); console.log('geo: loaded', files[files.length - 1], n.v4, 'v4 +', n.v6, 'v6 ranges'); }
} catch (e) { console.error('geo: load failed', e.message); }
refresh().catch(e => console.error('geo: refresh failed', e.message));
}
// this month's file: download if missing, then (re)load it; safe to call daily
async function refresh() {
const mk = monthKey();
const f = fileFor(mk);
if (!fs.existsSync(f)) {
try { await download(mk); } catch (e) {
// early in the month DB-IP may not have published yet: fall back to last month
const d = new Date(); d.setUTCMonth(d.getUTCMonth() - 1); const prev = monthKey(d);
if (!fs.existsSync(fileFor(prev))) await download(prev);
}
}
const dir = path.join(DATA_DIR, 'geo');
const files = fs.readdirSync(dir).filter(x => /^dbip-country-lite-\d{4}-\d{2}\.csv\.gz$/.test(x)).sort();
const newest = path.join(dir, files[files.length - 1]);
if (newest !== loadedFile) { const n = loadFile(newest); console.log('geo: loaded', files[files.length - 1], n.v4, 'v4 +', n.v6, 'v6 ranges'); }
for (const old of files.slice(0, -2)) { try { fs.unlinkSync(path.join(dir, old)); } catch (e) {} }
return { file: files[files.length - 1], ranges: v4.start.length + v6.start.length };
}
function status() { return { loaded, file: path.basename(loadedFile || ''), v4: v4.start.length, v6: v6.start.length }; }
module.exports = { init, refresh, countryOf, tierOf, tierLists, status, DEFAULT_TIER1, DEFAULT_TIER2 };