")`, in either quote * style, capturing the id. The snippet builds the tag URL in JS, so * this is usually the only place the id appears. * * @var string */ private const CLARITY_SNIPPET_REGEX = '/[\'"]clarity[\'"]\s*,\s*[\'"]script[\'"]\s*,\s*[\'"]([a-z0-9]+)[\'"]/i'; /** * Matches a literal clarity.ms/tag/ URL (also JSON-escaped), * capturing the id. * * @var string */ private const CLARITY_TAG_URL_REGEX = '#clarity\.ms\\\\?/tag\\\\?/([a-z0-9]+)#i'; /** * Matches gtag.js loader URLs (also JSON-escaped), capturing the id. * * @var string */ private const LOADER_REGEX = '#googletagmanager\.com\\\\?/gtag\\\\?/js\?id=(G-[A-Z0-9]+|GT-[A-Z0-9]+|UA-\d+-\d+)#'; /** * Matches gtag('config', '...') calls (either quote style), capturing the id. * * @var string */ private const CONFIG_REGEX = '/gtag\(\s*[\'"]config[\'"]\s*,\s*[\'"](G-[A-Z0-9]+|GT-[A-Z0-9]+|UA-\d+-\d+)/'; /** * Tag Manager container ids in gtm.js / ns.html URLs or quoted in the * standard snippet. * * @var string */ private const GTM_REGEX = '/(?:[?&](?:amp;)?id=|[\'"])(GTM-[A-Z0-9]+)/'; /** * How each ad platform's official base code shows its ID, as regular * expressions whose first group captures the ID. Each pattern finds one * mention per copy of the code (the script call, and the noscript image * where the vendor has one), so a copy is the highest count of any * pattern, as for the Google tag. URLs are matched in their * JSON-escaped form too. Where `requires` is set, the patterns are used * only on a page that also contains that text: a UET tag ID is a bare * `ti:"..."`, which is only a tag next to Microsoft's loader. * * The code samples come from docs/research/2026-10-04-pixel-platforms.md * (Google Ads from the gtag.js snippet Google gives), and are pinned by * tests/fixtures/pixels/snippets/. * * @var array */ private const PIXEL_PATTERNS = array( 'meta' => array( 'patterns' => array( '~fbq\(\s*[\'"]init[\'"]\s*,\s*[\'"]?(\d{6,20})~', '~facebook\.com\\\\?/tr\\\\?/?\?id=(\d{6,20})~', ), ), 'gads' => array( 'patterns' => array( '~googletagmanager\.com\\\\?/gtag\\\\?/js\?id=(AW-\d{6,12})~', '~gtag\(\s*[\'"]config[\'"]\s*,\s*[\'"](AW-\d{6,12})~', ), ), 'tiktok' => array( 'patterns' => array( '~ttq\.load\(\s*[\'"]([A-Za-z0-9]{20})[\'"]~', ), ), 'linkedin' => array( 'patterns' => array( '~_linkedin_partner_id\s*=\s*[\'"]?(\d{3,10})~', '~px\.ads\.linkedin\.com\\\\?/collect\\\\?/?\?[^"\'\s>]*?\bpid=(\d{3,10})~', ), ), 'uet' => array( 'patterns' => array( '~\bti\s*:\s*[\'"](\d{4,12})[\'"]~', ), 'requires' => 'bat.bing.com', ), 'pinterest' => array( 'patterns' => array( '~pintrk\(\s*[\'"]load[\'"]\s*,\s*[\'"](\d{13})[\'"]~', '~ct\.pinterest\.com\\\\?/v3\\\\?/?\?[^"\'\s>]*?\btid=(\d{13})~', ), ), 'snapchat' => array( 'patterns' => array( '~snaptr\(\s*[\'"]init[\'"]\s*,\s*[\'"]([0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12})[\'"]~', ), ), 'x' => array( 'patterns' => array( '~twq\(\s*[\'"](?:config|init)[\'"]\s*,\s*[\'"]([A-Za-z0-9]{5,10})[\'"]~', ), ), ); /** * Scans HTML for Google tag ids and Microsoft Clarity project ids. * * @param string $html Page HTML. * @return array{ga4: array, containers: string[], legacy: string[], ids: string[], clarity: string[], clarity_counts: array} * ga4: GA4 ids with how often each is loaded and configured; * containers: GTM- and GT- ids; legacy: UA- ids; ids: all of * those Google ids, unique (GA4, then containers, then legacy); * clarity: Clarity project ids, lower-cased and unique, in page * order; clarity_counts: how many copies of each Clarity * project the page loads. */ public static function scan( string $html ): array { $clarity_counts = self::scan_clarity( $html ); $ga4 = array(); $containers = array(); $legacy = array(); foreach ( array( 'loaders' => self::LOADER_REGEX, 'configs' => self::CONFIG_REGEX, ) as $kind => $regex ) { preg_match_all( $regex, $html, $matches ); foreach ( $matches[1] as $id ) { if ( str_starts_with( $id, 'G-' ) ) { $ga4[ $id ] = $ga4[ $id ] ?? array( 'loaders' => 0, 'configs' => 0, ); $ga4[ $id ][ $kind ] += 1; } elseif ( str_starts_with( $id, 'GT-' ) ) { $containers[] = $id; } else { $legacy[] = $id; } } } preg_match_all( self::GTM_REGEX, $html, $gtm ); $containers = array_values( array_unique( array_merge( $gtm[1], $containers ) ) ); $legacy = array_values( array_unique( $legacy ) ); return array( 'ga4' => $ga4, 'containers' => $containers, 'legacy' => $legacy, 'ids' => array_merge( array_keys( $ga4 ), $containers, $legacy ), // Cast back to strings: PHP turns an all-digit id key into an int. 'clarity' => array_map( 'strval', array_keys( $clarity_counts ) ), 'clarity_counts' => $clarity_counts, ); } /** * Scans HTML for ad pixels. Tagsmith's own config (the inline script * `tagsmith-pixels-js-before`, which says which pixels Tagsmith prints and * under which IDs) is read first and taken out, so it is never counted as * a copy: the pixels themselves are loaded later by script, once consent * allows, and are not in the page's HTML. What is left is every other * place a pixel's base code is written (a theme, another plugin), counted * per ID. * * @param string $html Page HTML. * @return array{ours: array, others: array>} * ours: platform => the ID Tagsmith's config lists for it; * others: platform => ID => copies of its base code found outside * that config. PHP turns an all-digit ID into an integer key: cast * it back with strval() when reading. */ public static function scan_pixels( string $html ): array { $ours = array(); $others = array(); $html = self::without_pixel_config( $html, $ours ); foreach ( self::PIXEL_PATTERNS as $key => $spec ) { if ( isset( $spec['requires'] ) && false === stripos( $html, $spec['requires'] ) ) { continue; } $copies = array(); foreach ( $spec['patterns'] as $regex ) { preg_match_all( $regex, $html, $matches ); $counts = array(); foreach ( $matches[1] as $raw ) { $id = Platforms::normalize_id( $key, $raw ); if ( null !== $id && '' !== $id ) { $counts[ $id ] = ( $counts[ $id ] ?? 0 ) + 1; } } foreach ( $counts as $id => $count ) { $copies[ $id ] = max( $copies[ $id ] ?? 0, $count ); } } if ( array() !== $copies ) { $others[ $key ] = $copies; } } // In the registry's order, whatever order the config listed them in. return array( 'ours' => array_intersect_key( array_replace( array_fill_keys( Platforms::keys(), '' ), $ours ), $ours ), 'others' => $others, ); } /** * Takes Tagsmith's pixel config script out of the HTML, and reads the * platform IDs from it. A config that is not valid JSON lists nothing. * * @param string $html The page HTML. * @param array $ours Receives platform => ID. * @return string The HTML without the config script. */ private static function without_pixel_config( string $html, array &$ours ): string { $stripped = preg_replace_callback( '~]*\bid=["\']tagsmith-pixels-js-before["\'][^>]*>(.*?)~is', static function ( array $block ) use ( &$ours ): string { if ( 1 !== preg_match( '~window\.tagsmithPixels\s*=\s*(\{.*\})\s*;~s', $block[1], $found ) ) { return ''; } $config = json_decode( $found[1], true ); $platforms = is_array( $config ) ? ( $config['platforms'] ?? null ) : null; if ( ! is_array( $platforms ) ) { return ''; } foreach ( $platforms as $key => $entry ) { $id = is_array( $entry ) && is_string( $key ) ? Platforms::normalize_id( $key, $entry['id'] ?? '' ) : null; if ( null !== $id && '' !== $id ) { $ours[ $key ] = $id; } } return ''; }, $html ); return is_string( $stripped ) ? $stripped : $html; } /** * Counts the copies of each Clarity project on the page. Within one * script (the text from one " Lower-cased project id => copies, in page order. */ private static function scan_clarity( string $html ): array { $counts = array(); $blocks = preg_split( '/ Id => matches. */ private static function count_matches( string $regex, string $text ): array { preg_match_all( $regex, $text, $matches ); return array_count_values( array_map( 'strtolower', $matches[1] ) ); } }