PHP 语言实现广告斗篷系统(Cloaking):六层检测链路架构详解教程

PHP 实现广告斗篷系统(Cloaking):六层检测链路架构详解

前言

做广告投放的开发同学应该都接触过「斗篷系统」这个需求:同一个 URL,审核蜘蛛看到合规白页,真实用户看到转化落地页。

网上能搜到的教程基本都停留在 UserAgent 关键词匹配的阶段,生产环境根本扛不住。本文从工程角度拆解一套生产级 Cloaking 系统的完整架构,包含六层检测链路、双缓存分发、子站点架构等核心模块,并给出 PHP 脱敏实现。

项目开源示例:https://github.com/abcloakpro/Cloak-System


一、为什么 UA 匹配不够用

先看一段常见的入门代码:

<?php
function detectBot(): bool {
    $bots = ['googlebot', 'bingbot', 'facebookexternalhit', 'crawler', 'spider', 'bot'];
    $ua = strtolower($_SERVER['HTTP_USER_AGENT'] ?? '');
    foreach ($bots as $bot) {
        if (strpos($ua, $bot) !== false) return true;
    }
    return false;
}

if (detectBot()) {
    include 'white_page.html';
} else {
    include 'landing_page.html';
}

这段代码的问题:

问题 说明
UA 可伪造 curl -A "googlebot" 就能绕过
无法识别机房流量 云服务商 IP 的 UA 可以是任意值
无法识别代理/VPN IPHub 这类 API 没调用
不支持地域投放 缺城市检测
不支持设备分流 桌面/移动混在一起
不支持时间窗 推广时段无法控制

生产级系统必须用多层独立、可叠加的检测链路


二、系统整体架构

                    ┌─────────────────────────────────────┐
                    │           访问者请求                 │
                    │   (URL 不变,由 index.php 统一接管)  │
                    └──────────────────┬──────────────────┘
                                       │
                    ┌──────────────────▼──────────────────┐
                    │      CDN 反代(真实 IP 头)         │
                    │      获取真实访客 IP,信任 CDN 段   │
                    └──────────────────┬──────────────────┘
                                       │
                    ┌──────────────────▼──────────────────┐
                    │          六层检测链路                │
                    │  指纹 → ASN → 代理 → 城市 → 设备     │
                    │              → 推广时间窗            │
                    └──────────────────┬──────────────────┘
                                       │
                    ┌──────────────────┴──────────────────┐
                    │                                     │
              命中拦截规则                          全部通过
                    │                                     │
          ┌─────────▼─────────┐              ┌────────────▼───────────┐
          │   白页 (white)    │              │   落地页 (landing page)│
          │  审核合规内容      │              │   双缓存分发           │
          │                    │              │  _m.html (移动)        │
          └────────────────────┘              │  .html   (桌面)        │
                                              └────────────────────────┘

核心原则:

  • 任一层命中 → 返回白页
  • 全部通过 → 返回落地页
  • URL 始终不变

三、六层检测链路详解

检测顺序设计

<?php
/**
 * 生产级斗篷检测入口
 * 任一层命中即返回白页,全部通过才返回落地页
 */

// ===== 0. 获取访客真实 IP(CDN 反代场景下取真实 IP 头) =====
$realIp = $_SERVER['HTTP_CF_CONNECTING_IP']
       ?? $_SERVER['HTTP_X_FORWARDED_FOR']
       ?? $_SERVER['REMOTE_ADDR'];

// ===== 1. 指纹检测(最高优先级,识别自动化工具 / 无头浏览器) =====
if (detectFingerprint($_SERVER, $_COOKIE)) {
    serveWhitePage();
    exit;
}

// ===== 2. ASN 黑名单(云服务 / VPN 提供商 ASN) =====
$asn = queryAsnByIp($realIp);
if (in_array($asn, getAsnBlacklist(), true)) {
    serveWhitePage();
    exit;
}

