placement = $placement; $this->elementor = new AI_Narration_Elementor( $placement ); } /** * True while Elementor content is being rendered for narration extraction. */ public static function is_extracting(): bool { return self::$extracting; } /** * Return whether the post is eligible under its explicit override or global rules. */ public function is_eligible( int $post_id, ?array $settings = null ): bool { $post = get_post( $post_id ); if ( ! $post instanceof WP_Post || 'publish' !== $post->post_status || '' !== (string) $post->post_password ) { return false; } $override = (string) get_post_meta( $post_id, '_ai_narration_override', true ); if ( 'enabled' === $override ) { return true; } if ( 'disabled' === $override ) { return false; } $settings = is_array( $settings ) ? $settings : AI_Narration_Admin::get_settings(); $post_types = isset( $settings['post_types'] ) && is_array( $settings['post_types'] ) ? $settings['post_types'] : array(); if ( ! in_array( $post->post_type, $post_types, true ) ) { return false; } $categories = isset( $settings['categories'] ) && is_array( $settings['categories'] ) ? array_map( 'absint', $settings['categories'] ) : array(); return empty( $categories ) || has_term( $categories, 'category', $post_id ); } /** * Return whether a published post actually contains the player/start marker. * Eligibility controls where narration is allowed; the marker is the final, * intentional opt-in for both the public player and persistent generation. */ public function has_narration_marker( int $post_id ): bool { $post = get_post( $post_id ); if ( ! $post instanceof WP_Post || 'publish' !== $post->post_status ) { return false; } $elementor_data = get_post_meta( $post_id, '_elementor_data', true ); if ( is_string( $elementor_data ) ) { $elementor_data = json_decode( $elementor_data, true ); } if ( is_array( $elementor_data ) && ! empty( $elementor_data ) ) { return $this->elementor->has_article_start( $elementor_data ); } $raw = (string) get_post_field( 'post_content', $post_id, 'raw' ); if ( $this->placement->contains_article_start( $raw ) ) { return true; } try { $rendered = $this->get_rendered_content( $post_id ); return $this->placement->contains_article_start( (string) $rendered['html'] ); } catch ( RuntimeException $exception ) { // The only extraction exception at this stage means a marker was found // but its Elementor HTML widget could not be isolated safely. Keep that // post visible so the administrator sees the genuine configuration error. return str_contains( $exception->getMessage(), 'placement marker' ); } } /** Resolve the sole Free narration profile. */ public function narration_profile( int $post_id, ?array $settings = null ): string { return 'a'; } /** * Create clean narration text and API-sized chunks from the published post. * * @return array{source:string,text:string,chunks:array,characters:int,words:int,hash:string,source_hash:string} * @throws RuntimeException When content cannot safely be narrated. */ public function prepare( int $post_id, ?array $settings = null ): array { $post = get_post( $post_id ); if ( ! $post instanceof WP_Post || 'publish' !== $post->post_status ) { throw new RuntimeException( 'The requested article is not published.' ); } $settings = is_array( $settings ) ? wp_parse_args( $settings, AI_Narration_Admin::default_settings() ) : AI_Narration_Admin::get_settings(); $read_h2_headings = ! empty( $settings['read_h2_headings'] ); $rendered = $this->get_rendered_content( $post_id ); $body = $this->bounded_narration_text( $rendered['html'], $read_h2_headings ); $source_body = $read_h2_headings ? $body : $this->bounded_narration_text( $rendered['html'], true ); $body = trim( $body ); if ( '' === $body ) { throw new RuntimeException( 'No speakable article content was found.' ); } $characters = $this->length( $body ); if ( $characters > self::MAX_ARTICLE_LENGTH ) { throw new RuntimeException( 'The article exceeds the 50,000 character safety limit.' ); } $chunks = $this->chunk_text( $body ); if ( empty( $chunks ) ) { throw new RuntimeException( 'No narration chunks could be created.' ); } return array( 'source' => $rendered['source'], 'text' => $body, 'chunks' => $chunks, 'characters' => $characters, 'words' => str_word_count( $body ), 'hash' => hash( 'sha256', implode( "\n\n", $chunks ) ), 'source_hash' => hash( 'sha256', trim( $source_body ) ), ); } /** * Estimate the initial listening time without generating or requesting audio. */ public function estimate_listening_minutes( int $post_id ): ?int { try { $prepared = $this->prepare( $post_id ); } catch ( Throwable $exception ) { return null; } $words = isset( $prepared['words'] ) ? absint( $prepared['words'] ) : 0; if ( 0 === $words ) { return null; } return max( 1, (int) ceil( $words / self::ESTIMATED_WORDS_PER_MINUTE ) ); } /** * Get the article HTML. For Elementor posts, the HTML widget containing the * placement marker is the narration boundary. * * @return array{source:string,html:string} */ private function get_rendered_content( int $post_id ): array { $html = ''; $source = 'WordPress post content'; $elementor = class_exists( '\\Elementor\\Plugin' ); if ( $elementor ) { try { $is_built = false; if ( isset( \Elementor\Plugin::$instance->db ) && method_exists( \Elementor\Plugin::$instance->db, 'is_built_with_elementor' ) ) { $is_built = (bool) \Elementor\Plugin::$instance->db->is_built_with_elementor( $post_id ); } else { $is_built = 'builder' === get_post_meta( $post_id, '_elementor_edit_mode', true ); } if ( $is_built ) { $elementor_data = get_post_meta( $post_id, '_elementor_data', true ); if ( is_string( $elementor_data ) ) { $elementor_data = json_decode( $elementor_data, true ); } $stored = is_array( $elementor_data ) ? $this->elementor->article_source( $elementor_data ) : array( 'source' => '', 'html' => '' ); $html = $stored['html']; if ( '' !== trim( $html ) ) { $source = $stored['source']; } elseif ( isset( \Elementor\Plugin::$instance->frontend ) && method_exists( \Elementor\Plugin::$instance->frontend, 'get_builder_content_for_display' ) ) { self::$extracting = true; $html = (string) \Elementor\Plugin::$instance->frontend->get_builder_content_for_display( $post_id, true ); $source = 'Elementor rendered content'; } } } catch ( Throwable $exception ) { $this->log_extraction_error( $post_id, $exception ); } finally { self::$extracting = false; } } if ( 'Elementor rendered content' === $source && $this->placement->contains_article_start( $html ) ) { // Elementor returns only this post's builder document here. Preserve it // so the start and stop boundary can span any number of later widgets; // prepare() applies that boundary immediately after this method returns. $source = 'Rendered Elementor content containing placement marker'; } if ( '' === trim( $html ) ) { $raw = (string) get_post_field( 'post_content', $post_id, 'raw' ); $raw = $this->placement->normalise_shortcodes( $raw ); $html = do_blocks( strip_shortcodes( $raw ) ); $source = 'WordPress post content'; } return array( 'source' => $source, 'html' => $html, ); } private function log_extraction_error( int $post_id, Throwable $exception ): void { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Deliberate non-sensitive operational logging. error_log( sprintf( '[AI Narration] Elementor extraction failed for post %d: %s', $post_id, get_class( $exception ) ) ); } private function bounded_narration_text( string $html, bool $read_h2_headings ): string { $tokenised = $this->placement->tokenise_article_boundaries( $html, self::START_TEXT_BOUNDARY, self::STOP_TEXT_BOUNDARY ); $text = $this->html_to_narration_text( $tokenised, $read_h2_headings ); $start = strpos( $text, self::START_TEXT_BOUNDARY ); if ( false === $start ) { throw new RuntimeException( 'The empty AI Narration placement could not be used as the narration start boundary.' ); } $bounded = (string) substr( $text, $start + strlen( self::START_TEXT_BOUNDARY ) ); $stop = strpos( $bounded, self::STOP_TEXT_BOUNDARY ); return trim( false === $stop ? $bounded : (string) substr( $bounded, 0, $stop ) ); } private function html_to_narration_text( string $html, bool $read_h2_headings ): string { $html = $this->placement->normalise_shortcodes( $html ); $html = strip_shortcodes( $html ); if ( ! class_exists( 'DOMDocument' ) ) { return $this->fallback_html_to_narration_text( $html, $read_h2_headings ); } $previous = libxml_use_internal_errors( true ); $document = new DOMDocument( '1.0', 'UTF-8' ); $wrapped = '
' . $html . '
'; $loaded = $document->loadHTML( '' . $wrapped, LIBXML_NOERROR | LIBXML_NOWARNING | LIBXML_NONET ); libxml_clear_errors(); libxml_use_internal_errors( $previous ); if ( ! $loaded ) { return $this->fallback_html_to_narration_text( $html, $read_h2_headings ); } $root = $document->getElementById( 'ai-narration-root' ); if ( ! $root instanceof DOMElement ) { return $this->fallback_html_to_narration_text( $html, $read_h2_headings ); } $text = $this->node_to_text( $root, $read_h2_headings ); return $this->normalise_text( $text ); } private function fallback_html_to_narration_text( string $html, bool $read_h2_headings ): string { if ( ! $read_h2_headings ) { $html = preg_replace( '#]*>.*?#is', ' ', $html ); } $html = preg_replace( '#<(script|style|noscript|svg|canvas|form|button|audio|video|iframe|figure|table|nav|aside|template)\b[^>]*>.*?#is', ' ', (string) $html ); $html = preg_replace( '##i', "\n", (string) $html ); $html = preg_replace( '##i', "\n\n", (string) $html ); return $this->normalise_text( wp_strip_all_tags( (string) $html ) ); } private function node_to_text( DOMNode $node, bool $read_h2_headings ): string { if ( XML_TEXT_NODE === $node->nodeType || XML_CDATA_SECTION_NODE === $node->nodeType ) { return (string) $node->nodeValue; } if ( ! $node instanceof DOMElement && ! $node->hasChildNodes() ) { return ''; } $tag = $node instanceof DOMElement ? strtolower( $node->tagName ) : ''; if ( $node instanceof DOMElement && $this->should_exclude_element( $node, $tag ) ) { return ''; } if ( 'h2' === $tag && ! $read_h2_headings ) { return ''; } if ( 'br' === $tag ) { return "\n"; } $block_tags = array( 'address', 'article', 'blockquote', 'dd', 'div', 'dl', 'dt', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'li', 'main', 'ol', 'p', 'section', 'ul' ); $output = in_array( $tag, $block_tags, true ) ? "\n" : ''; foreach ( $node->childNodes as $child ) { $output .= $this->node_to_text( $child, $read_h2_headings ); } if ( in_array( $tag, $block_tags, true ) ) { $output .= in_array( $tag, array( 'h1', 'h2', 'h3', 'h4', 'h5', 'h6' ), true ) ? ".\n\n" : "\n\n"; } return $output; } private function should_exclude_element( DOMElement $element, string $tag ): bool { $excluded_tags = array( 'audio', 'button', 'canvas', 'figcaption', 'figure', 'form', 'iframe', 'nav', 'noscript', 'script', 'style', 'svg', 'table', 'template', 'video' ); if ( in_array( $tag, $excluded_tags, true ) ) { return true; } if ( $element->hasAttribute( 'data-narration-start' ) || $element->hasAttribute( 'data-narration-stop' ) || 'true' === strtolower( $element->getAttribute( 'aria-hidden' ) ) ) { return true; } $class = ' ' . preg_replace( '/\s+/', ' ', strtolower( $element->getAttribute( 'class' ) ) ) . ' '; $excluded_classes = array( ' ai-narration-exclude ', ' tzh-no-listen ', ' ai-narration ', ' elementor-button-wrapper ', ' elementor-share-btn ', ' elementor-social-icons-wrapper ' ); foreach ( $excluded_classes as $excluded_class ) { if ( str_contains( $class, $excluded_class ) ) { return true; } } return false; } private function normalise_text( string $text ): string { $text = html_entity_decode( $text, ENT_QUOTES | ENT_HTML5, 'UTF-8' ); $text = preg_replace( '~(?:https?://|www\.)\S+~iu', '', $text ); $text = preg_replace( '/[\x{00A0}\x{2007}\x{202F}]/u', ' ', (string) $text ); $text = preg_replace( '/[ \t]+/u', ' ', (string) $text ); $text = preg_replace( '/ *\n */u', "\n", (string) $text ); $text = preg_replace( '/\n{3,}/u', "\n\n", (string) $text ); $paragraphs = preg_split( '/\n{2,}/u', trim( (string) $text ) ) ?: array(); $clean = array(); foreach ( $paragraphs as $paragraph ) { $paragraph = trim( preg_replace( '/\s+/u', ' ', $paragraph ) ); if ( '' !== $paragraph ) { $clean[] = $paragraph; } } return implode( "\n\n", $clean ); } /** * @return array */ private function chunk_text( string $text ): array { $text = trim( $text ); if ( $this->length( $text ) <= self::MAX_CHUNK_LENGTH ) { return array( $text ); } list( $opening, $remainder ) = $this->split_initial_chunk( $text ); $chunks = array( $opening ); if ( '' !== $remainder ) { $chunks = array_merge( $chunks, $this->chunk_remaining_text( $remainder ) ); } return array_values( array_filter( $chunks, fn ( string $chunk ): bool => '' !== $chunk && $this->length( $chunk ) <= self::MAX_CHUNK_LENGTH ) ); } /** * Create a full-size natural opening that can begin playing progressively * while later chunks prepare. * * @return array{0:string,1:string} */ private function split_initial_chunk( string $text ): array { $prefix = $this->substring( $text, 0, self::INITIAL_CHUNK_LENGTH ); $cut = 0; $matches = array(); if ( preg_match_all( "/[.!?][”\"']?\\s+|\\n{2,}/u", $prefix, $matches, PREG_OFFSET_CAPTURE ) ) { foreach ( $matches[0] as $match ) { $candidate = (int) $match[1] + strlen( (string) $match[0] ); if ( $candidate >= self::MIN_INITIAL_CHUNK_LENGTH ) { $cut = $candidate; } } } if ( 0 === $cut ) { $extended_prefix = $this->substring( $text, 0, self::MAX_INITIAL_CHUNK_LENGTH ); $matches = array(); if ( preg_match_all( "/[.!?][”\"']?\\s+|\\n{2,}/u", $extended_prefix, $matches, PREG_OFFSET_CAPTURE ) ) { foreach ( $matches[0] as $match ) { $candidate = (int) $match[1] + strlen( (string) $match[0] ); if ( $candidate >= self::MIN_INITIAL_CHUNK_LENGTH ) { $cut = $candidate; break; } } } } if ( 0 === $cut ) { $spaces = array(); if ( preg_match_all( '/\s+/u', $prefix, $spaces, PREG_OFFSET_CAPTURE ) ) { foreach ( $spaces[0] as $space ) { $candidate = (int) $space[1]; if ( $candidate >= self::MIN_INITIAL_CHUNK_LENGTH ) { $cut = $candidate; } } } } if ( 0 === $cut ) { $cut = strlen( $prefix ); } return array( trim( substr( $text, 0, $cut ) ), trim( substr( $text, $cut ) ), ); } /** * @return array */ private function chunk_remaining_text( string $text ): array { $paragraphs = preg_split( '/\n{2,}/u', $text ) ?: array(); $units = array(); foreach ( $paragraphs as $paragraph ) { $paragraph = trim( $paragraph ); if ( '' === $paragraph ) { continue; } if ( $this->length( $paragraph ) <= self::TARGET_CHUNK_LENGTH ) { $units[] = $paragraph; continue; } $sentences = preg_split( '/(?<=[.!?])\s+/u', $paragraph ) ?: array( $paragraph ); foreach ( $sentences as $sentence ) { $sentence = trim( $sentence ); if ( $this->length( $sentence ) <= self::MAX_CHUNK_LENGTH ) { $units[] = $sentence; } else { foreach ( $this->split_long_unit( $sentence ) as $part ) { $units[] = $part; } } } } $chunks = array(); $current = ''; foreach ( $units as $unit ) { $separator = '' === $current ? '' : "\n\n"; $candidate = $current . $separator . $unit; if ( '' !== $current && $this->length( $candidate ) > self::TARGET_CHUNK_LENGTH ) { $chunks[] = trim( $current ); $current = $unit; } else { $current = $candidate; } } if ( '' !== trim( $current ) ) { $chunks[] = trim( $current ); } return $chunks; } /** * @return array */ private function split_long_unit( string $unit ): array { $words = preg_split( '/\s+/u', trim( $unit ) ) ?: array(); $parts = array(); $current = ''; foreach ( $words as $word ) { if ( $this->length( $word ) > self::MAX_CHUNK_LENGTH ) { if ( '' !== $current ) { $parts[] = $current; $current = ''; } foreach ( $this->split_by_character_limit( $word, self::MAX_CHUNK_LENGTH ) as $hard_part ) { $parts[] = $hard_part; } continue; } $candidate = '' === $current ? $word : $current . ' ' . $word; if ( '' !== $current && $this->length( $candidate ) > self::MAX_CHUNK_LENGTH ) { $parts[] = $current; $current = $word; } else { $current = $candidate; } } if ( '' !== $current ) { $parts[] = $current; } return $parts; } /** @return array */ private function split_by_character_limit( string $text, int $limit ): array { $parts = array(); $offset = 0; $length = $this->length( $text ); while ( $offset < $length ) { $parts[] = $this->substring( $text, $offset, min( $limit, $length - $offset ) ); $offset += $limit; } return $parts; } private function length( string $text ): int { if ( function_exists( 'mb_strlen' ) ) { return mb_strlen( $text, 'UTF-8' ); } $characters = preg_split( '//u', $text, -1, PREG_SPLIT_NO_EMPTY ); return is_array( $characters ) ? count( $characters ) : strlen( $text ); } private function substring( string $text, int $start, int $length ): string { if ( function_exists( 'mb_substr' ) ) { return mb_substr( $text, $start, $length, 'UTF-8' ); } $characters = preg_split( '//u', $text, -1, PREG_SPLIT_NO_EMPTY ); return is_array( $characters ) ? implode( '', array_slice( $characters, $start, $length ) ) : substr( $text, $start, $length ); } private function lower( string $text ): string { return function_exists( 'mb_strtolower' ) ? mb_strtolower( $text, 'UTF-8' ) : strtolower( $text ); } }