['target' => 1500, 'min' => 1000, 'max' => 2500], 'explainer_guide' => ['target' => 1800, 'min' => 1200, 'max' => 3000], 'tutorial' => ['target' => 1200, 'min' => 900, 'max' => 2200], 'review_comparison' => ['target' => 1500, 'min' => 1000, 'max' => 2500], 'course_overview' => ['target' => 1500, 'min' => 1000, 'max' => 2500], 'training_module' => ['target' => 1500, 'min' => 1000, 'max' => 2500], 'case_study_ld' => ['target' => 1200, 'min' => 800, 'max' => 2000], 'service_page' => ['target' => 1000, 'min' => 700, 'max' => 1400], 'landing_page' => ['target' => 900, 'min' => 600, 'max' => 1300], 'about_us' => ['target' => 700, 'min' => 500, 'max' => 1000], 'faq_page' => ['target' => 800, 'min' => 400, 'max' => 1400], 'woocommerce_product'=> ['target' => 450, 'min' => 300, 'max' => 700], 'vehicle_listing' => ['target' => 400, 'min' => 250, 'max' => 650], 'press_release' => ['target' => 600, 'min' => 400, 'max' => 800], 'recipe' => ['target' => 600, 'min' => 350, 'max' => 1000], 'onboarding_doc' => ['target' => 800, 'min' => 500, 'max' => 1400], ]; const DEFAULT_PROFILE = ['target' => 1200, 'min' => 600, 'max' => 2000]; /** * Words that may not begin or end a derived keyword phrase, and that a * heading n-gram may not consist of. Deliberately small and boring — the * point is to stop "the best way to" becoming a "keyword", not to do NLP. */ const STOPWORDS = [ 'a','an','and','are','as','at','be','but','by','can','do','for','from', 'how','i','in','is','it','its','of','on','or','that','the','this','to', 'was','what','when','where','which','who','why','will','with','you','your', 'we','our','us','if','so','than','then','there','these','those','they', 'about','into','over','via','vs','versus', ]; /** * Anchor texts that carry no topical meaning and must never become an * anchor variant. Case-insensitive, matched whole. */ const ANCHOR_STOPLIST = [ 'click here','read more','here','this post','this article','learn more', 'more','link','this','that','see here','continue reading','full post', 'read the full article','check it out', ]; /** * Words that pass the proper-noun test only because content-site headings * are Title-Cased. They are never a post's real subject, so they must not * become a "stay on that" entity. Case-insensitive, matched whole. */ const ENTITY_STOPLIST = [ 'business','guide','guides','tips','best','top','ultimate','complete', 'review','reviews','overview','introduction','conclusion','summary', 'options','ways','things','reasons','benefits','pros','cons','faq','faqs', 'price','prices','pricing','cost','costs','buying','selling','everything', ]; /* ===================================================================== * PURE-ANALYSIS CORE — no WordPress, unit-testable. * ===================================================================== */ /** * Words in the visible text of $html. Tags and entities stripped first. * A "word" is a run of letters/digits, optionally joined by ' ’ or - so * "up-to-date" and "don't" count once, not two or three. */ public static function word_count($html) { $text = self::flatten($html); if ($text === '') return 0; return preg_match_all( '/[\p{L}\p{N}]+(?:[\'’\-][\p{L}\p{N}]+)*/u', $text, $m ); } /** * Every heading in document order, as [level => int, text => string]. * Text is flattened (tags/entities out, whitespace collapsed). */ public static function headings($html) { $out = []; if (preg_match_all('/]*>(.*?)<\/h\1>/isu', (string) $html, $m, PREG_SET_ORDER)) { foreach ($m as $row) { $text = trim(self::flatten($row[2])); if ($text !== '') { $out[] = ['level' => (int) $row[1], 'text' => $text]; } } } return $out; } /** Heading texts at a given level (2 =

), in order. */ public static function headings_at($html, $level) { $out = []; foreach (self::headings($html) as $h) { if ($h['level'] === (int) $level) $out[] = $h['text']; } return $out; } /** * A keyword derived from a slug. Hyphens/underscores to spaces, a trailing * WordPress dedupe suffix ("-2") removed, edges trimmed of stopwords. Last * resort before "most frequent heading phrase", so it stays literal. */ public static function keyword_from_slug($slug) { $slug = strtolower(trim((string) $slug)); if ($slug === '') return ''; $slug = preg_replace('/-\d+$/', '', $slug); // WP "-2" dedupe tail $slug = preg_replace('/[_\-]+/', ' ', $slug); $slug = preg_replace('/\s+/', ' ', trim($slug)); if ($slug === '') return ''; $words = explode(' ', $slug); $stop = array_flip(self::STOPWORDS); while ($words && isset($stop[$words[0]])) array_shift($words); while ($words && isset($stop[end($words)])) array_pop($words); return trim(implode(' ', $words)); } /** * The most frequent non-stopword 2–4 word phrase across the post's * headings. This is the last keyword fallback in the priority list and is * only reached when no SEO plugin key and no usable slug exist. * * Deterministic: builds every 2–4-gram from each heading that neither * begins nor ends on a stopword, counts them across all headings, and * returns the highest count. Ties break toward the LONGER phrase, then the * one seen earliest — so a specific 3-word phrase beats a generic 2-word * one that merely happens to tie. */ public static function keyword_from_headings($html) { $headings = array_map(fn($h) => $h['text'], self::headings($html)); if (!$headings) return ''; $stop = array_flip(self::STOPWORDS); $counts = []; // phrase => count $order = []; // phrase => first-seen index $seen = 0; foreach ($headings as $heading) { preg_match_all('/[\p{L}\p{N}]+/u', self::lc($heading), $wm); $words = $wm[0]; $n = count($words); for ($len = 2; $len <= 4; $len++) { for ($i = 0; $i + $len <= $n; $i++) { $gram = array_slice($words, $i, $len); if (isset($stop[$gram[0]]) || isset($stop[end($gram)])) continue; $phrase = implode(' ', $gram); if (!isset($counts[$phrase])) { $counts[$phrase] = 0; $order[$phrase] = $seen++; } $counts[$phrase]++; } } } if (!$counts) return ''; $best = ''; $bestScore = null; foreach ($counts as $phrase => $count) { // Score tuple: count desc, then word-length desc, then first-seen asc. $len = substr_count($phrase, ' ') + 1; $score = [$count, $len, -$order[$phrase]]; if ($bestScore === null || $score > $bestScore) { $bestScore = $score; $best = $phrase; } } return $best; } /** * Infer the ACB content type from title, slug, body and post type. Always * user-confirmable in the UI; this is a pre-selection, not a decision. * * Returns one of the LENGTH_PROFILES ids used by the brief modal. Product * post type is the strongest signal and wins outright; after that the order * runs comparison → tutorial → FAQ → blog, most-specific first. */ public static function infer_content_type($title, $slug, $html, $post_type = 'post') { $title = (string) $title; $slug = (string) $slug; $hay = self::lc($title . ' ' . $slug); if ($post_type === 'product') return 'woocommerce_product'; // Explicit procedural intent in the title ("how to") is a stronger, // more deliberate signal than an incidental "vs" — a how-to that // compares two options ("How to fit skirting (vinyl vs metal)") is a // tutorial, not a comparison. So "how to" is tested before "vs". if (preg_match('/\bhow[\s\-]?to\b/u', $hay)) return 'tutorial'; if (preg_match('/\b(vs|versus|compared)\b/u', $hay)) return 'review_comparison'; // A real multi-step ordered list is strong tutorial evidence on its own. if (self::max_ol_step_count($html) >= 4) return 'tutorial'; // FAQ page only when questions DOMINATE the section headings — not when // a long guide merely includes a FAQ block. A 3,000-word explainer with // three question headings among fourteen sections is a guide, not a FAQ. $q = self::question_heading_count($html); $sections = count(self::headings_at($html, 2)) + count(self::headings_at($html, 3)); if ($q >= 2 && $sections > 0 && $q * 2 >= $sections) return 'faq_page'; return 'blog_post'; } /** * Type-specific meta. Best effort; an empty array is fine. * faq_page → ['question_count' => N] * tutorial → ['step_count' => N] (steps in the longest ordered list) */ public static function content_type_meta($content_type, $html) { if ($content_type === 'faq_page') { return ['question_count' => self::question_heading_count($html)]; } if ($content_type === 'tutorial') { $steps = self::max_ol_step_count($html); return $steps > 0 ? ['step_count' => $steps] : []; } return []; } /** * target_word_count: the current length, floored into the type's band. * * The spec sets two rules — "clamped into the type's LENGTH_PROFILES band" * AND "never below the original. A refresh that comes back shorter is not a * refresh." Where those disagree (a post already longer than band.max), the * second wins: we raise a too-short post up to band.min, but we NEVER lower * a post below its current length. band.max is intentionally not applied as * a ceiling here. (The licence server's resolveTargetWords() applies its own * clamp at generation; that is its concern, not the brief's.) */ public static function target_word_count($current_words, $content_type) { $current = max(0, (int) $current_words); $profile = self::LENGTH_PROFILES[$content_type] ?? self::DEFAULT_PROFILE; return max($current, (int) $profile['min']); } /** * special_instructions, assembled from evidence. Returns two groups: * ['reliable' => [ ['type'=>slug,'text'=>str], ... ], * 'best_effort' => [ ... ]] * so the modal can render them under separate headings and let the user * remove any line. Callers that only want a string can join the texts. * * @param array $registered_shortcodes Tag names WordPress has registered * (array_keys($GLOBALS['shortcode_tags'])). Empty in tests, in * which case any [tag …] shape is reported. */ public static function notable_elements($html, array $registered_shortcodes = []) { $html = (string) $html; $reliable = []; $best_effort = []; // --- reliable --------------------------------------------------- if ($line = self::detect_shortcodes($html, $registered_shortcodes)) $reliable[] = $line; foreach (self::detect_phone_numbers($html) as $line) $reliable[] = $line; if ($line = self::detect_tables($html)) $reliable[] = $line; if ($line = self::detect_ordered_process($html)) $reliable[] = $line; if ($line = self::detect_structure($html)) $reliable[] = $line; if ($line = self::detect_locale($html)) $reliable[] = $line; // AICOBR_REFRESH_2026_08 — the entity "stay on topic" guard is no longer // emitted: generation now receives the full original article, so the // model can't drift off-subject and the crude "This post is about X" // line added no value. detect_heading_entities() is retained (unused) // in case a future non-content-passing path wants a cheap topic anchor. // --- best effort ------------------------------------------------ if ($line = self::detect_affiliate($html)) $best_effort[] = $line; if ($line = self::detect_cta($html)) $best_effort[] = $line; if ($line = self::detect_ads($html)) $best_effort[] = $line; return ['reliable' => $reliable, 'best_effort' => $best_effort]; } /** * anchor_variants, the §4.2 rules applied to raw rows. * * @param array $rows Each ['text'=>anchor, 'source_id'=>int, 'source_title'=>str], * one per matching OCCURRENCE (repeats allowed — that * is how count is derived). * @return array ['detail' => [ ['text','count','source_title','source_id'], ...], * 'list' => ['text', ...]] capped at 12, most frequent first. */ public static function anchor_variants_from(array $rows) { $stop = array_map('strtolower', self::ANCHOR_STOPLIST); $stop = array_flip($stop); $agg = []; // lc key => [text, count, source_title, source_id] foreach ($rows as $row) { $text = trim(preg_replace('/\s+/u', ' ', (string) ($row['text'] ?? ''))); if ($text === '') continue; $lc = self::lc($text); // Discard: stoplist, bare URLs, too short, too long. if (isset($stop[$lc])) continue; if (preg_match('#^(https?://|www\.)#i', $text)) continue; $wordN = preg_match_all('/[\p{L}\p{N}]+/u', $text, $x); if ($wordN < 2 || $wordN > 8) continue; if (!isset($agg[$lc])) { $agg[$lc] = [ 'text' => $text, 'count' => 0, 'source_title' => (string) ($row['source_title'] ?? ''), 'source_id' => (int) ($row['source_id'] ?? 0), ]; } $agg[$lc]['count']++; } if (!$agg) return ['detail' => [], 'list' => []]; // Most frequent first; ties break toward the shorter, more reusable // phrase, then alphabetically for stability. $items = array_values($agg); usort($items, function ($a, $b) { if ($a['count'] !== $b['count']) return $b['count'] <=> $a['count']; $la = strlen($a['text']); $lb = strlen($b['text']); if ($la !== $lb) return $la <=> $lb; return strcmp($a['text'], $b['text']); }); $items = array_slice($items, 0, 12); return [ 'detail' => $items, 'list' => array_map(fn($i) => $i['text'], $items), ]; } /* --------------------- detection helpers (pure) --------------------- */ /** How many
    steps in the longest ordered list (0 if none). */ public static function max_ol_step_count($html) { $max = 0; if (preg_match_all('/]*>(.*?)<\/ol>/isu', (string) $html, $m)) { foreach ($m[1] as $inner) { $n = preg_match_all('/]*>/iu', $inner, $x); if ($n > $max) $max = $n; } } return $max; } /** Question-shaped h2/h3: ends in "?" or opens with a question word. */ public static function question_heading_count($html) { $n = 0; foreach (self::headings($html) as $h) { if ($h['level'] !== 2 && $h['level'] !== 3) continue; // A question mark is the reliable signal. Starting with "what"/"how" // is not enough — "What to Inspect", "How to Choose" and "Why It // Matters" are declarative section titles, not FAQ questions. if (substr(rtrim($h['text']), -1) === '?') $n++; } return $n; } private static function detect_shortcodes($html, array $registered) { $tags = self::shortcode_tags_in($html, $registered); if (!$tags) return null; $list = implode('], [', $tags); $plural = count($tags) === 1 ? 'shortcode' : 'shortcodes'; return ['type' => 'shortcodes', 'text' => "Keep the [$list] $plural exactly where they are."]; } /** * The distinct shortcode tags present in $html (lowercased, de-duped, in * first-seen order). When $registered is non-empty, only tags that are * actually registered count. Shared by the brief detector and the * loss-check so the two never disagree. */ public static function shortcode_tags_in($html, array $registered = []) { if (!preg_match_all('/\[([a-z0-9][a-z0-9_\-]*)(?:\s[^\]]*)?\]/i', (string) $html, $m)) return []; $tags = []; $reg = $registered ? array_flip(array_map('strtolower', $registered)) : null; foreach ($m[1] as $tag) { $lc = strtolower($tag); if ($reg !== null && !isset($reg[$lc])) continue; // unknown = not a shortcode if (!in_array($lc, $tags, true)) $tags[] = $lc; } return $tags; } private static function detect_phone_numbers($html) { $out = []; foreach (self::phone_numbers_in($html) as $num) { $out[] = ['type' => 'phone', 'text' => "This post contains the phone number $num. Keep it exactly as written."]; } return $out; } /** * Distinct phone numbers in $html, keyed by normalised digits (so the same * number written two ways, or found in both a tel: href and the visible * text, appears once). Returns [digits => display]. Shared by the brief * detector and the loss-check; keying by digits makes the loss-check * tolerant of reformatting (spacing/dashes) — only a genuinely vanished * number counts as lost. */ public static function phone_numbers_in($html) { // Key by normalised digits so the same number written two ways, or found // in both a tel: href and the visible text, is reported once. $found = []; // digits => display string // tel: hrefs first — the most reliable signal. if (preg_match_all('/href=["\']tel:([^"\']+)["\']/i', (string) $html, $tm)) { foreach ($tm[1] as $raw) { $key = self::phone_key($raw); if (strlen($key) >= 7 && strlen($key) <= 15 && !isset($found[$key])) { $found[$key] = trim($raw); } } } // Visible-text numbers, matched WITHOUT overlap: collect every candidate // span from both the UK and US patterns, then accept longest-first and // reject anything overlapping an already-accepted span. This stops the // US pattern re-matching the tail of a UK number ("0121 456 7890" also // ending in a valid "121 456 7890") as a second, phantom number. $text = self::flatten($html); $cands = []; // [offset, length, raw] $patterns = [ '/\b0\d{2,4}[\s.\-]?\d{3,4}[\s.\-]?\d{0,4}\b/', // UK '/(?:\+?1[\s.\-]?)?\(?\d{3}\)?[\s.\-]?\d{3}[\s.\-]?\d{4}\b/', // US ]; foreach ($patterns as $p) { if (preg_match_all($p, $text, $pm, PREG_OFFSET_CAPTURE)) { foreach ($pm[0] as $hit) { $raw = $hit[0]; $digits = preg_replace('/\D/', '', $raw); if (strlen($digits) < 7 || strlen($digits) > 15) continue; $cands[] = [$hit[1], strlen($raw), trim($raw)]; } } } // Longest span first, then earliest — so the fuller UK match wins the // overlap against the shorter US sub-match. usort($cands, fn($a, $b) => ($b[1] <=> $a[1]) ?: ($a[0] <=> $b[0])); $taken = []; // accepted [start, end) foreach ($cands as [$start, $len, $raw]) { $end = $start + $len; $overlap = false; foreach ($taken as [$ts, $te]) { if ($start < $te && $end > $ts) { $overlap = true; break; } } if ($overlap) continue; $taken[] = [$start, $end]; $key = self::phone_key($raw); if (!isset($found[$key])) $found[$key] = $raw; } return $found; } private static function phone_key($raw) { return preg_replace('/\D/', '', $raw); } private static function detect_tables($html) { if (stripos($html, ' 'table', 'text' => "There is a table under '$heading'. Keep a table there."]; } return ['type' => 'table', 'text' => "This post contains a table. Keep it as a table."]; } private static function detect_ordered_process($html) { // Find the first
      with 3+ items and report its nearest heading. if (!preg_match_all('/]*>(.*?)<\/ol>/isu', (string) $html, $m, PREG_OFFSET_CAPTURE)) return null; foreach ($m[1] as $idx => $inner) { $steps = preg_match_all('/]*>/iu', $inner[0], $x); if ($steps >= 3) { $pos = $m[0][$idx][1]; $heading = self::nearest_preceding_heading($html, $pos); $where = $heading !== '' ? "The '$heading' section" : 'This post'; return ['type' => 'ordered_process', 'text' => "$where is a $steps-step numbered process. Keep it numbered and keep the step count."]; } } return null; } private static function detect_structure($html) { $h2 = self::headings_at($html, 2); if (!$h2) return null; $list = implode(', ', $h2); return ['type' => 'structure', 'text' => "Existing sections were: $list. Keep these unless there is a reason not to."]; } private static function detect_locale($html) { $text = self::lc(self::flatten($html)); if ($text === '') return null; // Small, high-signal marker sets. Not a spell-checker — a tilt detector. $uk = ['colour','favour','behaviour','neighbour','organise','organised','organisation', 'recognise','realise','analyse','centre','licence','defence','travelling', 'cancelled','labour','honour','catalogue','programme','optimise','maximise']; $us = ['color','favor','behavior','neighbor','organize','organized','organization', 'recognize','realize','analyze','center','license','defense','traveling', 'canceled','labor','honor','catalog','program','optimize','maximize']; $count = function ($words) use ($text) { $c = 0; foreach ($words as $w) $c += preg_match_all('/\b' . preg_quote($w, '/') . '\b/u', $text, $x); return $c; }; $ukN = $count($uk); $usN = $count($us); if ($ukN === 0 && $usN === 0) return null; if ($ukN === $usN) return null; // no clear tilt — say nothing return $ukN > $usN ? ['type' => 'locale', 'text' => 'Written in British English. Match it.'] : ['type' => 'locale', 'text' => 'Written in American English. Match it.']; } private static function detect_heading_entities($html) { $headings = self::headings($html); if (count($headings) < 2) return null; $stop = array_flip(self::STOPWORDS); $perHeading = []; // token => set of heading indices foreach ($headings as $i => $h) { // Proper-noun-ish tokens: initial capital with a lowercase letter // (Ford, Birmingham) or an all-caps acronym of 2+ (NHS, ACF). The // first token of a heading is excluded — sentence-initial caps lie. preg_match_all('/[\p{L}][\p{L}\p{N}\'’\-]*/u', $h['text'], $tm, PREG_OFFSET_CAPTURE); $isFirst = true; foreach ($tm[0] as $tok) { $word = $tok[0]; if ($isFirst) { $isFirst = false; continue; } $lc = self::lc($word); if (isset($stop[$lc])) continue; $properCase = preg_match('/^\p{Lu}[\p{Ll}]/u', $word); $acronym = preg_match('/^\p{Lu}{2,}$/u', $word); if (!$properCase && !$acronym) continue; $perHeading[$word][$i] = true; } } $entities = []; foreach ($perHeading as $word => $set) { if (count($set) >= 2) $entities[$word] = count($set); } if (!$entities) return null; // Gate the candidates. Title-cased headings capitalise ordinary nouns // ("Business", "Guide"), so recurrence across headings alone is not // enough. Two filters: a generic-word stoplist, and — when there is // real body prose to check against — a requirement that the word also // appears capitalised mid-sentence in the body, the mark of a true // proper noun. Below the prose threshold we cannot confirm either way, // so we keep the heading-only behaviour rather than drop blindly. $estop = array_flip(self::ENTITY_STOPLIST); $body = preg_replace('/]*>.*?<\/h\1>/isu', ' ', $html); $bodyText = self::flatten($body); $bodyWords = self::word_count($body); $checkProse = $bodyWords >= 40; $gated = []; foreach ($entities as $word => $count) { if (isset($estop[self::lc($word)])) continue; if ($checkProse && !self::entity_seen_in_prose($word, $bodyText)) continue; $gated[$word] = $count; } if (!$gated) return null; $entities = $gated; arsort($entities); $names = array_slice(array_keys($entities), 0, 3); $phrase = count($names) === 1 ? $names[0] : implode(', ', array_slice($names, 0, -1)) . ' and ' . end($names); return ['type' => 'entities', 'text' => "This post is specifically about $phrase. Stay on that."]; } private static function detect_affiliate($html) { if (self::affiliate_hit_count($html) === 0) return null; return ['type' => 'affiliate', 'text' => 'Some links here look like affiliate links. Keep them and roughly where they sit.']; } /** * How many affiliate-looking signals are in $html: rel=sponsored/nofollow, * known network domains, and the common ?tag= / ?ref= / /go/ / /recommends/ * patterns. Best-effort by nature (see spec §3.2) — used to WARN, never to * block, in the loss-check. */ public static function affiliate_hit_count($html) { $html = (string) $html; $hits = 0; $hits += preg_match_all('/rel=["\'][^"\']*\b(sponsored|nofollow)\b[^"\']*["\']/i', $html, $x); $hits += preg_match_all('#href=["\'][^"\']*(amazon\.[a-z.]+|amzn\.to|shareasale\.com|awin\d|/go/|/recommends/|[?&](tag|ref|aff|affiliate)=)#i', $html, $y); return $hits; } /** Number of elements in $html. */ public static function tables_count($html) { return preg_match_all('/ 'shortcode', 'item' => $tag, 'label' => "the [$tag] shortcode"]; } } // Phone numbers present before, gone after (by digits). $after_ph = self::phone_numbers_in($after); foreach (self::phone_numbers_in($before) as $key => $display) { if (!isset($after_ph[$key])) { $reliable[] = ['type' => 'phone', 'item' => $display, 'label' => "the phone number $display"]; } } // A table was present and is now gone entirely. if (self::tables_count($before) > 0 && self::tables_count($after) === 0) { $reliable[] = ['type' => 'table', 'item' => 'table', 'label' => 'the table']; } // AICOBR_REFRESH_LINKGUARD_2026_08 — ACB's own inserted internal links. // Each record carries the EXACT HTML written at insert time // (stored_html), so an exact-string absence from $after is a // deterministic "this link is gone" — reliable by the same definition as // a missing shortcode. Only post_content links are at risk: a link // written into an ACF field (field !== '') is untouched by a content // refresh, so it is skipped. Exact-match ONLY here keeps this method pure // and unit-testable — no scanner, no naive regex. refresh_apply // refines with the scanner so a link the draft preserved with different // markup (same target) is not reported as lost. foreach ($inbound_records as $rec) { if (!is_array($rec)) continue; if ((string) ($rec['field'] ?? '') !== '') continue; // ACF field — not at risk $stored = (string) ($rec['stored_html'] ?? ''); if ($stored === '') continue; if (strpos((string) $after, $stored) !== false) continue; // still present verbatim $anchor = trim((string) ($rec['anchor_text'] ?? '')); $url = (string) ($rec['target_url'] ?? ''); $reliable[] = [ 'type' => 'internal_link', 'item' => ($url !== '' ? $url : $stored), // stable ack key 'label' => $anchor !== '' ? 'the internal link on "' . $anchor . '"' : 'an internal link ACB inserted', ]; } // Best-effort: fewer affiliate-looking links than before. $before_aff = self::affiliate_hit_count($before); if ($before_aff > 0 && self::affiliate_hit_count($after) < $before_aff) { $best_effort[] = ['type' => 'affiliate', 'item' => 'affiliate', 'label' => 'one or more links that looked like affiliate links']; } return ['reliable' => $reliable, 'best_effort' => $best_effort]; } private static function detect_cta($html) { $hits = 0; $hits += preg_match_all('/]*>(.*?)<\/a>/isu', $html, $am)) { foreach ($am[1] as $inner) { $t = trim(self::flatten($inner)); if (preg_match('/^(get|download|book|contact|buy|order|sign up|subscribe|start|try|claim|shop)\b/i', $t)) $hits++; } } if ($hits === 0) return null; return ['type' => 'cta', 'text' => 'This post has call-to-action buttons or links. Keep an equivalent call to action.']; } private static function detect_ads($html) { $hits = 0; $hits += preg_match_all('/]*\badsbygoogle\b/i', $html, $x); $hits += preg_match_all('/class=["\'][^"\']*\b(adsbygoogle|advertisement|ad-unit|ad-slot|ad-container)\b[^"\']*["\']/i', $html, $y); $hits += preg_match_all('/\[(adrotate|adsense|ads|advert)[^\]]*\]/i', $html, $z); if ($hits === 0) return null; return ['type' => 'ads', 'text' => 'This post appears to contain ad units. Keep room for them where they were.']; } /** * The text of the nearest

      –

      that ENDS before byte offset $pos. * Used to describe where a table or process sits. '' when none precedes. */ private static function nearest_preceding_heading($html, $pos) { if (!preg_match_all('/]*>(.*?)<\/h\1>/isu', (string) $html, $m, PREG_OFFSET_CAPTURE)) return ''; $best = ''; foreach ($m[0] as $idx => $whole) { $end = $whole[1] + strlen($whole[0]); if ($end <= $pos) $best = trim(self::flatten($m[2][$idx][0])); else break; // matches are in document order } return $best; } /** * True when $word (its exact, capitalised form) appears as a * non-sentence-initial token in $bodyText. That is the tell of a genuine * proper noun: "the Crafter is..." keeps its capital mid-sentence, whereas * a title-cased common noun ("business use") is lowercase in prose. The * first token of each sentence is skipped because sentence-initial capitals * carry no signal. */ private static function entity_seen_in_prose($word, $bodyText) { if ($bodyText === '') return false; foreach (preg_split('/(?<=[.!?])\s+/u', $bodyText) as $sentence) { if (!preg_match_all('/[\p{L}][\p{L}\p{N}\'’\-]*/u', $sentence, $tm)) continue; $toks = $tm[0]; for ($k = 1, $n = count($toks); $k < $n; $k++) { if ($toks[$k] === $word) return true; } } return false; } /** Unicode-aware lowercase, degrading to strtolower where mbstring is * absent — the same defensive pattern the link scanner uses for mb_*. */ private static function lc($s) { return function_exists('mb_strtolower') ? mb_strtolower((string) $s, 'UTF-8') : strtolower((string) $s); } /** Tags and entities out, whitespace collapsed — visible text only. */ private static function flatten($html) { $t = preg_replace('/<(script|style)\b[^>]*>.*?<\/\1>/isu', ' ', (string) $html); $t = preg_replace('/<[^>]*>/su', ' ', (string) $t); $t = html_entity_decode($t, ENT_QUOTES, 'UTF-8'); return trim(preg_replace('/\s+/u', ' ', $t)); } /* ===================================================================== * WORDPRESS-FACING GATHERER — the only part that touches WordPress. * Not exercised by the unit tests; requires a live install. * ===================================================================== */ /** * Build the full brief payload for one post. READ-ONLY. Returns an array * shaped for the Refresh brief modal, or a WP_Error on a bad target. * * The returned payload carries both: * - flat brief fields ready to drop into newEntry (title, primary_keyword, * target_word_count, content_type, content_type_meta, special_instructions, * anchor_variants, image_prompt, include_image, suggest_images, publish_date, * post_type), and * - the structured extras the modal renders: notable{reliable,best_effort}, * anchor_variants_detail, sources{} (field => provenance label), and any * degrade notice. */ public static function extract($post_id) { $post_id = (int) $post_id; $post = get_post($post_id); if (!$post) { return new WP_Error('aicobr_refresh_no_post', 'Post not found.'); } $content = (string) $post->post_content; $slug = (string) $post->post_name; $sources = []; // --- keyword, in the spec's priority order ---------------------- $primary_keyword = ''; $meta_keys = [ '_yoast_wpseo_focuskw' => 'from Yoast', 'rank_math_focus_keyword' => 'from Rank Math', '_seopress_analysis_target_kw' => 'from SEOPress', ]; foreach ($meta_keys as $key => $label) { $val = trim((string) get_post_meta($post_id, $key, true)); if ($val !== '') { $primary_keyword = $val; $sources['primary_keyword'] = $label; break; } } if ($primary_keyword === '') { $primary_keyword = self::keyword_from_slug($slug); if ($primary_keyword !== '') $sources['primary_keyword'] = 'from the slug'; } if ($primary_keyword === '') { $primary_keyword = self::keyword_from_headings($content); if ($primary_keyword !== '') $sources['primary_keyword'] = 'from your headings'; } // --- type, length, structure ------------------------------------ $content_type = self::infer_content_type($post->post_title, $slug, $content, $post->post_type); $ctype_meta = self::content_type_meta($content_type, $content); $current_words = self::word_count($content); $target_words = self::target_word_count($current_words, $content_type); $sources['target_word_count'] = "from the current post ($current_words words)"; $sources['content_type'] = 'inferred — please confirm'; // --- notable elements ------------------------------------------- $registered = isset($GLOBALS['shortcode_tags']) && is_array($GLOBALS['shortcode_tags']) ? array_keys($GLOBALS['shortcode_tags']) : []; $notable = self::notable_elements($content, $registered); $special_instructions = implode("\n", array_map( fn($i) => $i['text'], array_merge($notable['reliable'], $notable['best_effort']) )); // --- anchor variants (its own query; §4) ------------------------ $permalink = get_permalink($post_id); $variants = self::anchor_variants($post_id, $permalink); if (!empty($variants['list'])) { $sources['anchor_variants'] = 'from ' . count($variants['detail']) . ' phrasings other posts use to link here'; } // --- image intent ----------------------------------------------- $thumb_id = (int) get_post_thumbnail_id($post_id); $has_thumb = $thumb_id > 0; $image_prompt = ''; if ($has_thumb) { $alt = trim((string) get_post_meta($thumb_id, '_wp_attachment_image_alt', true)); $att = get_post($thumb_id); $caption = $att ? trim((string) $att->post_excerpt) : ''; $title = $att ? trim((string) $att->post_title) : ''; $image_prompt = $alt !== '' ? $alt : ($caption !== '' ? $caption : $title); $sources['image_prompt'] = $alt !== '' ? 'from the featured image alt text' : 'from the featured image'; } $body_images = preg_match_all('/ 0 ? ($body_images < ($current_words / 500)) : false; // --- page-builder degrade notice -------------------------------- $notice = self::page_builder_notice($post_id); return [ // flat brief fields (map straight onto newEntry) 'title' => (string) $post->post_title, 'primary_keyword' => $primary_keyword, 'target_word_count' => $target_words, 'content_type' => $content_type, 'content_type_meta' => $ctype_meta, 'special_instructions' => $special_instructions, 'anchor_variants' => $variants['list'], 'image_prompt' => $image_prompt, 'include_image' => !$has_thumb, // don't replace an image the user chose 'suggest_images' => (bool) $suggest, 'publish_date' => current_time('Y-m-d'), // the refresh is new work 'post_type' => (string) $post->post_type, // structured extras for the modal 'notable' => $notable, 'anchor_variants_detail' => $variants['detail'], 'sources' => $sources, 'degrade_notice' => $notice, // tie-back seed (§6) — the modal stores these on save 'refresh_of_post_id' => $post_id, 'refresh_of_title' => (string) $post->post_title, ]; } /** * Anchor variants for one post: the phrasings other posts on the site use * when they link here. One query, one post — cheap by design (§4.1). * * Reuses AICOBR_Link_Scanner::anchor_texts_to() so real anchors are found * by the scanner's tag-pairing, not by naive regex — the trap the * scanner exists to avoid. */ public static function anchor_variants($post_id, $permalink) { global $wpdb; $permalink = (string) $permalink; if ($permalink === '' || !isset($wpdb)) return ['detail' => [], 'list' => []]; // Match on the path fragment so http/https and domain changes survive. $path = parse_url($permalink, PHP_URL_PATH); $frag = $path ?: $permalink; $types = self::enabled_post_types(); $ph = implode(',', array_fill(0, count($types), '%s')); $like = '%' . $wpdb->esc_like($frag) . '%'; // phpcs:ignore WordPress.DB.PreparedSQL -- $ph is placeholders only. $sql = "SELECT ID, post_title, post_content FROM {$wpdb->posts} WHERE post_status = 'publish' AND post_type IN ($ph) AND ID <> %d AND post_content LIKE %s"; $args = array_merge($types, [$post_id, $like]); $rows = $wpdb->get_results($wpdb->prepare($sql, $args)); $collected = []; if ($rows && class_exists('AICOBR_Link_Scanner')) { foreach ($rows as $r) { foreach (AICOBR_Link_Scanner::anchor_texts_to($r->post_content, $permalink) as $text) { $collected[] = ['text' => $text, 'source_id' => (int) $r->ID, 'source_title' => (string) $r->post_title]; } } } return self::anchor_variants_from($collected); } /** * Enabled inbound post types, mirroring the main plugin's resolution but * degrading to ['post','page'] if the filter is unavailable in this context. */ private static function enabled_post_types() { $types = apply_filters('aicobr_inbound_link_post_types', null); if (is_array($types) && $types) return array_values(array_unique(array_map('strval', $types))); return ['post', 'page']; } /** * Page-builder degrade notice (§3.4). Mirrors the meta-key list in the main * plugin's aicobr_is_page_builder_post(); nothing is blocked, extraction * just returns thin results and says so. */ private static function page_builder_notice($post_id) { $keys = ['_elementor_data', '_elementor_edit_mode', '_fl_builder_data', '_et_pb_use_builder', 'panels_data', '_wpb_vc_js_status']; foreach ($keys as $k) { $v = get_post_meta($post_id, $k, true); if (!empty($v) && $v !== 'off' && $v !== 'false') { return "Most of this post's content is stored in a page builder, so there wasn't much for ACB to read. " . "The title, keyword and metadata came through; the body did not. Fill in anything important by hand."; } } return ''; } } }