《移动游戏产品的架构·商业化与发行工程》8.2 广告作弊与点击农场

广告作弊抬高的是展示与点击,扣掉的却是发行商结算。本节以《潮汐消消》激励视频 eCPM ¥45、作弊点击占比 1.5%、填充率 92% 为背景,拆解点击农场、秒点、设备聚集与验证缺口四类信号,用可解释评分区分正常、待核与无效流量,并说明可结收入与追回口径。

广告作弊偷走的是结算,不只是预算

广告变现的账,和买量的账方向相反。

买量时,发行商是买方:CPI 越高,获客越贵;归因作弊会让预算买到假用户,损失体现在回收周期。广告变现时,发行商是卖方:展示和点击越多,平台应付的钱越多;无效流量会让平台或广告主拒付、扣量,损失体现在结算单上。

这两件事经常被混为一谈。买量归因作弊的受害者是发行商的 UA 预算,广告变现作弊的受害者是发行商的广告收入;前者的证据在归因回传和点击链路,后者的证据在展示、点击、转化和服务端验证。用同一套「设备黑了就封」的规则处理两边,既不准确,也会误伤正常玩家。

本节只讨论变现侧的无效流量(invalid traffic,IVT)。买量归因的边界在第 7 章已经处理过,这里不重复。

四类伪装,共用一套基线

点击农场不是一种行为,而是一组为了绕过不同检测而演化出来的模式。工程上不必给每种模式起名字,只需要找到能被指标捕捉的共同特征:

模式可观察信号为什么会出现误判风险
高频点击CTR 远高于同广告位基线脚本批量点击,或激励诱导新素材、强引导活动可能短期拉高 CTR
只点不转化点击后转化率接近零点击由机器产生,不会完成后续动作高客单价商品本来转化就低
秒点点击距展示的中位时间极短脚本在渲染完成前就点击误触、预加载或客户端埋点顺序错误
设备聚集单设备展示量远超正常一台设备模拟多个用户,或设备 ID 被复用共用设备、模拟器测试、家庭账号
验证缺口客户端上报点击,服务端没有完成回执客户端伪造点击,或回执链路丢失网络抖动、回调延迟、SDK 版本差异

单独看任何一个信号都不够。CTR 高可能是素材好,转化低可能是商品贵,秒点可能是埋点顺序错,设备聚集可能是网吧或测试机。可靠的判定要至少两个独立信号同时成立,并且能排除链路故障。

这也是为什么「无效流量」通常分成三档,而不是二值判断:

  • 正常:指标在基线范围内,按全量结算。
  • 待核:有异常信号但不足以定性,先挂起一部分结算,等待人工或渠道复核。
  • 无效:多个独立信号同时成立,或服务端验证明确缺失,按无效处理并追回。

用事实表建立可解释的基线

没有基线,阈值只能拍脑袋。案例事实表提供了几个关键参照:

指标消消方舟
广告位激励视频、插屏、Banner激励视频(少量)
人均激励次数/日3.5 次0.15 次
激励视频 eCPM¥45¥80
广告填充率92%88%
激励完成率88%93%
广告作弊点击占比1.5%0.6%

这组数字说明两件事。

第一,消消的激励频次高,样本密度大,按天甚至按小时做异常检测都有足够数据;方舟的人均激励次数只有 0.15 次,很多玩家几天才看一次,短窗口内的比率波动极大,必须拉长统计周期,或者按渠道、地区聚合后再判断。

第二,消消的作弊点击占比 1.5%,方舟 0.6%,但这不代表消消的风控更差。休闲游戏的激励视频频次高、单次价值低,黑产更容易用脚本批量刷;中重度游戏的广告位少、用户规模小,攻击收益低,作弊占比自然更低。把 1.5% 当成行业标准,或者要求方舟也降到 0.6% 以下,都是用结果代替原因。

基线要按「广告位 × 地区 × 渠道」分层。把激励视频和 Banner 混在一起算 CTR,会把展示形态差异当成作弊;把国内安卓和 iOS 混在一起算 eCPM,会把渠道价格差异当成异常。分层之后,每层的样本变少,需要设置最小展示量,低于阈值的层只做观察,不自动判定。

归因作弊与点击农场不是一回事

买量侧的归因作弊,典型手法是点击注入、最后点击劫持、SDK 伪造安装。它影响的是 CPI 和 ROI:案例里消消 CPI ¥3.5、方舟 CPI ¥28,归因窗口都是点击 7 天、浏览 1 天。一个假安装可能让发行商多付几十元,但不会直接产生广告结算收入。

变现侧的点击农场,影响的是 eCPM、填充率和结算单。它不关心安装,只关心展示和点击能不能换成钱。两者的设备信号可能重叠,但证据链不同:

