6.2 平台推荐与流量获取

把「平台推荐 / 搜索 / 裂变」与「买量」放进同一张漏斗口径做对照:给出可跑的 Python 对照脚本、同口径的 TypeScript 内核与单文件 HTML 原型,算清不同渠道组合下的混合 CAC 与自然量占比,并把平台推荐算法一律标注为不可复现的 X。

站内已有一篇H5 游戏产业全景 ,那篇是产业综述口径,把 H5 / 小游戏赛道的平台与流量格局横着讲了一遍;本节给的是可跑的渠道对照脚本:把「平台推荐 / 搜索 / 裂变」与「买量」放进同一张漏斗口径,算出不同组合下的混合 CAC 与自然量占比。区别在写法:那篇描述流量生态,本节把它变成一张能算的对照表——但所有真实平台数字都标成 X,因为平台推荐算法不可复现。

平台流量:只能观察,不能复现

上一节的自然增长模型里,刻意没放「平台自然推荐」这一项。原因是它和裂变、口碑的性质不同:裂变由产品的传播系数决定,口碑由产品质量决定,而平台推荐由平台的算法决定。算法什么时候把你放进推荐位、给多少曝光、哪些用户会看到,这套机制我们只能从后台数据里观察,无法在命令行里复现——这正是 BRIEF 里说的 X 层。

下面这张表是主流小游戏平台公开可观察的流量入口(观察时点:2026 年,仅作机制说明,非任何产品的后台实测):

平台主要免费流量入口可控性
微信小游戏游戏圈、好友分享卡片、平台推荐位分享可控 / 推荐不可控
抖音小游戏短视频挂载、直播挂载、任务中心内容可控 / 推荐不可控
快手小游戏短视频挂载、直播、推荐位内容可控 / 推荐不可控
百度 / 支付宝小游戏搜索直达、平台推荐位搜索词可控 / 推荐不可控

表里每一格的「不可控」都在提醒:你能优化的只有内容与搜索词,推荐量本身不可控。这不是消极,而是提醒做增长预算时,不能把「平台会推荐我」当成一个可以买到的量——它的正确用法是当作一个外部变量:有,则摊薄成本;没有,也不能指望。

D 层:渠道参数表

要把渠道放进对照,先得把每个渠道的漏斗参数写下来。下面这份是 D 层(静态层)产物:每个渠道用「可触达曝光 × 点击率‰ × 激活率‰」算新增,用「单用户成本」算花费。

{
  "version": 1,
  "game": "merge-island",
  "seed": 20261012,
  "note": "平台流量渠道对照:每渠道用「可触达曝光量 × 点击率‰ × 激活率‰」算新增,再按单用户成本算花费。exposure / ctrPermille / activatePermille 全部是假设参数,真实值由平台推荐算法与投放系统决定,需真实业务环境,命令行不可复现(X)。",
  "channels": [
    { "id": "ua", "name": "买量投放", "kind": "paid", "exposure": 400000, "ctrPermille": 30, "activatePermille": 250, "costCentPerUser": 120 },
    { "id": "platform", "name": "平台推荐位", "kind": "organic", "exposure": 260000, "ctrPermille": 24, "activatePermille": 300, "costCentPerUser": 0 },
    { "id": "search", "name": "自然搜索/榜单", "kind": "organic", "exposure": 90000, "ctrPermille": 40, "activatePermille": 320, "costCentPerUser": 0 },
    { "id": "viral", "name": "社交裂变回流", "kind": "organic", "exposure": 150000, "ctrPermille": 22, "activatePermille": 280, "costCentPerUser": 0 }
  ],
  "scenarios": [
    { "id": "paid_only", "name": "纯买量", "channels": ["ua"] },
    { "id": "paid_plus_platform", "name": "买量 + 平台推荐", "channels": ["ua", "platform"] },
    { "id": "full_mix", "name": "买量 + 平台 + 搜索 + 裂变", "channels": ["ua", "platform", "search", "viral"] }
  ]
}

⚠️ 这份配置里的 exposure / ctrPermille / activatePermille 全部是假设参数。真实值由平台算法与投放系统决定,需真实业务环境,命令行不可复现(X)。这里写具体数字,只是为了把对照方法跑通——换成你产品的真实后台数据,脚本直接可用。

