a84b851272
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>
188 lines
9.1 KiB
JavaScript
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 };
|