// ===== 3. 代理 / VPN 识别 =====
if (isProxyIp($realIp)) {
    serveWhitePage();
    exit;
}

// ===== 4. 城市检测(多 API 冗余,失败默认放行) =====
$city = detectCityRedundant($realIp);
if ($city !== '' && !isCityAllowed($city)) {
    serveWhitePage();
    exit;
}

// ===== 5. 设备控制(阻断桌面,放行移动) =====
$isMobile = isMobileDevice($_SERVER['HTTP_USER_AGENT']);
if (isDesktopRuleEnabled() && !$isMobile) {
    serveWhitePage();
    exit;
}

// ===== 6. 推广时间窗(00:00:00 视为不限制) =====
if (!isWithinPromotionTime()) {
    serveWhitePage();
    exit;
}

// ===== 全部通过 → 双缓存分发 =====
serveLandingPage($isMobile);

为什么是这个顺序?

层级 耗时 原因
1. 指纹 极低(本地) 性价比最高,先过滤自动化工具
2. ASN 低(本地缓存) 黑名单查询,O(1)
3. 代理 中(外部API) 依赖外部服务,放后面
4. 城市 高(多API冗余) 最慢,放最后段
5. 设备 极低(本地) 纯字符串判断
6. 时间窗 极低(本地) 纯本地判断,优先级最低

核心思路:快的先跑,慢的靠后,最大化短路跳出。


四、核心模块实现

4.1 城市检测多 API 冗余

<?php
/**
 * 城市检测:依次调用多家 IP 定位 API,任一成功即返回
 * 全部失败时返回空字符串,主流程据此默认放行(避免误拦截)
 */
function detectCityRedundant(string $ip): string
{
    $providers = [
        'provider_a' => function ($ip) {
            return queryProviderA($ip, API_KEY_A);
        },
        'provider_b' => function ($ip) {
            return queryProviderB($ip, API_KEY_B);
        },
        'provider_c' => function ($ip) {
            return queryProviderC($ip, API_KEY_C);
        },
        'provider_d' => function ($ip) {
            return queryProviderD($ip, API_KEY_D);
        },
    ];

    foreach ($providers as $fn) {
        $city = '';
        try {
            $city = trim((string)$fn($ip));
        } catch (Throwable $e) {
            // 单家失败不影响整体流程,继续降级到下一家
            continue;
        }
        if ($city !== '') {
            return normalizeCity($city);
        }
    }
    // 全部失败 → 返回空,主流程默认放行
    return '';
}

4.2 城市名归一化(踩坑点)

<?php
/**
 * 城市名归一化:分组后缀匹配,避免截断如「广州」
 *
 * ❌ 错误写法:preg_replace('/[省市自治区]$/', '', $city)
 *    会把「广州」截断成「广」
 *
 * ✅ 正确写法:用分组
 */
function normalizeCity(string $city): string
{
    return preg_replace('/(省|市|自治区)$/', '', $city);
}

/**
 * 城市白名单校验
 */
function isCityAllowed(string $city): bool
{
    $allowed = ['广州', '深圳', '北京', '上海'];  // 示例白名单
    return in_array($city, $allowed, true);
}

4.3 推广时间窗防御

<?php
/**
 * 推广时间窗判断
 * 占位值 00:00:00 视为不限制,避免误判为零时长窗口
 */
function isWithinPromotionTime(): bool
{
    $start = getConfig('promo_start', '00:00:00');
    $end   = getConfig('promo_end',   '00:00:00');

    // 00:00:00 占位值 → 不限制
    if ($start === '00:00:00' && $end === '00:00:00') {
        return true;
    }

    $now  = date('H:i:s');
    return ($now >= $start) && ($now <= $end);
}

4.4 双缓存分发

<?php
/**
 * 落地页双缓存分发
 * 桌面/移动分别缓存,移动端缺失时回退桌面缓存
 */
