Files
instantadpay/dripoffers.js
T
martbost 71ab9555e2 Give a syndicated offer a title a stranger can read
A campaign's name is the member's own internal label, and some of them are
two characters ("MG"). DripOffers rejects anything under five, and more to
the point a click-earner scanning the offerwall learns nothing from "MG".

So the ad's own headline comes first, then the label, and a very short
label is qualified with the destination host — "MG (mailer.gold)" — rather
than dropped or dressed up in marketing copy we invented on the member's
behalf. Nothing usable at all returns null instead of a made-up title.

Found by the backfill: campaign 88 failed on it.

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

180 lines
8.6 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): 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(',');
}
// 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 };