Files
instantadpay/dripoffers.js
T
martbost a84b851272 Correct the DripOffers capacity figure and tighten the cap
I read campaigns.received_today as a per-day counter and told Marty the
platform delivers about 2,700 clicks a day. It doesn't. That column is
only a daily figure while the nightly cron resets it, and that reset has
stopped running, so the value is an accumulation since it last ran.

The permanent click ledger is unambiguous: roughly 40 to 50 clicks a day
across the whole platform. It also agrees exactly with ordered-minus-
remaining on every campaign, which is a good independent check that the
read-never-derive design is reading the right thing.

So MAX_CLICKS drops from 10,000 to 2,500. At the real volume a 10,000-click
booking would hold the top of the offerwall for most of a year.

The pack-2 placement is working as intended and is already measurable: the
three backfilled campaigns took 14 of the platform's 17 clicks today.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 07:04:20 -05:00

188 lines
9.1 KiB
JavaScript

// 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, 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 };