PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.1
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.1
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
metasync / otto / otto_pixel.php

otto_pixel.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.7.1, at otto/otto_pixel.php

3,088 lines 143.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * This handles the OTTO SSR
4 */
5 if ( ! defined( 'ABSPATH' ) ) {
6 exit;
7 }
8
9 if (!defined('METASYNC_OTTO_EXCLUDED_TRANSIENT_KEY')) {
10 define('METASYNC_OTTO_EXCLUDED_TRANSIENT_KEY', 'metasync_otto_excluded_urls_manual');
11 }
12 if (!defined('METASYNC_OTTO_EXCLUDED_TRANSIENT_TTL')) {
13 define('METASYNC_OTTO_EXCLUDED_TRANSIENT_TTL', 60);
14 }
15
16 # uses the simple html dom library
17 use simplehtmldom\HtmlDocument;
18
19 # include the otto class file
20 #
21 # The vendor tree is generated content, unlike the plugin files beside it: an
22 # update that copies the plugin directory piece by piece can briefly leave this
23 # file beside an otto/vendor that is missing, half-written or holding a stale
24 # classmap. A bare require on a missing autoloader is a fatal during plugin
25 # bootstrap — the whole site, not just OTTO. Load it only when it is actually
26 # readable, and catch Throwable while doing so: the autoloader is a trampoline
27 # of bare requires into otto/vendor/composer/, so a tree that is present but
28 # still mid-copy (or a truncated autoload.php, which parses only to a
29 # ParseError) would otherwise fatal here too. Either way the parser class
30 # stays unloaded, and the DOM-parser guard in metasync_start_otto() degrades
31 # the request to OTTO-less rendering instead of fataling.
32 $metasync_otto_vendor_autoload = plugin_dir_path( __FILE__ ) . '/vendor/autoload.php';
33 if ( is_readable( $metasync_otto_vendor_autoload ) ) {
34 try {
35 require_once $metasync_otto_vendor_autoload;
36 } catch ( \Throwable $e ) {
37 # Deliberately swallowed: any exception raised while loading generated
38 # autoloader code means this request runs without the DOM parser. The
39 # guard below turns that into one OTTO-less request rather than a
40 # bootstrap fatal on the whole site.
41 }
42 }
43 require_once plugin_dir_path( __FILE__ ) . '/Otto_html_class.php';
44 require_once plugin_dir_path( __FILE__ ) . '/Otto_pixel_class.php';
45 require_once plugin_dir_path( __FILE__ ) . '/metasync-otto-seo-functions.php';
46 require_once plugin_dir_path( __FILE__ ) . '/class-metasync-otto-transient-cache.php';
47 require_once plugin_dir_path( __FILE__ ) . '/class-metasync-otto-job-status.php';
48 require_once plugin_dir_path( __FILE__ ) . '/class-metasync-otto-render-strategy.php';
49 require_once plugin_dir_path( __FILE__ ) . '/class-metasync-otto-config.php';
50 require_once plugin_dir_path( __FILE__ ) . '/class-metasync-otto-bot-detector.php';
51 require_once plugin_dir_path( __FILE__ ) . '/class-metasync-otto-bot-statistics-database.php';
52
53 /**
54 * Is $method actually available on $class in this process right now?
55 *
56 * A partially updated install can leave a newer copy of one plugin file beside
57 * an older copy of another — stale opcache bytecode for a single file is enough.
58 * class_exists() is satisfied by the older copy, and calling a method it does
59 * not declare is a fatal. Everything in this file runs on the front end, so that
60 * fatal is a white screen on every page view rather than a degraded feature.
61 *
62 * Defined here rather than pulled from a shared utility class on purpose: a
63 * helper loaded from another file could itself be the stale one.
64 *
65 * $class and $method are parameters rather than literals at the call site so the
66 * check survives static analysis, which would otherwise narrow a literal
67 * method_exists() on a known class to a constant true — the skew this guards
68 * against exists only at runtime.
69 *
70 * @param string $class Class about to be called.
71 * @param string $method Method about to be called on it.
72 * @return bool
73 */
74 function metasync_otto_class_provides($class, $method) {
75 return class_exists($class) && method_exists($class, $method);
76 }
77
78 # OPTIMIZED: get the metasync options (cached in static class)
79 $metasync_options = Metasync_Otto_Config::get_options();
80
81 # OTTO SSR is always enabled by default
82 $otto_enabled = true;
83
84 # add tag to wp head
85 add_action('wp_head', function(){
86 # load globals
87 global $metasync_options, $otto_enabled;
88
89 # OTTO SSR is always enabled
90 $string_enabled = 'true';
91
92 # OPTIMIZED: check uuid set using cached config
93 if(!Metasync_Otto_Config::is_otto_enabled()){
94 return;
95 }
96
97 # Performance optimization: Add DNS prefetch and preconnect for OTTO API
98 # This improves connection speed by resolving DNS and establishing connections early
99 # Use endpoint manager to get the correct domain
100 $otto_domain = 'sa.searchatlas.com'; # default
101 if (class_exists('Metasync_Endpoint_Manager')) {
102 $otto_api_domain = Metasync_Endpoint_Manager::get_endpoint('OTTO_API_DOMAIN');
103 $parsed = wp_parse_url($otto_api_domain);
104 if (!empty($parsed['host'])) {
105 $otto_domain = $parsed['host'];
106 }
107 }
108 echo '<link rel="dns-prefetch" href="//' . esc_attr($otto_domain) . '">' . "\n";
109 echo '<link rel="preconnect" href="https://' . esc_attr($otto_domain) . '" crossorigin>' . "\n";
110
111 # adding the otto tag to pages
112 $plugin_version = defined('METASYNC_VERSION') ? METASYNC_VERSION : 'unknown';
113 # OPTIMIZED: use cached uuid
114 $otto_tag = '<meta name="otto" content="uuid='.esc_attr(Metasync_Otto_Config::get_otto_uuid()).'; type=wordpress; enabled='.esc_attr($string_enabled).'; version='.esc_attr($plugin_version).'">';
115
116 # out the otto tag
117 echo $otto_tag;
118 }, 1); # Priority 1 to output early in head
119
120 /**
121 * Scheduling seam for the OTTO pipeline.
122 *
123 * wp_schedule_single_event() returns false on failure and the old pipeline
124 * ignored it, so a broken cron table acknowledged URLs that were never
125 * queued. Every schedule in this pipeline goes through this seam so the
126 * return value is honored (and tests can observe scheduling).
127 */
128 function metasync_otto_schedule_single_event($timestamp, $hook, $args = array()) {
129 return wp_schedule_single_event($timestamp, $hook, $args) === true;
130 }
131
132 /**
133 * Dedupe seam: does this URL already have live per-URL work in flight?
134 *
135 * Combines the durable status store with the raw cron table so a re-delivered
136 * webhook cannot stack a second job behind a live one.
137 */
138 function metasync_otto_job_already_scheduled($route) {
139 if (class_exists('Metasync_Otto_Job_Status', false)
140 && Metasync_Otto_Job_Status::has_active($route)) {
141 return true;
142 }
143 return wp_next_scheduled('metasync_process_otto_crawl_url_job', array($route)) !== false;
144 }
145
146 /**
147 * Cron-table dedupe seam: does a per-URL cron event already exist for this URL?
148 *
149 * The pending-queue drainer must dedupe against the scheduler table only.
150 * Every URL it takes off the durable queue carries a fresh webhook
151 * accepted/batch_overflow ledger row; that row exists so a re-delivered
152 * webhook cannot stack a second job behind live work, and consulting it
153 * from the drainer — after take_pending() has already removed the URL
154 * from the queue — silently dropped every overflow URL instead of
155 * draining it. Only a real, still-pending cron event may veto here.
156 */
157 function metasync_otto_job_event_pending($route) {
158 return wp_next_scheduled('metasync_process_otto_crawl_url_job', array($route)) !== false;
159 }
160
161 /**
162 * Start end point to handle requests on page updates
163 * The Otto Crawler will call this end point once a page is updated
164 **/
165
166 # function to register the route
167 function metasync_otto_crawl_notify($request){
168
169 # get request data params
170 $data = $request->get_json_params();
171
172 # fields
173 $fields = ['domain', 'urls'];
174
175 # validate the json request
176 foreach ($fields as $key => $value) {
177
178 # if the value is empty stop tehre
179 if(empty($data[$value])){
180
181 # Handle the POST request
182 return new WP_REST_Response(array(
183 'success' => false,
184 'message' => 'Invalid Field : '. $value,
185 ), 400);
186 }
187
188 }
189
190 # load otto pixel
191 $otto_pixel = new Metasync_otto_pixel(false);
192
193 # save the otto data first (cheap local DB write — kept synchronous)
194 $otto_pixel->save_crawl_data($data);
195
196 # Defer the expensive per-URL work (OTTO API fetch, post meta sync,
197 # per-URL cache purge) to background jobs. The webhook caller enforces a
198 # strict response-time budget, so the response acknowledges SCHEDULING,
199 # never execution. The per-URL status store records what actually
200 # happened to each URL once the jobs run.
201 #
202 # Cap the number of per-URL cron jobs scheduled per webhook batch so a
203 # large crawl cannot overwhelm WP-Cron on shared hosts. URLs past the cap
204 # are queued durably and worked off by a self-rescheduling drainer job —
205 # never silently dropped.
206 $max_jobs_per_batch = defined('METASYNC_MAX_JOBS_PER_BATCH') ? METASYNC_MAX_JOBS_PER_BATCH : 25;
207 $now = time();
208 $routes_to_process = array();
209 $accepted = 0;
210 $deferred = 0;
211 $rejected = 0;
212 $scheduled_this_batch = 0;
213
214 foreach($data['urls'] AS $key => $url){
215 # prepare the route
216 $route = $data['domain'] . $url;
217
218 # validate the route
219 $route = rtrim($route, '/');
220
221 # Resolve redirect table: use final destination URL before excluded/404 checks and OTTO processing
222 $route = metasync_otto_resolve_redirect_to_final_url($route);
223
224 # OTTO has confirmed this URL is crawlable. Remove any auto-exclusion that was
225 # previously set (e.g. because url_to_postid() returned 0 for a custom post type
226 # and metasync_otto_is_url_available() incorrectly treated it as a 404).
227 # Manual exclusions (auto_excluded = 0) are left untouched.
228 metasync_otto_remove_auto_exclusion($route);
229
230 # Skip manually excluded URLs - don't queue OTTO processing for them
231 if (metasync_is_otto_url_excluded($route)) {
232 continue;
233 }
234
235 # Every valid URL belongs to the batched cache-purge set even when its
236 # per-URL meta-sync job is deduped or deferred: the crawler hit all of
237 # them, so the caches for all of them must be refreshed.
238 $routes_to_process[] = $route;
239
240 # Duplicate delivery: an active job already owns this URL. Idempotent
241 # no-op — not re-scheduled, not queued, not counted as new work.
242 if (metasync_otto_job_already_scheduled($route)) {
243 continue;
244 }
245
246 if ($scheduled_this_batch >= $max_jobs_per_batch) {
247 # Batch overflow: queue durably for the drainer instead of dropping.
248 if (Metasync_Otto_Job_Status::enqueue_pending($route)) {
249 Metasync_Otto_Job_Status::record($route, Metasync_Otto_Job_Status::STATE_ACCEPTED, 'queue', 0, 'batch_overflow');
250 $deferred++;
251 } else {
252 $rejected++;
253 }
254 continue;
255 }
256
257 # Queue the per-URL processing for background execution. The handler
258 # (metasync_handle_otto_crawl_url_job) performs transient warming,
259 # SEO meta sync, and per-URL host cache purge.
260 # Offset each event by $key seconds to avoid wp_schedule_single_event()
261 # silently dropping duplicates when timestamp + hook + args collide.
262 # A false return means cron refused the event — fall back to the
263 # durable queue rather than acknowledging a URL nobody will process.
264 if (metasync_otto_schedule_single_event($now + $key, 'metasync_process_otto_crawl_url_job', array($route))) {
265 Metasync_Otto_Job_Status::record($route, Metasync_Otto_Job_Status::STATE_ACCEPTED, 'queue', 0, '');
266 $accepted++;
267 $scheduled_this_batch++;
268 } elseif (Metasync_Otto_Job_Status::enqueue_pending($route)) {
269 Metasync_Otto_Job_Status::record($route, Metasync_Otto_Job_Status::STATE_ACCEPTED, 'queue', 0, 'cron_refused');
270 $deferred++;
271 } else {
272 $rejected++;
273 }
274 }
275
276 # Schedule a single batch job for the edge CDN purge.
277 # The batch waits for per-URL stragglers (retries) before purging, so a
278 # retried URL's fresh content is what reaches the edge. Multi-URL purge
279 # in a single API call keeps edge-provider request counts low.
280 if (!empty($routes_to_process)) {
281 $batch_time = $now + count($data['urls']) + 5; // run after all per-URL jobs
282 metasync_otto_schedule_single_event($batch_time, 'metasync_process_otto_batch_cache_job', array($routes_to_process));
283 }
284
285 # Work off any overflow (or cron-refused URLs) from the durable queue.
286 if ($deferred > 0) {
287 metasync_otto_schedule_single_event($now + 120, 'metasync_otto_pending_drainer', array());
288 }
289
290 # Return 200 immediately so the webhook caller does not time out, with an
291 # honest accounting of what happened to the batch.
292 return new WP_REST_Response(array(
293 'success' => true,
294 'message' => 'OTTO crawl notification received',
295 'accepted' => $accepted,
296 'deferred' => $deferred,
297 'rejected' => $rejected,
298 ), 200);
299 }
300
301 /**
302 * Background handler for a single OTTO crawl-notify URL.
303 *
304 * Pipeline per URL:
305 * 1. Warm the OTTO transient cache — this is the ONE live API call the job
306 * makes; the warmed suggestions are handed to the meta sync so the
307 * endpoint is never fetched twice per URL.
308 * 2. Sync SEO post meta (metasync_process_otto_seo_data) and branch on its
309 * outcome: only SUCCESS / NO_CHANGE reach the purge step.
310 * 3. Clear the per-URL host cache and re-warm it.
311 *
312 * Retryable outcomes (API timeout / 5xx / 429 / lock contention / CPU deferral)
313 * retry with exponential backoff up to METASYNC_OTTO_JOB_MAX_RETRIES; the
314 * exhaustion event is recorded via metasync_record_failed_action() for
315 * Site Health. Permanent outcomes (not connected, excluded/404 URL, API auth
316 * or input error) never retry and never purge.
317 *
318 * @param string $route Fully-qualified URL to process.
319 * @param int $retry_count Current retry attempt (0 = first run).
320 */
321 function metasync_handle_otto_crawl_url_job($route = '', $retry_count = 0) {
322 // WP-Cron events persist independently of plugin code. A stale or malformed
323 // event may therefore invoke this callback without its required route.
324 // Docblock type isn't enforced at runtime; a stale cron record can pass a non-string.
325 // @phpstan-ignore-next-line function.alreadyNarrowedType
326 if (!is_string($route) || $route === '') {
327 error_log('MetaSync OTTO: skipping crawl-url job without a valid route.'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
328 return;
329 }
330
331 $max_retries = defined('METASYNC_OTTO_JOB_MAX_RETRIES') ? METASYNC_OTTO_JOB_MAX_RETRIES : 3;
332
333 # Shared retry/terminal bookkeeping so every failure path stays consistent.
334 $schedule_retry = function ($reason) use ($route, $retry_count, $max_retries) {
335 if ($retry_count < $max_retries) {
336 # Exponential backoff (60s, 120s, 240s) plus jitter so a batch of
337 # failures cannot stampede the API on the same tick.
338 $delay = 60 * pow(2, $retry_count) + wp_rand(0, 15);
339 $scheduled = metasync_otto_schedule_single_event(time() + $delay, 'metasync_process_otto_crawl_url_job', array($route, $retry_count + 1));
340 if (!$scheduled && Metasync_Otto_Job_Status::enqueue_pending($route)) {
341 Metasync_Otto_Job_Status::record($route, Metasync_Otto_Job_Status::STATE_ACCEPTED, 'queue', $retry_count + 1, $reason . '/cron_refused');
342 } else {
343 Metasync_Otto_Job_Status::record($route, Metasync_Otto_Job_Status::STATE_RETRYING, 'job', $retry_count + 1, $reason);
344 }
345 error_log('MetaSync OTTO: retrying crawl-url job for ' . $route . ' (attempt ' . ($retry_count + 1) . '/' . $max_retries . ') reason=' . $reason . ' in ' . $delay . 's'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
346 } else {
347 metasync_record_failed_action('metasync_process_otto_crawl_url_job');
348 Metasync_Otto_Job_Status::record($route, Metasync_Otto_Job_Status::STATE_FAILED, 'job', $retry_count, 'exhausted:' . $reason);
349 error_log('MetaSync OTTO: background crawl-url job permanently failed for ' . $route . ' after ' . $max_retries . ' retries, last reason=' . $reason); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
350 }
351 };
352
353 try {
354 # Not connected: retrying cannot help, and there is nothing to purge.
355 $otto_uuid = Metasync_Otto_Config::get_otto_uuid();
356 # A corrupted option row can hold a non-string UUID; the docblock
357 # contract alone does not enforce that at runtime.
358 # @phpstan-ignore-next-line function.alreadyNarrowedType
359 if (!is_string($otto_uuid) || $otto_uuid === '') {
360 Metasync_Otto_Job_Status::record($route, Metasync_Otto_Job_Status::STATE_FAILED, 'job', $retry_count, 'not_connected');
361 return;
362 }
363
364 # Step 1: Warm OTTO transient cache (fetch fresh suggestions from OTTO
365 # API into WP transient — the rate limiter, circuit breaker, and
366 # request lock all live inside this call).
367 $transient_cache = new Metasync_Otto_Transient_Cache($otto_uuid);
368 $warmed = $transient_cache->warm_cache($route);
369 if ($warmed === false) {
370 # A 4xx refusal (bad key, revoked project) is permanent: retrying
371 # cannot change the answer, so fail fast instead of burning the
372 # attempt budget on warm calls that will keep being rejected.
373 if ($transient_cache->last_failure_is_permanent()) {
374 metasync_record_failed_action('metasync_process_otto_crawl_url_job');
375 Metasync_Otto_Job_Status::record($route, Metasync_Otto_Job_Status::STATE_FAILED, 'job', $retry_count, 'permanent:warm_rejected');
376 error_log('MetaSync OTTO: crawl-url job failed permanently for ' . $route . ' (warm request rejected by the API)'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
377 return;
378 }
379 # Transient API failure (timeout / 5xx / breaker open). No meta
380 # write, no purge — straight to the retry path.
381 $schedule_retry('warm_failed');
382 return;
383 }
384
385 # Step 2: Write SEO post meta synchronously BEFORE the cache purge.
386 # This ensures the DB is fully up-to-date when the host re-populates
387 # the cache on the very next request. The warmed suggestions are
388 # passed through so the sync makes NO second API call.
389 # allow_defer=false: the crawl_url_job retry mechanism owns failure
390 # handling here; letting the sync self-reschedule caused an
391 # unbounded cron pile-up.
392 # warm_cache() may hand back a non-array truthy on contract drift;
393 # the sync treats that as "no prefetched suggestions".
394 # @phpstan-ignore-next-line function.alreadyNarrowedType
395 $prefetched = is_array($warmed) ? $warmed : null;
396 $outcome = metasync_process_otto_seo_data($route, false, 0, $prefetched);
397
398 if ($outcome === Metasync_Otto_Job_Status::OUTCOME_RETRYABLE
399 || $outcome === Metasync_Otto_Job_Status::OUTCOME_DEFERRED) {
400 $schedule_retry('outcome_' . $outcome);
401 return;
402 }
403
404 if ($outcome !== Metasync_Otto_Job_Status::OUTCOME_SUCCESS
405 && $outcome !== Metasync_Otto_Job_Status::OUTCOME_NO_CHANGE) {
406 # Permanent failure: no retry, no purge — caches must keep serving
407 # the last good content.
408 Metasync_Otto_Job_Status::record($route, Metasync_Otto_Job_Status::STATE_FAILED, 'meta', $retry_count, 'permanent:' . $outcome);
409 return;
410 }
411
412 # Step 3: meta is current (changed or confirmed unchanged) — clear the
413 # per-URL host cache entry and re-warm it so OTTO-modified output is
414 # what gets stored.
415 $otto_pixel = new Metasync_otto_pixel(false);
416 $otto_pixel->refresh_cache($route);
417 Metasync_Cache_Purge::purge_single_url($route);
418 Metasync_Cache_Purge::warm_urls(array($route));
419
420 # A URL that completed on a retry missed its batch's edge purge (the
421 # batch only waits so long). Late completers purge the edge themselves.
422 if ($retry_count > 0) {
423 Metasync_Edge_Cache_Purge::purge(array($route));
424 }
425
426 Metasync_Otto_Job_Status::record($route, Metasync_Otto_Job_Status::STATE_COMPLETED, 'job', $retry_count, $outcome);
427
428 } catch (Exception $e) {
429 $schedule_retry('exception:' . $e->getMessage());
430 }
431 }
432
433 /**
434 * Batch handler for the edge CDN purge.
435 *
436 * Runs after all per-URL jobs have had time to complete. Per-URL host caches
437 * are cleared and re-warmed by the individual jobs; this batched event only
438 * handles the edge CDN purge, which benefits from multi-URL API calls
439 * (Cloudflare, Fastly, Akamai, Sucuri, Sevella, etc.).
440 *
441 * URLs still retrying are stragglers: the batch waits (up to
442 * METASYNC_OTTO_BATCH_MAX_WAITS reschedules 60s apart) so their freshly
443 * synced content — not stale content — reaches the edge. URLs that complete
444 * after the wait budget purge the edge themselves from the retry path.
445 *
446 * @param array $routes List of fully-qualified URLs in this batch.
447 * @param int $wait_count How many times this batch has already waited.
448 */
449 function metasync_handle_otto_batch_cache_job($routes = array(), $wait_count = 0) {
450 // WP-Cron events persist independently of plugin code. A stale or malformed
451 // event may therefore invoke this callback without its required route list.
452 // Docblock type isn't enforced at runtime; a stale cron record can pass a non-array.
453 // @phpstan-ignore-next-line function.alreadyNarrowedType
454 if (!is_array($routes) || empty($routes)) {
455 error_log('MetaSync OTTO: skipping batch cache job without valid routes.'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
456 return;
457 }
458
459 $max_waits = defined('METASYNC_OTTO_BATCH_MAX_WAITS') ? METASYNC_OTTO_BATCH_MAX_WAITS : 3;
460 // Docblock type isn't enforced at runtime; a stale cron record can pass a non-numeric.
461 // @phpstan-ignore-next-line function.alreadyNarrowedType
462 $wait_count = is_numeric($wait_count) ? (int) $wait_count : 0;
463
464 # Split the batch: terminally-done URLs vs. URLs with work still in
465 # flight. Unknown-state URLs (legacy events scheduled before the status
466 # store existed, or deduped duplicates whose entry expired) count as done
467 # — their per-URL job has either finished or never will, and both mean
468 # the edge purge should not stall behind them.
469 $ready = array();
470 $stragglers = 0;
471 foreach ($routes as $route) {
472 $entry = Metasync_Otto_Job_Status::get($route);
473 $state = is_array($entry) && isset($entry['state']) ? $entry['state'] : null;
474 if ($state === Metasync_Otto_Job_Status::STATE_RETRYING
475 || $state === Metasync_Otto_Job_Status::STATE_ACCEPTED) {
476 $stragglers++;
477 } else {
478 # completed / failed / unknown: nothing left to wait for.
479 $ready[] = $route;
480 }
481 }
482
483 # Still live work in the batch and wait budget left: hold the edge purge.
484 if ($stragglers > 0 && $wait_count < $max_waits) {
485 metasync_otto_schedule_single_event(time() + 60, 'metasync_process_otto_batch_cache_job', array($routes, $wait_count + 1));
486 return;
487 }
488
489 if (empty($ready)) {
490 return;
491 }
492
493 try {
494 # Purge edge CDN caches (Cloudflare, Fastly, Akamai, Sucuri, Sevalla, etc.)
495 # Tag-based providers purge only the affected posts; full-flush providers fire once per batch.
496 Metasync_Edge_Cache_Purge::purge($ready);
497 } catch (Exception $e) {
498 metasync_record_failed_action('metasync_process_otto_batch_cache_job');
499 error_log('MetaSync OTTO: batch cache job failed for ' . count($ready) . ' URLs: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
500 }
501 }
502
503 /**
504 * Drainer: work off URLs that could not be scheduled inline (batch overflow
505 * or a cron that refused the event) from the durable pending queue.
506 *
507 * Processes at most 25 URLs per run to stay friendly to WP-Cron, schedules
508 * the same batched edge purge as the webhook path for the chunk it handled,
509 * and reschedules itself while the queue is non-empty. URLs it takes off
510 * the queue are scheduled unconditionally unless a real cron event already
511 * exists for them — the webhook's overflow bookkeeping must never veto,
512 * because by then the URL has already left the durable queue.
513 */
514 function metasync_handle_otto_pending_drainer() {
515 $chunk = Metasync_Otto_Job_Status::take_pending(25);
516 if (empty($chunk)) {
517 return;
518 }
519
520 $now = time();
521 $scheduled = 0;
522 foreach ($chunk as $key => $route) {
523 # This run owns the chunk: take_pending() already removed these URLs
524 # from the durable queue, so skipping here would drop them. Only a
525 # real cron event vetoes — the webhook's overflow ledger row must
526 # not (see metasync_otto_job_event_pending).
527 if (metasync_otto_job_event_pending($route)) {
528 continue;
529 }
530 if (metasync_otto_schedule_single_event($now + $key, 'metasync_process_otto_crawl_url_job', array($route))) {
531 Metasync_Otto_Job_Status::record($route, Metasync_Otto_Job_Status::STATE_ACCEPTED, 'drain', 0, '');
532 $scheduled++;
533 } else {
534 # Cron still refusing: put it back at the end of the queue.
535 Metasync_Otto_Job_Status::enqueue_pending($route);
536 }
537 }
538
539 if ($scheduled > 0) {
540 metasync_otto_schedule_single_event($now + count($chunk) + 5, 'metasync_process_otto_batch_cache_job', array($chunk));
541 }
542
543 if (Metasync_Otto_Job_Status::pending_count() > 0) {
544 metasync_otto_schedule_single_event($now + 60, 'metasync_otto_pending_drainer', array());
545 }
546 }
547
548 // metasync_is_custom_or_lps_page() now lives in includes/metasync-helpers.php
549 // (loaded unconditionally before this file) so all SEO surfaces share one rule.
550
551 /**
552 * Detect an Elementor editor / preview request that OTTO must never process.
553 *
554 * Elementor renders its editing canvas in a front-end <iframe> — a logged-in
555 * GET request that is NOT is_admin(), so the admin/ajax/REST guards in
556 * metasync_start_otto() do not catch it. If OTTO output-buffers and rewrites
557 * that response (SimpleHtmlDom in Otto_html_class), the markup the editor's
558 * JavaScript expects is altered and the canvas stays stuck on "loading" —
559 * the editor never finishes initialising. The editor surface is for authoring
560 * only; it is never crawled, so there is nothing for OTTO to optimise there.
561 *
562 * Locally this is masked because the OTTO API is unreachable (API_ERROR ⇒ no
563 * suggestions ⇒ no rewrite); on a live site with real suggestions the rewrite
564 * happens and the editor breaks. See the "Elementor editor fails to load while
565 * the plugin is active" report.
566 *
567 * @return bool True when the current request is an Elementor editor/preview.
568 */
569 function metasync_is_elementor_editor_request() {
570 # The preview iframe loads the front end with ?elementor-preview=POST_ID.
571 # This is the request OTTO would otherwise buffer and corrupt.
572 if (isset($_GET['elementor-preview'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only editor request detection
573 return true;
574 }
575
576 # Elementor editor / app entry points carried as an action on the front end.
577 if (isset($_REQUEST['action']) // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only editor request detection
578 && in_array($_REQUEST['action'], array('elementor', 'elementor_ajax'), true) // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only editor request detection
579 ) {
580 return true;
581 }
582
583 # Authoritative check when Elementor is loaded: its own preview-mode flag.
584 if (class_exists('\Elementor\Plugin')
585 && isset(\Elementor\Plugin::$instance->preview)
586 && is_object(\Elementor\Plugin::$instance->preview)
587 && method_exists(\Elementor\Plugin::$instance->preview, 'is_preview_mode')
588 && \Elementor\Plugin::$instance->preview->is_preview_mode()
589 ) {
590 return true;
591 }
592
593 return false;
594 }
595
596 function metasync_start_otto(){
597
598 # PERFORMANCE FIX: Cache is now enabled for speed
599 # Skip initial cache cleanup to preserve existing cache
600 # Cache files are valuable for performance - only clear on OTTO updates
601 # Periodic cache clearing can be configured in plugin settings if needed
602
603 # exclude AJAX requests and WooCommerce transactional pages from OTTO SSR
604 # SSR is now ENABLED for: single products, product categories, product tags
605 # SSR is SKIPPED for: cart, checkout
606 # Note: title/description filters (pre_get_document_title, wp_head meta desc)
607 # run on ALL pages regardless — they are hooked unconditionally in seo-functions.php
608
609 # ── WooCommerce-independent cart/checkout protection ──────────
610 # The is_cart()/is_checkout() guards below only recognize WooCommerce. Carts
611 # from other systems — e.g. the Point of Rental "Catalog" plugin on
612 # venturarental.com — are invisible to them, so OTTO would process those
613 # pages and let caching layers store them. A cached cart page corrupts the
614 # live cart (added items vanish because a stale page is served). The checks
615 # here do not depend on WooCommerce being active.
616
617 # Never run OTTO on non-GET requests. SSR exists for crawlers, which only
618 # issue GET; POST/PUT/etc. are form or cart submissions that must pass
619 # through untouched. (OTTO's own internal fetches use GET.)
620 if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'GET') {
621 return;
622 }
623
624 # Never run OTTO on WordPress core draft/pending previews
625 # (?p=<ID>&preview=true, with preview_id/preview_nonce on the full link).
626 # Previews are per-user, non-cacheable, and routed purely by query string —
627 # and OTTO's route identity ignores the query string, so a processed preview
628 # is answered with the route's public page (the homepage for /?p=…) instead
629 # of the draft the editor asked for. Skip early and let WP render it.
630 if (
631 (isset($_GET['preview']) && $_GET['preview'] === 'true') || // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only front-end check that skips OTTO
632 !empty($_GET['preview_id']) // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only front-end check that skips OTTO
633 ) {
634 if (!defined('DONOTCACHEPAGE')) {
635 define('DONOTCACHEPAGE', true);
636 }
637 return;
638 }
639
640 # Never run OTTO on the Elementor editor/preview iframe. It is a logged-in
641 # front-end GET (not is_admin()), so it slips past the admin guard below;
642 # buffering and rewriting it breaks the editor canvas ("fails to load").
643 if (metasync_is_elementor_editor_request()) {
644 return;
645 }
646
647 # WP core file-editor self-check. When an admin saves a THEME file
648 # via the Plugin/Theme Editor, wp_edit_theme_plugin_file() fires an internal
649 # loopback GET to home_url('/') carrying wp_scrape_key/wp_scrape_nonce to
650 # detect a white-screen. That request is NOT is_admin(), so — exactly like
651 # the Elementor case above — it slips past the admin/AJAX/REST guards below.
652 # OTTO must not output-buffer or rewrite it: the SimpleHtmlDom pass (and any
653 # fatal thrown inside the ob_start() callback, e.g. under memory pressure)
654 # corrupts the scrape and surfaces as the misleading
655 # "preg_match(): Cannot use output buffering in output buffering display
656 # handlers" fatal. Skipping OTTO lets WP render the page normally so the
657 # scrape works. Returning here also avoids the HTTP-fallback path firing a
658 # second loopback for an already-internal self-request.
659 if (metasync_is_scrape_request()) {
660 return;
661 }
662
663 # Skip OTTO on cart/checkout-type paths regardless of the cart plugin, and
664 # signal caching plugins (WP Rocket/Kinsta/etc.) to never cache them — a
665 # cached cart/checkout page is precisely what corrupts cart state.
666 # Matched against the FIRST path segment (relative to the WP home path) so
667 # subdirectory installs (/shop/cart) still work, while unrelated content pages
668 # like /blog/cart or /features/checkout do NOT false-match.
669 # Drop the query string with explode() (no shared internal pointer like strtok),
670 # then lower-case and strip surrounding slashes.
671 $otto_req_path = explode('?', (string) wp_unslash($_SERVER['REQUEST_URI'] ?? ''), 2)[0];
672 $otto_req_path = strtolower(trim($otto_req_path, '/'));
673 # Strip the site's base path so first-segment matching works on subdir installs.
674 $otto_home_path = trim((string) wp_parse_url(home_url(), PHP_URL_PATH), '/');
675 if ($otto_home_path !== '' && strpos($otto_req_path, $otto_home_path . '/') === 0) {
676 $otto_req_path = substr($otto_req_path, strlen($otto_home_path) + 1);
677 }
678 $otto_first_segment = explode('/', $otto_req_path, 2)[0];
679 # Filterable so sites can add/remove transactional slugs without code changes.
680 $otto_cart_segments = apply_filters('metasync_otto_cart_paths', array(
681 'cart', 'checkout', 'basket', 'request-a-quote', 'quote-request',
682 ));
683 $otto_cart_segments = array_map('strtolower', (array) $otto_cart_segments);
684 if ($otto_first_segment !== '' && in_array($otto_first_segment, $otto_cart_segments, true)) {
685 if (!defined('DONOTCACHEPAGE')) {
686 define('DONOTCACHEPAGE', true);
687 }
688 return;
689 }
690 # ──────────────────────────────────────────────────────────────────────
691
692 if (
693 # disable ajax calls
694 isset($_GET['ucfrontajaxaction']) || // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only front-end check that skips OTTO
695 # OTTO Preview mode - skip OTTO when previewing original content
696 (isset($_GET['otto_preview']) && $_GET['otto_preview'] === '1') || // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only front-end check that skips OTTO
697 # WooCommerce shop archive page only (products and categories now use SSR)
698 //(function_exists('is_shop') && is_shop()) ||
699 # Cart page
700 (function_exists('is_cart') && is_cart()) ||
701 # Checkout page
702 (function_exists('is_checkout') && is_checkout()) ||
703 # My Account page
704 //(function_exists('is_account_page') && is_account_page()) ||
705 # Standard WordPress AJAX
706 (function_exists('wp_doing_ajax') && wp_doing_ajax()) ||
707 # check by constant
708 (defined('DOING_AJAX') && DOING_AJAX) ||
709 # WooCommerce AJAX endpoint (e.g., ?wc-ajax=update_cart)
710 (isset($_REQUEST['wc-ajax']) && !empty($_REQUEST['wc-ajax'])) || // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only front-end check that skips OTTO
711 # AJAX requests via X-Requested-With header
712 (isset($_SERVER['HTTP_X_REQUESTED_WITH']) && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest') ||
713 # Gravity Forms submission detection - skip OTTO to allow form processing
714 (isset($_POST['gform_submit']) && ( // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
715 is_array($_POST['gform_submit']) || // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
716 (is_string($_POST['gform_submit']) && isset($_POST['is_submit_' . $_POST['gform_submit']]) && !empty($_POST['gform_submit'])) // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
717 )) ||
718 # Gravity Forms AJAX submission
719 (isset($_POST['gform_ajax']) && isset($_POST['gform_submit']) && ( // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
720 is_array($_POST['gform_submit']) || !empty($_POST['gform_submit']) // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
721 )) ||
722 # Gravity Forms file upload
723 (isset($_POST['gform_uploaded_files'])) || // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
724 # Any Gravity Forms POST parameter
725 (isset($_POST['gform_submit']) || isset($_POST['gform_unique_id']) || isset($_POST['gform_field_values'])) || // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
726 # Formidable Forms AJAX submission detection - skip OTTO to allow form processing
727 (isset($_POST['action']) && $_POST['action'] === 'frm_entries_create') || // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
728 # Formidable Forms POST parameters
729 (isset($_POST['form_id']) && !empty($_POST['form_id'])) || // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
730 # Formidable Forms action parameter
731 (isset($_POST['frm_action']) && !empty($_POST['frm_action'])) || // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
732 # Formidable Forms item_key (used in form submissions)
733 (isset($_POST['item_key']) && !empty($_POST['item_key'])) // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
734 ) {
735 return;
736 }
737
738 # fetch globals
739 global $metasync_options, $otto_enabled;
740
741 # OPTIMIZED: check for the disable otto for logged in users option using cached config
742 if(Metasync_Otto_Config::is_disabled_for_loggedin()){
743
744 # get user
745 $current_user = wp_get_current_user();
746
747 # check if user is logged in
748 if( !empty($current_user->ID)){
749
750 return;
751 }
752 }
753
754 # check if current URL is manually excluded from OTTO
755 # NOTE: auto-exclusions (false-positive 404 detections) are intentionally NOT checked
756 # here — they must not block OTTO rendering. Use metasync_is_otto_url_excluded() only
757 # in the webhook handler where we gate SEO meta writes.
758 $request_uri = sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'] ?? ''));
759 $current_url = home_url(strtok($request_uri, '?') ?: $request_uri);
760 if (metasync_is_otto_url_manually_excluded($current_url)) {
761 return;
762 }
763
764 # Skip OTTO for XML endpoints (sitemaps, RSS-as-xml, etc.). OTTO has no
765 # suggestions for machine-readable XML, and the upstream API returns
766 # API_ERROR for these URLs — which then stamps misleading
767 # X-MetaSync-OTTO-Cache: API_ERROR / X-MetaSync-OTTO-Method: NONE headers
768 # onto otherwise-healthy sitemap responses and causes false-alarm bug
769 # reports from customers.
770 $request_path = strtok($request_uri, '?');
771 if ($request_path && preg_match('#\.xml$#i', $request_path)) {
772 return;
773 }
774
775 # Machine-readable core endpoints. Using WordPress's own conditionals rather
776 # than matching the path: they are exact, and a path pattern here would also
777 # match ordinary content whose slug merely starts with the same characters
778 # (e.g. /robots.txt-explained/, /favicon.ico-vs-svg/).
779 # is_favicon() is WP 5.4+; this plugin supports 5.2.
780 if (is_robots() || (function_exists('is_favicon') && is_favicon())) {
781 return;
782 }
783
784 # Protocol paths and static assets that are never OTTO-able content.
785 # The guard fails open — if the strategy is unavailable we let the request
786 # proceed rather than fatal. metasync_otto_class_provides() rather than a
787 # bare class_exists(): a partially updated install can leave an older copy of
788 # the strategy class loaded that predates is_non_content_path(), which
789 # satisfies class_exists() and then fatals on every front-end request.
790 if (metasync_otto_class_provides('Metasync_Otto_Render_Strategy', 'is_non_content_path')
791 && Metasync_Otto_Render_Strategy::is_non_content_path($request_path)) {
792 return;
793 }
794
795 # BOT DETECTION: Always detect bots so crawl data reaches the SA backend.
796 # Real bots don't execute JS, so otto-tracker.js never fires for them.
797 # push_crawl_log_to_sa() fires a non-blocking wp_remote_post here instead.
798 $bot_detector = Metasync_Otto_Bot_Detector::get_instance();
799 $detection = $bot_detector->detect();
800 if ( $detection['is_bot'] ) {
801 $bot_detector->push_crawl_log_to_sa( $detection, $current_url );
802 }
803
804 # A headless frontend renders the public HTML, so stop after the cheap cache
805 # protections and crawl telemetry, before any OTTO rendering work begins.
806 #
807 # Routed through metasync_headless_is_active() rather than calling the class
808 # directly: Metasync_Headless_Config resolves only through the committed
809 # Composer classmap, so a partially updated install can leave this newer
810 # caller beside a missing or older copy with no fallback lookup path. This
811 # function is hooked to `wp`, so an unguarded static call is a white screen
812 # on every front-end page view. The helper treats an unresolvable class as
813 # "not headless", which keeps OTTO behaving as it did before headless mode.
814 # @phpstan-ignore-next-line function.alreadyNarrowedType
815 $metasync_is_headless = function_exists('metasync_headless_is_active')
816 ? metasync_headless_is_active()
817 : (class_exists('Metasync_Headless_Config') && Metasync_Headless_Config::is_active());
818
819 if ($metasync_is_headless) {
820 return;
821 }
822
823 # Throttle OTTO rendering for bots that are not exempt: at most one render
824 # per URL+bot every 5 minutes. Humans, search engines (Googlebot, Bingbot,
825 # Yahoo's Slurp, Sogou, Exabot, Applebot, ...), host cache warmers and
826 # synthetic performance auditors are never throttled — their hits always get
827 # full OTTO content, so indexing stays current, the page cache can actually
828 # be filled, and audit tools measure what a real visitor sees. SEO tools,
829 # social crawlers, archivers, AI scrapers, uptime monitors and unrecognized
830 # crawlers are de-duplicated so repeat hits don't re-trigger the expensive
831 # DOM-rewriting path.
832 #
833 # Two complementary gates, deliberately kept separate:
834 # - is_unthrottled_category() consults the classifier's explicit per-bot
835 # category map, so the exempt set is defined in exactly one place.
836 # - metasync_otto_is_unthrottled_infrastructure_agent() matches the raw
837 # user-agent (plus a query marker and a site filter), which catches
838 # preload agents that have no entry in the map at all.
839 if ( $detection['is_bot']
840 && ! Metasync_Otto_Bot_Detector::is_unthrottled_category( $detection['bot_type'] )
841 && ! metasync_otto_is_unthrottled_infrastructure_agent( $detection ) ) {
842 $normalized_url = strtok( $current_url, '?' );
843 if ( $normalized_url === false ) {
844 $normalized_url = $current_url;
845 }
846 // Lowercase + length cap so attacker-controlled bot_name variations
847 // (EvilBota, EvilBotB, EvilBotc, ...) collapse to a bounded key space
848 // and cannot flood wp_options with unique transient rows.
849 $bot_name = substr( strtolower( $detection['bot_name'] ?? 'unknown' ), 0, 32 );
850 $render_throttle_key = 'metasync_otto_rendered_' . md5( $normalized_url . '|' . $bot_name );
851 if ( get_transient( $render_throttle_key ) ) {
852 // Prevent page caches (WP Rocket, W3TC, etc.) from saving the
853 // un-OTTO'd response and serving it to subsequent human visitors.
854 if ( ! defined( 'DONOTCACHEPAGE' ) ) {
855 define( 'DONOTCACHEPAGE', true );
856 }
857 return;
858 }
859 set_transient( $render_throttle_key, 1, 5 * MINUTE_IN_SECONDS );
860 }
861
862 # Skip OTTO on 404s.
863 #
864 # OTTO suggestions are keyed to real, crawled pages. Asking the API about a URL
865 # that returns 404 spends a full API_TIMEOUT of PHP-FPM worker time on a
866 # guaranteed-empty answer. Not theoretical: in one production sample, 21 of 54
867 # crawler requests were 404s on URLs that never existed on the site, each
868 # costing ~2s of a worker.
869 #
870 # Deliberately placed AFTER bot detection so push_crawl_log_to_sa() still
871 # reports the crawl to SearchAtlas — that telemetry is how the crawler side
872 # can learn to stop requesting these URLs. Only the expensive suggestions
873 # lookup is skipped.
874 #
875 # Filterable because "should a 404 page get OTTO treatment" is arguably a site
876 # decision, even though there is no known case where it should.
877 if (is_404() && apply_filters('metasync_otto_skip_on_404', true)) {
878 return;
879 }
880
881 # Optionally skip OTTO processing for bot traffic (when the setting is enabled)
882 if ($bot_detector->should_skip_otto()) {
883 // Log the bot locally and count the saved API call
884 $bot_detector->log_detection($detection);
885 $bot_stats_db = Metasync_Otto_Bot_Statistics_Database::get_instance();
886 $bot_stats_db->increment_api_calls_saved();
887
888 // Skip OTTO processing for this bot
889 return;
890 }
891
892 # OPTIMIZED: Check if Otto should be disabled for WP Rocket compatibility
893 if (class_exists('WP_Rocket')) {
894 $wp_rocket_compat_mode = Metasync_Otto_Config::get_wp_rocket_compat_mode();
895
896 if ($wp_rocket_compat_mode === 'disable_otto') {
897 return; # Exit early, Otto is disabled when WP Rocket is active
898 }
899 }
900
901 # Skip OTTO for Divi AJAX pagination and paginated archive requests.
902 # ?et_blog = Divi AJAX pagination callback
903 # /page/N/ = paginated blog/archive pages — OTTO's buffer/HTTP render causes
904 # module numbering mismatch between page 1 (with TB template) and page N
905 # (without TB template), breaking Divi's JS pagination selector matching.
906 if (isset($_GET['et_blog']) || (is_paged() && !is_singular())) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only front-end check that skips OTTO
907 return;
908 }
909
910 # Skip OTTO on search-results pages. A search request carries its
911 # meaning entirely in the query string (e.g. /?s=term, or FiboSearch's
912 # /?s=term&post_type=product&dgwt_wcas=1). get_route() intentionally strips
913 # the query string to build a canonical, cache-key-stable route — which
914 # collapses a query-only search URL to the site root ('/'). OTTO then finds
915 # the HOME page's suggestions for that route and, on the HTTP render path,
916 # serves the home page in place of the search results (the address bar keeps
917 # the search URL — it is a silent content swap, not a redirect). This only
918 # surfaces for logged-in / uncached visitors, since page caches serve the
919 # correct pre-rendered search page to everyone else. Search-results pages
920 # have no per-canonical-URL OTTO suggestions of their own, so OTTO must never
921 # run here. The raw $_GET['s'] check is a defensive fallback for setups where
922 # the main query is altered before this point.
923 if (is_search() || !empty($_GET['s'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only front-end check that skips OTTO
924 return;
925 }
926
927 # Handle cache plugin compatibility early - before any caching happens
928 metasync_otto_handle_cache_compatibility();
929
930 # check if OTTO is disabled for this specific page/post
931 # Gate on is_singular() for the same reason the custom/LPS check below does:
932 # on an archive, search or term query get_the_ID() returns the first post of
933 # the loop — WP::register_globals() seeds $GLOBALS['post'] with it — not the
934 # page being rendered. Without the gate, one excluded or per-page-disabled
935 # post silently takes OTTO off every archive that happens to list it first.
936 $post_id = is_singular() ? ( get_queried_object_id() ?: get_the_ID() ) : 0;
937 if ($post_id && class_exists('Metasync_Otto_Frontend_Toolbar')) {
938 if (Metasync_Otto_Frontend_Toolbar::is_otto_disabled($post_id)) {
939 return;
940 }
941 }
942
943 # Skip OTTO on MetaSync custom HTML / LPS-imported pages — they ship their
944 # own complete, self-contained SEO and OTTO must not inject or overwrite it
945 # with a different/older project's SEO. Resolve the queried object
946 # id (with get_the_ID() fallback) so the static-front-page case — an LPS home
947 # set as the WP front page, where is_page() is false — is still detected.
948 # Applying the skip here, upstream of the single render_route_html() entry,
949 # covers all three render paths (Rocket buffer, output buffer, HTTP fallback).
950 # Only singular views (posts/pages, incl. a static front page) can be a
951 # custom/LPS page; gate on is_singular() so an archive/search/term query
952 # can never have its object id mistaken for a custom page's post id.
953 $custom_page_id = is_singular() ? ( get_queried_object_id() ?: get_the_ID() ) : 0;
954 if (metasync_is_custom_or_lps_page($custom_page_id)) {
955 if (!headers_sent() && Metasync_Otto_Render_Strategy::diagnostics_enabled()) {
956 header('X-MetaSync-OTTO-Method: EXCLUDED');
957 }
958 return;
959 }
960
961 # check if we are having an otto request
962 #
963 # Recognise our own internal fetch by header/User-Agent as well as by the
964 # query parameter. A redirect that rebuilds the URL strips the parameter,
965 # and without this the redirected request re-enters OTTO and fires another
966 # internal fetch - an unbounded self-request loop.
967 # method_exists() is redundant to static analysis - this MR adds the method,
968 # so PHPStan proves the call always true. It is kept for the upgrade window:
969 # during a plugin update an opcache can still hold the previous
970 # Metasync_Otto_Render_Strategy, where class_exists() passes but the method
971 # is absent, and an unguarded static call would fatal the page.
972 # @phpstan-ignore-next-line function.alreadyNarrowedType
973 $otto_has_fetch_detector = class_exists('Metasync_Otto_Render_Strategy') && method_exists('Metasync_Otto_Render_Strategy', 'is_internal_fetch');
974
975 $otto_is_internal_fetch = $otto_has_fetch_detector
976 ? Metasync_Otto_Render_Strategy::is_internal_fetch()
977 : !empty($_GET['is_otto_page_fetch']); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only loopback marker, request-scoped filters only
978
979 if($otto_is_internal_fetch){
980
981 # Block SEO plugins NOW for this internal fetch request
982 # metasync_otto_block_seo_plugins();
983 # $_SERVER['REQUEST_URI'] = remove_query_arg('is_otto_page_fetch', $_SERVER['REQUEST_URI']);
984 $block_title = !empty($_GET['otto_block_title']) && $_GET['otto_block_title'] === '1'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only loopback marker, request-scoped filters only
985 $block_description = !empty($_GET['otto_block_desc']) && $_GET['otto_block_desc'] === '1'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only loopback marker, request-scoped filters only
986
987 # Block SEO plugins conditionally based on what Otto has
988 if ($block_title || $block_description) {
989 metasync_otto_block_seo_plugins($block_title, $block_description);
990 }
991
992 # Remove ALL Otto parameters from REQUEST_URI to prevent them from appearing in pagination, etc.
993 $request_uri_raw = sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'] ?? ''));
994 $_SERVER['REQUEST_URI'] = remove_query_arg(
995 ['is_otto_page_fetch', 'otto_block_title', 'otto_block_desc'],
996 $request_uri_raw
997 );
998
999 # Also remove from $_GET to prevent WordPress from using them
1000 unset($_GET['is_otto_page_fetch']);
1001 unset($_GET['otto_block_title']);
1002 unset($_GET['otto_block_desc']);
1003 return;
1004 }
1005
1006 # to avoid unnecessary processese
1007 # OPTIMIZED: check that otto is configured
1008 # And the UUID is properly set before running OTT
1009
1010 # check that we have the option
1011 if(!Metasync_Otto_Config::is_otto_enabled()){
1012 return;
1013 }
1014
1015 # check that otto is enabled
1016 if(!$otto_enabled){
1017 return;
1018 }
1019
1020 # get the otto uuid
1021 $otto_uuid = Metasync_Otto_Config::get_otto_uuid();
1022
1023 # Upgrade-window guard: the bundled DOM parser resolves only through the
1024 # otto/vendor autoloader required at the top of this file. While an update
1025 # is mid-copy (or a stale opcache classmap is still resident), that
1026 # dependency can briefly be undefined here, and constructing the render
1027 # stack below would throw a class-not-found Error and take the whole page
1028 # down with it. Degrade to an OTTO-less page for this request instead —
1029 # the next request, on the settled tree, renders normally.
1030 if ( ! class_exists( 'simplehtmldom\HtmlDocument' ) ) {
1031 return;
1032 }
1033
1034 # start the class
1035 $otto = new Metasync_otto_pixel($otto_uuid);
1036
1037 # call render
1038 $otto->render_route_html();
1039 }
1040
1041 /**
1042 * Handle cache plugin compatibility with Otto
1043 * Controls DONOTCACHEPAGE constant based on active plugins and configuration
1044 * This function is called early in the WordPress lifecycle
1045 */
1046 function metasync_otto_handle_cache_compatibility() {
1047 # Detect active plugins. SG Optimizer detection now lives in
1048 # metasync_otto_disable_sg_page_cache(), which is invoked later
1049 # once OTTO confirms suggestions for the URL.
1050 $brizy_active = class_exists('Brizy_Editor') || defined('BRIZY_VERSION');
1051 $wp_rocket_active = class_exists('WP_Rocket');
1052
1053 # OPTIMIZED: Get configuration option using cached config
1054 $wp_rocket_compat_mode = Metasync_Otto_Config::get_wp_rocket_compat_mode();
1055
1056 # Check for Brizy posts in database
1057 global $wpdb;
1058 $has_brizy_posts = false;
1059
1060 if ($brizy_active) {
1061 # OPTIMIZED: Check cache first (1-hour TTL) to avoid querying on every page load
1062 $cached = get_transient('metasync_has_brizy_posts');
1063 if ($cached !== false) {
1064 $has_brizy_posts = ($cached === 'yes');
1065 } else {
1066 # Query database only if cache missed
1067 $has_brizy_posts = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- feature-detection probe for Brizy posts — postmeta flag scan has no WordPress API
1068 "SELECT COUNT(*) FROM {$wpdb->postmeta}
1069 WHERE meta_key = 'brizy_post_uid'
1070 AND meta_value != ''
1071 LIMIT 1"
1072 );
1073
1074 # Cache result for 1 hour
1075 $result = !empty($has_brizy_posts) ? 'yes' : 'no';
1076 set_transient('metasync_has_brizy_posts', $result, HOUR_IN_SECONDS);
1077 }
1078 }
1079
1080 # Determine if DONOTCACHEPAGE should be set
1081 $should_set_donotcachepage = false;
1082
1083 # Case 1: Brizy is active with posts - always needed. Brizy pages are
1084 # dynamically rendered regardless of OTTO, so caching must be disabled
1085 # whenever Brizy posts exist.
1086 if ($brizy_active && !empty($has_brizy_posts)) {
1087 $should_set_donotcachepage = true;
1088 }
1089
1090 # Case 2: User explicitly disabled Otto for WP Rocket compatibility
1091 elseif ($wp_rocket_active && $wp_rocket_compat_mode === 'disable_otto') {
1092 $should_set_donotcachepage = true;
1093 return; # Exit early, Otto won't run
1094 }
1095
1096 # Case 3: WP Rocket active with auto/buffer mode - DON'T set DONOTCACHEPAGE
1097 # This allows WP Rocket optimizations to continue working
1098
1099 # NOTE: SiteGround SG Optimizer cache bypass is intentionally NOT
1100 # handled here. Emitting no-cache headers / disabling SG cache on this hook
1101 # fired on EVERY front-end page — including pages OTTO never modifies —
1102 # forcing SG to bypass its page cache site-wide and spiking CPU. The SG
1103 # bypass is now deferred to metasync_otto_disable_sg_page_cache(), called
1104 # from render_route_html() only after OTTO confirms it has suggestions for
1105 # the current URL.
1106
1107 # Only set DONOTCACHEPAGE if needed
1108 if ($should_set_donotcachepage && !defined('DONOTCACHEPAGE')) {
1109 define('DONOTCACHEPAGE', true);
1110 }
1111 }
1112
1113 /**
1114 * Disable SiteGround SG Optimizer page caching for the CURRENT request only.
1115 *
1116 * Called from Metasync_otto_pixel::render_route_html() once OTTO has confirmed
1117 * it has suggestions to apply to the requested URL. Scoping the no-cache
1118 * override to pages OTTO actually modifies — rather than emitting it on every
1119 * front-end page via the unconditional `wp` hook — keeps SG's page cache
1120 * working site-wide and avoids the CPU spike reported in.
1121 *
1122 * Only meaningful on SiteGround sites without WP Rocket; a no-op otherwise.
1123 */
1124 function metasync_otto_disable_sg_page_cache() {
1125 # Only relevant when SG Optimizer is active and WP Rocket is not present.
1126 if (class_exists('WP_Rocket')) {
1127 return;
1128 }
1129 if (!is_plugin_active('sg-cachepress/sg-cachepress.php')) {
1130 return;
1131 }
1132
1133 if (!defined('DONOTCACHEPAGE')) {
1134 define('DONOTCACHEPAGE', true);
1135 }
1136 if (!defined('SG_CachePress_SUPERCACHER')) {
1137 define('SG_CachePress_SUPERCACHER', false);
1138 }
1139
1140 add_filter('sgo_html_cache_disable', '__return_true', 999);
1141 add_filter('sgo_css_combine_exclude', '__return_true', 999);
1142 add_filter('sgo_js_combine_exclude', '__return_true', 999);
1143 add_filter('sgo_cache_this_page', '__return_false', 999);
1144
1145 if (!headers_sent()) {
1146 header('Cache-Control: no-cache, must-revalidate, max-age=0');
1147 header('X-Accel-Expires: 0');
1148 }
1149 }
1150
1151 /**
1152 * How long a response that OTTO could not optimize may be cached, in seconds.
1153 *
1154 * Deliberately short rather than zero — see
1155 * metasync_otto_limit_response_cache() for why.
1156 */
1157 if (!defined('METASYNC_OTTO_FALLBACK_CACHE_TTL')) {
1158 define('METASYNC_OTTO_FALLBACK_CACHE_TTL', 60);
1159 }
1160
1161 /**
1162 * Cap how long the current response may be cached, because OTTO could not apply
1163 * its suggestions to it.
1164 *
1165 * OTTO fails open: when suggestions are unavailable, or when the HTML rewrite
1166 * fails, the site's own un-optimized markup is served instead. That markup is
1167 * valid — the harm is that a caching layer then stores it and serves it to
1168 * everyone for the rest of its TTL (a host edge cache with s-maxage=86400 pins
1169 * it for a day), which surfaces to customers as the OTTO title "reverting".
1170 *
1171 * A short TTL is used rather than `no-store`, which is the obvious choice but the
1172 * wrong one. Some failures look transient and are not: a page that always breaks
1173 * the rewriter, or a host that cannot fetch its own pages, fails identically on
1174 * every request. `no-store` would make those permanently uncacheable, sending
1175 * 100% of their traffic to the origin forever in exchange for markup that will
1176 * never improve. A one-minute ceiling fixes the reported bug just as completely —
1177 * a day-long pin becomes a minute — while capping the cost of being wrong about
1178 * which failures recover.
1179 *
1180 * Different layers read different signals, so all of them are emitted:
1181 *
1182 * - Cache-Control CDNs, host edge caches, browsers. `max-age=0` keeps
1183 * browsers revalidating while `s-maxage` lets shared
1184 * caches absorb a burst for the ceiling's duration.
1185 * - X-Accel-Expires Nginx's own directive. Evaluated with higher
1186 * precedence than Cache-Control, and not covered by
1187 * the `fastcgi_ignore_headers Cache-Control Expires`
1188 * recipe common on managed hosts — so it can survive
1189 * a config that discards Cache-Control. Nginx strips
1190 * X-Accel-* before the response reaches the client,
1191 * so there is no visitor-facing side effect.
1192 * - Surrogate-Control Varnish and Fastly. Note that on stock Varnish the
1193 * mere presence of this header suppresses its
1194 * Cache-Control check, so it must carry a real
1195 * lifetime rather than be left at some other value.
1196 * - DONOTCACHEPAGE WordPress-level page caches (WP Rocket, W3TC,
1197 * LiteSpeed, SG Optimizer). These run inside PHP and
1198 * never see response headers. They have no notion of a
1199 * short TTL — it is cache or do not — so they skip
1200 * entirely. That is safe here because callers only
1201 * invoke this for failures expected to resolve; a
1202 * permanent one (an oversized page, a host that cannot
1203 * reach itself) is left fully cacheable by the caller
1204 * and never reaches this function.
1205 *
1206 * Cache-tagging headers (Surrogate-Key, Cache-Tag, Edge-Cache-Tag) are
1207 * deliberately left in place. They are purge-targeting labels, not cacheability
1208 * directives — no CDN stores a response *because* a tag is present. On a layer
1209 * that stores this response anyway, those tags are the only handle a later purge
1210 * has on it, so removing them would discard the recovery path exactly where it
1211 * is needed most.
1212 *
1213 * Some layers may honour none of this; a managed host that overrides origin
1214 * cache headers will cache the response regardless. This bounds the damage where
1215 * it can and is inert where it cannot.
1216 *
1217 * @param string $reason Short machine-readable reason, surfaced as a response
1218 * header for debugging (e.g. 'API_ERROR', 'RENDER_FAILED').
1219 */
1220 /**
1221 * Build the response headers that cap how long an un-optimized response may be
1222 * cached.
1223 *
1224 * Thin wrapper over Metasync_Otto_Render_Strategy::fallback_cache_headers(), which
1225 * owns the header set so it can be unit-tested (this file cannot be loaded in a
1226 * test context). Returns an empty array if the strategy class is somehow absent,
1227 * so a caller emits nothing rather than an untested divergent copy.
1228 *
1229 * @param int $ttl Cache ceiling in seconds.
1230 * @param string $reason Optional machine-readable reason for the debug header.
1231 * @return array Header name => value.
1232 */
1233 function metasync_otto_fallback_cache_headers($ttl, $reason = '') {
1234 if (!class_exists('Metasync_Otto_Render_Strategy')) {
1235 return [];
1236 }
1237
1238 return Metasync_Otto_Render_Strategy::fallback_cache_headers_for_response($ttl, $reason);
1239 }
1240
1241 function metasync_otto_limit_response_cache($reason = '') {
1242 # This function must only ever make the response LESS cacheable. header()
1243 # replaces by default and the capped set carries X-Accel-Expires, which nginx
1244 # evaluates ahead of Cache-Control — so emitting it unconditionally could
1245 # override an upstream no-store and grant edge caching that nothing had
1246 # granted. The strategy reconciles existing cache directives before emitting
1247 # a capped set.
1248
1249 $strategy_available = class_exists('Metasync_Otto_Render_Strategy');
1250
1251 # 1. Logged-in users. OTTO renders for them unless the "disable on logged in"
1252 # setting is switched on, which is off by default — so this is the common
1253 # case, not an edge case. Their page may carry session-specific or
1254 # membership-gated content, and capping at 60 seconds would still let a
1255 # shared cache store and re-serve it to somebody else.
1256 if (function_exists('is_user_logged_in') && is_user_logged_in()) {
1257 if (!headers_sent() && $strategy_available) {
1258 foreach (Metasync_Otto_Render_Strategy::no_store_headers($reason) as $name => $value) {
1259 header($name . ': ' . $value);
1260 }
1261 }
1262 if (!defined('DONOTCACHEPAGE')) {
1263 define('DONOTCACHEPAGE', true);
1264 }
1265 return;
1266 }
1267
1268 # 2. Something upstream already restricted this response. Leave its headers
1269 # alone — a page deliberately marked no-store must not be handed a 60
1270 # second shared-cache lifetime by us.
1271 # The strategy turns an existing no-store into a consistent no-store set and
1272 # uses the strictest known existing lifetime for every cache layer.
1273 $ttl = (int) METASYNC_OTTO_FALLBACK_CACHE_TTL;
1274
1275 if (!headers_sent()) {
1276 foreach (metasync_otto_fallback_cache_headers($ttl, $reason) as $name => $value) {
1277 header($name . ': ' . $value);
1278 }
1279 }
1280
1281 # Reaches page-cache plugins, which do not see the headers above.
1282 if (!defined('DONOTCACHEPAGE')) {
1283 define('DONOTCACHEPAGE', true);
1284 }
1285 }
1286
1287 /**
1288 * Report an OTTO render failure to the local error log and to Sentry.
1289 *
1290 * These failures were invisible: the render path swallowed them and returned the
1291 * original HTML, so nobody knew how often they fired. Reporting them is what makes
1292 * a persistently-failing URL discoverable instead of silently costing origin work.
1293 *
1294 * Two constraints shape this:
1295 *
1296 * 1. It runs on the visitor render path, so it must not add latency. The Sentry
1297 * report is therefore queued here and sent by
1298 * metasync_otto_flush_render_failure_reports() after the response has been
1299 * handed to the visitor, so the cost is invisible rather than merely small.
1300 *
1301 * Two alternatives were tried and rejected. Sending inline with a blocking
1302 * transport — cURL with a 5s connect and 5s read timeout — could add ten
1303 * seconds to a page load, precisely when the request is already degraded.
1304 * Sending inline non-blocking is far cheaper but still not free: opening the
1305 * connection and writing the body costs the visitor something.
1306 *
1307 * A WP-Cron single event was the other obvious option and is worse than both:
1308 * every scheduled event is stored in the `cron` option, which is autoloaded on
1309 * every request site-wide, and WP's own 10-minute duplicate suppression keys on
1310 * the serialized args — which carry per-request byte counts here, so it would
1311 * never engage. A site with many failing URLs would grow that option
1312 * unboundedly and pay for it on every page load, which is the cost the deferral
1313 * was meant to avoid in the first place.
1314 *
1315 * The local log stays synchronous. It is cheaper than Sentry but not free —
1316 * Metasync_Error_Logger::log() rewrites a summary option on each call — which is
1317 * the second reason for the throttle below.
1318 *
1319 * 2. A failing page can be requested thousands of times an hour, so reports are
1320 * throttled per reason+URL.
1321 *
1322 * @param string $reason Machine-readable failure reason (e.g. 'RENDER_EXCEPTION').
1323 * @param string $message Human-readable description.
1324 * @param array $context Extra key/value context.
1325 * @param string $level Sentry level: 'error' or 'warning'.
1326 */
1327 function metasync_otto_report_render_failure($reason, $message, $context = [], $level = 'error') {
1328 $url = '';
1329 if (!empty($_SERVER['HTTP_HOST']) && !empty($_SERVER['REQUEST_URI'])) {
1330 $url = (is_ssl() ? 'https://' : 'http://')
1331 . $_SERVER['HTTP_HOST']
1332 . strtok(sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'])), '?');
1333 }
1334
1335 # Throttle: at most one report per reason+URL per 5 minutes. Requests without a
1336 # resolvable URL (CLI, odd SAPIs) collapse onto one key, which is intended —
1337 # they cannot be attributed to a page anyway.
1338 $throttle_key = 'metasync_otto_fail_' . md5($reason . '|' . $url);
1339 if (get_transient($throttle_key)) {
1340 return;
1341 }
1342 set_transient($throttle_key, 1, 5 * MINUTE_IN_SECONDS);
1343
1344 $context = array_merge([
1345 'reason' => $reason,
1346 'url' => $url,
1347 ], $context);
1348
1349 # Local structured log. Stays on the customer's site, so it is useful to
1350 # support but not visible to us — hence the Sentry dispatch below.
1351 if (class_exists('Metasync_Error_Logger')) {
1352 Metasync_Error_Logger::log(
1353 Metasync_Error_Logger::CATEGORY_OTTO_RENDER,
1354 $level === 'warning'
1355 ? Metasync_Error_Logger::SEVERITY_WARNING
1356 : Metasync_Error_Logger::SEVERITY_ERROR,
1357 $message,
1358 $context
1359 );
1360 }
1361
1362 # Sentry. Queued rather than sent here: even a non-blocking dispatch costs the
1363 # visitor something (opening the connection and writing the body), and this runs
1364 # on a request that is already degraded. The queue is flushed after the response
1365 # has been handed to the visitor — see metasync_otto_flush_render_failure_reports().
1366 if (!function_exists('metasync_sentry_capture_message_nonblocking')) {
1367 return;
1368 }
1369
1370 $GLOBALS['metasync_otto_pending_failure_reports'][] = [
1371 'message' => $message,
1372 'level' => $level,
1373 'context' => $context,
1374 ];
1375
1376 if (empty($GLOBALS['metasync_otto_failure_flush_hooked'])) {
1377 $GLOBALS['metasync_otto_failure_flush_hooked'] = true;
1378 # PHP_INT_MAX so this runs after everything else registered on shutdown —
1379 # WordPress flushes its own output buffers and closes the object cache at the
1380 # default priority, and this handler ends output for the request.
1381 add_action('shutdown', 'metasync_otto_flush_render_failure_reports', PHP_INT_MAX);
1382 }
1383 }
1384
1385 /**
1386 * Send any queued render-failure reports after the response has been delivered.
1387 *
1388 * fastcgi_finish_request() (PHP-FPM) and litespeed_finish_request() (LiteSpeed)
1389 * flush the response and close the connection to the visitor while leaving PHP
1390 * running. Calling one of them here means the page is already on its way before
1391 * any telemetry work starts, so the reporting cost is invisible to the visitor
1392 * rather than merely small.
1393 *
1394 * Neither exists under mod_php or the CLI SAPI. There the reports are still sent
1395 * at the very end of the request, after all page output has been generated and
1396 * flushed — the same ordering, just without the connection being closed first.
1397 *
1398 * The dispatch itself stays non-blocking. Now that the visitor is served, the only
1399 * remaining cost is PHP worker occupancy, and a blocking transport with a 5s
1400 * connect and 5s read timeout could tie up a worker for ten seconds per report on
1401 * a site that is failing often.
1402 */
1403 function metasync_otto_flush_render_failure_reports() {
1404 $reports = $GLOBALS['metasync_otto_pending_failure_reports'] ?? [];
1405 $GLOBALS['metasync_otto_pending_failure_reports'] = [];
1406
1407 if (empty($reports)) {
1408 return;
1409 }
1410
1411 if (function_exists('fastcgi_finish_request')) {
1412 fastcgi_finish_request();
1413 } elseif (function_exists('litespeed_finish_request')) {
1414 litespeed_finish_request();
1415 }
1416
1417 if (!function_exists('metasync_sentry_capture_message_nonblocking')) {
1418 return;
1419 }
1420
1421 foreach ($reports as $report) {
1422 try {
1423 metasync_sentry_capture_message_nonblocking(
1424 $report['message'],
1425 $report['level'],
1426 $report['context']
1427 );
1428 } catch (Exception $e) {
1429 # Telemetry must never be able to break a request.
1430 } catch (Error $e) {
1431 # Same.
1432 }
1433 }
1434 }
1435
1436 /**
1437 * Whether MetaSync is still emitting Open Graph / Twitter tags.
1438 *
1439 * The Social Media & Open Graph switch stops MetaSync writing those tags on
1440 * every render path, so the suppression below has to stand down with it —
1441 * removing another plugin's og:/twitter: tag while contributing none of our own
1442 * would leave the page with no social tags at all.
1443 *
1444 * The class_exists() guard keeps this usable if the flags file has not loaded
1445 * yet; treating that window as "enabled" preserves the previous behaviour.
1446 *
1447 * @return bool
1448 */
1449 function metasync_otto_social_output_enabled() {
1450 if (!class_exists('Metasync_Feature_Flags')) {
1451 return true;
1452 }
1453
1454 return Metasync_Feature_Flags::is_enabled(Metasync_Feature_Flags::SOCIAL_OG);
1455 }
1456
1457 /**
1458 * Block SEO plugins conditionally based on what Otto is providing
1459 * Only blocks title if Otto has title, only blocks description if Otto has description
1460 * This prevents duplicate SEO tags while allowing fallback to SEO plugins when Otto has no data.
1461 * Supports Yoast SEO, Rank Math, and AIOSEO (free + pro).
1462 *
1463 * @param bool $block_title Whether to block title tags.
1464 * @param bool $block_description Whether to block description tags.
1465 * @param array $description_tags Optional. Granular list of OTTO-provided description tags
1466 * (e.g. ['meta[name=description]', 'meta[property=og:description]']).
1467 * When provided, only matching AIOSEO tags are suppressed.
1468 * When empty, all AIOSEO description tags are suppressed (legacy behavior).
1469 */
1470 function metasync_otto_block_seo_plugins($block_title = false, $block_description = false, $description_tags = []) {
1471 # Disable Yoast SEO (free and premium)
1472 if (is_plugin_active('wordpress-seo/wp-seo.php') ||
1473 is_plugin_active('wordpress-seo-premium/wp-seo-premium.php')) {
1474
1475 # TITLE: Never block Yoast's title output during SSR fetch.
1476 # Yoast removes WordPress's native _wp_render_title_tag action and is the sole
1477 # renderer of the <title> tag. Returning false/empty from wpseo_title or removing
1478 # Title_Presenter leaves the page with NO <title> tag at all — OTTO's buffer
1479 # post-processing then has nothing to replace, producing a missing title.
1480 # Instead, let Yoast render its own title; OTTO's replace_title() will overwrite
1481 # it in the final HTML buffer. deduplicate_title_tags() cleans up any duplicates.
1482
1483 # Block description only if Otto has description
1484 if ($block_description) {
1485 add_filter('wpseo_metadesc', '__return_false', 999);
1486 add_filter('wpseo_meta_description', '__return_false', 999);
1487 add_filter('wpseo_metakeywords', '__return_false', 999);
1488 }
1489
1490 # Block Yoast's modern presenters — description only, never title
1491 add_filter('wpseo_frontend_presenters', function($presenters) use ($block_description) {
1492 if (!is_array($presenters)) return $presenters;
1493
1494 $presenters_to_remove = [];
1495
1496
1497 # Remove description presenters only when OTTO has a description
1498 if ($block_description) {
1499 # The plain meta description belongs to the SEO title/description
1500 # feature, which has its own setting.
1501 $presenters_to_remove[] = 'Yoast\WP\SEO\Presenters\Meta_Description_Presenter';
1502
1503 # The OG/Twitter descriptions are social tags. With that feature
1504 # switched off MetaSync writes none of its own, so Yoast's must be
1505 # left alone rather than removed with nothing to replace them.
1506 if (metasync_otto_social_output_enabled()) {
1507 $presenters_to_remove[] = 'Yoast\WP\SEO\Presenters\Open_Graph\Description_Presenter';
1508 $presenters_to_remove[] = 'Yoast\WP\SEO\Presenters\Twitter\Description_Presenter';
1509 }
1510 }
1511
1512 foreach ($presenters as $key => $presenter) {
1513 // Safely get class name, suppressing autoload errors
1514 // This prevents warnings when Composer autoloader tries to load deprecated Yoast files
1515 $class_name = is_object($presenter) ? @get_class($presenter) : '';
1516
1517 if (!empty($class_name) && in_array($class_name, $presenters_to_remove)) {
1518 unset($presenters[$key]);
1519 }
1520 }
1521 return $presenters;
1522 }, 999);
1523 }
1524
1525 # Disable Rank Math
1526 if (is_plugin_active('seo-by-rank-math/rank-math.php') ||
1527 is_plugin_active('seo-by-rankmath/rank-math.php')) {
1528
1529 # TITLE: Never blank Rank Math's title output during SSR fetch.
1530 # rank_math/frontend/title also gates Rank Math's og:title/twitter:title
1531 # output, so forcing it empty blanks those social tags along with the
1532 # document title. Let Rank Math render its own title/og/twitter tags;
1533 # OTTO's replace_title() and the dedup/precedence pass clean up duplicates.
1534
1535 if ($block_description) {
1536 add_filter('rank_math/frontend/description', '__return_false', 999);
1537 add_filter('rank_math/frontend/show_keywords', '__return_false', 999);
1538 }
1539 }
1540
1541 # Disable AIOSEO (free and pro)
1542 if (is_plugin_active('all-in-one-seo-pack/all_in_one_seo_pack.php') ||
1543 is_plugin_active('all-in-one-seo-pack-pro/all_in_one_seo_pack.php')) {
1544
1545 if ($block_title) {
1546 # The document title belongs to the SEO title feature, which has its
1547 # own setting; only the og:/twitter: unsets below are social.
1548 add_filter('aioseo_title', '__return_empty_string', 999);
1549
1550 if (metasync_otto_social_output_enabled()) {
1551 add_filter('aioseo_facebook_tags', function($meta) {
1552 if (is_array($meta)) { unset($meta['og:title']); }
1553 return $meta;
1554 }, 999);
1555 add_filter('aioseo_twitter_tags', function($meta) {
1556 if (is_array($meta)) { unset($meta['twitter:title']); }
1557 return $meta;
1558 }, 999);
1559 }
1560 }
1561
1562 if ($block_description) {
1563 # Use granular tag list when available to only block what OTTO provides
1564 $tags = !empty($description_tags) ? $description_tags : [];
1565 $block_standard = empty($tags) || in_array('meta[name=description]', $tags);
1566 # Social tags only get blocked while MetaSync is still emitting them.
1567 $social_enabled = metasync_otto_social_output_enabled();
1568 $block_og_desc = $social_enabled && (empty($tags) || in_array('meta[property=og:description]', $tags));
1569 $block_tw_desc = $social_enabled && (empty($tags) || in_array('meta[name=twitter:description]', $tags));
1570
1571 if ($block_standard) {
1572 add_filter('aioseo_description', '__return_empty_string', 999);
1573 }
1574 if ($block_og_desc) {
1575 add_filter('aioseo_facebook_tags', function($meta) {
1576 if (is_array($meta)) { unset($meta['og:description']); }
1577 return $meta;
1578 }, 999);
1579 }
1580 if ($block_tw_desc) {
1581 add_filter('aioseo_twitter_tags', function($meta) {
1582 if (is_array($meta)) { unset($meta['twitter:description']); }
1583 return $meta;
1584 }, 999);
1585 }
1586 }
1587 }
1588 }
1589 # Check whether OTTO is ALSO injected via JavaScript on the site (a misconfiguration
1590 # we warn admins about).
1591 #
1592 # The actual detection makes a loopback HTTP request to the site's OWN url. On hosts
1593 # that disallow same-server loopback (e.g. SiteGround), that request blocks for the
1594 # full timeout and then fails — and when it runs inline on admin_notices it makes
1595 # every wp-admin page hang. It must therefore NEVER run inline on an admin request.
1596 #
1597 # metasync_check_otto_js() is now read-only: it returns the cached result and, on a
1598 # cache miss, schedules a one-off BACKGROUND job (WP-Cron) to compute it. The notice
1599 # simply shows nothing until the background result is available.
1600 function metasync_check_otto_js(){
1601
1602 $cache_key = 'metasync_otto_js_detected';
1603 $cached = get_transient($cache_key);
1604
1605 if ($cached !== false) {
1606 return $cached === 'yes';
1607 }
1608
1609 # No cached result yet — compute it off the request so we never block admin
1610 # with a loopback call. Show nothing this load.
1611 metasync_schedule_otto_js_check();
1612 return false;
1613 };
1614
1615 # Queue the background loopback detection if it isn't already scheduled.
1616 function metasync_schedule_otto_js_check(){
1617 if (!wp_next_scheduled('metasync_otto_js_check_event')) {
1618 wp_schedule_single_event(time() + 5, 'metasync_otto_js_check_event');
1619 }
1620 }
1621
1622 # Background worker (runs in WP-Cron context, NOT on the admin request): performs the
1623 # blocking loopback request and caches the result. Hooked to metasync_otto_js_check_event.
1624 function metasync_run_otto_js_check(){
1625 $cache_key = 'metasync_otto_js_detected';
1626
1627 # the site url with the internal-fetch marker so OTTO does not re-process it
1628 $site_url = site_url() . '?is_otto_page_fetch=1';
1629
1630 $page_data = wp_remote_get($site_url, array('timeout' => 5, 'sslverify' => false));
1631
1632 if (is_wp_error($page_data)) {
1633 # Loopback failed (host likely blocks same-server requests). Cache "no" so we
1634 # don't keep re-queuing the check on every admin page load.
1635 set_transient($cache_key, 'no', HOUR_IN_SECONDS);
1636 return;
1637 }
1638
1639 $body = wp_remote_retrieve_body($page_data);
1640
1641 # Detect the OTTO script via a lightweight regex instead of parsing
1642 # the entire fetched page into a SimpleHtmlDom tree (which exhausted memory
1643 # on large pages — this check runs on admin page loads). We only need to
1644 # confirm a <script id="sa-dynamic-optimization" ... data-uuid="..."> exists.
1645 $found = preg_match(
1646 '/<script\b[^>]*\bid=["\']sa-dynamic-optimization["\'][^>]*\bdata-uuid=["\'][^"\']+["\']/i',
1647 (string) $body
1648 )
1649 || preg_match(
1650 # Attribute order may vary (data-uuid before id)
1651 '/<script\b[^>]*\bdata-uuid=["\'][^"\']+["\'][^>]*\bid=["\']sa-dynamic-optimization["\']/i',
1652 (string) $body
1653 );
1654
1655 if ($found) {
1656 set_transient($cache_key, 'yes', 12 * HOUR_IN_SECONDS);
1657 return;
1658 }
1659
1660 set_transient($cache_key, 'no', 12 * HOUR_IN_SECONDS);
1661 }
1662
1663 # Register the background worker hook.
1664 add_action('metasync_otto_js_check_event', 'metasync_run_otto_js_check');
1665
1666 # Handle AJAX Clear Cache request
1667 # NOTE: Cache system removed - this is now a no-op
1668 function metasync_clear_otto_cache_handler() {
1669 # State-changing admin action: verify the nonce sent by admin/js
1670 # (clear_otto_caches) and confirm plugin access before clearing the
1671 # detection transient or queueing the loopback re-check.
1672 check_ajax_referer('metasync_clear_otto_cache', 'nonce');
1673
1674 if (!Metasync::current_user_has_plugin_access()) {
1675 wp_send_json_error(['message' => 'Not authorized']);
1676 }
1677
1678 if (!empty($_GET['clear_otto_cache'])) {
1679 delete_transient('metasync_otto_js_detected');
1680 # Re-run the JS detection in the background so the notice refreshes.
1681 metasync_schedule_otto_js_check();
1682 # Cache system has been removed - no cache to clear
1683 wp_send_json_success(['message' => 'Cache system removed - all pages processed in real-time']);
1684 }
1685 else {
1686 wp_send_json_error(['message' => 'Missing parameter']);
1687 }
1688 }
1689
1690 # Clear cache hook
1691 add_action('wp_ajax_metasync_clear_otto_cache', 'metasync_clear_otto_cache_handler');
1692
1693 # add admin action to check script
1694 function metasync_show_otto_ssr_notice() {
1695 if (!Metasync::current_user_has_plugin_access()) {
1696 return; // Only show to admins
1697 }
1698
1699 # Get the plugin name using centralized method
1700 $plugin_name = Metasync::get_effective_plugin_name();
1701 $whitelabel_otto_name = Metasync::get_whitelabel_otto_name();
1702 if (metasync_check_otto_js()) {
1703
1704 # Show admin notice with plugin name included in the message
1705 echo '<div class="notice notice-error">
1706 <p><b>Warning from ' . esc_html($plugin_name) . '</b>
1707 <br>
1708 ' . esc_html($whitelabel_otto_name) . ' JavaScript has been detected on your site. Please remove it and configure ' . esc_html($whitelabel_otto_name) . ' for Wordpress. Contact support for help
1709 </p>
1710 </div>';
1711 }
1712 }
1713
1714 add_action('admin_notices', 'metasync_show_otto_ssr_notice');
1715
1716
1717 # staging dummy change
1718 # load otto in the wp hook
1719 add_action('wp', 'metasync_start_otto');
1720
1721 # ENHANCED OTTO SEO INTEGRATION
1722 # Register async SEO processing hook
1723 add_action('metasync_process_seo_job', 'metasync_process_otto_seo_data', 10, 4);
1724 add_action('metasync_process_otto_crawl_url_job', 'metasync_handle_otto_crawl_url_job', 10, 2);
1725 add_action('metasync_process_otto_batch_cache_job', 'metasync_handle_otto_batch_cache_job', 10, 2);
1726 add_action('metasync_otto_pending_drainer', 'metasync_handle_otto_pending_drainer');
1727
1728 # Process OTTO SEO data and update WordPress meta fields for SEO plugins
1729 # This function now runs asynchronously via WordPress cron system
1730 #
1731 # Returns one of the Metasync_Otto_Job_Status::OUTCOME_* strings so callers
1732 # can distinguish success, confirmed no-change, retryable failure, permanent
1733 # failure, and CPU deferral — a bare boolean cannot drive correct retry and
1734 # purge decisions.
1735 #
1736 # @param string $route Fully-qualified URL to process.
1737 # @param bool $allow_defer When false, skip CPU-deferral rescheduling (used when
1738 # called synchronously from crawl_url_job whose own retry
1739 # mechanism already handles failures).
1740 # @param int $deferral_count How many times this job has already been deferred for
1741 # CPU load. Prevents infinite reschedule loops.
1742 # @param array|null $prefetched_seo_data Suggestions already fetched by the transient
1743 # warm step. When an array it is used as-is so the job
1744 # makes exactly ONE API call per URL; the endpoint is
1745 # only fetched here when no prefetch was provided.
1746
1747 function metasync_process_otto_seo_data($route, $allow_defer = true, $deferral_count = 0, $prefetched_seo_data = null) {
1748 # Maximum number of times a job can be deferred before it is dropped.
1749 $max_deferrals = defined('METASYNC_SEO_JOB_MAX_DEFERRALS') ? METASYNC_SEO_JOB_MAX_DEFERRALS : 5;
1750 $lock_key = null;
1751 $lock_acquired = false;
1752
1753 try {
1754 # Validate input
1755 if (empty($route) || !is_string($route)) {
1756 return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
1757 }
1758
1759 # CPU load check — defer if server is under load.
1760 # Only reschedule when called from the cron hook (allow_defer=true) and
1761 # we haven't exceeded the maximum deferral count.
1762 # Checked BEFORE acquiring the lock so that deferred jobs don't
1763 # acquire-then-immediately-release it (which defeats concurrency protection).
1764 if (!Metasync_CPU_Monitor::is_load_safe()) {
1765 if ($allow_defer) {
1766 if ($deferral_count < $max_deferrals) {
1767 metasync_otto_schedule_single_event(
1768 time() + 60,
1769 'metasync_process_seo_job',
1770 array($route, true, $deferral_count + 1)
1771 );
1772 return Metasync_Otto_Job_Status::OUTCOME_DEFERRED;
1773 }
1774 # deferral budget exhausted — RUN the job late instead
1775 # of dropping it. The write is idempotent and bounded, and a
1776 # late sync is strictly better than SEO data that never
1777 # arrives. No new cron events are created, so the
1778 # anti-pile-up guarantee is preserved. Fall through.
1779 } else {
1780 # allow_defer=false (sync path): report retryable without creating
1781 # cron events — the crawl-url job's retry mechanism (or OTTO's
1782 # next crawl) re-triggers this URL.
1783 return Metasync_Otto_Job_Status::OUTCOME_RETRYABLE;
1784 }
1785 }
1786
1787 # Concurrency lock: prevent multiple SEO jobs from running simultaneously.
1788 # Uses a per-URL transient lock with a 120s TTL as a safety net (the lock is
1789 # explicitly deleted on completion). If the lock exists another run is already
1790 # processing this URL — reschedule once with a short delay instead of stacking.
1791 $lock_key = 'metasync_seo_lock_' . md5($route);
1792 if (get_transient($lock_key) !== false) {
1793 if ($allow_defer && $deferral_count < $max_deferrals) {
1794 metasync_otto_schedule_single_event(
1795 time() + 30,
1796 'metasync_process_seo_job',
1797 array($route, true, $deferral_count + 1)
1798 );
1799 }
1800 return Metasync_Otto_Job_Status::OUTCOME_RETRYABLE;
1801 }
1802 set_transient($lock_key, true, 120);
1803 $lock_acquired = true;
1804
1805 # Resolve redirect table: use final destination URL before 404 checks and OTTO processing
1806 $route = metasync_otto_resolve_redirect_to_final_url($route);
1807
1808 # Skip excluded URLs - don't process SEO data for them
1809 if (metasync_is_otto_url_excluded($route)) {
1810 //error_log('MetaSync OTTO: Skipping SEO processing for excluded URL: ' . $route);
1811 return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
1812 }
1813
1814 # Pre-flight 404 check: exclude URLs that would return 404 before making API call
1815 if (!metasync_otto_is_url_available($route)) {
1816 metasync_otto_auto_exclude_404_url($route);
1817 return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
1818 }
1819
1820 # Get OTTO UUID from settings
1821 # OPTIMIZED: Use cached options
1822 $otto_uuid = Metasync_Otto_Config::get_otto_uuid();
1823
1824 if (empty($otto_uuid)) {
1825 return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
1826 }
1827
1828 # Meta descriptions are always enabled by default - no check needed
1829
1830 # Obtain the OTTO suggestions for this URL. When the caller already
1831 # warmed the transient cache (crawl-url job), those suggestions are
1832 # handed in and NO second API call is made — the endpoint, rate
1833 # limiter, and breaker are only hit once per URL.
1834 # An empty array is a legitimate answer ("OTTO holds nothing for this
1835 # URL") and must not be treated as a fetch failure; only a strict
1836 # false from the fetcher is a failure.
1837 if (is_array($prefetched_seo_data)) {
1838 $seo_data = $prefetched_seo_data;
1839 } else {
1840 $failure_class = null;
1841 $seo_data = metasync_fetch_otto_seo_data($route, $otto_uuid, $failure_class);
1842 if ($seo_data === false) {
1843 if ($failure_class === Metasync_Otto_Job_Status::OUTCOME_PERMANENT) {
1844 # Auth/input-level rejection from the API — retrying the
1845 # same request cannot help.
1846 return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
1847 }
1848 # Timeout / 5xx / 429 / empty body / bad JSON: retryable.
1849 metasync_record_failed_action( 'metasync_process_seo_job' );
1850 return Metasync_Otto_Job_Status::OUTCOME_RETRYABLE;
1851 }
1852 }
1853
1854 # Mark this URL as crawled by OTTO for SSR
1855 # Extract domain and path from route
1856 $parsed_url = wp_parse_url($route);
1857 $domain_with_scheme = ($parsed_url['scheme'] ?? 'https') . '://' . ($parsed_url['host'] ?? '');
1858 $url_path = ($parsed_url['path'] ?? '/');
1859
1860 # Create crawl data structure
1861 $crawl_data = array(
1862 'domain' => $domain_with_scheme,
1863 'urls' => array($url_path)
1864 );
1865
1866 # Load Otto pixel class and save crawl data
1867 $otto_pixel = new Metasync_otto_pixel($otto_uuid);
1868 $otto_pixel->save_crawl_data($crawl_data);
1869
1870 # Get WordPress post ID from URL
1871 $post_id = url_to_postid($route);
1872
1873 # Special handling for WooCommerce shop page (url_to_postid doesn't work for it)
1874 if ((!$post_id || $post_id <= 0) && function_exists('wc_get_page_id')) {
1875 # Check if this URL is the WooCommerce shop page
1876 $shop_page_id = wc_get_page_id('shop');
1877 if ($shop_page_id > 0) {
1878 $shop_url = get_permalink($shop_page_id);
1879 $route_normalized = rtrim($route, '/');
1880 $shop_url_normalized = rtrim($shop_url, '/');
1881
1882 if ($route_normalized === $shop_url_normalized) {
1883 $post_id = $shop_page_id;
1884 }
1885 }
1886 }
1887
1888 # Try to find WooCommerce product by URL if url_to_postid failed
1889 if ((!$post_id || $post_id <= 0) && strpos($route, '/product/') !== false && function_exists('wc_get_products')) {
1890 # Extract product slug from URL
1891 $product_slug = basename(wp_parse_url($route, PHP_URL_PATH));
1892
1893 # Try to get product by slug
1894 $products = wc_get_products(array(
1895 'name' => $product_slug,
1896 'limit' => 1,
1897 'status' => 'publish',
1898 ));
1899
1900 if (empty($products)) {
1901 # Fallback: try by slug using WP_Query
1902 $args = array(
1903 'post_type' => 'product',
1904 'name' => $product_slug,
1905 'posts_per_page' => 1,
1906 'post_status' => 'publish',
1907 );
1908 $query = new WP_Query($args);
1909
1910 if ($query->have_posts()) {
1911 $product_post = $query->posts[0];
1912 $post_id = $product_post->ID;
1913 }
1914 } else {
1915 $product = $products[0];
1916 $post_id = $product->get_id();
1917 }
1918 }
1919
1920 if (!$post_id || $post_id <= 0) {
1921 # Check if this is a category page
1922 if (strpos($route, '/category/') !== false) {
1923 # Extract category slug from URL
1924 $category_slug = basename(wp_parse_url($route, PHP_URL_PATH));
1925 $category = get_category_by_slug($category_slug);
1926
1927 if ($category) {
1928 # Check if category would return 404 before applying OTTO changes
1929 if (metasync_would_term_return_404($category->term_id, 'category', $route)) {
1930 metasync_otto_auto_exclude_404_url($route);
1931 return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
1932 }
1933
1934 # Update comprehensive category SEO meta fields
1935 $update_result = metasync_update_comprehensive_category_seo_fields($category->term_id, $seo_data);
1936
1937 if ($update_result['updated']) {
1938 # Prepare trimmed values to 30 characters
1939 $trim = function($value) {
1940 if ($value === null) { return ''; }
1941 $value = (string) $value;
1942 $value = trim($value);
1943 if (mb_strlen($value) > 30) {
1944 return mb_substr($value, 0, 30);
1945 }
1946 return $value;
1947 };
1948
1949 # Log individual field updates for category
1950 foreach ($update_result['fields_updated'] as $field_type => $field_value) {
1951 $short = '';
1952 $title = '';
1953
1954 switch ($field_type) {
1955 case 'meta_title':
1956 $short = $trim($field_value);
1957 $title = "Category Meta Title Update ({$short}...)";
1958 break;
1959 case 'meta_description':
1960 $short = $trim($field_value);
1961 $title = "Category Meta Description Update ({$short}...)";
1962 break;
1963 case 'meta_keywords':
1964 $short = $trim($field_value);
1965 $title = "Category Meta Keywords Update ({$short}...)";
1966 break;
1967 case 'og_title':
1968 $short = $trim($field_value);
1969 $title = "Category Open Graph Title Update ({$short}...)";
1970 break;
1971 case 'og_description':
1972 $short = $trim($field_value);
1973 $title = "Category Open Graph Description Update ({$short}...)";
1974 break;
1975 case 'twitter_title':
1976 $short = $trim($field_value);
1977 $title = "Category Twitter Title Update ({$short}...)";
1978 break;
1979 case 'twitter_description':
1980 $short = $trim($field_value);
1981 $title = "Category Twitter Description Update ({$short}...)";
1982 break;
1983 case 'image_alt_data':
1984 $image_count = count($field_value);
1985 $title = "Category Image Alt Text Update ({$image_count} images)";
1986 break;
1987 case 'headings_data':
1988 $heading_count = count($field_value);
1989 $title = "Category Headings Update ({$heading_count} headings)";
1990 break;
1991 case 'structured_data':
1992 $title = 'Category Structured Data Update';
1993 break;
1994 }
1995
1996 if (is_scalar($field_value) && trim((string) $field_value) === '') {
1997 $title = str_replace(' Update (...)', ' Cleanup (previous value removed)', $title);
1998 }
1999
2000 if (!empty($title)) {
2001 metasync_log_sync_history([
2002 'title' => $title,
2003 'source' => 'OTTO SEO',
2004 'status' => 'published',
2005 'content_type' => 'Category SEO',
2006 'url' => $route,
2007 'meta_data' => json_encode([
2008 'field' => $field_type,
2009 'field_value' => $field_value,
2010 'category_id' => $category->term_id,
2011 'category_name' => $category->name
2012 ])
2013 ]);
2014 }
2015 }
2016
2017 return Metasync_Otto_Job_Status::OUTCOME_SUCCESS;
2018 }
2019
2020 return Metasync_Otto_Job_Status::OUTCOME_NO_CHANGE;
2021 }
2022 }
2023
2024 # Check if this is a WooCommerce product category
2025 if (strpos($route, '/product-category/') !== false) {
2026 # Extract product category slug from URL
2027 $category_slug = basename(wp_parse_url($route, PHP_URL_PATH));
2028 $term = get_term_by('slug', $category_slug, 'product_cat');
2029
2030 if ($term && !is_wp_error($term)) {
2031 # Check if product category would return 404 before applying OTTO changes
2032 if (metasync_would_term_return_404($term->term_id, 'product_cat', $route)) {
2033 metasync_otto_auto_exclude_404_url($route);
2034 return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
2035 }
2036
2037 # Update comprehensive taxonomy SEO meta fields
2038 $update_result = metasync_update_comprehensive_taxonomy_seo_fields($term->term_id, 'product_cat', $seo_data);
2039
2040 if ($update_result['updated']) {
2041 # Prepare trimmed values to 30 characters
2042 $trim = function($value) {
2043 if ($value === null) { return ''; }
2044 $value = (string) $value;
2045 $value = trim($value);
2046 if (mb_strlen($value) > 30) {
2047 return mb_substr($value, 0, 30);
2048 }
2049 return $value;
2050 };
2051
2052 # Log individual field updates for product category
2053 foreach ($update_result['fields_updated'] as $field_type => $field_value) {
2054 $short = '';
2055 $title = '';
2056
2057 switch ($field_type) {
2058 case 'meta_title':
2059 $short = $trim($field_value);
2060 $title = "Product Category Meta Title Update ({$short}...)";
2061 break;
2062 case 'meta_description':
2063 $short = $trim($field_value);
2064 $title = "Product Category Meta Description Update ({$short}...)";
2065 break;
2066 case 'meta_keywords':
2067 $short = $trim($field_value);
2068 $title = "Product Category Meta Keywords Update ({$short}...)";
2069 break;
2070 case 'og_title':
2071 $short = $trim($field_value);
2072 $title = "Product Category Open Graph Title Update ({$short}...)";
2073 break;
2074 case 'og_description':
2075 $short = $trim($field_value);
2076 $title = "Product Category Open Graph Description Update ({$short}...)";
2077 break;
2078 case 'twitter_title':
2079 $short = $trim($field_value);
2080 $title = "Product Category Twitter Title Update ({$short}...)";
2081 break;
2082 case 'twitter_description':
2083 $short = $trim($field_value);
2084 $title = "Product Category Twitter Description Update ({$short}...)";
2085 break;
2086 case 'image_alt_data':
2087 $image_count = count($field_value);
2088 $title = "Product Category Image Alt Text Update ({$image_count} images)";
2089 break;
2090 case 'headings_data':
2091 $heading_count = count($field_value);
2092 $title = "Product Category Headings Update ({$heading_count} headings)";
2093 break;
2094 case 'structured_data':
2095 $title = 'Product Category Structured Data Update';
2096 break;
2097 }
2098
2099 if (is_scalar($field_value) && trim((string) $field_value) === '') {
2100 $title = str_replace(' Update (...)', ' Cleanup (previous value removed)', $title);
2101 }
2102
2103 if (!empty($title)) {
2104 metasync_log_sync_history([
2105 'title' => $title,
2106 'source' => 'OTTO SEO',
2107 'status' => 'published',
2108 'content_type' => 'WooCommerce Product Category SEO',
2109 'url' => $route,
2110 'meta_data' => json_encode([
2111 'field' => $field_type,
2112 'field_value' => $field_value,
2113 'term_id' => $term->term_id,
2114 'term_name' => $term->name,
2115 'taxonomy' => 'product_cat'
2116 ])
2117 ]);
2118 }
2119 }
2120
2121 return Metasync_Otto_Job_Status::OUTCOME_SUCCESS;
2122 }
2123
2124 return Metasync_Otto_Job_Status::OUTCOME_NO_CHANGE;
2125 }
2126 }
2127
2128 # Check if this is the home page (landing page)
2129 $site_url = rtrim(site_url(), '/');
2130 $route_clean = rtrim($route, '/');
2131
2132 if ($route_clean === $site_url) {
2133 # Get the home page (front page)
2134 $front_page_id = get_option('page_on_front');
2135 $home_page = null;
2136
2137 if ($front_page_id && $front_page_id > 0) {
2138 $home_page = get_post($front_page_id);
2139 } else {
2140 # If no static front page is set, get the latest post
2141 $home_page = get_posts(['numberposts' => 1, 'post_status' => 'publish'])[0] ?? null;
2142 }
2143
2144 if ($home_page) {
2145 # Check if home page would return 404 before applying OTTO changes
2146 if (metasync_would_page_return_404($home_page->ID, $route)) {
2147 metasync_otto_auto_exclude_404_url($route);
2148 return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
2149 }
2150
2151 # Update comprehensive home page SEO meta fields
2152 $update_result = metasync_update_comprehensive_seo_fields($home_page->ID, $seo_data);
2153
2154 if ($update_result['updated']) {
2155 # Clear relevant caches
2156 metasync_clear_post_seo_caches($home_page->ID);
2157
2158 # Prepare trimmed values to 30 characters
2159 $trim = function($value) {
2160 if ($value === null) { return ''; }
2161 $value = (string) $value;
2162 $value = trim($value);
2163 if (mb_strlen($value) > 30) {
2164 return mb_substr($value, 0, 30);
2165 }
2166 return $value;
2167 };
2168
2169 # Log individual field updates for home page
2170 foreach ($update_result['fields_updated'] as $field_type => $field_value) {
2171 $short = '';
2172 $title = '';
2173
2174 switch ($field_type) {
2175 case 'meta_title':
2176 $short = $trim($field_value);
2177 $title = "Home Page Meta Title Update ({$short}...)";
2178 break;
2179 case 'meta_description':
2180 $short = $trim($field_value);
2181 $title = "Home Page Meta Description Update ({$short}...)";
2182 break;
2183 case 'meta_keywords':
2184 $short = $trim($field_value);
2185 $title = "Home Page Meta Keywords Update ({$short}...)";
2186 break;
2187 case 'og_title':
2188 $short = $trim($field_value);
2189 $title = "Home Page Open Graph Title Update ({$short}...)";
2190 break;
2191 case 'og_description':
2192 $short = $trim($field_value);
2193 $title = "Home Page Open Graph Description Update ({$short}...)";
2194 break;
2195 case 'twitter_title':
2196 $short = $trim($field_value);
2197 $title = "Home Page Twitter Title Update ({$short}...)";
2198 break;
2199 case 'twitter_description':
2200 $short = $trim($field_value);
2201 $title = "Home Page Twitter Description Update ({$short}...)";
2202 break;
2203 case 'image_alt_data':
2204 $image_count = count($field_value);
2205 $title = "Home Page Image Alt Text Update ({$image_count} images)";
2206 break;
2207 case 'headings_data':
2208 $heading_count = count($field_value);
2209 $title = "Home Page Headings Update ({$heading_count} headings)";
2210 break;
2211 case 'structured_data':
2212 $title = 'Home Page Structured Data Update';
2213 break;
2214 }
2215
2216 if (is_scalar($field_value) && trim((string) $field_value) === '') {
2217 $title = str_replace(' Update (...)', ' Cleanup (previous value removed)', $title);
2218 }
2219
2220 if (!empty($title)) {
2221 metasync_log_sync_history([
2222 'title' => $title,
2223 'source' => 'OTTO SEO',
2224 'status' => 'published',
2225 'content_type' => 'Home Page SEO',
2226 'url' => $route,
2227 'meta_data' => json_encode([
2228 'field' => $field_type,
2229 'field_value' => $field_value,
2230 'post_id' => $home_page->ID
2231 ])
2232 ]);
2233 }
2234 }
2235
2236 return Metasync_Otto_Job_Status::OUTCOME_SUCCESS;
2237 }
2238
2239 return Metasync_Otto_Job_Status::OUTCOME_NO_CHANGE;
2240 }
2241 }
2242
2243 # Check if this is the blog/posts page (page_for_posts)
2244 $posts_page_id = intval(get_option('page_for_posts'));
2245 if ($posts_page_id > 0) {
2246 $posts_page = get_post($posts_page_id);
2247 $posts_page_url = rtrim(get_permalink($posts_page_id), '/');
2248
2249 if ($posts_page && $route_clean === $posts_page_url) {
2250 # Check if blog page would return 404
2251 if (metasync_would_page_return_404($posts_page->ID, $route)) {
2252 metasync_otto_auto_exclude_404_url($route);
2253 return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
2254 }
2255
2256 # Update comprehensive blog page SEO meta fields
2257 $update_result = metasync_update_comprehensive_seo_fields($posts_page->ID, $seo_data);
2258
2259 if ($update_result['updated']) {
2260 # Clear relevant caches
2261 metasync_clear_post_seo_caches($posts_page->ID);
2262
2263 # Prepare trimmed values to 30 characters
2264 $trim = function($value) {
2265 if ($value === null) { return ''; }
2266 $value = (string) $value;
2267 $value = trim($value);
2268 if (mb_strlen($value) > 30) {
2269 return mb_substr($value, 0, 30);
2270 }
2271 return $value;
2272 };
2273
2274 # Log individual field updates for blog page
2275 foreach ($update_result['fields_updated'] as $field_type => $field_value) {
2276 $short = '';
2277 $title = '';
2278
2279 switch ($field_type) {
2280 case 'meta_title':
2281 $short = $trim($field_value);
2282 $title = "Blog Page Meta Title Update ({$short}...)";
2283 break;
2284 case 'meta_description':
2285 $short = $trim($field_value);
2286 $title = "Blog Page Meta Description Update ({$short}...)";
2287 break;
2288 case 'meta_keywords':
2289 $short = $trim($field_value);
2290 $title = "Blog Page Meta Keywords Update ({$short}...)";
2291 break;
2292 case 'og_title':
2293 $short = $trim($field_value);
2294 $title = "Blog Page Open Graph Title Update ({$short}...)";
2295 break;
2296 case 'og_description':
2297 $short = $trim($field_value);
2298 $title = "Blog Page Open Graph Description Update ({$short}...)";
2299 break;
2300 case 'twitter_title':
2301 $short = $trim($field_value);
2302 $title = "Blog Page Twitter Title Update ({$short}...)";
2303 break;
2304 case 'twitter_description':
2305 $short = $trim($field_value);
2306 $title = "Blog Page Twitter Description Update ({$short}...)";
2307 break;
2308 case 'image_alt_data':
2309 $image_count = count($field_value);
2310 $title = "Blog Page Image Alt Text Update ({$image_count} images)";
2311 break;
2312 case 'headings_data':
2313 $heading_count = count($field_value);
2314 $title = "Blog Page Headings Update ({$heading_count} headings)";
2315 break;
2316 case 'structured_data':
2317 $title = 'Blog Page Structured Data Update';
2318 break;
2319 }
2320
2321 if (is_scalar($field_value) && trim((string) $field_value) === '') {
2322 $title = str_replace(' Update (...)', ' Cleanup (previous value removed)', $title);
2323 }
2324
2325 if (!empty($title)) {
2326 metasync_log_sync_history([
2327 'title' => $title,
2328 'source' => 'OTTO SEO',
2329 'status' => 'published',
2330 'content_type' => 'Blog Page SEO',
2331 'url' => $route,
2332 'meta_data' => json_encode([
2333 'field' => $field_type,
2334 'field_value' => $field_value,
2335 'post_id' => $posts_page->ID
2336 ])
2337 ]);
2338 }
2339 }
2340
2341 return Metasync_Otto_Job_Status::OUTCOME_SUCCESS;
2342 }
2343
2344 return Metasync_Otto_Job_Status::OUTCOME_NO_CHANGE;
2345 }
2346 }
2347
2348 # URL didn't resolve to any supported entity (post, category, home page, blog page)
2349 # Treat as 404 and auto-exclude (e.g. deleted post, non-existent page)
2350 if (!metasync_otto_is_url_available($route)) {
2351 metasync_otto_auto_exclude_404_url($route);
2352 }
2353 return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
2354 }
2355
2356 # Verify this is actually a post, page, or WooCommerce product
2357 $post = get_post($post_id);
2358
2359 # Get supported post types dynamically
2360 $supported_post_types = metasync_get_supported_post_types();
2361
2362 if (!$post || !in_array($post->post_type, $supported_post_types)) {
2363 # Skip unsupported post types
2364 return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
2365 }
2366
2367 # Check if page would return 404 before applying OTTO changes
2368 if (metasync_would_page_return_404($post_id, $route)) {
2369 metasync_otto_auto_exclude_404_url($route);
2370 return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
2371 }
2372
2373 # Update comprehensive SEO meta fields
2374 $update_result = metasync_update_comprehensive_seo_fields($post_id, $seo_data);
2375
2376 if ($update_result['updated']) {
2377 # Clear relevant caches
2378 metasync_clear_post_seo_caches($post_id);
2379
2380 # Prepare trimmed values to 30 characters
2381 $trim = function($value) {
2382 if ($value === null) { return ''; }
2383 $value = (string) $value;
2384 $value = trim($value);
2385 if (mb_strlen($value) > 30) {
2386 return mb_substr($value, 0, 30);
2387 }
2388 return $value;
2389 };
2390
2391 # Log individual field updates
2392 foreach ($update_result['fields_updated'] as $field_type => $field_value) {
2393 $short = '';
2394 $title = '';
2395
2396 switch ($field_type) {
2397 case 'meta_title':
2398 $short = $trim($field_value);
2399 $title = 'Meta Title Update (' . $short . '...)';
2400 break;
2401 case 'meta_description':
2402 $short = $trim($field_value);
2403 $title = 'Meta Description Update (' . $short . '...)';
2404 break;
2405 case 'meta_keywords':
2406 $short = $trim($field_value);
2407 $title = 'Meta Keywords Update (' . $short . '...)';
2408 break;
2409 case 'og_title':
2410 $short = $trim($field_value);
2411 $title = 'Open Graph Title Update (' . $short . '...)';
2412 break;
2413 case 'og_description':
2414 $short = $trim($field_value);
2415 $title = 'Open Graph Description Update (' . $short . '...)';
2416 break;
2417 case 'twitter_title':
2418 $short = $trim($field_value);
2419 $title = 'Twitter Title Update (' . $short . '...)';
2420 break;
2421 case 'twitter_description':
2422 $short = $trim($field_value);
2423 $title = 'Twitter Description Update (' . $short . '...)';
2424 break;
2425 case 'image_alt_data':
2426 $image_count = count($field_value);
2427 $title = "Image Alt Text Update ({$image_count} images)";
2428 break;
2429 case 'headings_data':
2430 $heading_count = count($field_value);
2431 $title = "Headings Update ({$heading_count} headings)";
2432 break;
2433 case 'structured_data':
2434 $title = 'Structured Data Update';
2435 break;
2436 }
2437
2438 if (is_scalar($field_value) && trim((string) $field_value) === '') {
2439 $title = str_replace(' Update (...)', ' Cleanup (previous value removed)', $title);
2440 }
2441
2442 if (!empty($title)) {
2443 metasync_log_sync_history([
2444 'title' => $title,
2445 'source' => 'OTTO SEO',
2446 'status' => 'published',
2447 'content_type' => 'SEO Meta',
2448 'url' => $route,
2449 'meta_data' => json_encode([
2450 'field' => $field_type,
2451 'field_value' => $field_value,
2452 'post_id' => $post_id
2453 ])
2454 ]);
2455 }
2456 }
2457
2458 return Metasync_Otto_Job_Status::OUTCOME_SUCCESS;
2459 }
2460
2461 return Metasync_Otto_Job_Status::OUTCOME_NO_CHANGE;
2462
2463 } catch (Exception $e) {
2464 metasync_record_failed_action( 'metasync_process_seo_job' );
2465 return Metasync_Otto_Job_Status::OUTCOME_RETRYABLE;
2466 } finally {
2467 # Release the concurrency lock only if we actually acquired it.
2468 # Early returns (CPU deferral, lock contention) must NOT delete a lock
2469 # that another process may be holding.
2470 if ($lock_acquired && $lock_key) {
2471 delete_transient($lock_key);
2472 }
2473 }
2474 }
2475
2476 /**
2477 * Log sync history entry
2478 * @param array $data Sync data to log
2479 */
2480 function metasync_log_sync_history($data) {
2481 try {
2482 // Classes are now autoloaded, no need for manual require
2483 $sync_db = new Metasync_Sync_History_Database();
2484
2485 // Minimal duplicate prevention within short time window
2486 if (!empty($data['title']) && !empty($data['source'])) {
2487 global $wpdb;
2488 $recent = $wpdb->get_var($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- sync-history duplicate guard over the plugin custom table — write-path freshness required
2489 "SELECT COUNT(*) FROM `{$wpdb->prefix}metasync_sync_history` WHERE title = %s AND source = %s AND created_at >= %s",
2490 $data['title'],
2491 $data['source'],
2492 gmdate('Y-m-d H:i:s', time() - 60)
2493 ));
2494 if ((int)$recent > 0) {
2495 return; // skip duplicate log within 60 seconds
2496 }
2497 }
2498
2499 $sync_db->add($data);
2500
2501 } catch (Exception $e) {
2502 error_log("MetaSync: Failed to log sync history: " . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
2503 }
2504 }
2505
2506 /**
2507 * Get supported post types for OTTO SEO optimization
2508 * Includes WooCommerce products if WooCommerce is active
2509 *
2510 * @return array List of supported post types
2511 */
2512 function metasync_get_supported_post_types() {
2513 # Start with default post types
2514 $post_types = ['post', 'page'];
2515
2516 # Add WooCommerce product post type if WooCommerce is active
2517 if (class_exists('WooCommerce') || function_exists('is_woocommerce')) {
2518 $post_types[] = 'product';
2519 }
2520
2521 # Include all public custom post types (e.g. 'location', 'service', 'team', etc.)
2522 # so OTTO can write post meta for them during metasync_process_otto_seo_data().
2523 $custom_post_types = get_post_types(['public' => true, '_builtin' => false], 'names');
2524 if (!empty($custom_post_types)) {
2525 $post_types = array_merge($post_types, array_values($custom_post_types));
2526 }
2527
2528 # Allow developers to filter supported post types
2529 $post_types = apply_filters('metasync_otto_supported_post_types', $post_types);
2530
2531 return $post_types;
2532 }
2533
2534 /**
2535 * Get supported taxonomies for OTTO SEO optimization
2536 * Includes WooCommerce product categories and tags if WooCommerce is active
2537 *
2538 * @return array List of supported taxonomies
2539 */
2540 function metasync_get_supported_taxonomies() {
2541 # Start with default taxonomies
2542 $taxonomies = ['category'];
2543
2544 # Add WooCommerce taxonomies if WooCommerce is active
2545 if (class_exists('WooCommerce') || function_exists('is_woocommerce')) {
2546 $taxonomies[] = 'product_cat'; # WooCommerce product categories
2547 $taxonomies[] = 'product_tag'; # WooCommerce product tags
2548 }
2549
2550 # Allow developers to filter supported taxonomies
2551 $taxonomies = apply_filters('metasync_otto_supported_taxonomies', $taxonomies);
2552
2553 return $taxonomies;
2554 }
2555
2556 /**
2557 * Resolve a URL through the Redirect Manager table to its final destination (follows redirect chains).
2558 * Used before 404 checks and OTTO processing so the final canonical URL is used, not intermediate redirects.
2559 *
2560 * @param string $url Full URL (e.g. https://example.com/old-page)
2561 * @return string Final destination URL, or original $url if no redirect matches
2562 */
2563 function metasync_otto_resolve_redirect_to_final_url($url)
2564 {
2565 if (empty($url) || !is_string($url)) {
2566 return $url;
2567 }
2568 try {
2569 $db_path = plugin_dir_path(dirname(__FILE__)) . 'redirections/class-metasync-redirection-database.php';
2570 $class_path = plugin_dir_path(dirname(__FILE__)) . 'redirections/class-metasync-redirection.php';
2571 if (!file_exists($db_path) || !file_exists($class_path)) {
2572 return $url;
2573 }
2574 require_once $db_path;
2575 require_once $class_path;
2576 $db = new Metasync_Redirection_Database();
2577 $redirect = new Metasync_Redirection($db);
2578 return $redirect->resolve_url_to_final_destination($url, 10);
2579 } catch (Exception $e) {
2580 error_log('MetaSync OTTO: Redirect resolution failed for ' . $url . ' - ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
2581 return $url;
2582 }
2583 }
2584
2585 /**
2586 * Auto-exclude a URL from OTTO with description "Auto-excluded: 404"
2587 * Called when a URL is detected as returning 404 so it won't be sent to OTTO again
2588 *
2589 * @param string $url Full URL to exclude (e.g. https://example.com/404-page)
2590 * @return bool|string True on success, false on failure, 'duplicate'/'reactivated' if already exists
2591 */
2592 function metasync_otto_auto_exclude_404_url($url)
2593 {
2594 if (empty($url) || !is_string($url)) {
2595 return false;
2596 }
2597 $url = filter_var($url, FILTER_SANITIZE_URL);
2598 $url = esc_url_raw($url);
2599 if (empty($url) || mb_strlen($url) > 2048) {
2600 return false;
2601 }
2602 try {
2603 require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php';
2604 $db = new Metasync_Otto_Excluded_URLs_Database();
2605 return $db->add([
2606 'url_pattern' => $url,
2607 'pattern_type' => 'exact',
2608 'description' => 'Auto-excluded: 404',
2609 'status' => 'active',
2610 'auto_excluded' => 1,
2611 ]);
2612 } catch (Exception $e) {
2613 error_log('MetaSync OTTO: Failed to auto-exclude 404 URL: ' . $url . ' - ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
2614 return false;
2615 }
2616 }
2617
2618 /**
2619 * Remove a URL from the OTTO auto-exclusion list.
2620 * Called when OTTO sends a webhook for a URL, confirming it is valid and crawlable.
2621 * Only removes records where auto_excluded = 1 (never removes manual exclusions).
2622 *
2623 * @param string $url Full URL to un-exclude (e.g. https://example.com/location/page)
2624 * @return bool True on success
2625 */
2626 function metasync_otto_remove_auto_exclusion($url)
2627 {
2628 if (empty($url) || !is_string($url)) {
2629 return false;
2630 }
2631 try {
2632 require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php';
2633 $db = new Metasync_Otto_Excluded_URLs_Database();
2634 global $wpdb;
2635 # Normalize the same way is_url_excluded() does
2636 $url_normalized = rtrim(trim($url), '/');
2637 $records = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- webhook-path cleanup of auto-exclusions over the plugin custom table
2638 $wpdb->prepare(
2639 "SELECT id FROM `{$wpdb->prefix}metasync_otto_excluded_urls` WHERE url_pattern = %s AND auto_excluded = 1 AND status = 'active'",
2640 $url_normalized
2641 )
2642 );
2643 if (!empty($records)) {
2644 $ids = array_map(function ($r) { return (int) $r->id; }, $records);
2645 $db->delete($ids);
2646 }
2647 return true;
2648 } catch (Exception $e) {
2649 return false;
2650 }
2651 }
2652
2653 /**
2654 * Check if a URL is MANUALLY excluded from OTTO (auto_excluded = 0).
2655 * Used at render time (metasync_start_otto) — auto-exclusions must NOT block
2656 * rendering because they are often false positives (e.g. custom post types that
2657 * url_to_postid() can't resolve). Auto-exclusions are only used to gate the
2658 * SEO meta-writing webhook path.
2659 *
2660 * @param string $url URL to check
2661 * @return bool True if URL has a manual exclusion
2662 */
2663 function metasync_is_otto_url_manually_excluded($url)
2664 {
2665 if (empty($url) || !is_string($url)) {
2666 return false;
2667 }
2668 try {
2669 require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php';
2670 global $wpdb;
2671 $url_normalized = rtrim(trim($url), '/');
2672
2673 $records = get_transient(METASYNC_OTTO_EXCLUDED_TRANSIENT_KEY);
2674
2675 if ($records === false) {
2676 $records = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- render-path read IS cached via get/set_transient with invalidation on every write — wp_cache_* is not used because the payload must survive object-cache-only setups
2677 "SELECT url_pattern, pattern_type FROM `{$wpdb->prefix}metasync_otto_excluded_urls` WHERE status = 'active' AND (auto_excluded = 0 OR auto_excluded IS NULL) ORDER BY created_at DESC"
2678 );
2679
2680 // Graceful recovery: auto_excluded column missing on pre-v2.7.4 installs.
2681 // Schema changes belong to the upgrade routines, never the render path:
2682 // degrade to "not excluded" so OTTO keeps rendering, and schedule a
2683 // one-shot background repair that runs the same column migration off
2684 // the visitor request (handled by metasync_repair_otto_excluded_schema).
2685 if ($wpdb->last_error && strpos($wpdb->last_error, 'auto_excluded') !== false) {
2686 if (false === wp_next_scheduled('metasync_repair_otto_excluded_schema')) {
2687 wp_schedule_single_event(time() + MINUTE_IN_SECONDS, 'metasync_repair_otto_excluded_schema');
2688 }
2689 return false;
2690 }
2691
2692 set_transient(METASYNC_OTTO_EXCLUDED_TRANSIENT_KEY, $records ?: [], METASYNC_OTTO_EXCLUDED_TRANSIENT_TTL);
2693 }
2694
2695 if (empty($records)) {
2696 return false;
2697 }
2698
2699 foreach ($records as $excluded) {
2700 # Delegate to the shared matcher so this gate supports every pattern type the
2701 # add handler accepts (exact|contain|start|end|regex). Keeping a second switch
2702 # here is what let `end` and `regex` exclusions be silently ignored on the
2703 # visitor render path while still applying on the queue/SEO-write path.
2704 # The raw pattern is passed through - the matcher normalizes it per type,
2705 # because a regex must keep the trailing slash that closes its delimiters.
2706 if (Metasync_Otto_Excluded_URLs_Database::pattern_matches($url_normalized, $excluded->url_pattern, $excluded->pattern_type)) {
2707 return true;
2708 }
2709 }
2710 return false;
2711 } catch (Exception $e) {
2712 return false;
2713 }
2714 }
2715
2716 /**
2717 * Check if a URL is excluded from OTTO
2718 * @param string $url URL to check
2719 * @return bool True if URL is excluded, false otherwise
2720 */
2721 function metasync_is_otto_url_excluded($url)
2722 {
2723 try {
2724 // Load database class
2725 require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php';
2726 $db = new Metasync_Otto_Excluded_URLs_Database();
2727
2728 // Check if URL is excluded
2729 return $db->is_url_excluded($url);
2730
2731 } catch (Exception $e) {
2732 return false;
2733 }
2734 }
2735
2736 /**
2737 * Check if a URL is now available (would NOT return 404)
2738 * Uses same resolution logic as metasync_process_otto_seo_data
2739 * Used when rechecking auto-excluded 404 URLs after 7 days
2740 *
2741 * @param string $url Full URL to check (e.g. https://example.com/page)
2742 * @return bool True if URL is accessible, false if it would return 404
2743 */
2744 function metasync_otto_is_url_available($url)
2745 {
2746 if (empty($url) || !is_string($url)) {
2747 return false;
2748 }
2749
2750 $route = metasync_otto_resolve_redirect_to_final_url($url);
2751 $post_id = url_to_postid($route);
2752
2753 # WooCommerce shop page
2754 if ((!$post_id || $post_id <= 0) && function_exists('wc_get_page_id')) {
2755 $shop_page_id = wc_get_page_id('shop');
2756 if ($shop_page_id > 0) {
2757 $shop_url = get_permalink($shop_page_id);
2758 if (rtrim($route, '/') === rtrim($shop_url, '/')) {
2759 $post_id = $shop_page_id;
2760 }
2761 }
2762 }
2763
2764 # WooCommerce product by slug
2765 if ((!$post_id || $post_id <= 0) && strpos($route, '/product/') !== false && function_exists('wc_get_products')) {
2766 $product_slug = basename(wp_parse_url($route, PHP_URL_PATH));
2767 $products = wc_get_products(array('name' => $product_slug, 'limit' => 1, 'status' => 'publish'));
2768 if (!empty($products)) {
2769 $post_id = $products[0]->get_id();
2770 } else {
2771 $query = new WP_Query(array(
2772 'post_type' => 'product',
2773 'name' => $product_slug,
2774 'posts_per_page' => 1,
2775 'post_status' => 'publish',
2776 ));
2777 if ($query->have_posts()) {
2778 $post_id = $query->posts[0]->ID;
2779 }
2780 }
2781 }
2782
2783 if ($post_id && $post_id > 0) {
2784 $post = get_post($post_id);
2785 # Accept any post type — custom post types (e.g. 'location', 'service') are valid URLs.
2786 # The old in_array check against metasync_get_supported_post_types() caused CPT URLs
2787 # to be wrongly auto-excluded as "404" pages.
2788 if ($post) {
2789 return !metasync_would_page_return_404($post_id, $route);
2790 }
2791 }
2792
2793 # Category
2794 if (strpos($route, '/category/') !== false) {
2795 $category_slug = basename(wp_parse_url($route, PHP_URL_PATH));
2796 $category = get_category_by_slug($category_slug);
2797 if ($category) {
2798 return !metasync_would_term_return_404($category->term_id, 'category', $route);
2799 }
2800 }
2801
2802 # WooCommerce product category
2803 if (strpos($route, '/product-category/') !== false) {
2804 $category_slug = basename(wp_parse_url($route, PHP_URL_PATH));
2805 $term = get_term_by('slug', $category_slug, 'product_cat');
2806 if ($term && !is_wp_error($term)) {
2807 return !metasync_would_term_return_404($term->term_id, 'product_cat', $route);
2808 }
2809 }
2810
2811 # Home page
2812 if (rtrim($route, '/') === rtrim(site_url(), '/')) {
2813 $front_page_id = get_option('page_on_front');
2814 $home_page = ($front_page_id && $front_page_id > 0)
2815 ? get_post($front_page_id)
2816 : (get_posts(['numberposts' => 1, 'post_status' => 'publish'])[0] ?? null);
2817 if ($home_page) {
2818 return !metasync_would_page_return_404($home_page->ID, $route);
2819 }
2820 }
2821
2822 # Could not verify availability from local data (custom archive, paginated page, etc.).
2823 # Assume the URL IS available — OTTO only crawls reachable URLs, so if we can't
2824 # prove it's a 404, we should not auto-exclude it.
2825 return true;
2826 }
2827
2828 /**
2829 * Recheck auto-excluded 404 URLs when recheck_after has passed; remove from exclusion if now available
2830 * Uses recheck_after timestamp (default 7 days from exclusion) to decide when to recheck
2831 * Mark as permanent after 30 days if still 404 (no further rechecks)
2832 * Called by daily cron job
2833 */
2834 function metasync_otto_recheck_404_exclusions()
2835 {
2836 try {
2837 require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php';
2838 $db = new Metasync_Otto_Excluded_URLs_Database();
2839 $records = $db->get_auto_excluded_404_urls_due_for_recheck();
2840
2841 if (empty($records)) {
2842 return;
2843 }
2844
2845 $removed = 0;
2846 $marked_permanent = 0;
2847 $thirty_days_ago = strtotime('-30 days');
2848 $next_recheck = gmdate('Y-m-d H:i:s', current_time('timestamp') + 7 * DAY_IN_SECONDS);
2849
2850 foreach ($records as $record) {
2851 $url = trim($record->url_pattern);
2852 if (empty($url)) {
2853 continue;
2854 }
2855 if (metasync_otto_is_url_available($url)) {
2856 $db->delete([$record->id]);
2857 $removed++;
2858 } else {
2859 # Still 404: if excluded 30+ days ago, mark as permanent (no more rechecks)
2860 $created_ts = strtotime($record->created_at);
2861 if ($created_ts <= $thirty_days_ago) {
2862 $db->update(['is_permanent' => 1], $record->id);
2863 $marked_permanent++;
2864 } else {
2865 # Schedule next recheck in 7 days
2866 $db->update(['recheck_after' => $next_recheck], $record->id);
2867 }
2868 }
2869 }
2870
2871 } catch (Exception $e) {
2872 error_log('MetaSync OTTO: Recheck 404 exclusions failed - ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
2873 }
2874 }
2875
2876 add_action('metasync_otto_recheck_404_exclusions', 'metasync_otto_recheck_404_exclusions');
2877
2878 /**
2879 * Background repair of the metasync_otto_excluded_urls schema (cron).
2880 *
2881 * Scheduled by metasync_is_otto_url_manually_excluded() when a render-path
2882 * read fails because the auto_excluded column is missing (pre-2.7.4 install
2883 * whose version migration did not run). Runs the DB layer's self-healing
2884 * column migration off the visitor request instead of ALTERing inline.
2885 */
2886 function metasync_repair_otto_excluded_schema()
2887 {
2888 require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php';
2889 Metasync_Otto_Excluded_URLs_Database::ensure_schema();
2890 }
2891
2892 add_action('metasync_repair_otto_excluded_schema', 'metasync_repair_otto_excluded_schema');
2893
2894 /**
2895 * Check if a post/page would return 404 without making HTTP request
2896 * Uses WordPress database checks for fast validation
2897 *
2898 * @param int $post_id WordPress post ID
2899 * @param string $url The URL being checked (optional, for logging)
2900 * @return bool True if page would return 404, false if accessible
2901 */
2902 function metasync_would_page_return_404($post_id, $url = '') {
2903 if (!$post_id || $post_id <= 0) {
2904 return true; // No post ID = 404
2905 }
2906
2907 # Get the post object
2908 $post = get_post($post_id);
2909 if (!$post) {
2910 return true; // Post doesn't exist = 404
2911 }
2912
2913 # 1. Check post status - must be 'publish' to be publicly accessible
2914 if ($post->post_status !== 'publish') {
2915 return true; // Draft, pending, private, etc. = 404
2916 }
2917
2918 # 2. Check if post is password protected (requires password to view)
2919 if (!empty($post->post_password)) {
2920 # Password protected posts are not publicly accessible without password
2921 return true; // Password protected = effectively 404 for public
2922 }
2923
2924 # 3. Check if post is in trash
2925 if ($post->post_status === 'trash') {
2926 return true; // Trashed = 404
2927 }
2928
2929 # 4. Check if post type is publicly queryable
2930 $post_type_object = get_post_type_object($post->post_type);
2931 if ($post_type_object && !$post_type_object->publicly_queryable) {
2932 # Some post types might not be publicly accessible
2933 # But we allow if it's in our supported types
2934 $supported_post_types = metasync_get_supported_post_types();
2935 if (!in_array($post->post_type, $supported_post_types)) {
2936 return true; // Not publicly queryable = 404
2937 }
2938 }
2939
2940 # 5. WordPress 5.7+ has a built-in function for this
2941 if (function_exists('is_post_publicly_viewable')) {
2942 if (!is_post_publicly_viewable($post)) {
2943 return true; // Not publicly viewable = 404
2944 }
2945 }
2946
2947 # 6. Check if post is scheduled for future (not yet published)
2948 if ($post->post_date > current_time('mysql')) {
2949 return true; // Future post = 404 until publish date
2950 }
2951
2952 # All checks passed - page should be accessible
2953 return false;
2954 }
2955
2956 /**
2957 * Check if a taxonomy term (category, tag, etc.) would return 404
2958 * Uses WordPress database checks for fast validation
2959 *
2960 * @param int $term_id Term ID
2961 * @param string $taxonomy Taxonomy name (e.g., 'category', 'product_cat')
2962 * @param string $url The URL being checked (optional, for logging)
2963 * @return bool True if term would return 404, false if accessible
2964 */
2965 function metasync_would_term_return_404($term_id, $taxonomy, $url = '') {
2966 if (!$term_id || $term_id <= 0 || empty($taxonomy)) {
2967 return true; // Invalid term = 404
2968 }
2969
2970 # Get the term object
2971 $term = get_term($term_id, $taxonomy);
2972 if (is_wp_error($term) || !$term) {
2973 return true; // Term doesn't exist = 404
2974 }
2975
2976 # Check if taxonomy is publicly queryable
2977 $taxonomy_object = get_taxonomy($taxonomy);
2978 if (!$taxonomy_object || !$taxonomy_object->public) {
2979 # Check if it's in our supported taxonomies
2980 $supported_taxonomies = metasync_get_supported_taxonomies();
2981 if (!in_array($taxonomy, $supported_taxonomies)) {
2982 return true; // Not publicly queryable = 404
2983 }
2984 }
2985
2986 # Terms are generally always accessible if they exist and taxonomy is public
2987 # WordPress doesn't have a "draft" status for terms like posts do
2988 # But we can check if the term has a count (has posts assigned)
2989 # Empty terms might not be useful, but they're still accessible
2990
2991 # All checks passed - term should be accessible
2992 return false;
2993 }
2994
2995 /**
2996 * Invalidate Brizy posts cache when posts are saved
2997 * OPTIMIZATION: Clears transient cache to ensure accurate detection
2998 */
2999 add_action('save_post', function($post_id) {
3000 # Check if this post has Brizy metadata
3001 if (get_post_meta($post_id, 'brizy_post_uid', true)) {
3002 delete_transient('metasync_has_brizy_posts');
3003 }
3004 }, 10, 1);
3005
3006 /**
3007 * Identify infrastructure agents that must never hit the render throttle.
3008 *
3009 * Two classes of agent are covered:
3010 *
3011 * - Page-cache warmers. A host preloader (WP Cloud PageCacheBot, WP Rocket preload,
3012 * LiteSpeed, SG Optimizer, ...) exists purely to populate the page cache. Throttling
3013 * one returns un-OTTO'd HTML *and* defines DONOTCACHEPAGE, so the cache can never be
3014 * filled and every subsequent human visitor pays a full uncached render. Measured on
3015 * the reporting site: desktop cache MISS on 17/17 requests at ~2.8s TTFB, versus
3016 * ~0.56s with OTTO disabled.
3017 *
3018 * - Synthetic performance auditors (Lighthouse / PageSpeed Insights / GTmetrix).
3019 * These are what customers measure with, so they must receive the same fully
3020 * rendered OTTO output a real visitor gets. Currently `/lighthouse/i` and
3021 * `/pagespeed/i` are generic-bot patterns, so a second PSI run inside the 5-minute
3022 * window is served the throttled, un-OTTO'd, uncacheable page.
3023 *
3024 * Matching is done on the raw user-agent, deliberately independent of the bot-name
3025 * categorisation in Metasync_Otto_Bot_Detector.
3026 *
3027 * @param mixed $detection Result of Metasync_Otto_Bot_Detector::detect(), or malformed input.
3028 * @return bool True when this request must bypass the render throttle.
3029 */
3030 function metasync_otto_is_unthrottled_infrastructure_agent( $detection = array() ) {
3031
3032 $ua = '';
3033 if ( is_array( $detection ) && ! empty( $detection['user_agent'] ) ) {
3034 $ua = (string) $detection['user_agent'];
3035 } elseif ( isset( $_SERVER['HTTP_USER_AGENT'] ) ) {
3036 $ua = (string) $_SERVER['HTTP_USER_AGENT'];
3037 }
3038
3039 $needles = array(
3040 // Page-cache preloaders.
3041 'pagecachebot', // WP Cloud / Automattic (PageCacheBotDesktop|Mobile)
3042 'wp rocket', // WP Rocket preload
3043 'wprocket',
3044 'lscache_runner', // LiteSpeed Cache crawler
3045 'sg-optimizer', // SiteGround
3046 'sgoptimizer',
3047 'nitropack',
3048 'cache-warmer',
3049 'cachewarmer',
3050 'cache warmer',
3051 // Synthetic performance auditors.
3052 'lighthouse',
3053 'pagespeed',
3054 'gtmetrix',
3055 'pingdom',
3056 'webpagetest',
3057 );
3058
3059 $is_infra = false;
3060
3061 if ( '' !== $ua ) {
3062 $ua_lower = strtolower( $ua );
3063 foreach ( $needles as $needle ) {
3064 if ( false !== strpos( $ua_lower, $needle ) ) {
3065 $is_infra = true;
3066 break;
3067 }
3068 }
3069 }
3070
3071 // WP Cloud's warmer also tags its requests with a query marker.
3072 if ( ! $is_infra && isset( $_GET['x-cache-engine'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only flag for render throttling
3073 $is_infra = true;
3074 }
3075
3076 /**
3077 * Allow a site or host to declare additional preload agents.
3078 *
3079 * @param bool $is_infra Whether this request bypasses the OTTO render throttle.
3080 * @param string $ua The request user-agent.
3081 */
3082 if ( function_exists( 'apply_filters' ) ) {
3083 $is_infra = (bool) apply_filters( 'metasync_otto_unthrottled_agent', $is_infra, $ua );
3084 }
3085
3086 return $is_infra;
3087 }
3088