维度归因作弊广告点击农场
受害方发行商 UA 预算发行商广告结算
核心证据点击时间、归因回传、安装来源展示、点击、转化、服务端回执
结算影响多付 CPI被扣量或拒付
处置动作拒绝归因、调整渠道标记无效、挂起结算、追回

所以,不能用「这个设备在归因里异常」直接推出「这个玩家在广告里作弊」。反过来也一样。两套规则应该共享设备黑名单,但判定逻辑和处置动作必须分开。

毛收入、可结收入与追回

广告结算的基本公式不复杂:

毛收入 = 展示量 / 1000 × eCPM
可结收入 = 毛收入 × 可结比例
追回金额 = 毛收入 - 可结收入

复杂的是「可结比例」怎么定。本节示例用三档:

  • 正常流量:可结比例 100%。
  • 待核流量:可结比例 50%,先挂起一半,复核后补结或追回。
  • 无效流量:可结比例 0%,全额追回。

为什么待核要保留 50%,而不是全部扣掉?因为广告链路的异常不一定来自作弊。SDK 版本升级、回执延迟、埋点顺序错误都会造成「点击有、验证无」。一刀切扣掉,会把链路故障变成渠道纠纷;保留一半,既控制损失,也给复核留出空间。

这个比例不是会计核算,而是内部风控口径。真实的结算以广告平台或渠道的账单为准,发行商自己的评分用于三件事:决定哪些流量要人工看、哪些渠道要发函核对、哪些 SDK 版本要回滚。把内部评分直接当成对外扣款依据,通常会引发更大的商务冲突。

量级上可以这样估算:消消 80 万 DAU、人均 3.5 次激励视频、30 天,约 8,400 万次激励展示;按 1.5% 的可疑占比粗算,约 126 万次;按 eCPM ¥45 计算,对应约 ¥5.67 万。这不是财务报表,而是告诉风控团队「这件事值不值得投入一个人」的量级判断。方舟的激励频次低,同样的算法得到的金额小得多,优先级应该让给支付风控。

把判定写成纯函数

下面的实现把「流量样本」和「判定策略」分开。样本只描述事实,策略提供基线、阈值和分数区间;函数返回判定、可结金额和追回金额,不修改输入,也不调用外部接口。

// file: src/biz/ad-fraud.ts
// tier: L1
// verify: node --test test/biz/ad-fraud.test.ts

export type TrafficVerdict = "valid" | "suspect" | "invalid";
export type AdPlacement = "rewarded" | "interstitial" | "banner";

export interface TrafficSample {
  readonly placement: AdPlacement;
  readonly impressions: number;
  readonly clicks: number;
  readonly conversions: number;
  readonly verifiedCompletions: number;
  readonly uniqueDevices: number;
  readonly p50TimeToClickMs: number;
  readonly eCpmCents: number;
}

export interface TrafficPolicy {
  readonly expectedCtr: number;
  readonly expectedCvr: number;
  readonly minTimeToClickMs: number;
  readonly maxImpressionsPerDevice: number;
  readonly maxVerificationGap: number;
  readonly suspectScore: number;
  readonly invalidScore: number;
}

export interface TrafficSignal {
  readonly code: string;
  readonly points: number;
  readonly detail: string;
}

export interface TrafficAssessment {
  readonly placement: AdPlacement;
  readonly ctr: number;
  readonly cvr: number;
  readonly impressionsPerDevice: number;
  readonly grossCents: number;
  readonly billableCents: number;
  readonly clawbackCents: number;
  readonly score: number;
  readonly verdict: TrafficVerdict;
  readonly signals: readonly TrafficSignal[];
}

export interface RevenueAtRisk {
  readonly invalidImpressions: number;
  readonly atRiskCents: number;
}

const SUSPECT_BILLABLE_RATIO = 0.5;

function assertNonNegative(name: string, value: number): void {
  if (!Number.isFinite(value) || value < 0) {
    throw new RangeError(`${name} must be a finite non-negative number, got ${value}`);
  }
}

function validate(sample: TrafficSample): void {
  assertNonNegative("impressions", sample.impressions);
  assertNonNegative("clicks", sample.clicks);
  assertNonNegative("conversions", sample.conversions);
  assertNonNegative("verifiedCompletions", sample.verifiedCompletions);
  assertNonNegative("uniqueDevices", sample.uniqueDevices);
  assertNonNegative("p50TimeToClickMs", sample.p50TimeToClickMs);
  assertNonNegative("eCpmCents", sample.eCpmCents);
  if (sample.clicks > sample.impressions) {
    throw new RangeError("clicks cannot exceed impressions");
  }
  if (sample.conversions > sample.clicks) {
    throw new RangeError("conversions cannot exceed clicks");
  }
  if (sample.verifiedCompletions > sample.clicks) {
    throw new RangeError("verified completions cannot exceed clicks");
  }
}

