172 lines
6.2 KiB
PHP
172 lines
6.2 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace app\common\service;
|
||
|
||
/**
|
||
* 中转页浏览器 Pixel 脚本渲染(Facebook / TikTok)
|
||
*/
|
||
class SplitPixelBrowserRenderer
|
||
{
|
||
/**
|
||
* @param array{facebook: array<int, array<string, mixed>>, tiktok: array<int, array<string, mixed>>} $config
|
||
* @return array{
|
||
* head_html: string,
|
||
* body_html: string,
|
||
* track_lines: array<int, string>,
|
||
* track_jobs: array<int, array<string, mixed>>
|
||
* }
|
||
*/
|
||
public static function render(array $config): array
|
||
{
|
||
$fbRows = SplitPixelConfigService::getEnabledSorted($config, SplitPixelConfigService::PLATFORM_FACEBOOK);
|
||
$tkRows = SplitPixelConfigService::getEnabledSorted($config, SplitPixelConfigService::PLATFORM_TIKTOK);
|
||
|
||
$head = [];
|
||
$initLines = [];
|
||
$trackLines = [];
|
||
$jobs = [];
|
||
|
||
if ($fbRows !== []) {
|
||
$head[] = self::facebookLoaderScript();
|
||
}
|
||
if ($tkRows !== []) {
|
||
$head[] = self::tiktokLoaderScript();
|
||
}
|
||
|
||
foreach ($fbRows as $row) {
|
||
$pixelId = self::escapeJsString((string) ($row['pixel_id'] ?? ''));
|
||
$testCode = trim((string) ($row['test_code'] ?? ''));
|
||
$event = self::escapeJsString((string) ($row['event'] ?? 'PageView'));
|
||
if ($pixelId === '') {
|
||
continue;
|
||
}
|
||
if ($testCode !== '') {
|
||
$testEsc = self::escapeJsString($testCode);
|
||
$initLines[] = "if(typeof fbq!=='undefined'){fbq('init','{$pixelId}',{},{test_event_code:'{$testEsc}'});}";
|
||
} else {
|
||
$initLines[] = "if(typeof fbq!=='undefined'){fbq('init','{$pixelId}');}";
|
||
}
|
||
$jobs[] = [
|
||
'platform' => 'facebook',
|
||
'event' => (string) ($row['event'] ?? 'PageView'),
|
||
'pixel_id' => (string) ($row['pixel_id'] ?? ''),
|
||
];
|
||
$trackLines[] = "if(typeof fbq!=='undefined'){fbq('track','{$event}');}";
|
||
}
|
||
|
||
foreach ($tkRows as $row) {
|
||
$pixelId = self::escapeJsString((string) ($row['pixel_id'] ?? ''));
|
||
$event = self::mapTikTokBrowserEvent((string) ($row['event'] ?? 'PageView'));
|
||
$eventJs = self::escapeJsString($event);
|
||
if ($pixelId === '') {
|
||
continue;
|
||
}
|
||
$initLines[] = "if(typeof ttq!=='undefined'){ttq.load('{$pixelId}');ttq.page();}";
|
||
$jobs[] = [
|
||
'platform' => 'tiktok',
|
||
'event' => $event,
|
||
'pixel_id' => (string) ($row['pixel_id'] ?? ''),
|
||
];
|
||
$trackLines[] = "if(typeof ttq!=='undefined'){ttq.track('{$eventJs}');}";
|
||
}
|
||
|
||
return [
|
||
'head_html' => implode("\n", $head),
|
||
'body_html' => self::wrapScriptBlock($initLines),
|
||
'track_lines' => $trackLines,
|
||
'track_jobs' => $jobs,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 跳转 orchestrator:在 setTimeout 回调内触发像素事件后再 replace
|
||
*
|
||
* @param array<int, string> $trackLines
|
||
*/
|
||
public static function renderRedirectOrchestrator(string $redirectUrlJson, array $trackLines = [], int $maxWaitMs = 1500): string
|
||
{
|
||
$trackBlock = $trackLines !== [] ? implode("\n ", $trackLines) : '';
|
||
$script = self::renderRedirectOrchestratorScript();
|
||
|
||
return str_replace(
|
||
['{$redirectUrlJson}', '{$maxWaitMs}', '{$trackBlock}'],
|
||
[$redirectUrlJson, (string) $maxWaitMs, $trackBlock],
|
||
$script
|
||
);
|
||
}
|
||
|
||
private static function renderRedirectOrchestratorScript(): string
|
||
{
|
||
return <<<'JS'
|
||
<script type="text/javascript">
|
||
(function () {
|
||
var url = {$redirectUrlJson};
|
||
if (!url) {
|
||
return;
|
||
}
|
||
setTimeout(function () {
|
||
{$trackBlock}
|
||
window.location.replace(url);
|
||
}, {$maxWaitMs});
|
||
})();
|
||
</script>
|
||
JS;
|
||
}
|
||
|
||
/**
|
||
* @param array<int, string> $lines
|
||
*/
|
||
private static function wrapScriptBlock(array $lines): string
|
||
{
|
||
if ($lines === []) {
|
||
return '';
|
||
}
|
||
|
||
return '<script type="text/javascript">' . "\n"
|
||
. implode("\n", $lines) . "\n"
|
||
. '</script>';
|
||
}
|
||
|
||
private static function facebookLoaderScript(): string
|
||
{
|
||
return <<<'HTML'
|
||
<script>
|
||
!function(f,b,e,v,n,t,s){if(f.fbq)return;n=f.fbq=function(){n.callMethod?
|
||
n.callMethod.apply(n,arguments):n.queue.push(arguments)};if(!f._fbq)f._fbq=n;
|
||
n.push=n;n.loaded=!0;n.version='2.0';n.queue=[];t=b.createElement(e);t.async=!0;
|
||
t.src=v;s=b.getElementsByTagName(e)[0];s.parentNode.insertBefore(t,s)}(window,document,'script','https://connect.facebook.net/en_US/fbevents.js');
|
||
</script>
|
||
HTML;
|
||
}
|
||
|
||
private static function tiktokLoaderScript(): string
|
||
{
|
||
return <<<'HTML'
|
||
<script>
|
||
!function(w,d,t){w.TiktokAnalyticsObject=t;var ttq=w[t]=w[t]||[];ttq.methods=["page","track","identify","instances","debug","on","off","once","ready","alias","group","enableCookie","disableCookie"],ttq.setAndDefer=function(t,e){t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}};for(var i=0;i<ttq.methods.length;i++)ttq.setAndDefer(ttq,ttq.methods[i]);ttq.instance=function(t){for(var e=ttq._i[t]||[],n=0;n<ttq.methods.length;n++)ttq.setAndDefer(e,ttq.methods[n]);return e},ttq.load=function(e,n){var i="https://analytics.tiktok.com/i18n/pixel/events.js";ttq._i=ttq._i||{},ttq._i[e]=[],ttq._i[e]._u=i,ttq._t=ttq._t||{},ttq._t[e]=+new Date,ttq._o=ttq._o||{},ttq._o[e]=n||{};var o=document.createElement("script");o.type="text/javascript",o.async=!0,o.src=i+"?sdkid="+e+"&lib="+t;var a=document.getElementsByTagName("script")[0];a.parentNode.insertBefore(o,a)}}(window,document,'ttq');
|
||
</script>
|
||
HTML;
|
||
}
|
||
|
||
private static function mapTikTokBrowserEvent(string $event): string
|
||
{
|
||
$map = [
|
||
'PageView' => 'Pageview',
|
||
'Lead' => 'SubmitForm',
|
||
'Contact' => 'Contact',
|
||
'AddToCart' => 'AddToCart',
|
||
'Purchase' => 'CompletePayment',
|
||
'Subscribe' => 'Subscribe',
|
||
];
|
||
|
||
return $map[$event] ?? 'Pageview';
|
||
}
|
||
|
||
private static function escapeJsString(string $value): string
|
||
{
|
||
return str_replace(['\\', "'"], ['\\\\', "\\'"], $value);
|
||
}
|
||
}
|