// Syndicate member campaigns to DripOffers as paid-per-click offers. // // Third syndication rail, and the one that behaves least like the other two. Network Ad Space // and AdRevLinks both serve impressions into a rotation. DripOffers serves *clicks*: a real // person picks the offer off an offerwall, visits the target URL, and has to stay the pack's // dwell time before anybody gets paid. So a DripOffers click is worth far more than an // impression, and there are far fewer of them. // // The DripOffers database is not reachable from this server, so we talk to a narrow // authenticated endpoint that runs there (public_html/iap-bridge.php) and does the insert // locally. Six actions, no general query surface. // // FEATURE-FLAGGED: inert unless DRIPOFFERS_BRIDGE_URL and DRIPOFFERS_BRIDGE_KEY are set. // // Three things carried over deliberately from the NAS and AdRevLinks work: // // 1. DELIVERY IS READ, NEVER DERIVED. `status` counts rows in that platform's permanent // per-click ledger. We never compute delivery from "ordered minus remaining", because // remaining is mutable and deriving from it is what charged members for impressions that // never ran on NAS. // // 2. EVERY WRITE IS IDEMPOTENT. Each campaign carries ref "iap:" behind a UNIQUE index // on the far side, so a retry after a timeout cannot book a second campaign. // // 3. A SYNDICATION HICCUP NEVER BLOCKS THE MEMBER. Callers swallow errors; the IAP campaign // goes live either way. // // Capacity note (2026-09-18, corrected same day): the offerwall is a menu, not a rotator - // every eligible campaign is listed at once, ordered by reward, and the earner chooses. So // adding campaigns does not starve the others the way the AdRevLinks rotator does; it // lengthens the menu. // // What is finite is total clicks, and the real figure is SMALL: the permanent click ledger // shows roughly 40-50 clicks A DAY across the whole platform. (An earlier read of 2,700/day // was wrong - it came from campaigns.received_today, which is only a daily figure while the // nightly cron resets it, and that reset has stopped running.) Pack 2 puts our offers above // the house campaigns, and they are indeed taking the large majority of that trickle. // // Hence a deliberately small MAX_CLICKS: at this volume a 10,000-click booking would sit at // the top of the list for most of a year and crowd everything else out. Cost is not the reason // for the caps - Marty owns the platform and treats these placements as free. 'use strict'; const https = require('https'); const { URL } = require('url'); const MIN_CREDITS = Number(process.env.DRIPOFFERS_MIN_CREDITS || 1000); // below this, not worth a listing const MAX_CLICKS = Number(process.env.DRIPOFFERS_MAX_CLICKS || 2500); const MIN_CLICKS = 500; // the platform's own floor const PACK_ID = Number(process.env.DRIPOFFERS_PACK_ID || 2); // 2 = a 10-second dwell const USER_ID = Number(process.env.DRIPOFFERS_USER_ID || 1); // the funding account // Which IAP formats make sense as a click offer: the ones whose whole point is sending a // person to the target URL. Video and solo are a different product, and featured is a slot // buy on our own site rather than a destination. const KINDS = ['banner', 'text', 'visits']; function enabled() { return !!(process.env.DRIPOFFERS_BRIDGE_URL && process.env.DRIPOFFERS_BRIDGE_KEY); } function call(action, payload, timeoutMs) { return new Promise((resolve, reject) => { if (!enabled()) return reject(new Error('dripoffers-disabled')); const u = new URL(process.env.DRIPOFFERS_BRIDGE_URL); const body = JSON.stringify(Object.assign({ action }, payload || {})); const req = https.request({ hostname: u.hostname, path: u.pathname + u.search, method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body), 'X-Bridge-Key': process.env.DRIPOFFERS_BRIDGE_KEY }, timeout: timeoutMs || 25000 }, res => { let d = ''; res.on('data', c => d += c); res.on('end', () => { let j = null; try { j = JSON.parse(d); } catch (e) {} if (!j) return reject(new Error('dripoffers ' + res.statusCode + ': ' + d.slice(0, 120))); if (j.error) return reject(new Error(j.error)); resolve(j); }); }); req.on('error', reject); req.on('timeout', () => req.destroy(new Error('dripoffers timeout'))); req.end(body); }); } const refFor = campaignId => 'iap:' + Number(campaignId); const kindOk = type => KINDS.includes(String(type || '')); // How many clicks a campaign's remaining credits are worth, capped so one campaign cannot // park itself at the top of the offerwall for a month. function clicksFor(credits) { const v = Math.max(0, Math.floor(Number(credits) || 0)); return Math.min(v, MAX_CLICKS); } // Country targeting. Unlike the AdRevLinks popup rail - which only has prices for five // countries and so cannot carry a targeted campaign at all - this platform filters on an // explicit country list, so geo-restricted campaigns CAN syndicate here. That is the point of // including geo support: those campaigns currently reach no external rail at all. // // Tier 3 is the exception. It is defined as "everywhere the other two tiers are not", which an // inclusion list cannot express, so a tier-3-only campaign is skipped rather than sent // worldwide - sending it worldwide would quietly deliver the Tier 1 traffic its owner chose // not to target. function countriesFor(geo, tiers) { const g = String(geo || '').trim(); if (!g) return '*'; const want = g.split(',').map(s => s.trim()).filter(Boolean); if (want.length === 3) return '*'; const out = []; if (want.includes('1')) out.push(...(tiers && tiers.t1 ? tiers.t1 : [])); if (want.includes('2')) out.push(...(tiers && tiers.t2 ? tiers.t2 : [])); if (!out.length) return null; // tier 3 only: not expressible, skip return [...new Set(out.map(c => String(c).toUpperCase()))].join(','); } // What a click-earner sees in the offerwall list. A campaign's `name` is the member's own // internal label and is often too short to mean anything to a stranger ("MG"), so the ad's // headline comes first, then the label, and a very short label is qualified with the // destination host rather than dropped or dressed up in copy we invented for them. The // platform's own floor is five characters. function titleFor(campaign, url) { const clean = v => String(v || '').replace(/\s+/g, ' ').trim(); const head = clean(campaign.title), label = clean(campaign.name); let host = ''; try { host = new URL(url).hostname.replace(/^www\./, ''); } catch (e) {} const pick = head.length >= 5 ? head : label.length >= 5 ? label : label && host ? label + ' (' + host + ')' : host; return pick.length >= 5 ? pick.slice(0, 250) : null; } // Push a campaign. Returns null when it is not worth syndicating (or cannot be targeted // faithfully) rather than throwing, so the caller treats "too small", "wrong format" and // "disabled" the same quiet way. async function push(campaign, opts) { if (!enabled()) return null; if (!kindOk(campaign.type)) return null; const credits = Number((campaign.budget || 0) - (campaign.spent || 0) - (campaign.accrued || 0)); if (credits < MIN_CREDITS) return null; const clicks = clicksFor(credits); if (clicks < MIN_CLICKS) return null; const url = String(campaign.targetUrl || campaign.target_url || '').trim(); if (!/^https?:\/\//i.test(url)) return null; const countries = countriesFor(campaign.geo, (opts && opts.tiers) || null); if (countries === null) return null; const title = titleFor(campaign, url); if (!title) return null; // The offerwall shows this line under the title, so it has to read like an offer rather // than like an internal campaign record. const description = String(campaign.title || campaign.body || campaign.name || title).slice(0, 250); return await call('create_ptc', { title, description, url, clicks, countries, device: 0, pack_id: (opts && opts.packId) || PACK_ID, user_id: USER_ID, ref: refFor(campaign.id), activate: !(opts && opts.paused) }); } // Real delivery, counted from the platform's own click ledger. Never derived. async function readServed(campaignId) { if (!enabled()) return null; try { return await call('status', { ref: refFor(campaignId) }); } catch (e) { if (/not found/i.test(e.message)) return null; throw e; } } async function pause(campaignId) { return enabled() ? call('pause', { ref: refFor(campaignId) }) : null; } async function resume(campaignId) { return enabled() ? call('resume', { ref: refFor(campaignId) }) : null; } async function remove(campaignId) { if (!enabled()) return null; try { return await call('delete', { ref: refFor(campaignId) }); } catch (e) { if (/not found/i.test(e.message)) return null; throw e; } } async function packs() { return enabled() ? call('packs', {}) : null; } module.exports = { enabled, push, readServed, pause, resume, remove, packs, titleFor, clicksFor, countriesFor, kindOk, refFor, MIN_CREDITS, MAX_CLICKS, MIN_CLICKS, PACK_ID, KINDS };