71ab9555e2
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>
180 lines
8.6 KiB
JavaScript
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 };
|