S 层:三组渠道对照

下面这段是 S 层对照脚本,读 config/l06_traffic.json,对配置里的三种组合各跑一遍漏斗,算出总新增、混合 CAC 与自然量占比,产出 JSON + CSV + SVG。

#!/usr/bin/env python3
# file: sim/l06_traffic_compare.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/l06_traffic_compare.py --config config/ --out out/
"""平台流量渠道对照:读 config/l06_traffic.json,把「买量」与「平台推荐 / 搜索 / 裂变」放进同一张漏斗口径。
  渠道新增 = 曝光量 × 点击率‰ × 激活率‰ // 1_000_000;渠道花费 = 新增 × 单用户成本
  混合 CAC = 总花费 // 总新增;自然量占比 = (总新增 - 买量新增) × 1000 // 总新增
⚠️ 真实平台的曝光 / 点击率 / 激活率由平台算法决定,需真实业务环境,命令行不可复现(X);
本脚本参数全是假设值,只用来说明对照方法。产出 JSON + CSV + SVG。
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET


def load(d: str) -> dict:
    with open(os.path.join(d, "l06_traffic.json"), encoding="utf-8") as f:
        return json.load(f)


def channel_new(ch: dict) -> int:
    """渠道新增 = 曝光 × 点击率‰ × 激活率‰ // 1_000_000。全整数。"""
    return ch["exposure"] * ch["ctrPermille"] * ch["activatePermille"] // 1_000_000


def run_scenario(by_id: dict, ids: list[str]) -> dict:
    new_total = paid_new = cost_total = 0
    per = []
    for cid in ids:
        ch = by_id[cid]
        n = channel_new(ch)
        new_total += n
        cost_total += n * ch["costCentPerUser"]
        paid_new += n if ch["kind"] == "paid" else 0
        per.append({"id": cid, "name": ch["name"], "kind": ch["kind"], "newUsers": n})
    return {"newUsers": new_total, "costCent": cost_total,
            "blendedCacCent": cost_total // new_total if new_total else 0,
            "organicSharePermille": (new_total - paid_new) * 1000 // new_total if new_total else 0,
            "perChannel": per}


def bar(svg, x, y, w, h, fill):
    ET.SubElement(svg, "rect", {"x": str(x), "y": str(y), "width": str(w), "height": str(h), "rx": "4", "fill": fill})


def label(svg, x, y, s, size=12, fill="#374151"):
    ET.SubElement(svg, "text", {"x": str(x), "y": str(y), "font-size": str(size), "fill": fill}).text = s


def draw_svg(path: str, rows: list[dict]) -> int:
    W, H, PAD, ROW = 720, 70 + 46 * len(rows), 220, 46
    peak = max((r["blendedCacCent"] for r in rows), default=1) or 1
    svg = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W), "height": str(H), "viewBox": f"0 0 {W} {H}"})
    bar(svg, 0, 0, W, H, "#ffffff")
    label(svg, 16, 28, "不同渠道组合下的混合 CAC(分/新增,越短越好)", 15, "#111827")
    for i, r in enumerate(rows):
        y, w = 54 + i * ROW, (W - PAD - 40) * r["blendedCacCent"] // peak
        label(svg, 16, y + 20, r["name"])
        bar(svg, PAD, y + 6, w, 22, "#2563eb")
        label(svg, PAD + w + 8, y + 22, f'{r["blendedCacCent"]} 分 · 新增 {r["newUsers"]} · 自然 {r["organicSharePermille"]}‰', 12, "#6b7280")
    os.makedirs(os.path.dirname(path), exist_ok=True)
    ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)
    return len(ET.tostring(svg))


