| Server IP : 146.59.209.152 / Your IP : 216.73.216.152 Web Server : Apache System : Linux webm009.cluster131.gra.hosting.ovh.net 6.18.42-ovh-vps-grsec-zfs+ #1 SMP PREEMPT_DYNAMIC Wed Aug 5 15:59:48 CEST 2026 x86_64 User : monpetu ( 144298) PHP Version : 7.4.33 Disable Function : _dyuweyrj4,_dyuweyrj4r,dl MySQL : OFF | cURL : ON | WGET : ON | Perl : ON | Python : ON | Sudo : OFF | Pkexec : OFF Directory : /home/monpetu/www/ipprint/wp-content/plugins/surerank/inc/google-search-console/ |
Upload File : |
<?php
/**
* URL Inspection
*
* Wraps the Google Search Console URL Inspection API and normalizes the
* response into the five indexing states the SureRank meta box renders.
* Also owns the post/term meta cache layer.
*
* @since 1.7.5
* @package SureRank
*/
namespace SureRank\Inc\GoogleSearchConsole;
use SureRank\Inc\Frontend\Crawl_Optimization;
use SureRank\Inc\Traits\Get_Instance;
use WP_Term;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
/**
* Url_Inspection
*
* @since 1.7.5
*/
class Url_Inspection {
use Get_Instance;
/**
* Cache key stored against post/term meta.
*/
public const META_KEY = 'surerank_url_inspection';
/**
* Cache freshness in seconds (12 hours).
*/
public const FRESH_TTL = 12 * HOUR_IN_SECONDS;
/**
* Search Console URL Inspection endpoint.
*/
private const ENDPOINT = 'https://searchconsole.googleapis.com/v1/urlInspection/index:inspect';
/**
* Register the cached inspection result as protected meta on every
* public post type and every public taxonomy.
*
* @since 1.7.5
* @return void
*/
public static function register_meta_keys(): void {
$args = [
'type' => 'string',
'single' => true,
'show_in_rest' => false,
'auth_callback' => '__return_false',
];
foreach ( get_post_types( [ 'public' => true ] ) as $post_type ) {
register_post_meta( $post_type, self::META_KEY, $args );
}
foreach ( get_taxonomies( [ 'public' => true ] ) as $taxonomy ) {
register_term_meta( $taxonomy, self::META_KEY, $args );
}
}
/**
* Inspect a post URL — handles cache get/set + freshness.
*
* @param int $post_id Post ID.
* @param bool $refresh Force cache bypass.
* @return array{ok:bool, payload:array<string, mixed>, code?:string}
* @since 1.7.5
*/
public function get_for_post( int $post_id, bool $refresh = false ): array {
$url = $this->resolve_post_url( $post_id );
if ( '' === $url ) {
return $this->error_response( 'no_permalink', __( 'No public URL available yet (draft or invalid object).', 'surerank' ) );
}
return $this->get_cached_or_fetch( 'post', $post_id, $url, $refresh );
}
/**
* Inspect a term URL — handles cache get/set + freshness.
*
* @param int $term_id Term ID.
* @param bool $refresh Force cache bypass.
* @return array{ok:bool, payload:array<string, mixed>, code?:string}
* @since 1.7.5
*/
public function get_for_term( int $term_id, bool $refresh = false ): array {
$url = $this->resolve_term_url( $term_id );
if ( '' === $url ) {
return $this->error_response( 'no_permalink', __( 'No public URL available yet (draft or invalid object).', 'surerank' ) );
}
return $this->get_cached_or_fetch( 'term', $term_id, $url, $refresh );
}
/**
* Whether the selected GSC property matches the current WordPress site.
*
* Inspection results are only meaningful when the connected property
* actually covers this site. If the user connected GSC but selected a
* different property (different domain), inspection is suppressed so
* stale or irrelevant data can't surface.
*
* @return bool
* @since 1.7.5
*/
public static function selected_site_matches_current(): bool {
$selected = (string) Auth::get_instance()->get_credentials( 'site_url' );
if ( '' === $selected ) {
return false;
}
return self::normalize_site_url( $selected ) === self::normalize_site_url( (string) get_site_url() );
}
/**
* Normalize a Search Console / WP site URL for comparison.
*
* Strips an `sc-domain:` prefix (domain properties), otherwise strips
* scheme/`www.`/trailing slashes so a property like
* `https://www.example.com/` matches a WP install at `https://example.com`.
*
* @param string $url Raw URL or `sc-domain:` property value.
* @return string Normalized comparable string.
* @since 1.7.5
*/
public static function normalize_site_url( string $url ): string {
if ( 0 === strpos( $url, 'sc-domain:' ) ) {
return rtrim( substr( $url, strlen( 'sc-domain:' ) ), '/' );
}
$stripped = (string) preg_replace( '#^https?://#i', '', $url );
$stripped = (string) preg_replace( '#^www\.#i', '', $stripped );
return rtrim( $stripped, '/' );
}
/**
* Map a Search Console inspection response into the five SureRank states.
*
* @param array<string, mixed> $response Raw response from inspect API.
* @return array<string, mixed> Normalized payload.
* @since 1.7.5
*/
protected function normalize( array $response ): array {
// API shape: { inspectionResult: { indexStatusResult: { ... } } }.
$result = $response['inspectionResult'] ?? [];
$index_result = $result['indexStatusResult'] ?? [];
$verdict = (string) ( $index_result['verdict'] ?? '' );
$coverage = (string) ( $index_result['coverageState'] ?? '' );
$indexing_st = (string) ( $index_result['indexingState'] ?? '' );
$last_crawl = (string) ( $index_result['lastCrawlTime'] ?? '' );
return [
'status' => $this->resolve_status( $verdict, $coverage, $indexing_st ),
'coverageState' => $coverage,
'verdict' => $verdict,
'indexingState' => $indexing_st,
'lastCrawlTime' => $last_crawl,
'checked_at' => time(),
];
}
/**
* Cache-aware fetch — returns fresh cache when within TTL, otherwise
* calls Google and persists.
*
* @param string $kind 'post' or 'term'.
* @param int $object Post or term ID.
* @param string $url URL to inspect.
* @param bool $refresh Force cache bypass.
* @return array{ok:bool, payload:array<string, mixed>, code?:string}
* @since 1.7.5
*/
private function get_cached_or_fetch( string $kind, int $object, string $url, bool $refresh ): array {
$cached = $this->read_cache( $kind, $object );
if ( $cached && ! $refresh && $this->is_fresh( $cached ) ) {
$cached['fresh'] = true;
return [
'ok' => true,
'payload' => $cached,
];
}
$result = $this->call_api( $url );
if ( isset( $result['error'] ) && $result['error'] ) {
$payload = [
'message' => $result['message'] ?? __( 'Unable to fetch indexing status.', 'surerank' ),
'error_code' => $result['code'] ?? 'unknown_error',
];
if ( $cached ) {
$payload = array_merge( $cached, $payload );
$payload['stale'] = true;
}
return [
'ok' => false,
'payload' => $payload,
'code' => (string) ( $result['code'] ?? 'unknown_error' ),
];
}
$this->write_cache( $kind, $object, $result );
$result['fresh'] = false;
return [
'ok' => true,
'payload' => $result,
];
}
/**
* Read cached inspection result.
*
* @param string $kind 'post' or 'term'.
* @param int $object Object ID.
* @return array<string, mixed>|null
* @since 1.7.5
*/
private function read_cache( string $kind, int $object ): ?array {
$cached = 'term' === $kind
? get_term_meta( $object, self::META_KEY, true )
: get_post_meta( $object, self::META_KEY, true );
return is_array( $cached ) && ! empty( $cached ) ? $cached : null;
}
/**
* Persist inspection result to meta.
*
* @param string $kind 'post' or 'term'.
* @param int $object Object ID.
* @param array<string, mixed> $result Result payload.
* @return void
* @since 1.7.5
*/
private function write_cache( string $kind, int $object, array $result ): void {
if ( 'term' === $kind ) {
update_term_meta( $object, self::META_KEY, $result );
} else {
update_post_meta( $object, self::META_KEY, $result );
}
}
/**
* Whether the cache entry is within the freshness TTL.
*
* @param array<string, mixed> $cached Cached payload.
* @return bool
* @since 1.7.5
*/
private function is_fresh( array $cached ): bool {
return isset( $cached['checked_at'] )
&& ( time() - (int) $cached['checked_at'] ) < self::FRESH_TTL;
}
/**
* Call the Search Console URL Inspection API for the given URL.
*
* @param string $url Fully-qualified URL to inspect.
* @return array<string, mixed> Normalized result or error payload from GoogleConsole::call_api().
* @since 1.7.5
*/
private function call_api( string $url ): array {
$site_url = (string) Auth::get_instance()->get_credentials( 'site_url' );
if ( '' === $site_url ) {
return [
'error' => true,
'message' => __( 'No Search Console property selected.', 'surerank' ),
'code' => 'no_site_selected',
];
}
$body = [
'inspectionUrl' => $url,
'siteUrl' => $site_url,
'languageCode' => substr( get_locale(), 0, 2 ),
];
$response = GoogleConsole::get_instance()->call_api( self::ENDPOINT, 'POST', $body );
if ( isset( $response['error'] ) && $response['error'] ) {
return $response;
}
// A 2xx with an empty/malformed body decodes to null (or a scalar),
// which would fatal in normalize()'s array type-hint. Surface it as a
// mapped error instead so the UI shows a failure, not "Checking…".
if ( ! is_array( $response ) ) {
return [
'error' => true,
'message' => __( 'Unexpected response from Search Console.', 'surerank' ),
'code' => 'invalid_response',
];
}
return $this->normalize( $response );
}
/**
* Resolve one of the five SureRank indexing statuses.
*
* Driven primarily off `coverageState` — Google guarantees the wording
* matches what's shown inside the Search Console UI, so it's the most
* stable signal across API versions.
*
* @param string $verdict indexStatusResult.verdict (PASS|PARTIAL|FAIL|NEUTRAL).
* @param string $coverage indexStatusResult.coverageState.
* @param string $indexing_st indexStatusResult.indexingState.
* @return string One of: indexed | crawled_not_indexed | discovered_not_indexed | noindex | other_not_indexed.
* @since 1.7.5
*/
private function resolve_status( string $verdict, string $coverage, string $indexing_st ): string {
if ( in_array( $indexing_st, [ 'BLOCKED_BY_META_TAG', 'BLOCKED_BY_HTTP_HEADER' ], true ) ) {
return 'noindex';
}
if ( 'PASS' === $verdict ) {
return 'indexed';
}
if ( 0 === strpos( $coverage, 'Crawled' ) ) {
return 'crawled_not_indexed';
}
if ( 0 === strpos( $coverage, 'Discovered' ) ) {
return 'discovered_not_indexed';
}
return 'other_not_indexed';
}
/**
* Resolve a post's public URL (published posts only).
*
* @param int $post_id Post ID.
* @return string Permalink, or empty string when unavailable.
* @since 1.7.5
*/
private function resolve_post_url( int $post_id ): string {
if ( $post_id <= 0 ) {
return '';
}
if ( 'publish' !== get_post_status( $post_id ) ) {
return '';
}
$permalink = get_permalink( $post_id );
return is_string( $permalink ) ? $permalink : '';
}
/**
* Resolve a term's public URL, mirroring SureRank's canonical when
* `surerank_remove_category_base` filter is active.
*
* @param int $term_id Term ID.
* @return string Term link, or empty string when unavailable.
* @since 1.7.5
*/
private function resolve_term_url( int $term_id ): string {
if ( $term_id <= 0 ) {
return '';
}
$term_link = get_term_link( $term_id );
if ( is_wp_error( $term_link ) ) {
return '';
}
$term = get_term( $term_id );
if ( $term instanceof WP_Term
&& 'category' === $term->taxonomy
&& apply_filters( 'surerank_remove_category_base', false )
) {
$crawl = Crawl_Optimization::get_instance();
$term_link = $crawl->remove_category_base_from_links( $term_link, $term, $term->taxonomy );
}
return (string) $term_link;
}
/**
* Shape a non-API error response (e.g. missing permalink).
*
* @param string $code Stable error code for the JS layer.
* @param string $message Human-readable message.
* @return array{ok:bool, payload:array<string, mixed>, code:string}
* @since 1.7.5
*/
private function error_response( string $code, string $message ): array {
return [
'ok' => false,
'payload' => [
'message' => $message,
'error_code' => $code,
],
'code' => $code,
];
}
}