function serveLandingPage(bool $isMobile): void
{
    $url        = getCurrentRouteUrl();
    $cacheDir   = __DIR__ . '/cached';
    $desktopCk  = "{$cacheDir}/{$url}.html";
    $mobileCk   = "{$cacheDir}/{$url}_m.html";

    $target = $isMobile ? $mobileCk : $desktopCk;

    // 移动端缓存缺失 → 回退桌面缓存
    if (!file_exists($target) && $isMobile) {
        $target = $desktopCk;
    }

    if (file_exists($target)) {
        header('Content-Type: text/html; charset=utf-8');
        readfile($target);
        return;
    }

    // 缓存均不存在 → 触发抓取或返回白页兜底
    triggerCacheFetch($url);
    serveWhitePage();
}

4.5 设备识别

<?php
/**
 * 移动设备识别
 * 规则:desktop && !is_mobile → 阻断桌面,放行移动
 */
function isMobileDevice(string $userAgent): bool
{
    $mobileKeywords = [
        'iPhone', 'iPad', 'Android', 'Windows Phone',
        'Mobile', 'Opera Mini', 'BlackBerry',
    ];

    $ua = strtolower($userAgent);
    foreach ($mobileKeywords as $keyword) {
        if (strpos($ua, strtolower($keyword)) !== false) {
            return true;
        }
    }
    return false;
}

function isDesktopRuleEnabled(): bool
{
    // 从配置读取是否启用桌面阻断规则
    return (bool)getConfig('block_desktop', false);
}

4.6 ASN 黑名单查询

<?php
/**
 * ASN 黑名单查询
 * 返回云服务/VPN 提供商 ASN 列表
 */
function getAsnBlacklist(): array
{
    // 实际从数据库或缓存读取
    // 示例:主流云厂商 ASN
    return [
        '13335',  // Cloudflare
        '15169',  // Google
        '16509',  // Amazon AWS
        '8075',   // Microsoft Azure
        // ... 完整列表需定期同步
    ];
}

/**
 * 通过 IP 查询 ASN
 */
function queryAsnByIp(string $ip): string
{
    // 调用 ASN 查询服务,例如:
    // - IPHub
    // - ipinfo.io
    // - 自建 IP-ASN 数据库
    // 返回 ASN 字符串,失败返回空
    return '';
}

4.7 代理/VPN 识别

<?php
/**
 * 代理/VPN 识别
 * 通过第三方 API 查询 IP 是否为代理
 */
function isProxyIp(string $ip): bool
{
    $cacheKey = "proxy_check_{$ip}";

    // 优先查缓存(避免重复调用 API)
    $cached = getCache($cacheKey);
    if ($cached !== null) {
        return $cached === '1';
    }

    // 调用代理识别 API(脱敏)
    $result = callProxyCheckApi($ip, PROXY_API_KEY);

    // 缓存结果(TTL 视业务而定)
    $isProxy = $result ? '1' : '0';
    setCache($cacheKey, $isProxy, 3600);

    return $result;
}

五、缓存抓取系统

落地页不能每次实时抓取,需要后台预抓取并缓存。抓取模块的关键机制:

5.1 DOM 稳定性检测

<?php
/**
 * DOM 稳定性检测(Puppeteer/Playwright 场景)
 * 每 500ms 统计 DOM 节点数,连续多次稳定后才抓取
 */
async function waitForDomStable(page, options = {}) {
    const { interval = 500, stableCount = 4 } = options;
    let lastCount = 0;
    let stable = 0;

    while (stable < stableCount) {
        const count = await page.evaluate(() => document.querySelectorAll('*').length);
        if (count === lastCount) {
            stable++;
        } else {
            stable = 0;
        }
        lastCount = count;
        await new Promise(r => setTimeout(r, interval));
    }
}

5.2 三层弹窗清理

// 第一层:移除常见弹窗元素(40+ 选择器)
const popupSelectors = [
    '.popup', '.modal', '.dialog', '.overlay', '.mask',
    '.download-app', '.float-ad', '.coupon', '.redpacket',
    '[class*="popup"]', '[class*="modal"]', '[class*="overlay"]',
    // ... 完整 40+ 选择器
];

