) and URLs quoted inside a sentence don't
// match (core doesn't embed those either), so prose is untouched.
$html = preg_replace( '#^\s*https?://[^\s<>"]+\s*$#im', ' ', $html );
$html = preg_replace( '#]*)?>\s*https?://[^\s<>"]+\s*
#i', ' ', $html );
// 1. Strip block comments (Gutenberg leaves them in post_content).
$html = preg_replace( '##s', '', $html );
// 2. Remove noisy elements wholesale. Filterable so themes/plugins
// can add custom ones (e.g. an "advertisement" shortcode wrapper).
$noisy = apply_filters(
'ayudawp_aiss_summary_noisy_tags',
array( 'table', 'pre', 'code', 'figcaption', 'figure', 'form', 'button', 'nav', 'script', 'style', 'iframe', 'svg', 'audio', 'video' )
);
if ( ! empty( $noisy ) ) {
$pattern = '#<(' . implode( '|', array_map( 'preg_quote', $noisy ) ) . ')\b[^>]*>.*?\1>#is';
$html = preg_replace( $pattern, ' ', $html );
}
// 3. Replace block-level tag boundaries (open AND close) with a
// sentence separator so prose from different blocks doesn't get
// concatenated by wp_strip_all_tags().
$boundaries = 'p|h[1-6]|li|div|td|th|tr|br|hr|blockquote|aside|section|article|header|footer|dd|dt';
$html = preg_replace( '#?(?:' . $boundaries . ')(?:\s[^>]*)?/?>#i', '. ', $html );
// 4. Strip the rest.
$text = wp_strip_all_tags( $html );
// 5. Whitespace normalization + collapse repeated punctuation runs.
$text = preg_replace( '/\s+/u', ' ', $text );
$text = preg_replace( '/(\s*\.\s*){2,}/', '. ', $text ); // ". . . " → ". "
$text = preg_replace( '/\s+\./', '.', $text ); // " ." → "."
$text = preg_replace( '/\.{2,}/', '.', $text ); // ".." → "."
return trim( $text );
}
/**
* Split a block of text into sentences (v2.1.0)
*
* Single source of truth for sentence segmentation, shared by the extractive
* summarizer and the frontend renderer (which prints one per sentence for
* readability). Splits on sentence-final punctuation followed by whitespace and
* an uppercase/opening character, so decimals and most abbreviations inside a
* sentence are not split.
*
* @since 2.1.0
* @param string $text Plain text (already stripped of HTML).
* @return array Trimmed, non-empty sentences in original order.
*/
function ayudawp_aiss_split_sentences( $text ) {
$text = trim( (string) $text );
if ( '' === $text ) {
return array();
}
$parts = preg_split( '/(?<=[.!?])\s+(?=[A-ZÁÉÍÓÚÑ¿¡"\'])/u', $text );
if ( ! is_array( $parts ) ) {
return array( $text );
}
$parts = array_map( 'trim', $parts );
return array_values( array_filter( $parts, 'strlen' ) );
}
/**
* Generate an extractive summary from post content (v2.0.0)
*
* Picks the top N sentences scored by token-frequency (excluding stopwords),
* with a bonus for title words and a lead-position bonus for the first two
* sentences. Returns sentences in their original order so the summary reads
* naturally.
*
* Used as a fallback when WP AI Client is not available, or when the call
* to wp_ai_client_prompt() fails.
*
* @since 2.0.0
* @param string $content Raw post content (HTML accepted, will be stripped).
* @param string $title Post title — its words get a 2x weight bonus.
* @param int $n Target sentence count (clamped to 1..5).
* @return string Summary text, or '' if the content is too short to summarize.
*/
function ayudawp_aiss_extractive_summary( $content, $title, $n = 3 ) {
$text = ayudawp_aiss_clean_html_for_summary( (string) $content );
if ( '' === $text ) {
return '';
}
$sentences = ayudawp_aiss_split_sentences( $text );
if ( count( $sentences ) < 3 ) {
return '';
}
$stopwords = apply_filters( 'ayudawp_aiss_extractive_stopwords', ayudawp_aiss_get_stopwords() );
/**
* Tunable weights. Filterable so themes/plugins can adjust them without
* forking the algorithm.
*
* - lead_bonus: multiplier applied to the first two sentences. Lowered
* from the previous 1.3 because it made the first sentence always
* "win", giving the impression the summarizer hadn't done anything.
* - title_weight: how much extra each title word contributes to its
* frequency score. Raised from 2x to 3x so title-relevant sentences
* stand out more reliably.
* - min_words: discard sentences shorter than this (likely headings
* leaked from the markup, navigation labels, etc).
* - similarity_threshold: Jaccard similarity above which a candidate
* sentence is considered redundant with one already selected.
*
* @since 2.0.0
*/
$weights = apply_filters( 'ayudawp_aiss_extractive_weights', array(
'lead_bonus' => 1.1,
'title_weight' => 3,
'min_words' => 5,
'similarity_threshold' => 0.5,
) );
// Tokenize once per sentence and reuse — Jaccard needs token sets too.
$sentence_tokens = array();
foreach ( $sentences as $i => $sentence ) {
$tokens = preg_split( '/\W+/u', mb_strtolower( $sentence, 'UTF-8' ) );
$tokens = is_array( $tokens ) ? array_values( array_filter( $tokens, 'strlen' ) ) : array();
$sentence_tokens[ $i ] = $tokens;
}
// Build word-frequency table over content-bearing tokens.
$freq = array();
foreach ( $sentence_tokens as $tokens ) {
foreach ( $tokens as $token ) {
if ( isset( $stopwords[ $token ] ) || mb_strlen( $token, 'UTF-8' ) < 3 ) {
continue;
}
$freq[ $token ] = isset( $freq[ $token ] ) ? $freq[ $token ] + 1 : 1;
}
}
// Title words boost their frequency weight.
$title_tokens = preg_split( '/\W+/u', mb_strtolower( (string) $title, 'UTF-8' ) );
if ( is_array( $title_tokens ) ) {
foreach ( $title_tokens as $token ) {
if ( isset( $freq[ $token ] ) ) {
$freq[ $token ] *= (float) $weights['title_weight'];
}
}
}
// Score sentences and remember which are eligible (have enough words).
$scored = array();
$eligible = array();
$jaccard_set = array(); // Per-sentence content-token set used for similarity comparison.
foreach ( $sentence_tokens as $i => $tokens ) {
if ( count( $tokens ) < (int) $weights['min_words'] ) {
continue;
}
$score = 0.0;
$set = array();
foreach ( $tokens as $token ) {
if ( isset( $stopwords[ $token ] ) || mb_strlen( $token, 'UTF-8' ) < 3 ) {
continue;
}
if ( isset( $freq[ $token ] ) ) {
$score += $freq[ $token ];
}
$set[ $token ] = 1;
}
if ( $i < 2 ) {
$score *= (float) $weights['lead_bonus'];
}
$scored[ $i ] = $score;
$eligible[ $i ] = true;
$jaccard_set[ $i ] = $set;
}
if ( empty( $scored ) ) {
return '';
}
arsort( $scored );
$n = max( 1, min( 5, (int) $n ) );
// Greedy MMR-style selection: walk candidates by score descending and
// skip any that are too similar (Jaccard >= threshold) to a sentence
// already picked. Falls back to plain top-N if Jaccard filtering would
// leave us with fewer than N selections.
$threshold = (float) $weights['similarity_threshold'];
$selected = array();
foreach ( array_keys( $scored ) as $idx ) {
if ( count( $selected ) >= $n ) {
break;
}
$is_redundant = false;
foreach ( $selected as $picked ) {
$intersection = count( array_intersect_key( $jaccard_set[ $idx ], $jaccard_set[ $picked ] ) );
$union = count( $jaccard_set[ $idx ] + $jaccard_set[ $picked ] );
$similarity = $union > 0 ? ( $intersection / $union ) : 0;
if ( $similarity >= $threshold ) {
$is_redundant = true;
break;
}
}
if ( ! $is_redundant ) {
$selected[] = $idx;
}
}
// Backfill if the similarity filter was too aggressive.
if ( count( $selected ) < $n ) {
foreach ( array_keys( $scored ) as $idx ) {
if ( count( $selected ) >= $n ) {
break;
}
if ( ! in_array( $idx, $selected, true ) ) {
$selected[] = $idx;
}
}
}
// Restore original sentence order in the output.
sort( $selected );
$pieces = array();
foreach ( $selected as $index ) {
if ( isset( $sentences[ $index ] ) ) {
$pieces[] = $sentences[ $index ];
}
}
return trim( implode( ' ', $pieces ) );
}
/**
* Get the stopwords lookup table for the extractive summarizer (v2.0.0)
*
* Returns an associative array (`'the' => 1`) so callers can use
* `isset( $stopwords[ $token ] )` for O(1) membership checks.
*
* Covers English and Spanish — extend via the `ayudawp_aiss_extractive_stopwords`
* filter to support additional languages.
*
* @since 2.0.0
* @return array Associative array of stopwords.
*/
function ayudawp_aiss_get_stopwords() {
$words = array(
// English.
'the', 'and', 'for', 'are', 'but', 'not', 'you', 'all', 'can', 'her', 'was', 'one', 'our', 'out',
'day', 'get', 'has', 'him', 'his', 'how', 'man', 'new', 'now', 'old', 'see', 'two', 'way', 'who',
'boy', 'did', 'its', 'let', 'put', 'say', 'she', 'too', 'use', 'with', 'this', 'that', 'from',
'they', 'have', 'were', 'been', 'will', 'your', 'what', 'when', 'make', 'like', 'time', 'just',
'know', 'take', 'into', 'some', 'them', 'than', 'then', 'look', 'only', 'come', 'over', 'also',
'back', 'after', 'work', 'first', 'well', 'even', 'want', 'because', 'these', 'give', 'most',
'about', 'which', 'their', 'would', 'there', 'could', 'other', 'should', 'where', 'while',
'being', 'does', 'each', 'such', 'very', 'much', 'many', 'more', 'made', 'said', 'here',
// Spanish.
'los', 'las', 'una', 'uno', 'del', 'que', 'por', 'con', 'para', 'pero', 'sus', 'sin', 'son',
'mas', 'muy', 'ese', 'esa', 'eso', 'este', 'esta', 'esto', 'hay', 'fue', 'ser', 'han', 'has',
'como', 'cuando', 'donde', 'porque', 'sobre', 'entre', 'desde', 'hasta', 'también', 'tambien',
'cada', 'todo', 'todos', 'todas', 'algo', 'algun', 'alguna', 'mucho', 'mucha', 'muchos', 'muchas',
'otro', 'otra', 'otros', 'otras', 'mismo', 'misma', 'tanto', 'tanta', 'poco', 'poca', 'nada',
'ante', 'bajo', 'cabe', 'contra', 'segun', 'según', 'durante', 'mediante', 'hacia',
'aqui', 'aquí', 'alli', 'allí', 'ahi', 'ahí', 'asi', 'así', 'aun', 'aún', 'ya', 'si', 'sí',
'siempre', 'nunca', 'jamás', 'jamas', 'antes', 'despues', 'después', 'luego', 'entonces',
'estaba', 'estaban', 'estamos', 'estar', 'estado', 'estos', 'estas', 'eran', 'era', 'eres',
'haber', 'había', 'habia', 'habían', 'habian', 'hace', 'hacer', 'hecho', 'puede', 'pueden',
'porqué', 'porque', 'cual', 'cuál', 'quien', 'quién', 'cuanto', 'cuánto',
);
return array_fill_keys( $words, 1 );
}