271 lines
8.9 KiB
PHP
271 lines
8.9 KiB
PHP
|
|
<?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&utm_medium=paid_social&utm_campaign={{campaign.name}}&utm_content={{ad.name}}&campaign_id={{campaign.id}}&adset_id={{adset.id}}&ad_id={{ad.id}}&site_source={{site_source_name}}&placement={{placement}}',
|
|||
|
|
self::PLATFORM_GOOGLE => 'utm_source=google&utm_medium=cpc&utm_campaign={campaignid}&utm_content={adgroupid}&utm_term={keyword}&ad_id={creative}&network={network}&placement={placement}',
|
|||
|
|
self::PLATFORM_TIKTOK => 'utm_source=tiktok&utm_medium=video_ad&utm_campaign=__CAMPAIGN_ID__&utm_content=__AID__&adgroup_id=__CID__&placement=__PLACEMENT__',
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
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'] ?? ''));
|
|||
|
|
|
|||
|
|
// 优先级 1:ad_spm_id 强验证
|
|||
|
|
if ($spmId !== '' && isset($query['ad_spm_id']) && (string) $query['ad_spm_id'] === $spmId) {
|
|||
|
|
return ['passed' => true, 'matched_by' => 'ad_spm_id', 'reason' => ''];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 优先级 2:clid 或 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' => ''];
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 优先级 3:Referer
|
|||
|
|
foreach (self::$refererDomains as $domain) {
|
|||
|
|
if ($referer !== '' && strpos($referer, $domain) !== false) {
|
|||
|
|
return ['passed' => true, 'matched_by' => 'referer:' . $domain, 'reason' => ''];
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 优先级 4:User-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);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 生成 10–12 位随机字母数字 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);
|
|||
|
|
}
|
|||
|
|
}
|