*/ public static function phrases_for($entry, $target_title = '') { $out = []; $seen = []; $add = function ($phrase, $reason, $target = null) use (&$out, &$seen) { $phrase = trim(preg_replace('/\s+/u', ' ', (string) $phrase)); if ($phrase === '') return; $key = self::lower($phrase); if (isset($seen[$key])) return; $seen[$key] = true; $out[] = ['phrase' => $phrase, 'reason' => $reason, 'target' => $target]; }; // Spoke's own keywords → link points at the spoke (default target). $add($entry['primary_keyword'] ?? '', 'primary_keyword'); $add($entry['seo_focus_keyword'] ?? '', 'focus_keyword'); // AICOBR_ANCHOR_VARIANTS_2026_08 // Extra spoke phrasings → also link to the spoke. Emitted BEFORE the // pillar so spoke-before-pillar holds. Same >= 2-word guard as the // pillar: a one-word variant ("van") would match the whole library. $variants = $entry['anchor_variants'] ?? []; if (is_array($variants)) { foreach ($variants as $variant) { $v = trim(preg_replace('/\s+/u', ' ', (string) $variant)); $vw = count(preg_split('/\s+/u', $v, -1, PREG_SPLIT_NO_EMPTY)); if ($vw >= 2) $add($v, 'anchor_variant'); } } // Cluster pillar phrase → link points at the PILLAR page. $pillar = $entry['cluster_pillar'] ?? null; if (is_array($pillar)) { $pillar_title = (string) ($pillar['title'] ?? ''); $pillar_pid = (int) ($pillar['post_id'] ?? 0); $pillar_url = (string) ($pillar['url'] ?? ''); // >= 2 words: a single-word pillar ("Vans") would match the whole // library and bury the good anchors under nonsense. $words = count(preg_split('/\s+/u', trim($pillar_title), -1, PREG_SPLIT_NO_EMPTY)); if ($pillar_pid > 0 && $pillar_url !== '' && $words >= 2) { $add($pillar_title, 'cluster_pillar', [ 'post_id' => $pillar_pid, 'url' => $pillar_url, 'title' => $pillar_title, ]); } } return $out; } /** * AICOBR_NAME_GUARD_2026_08 * * From a pool of published names, the ones that a phrase would SPLIT: names * strictly longer than the phrase that begin with it on a word boundary. * * "ford transit" against a catalogue containing "ford transit custom review" * and "ford transit courier" yields both; "ford transit van" yields neither, * because no published name extends it. Equal names are excluded — a phrase * that IS a name is not splitting anything. * * @return string[] normalised, de-duplicated */ /** * AICOBR_NAME_GUARD_SEGMENTS_2026_08 * * Split a title into the parts that can independently name something. * * Real titles bury the product behind a prefix — "Top 10 Vans of 2026 – Ford * Transit Custom (No.1)", "Van Roundup | Ford Transit Connect Review". Testing * only the whole string means the guard never learns those names, which on a * site that titles its posts this way is most of them. * * Splitting on SEPARATORS, rather than looking for the phrase anywhere in the * title, is what keeps this safe. A separator marks a real boundary, so a * segment beginning with the phrase is name-shaped. Matching mid-segment would * turn ordinary prose titles into names — "Why the Ford Transit still wins in * 2026" would yield a "ford transit still wins…" extender and start refusing * legitimate links, invisibly. That trade is deliberate: this catches the * common structured title and declines to guess at free prose. * * @return string[] the whole string, plus each separated segment */ public static function name_segments($s) { $s = (string) $s; $out = [$s]; foreach (preg_split('/[:|(),\/•\x{2013}\x{2014}]|\s+-\s+/u', $s, -1, PREG_SPLIT_NO_EMPTY) ?: [] as $seg) { $seg = trim($seg); if ($seg !== '' && $seg !== $s) $out[] = $seg; } return $out; } public static function extenders($phrase, array $names) { // Determiner-tolerant: a variant is written "the Ford Transit" while every // catalogue name reads "Ford Transit Custom". Testing only the literal form // derives no extenders at all and the guard quietly passes every split. $keys = []; foreach ([AICOBR_Link_Scanner::name_key($phrase), AICOBR_Link_Scanner::core_key($phrase)] as $k) { if ($k !== '' && !in_array($k, $keys, true)) $keys[] = $k; } if (!$keys) return []; $out = []; foreach ($names as $n) { foreach (self::name_segments($n) as $candidate) { $nk = AICOBR_Link_Scanner::name_key($candidate); if ($nk === '') continue; foreach ($keys as $key) { if ($nk === $key) continue; // Determiner-tolerant on the SEGMENT too: "The Ford Transit // Courier" as a segment must register just as "Ford Transit // Courier" does. $ck = AICOBR_Link_Scanner::core_key($candidate); if (strpos($nk, $key . ' ') === 0) { $out[$nk] = true; break; } if ($ck !== $nk && $ck !== $key && strpos($ck, $key . ' ') === 0) { $out[$ck] = true; break; } } } } return array_keys($out); } /** * Names this site publishes, for the guard to check against. * * Two sources, both already the user's own declarations — no inference: * - diary entries: primary keyword, focus keyphrase and title. Free; the * option is a single row and usually already in the object cache. * - published post TITLES containing " ". This is what catches a * longer product that was never an ACB brief — a hand-written "Ford * Transit Courier review" from before the plugin existed. Without it the * guard would only protect names ACB itself planned, which is exactly * backwards: the old catalogue is the part most likely to hold them. * * Posts belonging to the link's destination are excluded, so a phrase is * never guarded against the very page it points at ("ford transit" must stay * linkable into the Ford Transit review whose own title extends it). * * Swappable for tests, like fetch(). */ public static $name_provider = null; public static function catalogue_names(array $phrases, array $exclude_ids = []) { if (is_callable(static::$name_provider)) { return (array) call_user_func(static::$name_provider, $phrases, $exclude_ids); } if (!function_exists('get_option')) return []; $exclude = array_map('intval', $exclude_ids) ?: [0]; $names = []; foreach ((array) get_option('aicobr_entries', []) as $e) { if (in_array((int) ($e['post_id'] ?? 0), $exclude, true)) continue; foreach (['primary_keyword', 'seo_focus_keyword', 'title'] as $k) { if (!empty($e[$k])) $names[] = (string) $e[$k]; } } global $wpdb; if (!isset($wpdb)) return $names; // One query for every phrase. " " with a trailing space: only a // title that CONTINUES past the phrase can extend it, so a title equal to // the phrase is correctly not fetched. $where = []; $vals = []; $seen_like = []; foreach ($phrases as $p) { // Both forms again: a title saying "Ford Transit Courier" must be found // from the variant "the Ford Transit", which it does not contain. foreach ([trim((string) $p), preg_replace('/^(?:the|a|an)\s+/i', '', trim((string) $p))] as $form) { if ($form === '' || isset($seen_like[$form])) continue; $seen_like[$form] = true; $where[] = 'post_title LIKE %s'; $vals[] = '%' . $wpdb->esc_like($form) . ' %'; } } if (!$where) return $names; $excl_ph = implode(',', array_fill(0, count($exclude), '%d')); $sql = $wpdb->prepare( "SELECT post_title FROM {$wpdb->posts} WHERE post_status = 'publish' AND ID NOT IN ($excl_ph) AND (" . implode(' OR ', $where) . ") LIMIT 200", array_merge($exclude, $vals) ); foreach ((array) $wpdb->get_col($sql) as $t) { $names[] = html_entity_decode((string) $t, ENT_QUOTES, 'UTF-8'); } /** * AICOBR_NAME_GUARD_SEGMENTS_2026_08 * * Names the automatic sources cannot see. Two shapes need this: * - a product this site sells but has never titled a post after; * - a title that names the product in free prose, "Reviewing the Ford * Transit Courier", where no separator marks the name. Deriving those * automatically would mean treating any words after the phrase as a * name, which turns "Why the Ford Transit still wins" into a guard * that suppresses real links. Declared beats guessed. * * add_filter('aicobr_inbound_guard_names', fn($n) => array_merge($n, [ * 'Ford Transit Courier', 'Ford Transit 350', * ])); */ if (function_exists('apply_filters')) { $names = (array) apply_filters('aicobr_inbound_guard_names', $names, $phrases, $exclude_ids); } return $names; } /** * The single token to pre-filter on in SQL. * * NOT the whole phrase. The scanner deliberately matches across whitespace * runs and  , so stored content may hold "sell my van" or * "sell my\nvan" — a SQL LIKE on the literal phrase would miss both and the * candidate would never reach the scanner. Filtering on the longest single * WORD cannot produce a false negative, because any occurrence of the full * phrase necessarily contains that word contiguously. * * Longest word is a proxy for selectivity: "van" appears everywhere on a van * site, "valuation" does not. It costs a wider fetch in the worst case, but * the scanner runs in ~0.5 ms per post, so a loose pre-filter is cheap and a * missed candidate is not recoverable. */ /** * Lower-case, mbstring-tolerant. * * acb-link-scanner.php guards every mb_* call with function_exists because it * must run outside WordPress in the test harness. This file is loaded by the * same harness, so it takes the same posture rather than assuming mbstring. */ private static function lower($s) { return function_exists('mb_strtolower') ? mb_strtolower((string) $s, 'UTF-8') : strtolower((string) $s); } /** * AICOBR_MB_GUARD_2026_08 * * Character length, mbstring-tolerant. Same posture as lower() above, which * this had failed to follow: like_token() called mb_strlen() directly, so on * a host without the mbstring extension it raised a FATAL rather than * degrading. WordPress does not require mbstring, and like_token() is on the * hot path of every inbound scan, so the whole feature died on such a host. * * The fallback is byte length. For choosing the LONGEST word that is a * faithful degradation: it can only differ where a word carries multibyte * characters, and picking a slightly different word changes which token the * SQL pre-filter uses, never whether a phrase can match. Selectivity may * drop; correctness does not. */ private static function len($s) { return function_exists('mb_strlen') ? mb_strlen((string) $s, 'UTF-8') : strlen((string) $s); } public static function like_token($phrase) { $words = preg_split('/\s+/u', trim((string) $phrase), -1, PREG_SPLIT_NO_EMPTY); if (!$words) return ''; usort($words, fn($a, $b) => self::len($b) <=> self::len($a)); return $words[0]; } /** * Build the suggestion list for one target post. * * @param array $entry Diary entry (declared keywords live here). * @param array $target ['post_id'=>int,'url'=>string,'title'=>string, * 'categories'=>int[],'date'=>string] * @param array $opts ['limit'=>int,'post_types'=>string[]] * @return array */ public static function suggestions($entry, $target, $opts = []) { $limit = max(1, (int) ($opts['limit'] ?? self::MAX_PER_TARGET)); $phrases = self::phrases_for($entry, $target['title'] ?? ''); // AICOBR_ABSENT_VS_UNSAFE_2026_08 // phrase_absent and no_safe_range were one counter, which made the review // UI tell users "the phrase is only ever in headings" when in fact the // phrase was nowhere in the post. The two need opposite fixes — change the // anchor phrase, versus accept the safety guard — so they are counted // apart. `scanned` is candidates returned by the SQL pre-filter (posts // containing the phrase's longest WORD), not posts that contain the // phrase; phrase_absent is the difference between the two. $skipped = [ 'already_links' => 0, 'page_builder' => 0, 'phrase_absent' => 0, 'no_safe_range' => 0, // AICOBR_NAME_GUARD_2026_08 — the phrase is there, in prose, in a safe // spot, and was refused ONLY because linking it would have split a // longer product this site publishes. 'name_collision' => 0, 'not_editable' => 0, ]; if (!$phrases) { return ['suggestions' => [], 'scanned' => 0, 'skipped' => $skipped, 'reason' => 'no_keywords']; } // Fetch once per distinct token rather than once per phrase — two // phrases often share their longest word. $tokens = []; foreach ($phrases as $p) { $t = self::like_token($p['phrase']); if ($t !== '') $tokens[self::lower($t)] = $t; } // Exclude the spoke itself, and any pillar a phrase points at, from the // source pool — a page must never be proposed as a source of a link to // itself. $spoke_id = (int) $target['post_id']; $exclude = [$spoke_id]; foreach ($phrases as $p) { $pid = (int) ($p['target']['post_id'] ?? 0); if ($pid && !in_array($pid, $exclude, true)) $exclude[] = $pid; } // AICOBR_NAME_GUARD_2026_08 — computed ONCE per scan, not per row: the // catalogue is a property of the site, not of the post being examined. $catalogue = static::catalogue_names(array_column($phrases, 'phrase'), $exclude); $extenders = []; foreach ($phrases as $i => $p) { $extenders[$i] = self::extenders($p['phrase'], $catalogue); } // AICOBR_CANDIDATE_CAP_2026_08 // The SQL pre-filter takes at most MAX_CANDIDATES rows PER TOKEN, oldest // first. On a large catalogue a common token ("van", "transit") matches // far more than that, so the scan silently saw a slice and every counter // downstream reported it as the whole truth. // // That is the one number in this feature that must not be quietly wrong: // the counters reconciling is the promise everything else rests on. So // truncation is now DETECTED and REPORTED rather than hidden. It is not // silently "fixed" by raising the ceiling to infinity either, because the // ceiling is what stops a two-word phrase dragging the whole posts table // into memory. // // Asking for one row more than the ceiling is how we tell "exactly at the // limit" apart from "there were more". The extra row is discarded. $rows = []; $truncated = []; foreach ($tokens as $token) { $found = static::fetch($token, $exclude, self::MAX_CANDIDATES + 1, $opts); if (count($found) > self::MAX_CANDIDATES) { $truncated[] = $token; $found = array_slice($found, 0, self::MAX_CANDIDATES); } foreach ($found as $r) { $rows[(int) $r['ID']] = $r; // dedupe across tokens } } $scanned = count($rows); $suggestions = []; // AICOBR_LINK_FORECAST_2026_08 // Forecast mode: the entry has no generated post yet, so there is no // target URL to check against and no post to exclude from the source // pool. Everything else (scanner, catalogue, name guard, skip reasons) // is identical, which is what makes the forecast an exact prediction of // this method's own later behaviour rather than an estimate. $forecast = !empty($opts['forecast']); foreach ($rows as $row) { $source_id = (int) $row['ID']; $content = (string) $row['post_content']; if (!static::can_edit($source_id)) { $skipped['not_editable']++; continue; } if (static::is_page_builder($source_id)) { $skipped['page_builder']++; continue; } // AICOBR_LINK_FORECAST_2026_08 — in forecast mode the spoke has no post // and no URL, so nothing can already link to it. Running the check would // compare against an empty URL, which links_to() would match against // every href on the page and zero the forecast. if (!$forecast && AICOBR_Link_Scanner::links_to($content, $target['url'])) { $skipped['already_links']++; continue; } // First phrase, in priority order, that yields a SAFE match. $hit = null; $used = null; foreach ($phrases as $i => $p) { $m = AICOBR_Link_Scanner::find_matches($content, $p['phrase'], 1, $extenders[$i]); if ($m) { $hit = $m[0]; $used = $p; break; } } if (!$hit) { // Three very different misses, reported apart because they call for // three different responses: rewrite the phrase, accept the safety // guard, or accept that the phrase belongs to a longer product. $unguarded = false; $present = false; foreach ($phrases as $i => $p) { if (!$present && AICOBR_Link_Scanner::occurs($content, $p['phrase'])) $present = true; if (!$unguarded && $extenders[$i] && AICOBR_Link_Scanner::find_matches($content, $p['phrase'], 1)) { $unguarded = true; } } if ($unguarded) $skipped['name_collision']++; elseif ($present) $skipped['no_safe_range']++; else $skipped['phrase_absent']++; continue; } // Where this specific link points: the matched phrase may target the // pillar instead of the spoke. $dest = (!empty($used['target']) && is_array($used['target'])) ? $used['target'] : [ 'post_id' => $spoke_id, 'url' => (string) $target['url'], 'title' => (string) ($target['title'] ?? ''), ]; // A source already linking to THIS destination is no opportunity. // The pre-loop guard above only covered the spoke url; a pillar // destination needs its own check. if ((int) $dest['post_id'] !== $spoke_id && AICOBR_Link_Scanner::links_to($content, (string) $dest['url'])) { $skipped['already_links']++; continue; } // AICOBR_ACF_WRITE_2026_08 — which body this match lives in. A match // offset beyond post_content falls inside an appended ACF field; map // it back to that field so insertion writes to the right place. Empty // string means post_content, the default everywhere. $match_field = ''; if (!empty($row['_acb_field_spans'])) { foreach ($row['_acb_field_spans'] as $span) { if ($hit['start'] >= $span['start'] && $hit['start'] < $span['end']) { $match_field = $span['field']; break; } } } // AICOBR_ACF_WRITE_2026_08 // For an ACF-field match, the insert route re-scans the FIELD'S OWN // value, not this combined blob. So the context fingerprint and the // excerpt must be taken against that field's value at its own offset, // or the immediate-before window would bleed in text from whatever // preceded the field in the blob and the re-find could drift. $ctx_body = $content; $ctx_start = $hit['start']; $ctx_end = $hit['end']; if ($match_field !== '' && !empty($row['_acb_field_spans'])) { foreach ($row['_acb_field_spans'] as $span) { if ($span['field'] === $match_field) { $ctx_body = substr($content, $span['start'], $span['end'] - $span['start']); $ctx_start = $hit['start'] - $span['start']; $ctx_end = $hit['end'] - $span['start']; break; } } } $suggestions[] = [ 'suggestion_id' => 'in_' . substr(md5($source_id . '|' . $dest['post_id'] . '|' . $hit['start']), 0, 8), 'source_post_id' => $source_id, 'source_title' => (string) $row['post_title'], 'source_date' => (string) $row['post_date'], 'anchor' => $hit['text'], 'reason' => $used['reason'], 'target_post_id' => (int) $dest['post_id'], 'target_url' => (string) $dest['url'], 'target_title' => (string) $dest['title'], 'field' => $match_field, 'start' => $hit['start'], 'end' => $hit['end'], 'excerpt' => AICOBR_Link_Scanner::excerpt($ctx_body, $ctx_start, $ctx_end, self::EXCERPT_PAD), // AICOBR_RESCAN_CONTEXT_2026_08 — the fingerprint the insert route // uses to re-find THIS occurrence in live content. Produced here, // consumed by AICOBR_Link_Scanner::find_reviewed_occurrence(). 'context' => AICOBR_Link_Scanner::occurrence_context($ctx_body, $ctx_start, $ctx_end), '_rank' => static::rank($row, $target), ]; } // Rank descending, then cap. MAX_PER_SOURCE is implicit: one suggestion // is produced per source post because find_matches is called with a // limit of 1 and the loop breaks on the first phrase that hits. usort($suggestions, fn($a, $b) => $b['_rank'] <=> $a['_rank']); // AICOBR_INBOUND_FOUND_2026_08 // How many opportunities EXIST, before the display cap. The review UI // prints "N scanned · N opportunities · N no safe spot"; without the // pre-cap figure those numbers silently stop adding up the moment a // catalogue yields more than `limit`, and the user is left to wonder // where the missing posts went. Reported separately so the UI can say // "showing 20 of 34" rather than quietly truncating. $found = count($suggestions); // AICOBR_LINK_FORECAST_2026_08 // Pre-cap count PER DESTINATION. The forecast must never merge the two: // a cluster's pillar phrase is identical for every spoke in that cluster, // so folding pillar matches into each brief's number would make all // twelve briefs look healthy and the zero state, which is the whole // reason the forecast exists, would never appear again. A pillar match // does not rescue a spoke orphan because the link points somewhere else. // // Spoke-before-pillar priority means these are already disjoint: a source // that can host a spoke link is counted against the spoke, so the pillar // figure is exactly the sources that could ONLY reach the pillar. $found_by_target = []; foreach ($suggestions as $s) { $k = (int) $s['target_post_id']; $found_by_target[$k] = ($found_by_target[$k] ?? 0) + 1; } $suggestions = array_slice($suggestions, 0, $limit); foreach ($suggestions as &$s) unset($s['_rank']); unset($s); return [ 'suggestions' => $suggestions, 'scanned' => $scanned, // AICOBR_CANDIDATE_CAP_2026_08 // True when the SQL pre-filter hit its ceiling, so `scanned` is a // floor rather than a total and every count derived from it is a // lower bound. Reported so the UI can say "200+" instead of // presenting a slice as the whole catalogue. 'truncated' => !empty($truncated), 'truncated_tokens' => array_values($truncated), 'candidate_cap' => self::MAX_CANDIDATES, 'skipped' => $skipped, 'found' => $found, // AICOBR_LINK_FORECAST_2026_08 — destination post_id => pre-cap count. 'found_by_target' => $found_by_target, 'limit' => $limit, // AICOBR_INBOUND_FOUND_2026_08 // Exactly what was searched for. A zero-match result is only // actionable if the user can see the phrases that missed — that is // what turns "no opportunities" into "my declared keyword is a // long-tail phrase nothing else on the site says". It also surfaces a // bad anchor variant at review time, which is the one place a // hand-typed variant can be caught (the plugin sees one entry at a // time and cannot scrub it against siblings the way the server does). 'phrases' => array_map( fn($p) => ['phrase' => $p['phrase'], 'reason' => $p['reason']], $phrases ), ]; } /** * AICOBR_LINK_FORECAST_2026_08 * * How connected a brief WILL be, before it is written. * * Link Whisper and LinkBoss arrive at a finished site, so they can only look * backwards at content that already exists. ACB holds dated briefs carrying * declared keywords weeks before a word is written, which is data no * bolted-on linker has or can obtain. This method spends it: it answers * "when this brief is published, how many posts will be able to link to it, * and which are the strongest?" * * It is suggestions() with no target post. The scanner, the catalogue query, * the name guard and every skip reason are unchanged, so the forecast is an * exact prediction of this class's own later behaviour rather than an * estimate. That exactness is the whole claim, so nothing here may guess. * * Returns the suggestions shape plus: * spoke_sources int posts that could link to the brief itself * pillar_sources int posts that could ONLY reach the cluster pillar * pillar ?array the pillar destination, when one applies * * The two counts are reported apart deliberately. See found_by_target. * * @param array $entry Diary entry with no generated post yet. * @param array $opts ['limit'=>int (named examples to return), 'post_types'=>string[]] */ public static function forecast($entry, $opts = []) { $pillar = (isset($entry['cluster_pillar']) && is_array($entry['cluster_pillar'])) ? $entry['cluster_pillar'] : null; // A synthetic target. post_id 0 is what marks "no post yet": it cannot // collide with a real ID, it excludes nothing from the source pool, and // it is the key spoke-destined suggestions are counted under. $target = [ 'post_id' => 0, 'url' => '', 'title' => (string) ($entry['title'] ?? ''), 'date' => (string) ($entry['publish_date'] ?? $entry['date'] ?? ''), 'categories' => [], ]; $result = static::suggestions($entry, $target, array_merge($opts, ['forecast' => true])); if (!empty($result['reason'])) { // no_keywords $result['spoke_sources'] = 0; $result['pillar_sources'] = 0; $result['pillar'] = null; return $result; } $by = (array) ($result['found_by_target'] ?? []); $pillar_id = $pillar ? (int) ($pillar['post_id'] ?? 0) : 0; $result['spoke_sources'] = (int) ($by[0] ?? 0); $result['pillar_sources'] = $pillar_id ? (int) ($by[$pillar_id] ?? 0) : 0; $result['pillar'] = $pillar_id ? [ 'post_id' => $pillar_id, 'title' => (string) ($pillar['title'] ?? ''), 'url' => (string) ($pillar['url'] ?? ''), // AICOBR_CLUSTER_LINKDOWN_2026_08 // Does the pillar carry [content_cluster]? If it does it lists each // spoke as that spoke publishes, so the spoke receives an inbound // link from the pillar and is NOT an orphan, whatever the catalogue // says. Calling it one anyway would be a false claim, and this // feature's only asset is that its statements are exact. 'lists_spokes' => !empty($pillar['lists_spokes']), ] : null; $result['forecast'] = true; return $result; } /** * Editorial ranking score. * * +100 shares a category or tag with the target — the strongest available * signal that the two posts genuinely belong together, and the same * rule Link Whisper exposes as "only link within a post's category". * + 50 published BEFORE the target: an older post has accrued more * authority and more existing inbound links, so a link from it * carries more weight than one from a page published yesterday. * + 0..25 length. A passing mention inside a 3,000-word article is a * better host than the same phrase in a 400-word note, where one * outbound link is a large share of the page. */ public static function rank($row, $target) { $score = 0; $srcCats = (array) ($row['categories'] ?? []); $tgtCats = (array) ($target['categories'] ?? []); if ($srcCats && $tgtCats && array_intersect($srcCats, $tgtCats)) $score += 100; $srcDate = strtotime((string) ($row['post_date'] ?? '')) ?: 0; $tgtDate = strtotime((string) ($target['date'] ?? '')) ?: 0; if ($srcDate && $tgtDate && $srcDate < $tgtDate) $score += 50; $words = str_word_count(strip_tags((string) ($row['post_content'] ?? ''))); $score += (int) min(25, floor($words / 200)); return $score; } // ── Swappable boundaries ──────────────────────────────────────────────── // Everything below touches WordPress. Overridden wholesale in tests. protected static function fetch($token, $exclude_ids, $limit, $opts = []) { if (is_callable(static::$repository)) { return (array) call_user_func(static::$repository, $token, $exclude_ids, $limit, $opts); } global $wpdb; $types = $opts['post_types'] ?? ['post']; $types = array_values(array_filter(array_map('sanitize_key', (array) $types))); if (!$types) $types = ['post']; $type_ph = implode(',', array_fill(0, count($types), '%s')); $excl = array_map('intval', (array) $exclude_ids) ?: [0]; $excl_ph = implode(',', array_fill(0, count($excl), '%d')); // esc_like then wildcard — a keyword containing % or _ would otherwise // become a wildcard and drag in the whole table. $like = '%' . $wpdb->esc_like((string) $token) . '%'; // AICOBR_ACF_CONTENT_FIELDS_2026_08 // The SQL pre-filter is "post_content LIKE %token%", which silently // excludes any post whose body text lives in ACF fields rather than // post_content: theirs is empty, so it never matches, and the post is // gone before the scanner ever reads it. When ACF content fields are // configured, widen the pre-filter to also admit posts of those types // whose MATCHING meta value contains the token. The postmeta join is // scoped to the configured meta keys and the configured types, so it // does not drag in unrelated meta. $acf_map = $opts['acf_content_fields'] ?? []; $acf_join = ''; $acf_clause = ''; // AICOBR_ACF_JOIN_PLACEHOLDER_2026_08 // Split into JOIN values and WHERE values, because they sit in different // places in the SQL string and prepare() binds strictly by POSITION. // // The bug this fixes: both were previously passed as one $acf_vals array // in the WHERE position, while the JOIN's own %s placeholders appear // FIRST in the query. Every value after them shifted by one, so // meta_key received a post type, post_type received the exclude id, // ID NOT IN received a LIKE string cast to 0, and post_content was // compared against a meta KEY name. The result was zero rows for every // scan on any site with acf_content_fields configured -- the feature // silently disabled the whole inbound scan, including plain posts. $acf_join_vals = []; // bound inside the JOIN (earliest in the SQL) $acf_clause_vals = []; // bound inside the WHERE (after post_content) if (is_array($acf_map) && $acf_map) { $meta_keys = []; foreach ($acf_map as $pt => $fields) { if (!in_array($pt, $types, true)) continue; // only scanned types foreach ((array) $fields as $f) $meta_keys[(string) $f] = true; } $meta_keys = array_keys($meta_keys); if ($meta_keys) { $mk_ph = implode(',', array_fill(0, count($meta_keys), '%s')); $acf_join = " LEFT JOIN {$wpdb->postmeta} acf ON acf.post_id = {$wpdb->posts}.ID AND acf.meta_key IN ($mk_ph)"; $acf_clause = " OR acf.meta_value LIKE %s"; $acf_join_vals = $meta_keys; $acf_clause_vals = [$like]; } } $sql = $wpdb->prepare( "SELECT DISTINCT {$wpdb->posts}.ID, post_title, post_content, post_date, post_type FROM {$wpdb->posts}{$acf_join} WHERE post_status = 'publish' AND post_type IN ($type_ph) AND {$wpdb->posts}.ID NOT IN ($excl_ph) AND (post_content LIKE %s{$acf_clause}) ORDER BY post_date ASC LIMIT %d", // Order MUST mirror the placeholder order in the string above: // JOIN meta keys, post types, excludes, post_content LIKE, // acf.meta_value LIKE, LIMIT. array_merge($acf_join_vals, $types, $excl, [$like], $acf_clause_vals, [(int) $limit]) ); $rows = $wpdb->get_results($sql, ARRAY_A) ?: []; foreach ($rows as &$r) { // AICOBR_ACF_CONTENT_FIELDS_2026_08 — fold the configured ACF text // into post_content so every downstream step (matching, the name // guard, insertion's own re-scan) sees one combined body. Appended, // not replaced: a post may legitimately have both. if (is_array($acf_map) && !empty($acf_map[$r['post_type']])) { // AICOBR_ACF_WRITE_2026_08 — record, per configured field, the // text and the offset at which it was appended, so a match found // in the combined body can be attributed back to the exact field // it belongs to. Insertion needs this: it writes into one field, // not the concatenation. post_content occupies [0, base_len); each // field occupies the span recorded here. $base = (string) $r['post_content']; $r['_acb_field_spans'] = []; $cursor = strlen($base); foreach ((array) $acf_map[$r['post_type']] as $fname) { $ftext = self::acf_text_for((int) $r['ID'], (string) $r['post_type'], [$r['post_type'] => [$fname]]); if ($ftext === '') continue; $sep = "\n\n"; $start = $cursor + strlen($sep); $r['post_content'] = ($cursor === strlen($base) && $base === '') ? $ftext // nothing before it : (string) $r['post_content'] . $sep . $ftext; // Recompute start honestly against the growing string. $start = strlen((string) $r['post_content']) - strlen($ftext); $r['_acb_field_spans'][] = ['field' => (string) $fname, 'start' => $start, 'end' => strlen((string) $r['post_content'])]; $cursor = strlen((string) $r['post_content']); } } $r['categories'] = array_merge( wp_get_post_categories((int) $r['ID']) ?: [], wp_get_post_tags((int) $r['ID'], ['fields' => 'ids']) ?: [] ); } return $rows; } /** * AICOBR_ACF_CONTENT_FIELDS_2026_08 * Text from a post's configured ACF fields. Reads postmeta directly so it * works without ACF loaded, and flattens non-scalar values (repeaters, * groups) to their scalar leaves. */ protected static function acf_text_for($post_id, $post_type, $map) { $fields = $map[$post_type] ?? []; if (!$fields) return ''; $parts = []; $walk = function ($v) use (&$walk, &$parts) { if (is_string($v) || is_numeric($v)) { $s = trim((string) $v); if ($s !== '') $parts[] = $s; } elseif (is_array($v)) { foreach ($v as $x) $walk($x); } }; foreach ($fields as $f) $walk(get_post_meta((int) $post_id, (string) $f, true)); return trim(implode(' ', $parts)); } protected static function can_edit($post_id) { return function_exists('current_user_can') ? current_user_can('edit_post', $post_id) : true; } protected static function is_page_builder($post_id) { if (!function_exists('get_post_meta')) return false; foreach (['_elementor_data', '_elementor_edit_mode', '_fl_builder_data', '_et_pb_use_builder', 'panels_data', '_wpb_vc_js_status'] as $k) { $v = get_post_meta($post_id, $k, true); if (!empty($v) && $v !== 'off' && $v !== 'false') return true; } return false; } } }