export function estimateRevenueAtRisk(
  impressions: number,
  eCpmCents: number,
  fraudShare: number,
): RevenueAtRisk {
  assertNonNegative("impressions", impressions);
  assertNonNegative("eCpmCents", eCpmCents);
  if (fraudShare < 0 || fraudShare > 1) {
    throw new RangeError(`fraudShare must be between 0 and 1, got ${fraudShare}`);
  }
  const invalidImpressions = Math.round(impressions * fraudShare);
  return {
    invalidImpressions,
    atRiskCents: Math.round((invalidImpressions / 1000) * eCpmCents),
  };
}

export function assessTraffic(sample: TrafficSample, policy: TrafficPolicy): TrafficAssessment {
  validate(sample);
  const ctr = sample.impressions === 0 ? 0 : sample.clicks / sample.impressions;
  const cvr = sample.clicks === 0 ? 0 : sample.conversions / sample.clicks;
  const impressionsPerDevice =
    sample.uniqueDevices === 0 ? 0 : sample.impressions / sample.uniqueDevices;
  const grossCents = (sample.impressions / 1000) * sample.eCpmCents;
  const signals: TrafficSignal[] = [];

  if (sample.clicks > 0 && ctr > policy.expectedCtr * 3) {
    signals.push({
      code: "ctr-spike",
      points: 25,
      detail: `CTR ${(ctr * 100).toFixed(2)}% is more than three times expected ${(policy.expectedCtr * 100).toFixed(2)}%`,
    });
  }
  if (sample.clicks >= 100 && cvr < policy.expectedCvr / 4) {
    signals.push({
      code: "conversion-collapse",
      points: 20,
      detail: `CVR ${(cvr * 100).toFixed(3)}% is far below expected ${(policy.expectedCvr * 100).toFixed(2)}%`,
    });
  }
  if (sample.clicks > 0 && sample.p50TimeToClickMs < policy.minTimeToClickMs) {
    signals.push({
      code: "instant-click",
      points: 20,
      detail: `median time to click ${sample.p50TimeToClickMs}ms is below ${policy.minTimeToClickMs}ms`,
    });
  }
  if (sample.uniqueDevices > 0 && impressionsPerDevice > policy.maxImpressionsPerDevice) {
    signals.push({
      code: "device-concentration",
      points: 20,
      detail: `${impressionsPerDevice.toFixed(1)} impressions per device exceeds ${policy.maxImpressionsPerDevice}`,
    });
  }
  if (sample.clicks > 0) {
    const gap = (sample.clicks - sample.verifiedCompletions) / sample.clicks;
    if (gap > policy.maxVerificationGap) {
      signals.push({
        code: "verification-gap",
        points: 30,
        detail: `${(gap * 100).toFixed(1)}% of clicks lack a server-side completion`,
      });
    }
  }

  const score = Math.min(100, signals.reduce((sum, signal) => sum + signal.points, 0));
  const verdict: TrafficVerdict =
    score >= policy.invalidScore ? "invalid" : score >= policy.suspectScore ? "suspect" : "valid";
  const payableRatio = verdict === "valid" ? 1 : verdict === "suspect" ? SUSPECT_BILLABLE_RATIO : 0;
  const billableCents = grossCents * payableRatio;
  return {
    placement: sample.placement,
    ctr,
    cvr,
    impressionsPerDevice,
    grossCents,
    billableCents,
    clawbackCents: grossCents - billableCents,
    score,
    verdict,
    signals,
  };
}

export function formatTrafficAssessment(assessment: TrafficAssessment): string {
  const codes = assessment.signals.map((signal) => signal.code).join(",") || "none";
  return `${assessment.placement} ${assessment.verdict} score=${assessment.score} gross=${Math.round(assessment.grossCents)} billable=${Math.round(assessment.billableCents)} signals=${codes}`;
}

代码里有几个刻意的边界。

第一,所有计数必须先通过合法性校验。点击不能多于展示,转化不能多于点击,服务端完成数不能多于点击;这些约束不满足时抛 RangeError,而不是返回一个「看起来正常」的结果。脏数据进入评分,比没有评分更危险。

第二,CTR 和 CVR 都做除零保护。展示为零时 CTR 记为 0,点击为零时 CVR 记为 0;这两种情况在低流量广告位很常见,不应该让整个批次失败。

第三,验证缺口用「点击减去服务端完成数」计算,而不是用客户端上报的完成率。客户端可以伪造完成回执,服务端回执才是结算依据。

第四,分数只用于分档,不直接决定扣款。invalidScore 和 suspectScore 是策略参数,消消和方舟可以不同;SUSPECT_BILLABLE_RATIO 固定在代码里,是因为它属于结算口径,不应该被每个广告位随意覆盖。

