diff --git a/backend/.env b/backend/.env index c8a42c3..ae9b07a 100644 --- a/backend/.env +++ b/backend/.env @@ -12,6 +12,3 @@ EMAIL_USER=AKIAVEW7G4PQUBGM52OF EMAIL_PASS=BLnD4hKGb6YkSz3gaQrf8fnyLi3C3/EdjOOsLEDTDPTz SECRET_KEY=HUEyqESqgQ1yTwzVlO6wprC9Kf1J1xuA PEXELS_KEY=Vc99rnmOhHhJAbgGQoKLZtsaIVfkeownoQNbTj78VemUjKh08ZYRbf18 -FIRECRAWL_API_KEY=fc-409763513f6c458c9d1d09e460346b17 -FIRECRAWL_BASE_URL=https://api.firecrawl.dev/v2 -FIRECRAWL_ENABLED=true diff --git a/backend/src/services/firecrawl.js b/backend/src/services/firecrawl.js index 75e712c..8a5fe02 100644 --- a/backend/src/services/firecrawl.js +++ b/backend/src/services/firecrawl.js @@ -1,49 +1,4 @@ -const fs = require('fs'); -const path = require('path'); -const axios = require('axios'); - -const FIRECRAWL_DEFAULT_BASE_URL = 'https://api.firecrawl.dev/v2'; -const FIRECRAWL_DEFAULT_POLL_INTERVAL_MS = 2000; -const FIRECRAWL_DEFAULT_TIMEOUT_MS = 45000; - -const BACKEND_ENV_PATH = path.join(__dirname, '..', '..', '.env'); - -function readBackendEnvFile() { - try { - const raw = fs.readFileSync(BACKEND_ENV_PATH, 'utf8'); - - return raw.split(/\r?\n/).reduce((accumulator, line) => { - const trimmedLine = line.trim(); - - if (!trimmedLine || trimmedLine.startsWith('#')) { - return accumulator; - } - - const separatorIndex = trimmedLine.indexOf('='); - - if (separatorIndex === -1) { - return accumulator; - } - - const key = trimmedLine.slice(0, separatorIndex).trim(); - const value = trimmedLine.slice(separatorIndex + 1).trim(); - - accumulator[key] = value.replace(/^"|"$/g, '').replace(/^'|'$/g, ''); - return accumulator; - }, {}); - } catch (error) { - return {}; - } -} - -function getEnvValue(name) { - if (process.env[name] !== undefined && process.env[name] !== null && process.env[name] !== '') { - return process.env[name]; - } - - return readBackendEnvFile()[name]; -} - +const FIRECRAWL_DEFAULT_BASE_URL = 'https://api.firecrawl.dev/v1'; function toBoolean(value, defaultValue = false) { if (value === undefined || value === null || value === '') { @@ -67,34 +22,12 @@ function toBoolean(value, defaultValue = false) { return defaultValue; } -function toPositiveInteger(value, defaultValue) { - const parsed = Number(value); - - if (Number.isInteger(parsed) && parsed > 0) { - return parsed; - } - - return defaultValue; -} - -function normalizeBaseUrl(baseUrl) { - return String(baseUrl || FIRECRAWL_DEFAULT_BASE_URL) - .trim() - .replace(/\/+$/, ''); -} - function getFirecrawlRuntime() { - const apiKey = String(getEnvValue('FIRECRAWL_API_KEY') || '').trim(); - const baseUrl = normalizeBaseUrl(getEnvValue('FIRECRAWL_BASE_URL')); - const enabled = toBoolean(getEnvValue('FIRECRAWL_ENABLED'), true); - const pollIntervalMs = toPositiveInteger( - getEnvValue('FIRECRAWL_POLL_INTERVAL_MS'), - FIRECRAWL_DEFAULT_POLL_INTERVAL_MS, - ); - const timeoutMs = toPositiveInteger( - getEnvValue('FIRECRAWL_TIMEOUT_MS'), - FIRECRAWL_DEFAULT_TIMEOUT_MS, - ); + const apiKey = String(process.env.FIRECRAWL_API_KEY || '').trim(); + const baseUrl = String( + process.env.FIRECRAWL_BASE_URL || FIRECRAWL_DEFAULT_BASE_URL, + ).trim(); + const enabled = toBoolean(process.env.FIRECRAWL_ENABLED, true); return { provider: 'firecrawl', @@ -102,255 +35,29 @@ function getFirecrawlRuntime() { enabled, configured: Boolean(apiKey), hasApiKey: Boolean(apiKey), - apiKey, - pollIntervalMs, - timeoutMs, - mode: enabled && apiKey ? 'active' : 'scaffold_only', + mode: 'scaffold_only', }; } -function buildFirecrawlMessage(runtime, entitlements, requestedPages) { - if (!entitlements?.canAdvancedCrawl) { - return 'Firecrawl is reserved for paid Advanced Crawl users. This request will stay on the built-in crawler.'; - } - - if (!runtime.enabled) { - return 'Firecrawl is configured in code, but FIRECRAWL_ENABLED is turned off. Paid users will stay on the built-in crawler until it is enabled.'; - } - - if (!runtime.configured) { - return 'Firecrawl is enabled for paid users, but FIRECRAWL_API_KEY is missing. Falling back to the built-in crawler until the key is configured.'; - } - - return requestedPages > 1 - ? 'Paid Advanced Crawl users are routed through Firecrawl for sitemap-aware, JavaScript-rendered multi-page crawling.' - : 'Paid Advanced Crawl users are routed through Firecrawl for sitemap-aware, JavaScript-rendered crawling.'; -} - function getFirecrawlScaffold({ requestedPages, entitlements } = {}) { const runtime = getFirecrawlRuntime(); - const availableForCurrentUser = Boolean(entitlements?.canAdvancedCrawl); - const shouldUseFirecrawl = Boolean( - availableForCurrentUser - && runtime.enabled - && runtime.configured, - ); + const wantsAdvancedCrawl = Number(requestedPages || 1) > 1; + const advancedCrawlUnlocked = Boolean(entitlements?.canAdvancedCrawl); + const shouldUseFirecrawlLater = runtime.enabled && (wantsAdvancedCrawl || advancedCrawlUnlocked); return { - provider: 'firecrawl', - baseUrl: runtime.baseUrl, - enabled: runtime.enabled, - configured: runtime.configured, - hasApiKey: runtime.hasApiKey, - mode: shouldUseFirecrawl ? 'active' : runtime.mode, - status: shouldUseFirecrawl ? 'active_for_paid_users' : 'scaffold_only', + ...runtime, + status: runtime.configured ? 'ready_for_activation' : 'awaiting_api_key', wouldHandleJavascript: true, wouldHandleSitemapDiscovery: true, - availableForCurrentUser, - shouldUseFirecrawl, - usePaidOnly: true, - message: buildFirecrawlMessage(runtime, entitlements, requestedPages), - }; -} - -function sleep(milliseconds) { - return new Promise((resolve) => { - setTimeout(resolve, milliseconds); - }); -} - -function isAbsoluteUrl(value) { - return /^https?:\/\//i.test(String(value || '')); -} - -function buildApiUrl(runtime, pathOrUrl) { - const value = String(pathOrUrl || '').trim(); - - if (!value) { - return runtime.baseUrl; - } - - if (isAbsoluteUrl(value)) { - return value; - } - - if (value.startsWith('/')) { - return `${runtime.baseUrl}${value}`; - } - - return `${runtime.baseUrl}/${value}`; -} - -function summarizeFirecrawlPayload(payload) { - if (!payload) { - return 'Unknown Firecrawl API error.'; - } - - if (typeof payload === 'string') { - return payload; - } - - if (typeof payload?.error === 'string' && payload.error.trim()) { - return payload.error; - } - - if (typeof payload?.message === 'string' && payload.message.trim()) { - return payload.message; - } - - return 'Unexpected Firecrawl API response.'; -} - -async function firecrawlRequest(runtime, method, pathOrUrl, options = {}) { - try { - const response = await axios({ - method, - url: buildApiUrl(runtime, pathOrUrl), - timeout: options.timeout || runtime.timeoutMs, - data: options.data, - headers: { - Authorization: `Bearer ${runtime.apiKey}`, - 'Content-Type': 'application/json', - ...(options.headers || {}), - }, - }); - - return response.data; - } catch (error) { - if (axios.isAxiosError(error)) { - const payload = error.response?.data; - const detail = summarizeFirecrawlPayload(payload); - const status = error.response?.status; - const wrappedError = new Error( - status - ? `Firecrawl request failed with status ${status}: ${detail}` - : `Firecrawl request failed: ${detail}`, - ); - - wrappedError.code = status || 502; - wrappedError.response = payload; - throw wrappedError; - } - - throw error; - } -} - -async function collectPagedStatus(runtime, initialStatus) { - const documents = Array.isArray(initialStatus?.data) - ? [...initialStatus.data] - : []; - let nextUrl = initialStatus?.next || null; - - while (nextUrl) { - const nextStatus = await firecrawlRequest(runtime, 'get', nextUrl); - - if (Array.isArray(nextStatus?.data) && nextStatus.data.length > 0) { - documents.push(...nextStatus.data); - } - - nextUrl = nextStatus?.next || null; - } - - return { - ...initialStatus, - data: documents, - next: null, - }; -} - -async function waitForCrawlCompletion(runtime, crawlId) { - const deadline = Date.now() + runtime.timeoutMs; - - while (Date.now() <= deadline) { - const status = await firecrawlRequest(runtime, 'get', `/crawl/${encodeURIComponent(crawlId)}`); - - if (status?.status === 'completed' || status?.status === 'failed') { - return collectPagedStatus(runtime, status); - } - - await sleep(runtime.pollIntervalMs); - } - - const timeoutError = new Error( - `Firecrawl crawl timed out after ${Math.round(runtime.timeoutMs / 1000)} seconds.`, - ); - timeoutError.code = 504; - throw timeoutError; -} - -async function getCrawlErrors(runtime, crawlId) { - try { - return await firecrawlRequest(runtime, 'get', `/crawl/${encodeURIComponent(crawlId)}/errors`); - } catch (error) { - console.error('Failed to fetch Firecrawl crawl errors:', error); - return { - errors: [], - robotsBlocked: [], - }; - } -} - -async function crawlSiteWithFirecrawl(url, requestedPages) { - const runtime = getFirecrawlRuntime(); - - if (!runtime.enabled) { - const error = new Error('Firecrawl is disabled in this environment.'); - error.code = 503; - throw error; - } - - if (!runtime.configured) { - const error = new Error('Firecrawl API key is not configured.'); - error.code = 503; - throw error; - } - - const started = await firecrawlRequest(runtime, 'post', '/crawl', { - data: { - url, - limit: requestedPages, - sitemap: 'include', - crawlEntireDomain: true, - allowExternalLinks: false, - allowSubdomains: false, - ignoreQueryParameters: true, - scrapeOptions: { - formats: ['html'], - }, - }, - }); - - const crawlId = started?.id; - - if (!crawlId) { - const error = new Error('Firecrawl did not return a crawl job ID.'); - error.code = 502; - error.response = started; - throw error; - } - - const status = await waitForCrawlCompletion(runtime, crawlId); - const crawlErrors = await getCrawlErrors(runtime, crawlId); - - return { - crawlId, - provider: 'firecrawl', - status: status?.status || 'unknown', - total: status?.total || 0, - completed: status?.completed || 0, - creditsUsed: status?.creditsUsed || 0, - expiresAt: status?.expiresAt || null, - data: Array.isArray(status?.data) ? status.data : [], - errors: Array.isArray(crawlErrors?.errors) ? crawlErrors.errors : [], - robotsBlocked: Array.isArray(crawlErrors?.robotsBlocked) - ? crawlErrors.robotsBlocked - : [], + shouldUseFirecrawlLater, + message: runtime.configured + ? 'Firecrawl scaffold is wired and ready for the next activation step, but this analyzer still uses the built-in crawler today.' + : 'Firecrawl scaffold is wired, but FIRECRAWL_API_KEY is not set yet. The analyzer still uses the built-in crawler for now.', }; } module.exports = { getFirecrawlRuntime, getFirecrawlScaffold, - crawlSiteWithFirecrawl, }; diff --git a/backend/src/services/siteEntitlements.js b/backend/src/services/siteEntitlements.js index 47e7c53..ce3ec26 100644 --- a/backend/src/services/siteEntitlements.js +++ b/backend/src/services/siteEntitlements.js @@ -1,6 +1,6 @@ const ValidationError = require('./notifications/errors/validation'); -const BASIC_MAX_PAGES_PER_CRAWL = 25; +const BASIC_MAX_PAGES_PER_CRAWL = 1; const ADVANCED_MAX_PAGES_PER_CRAWL = 25; const ADVANCED_CRAWL_PERMISSION = 'USE_ADVANCED_CRAWL'; const PLATFORM_OUTPUT_PERMISSION = 'USE_PLATFORM_OUTPUT'; @@ -68,7 +68,7 @@ function ensureRequestedPagesAllowed(requestedPages, currentUser) { if (requestedPages > entitlements.maxPagesPerCrawl) { const error = new Error( - `This analyzer supports up to ${entitlements.maxPagesPerCrawl} page${entitlements.maxPagesPerCrawl === 1 ? '' : 's'} per crawl. Reduce the requested page count to continue.`, + `Your current plan allows up to ${entitlements.maxPagesPerCrawl} page${entitlements.maxPagesPerCrawl === 1 ? '' : 's'} per crawl. Upgrade to Advanced Crawl to analyze ${requestedPages} pages.`, ); error.code = 403; throw error; diff --git a/backend/src/services/sites.js b/backend/src/services/sites.js index c771f34..9ee0055 100644 --- a/backend/src/services/sites.js +++ b/backend/src/services/sites.js @@ -8,107 +8,12 @@ const { ensureRequestedPagesAllowed, ensurePlatformOutputAllowed, } = require('./siteEntitlements'); -const { getFirecrawlScaffold, crawlSiteWithFirecrawl } = require('./firecrawl'); +const { getFirecrawlScaffold } = require('./firecrawl'); const REQUEST_TIMEOUT = 15000; const PREVIEW_LIMIT = 5; +const PAGE_PREVIEW_LIMIT = 10; const NON_HTML_FILE_PATTERN = /\.(?:7z|avi|bmp|css|csv|docx?|eot|gif|ico|jpe?g|js|json|map|mov|mp3|mp4|pdf|png|pptx?|rar|svg|tar|tgz|txt|wav|webm|webp|woff2?|xlsx?|xml|zip)$/i; -const WORDPRESS_PLUGIN_DEFINITIONS = [ - { - key: 'yoast', - label: 'Yoast SEO', - category: 'seo', - priority: 100, - patterns: [ - 'wp-content/plugins/wordpress-seo/', - 'class="yoast', - 'id="yoast', - 'yoast-schema-graph', - 'yoast_head', - ], - }, - { - key: 'rankmath', - label: 'Rank Math', - category: 'seo', - priority: 95, - patterns: [ - 'wp-content/plugins/seo-by-rank-math/', - 'rank-math', - 'rank_math', - ], - }, - { - key: 'aioseo', - label: 'All in One SEO', - category: 'seo', - priority: 90, - patterns: [ - 'wp-content/plugins/all-in-one-seo-pack/', - 'wp-content/plugins/aioseo/', - 'aioseo', - ], - }, - { - key: 'seopress', - label: 'SEOPress', - category: 'seo', - priority: 85, - patterns: [ - 'wp-content/plugins/wp-seopress/', - 'seopress', - ], - }, - { - key: 'woocommerce', - label: 'WooCommerce', - category: 'commerce', - priority: 80, - patterns: [ - 'wp-content/plugins/woocommerce/', - 'woocommerce', - 'wc-block-components', - 'single-product', - 'product-type-', - 'add_to_cart', - ], - }, - { - key: 'elementor', - label: 'Elementor', - category: 'builder', - priority: 70, - patterns: [ - 'wp-content/plugins/elementor/', - 'elementor-', - 'data-elementor', - ], - }, - { - key: 'wpbakery', - label: 'WPBakery', - category: 'builder', - priority: 65, - patterns: [ - 'wp-content/plugins/js_composer/', - 'wpb_js_composer', - 'vc_row', - 'wpb-content-wrapper', - ], - }, - { - key: 'acf', - label: 'Advanced Custom Fields', - category: 'fields', - priority: 60, - patterns: [ - 'wp-content/plugins/advanced-custom-fields/', - 'wp-content/plugins/acf/', - 'acf-field', - 'acf/', - ], - }, -]; function normalizeUrl(rawUrl) { if (!rawUrl || typeof rawUrl !== 'string') { @@ -182,72 +87,15 @@ function addJsonLdTypes(node, types) { normalizedTypes .filter(Boolean) - .forEach((type) => addSchemaTypeValue(type, types)); + .forEach((type) => types.add(String(type))); } Object.values(node).forEach((value) => addJsonLdTypes(value, types)); } -function normalizeSchemaTypeLabel(value) { - const trimmedValue = String(value || '').trim(); - - if (!trimmedValue) { - return null; - } - - const withoutAngleBrackets = trimmedValue.replace(/^<|>$/g, ''); - const withoutSchemaPrefix = withoutAngleBrackets - .replace(/^https?:\/\/(?:www\.)?schema\.org\//i, '') - .replace(/^schema:/i, '') - .replace(/^https?:\/\//i, ''); - const withoutFragment = withoutSchemaPrefix.split('#').pop() || withoutSchemaPrefix; - const withoutQuery = withoutFragment.split('?')[0] || withoutFragment; - const normalized = withoutQuery - .split('/') - .filter(Boolean) - .pop(); - - return normalized ? normalized.trim() : null; -} - -function extractAttributeValues(html, attributeName) { - const matches = [ - ...String(html || '').matchAll( - new RegExp("\\b" + attributeName + "\\s*=\\s*(?:\"([^\"]+)\"|'([^']+)'|([^\\s>]+))", 'gi'), - ), - ]; - - return matches - .map((match) => match[1] || match[2] || match[3] || '') - .map((value) => value.trim()) - .filter(Boolean); -} - -function addSchemaTypeValue(value, types) { - if (!value) { - return; - } - - const typeCandidates = Array.isArray(value) - ? value - : String(value) - .split(/\s+/) - .map((entry) => entry.trim()) - .filter(Boolean); - - typeCandidates.forEach((candidate) => { - const normalizedCandidate = normalizeSchemaTypeLabel(candidate); - - if (normalizedCandidate) { - types.add(normalizedCandidate); - } - }); -} - function extractSchemaSummary(html) { - const resolvedHtml = String(html || ''); const jsonLdMatches = [ - ...resolvedHtml.matchAll( + ...html.matchAll( /]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi, ), ]; @@ -273,36 +121,19 @@ function extractSchemaSummary(html) { } }); - const microdataTypes = new Set(); - const rdfaTypes = new Set(); - const microdataCount = (resolvedHtml.match(/\sitemscope(?:\s|=|>)/gi) || []).length; - const rdfaTypeofCount = (resolvedHtml.match(/\stypeof\s*=\s*(?:"[^"]+"|'[^']+'|[^\s>]+)/gi) || []).length; - const rdfaPropertyCount = (resolvedHtml.match(/\sproperty\s*=\s*(?:"[^"]+"|'[^']+'|[^\s>]+)/gi) || []).length; - const rdfaVocabCount = (resolvedHtml.match(/\svocab\s*=\s*(?:"[^"]+"|'[^']+'|[^\s>]+)/gi) || []).length; + const microdataCount = (html.match(/\sitemscope(?:\s|=|>)/gi) || []).length; + const rdfaTypeofCount = (html.match(/\stypeof\s*=\s*(["']).*?\1/gi) || []).length; + const rdfaPropertyCount = (html.match(/\sproperty\s*=\s*(["']).*?\1/gi) || []).length; + const rdfaVocabCount = (html.match(/\svocab\s*=\s*(["']).*?\1/gi) || []).length; const rdfaCount = Math.max( rdfaTypeofCount, rdfaPropertyCount, rdfaVocabCount, ); - extractAttributeValues(resolvedHtml, 'itemtype').forEach((itemType) => { - addSchemaTypeValue(itemType, microdataTypes); - }); - - extractAttributeValues(resolvedHtml, 'typeof').forEach((typeOfValue) => { - addSchemaTypeValue(typeOfValue, rdfaTypes); - }); - - const detectedTypes = new Set([ - ...Array.from(jsonLdTypes), - ...Array.from(microdataTypes), - ...Array.from(rdfaTypes), - ]); - return { hasStructuredData: jsonLdMatches.length > 0 || microdataCount > 0 || rdfaCount > 0, - types: Array.from(detectedTypes), jsonLd: { count: jsonLdMatches.length, types: Array.from(jsonLdTypes), @@ -311,12 +142,10 @@ function extractSchemaSummary(html) { microdata: { count: microdataCount, detected: microdataCount > 0, - types: Array.from(microdataTypes), }, rdfa: { count: rdfaCount, detected: rdfaCount > 0, - types: Array.from(rdfaTypes), }, }; } @@ -409,339 +238,6 @@ function detectPlatform(html, headers, analyzedUrl) { }; } -function getConfidenceLabel(score) { - if (score >= 3) { - return 'high'; - } - - if (score >= 2) { - return 'medium'; - } - - return 'low'; -} - -function getConfidenceRank(confidence) { - if (confidence === 'high') { - return 3; - } - - if (confidence === 'medium') { - return 2; - } - - return 1; -} - -function getWordPressPluginPriority(pluginKey) { - const pluginDefinition = WORDPRESS_PLUGIN_DEFINITIONS.find((definition) => definition.key === pluginKey); - - return pluginDefinition?.priority || 0; -} - -function getWordPressTargetSchemaTypes(schemaTypes = [], pageSignals = {}) { - const targetTypes = new Set(); - - if (hasSchemaType(schemaTypes, ['LocalBusiness']) || pageSignals.hasLocalBusinessHints) { - targetTypes.add('LocalBusiness'); - } else { - targetTypes.add('Organization'); - } - - targetTypes.add('WebSite'); - - if (hasSchemaType(schemaTypes, ['Product', 'Offer', 'AggregateRating', 'Review']) || pageSignals.hasProductHints) { - targetTypes.add('Product'); - targetTypes.add('Offer'); - } - - if (hasSchemaType(schemaTypes, ['Article', 'BlogPosting', 'NewsArticle']) || pageSignals.hasBlogHints) { - targetTypes.add('BlogPosting'); - } - - if (hasSchemaType(schemaTypes, ['FAQPage']) || pageSignals.hasFaqHints) { - targetTypes.add('FAQPage'); - } - - if (hasSchemaType(schemaTypes, ['Service']) || pageSignals.hasServiceHints) { - targetTypes.add('Service'); - } - - return Array.from(targetTypes); -} - -function buildWordPressPluginRecommendations(plugins, schemaTypes = [], pageSignals = {}) { - const pluginList = Array.isArray(plugins) ? plugins : []; - const pluginMap = new Map(pluginList.map((plugin) => [plugin.key, plugin])); - const targetTypes = getWordPressTargetSchemaTypes(schemaTypes, pageSignals); - const sitewideTypes = targetTypes.filter((type) => ['Organization', 'LocalBusiness', 'WebSite', 'BlogPosting', 'FAQPage', 'Service'].includes(type)); - const productTypes = targetTypes.filter((type) => ['Product', 'Offer'].includes(type)); - const recommendations = []; - const seoPlugins = pluginList.filter((plugin) => plugin.category === 'seo'); - const primarySeoPlugin = seoPlugins - .slice() - .sort((left, right) => getWordPressPluginPriority(right.key) - getWordPressPluginPriority(left.key))[0]; - - if (seoPlugins.length > 1) { - recommendations.push({ - key: 'seo-consolidation', - label: 'SEO plugin consolidation', - category: 'seo', - priority: 'high', - title: 'Choose a single SEO/schema owner before changing markup', - summary: `Detected SEO plugins (${seoPlugins.map((plugin) => plugin.label).join(', ')}) can overlap on Organization, WebSite, Article, and FAQ output.`, - recommendedApproach: 'Disable overlapping schema modules or remove duplicate output before implementing new JSON-LD in theme files.', - applicableSchemaTypes: sitewideTypes.length > 0 ? sitewideTypes : ['Organization', 'WebSite'], - }); - } else if (primarySeoPlugin) { - recommendations.push({ - key: primarySeoPlugin.key, - label: primarySeoPlugin.label, - category: primarySeoPlugin.category, - priority: 'high', - title: `Use ${primarySeoPlugin.label} for sitewide schema changes first`, - summary: `${primarySeoPlugin.label} is the strongest candidate to own homepage and editorial schema before custom theme code is added.`, - recommendedApproach: `Implement ${sitewideTypes.join(', ') || 'sitewide'} changes through ${primarySeoPlugin.label} settings, schema modules, or documented filters before hardcoding JSON-LD.`, - applicableSchemaTypes: sitewideTypes.length > 0 ? sitewideTypes : ['Organization', 'WebSite'], - }); - } - - if (pluginMap.has('woocommerce')) { - recommendations.push({ - key: 'woocommerce', - label: 'WooCommerce', - category: 'commerce', - priority: productTypes.length > 0 ? 'high' : 'medium', - title: 'Handle product schema in WooCommerce templates or hooks', - summary: 'WooCommerce commonly outputs Product and Offer markup, so product-level schema changes should be audited in plugin templates before theme JSON-LD is added.', - recommendedApproach: productTypes.length > 0 - ? 'Review existing WooCommerce product schema first, then extend product/offer data with WooCommerce hooks or template overrides only where needed.' - : 'If product pages are added later, keep WooCommerce as the first place to audit before adding custom Product schema snippets.', - applicableSchemaTypes: productTypes.length > 0 ? productTypes : ['Product', 'Offer'], - }); - } - - if (pluginMap.has('acf')) { - recommendations.push({ - key: 'acf', - label: 'Advanced Custom Fields', - category: 'fields', - priority: 'medium', - title: 'Use ACF fields as the schema data source', - summary: 'ACF is a good place to store repeatable schema inputs so FAQ, Service, and editorial markup stay editable for content teams.', - recommendedApproach: 'Map ACF fields to JSON-LD generation in a theme helper or lightweight custom plugin instead of hardcoding page-specific values in templates.', - applicableSchemaTypes: targetTypes.filter((type) => ['FAQPage', 'Service', 'BlogPosting', 'LocalBusiness'].includes(type)), - }); - } - - const builderPlugins = pluginList.filter((plugin) => plugin.category === 'builder'); - if (builderPlugins.length > 0) { - recommendations.push({ - key: 'builder-templates', - label: builderPlugins.map((plugin) => plugin.label).join(' + '), - category: 'builder', - priority: 'medium', - title: 'Confirm builder templates before inserting page-level schema', - summary: `${builderPlugins.map((plugin) => plugin.label).join(' and ')} may control landing-page markup, so schema placement should be validated in the builder/template layer.`, - recommendedApproach: 'Check builder-controlled templates, theme hooks, and reusable sections before injecting FAQPage or Service markup into a single template file.', - applicableSchemaTypes: targetTypes.filter((type) => ['FAQPage', 'Service', 'BlogPosting'].includes(type)), - }); - } - - if (recommendations.length === 0) { - recommendations.push({ - key: 'theme-custom', - label: 'Theme / custom code', - category: 'custom', - priority: 'medium', - title: 'Implement schema in theme or custom plugin code', - summary: 'No major schema-owning WordPress plugin was detected, so the theme or a custom plugin is the most likely implementation layer.', - recommendedApproach: 'Use reusable helpers, theme hooks, or a lightweight custom plugin to generate schema from central data instead of duplicating snippets across templates.', - applicableSchemaTypes: targetTypes, - }); - } - - return recommendations - .map((recommendation) => ({ - ...recommendation, - applicableSchemaTypes: Array.from(new Set(recommendation.applicableSchemaTypes || [])).filter(Boolean), - })) - .slice(0, 4); -} - -function buildWordPressDuplicateRisk(plugins, schemaTypes = [], pageSignals = {}) { - const pluginList = Array.isArray(plugins) ? plugins : []; - const seoPlugins = pluginList.filter((plugin) => plugin.category === 'seo'); - const builderPlugins = pluginList.filter((plugin) => plugin.category === 'builder'); - const hasWooCommerce = pluginList.some((plugin) => plugin.key === 'woocommerce'); - const hasProductSchema = hasSchemaType(schemaTypes, ['Product', 'Offer', 'AggregateRating', 'Review']) || pageSignals.hasProductHints; - const warnings = []; - let level = 'low'; - - if (seoPlugins.length > 1) { - level = 'high'; - warnings.push(`Multiple SEO/schema plugins were detected (${seoPlugins.map((plugin) => plugin.label).join(', ')}), which often leads to duplicate Organization, WebSite, Article, or FAQ graphs.`); - } - - if (seoPlugins.length > 0 && hasWooCommerce && hasProductSchema) { - if (level !== 'high') { - level = 'medium'; - } - warnings.push('A sitewide SEO plugin and WooCommerce may both influence product-related schema, so Product, Offer, Review, or AggregateRating markup should be audited for overlap.'); - } - - if (builderPlugins.length > 0 && seoPlugins.length > 0 && (pageSignals.hasFaqHints || pageSignals.hasServiceHints || pageSignals.hasBlogHints)) { - if (level === 'low') { - level = 'medium'; - } - warnings.push('Builder-managed landing pages can reintroduce page-level FAQ, Service, or Article markup on top of SEO-plugin output if custom snippets were added previously.'); - } - - if (warnings.length === 0) { - return { - level: 'low', - label: 'Low duplicate-schema risk', - summary: 'The detected WordPress stack does not show strong duplicate-schema signals yet, but existing snippets should still be audited before new markup is added.', - warnings: [], - affectedPlugins: pluginList.map((plugin) => plugin.label), - }; - } - - return { - level, - label: `${level.charAt(0).toUpperCase()}${level.slice(1)} duplicate-schema risk`, - summary: level === 'high' - ? 'More than one schema-capable layer appears active, so duplicate graphs are likely unless ownership is consolidated first.' - : 'At least two WordPress layers may influence schema output, so existing markup should be audited before new snippets are shipped.', - warnings, - affectedPlugins: pluginList.map((plugin) => plugin.label), - }; -} - -function buildWordPressSchemaOwnership(plugins, schemaTypes = []) { - const pluginList = Array.isArray(plugins) ? plugins : []; - const seoPlugins = pluginList.filter((plugin) => plugin.category === 'seo'); - const hasWooCommerce = pluginList.some((plugin) => plugin.key === 'woocommerce'); - const hasProductSchema = hasSchemaType(schemaTypes, ['Product', 'Offer', 'AggregateRating', 'Review']); - const builderPlugins = pluginList.filter((plugin) => plugin.category === 'builder'); - const notes = []; - - if (seoPlugins.length > 1) { - notes.push('Audit the existing schema output before adding new markup so multiple SEO plugins do not duplicate the graph.'); - } - - if (hasWooCommerce) { - notes.push('Review WooCommerce product and offer markup before adding custom Product JSON-LD.'); - } - - if (builderPlugins.length > 0) { - notes.push('Page-builder templates may control where schema blocks should be inserted on landing pages and service pages.'); - } - - if (seoPlugins.length > 1) { - return { - mode: 'mixed', - label: 'Multiple schema-capable plugins detected', - summary: 'More than one SEO/schema plugin appears active. Consolidate schema ownership before adding new output to avoid duplicate graphs.', - recommendedImplementation: 'Choose one schema owner first, then extend that plugin or remove overlapping output before coding theme-level JSON-LD.', - notes, - }; - } - - if (seoPlugins.length === 1 && hasWooCommerce && hasProductSchema) { - return { - mode: 'mixed', - label: `${seoPlugins[0].label} + WooCommerce likely share schema ownership`, - summary: `${seoPlugins[0].label} likely controls sitewide schema while WooCommerce likely controls product-level markup. Extend those layers before hardcoding duplicate schema.`, - recommendedImplementation: `Use ${seoPlugins[0].label} for organization/sitewide schema and review WooCommerce product templates or hooks for product schema changes.`, - notes, - }; - } - - if (seoPlugins.length === 1) { - return { - mode: 'plugin_managed', - label: `${seoPlugins[0].label} likely manages primary schema output`, - summary: `${seoPlugins[0].label} appears to be the main schema layer for this WordPress site. Prefer plugin settings, custom fields, or documented filters before injecting theme JSON-LD.`, - recommendedImplementation: `Implement schema changes through ${seoPlugins[0].label} first, and only fall back to theme or custom code for unsupported schema types.`, - notes, - }; - } - - if (hasWooCommerce && hasProductSchema) { - return { - mode: 'plugin_managed', - label: 'WooCommerce likely manages product schema', - summary: 'WooCommerce signals and product schema were detected together, so product-level structured data may already be plugin-managed.', - recommendedImplementation: 'Audit existing WooCommerce product markup before adding custom Product or Offer schema in theme files.', - notes, - }; - } - - return { - mode: 'theme_or_custom', - label: 'Theme or custom code likely manages schema output', - summary: 'No major WordPress SEO/schema plugin was detected. Schema changes may need to be implemented in the theme, a custom plugin, or custom fields.', - recommendedImplementation: 'Plan for theme hooks, a lightweight custom plugin, or custom field-driven JSON-LD injection for the recommended schema types.', - notes, - }; -} - -function detectWordPressSignals(html, platform, schema, pageSignals = {}) { - const lowerHtml = String(html || '').toLowerCase(); - const hasWordPressSignals = - platform?.detected === 'wordpress' - || lowerHtml.includes('wp-content') - || lowerHtml.includes('wp-includes') - || lowerHtml.includes('wp-json') - || lowerHtml.includes('content="wordpress'); - - if (!hasWordPressSignals) { - return { - detected: false, - plugins: [], - schemaOwnership: null, - pluginRecommendations: [], - duplicateRisk: null, - }; - } - - const plugins = WORDPRESS_PLUGIN_DEFINITIONS - .map((definition) => { - const matchedSignals = definition.patterns.filter((pattern) => lowerHtml.includes(pattern)); - - if (matchedSignals.length === 0) { - return null; - } - - return { - key: definition.key, - label: definition.label, - category: definition.category, - confidence: getConfidenceLabel(matchedSignals.length), - evidence: matchedSignals.slice(0, 4), - implementationHint: buildWordPressSchemaOwnership([ - { - key: definition.key, - label: definition.label, - category: definition.category, - }, - ], schema?.types || []).recommendedImplementation, - priority: definition.priority, - }; - }) - .filter(Boolean) - .sort((left, right) => (right.priority || 0) - (left.priority || 0)); - - return { - detected: true, - plugins, - schemaOwnership: buildWordPressSchemaOwnership(plugins, schema?.types || []), - pluginRecommendations: buildWordPressPluginRecommendations(plugins, schema?.types || [], pageSignals), - duplicateRisk: buildWordPressDuplicateRisk(plugins, schema?.types || [], pageSignals), - }; -} - function isHtmlLikeResponse(response) { const contentType = String(response?.headers?.['content-type'] || '').toLowerCase(); @@ -775,152 +271,6 @@ function normalizeAllowedHostnames(allowedHostnames) { return new Set(); } -function normalizeTargetPathname(pathname) { - const trimmedPathname = String(pathname || '').trim(); - - if (!trimmedPathname || trimmedPathname === '/') { - return '/'; - } - - return `/${trimmedPathname.replace(/^\/+/, '').replace(/\/+$/, '')}`; -} - -function buildCrawlTarget(rawTarget, baseUrl, label) { - const trimmedTarget = String(rawTarget || '').trim(); - - if (!trimmedTarget) { - return null; - } - - let parsedTarget; - - try { - if (/^https?:\/\//i.test(trimmedTarget)) { - parsedTarget = new URL(trimmedTarget); - } else if (trimmedTarget.startsWith('/')) { - parsedTarget = new URL(trimmedTarget, baseUrl); - } else { - parsedTarget = new URL(`/${trimmedTarget.replace(/^\/+/, '')}`, baseUrl); - } - } catch (error) { - const targetError = new Error(`Invalid ${label} target: ${trimmedTarget}`); - targetError.code = 400; - throw targetError; - } - - if (!['http:', 'https:'].includes(parsedTarget.protocol)) { - const targetError = new Error(`Invalid ${label} target: ${trimmedTarget}`); - targetError.code = 400; - throw targetError; - } - - const baseHostname = new URL(baseUrl).hostname.toLowerCase(); - - if (parsedTarget.hostname.toLowerCase() !== baseHostname) { - const targetError = new Error( - `${label} targets must stay on the same website as the analyzed URL.`, - ); - targetError.code = 400; - throw targetError; - } - - parsedTarget.hash = ''; - parsedTarget.search = ''; - - const path = normalizeTargetPathname(parsedTarget.pathname); - const url = normalizeUrl(parsedTarget.toString()); - - return { - input: trimmedTarget, - label: /^https?:\/\//i.test(trimmedTarget) ? url : path, - path, - url, - }; -} - -function parseCrawlTargets(rawTargets, baseUrl, label) { - const targetValues = Array.isArray(rawTargets) - ? rawTargets - : String(rawTargets || '').split(/\r?\n/); - const dedupedTargets = new Map(); - - targetValues - .map((targetValue) => String(targetValue || '').trim()) - .filter(Boolean) - .forEach((targetValue) => { - const normalizedTarget = buildCrawlTarget(targetValue, baseUrl, label); - - dedupedTargets.set(normalizedTarget.url, normalizedTarget); - }); - - return Array.from(dedupedTargets.values()); -} - -function normalizeCrawlTargets(data, baseUrl) { - return { - includeTargets: parseCrawlTargets(data?.includeTargets, baseUrl, 'include'), - excludeTargets: parseCrawlTargets(data?.excludeTargets, baseUrl, 'exclude'), - }; -} - -function isUrlMatchingTarget(candidateUrl, target) { - if (!candidateUrl || !target?.path) { - return false; - } - - let parsedUrl; - - try { - parsedUrl = new URL(normalizeUrl(candidateUrl)); - } catch (error) { - return false; - } - - const candidatePath = normalizeTargetPathname(parsedUrl.pathname); - - if (target.path === '/') { - return true; - } - - return candidatePath === target.path || candidatePath.startsWith(`${target.path}/`); -} - -function matchesAnyCrawlTarget(candidateUrl, targets = []) { - return targets.some((target) => isUrlMatchingTarget(candidateUrl, target)); -} - -function isUrlAllowedByCrawlTargets(candidateUrl, crawlTargets = {}) { - const includeTargets = crawlTargets.includeTargets || []; - const excludeTargets = crawlTargets.excludeTargets || []; - - if (includeTargets.length > 0 && !matchesAnyCrawlTarget(candidateUrl, includeTargets)) { - return false; - } - - if (excludeTargets.length > 0 && matchesAnyCrawlTarget(candidateUrl, excludeTargets)) { - return false; - } - - return true; -} - -function buildSeedUrls(baseUrl, crawlTargets = {}) { - const seedUrls = new Set([baseUrl]); - - (crawlTargets.includeTargets || []).forEach((target) => { - seedUrls.add(target.url); - }); - - return Array.from(seedUrls); -} - -function summarizeCrawlTargets(crawlTargets = {}) { - return { - includeTargets: (crawlTargets.includeTargets || []).map((target) => target.label), - excludeTargets: (crawlTargets.excludeTargets || []).map((target) => target.label), - }; -} - function normalizeCrawlUrl(rawUrl, parentUrl, allowedHostnames) { if (!rawUrl || typeof rawUrl !== 'string') { return null; @@ -1035,7 +385,6 @@ async function fetchAnalyzedPage(pageUrl, allowedHostnames) { pageTitle, platform, ); - const wordpress = detectWordPressSignals(html, platform, schema, pageSignals); return { requestedUrl: pageUrl, @@ -1045,7 +394,6 @@ async function fetchAnalyzedPage(pageUrl, allowedHostnames) { html, platform, schema, - wordpress, pageSignals, discoveredLinks: extractInternalLinks( html, @@ -1055,186 +403,12 @@ async function fetchAnalyzedPage(pageUrl, allowedHostnames) { }; } -function analyzeFetchedPage({ - requestedUrl, - analyzedUrl, - html, - statusCode, - headers = {}, - allowedHostnames, - discoveredLinks = null, - pageTitle = null, -}) { - const normalizedAnalyzedUrl = normalizeUrl(analyzedUrl || requestedUrl); - const normalizedAllowedHostnames = normalizeAllowedHostnames(allowedHostnames); - const analyzedHostname = new URL(normalizedAnalyzedUrl).hostname.toLowerCase(); - normalizedAllowedHostnames.add(analyzedHostname); - - if (allowedHostnames instanceof Set) { - allowedHostnames.add(analyzedHostname); - } - - const resolvedHtml = typeof html === 'string' ? html : ''; - const resolvedPageTitle = pageTitle || extractPageTitle(resolvedHtml); - const platform = detectPlatform(resolvedHtml, headers, normalizedAnalyzedUrl); - const schema = extractSchemaSummary(resolvedHtml); - const pageSignals = inferPageSignals( - resolvedHtml, - normalizedAnalyzedUrl, - resolvedPageTitle, - platform, - ); - const wordpress = detectWordPressSignals(resolvedHtml, platform, schema, pageSignals); - const normalizedLinks = Array.isArray(discoveredLinks) - ? Array.from( - new Set( - discoveredLinks - .map((linkUrl) => normalizeCrawlUrl(linkUrl, normalizedAnalyzedUrl, normalizedAllowedHostnames)) - .filter(Boolean), - ), - ) - : extractInternalLinks( - resolvedHtml, - normalizedAnalyzedUrl, - normalizedAllowedHostnames, - ); - - return { - requestedUrl: requestedUrl || normalizedAnalyzedUrl, - analyzedUrl: normalizedAnalyzedUrl, - pageTitle: resolvedPageTitle, - statusCode: statusCode || null, - html: resolvedHtml, - platform, - schema, - wordpress, - pageSignals, - discoveredLinks: normalizedLinks, - }; -} - -function transformFirecrawlDocument(document, allowedHostnames) { - const metadata = document?.metadata || {}; - const sourceUrl = - metadata.sourceURL - || metadata.sourceUrl - || metadata.url - || document?.url - || document?.sourceURL - || document?.sourceUrl; - - if (!sourceUrl) { - return null; - } - - const html = - typeof document?.html === 'string' - ? document.html - : typeof document?.rawHtml === 'string' - ? document.rawHtml - : typeof document?.content === 'string' - ? document.content - : ''; - - return analyzeFetchedPage({ - requestedUrl: sourceUrl, - analyzedUrl: sourceUrl, - html, - statusCode: document?.metadata?.statusCode || 200, - headers: {}, - allowedHostnames, - discoveredLinks: Array.isArray(document?.links) ? document.links : null, - pageTitle: metadata.title || null, - }); -} - -async function crawlPagesWithFirecrawl(baseUrl, requestedPages, crawlTargets = {}) { +async function crawlPages(baseUrl, requestedPages) { const normalizedBaseUrl = normalizeUrl(baseUrl); const allowedHostnames = new Set([new URL(normalizedBaseUrl).hostname.toLowerCase()]); - const firecrawlResult = await crawlSiteWithFirecrawl(normalizedBaseUrl, requestedPages); - const pages = []; - const analyzedUrls = new Set(); - - (firecrawlResult.data || []).forEach((document) => { - try { - const page = transformFirecrawlDocument(document, allowedHostnames); - - if ( - !page - || analyzedUrls.has(page.analyzedUrl) - || !isUrlAllowedByCrawlTargets(page.analyzedUrl, crawlTargets) - ) { - return; - } - - analyzedUrls.add(page.analyzedUrl); - pages.push(page); - } catch (error) { - console.error('Failed to transform Firecrawl document:', error); - } - }); - - const failedPages = []; - - (firecrawlResult.errors || []).forEach((entry) => { - const failedUrl = normalizeCrawlUrl( - entry?.path || entry?.url || entry?.sourceURL || normalizedBaseUrl, - normalizedBaseUrl, - allowedHostnames, - ) || normalizedBaseUrl; - - if (!isUrlAllowedByCrawlTargets(failedUrl, crawlTargets)) { - return; - } - - failedPages.push({ - url: failedUrl, - error: entry?.error || entry?.message || 'Firecrawl could not fetch this page.', - }); - }); - - (firecrawlResult.robotsBlocked || []).forEach((entry) => { - const blockedUrl = normalizeCrawlUrl( - entry?.path || entry?.url || normalizedBaseUrl, - normalizedBaseUrl, - allowedHostnames, - ) || normalizedBaseUrl; - - if (!isUrlAllowedByCrawlTargets(blockedUrl, crawlTargets)) { - return; - } - - failedPages.push({ - url: blockedUrl, - error: 'Blocked by robots.txt during Firecrawl crawl.', - }); - }); - - return { - provider: 'firecrawl', - pages, - failedPages, - discoveredInternalPages: Math.max((firecrawlResult.total || pages.length) - 1, 0), - firecrawlJob: { - crawlId: firecrawlResult.crawlId, - status: firecrawlResult.status, - total: firecrawlResult.total, - completed: firecrawlResult.completed, - creditsUsed: firecrawlResult.creditsUsed, - expiresAt: firecrawlResult.expiresAt, - failedPages: failedPages.length, - }, - }; -} - -async function crawlPages(baseUrl, requestedPages, crawlTargets = {}) { - const normalizedBaseUrl = normalizeUrl(baseUrl); - const allowedHostnames = new Set([new URL(normalizedBaseUrl).hostname.toLowerCase()]); - const seedUrls = buildSeedUrls(normalizedBaseUrl, crawlTargets); - const seedUrlSet = new Set(seedUrls); const visitedUrls = new Set(); - const queuedUrls = new Set(seedUrls); - const pendingUrls = [...seedUrls]; + const queuedUrls = new Set([normalizedBaseUrl]); + const pendingUrls = [normalizedBaseUrl]; const pages = []; const failedPages = []; let discoveredInternalPages = 0; @@ -1246,29 +420,15 @@ async function crawlPages(baseUrl, requestedPages, crawlTargets = {}) { continue; } - const isBootstrapSeed = seedUrlSet.has(nextUrl) && nextUrl === normalizedBaseUrl; - - if (!isBootstrapSeed && !isUrlAllowedByCrawlTargets(nextUrl, crawlTargets)) { - visitedUrls.add(nextUrl); - continue; - } - visitedUrls.add(nextUrl); try { const page = await fetchAnalyzedPage(nextUrl, allowedHostnames); visitedUrls.add(page.analyzedUrl); queuedUrls.add(page.analyzedUrl); - - if (isUrlAllowedByCrawlTargets(page.analyzedUrl, crawlTargets)) { - pages.push(page); - } + pages.push(page); page.discoveredLinks.forEach((linkUrl) => { - if (!isUrlAllowedByCrawlTargets(linkUrl, crawlTargets)) { - return; - } - if (!visitedUrls.has(linkUrl) && !queuedUrls.has(linkUrl)) { queuedUrls.add(linkUrl); pendingUrls.push(linkUrl); @@ -1290,100 +450,8 @@ async function crawlPages(baseUrl, requestedPages, crawlTargets = {}) { }; } - -function buildAggregateWordPress(pageAnalyses, aggregateSchema, aggregateSignals = {}) { - const pluginMap = new Map(); - let detectedPageCount = 0; - - pageAnalyses.forEach((page) => { - const wordpress = page.wordpress || {}; - - if (!wordpress.detected) { - return; - } - - detectedPageCount += 1; - - (wordpress.plugins || []).forEach((plugin) => { - const existing = pluginMap.get(plugin.key); - const mergedEvidence = Array.from(new Set([ - ...(existing?.evidence || []), - ...(plugin.evidence || []), - ])).slice(0, 5); - const mergedPages = Array.from(new Set([ - ...(existing?.pageUrls || []), - page.analyzedUrl, - ])).filter(Boolean); - const nextPlugin = { - key: plugin.key, - label: plugin.label, - category: plugin.category, - confidence: plugin.confidence, - implementationHint: plugin.implementationHint, - evidence: mergedEvidence, - pageUrls: mergedPages, - }; - - if (!existing) { - pluginMap.set(plugin.key, nextPlugin); - return; - } - - if (getConfidenceRank(plugin.confidence) >= getConfidenceRank(existing.confidence)) { - pluginMap.set(plugin.key, nextPlugin); - return; - } - - pluginMap.set(plugin.key, { - ...existing, - evidence: mergedEvidence, - pageUrls: mergedPages, - }); - }); - }); - - const plugins = Array.from(pluginMap.values()) - .sort((left, right) => { - const confidenceDelta = getConfidenceRank(right.confidence) - getConfidenceRank(left.confidence); - - if (confidenceDelta !== 0) { - return confidenceDelta; - } - - return left.label.localeCompare(right.label); - }) - .map((plugin) => ({ - ...plugin, - pageCount: plugin.pageUrls.length, - pageUrls: plugin.pageUrls.slice(0, 5), - })); - - if (detectedPageCount === 0) { - return { - detected: false, - detectedPageCount: 0, - plugins: [], - schemaOwnership: null, - pluginRecommendations: [], - duplicateRisk: null, - }; - } - - return { - detected: true, - detectedPageCount, - plugins, - schemaOwnership: buildWordPressSchemaOwnership(plugins, aggregateSchema?.types || []), - pluginRecommendations: buildWordPressPluginRecommendations(plugins, aggregateSchema?.types || [], aggregateSignals), - duplicateRisk: buildWordPressDuplicateRisk(plugins, aggregateSchema?.types || [], aggregateSignals), - }; -} - function buildAggregateSchema(pageAnalyses) { - const allTypes = new Set(); const jsonLdTypes = new Set(); - const microdataTypes = new Set(); - const rdfaTypes = new Set(); const invalidBlocks = []; let jsonLdCount = 0; let microdataCount = 0; @@ -1396,10 +464,7 @@ function buildAggregateSchema(pageAnalyses) { microdataCount += schema.microdata?.count || 0; rdfaCount += schema.rdfa?.count || 0; - (schema.types || []).forEach((typeName) => allTypes.add(typeName)); (schema.jsonLd?.types || []).forEach((typeName) => jsonLdTypes.add(typeName)); - (schema.microdata?.types || []).forEach((typeName) => microdataTypes.add(typeName)); - (schema.rdfa?.types || []).forEach((typeName) => rdfaTypes.add(typeName)); (schema.jsonLd?.invalidBlocks || []).forEach((block) => { invalidBlocks.push({ ...block, @@ -1410,7 +475,6 @@ function buildAggregateSchema(pageAnalyses) { return { hasStructuredData: pageAnalyses.some((page) => page.schema?.hasStructuredData), - types: Array.from(allTypes), jsonLd: { count: jsonLdCount, types: Array.from(jsonLdTypes), @@ -1419,12 +483,10 @@ function buildAggregateSchema(pageAnalyses) { microdata: { count: microdataCount, detected: microdataCount > 0, - types: Array.from(microdataTypes), }, rdfa: { count: rdfaCount, detected: rdfaCount > 0, - types: Array.from(rdfaTypes), }, }; } @@ -1439,25 +501,21 @@ function buildAggregateSignals(pageAnalyses) { hasProductHints: accumulator.hasProductHints || Boolean(pageSignals.hasProductHints), hasLocalBusinessHints: accumulator.hasLocalBusinessHints || Boolean(pageSignals.hasLocalBusinessHints), - hasServiceHints: accumulator.hasServiceHints || Boolean(pageSignals.hasServiceHints), faqPages: accumulator.faqPages + (pageSignals.hasFaqHints ? 1 : 0), blogPages: accumulator.blogPages + (pageSignals.hasBlogHints ? 1 : 0), productPages: accumulator.productPages + (pageSignals.hasProductHints ? 1 : 0), localBusinessPages: accumulator.localBusinessPages + (pageSignals.hasLocalBusinessHints ? 1 : 0), - servicePages: accumulator.servicePages + (pageSignals.hasServiceHints ? 1 : 0), }; }, { hasFaqHints: false, hasBlogHints: false, hasProductHints: false, hasLocalBusinessHints: false, - hasServiceHints: false, faqPages: 0, blogPages: 0, productPages: 0, localBusinessPages: 0, - servicePages: 0, }); } @@ -1465,29 +523,30 @@ function buildCrawlNotice({ requestedPages, actualPagesAnalyzed, failedPages, - crawlTargetSummary, + discoveredInternalPages, + firecrawl, }) { - const parts = []; - - if (requestedPages > 1) { - parts.push( - `The crawl analyzed ${actualPagesAnalyzed} of ${requestedPages} requested page${requestedPages === 1 ? '' : 's'}.`, - ); + if (requestedPages <= 1) { + return null; } - if (actualPagesAnalyzed < requestedPages) { - parts.push('Fewer matching crawlable pages were found than requested.'); - } + const parts = [ + `Advanced crawl analyzed ${actualPagesAnalyzed} of ${requestedPages} requested page${requestedPages === 1 ? '' : 's'}.`, + ]; - if ((crawlTargetSummary?.includeTargets || []).length > 0 || (crawlTargetSummary?.excludeTargets || []).length > 0) { - parts.push('Custom include/exclude targeting was applied to this report.'); + if (discoveredInternalPages + 1 < requestedPages) { + parts.push('Fewer crawlable internal HTML pages were discovered than requested.'); } if (failedPages > 0) { parts.push(`${failedPages} page${failedPages === 1 ? '' : 's'} could not be fetched during the crawl.`); } - return parts.length > 0 ? parts.join(' ') : null; + if (firecrawl?.message) { + parts.push(firecrawl.message); + } + + return parts.join(' '); } function buildAggregateAnalysis({ @@ -1498,15 +557,11 @@ function buildAggregateAnalysis({ discoveredInternalPages, failedPages, firecrawl, - crawlTargets, - provider = 'internal', }) { const homepage = pageAnalyses[0]; const finishedAt = new Date(); const aggregateSchema = buildAggregateSchema(pageAnalyses); const aggregateSignals = buildAggregateSignals(pageAnalyses); - const aggregateWordPress = buildAggregateWordPress(pageAnalyses, aggregateSchema, aggregateSignals); - const crawlTargetSummary = summarizeCrawlTargets(crawlTargets); return { requestedUrl: normalizedUrl, @@ -1520,16 +575,13 @@ function buildAggregateAnalysis({ matchedSignals: [], }, schema: aggregateSchema, - wordpress: aggregateWordPress, recommendationCount: 0, crawlPlan: { requestedPages, allowedPages: entitlements.maxPagesPerCrawl, actualPagesAnalyzed: pageAnalyses.length, advancedCrawlEnabled: entitlements.canAdvancedCrawl, - provider, - includeTargets: crawlTargetSummary.includeTargets, - excludeTargets: crawlTargetSummary.excludeTargets, + provider: 'internal', }, crawlSummary: { pagesWithStructuredData: pageAnalyses.filter((page) => page.schema?.hasStructuredData).length, @@ -1540,24 +592,14 @@ function buildAggregateAnalysis({ failedPages: failedPages.length, discoveredInternalPages, }, - pages: pageAnalyses.map((page) => ({ + pages: pageAnalyses.slice(0, PAGE_PREVIEW_LIMIT).map((page) => ({ url: page.analyzedUrl, title: page.pageTitle, statusCode: page.statusCode, hasStructuredData: Boolean(page.schema?.hasStructuredData), - schemaTypes: page.schema?.types || [], jsonLdTypes: page.schema?.jsonLd?.types || [], - wordpress: { - detected: Boolean(page.wordpress?.detected), - plugins: (page.wordpress?.plugins || []).map((plugin) => ({ - key: plugin.key, - label: plugin.label, - category: plugin.category, - confidence: plugin.confidence, - })), - }, })), - failedPages, + failedPages: failedPages.slice(0, PAGE_PREVIEW_LIMIT), aggregateSignals, entitlements, firecrawl, @@ -1565,13 +607,14 @@ function buildAggregateAnalysis({ requestedPages, actualPagesAnalyzed: pageAnalyses.length, failedPages: failedPages.length, - crawlTargetSummary, + discoveredInternalPages, + firecrawl, }), finishedAt, }; } -function buildFailureAnalysis(normalizedUrl, error, firecrawl, provider = 'internal') { +function buildFailureAnalysis(normalizedUrl, error, firecrawl) { const isAxiosError = axios.isAxiosError(error); return { @@ -1585,23 +628,11 @@ function buildFailureAnalysis(normalizedUrl, error, firecrawl, provider = 'inter }, schema: { hasStructuredData: false, - types: [], jsonLd: { count: 0, types: [], invalidBlocks: [] }, - microdata: { count: 0, detected: false, types: [] }, - rdfa: { count: 0, detected: false, types: [] }, - }, - wordpress: { - detected: false, - detectedPageCount: 0, - plugins: [], - schemaOwnership: null, - pluginRecommendations: [], - duplicateRisk: null, + microdata: { count: 0, detected: false }, + rdfa: { count: 0, detected: false }, }, firecrawl, - crawlPlan: { - provider, - }, error: isAxiosError ? error.response ? `Request failed with status ${error.response.status}` @@ -1652,13 +683,6 @@ function inferPageSignals(html, analyzedUrl, pageTitle, platform) { combined.includes('visit us') || combined.includes('call us') || combined.includes('directions'), - hasServiceHints: - combined.includes('service') || - combined.includes('services') || - combined.includes('book now') || - combined.includes('schedule consultation') || - combined.includes('get quote') || - combined.includes('request a quote'), }; } @@ -1809,7 +833,7 @@ function buildRecommendationCode({ baseUrl, siteName, schemaType, pageScope }) { function buildRecommendations({ baseUrl, siteName, analysis, html, pageAnalyses = [] }) { const recommendationList = []; - const schemaTypes = analysis?.schema?.types || analysis?.schema?.jsonLd?.types || []; + const schemaTypes = analysis?.schema?.jsonLd?.types || []; const aggregateSignals = analysis?.aggregateSignals || {}; const pageSignals = pageAnalyses.length > 0 ? aggregateSignals @@ -1978,31 +1002,6 @@ function buildRecommendations({ baseUrl, siteName, analysis, html, pageAnalyses }); } - if ( - pageSignals.hasServiceHints - && !hasSchemaType(schemaTypes, ['Service']) - ) { - recommendationList.push({ - title: 'Add Service schema on service landing pages', - recommendation_type: 'missing_page_type', - schema_type: 'Service', - page_scope: 'service-pages', - priority: 'medium', - reason: - pageAnalyses.length > 1 - ? `Service-oriented signals appeared across ${pageSignals.servicePages || 1} analyzed page${(pageSignals.servicePages || 1) === 1 ? '' : 's'}, but Service schema was not detected.` - : 'The analyzed page appears to describe a service offering, but Service schema was not detected.', - expected_impact: - 'Helps search engines and AI systems understand the services offered, their provider, and their audience.', - suggested_schema: buildRecommendationCode({ - baseUrl, - siteName, - schemaType: 'Service', - pageScope: 'service', - }), - }); - } - return recommendationList.slice(0, PREVIEW_LIMIT); } @@ -2085,7 +1084,6 @@ function buildExportPayload({ site, analysis, recommendations }) { const exportableRecommendations = (recommendations || []).filter( (recommendation) => recommendation.suggested_schema, ); - const wordpressSummary = analysis?.wordpress || {}; const sections = exportableRecommendations.map((recommendation) => { return [ @@ -2100,22 +1098,12 @@ function buildExportPayload({ site, analysis, recommendations }) { .join('\n'); }); - const wordpressGuidanceLines = wordpressSummary.detected ? [ - wordpressSummary.schemaOwnership?.label ? `WordPress ownership: ${wordpressSummary.schemaOwnership.label}` : '', - wordpressSummary.schemaOwnership?.recommendedImplementation ? `Implementation guidance: ${wordpressSummary.schemaOwnership.recommendedImplementation}` : '', - wordpressSummary.duplicateRisk?.label ? `Duplicate risk: ${wordpressSummary.duplicateRisk.label}` : '', - ...(wordpressSummary.duplicateRisk?.warnings || []).map((warning) => `- ${warning}`), - ...(wordpressSummary.pluginRecommendations || []).map((recommendation) => `${recommendation.label}: ${recommendation.recommendedApproach}`), - ].filter(Boolean) : []; - const content = [ `Schema recommendations for ${site?.name || hostname}`, `Base URL: ${site?.base_url || ''}`, analysis?.pageTitle ? `Analyzed page: ${analysis.pageTitle}` : '', analysis?.fetchedAt ? `Analyzed at: ${analysis.fetchedAt}` : '', - wordpressGuidanceLines.length > 0 ? 'WordPress implementation notes:' : '', - ...wordpressGuidanceLines, - wordpressGuidanceLines.length > 0 ? '' : '', + '', ...sections, ] .filter(Boolean) @@ -2135,8 +1123,7 @@ module.exports = class SitesService { const requestedPages = parseRequestedPages(data?.requestedPages); const entitlements = ensureRequestedPagesAllowed(requestedPages, currentUser); const normalizedUrl = normalizeUrl(data?.url || data?.base_url); - const crawlTargets = normalizeCrawlTargets(data, normalizedUrl); - let firecrawl = getFirecrawlScaffold({ requestedPages, entitlements }); + const firecrawl = getFirecrawlScaffold({ requestedPages, entitlements }); const requestedName = typeof data?.name === 'string' && data.name.trim() ? data.name.trim() @@ -2203,50 +1190,12 @@ module.exports = class SitesService { } try { - let crawlResult; - - if (firecrawl.shouldUseFirecrawl) { - try { - crawlResult = await crawlPagesWithFirecrawl(normalizedUrl, requestedPages, crawlTargets); - firecrawl = { - ...firecrawl, - currentProvider: 'firecrawl', - crawlId: crawlResult.firecrawlJob?.crawlId || null, - crawlStatus: crawlResult.firecrawlJob?.status || null, - creditsUsed: crawlResult.firecrawlJob?.creditsUsed || 0, - message: crawlResult.firecrawlJob?.status === 'failed' - ? 'Firecrawl ran for this paid request, but the crawl reported failures. Partial results are shown when available.' - : 'Firecrawl handled this paid request with sitemap-aware, JavaScript-rendered crawling.', - }; - } catch (error) { - console.error('Firecrawl crawl failed, falling back to internal crawl:', error); - firecrawl = { - ...firecrawl, - currentProvider: 'internal', - status: 'fallback_internal_after_error', - shouldUseFirecrawl: false, - fallbackReason: error.message, - message: `Firecrawl was selected for this paid request but failed to run (${error.message}). The analyzer fell back to the built-in crawler.`, - }; - crawlResult = await crawlPages(normalizedUrl, requestedPages, crawlTargets); - } - } else { - crawlResult = await crawlPages(normalizedUrl, requestedPages, crawlTargets); - firecrawl = { - ...firecrawl, - currentProvider: 'internal', - }; - } - + const crawlResult = await crawlPages(normalizedUrl, requestedPages); const pageAnalyses = crawlResult.pages; if (pageAnalyses.length === 0) { const firstFailure = crawlResult.failedPages[0]; - const error = new Error( - crawlTargets.includeTargets.length > 0 || crawlTargets.excludeTargets.length > 0 - ? 'No pages matched the include/exclude targeting rules you entered.' - : firstFailure?.error || 'Site analysis failed.', - ); + const error = new Error(firstFailure?.error || 'Site analysis failed.'); error.code = 400; throw error; } @@ -2259,8 +1208,6 @@ module.exports = class SitesService { discoveredInternalPages: crawlResult.discoveredInternalPages, failedPages: crawlResult.failedPages, firecrawl, - crawlTargets, - provider: crawlResult.provider || 'internal', }); const homepage = pageAnalyses[0]; const finishedAt = analysis.finishedAt; @@ -2344,12 +1291,7 @@ module.exports = class SitesService { } catch (error) { console.error('Site analysis failed:', error); - const failureAnalysis = buildFailureAnalysis( - normalizedUrl, - error, - firecrawl, - firecrawl?.currentProvider || 'internal', - ); + const failureAnalysis = buildFailureAnalysis(normalizedUrl, error, firecrawl); const failedAt = new Date(); const failureTransaction = await db.sequelize.transaction(); let failedSite; @@ -2409,7 +1351,7 @@ module.exports = class SitesService { allowedPages: entitlements.maxPagesPerCrawl, actualPagesAnalyzed: 0, advancedCrawlEnabled: entitlements.canAdvancedCrawl, - provider: failureAnalysis.crawlPlan?.provider || 'internal', + provider: 'internal', }, entitlements, }, diff --git a/frontend/src/components/Logo/index.tsx b/frontend/src/components/Logo/index.tsx index 30747d0..a582e29 100644 --- a/frontend/src/components/Logo/index.tsx +++ b/frontend/src/components/Logo/index.tsx @@ -1,4 +1,3 @@ -import Image from 'next/image' import React from 'react' type Props = { @@ -7,12 +6,10 @@ type Props = { export default function Logo({ className = '' }: Props) { return ( - Flatlogic logo + alt={'Flatlogic logo'}> + ) } diff --git a/frontend/src/helpers/siteEntitlements.ts b/frontend/src/helpers/siteEntitlements.ts index 1877880..c0d14f9 100644 --- a/frontend/src/helpers/siteEntitlements.ts +++ b/frontend/src/helpers/siteEntitlements.ts @@ -1,6 +1,6 @@ import { hasPermission } from './userPermissions'; -export const BASIC_MAX_PAGES_PER_CRAWL = 25; +export const BASIC_MAX_PAGES_PER_CRAWL = 1; export const ADVANCED_MAX_PAGES_PER_CRAWL = 25; export const ADVANCED_CRAWL_PERMISSION = 'USE_ADVANCED_CRAWL'; export const PLATFORM_OUTPUT_PERMISSION = 'USE_PLATFORM_OUTPUT'; diff --git a/frontend/src/pages/profile.tsx b/frontend/src/pages/profile.tsx index 8c0d6b8..f5eb7cf 100644 --- a/frontend/src/pages/profile.tsx +++ b/frontend/src/pages/profile.tsx @@ -3,9 +3,10 @@ import { mdiUpload, } from '@mdi/js'; import Head from 'next/head'; -import Image from 'next/image'; import React, { ReactElement, useEffect, useState } from 'react'; -import { toast } from 'react-toastify'; +import { ToastContainer, toast } from 'react-toastify'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; import CardBox from '../components/CardBox'; import LayoutAuthenticated from '../layouts/Authenticated'; @@ -18,17 +19,21 @@ import FormField from '../components/FormField'; import BaseDivider from '../components/BaseDivider'; import BaseButtons from '../components/BaseButtons'; import BaseButton from '../components/BaseButton'; +import FormCheckRadio from '../components/FormCheckRadio'; +import FormCheckRadioGroup from '../components/FormCheckRadioGroup'; import FormImagePicker from '../components/FormImagePicker'; import { SwitchField } from '../components/SwitchField'; import { SelectField } from '../components/SelectField'; -import { update } from '../stores/users/usersSlice'; +import { update, fetch } from '../stores/users/usersSlice'; import { useAppDispatch, useAppSelector } from '../stores/hooks'; import { useRouter } from 'next/router'; -import { findMe } from '../stores/authSlice'; +import {findMe} from "../stores/authSlice"; const EditUsers = () => { - const { currentUser } = useAppSelector((state) => state.auth); + const { currentUser, isFetching, token } = useAppSelector( + (state) => state.auth, + ); const router = useRouter(); const dispatch = useAppDispatch(); const notify = (type, msg) => toast(msg, { type }); @@ -77,15 +82,9 @@ const EditUsers = () => { {''} - {currentUser?.avatar?.[0]?.publicUrl &&
+ {currentUser?.avatar[0]?.publicUrl &&
- Avatar + Avatar
} void; - children: React.ReactNode; -}; - -type ResultsTabButtonProps = { - label: string; - iconPath: string; - count?: number | string; - isActive: boolean; - onClick: () => void; -}; - -type PageFilterChipProps = { - label: string; - count: number; - iconPath: string; - isActive: boolean; - onClick: () => void; -}; - -type DeliverySummaryCardProps = { - label: string; - value: string | number; - helper: string; - iconPath: string; - toneClassName?: string; -}; - -type StatusBadgeProps = { - children: React.ReactNode; - className?: string; - iconPath?: string; - iconSize?: number; - uppercase?: boolean; - compact?: boolean; - breakAll?: boolean; - shadow?: boolean; -}; - -type DeliveryActionCardProps = { - title: string; - description: string; - iconPath: string; - badge?: React.ReactNode; - children: React.ReactNode; -}; - -type SectionStatCardProps = { - label: string; - value: React.ReactNode; - helper?: React.ReactNode; - iconPath?: string; - className?: string; - labelClassName?: string; - valueClassName?: string; - helperClassName?: string; - iconWrapperClassName?: string; - iconSize?: number; -}; - -type SummaryPanelProps = { - label: string; - value?: React.ReactNode; - description?: React.ReactNode; - aside?: React.ReactNode; - children?: React.ReactNode; - className?: string; - labelClassName?: string; - valueClassName?: string; - descriptionClassName?: string; -}; - -type PlatformOutputMeta = { - implementationLabel: string; - developerDestination: string; - payloadLabel: string; - liveStatus: string; - demoNote: string; - iconPath: string; - steps: string[]; -}; - -type PlatformPreviewArtifact = { - id: string; - label: string; - fileName: string; - description: string; - iconPath: string; - toneClassName?: string; -}; - -type PlatformFinalDeliverable = { - id: string; - title: string; - destination: string; - owner: string; - description: string; - iconPath: string; - statusLabel: string; - toneClassName?: string; -}; - -type PlatformPageMapping = { - id: string; - pageLabel: string; - pageUrl: string | null; - schemaType: string; - packageFile: string; - deliverableTitle: string; - destination: string; - actionLabel: string; - statusLabel: string; - statusClassName: string; - iconPath: string; -}; - -type PlatformTimelineStep = { - id: string; - label: string; - description: string; - iconPath: string; - status: 'complete' | 'current' | 'upcoming'; - timeLabel: string; -}; - const PLATFORM_OPTIONS = [ { value: 'wordpress', label: 'WordPress' }, { value: 'shopify', label: 'Shopify' }, @@ -309,488 +130,20 @@ const PLATFORM_OPTIONS = [ { value: 'custom', label: 'Custom / Other' }, ]; -const PLATFORM_OUTPUT_META: Record = { - wordpress: { - implementationLabel: 'JSON-LD handoff for theme or SEO plugin injection', - developerDestination: 'Theme hook, SEO plugin field, or reusable snippet partial', - payloadLabel: 'WordPress deployment package', - liveStatus: 'Live generator is still gated behind the Premium Step 4 workflow.', - demoNote: 'This demo shows the package shape a WordPress developer would receive, without publishing anything.', - iconPath: icon.mdiWordpress, - steps: [ - 'Bundle the schema-ready fixes into reusable JSON-LD blocks.', - 'Map each block to the matching WordPress template or page type.', - 'Hand off insertion notes for theme hooks or plugin fields.', - ], - }, - shopify: { - implementationLabel: 'Theme/app embed package for Shopify templates', - developerDestination: 'Theme section, app embed, or Liquid snippet placement', - payloadLabel: 'Shopify theme package', - liveStatus: 'Live generator is still gated behind the Premium Step 4 workflow.', - demoNote: 'This demo previews how a Shopify-focused package would be organized for implementation.', - iconPath: icon.mdiShopping, - steps: [ - 'Group schema output by product, collection, and content page patterns.', - 'Show where Liquid snippets or theme app blocks would be inserted.', - 'Package implementation notes for theme review before launch.', - ], - }, - webflow: { - implementationLabel: 'Embed/code-block package for Webflow pages and CMS templates', - developerDestination: 'Page settings, CMS template embeds, or shared custom code areas', - payloadLabel: 'Webflow embed package', - liveStatus: 'Live generator is still gated behind the Premium Step 4 workflow.', - demoNote: 'This demo mirrors the structure a Webflow implementer would review before copying embeds.', - iconPath: icon.mdiMonitorDashboard, - steps: [ - 'Split schema by static pages and CMS collection templates.', - 'Highlight where custom code or embed blocks would be added.', - 'Prepare a page-by-page install checklist for Webflow publishing.', - ], - }, - custom: { - implementationLabel: 'Developer handoff package for custom sites or other CMS platforms', - developerDestination: 'Template partials, component wrappers, or tag-manager-assisted deployment', - payloadLabel: 'Custom implementation package', - liveStatus: 'Live generator is still gated behind the Premium Step 4 workflow.', - demoNote: 'This demo keeps the output generic so your developer can adapt it to the stack in use.', - iconPath: icon.mdiCodeTags, - steps: [ - 'Bundle schema by priority so engineering can ship the highest-impact fixes first.', - 'Map each fix to the most likely template or component owner.', - 'Provide a simple validation checklist for QA after deployment.', - ], - }, -}; - -const parseTargetLines = (value: string) => value - .split(/\r?\n/) - .map((entry) => entry.trim()) - .filter(Boolean); - -const recommendationPriorityOrder = ['critical', 'high', 'medium', 'low', 'other'] as const; -type RecommendationPriorityId = (typeof recommendationPriorityOrder)[number]; - -type RecommendationPriorityMeta = { - id: RecommendationPriorityId; - label: string; - sortOrder: number; - iconPath: string; - badgeClassName: string; - sectionTitle: string; - sectionDescription: string; - accentClassName: string; -}; - -const recommendationPriorityMetaMap: Record = { - critical: { - id: 'critical', - label: 'Critical', - sortOrder: 0, - iconPath: icon.mdiAlertCircleOutline, - badgeClassName: 'bg-rose-600 text-white dark:bg-rose-500 dark:text-white', - sectionTitle: 'Critical fixes first', - sectionDescription: 'Resolve these before anything else because they are the most urgent structured data gaps.', - accentClassName: 'border-rose-200 bg-rose-50/80 dark:border-rose-500/30 dark:bg-rose-500/10', - }, - high: { - id: 'high', - label: 'High', - sortOrder: 1, - iconPath: icon.mdiAlertOutline, - badgeClassName: 'bg-amber-500 text-white dark:bg-amber-400 dark:text-slate-950', - sectionTitle: 'High priority', - sectionDescription: 'These recommendations should be tackled early because they likely affect key pages or important schema coverage.', - accentClassName: 'border-amber-200 bg-amber-50/80 dark:border-amber-500/30 dark:bg-amber-500/10', - }, - medium: { - id: 'medium', - label: 'Medium', - sortOrder: 2, - iconPath: icon.mdiArrowDownCircleOutline, - badgeClassName: 'bg-sky-600 text-white dark:bg-sky-500 dark:text-white', - sectionTitle: 'Next up', - sectionDescription: 'Address these after the urgent items to improve broader coverage and quality.', - accentClassName: 'border-sky-200 bg-sky-50/80 dark:border-sky-500/30 dark:bg-sky-500/10', - }, - low: { - id: 'low', - label: 'Low', - sortOrder: 3, - iconPath: icon.mdiCheckCircleOutline, - badgeClassName: 'bg-emerald-600 text-white dark:bg-emerald-500 dark:text-white', - sectionTitle: 'Quick wins', - sectionDescription: 'Useful polish items that can be handled once higher-impact fixes are moving.', - accentClassName: 'border-emerald-200 bg-emerald-50/80 dark:border-emerald-500/30 dark:bg-emerald-500/10', - }, - other: { - id: 'other', - label: 'Unprioritized', - sortOrder: 4, - iconPath: icon.mdiLightbulbOutline, - badgeClassName: 'bg-slate-800 text-white dark:bg-slate-200 dark:text-slate-950', - sectionTitle: 'More opportunities', - sectionDescription: 'These are useful follow-up ideas that were not assigned a stronger priority label.', - accentClassName: 'border-slate-200 bg-slate-50/80 dark:border-slate-700 dark:bg-slate-900/40', - }, -}; - -const normalizeRecommendationPriority = (priority?: string): RecommendationPriorityId => { - const normalizedPriority = priority?.trim().toLowerCase(); - - if (!normalizedPriority) { - return 'other'; - } - - if (normalizedPriority.includes('critical') || normalizedPriority === 'p0') { - return 'critical'; - } - - if (normalizedPriority.includes('high') || normalizedPriority === 'p1') { - return 'high'; - } - - if (normalizedPriority.includes('medium') || normalizedPriority.includes('med') || normalizedPriority === 'p2') { - return 'medium'; - } - - if (normalizedPriority.includes('low') || normalizedPriority === 'p3') { - return 'low'; - } - - return 'other'; -}; - -const getRecommendationPriorityMeta = (priority?: string) => recommendationPriorityMetaMap[normalizeRecommendationPriority(priority)]; - -const isFixFirstRecommendation = (recommendation: Recommendation) => { - const priorityId = normalizeRecommendationPriority(recommendation.priority); - return priorityId === 'critical' || priorityId === 'high'; -}; - -const getRecommendationScopeSortOrder = (pageScope?: string) => { - const normalizedScope = pageScope?.trim().toLowerCase() || ''; - - if (!normalizedScope) { - return 4; - } - - if (normalizedScope.includes('site') || normalizedScope.includes('global') || normalizedScope.includes('all')) { - return 0; - } - - if (normalizedScope.includes('home')) { - return 1; - } - - if (normalizedScope.includes('template') || normalizedScope.includes('category') || normalizedScope.includes('collection') || normalizedScope.includes('product')) { - return 2; - } - - if (normalizedScope.includes('page')) { - return 3; - } - - return 4; -}; - -const SetupAccordionSection = ({ - title, - description, - iconPath, - badge, - isOpen, - onToggle, - children, -}: SetupAccordionSectionProps) => ( -
- - - {isOpen && ( -
- {children} -
- )} -
-); - -const ResultsTabButton = ({ label, iconPath, count, isActive, onClick }: ResultsTabButtonProps) => ( - -); - -const PageFilterChip = ({ label, count, iconPath, isActive, onClick }: PageFilterChipProps) => ( - -); - -const StatusBadge = ({ - children, - className = '', - iconPath, - iconSize = 14, - uppercase = false, - compact = false, - breakAll = false, - shadow = false, -}: StatusBadgeProps) => ( - - {iconPath && } - {children} - -); - -const DeliverySummaryCard = ({ - label, - value, - helper, - iconPath, - toneClassName = 'border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-950/40', -}: DeliverySummaryCardProps) => ( -
-
-
-
{label}
-
{value}
-
- - - -
-

{helper}

-
-); - -const DeliveryActionCard = ({ title, description, iconPath, badge, children }: DeliveryActionCardProps) => ( -
-
-
- - - -
-
{title}
-

{description}

-
-
- {badge} -
-
{children}
-
-); - -const SectionStatCard = ({ - label, - value, - helper, - iconPath, - className = '', - labelClassName = '', - valueClassName = '', - helperClassName = '', - iconWrapperClassName = '', - iconSize = 18, -}: SectionStatCardProps) => ( -
-
-
-
- {label} -
-
- {value} -
- {helper && ( -
- {helper} -
- )} -
- {iconPath && ( - - - - )} -
-
-); - -const SummaryPanel = ({ - label, - value, - description, - aside, - children, - className = '', - labelClassName = '', - valueClassName = '', - descriptionClassName = '', -}: SummaryPanelProps) => ( -
-
-
-
- {label} -
- {value && ( -
- {value} -
- )} - {description && ( -
- {description} -
- )} -
- {aside &&
{aside}
} -
- {children &&
{children}
} -
-); +const initialReport: ReportResponse | null = null; const SchemaAnalyzerPage = () => { const { currentUser } = useAppSelector((state) => state.auth); const [url, setUrl] = React.useState(''); const [requestedPages, setRequestedPages] = React.useState(1); - const [includeTargets, setIncludeTargets] = React.useState(''); - const [excludeTargets, setExcludeTargets] = React.useState(''); const [selectedPlatform, setSelectedPlatform] = React.useState('wordpress'); const [emailTo, setEmailTo] = React.useState(currentUser?.email || ''); - const [report, setReport] = React.useState(null); + const [report, setReport] = React.useState(initialReport); const [isAnalyzing, setIsAnalyzing] = React.useState(false); const [isExportingAll, setIsExportingAll] = React.useState(false); const [emailingId, setEmailingId] = React.useState(null); const [exportingId, setExportingId] = React.useState(null); const [isCheckingPlatformOutput, setIsCheckingPlatformOutput] = React.useState(false); - const [isSimulatingPlatformSend, setIsSimulatingPlatformSend] = React.useState(false); - const [lastPlatformSimulationAt, setLastPlatformSimulationAt] = React.useState(null); - const [openSections, setOpenSections] = React.useState>({ - targeting: false, - options: false, - limits: false, - }); - const [activeResultsTab, setActiveResultsTab] = React.useState('overview'); - const [activePageFilter, setActivePageFilter] = React.useState('all'); - const [activeRecommendationFilter, setActiveRecommendationFilter] = React.useState('all'); - const [isFailedPagesExpanded, setIsFailedPagesExpanded] = React.useState(false); - const [expandedRecommendationIds, setExpandedRecommendationIds] = React.useState>({}); - const resultsRef = React.useRef(null); - - const scrollToResults = React.useCallback(() => { - resultsRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); - }, []); React.useEffect(() => { if (currentUser?.email) { @@ -798,29 +151,6 @@ const SchemaAnalyzerPage = () => { } }, [currentUser?.email]); - React.useEffect(() => { - setLastPlatformSimulationAt(null); - }, [selectedPlatform]); - - React.useEffect(() => { - if (!report?.analysis) { - return undefined; - } - - setActiveResultsTab('overview'); - setActivePageFilter('all'); - setActiveRecommendationFilter('all'); - setIsFailedPagesExpanded(false); - setExpandedRecommendationIds({}); - setLastPlatformSimulationAt(null); - - const timeoutId = window.setTimeout(() => { - scrollToResults(); - }, 150); - - return () => window.clearTimeout(timeoutId); - }, [report?.analysis?.analyzedUrl, report?.analysis?.fetchedAt, scrollToResults]); - const notify = React.useCallback((type: 'success' | 'error' | 'info', message: string) => { toast(message, { type, position: 'bottom-center' }); }, []); @@ -830,945 +160,22 @@ const SchemaAnalyzerPage = () => { [currentUser], ); const entitlements = report?.entitlements || report?.analysis?.entitlements || fallbackEntitlements; - const maxPagesPerCrawl = entitlements?.maxPagesPerCrawl || fallbackEntitlements.maxPagesPerCrawl || 25; + const maxPagesPerCrawl = entitlements?.maxPagesPerCrawl || 1; const recommendations = report?.recommendations || []; const exportableRecommendations = recommendations.filter( (recommendation) => recommendation.suggested_schema, ); - const sortedRecommendations = React.useMemo(() => ( - [...recommendations].sort((leftRecommendation, rightRecommendation) => { - const leftPriority = getRecommendationPriorityMeta(leftRecommendation.priority); - const rightPriority = getRecommendationPriorityMeta(rightRecommendation.priority); - - if (leftPriority.sortOrder !== rightPriority.sortOrder) { - return leftPriority.sortOrder - rightPriority.sortOrder; - } - - const leftScopeOrder = getRecommendationScopeSortOrder(leftRecommendation.page_scope); - const rightScopeOrder = getRecommendationScopeSortOrder(rightRecommendation.page_scope); - if (leftScopeOrder !== rightScopeOrder) { - return leftScopeOrder - rightScopeOrder; - } - - const leftHasCode = Number(Boolean(leftRecommendation.suggested_schema)); - const rightHasCode = Number(Boolean(rightRecommendation.suggested_schema)); - if (leftHasCode !== rightHasCode) { - return rightHasCode - leftHasCode; - } - - return leftRecommendation.title.localeCompare(rightRecommendation.title); - }) - ), [recommendations]); - const crawlPlan = report?.analysis?.crawlPlan; const isRequestedPagesOverLimit = requestedPages > maxPagesPerCrawl; - const draftIncludeTargets = React.useMemo(() => parseTargetLines(includeTargets), [includeTargets]); - const draftExcludeTargets = React.useMemo(() => parseTargetLines(excludeTargets), [excludeTargets]); - const appliedIncludeTargets = crawlPlan?.includeTargets || draftIncludeTargets; - const appliedExcludeTargets = crawlPlan?.excludeTargets || draftExcludeTargets; - const analyzedPages = report?.analysis?.pages || []; - const failedPages = report?.analysis?.failedPages || []; - const detectedSchemaTypes = report?.analysis?.schema?.types || report?.analysis?.schema?.jsonLd?.types || []; - const wordpressAnalysis = report?.analysis?.wordpress || null; - const detectedWordPressPlugins = wordpressAnalysis?.plugins || []; - const wordpressSchemaOwnership = wordpressAnalysis?.schemaOwnership || null; - const wordpressPluginRecommendations = wordpressAnalysis?.pluginRecommendations || []; - const wordpressDuplicateRisk = wordpressAnalysis?.duplicateRisk || null; - const wordpressDuplicateRiskToneClassName = wordpressDuplicateRisk?.level === 'high' - ? 'border-rose-200 bg-rose-50 text-rose-800 dark:border-rose-500/40 dark:bg-rose-500/10 dark:text-rose-100' - : wordpressDuplicateRisk?.level === 'medium' - ? 'border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-100' - : 'border-emerald-200 bg-emerald-50 text-emerald-800 dark:border-emerald-500/40 dark:bg-emerald-500/10 dark:text-emerald-100'; - const wordpressDuplicateRiskBadgeClassName = wordpressDuplicateRisk?.level === 'high' - ? 'bg-rose-100 text-rose-700 dark:bg-rose-500/10 dark:text-rose-200' - : wordpressDuplicateRisk?.level === 'medium' - ? 'bg-amber-100 text-amber-700 dark:bg-amber-500/10 dark:text-amber-200' - : 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-200'; - const invalidJsonLdBlocks = report?.analysis?.schema?.jsonLd?.invalidBlocks || []; - const hasTargetingRules = appliedIncludeTargets.length > 0 || appliedExcludeTargets.length > 0; - const selectedPlatformLabel = PLATFORM_OPTIONS.find( - (platformOption) => platformOption.value === selectedPlatform, - )?.label || 'Custom / Other'; - const selectedPlatformMeta = PLATFORM_OUTPUT_META[selectedPlatform] || PLATFORM_OUTPUT_META.custom; - const analyzedTimestamp = report?.analysis?.fetchedAt - ? new Date(report.analysis.fetchedAt).toLocaleString() - : null; - const lastPlatformSimulationLabel = lastPlatformSimulationAt - ? new Date(lastPlatformSimulationAt).toLocaleString() - : null; - const trimmedEmailTo = emailTo.trim(); - const trimmedUrl = url.trim(); - const hasEmailRecipient = trimmedEmailTo.length > 0; - const hasUrl = trimmedUrl.length > 0; - const requestedPagesForRun = crawlPlan?.requestedPages || requestedPages; - const actualPagesAnalyzed = crawlPlan?.actualPagesAnalyzed || analyzedPages.length; - const failedPagesCount = report?.analysis?.crawlSummary?.failedPages ?? failedPages.length; - const requestedPageTargetMet = actualPagesAnalyzed >= requestedPagesForRun; - const pageCountStatusLabel = requestedPageTargetMet - ? `Exact ${requestedPagesForRun}-page target met` - : `Only ${actualPagesAnalyzed} eligible page${actualPagesAnalyzed === 1 ? ' was' : 's were'} found`; - const pageCountStatusDescription = requestedPageTargetMet - ? `This run analyzed all ${requestedPagesForRun} requested page${requestedPagesForRun === 1 ? '' : 's'}${failedPagesCount > 0 ? `, while ${failedPagesCount} additional fetch failure${failedPagesCount === 1 ? ' was' : 's were'} logged separately.` : '.'}` - : `You requested ${requestedPagesForRun} page${requestedPagesForRun === 1 ? '' : 's'}, but only ${actualPagesAnalyzed} crawlable page${actualPagesAnalyzed === 1 ? '' : 's'} matched the site and targeting rules for this run.`; - const targetingSummary = hasTargetingRules - ? `${appliedIncludeTargets.length} include · ${appliedExcludeTargets.length} exclude` - : 'No targeting rules'; - const recommendationQuickFilters = [ - { - id: 'all' as const, - label: 'All', - count: recommendations.length, - iconPath: icon.mdiViewListOutline, - }, - { - id: 'fixFirst' as const, - label: 'Fix first', - count: sortedRecommendations.filter((recommendation) => isFixFirstRecommendation(recommendation)).length, - iconPath: icon.mdiAlertCircleOutline, - }, - { - id: 'codeReady' as const, - label: 'Code ready', - count: sortedRecommendations.filter((recommendation) => recommendation.suggested_schema).length, - iconPath: icon.mdiCodeBraces, - }, - { - id: 'needsCode' as const, - label: 'Needs code', - count: sortedRecommendations.filter((recommendation) => !recommendation.suggested_schema).length, - iconPath: icon.mdiLightbulbOutline, - }, - ]; - const pageFilterOptions = [ - { - id: 'all' as const, - label: 'All', - count: analyzedPages.length + failedPages.length, - iconPath: icon.mdiViewListOutline, - }, - { - id: 'withSchema' as const, - label: 'With schema', - count: analyzedPages.filter((page) => page.hasStructuredData).length, - iconPath: icon.mdiCheckCircleOutline, - }, - { - id: 'missingSchema' as const, - label: 'Missing schema', - count: analyzedPages.filter((page) => !page.hasStructuredData).length, - iconPath: icon.mdiAlertCircleOutline, - }, - { - id: 'failed' as const, - label: 'Failed', - count: failedPages.length, - iconPath: icon.mdiCloseCircleOutline, - }, - ]; - const filteredRecommendations = React.useMemo(() => { - if (activeRecommendationFilter === 'fixFirst') { - return sortedRecommendations.filter((recommendation) => isFixFirstRecommendation(recommendation)); - } - - if (activeRecommendationFilter === 'codeReady') { - return sortedRecommendations.filter((recommendation) => recommendation.suggested_schema); - } - - if (activeRecommendationFilter === 'needsCode') { - return sortedRecommendations.filter((recommendation) => !recommendation.suggested_schema); - } - - return sortedRecommendations; - }, [activeRecommendationFilter, sortedRecommendations]); - const recommendationGroups = React.useMemo(() => recommendationPriorityOrder - .map((priorityId) => ({ - meta: recommendationPriorityMetaMap[priorityId], - recommendations: filteredRecommendations.filter( - (recommendation) => normalizeRecommendationPriority(recommendation.priority) === priorityId, - ), - })) - .filter((group) => group.recommendations.length > 0), [filteredRecommendations]); - const activeRecommendationFilterLabel = recommendationQuickFilters.find( - (filterOption) => filterOption.id === activeRecommendationFilter, - )?.label || 'All'; - const recommendationEmptyStateMessage = activeRecommendationFilter === 'fixFirst' - ? 'No high-priority recommendations are waiting in this report.' - : activeRecommendationFilter === 'codeReady' - ? 'No recommendations with generated code are available yet.' - : activeRecommendationFilter === 'needsCode' - ? 'Every visible recommendation already has a code snippet attached.' - : 'No recommendations were generated for this page yet.'; - - const filteredAnalyzedPages = React.useMemo(() => { - if (activePageFilter === 'withSchema') { - return analyzedPages.filter((page) => page.hasStructuredData); - } - - if (activePageFilter === 'missingSchema') { - return analyzedPages.filter((page) => !page.hasStructuredData); - } - - if (activePageFilter === 'failed') { - return []; - } - - return analyzedPages; - }, [activePageFilter, analyzedPages]); - const shouldShowFailedSection = failedPages.length > 0 && (activePageFilter === 'all' || activePageFilter === 'failed'); - const emptyPagesStateMessage = activePageFilter === 'failed' - ? 'No failed internal pages were recorded for this analysis run.' - : activePageFilter === 'withSchema' - ? 'No analyzed pages with structured data match this filter yet.' - : activePageFilter === 'missingSchema' - ? 'No analyzed pages are missing structured data for this run.' - : 'No page-level results are available yet for this analysis run.'; - const step4SchemaTypes = React.useMemo(() => Array.from(new Set([ - ...detectedSchemaTypes, - ...recommendations.map((recommendation) => recommendation.schema_type || '').filter(Boolean), - ])).slice(0, 8), [detectedSchemaTypes, recommendations]); - const step4PrimarySchemaType = step4SchemaTypes[0] || 'Organization'; - const step4PrimaryPageUrl = analyzedPages[0]?.url || report?.analysis?.analyzedUrl || report?.site?.base_url || trimmedUrl || 'https://example.com'; - const platformPreviewArtifacts = React.useMemo(() => { - const analyzedPageLabel = actualPagesAnalyzed === 1 ? '1 analyzed page' : `${actualPagesAnalyzed} analyzed pages`; - - if (selectedPlatform === 'wordpress') { - return [ - { - id: 'payload', - label: 'Schema package JSON', - fileName: 'schema-package.json', - description: `Contains the Step 4 payload for all ${analyzedPageLabel} in this run.`, - iconPath: icon.mdiCodeJson, - toneClassName: 'border-sky-200 bg-sky-50/70 dark:border-sky-500/30 dark:bg-sky-500/10', - }, - { - id: 'snippet', - label: 'Theme/plugin snippet', - fileName: 'wp-schema-snippet.php', - description: 'Ready for a theme hook, reusable partial, or an SEO plugin custom schema field.', - iconPath: icon.mdiWordpress, - }, - { - id: 'notes', - label: 'Install notes', - fileName: 'wordpress-install-notes.md', - description: 'Explains which templates or page types each schema block should be mapped to.', - iconPath: icon.mdiTextBoxCheckOutline, - }, - ]; - } - - if (selectedPlatform === 'shopify') { - return [ - { - id: 'payload', - label: 'Schema package JSON', - fileName: 'schema-package.json', - description: `Captures the exact ${analyzedPageLabel} selected for this Shopify handoff.`, - iconPath: icon.mdiCodeJson, - toneClassName: 'border-violet-200 bg-violet-50/70 dark:border-violet-500/30 dark:bg-violet-500/10', - }, - { - id: 'snippet', - label: 'Liquid snippet', - fileName: 'snippets/schema-output.liquid', - description: 'Shows how product, collection, or content templates could render JSON-LD in Liquid.', - iconPath: icon.mdiShopping, - }, - { - id: 'notes', - label: 'Theme checklist', - fileName: 'shopify-theme-checklist.md', - description: 'Documents the theme sections or app embed locations your developer should review.', - iconPath: icon.mdiClipboardCheckOutline, - }, - ]; - } - - if (selectedPlatform === 'webflow') { - return [ - { - id: 'payload', - label: 'Schema package JSON', - fileName: 'schema-package.json', - description: `Preserves all ${analyzedPageLabel} so the Webflow handoff matches the crawl exactly.`, - iconPath: icon.mdiCodeJson, - toneClassName: 'border-emerald-200 bg-emerald-50/70 dark:border-emerald-500/30 dark:bg-emerald-500/10', - }, - { - id: 'snippet', - label: 'Embed snippet', - fileName: 'webflow-embed-snippet.html', - description: 'Represents the custom code block that would be pasted into page settings or CMS templates.', - iconPath: icon.mdiMonitorDashboard, - }, - { - id: 'notes', - label: 'Publish checklist', - fileName: 'webflow-publish-checklist.md', - description: 'Calls out which static pages or CMS collections need a schema embed before publish.', - iconPath: icon.mdiClipboardPulseOutline, - }, - ]; - } - - return [ - { - id: 'payload', - label: 'Schema package JSON', - fileName: 'schema-package.json', - description: `Keeps the full ${analyzedPageLabel} package available for engineering review.`, - iconPath: icon.mdiCodeJson, - toneClassName: 'border-amber-200 bg-amber-50/70 dark:border-amber-500/30 dark:bg-amber-500/10', - }, - { - id: 'snippet', - label: 'Implementation starter', - fileName: 'schema-loader.ts', - description: 'A generic starter that engineering can adapt to the current frontend, backend, or CMS stack.', - iconPath: icon.mdiCodeTags, - }, - { - id: 'notes', - label: 'Developer notes', - fileName: 'implementation-notes.md', - description: 'Summarizes target templates, QA checks, and rollout notes for a custom implementation.', - iconPath: icon.mdiNotebookOutline, - }, - ]; - }, [actualPagesAnalyzed, selectedPlatform]); - const platformFinalDeliverables = React.useMemo(() => { - if (selectedPlatform === 'wordpress') { - return [ - { - id: 'plugin-field', - title: 'SEO plugin field', - destination: 'Yoast / Rank Math / custom schema field', - owner: 'SEO owner', - description: 'Best for singular pages where a marketer or SEO specialist can manage the final JSON-LD without editing templates.', - iconPath: icon.mdiWordpress, - statusLabel: 'Ready for config', - toneClassName: 'border-sky-200 bg-sky-50/70 dark:border-sky-500/30 dark:bg-sky-500/10', - }, - { - id: 'theme-hook', - title: 'Theme hook snippet', - destination: 'wp_head hook or reusable template partial', - owner: 'Frontend developer', - description: 'Used when the schema package needs template-aware logic across posts, pages, or custom content types.', - iconPath: icon.mdiCodeBraces, - statusLabel: 'Ready for theme dev', - }, - { - id: 'validation', - title: 'Validation checklist', - destination: 'Rich Results + source-code spot checks', - owner: 'QA reviewer', - description: 'Confirms the packaged schema renders on every analyzed page before the team marks the release complete.', - iconPath: icon.mdiClipboardCheckOutline, - statusLabel: 'Ready for QA', - }, - ]; - } - - if (selectedPlatform === 'shopify') { - return [ - { - id: 'liquid-snippet', - title: 'Liquid theme snippet', - destination: 'snippets/schema-output.liquid', - owner: 'Theme developer', - description: 'Delivers the generated JSON-LD into product, collection, or article templates with Liquid-aware variables.', - iconPath: icon.mdiShopping, - statusLabel: 'Ready for theme merge', - toneClassName: 'border-violet-200 bg-violet-50/70 dark:border-violet-500/30 dark:bg-violet-500/10', - }, - { - id: 'app-embed', - title: 'Theme app/embed slot', - destination: 'App embed block or section-level insertion point', - owner: 'Merchant ops', - description: 'Shows where a merchant or implementation partner would enable the package inside the active theme.', - iconPath: icon.mdiPackageVariantClosed, - statusLabel: 'Ready for enablement', - }, - { - id: 'validation', - title: 'Storefront validation', - destination: 'Preview theme + rich result validation', - owner: 'QA reviewer', - description: 'Validates the schema output across the selected storefront pages before the theme is published live.', - iconPath: icon.mdiClipboardCheckOutline, - statusLabel: 'Ready for QA', - }, - ]; - } - - if (selectedPlatform === 'webflow') { - return [ - { - id: 'page-embed', - title: 'Page embed block', - destination: 'Page settings custom code or embed element', - owner: 'Webflow editor', - description: 'Ideal for static pages that need a fast JSON-LD insert without changing the rest of the layout.', - iconPath: icon.mdiMonitorDashboard, - statusLabel: 'Ready for page paste', - toneClassName: 'border-emerald-200 bg-emerald-50/70 dark:border-emerald-500/30 dark:bg-emerald-500/10', - }, - { - id: 'cms-template', - title: 'CMS template mapping', - destination: 'Collection template custom code region', - owner: 'CMS implementer', - description: 'Maps the generated package to Webflow CMS templates so repeatable schema can be applied at scale.', - iconPath: icon.mdiCodeBraces, - statusLabel: 'Ready for template wiring', - }, - { - id: 'publish-review', - title: 'Publish review', - destination: 'Pre-publish checklist and live spot check', - owner: 'Publisher', - description: 'Adds a final review pass before Webflow publish so every analyzed page gets its expected schema block.', - iconPath: icon.mdiClipboardPulseOutline, - statusLabel: 'Ready for publish review', - }, - ]; - } - - return [ - { - id: 'component-slot', - title: 'Component integration slot', - destination: 'Shared layout, component wrapper, or middleware hook', - owner: 'Engineering', - description: 'Provides the most flexible handoff for React, Node, headless CMS, or other custom delivery stacks.', - iconPath: icon.mdiCodeTags, - statusLabel: 'Ready for engineering', - toneClassName: 'border-amber-200 bg-amber-50/70 dark:border-amber-500/30 dark:bg-amber-500/10', - }, - { - id: 'deployment-task', - title: 'Deployment task', - destination: 'Release checklist or ticketed implementation step', - owner: 'Project manager', - description: 'Turns the Step 4 package into a concrete delivery task that engineering can estimate and schedule.', - iconPath: icon.mdiTrayArrowUp, - statusLabel: 'Ready for planning', - }, - { - id: 'validation', - title: 'Post-release validation', - destination: 'QA checklist and monitor pass', - owner: 'QA reviewer', - description: 'Captures the checks needed after deployment so the team can confirm production pages match the package.', - iconPath: icon.mdiClipboardCheckOutline, - statusLabel: 'Ready for sign-off', - }, - ]; - }, [selectedPlatform]); - const platformPageMappings = React.useMemo(() => { - const implementationArtifacts = platformPreviewArtifacts.filter((artifact) => artifact.id !== 'payload'); - - return analyzedPages.map((page, index) => { - const mappedDeliverable = platformFinalDeliverables[index % platformFinalDeliverables.length] || platformFinalDeliverables[0]; - const mappedArtifact = implementationArtifacts[index % implementationArtifacts.length] || platformPreviewArtifacts[0]; - const schemaType = page.schemaTypes?.[0] || page.jsonLdTypes?.[0] || step4SchemaTypes[index % step4SchemaTypes.length] || step4PrimarySchemaType; - const hasExistingStructuredData = Boolean(page.hasStructuredData); - - return { - id: page.url || `${page.title || 'page'}-${index}`, - pageLabel: page.title || page.url || `Page ${index + 1}`, - pageUrl: page.url || null, - schemaType, - packageFile: mappedArtifact?.fileName || platformPreviewArtifacts[0]?.fileName || selectedPlatformMeta.payloadLabel, - deliverableTitle: mappedDeliverable?.title || selectedPlatformMeta.payloadLabel, - destination: mappedDeliverable?.destination || selectedPlatformMeta.developerDestination, - actionLabel: hasExistingStructuredData - ? 'Review the existing markup and merge only the missing schema improvements.' - : 'Inject the generated schema output for this page during implementation.', - statusLabel: hasExistingStructuredData ? 'Existing markup detected' : 'Schema output pending', - statusClassName: hasExistingStructuredData - ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-200' - : 'bg-amber-50 text-amber-700 dark:bg-amber-500/10 dark:text-amber-200', - iconPath: hasExistingStructuredData ? icon.mdiCheckCircleOutline : icon.mdiAlertCircleOutline, - }; - }); - }, [ - analyzedPages, - platformFinalDeliverables, - platformPreviewArtifacts, - selectedPlatformMeta.developerDestination, - selectedPlatformMeta.payloadLabel, - step4PrimarySchemaType, - step4SchemaTypes, - ]); - const platformPublishTimeline = React.useMemo(() => { - const baseTimestamp = lastPlatformSimulationAt ? new Date(lastPlatformSimulationAt) : null; - const formatStepTime = (minuteOffset: number, fallback: string) => { - if (!baseTimestamp) { - return fallback; - } - - return new Date(baseTimestamp.getTime() + (minuteOffset * 60 * 1000)).toLocaleTimeString([], { - hour: 'numeric', - minute: '2-digit', - }); - }; - - if (!baseTimestamp) { - return [ - { - id: 'queued', - label: 'Queued', - description: `${selectedPlatformLabel} handoff is ready to stage as soon as you run the demo send.`, - iconPath: icon.mdiClockOutline, - status: 'current', - timeLabel: 'Waiting for demo run', - }, - { - id: 'packaged', - label: 'Packaged', - description: 'Package files, page mapping, and payload JSON will be assembled for review.', - iconPath: icon.mdiPackageVariantClosed, - status: 'upcoming', - timeLabel: 'Pending', - }, - { - id: 'ready-for-dev', - label: 'Ready for dev', - description: 'The implementation handoff will be marked ready for a developer or implementation partner.', - iconPath: icon.mdiTrayArrowUp, - status: 'upcoming', - timeLabel: 'Pending', - }, - { - id: 'approved', - label: 'Approved', - description: 'Stakeholder approval appears here after the Step 4 demo handoff is reviewed.', - iconPath: icon.mdiThumbUpOutline, - status: 'upcoming', - timeLabel: 'Pending', - }, - ]; - } - - return [ - { - id: 'queued', - label: 'Queued', - description: `${selectedPlatformLabel} demo package entered the delivery queue.`, - iconPath: icon.mdiClockOutline, - status: 'complete', - timeLabel: formatStepTime(0, 'Queued'), - }, - { - id: 'packaged', - label: 'Packaged', - description: 'Files, payload preview, and page mapping were bundled into the demo handoff.', - iconPath: icon.mdiPackageVariantClosed, - status: 'complete', - timeLabel: formatStepTime(1, 'Packaged'), - }, - { - id: 'ready-for-dev', - label: 'Ready for dev', - description: 'The handoff was marked ready for engineering review and platform implementation.', - iconPath: icon.mdiTrayArrowUp, - status: 'complete', - timeLabel: formatStepTime(3, 'Ready'), - }, - { - id: 'approved', - label: 'Approved', - description: 'The preview package reached the final stakeholder-approved demo state.', - iconPath: icon.mdiThumbUpOutline, - status: 'current', - timeLabel: formatStepTime(6, 'Approved'), - }, - ]; - }, [lastPlatformSimulationAt, selectedPlatformLabel]); - const platformImplementationPreview = React.useMemo(() => { - if (selectedPlatform === 'wordpress') { - return ` - - - { - "@context": "https://schema.org", - "@type": "${step4PrimarySchemaType}", - "url": "{{ shop.url }}{{ request.path }}" - } - -{% endif %}`; - } - - if (selectedPlatform === 'webflow') { - return ``; - } - - return `export const schemaPayload = { - "@context": "https://schema.org", - "@type": "${step4PrimarySchemaType}", - url: "${step4PrimaryPageUrl}", -};`; - }, [selectedPlatform, step4PrimaryPageUrl, step4PrimarySchemaType]); - const platformOutputPreviewPayload = React.useMemo(() => ({ - demo: true, - livePublish: false, - platform: { - key: selectedPlatform, - label: selectedPlatformLabel, - packageType: selectedPlatformMeta.payloadLabel, - implementation: selectedPlatformMeta.implementationLabel, - destination: selectedPlatformMeta.developerDestination, - }, - site: { - id: report?.site?.id || null, - name: report?.site?.name || null, - baseUrl: report?.site?.base_url || trimmedUrl || null, - detectedPlatform: report?.analysis?.platform?.label || 'Unknown', - }, - analysis: { - analyzedAt: report?.analysis?.fetchedAt || new Date().toISOString(), - requestedPages: requestedPagesForRun, - actualPagesAnalyzed, - failedPages: failedPagesCount, - exactRequestedPageTargetMet: requestedPageTargetMet, - structuredDataTypes: step4SchemaTypes, - wordpress: wordpressAnalysis?.detected ? { - detected: true, - detectedPlugins: detectedWordPressPlugins.map((plugin) => plugin.label || plugin.key || 'Plugin'), - schemaOwnership: wordpressSchemaOwnership?.label || null, - implementationGuidance: wordpressSchemaOwnership?.recommendedImplementation || null, - duplicateRisk: wordpressDuplicateRisk ? { - level: wordpressDuplicateRisk.level || null, - label: wordpressDuplicateRisk.label || null, - summary: wordpressDuplicateRisk.summary || null, - warnings: wordpressDuplicateRisk.warnings || [], - } : null, - pluginRecommendations: wordpressPluginRecommendations.map((recommendation) => ({ - plugin: recommendation.label || recommendation.key || 'Plugin', - title: recommendation.title || null, - recommendedApproach: recommendation.recommendedApproach || null, - applicableSchemaTypes: recommendation.applicableSchemaTypes || [], - })), - } : null, - notice: report?.analysis?.notice || null, - }, - pages: analyzedPages.map((page) => ({ - url: page.url || null, - title: page.title || null, - statusCode: page.statusCode || null, - hasStructuredData: Boolean(page.hasStructuredData), - schemaTypes: page.schemaTypes || page.jsonLdTypes || [], - wordpressPlugins: (page.wordpress?.plugins || []).map((plugin) => plugin.label || plugin.key || 'Plugin'), - })), - finalDeliverables: platformFinalDeliverables.map((deliverable) => ({ - id: deliverable.id, - title: deliverable.title, - owner: deliverable.owner, - destination: deliverable.destination, - status: deliverable.statusLabel, - })), - pageMappings: platformPageMappings.map((mapping) => ({ - page: mapping.pageLabel, - url: mapping.pageUrl, - schemaType: mapping.schemaType, - deliverable: mapping.deliverableTitle, - destination: mapping.destination, - packageFile: mapping.packageFile, - action: mapping.actionLabel, - status: mapping.statusLabel, - })), - timeline: platformPublishTimeline.map((step) => ({ - id: step.id, - label: step.label, - status: step.status, - timeLabel: step.timeLabel, - })), - recommendations: exportableRecommendations.map((recommendation) => ({ - id: recommendation.id, - title: recommendation.title, - priority: recommendation.priority || 'n/a', - scope: recommendation.page_scope || 'n/a', - schemaType: recommendation.schema_type || 'Schema', - })), - }), [ - actualPagesAnalyzed, - analyzedPages, - exportableRecommendations, - failedPagesCount, - platformFinalDeliverables, - platformPageMappings, - platformPublishTimeline, - report?.analysis?.fetchedAt, - report?.analysis?.notice, - report?.analysis?.platform?.label, - report?.site?.base_url, - report?.site?.id, - report?.site?.name, - requestedPageTargetMet, - requestedPagesForRun, - selectedPlatform, - selectedPlatformLabel, - selectedPlatformMeta.developerDestination, - selectedPlatformMeta.implementationLabel, - selectedPlatformMeta.payloadLabel, - step4SchemaTypes, - trimmedUrl, - wordpressAnalysis?.detected, - detectedWordPressPlugins, - wordpressSchemaOwnership?.label, - wordpressSchemaOwnership?.recommendedImplementation, - wordpressDuplicateRisk, - wordpressPluginRecommendations, - ]); - const platformOutputPreviewJson = React.useMemo( - () => JSON.stringify(platformOutputPreviewPayload, null, 2), - [platformOutputPreviewPayload], - ); - const platformHandoffText = React.useMemo(() => [ - `Step 4 demo handoff for ${selectedPlatformLabel}`, - `Site: ${report?.site?.base_url || trimmedUrl || 'Not set'}`, - `Requested pages: ${requestedPagesForRun}`, - `Pages analyzed: ${actualPagesAnalyzed}`, - `Platform package: ${selectedPlatformMeta.payloadLabel}`, - `Implementation target: ${selectedPlatformMeta.developerDestination}`, - step4SchemaTypes.length > 0 ? `Schema types: ${step4SchemaTypes.join(', ')}` : 'Schema types: To be determined from recommendations', - selectedPlatform === 'wordpress' && detectedWordPressPlugins.length > 0 - ? `Detected WordPress plugins: ${detectedWordPressPlugins.map((plugin) => plugin.label || plugin.key || 'Plugin').join(', ')}` - : '', - selectedPlatform === 'wordpress' && wordpressSchemaOwnership?.recommendedImplementation - ? `WordPress implementation guidance: ${wordpressSchemaOwnership.recommendedImplementation}` - : '', - selectedPlatform === 'wordpress' && wordpressDuplicateRisk?.label - ? `Duplicate-schema risk: ${wordpressDuplicateRisk.label}${wordpressDuplicateRisk.summary ? ` — ${wordpressDuplicateRisk.summary}` : ''}` - : '', - selectedPlatform === 'wordpress' && wordpressPluginRecommendations.length > 0 - ? `Plugin implementation recommendations: ${wordpressPluginRecommendations.slice(0, 2).map((recommendation) => `${recommendation.label || recommendation.key || 'Plugin'} — ${recommendation.recommendedApproach || recommendation.summary || 'Review detected plugin output before coding.'}`).join(' | ')}` - : '', - '', - 'Demo workflow preview:', - ...selectedPlatformMeta.steps.map((step, index) => `${index + 1}. ${step}`), - ].filter(Boolean).join('\n'), [ - actualPagesAnalyzed, - report?.site?.base_url, - requestedPagesForRun, - selectedPlatformLabel, - selectedPlatformMeta.developerDestination, - selectedPlatformMeta.payloadLabel, - selectedPlatformMeta.steps, - selectedPlatform, - step4SchemaTypes, - trimmedUrl, - detectedWordPressPlugins, - wordpressSchemaOwnership?.recommendedImplementation, - wordpressDuplicateRisk?.label, - wordpressDuplicateRisk?.summary, - wordpressPluginRecommendations, - ]); - const deliverySummaryCards = [ - { - label: 'Code-ready fixes', - value: exportableRecommendations.length, - helper: exportableRecommendations.length > 0 - ? `${exportableRecommendations.length} recommendation${exportableRecommendations.length === 1 ? '' : 's'} can be exported right now.` - : 'No code-ready recommendations yet. Use the Recommendations tab to refine the handoff.', - iconPath: icon.mdiCodeBraces, - toneClassName: exportableRecommendations.length > 0 - ? 'border-emerald-200 bg-emerald-50/80 dark:border-emerald-500/30 dark:bg-emerald-500/10' - : 'border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-950/40', - }, - { - label: 'Email recipient', - value: hasEmailRecipient ? 'Ready' : 'Missing', - helper: hasEmailRecipient ? trimmedEmailTo : 'Add a developer email before sending the handoff.', - iconPath: hasEmailRecipient ? icon.mdiEmailOutline : icon.mdiAlertCircleOutline, - toneClassName: hasEmailRecipient - ? 'border-sky-200 bg-sky-50/80 dark:border-sky-500/30 dark:bg-sky-500/10' - : 'border-amber-200 bg-amber-50/80 dark:border-amber-500/30 dark:bg-amber-500/10', - }, - { - label: 'Platform output', - value: entitlements?.canPlatformOutput ? 'Unlocked' : 'Premium', - helper: entitlements?.canPlatformOutput - ? `${selectedPlatformLabel} output can be checked in Step 4.` - : 'Premium is required for Step 4 platform-specific output.', - iconPath: entitlements?.canPlatformOutput ? icon.mdiCheckCircleOutline : icon.mdiLockOutline, - toneClassName: entitlements?.canPlatformOutput - ? 'border-violet-200 bg-violet-50/80 dark:border-violet-500/30 dark:bg-violet-500/10' - : 'border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-950/40', - }, - ]; - const deliveryChecklist = [ - { - id: 'recipient', - label: 'Recipient email', - value: hasEmailRecipient ? trimmedEmailTo : 'Add an email to send the handoff.', - isReady: hasEmailRecipient, - }, - { - id: 'export', - label: 'Export package', - value: exportableRecommendations.length > 0 - ? `${exportableRecommendations.length} code-ready recommendation${exportableRecommendations.length === 1 ? '' : 's'} available.` - : 'Export all still works, but no generated code is attached yet.', - isReady: Boolean(report?.site?.id), - }, - { - id: 'platform', - label: 'Step 4 output', - value: entitlements?.canPlatformOutput - ? `${selectedPlatformLabel} output is available for this workspace.` - : `${selectedPlatformLabel} output requires Premium access.`, - isReady: Boolean(entitlements?.canPlatformOutput), - }, - ]; - const siteSnapshotStats = [ - { - label: 'Platform', - value: report?.analysis?.platform?.label || 'Unknown platform', - }, - { - label: 'Last updated', - value: analyzedTimestamp || 'Just now', - }, - ]; - const planDetailStats = [ - { - label: 'Access level', - value: entitlements?.canPlatformOutput ? 'Premium' : 'Basic', - }, - { - label: 'Pages allowed', - value: `Up to ${maxPagesPerCrawl} pages per crawl`, - }, - { - label: 'Step 4 output', - value: entitlements?.canPlatformOutput ? 'Available to check' : 'Premium only', - }, - ]; - const overviewStats = [ - { - label: 'Pages analyzed', - value: crawlPlan?.actualPagesAnalyzed || analyzedPages.length || 0, - helper: 'Crawl total', - iconPath: icon.mdiFileDocumentOutline, - }, - { - label: 'Recommendations', - value: recommendations.length, - helper: 'Next actions', - iconPath: icon.mdiLightbulbOutline, - }, - { - label: 'Structured data', - value: report?.analysis?.crawlSummary?.pagesWithStructuredData ?? (report?.analysis?.schema?.hasStructuredData ? 1 : 0), - helper: 'Pages with schema', - iconPath: icon.mdiCheckCircleOutline, - }, - { - label: 'JSON-LD blocks', - value: report?.analysis?.schema?.jsonLd?.count || 0, - helper: 'Detected snippets', - iconPath: icon.mdiCodeJson, - }, - { - label: 'Failed fetches', - value: report?.analysis?.crawlSummary?.failedPages ?? failedPages.length, - helper: 'Needs follow-up', - iconPath: icon.mdiAlertCircleOutline, - }, - { - label: 'Invalid blocks', - value: invalidJsonLdBlocks.length, - helper: 'Needs cleanup', - iconPath: icon.mdiAlertOutline, - }, - ]; - const crawlPlanStats = [ - { - label: 'Requested', - value: crawlPlan?.requestedPages || 1, - }, - { - label: 'Plan limit', - value: crawlPlan?.allowedPages || maxPagesPerCrawl, - }, - { - label: 'Analyzed', - value: crawlPlan?.actualPagesAnalyzed || 0, - }, - { - label: 'Failed', - value: report?.analysis?.crawlSummary?.failedPages ?? failedPages.length, - }, - ]; - const crawlSummaryStats = report?.analysis?.crawlSummary - ? [ - { - label: 'Pages without structured data', - value: report.analysis.crawlSummary.pagesWithoutStructuredData ?? 0, - }, - { - label: 'Discovered internal pages', - value: report.analysis.crawlSummary.discoveredInternalPages ?? analyzedPages.length, - }, - ] - : []; - - React.useEffect(() => { - if ((draftIncludeTargets.length > 0 || draftExcludeTargets.length > 0) && !openSections.targeting) { - setOpenSections((currentSections) => ({ - ...currentSections, - targeting: true, - })); - } - }, [draftExcludeTargets.length, draftIncludeTargets.length, openSections.targeting]); - - React.useEffect(() => { - if (activePageFilter === 'failed' && failedPages.length > 0) { - setIsFailedPagesExpanded(true); - } - }, [activePageFilter, failedPages.length]); - - const toggleSection = (section: SetupSectionId) => { - setOpenSections((currentSections) => ({ - ...currentSections, - [section]: !currentSections[section], - })); - }; - - const toggleRecommendationCode = (recommendationId: string) => { - setExpandedRecommendationIds((currentIds) => ({ - ...currentIds, - [recommendationId]: !currentIds[recommendationId], - })); + const firecrawlStatus = report?.analysis?.firecrawl || { + provider: 'firecrawl', + configured: false, + wouldHandleJavascript: true, + wouldHandleSitemapDiscovery: true, + message: 'Firecrawl scaffold is wired in code, but this environment still needs a FIRECRAWL_API_KEY before activation.', }; const handleAnalyze = async () => { - if (!trimmedUrl) { + if (!url.trim()) { notify('error', 'Enter a website URL first.'); return; } @@ -1776,7 +183,7 @@ add_action('wp_head', function () { if (isRequestedPagesOverLimit) { notify( 'error', - `This analyzer supports up to ${maxPagesPerCrawl} page${maxPagesPerCrawl === 1 ? '' : 's'} per crawl. Reduce the page count to continue.`, + `Your current plan allows up to ${maxPagesPerCrawl} page${maxPagesPerCrawl === 1 ? '' : 's'} per crawl. Upgrade to Advanced Crawl to go beyond that limit.`, ); return; } @@ -1784,10 +191,8 @@ add_action('wp_head', function () { try { setIsAnalyzing(true); const response = await axios.post('/sites/analyze', { - url: trimmedUrl, + url: url.trim(), requestedPages, - includeTargets, - excludeTargets, }); setReport(response.data); @@ -1821,39 +226,6 @@ add_action('wp_head', function () { } }; - const handleCopyText = async (value: string, successMessage: string) => { - try { - await navigator.clipboard.writeText(value); - notify('success', successMessage); - } catch (error) { - console.error('Copy text failed:', error); - notify('error', 'Unable to copy text in this browser.'); - } - }; - - const handleSimulatePlatformSend = async () => { - if (!report?.site?.id) { - notify('error', 'Analyze a site first.'); - return; - } - - try { - setIsSimulatingPlatformSend(true); - await new Promise((resolve) => { - window.setTimeout(resolve, 900); - }); - - const simulatedAt = new Date().toISOString(); - setLastPlatformSimulationAt(simulatedAt); - notify('success', `Demo output prepared for ${selectedPlatformLabel}. No live publish was performed.`); - } catch (error) { - console.error('Simulate Step 4 send failed:', error); - notify('error', 'Unable to simulate Step 4 output right now.'); - } finally { - setIsSimulatingPlatformSend(false); - } - }; - const downloadBlob = (blob: Blob, filename: string) => { const blobUrl = window.URL.createObjectURL(blob); const link = document.createElement('a'); @@ -1927,7 +299,7 @@ add_action('wp_head', function () { return; } - if (!trimmedEmailTo) { + if (!emailTo.trim()) { notify('error', 'Add a recipient email first.'); return; } @@ -1937,11 +309,11 @@ add_action('wp_head', function () { await axios.post('/sites/email-code', recommendationId ? { recommendationId, - to: trimmedEmailTo, + to: emailTo.trim(), } : { siteId: report.site.id, - to: trimmedEmailTo, + to: emailTo.trim(), }); notify('success', recommendationId ? 'Schema code emailed.' : 'Full recommendation report emailed.'); } catch (error: any) { @@ -1980,6 +352,8 @@ add_action('wp_head', function () { } }; + const crawlPlan = report?.analysis?.crawlPlan; + return ( <> @@ -1994,39 +368,39 @@ add_action('wp_head', function () { {''} - -
+ +

Analyze a customer site

-

- Enter a domain or full URL, choose how many pages to review, and optionally focus the report on the - folders, categories, or pages that matter most. This setup keeps the page cleaner on mobile while still - supporting up to {maxPagesPerCrawl} pages per crawl. +

+ Enter a domain or full URL. The app will detect the platform, crawl up to your allowed page limit, + inspect structured data across the discovered pages, generate rules-based schema recommendations, + and prepare developer-ready code snippets.

-
-
- - setUrl(event.target.value)} - onKeyDown={(event) => { - if (event.key === 'Enter') { - event.preventDefault(); - void handleAnalyze(); - } - }} - /> - +
+ + setUrl(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + handleAnalyze().catch(() => null); + } + }} + /> + +
-
+
-
-
Quick setup
-
Pick a page count here, then use Target pages below if you want a more focused report.
-
+ + +
+ + {isRequestedPagesOverLimit && ( +
+ You requested {requestedPages} pages, but this account is capped at {maxPagesPerCrawl}. Upgrade to + Advanced Crawl to raise that limit. +
+ )} + + + { + handleAnalyze().catch(() => null); + }} + /> + { + const combined = exportableRecommendations + .map((recommendation) => recommendation.suggested_schema) + .filter(Boolean) + .join('\n\n'); + navigator.clipboard + .writeText(combined) + .then(() => notify('success', 'All schema code copied to clipboard.')) + .catch((error) => { + console.error('Copy all code failed:', error); + notify('error', 'Unable to copy the combined code.'); + }); + }} + /> + { + handlePlatformOutputCheck().catch(() => null); + }} + /> +
- {isRequestedPagesOverLimit && ( -
- You requested {requestedPages} pages, but this analyzer is capped at {maxPagesPerCrawl}. Reduce the page count to continue. -
- )} - -
- toggleSection('targeting')} - > -
- -