'html', 'csv' => 'csv', 'xlsx' => 'xlsx', 'pdf' => 'pdf', ]; return $extensions[$format] ?? 'bin'; } private static function send_raw_export($body, $content_type, $filename) { if (!headers_sent()) { nocache_headers(); header('Content-Type: ' . ($content_type ?: 'application/octet-stream')); header('Content-Disposition: attachment; filename="' . $filename . '"'); header('Content-Length: ' . strlen($body)); } // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Raw attachment bytes; HTML is escaped by the report renderer and escaping PDF/XLSX/CSV would corrupt downloads. echo $body; exit; } private static function render_html_export($id) { $client = new AI_SEO_CRO_Scan_Client(); $report_result = $client->get_report($id); if (empty($report_result['success']) || !is_array($report_result['data'])) { return ['success' => false, 'message' => 'Unable to fetch report data', 'error' => $report_result]; } $scan_result = $client->get_scan($id); $scan = []; if (!empty($scan_result['success']) && is_array($scan_result['data'])) { $scan = $scan_result['data']['scan'] ?? $scan_result['data']; } $report = self::filter_excluded_report_pages($report_result['data']['report'] ?? $report_result['data']); return [ 'success' => true, 'body' => ai_seo_cro_render_report_html($id, $report, $scan), 'content_type' => 'text/html; charset=utf-8', ]; } public static function start_scan($request) { $client = new AI_SEO_CRO_Scan_Client(); $params = json_decode($request->get_body(), true) ?: []; $projectName = sanitize_text_field($params['projectName'] ?? 'WP Scan'); $startUrl = esc_url_raw($params['startUrl'] ?? ''); $maxPages = isset($params['maxPages']) ? (int) $params['maxPages'] : 100; $maxDepth = isset($params['maxDepth']) ? (int) $params['maxDepth'] : 6; $includeQueryParameters = null; if (array_key_exists('includeQueryParameters', $params)) { if (!is_bool($params['includeQueryParameters'])) { return new WP_Error('ai_seo_cro_invalid_query_setting', 'includeQueryParameters must be a boolean.', ['status' => 400]); } $includeQueryParameters = $params['includeQueryParameters']; } if (!$startUrl) { return rest_ensure_response(['success' => false, 'message' => 'startUrl is required']); } $result = $client->create_scan($projectName, $startUrl, $maxPages, $maxDepth, $includeQueryParameters); return rest_ensure_response($result['data'] ?? ['success' => false, 'message' => 'Unknown error', 'raw' => $result]); } public static function get_scan($request) { $id = $request['id'] ?? ''; $client = new AI_SEO_CRO_Scan_Client(); $result = $client->get_scan($id); return rest_ensure_response($result['data'] ?? ['success' => false, 'message' => 'Unable to fetch scan']); } public static function delete_scan($request) { $id = sanitize_text_field($request['id'] ?? ''); if (!$id) { return new WP_Error('ai_seo_cro_invalid_scan_id', 'Invalid scan ID', ['status' => 400]); } $client = new AI_SEO_CRO_Scan_Client(); $result = $client->delete_scan($id); if (empty($result['success'])) { $response_data = is_array($result['data'] ?? null) ? $result['data'] : []; $status = (int) ($result['status'] ?? 0); return new WP_Error( 'ai_seo_cro_scan_delete_failed', $response_data['message'] ?? 'Unable to delete scan', ['status' => $status >= 400 ? $status : 502, 'error' => $result] ); } return rest_ensure_response($result['data'] ?? [ 'success' => true, 'scanId' => $id, 'message' => 'Scan deleted', ]); } public static function get_report($request) { $id = $request['id'] ?? ''; $client = new AI_SEO_CRO_Scan_Client(); $result = $client->get_report($id); $data = $result['data'] ?? ['success' => false, 'message' => 'Unable to fetch report']; if (isset($data['report']) && is_array($data['report'])) { $data['report'] = self::filter_excluded_report_pages($data['report']); } elseif (is_array($data)) { $data = self::filter_excluded_report_pages($data); } return rest_ensure_response($data); } public static function get_report_html($request) { $id = sanitize_text_field($request['id'] ?? ''); if (!$id) { return new WP_Error('ai_seo_cro_invalid_scan_id', 'Invalid scan ID', ['status' => 400]); } $result = self::render_html_export($id); if (empty($result['success'])) { return new WP_Error( 'ai_seo_cro_report_html_failed', $result['message'] ?? 'Unable to render report', ['status' => 502, 'error' => $result] ); } return rest_ensure_response([ 'success' => true, 'html' => $result['body'], ]); } public static function export_scan($request) { $id = $request['id'] ?? ''; $format = self::allowed_export_format($request->get_param('format') ?: 'html'); $client = new AI_SEO_CRO_Scan_Client(); $result = $format === 'html' ? self::render_html_export($id) : $client->export_scan($id, $format); if (empty($result['success']) || empty($result['body'])) { return new WP_Error( 'ai_seo_cro_export_failed', 'Export failed', ['status' => 502, 'error' => $result] ); } $content_type = $result['content_type'] ?: 'application/octet-stream'; $body = $result['body']; $safe_id = preg_replace('/[^a-z0-9_-]/i', '-', $id); $filename = sprintf('scan-%s.%s', $safe_id, self::export_extension($format)); self::send_raw_export($body, $content_type, $filename); } public static function save_export($request) { try { $id = sanitize_text_field($request['id'] ?? ''); $format = self::allowed_export_format($request->get_param('format') ?: 'html'); if (!$id) { return new WP_Error('ai_seo_cro_invalid_scan_id', 'Invalid scan ID', ['status' => 400]); } $client = new AI_SEO_CRO_Scan_Client(); $result = $format === 'html' ? self::render_html_export($id) : $client->export_scan($id, $format); if (empty($result['success'])) { return new WP_Error( 'ai_seo_cro_export_fetch_failed', 'Export fetch failed', ['status' => 502, 'error' => $result] ); } $body = $result['body'] ?? ''; if ($body === '') { return new WP_Error('ai_seo_cro_empty_export', 'Export body is empty', ['status' => 502]); } $content_type = $result['content_type'] ?? 'application/octet-stream'; $attachment_mime_type = trim(strtok($content_type, ';')) ?: 'application/octet-stream'; $ext = self::export_extension($format); $safe_id = preg_replace('/[^a-z0-9_-]/i', '-', $id); $filename = sprintf('scan-%s.%s', $safe_id, $ext); $upload_dir = wp_upload_dir(); if (!empty($upload_dir['error'])) { return new WP_Error('ai_seo_cro_upload_dir_error', 'Upload directory error: ' . $upload_dir['error'], ['status' => 500]); } $upload_path = $upload_dir['path'] . '/' . $filename; $upload_url = $upload_dir['url'] . '/' . $filename; if (!wp_mkdir_p($upload_dir['path'])) { return new WP_Error('ai_seo_cro_upload_dir_create_failed', 'Failed to create upload directory', ['status' => 500]); } $written = file_put_contents($upload_path, $body); if ($written === false) { return new WP_Error('ai_seo_cro_write_failed', 'Failed to write file to disk', ['status' => 500]); } $attachment = [ 'guid' => $upload_url, 'post_mime_type' => $attachment_mime_type, 'post_title' => sanitize_text_field('Scan export ' . $id), 'post_content' => '', 'post_status' => 'inherit' ]; $attach_id = wp_insert_attachment($attachment, $upload_path); if (is_wp_error($attach_id) || !$attach_id) { return new WP_Error( 'ai_seo_cro_attachment_failed', 'Failed to create attachment: ' . (is_wp_error($attach_id) ? $attach_id->get_error_message() : 'Unknown error'), ['status' => 500] ); } $filetype = wp_check_filetype($upload_path); if (!empty($filetype['type']) && preg_match('/^(image|audio|video)\//', $filetype['type'])) { require_once ABSPATH . 'wp-admin/includes/image.php'; $meta = wp_generate_attachment_metadata($attach_id, $upload_path); if (!empty($meta)) { wp_update_attachment_metadata($attach_id, $meta); } } $final_url = wp_get_attachment_url($attach_id); if (!$final_url) { $final_url = $upload_url; } return rest_ensure_response(['success' => true, 'attachment_id' => $attach_id, 'url' => $final_url]); } catch (Throwable $e) { return new WP_Error( 'ai_seo_cro_save_export_exception', 'Save failed: ' . $e->getMessage(), ['status' => 500] ); } } public static function list_scans($request) { $params = []; foreach (['status', 'startUrl'] as $key) { if ($request->get_param($key) !== null) { $params[$key] = $request->get_param($key); } } $params['limit'] = 5; $params['offset'] = 0; if (empty($params['startUrl'])) { $params['startUrl'] = home_url('/'); } $client = new AI_SEO_CRO_Scan_Client(); $result = $client->list_scans($params); $data = $result['data'] ?? []; $scans = $data['scans'] ?? ($data['data']['scans'] ?? (is_array($data) ? $data : [])); $latest_report = []; $active_scan = []; foreach ((array) $scans as $scan) { $status = strtoupper((string) ($scan['status'] ?? $scan['state'] ?? '')); $pages_crawled = (int) ($scan['pagesCrawled'] ?? $scan['pages_crawled'] ?? 0); if (!$active_scan && in_array($status, ['QUEUED', 'RUNNING', 'PROCESSING'], true)) { $active_scan[] = $scan; } if (!$latest_report && $status === 'COMPLETED' && $pages_crawled > 0) { $latest_report[] = $scan; } if ($active_scan && $latest_report) { break; } } $visible_scans = array_merge($active_scan, $latest_report); return rest_ensure_response([ 'success' => !empty($result['success']), 'scans' => $visible_scans, 'total' => count($visible_scans), ]); } public static function get_scan_history($request) { $client = new AI_SEO_CRO_Scan_Client(); $list_result = $client->list_scans(['limit' => 12, 'offset' => 0, 'startUrl' => home_url('/')]); $list_data = $list_result['data'] ?? []; $scans = $list_data['scans'] ?? ($list_data['data']['scans'] ?? (is_array($list_data) ? $list_data : [])); $points = []; foreach (array_slice((array) $scans, 0, 12) as $scan) { if (strtoupper((string) ($scan['status'] ?? $scan['state'] ?? '')) !== 'COMPLETED') { continue; } if ((int) ($scan['pagesCrawled'] ?? $scan['pages_crawled'] ?? 0) < 1) { continue; } $id = sanitize_text_field($scan['id'] ?? $scan['scanId'] ?? ''); if (!$id) { continue; } $report_result = $client->get_report($id); $report_data = $report_result['data'] ?? []; $report = $report_data['report'] ?? $report_data; if (!is_array($report)) { continue; } if ((int) ($report['pagesCrawled'] ?? 0) < 1) { continue; } $page_severity = $report['pageIssuesBySeverity'] ?? []; $site_severity = $report['siteIssuesBySeverity'] ?? []; $issue_codes = is_array($report['pageIssuesByCode'] ?? null) ? $report['pageIssuesByCode'] : []; foreach ((array) ($report['topSiteIssues'] ?? []) as $site_issue) { $code = sanitize_key($site_issue['code'] ?? ''); if ($code !== '') { $issue_codes[strtoupper($code)] = (int) ($site_issue['affectedCount'] ?? $issue_codes[strtoupper($code)] ?? 0); } } $points[] = [ 'scanId' => $id, 'date' => $scan['completedAt'] ?? $scan['updatedAt'] ?? $scan['createdAt'] ?? '', 'pagesCrawled' => (int) ($report['pagesCrawled'] ?? 0), 'siteHealth' => (int) ($report['siteScore'] ?? 0), 'totalIssues' => (int) ($report['issueCount'] ?? 0) + (int) ($report['sitewideIssueCount'] ?? 0), 'errors' => (int) ($page_severity['HIGH'] ?? 0) + (int) ($site_severity['HIGH'] ?? 0), 'warnings' => (int) ($page_severity['MEDIUM'] ?? 0) + (int) ($site_severity['MEDIUM'] ?? 0), 'notices' => (int) ($page_severity['NOTICE'] ?? 0) + (int) ($site_severity['NOTICE'] ?? 0), 'serverErrors' => (int) ($report['httpStatusDistribution']['5xx'] ?? 0), 'issueCodes' => $issue_codes, ]; } return rest_ensure_response(['success' => true, 'points' => array_reverse($points)]); } private static function filter_excluded_report_pages($report) { if (!is_array($report)) { return $report; } foreach (['pageIssues', 'page_issues', 'issues', 'pages', 'samplePages', 'sample_pages', 'weakestPages', 'weakest_pages', 'failedPages', 'failed_pages'] as $key) { if (!isset($report[$key]) || !is_array($report[$key])) { continue; } $report[$key] = array_values(array_filter($report[$key], function ($item) { return !ai_seo_cro_report_should_exclude_issue($item); })); } foreach (['topSiteIssues', 'topIssues', 'topCroIssues', 'croIssues'] as $key) { if (!isset($report[$key]) || !is_array($report[$key])) { continue; } $report[$key] = array_values(array_filter(array_map([__CLASS__, 'filter_excluded_issue_samples'], $report[$key]), function ($item) { $samples = ai_seo_cro_issue_samples($item); $has_samples = is_array($item) && ( array_key_exists('samples', $item) || array_key_exists('sampleUrls', $item) || array_key_exists('sample_urls', $item) || array_key_exists('urls', $item) ); return !ai_seo_cro_report_should_exclude_issue($item) && (!$has_samples || !empty($samples)); })); } return $report; } private static function filter_excluded_issue_samples($item) { if (!is_array($item)) { return $item; } foreach (['samples', 'sampleUrls', 'sample_urls', 'urls'] as $key) { if (!isset($item[$key])) { continue; } $values = ai_seo_cro_as_list($item[$key]); $item[$key] = array_values(array_filter($values, function ($url) { return !function_exists('ai_seo_cro_is_seo_excluded_url') || !ai_seo_cro_is_seo_excluded_url((string) $url); })); foreach (['affectedPages', 'affectedCount', 'affected_count', 'count', 'pageCount', 'page_count'] as $count_key) { if (array_key_exists($count_key, $item)) { $item[$count_key] = count($item[$key]); } } } return $item; } private static function get_report_item_url($item) { if (!is_array($item)) { return ''; } foreach (['url', 'pageUrl', 'page_url', 'href', 'loc', 'location'] as $key) { if (empty($item[$key])) { continue; } $candidate = (string) $item[$key]; if (wp_parse_url($candidate, PHP_URL_HOST)) { return $candidate; } } if (isset($item['page']) && is_array($item['page'])) { return self::get_report_item_url($item['page']); } return ''; } }