第五,estimateRevenueAtRisk 是独立的量级估算函数,不参与单条流量的判定。它接收展示量、eCPM 和可疑占比,返回可疑展示数与金额;适合做月度复盘,不适合做实时扣量。

阈值从哪里来

阈值最忌讳两种情况:拍一个整数,或者从别家产品抄一组。可用的做法是:

  1. 先分层。按广告位、地区、渠道、SDK 版本分组,每组单独算基线。
  2. 再看分布。取 30 天的 CTR、CVR、点击时延、单设备展示量,看中位数和分位数,不要只看平均值。
  3. 留出样本量。低于最小展示量的组只观察,不自动判定;否则一个新渠道上线第一天就可能被误判。
  4. 回测。用历史数据跑一遍,统计每个阈值下的误判率和漏判率,再决定分数区间。
  5. 定期复核。素材、渠道价格、用户结构变化都会让基线漂移;阈值应该按月或按版本复核,而不是一次写死。

案例里的「3 倍基线」「四分之一基线」只是示例参数,不是行业常数。消消的激励视频频次高,3 倍 CTR 可能很快触发;方舟的样本少,可能需要 5 倍才够显著。参数必须跟着样本量走。

取舍:误判一个渠道,可能失去整个填充

广告风控的取舍比支付风控更偏商务。支付风控误杀一个玩家,损失是一个人的客诉;广告风控误判一个渠道,可能影响填充率、结算关系和后续合作。

消消的填充率是 92%,方舟是 88%。填充率已经不高的情况下,如果把「待核」直接当「无效」扣量,渠道可能减少对该广告位的竞价,填充率进一步下降,形成负循环。反过来,如果完全不扣,作弊流量会拉低整体 eCPM,广告主逐步降低出价,损失更慢但更确定。

案例里的策略是「先挂起、后定性」:

  • 单信号异常,进入待核,保留 50% 结算,不通知渠道。
  • 两个以上独立信号,或服务端验证缺口超过阈值,才进入无效。
  • 无效判定必须能导出样本,供渠道复核;不能只给一个分数。
  • 每月统计一次追回金额和误判申诉,误判率高的规则先降级,不直接删除。

代价是结算流程变复杂:需要挂起账、复核队列、渠道沟通和补结机制。收益是关系可修复。广告变现是重复博弈,一次误判的商务成本,往往高于那批流量的账面金额。

小结

  • 广告作弊影响的是发行商的结算,不是买量预算;归因作弊和点击农场要分开处理。
  • 高频点击、只点不转化、秒点、设备聚集、验证缺口是五类可观察信号,单信号不足以定性。
  • 基线要按广告位、地区、渠道分层;消消的 1.5% 和方舟的 0.6% 是结果,不是统一目标。
  • 评分输出正常、待核、无效三档,对应 100%、50%、0% 的可结比例,追回金额按差额计算。
  • 阈值要回测、要留样本量、要定期复核;误判渠道的商务成本可能高于账面损失。

阅读导航:上一节:8.1 支付作弊与退款滥用 · 下一节:8.3 风控规则引擎与人工复核 。

常见错误与边界

  1. 把买量归因作弊和广告点击农场当成一件事。两者的受害方、证据链和处置动作都不同,混用规则会既抓不准也误伤。
  2. 只看 CTR 就判定作弊。新素材、强引导活动都能短期拉高 CTR,至少要结合转化率、点击时延或验证缺口。
  3. 用客户端完成回执计算验证率。客户端回执可伪造,服务端回执才是结算依据;两者对不上才是信号。
  4. 把 1.5% 当成全产品统一目标。消消和方舟的广告位、频次、用户规模不同,作弊占比不能直接横比。
  5. 展示为零时让 CTR 计算抛异常。低流量广告位经常没有展示,除零保护缺失会让整批数据失败。
  6. 点击多于展示仍然继续评分。脏数据会污染基线,必须先校验计数关系,再进入判定。
  7. 待核流量直接按无效扣款。链路故障和 SDK 升级也会造成验证缺口,一刀切会把技术问题变成商务纠纷。
  8. 内部评分直接当作对外扣款依据。真实结算以平台账单为准,内部评分用于排序、复核和渠道沟通。
  9. 阈值一次写死、从不回测。素材、价格和用户结构会漂移,旧阈值会在几个月后变成系统性误判。
  10. 只追回金额、不保留样本。渠道复核需要可解释的证据,只有一个分数无法申诉,也无法定位规则错误。
  11. 把无效流量判定当成封号依据。广告异常不等于账号作弊,直接封号会误伤正常玩家,也会扩大客诉。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《移动游戏产品的架构·商业化与发行工程》附录 C 商业化与发行评审清单
  2. 《移动游戏产品的架构·商业化与发行工程》附录 B 双案例商业化事实表
  3. 《移动游戏产品的架构·商业化与发行工程》附录 A 商业化与发行术语速查