Files
CLOAK/lib/Cloak/AttributionUrlMerger.php
T
2026-06-27 23:58:54 +08:00

267 lines
7.0 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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;
}
}