popupSelectors.forEach(sel => {
    document.querySelectorAll(sel).forEach(el => {
        const style = getComputedStyle(el);
        const isFixed = style.position === 'fixed' || style.position === 'absolute';
        const highZ = parseInt(style.zIndex) > 100;
        if (isFixed || highZ) el.remove();
    });
});

// 第二层:移除全屏遮罩
document.querySelectorAll('*').forEach(el => {
    const style = getComputedStyle(el);
    const isFixed = style.position === 'fixed';
    const rect = el.getBoundingClientRect();
    const coversScreen = (rect.width / innerWidth) > 0.8
                      && (rect.height / innerHeight) > 0.8;
    const noContent = el.textContent.trim().length < 10;
    if (isFixed && coversScreen && noContent) el.remove();
});

// 第三层:恢复 body 滚动
document.body.style.overflow = '';
document.body.style.position = '';
document.body.style.top = '';
document.body.classList.remove('lock', 'noscroll', 'modal-open');

5.3 原子目录切换

<?php
/**
 * 原子目录切换:消除缓存更新期间的空窗
 *
 * 流程:
 * 1. 抓取新缓存到 cached_new/
 * 2. 重命名 cached/ → cached_old/
 * 3. 重命名 cached_new/ → cached/
 * 4. 删除 cached_old/
 *
 * rename() 是原子操作,中间不会出现缓存空窗
 */
function atomicCacheSwitch(string $newDir, string $currentDir): void
{
    $oldDir = $currentDir . '_old_' . time();

    if (is_dir($currentDir)) {
        rename($currentDir, $oldDir);
    }
    rename($newDir, $currentDir);

    // 异步清理旧目录
    register_shutdown_function(function () use ($oldDir) {
        removeDirRecursive($oldDir);
    });
}

六、安全与防护

6.1 PHP 配置硬性约束

<?php
// 必须关闭错误显示,否则错误信息会污染 JSON 响应
ini_set('display_errors', 0);
error_reporting(E_ALL);

// 所有 API 响应前清理输出缓冲
ob_end_clean();
header('Content-Type: application/json; charset=utf-8');
echo json_encode($data);

6.2 长任务异步收尾

<?php
/**
 * 主机面板 API 调用等长任务使用 fastcgi_finish_request
 * 避免客户端等待超时
 */
function longRunningTask(): void
{
    // 先把响应发给客户端
    if (function_exists('fastcgi_finish_request')) {
        header('Content-Type: application/json');
        echo json_encode(['code' => 0, 'msg' => 'ok']);
        fastcgi_finish_request();
    }

    // 后台继续执行长任务
    callHostingPanelApi();
    updateDatabase();
}

6.3 CDN 真实 IP 获取

<?php
/**
 * 获取真实访客 IP(CDN 反代场景)
 * 必须信任 CDN 提供商的真实 IP 头,否则拿到的是 CDN 节点 IP
 */
function getRealClientIp(): string
{
    // Cloudflare: HTTP_CF_CONNECTING_IP
    // 阿里云 CDN: HTTP_ALI_CDN_REAL_IP
    // 通用: HTTP_X_FORWARDED_FOR(需配合 Nginx 信任配置)
    return $_SERVER['HTTP_CF_CONNECTING_IP']
        ?? $_SERVER['HTTP_X_FORWARDED_FOR']
        ?? $_SERVER['REMOTE_ADDR'];
}

七、子站点架构(企业版 SaaS)

7.1 配置 AES 加密存储

<?php
/**
 * 子站点配置 AES 加密
 * 存入 config 表时加密,运行时解密
 */
function encryptSubsiteConfig(array $config): string
{
    $json = json_encode($config, JSON_UNESCAPED_UNICODE);
    $iv = random_bytes(openssl_cipher_iv_length('AES-256-CBC'));
    $encrypted = openssl_encrypt($json, 'AES-256-CBC', SUBSITE_AES_KEY, 0, $iv);
    return base64_encode($iv . $encrypted);
}

