267 lines
7.0 KiB
PHP
267 lines
7.0 KiB
PHP
|
|
<?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 默认 p1–p6 → 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;
|
|||
|
|
}
|
|||
|
|
}
|