$findings * @return array */ public static function finding_ids_from(array $findings): array { $ids = []; foreach ($findings as $f) { if (!is_array($f)) { continue; } $fid = (string) ($f['finding_id'] ?? ($f['id'] ?? '')); if ($fid !== '') { $ids[$fid] = true; } } return array_keys($ids); } public function __construct() { $this->repo = new Repository(); } /** * Run full audit. * * NOTE: * - When triggered from the UI, this is usually scheduled via WP-Cron (background). * - You can also pass an existing run_id (e.g. a queued run) to avoid creating a new one. */ public function run_full_audit(int $existing_run_id = 0): array { // v3.3.27 — FIX for the 60% / "Analyzing pages 64/128" deadlock. // The previous code preserved max_execution_time as-is (e.g. SiteGround // ships 360s by default). A 128-page scan needs ~6+ minutes; the PHP // request died silently right at the limit, leaving status='running' // and the lock dangling for the full 18-min TTL. // // Three safeguards now: // 1. set_time_limit(0) attempts unlimited execution (may be ignored // on hardened shared hosts but works on most). // 2. ignore_user_abort(true) keeps the script alive even if the // browser tab navigates away while the request is mid-flight. // 3. periodic set_time_limit(0) reset before each per-URL crawl // (further down) prevents the cumulative limit from biting. if (function_exists('set_time_limit')) { // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- Long site audits can exceed default max_execution_time; reset is silent (function_exists guard) and host-policy-respecting. @set_time_limit(0); } if (function_exists('ignore_user_abort')) { @ignore_user_abort(true); } // Raise memory ceiling to 512M if currently lower (won't shrink it). $current_mem = (int) ini_get('memory_limit'); if ($current_mem > 0 && $current_mem < 512 && function_exists('ini_set')) { // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- Audit collectors keep large DOM trees in memory; raise (never lower) when host memory_limit is below 512M. @ini_set('memory_limit', '512M'); } $settings = Settings::get(); $start = microtime(true); $run_id = $existing_run_id > 0 ? $existing_run_id : $this->repo->create_run('audit', 'running'); $run_notes = []; if ($run_id > 0) { $existing_run = $this->repo->get_run($run_id); if ($existing_run && !empty($existing_run['notes'])) { $decoded_notes = json_decode((string) $existing_run['notes'], true); if (is_array($decoded_notes)) { $run_notes = $decoded_notes; } } } if ($run_id > 0 && $existing_run_id > 0) { // Ensure status is correct (queued -> running) and clear any previous output if needed. $preserved_notes = $run_notes; $this->repo->update_run($run_id, [ 'status' => 'running', 'ended_at' => null, 'overall_score' => null, 'category_scores' => null, 'counts_json' => null, 'context_json' => null, 'duration_ms' => null, 'notes' => !empty($preserved_notes) ? wp_json_encode($preserved_notes) : '', ]); } // v3.3.52+ — the integrity/license runner gate exists only for the // licensed Pro build; the WordPress.org free build has no license or // signed integrity manifest, so it skips this gate entirely. $this->update_audit_progress($run_id, 5, I18n::tr('Reading the site settings', 'قراءة إعدادات الموقع')); // Collect base context. $wp = (new WpCollector())->collect(); $db = (new DbCollector())->collect(); $fs = (new FsCollector())->collect($settings); $http = new HttpCollector(); $home_url = home_url('/'); $http_home = $http->request($home_url, ['timeout' => 12], 5); $this->update_audit_progress($run_id, 15, I18n::tr('Checking the homepage and server', 'فحص الصفحة الرئيسية والخادم')); // Build the analysis list. $requested_scan_mode = isset($run_notes['requested_scan_mode']) ? (string) $run_notes['requested_scan_mode'] : ''; $scan_mode = in_array($requested_scan_mode, ['sample', 'full'], true) ? $requested_scan_mode : (string) ($settings['scan_mode'] ?? 'full'); $preferred_crawl_run_id = isset($run_notes['crawl_source_run_id']) ? (int) $run_notes['crawl_source_run_id'] : 0; $limits = isset($settings['scan_limits']) && is_array($settings['scan_limits']) ? $settings['scan_limits'] : []; $crawlCollector = new CrawlCollector(); $analysis_limit = $scan_mode === 'sample' ? (int) ($limits['sample_scan_pages'] ?? ($limits['sample_pages'] ?? 10)) : (int) ($limits['audit_analysis_max_pages'] ?? 0); if ($scan_mode === 'sample') { $analysis_limit = max(3, min(1000, $analysis_limit)); } else { $analysis_limit = max(0, min(200000, $analysis_limit)); } $discovery_target = $scan_mode === 'sample' ? $analysis_limit : (int) ($limits['full_scan_max_pages'] ?? 0); $discovery_target = max(0, min(200000, $discovery_target)); $audit_source = $scan_mode === 'sample' ? 'sample_seed_discovery' : 'fresh_seed_discovery'; $audit_source_run_id = 0; $crawl_rows_for_checks = []; $crawl_inventory_total = 0; $seedInfo = ['source' => '', 'sitemap_url' => '', 'urls' => [], 'checked' => []]; $source_urls = []; $seen_urls = []; if ($scan_mode === 'full') { $crawl_source_run = null; if ($preferred_crawl_run_id > 0) { $preferred_run = $this->repo->get_run($preferred_crawl_run_id); if ($preferred_run && (string) ($preferred_run['run_type'] ?? '') === 'crawl' && (string) ($preferred_run['status'] ?? '') === 'completed') { $crawl_source_run = $preferred_run; } } if (!$crawl_source_run) { $crawl_source_run = $this->repo->get_latest_completed_run('crawl'); } if ($crawl_source_run) { $audit_source_run_id = (int) ($crawl_source_run['id'] ?? 0); if ($audit_source_run_id > 0) { $crawl_rows_for_checks = $this->repo->get_all_crawl_rows($audit_source_run_id); $crawl_inventory_total = count($crawl_rows_for_checks); foreach ($crawl_rows_for_checks as $row) { if (!is_array($row)) { continue; } $candidate = (string) ($row['normalized_url'] ?? $row['url'] ?? ''); if ($candidate === '' || !Url::is_internal_url($candidate) || Url::is_default_excluded_url($candidate) || Url::is_probably_non_html_url($candidate)) { continue; } $norm = Url::normalize($candidate); if ($norm === '' || isset($seen_urls[$norm])) { continue; } $seen_urls[$norm] = true; $source_urls[] = $candidate; } if (!empty($source_urls)) { $crawl_seed_info = []; $crawl_context_json = (string) ($crawl_source_run['context_json'] ?? ''); if ($crawl_context_json !== '') { $crawl_context = json_decode($crawl_context_json, true); if (is_array($crawl_context) && isset($crawl_context['seed_info']) && is_array($crawl_context['seed_info'])) { $crawl_seed_info = $crawl_context['seed_info']; } } $audit_source = $preferred_crawl_run_id > 0 ? 'linked_crawl_inventory' : 'latest_crawl_inventory'; $seed_source = (string) ($crawl_seed_info['source'] ?? ''); $seed_sitemap_url = (string) ($crawl_seed_info['sitemap_url'] ?? ''); $seed_checked = isset($crawl_seed_info['checked']) && is_array($crawl_seed_info['checked']) ? $crawl_seed_info['checked'] : []; $seedInfo = [ 'source' => $seed_source !== '' ? $seed_source : $audit_source, 'sitemap_url' => $seed_sitemap_url, 'urls' => $source_urls, 'checked' => $seed_checked, 'crawl_run_id' => $audit_source_run_id, ]; } } } } if (empty($source_urls)) { $seedInfo = $crawlCollector->discover_seeds($discovery_target > 0 ? $discovery_target : 0); foreach ((array) ($seedInfo['urls'] ?? []) as $u) { if (!is_string($u) || $u === '' || !Url::is_internal_url($u) || Url::is_default_excluded_url($u) || Url::is_probably_non_html_url($u)) { continue; } $norm = Url::normalize($u); if ($norm === '' || isset($seen_urls[$norm])) { continue; } $seen_urls[$norm] = true; $source_urls[] = $u; } $crawl_inventory_total = count($source_urls); } if (empty($source_urls)) { $source_urls = [home_url('/')]; $seedInfo['urls'] = $source_urls; $crawl_inventory_total = 1; } $source_total = count($source_urls); $analysis_target = $analysis_limit > 0 ? min($source_total, $analysis_limit) : $source_total; if ($analysis_limit > 0) { $source_urls = array_slice($source_urls, 0, $analysis_limit); } // v3.3.40 — bumped to 50 to match the build-inventory ceiling. // New 4-stage progression: 5-50% build, 50-75% analysis, // 75-95% checks, 95-100% finalize. $this->update_audit_progress($run_id, 50, sprintf(I18n::tr('Preparing pages (%d found)', 'تجهيز الصفحات (عُثر على %d)'), $source_total)); $sample_pages = []; // Use the crawl inventory for the URL list, then live-fetch every URL // selected by the scan limit so site-wide scans can analyze the site. // // Why: The DB crawl table stores only basic columns (title, meta, // word_count, etc.) — it does NOT store rich analysis data like // asset_counts, webp_images, srcset_count, css details, viewport, // internal_links, etc. The analysis_json column was designed to fix // this, but ALTER TABLE fails silently on Hostinger shared hosting // (MySQL user lacks ALTER privilege). // // The crawl inventory is still used for URL discovery and CrawlCheck // findings (titles, redirects, orphans). // The audit analyzes exactly the number of pages the site owner chose // in Settings → Scan Limits ("how many pages to analyze"; 0 = every // page discovered). $source_urls was already truncated to that value // above; the audit depth is only the site owner's setting. // Larger depths run safely because each URL fetch resets the PHP // execution-time limit below, and the audit executes in background cron // batches (@set_time_limit(0)). if ($analysis_limit > 0 && $source_total > $analysis_limit) { // Transparency only: record that the user's own depth setting — not // a payment gate — limited how many discovered pages were analyzed. $run_notes['analysis_limited_by_setting'] = true; $run_notes['analysis_limit'] = $analysis_limit; $run_notes['analysis_discovered'] = $source_total; } $sample_urls = $source_urls; // v3.3.55 — Reuse the crawl inventory's already-fetched rich page data // instead of re-fetching every URL live. The crawl worker stores the // FULL crawl_url() output as analysis_json (added in v1.0.77 precisely // "so audit reuse gets ALL fields"); hydrate_crawl_row() reconstructs // it. Re-fetching every analyzed URL live was the dominant cost // (~1.5-2s/page → ~340s for 200 pages) that pushed run_full_audit past // the host/proxy HARD request timeout on large sites — Cloudflare 524 // (~100s), or LiteSpeed/PHP-FPM request_terminate_timeout (~60-90s) — // killing the request mid-analysis and leaving the watchdog to reap the // run as "failed". ignore_user_abort + set_time_limit(0) CANNOT override // the web server's own request limit, so the only robust fix is to make // the work fit: reuse turns Phase A into a DB read. // // SAFETY: on a legacy upgrade where the analysis_json column is missing // (dbDelta's ALTER TABLE can fail silently under a MySQL user without // ALTER privilege), the hydrated row carries only the basic columns. We // detect that per row (no rich-analysis key) and fall back to a live // fetch for that URL, so the checks never run on degraded data. Fresh // installs get the column via CREATE TABLE, so they reuse and stay fast. $rows_by_norm = []; foreach ($crawl_rows_for_checks as $crow) { if (!is_array($crow) || !$this->crawl_row_has_rich_data($crow)) { continue; } // Key by the SAME normalization the lookup uses below, so a match is // guaranteed regardless of whether the stored normalized_url is // already canonical (don't depend on Url::normalize being perfectly // idempotent). $raw = (string) ($crow['normalized_url'] ?? ''); if ($raw === '') { $raw = (string) ($crow['url'] ?? ''); } $rn = $raw !== '' ? Url::normalize($raw) : ''; if ($rn !== '' && !isset($rows_by_norm[$rn])) { $rows_by_norm[$rn] = $crow; } } $reused_pages = 0; $live_fetched_pages = 0; // v3.3.40 — analysis phase now runs from 50% to 75%. $this->update_audit_progress($run_id, 50, sprintf(I18n::tr('Reading %d pages', 'قراءة الصفحات (%d)'), count($sample_urls))); $i = 0; $last_crawl_progress = 50; $skipped_urls = []; $slow_pages = 0; foreach ($sample_urls as $u) { if (!is_string($u) || $u === '') { continue; } $norm = Url::normalize($u); if ($norm !== '' && isset($rows_by_norm[$norm])) { // Reuse the crawl's stored rich data — no network round-trip. $sample_pages[] = $rows_by_norm[$norm]; $reused_pages++; } else { // Fallback (seed-discovery path, or a host missing analysis_json). // v3.3.27 — reset PHP time limit before each live URL fetch. if (function_exists('set_time_limit')) { // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- Per-URL execution-time reset (60s) so a long audit cannot trip max_execution_time mid-crawl. @set_time_limit(60); } // v3.3.28 — wrap in try/catch so a single slow/broken URL doesn't // kill the whole audit. If wp_remote_get hangs (rare but possible // on slow upstreams), we lose at most one page, not the entire scan. try { $page_start = microtime(true); $sample_pages[] = $crawlCollector->crawl_url($u, $audit_source); $live_fetched_pages++; $page_ms = (int) ((microtime(true) - $page_start) * 1000); // If a single URL took >15s, log it so we know which one is the // bottleneck (visible in run notes for support diagnostics). if ($page_ms > 15000) { $slow_pages++; } } catch (\Throwable $e) { $skipped_urls[] = ['url' => $u, 'reason' => 'exception', 'message' => substr($e->getMessage(), 0, 200)]; } } $i++; // v3.3.40 — analysis maps to 50-75% range (25 points). $crawl_progress = 50 + (int) floor((max(1, $i) / max(1, count($sample_urls))) * 25); $crawl_progress = max(50, min(75, $crawl_progress)); if ($crawl_progress > $last_crawl_progress) { $this->update_audit_progress($run_id, $crawl_progress, sprintf(I18n::tr('Reading page %1$d of %2$d', 'قراءة الصفحة %1$d من %2$d'), $i, count($sample_urls))); $last_crawl_progress = $crawl_progress; } } // Transparency for diagnostics: how many pages were reused vs re-fetched. $run_notes['analysis_reused_pages'] = $reused_pages; $run_notes['analysis_live_fetched_pages'] = $live_fetched_pages; // Count observed outcomes, not attempted requests or assumed HTML coverage. $coverage = ['received' => 0, 'parsed_html' => 0, 'unreachable' => 0, 'http_error' => 0, 'redirect_unresolved' => 0, 'non_html' => 0, 'parse_failed' => 0]; foreach ($sample_pages as $page) { $status = (int) ($page['status_code'] ?? 0); if ($status === 0) { $coverage['unreachable']++; continue; } $coverage['received']++; if ($status >= 400) { $coverage['http_error']++; } elseif ($status >= 300) { $coverage['redirect_unresolved']++; } elseif (empty($page['html_analyzed'])) { $coverage['non_html']++; } elseif (empty($page['_html_parsed'])) { $coverage['parse_failed']++; } else { $coverage['parsed_html']++; } } $coverage['skipped_exception'] = count($skipped_urls); $coverage['slow_pages'] = $slow_pages; $coverage['limited_by_setting'] = max(0, $source_total - $analysis_target); // v3.3.40 — bumped from 62→76 (start of checks phase). $this->update_audit_progress($run_id, 76, sprintf(I18n::tr('%d pages ready for checking', 'الصفحات الجاهزة للفحص: %d'), count($sample_pages))); // Store analyzed pages into the audit run crawl table for evidence. $this->repo->clear_crawl_for_run($run_id); foreach ($sample_pages as $p) { $this->repo->insert_crawl_url($run_id, $p); } // 4.0.18: a probe that fails fast (status 0 within a few seconds: a // busy host refusing the connection) is retried once after a short // pause. A probe that timed out is not retried, so one slow host // cannot double the audit time. Either way the checks skip a failed // probe and ProbeCoverageReport reports it as "could not check". // Each probe gets a fresh execution-time budget, like each page fetch. $probe = static function (string $path) use ($http): array { if (function_exists('set_time_limit')) { // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- Per-probe execution-time reset (60s), same policy as the per-URL fetch above. @set_time_limit(60); } $started = microtime(true); $res = $http->request(home_url($path), ['timeout' => 10], 2); if ((int) ($res['status_code'] ?? 0) === 0 && (microtime(true) - $started) < 5) { usleep(750000); $res = $http->request(home_url($path), ['timeout' => 10], 2); } return $res; }; // Robots.txt check fetch (internal). $robots_res = $probe('/robots.txt'); // xmlrpc check $xmlrpc_res = $probe('/xmlrpc.php'); // AI crawler discovery signals $llms_res = $probe('/llms.txt'); // v1.0.88: Agent-discovery surface (RFC 9727 api-catalog + agent-skills + Markdown content negotiation). $api_catalog_res = $probe('/.well-known/api-catalog'); $agent_skills_res = $probe('/.well-known/agent-skills/index.json'); $markdown_res = $http->request(home_url('/'), [ 'timeout' => 10, 'headers' => ['Accept' => 'text/markdown'], ], 2); // v3.3.11 — log + import collectors removed when those features // were cut. The audit data structure keeps the keys but with empty // arrays so downstream checks don't need to be touched. $logs_preview = []; $imports_last = []; // Adapters $adapterRegistry = new AdapterRegistry(); $adapterResult = $adapterRegistry->run($wp); $adapter_context = $adapterResult['context'] ?? []; $adapter_findings = $adapterResult['findings'] ?? []; $data = [ 'wp' => $wp, 'db' => $db, 'fs' => $fs, 'http_home' => $http_home, 'robots' => $robots_res, 'xmlrpc' => $xmlrpc_res, 'llms' => $llms_res, 'api_catalog' => $api_catalog_res, 'agent_skills' => $agent_skills_res, 'markdown_negotiation' => $markdown_res, 'sample_pages' => $sample_pages, 'crawl_rows' => !empty($crawl_rows_for_checks) ? $crawl_rows_for_checks : $sample_pages, 'seed_info' => $seedInfo, 'logs_preview' => $logs_preview, 'imports_last' => $imports_last, 'adapters' => $adapter_context, 'audit_scope' => [ 'scan_mode' => $scan_mode, 'source' => $audit_source, 'source_run_id' => $audit_source_run_id, 'preferred_crawl_run_id' => $preferred_crawl_run_id, 'discovered_urls' => $source_total, 'coverage' => $coverage, 'analysis_target' => $analysis_target, 'analysis_limit' => $analysis_limit, 'analysis_truncated' => $analysis_limit > 0 && $source_total > $analysis_limit, 'crawl_inventory_total' => $crawl_inventory_total, ], ]; // v3.3.11 — log collector removed; LogCheck dropped from the audit // suite. Keep the keys empty so any orphan ctx->get() reads return []. $data['logs_structured'] = []; $data['logs_collector_findings'] = []; $ctx = new RunContext($run_id, 'audit', $settings, $data); // v3.3.40 — checks phase 76-95%. $this->update_audit_progress($run_id, 76, I18n::tr('Running the checks', 'تشغيل الفحوصات')); // Checks — v2.1.0 expanded set covering SEO + GEO + AIO + Voice + Local + NLP + Logs + Env + AEO // // v3.3.52+ — edition-aware, deletion-safe registration. Each check is // listed by class name and instantiated only if its file is present // (class_exists). The free WordPress.org edition physically ships only // its own check files; the Pro-only check files are simply absent from // that build, so they have no class to instantiate and drop out // cleanly — no fatal, and nothing is hidden at runtime. The Pro build // ships every check file and runs the full suite. Free and Pro are // separate builds with different files, not one engine gated by a flag. $check_classes = [ // 10 free checks shipped with the WordPress.org Lite build. Checks\PerformanceCheck::class, Checks\TechnicalSeoCheck::class, Checks\OnPageCheck::class, Checks\AdvancedSchemaCheck::class, Checks\MobileUsabilityCheck::class, Checks\SecurityCheck::class, Checks\MediaCheck::class, Checks\CrawlCheck::class, Checks\AnalyticsStackCheck::class, // AI-readiness (AEO/GEO) — the free edition's headline differentiator: // AI-crawler access in robots.txt, llms.txt, JSON-LD entity / author / // E-E-A-T coverage, FAQ/HowTo, H2/H3 passage structure, Open Graph. Checks\AiFriendlyCheck::class, // 4.0.18: reports site requests that failed, instead of silence. Checks\ProbeCoverageReport::class, ]; /** * Add-on extension point for extra audit checks. * * The WordPress.org Lite ZIP ships only its own local checks. A separate * Pro add-on can append additional class names here; each class is still * instantiated only when it exists and implements CheckInterface. * * @param array $check_classes Audit check classes. * @param RunContext $ctx Current run context. * @param array $settings Normalized settings. */ $check_classes = apply_filters('aetos_seo_audit_check_classes', $check_classes, $ctx, $settings); if (!is_array($check_classes)) { $check_classes = []; } $check_classes = array_values(array_unique(array_filter($check_classes, 'is_string'))); // 4.0.18: record which check modules this run actually had, so an // add-on run that silently lost its checks is visible afterwards. $addon_classes = array_values(array_filter($check_classes, static function (string $class_name): bool { return strpos(ltrim($class_name, '\\'), 'AetosSeo\\') !== 0; })); /** * Filters the module record stored with each audit run. An add-on * adds what it expected to register and its licence state. * * @param array $modules */ $check_modules = apply_filters('aetos_seo_audit_module_info', [ 'checks_total' => count($check_classes), 'addon_checks' => count($addon_classes), ], $check_classes); $check_modules = is_array($check_modules) ? $check_modules : []; $findings = $this->run_checks($check_classes, $ctx, $run_id, true); // 4.0.18: render the same findings in the other shipped language and // store both, so the Findings page, the evidence dialog and reports // follow the admin language chosen later instead of the scan language. // Live probes are memoized on $ctx, so this pass does no network work. $primary_lang = I18n::lang(); $other_lang = I18n::other_lang($primary_lang); try { $other_findings = I18n::with_lang($other_lang, function () use ($check_classes, $ctx, $run_id): array { return $this->run_checks($check_classes, $ctx, $run_id, false); }); $findings = self::attach_translations($findings, $other_findings, $primary_lang, $other_lang); } catch (\Throwable $e) { if (defined('WP_DEBUG') && WP_DEBUG) { error_log('[Aetos SEO] second-language pass failed: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- WP_DEBUG-guarded debug logging only } } // v3.3.40 — finalize phase 96-99%. $this->update_audit_progress($run_id, 96, I18n::tr('Calculating the score', 'حساب النتيجة')); // Bug fix v1.0.55: adapter findings (Rank Math detected, WP Rocket detected, // Imagify detected, …) used to be merged into the main $findings list and // stored in the wp_*_findings table. They were never *real* findings — they're // a stack inventory — and they polluted the Findings UI, the score // breakdowns, and the exported `findings` array. We now keep them in a // dedicated `detected_stack` bucket attached to the run context only. $detected_stack = []; if (is_array($adapter_findings)) { foreach ($adapter_findings as $af) { if (!is_array($af)) { continue; } $detected_stack[] = [ 'id' => (string) ($af['id'] ?? ''), 'title' => (string) ($af['title'] ?? ''), 'category' => (string) ($af['category'] ?? ''), 'check_id' => (string) ($af['check_id'] ?? ''), 'evidence' => isset($af['evidence']) && is_array($af['evidence']) ? $af['evidence'] : [], 'why_it_matters' => (string) ($af['why_it_matters'] ?? ''), ]; } } if ($scan_mode === 'full' && $analysis_limit > 0 && $source_total > $analysis_limit) { // 4.0.18: rendered in both languages like every check finding. $analyzed = count($sample_pages); $limit_finding = self::analysis_limit_finding($analyzed, $source_total); $limit_alt = I18n::with_lang($other_lang, static function () use ($analyzed, $source_total): array { return self::analysis_limit_finding($analyzed, $source_total); }); foreach (self::attach_translations([$limit_finding], [$limit_alt], $primary_lang, $other_lang) as $f) { $findings[] = $f; } } // v2.8.1 — deduplicate before scoring. Multiple checks (Crawl + // TechnicalSEO + OnPage) sometimes report the same root cause // against the same URL set, which double-counted the score impact. // FindingsDeduper keeps the most-impactful single finding per // (topic, url_set) pair and stamps `evidence.merged_from` so the UI // can show provenance. $findings = (new FindingsDeduper())->dedupe($findings); $findings = self::apply_scoring_policy($findings); // v3.3.57 — derive a real lifecycle status from the markers the checks // already emit, instead of leaving every finding "open" and parking the // distinction inside title text ("(mitigated)") where no counter can see // it. A production run reported counts_json 30/30 open while 5+ // findings were explicitly parked or mitigated — the customer reads "30 // open problems" and trusts the number less, and run-to-run progress // tracking is polluted. The rules reuse the exact tier vocabulary the // Findings page and the HTML report already classify by: // parked = severity info AND |score_impact| = 0 (the established // parked_recommendation tier) // mitigated = evidence.mitigation_notes non-empty (the same marker // that appends "(mitigated)" to the title at emit time) // Scoring is intentionally untouched: mitigated/parked findings keep // their (already reduced) score_impact in the category math below. $findings = $this->derive_finding_lifecycle_statuses($findings); // Compute scores. $category_scores = $this->compute_category_scores($findings); $score_categories = [ 'Performance', 'Technical SEO', 'On-Page', 'AI-Friendly', 'Mobile Usability', 'Security', 'Database', 'Media', 'Crawl', 'Logs', ]; $score_categories = apply_filters('aetos_seo_score_categories', $score_categories, $category_scores, $findings); if (!is_array($score_categories)) { $score_categories = []; } $score_categories = array_values(array_unique(array_filter($score_categories, 'is_string'))); $overall = $this->compute_overall_score($category_scores, $score_categories); $duration_ms = (int) ((microtime(true) - $start) * 1000); // Persist $this->repo->clear_findings_for_run($run_id); $this->repo->insert_findings($run_id, $findings); // v2.1.3 — auto-resolve previous-run findings whose finding_id no // longer appears in this run. This makes "fixed" issues disappear // from the dashboard automatically instead of staying open forever. try { $current_finding_ids = self::finding_ids_from($findings); // Never treat "no identifiers collected" as "everything was fixed". if (empty($findings) || !empty($current_finding_ids)) { $this->repo->auto_resolve_findings_not_in_set($run_id, $current_finding_ids); } } catch (\Throwable $e) { if (defined('WP_DEBUG') && WP_DEBUG) { error_log('[Aetos SEO] auto-resolve failed: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- WP_DEBUG-guarded debug logging only } } $this->update_audit_progress($run_id, 96, I18n::tr('Saving the results', 'حفظ النتائج')); // v3.3.57 — status-aware breakdown. `findings_open` now means "actually // needs work": mitigated and parked findings are counted separately so // dashboards/exports stop inflating the problem count. $status_counts = ['open' => 0, 'mitigated' => 0, 'parked' => 0]; foreach ($findings as $f) { $s = strtolower((string) (is_array($f) ? ($f['status'] ?? 'open') : 'open')); if (!isset($status_counts[$s])) { $s = 'open'; } $status_counts[$s]++; } $counts = [ 'findings_total' => count($findings), 'findings_open' => $status_counts['open'], 'findings_mitigated' => $status_counts['mitigated'], 'findings_parked' => $status_counts['parked'], ]; $context_for_storage = $data; $context_for_storage['audit_scope']['modules'] = $check_modules; if (isset($context_for_storage['sample_pages']) && is_array($context_for_storage['sample_pages']) && count($context_for_storage['sample_pages']) > 50) { $context_for_storage['sample_pages'] = array_slice($context_for_storage['sample_pages'], 0, 50); $context_for_storage['audit_scope']['sample_pages_truncated_for_storage'] = true; } if (isset($context_for_storage['crawl_rows']) && is_array($context_for_storage['crawl_rows'])) { $context_for_storage['crawl_rows_summary'] = [ 'total' => count($context_for_storage['crawl_rows']), 'samples' => array_slice($context_for_storage['crawl_rows'], 0, 20), ]; unset($context_for_storage['crawl_rows']); } // Persist detected stack on the run context so BotContextBuilder can surface // it in a dedicated `detected_stack` key (separate from `findings`). $context_for_storage['detected_stack'] = $detected_stack; $context_sanitized = Redactor::redact_array($context_for_storage); $final_notes = array_merge($run_notes, [ 'phase' => 'completed', 'progress_percent' => 100, 'updated_at' => current_time('mysql'), 'used_crawl_run_id' => $audit_source_run_id, 'used_scan_mode' => $scan_mode, 'result_language' => I18n::lang(), ]); $this->repo->update_run($run_id, [ 'status' => 'completed', 'ended_at' => current_time('mysql'), 'overall_score' => $overall, 'category_scores' => wp_json_encode($category_scores), 'counts_json' => wp_json_encode($counts), 'context_json' => wp_json_encode($context_sanitized), 'duration_ms' => $duration_ms, 'notes' => wp_json_encode($final_notes), ]); // 4.0.18 (D1): bounded storage. One audit keeps about 0.3 MB of // context plus its crawl rows; keep the last 10 audits (enough for the // history chart) and the last 3 crawls, and delete older ones. try { $this->repo->prune_runs('audit', (int) apply_filters('aetos_seo_keep_audit_runs', 10)); $this->repo->prune_runs('crawl', (int) apply_filters('aetos_seo_keep_crawl_runs', 3)); } catch (\Throwable $e) { if (defined('WP_DEBUG') && WP_DEBUG) { error_log('[Aetos SEO] pruning old runs failed: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- WP_DEBUG-guarded debug logging only } } // v2.3.0 — optional email summary on audit completion. Off by default. // Toggle via Settings → notifications.email_on_complete. try { $this->maybe_send_audit_email($run_id, $overall, $category_scores, $findings, $counts, $final_notes); } catch (\Throwable $e) { if (defined('WP_DEBUG') && WP_DEBUG) { error_log('[Aetos SEO] email summary failed: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- WP_DEBUG-guarded debug logging only } } return ['run_id' => $run_id, 'overall' => $overall, 'category_scores' => $category_scores, 'findings_total' => count($findings), 'duration_ms' => $duration_ms]; } /** * v2.3.0 — send a brief HTML email summary when an audit completes. * Skipped silently when notifications.email_on_complete is off or no * recipient is configured. * * @param array $category_scores * @param array> $findings * @param array $counts */ /** * @return array */ private static function analysis_limit_finding(int $analyzed, int $total): array { return [ 'id' => 'audit_analysis_limit_applied', 'title' => I18n::tr('Only part of the site was analysed in detail', 'حُلل جزء من الموقع فقط بالتفصيل'), 'category' => 'Crawl', 'severity' => 'info', 'score_impact' => 0, 'status' => 'open', 'evidence' => [ 'source' => 'config', 'details' => sprintf(I18n::tr('Pages analysed in detail: %1$d of %2$d found.', 'الصفحات المحللة بالتفصيل: %1$d من أصل %2$d عُثر عليها.'), $analyzed, $total), ], 'why_it_matters' => I18n::tr('Pages outside the limit were not checked, so their issues are not in this list.', 'الصفحات خارج الحد لم تُفحص، لذلك لا تظهر مشكلاتها في هذه القائمة.'), 'recommendation_steps' => [I18n::tr('In Settings, choose "Whole site" to analyse every page, if your hosting allows it.', 'من الإعدادات اختر «كل الموقع» لتحليل كل الصفحات إذا كانت استضافتك تتحمل ذلك.')], 'retest' => [ 'how' => I18n::tr('Change the scan size in Settings and run a new audit.', 'غيّر حجم الفحص من الإعدادات وشغّل فحصًا جديدًا.'), 'pass_condition' => I18n::tr('Every page found is analysed.', 'تُحلل كل الصفحات التي يُعثر عليها.'), ], 'check_id' => 'audit_scope', ]; } /** * Instantiate and run every audit check once, in the current I18n language. * * @param array $check_classes * @return array> */ private function run_checks(array $check_classes, RunContext $ctx, int $run_id, bool $report_progress): array { $checks = []; foreach ($check_classes as $check_class) { if (class_exists($check_class)) { $check = new $check_class(); if ($check instanceof CheckInterface) { $checks[] = $check; } } } $findings = []; $total_checks = count($checks); foreach ($checks as $idx => $check) { // 4.0.18: every check (in both language passes) gets its own // execution-time budget, so the second pass cannot inherit a // budget the page fetches or probes already spent. if (function_exists('set_time_limit')) { // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- Per-check execution-time reset (60s), same policy as the per-URL fetch. @set_time_limit(60); } if ($report_progress) { $check_no = (int) $idx + 1; // v3.3.40 — checks map to 76-95% range (19 points). $check_progress = 76 + (int) floor(($check_no / max(1, $total_checks)) * 19); $check_progress = max(77, min(95, $check_progress)); $this->update_audit_progress($run_id, $check_progress, sprintf(I18n::tr('Running check %1$d of %2$d', 'تشغيل الفحص %1$d من %2$d'), $check_no, $total_checks)); } try { foreach ($check->run($ctx) as $f) { $findings[] = $f; } } catch (\Throwable $e) { // v2.8.1 — ErrorAware Score: an errored check also emits a // med-severity penalty in its own category so the category // does not read an artificial 100/100. $check_category = $this->check_class_to_category(get_class($check)); $exception_msg = (string) $e->getMessage(); $findings[] = [ 'id' => 'log_check_error_' . $check->id(), 'title' => sprintf(I18n::tr('Internal error while running check: %s', 'خطأ داخلي أثناء تشغيل الفحص: %s'), $check->id()), 'category' => 'Logs', 'severity' => 'info', 'score_impact' => 0, 'status' => 'open', 'evidence' => [ 'source' => 'log', 'details' => 'Exception: ' . $exception_msg, ], 'why_it_matters' => I18n::tr('Part of the audit may not have completed.', 'قد لا يكون جزء من الفحص قد اكتمل.'), 'recommendation_steps' => [I18n::tr('Check the PHP error log, then run the audit again.', 'راجع سجل أخطاء PHP ثم شغّل الفحص مرة أخرى.')], 'retest' => [ 'how' => I18n::tr('Run a new audit.', 'شغّل فحصًا جديدًا.'), 'pass_condition' => I18n::tr('The check completes without an error.', 'يكتمل الفحص دون خطأ.'), ], 'check_id' => 'internal', ]; $findings[] = [ 'id' => 'internal_check_error_' . $check->id(), 'title' => sprintf( I18n::tr( 'A check failed in %s - this category score is unreliable until the audit runs successfully again', 'فشل تنفيذ فحص في قسم %s - نتيجة هذا القسم غير موثوقة حتى يُعاد التشغيل بنجاح' ), CategoryLabels::label($check_category) ), 'category' => $check_category, 'severity' => 'med', 'score_impact' => -10, 'status' => 'open', 'evidence' => [ 'source' => 'log', 'check_id' => $check->id(), 'exception' => $exception_msg, ], 'why_it_matters' => I18n::tr('A check that failed would leave its category looking perfect. This penalty keeps the unreliable result visible.', 'الفحص الذي فشل يجعل نتيجة القسم تبدو كاملة، وهذا الخصم يوضح أن النتيجة غير موثوقة.'), 'recommendation_steps' => [ I18n::tr('Check the PHP error log to find the cause.', 'راجع سجل أخطاء PHP لمعرفة السبب.'), I18n::tr('Update the plugin, then run the audit again.', 'حدّث الإضافة ثم شغّل الفحص مرة أخرى.'), ], 'retest' => [ 'how' => I18n::tr('Run a new audit.', 'شغّل فحصًا جديدًا.'), 'pass_condition' => I18n::tr('The check completes without an error.', 'يكتمل الفحص دون خطأ.'), ], 'check_id' => 'internal_score_penalty', ]; } } return $findings; } /** * 4.0.18: store every finding's user-facing text in both shipped * languages under evidence._i18n. Findings from the two passes are paired * by finding id and occurrence order. Only evidence keys whose value * differs between the languages are stored twice. * * @param array> $primary * @param array> $other * @return array> */ public static function attach_translations(array $primary, array $other, string $primary_lang, string $other_lang): array { $by_id = []; foreach ($other as $f) { if (is_array($f) && isset($f['id'])) { $by_id[(string) $f['id']][] = $f; } } $seen = []; foreach ($primary as $i => $f) { if (!is_array($f) || !isset($f['id'])) { continue; } $id = (string) $f['id']; $seen[$id] = isset($seen[$id]) ? $seen[$id] + 1 : 0; $texts = [$primary_lang => self::finding_texts($f)]; $alt = $by_id[$id][$seen[$id]] ?? null; $evidence = isset($f['evidence']) && is_array($f['evidence']) ? $f['evidence'] : []; if (is_array($alt)) { $texts[$other_lang] = self::finding_texts($alt); $alt_evidence = isset($alt['evidence']) && is_array($alt['evidence']) ? $alt['evidence'] : []; $primary_diff = []; $other_diff = []; foreach ($evidence as $key => $value) { if (!array_key_exists($key, $alt_evidence)) { continue; } if (wp_json_encode($value) !== wp_json_encode($alt_evidence[$key])) { $primary_diff[$key] = $value; $other_diff[$key] = $alt_evidence[$key]; } } if (!empty($primary_diff)) { $texts[$primary_lang]['evidence'] = $primary_diff; $texts[$other_lang]['evidence'] = $other_diff; } } $evidence['_i18n'] = $texts; $primary[$i]['evidence'] = $evidence; } return $primary; } /** * 4.0.18: findings about draft web conventions (not adopted by search * engines or major AI assistants) and about metrics search engines do not * use are shown as optional notes and never lower the score. */ public const NO_SCORE_FINDINGS = [ 'ai_api_catalog_missing', 'ai_agent_skills_missing', 'ai_markdown_negotiation_not_supported', 'ai_robots_content_signal_optional', 'onpage_low_text_html_ratio', ]; /** * @param array> $findings * @return array> */ public static function apply_scoring_policy(array $findings): array { foreach ($findings as $i => $f) { if (!is_array($f) || !in_array((string) ($f['id'] ?? ''), self::NO_SCORE_FINDINGS, true)) { continue; } $findings[$i]['score_impact'] = 0; $findings[$i]['severity'] = 'info'; $evidence = isset($f['evidence']) && is_array($f['evidence']) ? $f['evidence'] : []; $evidence['tier'] = 'optional_experimental'; $findings[$i]['evidence'] = $evidence; } return $findings; } /** * @param array $f * @return array */ private static function finding_texts(array $f): array { return [ 'title' => (string) ($f['title'] ?? ''), 'why_it_matters' => (string) ($f['why_it_matters'] ?? ''), 'recommendation_steps' => array_values((array) ($f['recommendation_steps'] ?? [])), 'retest' => (array) ($f['retest'] ?? []), ]; } private function maybe_send_audit_email(int $run_id, int $overall, array $category_scores, array $findings, array $counts, array $run_notes = []): void { if (!function_exists('wp_mail')) { return; } $settings = Settings::get(); $notif = isset($settings['notifications']) && is_array($settings['notifications']) ? $settings['notifications'] : []; $monitoring = isset($settings['monitoring']) && is_array($settings['monitoring']) ? $settings['monitoring'] : []; $is_scheduled_monitor = (string) ($run_notes['trigger'] ?? '') === 'scheduled_monitor'; $email_enabled = !empty($notif['email_on_complete']) || ($is_scheduled_monitor && !empty($monitoring['email_digest'])); if (!$email_enabled) { return; } $to = trim((string) ($notif['email_recipient'] ?? '')); if ($to === '' && function_exists('get_option')) { $to = (string) get_option('admin_email'); } if ($to === '') { return; } $brand = defined('AETOS_SEO_BRAND_NAME') ? (string) AETOS_SEO_BRAND_NAME : 'Aetos SEO'; $site_host = function_exists('home_url') ? (string) (wp_parse_url(home_url('/'), PHP_URL_HOST) ?: home_url('/')) : ''; $subject_prefix = $is_scheduled_monitor ? I18n::tr('Scheduled audit complete', 'اكتمل الفحص المجدول') : I18n::tr('Audit complete', 'اكتمل الفحص'); $subject = sprintf( I18n::tr('[%1$s] %2$s - score %3$d/100 (%4$s)', '[%1$s] %2$s - النتيجة %3$d/100 (%4$s)'), $brand, $subject_prefix, (int) $overall, $site_host ); // Bucket sev counts $sev = ['high' => 0, 'med' => 0, 'low' => 0, 'info' => 0]; foreach ($findings as $f) { $s = strtolower((string) ($f['severity'] ?? 'info')); if (isset($sev[$s])) $sev[$s]++; } $top = []; foreach ($findings as $f) { if (strtolower((string) ($f['severity'] ?? '')) === 'high' || strtolower((string) ($f['severity'] ?? '')) === 'med' ) { $top[] = [ 'severity' => (string) $f['severity'], 'title' => (string) ($f['title'] ?? ''), 'impact' => (int) ($f['score_impact'] ?? 0), 'category' => (string) ($f['category'] ?? ''), 'finding_id' => (string) ($f['finding_id'] ?? ($f['id'] ?? '')), ]; if (count($top) >= 8) break; } } $admin_url = function_exists('admin_url') ? admin_url('admin.php?page=aetos-seo-findings&run_id=' . (int) $run_id) : ''; $lang = I18n::is_arabic() ? 'ar' : 'en'; $dir = I18n::is_arabic() ? 'rtl' : 'ltr'; $align = I18n::is_arabic() ? 'right' : 'left'; $list_padding = I18n::is_arabic() ? 'padding-right:18px;padding-left:0' : 'padding-left:18px;padding-right:0'; $html = ''; $html .= '
'; $html .= '

' . esc_html($brand) . ' - ' . esc_html($subject_prefix) . '

'; $html .= '

' . esc_html($site_host) . ' • ' . esc_html(I18n::tr('Run', 'الفحص')) . ' #' . (int) $run_id . '

'; $color = $overall >= 90 ? '#16a34a' : ($overall >= 75 ? '#65a30d' : ($overall >= 50 ? '#ca8a04' : '#dc2626')); $html .= '
'; $html .= '
' . esc_html(I18n::tr('Overall Score', 'النتيجة الإجمالية')) . '
'; $html .= '
' . (int) $overall . '/100
'; $html .= '
'; $html .= sprintf( '🔴 %1$d %2$s  🟠 %3$d %4$s  🟡 %5$d %6$s  ℹ️ %7$d %8$s', $sev['high'], esc_html(I18n::tr('high', 'عالي')), $sev['med'], esc_html(I18n::tr('medium', 'متوسط')), $sev['low'], esc_html(I18n::tr('low', 'منخفض')), $sev['info'], esc_html(I18n::tr('info', 'معلومة')) ); $html .= '
'; if (!empty($category_scores)) { $html .= '

' . esc_html(I18n::tr('Category Scores', 'نتائج الفئات')) . '

'; foreach ($category_scores as $cat => $val) { $cval = (int) $val; $bar_color = $cval >= 90 ? '#16a34a' : ($cval >= 75 ? '#65a30d' : ($cval >= 50 ? '#ca8a04' : '#dc2626')); $html .= '
'; $html .= '' . esc_html(CategoryLabels::label((string) $cat)) . ''; $html .= ''; $html .= ''; $html .= ''; $html .= '' . $cval . '/100'; $html .= '
'; } $html .= '
'; } if (!empty($top)) { $html .= '

' . esc_html(I18n::tr('Top Real Issues', 'أهم المشاكل الحقيقية')) . '

    '; foreach ($top as $row) { $html .= '
  1. '; $html .= '' . esc_html($this->email_severity_label((string) $row['severity'])) . ' ' . esc_html($this->email_finding_title($row)); $html .= ' (' . (int) $row['impact'] . ' ' . esc_html(I18n::tr('pts', 'نقطة')) . ')'; $html .= '
  2. '; } $html .= '
'; } if ($admin_url !== '') { $html .= ''; } $html .= '
'; $html .= esc_html(I18n::tr('Sent by', 'تم الإرسال بواسطة')) . ' ' . esc_html($brand) . ' ' . esc_html(I18n::tr('on', 'في')) . ' ' . esc_html(current_time('mysql')); $html .= '
'; wp_mail($to, $subject, $html, ['Content-Type: text/html; charset=UTF-8']); } /** * @param array $finding */ private function email_finding_title(array $finding): string { $category = (string) ($finding['category'] ?? ''); $finding_id = (string) ($finding['finding_id'] ?? ''); $fallback = sprintf( I18n::tr('%1$s issue: %2$s', 'مشكلة %1$s: %2$s'), $category !== '' ? CategoryLabels::label($category) : I18n::tr('Audit', 'الفحص'), $finding_id !== '' ? $finding_id : I18n::tr('stored finding', 'نتيجة مخزنة') ); return I18n::english_safe((string) ($finding['title'] ?? ''), $fallback); } private function email_severity_label(string $severity): string { switch (strtolower($severity)) { case 'high': return I18n::tr('HIGH', 'عالٍ'); case 'med': case 'medium': return I18n::tr('MED', 'متوسط'); case 'low': return I18n::tr('LOW', 'منخفض'); default: return I18n::tr('INFO', 'معلومة'); } } private function update_audit_progress(int $run_id, int $percent, string $phase): void { if ($run_id <= 0) { return; } $percent = max(0, min(99, $percent)); $existing_notes = []; $run = $this->repo->get_run($run_id); if ($run && !empty($run['notes'])) { $decoded = json_decode((string) $run['notes'], true); if (is_array($decoded)) { $existing_notes = $decoded; } } $previous_progress = isset($existing_notes['progress_percent']) && is_numeric($existing_notes['progress_percent']) ? (int) $existing_notes['progress_percent'] : 0; // Never move backward in the UI progress bar. $percent = max($percent, min(99, $previous_progress)); $payload = array_merge($existing_notes, [ 'phase' => $phase, 'progress_percent' => $percent, 'updated_at' => current_time('mysql'), ]); $this->repo->update_run($run_id, [ 'status' => 'running', 'notes' => wp_json_encode($payload), ]); } /** * v3.3.55 — Does a hydrated crawl row carry the rich analysis data the * audit checks need (so we can reuse it instead of re-fetching the URL)? * * These keys only ever come from the stored analysis_json (the full * crawl_url() output reconstructed by Repository::hydrate_crawl_row), never * from the basic crawl columns. Their presence means the crawl captured the * rich page signals; their absence means the analysis_json column is missing * on this host (legacy upgrade where ALTER TABLE silently failed), so the * caller must live-fetch this URL to avoid running checks on degraded data. * * @param array $row */ private function crawl_row_has_rich_data(array $row): bool { foreach (['_html_parsed', 'viewport', 'asset_counts', 'internal_links', 'has_bom_before_doctype'] as $rich_key) { if (array_key_exists($rich_key, $row)) { return true; } } return false; } /** * v3.3.57 — single source for the finding lifecycle status. Derivation * rules (in priority order, only applied when the check left the default * "open"): * parked — severity info AND |score_impact| = 0. Identical to the * parked_recommendation tier the Findings page and the HTML * report already bucket by, so the status field and the * tier sections can never disagree. * mitigated — evidence.mitigation_notes non-empty. The same marker the * Performance/Mobile checks use to append "(mitigated)" to * the title. * A check may also set an explicit non-open status itself; that always * wins. Score impact is never modified here. * * @param array> $findings * @return array> */ private function derive_finding_lifecycle_statuses(array $findings): array { foreach ($findings as $i => $f) { if (!is_array($f)) { continue; } $status = strtolower((string) ($f['status'] ?? 'open')); if ($status !== '' && $status !== 'open') { continue; } $sev = strtolower((string) ($f['severity'] ?? 'info')); $impact = abs((int) ($f['score_impact'] ?? 0)); $evidence = isset($f['evidence']) && is_array($f['evidence']) ? $f['evidence'] : []; if ($sev === 'info' && $impact === 0) { $findings[$i]['status'] = 'parked'; } elseif (!empty($evidence['mitigation_notes'])) { $findings[$i]['status'] = 'mitigated'; } } return $findings; } /** * Start a crawl run and store initial state. */ public function start_crawl(): array { $settings = Settings::get(); $existing = $this->repo->get_running_run('crawl'); if ($existing) { // Resume existing. $run_id = (int) $existing['id']; $summary = (array) get_option(Settings::CRAWL_STATE_OPTION, []); $summary = array_merge($summary, [ 'run_id' => $run_id, 'status' => 'running', 'updated_at' => current_time('mysql'), ]); // Keep options small: do not store queue/visited in wp_options. unset($summary['queue'], $summary['visited']); update_option(Settings::CRAWL_STATE_OPTION, $summary, false); return ['run_id' => (int) $existing['id'], 'status' => 'running', 'message' => 'Resumed existing crawl run.']; } $run_id = $this->repo->create_run('crawl', 'running'); $crawlCollector = new CrawlCollector(); $raw_max_urls = (int) ($settings['scan_limits']['crawl_max_urls'] ?? 0); $cap = (int) apply_filters('aetos_seo_crawl_max_urls_cap', 200000); if ($cap <= 0) { $cap = 200000; } $max_urls = $raw_max_urls <= 0 ? 0 : max(1, min($cap, $raw_max_urls)); $queue_cap = (int) ($settings['scan_limits']['crawl_queue_cap'] ?? 50000); $queue_cap = max(500, min(200000, $queue_cap)); $seed_limit = $max_urls > 0 ? min($max_urls, $queue_cap) : 0; $seedInfo = $crawlCollector->discover_seeds($seed_limit); $seed_urls = (array) ($seedInfo['urls'] ?? []); if (empty($seed_urls)) { $seed_urls = [home_url('/')]; } $seed_urls = array_values(array_filter($seed_urls, static function ($u) { return is_string($u) && $u !== '' && Url::is_internal_url($u) && !Url::is_default_excluded_url($u) && !Url::is_probably_non_html_url($u); })); if (empty($seed_urls)) { $seed_urls = [home_url('/')]; } $this->repo->clear_crawl_for_run($run_id); $this->repo->delete_crawl_state($run_id); $full_state = [ 'run_id' => $run_id, 'status' => 'running', 'seed_info' => $seedInfo, 'queue' => array_values(array_unique(array_slice($seed_urls, 0, $queue_cap))), 'visited' => [], 'processed' => 0, 'max_urls' => $max_urls, 'queue_cap' => $queue_cap, 'progress_peak_percent' => 0, 'started_at' => current_time('mysql'), 'updated_at' => current_time('mysql'), ]; $this->repo->save_crawl_state($run_id, $full_state); $summary = [ 'run_id' => $run_id, 'status' => 'running', 'processed' => 0, 'queue' => count($full_state['queue']), 'max_urls' => $max_urls, 'queue_cap' => $queue_cap, 'progress_peak_percent' => 0, 'started_at' => $full_state['started_at'], 'updated_at' => $full_state['updated_at'], ]; update_option(Settings::CRAWL_STATE_OPTION, $summary, false); return ['run_id' => $run_id, 'status' => 'running', 'message' => 'Crawl started']; } /** * Pause the currently running crawl (UI convenience). */ public function pause_crawl(): array { $summary = (array) get_option(Settings::CRAWL_STATE_OPTION, []); $run_id = isset($summary['run_id']) ? (int) $summary['run_id'] : 0; if ($run_id <= 0) { return ['status' => 'idle', 'message' => 'No crawl state']; } $state = $this->repo->get_crawl_state($run_id); if (empty($state) || !is_array($state)) { return ['status' => 'idle', 'message' => 'Missing crawl state']; } $state['status'] = 'paused'; $state['updated_at'] = current_time('mysql'); $this->repo->save_crawl_state($run_id, $state); $queue_count = isset($state['queue']) && is_array($state['queue']) ? count($state['queue']) : (int) ($state['queue'] ?? 0); $summary2 = [ 'run_id' => $run_id, 'status' => 'paused', 'processed' => (int) ($state['processed'] ?? 0), 'queue' => $queue_count, 'max_urls' => (int) ($state['max_urls'] ?? 0), 'progress_peak_percent' => (int) ($state['progress_peak_percent'] ?? 0), 'started_at' => (string) ($state['started_at'] ?? current_time('mysql')), 'updated_at' => (string) $state['updated_at'], ]; update_option(Settings::CRAWL_STATE_OPTION, $summary2, false); return [ 'run_id' => $run_id, 'status' => 'paused', 'done' => false, 'metrics' => $this->build_crawl_metrics($state, $queue_count, 0, 0), 'progress' => sprintf('Status: %s', 'paused'), ]; } /** * Resume a paused crawl (if any). */ public function resume_crawl(): array { $summary = (array) get_option(Settings::CRAWL_STATE_OPTION, []); $run_id = isset($summary['run_id']) ? (int) $summary['run_id'] : 0; if ($run_id <= 0) { return ['status' => 'idle', 'message' => 'No crawl state']; } $state = $this->repo->get_crawl_state($run_id); if (empty($state) || !is_array($state)) { return ['status' => 'idle', 'message' => 'Missing crawl state']; } if (($state['status'] ?? '') === 'paused') { $state['status'] = 'running'; $state['updated_at'] = current_time('mysql'); $this->repo->save_crawl_state($run_id, $state); $queue_count = isset($state['queue']) && is_array($state['queue']) ? count($state['queue']) : (int) ($state['queue'] ?? 0); $summary2 = [ 'run_id' => $run_id, 'status' => 'running', 'processed' => (int) ($state['processed'] ?? 0), 'queue' => $queue_count, 'max_urls' => (int) ($state['max_urls'] ?? 0), 'progress_peak_percent' => (int) ($state['progress_peak_percent'] ?? 0), 'started_at' => (string) ($state['started_at'] ?? current_time('mysql')), 'updated_at' => (string) $state['updated_at'], ]; update_option(Settings::CRAWL_STATE_OPTION, $summary2, false); } $queue_count = isset($state['queue']) && is_array($state['queue']) ? count($state['queue']) : (int) ($state['queue'] ?? 0); return [ 'run_id' => $run_id, 'status' => (string) ($state['status'] ?? 'unknown'), 'done' => false, 'metrics' => $this->build_crawl_metrics($state, $queue_count, 0, 0), 'progress' => sprintf('Status: %s', (string) ($state['status'] ?? 'unknown')), ]; } /** * Get current crawl status/metrics without running a batch (UI polling). */ public function get_crawl_status(): array { $summary = (array) get_option(Settings::CRAWL_STATE_OPTION, []); $run_id = isset($summary['run_id']) ? (int) $summary['run_id'] : 0; if ($run_id <= 0) { return ['status' => 'idle', 'message' => 'No crawl state']; } $state = $this->repo->get_crawl_state($run_id); if (empty($state) || !is_array($state)) { $state = [ 'run_id' => $run_id, 'status' => (string) ($summary['status'] ?? 'unknown'), 'processed' => (int) ($summary['processed'] ?? 0), 'queue' => (int) ($summary['queue'] ?? 0), 'max_urls' => (int) ($summary['max_urls'] ?? 0), 'progress_peak_percent' => (int) ($summary['progress_peak_percent'] ?? 0), 'started_at' => (string) ($summary['started_at'] ?? ''), 'updated_at' => (string) ($summary['updated_at'] ?? ''), ]; } $queue_count = isset($state['queue']) && is_array($state['queue']) ? count($state['queue']) : (int) ($state['queue'] ?? 0); $metrics = $this->build_crawl_metrics($state, $queue_count, 0, 0); return [ 'run_id' => $run_id, 'status' => (string) ($state['status'] ?? 'unknown'), 'done' => (($state['status'] ?? '') === 'done' || ($state['status'] ?? '') === 'completed'), 'metrics' => $metrics, ]; } /** * Process next crawl batch. */ public function crawl_next_batch(): array { $settings = Settings::get(); $summary = (array) get_option(Settings::CRAWL_STATE_OPTION, []); // Shared hosting friendly delay between batches (ms). $delay_ms = (int) apply_filters('aetos_seo_crawl_delay_ms', 1200); if ($delay_ms < 1200) { $delay_ms = 1200; } $run_id = isset($summary['run_id']) ? (int) $summary['run_id'] : 0; if ($run_id <= 0) { return ['status' => 'idle', 'message' => 'No crawl state']; } if (($summary['status'] ?? '') !== 'running') { return ['status' => (string) ($summary['status'] ?? 'unknown'), 'message' => 'Not running']; } // Prevent race conditions: only one batch can run at a time. $lock_hint = (int) ($settings['scan_limits']['max_seconds_per_batch'] ?? ($settings['scan_limits']['max_seconds'] ?? 8)); $lock_token = $this->repo->acquire_crawl_lock($run_id, max(15, $lock_hint + 10)); if ($lock_token === '') { /* translators: %s = status */ $progress_text = sprintf(__('Another batch is already running. Please retry in a few seconds.\nStatus: %s', 'aetos-seo'), 'running'); $hint_state = [ 'processed' => (int) ($summary['processed'] ?? 0), 'queue' => (int) ($summary['queue'] ?? 0), 'max_urls' => (int) ($summary['max_urls'] ?? 0), 'progress_peak_percent' => (int) ($summary['progress_peak_percent'] ?? 0), 'started_at' => (string) ($summary['started_at'] ?? ''), 'updated_at' => (string) ($summary['updated_at'] ?? ''), 'status' => 'running', ]; $metrics = $this->build_crawl_metrics($hint_state, 0, 0, 0); return [ 'run_id' => $run_id, 'status' => 'running', 'done' => false, 'retry_after_ms' => $delay_ms, 'progress' => $progress_text, 'metrics' => $metrics, ]; } try { $state = $this->repo->get_crawl_state($run_id); // Migration: older installs stored full state in wp_options. if (empty($state)) { $legacy = (array) get_option(Settings::CRAWL_STATE_OPTION, []); if (isset($legacy['queue']) || isset($legacy['visited'])) { $state = $legacy; $this->repo->save_crawl_state($run_id, $state); } } if (empty($state) || (($state['status'] ?? '') !== 'running')) { return ['status' => 'idle', 'message' => 'Missing or invalid crawl state']; } $queue = isset($state['queue']) && is_array($state['queue']) ? $state['queue'] : []; $visited = isset($state['visited']) && is_array($state['visited']) ? $state['visited'] : []; $processed = isset($state['processed']) ? (int) $state['processed'] : 0; $max_urls = isset($state['max_urls']) ? (int) $state['max_urls'] : 0; $queue_cap = isset($state['queue_cap']) ? (int) $state['queue_cap'] : (int) ($settings['scan_limits']['crawl_queue_cap'] ?? 50000); $queue_cap = max(500, min(200000, $queue_cap)); $per_batch_seconds = (int) ($settings['scan_limits']['max_seconds_per_batch'] ?? ($settings['scan_limits']['max_seconds'] ?? 8)); $configured_batch_urls = (int) ($settings['scan_limits']['crawl_per_batch'] ?? 2); $batch_urls = max(1, min(20, max($configured_batch_urls, $per_batch_seconds * 2))); $time_limit = (float) ($settings['scan_limits']['max_seconds_per_batch'] ?? ($settings['scan_limits']['max_seconds'] ?? 8)); $crawlCollector = new CrawlCollector(); $new_rows = 0; $new_links = 0; $t0 = microtime(true); while (!empty($queue) && ($max_urls <= 0 || $processed < $max_urls) && $new_rows < $batch_urls) { $url = array_shift($queue); if (!is_string($url) || $url === '') { continue; } $norm = Url::normalize($url); if (isset($visited[$norm])) { continue; } if (!Url::is_internal_url($url)) { continue; } if (Url::is_default_excluded_url($url) || Url::is_probably_non_html_url($url)) { continue; } try { $row = $crawlCollector->crawl_url($url, 'crawl'); } catch (\Throwable $error) { // A single malformed page or a third-party HTTP filter must // not leave the same URL at the front of the queue forever. $row = [ 'url' => $url, 'normalized_url' => $norm, 'status_code' => 0, 'redirect_chain' => [], 'content_type' => '', 'html_analyzed' => false, '_html_parsed' => false, 'internal_links' => [], 'discovered_from' => 'crawl', '_crawl_error' => [ 'code' => 'collector_exception', 'type' => get_class($error), 'message' => Redactor::redact($error->getMessage(), 200), ], ]; } $visited[$norm] = 1; $processed++; $new_rows++; $this->repo->insert_crawl_url($run_id, $row); $links = $row['internal_links'] ?? []; if (is_array($links)) { foreach ($links as $lnk) { if (!is_string($lnk)) { continue; } $n2 = Url::normalize($lnk); if (isset($visited[$n2])) { continue; } if (!Url::is_internal_url($lnk) || Url::is_default_excluded_url($lnk) || Url::is_probably_non_html_url($lnk)) { continue; } if (count($queue) < $queue_cap) { $queue[] = $lnk; $new_links++; } } } if ((microtime(true) - $t0) > $time_limit) { break; } } $state['queue'] = array_values($queue); $state['visited'] = $visited; $state['processed'] = $processed; $state['updated_at'] = current_time('mysql'); $done = false; $limit_reached = $max_urls > 0 && $processed >= $max_urls && !empty($queue); if (($max_urls > 0 && $processed >= $max_urls) || empty($queue)) { $done = true; $state['status'] = 'done'; $state['ended_at'] = current_time('mysql'); $state['limit_reached'] = $limit_reached; // Finalize run: generate crawl findings. $rows = $this->repo->get_all_crawl_rows($run_id); $ctx = new RunContext($run_id, 'crawl', $settings, ['crawl_rows' => $rows]); $crawlCheck = new Checks\CrawlCheck(); $findings = $crawlCheck->run($ctx); $this->repo->clear_findings_for_run($run_id); $this->repo->insert_findings($run_id, $findings); $category_scores = $this->compute_category_scores($findings); $overall = $this->compute_overall_score($category_scores, ['Crawl']); $this->repo->update_run($run_id, [ 'status' => 'completed', 'ended_at' => current_time('mysql'), 'overall_score' => $overall, 'category_scores' => wp_json_encode($category_scores), 'counts_json' => wp_json_encode(['findings_total' => count($findings)]), 'context_json' => wp_json_encode(Redactor::redact_array([ 'seed_info' => $state['seed_info'] ?? [], 'rows' => count($rows), 'limit_reached' => $limit_reached, 'max_urls' => $max_urls, ])), 'duration_ms' => (int) (max(0, time() - Time::mysql_to_timestamp((string) ($state['started_at'] ?? current_time('mysql')))) * 1000), 'notes' => $limit_reached ? 'crawl completed at configured URL limit' : 'crawl completed', ]); } $queue_count = (int) count($queue); $metrics = $this->build_crawl_metrics($state, $queue_count, $new_rows, $new_links); $metrics['limit_reached'] = !empty($state['limit_reached']); $state['progress_peak_percent'] = max( (int) ($state['progress_peak_percent'] ?? 0), (int) ($metrics['percent'] ?? 0) ); // Persist full state in DB. $this->repo->save_crawl_state($run_id, $state); // Persist summary state in wp_options (no queue/visited). $summary2 = [ 'run_id' => $run_id, 'status' => (string) ($state['status'] ?? 'running'), 'processed' => $processed, 'queue' => $queue_count, 'max_urls' => $max_urls, 'queue_cap' => $queue_cap, 'progress_peak_percent' => (int) $state['progress_peak_percent'], 'started_at' => (string) ($state['started_at'] ?? current_time('mysql')), 'updated_at' => (string) ($state['updated_at'] ?? current_time('mysql')), ]; if (!empty($state['limit_reached'])) { $summary2['limit_reached'] = 1; } if (!empty($state['ended_at'])) { $summary2['ended_at'] = (string) $state['ended_at']; } update_option(Settings::CRAWL_STATE_OPTION, $summary2, false); $denom = (int) ($metrics['target_total'] ?? ($metrics['max_urls'] ?? 0)); $cap = (int) ($metrics['max_urls'] ?? 0); $cap_txt = $cap > 0 ? sprintf(' / %d', $denom) : sprintf(' / discovered %d', $denom); $progress_text = sprintf( "Processed: %d%s\nQueue: %d / %d | New links queued: %d | Batch URLs: %d\nStatus: %s", (int) $metrics['processed'], $cap_txt, (int) $metrics['queue'], $queue_cap, (int) $metrics['new_links'], (int) $metrics['batch_urls'], (string) $metrics['status'] ); if (!empty($metrics['limit_reached'])) { $progress_text .= "\nConfigured crawl limit reached."; } return [ 'run_id' => $run_id, 'status' => (string) ($state['status'] ?? 'unknown'), 'done' => $done, 'retry_after_ms' => $done ? 0 : $delay_ms, 'progress' => $progress_text, 'metrics' => $metrics, ]; } finally { $this->repo->release_crawl_lock($run_id, $lock_token); } } /** * Build crawl metrics for the admin UI (progress bar, speed, ETA). * * @param array $state * @return array */ private function build_crawl_metrics(array $state, int $queue_count, int $batch_urls, int $new_links): array { $processed = (int) ($state['processed'] ?? 0); $max_urls = (int) ($state['max_urls'] ?? 0); $queue_count = max(0, $queue_count); // Adaptive progress: use discovered URLs (processed + queue) as the moving target, // capped by max_urls. This avoids a frozen low percent when max_urls is high but the site is small. $discovered = $processed + $queue_count; $target_total = $discovered > 0 ? $discovered : max(1, $processed); if ($max_urls > 0) { $target_total = min($max_urls, $target_total); } $target_total = max(1, $target_total); $status = (string) ($state['status'] ?? 'unknown'); $stored_peak = (int) ($state['progress_peak_percent'] ?? 0); $percent = (int) floor(min(100, max(0, ($processed / $target_total) * 100))); if ($status === 'done' || $status === 'completed') { $percent = 100; } // v3.3.41 — removed `$percent = max($percent, min(99, $stored_peak));` // that latched the reported percent at the historical max. With // the new linear build-phase formula in Plugin::cron_run_crawl(), // the bar legitimately drops when the queue refills (more URLs // discovered). Latching at peak made the bar appear stuck. The // `$stored_peak` value is still tracked in state for downstream // consumers, but the live `percent` field now reflects reality. $started_at = (string) ($state['started_at'] ?? ''); $elapsed_s = 0; if ($started_at !== '') { $ts = Time::mysql_to_timestamp($started_at); if ($ts) { $elapsed_s = max(0, time() - (int) $ts); } } $rate_ppm = 0.0; // pages per minute if ($elapsed_s > 0 && $processed > 0) { $rate_ppm = ($processed / $elapsed_s) * 60.0; } $eta_s = 0; if ($rate_ppm > 0 && $target_total > 0 && $processed < $target_total) { $remaining = max(0, $target_total - $processed); $pps = $rate_ppm / 60.0; if ($pps > 0) { $eta_s = (int) round($remaining / $pps); } } return [ 'status' => $status, 'processed' => $processed, 'max_urls' => $max_urls, 'unlimited' => $max_urls <= 0, 'discovered' => $discovered, 'target_total' => $target_total, 'queue' => max(0, $queue_count), 'batch_urls' => max(0, $batch_urls), 'new_links' => max(0, $new_links), 'percent' => $percent, 'limit_reached' => !empty($state['limit_reached']), 'progress_peak_percent' => max($stored_peak, $percent), 'started_at' => $started_at, 'elapsed_s' => $elapsed_s, 'rate_ppm' => round($rate_ppm, 1), 'eta_s' => $eta_s, ]; } /** * v2.2.0 — extract URLs from a finding's evidence.samples for live re-test. * Mirrors the report exporter's sample-URL extraction, inline for the Runner. * * @param array $row * @return array */ private function extract_finding_urls(array $row): array { $evidence = isset($row['evidence']) ? $row['evidence'] : []; if (is_string($evidence)) { $decoded = json_decode($evidence, true); if (is_array($decoded)) { $evidence = $decoded; } else { return []; } } if (!is_array($evidence)) { return []; } $urls = []; $candidate_keys = ['samples']; foreach ($evidence as $k => $v) { if (is_string($k) && (strpos($k, 'samples') === 0 || strpos($k, '_samples') !== false || $k === 'pairs' || $k === 'candidates')) { $candidate_keys[] = $k; } } $candidate_keys = array_unique($candidate_keys); foreach ($candidate_keys as $key) { if (!isset($evidence[$key]) || !is_array($evidence[$key])) { continue; } foreach ($evidence[$key] as $item) { if (is_string($item)) { $urls[] = $item; } elseif (is_array($item)) { if (!empty($item['url']) && is_string($item['url'])) { $urls[] = $item['url']; } foreach (['page_a', 'page_b'] as $sub) { if (isset($item[$sub]) && is_array($item[$sub]) && !empty($item[$sub]['url'])) { $urls[] = (string) $item[$sub]['url']; } } } } } return array_values(array_unique($urls)); } /** * v2.8.1 — Map check class name to its primary audit category. * Used by the catch block in the runner so an internal check error gets * tagged in its natural category (Performance, On-Page, …) instead of * always 'Logs'. Without this mapping, an errored Performance check * leaves Performance at 100/100 — a misleading "perfect" score. */ private function check_class_to_category(string $fqcn): string { // Strip namespace. $short = (string) substr((string) strrchr('\\' . $fqcn, '\\'), 1); // The Lite build ships only the 10 free checks, so only their class // names can ever reach this lookup. The Pro-only check classes live in // the physically-stripped block below; any class not in the map (which // cannot happen in Lite) falls back to 'Other'. static $map = [ 'PerformanceCheck' => 'Performance', 'OnPageCheck' => 'On-Page', 'TechnicalSEOCheck' => 'Technical SEO', 'AdvancedSchemaCheck' => 'Technical SEO', 'CrawlCheck' => 'Crawl', 'AiFriendlyCheck' => 'AI-Friendly', 'AnalyticsStackCheck' => 'Analytics', 'MobileUsabilityCheck' => 'Mobile Usability', 'SecurityCheck' => 'Security', 'MediaCheck' => 'Media', ]; $map = apply_filters('aetos_seo_check_class_category_map', $map); if (!is_array($map)) { $map = []; } return isset($map[$short]) && is_string($map[$short]) ? $map[$short] : 'Other'; } /** * @param array> $findings * @return array */ private function compute_category_scores(array $findings): array { $scores = []; // Start at 100. foreach ($findings as $f) { $cat = (string) ($f['category'] ?? ''); if ($cat === '') { continue; } if (!isset($scores[$cat])) { $scores[$cat] = 100; } $impact = (int) ($f['score_impact'] ?? 0); $scores[$cat] += $impact; // impacts are negative usually. } foreach ($scores as $cat => $v) { $scores[$cat] = max(0, min(100, (int) $v)); } return $scores; } /** * @param array $category_scores * @param string[] $include_categories */ private function compute_overall_score(array $category_scores, array $include_categories): int { $vals = []; foreach ($include_categories as $cat) { if (isset($category_scores[$cat])) { $vals[] = (int) $category_scores[$cat]; } // Missing category is skipped — only average what was actually measured. // Previously this defaulted to 100, which artificially inflated scores. } if (empty($vals)) { return 0; } $sum = array_sum($vals); return (int) round($sum / count($vals)); } }