PHP 语言实现广告斗篷系统(Cloaking):六层检测链路架构详解教程
PHP 实现广告斗篷系统(Cloaking):六层检测链路架构详解
前言
做广告投放的开发同学应该都接触过「斗篷系统」这个需求:同一个 URL,审核蜘蛛看到合规白页,真实用户看到转化落地页。
网上能搜到的教程基本都停留在 UserAgent 关键词匹配的阶段,生产环境根本扛不住。本文从工程角度拆解一套生产级 Cloaking 系统的完整架构,包含六层检测链路、双缓存分发、子站点架构等核心模块,并给出 PHP 脱敏实现。
一、为什么 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 系统的核心架构,关键点:
- 六层检测链路:指纹 → ASN → 代理 → 城市 → 设备 → 时间窗
- 顺序设计:快的先跑,慢的靠后,最大化短路跳出
- 双缓存分发:桌面/移动分离,移动缺失回退桌面
- 原子目录切换:消除缓存更新空窗
- 安全约束:关错误显示、清缓冲、异步收尾、CDN 真实 IP
- 子站点架构:AES 加密、字段映射、删除顺序
代码已脱敏处理,移除了 API Key、真实路径、表名等敏感信息,可直接作为架构参考。完整可运行的生产级系统可见文末链接。
参考资料
本文代码基于 PHP 7.4+ 编写,涉及异步操作需配合 Swoole / RoadRunner。
浙公网安备 33010602011771号