admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'qwsc_speedtest' ), 'keyUrl' => self::KEY_URL, 'psiUrl' => 'https://pagespeed.web.dev/analysis?url=', 'hasKey' => ( '' !== self::api_key() ), 'features' => self::feature_snapshot(), 'latest' => self::latest_result(), 'links' => array( 'cache' => admin_url( 'admin.php?page=qw-speed-cache-cache' ), 'optimize' => admin_url( 'admin.php?page=qw-speed-cache-optimize' ), 'critical' => admin_url( 'admin.php?page=qw-speed-cache-critical' ), 'media' => admin_url( 'admin.php?page=qw-speed-cache-media' ), 'assets' => admin_url( 'admin.php?page=qw-speed-cache-assets' ), 'presets' => admin_url( 'admin.php?page=qw-speed-cache-presets' ), 'advanced' => admin_url( 'admin.php?page=qw-speed-cache-advanced' ), 'license' => admin_url( 'admin.php?page=qw-speed-cache-license' ), 'dashboard' => admin_url( 'admin.php?page=qw-speed-cache' ), ), 'i18n' => array( 'mobile' => __( 'Mobile', 'qw-optimize' ), 'desktop' => __( 'Desktop', 'qw-optimize' ), 'testing' => __( 'Testing on Google’s servers…', 'qw-optimize' ), 'takes' => __( 'This usually takes 20 to 60 seconds.', 'qw-optimize' ), 'perf' => __( 'Performance', 'qw-optimize' ), 'a11y' => __( 'Accessibility', 'qw-optimize' ), 'bp' => __( 'Best practices', 'qw-optimize' ), 'seo' => __( 'SEO', 'qw-optimize' ), 'fix' => __( 'What to improve', 'qw-optimize' ), 'nofix' => __( 'No major issues found. Nice work.', 'qw-optimize' ), 'metrics' => __( 'Key metrics', 'qw-optimize' ), 'field' => __( 'Real visitors', 'qw-optimize' ), 'retry' => __( 'Try again', 'qw-optimize' ), 'failed' => __( 'The test could not finish.', 'qw-optimize' ), 'badUrl' => __( 'Enter a full web address that starts with http:// or https://', 'qw-optimize' ), 'running' => __( 'Testing…', 'qw-optimize' ), 'run' => __( 'Run speed test', 'qw-optimize' ), 'seconds' => __( 's', 'qw-optimize' ), 'tested' => __( 'Tested', 'qw-optimize' ), 'getKey' => __( 'Get a free API key', 'qw-optimize' ), 'haveKey' => __( 'I already have a key', 'qw-optimize' ), ), ) ); } /** * @return string */ /** * Current on/off state for recommendation cards. * * @return array */ public static function feature_snapshot() { $s = get_option( 'qwsc_settings', array() ); if ( ! is_array( $s ) ) { $s = array(); } $on = static function ( $key ) use ( $s ) { return ! empty( $s[ $key ] ); }; return array( 'page_cache' => $on( 'cache_enabled' ) || $on( 'page_cache_enabled' ), 'minify_css' => $on( 'minify_css' ), 'minify_js' => $on( 'minify_js' ), 'minify_html' => $on( 'minify_html' ), 'delay_js' => $on( 'delay_js' ), 'combine_css' => $on( 'combine_css' ), 'rucss' => $on( 'remove_unused_css' ), 'critical_css' => $on( 'critical_css_enabled' ), 'lazyload' => $on( 'lazyload_images' ) || $on( 'lazyload_iframes' ), 'webp' => $on( 'webp_delivery' ) || $on( 'avif_delivery' ), 'asset_unload' => $on( 'asset_unload_enabled' ), 'browser_cache' => $on( 'browser_cache_enabled' ), 'critical_images' => $on( 'preload_critical_images' ), 'defer_js' => $on( 'defer_js' ), 'font_display' => $on( 'font_display_sitewide' ), 'font_optimize' => $on( 'google_fonts_optimize' ), ); } /** * Dedicated option so the key is never lost via settings merge / settings filtering. */ const KEY_OPTION = 'qwsc_pagespeed_api_key'; /** * Read PageSpeed API key (dedicated option, then legacy settings fallback). */ public static function api_key() { $key = get_option( self::KEY_OPTION, '' ); if ( is_string( $key ) && '' !== trim( $key ) ) { return trim( $key ); } $s = get_option( 'qwsc_settings', array() ); if ( ! is_array( $s ) ) { return ''; } if ( ! empty( $s['pagespeed_api_key'] ) ) { return trim( (string) $s['pagespeed_api_key'] ); } if ( ! empty( $s['crux_api_key'] ) ) { return trim( (string) $s['crux_api_key'] ); } return ''; } /** * Masked preview for UI (never show full key). */ public static function api_key_masked() { $key = self::api_key(); if ( '' === $key ) { return ''; } $len = strlen( $key ); if ( $len <= 8 ) { return str_repeat( '•', $len ); } return substr( $key, 0, 4 ) . str_repeat( '•', max( 6, $len - 8 ) ) . substr( $key, -4 ); } public function save_key() { check_admin_referer( 'qwsc_speedtest_key' ); if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Unauthorized', 'qw-optimize' ), '', array( 'response' => 403 ) ); } $raw = isset( $_POST['qwsc_psi_key'] ) && is_string( $_POST['qwsc_psi_key'] ) ? trim( wp_unslash( $_POST['qwsc_psi_key'] ) ) : ''; $key = preg_replace( '/[^A-Za-z0-9_\-]/', '', $raw ); if ( '' === $key ) { // Empty field: keep existing — do NOT show false "saved" success. wp_safe_redirect( admin_url( 'admin.php?page=' . self::SLUG . '&key=keep' ) ); exit; } // Store in dedicated option (autoload off). update_option( self::KEY_OPTION, $key, false ); // Mirror into settings for CrUX / older code paths. $s = get_option( 'qwsc_settings', array() ); if ( ! is_array( $s ) ) { $s = array(); } $s['crux_api_key'] = $key; update_option( 'qwsc_settings', $s, false ); if ( method_exists( \QWSpeedCache\Plugin::instance(), 'reload_settings' ) ) { \QWSpeedCache\Plugin::instance()->reload_settings(); } wp_safe_redirect( admin_url( 'admin.php?page=' . self::SLUG . '&key=1' ) ); exit; } public function ajax_run() { check_ajax_referer( 'qwsc_speedtest', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'You do not have permission to do this.', 'qw-optimize' ) ), 403 ); } $url = isset( $_POST['url'] ) && is_string( $_POST['url'] ) ? esc_url_raw( trim( wp_unslash( $_POST['url'] ) ) ) : ''; $strategy = isset( $_POST['strategy'] ) && is_string( $_POST['strategy'] ) ? sanitize_key( wp_unslash( $_POST['strategy'] ) ) : 'mobile'; $run_id = isset( $_POST['run_id'] ) && is_string( $_POST['run_id'] ) ? sanitize_key( wp_unslash( $_POST['run_id'] ) ) : ''; if ( ! in_array( $strategy, array( 'mobile', 'desktop' ), true ) ) { $strategy = 'mobile'; } if ( '' === $url || ! wp_http_validate_url( $url ) ) { wp_send_json_error( array( 'message' => __( 'Enter a public web address that starts with http:// or https://', 'qw-optimize' ) ) ); } $result = $this->run( $url, $strategy, self::api_key() ); if ( is_wp_error( $result ) ) { wp_send_json_error( array( 'message' => $result->get_error_message(), 'code' => $result->get_error_code() ) ); } $this->remember( $url, $strategy, $result ); $this->remember_latest_full( $url, $strategy, $result, $run_id ); wp_send_json_success( $result ); } /** * Local quick check — no Google API key required. */ public function ajax_local() { check_ajax_referer( 'qwsc_speedtest', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'You do not have permission to do this.', 'qw-optimize' ) ), 403 ); } $url = isset( $_POST['url'] ) && is_string( $_POST['url'] ) ? esc_url_raw( trim( wp_unslash( $_POST['url'] ) ) ) : ''; if ( '' === $url || ! wp_http_validate_url( $url ) ) { wp_send_json_error( array( 'message' => __( 'Enter a public web address that starts with http:// or https://', 'qw-optimize' ) ) ); } $result = self::local_analyze( $url ); if ( is_wp_error( $result ) ) { wp_send_json_error( array( 'message' => $result->get_error_message() ) ); } $this->remember_latest_local( $result ); wp_send_json_success( $result ); } /** * Fetch a URL from this server and report basic performance signals. * * @param string $url Page URL. * @return array|\WP_Error */ public static function local_analyze( $url ) { // Validate again at the network boundary: callers other than AJAX may use this method. if ( ! is_string( $url ) || ! wp_http_validate_url( $url ) ) { return new \WP_Error( 'qwsc_local_invalid_url', __( 'Enter a valid public HTTP or HTTPS URL.', 'qw-optimize' ) ); } $start = microtime( true ); $response = wp_safe_remote_get( $url, array( 'timeout' => 30, 'redirection' => 3, 'sslverify' => true, 'limit_response_size' => 2097152, 'headers' => array( 'Accept' => 'text/html,application/xhtml+xml', 'User-Agent' => 'QW-Speed-Cache-LocalTest/1.0; ' . home_url( '/' ), ), ) ); $elapsed_ms = (int) round( ( microtime( true ) - $start ) * 1000 ); if ( is_wp_error( $response ) ) { return new \WP_Error( 'qwsc_local_http', sprintf( /* translators: %s: error */ __( 'Could not fetch the page: %s', 'qw-optimize' ), $response->get_error_message() ) ); } $code = (int) wp_remote_retrieve_response_code( $response ); $body = (string) wp_remote_retrieve_body( $response ); $headers = wp_remote_retrieve_headers( $response ); if ( is_object( $headers ) && method_exists( $headers, 'getAll' ) ) { $hdrs = $headers->getAll(); } else { $hdrs = is_array( $headers ) ? $headers : array(); } $html_bytes = strlen( $body ); $scripts = preg_match_all( '/]+rel=[\'"]?stylesheet/i', $body ); $images = preg_match_all( '/ 1500 ) { $tips[] = __( 'Server response is slow (TTFB proxy > 1.5s). Enable page cache and check hosting.', 'qw-optimize' ); } elseif ( $elapsed_ms > 600 ) { $tips[] = __( 'Response time is moderate. Page cache and a CDN usually help.', 'qw-optimize' ); } if ( $html_bytes > 300000 ) { $tips[] = __( 'HTML document is large (>300 KB). Reduce markup or defer heavy builder output.', 'qw-optimize' ); } if ( $scripts > 25 ) { $tips[] = sprintf( /* translators: %d: count */ __( 'Many script tags detected (%d). Consider Delay JS and unloading unused assets.', 'qw-optimize' ), (int) $scripts ); } if ( $styles > 15 ) { $tips[] = sprintf( /* translators: %d: count */ __( 'Many stylesheets detected (%d). Critical CSS / combining (Pro) can help.', 'qw-optimize' ), (int) $styles ); } if ( $images > 30 ) { $tips[] = __( 'Many images on the page. Use lazy-load and modern formats (WebP/AVIF).', 'qw-optimize' ); } if ( '' === $cache_status ) { $tips[] = __( 'No obvious page-cache header detected on this response. Confirm “Page cache” is on and test while logged out.', 'qw-optimize' ); } if ( ! $tips ) { $tips[] = __( 'No major red flags from this local check. Run Google PageSpeed for full lab scores.', 'qw-optimize' ); } // Simple health score 0–100 from local signals only (not Lighthouse). $score = 100; if ( $elapsed_ms > 300 ) { $score -= min( 40, (int) ( ( $elapsed_ms - 300 ) / 50 ) ); } if ( $html_bytes > 100000 ) { $score -= min( 20, (int) ( ( $html_bytes - 100000 ) / 25000 ) ); } if ( $scripts > 15 ) { $score -= min( 20, ( $scripts - 15 ) ); } if ( $styles > 10 ) { $score -= min( 10, ( $styles - 10 ) ); } $score = max( 5, min( 100, $score ) ); return array( 'type' => 'local', 'url' => $url, 'http_code' => $code, 'ttfb_ms' => $elapsed_ms, 'html_bytes' => $html_bytes, 'html_kb' => round( $html_bytes / 1024, 1 ), 'scripts' => (int) $scripts, 'styles' => (int) $styles, 'images' => (int) $images, 'iframes' => (int) $iframes, 'cache_status' => $cache_status, 'score' => $score, 'tips' => $tips, ); } /** * Call PageSpeed Insights and return a compact result. * * @param string $url Page URL. * @param string $strategy mobile|desktop. * @param string $key Optional API key. * @return array|\WP_Error */ public function run( $url, $strategy, $key = '' ) { $query = 'url=' . rawurlencode( $url ) . '&strategy=' . rawurlencode( $strategy ) . '&category=performance&category=accessibility&category=best-practices&category=seo'; if ( '' !== $key ) { $query .= '&key=' . rawurlencode( $key ); } $response = wp_safe_remote_get( self::ENDPOINT . '?' . $query, array( 'timeout' => 120, 'sslverify' => true, 'limit_response_size' => 4 * MB_IN_BYTES, 'reject_unsafe_urls' => true, 'headers' => array( 'Accept' => 'application/json' ), ) ); if ( is_wp_error( $response ) ) { return new \WP_Error( 'qwsc_psi_http', sprintf( /* translators: %s: error text */ __( 'Could not reach Google PageSpeed: %s', 'qw-optimize' ), $response->get_error_message() ) ); } $code = (int) wp_remote_retrieve_response_code( $response ); $json = json_decode( (string) wp_remote_retrieve_body( $response ), true ); if ( $code >= 400 || ! is_array( $json ) || ! empty( $json['error'] ) ) { $msg = ( is_array( $json ) && ! empty( $json['error']['message'] ) ) ? (string) $json['error']['message'] : ''; $status = ( is_array( $json ) && ! empty( $json['error']['status'] ) ) ? (string) $json['error']['status'] : ''; $has_key = ( '' !== (string) $key ); if ( 429 === $code || 'RESOURCE_EXHAUSTED' === $status ) { if ( $has_key ) { return new \WP_Error( 'qwsc_psi_quota', __( 'Google still rate-limited this request even with your API key. Wait a minute and try again, confirm the PageSpeed Insights API is enabled on the key, and that the key is not restricted to browser/HTTP referrers only (server calls need “None” or IP restrictions).', 'qw-optimize' ) ); } return new \WP_Error( 'qwsc_psi_quota', __( 'Google’s free limit was reached. Add a free PageSpeed API key below, then run the test again.', 'qw-optimize' ) ); } if ( in_array( $code, array( 400, 403 ), true ) && ( false !== stripos( $msg, 'API key' ) || false !== stripos( $msg, 'not valid' ) || false !== stripos( $msg, 'permission' ) || false !== stripos( $msg, 'blocked' ) || 'PERMISSION_DENIED' === $status ) ) { return new \WP_Error( 'qwsc_psi_key', __( 'Google rejected the API key. Open Google Cloud → enable “PageSpeed Insights API” for the project, use an unrestricted key (or IP-restricted for this server), paste it again, and Save.', 'qw-optimize' ) ); } if ( '' === $msg ) { $msg = sprintf( /* translators: %d: HTTP code */ __( 'Google returned an error (HTTP %d).', 'qw-optimize' ), $code ); } return new \WP_Error( 'qwsc_psi_error', sprintf( /* translators: %s: error text */ __( 'Google could not test this page: %s', 'qw-optimize' ), wp_strip_all_tags( $msg ) ) ); } return self::normalize( $json, $url, $strategy ); } /** * Reduce the Lighthouse payload to what the page shows. * * @param array $json Decoded API response. * @param string $url Requested URL. * @param string $strategy mobile|desktop. * @return array */ public static function normalize( $json, $url, $strategy ) { $lh = isset( $json['lighthouseResult'] ) && is_array( $json['lighthouseResult'] ) ? $json['lighthouseResult'] : array(); $audits = isset( $lh['audits'] ) && is_array( $lh['audits'] ) ? $lh['audits'] : array(); $cats = isset( $lh['categories'] ) && is_array( $lh['categories'] ) ? $lh['categories'] : array(); $scores = array(); foreach ( array( 'performance', 'accessibility', 'best-practices', 'seo' ) as $c ) { $scores[ $c ] = isset( $cats[ $c ]['score'] ) && is_numeric( $cats[ $c ]['score'] ) ? (int) round( (float) $cats[ $c ]['score'] * 100 ) : null; } $metric_ids = array( 'first-contentful-paint' => 'FCP', 'largest-contentful-paint' => 'LCP', 'total-blocking-time' => 'TBT', 'cumulative-layout-shift' => 'CLS', 'speed-index' => 'Speed Index', 'interactive' => 'TTI', ); $metrics = array(); foreach ( $metric_ids as $id => $label ) { if ( empty( $audits[ $id ] ) ) { continue; } $a = $audits[ $id ]; $metrics[] = array( 'id' => $id, 'label' => $label, 'value' => isset( $a['displayValue'] ) ? wp_strip_all_tags( (string) $a['displayValue'] ) : '', 'score' => isset( $a['score'] ) && is_numeric( $a['score'] ) ? (float) $a['score'] : null, ); } $issues = array(); if ( ! empty( $cats['performance']['auditRefs'] ) && is_array( $cats['performance']['auditRefs'] ) ) { foreach ( $cats['performance']['auditRefs'] as $ref ) { $id = isset( $ref['id'] ) ? $ref['id'] : ''; if ( '' === $id || empty( $audits[ $id ] ) ) { continue; } $grp = isset( $ref['group'] ) ? $ref['group'] : ''; if ( ! in_array( $grp, array( 'load-opportunities', 'diagnostics' ), true ) ) { continue; } $a = $audits[ $id ]; if ( ! isset( $a['score'] ) || ! is_numeric( $a['score'] ) || (float) $a['score'] >= 0.9 ) { continue; } $save = isset( $a['details']['overallSavingsMs'] ) ? (float) $a['details']['overallSavingsMs'] : 0.0; $issues[] = array( 'title' => isset( $a['title'] ) ? wp_strip_all_tags( (string) $a['title'] ) : $id, 'value' => isset( $a['displayValue'] ) ? wp_strip_all_tags( (string) $a['displayValue'] ) : '', 'score' => (float) $a['score'], 'save' => $save, ); } usort( $issues, function ( $x, $y ) { if ( $x['save'] === $y['save'] ) { return $x['score'] <=> $y['score']; } return $y['save'] <=> $x['save']; } ); $issues = array_slice( $issues, 0, 7 ); } $shot = ''; if ( ! empty( $audits['final-screenshot']['details']['data'] ) && is_string( $audits['final-screenshot']['details']['data'] ) ) { $d = $audits['final-screenshot']['details']['data']; if ( preg_match( '#^data:image/(jpeg|png|webp);base64,[A-Za-z0-9+/=]+$#', $d ) && strlen( $d ) < 400000 ) { $shot = $d; } } $field = ''; if ( ! empty( $json['loadingExperience']['overall_category'] ) ) { $field = sanitize_key( strtolower( (string) $json['loadingExperience']['overall_category'] ) ); } return array( 'url' => isset( $lh['finalUrl'] ) ? esc_url_raw( (string) $lh['finalUrl'] ) : esc_url_raw( $url ), 'strategy' => $strategy, 'scores' => $scores, 'metrics' => $metrics, 'issues' => $issues, 'shot' => $shot, 'field' => $field, 'time' => time(), ); } /** * Return the latest complete result snapshot used to restore the Speed Test UI. * * @return array */ public static function latest_result() { $latest = get_option( self::LATEST, array() ); if ( ! is_array( $latest ) ) { return array(); } if ( 'full' !== ( $latest['mode'] ?? '' ) ) { return $latest; } $run_id = isset( $latest['run_id'] ) ? (string) $latest['run_id'] : ''; foreach ( array( 'mobile' => self::LATEST_MOBILE, 'desktop' => self::LATEST_DESKTOP ) as $strategy => $option ) { $stored = get_option( $option, array() ); $latest[ $strategy ] = ( is_array( $stored ) && $run_id !== '' && $run_id === ( $stored['run_id'] ?? '' ) && is_array( $stored['data'] ?? null ) ) ? $stored['data'] : null; } return $latest; } /** * Persist a local quick-check result. A new local run replaces the previous result. * * @param array $result Local result. * @return void */ private function remember_latest_local( $result ) { $clean = is_array( $result ) ? $result : array(); update_option( self::LATEST, array( 'mode' => 'local', 'time' => time(), 'url' => isset( $clean['url'] ) ? esc_url_raw( $clean['url'] ) : '', 'local' => $clean, ), false ); } /** * Persist the latest full PageSpeed run. Mobile and desktop requests share one run id. * Starting a new run replaces the previous persisted result, even for the same URL. * * @param string $url Tested URL. * @param string $strategy mobile|desktop. * @param array $result Normalized PageSpeed result. * @param string $run_id Client-generated run id shared by both device requests. * @return void */ private function remember_latest_full( $url, $strategy, $result, $run_id ) { $run_id = $run_id ? substr( $run_id, 0, 64 ) : 'legacy-' . time(); $clean = is_array( $result ) ? $result : array(); // Avoid storing large Lighthouse screenshot data in wp_options. if ( isset( $clean['shot'] ) ) { $clean['shot'] = ''; } $device_option = 'desktop' === $strategy ? self::LATEST_DESKTOP : self::LATEST_MOBILE; update_option( $device_option, array( 'run_id' => $run_id, 'data' => $clean ), false ); // Store only run metadata here; device payloads live separately to avoid concurrent // mobile/desktop AJAX requests overwriting one another. update_option( self::LATEST, array( 'mode' => 'full', 'run_id' => $run_id, 'time' => time(), 'url' => esc_url_raw( $url ), ), false ); } /** * Keep the last 20 results (scores only). */ private function remember( $url, $strategy, $r ) { $hist = get_option( self::HISTORY, array() ); if ( ! is_array( $hist ) ) { $hist = array(); } $row = array( 't' => time(), 'url' => $url, 'dev' => $strategy, 'perf' => isset( $r['scores']['performance'] ) ? $r['scores']['performance'] : null, 'lcp' => '', ); foreach ( $r['metrics'] as $m ) { if ( 'largest-contentful-paint' === $m['id'] ) { $row['lcp'] = $m['value']; } } array_unshift( $hist, $row ); update_option( self::HISTORY, array_slice( $hist, 0, 20 ), false ); } public function render() { if ( ! current_user_can( 'manage_options' ) ) { return; } $hist = get_option( self::HISTORY, array() ); $hist = is_array( $hist ) ? array_slice( $hist, 0, 10 ) : array(); $has = '' !== self::api_key(); $masked = self::api_key_masked(); $key_q = isset( $_GET['key'] ) ? sanitize_key( wp_unslash( $_GET['key'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended $saved = ( '1' === $key_q ); ?>