queries(); if ( count( $queries ) >= self::MAX_QUERIES ) { /* translators: %d: maximum number of tracked questions */ return new WP_Error( 'evoka_too_many', sprintf( __( 'You can track up to %d questions. Remove one before adding another.', 'evoka' ), self::MAX_QUERIES ) ); } foreach ( $queries as $q ) { if ( 0 === strcasecmp( $q['query'], $query ) ) { return new WP_Error( 'evoka_duplicate', __( 'That question is already tracked.', 'evoka' ) ); } } $row = array( 'id' => wp_generate_password( 8, false ), 'query' => $query, 'created' => time(), ); $queries[] = $row; update_option( self::QUERIES_OPTION, $queries, false ); return $row; } /** * Remove a tracked query and its history. * * @param string $id Query id. * @return bool True if a query was removed. */ public function remove( $id ) { $queries = $this->queries(); $filtered = array_values( array_filter( $queries, function ( $q ) use ( $id ) { return $q['id'] !== $id; } ) ); $removed = count( $filtered ) !== count( $queries ); if ( $removed ) { update_option( self::QUERIES_OPTION, $filtered, false ); $history = get_option( self::HISTORY_OPTION, array() ); unset( $history[ $id ] ); update_option( self::HISTORY_OPTION, $history, false ); } return $removed; } /** * History for one tracked query, oldest first. * * @param string $id Query id. * @return array */ public function history( $id ) { $history = get_option( self::HISTORY_OPTION, array() ); return isset( $history[ $id ] ) ? (array) $history[ $id ] : array(); } /** * Run every tracked query now and append to history. No-ops quietly if * no BYOK key/provider is configured - tracking is opt-in on top of an * already-opt-in feature, so a missing key is not an error state. * * @return int Number of queries actually run. */ public function run_all() { $byok = new Evoka_Byok_Client(); if ( ! $byok->is_configured() ) { return 0; } $ran = 0; foreach ( $this->queries() as $q ) { if ( ! is_wp_error( $this->run_one( $q['id'], $byok ) ) ) { ++$ran; } } return $ran; } /** * Run one tracked query now and append to its history. * * @param string $id Query id. * @param Evoka_Byok_Client|null $byok Reuse a client, or null to create one. * @return array|WP_Error The recorded history point, or an error. */ public function run_one( $id, $byok = null ) { $queries = $this->queries(); $query = null; foreach ( $queries as $q ) { if ( $q['id'] === $id ) { $query = $q; break; } } if ( null === $query ) { return new WP_Error( 'evoka_not_found', __( 'That tracked question no longer exists.', 'evoka' ) ); } $byok = $byok ? $byok : new Evoka_Byok_Client(); if ( ! $byok->is_configured() ) { return new WP_Error( 'evoka_no_provider', __( 'Add your own API key and choose a provider in Evoka -> Settings first.', 'evoka' ) ); } $result = $byok->check_citation( $query['query'] ); if ( is_wp_error( $result ) ) { return $result; } $point = array( 'date' => gmdate( 'Y-m-d' ), 'cited' => (bool) $result['cited'], 'other_domains' => $this->other_domains( $result['answer'], $result['sources'], $result['domain'] ), ); $history = get_option( self::HISTORY_OPTION, array() ); $history[ $id ] = isset( $history[ $id ] ) ? $history[ $id ] : array(); $history[ $id ][] = $point; $history[ $id ] = array_slice( $history[ $id ], -self::MAX_HISTORY ); update_option( self::HISTORY_OPTION, $history, false ); return $point; } /** * Extract other domains mentioned in an answer/its sources, excluding the * site's own domain. Crude pattern match on the answer text plus any * source URLs - a directional "who else got mentioned" signal, not a * verified competitor list, so the UI must label it as such. * * @param string $answer Raw answer text. * @param array $sources Source URLs returned with the answer. * @param string $domain The site's own domain (excluded from results). * @return array Up to 5 domains. */ public function other_domains( $answer, $sources, $domain ) { $found = array(); foreach ( (array) $sources as $url ) { $host = (string) wp_parse_url( $url, PHP_URL_HOST ); if ( $host ) { $found[] = strtolower( preg_replace( '/^www\./i', '', $host ) ); } } if ( preg_match_all( '/\b([a-z0-9][a-z0-9-]{0,61}(?:\.[a-z0-9][a-z0-9-]{0,61}){0,3}\.(?:com|org|net|io|ai|co|dev|app|edu|gov))\b/i', (string) $answer, $m ) ) { foreach ( $m[1] as $host ) { $found[] = strtolower( preg_replace( '/^www\./i', '', $host ) ); } } $own = strtolower( preg_replace( '/^www\./i', '', (string) $domain ) ); $found = array_values( array_unique( array_filter( $found, function ( $h ) use ( $own ) { return '' !== $own ? ( $h !== $own && ! Evoka_Byok_Client::mentions_domain( $h, $own ) ) : true; } ) ) ); return array_slice( $found, 0, 5 ); } }