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:
@@ -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 };
|
||||
Reference in New Issue
Block a user