validator = new AdGhost_Safety_Validator(); } public function detect_points($content) { $content_hash = md5($content); if (isset($this->points_cache[$content_hash])) { return $this->points_cache[$content_hash]; } $points = []; $rules = new AdGhost_Paragraph_Rules(); $ends = $rules->paragraph_ends($content); $total_paragraphs = count($ends); $tag_alt = implode('|', array_map(function ($t) { return preg_quote($t, '/'); }, AdGhost_Paragraph_Rules::count_tags($rules->all()))); $previous_end = 0; $words_before = 0; foreach ($ends as $index => $offset) { $paragraph_num = $index + 1; $segment = substr($content, $previous_end, $offset - $previous_end); $previous_end = $offset; $words_before += AdGhost_Content_Analyzer::count_words($segment); $own_text = $segment; if (preg_match_all('/<(?:' . $tag_alt . ')\b[^>]*>/i', $segment, $opens, PREG_OFFSET_CAPTURE)) { $own_text = substr($segment, end($opens[0])[1]); } $validation = $this->validate_point($content, $offset); $points[] = [ 'id' => 'p' . $paragraph_num, 'type' => 'paragraph', 'label' => 'Paragraph #' . $paragraph_num, 'offset' => $offset, 'paragraph_number' => $paragraph_num, 'word_count' => AdGhost_Content_Analyzer::count_words($own_text), 'text' => mb_substr(trim(html_entity_decode(wp_strip_all_tags($own_text), ENT_QUOTES, 'UTF-8')), 0, 2000), 'words_before' => $words_before, 'next' => self::next_block($content, $offset), 'validation' => $validation, 'score' => $this->score_point($paragraph_num, $total_paragraphs, $segment, $validation, 'paragraph') ]; } preg_match_all('/<(h[1-6])(\s[^>]*)?>(.*?)<\/\1>/i', $content, $h_matches, PREG_OFFSET_CAPTURE); foreach ($h_matches[0] as $index => $match) { $offset = $match[1]; if (self::paragraphs_before($ends, $offset) === 0) { continue; } $validation = $this->validate_point($content, $offset); $points[] = [ 'id' => 'h' . ($index + 1), 'type' => 'heading', 'label' => 'Before heading: ' . $h_matches[1][$index][0], 'offset' => $offset, 'paragraph_number' => self::paragraphs_before($ends, $offset), 'word_count' => AdGhost_Content_Analyzer::count_words($h_matches[3][$index][0]), 'text' => '', 'validation' => $validation, 'score' => $this->score_point($index + 1, max(1, count($h_matches[0])), $h_matches[3][$index][0], $validation, 'heading') ]; } $this->points_cache[$content_hash] = $points; return $points; } public static function next_block($content, $offset) { $rest = substr($content, $offset, 600); $rest = ltrim(preg_replace('/^(?:\s|)+/s', '', $rest)); if ($rest === '') { return 'end'; } if (preg_match('/^]*\bwp-block-(?:image|gallery)|img\b|figure\b[^>]*>\s*]*wp-block-embed|iframe|video)\b/i', $rest)) { return 'media'; } return 'text'; } private static function paragraphs_before(array $ends, $offset) { $n = 0; foreach ($ends as $end) { if ($end > $offset) { break; } $n++; } return $n; } private function validate_point($content, $offset) { $result = $this->validator->validate(['offset' => $offset], $content); return [ 'is_safe' => $result['passed'], 'reasons' => $result['reasons'], 'safety_score' => $result['score'], 'checks' => $result['checks'], 'width_percent' => $result['width_percent'], 'width_px' => $result['width_px'], ]; } private function score_point($num, $total, $text, $validation, $block_type = 'paragraph') { if (empty($validation['is_safe'])) { return [ 'safety' => $validation['safety_score'] ?? 20, 'ux' => 10, 'performance' => 10, 'details' => ['rejected' => 'Point failed safety validation'], ]; } $word_count = AdGhost_Content_Analyzer::count_words((string) $text); $position = $this->score_position($num, $total); $buffer = $this->score_buffer($word_count); $natural = $this->score_natural_break($block_type); $viewability = $this->score_viewability($num); $engagement = ['points' => 20, 'reason' => 'Standard content context']; $competition = ['points' => 30, 'reason' => 'No other ad nearby']; $ux = min(100, $position['points'] + $buffer['points'] + $natural['points']); $performance = min(100, $viewability['points'] + $engagement['points'] + $competition['points']); return [ 'safety' => $validation['safety_score'] ?? 100, 'ux' => $ux, 'performance' => $performance, 'details' => [ 'position' => $position, 'buffer' => $buffer, 'natural_break' => $natural, 'viewability' => $viewability, 'engagement' => $engagement, 'competition' => $competition, ], ]; } private function score_position($num, $total) { if ($total <= 0) { return ['points' => 20, 'reason' => 'Unknown position']; } $position = ($num / $total) * 100; if ($position < 10) { return ['points' => 25, 'reason' => 'Very top of the content']; } if ($position < 25) { return ['points' => 35, 'reason' => 'Early in the content']; } if ($position <= 75) { return ['points' => 40, 'reason' => 'Middle of the content (optimal)']; } if ($position <= 90) { return ['points' => 30, 'reason' => 'Late in the content']; } return ['points' => 20, 'reason' => 'Very bottom (less visible)']; } private function score_buffer($word_count) { if ($word_count < 100) { return ['points' => 10, 'reason' => sprintf('Short block (%d words)', $word_count)]; } if ($word_count < 300) { return ['points' => 20, 'reason' => sprintf('Medium block (%d words)', $word_count)]; } return ['points' => 30, 'reason' => sprintf('Long block (%d words)', $word_count)]; } private function score_natural_break($block_type) { if ($block_type === 'paragraph') { return ['points' => 30, 'reason' => 'Natural paragraph break']; } if ($block_type === 'heading') { return ['points' => 20, 'reason' => 'Before a heading']; } return ['points' => 15, 'reason' => 'Generic break']; } private function score_viewability($num) { if ($num <= 2) { return ['points' => 35, 'reason' => 'Above the fold']; } if ($num <= 6) { return ['points' => 40, 'reason' => 'First scroll (optimal viewability)']; } return ['points' => 30, 'reason' => 'Below the fold']; } }