*/ private const INTENT_TOKENS = array( 'レビュー', '比較', 'おすすめ', 'ランキング', '選び方', '買って', '使って', '便利', ); /** * Scan one post for candidate search keywords. * * @param PostScanItem $post Post. * * @return list */ public function scanPost( PostScanItem $post ): array { $text = $this->plainText( $post->title() . "\n" . $post->content() ); if ( trim( $text ) === '' ) { return array(); } $score = $this->intentScore( $text ); $matches = array(); foreach ( $this->candidateKeywords( $post->title(), $text ) as $candidate ) { $matches[] = array( 'post_id' => $post->id(), 'post_title' => $post->title(), 'candidate' => $candidate, 'reason' => $score > 0 ? 'review_intent' : 'title_candidate', 'confidence' => max( 40, min( 95, 55 + ( $score * 10 ) ) ), 'requires_confirmation' => true, ); } return $matches; } /** * Scan several posts. * * @param list $posts Posts. * * @return array{posts_scanned:int,opportunities:list} */ public function scanBatch( array $posts ): array { $opportunities = array(); foreach ( $posts as $post ) { $opportunities = array_merge( $opportunities, $this->scanPost( $post ) ); } return array( 'posts_scanned' => count( $posts ), 'opportunities' => $opportunities, ); } /** * Score product-link opportunity intent. * * @param string $text Plain text. */ private function intentScore( string $text ): int { $score = 0; foreach ( self::INTENT_TOKENS as $token ) { if ( str_contains( $text, $token ) ) { ++$score; } } $marketplacePattern = '/https?:\/\/[^\s]*(?:\/dp\/[A-Z0-9]{8,}' . '|item\.rakuten\.co\.jp|shopping\.yahoo\.co\.jp)/i'; if ( preg_match( $marketplacePattern, $text ) === 1 ) { $score += 2; } return $score; } /** * Build candidate keywords from title and text. * * @param string $title Title. * @param string $text Plain text. * * @return list */ private function candidateKeywords( string $title, string $text ): array { $candidates = array(); $cleanTitle = $this->normalizeCandidate( $title ); if ( $cleanTitle !== '' ) { $candidates[] = $cleanTitle; } if ( preg_match_all( '/[A-Za-z0-9][A-Za-z0-9\- ]{2,40}/', $text, $found ) === 1 ) { foreach ( $found[0] as $candidate ) { $normalized = $this->normalizeCandidate( $candidate ); if ( $normalized !== '' ) { $candidates[] = $normalized; } } } $unique = array(); foreach ( $candidates as $candidate ) { $key = strtolower( $candidate ); if ( isset( $unique[ $key ] ) ) { continue; } $unique[ $key ] = $candidate; if ( count( $unique ) >= 3 ) { break; } } return array_values( $unique ); } /** * Normalize candidate text. * * @param string $value Candidate. */ private function normalizeCandidate( string $value ): string { $value = trim( preg_replace( '/\s+/', ' ', $this->plainText( $value ) ) ?? $value ); $value = trim( preg_replace( '/(?:レビュー|比較|おすすめ|ランキング|選び方)$/u', '', $value ) ?? $value ); if ( $value === '' ) { return ''; } if ( function_exists( 'mb_strlen' ) && function_exists( 'mb_substr' ) ) { return mb_strlen( $value ) > 60 ? mb_substr( $value, 0, 60 ) : $value; } return strlen( $value ) > 60 ? substr( $value, 0, 60 ) : $value; } /** * Convert HTML/block content to plain text. * * @param string $content Content. */ private function plainText( string $content ): string { if ( function_exists( 'wp_strip_all_tags' ) ) { return (string) wp_strip_all_tags( $content ); } return trim( preg_replace( '/<[^>]*>/', '', $content ) ?? $content ); } }