connected() ) { return; } // The same rows, policy and currency window Summary::build() hands to // Conflicts::detect() — a check for a conflict the panel would never // show is money spent on a question nobody asked. $catalog = Catalog::known(); $crawlers = array(); foreach ( Table::summary( 7 ) as $row ) { $meta = isset( $catalog[ $row['ua'] ] ) ? (array) $catalog[ $row['ua'] ] : array(); $crawlers[] = array_merge( $row, array( 'name' => isset( $meta[0] ) ? (string) $meta[0] : (string) $row['ua'], 'operator' => isset( $meta[1] ) ? (string) $meta[1] : '', ) ); } $signal = (array) $core->get( 'content_signal', array() ); $policy = array( 'ai_input' => ! isset( $signal['ai_input'] ) || false !== $signal['ai_input'], 'ai_train' => ! isset( $signal['ai_train'] ) || false !== $signal['ai_train'], 'blocked_agents' => array_map( 'strtolower', array_map( 'strval', (array) $core->get( 'blocked_trainers', array() ) ) ), ); $recent = Table::recent( 24 ); $conflicts = Conflicts::detect( $crawlers, $policy, 7, $recent ); $wanted = array(); foreach ( $conflicts as $conflict ) { if ( 'warn' === (string) $conflict['level'] && 0 === strpos( (string) $conflict['id'], 'edge-blocks-' ) ) { // detect() rolls crawlers up per operator; recover the operator // names the same way it built the ids. foreach ( $crawlers as $c ) { $slug = Conflicts::operator_slug( (string) $c['operator'] ); if ( '' !== $slug && 'edge-blocks-' . $slug === (string) $conflict['id'] ) { $wanted[ (string) $c['operator'] ] = true; } } } } if ( ! $wanted ) { $cloudflare->note_spoof_checks( array() ); return; } $now = time(); $out = $client->blocked_sources( $cloudflare->token(), (string) $cloudflare->get( 'zone_id', '' ), $now - DAY_IN_SECONDS, $now ); if ( isset( $out['error'] ) ) { // Keep the previous verdicts — they age out rather than vanishing — // but write the failure down beside them. A check that cannot run // must not be indistinguishable from one that never fires: the // reserved key matches no operator, so no consumer reads it as a // verdict, and the next clean run replaces the whole map. $map = $cloudflare->spoof_checks(); $map['_failed'] = array( 'at' => $now, 'error' => (string) $out['error'] ); $cloudflare->note_spoof_checks( $map ); return; } $rows = self::sample_rows( (array) $out['rows'], $catalog, $wanted, $policy ); // How much blocking the conflict's own crawlers saw in the same last day, // read at the same moment as the sample — what lets // {@see stands_down()} tell a small sample from an incomplete one. $recent_by_op = self::recent_blocked( $crawlers, $policy, $recent ); $checks = self::classify( $rows, array( BotVerifier::class, 'claim_verdict' ) ); foreach ( $checks as $op => $tally ) { $checks[ $op ]['at'] = $now; $checks[ $op ]['recent'] = isset( $recent_by_op[ $op ] ) ? $recent_by_op[ $op ] : 0; } $cloudflare->note_spoof_checks( $checks ); } /** * The blocked-source rows that belong to a firing conflict: a known AI * crawler, of an operator a warn conflict accuses, and not one the owner * wants blocked. * * ⛔ Only the traffic the conflict is ABOUT. A crawler the owner blocks on * purpose never fed the warning ({@see Conflicts::detect()}), so its blocked * requests are no evidence either way — on heera.it (2026-09-23) seven of * the eight sampled "OpenAI" requests were GPTBot, which he blocks. * * @param array $rows Client::blocked_sources() rows { ip, ua, requests }. * @param array $catalog Catalog::known() — token => [ name, operator ]. * @param array $wanted operator => true, the operators a warn conflict accuses. * @param array $policy The declarations {@see Conflicts::detect()} takes. * @return array */ public static function sample_rows( array $rows, array $catalog, array $wanted, array $policy ) { $out = array(); foreach ( $rows as $row ) { $token = Module::crawler_token( (string) $row['ua'] ); if ( '' === $token || ! isset( $catalog[ $token ] ) ) { continue; } $op = (string) $catalog[ $token ][1]; if ( ! isset( $wanted[ $op ] ) || Conflicts::blocking_is_owners_wish( $token, $policy ) ) { continue; } $out[] = array( 'ip' => (string) $row['ip'], 'ua' => (string) $row['ua'], 'operator' => $op, 'requests' => (int) $row['requests'], ); } return $out; } /** * Per operator, the requests the edge blocked in the last day from the * crawlers a warn conflict counts — every crawler of that operator except * the ones the owner wants blocked ({@see Conflicts::blocking_is_owners_wish()}). * * @param array $crawlers Rows with { ua, operator }. * @param array $policy The declarations {@see Conflicts::detect()} takes. * @param array $recent ua => { blocked, probes?, passed } ({@see Table::recent()}). * @return array operator => blocked requests. */ public static function recent_blocked( array $crawlers, array $policy, array $recent ) { $out = array(); foreach ( $crawlers as $c ) { $ua = strtolower( (string) $c['ua'] ); $op = (string) $c['operator']; if ( '' === $op || Conflicts::blocking_is_owners_wish( $ua, $policy ) ) { continue; } $out[ $op ] = ( isset( $out[ $op ] ) ? $out[ $op ] : 0 ) + ( isset( $recent[ $ua ] ) ? Conflicts::refused( (array) $recent[ $ua ] ) : 0 ); } return $out; } /** * The pure half: tally blocked requests per operator by what their source * address proved. One verdict per distinct address — an address's identity * is stable within a run — and at most $cap addresses actually looked up; * requests from addresses past the cap count as undetermined, never as * either verdict. * * @param array $rows * Blocked-source rows, busiest first. * @param callable $verdict function( string $ua, string $ip ): int — * 0 undetermined, 1 the operator's own address, 2 someone else's. * @param int $cap Distinct-address lookup budget. * @return array */ public static function classify( array $rows, $verdict, $cap = self::MAX_IP_LOOKUPS ) { $out = array(); $seen = array(); foreach ( $rows as $row ) { $op = (string) $row['operator']; $n = max( 0, (int) $row['requests'] ); if ( '' === $op || 0 === $n ) { continue; } if ( ! isset( $out[ $op ] ) ) { $out[ $op ] = array( 'sampled' => 0, 'verified' => 0, 'spoofed' => 0, 'unknown' => 0 ); } $out[ $op ]['sampled'] += $n; $ip = (string) $row['ip']; if ( '' === $ip ) { $out[ $op ]['unknown'] += $n; continue; } if ( ! array_key_exists( $ip, $seen ) ) { $seen[ $ip ] = count( $seen ) < (int) $cap ? (int) call_user_func( $verdict, (string) $row['ua'], $ip ) : 0; } if ( 1 === $seen[ $ip ] ) { $out[ $op ]['verified'] += $n; } elseif ( 2 === $seen[ $ip ] ) { $out[ $op ]['spoofed'] += $n; } else { $out[ $op ]['unknown'] += $n; } } return $out; } /** * Whether a stored check says the warning should stand down: everything it * could determine was someone ELSE wearing the operator's name, none of it * the operator, in volume, and recently. * * Any verified request keeps the warning: real blocking is the actionable * fact whatever noise rides beside it. And a sample that is mostly * undetermined keeps it too — "could not say" is not "not them". * * ⛔⛔ THE VOLUME FLOOR GUARDS AGAINST AN INCOMPLETE SAMPLE, NOT A SMALL * ONE. The sample covers one day; the conflict counts seven. As a scanner * fades, the day's blocking shrinks under {@see Conflicts::MIN_BLOCKED} * while the week stays over it — so a flat floor re-armed the warning * exactly as the campaign died. On heera.it (2026-09-23) every one of the * day's blocked "OpenAI" requests was proven fake and the card still said * Cloudflare was blocking OpenAI. When the check also recorded how much * blocking the conflict's crawlers saw that same day (`recent`), proving at * least that many fake is proving all of it, and that is enough. * Without `recent` (a tally stored before it existed) the flat floor stands. * * @param mixed $check A stored tally { at, sampled, verified, spoofed, unknown, recent? }. * @param int $now Current unix time (injected for tests). * @return bool */ public static function stands_down( $check, $now ) { if ( ! is_array( $check ) ) { return false; } $at = isset( $check['at'] ) ? (int) $check['at'] : 0; $sampled = isset( $check['sampled'] ) ? (int) $check['sampled'] : 0; $verified = isset( $check['verified'] ) ? (int) $check['verified'] : 0; $spoofed = isset( $check['spoofed'] ) ? (int) $check['spoofed'] : 0; $recent = isset( $check['recent'] ) ? (int) $check['recent'] : 0; $floor = $recent > 0 ? min( Conflicts::MIN_BLOCKED, $recent ) : Conflicts::MIN_BLOCKED; return $at > 0 && ( $now - $at ) <= self::FRESH_SECONDS && 0 === $verified && $spoofed >= $floor && $sampled > 0 && $spoofed >= $sampled * self::MIN_SPOOFED_SHARE; } }