", "e": [ { "t": "view", "a": 12, "k": 3, ... }, … ] } */ class Collector { /** * Largest batch accepted. */ const MAX_EVENTS = 50; /** * Hooks. */ public function register() { add_action( 'rest_api_init', array( $this, 'routes' ) ); add_action( 'kdocs_feedback_submitted', array( $this, 'feedback' ), 20, 5 ); } /** * Routes. */ public function routes() { register_rest_route( Routes::NS, '/collect', array( 'methods' => 'POST', 'callback' => array( $this, 'collect' ), 'permission_callback' => '__return_true', ) ); } /** * Stores a batch of events. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response */ public function collect( $request ) { $body = json_decode( (string) $request->get_body(), true ); if ( ! is_array( $body ) || empty( $body['e'] ) || ! is_array( $body['e'] ) || ! Identity::countable() ) { return new \WP_REST_Response( null, 204 ); } $session = self::token( $body['s'] ?? '' ); $events = array(); foreach ( array_slice( $body['e'], 0, self::MAX_EVENTS ) as $raw ) { $event = is_array( $raw ) ? self::clean( $raw, $session ) : null; if ( $event ) { $events[] = $event; } } if ( $events && Identity::within_limit( count( $events ) ) ) { Store::insert( $events ); foreach ( $events as $event ) { if ( 'view' === $event['type'] && $event['article_id'] ) { self::count_view( $event['article_id'] ); } } } // Beacons ignore the response; 204 keeps it tiny. return new \WP_REST_Response( null, 204 ); } /** * Validates one event. * * @param array $raw Event from the browser. * @param string $session Session id. * @return array|null */ private static function clean( array $raw, $session ) { $type = sanitize_key( (string) ( $raw['t'] ?? '' ) ); // Feedback is recorded server side by the feedback endpoint. if ( 'feedback' === $type || ! in_array( $type, Store::types(), true ) ) { return null; } $article = absint( $raw['a'] ?? 0 ); if ( $article ) { $post = get_post( $article ); if ( ! $post || PostTypes::ARTICLE !== $post->post_type || 'publish' !== $post->post_status ) { return null; } $kb = Taxonomies::article_kb( $post ); } else { $kb_id = absint( $raw['k'] ?? 0 ); $kb = $kb_id ? get_term( $kb_id, Taxonomies::KB ) : null; $kb = $kb && ! is_wp_error( $kb ) ? $kb : null; } $label = ''; $n1 = 0; $n2 = 0; switch ( $type ) { case 'search': case 'widget_search': $label = self::term( $raw['q'] ?? '' ); $n1 = min( 1000, absint( $raw['n'] ?? 0 ) ); if ( '' === $label ) { return null; } break; case 'search_click': $label = self::term( $raw['q'] ?? '' ); $n1 = min( 100, max( 1, absint( $raw['p'] ?? 1 ) ) ); break; case 'read': $n1 = min( 100, absint( $raw['d'] ?? 0 ) ); $n2 = min( 3600, absint( $raw['s'] ?? 0 ) ); break; case 'link_out': $label = sanitize_text_field( (string) wp_parse_url( esc_url_raw( (string) ( $raw['u'] ?? '' ) ), PHP_URL_HOST ) ); break; case '404': case 'widget_open': // A path on this site (where the widget was opened). $label = mb_substr( sanitize_text_field( (string) wp_parse_url( (string) ( $raw['u'] ?? '' ), PHP_URL_PATH ) ), 0, 191 ); break; case 'ai_rating': case 'summary_rating': $label = in_array( $raw['l'] ?? '', array( 'yes', 'no' ), true ) ? $raw['l'] : ''; if ( '' === $label ) { return null; } break; } $ref = sanitize_key( (string) ( $raw['r'] ?? '' ) ); return array( 'type' => $type, 'kb_id' => $kb ? (int) $kb->term_id : 0, 'article_id' => $article, 'session' => $session, 'search_id' => self::token( $raw['i'] ?? '' ), 'label' => $label, 'n1' => $n1, 'n2' => $n2, 'ref' => in_array( $ref, array( 'direct', 'search', 'internal', 'external', 'widget' ), true ) ? $ref : '', ); } /** * Random id from the browser (session or search id): 16 alphanumerics. * * @param mixed $value Value. * @return string */ private static function token( $value ) { $value = preg_replace( '/[^A-Za-z0-9]/', '', (string) $value ); return substr( $value, 0, 16 ); } /** * Normalized search term (lower case, single spaces, 100 characters). * * @param mixed $value Term. * @return string */ public static function term( $value ) { $term = sanitize_text_field( (string) $value ); $term = function_exists( 'mb_strtolower' ) ? mb_strtolower( $term ) : strtolower( $term ); $term = trim( preg_replace( '/\s+/', ' ', $term ) ); return mb_substr( $term, 0, 100 ); } /** * Keeps the cached total view count on the article (shown in lists and sorts). * * @param int $article_id Article. */ private static function count_view( $article_id ) { update_post_meta( $article_id, '_kdocs_views', (int) get_post_meta( $article_id, '_kdocs_views', true ) + 1 ); /** * Fires after a view was counted. * * @param int $article_id Article. */ do_action( 'kdocs_article_viewed', $article_id ); } /** * Records votes and reasons as events. * * @param int $article_id Article. * @param string $vote yes|no. * @param string $message Message. * @param string $email Email. * @param string $reason Reason. */ public function feedback( $article_id, $vote, $message, $email, $reason = '' ) { $kb = Taxonomies::article_kb( $article_id ); $base = array( 'kb_id' => $kb ? (int) $kb->term_id : 0, 'article_id' => (int) $article_id, ); if ( '' === $message && '' === $reason ) { Store::log( 'feedback', $base + array( 'label' => $vote ) ); } elseif ( $reason ) { Store::log( 'feedback_reason', $base + array( 'label' => $reason ) ); } } }