display label. Column order in the citations table. */ const ENGINES = array( 'openai' => 'ChatGPT', 'perplexity' => 'Perplexity', 'google' => 'Gemini', 'anthropic' => 'Claude', 'grok' => 'Grok', 'ollama' => 'Ollama', ); const OPT_RESULTS = 'aicitare_citations_live'; const OPT_CHECKED = 'aicitare_vis_checked'; const OPT_STATUS = 'aicitare_engine_status'; const OPT_ANSWERS = 'aicitare_answers'; const OPT_PROMPTS = 'aicitare_prompts'; const OPT_HISTORY = 'aicitare_vis_history'; const OPT_COMPETITORS = 'aicitare_competitors'; const OPT_SHARE = 'aicitare_share'; const OPT_DISCOVERED = 'aicitare_discovered'; /** Domains to ignore when auto-discovering competitors. */ const NOISE = array( 'schema.org', 'w3.org', 'wordpress.org', 'gravatar.com', 'gstatic.com', 'googleapis.com', 'google.com', 'bing.com', 'youtube.com', 'facebook.com', 'twitter.com', 'x.com', 'instagram.com', 'openai.com', 'perplexity.ai', 'anthropic.com', 'x.ai', 'reddit.com', 'wikipedia.org', 'amazon.com', ); public function hooks() { add_action( 'aicitare_weekly_visibility', array( $this, 'run' ) ); } /** Tracked prompts (default: the seed questions; filterable). */ public static function prompts() { $stored = get_option( self::OPT_PROMPTS ); if ( is_array( $stored ) && $stored ) { return $stored; } // No prompts saved yet: the pillow demo prompts only in Demo mode, // otherwise start empty so the user adds their own. $settings = AICitare_Data::get_settings(); $qs = array(); if ( ! empty( $settings['demo_mode'] ) ) { foreach ( AICitare_Data::citations() as $c ) { $qs[] = $c['q']; } } return apply_filters( 'aicitare_prompts', $qs ); } /** Save the tracked-prompts list (empty array reverts to defaults). */ public static function save_prompts( $list ) { $clean = array(); foreach ( (array) $list as $p ) { $p = sanitize_text_field( $p ); if ( '' !== trim( $p ) ) { $clean[] = $p; } } $clean = array_values( array_unique( $clean ) ); if ( empty( $clean ) ) { delete_option( self::OPT_PROMPTS ); } else { update_option( self::OPT_PROMPTS, $clean ); } return self::prompts(); } /** Citation-trend history (totals & avg position per check). */ public static function vis_history() { $h = get_option( self::OPT_HISTORY ); return is_array( $h ) ? $h : array(); } /** Tracked competitors [{ name, domain }] (default: common pillow-review sites). */ public static function competitors_list() { $stored = get_option( self::OPT_COMPETITORS ); if ( is_array( $stored ) && $stored ) { return $stored; } return array( array( 'name' => 'Sleep Foundation', 'domain' => 'sleepfoundation.org' ), array( 'name' => 'Wirecutter', 'domain' => 'nytimes.com/wirecutter' ), array( 'name' => 'Sleepopolis', 'domain' => 'sleepopolis.com' ), array( 'name' => "Tom's Guide", 'domain' => 'tomsguide.com' ), ); } /** Save competitor list (empty reverts to defaults). */ public static function save_competitors( $list ) { $clean = array(); foreach ( (array) $list as $c ) { $name = isset( $c['name'] ) ? sanitize_text_field( $c['name'] ) : ''; $domain = isset( $c['domain'] ) ? sanitize_text_field( $c['domain'] ) : ''; $domain = preg_replace( '#^https?://#', '', trim( $domain ) ); $domain = preg_replace( '#^www\.#', '', $domain ); if ( '' !== $name && '' !== $domain ) { $clean[] = array( 'name' => $name, 'domain' => $domain ); } } if ( empty( $clean ) ) { delete_option( self::OPT_COMPETITORS ); } else { update_option( self::OPT_COMPETITORS, $clean ); } return self::competitors_list(); } /** * Share-of-voice for the dashboard: * - computed live data if a visibility check has produced it; else * - the demo dataset when Demo mode is on (illustrative); else * - the user's tracked competitors at 0% (so additions show immediately, * pending a real check). */ /** Auto-discovered competitor suggestions from the last check. */ public static function discovered() { $d = get_option( self::OPT_DISCOVERED ); return is_array( $d ) ? $d : array(); } /** Domains referenced in a response (from sources + text), normalized & unique. */ private function extract_domains( $resp ) { $hosts = array(); foreach ( (array) ( isset( $resp['sources'] ) ? $resp['sources'] : array() ) as $u ) { $h = self::norm_host( $u ); if ( $h ) { $hosts[] = $h; } } $txt = isset( $resp['text'] ) ? $resp['text'] : ''; if ( $txt && preg_match_all( '#\b((?:[a-z0-9-]+\.)+(?:com|org|net|io|co|ai|gov|edu|guide|shop|store|blog))\b#i', $txt, $m ) ) { foreach ( $m[1] as $d ) { $h = self::norm_host( $d ); if ( $h ) { $hosts[] = $h; } } } return array_values( array_unique( $hosts ) ); } /** Normalize a URL/host to a bare lowercase host (no scheme, no www, no path). */ private static function norm_host( $u ) { $u = trim( (string) $u ); if ( '' === $u ) { return ''; } if ( ! preg_match( '#^https?://#i', $u ) ) { $u = 'http://' . $u; } $h = wp_parse_url( $u, PHP_URL_HOST ); return $h ? preg_replace( '#^www\.#', '', strtolower( $h ) ) : ''; } /** Human-friendly name from a domain (sleepopolis.com -> Sleepopolis). */ private static function nice_name( $domain ) { $parts = explode( '.', $domain ); return ucfirst( $parts[0] ); } public static function get_share() { $live = get_option( self::OPT_SHARE ); if ( is_array( $live ) && $live ) { return $live; } $settings = AICitare_Data::get_settings(); if ( ! empty( $settings['demo_mode'] ) ) { return AICitare_Data::competitors(); } $out = array( array( 'name' => get_bloginfo( 'name' ), 'share' => 0, 'me' => true ) ); foreach ( self::competitors_list() as $c ) { $out[] = array( 'name' => $c['name'], 'share' => 0 ); } return $out; } /** Which engines are usable now (API key configured, or Ollama enabled). */ public static function active_engines() { $keys = AICitare_Data::get_keys_plain(); $settings = AICitare_Data::get_settings(); $active = array(); foreach ( array_keys( self::ENGINES ) as $eng ) { if ( 'ollama' === $eng ) { $active[ $eng ] = ! empty( $settings['ollama_enabled'] ) && ! empty( $settings['ollama_url'] ); } else { $active[ $eng ] = ! empty( $keys[ $eng ] ); } } return $active; } /** Run the full check across prompts × engines. */ public function run() { $keys = AICitare_Data::get_keys_plain(); $settings = AICitare_Data::get_settings(); $host = self::host(); $name = self::site_name(); $comps = self::competitors_list(); $results = array(); $counts = array(); // mention tally for share-of-voice. $status = array(); // per-engine: ok count + last error, for diagnostics. $answers = array(); // raw model answers per prompt, for inspection. $disc = array(); // auto-discovered domain tally. // Domains to exclude from discovery: our own + already-tracked + noise. $exclude = array_merge( array( self::norm_host( $host ) ), self::NOISE ); foreach ( $comps as $c ) { $h = self::norm_host( $c['domain'] ); if ( $h ) { $exclude[] = $h; } } foreach ( self::prompts() as $q ) { $row = array(); $arow = array(); foreach ( array_keys( self::ENGINES ) as $eng ) { if ( 'ollama' === $eng ) { if ( empty( $settings['ollama_enabled'] ) || empty( $settings['ollama_url'] ) ) { $row[] = null; continue; } $resp = $this->ollama( $settings['ollama_url'], isset( $settings['ollama_model'] ) ? $settings['ollama_model'] : '', $q ); } else { $key = isset( $keys[ $eng ] ) ? $keys[ $eng ] : ''; if ( '' === $key ) { $row[] = null; continue; } $resp = $this->ask( $eng, $key, $q ); } if ( ! isset( $status[ $eng ] ) ) { $status[ $eng ] = array( 'ok' => 0, 'err' => '' ); } if ( is_wp_error( $resp ) ) { $status[ $eng ]['err'] = $resp->get_error_message(); $arow[ $eng ] = '⚠ ' . $resp->get_error_message(); $row[] = null; continue; } $status[ $eng ]['ok']++; $text = isset( $resp['text'] ) ? $resp['text'] : ''; $arow[ $eng ] = function_exists( 'mb_substr' ) ? mb_substr( $text, 0, 1200 ) : substr( $text, 0, 1200 ); $row[] = $this->position( $resp, $host, $name ); foreach ( $this->mentions( $resp, $host, $name, $comps ) as $k ) { $counts[ $k ] = ( isset( $counts[ $k ] ) ? $counts[ $k ] : 0 ) + 1; } foreach ( $this->extract_domains( $resp ) as $d ) { if ( in_array( $d, $exclude, true ) ) { continue; } $disc[ $d ] = ( isset( $disc[ $d ] ) ? $disc[ $d ] : 0 ) + 1; } } $results[] = array( 'q' => $q, 'e' => $row ); $answers[] = array( 'q' => $q, 'a' => $arow ); } update_option( self::OPT_ANSWERS, $answers ); update_option( self::OPT_RESULTS, $results ); update_option( self::OPT_CHECKED, current_time( 'mysql' ) ); update_option( self::OPT_SHARE, $this->compute_share( $counts, $comps ) ); update_option( self::OPT_STATUS, $status ); // Build auto-discovered competitor suggestions (most-cited first). arsort( $disc ); $discovered = array(); $k = 0; foreach ( $disc as $dom => $cnt ) { if ( $cnt < 2 ) { continue; // only suggest domains seen more than once. } $discovered[] = array( 'domain' => $dom, 'name' => self::nice_name( $dom ), 'count' => (int) $cnt ); if ( ++$k >= 12 ) { break; } } update_option( self::OPT_DISCOVERED, $discovered ); // Append to the citation-trend history. $total = 0; $pos = array(); foreach ( $results as $r ) { foreach ( $r['e'] as $v ) { if ( null !== $v ) { $total++; $pos[] = $v; } } } $hist = self::vis_history(); $hist[] = array( 'date' => current_time( 'Y-m-d' ), 'total' => $total, 'avg' => $pos ? round( array_sum( $pos ) / count( $pos ), 1 ) : 0, ); if ( count( $hist ) > 24 ) { $hist = array_slice( $hist, -24 ); } update_option( self::OPT_HISTORY, $hist ); $active = count( array_filter( self::active_engines() ) ); AICitare_Log::add( 'visibility', sprintf( 'Visibility check — %d prompts, %d engine(s), %d citations', count( self::prompts() ), $active, $total ) ); return $results; } /** Which tracked entities (our site + competitors) are mentioned in a response. */ private function mentions( $resp, $host, $name, $comps ) { $hay = strtolower( ( isset( $resp['text'] ) ? $resp['text'] : '' ) . ' ' . implode( ' ', (array) ( isset( $resp['sources'] ) ? $resp['sources'] : array() ) ) ); $out = array(); if ( self::hay_has( $hay, $host, $name ) ) { $out[] = '__me__'; } foreach ( $comps as $c ) { if ( self::hay_has( $hay, trim( $c['domain'] ), $c['name'] ) ) { $out[] = 'c:' . $c['name']; } } return array_unique( $out ); } /** True if the haystack contains the domain, or the brand name (>=4 chars). */ private static function hay_has( $hay, $domain, $name ) { $domain = strtolower( (string) $domain ); if ( '' !== $domain && false !== strpos( $hay, $domain ) ) { return true; } $name = strtolower( trim( (string) $name ) ); if ( strlen( $name ) >= 4 && false !== strpos( $hay, $name ) ) { return true; } return false; } /** Lowercased site/brand name for text matching. */ private static function site_name() { return strtolower( trim( (string) get_bloginfo( 'name' ) ) ); } /** Turn a mention tally into share-of-voice percentages. */ private function compute_share( $counts, $comps ) { $total = array_sum( $counts ); if ( $total <= 0 ) { return array(); // nothing detected → keep demo data. } $share = array(); if ( ! empty( $counts['__me__'] ) ) { $share[] = array( 'name' => get_bloginfo( 'name' ), 'share' => (int) round( $counts['__me__'] / $total * 100 ), 'me' => true ); } foreach ( $comps as $c ) { $k = 'c:' . $c['name']; if ( ! empty( $counts[ $k ] ) ) { $share[] = array( 'name' => $c['name'], 'share' => (int) round( $counts[ $k ] / $total * 100 ) ); } } usort( $share, function ( $a, $b ) { return $b['share'] - $a['share']; } ); return $share; } /** Determine citation position from an engine response (by domain in sources, or domain/brand in text). */ private function position( $resp, $host, $name = '' ) { if ( $host && ! empty( $resp['sources'] ) ) { $pos = 1; foreach ( $resp['sources'] as $url ) { if ( false !== stripos( (string) $url, $host ) ) { return $pos; } $pos++; } } $text = isset( $resp['text'] ) ? strtolower( $resp['text'] ) : ''; if ( '' === $text ) { return null; } // Domain or brand-name mention in the answer text counts as cited. if ( $host && false !== strpos( $text, strtolower( $host ) ) ) { return 1; } $name = strtolower( trim( (string) $name ) ); if ( strlen( $name ) >= 4 && false !== strpos( $text, $name ) ) { return 1; } return null; } /** Recommended default model per engine. */ public static function recommended( $eng ) { $map = array( 'openai' => 'gpt-4o-mini', 'perplexity' => 'sonar', 'google' => 'gemini-2.0-flash', 'anthropic' => 'claude-3-5-haiku-latest', 'grok' => 'grok-3', 'ollama' => 'llama3.2', ); return isset( $map[ $eng ] ) ? $map[ $eng ] : ''; } /** Fetch the list of available models for an engine from its API. */ public static function list_models( $eng ) { $rec = self::recommended( $eng ); if ( 'ollama' === $eng ) { $s = AICitare_Data::get_settings(); if ( empty( $s['ollama_enabled'] ) || empty( $s['ollama_url'] ) ) { return array( 'ok' => false, 'msg' => 'Ollama is not enabled' ); } $r = wp_remote_get( rtrim( $s['ollama_url'], '/' ) . '/api/tags', array( 'timeout' => 15 ) ); if ( is_wp_error( $r ) ) { return array( 'ok' => false, 'msg' => $r->get_error_message() ); } $models = array(); $j = json_decode( wp_remote_retrieve_body( $r ), true ); if ( isset( $j['models'] ) ) { foreach ( $j['models'] as $m ) { if ( ! empty( $m['name'] ) ) { $models[] = $m['name']; } } } sort( $models ); return array( 'ok' => true, 'models' => $models, 'recommended' => $rec ); } if ( 'perplexity' === $eng ) { // Perplexity has no public models endpoint — curated list. return array( 'ok' => true, 'models' => array( 'sonar', 'sonar-pro', 'sonar-reasoning', 'sonar-reasoning-pro', 'sonar-deep-research' ), 'recommended' => $rec ); } $keys = AICitare_Data::get_keys_plain(); $key = isset( $keys[ $eng ] ) ? $keys[ $eng ] : ''; if ( '' === $key ) { return array( 'ok' => false, 'msg' => 'No key set' ); } $models = array(); if ( 'openai' === $eng || 'grok' === $eng ) { $url = 'openai' === $eng ? 'https://api.openai.com/v1/models' : 'https://api.x.ai/v1/models'; $r = wp_remote_get( $url, array( 'timeout' => 20, 'headers' => array( 'Authorization' => 'Bearer ' . $key ) ) ); $models = self::ids_from_data( $r ); } elseif ( 'anthropic' === $eng ) { $r = wp_remote_get( 'https://api.anthropic.com/v1/models', array( 'timeout' => 20, 'headers' => array( 'x-api-key' => $key, 'anthropic-version' => '2023-06-01' ) ) ); $models = self::ids_from_data( $r ); } elseif ( 'google' === $eng ) { $r = wp_remote_get( 'https://generativelanguage.googleapis.com/v1beta/models?key=' . rawurlencode( $key ), array( 'timeout' => 20 ) ); if ( ! is_wp_error( $r ) && (int) wp_remote_retrieve_response_code( $r ) < 400 ) { $j = json_decode( wp_remote_retrieve_body( $r ), true ); if ( isset( $j['models'] ) ) { foreach ( $j['models'] as $m ) { $methods = isset( $m['supportedGenerationMethods'] ) ? $m['supportedGenerationMethods'] : array(); if ( in_array( 'generateContent', $methods, true ) && ! empty( $m['name'] ) ) { $models[] = preg_replace( '#^models/#', '', $m['name'] ); } } } } } else { return array( 'ok' => false, 'msg' => 'Unsupported engine' ); } if ( is_wp_error( $r ) ) { return array( 'ok' => false, 'msg' => $r->get_error_message() ); } $code = (int) wp_remote_retrieve_response_code( $r ); if ( $code >= 400 ) { $b = json_decode( wp_remote_retrieve_body( $r ), true ); return array( 'ok' => false, 'msg' => 'HTTP ' . $code . ( isset( $b['error']['message'] ) ? ': ' . $b['error']['message'] : '' ) ); } sort( $models ); return array( 'ok' => true, 'models' => array_values( array_unique( $models ) ), 'recommended' => $rec ); } private static function ids_from_data( $r ) { if ( is_wp_error( $r ) ) { return array(); } $j = json_decode( wp_remote_retrieve_body( $r ), true ); $out = array(); if ( isset( $j['data'] ) ) { foreach ( $j['data'] as $m ) { if ( ! empty( $m['id'] ) ) { $out[] = $m['id']; } } } return $out; } /** Test a single engine with a trivial prompt. Returns ['ok'=>bool,'msg'=>string]. */ public static function test( $eng ) { $self = new self(); $q = 'Reply with the single word OK.'; if ( 'ollama' === $eng ) { $s = AICitare_Data::get_settings(); if ( empty( $s['ollama_enabled'] ) || empty( $s['ollama_url'] ) ) { return array( 'ok' => false, 'msg' => 'Ollama is not enabled' ); } $r = $self->ollama( $s['ollama_url'], isset( $s['ollama_model'] ) ? $s['ollama_model'] : '', $q ); } else { $keys = AICitare_Data::get_keys_plain(); $key = isset( $keys[ $eng ] ) ? $keys[ $eng ] : ''; if ( '' === $key ) { return array( 'ok' => false, 'msg' => 'No key set' ); } $r = $self->ask( $eng, $key, $q ); } if ( is_wp_error( $r ) ) { return array( 'ok' => false, 'msg' => $r->get_error_message() ); } $txt = isset( $r['text'] ) ? trim( $r['text'] ) : ''; $snip = '' !== $txt ? ( function_exists( 'mb_substr' ) ? mb_substr( $txt, 0, 60 ) : substr( $txt, 0, 60 ) ) : ''; return array( 'ok' => true, 'msg' => $snip ? ( 'Responded: ' . $snip ) : 'Responded' ); } /** * Produce GEO-optimized rephrasings of a piece of text. * Returns [ 'ok'=>true, 'engine'=>slug, 'variants'=>[..] ] or WP_Error. */ public static function rephrase( $text, $engine = '', $n = 3 ) { $text = trim( (string) $text ); if ( '' === $text ) { return new WP_Error( 'empty', 'Please provide some text to rephrase.' ); } if ( strlen( $text ) > 6000 ) { $text = substr( $text, 0, 6000 ); } $n = max( 1, min( 5, (int) $n ) ); $active = self::active_engines(); if ( '' === $engine || empty( $active[ $engine ] ) ) { $engine = ''; foreach ( $active as $e => $on ) { if ( $on ) { $engine = $e; break; } } } if ( '' === $engine ) { return new WP_Error( 'no_engine', 'No LLM is configured. Add an API key (or enable Ollama) in Settings.' ); } $prompt = "You are a GEO (Generative Engine Optimization) copy editor. Rewrite the text below so AI assistants (ChatGPT, Perplexity, Google AI Overviews, Gemini, Claude) are more likely to quote and cite it.\n\n" . "Each rewrite should: lead with a direct, self-contained answer; state clear, verifiable facts; use a natural, confident, non-spammy tone; stay roughly the same length; and keep the SAME language as the input.\n\n" . "Give exactly {$n} DISTINCT alternatives that differ in phrasing and structure.\n" . "Return ONLY a JSON array of {$n} strings — no markdown fences, no numbering, no commentary.\n\n" . "TEXT:\n\"\"\"\n{$text}\n\"\"\""; $self = new self(); if ( 'ollama' === $engine ) { $s = AICitare_Data::get_settings(); $r = $self->ollama( $s['ollama_url'], isset( $s['ollama_model'] ) ? $s['ollama_model'] : '', $prompt ); } else { $keys = AICitare_Data::get_keys_plain(); $r = $self->ask( $engine, isset( $keys[ $engine ] ) ? $keys[ $engine ] : '', $prompt ); } if ( is_wp_error( $r ) ) { return $r; } $variants = self::parse_variants( isset( $r['text'] ) ? $r['text'] : '', $n ); if ( empty( $variants ) ) { return new WP_Error( 'parse', 'The model did not return usable suggestions. Please try again.' ); } AICitare_Log::add( 'rephrase', 'Generated ' . count( $variants ) . ' GEO rephrasings via ' . $engine . '.' ); return array( 'ok' => true, 'engine' => $engine, 'variants' => $variants ); } /** * Generate FAQ question/answer pairs from article text via the active LLM. * Returns [ 'ok'=>true, 'engine'=>slug, 'faqs'=>[ {q,a}, ... ] ] or WP_Error. */ public static function generate_faq( $text, $engine = '', $n = 5 ) { $text = trim( wp_strip_all_tags( (string) $text ) ); if ( '' === $text ) { return new WP_Error( 'empty', 'There is no content to build a FAQ from.' ); } if ( strlen( $text ) > 8000 ) { $text = substr( $text, 0, 8000 ); } $n = max( 3, min( 8, (int) $n ) ); $active = self::active_engines(); if ( '' === $engine || empty( $active[ $engine ] ) ) { $engine = ''; foreach ( $active as $e => $on ) { if ( $on ) { $engine = $e; break; } } } if ( '' === $engine ) { return new WP_Error( 'no_engine', 'No LLM is configured. Add an API key (or enable Ollama) in Settings.' ); } $prompt = "You are an SEO/GEO editor. From the article below, write {$n} frequently-asked question-and-answer pairs that real readers would ask and that are answerable from the article.\n\n" . "Each answer must be 1–3 sentences, factual, and in the SAME language as the article. Do not invent facts not supported by the text.\n" . "Return ONLY a JSON array of objects, each with keys \"q\" (question) and \"a\" (answer). No markdown, no commentary.\n\n" . "ARTICLE:\n\"\"\"\n{$text}\n\"\"\""; $self = new self(); if ( 'ollama' === $engine ) { $s = AICitare_Data::get_settings(); $r = $self->ollama( $s['ollama_url'], isset( $s['ollama_model'] ) ? $s['ollama_model'] : '', $prompt ); } else { $keys = AICitare_Data::get_keys_plain(); $r = $self->ask( $engine, isset( $keys[ $engine ] ) ? $keys[ $engine ] : '', $prompt ); } if ( is_wp_error( $r ) ) { return $r; } $faqs = self::parse_faq( isset( $r['text'] ) ? $r['text'] : '', $n ); if ( empty( $faqs ) ) { return new WP_Error( 'parse', 'The model did not return usable FAQ pairs. Please try again.' ); } AICitare_Log::add( 'faq', 'Generated ' . count( $faqs ) . ' FAQ pairs via ' . $engine . '.' ); return array( 'ok' => true, 'engine' => $engine, 'faqs' => $faqs ); } /** Parse model output into a list of { q, a } FAQ pairs. */ private static function parse_faq( $text, $n ) { $text = trim( (string) $text ); $text = preg_replace( '#^```[a-z]*\s*#i', '', $text ); $text = preg_replace( '#\s*```$#', '', $text ); $start = strpos( $text, '[' ); $end = strrpos( $text, ']' ); $out = array(); if ( false !== $start && false !== $end && $end > $start ) { $arr = json_decode( substr( $text, $start, $end - $start + 1 ), true ); if ( is_array( $arr ) ) { foreach ( $arr as $row ) { if ( ! is_array( $row ) ) { continue; } $q = isset( $row['q'] ) ? trim( (string) $row['q'] ) : ( isset( $row['question'] ) ? trim( (string) $row['question'] ) : '' ); $a = isset( $row['a'] ) ? trim( (string) $row['a'] ) : ( isset( $row['answer'] ) ? trim( (string) $row['answer'] ) : '' ); if ( '' !== $q && '' !== $a ) { $out[] = array( 'q' => $q, 'a' => $a ); } } } } return array_slice( $out, 0, $n ); } /** * Return the real post body of a dashboard page, split into paragraphs. * Dashboard page ids are 'wp{ID}' for real posts; demo pages return a WP_Error. */ public static function page_paragraphs( $page_id ) { $page_id = (string) $page_id; $pid = 0; if ( preg_match( '#^wp(\d+)$#', $page_id, $m ) ) { $pid = (int) $m[1]; } if ( ! $pid ) { return new WP_Error( 'demo', 'This is a demo page — there is no WordPress post to load. Paste text below instead.' ); } $post = get_post( $pid ); if ( ! $post ) { return new WP_Error( 'notfound', 'Post not found.' ); } $html = do_shortcode( $post->post_content ); $paras = self::html_paragraphs( $html ); if ( empty( $paras ) ) { $paras = self::html_paragraphs( wpautop( $post->post_content ) ); } if ( empty( $paras ) ) { return new WP_Error( 'nocontent', 'This post has no readable paragraphs to rephrase.' ); } return array( 'ok' => true, 'postId' => $pid, 'title' => get_the_title( $pid ), 'paragraphs' => $paras, 'editUrl' => admin_url( 'post.php?post=' . $pid . '&action=edit' ), ); } /** Extract block-level text chunks (paragraphs, headings, list items) from HTML. */ private static function html_paragraphs( $html ) { $out = array(); if ( preg_match_all( '#<(p|h[1-6]|li)\b[^>]*>(.*?)#is', (string) $html, $m, PREG_SET_ORDER ) ) { foreach ( $m as $mm ) { $txt = self::block_text( $mm[2] ); if ( ( function_exists( 'mb_strlen' ) ? mb_strlen( $txt ) : strlen( $txt ) ) < 15 ) { continue; } $out[] = array( 'text' => $txt, 'tag' => strtolower( $mm[1] ) ); if ( count( $out ) >= 40 ) { break; } } } return $out; } /** Normalize a block's inner HTML to plain text (shared by extraction and replacement). */ private static function block_text( $inner ) { return trim( preg_replace( '#\s+#', ' ', html_entity_decode( wp_strip_all_tags( $inner ), ENT_QUOTES ) ) ); } /** * Apply chosen paragraph rephrasings and save the result as a DRAFT copy * awaiting approval, leaving the live post untouched. * $changes = [ [ 'i' => blockIndex, 'text' => newText ], ... ]. */ public static function create_rephrase_draft( $page_id, $changes ) { $page_id = (string) $page_id; if ( ! preg_match( '#^wp(\d+)$#', $page_id, $m ) ) { return new WP_Error( 'demo', 'Only real WordPress posts can be saved as drafts.' ); } $post = get_post( (int) $m[1] ); if ( ! $post ) { return new WP_Error( 'notfound', 'Post not found.' ); } $repl = array(); if ( is_array( $changes ) ) { foreach ( $changes as $c ) { if ( isset( $c['i'] ) && isset( $c['text'] ) && '' !== trim( (string) $c['text'] ) ) { $repl[ (int) $c['i'] ] = (string) $c['text']; } } } if ( empty( $repl ) ) { return new WP_Error( 'empty', 'No paragraph changes were selected.' ); } $html = do_shortcode( $post->post_content ); $kept = -1; $applied = 0; $new = preg_replace_callback( '#<(p|h[1-6]|li)\b[^>]*>(.*?)#is', function ( $mm ) use ( &$kept, &$applied, $repl ) { $txt = self::block_text( $mm[2] ); if ( ( function_exists( 'mb_strlen' ) ? mb_strlen( $txt ) : strlen( $txt ) ) < 15 ) { return $mm[0]; // Skipped block — not counted (matches extraction). } $kept++; if ( $kept < 40 && isset( $repl[ $kept ] ) ) { $applied++; return '<' . strtolower( $mm[1] ) . '>' . esc_html( $repl[ $kept ] ) . ''; } return $mm[0]; }, $html ); if ( ! $applied ) { return new WP_Error( 'nomatch', 'Could not locate the selected paragraphs in the post.' ); } $pid = wp_insert_post( array( 'post_title' => get_the_title( $post ) . ' (GEO draft)', 'post_content' => wp_kses_post( $new ), 'post_status' => 'draft', 'post_type' => $post->post_type, 'post_excerpt' => $post->post_excerpt, ), true ); if ( is_wp_error( $pid ) ) { return $pid; } update_post_meta( $pid, '_aicitare_rephrase_source', $post->ID ); AICitare_Log::add( 'rephrase', 'Created GEO draft #' . $pid . ' from post #' . $post->ID . ' (' . $applied . ' paragraphs).' ); return array( 'ok' => true, 'draftId' => (int) $pid, 'applied' => $applied, 'editUrl' => admin_url( 'post.php?post=' . $pid . '&action=edit' ), ); } /** Parse model output into a list of variant strings (JSON array preferred, then list/paragraph fallback). */ private static function parse_variants( $text, $n ) { $text = trim( (string) $text ); $text = preg_replace( '#^```[a-z]*\s*#i', '', $text ); $text = preg_replace( '#\s*```$#', '', $text ); $variants = array(); $start = strpos( $text, '[' ); $end = strrpos( $text, ']' ); if ( false !== $start && false !== $end && $end > $start ) { $arr = json_decode( substr( $text, $start, $end - $start + 1 ), true ); if ( is_array( $arr ) ) { foreach ( $arr as $v ) { if ( is_string( $v ) && '' !== trim( $v ) ) { $variants[] = trim( $v ); } } } } if ( empty( $variants ) ) { $parts = preg_split( '#\n\s*(?:\d+[\.\)]|[-*])\s+#', "\n" . $text ); foreach ( $parts as $p ) { $p = trim( $p ); if ( '' !== $p ) { $variants[] = $p; } } } if ( count( $variants ) < 2 ) { $variants = array(); foreach ( preg_split( '#\n{2,}#', $text ) as $p ) { $p = trim( $p ); if ( '' !== $p ) { $variants[] = $p; } } } // Strip surrounding quotes the model may add. $variants = array_map( function ( $v ) { return trim( $v, " \t\n\r\0\x0B\"'" ); }, $variants ); return array_slice( array_values( array_filter( $variants ) ), 0, $n ); } /** Dispatch to the right engine adapter. */ private function ask( $eng, $key, $q ) { switch ( $eng ) { case 'openai': return $this->openai_compatible( 'https://api.openai.com/v1/chat/completions', $key, $q, self::model_for( 'openai', 'gpt-4o-mini' ) ); case 'perplexity': return $this->openai_compatible( 'https://api.perplexity.ai/chat/completions', $key, $q, self::model_for( 'perplexity', 'sonar' ) ); case 'grok': return $this->openai_compatible( 'https://api.x.ai/v1/chat/completions', $key, $q, self::model_for( 'grok', 'grok-3' ) ); case 'anthropic': return $this->anthropic( $key, $q ); case 'google': return $this->gemini( $key, $q ); } return new WP_Error( 'unknown_engine', 'Unknown engine' ); } /** Resolve the model for an engine: user setting > default (filterable). */ private static function model_for( $eng, $default ) { $s = AICitare_Data::get_settings(); $m = ( isset( $s['models'][ $eng ] ) && '' !== trim( $s['models'][ $eng ] ) ) ? trim( $s['models'][ $eng ] ) : $default; return apply_filters( 'aicitare_model_' . $eng, $m ); } /** Build a descriptive WP_Error from an API error response (surfaces the provider's message). */ private static function http_err( $res, $body, $code ) { $msg = ''; if ( is_array( $body ) ) { if ( isset( $body['error']['message'] ) ) { $msg = $body['error']['message']; } elseif ( isset( $body['error'] ) && is_string( $body['error'] ) ) { $msg = $body['error']; } elseif ( isset( $body['message'] ) && is_string( $body['message'] ) ) { $msg = $body['message']; } elseif ( isset( $body['msg'] ) && is_string( $body['msg'] ) ) { $msg = $body['msg']; } } if ( '' === $msg ) { $raw = trim( (string) wp_remote_retrieve_body( $res ) ); if ( '' !== $raw ) { $msg = function_exists( 'mb_substr' ) ? mb_substr( $raw, 0, 200 ) : substr( $raw, 0, 200 ); } } return new WP_Error( 'api_error', 'HTTP ' . $code . ( '' !== $msg ? ': ' . $msg : '' ) ); } /** OpenAI-compatible chat endpoint (OpenAI, Perplexity, Grok). */ private function openai_compatible( $url, $key, $q, $model ) { $res = wp_remote_post( $url, array( 'timeout' => 30, 'headers' => array( 'Authorization' => 'Bearer ' . $key, 'Content-Type' => 'application/json' ), 'body' => wp_json_encode( array( 'model' => $model, 'messages' => array( array( 'role' => 'user', 'content' => $q ) ) ) ), ) ); if ( is_wp_error( $res ) ) { return $res; } $code = wp_remote_retrieve_response_code( $res ); $body = json_decode( wp_remote_retrieve_body( $res ), true ); if ( $code >= 400 ) { return self::http_err( $res, $body, $code ); } $text = isset( $body['choices'][0]['message']['content'] ) ? $body['choices'][0]['message']['content'] : ''; $sources = ( isset( $body['citations'] ) && is_array( $body['citations'] ) ) ? $body['citations'] : array(); return array( 'text' => $text, 'sources' => $sources ); } /** Anthropic Messages API. */ private function anthropic( $key, $q ) { $res = wp_remote_post( 'https://api.anthropic.com/v1/messages', array( 'timeout' => 30, 'headers' => array( 'x-api-key' => $key, 'anthropic-version' => '2023-06-01', 'content-type' => 'application/json' ), 'body' => wp_json_encode( array( 'model' => self::model_for( 'anthropic', 'claude-3-5-haiku-latest' ), 'max_tokens' => 1024, 'messages' => array( array( 'role' => 'user', 'content' => $q ) ), ) ), ) ); if ( is_wp_error( $res ) ) { return $res; } $code = wp_remote_retrieve_response_code( $res ); $body = json_decode( wp_remote_retrieve_body( $res ), true ); if ( $code >= 400 ) { return self::http_err( $res, $body, $code ); } return array( 'text' => isset( $body['content'][0]['text'] ) ? $body['content'][0]['text'] : '', 'sources' => array() ); } /** Local Ollama (OpenAI-compatible endpoint, no API key). */ private function ollama( $base, $model, $q ) { $base = rtrim( (string) $base, '/' ); $url = $base . '/v1/chat/completions'; $res = wp_remote_post( $url, array( 'timeout' => 60, // local models can be slow. 'headers' => array( 'Content-Type' => 'application/json' ), 'body' => wp_json_encode( array( 'model' => $model ? $model : 'llama3.2', 'messages' => array( array( 'role' => 'user', 'content' => $q ) ), 'stream' => false, ) ), ) ); if ( is_wp_error( $res ) ) { return $res; } $code = wp_remote_retrieve_response_code( $res ); $body = json_decode( wp_remote_retrieve_body( $res ), true ); if ( $code >= 400 ) { return self::http_err( $res, $body, $code ); } return array( 'text' => isset( $body['choices'][0]['message']['content'] ) ? $body['choices'][0]['message']['content'] : '', 'sources' => array() ); } /** Google Gemini generateContent. */ private function gemini( $key, $q ) { $model = apply_filters( 'aicitare_model_google', 'gemini-2.0-flash' ); $url = 'https://generativelanguage.googleapis.com/v1beta/models/' . $model . ':generateContent?key=' . rawurlencode( $key ); $res = wp_remote_post( $url, array( 'timeout' => 30, 'headers' => array( 'Content-Type' => 'application/json' ), 'body' => wp_json_encode( array( 'contents' => array( array( 'parts' => array( array( 'text' => $q ) ) ) ) ) ), ) ); if ( is_wp_error( $res ) ) { return $res; } $code = wp_remote_retrieve_response_code( $res ); $body = json_decode( wp_remote_retrieve_body( $res ), true ); if ( $code >= 400 ) { return self::http_err( $res, $body, $code ); } $text = isset( $body['candidates'][0]['content']['parts'][0]['text'] ) ? $body['candidates'][0]['content']['parts'][0]['text'] : ''; return array( 'text' => $text, 'sources' => array() ); } /** Live citations if a check has run; the demo dataset only in Demo mode; else empty. */ public static function get_citations() { $live = get_option( self::OPT_RESULTS ); if ( is_array( $live ) && $live ) { return $live; } $settings = AICitare_Data::get_settings(); return ! empty( $settings['demo_mode'] ) ? AICitare_Data::citations() : array(); } public static function last_checked() { return get_option( self::OPT_CHECKED, '' ); } /** Per-engine status from the last check (ok count + last error). */ public static function engine_status() { $s = get_option( self::OPT_STATUS ); return is_array( $s ) ? $s : array(); } /** Raw model answers from the last check, keyed by prompt. */ public static function answers() { $a = get_option( self::OPT_ANSWERS ); return is_array( $a ) ? $a : array(); } private static function host() { $h = wp_parse_url( home_url(), PHP_URL_HOST ); return preg_replace( '/^www\./', '', (string) $h ); } /** * Content gaps vs competitors: prompts where a tracked competitor is cited * but this site is NOT. Requires a live visibility check to be meaningful. * Returns [ { q, competitors:[names] }, ... ]. */ public static function competitor_gaps() { $answers = self::answers(); $results = get_option( self::OPT_RESULTS ); $comps = self::competitors_list(); if ( empty( $answers ) || ! is_array( $results ) || empty( $comps ) ) { return array(); } // Map prompt -> was our site cited on any engine? $cited = array(); foreach ( $results as $r ) { $is = false; foreach ( (array) $r['e'] as $v ) { if ( null !== $v ) { $is = true; break; } } $cited[ $r['q'] ] = $is; } $gaps = array(); foreach ( $answers as $row ) { $q = isset( $row['q'] ) ? $row['q'] : ''; if ( '' === $q || ! empty( $cited[ $q ] ) ) { continue; // We are already cited → not a gap. } $hay = strtolower( implode( ' ', array_map( 'strval', (array) ( isset( $row['a'] ) ? $row['a'] : array() ) ) ) ); $winners = array(); foreach ( $comps as $c ) { $dom = self::norm_host( $c['domain'] ); $nm = strtolower( trim( isset( $c['name'] ) ? $c['name'] : '' ) ); $hit = ( $dom && false !== strpos( $hay, $dom ) ) || ( $nm && strlen( $nm ) >= 3 && false !== strpos( $hay, $nm ) ); if ( $hit ) { $winners[] = ( isset( $c['name'] ) && '' !== $c['name'] ) ? $c['name'] : $dom; } } if ( $winners ) { $gaps[] = array( 'q' => $q, 'competitors' => array_values( array_unique( $winners ) ) ); } } return $gaps; } }