Member campaigns now go out as click offers on DripOffers
Third syndication rail. The first two send impressions; this one sends clicks — a real person picks the offer off the offerwall, goes to the member's link, and has to stay the dwell time before anyone is paid. Two things it does that the other rails can't: - It carries geo-targeted campaigns. DripOffers filters on an explicit country list, so a campaign aimed at Tier 1 finally reaches an outside audience instead of staying on our own site. Tier-3-only campaigns are skipped rather than quietly sent worldwide, because an inclusion list can't express "everywhere except the other tiers" and widening it would deliver exactly the traffic the owner chose to exclude. - Delivery is counted, not derived. It reads rows from the platform's permanent click ledger. It never computes delivery as ordered minus remaining, and pausing never zeroes remaining — that pair is what charged members for impressions that never ran on Network Ad Space. Also fixes two live bugs found while wiring it: the AdRevLinks pause was nested inside the Network Ad Space check in both the end sweep and setStatus, so with NAS switched off an ended or paused campaign kept running on AdRevLinks. Each rail is now checked on its own. Caps: 1,000 credits minimum, 10,000 clicks maximum per campaign. Not for cost — Marty owns the platform and these placements are free — but because the whole site delivers around 2,700 clicks a day, and one campaign booking 50,000 would sit in the list for weeks. Inert unless DRIPOFFERS_BRIDGE_URL and _KEY are set. 26 checks green against the live endpoint; member walk clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+161
@@ -0,0 +1,161 @@
|
||||
// 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:<id>" 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): the offerwall is a menu, not a rotator - every eligible campaign
|
||||
// is listed at once, ordered by reward, and the member 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, running around 2,700 a day across the whole platform. Hence MAX_CLICKS
|
||||
// below: one campaign booking 50,000 clicks would sit in the list for weeks. 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 || 10000);
|
||||
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(',');
|
||||
}
|
||||
|
||||
// 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 = String(campaign.name || campaign.title || 'InstantAdPay').slice(0, 250);
|
||||
// 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,
|
||||
clicksFor, countriesFor, kindOk, refFor,
|
||||
MIN_CREDITS, MAX_CLICKS, MIN_CLICKS, PACK_ID, KINDS };
|
||||
Reference in New Issue
Block a user