def write_csv(path: str, header: list, rows: list) -> None:
    with open(path, "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(header)
        for r in rows:
            w.writerow(r)


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    a = ap.parse_args()
    cfg = load(a.config)
    by_id = {c["id"]: c for c in cfg["channels"]}
    rows = [{"id": sc["id"], "name": sc["name"], "channels": sc["channels"], **run_scenario(by_id, sc["channels"])}
            for sc in cfg["scenarios"]]
    os.makedirs(a.out, exist_ok=True)
    svg = draw_svg(os.path.join(a.out, "l06_traffic_compare.svg"), rows)
    write_csv(os.path.join(a.out, "l06_traffic_compare.csv"),
              ["scenarioId", "name", "newUsers", "costCent", "blendedCacCent", "organicSharePermille"],
              [[r["id"], r["name"], r["newUsers"], r["costCent"], r["blendedCacCent"], r["organicSharePermille"]] for r in rows])
    write_csv(os.path.join(a.out, "l06_traffic_channels.csv"),
              ["id", "name", "kind", "exposure", "ctrPermille", "activatePermille", "costCentPerUser", "newUsers"],
              [[ch["id"], ch["name"], ch["kind"], ch["exposure"], ch["ctrPermille"], ch["activatePermille"], ch["costCentPerUser"], channel_new(ch)] for ch in cfg["channels"]])
    rep = {"version": cfg["version"], "game": cfg["game"], "seed": cfg["seed"], "channelCount": len(cfg["channels"]),
           "channels": [{"id": ch["id"], "name": ch["name"], "kind": ch["kind"], "newUsers": channel_new(ch)} for ch in cfg["channels"]],
           "scenarios": rows, "csv": os.path.join(a.out, "l06_traffic_compare.csv"),
           "svg": os.path.join(a.out, "l06_traffic_compare.svg"), "svgBytes": svg}
    with open(os.path.join(a.out, "l06_traffic_compare.json"), "w", encoding="utf-8") as f:
        json.dump(rep, f, ensure_ascii=False, indent=2)
    print(json.dumps({"channels": [(c["id"], c["newUsers"]) for c in rep["channels"]],
                      "scenarios": [(r["id"], r["newUsers"], r["blendedCacCent"], r["organicSharePermille"]) for r in rows]}, ensure_ascii=False))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

脚本的核心是一行整数运算:新增 = 曝光 × 点击率‰ × 激活率‰ // 1_000_000(两个千分比相乘正好是百万分之一)。然后 混合 CAC = 总花费 // 总新增——自然渠道花费为 0,所以自然量越多、分母越大,CAC 越低。在本机跑它(假设参数,非实测),输出如下(同机相对量级,绝对数值不可移植):

{"channels": [["ua", 3000], ["platform", 1872], ["search", 1152], ["viral", 924]], "scenarios": [["paid_only", 3000, 120, 0], ["paid_plus_platform", 4872, 73, 384], ["full_mix", 6948, 51, 568]]}

三组对照把「平台流量值多少」摊开了:纯买量 3000 新增、混合 CAC 120 分;买量 + 平台推荐 4872 新增、CAC 降到 73 分;全渠道 6948 新增、CAC 降到 51 分、自然量占比 56.8%。平台推荐 + 搜索 + 裂变三项,把买量的有效成本摊薄了一半以上——但这三项里只有裂变是产品自己能放大的,平台推荐和搜索只能优化、不能保证。

W 层:把对照装进单文件 HTML

光看数字不直观,得能在浏览器里切着看。下面这段是 W 层(网页层)构建脚本 web/build_l06_traffic.mjs:复用 web/pack.mjs 的擦除逻辑,把渠道漏斗内核 core/src/l06/traffic.ts(P 内核层,与 6.1 的 growth.ts 同构:全整数、无 Math.random,完整文件见配套仓库)内联进单文件 HTML,产物 web/dist/l06.html。

#!/usr/bin/env node
// file: web/build_l06_traffic.mjs
// tier: W
// verify: node web/build_l06_traffic.mjs
// 把 core/src/l06/traffic.ts 打成单文件 HTML(渠道组合对照探索器)。复用 pack.mjs 的擦除逻辑。

import { readFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { bundle, page, writePage, inlineConfig } from "./pack.mjs";

const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const cfg = JSON.parse(readFileSync(join(ROOT, "config", "l06_traffic.json"), "utf8"));
const js = bundle(ROOT, ["core/src/l06/traffic.ts"]);
const script = `const cfg = ${inlineConfig(cfg)};
${js}
const cv = document.getElementById("stage"), g = cv.getContext("2d");
const byId = Object.fromEntries(cfg.channels.map((c) => [c.id, c]));
let active = cfg.channels.map((c) => c.id), mix = null;
function compute() { mix = runMix(active.map((id) => byId[id])); draw(); }
function draw() {
  g.clearRect(0, 0, cv.width, cv.height);
  const peak = Math.max(...mix.perChannel.map((p) => p.newUsers), 1);
  mix.perChannel.forEach((p, i) => {
    const y = 16 + i * 34, w = Math.floor((cv.width - 130) * p.newUsers / peak);
    g.fillStyle = p.kind === "paid" ? "#f59e0b" : "#38bdf8";
    g.fillRect(96, y, w, 22);
    g.fillStyle = "#e2e8f0"; g.font = "13px system-ui";
    g.fillText(p.name, 6, y + 16);
    g.fillText(String(p.newUsers), 96 + w + 6, y + 16);
  });
  document.getElementById("cac").textContent = Math.floor(mix.blendedCacCent / 100) + "." + String(mix.blendedCacCent % 100).padStart(2, "0") + " 元";
  document.getElementById("share").textContent = Math.floor(mix.organicSharePermille / 10) + "." + (mix.organicSharePermille % 10) + "%";
  document.getElementById("new").textContent = String(mix.newUsers);
  globalThis.__pebble = { blendedCacCent: mix.blendedCacCent, organicSharePermille: mix.organicSharePermille,
    newUsers: mix.newUsers, perChannel: mix.perChannel.map((p) => [p.id, p.newUsers]), active: active.slice(),
    canvasDrawn: true, api };
}
const api = {
  set(ids) { active = ids.slice(); compute(); return { newUsers: mix.newUsers, blendedCacCent: mix.blendedCacCent, organicSharePermille: mix.organicSharePermille }; },
  get() { return { newUsers: mix.newUsers, blendedCacCent: mix.blendedCacCent, organicSharePermille: mix.organicSharePermille, active: active.slice() }; },
};
compute();
`;
const body = `  <h1>《合成小岛》· 渠道组合对照探索器</h1>
  <div class="sub">渠道漏斗内核来自 core/src/l06/traffic.ts:全整数、无 Math.random</div>
  <div class="card row"><div><div class="k">混合 CAC</div><div class="big" id="cac">0 元</div></div><div><div class="k">自然量占比</div><div class="big" id="share">0%</div></div><div><div class="k">总新增</div><div class="big" id="new">0</div></div></div>
  <div class="card"><canvas id="stage" width="480" height="160"></canvas></div>
  <div class="sub">切到「全渠道」:平台推荐 / 搜索 / 裂变把买量花费摊薄,混合 CAC 从 1.20 元降到 0.51 元</div>`;
const bytes = writePage(join(ROOT, "web/dist/l06.html"), page({ title: "《合成小岛》渠道组合对照探索器 · l06", body, script, css: "#stage { width: 480px; height: 160px; }" }));
console.log(JSON.stringify({ html: "web/dist/l06.html", htmlBytes: bytes, bundleLines: js.split("\n").length }));

下面这段是 W 层验证脚本 web/verify_l06_traffic.mjs:用 Playwright 无头打开 HTML,调 api 切换渠道组合,断言混合 CAC 与自然量占比,并截图。playwright 用 createRequire 加载——NODE_PATH 对 ESM 的 import 无效。

#!/usr/bin/env node
// file: web/verify_l06_traffic.mjs
// tier: W
// verify: NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_l06_traffic.mjs web/dist/l06.html
// W 层验收:Playwright 无头打开单文件 HTML,调 api 切渠道组合,读回断言 + 截图。
// playwright 用 createRequire 加载——NODE_PATH 对 ESM 的 import 无效。

import { createRequire } from "node:module";
import { pathToFileURL, fileURLToPath } from "node:url";
import { dirname, join, resolve } from "node:path";
import { mkdirSync } from "node:fs";
import assert from "node:assert/strict";

const { chromium } = createRequire(import.meta.url)("playwright");
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const url = pathToFileURL(resolve(ROOT, process.argv[2] ?? "web/dist/l06.html")).href;
mkdirSync(join(ROOT, "out/shots"), { recursive: true });
const browser = await chromium.launch();
const warm = await browser.newPage(); await warm.goto(url); await warm.close(); // 预热:冷启动首个 page 读 canvas 像素恒为空白
const errors = [];
const p = await browser.newPage({ viewport: { width: 800, height: 700 } });
p.on("pageerror", (e) => errors.push(String(e)));
await p.goto(url);
await p.waitForFunction(() => globalThis.__pebble?.canvasDrawn === true, null, { timeout: 10000 });
const full = await p.evaluate(() => globalThis.__pebble.api.get());
const paidOnly = await p.evaluate(() => globalThis.__pebble.api.set(["ua"]));
const paidPlatform = await p.evaluate(() => globalThis.__pebble.api.set(["ua", "platform"]));
const domCac = (await p.textContent("#cac")).trim();
const domShare = (await p.textContent("#share")).trim();
const painted = await p.evaluate(() => {
  const d = document.getElementById("stage").getContext("2d").getImageData(0, 0, 480, 160).data;
  let n = 0; for (let i = 0; i < d.length; i += 4) if (d[i + 3] > 0 && d[i] < 250) n += 1; return n;
});
await p.screenshot({ path: join(ROOT, "out/shots/l06_traffic.png"), fullPage: true });
await p.close();
const p2 = await browser.newPage();
await p2.goto(url);
await p2.waitForFunction(() => globalThis.__pebble?.canvasDrawn === true, null, { timeout: 10000 });
const rerun = await p2.evaluate(() => globalThis.__pebble.api.get());
await p2.close();
await browser.close();

assert.equal(errors.length, 0, `页面错误: ${errors.join("; ")}`);
assert.equal(full.newUsers, 6948, "全渠道总新增应为 6948");
assert.equal(full.blendedCacCent, 51, "全渠道混合 CAC 应为 51 分");
assert.equal(full.organicSharePermille, 568, "全渠道自然量占比应为 568‰");
assert.equal(paidOnly.blendedCacCent, 120, "纯买量 CAC 应为 120 分");
assert.equal(paidOnly.organicSharePermille, 0, "纯买量自然量占比应为 0");
assert.equal(paidPlatform.blendedCacCent, 73, "买量+平台 CAC 应为 73 分");
assert.equal(domCac, "0.73 元", "DOM 混合 CAC 应显示 0.73 元");
assert.equal(domShare, "38.4%", "DOM 自然量占比应显示 38.4%");
assert.ok(painted > 300, `canvas 应有绘制内容,实测 ${painted} px`);
assert.deepEqual(rerun.active, full.active, "同一次加载应可复现");

console.log(JSON.stringify({
  page: "web/dist/l06.html", full, paidOnly, paidPlatform, domCac, domShare,
  canvasPaintedPx: painted, rerunMatches: true, screenshot: "out/shots/l06_traffic.png",
}));
console.log("SMOKE-W6-2: PASS");

先构建、再验证,本机实跑输出如下(同机相对量级,不可移植):

$ node web/build_l06_traffic.mjs
{"html":"web/dist/l06.html","htmlBytes":6347,"bundleLines":31}
$ NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_l06_traffic.mjs web/dist/l06.html
{"page":"web/dist/l06.html","full":{"newUsers":6948,"blendedCacCent":51,"organicSharePermille":568,"active":["ua","platform","search","viral"]},"paidOnly":{"newUsers":3000,"blendedCacCent":120,"organicSharePermille":0},"paidPlatform":{"newUsers":4872,"blendedCacCent":73,"organicSharePermille":384},"domCac":"0.73 元","domShare":"38.4%","canvasPaintedPx":13809,"rerunMatches":true,"screenshot":"out/shots/l06_traffic.png"}
SMOKE-W6-2: PASS

平台流量的正确用法

把这一节收成一句可执行的话:平台流量是杠杆,不是预算项。你不能「买」平台推荐,只能:① 把内容做好(短视频、直播挂载是唯一可控入口);② 把搜索词占住(产品名、品类词);③ 把裂变系数抬上去(这是唯一完全可控的免费量)。前两项是「提高被推荐的概率」,第三项才是「确定能拿到的量」。下一节就把这三项连同买量一起,放进一个预算分配模型——同样一笔钱,怎么分给不同渠道,总新增最大。

阅读导航:上一节:自然增长与裂变机制 · 下一节:增长预算分配 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

  1. 附录 F:全书公式、参数与数据集索引
  2. 9.3 同意管理与数据出境
  3. 9.2 COPPA 与儿童向游戏的限制