function decryptSubsiteConfig(string $encrypted): array
{
    $data = base64_decode($encrypted);
    $ivLen = openssl_cipher_iv_length('AES-256-CBC');
    $iv = substr($data, 0, $ivLen);
    $payload = substr($data, $ivLen);
    $json = openssl_decrypt($payload, 'AES-256-CBC', SUBSITE_AES_KEY, 0, $iv);
    return json_decode($json, true) ?: [];
}

7.2 路由删除顺序

<?php
/**
 * 删除路由必须按顺序执行,避免孤儿数据
 *
 * 顺序:清理主机面板站点 → 删除路由规则 → 删除路由链接 → 删除路由主记录
 */
function deleteRoute(int $routeId): void
{
    $pdo->beginTransaction();
    try {
        // 1. 读取路由链接中的站点信息
        $siteInfo = getRouteLinkInfo($routeId);

        // 2. 清理主机面板站点
        cleanupHostingSite($siteInfo['abrul']);

        // 3. 删除路由规则
        deleteRouteRules($routeId);

        // 4. 删除路由链接
        deleteRouteLinks($routeId);

        // 5. 删除路由主记录
        deleteRouteMain($routeId);

        $pdo->commit();
    } catch (Throwable $e) {
        $pdo->rollBack();
        throw $e;
    }
}

八、生产环境踩坑总结

8.1 城市正则陷阱

// ❌ 错误:字符类会截断「广州」为「广」
preg_replace('/[省市自治区]$/', '', $city);

// ✅ 正确:分组匹配
preg_replace('/(省|市|自治区)$/', '', $city);

8.2 时间窗占位值

// 00:00:00 作为「不限制」的占位值,必须特殊处理
// 否则会被误判为零时长窗口
if ($start === '00:00:00' && $end === '00:00:00') {
    return true;  // 视为不限制
}

8.3 会话文件归属

定时任务必须以 Web 服务器用户执行,否则会话文件归属 root,导致用户请求读取会话失败:

# ❌ 错误:root 执行 cron
*/5 * * * * php /path/to/cron.php

# ✅ 正确:www 用户执行 cron
*/5 * * * * sudo -u www php /path/to/cron.php

8.4 文件权限

root 用户通过 SSH 创建/修改文件后,必须重新设置归属:

# root 操作后必须执行
chown -R www:www /path/to/site/

九、开源版 vs 生产级

能力 开源版 生产级
检测层级 单层 UA 匹配 六层链路
指纹库 静态 持续维护
ASN 黑名单 定期同步
代理识别 API 实时查询
城市检测 单 API 多 API 冗余
缓存分发 桌面/移动双缓存
子站点架构 AES 加密独立后台
抓取稳定性 DOM 稳定性 + 弹窗清理

开源项目地址:https://github.com/abcloakpro/Cloak-System


十、总结

本文拆解了一套生产级 Cloaking 系统的核心架构,关键点:

  1. 六层检测链路:指纹 → ASN → 代理 → 城市 → 设备 → 时间窗
  2. 顺序设计:快的先跑,慢的靠后,最大化短路跳出
  3. 双缓存分发:桌面/移动分离,移动缺失回退桌面
  4. 原子目录切换:消除缓存更新空窗
  5. 安全约束:关错误显示、清缓冲、异步收尾、CDN 真实 IP
  6. 子站点架构:AES 加密、字段映射、删除顺序

代码已脱敏处理,移除了 API Key、真实路径、表名等敏感信息,可直接作为架构参考。完整可运行的生产级系统可见文末链接。


参考资料


本文代码基于 PHP 7.4+ 编写,涉及异步操作需配合 Swoole / RoadRunner。

posted @ 2026-07-18 14:14  ABcloakPro  阅读(4)  评论(0)    收藏  举报