validate_url( $url ); if ( is_wp_error( $url ) ) { return $url; } $response = $this->safe_fetch( $url ); if ( is_wp_error( $response ) ) { return $response; } $html = $response['body']; $final_url = $response['final_url']; $parts = wp_parse_url( $final_url ); $origin = $parts['scheme'] . '://' . $parts['host'] . ( isset( $parts['port'] ) ? ':' . $parts['port'] : '' ); $dom = $this->load_dom( $html ); $checks = array(); // --- A. AIアクセス基盤 (30) --- $checks[] = $this->check_llms_txt( $origin ); $checks[] = $this->check_robots( $origin ); $checks[] = $this->check_sitemap( $origin ); $checks[] = $this->check_https( $final_url ); // --- B. 構造化・引用されやすさ (40) --- $jsonld = $this->extract_jsonld_types( $dom ); $checks[] = $this->check_jsonld( $jsonld ); $checks[] = $this->check_faq_howto( $jsonld ); $checks[] = $this->check_title_meta( $dom ); $checks[] = $this->check_headings( $dom ); $checks[] = $this->check_ogp( $dom ); // --- C. 信頼シグナル (30) --- $checks[] = $this->check_author( $dom, $jsonld ); $checks[] = $this->check_dates( $dom, $html, $jsonld ); $checks[] = $this->check_outbound_links( $dom, $parts['host'] ); $checks[] = $this->check_rss( $dom, $origin ); $score = 0; $max = 0; $cats = array(); foreach ( $checks as $c ) { $score += $c['passed'] ? $c['weight'] : 0; $max += $c['weight']; if ( ! isset( $cats[ $c['category'] ] ) ) { $cats[ $c['category'] ] = array( 'score' => 0, 'max' => 0 ); } $cats[ $c['category'] ]['score'] += $c['passed'] ? $c['weight'] : 0; $cats[ $c['category'] ]['max'] += $c['weight']; } return array( 'url' => $final_url, 'score' => $score, 'max' => $max, 'rank' => $this->rank( $score ), 'categories' => $cats, 'checks' => $checks, 'checked_at' => current_time( 'mysql' ), ); } /** スコアから評価ランクを返す。 */ private function rank( $score ) { if ( $score >= 85 ) { return 'A'; } if ( $score >= 65 ) { return 'B'; } if ( $score >= 40 ) { return 'C'; } return 'D'; } /* --------------------------------------------------------------------- * URL検証・安全な取得(SSRF対策) * ------------------------------------------------------------------- */ /** * URLを検証する。http/https限定・プライベートIP宛を拒否。 */ public function validate_url( $url ) { $url = trim( (string) $url ); if ( '' === $url ) { return new WP_Error( 'acsaiv_empty', __( 'Please enter a URL.', 'acs-ai-citation-readiness' ) ); } if ( ! preg_match( '#^https?://#i', $url ) ) { $url = 'https://' . $url; } $url = esc_url_raw( $url, array( 'http', 'https' ) ); if ( '' === $url ) { return new WP_Error( 'acsaiv_invalid', __( 'This is not a valid URL.', 'acs-ai-citation-readiness' ) ); } $host = wp_parse_url( $url, PHP_URL_HOST ); if ( ! $host || is_wp_error( $this->assert_public_host( $host ) ) ) { return new WP_Error( 'acsaiv_blocked', __( 'This URL cannot be checked. Please enter the URL of a publicly accessible website.', 'acs-ai-citation-readiness' ) ); } return $url; } /** * ホストがグローバルIPに解決されることを確認する。 */ private function assert_public_host( $host ) { // IPリテラルの場合はそのまま判定。 $ip = $host; if ( ! filter_var( $host, FILTER_VALIDATE_IP ) ) { $resolved = gethostbyname( $host ); if ( $resolved === $host ) { return new WP_Error( 'acsaiv_dns', 'DNS解決に失敗' ); } $ip = $resolved; } if ( ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) { return new WP_Error( 'acsaiv_private', 'プライベートIP宛は拒否' ); } return true; } /** * サイズ・タイムアウト・リダイレクトを制限してURLを取得する。 */ private function safe_fetch( $url, $head_only = false ) { $args = array( 'timeout' => self::FETCH_TIMEOUT, 'redirection' => 3, 'limit_response_size' => self::MAX_BODY_BYTES, 'user-agent' => 'Mozilla/5.0 (compatible; ACS-AI-Visibility/1.0; +https://acs-developer.com/)', ); $response = $head_only ? wp_remote_head( $url, $args ) : wp_remote_get( $url, $args ); if ( is_wp_error( $response ) ) { return new WP_Error( 'acsaiv_fetch', __( 'Could not fetch the site. Please check the URL.', 'acs-ai-citation-readiness' ) ); } $code = wp_remote_retrieve_response_code( $response ); if ( $code < 200 || $code >= 400 ) { /* translators: %d: HTTP status code. */ return new WP_Error( 'acsaiv_http', sprintf( __( 'The site returned an error (HTTP %d).', 'acs-ai-citation-readiness' ), $code ) ); } return array( 'body' => wp_remote_retrieve_body( $response ), 'code' => $code, 'final_url' => $url, ); } /** サブリソース(robots.txt等)の存在確認。200かつ本文ありでtrue。 */ private function resource_exists( $url, &$body = null ) { $res = $this->safe_fetch( $url ); if ( is_wp_error( $res ) ) { return false; } $body = $res['body']; return '' !== trim( (string) $body ); } private function load_dom( $html ) { $dom = new DOMDocument(); libxml_use_internal_errors( true ); $dom->loadHTML( '' . $html ); libxml_clear_errors(); return $dom; } /* --------------------------------------------------------------------- * チェック項目 * ------------------------------------------------------------------- */ private function result( $category, $label, $weight, $passed, $advice_ok, $advice_ng ) { return array( 'category' => $category, 'label' => $label, 'weight' => $weight, 'passed' => (bool) $passed, 'advice' => $passed ? $advice_ok : $advice_ng, ); } private function check_llms_txt( $origin ) { $body = null; $exists = $this->resource_exists( $origin . '/llms.txt', $body ); // HTMLが返ってくる404ページ対策: llms.txtはMarkdown/プレーンテキストのはず。 if ( $exists && false !== stripos( substr( (string) $body, 0, 500 ), 'result( 'A', 'llms.txt', 12, $exists, __( 'llms.txt is in place. AI can easily understand your site structure.', 'acs-ai-citation-readiness' ), __( 'llms.txt is missing. Add an entry-point file that tells AI what your site is about (this plugin can generate it automatically).', 'acs-ai-citation-readiness' ) ); } private function check_robots( $origin ) { $body = null; $blocked = array(); if ( $this->resource_exists( $origin . '/robots.txt', $body ) ) { $blocked = $this->blocked_ai_crawlers( (string) $body ); } $passed = empty( $blocked ); return $this->result( 'A', __( 'AI crawler access (robots.txt)', 'acs-ai-citation-readiness' ), 10, $passed, __( 'Major AI crawlers are not blocked.', 'acs-ai-citation-readiness' ), /* translators: %s: comma-separated list of blocked AI crawler names. */ sprintf( __( 'robots.txt blocks %s. Consider allowing them if you want to be cited.', 'acs-ai-citation-readiness' ), implode( ', ', $blocked ) ) ); } /** * robots.txtを解析し、サイト全体をDisallowされているAIクローラー名の配列を返す。 */ public function blocked_ai_crawlers( $robots_txt ) { $blocked = array(); $current_agents = array(); $rules_started = false; foreach ( preg_split( '/\r\n|\r|\n/', $robots_txt ) 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 ( $rules_started ) { $current_agents = array(); $rules_started = false; } $current_agents[] = $value; } elseif ( 'disallow' === $field ) { $rules_started = true; if ( '/' === $value ) { foreach ( self::AI_CRAWLERS as $bot ) { foreach ( $current_agents as $agent ) { if ( 0 === strcasecmp( $agent, $bot ) ) { $blocked[] = $bot; } } } } } else { $rules_started = true; } } return array_values( array_unique( $blocked ) ); } private function check_sitemap( $origin ) { $exists = $this->resource_exists( $origin . '/sitemap.xml' ) || $this->resource_exists( $origin . '/sitemap_index.xml' ) || $this->resource_exists( $origin . '/wp-sitemap.xml' ); return $this->result( 'A', 'sitemap.xml', 4, $exists, __( 'Sitemap found.', 'acs-ai-citation-readiness' ), __( 'No sitemap found. Crawlers may index your site less efficiently.', 'acs-ai-citation-readiness' ) ); } private function check_https( $url ) { $passed = 0 === stripos( $url, 'https://' ); return $this->result( 'A', 'HTTPS', 4, $passed, __( 'Served over HTTPS.', 'acs-ai-citation-readiness' ), __( 'Not served over HTTPS. Always-on SSL is strongly recommended because it directly affects trust evaluation.', 'acs-ai-citation-readiness' ) ); } /** ページ内のJSON-LD @type を全て集める。 */ private function extract_jsonld_types( DOMDocument $dom ) { $types = array(); foreach ( $dom->getElementsByTagName( 'script' ) as $script ) { if ( 'application/ld+json' !== strtolower( (string) $script->getAttribute( 'type' ) ) ) { continue; } $data = json_decode( $script->textContent, true ); if ( null === $data ) { continue; } $stack = array( $data ); while ( $stack ) { $node = array_pop( $stack ); if ( ! is_array( $node ) ) { continue; } if ( isset( $node['@type'] ) ) { foreach ( (array) $node['@type'] as $t ) { if ( is_string( $t ) ) { $types[] = $t; } } } foreach ( $node as $v ) { if ( is_array( $v ) ) { $stack[] = $v; } } } } return array_values( array_unique( $types ) ); } private function check_jsonld( array $types ) { return $this->result( 'B', __( 'Structured data (JSON-LD)', 'acs-ai-citation-readiness' ), 10, ! empty( $types ), /* translators: %s: comma-separated list of schema.org types found. */ sprintf( __( 'Structured data found (%s).', 'acs-ai-citation-readiness' ), implode( ', ', array_slice( $types, 0, 5 ) ) ), __( 'No structured data found. Provide machine-readable markup so AI can understand what your pages mean.', 'acs-ai-citation-readiness' ) ); } private function check_faq_howto( array $types ) { $passed = (bool) array_intersect( array( 'FAQPage', 'HowTo', 'Question' ), $types ); return $this->result( 'B', __( 'FAQ / HowTo schema', 'acs-ai-citation-readiness' ), 8, $passed, __( 'FAQ/HowTo schema found. Question-and-answer formats are easy for AI to cite in answers.', 'acs-ai-citation-readiness' ), __( 'No FAQ/HowTo schema found. Structuring frequent questions as Q&A makes them easier to cite (insert them with the FAQ shortcode in this plugin).', 'acs-ai-citation-readiness' ) ); } private function check_title_meta( DOMDocument $dom ) { $title = ''; $nodes = $dom->getElementsByTagName( 'title' ); if ( $nodes->length > 0 ) { $title = trim( $nodes->item( 0 )->textContent ); } $desc = $this->get_meta( $dom, 'description' ); $title_len = mb_strlen( $title ); $desc_len = mb_strlen( $desc ); $passed = $title_len >= 10 && $title_len <= 70 && $desc_len >= 50 && $desc_len <= 200; return $this->result( 'B', __( 'title / meta description', 'acs-ai-citation-readiness' ), 8, $passed, __( 'The title and meta description have appropriate lengths.', 'acs-ai-citation-readiness' ), __( 'The title or meta description is missing or unsuitable. AI uses summaries as answer material, so write a description that conveys the conclusion of the page.', 'acs-ai-citation-readiness' ) ); } private function check_headings( DOMDocument $dom ) { $h1 = $dom->getElementsByTagName( 'h1' )->length; $h2 = $dom->getElementsByTagName( 'h2' )->length; $passed = ( 1 === $h1 && $h2 >= 2 ); return $this->result( 'B', __( 'Heading structure', 'acs-ai-citation-readiness' ), 8, $passed, __( 'One h1 and multiple h2 headings: AI can follow the structure easily.', 'acs-ai-citation-readiness' ), /* translators: 1: number of h1 headings, 2: number of h2 headings. */ sprintf( __( 'Heading structure can be improved (h1: %1$d / h2: %2$d). Use exactly one h1 and divide the body with h2 headings.', 'acs-ai-citation-readiness' ), $h1, $h2 ) ); } private function check_ogp( DOMDocument $dom ) { $og_title = $this->get_meta( $dom, 'og:title', 'property' ); $og_desc = $this->get_meta( $dom, 'og:description', 'property' ); $passed = '' !== $og_title && '' !== $og_desc; return $this->result( 'B', 'OGP', 6, $passed, __( 'OGP tags are set.', 'acs-ai-citation-readiness' ), __( 'OGP tags (og:title / og:description) are missing. This hurts previews on social media and in AI interfaces.', 'acs-ai-citation-readiness' ) ); } private function check_author( DOMDocument $dom, array $types ) { $passed = (bool) array_intersect( array( 'Person', 'Organization' ), $types ); if ( ! $passed ) { // 運営者情報系ページへのリンクで代替判定。 foreach ( $dom->getElementsByTagName( 'a' ) as $a ) { $href = strtolower( (string) $a->getAttribute( 'href' ) ); $text = (string) $a->textContent; if ( preg_match( '#(about|company|profile|author)#', $href ) || false !== mb_strpos( $text, '運営者' ) || false !== mb_strpos( $text, '会社概要' ) ) { $passed = true; break; } } } return $this->result( 'C', __( 'Author / publisher info', 'acs-ai-citation-readiness' ), 10, $passed, __( 'Author or publisher information is present.', 'acs-ai-citation-readiness' ), __( 'No author or publisher information found. Knowing who wrote the content is the core of trust evaluation (E-E-A-T) in the AI era. Add an about page and Person/Organization schema.', 'acs-ai-citation-readiness' ) ); } private function check_dates( DOMDocument $dom, $html, array $types ) { $passed = $dom->getElementsByTagName( 'time' )->length > 0 || false !== strpos( $html, 'datePublished' ) || false !== strpos( $html, 'dateModified' ); return $this->result( 'C', __( 'Date information', 'acs-ai-citation-readiness' ), 10, $passed, __( 'Published and modified dates are present.', 'acs-ai-citation-readiness' ), __( 'No date information found. AI values freshness, so expose published and modified dates in machine-readable form.', 'acs-ai-citation-readiness' ) ); } private function check_outbound_links( DOMDocument $dom, $host ) { $count = 0; foreach ( $dom->getElementsByTagName( 'a' ) as $a ) { $href = (string) $a->getAttribute( 'href' ); $h = wp_parse_url( $href, PHP_URL_HOST ); if ( $h && 0 !== strcasecmp( $h, $host ) ) { $count++; } } $passed = $count >= 1; return $this->result( 'C', __( 'Citations / outbound links', 'acs-ai-citation-readiness' ), 6, $passed, __( 'Outbound reference links are present. Citing sources is a trust signal.', 'acs-ai-citation-readiness' ), __( 'No outbound source links found. Linking to primary sources that back your claims raises trust with AI.', 'acs-ai-citation-readiness' ) ); } private function check_rss( DOMDocument $dom, $origin ) { $passed = false; foreach ( $dom->getElementsByTagName( 'link' ) as $link ) { if ( false !== stripos( (string) $link->getAttribute( 'type' ), 'rss' ) ) { $passed = true; break; } } if ( ! $passed ) { $passed = $this->resource_exists( $origin . '/feed/' ); } return $this->result( 'C', __( 'RSS feed', 'acs-ai-citation-readiness' ), 4, $passed, __( 'RSS feed found. Updates propagate easily to AI and crawlers.', 'acs-ai-citation-readiness' ), __( 'No RSS feed found. A machine-readable update feed encourages more frequent crawling.', 'acs-ai-citation-readiness' ) ); } /** meta要素の値を取得する。 */ private function get_meta( DOMDocument $dom, $name, $attr = 'name' ) { foreach ( $dom->getElementsByTagName( 'meta' ) as $meta ) { if ( 0 === strcasecmp( (string) $meta->getAttribute( $attr ), $name ) ) { return trim( (string) $meta->getAttribute( 'content' ) ); } } return ''; } }