settings = $settings; $this->bots = $bots; } /** * Register hooks. */ public function hooks() { add_action( 'rest_api_init', array( $this, 'register_routes' ) ); add_action( 'init', array( $this, 'maybe_schedule_scan' ) ); add_action( self::CRON_HOOK, array( $this, 'run_scheduled_scan' ) ); // A saved post's audit is stale by definition. add_action( 'save_post', array( $this, 'invalidate_post' ) ); // Bot access and llms.txt settings are both site-level audit inputs. add_action( 'update_option_' . Oorank_Settings::OPTION, array( $this, 'invalidate_site_context' ) ); } // ---- Checks ---- /** * Check definitions: key => label, weight, description. * * Weights sum to 100 and are ordered by citation impact — server-rendered * body text first, because a page an engine cannot read scores nothing * else that matters. * * Each check declares a scope. Site-scoped checks (llms.txt reachable, bot * access) are the same fact on every URL, so they are scored separately: * with them inside the page score, 22 of 100 points were identical * site-wide and the report's ranking of "worst pages" was compressed by a * constant. They still count towards the overall score, at a weight that * reflects one decision made once rather than a property of the page. * * @return array */ public static function check_definitions() { $checks = array( 'body_text' => array( 'label' => __( 'Server-rendered body text', 'oorank' ), 'weight' => 20, 'scope' => 'page', 'help' => __( 'AI crawlers do not run JavaScript. The page must ship readable text in the HTML response.', 'oorank' ), ), 'json_ld' => array( 'label' => __( 'Valid JSON-LD structured data', 'oorank' ), 'weight' => 12, 'scope' => 'page', 'help' => __( 'Structured data tells answer engines what the page is about. Malformed JSON-LD is ignored entirely.', 'oorank' ), ), 'title_meta' => array( 'label' => __( 'Title and meta description', 'oorank' ), 'weight' => 9, 'scope' => 'page', 'help' => __( 'The title and description are what an engine quotes when it cites the page.', 'oorank' ), ), 'canonical' => array( 'label' => __( 'Self-referential canonical', 'oorank' ), 'weight' => 7, 'scope' => 'page', 'help' => __( 'A canonical pointing elsewhere hands the citation to another URL.', 'oorank' ), ), 'headings' => array( 'label' => __( 'Heading hierarchy', 'oorank' ), 'weight' => 8, 'scope' => 'page', 'help' => __( 'Engines chunk pages by heading. One H1 with nested H2/H3 chunks cleanly.', 'oorank' ), ), 'answer_chunks' => array( 'label' => __( 'Liftable paragraphs', 'oorank' ), 'weight' => 8, 'scope' => 'page', 'help' => __( 'An answer engine quotes a passage, not a page. Paragraphs and sentences short enough to lift whole are the ones that get quoted.', 'oorank' ), ), 'question_headings' => array( 'label' => __( 'Question-form headings', 'oorank' ), 'weight' => 6, 'scope' => 'page', 'help' => __( 'A heading that states the question it answers matches what someone actually asked.', 'oorank' ), ), 'summary_block' => array( 'label' => __( 'Answer up front', 'oorank' ), 'weight' => 5, 'scope' => 'page', 'help' => __( 'A short, self-contained opening paragraph is what an engine reads as the page\'s answer.', 'oorank' ), ), 'freshness' => array( 'label' => __( 'Freshness', 'oorank' ), 'weight' => 5, 'scope' => 'page', 'help' => __( 'Answer engines discount stale content. Keep dateModified honest.', 'oorank' ), ), 'lists_tables' => array( 'label' => __( 'Lists and tables', 'oorank' ), 'weight' => 4, 'scope' => 'page', 'help' => __( 'Structured passages restate cleanly in an answer, which plain prose often does not.', 'oorank' ), ), 'standalone' => array( 'label' => __( 'Self-contained paragraphs', 'oorank' ), 'weight' => 4, 'scope' => 'page', 'help' => __( 'A paragraph opening with "it" or "this" loses its meaning the moment it is quoted on its own.', 'oorank' ), ), 'bot_access' => array( 'label' => __( 'AI bot access', 'oorank' ), 'weight' => 7, 'scope' => 'site', 'help' => __( 'Blocking an AI search bot removes every chance of being cited by it.', 'oorank' ), ), 'llms_txt' => array( 'label' => __( 'llms.txt reachable', 'oorank' ), 'weight' => 5, 'scope' => 'site', 'help' => __( 'A machine-readable index helps engines find your best content.', 'oorank' ), ), ); /** * Filter the audit check definitions. * * Extension seam: add-ons may append checks. Weights are normalised, so * a new check does not have to keep the total at 100. * * @param array $checks Check definitions keyed by check slug. */ return apply_filters( 'oorank_audit_checks', $checks ); } // ---- Auditing ---- /** * Audit a single URL. * * @param string $url Absolute URL on this site. * @param int $post_id Optional post ID the URL belongs to (for deep links). * @param bool $force Skip the cache. * @return array|WP_Error Result array, or WP_Error when the URL is invalid * or unreachable. */ public function audit( $url, $post_id = 0, $force = false ) { $url = $this->normalize_url( $url ); if ( is_wp_error( $url ) ) { return $url; } $post_id = absint( $post_id ); if ( ! $force ) { $cached = get_transient( $this->cache_key( $url ) ); if ( is_array( $cached ) && isset( $cached['score'] ) ) { if ( $post_id ) { // A site scan re-uses fresh caches; the report still needs the row. $cached['post_id'] = $post_id; $this->record( $cached ); } return $cached; } } $response = $this->fetch( $url ); if ( is_wp_error( $response ) ) { return $response; } $code = (int) wp_remote_retrieve_response_code( $response ); if ( $code < 200 || $code >= 300 ) { return new WP_Error( 'oorank_audit_http', sprintf( /* translators: %d: HTTP status code. */ __( 'The page returned HTTP %d, so it cannot be audited.', 'oorank' ), $code ), array( 'status' => 400 ) ); } $html = (string) wp_remote_retrieve_body( $response ); $doc = $this->parse_html( $html ); if ( is_wp_error( $doc ) ) { return $doc; } $xpath = new DOMXPath( $doc ); $site = $this->site_context( $force ); // One pass over the content region, shared by every answer-ready // check — they ask different questions of the same reading. $this->analysis = Oorank_Answer_Ready::analyze( $xpath ); $results = array(); foreach ( self::check_definitions() as $key => $def ) { $method = 'check_' . $key; if ( method_exists( $this, $method ) ) { $outcome = $this->$method( $xpath, $url, $post_id, $site ); } else { /** * Filter the outcome of a check free core does not implement. * * Extension seam paired with oorank_audit_checks. * * @param array|null $outcome Outcome array or null to skip. * @param string $key Check slug. * @param DOMXPath $xpath Parsed document. * @param string $url Audited URL. * @param int $post_id Post ID, 0 when unknown. */ $outcome = apply_filters( 'oorank_audit_check_result', null, $key, $xpath, $url, $post_id ); } if ( ! is_array( $outcome ) || ! isset( $outcome['status'] ) ) { continue; } $weight = (int) $def['weight']; $results[] = array( 'key' => $key, 'label' => $def['label'], 'help' => $def['help'], 'scope' => isset( $def['scope'] ) ? $def['scope'] : 'page', 'status' => $outcome['status'], 'weight' => $weight, 'earned' => $this->earned( $outcome['status'], $weight ), 'message' => isset( $outcome['message'] ) ? $outcome['message'] : '', 'fix' => isset( $outcome['fix'] ) ? $outcome['fix'] : '', 'fix_url' => isset( $outcome['fix_url'] ) ? $outcome['fix_url'] : '', ); } $result = array( 'url' => $url, 'post_id' => $post_id, 'title' => $post_id ? get_the_title( $post_id ) : $this->text( $xpath, '//title' ), 'score' => $this->score( $results ), 'scores' => array( 'page' => $this->score( $this->scoped( $results, 'page' ) ), 'site' => $this->score( $this->scoped( $results, 'site' ) ), ), 'checks' => $this->prioritize( $results ), 'guidance' => $this->guidance(), 'ts' => time(), ); $result['grade'] = self::grade( $result['score'] ); set_transient( $this->cache_key( $url ), $result, self::CACHE_TTL ); if ( $post_id ) { $this->record( $result ); } return $result; } /** * Audit a post by ID. * * @param int $post_id Post ID. * @param bool $force Skip the cache. * @return array|WP_Error */ public function audit_post( $post_id, $force = false ) { $post_id = absint( $post_id ); $post = $post_id ? get_post( $post_id ) : null; if ( ! $post ) { return new WP_Error( 'oorank_audit_post', __( 'Unknown post.', 'oorank' ), array( 'status' => 404 ) ); } if ( 'publish' !== $post->post_status ) { return new WP_Error( 'oorank_audit_unpublished', __( 'Only published content can be audited — AI crawlers can only read what they can reach.', 'oorank' ), array( 'status' => 400 ) ); } return $this->audit( get_permalink( $post ), $post->ID, $force ); } /** * Cached result for a URL, without fetching. * * @param string $url URL. * @return array|null */ public function cached( $url ) { $url = $this->normalize_url( $url ); if ( is_wp_error( $url ) ) { return null; } $cached = get_transient( $this->cache_key( $url ) ); return ( is_array( $cached ) && isset( $cached['score'] ) ) ? $cached : null; } /** * Drop a post's cached audit when it is edited. * * @param int $post_id Post ID. */ public function invalidate_post( $post_id ) { if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) { return; } $permalink = get_permalink( $post_id ); if ( $permalink ) { delete_transient( $this->cache_key( $this->base_url( $permalink ) ) ); } } /** * Drop the shared site context so the next audit re-reads robots.txt and * llms.txt. */ public function invalidate_site_context() { delete_transient( self::SITE_CACHE ); } // ---- Individual checks ---- /** * Rendered body text length — the highest-weighted signal. * * @param DOMXPath $xpath Document. * @param string $url Audited URL. * @param int $post_id Post ID. * @param array $site Site context. * @return array */ private function check_body_text( DOMXPath $xpath, $url, $post_id, $site = array() ) { unset( $url, $site ); // Script, style and noscript content is markup, not readable text. The // document is shared with the other checks, so exclude rather than strip. $nodes = $xpath->query( '//body//text()[not(ancestor::script) and not(ancestor::style) and not(ancestor::noscript)]' ); $text = ''; foreach ( $nodes as $node ) { $text .= ' ' . $node->textContent; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- DOM API property. } $text = trim( preg_replace( '/\s+/u', ' ', $text ) ); $words = ( '' === $text ) ? 0 : count( preg_split( '/\s+/u', $text ) ); if ( $words >= self::WORDS_GOOD ) { return array( 'status' => self::PASS, 'message' => sprintf( /* translators: %s: word count. */ __( '%s words of text in the HTML response.', 'oorank' ), number_format_i18n( $words ) ), ); } $status = ( $words >= self::WORDS_THIN ) ? self::WARN : self::FAIL; return array( 'status' => $status, 'message' => sprintf( /* translators: 1: word count, 2: recommended minimum word count. */ __( 'Only %1$s words in the HTML response (aim for %2$s+).', 'oorank' ), number_format_i18n( $words ), number_format_i18n( self::WORDS_GOOD ) ), 'fix' => ( $words < self::WORDS_THIN ) ? __( 'Add substantive copy, or render this content server-side — JavaScript-built pages are invisible to AI crawlers.', 'oorank' ) : __( 'Expand the page with a direct-answer summary, definitions or a short Q&A block.', 'oorank' ), 'fix_url' => $this->edit_link( $post_id ), ); } /** * Valid JSON-LD present. * * @param DOMXPath $xpath Document. * @param string $url Audited URL. * @param int $post_id Post ID. * @param array $site Site context. * @return array */ private function check_json_ld( DOMXPath $xpath, $url, $post_id, $site = array() ) { unset( $url, $site ); $blocks = $xpath->query( '//script[translate(@type,"ABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyz")="application/ld+json"]' ); if ( 0 === $blocks->length ) { return array( 'status' => self::FAIL, 'message' => __( 'No JSON-LD structured data found.', 'oorank' ), 'fix' => __( 'Add Article, FAQPage or Product structured data — an SEO plugin or Oorank schema output can emit it.', 'oorank' ), 'fix_url' => $this->edit_link( $post_id ), ); } $valid = 0; $types = array(); foreach ( $blocks as $block ) { $data = json_decode( trim( (string) $block->textContent ), true ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- DOM API property. if ( null === $data || ! is_array( $data ) ) { continue; } ++$valid; $types = array_merge( $types, $this->collect_types( $data ) ); } if ( 0 === $valid ) { return array( 'status' => self::FAIL, 'message' => __( 'JSON-LD is present but malformed, so engines discard it.', 'oorank' ), 'fix' => __( 'Fix the JSON syntax in the structured-data block. Malformed JSON-LD is ignored entirely.', 'oorank' ), 'fix_url' => $this->edit_link( $post_id ), ); } if ( $valid < $blocks->length ) { return array( 'status' => self::WARN, 'message' => sprintf( /* translators: 1: valid block count, 2: total block count. */ __( '%1$d of %2$d structured-data blocks parse; the rest are malformed.', 'oorank' ), $valid, $blocks->length ), 'fix' => __( 'Repair or remove the invalid JSON-LD block.', 'oorank' ), 'fix_url' => $this->edit_link( $post_id ), ); } $types = array_unique( array_filter( $types ) ); return array( 'status' => self::PASS, 'message' => $types ? sprintf( /* translators: %s: comma-separated schema.org type list. */ __( 'Valid JSON-LD found: %s.', 'oorank' ), implode( ', ', array_slice( $types, 0, 5 ) ) ) : __( 'Valid JSON-LD found.', 'oorank' ), ); } /** * Title and meta description. * * @param DOMXPath $xpath Document. * @param string $url Audited URL. * @param int $post_id Post ID. * @param array $site Site context. * @return array */ private function check_title_meta( DOMXPath $xpath, $url, $post_id, $site = array() ) { unset( $url, $site ); $title = $this->text( $xpath, '//title' ); $desc = $this->attr( $xpath, '//meta[translate(@name,"ABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyz")="description"]', 'content' ); if ( '' === $title && '' === $desc ) { return array( 'status' => self::FAIL, 'message' => __( 'No title and no meta description.', 'oorank' ), 'fix' => __( 'Give the page a specific title and a one-sentence meta description.', 'oorank' ), 'fix_url' => $this->edit_link( $post_id ), ); } if ( '' === $desc ) { return array( 'status' => self::WARN, 'message' => __( 'Title present, meta description missing.', 'oorank' ), 'fix' => __( 'Add a meta description — it is often the sentence an answer engine quotes.', 'oorank' ), 'fix_url' => $this->edit_link( $post_id ), ); } if ( '' === $title ) { return array( 'status' => self::WARN, 'message' => __( 'Meta description present, title missing.', 'oorank' ), 'fix' => __( 'Add a descriptive for the page.', 'oorank' ), 'fix_url' => $this->edit_link( $post_id ), ); } if ( mb_strlen( $desc ) < 50 ) { return array( 'status' => self::WARN, 'message' => sprintf( /* translators: %d: meta description length in characters. */ __( 'Meta description is only %d characters — too thin to be quoted.', 'oorank' ), mb_strlen( $desc ) ), 'fix' => __( 'Expand the meta description to a full sentence (50–160 characters).', 'oorank' ), 'fix_url' => $this->edit_link( $post_id ), ); } return array( 'status' => self::PASS, 'message' => __( 'Title and meta description are both present.', 'oorank' ), ); } /** * AI bot access, read from the site's live robots.txt. * * @param DOMXPath $xpath Document. * @param string $url Audited URL. * @param int $post_id Post ID. * @param array $site Site context. * @return array */ private function check_bot_access( DOMXPath $xpath, $url, $post_id, $site = array() ) { unset( $xpath, $url, $post_id ); $robots = isset( $site['robots'] ) ? (string) $site['robots'] : ''; $registry = Oorank_Bot_Manager::registry(); // The live file is the truth — it also reflects a static robots.txt the // robots_txt filter never sees. Fall back to stored state if unreadable. $have_robots = ( '' !== trim( $robots ) ); $stored_block = $have_robots ? array() : array_flip( $this->bots->blocked() ); $blocked_search = array(); $blocked_training = array(); foreach ( $registry as $slug => $entry ) { if ( empty( $entry['ua'] ) ) { continue; } $blocked = $have_robots ? $this->robots_blocks( $robots, $entry['ua'] ) : isset( $stored_block[ $slug ] ); if ( ! $blocked ) { continue; } // On-demand fetchers sit with search, not with training: blocking // Claude-User stops the assistant reading this page for a user who // asked about it, which is a citation lost in the same way. if ( 'search' === $entry['category'] || 'assistant' === $entry['category'] ) { $blocked_search[] = $entry['name']; } else { $blocked_training[] = $entry['name']; } } $settings_url = $this->settings_link( 'access' ); if ( $have_robots && $this->robots_blocks( $robots, '*' ) ) { return array( 'status' => self::FAIL, 'message' => __( 'robots.txt disallows every crawler on the site.', 'oorank' ), 'fix' => __( 'Remove the site-wide Disallow, or the page can never be crawled or cited.', 'oorank' ), 'fix_url' => $settings_url, ); } if ( $blocked_search ) { return array( 'status' => self::FAIL, 'message' => sprintf( /* translators: %s: comma-separated bot names. */ __( 'AI search crawlers are blocked: %s.', 'oorank' ), implode( ', ', $blocked_search ) ), 'fix' => __( 'Allow the AI search bots — blocking them removes every citation opportunity. Training bots can stay blocked.', 'oorank' ), 'fix_url' => $settings_url, ); } if ( $blocked_training ) { return array( 'status' => self::PASS, 'message' => sprintf( /* translators: %s: comma-separated bot names. */ __( 'AI search crawlers are allowed. Opted out of training for: %s.', 'oorank' ), implode( ', ', $blocked_training ) ), ); } return array( 'status' => self::PASS, 'message' => __( 'No AI crawler is blocked.', 'oorank' ), ); } /** * Self-referential canonical. * * @param DOMXPath $xpath Document. * @param string $url Audited URL. * @param int $post_id Post ID. * @param array $site Site context. * @return array */ private function check_canonical( DOMXPath $xpath, $url, $post_id, $site = array() ) { unset( $site ); $canonical = $this->attr( $xpath, '//link[contains(concat(" ",translate(@rel,"ABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyz")," ")," canonical ")]', 'href' ); if ( '' === $canonical ) { return array( 'status' => self::FAIL, 'message' => __( 'No canonical link found.', 'oorank' ), 'fix' => __( 'Emit a self-referential rel="canonical" so engines attribute the content to this URL.', 'oorank' ), 'fix_url' => $this->edit_link( $post_id ), ); } if ( $this->same_url( $canonical, $url ) ) { return array( 'status' => self::PASS, 'message' => __( 'Canonical points at this URL.', 'oorank' ), ); } return array( 'status' => self::WARN, 'message' => sprintf( /* translators: %s: canonical URL found on the page. */ __( 'Canonical points elsewhere: %s', 'oorank' ), $canonical ), 'fix' => __( 'Unless this page is a deliberate duplicate, point the canonical at itself — otherwise the citation goes to the other URL.', 'oorank' ), 'fix_url' => $this->edit_link( $post_id ), ); } /** * Site-level llms.txt reachability. * * @param DOMXPath $xpath Document. * @param string $url Audited URL. * @param int $post_id Post ID. * @param array $site Site context. * @return array */ private function check_llms_txt( DOMXPath $xpath, $url, $post_id, $site = array() ) { unset( $xpath, $url, $post_id ); if ( ! empty( $site['llms_txt'] ) ) { return array( 'status' => self::PASS, 'message' => __( '/llms.txt is reachable and non-empty.', 'oorank' ), ); } return array( 'status' => self::FAIL, 'message' => __( '/llms.txt is not reachable.', 'oorank' ), 'fix' => __( 'Rebuild llms.txt from the Oorank settings screen. Under plain permalinks, Rebuild writes a physical file instead.', 'oorank' ), 'fix_url' => $this->settings_link( 'llms' ), ); } /** * Heading hierarchy — one H1, no skipped levels. * * @param DOMXPath $xpath Document. * @param string $url Audited URL. * @param int $post_id Post ID. * @param array $site Site context. * @return array */ private function check_headings( DOMXPath $xpath, $url, $post_id, $site = array() ) { unset( $url, $site ); $levels = array(); foreach ( $xpath->query( '//h1|//h2|//h3|//h4|//h5|//h6' ) as $node ) { $levels[] = (int) substr( $node->nodeName, 1 ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- DOM API property. } if ( empty( $levels ) ) { return array( 'status' => self::FAIL, 'message' => __( 'The page has no headings at all.', 'oorank' ), 'fix' => __( 'Add one H1 and nested H2/H3 sections — engines chunk pages by heading.', 'oorank' ), 'fix_url' => $this->edit_link( $post_id ), ); } $h1_count = count( array_keys( $levels, 1, true ) ); $skipped = false; $previous = 0; foreach ( $levels as $level ) { if ( $previous && $level > $previous + 1 ) { $skipped = true; break; } $previous = $level; } if ( 0 === $h1_count ) { return array( 'status' => self::FAIL, 'message' => __( 'No H1 on the page.', 'oorank' ), 'fix' => __( 'Give the page exactly one H1 stating what it is about.', 'oorank' ), 'fix_url' => $this->edit_link( $post_id ), ); } if ( $h1_count > 1 ) { return array( 'status' => self::WARN, 'message' => sprintf( /* translators: %d: number of H1 elements found. */ __( 'Found %d H1 elements — the page has no single subject.', 'oorank' ), $h1_count ), 'fix' => __( 'Keep one H1 and demote the rest to H2.', 'oorank' ), 'fix_url' => $this->edit_link( $post_id ), ); } if ( $skipped ) { return array( 'status' => self::WARN, 'message' => __( 'Heading levels skip a step (for example H2 straight to H4).', 'oorank' ), 'fix' => __( 'Nest headings one level at a time so sections chunk cleanly.', 'oorank' ), 'fix_url' => $this->edit_link( $post_id ), ); } return array( 'status' => self::PASS, 'message' => sprintf( /* translators: %d: number of headings on the page. */ __( 'One H1 with %d headings nested below it.', 'oorank' ), count( $levels ) - 1 ), ); } /** * Freshness from JSON-LD dateModified, falling back to the post record. * * @param DOMXPath $xpath Document. * @param string $url Audited URL. * @param int $post_id Post ID. * @param array $site Site context. * @return array */ private function check_freshness( DOMXPath $xpath, $url, $post_id, $site = array() ) { unset( $url, $site ); $modified = $this->find_date_modified( $xpath ); if ( ! $modified && $post_id ) { // Not what WordPress last touched — when the content last changed. $stamp = Oorank_Freshness::modified_gmt( $post_id ); if ( $stamp ) { $modified = strtotime( $stamp . ' UTC' ); } } if ( ! $modified ) { return array( 'status' => self::WARN, 'message' => __( 'No modification date could be read from the page.', 'oorank' ), 'fix' => __( 'Publish dateModified in your structured data so engines can judge freshness.', 'oorank' ), 'fix_url' => $this->edit_link( $post_id ), ); } $age_days = (int) floor( ( time() - $modified ) / DAY_IN_SECONDS ); // The owner sets what "fresh" means for their content; twice that is // where a warning becomes a failure. $fresh_days = (int) round( Oorank_Freshness::months( $this->settings ) * 30.44 ); if ( $age_days <= $fresh_days ) { return array( 'status' => self::PASS, 'message' => sprintf( /* translators: %s: human-readable time difference, e.g. "3 months". */ __( 'Last modified %s ago.', 'oorank' ), human_time_diff( $modified, time() ) ), ); } return array( 'status' => ( $age_days <= ( $fresh_days * 2 ) ) ? self::WARN : self::FAIL, 'message' => sprintf( /* translators: %s: human-readable time difference, e.g. "3 months". */ __( 'Last modified %s ago.', 'oorank' ), human_time_diff( $modified, time() ) ), 'fix' => __( 'Review and refresh the content, then let dateModified reflect the real edit.', 'oorank' ), 'fix_url' => $this->edit_link( $post_id ), ); } /** * Headings that state the question they answer. * * @return array */ private function check_question_headings() { $total = (int) $this->analysis['heading_total']; $questions = (int) $this->analysis['questions']; if ( 0 === $total ) { return array( 'status' => self::WARN, 'message' => __( 'The page has no H2 or H3 headings to chunk by.', 'oorank' ), 'fix' => __( 'Break the page into sections and title each one with the question it answers.', 'oorank' ), ); } $share = $questions / $total; if ( $share >= 0.4 ) { return array( 'status' => self::PASS, 'message' => sprintf( /* translators: 1: number of question-form headings, 2: total headings. */ __( '%1$d of %2$d headings are question-form.', 'oorank' ), $questions, $total ), ); } return array( 'status' => ( $share > 0 ) ? self::WARN : self::FAIL, 'message' => sprintf( /* translators: 1: number of question-form headings, 2: total headings. */ __( 'Only %1$d of %2$d headings read as a question.', 'oorank' ), $questions, $total ), 'fix' => __( 'Rewrite section headings as the question a reader would type, so the heading matches the query.', 'oorank' ), ); } /** * Paragraphs and sentences short enough to quote whole. * * @return array */ private function check_answer_chunks() { $paragraphs = (int) $this->analysis['paragraphs']; $long_paras = count( $this->analysis['long_paragraphs'] ); $long_sents = count( $this->analysis['long_sentences'] ); if ( 0 === $paragraphs ) { return array( 'status' => self::FAIL, 'message' => __( 'No prose paragraphs were found in the page content.', 'oorank' ), 'fix' => __( 'Answer engines quote paragraphs. A page built only from widgets or media has nothing to lift.', 'oorank' ), ); } if ( ! $long_paras && ! $long_sents ) { return array( 'status' => self::PASS, 'message' => sprintf( /* translators: %d: number of paragraphs. */ __( 'All %d paragraphs are short enough to quote whole.', 'oorank' ), $paragraphs ), ); } // A single sprawling paragraph is a note; a third of the page being // unliftable is a real barrier to being quoted. $status = ( $long_paras > max( 2, $paragraphs / 3 ) ) ? self::FAIL : self::WARN; return array( 'status' => $status, 'message' => sprintf( /* translators: 1: number of over-long paragraphs, 2: number of over-long sentences. */ __( '%1$d paragraphs and %2$d sentences are too long to lift into an answer.', 'oorank' ), $long_paras, $long_sents ), 'fix' => sprintf( /* translators: 1: paragraph word ceiling, 2: sentence word ceiling. */ __( 'Split paragraphs over %1$d words and sentences over %2$d, so each one answers something on its own.', 'oorank' ), Oorank_Answer_Ready::PARAGRAPH_MAX, Oorank_Answer_Ready::SENTENCE_MAX ), ); } /** * A short, self-contained opening paragraph. * * @return array */ private function check_summary_block() { if ( $this->analysis['summary'] ) { return array( 'status' => self::PASS, 'message' => sprintf( /* translators: %d: word count of the opening paragraph. */ __( 'The page opens with a %d-word answer.', 'oorank' ), (int) $this->analysis['summary_words'] ), ); } if ( 0 === (int) $this->analysis['summary_words'] ) { return array( 'status' => self::FAIL, 'message' => __( 'The page has no opening paragraph.', 'oorank' ), 'fix' => __( 'Open with two or three sentences that answer the page\'s question outright.', 'oorank' ), ); } return array( 'status' => self::WARN, 'message' => sprintf( /* translators: 1: word count of the opening paragraph, 2: recommended ceiling. */ __( 'The opening paragraph is %1$d words; an answer engine reads the first %2$d or so as the page\'s answer.', 'oorank' ), (int) $this->analysis['summary_words'], Oorank_Answer_Ready::SUMMARY_MAX ), 'fix' => __( 'Lead with a short direct answer, then expand below it.', 'oorank' ), ); } /** * Lists or tables an engine can restate. * * @return array */ private function check_lists_tables() { $lists = (int) $this->analysis['lists']; $tables = (int) $this->analysis['tables']; if ( $lists || $tables ) { return array( 'status' => self::PASS, 'message' => sprintf( /* translators: 1: number of lists, 2: number of tables. */ __( '%1$d lists and %2$d tables in the content.', 'oorank' ), $lists, $tables ), ); } return array( 'status' => self::WARN, 'message' => __( 'The content is unbroken prose — no lists or tables.', 'oorank' ), 'fix' => __( 'Where the content enumerates steps, options or comparisons, mark it up as a list or table.', 'oorank' ), ); } /** * Paragraphs that survive being quoted alone. * * @return array */ private function check_standalone() { $openers = count( $this->analysis['pronoun_openers'] ); $paragraphs = max( 1, (int) $this->analysis['paragraphs'] ); if ( ! $openers ) { return array( 'status' => self::PASS, 'message' => __( 'No paragraph opens with a pronoun that needs the one above it.', 'oorank' ), ); } $share = $openers / $paragraphs; return array( 'status' => ( $share > 0.25 ) ? self::FAIL : self::WARN, 'message' => sprintf( /* translators: 1: number of paragraphs, 2: total paragraphs. */ __( '%1$d of %2$d paragraphs open with a pronoun and cannot be quoted alone.', 'oorank' ), $openers, (int) $this->analysis['paragraphs'] ), 'fix' => __( 'Name the subject at the start of the paragraph instead of referring back to it.', 'oorank' ), ); } /** * The rewrite guidance behind the answer-ready checks — the specific * headings, paragraphs and openers a score alone cannot point at. * * @return array */ private function guidance() { if ( empty( $this->analysis ) ) { return array(); } return array( 'statementHeadings' => $this->analysis['headings'], 'longParagraphs' => $this->analysis['long_paragraphs'], 'longSentences' => $this->analysis['long_sentences'], 'pronounOpeners' => $this->analysis['pronoun_openers'], ); } /** * The checks belonging to one scope. * * @param array $checks Check results. * @param string $scope page|site. * @return array */ private function scoped( $checks, $scope ) { $out = array(); foreach ( $checks as $check ) { if ( ( isset( $check['scope'] ) ? $check['scope'] : 'page' ) === $scope ) { $out[] = $check; } } return $out; } // ---- Site-wide report ---- /** * The stored site report. * * @return array{ts:int, items:array, average:int, total:int} */ public function report() { $report = get_option( self::REPORT_OPTION, array() ); if ( ! is_array( $report ) || empty( $report['items'] ) || ! is_array( $report['items'] ) ) { return array( 'ts' => 0, 'items' => array(), 'average' => 0, 'total' => 0, ); } $scores = wp_list_pluck( $report['items'], 'score' ); // Rows recorded before the page/site split have no page score; they // are excluded rather than counted as their overall score, which would // quietly mix two different measurements into one average. $pages = array(); foreach ( $report['items'] as $item ) { if ( isset( $item['page_score'] ) ) { $pages[] = (int) $item['page_score']; } } // The admin app links each row straight into the editor; resolving the // link here keeps that a string lookup rather than a second request. $items = array_map( static function ( $item ) { $item['edit_url'] = ! empty( $item['post_id'] ) ? (string) get_edit_post_link( (int) $item['post_id'], 'raw' ) : ''; return $item; }, array_values( $report['items'] ) ); return array( 'ts' => isset( $report['ts'] ) ? (int) $report['ts'] : 0, 'items' => $items, 'average' => $scores ? (int) round( array_sum( $scores ) / count( $scores ) ) : 0, 'pageAverage' => $pages ? (int) round( array_sum( $pages ) / count( $pages ) ) : 0, 'siteScore' => $this->site_score(), 'total' => count( $report['items'] ), ); } /** * The site-scope score: the checks that are the same fact on every URL. * * Read from the most recent stored audit rather than recomputed, so the * report shows what was actually measured and never fires a fetch of its * own just to render a card. * * @return int|null Score, or null when nothing has been audited yet. */ public function site_score() { $report = get_option( self::REPORT_OPTION, array() ); $items = ( is_array( $report ) && isset( $report['items'] ) && is_array( $report['items'] ) ) ? $report['items'] : array(); foreach ( $items as $item ) { if ( empty( $item['url'] ) ) { continue; } $cached = $this->cached( $item['url'] ); if ( is_array( $cached ) && isset( $cached['scores']['site'] ) ) { return (int) $cached['scores']['site']; } } return null; } /** * IDs of the posts a site scan covers, in scan order. * * Uses the same post types as llms.txt, so the audit reports on exactly the * content the site advertises to AI engines. * * @return int[] */ public function scannable_ids() { $settings = $this->settings->get(); $post_types = isset( $settings['llms']['post_types'] ) ? (array) $settings['llms']['post_types'] : array( 'page', 'post' ); $post_types = array_values( array_filter( $post_types, 'post_type_exists' ) ); if ( empty( $post_types ) ) { return array(); } $ids = get_posts( array( 'post_type' => $post_types, 'post_status' => 'publish', 'has_password' => false, 'numberposts' => self::REPORT_MAX, 'orderby' => 'modified', 'order' => 'DESC', 'fields' => 'ids', 'suppress_filters' => false, ) ); return array_map( 'absint', (array) $ids ); } /** * Audit one chunk of the site, starting at an offset. * * Chunked so a large site never hits a request timeout: the caller loops * until `done` comes back true. * * @param int $offset Index into the scannable list. * @param int $limit How many to audit this pass. * @param bool $force Skip per-URL caches. * @return array{done:bool, offset:int, total:int, scanned:int} */ public function scan_chunk( $offset = 0, $limit = self::SCAN_CHUNK, $force = false ) { $ids = $this->scannable_ids(); $total = count( $ids ); $offset = max( 0, (int) $offset ); $limit = max( 1, min( 50, (int) $limit ) ); if ( 0 === $offset ) { // A fresh pass replaces the previous report rather than merging into it. $this->reset_report(); } $slice = array_slice( $ids, $offset, $limit ); $scanned = 0; foreach ( $slice as $post_id ) { $result = $this->audit_post( $post_id, $force ); if ( ! is_wp_error( $result ) ) { ++$scanned; } } $next = $offset + count( $slice ); return array( 'done' => ( $next >= $total ), 'offset' => $next, 'total' => $total, 'scanned' => $scanned, ); } /** * Schedule the weekly background scan once. */ public function maybe_schedule_scan() { if ( ! wp_next_scheduled( self::CRON_HOOK ) ) { wp_schedule_event( time() + HOUR_IN_SECONDS, 'weekly', self::CRON_HOOK ); } } /** * Cron callback — refresh the site report in bounded chunks. * * Walks the whole list across the run, but re-uses fresh per-URL caches so * a weekly pass on an already-audited site is cheap. */ public function run_scheduled_scan() { $before = $this->score_snapshot(); $offset = 0; do { $progress = $this->scan_chunk( $offset, self::SCAN_CHUNK, false ); $offset = $progress['offset']; } while ( ! $progress['done'] && $offset < self::REPORT_MAX ); $this->send_digest( $before, $this->score_snapshot() ); } /** * Page scores keyed by post ID, as the report currently stands. * * @return array<int, int> */ private function score_snapshot() { $report = get_option( self::REPORT_OPTION, array() ); $items = ( is_array( $report ) && isset( $report['items'] ) && is_array( $report['items'] ) ) ? $report['items'] : array(); $snapshot = array(); foreach ( $items as $id => $item ) { $snapshot[ (int) $id ] = isset( $item['page_score'] ) ? (int) $item['page_score'] : (int) $item['score']; } return $snapshot; } /** * Email what changed since the last scan. * * A weekly "here is your score" email is deleted unread; a weekly "three * pages dropped below 60" email is the reason to come back. So the digest * only sends when something actually moved, and leads with the movement. * * @param array $before Scores before this scan. * @param array $after Scores after it. */ private function send_digest( array $before, array $after ) { $settings = $this->settings->get(); if ( empty( $settings['digest']['enabled'] ) || empty( $before ) ) { return; } $threshold = isset( $settings['digest']['threshold'] ) ? (int) $settings['digest']['threshold'] : 5; $dropped = array(); $improved = array(); $added = array(); foreach ( $after as $id => $score ) { if ( ! isset( $before[ $id ] ) ) { $added[ $id ] = $score; continue; } $delta = $score - $before[ $id ]; if ( $delta <= -$threshold ) { $dropped[ $id ] = array( $before[ $id ], $score ); } elseif ( $delta >= $threshold ) { $improved[ $id ] = array( $before[ $id ], $score ); } } if ( empty( $dropped ) && empty( $improved ) ) { return; } $to = isset( $settings['digest']['email'] ) && is_email( $settings['digest']['email'] ) ? $settings['digest']['email'] : get_option( 'admin_email' ); $lines = array(); foreach ( $dropped as $id => $pair ) { $lines[] = sprintf( /* translators: 1: content title, 2: previous score, 3: new score. */ __( 'DOWN %1$s: %2$d to %3$d', 'oorank' ), get_the_title( $id ), $pair[0], $pair[1] ); } foreach ( $improved as $id => $pair ) { $lines[] = sprintf( /* translators: 1: content title, 2: previous score, 3: new score. */ __( 'UP %1$s: %2$d to %3$d', 'oorank' ), get_the_title( $id ), $pair[0], $pair[1] ); } $body = implode( "\n", $lines ) . "\n\n" . sprintf( /* translators: %s: admin URL of the Oorank audit screen. */ __( 'Full report: %s', 'oorank' ), admin_url( 'admin.php?page=' . Oorank_Admin::MENU_SLUG . '&tab=audit' ) ); $subject = sprintf( /* translators: 1: site name, 2: number of pages that dropped, 3: number that improved. */ __( '[%1$s] AI readiness: %2$d down, %3$d up', 'oorank' ), get_bloginfo( 'name' ), count( $dropped ), count( $improved ) ); /** * Filter the weekly digest before it is sent. * * @param array $email to, subject and body. * @param array $dropped Post ID => [before, after]. * @param array $improved Post ID => [before, after]. */ $email = apply_filters( 'oorank_audit_digest', array( 'to' => $to, 'subject' => $subject, 'body' => $body, ), $dropped, $improved ); if ( empty( $email['to'] ) ) { return; } wp_mail( $email['to'], $email['subject'], $email['body'] ); } /** * Add (or replace) a result in the site report. * * @param array $result Audit result. */ private function record( $result ) { $report = get_option( self::REPORT_OPTION, array() ); $items = ( is_array( $report ) && isset( $report['items'] ) && is_array( $report['items'] ) ) ? $report['items'] : array(); $items[ (int) $result['post_id'] ] = array( 'post_id' => (int) $result['post_id'], 'url' => $result['url'], 'title' => $result['title'], 'score' => (int) $result['score'], // Ranking the worst pages is a page-scope question: every row // shares the same site score, so including it only flattens the // spread the ranking exists to show. 'page_score' => isset( $result['scores']['page'] ) ? (int) $result['scores']['page'] : (int) $result['score'], 'grade' => $result['grade'], 'ts' => (int) $result['ts'], ); // Keep the newest results only; the report is a snapshot, not a log. if ( count( $items ) > self::REPORT_MAX ) { uasort( $items, static function ( $a, $b ) { return $b['ts'] <=> $a['ts']; } ); $items = array_slice( $items, 0, self::REPORT_MAX, true ); } update_option( self::REPORT_OPTION, array( 'ts' => time(), 'items' => $items, ), false ); } /** * Empty the stored report. */ private function reset_report() { update_option( self::REPORT_OPTION, array( 'ts' => time(), 'items' => array(), ), false ); } // ---- REST ---- /** * Register the audit routes (nonce + capability gated). */ public function register_routes() { $permission = static function () { return Oorank_Roles::can(); }; register_rest_route( Oorank_Rest::NAMESPACE, '/audit/run', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'rest_run' ), 'permission_callback' => $permission, 'args' => array( 'url' => array( 'type' => 'string', 'required' => false, 'sanitize_callback' => 'esc_url_raw', ), 'post_id' => array( 'type' => 'integer', 'required' => false, 'sanitize_callback' => 'absint', ), 'force' => array( 'type' => 'boolean', 'required' => false, 'default' => true, ), ), ) ); register_rest_route( Oorank_Rest::NAMESPACE, '/audit/scan', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'rest_scan' ), 'permission_callback' => $permission, 'args' => array( 'offset' => array( 'type' => 'integer', 'required' => false, 'default' => 0, 'sanitize_callback' => 'absint', ), ), ) ); register_rest_route( Oorank_Rest::NAMESPACE, '/audit/bulk', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'rest_bulk' ), 'permission_callback' => $permission, 'args' => array( 'action' => array( 'type' => 'string', 'required' => true, 'enum' => array( 'reaudit', 'exclude', 'include' ), ), 'ids' => array( 'type' => 'array', 'required' => true, 'items' => array( 'type' => 'integer' ), ), ), ) ); register_rest_route( Oorank_Rest::NAMESPACE, '/audit/report', array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'rest_report' ), 'permission_callback' => $permission, ) ); } /** * POST /audit/run — audit one URL or post. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function rest_run( WP_REST_Request $request ) { $post_id = absint( $request->get_param( 'post_id' ) ); $force = (bool) $request->get_param( 'force' ); $result = $post_id ? $this->audit_post( $post_id, $force ) : $this->audit( (string) $request->get_param( 'url' ), 0, $force ); if ( is_wp_error( $result ) ) { return $result; } return rest_ensure_response( $result ); } /** * POST /audit/scan — audit one chunk of the site. * * @param WP_REST_Request $request Request. * @return WP_REST_Response */ public function rest_scan( WP_REST_Request $request ) { $progress = $this->scan_chunk( absint( $request->get_param( 'offset' ) ) ); $progress['report'] = $this->report(); return rest_ensure_response( $progress ); } /** * POST /audit/bulk — act on several report rows at once. * * The report ranks the worst pages and then made you fix them one at a * time. Two actions cover what that ranking is for: re-check the pages you * have just edited, and take the ones that will never score well — a cart, * a login page — out of both the index and the ranking, so the average * stops being dragged down by pages that were never candidates for * citation. * * @param WP_REST_Request $request Request. * @return WP_REST_Response */ public function rest_bulk( WP_REST_Request $request ) { $action = (string) $request->get_param( 'action' ); $ids = array_slice( array_filter( array_map( 'absint', (array) $request->get_param( 'ids' ) ) ), 0, self::BULK_MAX ); $done = 0; $failed = array(); foreach ( $ids as $id ) { if ( 'reaudit' === $action ) { $result = $this->audit_post( $id, true ); if ( is_wp_error( $result ) ) { $failed[] = $id; continue; } ++$done; continue; } // Excluding a page edits that page's settings, so it needs the // right to edit that page — not merely the right to see the report. if ( ! current_user_can( 'edit_post', $id ) ) { $failed[] = $id; continue; } if ( 'exclude' === $action ) { update_post_meta( $id, Oorank_Llms_Meta_Box::EXCLUDE_META, 1 ); $this->forget( $id ); } else { delete_post_meta( $id, Oorank_Llms_Meta_Box::EXCLUDE_META ); } ++$done; } if ( 'reaudit' !== $action ) { // Both directions change what llms.txt lists. oorank()->llms_txt->rebuild(); } return rest_ensure_response( array( 'done' => $done, 'failed' => $failed, 'report' => $this->report(), ) ); } /** * Drop a post from the stored report. * * @param int $post_id Post ID. */ private function forget( $post_id ) { $report = get_option( self::REPORT_OPTION, array() ); if ( ! is_array( $report ) || empty( $report['items'][ $post_id ] ) ) { return; } unset( $report['items'][ $post_id ] ); update_option( self::REPORT_OPTION, $report, false ); $this->invalidate_post( $post_id ); } /** * GET /audit/report — the stored site report. * * @return WP_REST_Response */ public function rest_report() { return rest_ensure_response( $this->report() ); } // ---- Scoring helpers ---- /** * Weighted 0–100 score. A warn earns half its weight. * * @param array $checks Check results. * @return int */ private function score( $checks ) { $total = 0; $earned = 0.0; foreach ( $checks as $check ) { $total += (int) $check['weight']; $earned += (float) $check['earned']; } if ( $total <= 0 ) { return 0; } return (int) round( ( $earned / $total ) * 100 ); } /** * Points earned for a status. * * @param string $status Status. * @param int $weight Check weight. * @return float */ private function earned( $status, $weight ) { if ( self::PASS === $status ) { return (float) $weight; } if ( self::WARN === $status ) { return $weight / 2; } return 0.0; } /** * Order results as a fix list: failures first, then warnings, heaviest * first within each group, passes last. * * @param array $checks Check results. * @return array */ private function prioritize( $checks ) { $rank = array( self::FAIL => 0, self::WARN => 1, self::PASS => 2, ); usort( $checks, static function ( $a, $b ) use ( $rank ) { $a_rank = isset( $rank[ $a['status'] ] ) ? $rank[ $a['status'] ] : 3; $b_rank = isset( $rank[ $b['status'] ] ) ? $rank[ $b['status'] ] : 3; if ( $a_rank !== $b_rank ) { return $a_rank <=> $b_rank; } return $b['weight'] <=> $a['weight']; } ); return $checks; } /** * Grade band for a score. * * @param int $score Score 0–100. * @return string One of excellent, good, fair, poor. */ public static function grade( $score ) { $score = (int) $score; if ( $score >= 90 ) { return 'excellent'; } if ( $score >= 70 ) { return 'good'; } if ( $score >= 50 ) { return 'fair'; } return 'poor'; } /** * Human-readable grade label. * * @param string $grade Grade slug. * @return string */ public static function grade_label( $grade ) { $labels = array( 'excellent' => __( 'Excellent', 'oorank' ), 'good' => __( 'Good', 'oorank' ), 'fair' => __( 'Needs work', 'oorank' ), 'poor' => __( 'Poor', 'oorank' ), ); return isset( $labels[ $grade ] ) ? $labels[ $grade ] : $grade; } /** * Human-readable status label. * * @param string $status Status slug. * @return string */ public static function status_label( $status ) { $labels = array( self::PASS => __( 'Pass', 'oorank' ), self::WARN => __( 'Warning', 'oorank' ), self::FAIL => __( 'Fail', 'oorank' ), ); return isset( $labels[ $status ] ) ? $labels[ $status ] : $status; } // ---- Fetch + parse helpers ---- /** * Fetch a URL as a generic crawler would. * * @param string $url URL on this site. * @return array|WP_Error */ private function fetch( $url ) { $args = array( 'timeout' => 15, 'redirection' => 3, 'user-agent' => 'Mozilla/5.0 (compatible; OorankAudit/' . OORANK_VERSION . '; +https://wordpress.org/plugins/oorank)', 'headers' => array( 'Accept' => 'text/html' ), 'sslverify' => ! $this->is_local_host( $url ), ); $pinned = ''; $response = $this->request( $url, $args, $pinned ); // The HTTP transport carries its own DNS resolver, which on some hosts // cannot see what PHP itself can (split-horizon DNS, custom local // TLDs). Pin the address PHP resolves rather than reporting the site's // own pages as unreachable. if ( is_wp_error( $response ) && $this->is_dns_error( $response ) ) { $pinned = $this->pinned_host( $url ); if ( '' !== $pinned ) { $response = $this->request( $url, $args, $pinned ); } } // Development sites routinely serve a certificate the local CA store // does not trust. The request never leaves this site's own host, so // retry unverified rather than reporting the site as unreachable. // // Only in a development context: on a live site a certificate error is // something the owner needs to see, not something to paper over — // crawlers hit the same wall. if ( is_wp_error( $response ) && $args['sslverify'] && $this->is_ssl_error( $response ) && $this->allows_insecure_tls( $url ) ) { $args['sslverify'] = false; $response = $this->request( $url, $args, $pinned ); } if ( is_wp_error( $response ) ) { if ( $this->is_ssl_error( $response ) ) { return new WP_Error( 'oorank_audit_tls', __( "This site's certificate could not be verified, so the page cannot be fetched. AI crawlers hit the same wall — fix the certificate first.", 'oorank' ), array( 'status' => 502 ) ); } return new WP_Error( 'oorank_audit_fetch', sprintf( /* translators: %s: HTTP error message. */ __( 'Could not fetch the page: %s', 'oorank' ), $response->get_error_message() ), array( 'status' => 502 ) ); } return $response; } /** * Fetch a plain-text site file, returning '' unless it really is one. * * WordPress serves an HTML page — often with a 200 — for paths whose * rewrite rules are missing, so a body alone proves nothing. Anything that * comes back as markup means the file is not really there. The content type * itself is not decisive: web servers hand out .txt files as text/plain, * application/octet-stream and more besides. * * @param string $url URL on this site. * @return string Body, or '' when the file is absent or served as markup. */ private function fetch_text( $url ) { $response = $this->fetch( $url ); if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) { return ''; } $type = strtolower( (string) wp_remote_retrieve_header( $response, 'content-type' ) ); if ( false !== strpos( $type, 'html' ) || false !== strpos( $type, 'xml' ) ) { return ''; } $body = trim( (string) wp_remote_retrieve_body( $response ) ); if ( preg_match( '/^\s*(<!doctype|<html|<\?xml)/i', $body ) ) { return ''; } return $body; } /** * One GET, optionally with the host pinned to a known address. * * @param string $url URL to fetch. * @param array $args wp_remote_get arguments. * @param string $pinned CURLOPT_RESOLVE entry, or '' for normal resolution. * @return array|WP_Error */ private function request( $url, $args, $pinned ) { if ( '' === $pinned ) { return wp_remote_get( $url, $args ); } $pin = static function ( $handle ) use ( $pinned ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_setopt -- Configuring WordPress' own cURL handle through core's designated filter. curl_setopt( $handle, CURLOPT_RESOLVE, array( $pinned ) ); }; add_action( 'http_api_curl', $pin ); $response = wp_remote_get( $url, $args ); remove_action( 'http_api_curl', $pin ); return $response; } /** * A CURLOPT_RESOLVE entry pinning this URL's host to the address PHP's own * resolver reports, or '' when PHP cannot resolve it either. * * @param string $url URL on this site. * @return string "host:port:ip", or ''. */ private function pinned_host( $url ) { $parts = wp_parse_url( $url ); $host = isset( $parts['host'] ) ? $parts['host'] : ''; if ( '' === $host || ! function_exists( 'curl_setopt' ) ) { return ''; } $ip = gethostbyname( $host ); if ( $ip === $host || ! filter_var( $ip, FILTER_VALIDATE_IP ) ) { return ''; } if ( isset( $parts['port'] ) ) { $port = (int) $parts['port']; } else { $port = ( isset( $parts['scheme'] ) && 'https' === $parts['scheme'] ) ? 443 : 80; } return $host . ':' . $port . ':' . $ip; } /** * Whether a transport error was a name-resolution failure. * * @param WP_Error $error Transport error. * @return bool */ private function is_dns_error( WP_Error $error ) { $message = strtolower( $error->get_error_message() ); foreach ( array( 'resolve host', 'resolving timed out', 'name or service not known', 'nodename nor servname' ) as $needle ) { if ( false !== strpos( $message, $needle ) ) { return true; } } return false; } /** * Whether a transport error was a certificate failure. * * @param WP_Error $error Transport error. * @return bool */ private function is_ssl_error( WP_Error $error ) { $message = strtolower( $error->get_error_message() ); foreach ( array( 'ssl', 'certificate', 'cert ' ) as $needle ) { if ( false !== strpos( $message, $needle ) ) { return true; } } return false; } /** * Parse an HTML string into a DOM document. * * @param string $html Raw HTML. * @return DOMDocument|WP_Error */ private function parse_html( $html ) { if ( ! class_exists( 'DOMDocument' ) || ! class_exists( 'DOMXPath' ) ) { return new WP_Error( 'oorank_audit_dom', __( 'The PHP DOM extension is required to run audits.', 'oorank' ), array( 'status' => 500 ) ); } if ( '' === trim( $html ) ) { return new WP_Error( 'oorank_audit_empty', __( 'The page returned an empty response.', 'oorank' ), array( 'status' => 400 ) ); } $previous = libxml_use_internal_errors( true ); $doc = new DOMDocument(); $loaded = $doc->loadHTML( '<?xml encoding="utf-8" ?>' . $html ); libxml_clear_errors(); libxml_use_internal_errors( $previous ); if ( ! $loaded ) { return new WP_Error( 'oorank_audit_parse', __( 'The page HTML could not be parsed.', 'oorank' ), array( 'status' => 400 ) ); } return $doc; } /** * Site-level facts shared by every URL audited in this hour: whether * llms.txt is reachable, and the live robots.txt body. * * @param bool $force Re-fetch instead of reading the cache. A user who just * fixed llms.txt or changed bot access and pressed "Run * audit" must not be shown the previous hour's answer. * @return array{llms_txt:bool, robots:string} */ private function site_context( $force = false ) { if ( ! $force ) { $cached = get_transient( self::SITE_CACHE ); if ( is_array( $cached ) && isset( $cached['robots'] ) ) { return $cached; } } $context = array( 'llms_txt' => false, 'robots' => '', ); $context['llms_txt'] = '' !== $this->fetch_text( home_url( '/llms.txt' ) ); $context['robots'] = $this->fetch_text( home_url( '/robots.txt' ) ); set_transient( self::SITE_CACHE, $context, self::SITE_CACHE_TTL ); return $context; } /** * Whether a robots.txt body disallows the whole site for a user-agent * token. A group naming the token wins over the wildcard group. * * @param string $robots robots.txt body. * @param string $token User-agent token, or '*'. * @return bool */ private function robots_blocks( $robots, $token ) { if ( '' === trim( (string) $robots ) ) { return false; } $groups = array(); $agents = array(); $reading = false; foreach ( preg_split( '/\R/', $robots ) as $line ) { $line = trim( preg_replace( '/#.*$/', '', $line ) ); if ( '' === $line || false === strpos( $line, ':' ) ) { continue; } list( $field, $value ) = array_map( 'trim', explode( ':', $line, 2 ) ); $field = strtolower( $field ); if ( 'user-agent' === $field ) { if ( $reading ) { // A User-agent after a rule starts a new group. $agents = array(); $reading = false; } $agents[] = strtolower( $value ); continue; } if ( 'disallow' !== $field || empty( $agents ) ) { continue; } $reading = true; foreach ( $agents as $agent ) { if ( ! isset( $groups[ $agent ] ) ) { $groups[ $agent ] = array(); } $groups[ $agent ][] = $value; } } $token = strtolower( $token ); $key = isset( $groups[ $token ] ) ? $token : '*'; if ( ! isset( $groups[ $key ] ) ) { return false; } return in_array( '/', $groups[ $key ], true ); } /** * Pull dateModified out of any JSON-LD block on the page. * * @param DOMXPath $xpath Document. * @return int|false Unix timestamp, or false. */ private function find_date_modified( DOMXPath $xpath ) { foreach ( $xpath->query( '//script[translate(@type,"ABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyz")="application/ld+json"]' ) as $block ) { $data = json_decode( trim( (string) $block->textContent ), true ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- DOM API property. if ( ! is_array( $data ) ) { continue; } $found = $this->find_key( $data, 'dateModified' ); if ( $found ) { $stamp = strtotime( (string) $found ); if ( $stamp ) { return $stamp; } } } $meta = $this->attr( $xpath, '//meta[@property="article:modified_time"]', 'content' ); if ( '' !== $meta ) { $stamp = strtotime( $meta ); if ( $stamp ) { return $stamp; } } return false; } /** * Depth-first search for a scalar value by key in a decoded JSON graph. * * @param array $data Decoded JSON. * @param string $key Key to find. * @return string|null */ private function find_key( $data, $key ) { foreach ( $data as $k => $value ) { if ( $k === $key && is_scalar( $value ) ) { return (string) $value; } if ( is_array( $value ) ) { $found = $this->find_key( $value, $key ); if ( null !== $found ) { return $found; } } } return null; } /** * Collect every @type value in a decoded JSON-LD graph. * * @param array $data Decoded JSON. * @return string[] */ private function collect_types( $data ) { $types = array(); foreach ( $data as $key => $value ) { if ( '@type' === $key ) { foreach ( (array) $value as $type ) { if ( is_scalar( $type ) ) { $types[] = (string) $type; } } continue; } if ( is_array( $value ) ) { $types = array_merge( $types, $this->collect_types( $value ) ); } } return $types; } /** * First matching element's text content. * * @param DOMXPath $xpath Document. * @param string $query XPath query. * @return string */ private function text( DOMXPath $xpath, $query ) { $node = $xpath->query( $query )->item( 0 ); return $node ? trim( preg_replace( '/\s+/u', ' ', (string) $node->textContent ) ) : ''; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- DOM API property. } /** * First matching element's attribute value. * * @param DOMXPath $xpath Document. * @param string $query XPath query. * @param string $attr Attribute name. * @return string */ private function attr( DOMXPath $xpath, $query, $attr ) { $node = $xpath->query( $query )->item( 0 ); return $node ? trim( (string) $node->getAttribute( $attr ) ) : ''; } // ---- URL helpers ---- /** * Validate and normalise a URL, rejecting anything off this site's host. * * The audit is a self-audit: restricting the host keeps free core free of * third-party requests and keeps the endpoint from being used as a proxy. * * @param string $url Candidate URL. * @return string|WP_Error */ private function normalize_url( $url ) { $url = trim( (string) $url ); if ( '' === $url ) { return new WP_Error( 'oorank_audit_url', __( 'No URL to audit.', 'oorank' ), array( 'status' => 400 ) ); } // Accept a bare path or host-relative URL typed into the audit screen. if ( ! preg_match( '#^https?://#i', $url ) ) { $url = home_url( '/' . ltrim( $url, '/' ) ); } $url = esc_url_raw( $url ); if ( '' === $url ) { return new WP_Error( 'oorank_audit_url', __( 'That is not a valid URL.', 'oorank' ), array( 'status' => 400 ) ); } if ( ! $this->is_same_site( $url ) ) { return new WP_Error( 'oorank_audit_offsite', __( 'Oorank only audits URLs on this site.', 'oorank' ), array( 'status' => 400 ) ); } return $this->base_url( $url ); } /** * Strip the fragment and trailing "?" from a URL for cache-key stability. * * @param string $url URL. * @return string */ private function base_url( $url ) { $url = strtok( (string) $url, '#' ); return rtrim( (string) $url, '?' ); } /** * Whether a URL is on this site's host. * * @param string $url URL. * @return bool */ private function is_same_site( $url ) { $host = wp_parse_url( $url, PHP_URL_HOST ); $home = wp_parse_url( home_url( '/' ), PHP_URL_HOST ); return ( $host && $home && strtolower( $host ) === strtolower( $home ) ); } /** * Whether the URL's host looks like a local development host. * * @param string $url URL. * @return bool */ private function is_local_host( $url ) { $host = strtolower( (string) wp_parse_url( $url, PHP_URL_HOST ) ); if ( '' === $host ) { return false; } if ( in_array( $host, array( 'localhost', '127.0.0.1', '::1' ), true ) ) { return true; } foreach ( array( '.test', '.local', '.localhost', '.dev.local' ) as $suffix ) { if ( substr( $host, -strlen( $suffix ) ) === $suffix ) { return true; } } return false; } /** * Whether an untrusted certificate may be ignored for this URL. * * True only in a development context — a local hostname, or a site that * declares itself local/development via WP_ENVIRONMENT_TYPE. * * @param string $url URL on this site. * @return bool */ private function allows_insecure_tls( $url ) { if ( $this->is_local_host( $url ) ) { return true; } return in_array( wp_get_environment_type(), array( 'local', 'development' ), true ); } /** * Whether two URLs address the same resource, ignoring scheme, "www." and * a trailing slash. * * @param string $a First URL. * @param string $b Second URL. * @return bool */ private function same_url( $a, $b ) { return $this->comparable( $a ) === $this->comparable( $b ); } /** * Reduce a URL to a comparable host+path string. * * @param string $url URL. * @return string */ private function comparable( $url ) { $parts = wp_parse_url( $this->base_url( $url ) ); if ( ! is_array( $parts ) ) { return ''; } $host = isset( $parts['host'] ) ? preg_replace( '/^www\./i', '', strtolower( $parts['host'] ) ) : ''; $path = isset( $parts['path'] ) ? untrailingslashit( $parts['path'] ) : ''; $query = isset( $parts['query'] ) ? '?' . $parts['query'] : ''; return $host . $path . $query; } /** * Transient key for a URL. * * @param string $url URL. * @return string */ private function cache_key( $url ) { return self::CACHE_PREFIX . md5( $url ); } /** * Edit-post deep link for a fix. * * Built directly rather than via get_edit_post_link(), which returns null * when no user is in context — audits also run from cron and WP-CLI, and * their cached results have to carry working links for whoever reads them. * Every surface that renders a result is already capability gated. * * @param int $post_id Post ID. * @return string */ private function edit_link( $post_id ) { $post_id = absint( $post_id ); if ( ! $post_id ) { return ''; } return admin_url( 'post.php?post=' . $post_id . '&action=edit' ); } /** * Link to a Oorank admin screen. * * @param string $tab Screen slug to deep-link to. Empty lands on Overview. * @return string */ private function settings_link( $tab = '' ) { $args = array( 'page' => Oorank_Admin::MENU_SLUG ); if ( $tab ) { $args['tab'] = $tab; } return add_query_arg( $args, admin_url( 'admin.php' ) ); } }