Files
CLOAK/lib/Cloak/AttributionUrlMerger.php
T

267 lines
7.0 KiB
PHP
Raw Normal View History

2026-06-27 23:58:54 +08:00
<?php
/**
* Adjust / MMP 归因 URL 合并:宏占位符替换与安全 query 重建
*/
class AttributionUrlMerger
{
/** @var string[] */
private static $adjustHostSuffixes = [
'adjust.com',
'go.link',
'aindl.com',
];
/** @var string[] 广告平台 click ID,透传到 DB_FP 落地页供 Adjust JS 读取 */
private static $passthroughKeys = [
'fbclid',
'gclid',
'wbraid',
'gbraid',
'ttclid',
];
/** @var string[] cloak 内部参数,不进入最终 Adjust URL */
private static $stripKeys = [
'ad_spm_id',
'time',
'utm_source',
'utm_medium',
'utm_campaign',
'utm_term',
'utm_content',
'site_source',
'placement',
'campaign_id',
'adgroup_id',
'network',
];
/** @var array<string,string> Adjust 默认 p1p6 → Facebook 宏 */
private static $defaultMacroParams = [
'p1' => '{{campaign.name}}',
'p2' => '{{campaign.id}}',
'p3' => '{{adset.name}}',
'p4' => '{{adset.id}}',
'p5' => '{{ad.name}}',
'p6' => '{{ad.id}}',
];
/**
* 目标 URL 是否需要归因宏合并
*/
public static function needsAttributionMerge($targetUrl)
{
$targetUrl = trim((string) $targetUrl);
if ($targetUrl === '') {
return false;
}
if (stripos($targetUrl, '%7B%7B') !== false || stripos($targetUrl, '%7D%7D') !== false) {
return true;
}
if (preg_match('/\{\{[a-zA-Z0-9_.]+\}\}/', $targetUrl)) {
return true;
}
$parts = parse_url($targetUrl);
$host = strtolower((string) ($parts['host'] ?? ''));
return self::isAdjustHost($host);
}
/**
* 从 DB_FP URL 解析需追加到广告链接的宏参数(键名 => Facebook 宏模板)
*
* @return array<string,string>
*/
public static function parseAdjustMacroKeys($targetUrl)
{
$targetUrl = trim((string) $targetUrl);
if ($targetUrl === '') {
return [];
}
$parts = parse_url($targetUrl);
$query = [];
if (!empty($parts['query'])) {
parse_str($parts['query'], $query);
}
$keys = [];
foreach ($query as $key => $value) {
if (!self::isMacroPlaceholder($value)) {
continue;
}
$macro = self::extractMacroTemplate($value);
if ($macro !== '') {
$keys[(string) $key] = $macro;
}
}
if (empty($keys) && self::isAdjustHost(strtolower((string) ($parts['host'] ?? '')))) {
return self::$defaultMacroParams;
}
return $keys;
}
/**
* 将落地页 query 合并进目标 URL,替换宏占位符
*
* @param array<string,mixed> $incomingQuery
*/
public static function merge($targetUrl, array $incomingQuery)
{
$targetUrl = trim((string) $targetUrl);
if ($targetUrl === '') {
return '';
}
$parts = parse_url($targetUrl);
if ($parts === false) {
return $targetUrl;
}
$targetQuery = [];
if (!empty($parts['query'])) {
parse_str($parts['query'], $targetQuery);
}
$merged = [];
foreach ($targetQuery as $key => $value) {
$key = (string) $key;
$value = (string) $value;
if (self::isMacroPlaceholder($value) && self::hasIncomingValue($incomingQuery, $key)) {
$merged[$key] = (string) $incomingQuery[$key];
continue;
}
if (!self::isMacroPlaceholder($value)) {
$merged[$key] = $value;
}
}
foreach (self::$passthroughKeys as $ptKey) {
if (!self::hasIncomingValue($incomingQuery, $ptKey)) {
continue;
}
$merged[$ptKey] = (string) $incomingQuery[$ptKey];
}
return self::buildUrl($parts, $merged);
}
/**
* @param array<string,mixed> $query
*/
private static function buildUrl(array $parts, array $query)
{
$scheme = $parts['scheme'] ?? 'https';
$host = $parts['host'] ?? '';
$port = isset($parts['port']) ? ':' . $parts['port'] : '';
$path = $parts['path'] ?? '';
$frag = isset($parts['fragment']) ? '#' . $parts['fragment'] : '';
$pairs = [];
foreach ($query as $key => $value) {
if (in_array((string) $key, self::$stripKeys, true)) {
continue;
}
$pairs[] = rawurlencode((string) $key) . '=' . rawurlencode((string) $value);
}
$queryStr = implode('&', $pairs);
$url = $scheme . '://' . $host . $port . $path;
if ($queryStr !== '') {
$url .= '?' . $queryStr;
}
return $url . $frag;
}
/**
* @param mixed $value
*/
public static function isMacroPlaceholder($value)
{
$value = trim((string) $value);
if ($value === '') {
return false;
}
if (preg_match('/^\{\{[a-zA-Z0-9_.]+\}\}$/', $value)) {
return true;
}
$decoded = rawurldecode($value);
if ($decoded !== $value && preg_match('/^\{\{[a-zA-Z0-9_.]+\}\}$/', $decoded)) {
return true;
}
return (bool) preg_match('/^%7B%7B[a-zA-Z0-9_.]+%7D%7D$/i', $value);
}
/**
* @param mixed $value
*/
public static function extractMacroTemplate($value)
{
$value = trim((string) $value);
if ($value === '') {
return '';
}
if (preg_match('/^\{\{([a-zA-Z0-9_.]+)\}\}$/', $value, $m)) {
return '{{' . $m[1] . '}}';
}
$decoded = rawurldecode($value);
if (preg_match('/^\{\{([a-zA-Z0-9_.]+)\}\}$/', $decoded, $m)) {
return '{{' . $m[1] . '}}';
}
if (preg_match('/^%7B%7B([a-zA-Z0-9_.]+)%7D%7D$/i', $value, $m)) {
return '{{' . $m[1] . '}}';
}
return '';
}
/**
* @param array<string,mixed> $incomingQuery
*/
private static function hasIncomingValue(array $incomingQuery, $key)
{
if (!array_key_exists($key, $incomingQuery)) {
return false;
}
$value = $incomingQuery[$key];
if ($value === null || $value === '') {
return false;
}
if (is_array($value)) {
return false;
}
if (self::isMacroPlaceholder($value)) {
return false;
}
return true;
}
private static function isAdjustHost($host)
{
if ($host === '') {
return false;
}
foreach (self::$adjustHostSuffixes as $suffix) {
if ($host === $suffix || substr($host, -strlen('.' . $suffix)) === '.' . $suffix) {
return true;
}
}
return false;
}
}