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:
@@ -25,6 +25,7 @@ let QR = null; try { QR = require('qrcode'); } catch (e) { /* optional */ }
|
||||
const chatbot = require('./chatbot');
|
||||
const coach = require('./coach'); // coaching view, nudges, digest, prospects, link stats
|
||||
const tank = require('./tank'); // holding tank: unsponsored free members, adoptions, pay-it-forward
|
||||
const geo = require('./geo'); // viewer country -> tier (DB-IP lite), for campaign targeting
|
||||
const burner = require('./burner'); // automatic on-chain credit burns (inert without ENGINE_KEY)
|
||||
|
||||
const PORT = Number(process.env.PORT || 3000);
|
||||
@@ -126,6 +127,8 @@ const codeHits = new Map(); // ip -> { t: [send timestamps, 24h], passUntil, cha
|
||||
const codeGlobal = { minute: 0, n: 0 };
|
||||
const codeAlert = { last: 0, trips: 0, ips: new Set() };
|
||||
function clientIp(req) { return String(req.headers['x-forwarded-for'] || req.socket.remoteAddress || '').split(',')[0].trim() || 'unknown'; }
|
||||
// the viewer's country and tier for ad targeting (null tier = unknown: never matches a restricted campaign)
|
||||
function viewerGeo(req) { const cc = geo.countryOf(clientIp(req)); return { cc, tier: geo.tierOf(cc, siteConfig()) }; }
|
||||
function codeChallenge(rec) {
|
||||
const pick = CAPTCHA.slice().sort(() => Math.random() - 0.5).slice(0, 5);
|
||||
const answer = Math.floor(Math.random() * pick.length);
|
||||
@@ -307,6 +310,8 @@ async function boot() {
|
||||
// follow-up email sequence: send whatever came due (every 10 min, first pass shortly after boot)
|
||||
coach.init({ dataDir: DATA_DIR, chain, accounts, mailer });
|
||||
tank.init({ dataDir: DATA_DIR, chain, accounts, messages, mailer, site: 'https://instantadpay.com' });
|
||||
geo.init({ dataDir: DATA_DIR }).catch(e => console.error('geo init', e.message));
|
||||
setInterval(() => geo.refresh().catch(e => console.error('geo refresh', e.message)), 24 * 3600 * 1000); // monthly file, checked daily
|
||||
setInterval(() => tank.sweep().catch(e => console.error('tank sweep', e.message)), 60 * 60 * 1000); // adoptions past their 7-day window
|
||||
burner.init({ chain, ads });
|
||||
setTimeout(() => coach.nudgeTick().catch(e => console.error('coach', e.message)), 90 * 1000);
|
||||
@@ -335,6 +340,8 @@ function siteConfig() {
|
||||
telegramBotToken: '', telegramChatId: '', telegramTopicId: '', telegramEvents: 'payouts', telegramCtaUrl: 'https://instantadpay.com/',
|
||||
telegramAdminChatId: '', // private chat for admin alerts (sign-up guard bursts); falls back to ADMIN_EMAIL
|
||||
launchAt: '', // public launch moment, ISO 8601 with offset (e.g. 2026-09-18T19:00:00-05:00): countdown on /launch + dashboard mark
|
||||
geoTier1: '', // comma-separated ISO country codes; empty = built-in default (US, CA, GB, AU, NZ, IE, DE, FR, NL, SE, NO, DK, FI, CH, AT, BE)
|
||||
geoTier2: '', // empty = built-in default (rest of Western/Central Europe, JP, KR, SG, HK, TW, IL, Gulf, ZA, BR, MX, AR, CL, CO ...); tier 3 = everything else
|
||||
pnlFixedMonthlyUsd: 0
|
||||
}, saved);
|
||||
}
|
||||
@@ -1093,7 +1100,7 @@ const server = http.createServer(async (req, res) => {
|
||||
if (status.views >= status.target || status.claimed) return json(res, 200, { ad: null, status });
|
||||
const type = String(u.searchParams.get('type') || 'banner');
|
||||
// members never see (or earn from) their own campaigns in the viewer
|
||||
const ad = await ads.serve(type === 'text' ? 'text' : 'banner', { excludeEmail: s.email });
|
||||
const ad = await ads.serve(type === 'text' ? 'text' : 'banner', Object.assign({ excludeEmail: s.email }, viewerGeo(req)));
|
||||
if (!ad) return json(res, 200, { ad: null, status });
|
||||
const token = crypto.randomBytes(16).toString('hex');
|
||||
// the viewer tab frames the advertiser's REAL url (no click counted for a paid view)
|
||||
@@ -1382,7 +1389,7 @@ const server = http.createServer(async (req, res) => {
|
||||
}
|
||||
// -- featured rotation: the live featured links + dilution stats
|
||||
if (p === '/api/featured' && req.method === 'GET') {
|
||||
const items = await ads.serveFeatured();
|
||||
const items = await ads.serveFeatured(viewerGeo(req));
|
||||
const names = await accounts.namesForMembers([...new Set(items.map(i => i.memberId).filter(Boolean))]);
|
||||
for (const i of items) i.by = (i.memberId && names[i.memberId]) ? '@' + names[i.memberId] : (i.memberId ? 'member #' + i.memberId : null);
|
||||
return json(res, 200, { items });
|
||||
@@ -1530,7 +1537,7 @@ const server = http.createServer(async (req, res) => {
|
||||
const status = await ads.videoStatus(s.email);
|
||||
if (status.left <= 0) return json(res, 200, { ad: null, status });
|
||||
const orientation = String(u.searchParams.get('orientation') || ''); // 'portrait' = Shorts reel, 'landscape' = Watch videos tab
|
||||
const ad = await ads.serveVideo({ excludeEmail: s.email, orientation }); // never your own video
|
||||
const ad = await ads.serveVideo(Object.assign({ excludeEmail: s.email, orientation }, viewerGeo(req))); // never your own video
|
||||
if (!ad) return json(res, 200, { ad: null, status });
|
||||
const token = crypto.randomBytes(16).toString('hex');
|
||||
videoTokens.set(s.email, { token, ts: Date.now(), id: ad.id, secs: ad.watchSecs });
|
||||
@@ -1562,7 +1569,7 @@ const server = http.createServer(async (req, res) => {
|
||||
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
|
||||
const status = await ads.visitStatus(s.email);
|
||||
if (status.count >= status.cap) return json(res, 200, { ad: null, status });
|
||||
const ad = await ads.serveVisit(s.email);
|
||||
const ad = await ads.serveVisit(s.email, viewerGeo(req));
|
||||
if (!ad) return json(res, 200, { ad: null, status });
|
||||
const token = crypto.randomBytes(16).toString('hex');
|
||||
visitTokens.set(s.email, { token, ts: Date.now(), id: ad.id });
|
||||
@@ -1601,7 +1608,7 @@ const server = http.createServer(async (req, res) => {
|
||||
if (p === '/api/my/inbox' && req.method === 'GET') {
|
||||
const s = await auth.fromRequest(req);
|
||||
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
|
||||
const r = await ads.inboxList(s.email);
|
||||
const r = await ads.inboxList(s.email, viewerGeo(req));
|
||||
const names = await accounts.namesForMembers([...new Set(r.items.map(i => i.fromMemberId).filter(Boolean))]);
|
||||
for (const i of r.items) i.fromName = (i.fromMemberId && names[i.fromMemberId]) ? '@' + names[i.fromMemberId]
|
||||
: i.fromMemberId ? 'member #' + i.fromMemberId : 'a member';
|
||||
@@ -1656,7 +1663,7 @@ const server = http.createServer(async (req, res) => {
|
||||
// -- ad engine (spec §8b v1: banners, text, login ads)
|
||||
if (p === '/api/ads/slot' && req.method === 'GET') {
|
||||
const t = String(u.searchParams.get('type') || 'banner');
|
||||
const ad = await ads.serve(t, { width: Number(u.searchParams.get('w')) || 0, height: Number(u.searchParams.get('h')) || 0 });
|
||||
const ad = await ads.serve(t, Object.assign({ width: Number(u.searchParams.get('w')) || 0, height: Number(u.searchParams.get('h')) || 0 }, viewerGeo(req)));
|
||||
// login ads: the member clicks "Open Ad" (a real, counted click into a
|
||||
// new tab) while the countdown runs on our interstitial page
|
||||
if (ad && t === 'login') ad.dwell = ads.rates().loginDwellSeconds || 10;
|
||||
@@ -1677,6 +1684,8 @@ const server = http.createServer(async (req, res) => {
|
||||
const out = { campaigns: await ads.listCampaigns(s.email), rates: ads.rates(), bannerSizes: ads.bannerSizes() };
|
||||
out.clickSources = await coach.clickSources(out.campaigns.map(c => c.id));
|
||||
out.hours = await ads.hoursFor(out.campaigns.map(c => c.id)); // on-site views per UTC hour, last 7 days
|
||||
out.geo = await ads.geoFor(out.campaigns.map(c => c.id)); // on-site serves per viewer country
|
||||
{ const t = geo.tierLists(siteConfig()); out.tiers = { t1: [...t.t1], t2: [...t.t2] }; out.geoReady = geo.status().loaded; }
|
||||
const pool = await ads.balances((await myMemberIds(s)).ids, s.email);
|
||||
out.purchasedCredits = pool.total;
|
||||
out.largestPosition = pool.best.avail; // a single campaign budget has to fit one position
|
||||
|
||||
Reference in New Issue
Block a user