Files
CLOAK/lib/CloakAdSourceGuard.php
T

271 lines
8.5 KiB
PHP
Raw Normal View History

2026-06-14 14:00:24 +08:00
<?php
/**
* 广告来源限制 — 4 级优先级检测 + 广告链接 query 拼装
*/
class CloakAdSourceGuard
{
const PLATFORM_FB = 'fb';
const PLATFORM_GOOGLE = 'google';
const PLATFORM_TIKTOK = 'tiktok';
private static $refererDomains = [
'fb.com',
'facebook.com',
'instagram.com',
'l.facebook.com',
'lm.facebook.com',
'm.facebook.com',
'google.com',
'googleadservices.com',
'doubleclick.net',
'youtube.com',
'googlesyndication.com',
'tiktok.com',
'tiktokv.com',
'bytedance.com',
'snssdk.com',
'pangleglobal.com',
'ads.tiktok.com',
];
private static $uaPatterns = [
'fban',
'fbav',
'instagram',
'googleads',
'adsbot-google',
'gsa/',
'tiktok',
'musical_ly',
'bytedancewebview',
'bytelocale',
'trill_',
];
private static $clidParams = ['fbclid', 'gclid', 'wbraid', 'gbraid', 'ttclid'];
private static $tiktokMacros = [
'__CAMPAIGN_ID__',
'__AID__',
'__CID__',
'__PLACEMENT__',
];
private static $fullTemplates = [
self::PLATFORM_FB => 'utm_source=facebook&site_source={{site_source_name}}&placement={{placement}}',
self::PLATFORM_GOOGLE => 'utm_source=google&utm_term={keyword}&network={network}&placement={placement}',
self::PLATFORM_TIKTOK => 'utm_source=tiktok&placement=__PLACEMENT__',
2026-06-14 14:00:24 +08:00
];
private static $multiPlatformParams = [
self::PLATFORM_FB => 'campaign_id={{campaign.id}}',
self::PLATFORM_GOOGLE => 'utm_campaign={campaignid}',
self::PLATFORM_TIKTOK => 'adgroup_id=__CID__',
];
/**
* @param array $config ad_fb, ad_google, ad_tiktok, ad_strict, ad_spm_id (bool/string)
* @param array $context referer, query, user_agent
* @return array { passed: bool, matched_by: string, reason: string }
*/
public static function evaluate(array $config, array $context)
{
$enabled = self::anyPlatformEnabled($config);
if (!$enabled) {
return ['passed' => true, 'matched_by' => 'guard_disabled', 'reason' => ''];
}
$query = $context['query'] ?? [];
$referer = strtolower((string) ($context['referer'] ?? ''));
$userAgent = strtolower((string) ($context['user_agent'] ?? ''));
$spmId = trim((string) ($config['ad_spm_id'] ?? ''));
// 优先级 1ad_spm_id 强验证
if ($spmId !== '' && isset($query['ad_spm_id']) && (string) $query['ad_spm_id'] === $spmId) {
return ['passed' => true, 'matched_by' => 'ad_spm_id', 'reason' => ''];
}
// 优先级 2clid 或 TikTok 动态宏
foreach (self::$clidParams as $param) {
if (!empty($query[$param])) {
return ['passed' => true, 'matched_by' => 'clid:' . $param, 'reason' => ''];
}
}
$queryString = self::buildQueryString($query);
foreach (self::$tiktokMacros as $macro) {
if (strpos($queryString, $macro) !== false) {
return ['passed' => true, 'matched_by' => 'tiktok_macro:' . $macro, 'reason' => ''];
}
}
// 优先级 3Referer
foreach (self::$refererDomains as $domain) {
if ($referer !== '' && strpos($referer, $domain) !== false) {
return ['passed' => true, 'matched_by' => 'referer:' . $domain, 'reason' => ''];
}
}
// 优先级 4User-Agent
foreach (self::$uaPatterns as $pattern) {
if ($userAgent !== '' && strpos($userAgent, $pattern) !== false) {
return ['passed' => true, 'matched_by' => 'ua:' . $pattern, 'reason' => ''];
}
}
$strict = !empty($config['ad_strict']);
if ($strict) {
return [
'passed' => false,
'matched_by' => 'none',
'reason' => '来源限制:未命中广告来源验证',
];
}
return [
'passed' => false,
'matched_by' => 'none',
'reason' => '未确定流量来源',
];
}
/**
* @param array $enabledPlatforms PLATFORM_* 常量列表
* @param string $spmId
* @return string query 字符串(不含前导 ?
*/
public static function buildAdLinkQuery(array $enabledPlatforms, $spmId)
{
$spmId = trim((string) $spmId);
if (empty($enabledPlatforms)) {
return $spmId !== '' ? 'ad_spm_id=' . rawurlencode($spmId) : '';
}
$parts = [];
if (count($enabledPlatforms) === 1) {
$platform = $enabledPlatforms[0];
if (isset(self::$fullTemplates[$platform])) {
$parts[] = self::$fullTemplates[$platform];
}
} else {
foreach ($enabledPlatforms as $platform) {
if (isset(self::$multiPlatformParams[$platform])) {
$parts[] = self::$multiPlatformParams[$platform];
}
}
}
if ($spmId !== '') {
$parts[] = 'ad_spm_id=' . rawurlencode($spmId);
}
return implode('&', $parts);
}
/**
* 生成 1012 位随机字母数字 ad_spm_id
*/
public static function generateSpmId()
{
$len = random_int(10, 12);
$chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
$max = strlen($chars) - 1;
$id = '';
for ($i = 0; $i < $len; $i++) {
$id .= $chars[random_int(0, $max)];
}
return $id;
}
/**
* @param string $spmId
*/
public static function isValidSpmId($spmId)
{
return (bool) preg_match('/^[A-Za-z0-9]{10,12}$/', (string) $spmId);
}
/**
* @param array $config
*/
public static function anyPlatformEnabled(array $config)
{
return !empty($config['ad_fb'])
|| !empty($config['ad_google'])
|| !empty($config['ad_tiktok']);
}
/**
* 从当前站点配置读取广告来源限制参数
*/
public static function configFromDefines()
{
return [
'ad_fb' => defined('CLOAK_AD_FB') && strtoupper(CLOAK_AD_FB) === 'ON',
'ad_google' => defined('CLOAK_AD_GOOGLE') && strtoupper(CLOAK_AD_GOOGLE) === 'ON',
'ad_tiktok' => defined('CLOAK_AD_TIKTOK') && strtoupper(CLOAK_AD_TIKTOK) === 'ON',
'ad_strict' => defined('CLOAK_AD_STRICT') && strtoupper(CLOAK_AD_STRICT) === 'ON',
'ad_spm_id' => defined('CLOAK_AD_SPM_ID') ? (string) CLOAK_AD_SPM_ID : '',
];
}
/**
* 合并 Referer query 与 $_GET(与流水线 Guard 一致)
*/
public static function buildMergedRequestQuery(array $get = null)
{
if ($get === null) {
$get = $_GET;
}
$refRaw = isset($_SERVER['HTTP_REFERER']) ? (string) $_SERVER['HTTP_REFERER'] : '';
$refParts = parse_url($refRaw);
$refQ = [];
if (!empty($refParts['query'])) {
parse_str($refParts['query'], $refQ);
}
return array_merge($refQ, $get);
}
/**
* 构建 evaluate 用的请求上下文
*/
public static function buildRequestContext(array $get = null)
{
return [
'referer' => isset($_SERVER['HTTP_REFERER']) ? (string) $_SERVER['HTTP_REFERER'] : '',
'query' => self::buildMergedRequestQuery($get),
'user_agent' => isset($_SERVER['HTTP_USER_AGENT']) ? (string) $_SERVER['HTTP_USER_AGENT'] : '',
];
}
/**
* 临时链接参数:是否满足开始/续期计时的来源条件
* 已开启广告来源限制 → 走 4 级优先级规则;未开启 → 兼容旧 clid/ad_spm_id 触发
*/
public static function shouldStartUrlArgsTimer(array $config, array $context)
{
if (!self::anyPlatformEnabled($config)) {
$query = $context['query'] ?? [];
foreach (array_merge(self::$clidParams, ['ad_spm_id']) as $param) {
if (!empty($query[$param])) {
return true;
}
}
return false;
}
$result = self::evaluate($config, $context);
return !empty($result['passed']);
}
/**
* @param array $query
*/
private static function buildQueryString(array $query)
{
if (empty($query)) {
return '';
}
return http_build_query($query);
}
}