*/ private const SHORTCODE_TAGS = array( 'product_link', 'product-card', 'affiliate_product', 'affiliate-card', 'legacy_product_link', ); /** * Scan one post. * * @param PostScanItem $post Post. * * @return list */ public function scanPost( PostScanItem $post ): array { $matches = array(); foreach ( self::SHORTCODE_TAGS as $tag ) { $matches = array_merge( $matches, $this->scanShortcodeTag( $post, $tag ) ); } $matches = array_merge( $matches, $this->scanMarketplaceLinks( $post ) ); $matches = array_merge( $matches, $this->scanCardLikeHtml( $post ) ); usort( $matches, static fn ( array $a, array $b ): int => $a['offset'] <=> $b['offset'] ); return $matches; } /** * Scan several posts and return a dry-run report. * * @param list $posts Posts. * * @return array{posts_scanned:int,matches:list} */ public function dryRun( array $posts ): array { $matches = array(); foreach ( $posts as $post ) { $matches = array_merge( $matches, $this->scanPost( $post ) ); } return array( 'posts_scanned' => count( $posts ), 'matches' => $matches, ); } /** * Scan one shortcode tag. * * @param PostScanItem $post Post. * @param string $tag Shortcode tag. * * @return list */ private function scanShortcodeTag( PostScanItem $post, string $tag ): array { $matches = array(); $pattern = '/\[\s*' . preg_quote( $tag, '/' ) . '\b[^\]]*\]/i'; if ( preg_match_all( $pattern, $post->content(), $found, PREG_OFFSET_CAPTURE ) !== false ) { foreach ( $found[0] as $match ) { $matches[] = $this->buildMatch( $post, 'shortcode', $tag, (int) $match[1], 95, (string) $match[0] ); } } return $matches; } /** * Scan product-like marketplace links. * * @param PostScanItem $post Post. * * @return list */ private function scanMarketplaceLinks( PostScanItem $post ): array { $matches = array(); $pattern = '~https?://[^\s"\']*(?:/dp/[A-Z0-9]{8,}|item\.rakuten\.co\.jp|shopping\.yahoo\.co\.jp)[^\s"\']*~i'; if ( preg_match_all( $pattern, $post->content(), $found, PREG_OFFSET_CAPTURE ) !== false ) { foreach ( $found[0] as $match ) { $matches[] = $this->buildMatch( $post, 'marketplace_link', 'marketplace_url', (int) $match[1], 80, (string) $match[0] ); } } return $matches; } /** * Scan generic product-card-like HTML classes. * * @param PostScanItem $post Post. * * @return list */ private function scanCardLikeHtml( PostScanItem $post ): array { $matches = array(); $pattern = '/<(?:div|section|article)\b[^>]*class=["\'][^"\']*' . '(?:product-link|affiliate-card|product-card)[^"\']*["\'][^>]*>/i'; if ( preg_match_all( $pattern, $post->content(), $found, PREG_OFFSET_CAPTURE ) !== false ) { foreach ( $found[0] as $match ) { $matches[] = $this->buildMatch( $post, 'html_card', 'class_name', (int) $match[1], 70, (string) $match[0] ); } } return $matches; } /** * Build a report match. * * @param PostScanItem $post Post. * @param string $kind Match kind. * @param string $source Source token. * @param int $offset Offset. * @param int $confidence Confidence. * @param string $preview Preview. * * @return array{post_id:int,post_title:string,kind:string,source:string,offset:int,confidence:int,preview:string} */ private function buildMatch( PostScanItem $post, string $kind, string $source, int $offset, int $confidence, string $preview ): array { return array( 'post_id' => $post->id(), 'post_title' => $post->title(), 'kind' => $kind, 'source' => $source, 'offset' => max( 0, $offset ), 'confidence' => max( 0, min( 100, $confidence ) ), 'preview' => $this->preview( $preview ), ); } /** * Normalize a preview string. * * @param string $value Preview candidate. */ private function preview( string $value ): string { $plain = trim( preg_replace( '/\s+/', ' ', $value ) ?? $value ); if ( function_exists( 'mb_strlen' ) && function_exists( 'mb_substr' ) ) { return mb_strlen( $plain ) > 140 ? mb_substr( $plain, 0, 137 ) . '...' : $plain; } return strlen( $plain ) > 140 ? substr( $plain, 0, 137 ) . '...' : $plain; } }