urls_to_scan(); $found = []; $errors = []; $cookies_map = COOKROCO_Blocker::category_cookies(); $vendors = COOKROCO_Blocker::vendors(); foreach ( $urls as $url ) { $resp = wp_remote_get( $url, [ 'timeout' => 15, 'redirection' => 2, 'user-agent' => 'CookieRocketScanner/1.0 (+' . home_url( '/' ) . ')', ] ); if ( is_wp_error( $resp ) ) { $errors[] = $url; continue; } // A response that is NOT 2xx is not the site's page: it is a basic-auth 401, a // firewall 403, a Cloudflare challenge or a 500. Analyzing it as if it were the site // always returns zero trackers — no signature matches an error page — and that zero // would be presented as "clean". A staging site behind a password would otherwise // get a reassuring verdict without one of its pages having been read. $code = (int) wp_remote_retrieve_response_code( $resp ); $body = (string) wp_remote_retrieve_body( $resp ); // A 404 can be two very different things and they have to be told apart. If the theme // rendered it, it is a site template with its own scripts and deserves reviewing — the // deep crawl asks for one on purpose. If the server returned it because permalinks are // broken, there is nothing to read. The difference is measurable: an Apache 404 weighs // about 355 bytes; a theme's, hundreds of thousands. // Size alone is not enough: a host-branded 404, or the one from a WordPress whose // rewrite rules have died, also exceeds 2 KB and is not a theme template. A // fingerprint that WordPress rendered it is required as well. If a site rewrites those // paths and its 404 does not carry one, it counts as unreadable: erring toward "could // not be read" is harmless, erring toward "clean" is not. $rendered = ( $code >= 200 && $code < 300 ) || ( 404 === $code && strlen( $body ) >= self::MIN_PAGE_BYTES && ( false !== stripos( $body, 'wp-content' ) || false !== stripos( $body, 'wp-includes' ) ) ); if ( ! $rendered ) { $errors[] = $url; continue; } $html = $body; if ( '' === $html ) { $errors[] = $url; continue; } foreach ( $vendors as $id => $v ) { if ( isset( $found[ $id ] ) ) { continue; } $via = $this->html_matches_vendor( $html, $v ); if ( '' !== $via ) { $found[ $id ] = [ 'label' => $v['label'], 'cat' => $v['cat'], 'cookies' => ! empty( $v['cookies'] ) ? $v['cookies'] : ( $cookies_map[ $v['cat'] ] ?? [] ), // per-vendor cookies, not the whole category superset 'via' => $via, // 'src' = enqueued and blockable; 'inline' = needs raw-HTML blocking ]; } } if ( ! isset( $found['google_tag_manager'] ) && $this->html_has_gtm( $html ) ) { $found['google_tag_manager'] = [ 'label' => 'Google Tag Manager', 'cat' => 'analytics', 'cookies' => [], 'via' => 'src', 'managed' => true, // gated by Consent Mode, not by script blocking ]; } } $this->enable_detected_categories( $found ); // What was found last time is kept so the scan can say WHAT CHANGED. Without it a scan // is an isolated snapshot with no way to report "a new tracker appeared". $previous_found = (array) ( get_option( self::OPTION, [] )['found'] ?? [] ); $stored = (array) get_option( self::OPTION, [] ); $blind = ! empty( $urls ) && count( $errors ) >= count( $urls ); if ( $blind ) { // Not a single page was read: the zero above is not a finding, it is the absence of // reading. Storing it as a result would empty the published cookie policy and wipe the // inventory — with a firewall answering 403 to the monthly cron, a site would lose its // declaration without anyone touching anything. The last reading that DID happen is // kept, with its date, and only the failed attempt is recorded: that is what the // dashboard notice runs on, which already tells "could not be read" from "nothing". $result = $stored; $result['scanned'] = $urls; $result['errors'] = $errors; $result['blind'] = true; $result['tried'] = current_time( 'mysql' ); if ( ! isset( $result['found'] ) ) $result['found'] = []; if ( ! isset( $result['prev_found'] ) ) $result['prev_found'] = []; if ( ! isset( $result['new'] ) ) $result['new'] = []; if ( ! isset( $result['gone'] ) ) $result['gone'] = []; // Without a previous scan there is no date to keep, and without a date scan_age_days() // returns null: the screen says "run your first scan" and the failure becomes invisible, // the exact opposite of what this branch is for. The date of the attempt is stamped. if ( empty( $result['ts'] ) ) { $result['time'] = current_time( 'mysql' ); $result['ts'] = time(); } update_option( self::OPTION, $result ); return $result; } $result = [ 'found' => $found, 'prev_found' => $previous_found, 'new' => array_values( array_diff( array_keys( $found ), array_keys( $previous_found ) ) ), 'gone' => array_values( array_diff( array_keys( $previous_found ), array_keys( $found ) ) ), 'scanned' => $urls, 'errors' => $errors, 'time' => current_time( 'mysql' ), 'ts' => time(), ]; update_option( self::OPTION, $result ); return $result; } /** * After a scan, mark every category that has a detected tracker as enabled, so those cookies * actually appear in the generated Cookie Policy — a category left disabled is skipped from * the declaration (COOKROCO_Policy), which would leave real cookies undisclosed. * * Strictly additive: it only ever ENABLES. It never disables a category the owner chose to * keep off, never touches the strictly-necessary one, and only writes when something changed. */ private function enable_detected_categories( array $found ): void { if ( empty( $found ) ) { return; } $cats = get_option( 'cookroco_cookie_categories', [] ); if ( ! is_array( $cats ) ) { return; } $changed = false; foreach ( $found as $vendor ) { $cat = isset( $vendor['cat'] ) ? (string) $vendor['cat'] : ''; if ( '' === $cat || empty( $cats[ $cat ] ) || ! is_array( $cats[ $cat ] ) ) { continue; } if ( empty( $cats[ $cat ]['enabled'] ) ) { $cats[ $cat ]['enabled'] = true; $changed = true; } } if ( $changed ) { update_option( 'cookroco_cookie_categories', $cats ); } } /** * How the HTML matched a vendor: 'src' (an enqueued/tagged script URL), 'inline' (a signature * inside a hand-pasted script body), or '' for no match. * * The distinction is load-bearing: script_loader_tag only reaches enqueued scripts, so an * inline match is NOT blocked unless block_raw_html is on. The scanner reports which is which * so the status column can state what is actually true. */ private function html_matches_vendor( string $html, array $v ): string { // A 'src' only counts when the signature lives INSIDE an src= attribute: the same // string also appears in the body of a hand-pasted script (for example // t.src='https://www.clarity.ms/tag/'+i), and script_loader_tag does NOT reach that // case. Conflating them would make the status column claim a block that does not exist. foreach ( $v['src'] as $needle ) { // The lookbehind excludes JavaScript's `t.src=` and lazy loading's `data-src=`: only a // real HTML src attribute counts, which is all script_loader_tag touches. // data-cookroco-src is the same attribute once Cookie Rocket has blocked the file. if ( preg_match( '#(?:(? 0 ) { $permalink = get_permalink( $id ); if ( $permalink ) { $urls[] = $permalink; } } } } /** * Lets URLs be added to the crawl. Cookie Rocket Pro hooks its deep sitemap crawl here; * a developer can add whatever templates matter to them. * * @param string[] $urls The URLs that will be read. */ $urls = (array) apply_filters( 'cookroco_scan_urls', $urls ); return array_values( array_unique( array_filter( array_map( 'strval', $urls ) ) ) ); } /** Last stored scan result (or empty array). */ public function last_scan(): array { $r = get_option( self::OPTION, [] ); return is_array( $r ) ? $r : []; } /** * Whole days since the last scan, or null if the site was never scanned. * A scan is a point-in-time snapshot; this powers the "your inventory is aging" notice. */ public function scan_age_days(): ?int { $last = $this->last_scan(); $ts = isset( $last['ts'] ) ? (int) $last['ts'] : ( ! empty( $last['time'] ) ? (int) strtotime( (string) $last['time'] ) : 0 ); if ( ! $ts ) { return null; } // Clamp to 0 so a future/clock-skewed timestamp can never show a negative "days ago". return max( 0, (int) floor( ( time() - $ts ) / DAY_IN_SECONDS ) ); } /** True when a scan exists but is older than STALE_DAYS — the site may have changed since. */ public function is_stale(): bool { $age = $this->scan_age_days(); return null !== $age && $age >= self::STALE_DAYS; } }