, 'status'=>, 'reason'=> ], ... ]. */ /** * Audit every external link in HTML and return a verdict for each. * [ [ 'url'=>, 'ok'=>bool, 'status'=>int, 'reason'=>, 'type'=> 'http'|'amazon-api'|'amazon' ], ... ] */ public static function audit_content( $html ) { $results = array(); $use_pa = class_exists( 'AICitare_Amazon' ) && AICitare_Amazon::configured(); $texts = self::extract_links( $html ); // url => anchor text. $amazon_asins = array(); // host => [ asin => url ]. foreach ( $texts as $url => $text ) { $host = wp_parse_url( $url, PHP_URL_HOST ); if ( self::is_amazon_link( $url, $host ) ) { if ( $use_pa && AICitare_Amazon::is_amazon_host( $host ) ) { $asin = AICitare_Amazon::asin( $url ); if ( $asin ) { $amazon_asins[ $host ][ $asin ] = $url; continue; } } $results[] = array( 'url' => $url, 'ok' => true, 'status' => 0, 'reason' => 'Amazon — not verified', 'type' => 'amazon', 'text' => $text ); continue; } $r = self::check_url( $url ); $results[] = array( 'url' => $url, 'ok' => ! empty( $r['ok'] ), 'status' => isset( $r['status'] ) ? $r['status'] : 0, 'reason' => isset( $r['reason'] ) ? $r['reason'] : '', 'type' => 'http', 'text' => $text, ); } foreach ( $amazon_asins as $host => $map ) { $verdicts = AICitare_Amazon::check_asins( array_keys( $map ), $host ); foreach ( $verdicts as $asin => $v ) { $ok = ! empty( $v['ok'] ); $url = $map[ $asin ]; $results[] = array( 'url' => $url, 'ok' => $ok, 'status' => 0, 'reason' => $ok ? ( '' !== $v['reason'] ? $v['reason'] : 'verified via PA-API' ) : ( $v['reason'] ? $v['reason'] : 'product not found' ), 'type' => 'amazon-api', 'text' => isset( $texts[ $url ] ) ? $texts[ $url ] : '', ); } } return $results; } /** Broken links only (kept for back-compat). */ public static function check_content( $html ) { $broken = array(); $use_pa = class_exists( 'AICitare_Amazon' ) && AICitare_Amazon::configured(); $amazon_asins = array(); // host => [ asin => url ]. foreach ( self::extract_links( $html ) as $url => $text ) { $host = wp_parse_url( $url, PHP_URL_HOST ); $is_amazon = self::is_amazon_link( $url, $host ); // Amazon is verified ONLY via the official PA-API. Raw HTTP checks against // Amazon are unreliable (bot-blocking / timeouts) and cause false "broken" // reports, so without PA-API we skip Amazon links entirely. if ( $is_amazon ) { if ( $use_pa && AICitare_Amazon::is_amazon_host( $host ) ) { $asin = AICitare_Amazon::asin( $url ); if ( $asin ) { $amazon_asins[ $host ][ $asin ] = $url; } } continue; // Never flag Amazon links via HTTP. } $r = self::check_url( $url ); if ( $r && empty( $r['ok'] ) && empty( $r['skipped'] ) ) { $broken[] = array( 'url' => $url, 'status' => $r['status'], 'reason' => $r['reason'] ); } } foreach ( $amazon_asins as $host => $map ) { $verdicts = AICitare_Amazon::check_asins( array_keys( $map ), $host ); foreach ( $verdicts as $asin => $v ) { if ( empty( $v['ok'] ) ) { $broken[] = array( 'url' => $map[ $asin ], 'status' => 0, 'reason' => $v['reason'] ? $v['reason'] : 'product not found' ); } } } return $broken; } /** Is this any Amazon storefront or short link? */ private static function is_amazon_link( $url, $host ) { if ( false !== stripos( $url, 'amzn.to' ) || false !== stripos( $url, 'amzn.com' ) ) { return true; } return (bool) preg_match( '#(^|\.)amazon\.[a-z.]+$#', strtolower( (string) $host ) ); } /** Pull unique external http(s) links from HTML (capped). Returns url => anchor-text. */ private static function extract_links( $html ) { $out = array(); if ( preg_match_all( '#]*href=["\']([^"\']+)["\'][^>]*>(.*?)#is', (string) $html, $m, PREG_SET_ORDER ) ) { foreach ( $m as $mm ) { $h = html_entity_decode( $mm[1], ENT_QUOTES ); if ( ! preg_match( '#^https?://#i', $h ) ) { continue; // Only absolute external links. } $h = preg_replace( '#\#.*$#', '', $h ); $host = wp_parse_url( $h, PHP_URL_HOST ); if ( $host && 0 === strcasecmp( $host, (string) wp_parse_url( home_url(), PHP_URL_HOST ) ) ) { continue; // Skip our own domain. } if ( ! isset( $out[ $h ] ) ) { $out[ $h ] = trim( preg_replace( '#\s+#', ' ', wp_strip_all_tags( $mm[2] ) ) ); if ( count( $out ) >= self::MAX_PER_POST ) { break; } } } } return $out; } /** Check a single URL (cached). */ public static function check_url( $url ) { $key = 'aicitare_lnk2_' . md5( $url ); // v2: status-only (invalidates old body-based cache). $cached = get_transient( $key ); if ( is_array( $cached ) ) { return $cached; } if ( self::$budget <= 0 ) { return array( 'ok' => true, 'status' => 0, 'reason' => 'not checked', 'skipped' => true ); } self::$budget--; $args = array( 'timeout' => 6, 'redirection' => 4, 'user-agent' => 'Mozilla/5.0 (compatible; GEO-Dashboard-LinkCheck/1.0)', 'headers' => array( 'Accept' => '*/*' ), ); $res = wp_remote_head( $url, $args ); $code = is_wp_error( $res ) ? 0 : (int) wp_remote_retrieve_response_code( $res ); if ( is_wp_error( $res ) || 0 === $code || 403 === $code || 405 === $code || $code >= 500 ) { $res = wp_remote_get( $url, $args ); // Some servers reject HEAD. } $out = self::classify( $res ); set_transient( $key, $out, $out['ok'] ? self::TTL : self::TTL_BAD ); return $out; } /** * Turn an HTTP response into an ok/broken verdict — status-code based only. * * We deliberately do NOT parse page bodies: large retailers (Amazon in * particular) serve bots a captcha / blocked / empty page instead of the real * product, so body text is unreliable and causes false positives. A genuinely * dead product returns a 404/410 status, which we catch here. */ private static function classify( $res ) { if ( is_wp_error( $res ) ) { return array( 'ok' => false, 'status' => 0, 'reason' => 'unreachable' ); } $code = (int) wp_remote_retrieve_response_code( $res ); // Bot-blocks / rate limits: cannot verify, do not flag. if ( in_array( $code, array( 401, 403, 429, 503 ), true ) ) { return array( 'ok' => true, 'status' => $code, 'reason' => 'blocked (unverified)' ); } if ( 404 === $code || 410 === $code ) { return array( 'ok' => false, 'status' => $code, 'reason' => 'HTTP ' . $code . ' not found' ); } if ( $code >= 400 && $code < 500 ) { return array( 'ok' => false, 'status' => $code, 'reason' => 'HTTP ' . $code ); } if ( $code >= 500 ) { return array( 'ok' => true, 'status' => $code, 'reason' => 'server error (unverified)' ); } return array( 'ok' => true, 'status' => $code ? $code : 200, 'reason' => '' ); } }