moizshah956 commited on
Commit
7a8be67
·
verified ·
1 Parent(s): 7bce751

Upload 5 files

Browse files
Files changed (5) hide show
  1. Dockerfile +46 -0
  2. ai_visibility.py +1463 -0
  3. app.py +1479 -0
  4. requirements.txt +14 -0
  5. seo_analyzer.py +1532 -0
Dockerfile ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.9-slim
2
+
3
+ WORKDIR /app
4
+
5
+ # Install only essential dependencies that definitely exist in Debian Trixie
6
+ RUN apt-get update && apt-get install -y \
7
+ wget \
8
+ curl \
9
+ libnss3 \
10
+ libatk-bridge2.0-0 \
11
+ libx11-6 \
12
+ libxcomposite1 \
13
+ libxdamage1 \
14
+ libxext6 \
15
+ libxfixes3 \
16
+ libxrandr2 \
17
+ libxss1 \
18
+ libasound2 \
19
+ libgtk-3-0 \
20
+ libgbm1 \
21
+ libcups2 \
22
+ libdbus-1-3 \
23
+ && rm -rf /var/lib/apt/lists/*
24
+
25
+ # Copy requirements and install Python dependencies
26
+ COPY requirements.txt .
27
+ RUN pip install --no-cache-dir -r requirements.txt
28
+
29
+ # Install Playwright with minimal dependencies
30
+ RUN playwright install chromium
31
+
32
+ # Copy application files
33
+ COPY . .
34
+
35
+ # Create necessary directories
36
+ RUN mkdir -p /tmp
37
+
38
+ # Expose port
39
+ EXPOSE 7860
40
+
41
+ # Health check
42
+ HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \
43
+ CMD curl -f http://localhost:7860/health || exit 1
44
+
45
+ # Start the application
46
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
ai_visibility.py ADDED
@@ -0,0 +1,1463 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ AI Visibility / AI Search Readiness analysis.
3
+
4
+ Estimates how well a page is structured to be understood, retrieved, cited,
5
+ summarized and recommended by AI-powered search systems (ChatGPT, Google AI
6
+ Overviews, Perplexity, Claude, etc). These are NOT official ranking factors of
7
+ any AI system - they are deterministic, measurable proxy signals derived from
8
+ the page's HTML/text plus (optionally) a lightweight LLM pass for the handful
9
+ of metrics that cannot be reliably computed with parsing alone.
10
+
11
+ This module reports an "AI Readiness Score" - whether a page is technically
12
+ and semantically prepared for AI systems. It does NOT claim to measure actual
13
+ observed visibility in AI answers/citations (query coverage, citation rate,
14
+ etc) - that requires live AI-query data this crawler does not have, so that
15
+ field is always returned as "not_measured" rather than guessed at.
16
+
17
+ Key design principles (see README/PR notes for the full rationale):
18
+ - Pages are classified by type (homepage, article, product, ...) and only
19
+ scored against metrics that are relevant to that type.
20
+ - Missing evidence is reported as "unknown" / null, never coerced to a low
21
+ score. Unknown != bad.
22
+ - Weights are centralized and configurable, per page type.
23
+
24
+ This module is intentionally decoupled from seo_analyzer.py's SEO scoring.
25
+ It reuses the same crawling primitives (discover_urls_parallel /
26
+ fetch_all_pages_parallel) so pages are only fetched once per run via
27
+ Playwright, but it does its own HTML parsing and its own scoring - it never
28
+ touches or overrides the existing seo_score / page_summary fields.
29
+ """
30
+
31
+ import os
32
+ import re
33
+ import json
34
+ import asyncio
35
+ from collections import Counter
36
+ from urllib.parse import urlparse
37
+ from urllib.request import urlopen, Request
38
+ from dotenv import load_dotenv
39
+ load_dotenv()
40
+
41
+ from bs4 import BeautifulSoup
42
+
43
+ from seo_analyzer import (
44
+ discover_urls_parallel,
45
+ fetch_all_pages_parallel,
46
+ OPENAI_AVAILABLE,
47
+ )
48
+
49
+ try:
50
+ import openai
51
+ except Exception:
52
+ openai = None
53
+
54
+ # SECURITY FIX: never hardcode API keys in source. Load from environment.
55
+ # Set this in your shell / deployment config, e.g.:
56
+ # export OPENAI_API_KEY="sk-..."
57
+ # If it's unset, `use_ai=True` calls will simply fall back to the
58
+ # deterministic-only scoring path (see _llm_semantic_enhance below).
59
+ OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY")
60
+ # Reused across calls so we're not re-instantiating the client every request.
61
+ _openai_client = None
62
+
63
+
64
+ def _get_openai_client():
65
+ global _openai_client
66
+ if _openai_client is None and openai is not None and OPENAI_API_KEY:
67
+ _openai_client = openai.OpenAI(api_key=OPENAI_API_KEY)
68
+ return _openai_client
69
+
70
+
71
+ # ==============================
72
+ # PAGE TYPES
73
+ # ==============================
74
+ PAGE_TYPES = [
75
+ "homepage", "portal", "article", "blog_post", "news", "product",
76
+ "product_category", "documentation", "forum", "review", "comparison",
77
+ "organization", "landing_page", "service_page", "directory",
78
+ "search_page", "unknown",
79
+ ]
80
+
81
+ # ==============================
82
+ # CENTRALIZED SCORING CONFIGURATION
83
+ # ==============================
84
+ # Base ("default") weights - used for page types without a specific profile.
85
+ # Categories are the 8 "AI Readiness" pillars. Weights sum to 1.0.
86
+ DEFAULT_WEIGHTS = {
87
+ "semantic": 0.20,
88
+ "content_answerability": 0.25,
89
+ "entity": 0.15,
90
+ "trust": 0.15,
91
+ "citation": 0.10,
92
+ "retrieval": 0.05,
93
+ "structured_data": 0.05,
94
+ "freshness": 0.05,
95
+ }
96
+
97
+ # Per-page-type overrides. Only categories that differ from DEFAULT_WEIGHTS
98
+ # need to be listed - the profile is merged over the default and renormalized.
99
+ PAGE_TYPE_WEIGHT_OVERRIDES = {
100
+ "homepage": {
101
+ "semantic": 0.15, "content_answerability": 0.10, "entity": 0.25,
102
+ "trust": 0.15, "citation": 0.05, "retrieval": 0.20,
103
+ "structured_data": 0.05, "freshness": 0.05,
104
+ },
105
+ "portal": {
106
+ "semantic": 0.15, "content_answerability": 0.10, "entity": 0.25,
107
+ "trust": 0.15, "citation": 0.05, "retrieval": 0.20,
108
+ "structured_data": 0.05, "freshness": 0.05,
109
+ },
110
+ "article": {
111
+ "semantic": 0.20, "content_answerability": 0.25, "entity": 0.10,
112
+ "trust": 0.15, "citation": 0.15, "retrieval": 0.05,
113
+ "structured_data": 0.05, "freshness": 0.05,
114
+ },
115
+ "blog_post": {
116
+ "semantic": 0.20, "content_answerability": 0.25, "entity": 0.10,
117
+ "trust": 0.15, "citation": 0.15, "retrieval": 0.05,
118
+ "structured_data": 0.05, "freshness": 0.05,
119
+ },
120
+ "news": {
121
+ "semantic": 0.18, "content_answerability": 0.22, "entity": 0.12,
122
+ "trust": 0.15, "citation": 0.13, "retrieval": 0.05,
123
+ "structured_data": 0.05, "freshness": 0.10,
124
+ },
125
+ "documentation": {
126
+ "semantic": 0.15, "content_answerability": 0.30, "entity": 0.10,
127
+ "trust": 0.10, "citation": 0.10, "retrieval": 0.10,
128
+ "structured_data": 0.10, "freshness": 0.05,
129
+ },
130
+ "product": {
131
+ "semantic": 0.10, "content_answerability": 0.20, "entity": 0.15,
132
+ "trust": 0.10, "citation": 0.05, "retrieval": 0.10,
133
+ "structured_data": 0.25, "freshness": 0.05,
134
+ },
135
+ "product_category": {
136
+ "semantic": 0.10, "content_answerability": 0.15, "entity": 0.15,
137
+ "trust": 0.10, "citation": 0.05, "retrieval": 0.15,
138
+ "structured_data": 0.25, "freshness": 0.05,
139
+ },
140
+ "forum": {
141
+ "semantic": 0.15, "content_answerability": 0.20, "entity": 0.10,
142
+ "trust": 0.15, "citation": 0.15, "retrieval": 0.10,
143
+ "structured_data": 0.05, "freshness": 0.10,
144
+ },
145
+ "review": {
146
+ "semantic": 0.15, "content_answerability": 0.20, "entity": 0.10,
147
+ "trust": 0.20, "citation": 0.15, "retrieval": 0.05,
148
+ "structured_data": 0.05, "freshness": 0.10,
149
+ },
150
+ "comparison": {
151
+ "semantic": 0.18, "content_answerability": 0.22, "entity": 0.12,
152
+ "trust": 0.15, "citation": 0.13, "retrieval": 0.05,
153
+ "structured_data": 0.05, "freshness": 0.10,
154
+ },
155
+ "organization": {
156
+ "semantic": 0.15, "content_answerability": 0.10, "entity": 0.25,
157
+ "trust": 0.25, "citation": 0.05, "retrieval": 0.10,
158
+ "structured_data": 0.05, "freshness": 0.05,
159
+ },
160
+ "service_page": {
161
+ "semantic": 0.15, "content_answerability": 0.15, "entity": 0.20,
162
+ "trust": 0.20, "citation": 0.05, "retrieval": 0.10,
163
+ "structured_data": 0.10, "freshness": 0.05,
164
+ },
165
+ "landing_page": {
166
+ "semantic": 0.15, "content_answerability": 0.10, "entity": 0.20,
167
+ "trust": 0.20, "citation": 0.05, "retrieval": 0.15,
168
+ "structured_data": 0.10, "freshness": 0.05,
169
+ },
170
+ }
171
+
172
+
173
+ def get_weights_for_page_type(page_type):
174
+ profile = dict(DEFAULT_WEIGHTS)
175
+ profile.update(PAGE_TYPE_WEIGHT_OVERRIDES.get(page_type, {}))
176
+ total = sum(profile.values()) or 1.0
177
+ return {k: v / total for k, v in profile.items()}
178
+
179
+
180
+ # Per-page-type relevance map: which "trust/answerability" sub-signals are
181
+ # actually meaningful for this page type. Signals not listed are treated as
182
+ # not_applicable (excluded from scoring) rather than penalized when absent.
183
+ TYPES_WHERE_AUTHOR_RELEVANT = {
184
+ "article", "blog_post", "news", "review", "comparison", "documentation", "forum",
185
+ }
186
+ TYPES_WHERE_FRESHNESS_RELEVANT = {
187
+ "article", "blog_post", "news", "review", "comparison", "documentation", "forum", "product",
188
+ }
189
+ TYPES_WHERE_FAQ_RELEVANT = {
190
+ "article", "blog_post", "documentation", "product", "product_category", "service_page", "landing_page",
191
+ }
192
+ TYPES_WHERE_PRODUCT_SCHEMA_RELEVANT = {"product", "product_category"}
193
+ TYPES_WHERE_ARTICLE_SCHEMA_RELEVANT = {"article", "blog_post", "news"}
194
+
195
+ DATE_PATTERNS = [
196
+ re.compile(r'\b(19|20)\d{2}-\d{2}-\d{2}\b'),
197
+ re.compile(r'\b(January|February|March|April|May|June|July|August|September|October|November|December)\s+\d{1,2},?\s+(19|20)\d{2}\b', re.I),
198
+ re.compile(r'\b\d{1,2}\s+(January|February|March|April|May|June|July|August|September|October|November|December)\s+(19|20)\d{2}\b', re.I),
199
+ ]
200
+
201
+ FIRST_HAND_PATTERNS = re.compile(
202
+ r'\b(we tested|we found|our (research|study|testing|analysis|experiment)|in our experience|hands[- ]on|i tested|i used|we measured|we surveyed)\b',
203
+ re.I,
204
+ )
205
+
206
+ SOURCE_ATTRIBUTION_PATTERNS = re.compile(
207
+ r'\b(according to|source:|cited by|reported by|study by|research (from|by)|as reported)\b', re.I
208
+ )
209
+
210
+
211
+ def _words(text):
212
+ return re.findall(r"[A-Za-z']+", text or "")
213
+
214
+
215
+ def _pct(n, d):
216
+ return round((n / d) * 100, 1) if d else 0.0
217
+
218
+
219
+ def _clamp(v, lo=0, hi=100):
220
+ return max(lo, min(hi, v))
221
+
222
+
223
+ # ==============================
224
+ # PAGE TYPE CLASSIFICATION (deterministic)
225
+ # ==============================
226
+ def classify_page_type(seo_data, soup, text, word_count, schema_types, links):
227
+ """Classify the page using URL structure, schema, and content-structure
228
+ signals. Returns {"type": str, "confidence": float, "signals": [...]}.
229
+ No LLM call - this must be fast and run for every page.
230
+ """
231
+ url = seo_data.get("url", "")
232
+ path = urlparse(url).path.strip("/").lower()
233
+ lower_types = [t.lower() for t in schema_types]
234
+
235
+ heading_tags = soup.find_all(re.compile("^h[1-6]$"))
236
+ question_headings = sum(1 for h in heading_tags if h.get_text(strip=True).endswith("?"))
237
+ breadcrumb_present = bool(soup.find(attrs={"class": re.compile("breadcrumb", re.I)})) or any(
238
+ "breadcrumblist" in t for t in lower_types
239
+ )
240
+ has_price = bool(re.search(r'(\$|USD|EUR|£|₹)\s?\d', text or ""))
241
+ add_to_cart = bool(re.search(r'add to cart|buy now|add to bag|add to trolley', text or "", re.I))
242
+ nav_link_count = len(links)
243
+
244
+ votes = Counter()
245
+ signals = []
246
+
247
+ def vote(t, n, reason):
248
+ votes[t] += n
249
+ signals.append(f"{t}+{n}:{reason}")
250
+
251
+ # --- URL structure signals ---
252
+ if path == "":
253
+ vote("homepage", 3, "root path")
254
+ if re.search(r'\b(blog|article|post)\b', path):
255
+ vote("blog_post", 2, "url path")
256
+ if re.search(r'\bnews\b', path):
257
+ vote("news", 2, "url path")
258
+ if re.search(r'\b(docs?|documentation|guide|help|kb|support|wiki)\b', path):
259
+ vote("documentation", 2, "url path")
260
+ if re.search(r'\b(product|item|shop|store)\b', path):
261
+ vote("product", 2, "url path")
262
+ if re.search(r'\b(category|collection|catalog|categories)\b', path):
263
+ vote("product_category", 2, "url path")
264
+ if re.search(r'\b(forum|thread|topic|community|discussion)\b', path):
265
+ vote("forum", 2, "url path")
266
+ if re.search(r'\breview', path):
267
+ vote("review", 2, "url path")
268
+ if re.search(r'\b(vs|compare|comparison|alternatives)\b', path):
269
+ vote("comparison", 2, "url path")
270
+ if re.search(r'\b(about|company|who-we-are|team)\b', path):
271
+ vote("organization", 2, "url path")
272
+ if re.search(r'\b(pricing|services|solutions|features)\b', path):
273
+ vote("service_page", 1, "url path")
274
+ if re.search(r'\bsearch\b', path) or "q=" in urlparse(url).query:
275
+ vote("search_page", 2, "url/query")
276
+ if re.search(r'\b(directory|listings?)\b', path):
277
+ vote("directory", 1, "url path")
278
+ if re.search(r'\b(landing|lp)\b', path):
279
+ vote("landing_page", 1, "url path")
280
+
281
+ # --- schema.org signals ---
282
+ if "product" in lower_types:
283
+ vote("product", 3, "Product schema")
284
+ if any(t in lower_types for t in ("article", "newsarticle", "blogposting")):
285
+ vote("article" if "newsarticle" not in lower_types else "news", 3, "Article-family schema")
286
+ if "organization" in lower_types and path == "":
287
+ vote("organization", 1, "Organization schema on root")
288
+ if "faqpage" in lower_types:
289
+ vote("documentation", 1, "FAQPage schema")
290
+ if any(t in lower_types for t in ("itemlist", "collectionpage")):
291
+ vote("portal", 2, "ItemList/CollectionPage schema")
292
+ if "webpage" in lower_types and path == "":
293
+ vote("homepage", 1, "WebPage schema on root")
294
+
295
+ # --- structural/content signals ---
296
+ if nav_link_count >= 40 and word_count < 800:
297
+ vote("portal", 2, "high link density, low unique text")
298
+ if path == "" and nav_link_count >= 25:
299
+ vote("homepage", 2, "root path with heavy navigation")
300
+ if word_count >= 500 and heading_tags and not has_price:
301
+ vote("article", 1, "substantial prose content")
302
+ if has_price and add_to_cart:
303
+ vote("product", 3, "price + add-to-cart")
304
+ elif has_price:
305
+ vote("product", 1, "price present")
306
+ if question_headings >= 3:
307
+ vote("documentation", 1, "multiple question-style headings")
308
+ vote("forum", 1, "multiple question-style headings")
309
+ if breadcrumb_present and word_count > 300:
310
+ vote("article", 1, "breadcrumb + substantial content")
311
+
312
+ if not votes:
313
+ return {"type": "unknown", "confidence": 0.3, "signals": []}
314
+
315
+ page_type, top_votes = votes.most_common(1)[0]
316
+ total_votes = sum(votes.values())
317
+ confidence = round(min(0.98, 0.35 + (top_votes / max(1, total_votes)) * 0.6), 2)
318
+ return {"type": page_type, "confidence": confidence, "signals": signals}
319
+
320
+
321
+ # ==============================
322
+ # CATEGORY 1: TOPIC & SEMANTIC UNDERSTANDING (query-independent by default)
323
+ # ==============================
324
+ def _analyze_semantic(seo_data, soup, text, word_count, target_query=None):
325
+ title = (seo_data.get("title") or "").strip()
326
+ description = (seo_data.get("description") or "").strip()
327
+ h1s = [h.get_text(" ", strip=True) for h in soup.find_all("h1")]
328
+ h1_text = " ".join(h1s)
329
+
330
+ title_words = set(w.lower() for w in _words(title) if len(w) > 3)
331
+ h1_words = set(w.lower() for w in _words(h1_text) if len(w) > 3)
332
+ desc_words = set(w.lower() for w in _words(description) if len(w) > 3)
333
+
334
+ # topic_clarity: title exists, has an h1, and they share vocabulary.
335
+ # This does NOT require an external reference query.
336
+ topic_clarity = 0
337
+ if title:
338
+ topic_clarity += 40
339
+ if h1_text:
340
+ topic_clarity += 30
341
+ if title_words and h1_words:
342
+ overlap = len(title_words & h1_words) / max(1, len(title_words | h1_words))
343
+ topic_clarity += round(overlap * 30)
344
+ topic_clarity = _clamp(topic_clarity)
345
+
346
+ # topic_coherence: does title/description/h1 vocabulary agree with itself
347
+ # (internal consistency proxy, not "relevance" to any external query).
348
+ all_pairs = [p for p in [
349
+ (title_words, desc_words),
350
+ (title_words, h1_words),
351
+ (desc_words, h1_words),
352
+ ] if p[0] and p[1]]
353
+ if all_pairs:
354
+ sims = [len(a & b) / max(1, len(a | b)) for a, b in all_pairs]
355
+ topic_coherence = _clamp(round(sum(sims) / len(sims) * 100))
356
+ else:
357
+ topic_coherence = 0
358
+
359
+ # content_completeness: word count + heading coverage + list/table presence
360
+ heading_count = len(soup.find_all(re.compile("^h[1-6]$")))
361
+ completeness = 0
362
+ if word_count >= 1000:
363
+ completeness += 40
364
+ elif word_count >= 500:
365
+ completeness += 30
366
+ elif word_count >= 300:
367
+ completeness += 15
368
+ if heading_count >= 3:
369
+ completeness += 30
370
+ elif heading_count >= 1:
371
+ completeness += 15
372
+ if soup.find_all(["ul", "ol", "table"]):
373
+ completeness += 15
374
+ if description:
375
+ completeness += 15
376
+ content_completeness = _clamp(completeness)
377
+
378
+ # content_depth: vocabulary richness + paragraph count
379
+ paragraphs = [p.get_text(" ", strip=True) for p in soup.find_all("p")]
380
+ paragraphs = [p for p in paragraphs if p]
381
+ unique_words = set(w.lower() for w in _words(text))
382
+ richness = _pct(len(unique_words), max(1, word_count))
383
+ depth = 0
384
+ depth += min(40, round(richness))
385
+ depth += min(30, len(paragraphs) * 2)
386
+ depth += min(30, heading_count * 5)
387
+ content_depth = _clamp(depth)
388
+
389
+ result = {
390
+ "topic_clarity": topic_clarity,
391
+ "topic_coherence": topic_coherence,
392
+ "content_completeness": content_completeness,
393
+ "content_depth": content_depth,
394
+ "semantic_relevance": None,
395
+ "semantic_relevance_status": "requires_target_query",
396
+ "search_intent_match": None,
397
+ "search_intent_match_status": "requires_target_query",
398
+ }
399
+
400
+ # These two metrics genuinely require a reference query/keyword to mean
401
+ # anything. Without one they are reported as unknown, not guessed at.
402
+ if target_query:
403
+ query_words = set(w.lower() for w in _words(target_query) if len(w) > 2)
404
+ if query_words:
405
+ corpus_pairs = [
406
+ (query_words, title_words),
407
+ (query_words, h1_words),
408
+ (query_words, set(w.lower() for w in _words(text)[:400])),
409
+ ]
410
+ sims = [len(a & b) / max(1, len(a)) for a, b in corpus_pairs if a]
411
+ semantic_relevance = _clamp(round((sum(sims) / len(sims)) * 100)) if sims else 0
412
+ result["semantic_relevance"] = semantic_relevance
413
+ result["semantic_relevance_status"] = "measured"
414
+
415
+ intent_markers = ["how to", "what is", "why", "best", "guide", "review", "vs", "top ", "buy", "price"]
416
+ lowered_query = target_query.lower()
417
+ matched_intent = next((m for m in intent_markers if m in lowered_query), None)
418
+ overlap_in_content = len(query_words & set(w.lower() for w in _words(text))) / max(1, len(query_words))
419
+ search_intent_match = _clamp(round(overlap_in_content * 100))
420
+ if matched_intent in ("how to", "guide") and soup.find_all(["ol", "ul"]):
421
+ search_intent_match = _clamp(search_intent_match + 15)
422
+ result["search_intent_match"] = search_intent_match
423
+ result["search_intent_match_status"] = "measured"
424
+
425
+ return result
426
+
427
+
428
+ # ==============================
429
+ # CATEGORY 2: ENTITY UNDERSTANDING
430
+ # ==============================
431
+ def _analyze_entities(seo_data, soup, text, word_count, schema_types, page_type):
432
+ # Proper-noun style heuristic: capitalized word sequences not at sentence start.
433
+ # This works from visible text/headings/title/links - it does NOT require
434
+ # JSON-LD to detect entities. Schema (below) only boosts confidence.
435
+ proper_noun_seqs = re.findall(r'(?<!\. )(?<!^)\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+){0,3})\b', text or "")
436
+ title_seqs = re.findall(r'\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+){0,3})\b', seo_data.get("title") or "")
437
+ heading_seqs = []
438
+ for h in soup.find_all(re.compile("^h[1-6]$")):
439
+ heading_seqs.extend(re.findall(r'\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+){0,3})\b', h.get_text(" ", strip=True)))
440
+
441
+ entity_counter = Counter(s.strip() for s in proper_noun_seqs + title_seqs + heading_seqs if len(s.strip()) > 2)
442
+ entity_count = len(entity_counter)
443
+
444
+ if word_count < 20:
445
+ entity_clarity = None
446
+ entity_clarity_status = "insufficient_text"
447
+ else:
448
+ entity_clarity = _clamp(round(_pct(entity_count, max(1, word_count // 20))))
449
+ entity_clarity_status = "measured"
450
+
451
+ if entity_counter:
452
+ top_mentions = entity_counter.most_common(1)[0][1]
453
+ entity_consistency = _clamp(round(_pct(top_mentions, sum(entity_counter.values()))))
454
+ else:
455
+ entity_consistency = 0
456
+
457
+ lower_types = [t.lower() for t in schema_types]
458
+ organization_entity_present = bool(
459
+ {"organization", "corporation", "localbusiness"} & set(lower_types)
460
+ ) or bool(soup.find(attrs={"itemtype": re.compile("Organization", re.I)})) or bool(
461
+ soup.find("footer") and re.search(r'\b(Inc\.|LLC|Ltd\.|Corporation|Corp\.)\b', text or "")
462
+ )
463
+
464
+ author_entity_present = bool(
465
+ seo_data.get("metas", {}).get("author")
466
+ ) or bool(soup.find(attrs={"rel": "author"})) or bool(
467
+ soup.find(class_=re.compile("author|byline", re.I))
468
+ ) or "person" in lower_types
469
+
470
+ product_entity_present = "product" in lower_types or bool(
471
+ re.search(r'\b(SKU|model number|add to cart)\b', text or "", re.I)
472
+ )
473
+ place_entity_present = bool(re.search(r'\b\d{5}(-\d{4})?\b', text or "")) and bool(
474
+ re.search(r'\b(Street|Ave|Avenue|Road|Blvd|City|Country)\b', text or "", re.I)
475
+ )
476
+ brand_entity_present = organization_entity_present or bool(
477
+ re.search(r'\b(®|™)\b', text or "")
478
+ )
479
+
480
+ entity_types_present = [
481
+ t for t, present in [
482
+ ("organization", organization_entity_present),
483
+ ("person", author_entity_present),
484
+ ("product", product_entity_present),
485
+ ("place", place_entity_present),
486
+ ("brand", brand_entity_present),
487
+ ] if present
488
+ ]
489
+
490
+ return {
491
+ "entity_count": entity_count,
492
+ "entity_clarity": entity_clarity,
493
+ "entity_clarity_status": entity_clarity_status,
494
+ "entity_consistency": entity_consistency,
495
+ "entity_types": entity_types_present,
496
+ "organization_entity_present": organization_entity_present,
497
+ "author_entity_present": author_entity_present,
498
+ "product_entity_present": product_entity_present,
499
+ "place_entity_present": place_entity_present,
500
+ "brand_entity_present": brand_entity_present,
501
+ }
502
+
503
+
504
+ # ==============================
505
+ # CATEGORY 3: ANSWERABILITY
506
+ # ==============================
507
+ def _analyze_answerability(soup, text):
508
+ heading_tags = soup.find_all(re.compile("^h[1-6]$"))
509
+ question_headings = [h for h in heading_tags if h.get_text(strip=True).endswith("?")]
510
+ question_count = len(question_headings)
511
+
512
+ def _next_text_len(tag):
513
+ sib = tag.find_next_sibling()
514
+ hops = 0
515
+ while sib is not None and hops < 3:
516
+ content = sib.get_text(" ", strip=True)
517
+ if content:
518
+ return len(_words(content))
519
+ sib = sib.find_next_sibling()
520
+ hops += 1
521
+ return 0
522
+
523
+ answered = sum(1 for h in question_headings if _next_text_len(h) >= 15)
524
+ questions_answered = answered
525
+ answer_coverage = _pct(answered, question_count) if question_count else None
526
+
527
+ first_p = soup.find("p")
528
+ first_p_text = first_p.get_text(" ", strip=True) if first_p else ""
529
+ direct_answer_presence = bool(first_p_text) and 40 <= len(first_p_text) <= 400
530
+
531
+ definition_presence = bool(
532
+ re.search(r'\b\w+\s+(is|are|refers to|means)\s+(a|an|the)\b', text or "", re.I)
533
+ ) or bool(soup.find("dfn")) or bool(soup.find("dl"))
534
+
535
+ faq_heading = soup.find(
536
+ lambda t: t.name in ("h1", "h2", "h3") and re.search(r"faq|frequently asked", t.get_text(" ", strip=True), re.I)
537
+ )
538
+ faq_coverage = 100 if faq_heading else (_clamp(question_count * 20) if question_count else 0)
539
+
540
+ return {
541
+ "question_count": question_count,
542
+ "questions_answered": questions_answered,
543
+ "answer_coverage": answer_coverage,
544
+ "direct_answer_presence": direct_answer_presence,
545
+ "definition_presence": definition_presence,
546
+ "faq_coverage": faq_coverage,
547
+ }
548
+
549
+
550
+ # ==============================
551
+ # CATEGORY 4: INFORMATION QUALITY
552
+ # ==============================
553
+ def _analyze_information_quality(soup, text):
554
+ sentences = re.split(r'(?<=[.!?])\s+', text or "")
555
+ stat_pattern = re.compile(r'\b\d+([.,]\d+)?\s?(%|percent)?\b')
556
+ factual_sentences = [s for s in sentences if re.search(r'\d', s) and stat_pattern.search(s)]
557
+ factual_claims = len(factual_sentences)
558
+
559
+ claims_with_sources = sum(
560
+ 1 for s in factual_sentences if SOURCE_ATTRIBUTION_PATTERNS.search(s)
561
+ )
562
+ claims_with_sources_ratio = _pct(claims_with_sources, max(1, factual_claims)) if factual_claims else None
563
+
564
+ original_info_hits = len(FIRST_HAND_PATTERNS.findall(text or ""))
565
+ original_information = _clamp(min(100, original_info_hits * 25))
566
+ first_hand_experience = original_information
567
+
568
+ return {
569
+ "factual_claims": factual_claims,
570
+ "claims_with_sources": claims_with_sources,
571
+ "claims_with_sources_ratio": claims_with_sources_ratio,
572
+ "original_information": original_information,
573
+ "first_hand_experience": first_hand_experience,
574
+ }
575
+
576
+
577
+ # ==============================
578
+ # CATEGORY 5: TRUST / AUTHORITY
579
+ # ==============================
580
+ def _analyze_trust(seo_data, soup, text, links, page_type):
581
+ author_relevant = page_type in TYPES_WHERE_AUTHOR_RELEVANT
582
+
583
+ author_bio_hit = bool(re.search(r'\b(PhD|M\.?D\.?|certified|expert|years of experience|founder|CEO|specialist)\b', text or "", re.I))
584
+ if author_relevant:
585
+ author_expertise = 70 if author_bio_hit else 0
586
+ author_expertise_status = "measured"
587
+ else:
588
+ author_expertise = None
589
+ author_expertise_status = "not_applicable"
590
+ author_credentials = author_bio_hit
591
+
592
+ hrefs = [l.get("href", "") for l in links]
593
+ about_page_present = any(re.search(r'/about', h, re.I) for h in hrefs)
594
+ contact_page_present = any(re.search(r'/contact', h, re.I) for h in hrefs)
595
+ email_present = bool(re.search(r'[\w.+-]+@[\w-]+\.[\w.-]+', text or ""))
596
+ phone_present = bool(re.search(r'(\+?\d[\d\s().-]{7,}\d)', text or ""))
597
+ contact_information_present = contact_page_present or email_present or phone_present
598
+
599
+ privacy_present = any(re.search(r'privacy', h, re.I) for h in hrefs)
600
+ terms_present = any(re.search(r'terms', h, re.I) for h in hrefs)
601
+ https_present = str(seo_data.get("url", "")).startswith("https://")
602
+
603
+ trust_signal_flags = [about_page_present, contact_information_present, privacy_present, terms_present, https_present]
604
+ trust_signals = sum(trust_signal_flags)
605
+
606
+ organization_transparency = _clamp(_pct(trust_signals, len(trust_signal_flags)))
607
+
608
+ return {
609
+ "author_expertise": author_expertise,
610
+ "author_expertise_status": author_expertise_status,
611
+ "author_credentials": author_credentials if author_relevant else None,
612
+ "organization_transparency": organization_transparency,
613
+ "about_page_present": about_page_present,
614
+ "contact_information_present": contact_information_present,
615
+ "trust_signals": trust_signals,
616
+ }
617
+
618
+
619
+ # ==============================
620
+ # CATEGORY 6: STRUCTURED DATA (supporting signal, not dominant)
621
+ # ==============================
622
+ def _extract_schema_types(seo_data, soup):
623
+ schemas = seo_data.get("schemas", [])
624
+ schema_types = []
625
+ schema_valid = True
626
+
627
+ for schema in schemas:
628
+ try:
629
+ if isinstance(schema, dict):
630
+ if "@type" in schema:
631
+ t = schema["@type"]
632
+ schema_types.extend(t if isinstance(t, list) else [t])
633
+ if "@graph" in schema and isinstance(schema["@graph"], list):
634
+ for item in schema["@graph"]:
635
+ if isinstance(item, dict) and "@type" in item:
636
+ schema_types.append(item["@type"])
637
+ elif isinstance(schema, list):
638
+ for item in schema:
639
+ if isinstance(item, dict) and "@type" in item:
640
+ schema_types.append(item["@type"])
641
+ except Exception:
642
+ schema_valid = False
643
+
644
+ schema_types = list(dict.fromkeys(str(t) for t in schema_types))
645
+ return schema_types, schema_valid
646
+
647
+
648
+ def _analyze_structured_data(seo_data, schema_types, schema_valid, page_type):
649
+ lower_types = [t.lower() for t in schema_types]
650
+ schema_present = len(schema_types) > 0
651
+ organization_schema = any("organization" in t for t in lower_types)
652
+ article_schema = any("article" in t for t in lower_types)
653
+ product_schema = any("product" in t for t in lower_types)
654
+ faq_schema = any("faq" in t for t in lower_types)
655
+ breadcrumb_schema = any("breadcrumb" in t for t in lower_types)
656
+
657
+ # completeness: how many of the schema types *relevant to this page type*
658
+ # are present, rather than expecting every type on every page.
659
+ relevant_types = {"organization"}
660
+ if page_type in TYPES_WHERE_ARTICLE_SCHEMA_RELEVANT:
661
+ relevant_types.add("article")
662
+ if page_type in TYPES_WHERE_PRODUCT_SCHEMA_RELEVANT:
663
+ relevant_types.add("product")
664
+ if page_type in TYPES_WHERE_FAQ_RELEVANT:
665
+ relevant_types.add("faq")
666
+ present_map = {
667
+ "organization": organization_schema, "article": article_schema,
668
+ "product": product_schema, "faq": faq_schema,
669
+ }
670
+ relevant_present = sum(1 for t in relevant_types if present_map.get(t))
671
+ schema_completeness = _clamp(round(_pct(relevant_present, max(1, len(relevant_types)))))
672
+
673
+ org_schema_name = None
674
+ for schema in seo_data.get("schemas", []) or []:
675
+ if isinstance(schema, dict) and str(schema.get("@type", "")).lower() == "organization":
676
+ org_schema_name = schema.get("name")
677
+ break
678
+ domain = urlparse(seo_data.get("url", "")).netloc.replace("www.", "").split(".")[0]
679
+ schema_entity_alignment = bool(
680
+ org_schema_name and domain and domain.lower() in str(org_schema_name).lower()
681
+ )
682
+
683
+ return {
684
+ "schema_present": schema_present,
685
+ "schema_types": ", ".join(schema_types) if schema_types else "not_detected",
686
+ "schema_valid": schema_valid,
687
+ "schema_completeness": schema_completeness,
688
+ "schema_entity_alignment": schema_entity_alignment,
689
+ "organization_schema": organization_schema,
690
+ "article_schema": article_schema,
691
+ "product_schema": product_schema,
692
+ "faq_schema": faq_schema,
693
+ "breadcrumb_schema": breadcrumb_schema,
694
+ }
695
+
696
+
697
+ # ==============================
698
+ # CATEGORY 7: RETRIEVAL / CRAWLABILITY
699
+ # ==============================
700
+ def _fetch_raw_html_sync(url, timeout=6):
701
+ """Cheap plain-HTTP GET (no browser) used only to compare against the
702
+ Playwright-rendered HTML, so we can tell whether critical content is
703
+ server-rendered or injected by JavaScript. This is a single lightweight
704
+ request per page, not a second full crawl."""
705
+ try:
706
+ req = Request(url, headers={"User-Agent": "Mozilla/5.0 (compatible; AIVisibilityBot/1.0)"})
707
+ with urlopen(req, timeout=timeout) as resp:
708
+ raw = resp.read(2_000_000)
709
+ return raw.decode("utf-8", errors="ignore")
710
+ except Exception:
711
+ return None
712
+
713
+
714
+ async def _analyze_retrieval(seo_data, word_count, text, html):
715
+ robots = (seo_data.get("robots") or "").lower()
716
+ indexable = "noindex" not in robots
717
+ robots_allowed = indexable # proxy: only meta robots is checked, robots.txt is not fetched
718
+
719
+ canonical = seo_data.get("canonical") or ""
720
+ domain = urlparse(seo_data.get("url", "")).netloc
721
+ canonical_valid = bool(canonical) and (domain == "" or domain in canonical)
722
+
723
+ content_accessible = word_count > 0
724
+ renderable_content = word_count >= 50
725
+
726
+ url = seo_data.get("url", "")
727
+ raw_html = None
728
+ if url:
729
+ raw_html = await asyncio.get_event_loop().run_in_executor(None, _fetch_raw_html_sync, url)
730
+
731
+ content_rendering = {
732
+ "raw_word_count": None,
733
+ "rendered_word_count": word_count,
734
+ "rendering_dependency_ratio": None,
735
+ "critical_content_server_rendered": None,
736
+ "retrieval_status": "unknown",
737
+ }
738
+
739
+ if raw_html is not None:
740
+ raw_soup = BeautifulSoup(raw_html, "html.parser")
741
+ raw_text = raw_soup.get_text(separator=" ", strip=True)
742
+ raw_word_count = len(_words(raw_text))
743
+ content_rendering["raw_word_count"] = raw_word_count
744
+ ratio = _pct(raw_word_count, max(1, word_count))
745
+ content_rendering["rendering_dependency_ratio"] = ratio
746
+ content_rendering["critical_content_server_rendered"] = raw_word_count >= 50 and ratio >= 60
747
+ if not content_accessible:
748
+ content_rendering["retrieval_status"] = "poor"
749
+ elif content_rendering["critical_content_server_rendered"]:
750
+ content_rendering["retrieval_status"] = "good"
751
+ elif raw_word_count >= 50:
752
+ content_rendering["retrieval_status"] = "limited"
753
+ else:
754
+ content_rendering["retrieval_status"] = "js_dependent"
755
+ else:
756
+ # Couldn't do the plain-HTTP comparison (blocked, timeout, etc).
757
+ # We do NOT penalize the page for this - it's a measurement gap.
758
+ content_rendering["retrieval_status"] = "unknown"
759
+
760
+ return {
761
+ "indexable": indexable,
762
+ "robots_allowed": robots_allowed,
763
+ "canonical_valid": canonical_valid,
764
+ "content_accessible": content_accessible,
765
+ "renderable_content": renderable_content,
766
+ "content_rendering": content_rendering,
767
+ }
768
+
769
+
770
+ # ==============================
771
+ # CATEGORY 8: CONTENT STRUCTURE
772
+ # ==============================
773
+ def _analyze_content_structure(soup):
774
+ heading_tags = soup.find_all(re.compile("^h[1-6]$"))
775
+ levels = []
776
+ for h in heading_tags:
777
+ try:
778
+ levels.append(int(h.name[1]))
779
+ except Exception:
780
+ continue
781
+
782
+ heading_structure_score = 0
783
+ if 1 in levels:
784
+ heading_structure_score += 40
785
+ if 2 in levels:
786
+ heading_structure_score += 30
787
+ if levels == sorted(levels):
788
+ heading_structure_score += 30
789
+ heading_structure_score = _clamp(heading_structure_score)
790
+
791
+ paragraphs = [p.get_text(" ", strip=True) for p in soup.find_all("p")]
792
+ paragraphs = [p for p in paragraphs if p]
793
+ if paragraphs:
794
+ avg_len = sum(len(_words(p)) for p in paragraphs) / len(paragraphs)
795
+ paragraph_clarity = 100 if 15 <= avg_len <= 40 else _clamp(100 - abs(avg_len - 27) * 3)
796
+ else:
797
+ paragraph_clarity = 0
798
+
799
+ list_usage = len(soup.find_all(["ul", "ol"])) > 0
800
+ table_usage = len(soup.find_all("table")) > 0
801
+ definition_sections = bool(soup.find("dl")) or bool(soup.find("dfn"))
802
+ summary_present = bool(soup.find(
803
+ lambda t: t.name in ("h1", "h2", "h3") and re.search(r"summary|tl;?dr|key takeaways", t.get_text(" ", strip=True), re.I)
804
+ ))
805
+
806
+ return {
807
+ "heading_structure_score": heading_structure_score,
808
+ "paragraph_clarity": round(paragraph_clarity),
809
+ "list_usage": list_usage,
810
+ "table_usage": table_usage,
811
+ "definition_sections": definition_sections,
812
+ "summary_present": summary_present,
813
+ }
814
+
815
+
816
+ # ==============================
817
+ # CATEGORY 9: CITATION POTENTIAL
818
+ # (based on original data/quotes/attribution actually present in the
819
+ # content, not on raw external-link counting)
820
+ # ==============================
821
+ def _analyze_citation_potential(soup, text):
822
+ numbers = re.findall(r'\b\d+(?:[.,]\d+)?%?\b', text or "")
823
+ unique_data_points = len(set(numbers))
824
+ statistics_present = unique_data_points > 0
825
+
826
+ original_research = bool(FIRST_HAND_PATTERNS.search(text or ""))
827
+
828
+ blockquotes = soup.find_all("blockquote")
829
+ quoted_sentences = re.findall(r'"[^"]{20,200}"', text or "")
830
+ quotable_statements = len(blockquotes) + len(quoted_sentences)
831
+
832
+ source_attribution = len(SOURCE_ATTRIBUTION_PATTERNS.findall(text or ""))
833
+
834
+ score = 0
835
+ score += min(35, unique_data_points * 3)
836
+ score += 25 if original_research else 0
837
+ score += min(20, quotable_statements * 5)
838
+ score += min(20, source_attribution * 10)
839
+ citation_potential = _clamp(score)
840
+
841
+ return {
842
+ "unique_data_points": unique_data_points,
843
+ "statistics_present": statistics_present,
844
+ "original_research": original_research,
845
+ "quotable_statements": quotable_statements,
846
+ "source_attribution": source_attribution,
847
+ "citation_potential": citation_potential,
848
+ }
849
+
850
+
851
+ # ==============================
852
+ # CATEGORY 10: FRESHNESS (fresh / stale / very_stale / unknown - never
853
+ # "guessed outdated")
854
+ # ==============================
855
+ def _analyze_freshness(seo_data, soup, text, page_type):
856
+ metas = seo_data.get("metas", {}) or {}
857
+ last_updated = (
858
+ metas.get("article:modified_time")
859
+ or metas.get("article:published_time")
860
+ or metas.get("date")
861
+ )
862
+
863
+ if not last_updated:
864
+ time_tag = soup.find("time", attrs={"datetime": True})
865
+ if time_tag:
866
+ last_updated = time_tag.get("datetime")
867
+
868
+ if not last_updated:
869
+ for pattern in DATE_PATTERNS:
870
+ match = pattern.search(text or "")
871
+ if match:
872
+ last_updated = match.group(0)
873
+ break
874
+
875
+ date_visible = bool(last_updated)
876
+ content_age_days = None
877
+
878
+ if last_updated:
879
+ try:
880
+ from datetime import datetime, timezone
881
+ parsed = None
882
+ for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%d"):
883
+ try:
884
+ parsed = datetime.strptime(last_updated[:19].replace("Z", ""), fmt)
885
+ break
886
+ except Exception:
887
+ continue
888
+ if parsed:
889
+ parsed = parsed.replace(tzinfo=timezone.utc)
890
+ content_age_days = (datetime.now(timezone.utc) - parsed).days
891
+ except Exception:
892
+ content_age_days = None
893
+
894
+ if not date_visible:
895
+ freshness_status = "unknown"
896
+ freshness_reason = "No reliable publication/update date detected on the page."
897
+ elif isinstance(content_age_days, int):
898
+ # FIX: previously the 365< age <=730 and >730 branches both produced
899
+ # "stale", making the 730-day boundary dead code. Now a genuine
900
+ # "very_stale" tier exists for content older than 2 years.
901
+ if content_age_days <= 365:
902
+ freshness_status = "fresh"
903
+ elif content_age_days <= 730:
904
+ freshness_status = "stale"
905
+ else:
906
+ freshness_status = "very_stale"
907
+ freshness_reason = f"Date detected: content is {content_age_days} days old."
908
+ else:
909
+ freshness_status = "unknown"
910
+ freshness_reason = "A date-like string was found but could not be reliably parsed."
911
+
912
+ return {
913
+ "last_updated": last_updated or "not_detected",
914
+ "content_age_days": content_age_days,
915
+ "update_frequency": "not_detected", # requires historical crawl data, unavailable here
916
+ "date_visible": date_visible,
917
+ "freshness_status": freshness_status,
918
+ "freshness_reason": freshness_reason,
919
+ "freshness_relevant": page_type in TYPES_WHERE_FRESHNESS_RELEVANT,
920
+ }
921
+
922
+
923
+ # ==============================
924
+ # CATEGORY 11: BRAND / ENTITY CONSISTENCY
925
+ # ==============================
926
+ def _analyze_brand_consistency(seo_data, soup, text, schema_types):
927
+ domain = urlparse(seo_data.get("url", "")).netloc.replace("www.", "")
928
+ brand_guess = domain.split(".")[0] if domain else ""
929
+
930
+ title = (seo_data.get("title") or "")
931
+ footer = soup.find("footer")
932
+ footer_text = footer.get_text(" ", strip=True) if footer else ""
933
+
934
+ brand_in_title = bool(brand_guess) and brand_guess.lower() in title.lower()
935
+ brand_in_footer = bool(brand_guess) and brand_guess.lower() in footer_text.lower()
936
+ brand_in_body = bool(brand_guess) and brand_guess.lower() in (text or "").lower()
937
+ brand_name_consistency = _clamp(sum([brand_in_title, brand_in_footer, brand_in_body]) * 33)
938
+
939
+ org_schema_name = None
940
+ for schema in seo_data.get("schemas", []) or []:
941
+ if isinstance(schema, dict) and str(schema.get("@type", "")).lower() == "organization":
942
+ org_schema_name = schema.get("name")
943
+ break
944
+
945
+ company_information_consistency = _clamp(
946
+ 70 if (org_schema_name and brand_guess and brand_guess.lower() in str(org_schema_name).lower()) else (30 if brand_in_footer else 0)
947
+ )
948
+
949
+ author_meta = (seo_data.get("metas", {}) or {}).get("author", "")
950
+ byline = soup.find(class_=re.compile("author|byline", re.I))
951
+ byline_text = byline.get_text(" ", strip=True) if byline else ""
952
+ author_information_consistency = _clamp(
953
+ 70 if (author_meta and byline_text and author_meta.lower() in byline_text.lower())
954
+ else (40 if (author_meta or byline_text) else 0)
955
+ )
956
+
957
+ return {
958
+ "brand_name_consistency": brand_name_consistency,
959
+ "company_information_consistency": company_information_consistency,
960
+ "author_information_consistency": author_information_consistency,
961
+ }
962
+
963
+
964
+ # ==============================
965
+ # CATEGORY SCORE ROLLUPS (unknown values excluded, weight redistributed)
966
+ # ==============================
967
+ def _avg_known(*values):
968
+ """Average only the non-None values. Returns None if all are unknown -
969
+ the caller decides how to treat that (usually: exclude from rollup)."""
970
+ known = [v for v in values if v is not None]
971
+ if not known:
972
+ return None
973
+ return round(sum(known) / len(known))
974
+
975
+
976
+ def _rollup_scores(m, page_type):
977
+ weights = get_weights_for_page_type(page_type)
978
+
979
+ semantic_parts = [m["topic_clarity"], m["topic_coherence"], m["content_completeness"], m["content_depth"]]
980
+ if m.get("semantic_relevance") is not None:
981
+ semantic_parts.append(m["semantic_relevance"])
982
+ if m.get("search_intent_match") is not None:
983
+ semantic_parts.append(m["search_intent_match"])
984
+ semantic = _avg_known(*semantic_parts)
985
+
986
+ content_answerability = _avg_known(
987
+ m["content_completeness"],
988
+ m["answer_coverage"],
989
+ 100 if m["direct_answer_presence"] else 0,
990
+ 100 if m["definition_presence"] else 0,
991
+ m["faq_coverage"] if page_type in TYPES_WHERE_FAQ_RELEVANT or m["question_count"] > 0 else None,
992
+ _clamp(min(100, m["factual_claims"] * 10)),
993
+ )
994
+
995
+ entity_parts = [m["entity_consistency"]]
996
+ if m.get("entity_clarity") is not None:
997
+ entity_parts.append(m["entity_clarity"])
998
+ entity_parts.append(100 if m["organization_entity_present"] else 0)
999
+ if page_type in TYPES_WHERE_AUTHOR_RELEVANT:
1000
+ entity_parts.append(100 if m["author_entity_present"] else 0)
1001
+ entity = _avg_known(*entity_parts)
1002
+
1003
+ trust_parts = [
1004
+ m["organization_transparency"],
1005
+ 100 if m["about_page_present"] else 0,
1006
+ 100 if m["contact_information_present"] else 0,
1007
+ ]
1008
+ if m.get("author_expertise") is not None:
1009
+ trust_parts.append(m["author_expertise"])
1010
+ trust = _avg_known(*trust_parts)
1011
+
1012
+ citation = m["citation_potential"]
1013
+
1014
+ retrieval_parts = [
1015
+ 100 if m["indexable"] else 0,
1016
+ 100 if m["canonical_valid"] else 0,
1017
+ 100 if m["content_accessible"] else 0,
1018
+ ]
1019
+ rendering_status = m.get("content_rendering", {}).get("retrieval_status")
1020
+ if rendering_status == "good":
1021
+ retrieval_parts.append(100)
1022
+ elif rendering_status == "limited":
1023
+ retrieval_parts.append(60)
1024
+ elif rendering_status == "js_dependent":
1025
+ retrieval_parts.append(30)
1026
+ # "unknown" contributes nothing - not penalized, not rewarded.
1027
+ retrieval = _avg_known(*retrieval_parts)
1028
+
1029
+ schema_hits = sum([
1030
+ m["organization_schema"], m["article_schema"], m["product_schema"],
1031
+ m["faq_schema"], m["breadcrumb_schema"],
1032
+ ])
1033
+ structured_data = _clamp(
1034
+ m["schema_completeness"] * 0.6 + (20 if m["schema_valid"] and m["schema_present"] else 0) + schema_hits * 4
1035
+ )
1036
+
1037
+ if m["freshness_status"] == "fresh":
1038
+ if isinstance(m["content_age_days"], int):
1039
+ freshness = 100 if m["content_age_days"] <= 90 else 75
1040
+ else:
1041
+ freshness = 75
1042
+ elif m["freshness_status"] == "stale":
1043
+ freshness = 30
1044
+ elif m["freshness_status"] == "very_stale":
1045
+ freshness = 10
1046
+ else:
1047
+ freshness = None # unknown - excluded from weighted rollup entirely
1048
+
1049
+ components = {
1050
+ "semantic": semantic,
1051
+ "content_answerability": content_answerability,
1052
+ "entity": entity,
1053
+ "trust": trust,
1054
+ "citation": citation,
1055
+ "retrieval": retrieval,
1056
+ "structured_data": structured_data,
1057
+ "freshness": freshness,
1058
+ }
1059
+
1060
+ # Weighted average over KNOWN components only; unknown components'
1061
+ # weight is redistributed proportionally rather than counted as 0.
1062
+ known_weight = sum(weights[k] for k, v in components.items() if v is not None)
1063
+ if known_weight <= 0:
1064
+ ai_readiness_score = 0
1065
+ else:
1066
+ ai_readiness_score = round(
1067
+ sum(components[k] * weights[k] for k in components if components[k] is not None) / known_weight
1068
+ )
1069
+ ai_readiness_score = _clamp(ai_readiness_score)
1070
+
1071
+ return {
1072
+ "semantic_score": semantic,
1073
+ "content_answerability_score": content_answerability,
1074
+ "entity_score": entity,
1075
+ "trust_score": trust,
1076
+ "citation_potential_score": citation,
1077
+ "retrieval_score": retrieval,
1078
+ "structured_data_score": structured_data,
1079
+ "freshness_score": freshness,
1080
+ "ai_readiness_score": ai_readiness_score,
1081
+ # legacy alias kept for the existing frontend/API consumers
1082
+ "ai_visibility_score": ai_readiness_score,
1083
+ "weights_used": weights,
1084
+ }
1085
+
1086
+
1087
+ # ==============================
1088
+ # OPTIONAL LLM ENHANCEMENT (semantic-only, reuses existing OpenAI setup)
1089
+ # ==============================
1090
+ async def _llm_semantic_enhance(seo_data, text, deterministic, target_query=None):
1091
+ # FIX: previously used the removed openai<1.0 `openai.ChatCompletion.create`
1092
+ # API, which raises AttributeError on any openai-python>=1.0 install and
1093
+ # silently fell back to deterministic-only scoring every time. Now uses
1094
+ # the current client-based API (openai.OpenAI().chat.completions.create).
1095
+ client = _get_openai_client()
1096
+ if not OPENAI_AVAILABLE or client is None:
1097
+ return None
1098
+
1099
+ excerpt = (text or "")[:2000]
1100
+ query_line = f"TARGET QUERY: {target_query}\n" if target_query else ""
1101
+ prompt = f"""You are assessing AI-search readiness of a web page (not traditional SEO).
1102
+ Given the page title, meta description and a content excerpt, score each item 0-100 based on how easily
1103
+ an AI system could understand and summarize this page. Return ONLY valid JSON with these exact keys:
1104
+ topic_clarity, content_completeness, entity_clarity, citation_potential, original_information{"," if target_query else ""}
1105
+ {"semantic_relevance, search_intent_match" if target_query else ""}
1106
+
1107
+ {query_line}TITLE: {seo_data.get('title', '')}
1108
+ META DESCRIPTION: {seo_data.get('description', '')}
1109
+ CONTENT EXCERPT: {excerpt}
1110
+ """
1111
+ try:
1112
+ response = await asyncio.get_event_loop().run_in_executor(
1113
+ None,
1114
+ lambda: client.chat.completions.create(
1115
+ model="gpt-4o-mini",
1116
+ messages=[
1117
+ {"role": "system", "content": "You output only strict JSON, no prose, no markdown fences."},
1118
+ {"role": "user", "content": prompt},
1119
+ ],
1120
+ max_tokens=300,
1121
+ temperature=0.3,
1122
+ ),
1123
+ )
1124
+ raw = response.choices[0].message.content.strip()
1125
+ raw = re.sub(r"^```(json)?|```$", "", raw.strip(), flags=re.I).strip()
1126
+ data = json.loads(raw)
1127
+ keys = ["topic_clarity", "content_completeness", "entity_clarity", "citation_potential", "original_information"]
1128
+ if target_query:
1129
+ keys += ["semantic_relevance", "search_intent_match"]
1130
+ cleaned = {}
1131
+ for k in keys:
1132
+ v = data.get(k)
1133
+ if isinstance(v, (int, float)):
1134
+ cleaned[k] = _clamp(round(v))
1135
+ return cleaned or None
1136
+ except Exception as e:
1137
+ print(f" AI visibility LLM enhancement failed: {str(e)[:120]}")
1138
+ return None
1139
+
1140
+
1141
+ def _blend(deterministic_value, llm_value):
1142
+ if llm_value is None:
1143
+ return deterministic_value
1144
+ if deterministic_value is None:
1145
+ return llm_value
1146
+ return round((deterministic_value + llm_value) / 2)
1147
+
1148
+
1149
+ # ==============================
1150
+ # PER-PAGE ORCHESTRATION
1151
+ # ==============================
1152
+ async def analyze_page_ai_visibility(seo_data, domain, use_ai=False, target_query=None):
1153
+ """Compute the full ai_visibility metric set for one already-fetched page."""
1154
+ empty_scores = {
1155
+ "semantic_score": None, "content_answerability_score": None, "entity_score": None,
1156
+ "trust_score": None, "citation_potential_score": None, "retrieval_score": None,
1157
+ "structured_data_score": None, "freshness_score": None,
1158
+ "ai_readiness_score": 0, "ai_visibility_score": 0, "weights_used": {},
1159
+ }
1160
+ try:
1161
+ html = seo_data.get("html", "")
1162
+ soup = BeautifulSoup(html, "html.parser")
1163
+ text = soup.get_text(separator=" ", strip=True)
1164
+ word_count = len(_words(text))
1165
+ links = seo_data.get("links", [])
1166
+
1167
+ schema_types, schema_valid = _extract_schema_types(seo_data, soup)
1168
+ page_type_info = classify_page_type(seo_data, soup, text, word_count, schema_types, links)
1169
+ page_type = page_type_info["type"]
1170
+
1171
+ m = {}
1172
+ m.update(_analyze_semantic(seo_data, soup, text, word_count, target_query))
1173
+ m.update(_analyze_entities(seo_data, soup, text, word_count, schema_types, page_type))
1174
+ m.update(_analyze_answerability(soup, text))
1175
+ m.update(_analyze_information_quality(soup, text))
1176
+ m.update(_analyze_trust(seo_data, soup, text, links, page_type))
1177
+ m.update(_analyze_structured_data(seo_data, schema_types, schema_valid, page_type))
1178
+ m.update(await _analyze_retrieval(seo_data, word_count, text, html))
1179
+ m.update(_analyze_content_structure(soup))
1180
+ m.update(_analyze_citation_potential(soup, text))
1181
+ m.update(_analyze_freshness(seo_data, soup, text, page_type))
1182
+ m.update(_analyze_brand_consistency(seo_data, soup, text, schema_types))
1183
+
1184
+ if use_ai:
1185
+ llm_result = await _llm_semantic_enhance(seo_data, text, m, target_query)
1186
+ if llm_result:
1187
+ for k, v in llm_result.items():
1188
+ if k in m:
1189
+ m[k] = _blend(m[k], v)
1190
+
1191
+ scores = _rollup_scores(m, page_type)
1192
+
1193
+ # Build unknown/not-applicable metric lists for transparency.
1194
+ unknown_metrics = []
1195
+ if m.get("semantic_relevance_status") == "requires_target_query":
1196
+ unknown_metrics.append({"metric": "semantic_relevance", "reason": "No target keyword/query was supplied for this analysis."})
1197
+ if m.get("freshness_status") == "unknown":
1198
+ unknown_metrics.append({"metric": "freshness", "reason": m.get("freshness_reason", "No date detected.")})
1199
+ if m.get("author_expertise_status") == "not_applicable":
1200
+ unknown_metrics.append({"metric": "author_expertise", "reason": f"Not applicable for page type '{page_type}'."})
1201
+ if m.get("content_rendering", {}).get("retrieval_status") == "unknown":
1202
+ unknown_metrics.append({"metric": "retrieval_rendering", "reason": "Could not fetch raw HTML to compare against rendered content."})
1203
+
1204
+ page_ai_visibility = {
1205
+ "page_type": page_type,
1206
+ "page_type_confidence": page_type_info["confidence"],
1207
+ "topic_clarity": m["topic_clarity"],
1208
+ "semantic_relevance": m["semantic_relevance"],
1209
+ "content_completeness": m["content_completeness"],
1210
+ "entity_clarity": m["entity_clarity"],
1211
+ "answer_coverage": m["answer_coverage"],
1212
+ "factual_information": m["factual_claims"],
1213
+ "original_information": m["original_information"],
1214
+ "author_expertise": m["author_expertise"],
1215
+ "schema_quality": scores["structured_data_score"],
1216
+ "crawlability": scores["retrieval_score"],
1217
+ "content_structure": m["heading_structure_score"],
1218
+ "citation_potential": m["citation_potential"],
1219
+ "freshness_status": m["freshness_status"],
1220
+ "freshness": scores["freshness_score"],
1221
+ "ai_readiness_score": scores["ai_readiness_score"],
1222
+ "ai_visibility_score": scores["ai_visibility_score"],
1223
+ }
1224
+
1225
+ return {
1226
+ "url": seo_data.get("url", ""),
1227
+ "title": seo_data.get("title", ""),
1228
+ "page_type": page_type,
1229
+ "page_type_confidence": page_type_info["confidence"],
1230
+ "raw_metrics": m,
1231
+ "scores": scores,
1232
+ "unknown_metrics": unknown_metrics,
1233
+ "ai_visibility": page_ai_visibility,
1234
+ }
1235
+ except Exception as e:
1236
+ print(f"AI visibility analysis error for {seo_data.get('url', 'unknown')}: {e}")
1237
+ return {
1238
+ "url": seo_data.get("url", ""),
1239
+ "title": seo_data.get("title", ""),
1240
+ "page_type": "unknown",
1241
+ "page_type_confidence": 0,
1242
+ "raw_metrics": {},
1243
+ "scores": empty_scores,
1244
+ "unknown_metrics": [],
1245
+ "ai_visibility": {
1246
+ "page_type": "unknown", "page_type_confidence": 0,
1247
+ "topic_clarity": 0, "semantic_relevance": None, "content_completeness": 0,
1248
+ "entity_clarity": None, "answer_coverage": None, "factual_information": 0,
1249
+ "original_information": 0, "author_expertise": None, "schema_quality": 0,
1250
+ "crawlability": 0, "content_structure": 0, "citation_potential": 0,
1251
+ "freshness_status": "unknown", "freshness": None,
1252
+ "ai_readiness_score": 0, "ai_visibility_score": 0,
1253
+ },
1254
+ "error": str(e),
1255
+ }
1256
+
1257
+
1258
+ # ==============================
1259
+ # ISSUE / STRENGTH GENERATION (evidence-based, page-type-aware)
1260
+ # ==============================
1261
+ def _build_issues_and_strengths(page_results):
1262
+ issues = []
1263
+ strengths = []
1264
+ unknowns = []
1265
+
1266
+ for p in page_results:
1267
+ s = p["scores"]
1268
+ m = p["raw_metrics"]
1269
+ url = p["url"]
1270
+ page_type = p.get("page_type", "unknown")
1271
+ if not m:
1272
+ continue
1273
+
1274
+ # --- Answerability: only relevant where questions/FAQ genuinely matter ---
1275
+ if page_type in TYPES_WHERE_FAQ_RELEVANT or m.get("question_count", 0) > 0:
1276
+ ca_score = s.get("content_answerability_score")
1277
+ if ca_score is not None and ca_score < 50:
1278
+ issues.append({
1279
+ "title": "Missing clear answers to common questions",
1280
+ "severity": "high" if ca_score < 25 else "medium",
1281
+ "page": url, "metric": "content_answerability_score", "current_value": ca_score,
1282
+ "explanation": f"Content & answerability score is {ca_score}/100 for this {page_type} page - questions are not clearly answered, or no question-style headings/FAQ exist.",
1283
+ "recommended_fix": "Add direct, concise answers (40-300 chars) immediately after question-style headings, and consider an FAQ section.",
1284
+ })
1285
+ elif ca_score is not None and ca_score >= 70:
1286
+ strengths.append({"page": url, "title": "Strong answerability", "detail": f"Content & answerability score {ca_score}/100."})
1287
+
1288
+ # --- Entity ---
1289
+ if s.get("entity_score") is not None:
1290
+ if s["entity_score"] < 40:
1291
+ issues.append({
1292
+ "title": "Weak entity information",
1293
+ "severity": "medium", "page": url, "metric": "entity_score", "current_value": s["entity_score"],
1294
+ "explanation": f"Entity score is {s['entity_score']}/100 - the page doesn't clearly establish named entities (organizations, people, products) relevant to a '{page_type}' page.",
1295
+ "recommended_fix": "Mention your organization/brand and product names explicitly and consistently, and add Organization/Person schema.",
1296
+ })
1297
+ elif s["entity_score"] >= 70:
1298
+ strengths.append({"page": url, "title": "Strong entity clarity", "detail": f"Entity score {s['entity_score']}/100."})
1299
+
1300
+ # --- Trust / author (only where relevant to page type) ---
1301
+ if page_type in TYPES_WHERE_AUTHOR_RELEVANT:
1302
+ if not m.get("author_entity_present") or (m.get("author_expertise") in (0, None)):
1303
+ issues.append({
1304
+ "title": "No author expertise information detected",
1305
+ "severity": "medium", "page": url, "metric": "author_expertise", "current_value": m.get("author_expertise"),
1306
+ "explanation": f"No author byline or credentials were found on this {page_type} page, where authorship signals matter for trust.",
1307
+ "recommended_fix": "Add a visible author byline with credentials, or an author bio linking to their expertise.",
1308
+ })
1309
+
1310
+ # --- Citation potential ---
1311
+ if s.get("citation_potential_score") is not None and s["citation_potential_score"] < 40:
1312
+ issues.append({
1313
+ "title": "Low citation potential",
1314
+ "severity": "low", "page": url, "metric": "citation_potential_score", "current_value": s["citation_potential_score"],
1315
+ "explanation": f"Citation potential score is {s['citation_potential_score']}/100 - the page has few unique statistics, quotes, or attributed sources an AI system could cite.",
1316
+ "recommended_fix": "Add original statistics, data points, or quotable expert statements with clear sourcing.",
1317
+ })
1318
+ elif s.get("citation_potential_score", 0) >= 70:
1319
+ strengths.append({"page": url, "title": "Strong citation potential", "detail": f"Citation potential score {s['citation_potential_score']}/100."})
1320
+
1321
+ # --- Retrieval / JS dependency: only flagged when there's actual evidence content is JS-gated ---
1322
+ rendering = m.get("content_rendering", {})
1323
+ if rendering.get("retrieval_status") == "js_dependent":
1324
+ issues.append({
1325
+ "title": "Critical content is not present in server-rendered HTML",
1326
+ "severity": "medium", "page": url, "metric": "retrieval_rendering",
1327
+ "current_value": rendering.get("rendering_dependency_ratio"),
1328
+ "explanation": f"Raw (non-JS) fetch returned only {rendering.get('raw_word_count', 0)} words vs {rendering.get('rendered_word_count', 0)} rendered - most content is injected by JavaScript, which some AI crawlers do not execute.",
1329
+ "recommended_fix": "Ensure key content is present in server-rendered HTML (SSR) or a no-JS fallback.",
1330
+ })
1331
+ elif rendering.get("retrieval_status") == "good":
1332
+ strengths.append({"page": url, "title": "Content is server-rendered", "detail": "Critical text content is present without executing JavaScript."})
1333
+
1334
+ # --- Content structure ---
1335
+ if m.get("heading_structure_score", 0) < 50:
1336
+ issues.append({
1337
+ "title": "Poor content structure",
1338
+ "severity": "low", "page": url, "metric": "heading_structure_score", "current_value": m.get("heading_structure_score", 0),
1339
+ "explanation": f"Heading structure score is {m.get('heading_structure_score', 0)}/100.",
1340
+ "recommended_fix": "Use a single H1 followed by a logical H2/H3 hierarchy so AI systems can segment the content.",
1341
+ })
1342
+
1343
+ # --- Structured data: only an issue when a relevant schema type is actually missing ---
1344
+ relevant_missing = []
1345
+ if page_type in TYPES_WHERE_ARTICLE_SCHEMA_RELEVANT and not m.get("article_schema"):
1346
+ relevant_missing.append("Article")
1347
+ if page_type in TYPES_WHERE_PRODUCT_SCHEMA_RELEVANT and not m.get("product_schema"):
1348
+ relevant_missing.append("Product")
1349
+ if page_type in TYPES_WHERE_FAQ_RELEVANT and m.get("question_count", 0) >= 2 and not m.get("faq_schema"):
1350
+ relevant_missing.append("FAQPage")
1351
+ if relevant_missing:
1352
+ issues.append({
1353
+ "title": "Missing structured data",
1354
+ "severity": "medium", "page": url, "metric": "schema_present", "current_value": m.get("schema_types"),
1355
+ "explanation": f"No {'/'.join(relevant_missing)} schema was detected, though it is relevant for a '{page_type}' page.",
1356
+ "recommended_fix": f"Add {'/'.join(relevant_missing)} JSON-LD structured data.",
1357
+ })
1358
+
1359
+ # --- Freshness: unknown is reported separately, never as "outdated" ---
1360
+ if m.get("freshness_status") == "unknown" and page_type in TYPES_WHERE_FRESHNESS_RELEVANT:
1361
+ unknowns.append({"page": url, "metric": "freshness", "reason": "No publish/update date could be detected on this page, where freshness is typically relevant."})
1362
+ elif m.get("freshness_status") in ("stale", "very_stale"):
1363
+ issues.append({
1364
+ "title": "Content appears stale",
1365
+ "severity": "low" if m.get("freshness_status") == "stale" else "medium",
1366
+ "page": url, "metric": "freshness_status", "current_value": m.get("content_age_days"),
1367
+ "explanation": f"A reliable date was detected and the content is {m.get('content_age_days')} days old.",
1368
+ "recommended_fix": "Review and update the content, then refresh the visible date / article:modified_time meta tag.",
1369
+ })
1370
+
1371
+ for u in p.get("unknown_metrics", []):
1372
+ unknowns.append({"page": url, **u})
1373
+
1374
+ severity_order = {"critical": 0, "high": 1, "medium": 2, "low": 3}
1375
+ issues.sort(key=lambda x: severity_order.get(x["severity"], 4))
1376
+ return issues[:30], strengths[:20], unknowns[:30]
1377
+
1378
+
1379
+ # ==============================
1380
+ # TOP-LEVEL ENTRYPOINT
1381
+ # ==============================
1382
+ async def run_ai_visibility_analysis(base_url, max_pages=5, max_concurrent=1, use_ai=False, target_query=None):
1383
+ """
1384
+ Fetches pages (once) and computes AI-readiness metrics for each.
1385
+
1386
+ Returns a dict with an `ai_readiness_score` (transparent 0-100 proxy
1387
+ score for how well pages are prepared to be understood/cited by AI
1388
+ systems) plus per-page results, page-type classification, issues,
1389
+ strengths, and unknown metrics. `actual_ai_visibility` is always
1390
+ "not_measured" - this crawler has no access to real AI-query/citation
1391
+ data, so it never fabricates one.
1392
+ """
1393
+ if not base_url:
1394
+ raise ValueError("base_url is required")
1395
+
1396
+ domain = urlparse(base_url).netloc
1397
+
1398
+ urls = await discover_urls_parallel(base_url, max_pages)
1399
+ if not urls:
1400
+ urls = [base_url]
1401
+
1402
+ playwright_data = await fetch_all_pages_parallel(urls, max_concurrent)
1403
+ if not playwright_data:
1404
+ return {
1405
+ "status": "error",
1406
+ "message": "Failed to fetch any pages. Site may be blocking bots or require authentication.",
1407
+ }
1408
+
1409
+ page_results = []
1410
+ for seo_data in playwright_data:
1411
+ result = await analyze_page_ai_visibility(seo_data, domain, use_ai=use_ai, target_query=target_query)
1412
+ page_results.append(result)
1413
+
1414
+ valid_scores = [p["scores"]["ai_readiness_score"] for p in page_results if p.get("raw_metrics")]
1415
+ overall_score = round(sum(valid_scores) / len(valid_scores)) if valid_scores else 0
1416
+
1417
+ def _avg(key):
1418
+ vals = [p["scores"][key] for p in page_results if p.get("raw_metrics") and p["scores"].get(key) is not None]
1419
+ return round(sum(vals) / len(vals)) if vals else None
1420
+
1421
+ category_averages = {
1422
+ "semantic_score": _avg("semantic_score"),
1423
+ "content_answerability_score": _avg("content_answerability_score"),
1424
+ "entity_score": _avg("entity_score"),
1425
+ "trust_score": _avg("trust_score"),
1426
+ "citation_potential_score": _avg("citation_potential_score"),
1427
+ "retrieval_score": _avg("retrieval_score"),
1428
+ "structured_data_score": _avg("structured_data_score"),
1429
+ "freshness_score": _avg("freshness_score"),
1430
+ }
1431
+
1432
+ page_type_breakdown = dict(Counter(p.get("page_type", "unknown") for p in page_results))
1433
+
1434
+ issues, strengths, unknowns = _build_issues_and_strengths(page_results)
1435
+
1436
+ pages_summary = [
1437
+ {
1438
+ "url": p["url"],
1439
+ "title": p["title"],
1440
+ **p["ai_visibility"],
1441
+ }
1442
+ for p in page_results
1443
+ ]
1444
+
1445
+ return {
1446
+ "status": "success",
1447
+ "url": base_url,
1448
+ "pages_analyzed": len(page_results),
1449
+ "page_type_breakdown": page_type_breakdown,
1450
+ "target_query": target_query,
1451
+ "ai_readiness_score": overall_score,
1452
+ # legacy alias for existing frontend/API consumers
1453
+ "ai_visibility_score": overall_score,
1454
+ "actual_ai_visibility": {
1455
+ "status": "not_measured",
1456
+ "reason": "Real AI-query citation/mention data is not available to this crawler. This score reflects readiness proxies only, not observed visibility.",
1457
+ },
1458
+ "category_scores": category_averages,
1459
+ "issues": issues,
1460
+ "strengths": strengths,
1461
+ "unknown_metrics": unknowns,
1462
+ "results_preview": pages_summary,
1463
+ }
app.py ADDED
@@ -0,0 +1,1479 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI, HTTPException
2
+ from fastapi.responses import FileResponse, HTMLResponse
3
+ from fastapi.middleware.cors import CORSMiddleware
4
+ import os
5
+ import asyncio
6
+ import uuid
7
+ import csv
8
+ from datetime import datetime
9
+
10
+ # Import your SEO analyzer functions
11
+ from seo_analyzer import run_seo_analysis_fastapi
12
+ from ai_visibility import run_ai_visibility_analysis
13
+
14
+ app = FastAPI(
15
+ title="SEO Analysis API",
16
+ version="2.0",
17
+ description="Advanced SEO analysis with AI-powered suggestions"
18
+ )
19
+
20
+ app.add_middleware(
21
+ CORSMiddleware,
22
+ allow_origins=["*"],
23
+ allow_credentials=True,
24
+ allow_methods=["*"],
25
+ allow_headers=["*"],
26
+ )
27
+
28
+ # Simple HTML UI - No static files needed
29
+ HTML_TEMPLATE = """
30
+ <!DOCTYPE html>
31
+ <html>
32
+ <head>
33
+ <title>SEO Analysis Tool</title>
34
+ <style>
35
+ * { margin: 0; padding: 0; box-sizing: border-box; }
36
+ body { font-family: Arial, sans-serif; background: #f5f5f5; padding: 20px; }
37
+ .container { max-width: 1200px; margin: 0 auto; background: white; padding: 30px; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
38
+ h1 { color: #333; margin-bottom: 20px; text-align: center; }
39
+ .form-group { margin-bottom: 20px; }
40
+ label { display: block; margin-bottom: 5px; font-weight: bold; color: #555; }
41
+ input[type="text"], input[type="number"] { width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 5px; font-size: 16px; }
42
+ .checkbox-group { display: flex; align-items: center; gap: 10px; }
43
+ input[type="checkbox"] { width: 20px; height: 20px; }
44
+ button { background: #007bff; color: white; border: none; padding: 12px 30px; border-radius: 5px; cursor: pointer; font-size: 16px; margin-right: 10px; }
45
+ button:hover { background: #0056b3; }
46
+ button:disabled { background: #6c757d; cursor: not-allowed; }
47
+ .ai-visibility-btn { background: #6f42c1; }
48
+ .ai-visibility-btn:hover { background: #5a32a3; }
49
+ .loading { display: none; color: #007bff; margin: 20px 0; }
50
+ .results { margin-top: 30px; display: none; }
51
+ .ai-score-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 10px; margin: 15px 0; }
52
+ .ai-score-tile { background: #f3f0fa; border-radius: 6px; padding: 12px; text-align: center; }
53
+ .ai-score-tile .val { font-size: 24px; font-weight: bold; color: #6f42c1; }
54
+ .ai-score-tile .lbl { font-size: 12px; color: #555; margin-top: 4px; }
55
+ .ai-issue { background: white; border-left: 4px solid #6f42c1; margin: 10px 0; padding: 10px; border-radius: 5px; }
56
+ .score { font-size: 48px; font-weight: bold; text-align: center; margin: 20px 0; }
57
+ .score.good { color: #28a745; }
58
+ .score.average { color: #ffc107; }
59
+ .score.poor { color: #dc3545; }
60
+ .section { margin: 20px 0; padding: 20px; border-radius: 5px; }
61
+ .strengths { background: #d4edda; border-left: 4px solid #28a745; }
62
+ .faults { background: #f8d7da; border-left: 4px solid #dc3545; }
63
+ .suggestions { background: #fff3cd; border-left: 4px solid #ffc107; }
64
+ .suggestion-item { margin: 10px 0; padding: 10px; background: white; border-radius: 5px; }
65
+ .error { background: #f8d7da; color: #721c24; padding: 15px; border-radius: 5px; margin: 20px 0; }
66
+ .download-btn { background: #28a745; color: white; text-decoration: none; padding: 10px 20px; border-radius: 5px; display: inline-block; }
67
+ .download-btn:hover { background: #1e7e34; }
68
+ .preview { background: #e9ecef; padding: 15px; border-radius: 5px; margin: 10px 0; }
69
+ .api-test { background: #d1ecf1; padding: 15px; border-radius: 5px; margin: 10px 0; }
70
+ </style>
71
+ </head>
72
+ <body>
73
+ <div class="container">
74
+ <h1>🚀 SEO Analysis Tool</h1>
75
+
76
+ <div class="form-group">
77
+ <label for="url">Website URL:</label>
78
+ <input type="text" id="url" placeholder="https://example.com" value="https://example.com">
79
+ </div>
80
+
81
+ <div class="form-group">
82
+ <label for="maxPages">Max Pages to Analyze:</label>
83
+ <input type="number" id="maxPages" value="3" min="1" max="20">
84
+ </div>
85
+
86
+ <div class="form-group">
87
+ <label for="maxConcurrent">Max Concurrent Browsers:</label>
88
+ <input type="number" id="maxConcurrent" value="1" min="1" max="5">
89
+ </div>
90
+
91
+ <div class="form-group checkbox-group">
92
+ <input type="checkbox" id="useAI" checked>
93
+ <label for="useAI">Enable AI Suggestions</label>
94
+ </div>
95
+
96
+ <button onclick="analyzeSEO()">Analyze SEO</button>
97
+ <button onclick="analyzeAIVisibility()" class="ai-visibility-btn"> AI Visibility Analysis</button>
98
+ <button onclick="testAPI()" style="background: #6c757d;">Test API</button>
99
+
100
+ <div id="loading" class="loading">
101
+ 🔍 Analyzing website... This may take a few minutes.
102
+ </div>
103
+ <div id="aiLoading" class="loading">
104
+ Running AI Visibility analysis... This may take a few minutes.
105
+ </div>
106
+
107
+ <div id="results" class="results"></div>
108
+ <div id="aiResults" class="results"></div>
109
+ </div>
110
+ <script>
111
+ async function analyzeAIVisibility() {
112
+ const url = document.getElementById('url').value;
113
+ const maxPages = document.getElementById('maxPages').value;
114
+ const maxConcurrent = document.getElementById('maxConcurrent').value;
115
+ const useAI = document.getElementById('useAI').checked;
116
+
117
+ if (!url) {
118
+ alert('Please enter a website URL');
119
+ return;
120
+ }
121
+
122
+ const loading = document.getElementById('aiLoading');
123
+ const results = document.getElementById('aiResults');
124
+
125
+ loading.style.display = 'block';
126
+ results.style.display = 'none';
127
+ results.innerHTML = '';
128
+
129
+ try {
130
+ const apiUrl = `/analyze-ai-visibility?url=${encodeURIComponent(url)}&max_pages=${maxPages}&use_ai=${useAI}&max_concurrent=${maxConcurrent}`;
131
+ const response = await fetch(apiUrl);
132
+ const data = await response.json();
133
+ displayAIVisibilityResults(data);
134
+ } catch (error) {
135
+ results.innerHTML = `<div class="error">Error: ${error.message}</div>`;
136
+ results.style.display = 'block';
137
+ } finally {
138
+ loading.style.display = 'none';
139
+ }
140
+ }
141
+
142
+ function displayAIVisibilityResults(data) {
143
+ const results = document.getElementById('aiResults');
144
+ let html = '';
145
+
146
+ if (data.status === 'error') {
147
+ html = `<div class="error">${data.message}</div>`;
148
+ results.innerHTML = html;
149
+ results.style.display = 'block';
150
+ return;
151
+ }
152
+
153
+ const fmt = (v) => (v === null || v === undefined) ? 'Unknown' : v;
154
+
155
+ let scoreClass = 'poor';
156
+ if (data.ai_readiness_score >= 70) scoreClass = 'good';
157
+ else if (data.ai_readiness_score >= 50) scoreClass = 'average';
158
+
159
+ html += `
160
+ <div class="score ${scoreClass}">${data.ai_readiness_score}/100</div>
161
+ <p style="text-align: center; margin-bottom: 4px;">
162
+ AI Readiness across <strong>${data.pages_analyzed}</strong> pages from <strong>${data.url}</strong>
163
+ </p>
164
+ <p style="text-align: center; color: #888; font-size: 0.85em; margin-bottom: 20px;">
165
+ Actual AI Visibility (real citation/mention data): <em>${data.actual_ai_visibility ? data.actual_ai_visibility.status : 'not_measured'}</em> - this score reflects readiness proxies, not observed AI citations.
166
+ </p>
167
+ `;
168
+
169
+ if (data.page_type_breakdown) {
170
+ const types = Object.entries(data.page_type_breakdown).map(([t, n]) => `${t} (${n})`).join(', ');
171
+ html += `<p style="text-align:center; color:#555; margin-bottom: 15px;"><strong>Page types detected:</strong> ${types}</p>`;
172
+ }
173
+
174
+ if (data.category_scores) {
175
+ const labels = {
176
+ semantic_score: 'Semantic',
177
+ content_answerability_score: 'Content & Answerability',
178
+ entity_score: 'Entity',
179
+ trust_score: 'Trust',
180
+ structured_data_score: 'Structured Data',
181
+ retrieval_score: 'Retrieval',
182
+ citation_potential_score: 'Citation Potential',
183
+ freshness_score: 'Freshness'
184
+ };
185
+ html += `<div class="ai-score-grid">`;
186
+ for (const key in labels) {
187
+ const val = fmt(data.category_scores[key]);
188
+ html += `<div class="ai-score-tile"><div class="val">${val}</div><div class="lbl">${labels[key]}</div></div>`;
189
+ }
190
+ html += `</div>`;
191
+ }
192
+
193
+ if (data.strengths && data.strengths.length > 0) {
194
+ html += `<div class="section strengths"><h3>✅ Strengths (${data.strengths.length})</h3>`;
195
+ data.strengths.forEach(st => {
196
+ html += `<div class="ai-issue" style="border-left-color:#28a745;"><strong>${st.title}</strong><br><span style="color:#666;">Page: ${st.page}</span><br><span>${st.detail || ''}</span></div>`;
197
+ });
198
+ html += `</div>`;
199
+ }
200
+
201
+ if (data.issues && data.issues.length > 0) {
202
+ html += `<div class="section suggestions"><h3>⚠️ AI Visibility Issues (${data.issues.length})</h3>`;
203
+ data.issues.forEach(issue => {
204
+ html += `
205
+ <div class="ai-issue">
206
+ <strong>${issue.title}</strong> <span style="color: #6f42c1; font-size: 0.9em;">(Severity: ${issue.severity})</span><br>
207
+ <span style="color: #666;">Page: ${issue.page}</span><br>
208
+ <span>${issue.explanation}</span><br>
209
+ <span style="color: #666; font-size: 0.9em;">💡 Fix: ${issue.recommended_fix}</span>
210
+ </div>
211
+ `;
212
+ });
213
+ html += `</div>`;
214
+ }
215
+
216
+ if (data.unknown_metrics && data.unknown_metrics.length > 0) {
217
+ html += `<div class="section" style="background:#e9ecef;"><h3>❔ Unknown (not enough evidence - not the same as poor)</h3>`;
218
+ data.unknown_metrics.forEach(u => {
219
+ html += `<div class="ai-issue" style="border-left-color:#6c757d;"><strong>${u.metric}</strong><br><span style="color:#666;">Page: ${u.page}</span><br><span>${u.reason}</span></div>`;
220
+ });
221
+ html += `</div>`;
222
+ }
223
+
224
+ if (data.results_preview && data.results_preview.length > 0) {
225
+ html += `<div class="section"><h3>📄 Per-Page AI Visibility (${data.results_preview.length})</h3>`;
226
+ data.results_preview.forEach(page => {
227
+ html += `
228
+ <div class="preview">
229
+ <p><strong>URL:</strong> ${page.url}</p>
230
+ <p><strong>Page Type:</strong> ${page.page_type} (${Math.round((page.page_type_confidence || 0) * 100)}% confidence)</p>
231
+ <p><strong>AI Readiness Score:</strong> ${page.ai_readiness_score}/100</p>
232
+ <p><strong>Topic Clarity:</strong> ${page.topic_clarity}</p>
233
+ <p><strong>Semantic Relevance:</strong> ${fmt(page.semantic_relevance)}</p>
234
+ <p><strong>Content Completeness:</strong> ${page.content_completeness}</p>
235
+ <p><strong>Entity Clarity:</strong> ${fmt(page.entity_clarity)}</p>
236
+ <p><strong>Answer Coverage:</strong> ${fmt(page.answer_coverage)}</p>
237
+ <p><strong>Schema Quality:</strong> ${page.schema_quality}</p>
238
+ <p><strong>Crawlability:</strong> ${page.crawlability}</p>
239
+ <p><strong>Content Structure:</strong> ${page.content_structure}</p>
240
+ <p><strong>Citation Potential:</strong> ${page.citation_potential}</p>
241
+ <p><strong>Freshness:</strong> ${page.freshness_status} ${fmt(page.freshness)}</p>
242
+ </div>
243
+ `;
244
+ });
245
+ html += `</div>`;
246
+ }
247
+
248
+ results.innerHTML = html;
249
+ results.style.display = 'block';
250
+ }
251
+
252
+ async function analyzeSEO() {
253
+ const url = document.getElementById('url').value;
254
+ const maxPages = document.getElementById('maxPages').value;
255
+ const maxConcurrent = document.getElementById('maxConcurrent').value;
256
+ const useAI = document.getElementById('useAI').checked;
257
+
258
+ if (!url) {
259
+ alert('Please enter a website URL');
260
+ return;
261
+ }
262
+
263
+ const loading = document.getElementById('loading');
264
+ const results = document.getElementById('results');
265
+
266
+ loading.style.display = 'block';
267
+ results.style.display = 'none';
268
+ results.innerHTML = '';
269
+
270
+ try {
271
+ const apiUrl = `/analyze?url=${encodeURIComponent(url)}&max_pages=${maxPages}&use_ai=${useAI}&max_concurrent=${maxConcurrent}`;
272
+ console.log('Calling API:', apiUrl);
273
+
274
+ const response = await fetch(apiUrl);
275
+ const data = await response.json();
276
+
277
+ displayResults(data);
278
+ } catch (error) {
279
+ results.innerHTML = `<div class="error">Error: ${error.message}</div>`;
280
+ results.style.display = 'block';
281
+ } finally {
282
+ loading.style.display = 'none';
283
+ }
284
+ }
285
+
286
+ async function testAPI() {
287
+ const loading = document.getElementById('loading');
288
+ const results = document.getElementById('results');
289
+
290
+ loading.style.display = 'block';
291
+ results.style.display = 'none';
292
+
293
+ try {
294
+ const response = await fetch('/health');
295
+ const data = await response.json();
296
+
297
+ results.innerHTML = `
298
+ <div class="api-test">
299
+ <h3>✅ API Health Check</h3>
300
+ <p><strong>Status:</strong> ${data.status}</p>
301
+ <p><strong>Message:</strong> ${data.message}</p>
302
+ <p><strong>Timestamp:</strong> ${data.timestamp}</p>
303
+ </div>
304
+ `;
305
+ results.style.display = 'block';
306
+ } catch (error) {
307
+ results.innerHTML = `<div class="error">API Test Failed: ${error.message}</div>`;
308
+ results.style.display = 'block';
309
+ } finally {
310
+ loading.style.display = 'none';
311
+ }
312
+ }
313
+
314
+ function displayResults(data) {
315
+ const results = document.getElementById('results');
316
+ let html = '';
317
+
318
+ if (data.status === 'error') {
319
+ html = `<div class="error">${data.message}</div>`;
320
+ } else {
321
+ // Score display
322
+ let scoreClass = 'poor';
323
+ if (data.overall_score >= 70) scoreClass = 'good';
324
+ else if (data.overall_score >= 50) scoreClass = 'average';
325
+
326
+ html += `
327
+ <div class="score ${scoreClass}">${data.overall_score}/100</div>
328
+ <p style="text-align: center; margin-bottom: 20px;">
329
+ Analyzed <strong>${data.pages_analyzed}</strong> pages from <strong>${data.url}</strong>
330
+ </p>
331
+ `;
332
+
333
+ // Download button
334
+ if (data.csv_download) {
335
+ html += `
336
+ <div style="text-align: center; margin: 20px 0;">
337
+ <a href="${data.csv_download}" class="download-btn">
338
+ 📊 Download Full CSV Report
339
+ </a>
340
+ </div>
341
+ `;
342
+ }
343
+
344
+ // Strengths
345
+ if (data.strengths && data.strengths.length > 0) {
346
+ html += `<div class="section strengths"><h3>✅ Strengths (${data.strengths.length})</h3>`;
347
+ data.strengths.forEach(strength => {
348
+ html += `<div class="suggestion-item"><strong>${strength.title}:</strong> ${strength.detail} <span style="color: #666; font-size: 0.9em;">(Impact: ${strength.impact})</span></div>`;
349
+ });
350
+ html += `</div>`;
351
+ }
352
+
353
+ // Faults
354
+ if (data.faults && data.faults.length > 0) {
355
+ html += `<div class="section faults"><h3>❌ Issues Found (${data.faults.length})</h3>`;
356
+ data.faults.forEach(fault => {
357
+ html += `<div class="suggestion-item"><strong>${fault.title}:</strong> ${fault.detail} <span style="color: #dc3545; font-weight: bold;">(Severity: ${fault.severity})</span>${fault.fix ? `<br><span style="color: #666; font-size: 0.9em;">💡 Fix: ${fault.fix}</span>` : ''}</div>`;
358
+ });
359
+ html += `</div>`;
360
+ }
361
+
362
+ // AI Suggestions
363
+ if (data.suggestions && data.suggestions.length > 0) {
364
+ html += `<div class="section suggestions"><h3>💡 AI Suggestions</h3>`;
365
+ data.suggestions.forEach(suggestion => {
366
+ html += `<div class="suggestion-item"><strong>${suggestion.title}:</strong> ${suggestion.detail}</div>`;
367
+ });
368
+ html += `</div>`;
369
+ }
370
+
371
+ // Preview - SHOW ALL PAGES NOW with ALL DETAILED METRICS
372
+ if (data.results_preview && data.results_preview.length > 0) {
373
+ html += `<div class="section"><h3>📄 All Analyzed Pages (${data.results_preview.length})</h3>`;
374
+ data.results_preview.forEach((page, index) => {
375
+ html += `
376
+ <div class="preview">
377
+ <p><strong>URL:</strong> ${page.url}</p>
378
+ <p><strong>Score:</strong> ${page.seo_score}/100</p>
379
+ <p><strong>Title:</strong> ${page.title || 'No title'}</p>
380
+ <p><strong>Meta Description:</strong> ${page.meta_description || 'No meta description'}</p>
381
+ <p><strong>Word Count:</strong> ${page.word_count}</p>
382
+ <p><strong>H1 Count:</strong> ${page.h1_count}</p>
383
+ <p><strong>H2 Count:</strong> ${page.h2_count}</p>
384
+ <p><strong>H3 Count:</strong> ${page.h3_count || 0}</p>
385
+ <p><strong>Heading Order:</strong> ${page.heading_order || 'None'}</p>
386
+ <p><strong>Missing Alt Tags:</strong> ${page.missing_alt_tags || 0} of ${page.total_images || 0} images</p>
387
+ <p><strong>Total Images:</strong> ${page.total_images || 0}</p>
388
+ <p><strong>Small Images (&lt;100px):</strong> ${page.small_images || 0}</p>
389
+ <p><strong>Large Images (&gt;2000px):</strong> ${page.large_images || 0}</p>
390
+ <p><strong>Ideal Images (100-2000px):</strong> ${page.ideal_images || 0}</p>
391
+ <p><strong>Internal Links:</strong> ${page.internal_links || 0}</p>
392
+ <p><strong>External Links:</strong> ${page.external_links || 0}</p>
393
+ <p><strong>Canonical Tag:</strong> ${page.canonical_tag ? '✅ Yes' : '❌ No'}</p>
394
+ <p><strong>Robots Meta:</strong> ${page.robots_meta || 'None'}</p>
395
+ <p><strong>Viewport:</strong> ${page.viewport_present ? '✅ Yes' : '❌ No'}</p>
396
+ <p><strong>Schema Types:</strong> ${page.schema_types || 'No schema found'}</p>
397
+ <p><strong>OpenGraph Tags:</strong> ${page.opengraph_tags || 0}</p>
398
+ <p><strong>Twitter Tags:</strong> ${page.twitter_tags || 0}</p>
399
+ <p><strong>Readability Score:</strong> ${page.readability_score || 0}/100</p>
400
+ <p><strong>Grammar Errors:</strong> ${page.grammar_errors || 0}</p>
401
+ <p><strong>Text/HTML Ratio:</strong> ${page.text_to_html_ratio || 0}%</p>
402
+ <p><strong>Top Keywords:</strong> ${page.top_keywords || 'None'}</p>
403
+ <p><strong>Load Time:</strong> ${page.load_time || 0}ms</p>
404
+ </div>
405
+ `;
406
+ });
407
+ html += `</div>`;
408
+ }
409
+ }
410
+
411
+ results.innerHTML = html;
412
+ results.style.display = 'block';
413
+ }
414
+
415
+ // Enter key support
416
+ document.getElementById('url').addEventListener('keypress', function(e) {
417
+ if (e.key === 'Enter') {
418
+ analyzeSEO();
419
+ }
420
+ });
421
+ </script>
422
+ </body>
423
+ </html>
424
+ """
425
+
426
+ @app.get("/", response_class=HTMLResponse)
427
+ async def root():
428
+ return HTML_TEMPLATE
429
+
430
+ @app.get("/health")
431
+ async def health_check():
432
+ return {
433
+ "status": "healthy",
434
+ "message": "SEO Analysis API is running",
435
+ "timestamp": datetime.now().isoformat()
436
+ }
437
+
438
+ @app.get("/analyze")
439
+ async def analyze_seo(
440
+ url: str,
441
+ max_pages: int = 5,
442
+ use_ai: bool = True,
443
+ max_concurrent: int = 1
444
+ ):
445
+ """
446
+ Analyze a single website for SEO optimization
447
+
448
+ Args:
449
+ url: Website URL to analyze
450
+ max_pages: Maximum number of pages to analyze (default: 5)
451
+ use_ai: Enable AI-powered suggestions (default: True)
452
+ max_concurrent: Number of concurrent browsers (default: 1)
453
+ """
454
+ all_pages_summary = []
455
+ try:
456
+ print(f"🔍 Starting SEO analysis for: {url}")
457
+
458
+ # Run SEO analysis
459
+ results, csv_path = await run_seo_analysis_fastapi(
460
+ base_url=url,
461
+ max_pages=max_pages,
462
+ use_ai=use_ai,
463
+ max_concurrent=max_concurrent,
464
+ download=False
465
+ )
466
+
467
+ # Calculate overall metrics
468
+ avg_score = 0
469
+ total_pages = len(results)
470
+
471
+ if results and 'error' not in results[0]:
472
+ avg_score = round(sum(page.get("seo_score", 0) for page in results) / len(results), 1)
473
+
474
+ # Extract strengths and faults
475
+ strengths = []
476
+ faults = []
477
+ suggestions = []
478
+
479
+ if results and 'error' not in results[0]:
480
+ # Create complete page summaries for ALL pages with ALL metrics
481
+ # all_pages_summary = []
482
+ for page in results:
483
+ page_summary = {
484
+ "url": page.get("url", ""),
485
+ "title": page.get("title", ""),
486
+ "meta_description": page.get("meta_description", ""),
487
+ "h1_count": page.get("h1_count", 0),
488
+ "h2_count": page.get("h2_count", 0),
489
+ "h3_count": page.get("h3_count", 0),
490
+ "heading_order": page.get("heading_order", ""),
491
+ "missing_alt_tags": page.get("missing_alt_tags", 0),
492
+ "total_images": page.get("total_images", 0),
493
+ "small_images": page.get("small_images", 0),
494
+ "large_images": page.get("large_images", 0),
495
+ "ideal_images": page.get("ideal_images", 0),
496
+ "internal_links": page.get("internal_links", 0),
497
+ "external_links": page.get("external_links", 0),
498
+ "canonical_tag": page.get("canonical_tag", False),
499
+ "robots_meta": page.get("robots_meta", ""),
500
+ "viewport_present": page.get("viewport_present", False),
501
+ "schema_types": page.get("schema_types", ""),
502
+ "opengraph_tags": page.get("opengraph_tags", 0),
503
+ "twitter_tags": page.get("twitter_tags", 0),
504
+ "word_count": page.get("word_count", 0),
505
+ "readability_score": page.get("readability_score", 0),
506
+ "grammar_errors": page.get("grammar_errors", 0),
507
+ "text_to_html_ratio": page.get("text_to_html_ratio", 0),
508
+ "top_keywords": page.get("top_keywords", ""),
509
+ "load_time": page.get("load_time", 0),
510
+ "seo_score": page.get("seo_score", 0)
511
+ }
512
+ all_pages_summary.append(page_summary)
513
+
514
+ # Sort pages by score (highest first)
515
+ all_pages_summary.sort(key=lambda x: x["seo_score"], reverse=True)
516
+
517
+ # Calculate comprehensive metrics
518
+ total_pages = len(results)
519
+
520
+ # 1. TITLE ANALYSIS
521
+ title_lengths = [len(page.get('title', '')) for page in results if page.get('title')]
522
+ good_title_pages = sum(1 for page in results if page.get('title') and 45 <= len(page.get('title', '')) <= 65)
523
+ missing_title_pages = sum(1 for page in results if not page.get('title') or page.get('title', '').strip() == '')
524
+ short_title_pages = sum(1 for page in results if page.get('title') and len(page.get('title', '')) < 45)
525
+ long_title_pages = sum(1 for page in results if page.get('title') and len(page.get('title', '')) > 65)
526
+
527
+ # 2. META DESCRIPTION ANALYSIS
528
+ meta_lengths = [len(page.get('meta_description', '')) for page in results if page.get('meta_description')]
529
+ good_meta_pages = sum(1 for page in results if page.get('meta_description') and 120 <= len(page.get('meta_description', '')) <= 155)
530
+ missing_meta_pages = sum(1 for page in results if not page.get('meta_description') or page.get('meta_description', '').strip() == '')
531
+ short_meta_pages = sum(1 for page in results if page.get('meta_description') and len(page.get('meta_description', '')) < 120)
532
+ long_meta_pages = sum(1 for page in results if page.get('meta_description') and len(page.get('meta_description', '')) > 155)
533
+
534
+ # 3. HEADING STRUCTURE ANALYSIS
535
+ proper_h1_pages = sum(1 for page in results if page.get('h1_count', 0) == 1)
536
+ multiple_h1_pages = sum(1 for page in results if page.get('h1_count', 0) > 1)
537
+ missing_h1_pages = sum(1 for page in results if page.get('h1_count', 0) == 0)
538
+ good_heading_hierarchy_pages = sum(1 for page in results if page.get('heading_order') and 'H1 → H2 → H3' in page.get('heading_order', ''))
539
+ has_h2_pages = sum(1 for page in results if page.get('h2_count', 0) > 0)
540
+ has_h3_pages = sum(1 for page in results if page.get('h3_count', 0) > 0)
541
+
542
+ # 4. IMAGE ANALYSIS
543
+ total_images_all = sum(page.get('total_images', 0) for page in results)
544
+ total_missing_alt = sum(page.get('missing_alt_tags', 0) for page in results)
545
+ total_ideal_images = sum(page.get('ideal_images', 0) for page in results)
546
+ total_small_images = sum(page.get('small_images', 0) for page in results)
547
+ total_large_images = sum(page.get('large_images', 0) for page in results)
548
+ pages_with_images = sum(1 for page in results if page.get('total_images', 0) > 0)
549
+ pages_without_images = sum(1 for page in results if page.get('total_images', 0) == 0)
550
+ perfect_image_pages = sum(1 for page in results if page.get('missing_alt_tags', 0) == 0 and page.get('total_images', 0) > 0)
551
+
552
+ # 5. CONTENT ANALYSIS
553
+ good_wordcount_pages = sum(1 for page in results if page.get('word_count', 0) >= 500)
554
+ thin_content_pages = sum(1 for page in results if page.get('word_count', 0) < 300)
555
+ excellent_readability_pages = sum(1 for page in results if page.get('readability_score', 0) >= 70)
556
+ poor_readability_pages = sum(1 for page in results if page.get('readability_score', 0) < 50)
557
+ grammar_error_pages = sum(1 for page in results if page.get('grammar_errors', 0) > 0)
558
+ good_text_ratio_pages = sum(1 for page in results if page.get('text_to_html_ratio', 0) >= 25)
559
+ low_text_ratio_pages = sum(1 for page in results if page.get('text_to_html_ratio', 0) < 15)
560
+
561
+ # 6. LINK ANALYSIS
562
+ internal_links_total = sum(page.get('internal_links', 0) for page in results)
563
+ external_links_total = sum(page.get('external_links', 0) for page in results)
564
+ good_internal_link_pages = sum(1 for page in results if page.get('internal_links', 0) >= 5)
565
+ good_external_link_pages = sum(1 for page in results if page.get('external_links', 0) >= 2 and page.get('external_links', 0) <= 10)
566
+ no_internal_links_pages = sum(1 for page in results if page.get('internal_links', 0) == 0)
567
+ excessive_external_links_pages = sum(1 for page in results if page.get('external_links', 0) > 15)
568
+
569
+ # 7. TECHNICAL SEO ANALYSIS
570
+ mobile_friendly_pages = sum(1 for page in results if page.get('viewport_present', False))
571
+ canonical_pages = sum(1 for page in results if page.get('canonical_tag', False))
572
+ has_schema_pages = sum(1 for page in results if page.get('schema_types') and page.get('schema_types') != "No schema found" and page.get('schema_types', '').strip() != '')
573
+ good_opengraph_pages = sum(1 for page in results if page.get('opengraph_tags', 0) >= 5)
574
+ good_twitter_pages = sum(1 for page in results if page.get('twitter_tags', 0) >= 5)
575
+ noindex_pages = sum(1 for page in results if page.get('robots_meta') and 'noindex' in str(page.get('robots_meta', '')).lower())
576
+
577
+ # 8. PERFORMANCE ANALYSIS
578
+ fast_load_pages = sum(1 for page in results if page.get('load_time', 0) < 2000)
579
+ slow_load_pages = sum(1 for page in results if page.get('load_time', 0) > 4000)
580
+
581
+ # 9. KEYWORD ANALYSIS
582
+ has_keywords_pages = sum(1 for page in results if page.get('top_keywords') and page.get('top_keywords', '').strip() != '')
583
+
584
+ # STRENGTHS ANALYSIS
585
+ strengths = []
586
+
587
+ if good_title_pages > 0:
588
+ strengths.append({
589
+ "title": "Optimized Title Tags",
590
+ "detail": f"{good_title_pages}/{total_pages} pages ({good_title_pages/total_pages*100:.0f}%) have well-optimized title tags (45-65 chars)",
591
+ "impact": "high",
592
+ "metric": f"{good_title_pages}/{total_pages} pages"
593
+ })
594
+
595
+ if good_meta_pages > 0:
596
+ strengths.append({
597
+ "title": "Proper Meta Descriptions",
598
+ "detail": f"{good_meta_pages}/{total_pages} pages ({good_meta_pages/total_pages*100:.0f}%) have well-formatted meta descriptions",
599
+ "impact": "high",
600
+ "metric": f"{good_meta_pages}/{total_pages} pages"
601
+ })
602
+
603
+ if mobile_friendly_pages > 0:
604
+ strengths.append({
605
+ "title": "Mobile Responsive",
606
+ "detail": f"{mobile_friendly_pages}/{total_pages} pages ({mobile_friendly_pages/total_pages*100:.0f}%) are mobile-friendly",
607
+ "impact": "high",
608
+ "metric": f"{mobile_friendly_pages}/{total_pages} pages"
609
+ })
610
+
611
+ if proper_h1_pages > 0:
612
+ strengths.append({
613
+ "title": "Proper H1 Structure",
614
+ "detail": f"{proper_h1_pages}/{total_pages} pages ({proper_h1_pages/total_pages*100:.0f}%) have exactly one H1 heading",
615
+ "impact": "high",
616
+ "metric": f"{proper_h1_pages}/{total_pages} pages"
617
+ })
618
+
619
+ if good_heading_hierarchy_pages > 0:
620
+ strengths.append({
621
+ "title": "Good Heading Hierarchy",
622
+ "detail": f"{good_heading_hierarchy_pages}/{total_pages} pages follow proper H1 → H2 → H3 structure",
623
+ "impact": "medium",
624
+ "metric": f"{good_heading_hierarchy_pages}/{total_pages} pages"
625
+ })
626
+
627
+ if perfect_image_pages > 0 and pages_with_images > 0:
628
+ strengths.append({
629
+ "title": "Image Optimization",
630
+ "detail": f"{perfect_image_pages}/{pages_with_images} pages ({perfect_image_pages/pages_with_images*100:.0f}%) with images have perfect alt text",
631
+ "impact": "medium",
632
+ "metric": f"{perfect_image_pages}/{pages_with_images} pages"
633
+ })
634
+
635
+ if good_wordcount_pages > 0:
636
+ strengths.append({
637
+ "title": "Quality Content",
638
+ "detail": f"{good_wordcount_pages}/{total_pages} pages ({good_wordcount_pages/total_pages*100:.0f}%) have comprehensive content (500+ words)",
639
+ "impact": "high",
640
+ "metric": f"{good_wordcount_pages}/{total_pages} pages"
641
+ })
642
+
643
+ if excellent_readability_pages > 0:
644
+ strengths.append({
645
+ "title": "Excellent Readability",
646
+ "detail": f"{excellent_readability_pages}/{total_pages} pages ({excellent_readability_pages/total_pages*100:.0f}%) have high readability scores (70+)",
647
+ "impact": "medium",
648
+ "metric": f"{excellent_readability_pages}/{total_pages} pages"
649
+ })
650
+
651
+ if canonical_pages > 0:
652
+ strengths.append({
653
+ "title": "Canonical Tags",
654
+ "detail": f"{canonical_pages}/{total_pages} pages ({canonical_pages/total_pages*100:.0f}%) have canonical tags preventing duplicate content",
655
+ "impact": "high",
656
+ "metric": f"{canonical_pages}/{total_pages} pages"
657
+ })
658
+
659
+ if has_schema_pages > 0:
660
+ strengths.append({
661
+ "title": "Schema Markup",
662
+ "detail": f"{has_schema_pages}/{total_pages} pages ({has_schema_pages/total_pages*100:.0f}%) use structured data markup",
663
+ "impact": "medium",
664
+ "metric": f"{has_schema_pages}/{total_pages} pages"
665
+ })
666
+
667
+ if good_internal_link_pages > 0:
668
+ strengths.append({
669
+ "title": "Internal Linking",
670
+ "detail": f"{good_internal_link_pages}/{total_pages} pages ({good_internal_link_pages/total_pages*100:.0f}%) have strong internal linking (5+ internal links)",
671
+ "impact": "medium",
672
+ "metric": f"{good_internal_link_pages}/{total_pages} pages"
673
+ })
674
+
675
+ if fast_load_pages > 0:
676
+ strengths.append({
677
+ "title": "Fast Loading Pages",
678
+ "detail": f"{fast_load_pages}/{total_pages} pages ({fast_load_pages/total_pages*100:.0f}%) load quickly (<2 seconds)",
679
+ "impact": "high",
680
+ "metric": f"{fast_load_pages}/{total_pages} pages"
681
+ })
682
+
683
+ if has_keywords_pages > 0:
684
+ strengths.append({
685
+ "title": "Keyword Optimization",
686
+ "detail": f"{has_keywords_pages}/{total_pages} pages ({has_keywords_pages/total_pages*100:.0f}%) show clear keyword targeting",
687
+ "impact": "high",
688
+ "metric": f"{has_keywords_pages}/{total_pages} pages"
689
+ })
690
+
691
+ if good_opengraph_pages > 0:
692
+ strengths.append({
693
+ "title": "Social Media Ready",
694
+ "detail": f"{good_opengraph_pages}/{total_pages} pages ({good_opengraph_pages/total_pages*100:.0f}%) have comprehensive OpenGraph tags",
695
+ "impact": "low",
696
+ "metric": f"{good_opengraph_pages}/{total_pages} pages"
697
+ })
698
+
699
+ # FAULTS ANALYSIS
700
+ faults = []
701
+
702
+ if missing_title_pages > 0:
703
+ faults.append({
704
+ "title": "Missing Title Tags",
705
+ "detail": f"{missing_title_pages}/{total_pages} pages ({missing_title_pages/total_pages*100:.0f}%) are missing title tags",
706
+ "severity": "critical",
707
+ "pages": missing_title_pages,
708
+ "fix": "Add descriptive title tags to every page"
709
+ })
710
+
711
+ if missing_h1_pages > 0:
712
+ faults.append({
713
+ "title": "Missing H1 Headings",
714
+ "detail": f"{missing_h1_pages}/{total_pages} pages ({missing_h1_pages/total_pages*100:.0f}%) are missing H1 headings",
715
+ "severity": "critical",
716
+ "pages": missing_h1_pages,
717
+ "fix": "Add a single, descriptive H1 heading to each page"
718
+ })
719
+
720
+ if multiple_h1_pages > 0:
721
+ faults.append({
722
+ "title": "Multiple H1 Headings",
723
+ "detail": f"{multiple_h1_pages}/{total_pages} pages ({multiple_h1_pages/total_pages*100:.0f}%) have multiple H1 headings",
724
+ "severity": "high",
725
+ "pages": multiple_h1_pages,
726
+ "fix": "Reduce to one H1 per page for better SEO"
727
+ })
728
+
729
+ if missing_meta_pages > 0:
730
+ faults.append({
731
+ "title": "Missing Meta Descriptions",
732
+ "detail": f"{missing_meta_pages}/{total_pages} pages ({missing_meta_pages/total_pages*100:.0f}%) are missing meta descriptions",
733
+ "severity": "high",
734
+ "pages": missing_meta_pages,
735
+ "fix": "Add compelling meta descriptions to improve click-through rates"
736
+ })
737
+
738
+ if total_missing_alt > 0 and total_images_all > 0:
739
+ alt_ratio = (total_missing_alt / total_images_all) * 100
740
+ faults.append({
741
+ "title": "Missing Image Alt Text",
742
+ "detail": f"{total_missing_alt}/{total_images_all} images ({alt_ratio:.0f}%) are missing alt text",
743
+ "severity": "high" if alt_ratio > 20 else "medium",
744
+ "pages": pages_with_images,
745
+ "fix": "Add descriptive alt text to all images"
746
+ })
747
+
748
+ if total_small_images > 0:
749
+ faults.append({
750
+ "title": "Small Images Detected",
751
+ "detail": f"{total_small_images} images are too small (<100px) which may appear blurry on high-resolution screens",
752
+ "severity": "low",
753
+ "pages": sum(1 for page in results if page.get('small_images', 0) > 0),
754
+ "fix": "Use higher resolution images (minimum 100px width/height)"
755
+ })
756
+
757
+ if total_large_images > 0:
758
+ faults.append({
759
+ "title": "Oversized Images",
760
+ "detail": f"{total_large_images} images are too large (>2000px) causing slow page loads",
761
+ "severity": "medium",
762
+ "pages": sum(1 for page in results if page.get('large_images', 0) > 0),
763
+ "fix": "Optimize and resize large images to under 2000px"
764
+ })
765
+
766
+ if thin_content_pages > 0:
767
+ faults.append({
768
+ "title": "Thin Content",
769
+ "detail": f"{thin_content_pages}/{total_pages} pages ({thin_content_pages/total_pages*100:.0f}%) have thin content (<300 words)",
770
+ "severity": "high",
771
+ "pages": thin_content_pages,
772
+ "fix": "Expand content to provide more value (aim for 500+ words)"
773
+ })
774
+
775
+ if poor_readability_pages > 0:
776
+ faults.append({
777
+ "title": "Poor Readability",
778
+ "detail": f"{poor_readability_pages}/{total_pages} pages ({poor_readability_pages/total_pages*100:.0f}%) have low readability scores (<50)",
779
+ "severity": "medium",
780
+ "pages": poor_readability_pages,
781
+ "fix": "Simplify language, use shorter sentences and paragraphs"
782
+ })
783
+
784
+ if grammar_error_pages > 0:
785
+ faults.append({
786
+ "title": "Grammar Errors",
787
+ "detail": f"{grammar_error_pages}/{total_pages} pages ({grammar_error_pages/total_pages*100:.0f}%) contain grammar errors",
788
+ "severity": "medium",
789
+ "pages": grammar_error_pages,
790
+ "fix": "Review and correct grammar mistakes for better credibility"
791
+ })
792
+
793
+ if low_text_ratio_pages > 0:
794
+ faults.append({
795
+ "title": "Low Text to HTML Ratio",
796
+ "detail": f"{low_text_ratio_pages}/{total_pages} pages ({low_text_ratio_pages/total_pages*100:.0f}%) have low text content (<15% text ratio)",
797
+ "severity": "medium",
798
+ "pages": low_text_ratio_pages,
799
+ "fix": "Increase meaningful text content relative to HTML code"
800
+ })
801
+
802
+ if no_internal_links_pages > 0:
803
+ faults.append({
804
+ "title": "No Internal Links",
805
+ "detail": f"{no_internal_links_pages}/{total_pages} pages ({no_internal_links_pages/total_pages*100:.0f}%) have no internal links",
806
+ "severity": "high",
807
+ "pages": no_internal_links_pages,
808
+ "fix": "Add internal links to improve site structure and crawlability"
809
+ })
810
+
811
+ if excessive_external_links_pages > 0:
812
+ faults.append({
813
+ "title": "Excessive External Links",
814
+ "detail": f"{excessive_external_links_pages}/{total_pages} pages have too many external links (>15)",
815
+ "severity": "low",
816
+ "pages": excessive_external_links_pages,
817
+ "fix": "Reduce external links to maintain link equity"
818
+ })
819
+
820
+ if mobile_friendly_pages < total_pages:
821
+ non_mobile_pages = total_pages - mobile_friendly_pages
822
+ faults.append({
823
+ "title": "Non-Mobile Friendly Pages",
824
+ "detail": f"{non_mobile_pages}/{total_pages} pages ({non_mobile_pages/total_pages*100:.0f}%) lack mobile viewport tag",
825
+ "severity": "critical",
826
+ "pages": non_mobile_pages,
827
+ "fix": "Add viewport meta tag to all pages: <meta name='viewport' content='width=device-width, initial-scale=1'>"
828
+ })
829
+
830
+ if noindex_pages > 0:
831
+ faults.append({
832
+ "title": "Noindex Pages",
833
+ "detail": f"{noindex_pages} pages are marked with 'noindex' and won't appear in search results",
834
+ "severity": "critical",
835
+ "pages": noindex_pages,
836
+ "fix": "Remove 'noindex' from pages you want to rank unless intentionally hidden"
837
+ })
838
+
839
+ if has_schema_pages < total_pages:
840
+ missing_schema_pages = total_pages - has_schema_pages
841
+ faults.append({
842
+ "title": "Missing Schema Markup",
843
+ "detail": f"{missing_schema_pages}/{total_pages} pages ({missing_schema_pages/total_pages*100:.0f}%) lack structured data markup",
844
+ "severity": "medium",
845
+ "pages": missing_schema_pages,
846
+ "fix": "Add appropriate schema.org markup to improve rich snippets"
847
+ })
848
+
849
+ if slow_load_pages > 0:
850
+ faults.append({
851
+ "title": "Slow Loading Pages",
852
+ "detail": f"{slow_load_pages}/{total_pages} pages ({slow_load_pages/total_pages*100:.0f}%) load slowly (>4 seconds)",
853
+ "severity": "high",
854
+ "pages": slow_load_pages,
855
+ "fix": "Optimize images, minify CSS/JS, enable caching"
856
+ })
857
+
858
+ if pages_without_images > 0:
859
+ faults.append({
860
+ "title": "Pages Without Images",
861
+ "detail": f"{pages_without_images}/{total_pages} pages ({pages_without_images/total_pages*100:.0f}%) have no images",
862
+ "severity": "low",
863
+ "pages": pages_without_images,
864
+ "fix": "Add relevant images to improve engagement and visual appeal"
865
+ })
866
+
867
+ # Limit to top 10 faults by severity
868
+ severity_order = {"critical": 0, "high": 1, "medium": 2, "low": 3}
869
+ faults.sort(key=lambda x: severity_order[x["severity"]])
870
+ faults = faults[:15]
871
+
872
+ # Extract AI suggestions
873
+ if results and 'error' not in results[0] and use_ai:
874
+ for page in results:
875
+ if page.get('ai_suggestions') and page['ai_suggestions'] not in [
876
+ "AI suggestions disabled - set valid OPENAI_API_KEY",
877
+ "AI disabled - set valid OPENAI_API_KEY",
878
+ "AI suggestions disabled - set use_ai=True and OPENAI_API_KEY"
879
+ ]:
880
+ # Extract key suggestions from AI output
881
+ ai_text = page['ai_suggestions']
882
+
883
+ # Take first few lines as suggestions
884
+ lines = [line.strip() for line in ai_text.split('\n') if line.strip() and len(line.strip()) > 20]
885
+ for line in lines[:3]:
886
+ suggestions.append({
887
+ "title": "SEO Recommendation",
888
+ "detail": line[:150],
889
+ "priority": "high"
890
+ })
891
+
892
+ # Limit suggestions
893
+ suggestions = suggestions[:5]
894
+
895
+ return {
896
+ "status": "success",
897
+ "url": url,
898
+ "pages_analyzed": total_pages,
899
+ "overall_score": avg_score,
900
+ "strengths": strengths,
901
+ "faults": faults,
902
+ "suggestions": suggestions,
903
+ # Return ALL pages summary with ALL metrics
904
+ "results_preview": all_pages_summary,
905
+ "csv_download": f"/download/{os.path.basename(csv_path)}" if results and 'error' not in results[0] else None
906
+ }
907
+
908
+ except Exception as e:
909
+ print(f"❌ Analysis error: {e}")
910
+ return {
911
+ "status": "error",
912
+ "message": str(e)
913
+ }
914
+
915
+ @app.get("/analyze-ai-visibility")
916
+ async def analyze_ai_visibility(
917
+ url: str,
918
+ max_pages: int = 5,
919
+ use_ai: bool = True,
920
+ max_concurrent: int = 1,
921
+ target_query: str = None,
922
+ ):
923
+ """
924
+ Analyze a website's AI Visibility / AI Search Readiness.
925
+
926
+ Runs independently of /analyze - it fetches pages itself (once) and
927
+ computes proxy signals for how well content can be understood, retrieved,
928
+ cited and summarized by AI-powered search systems.
929
+
930
+ target_query (optional): a keyword/query to measure semantic_relevance
931
+ and search_intent_match against. Without it, those two metrics are
932
+ reported as unknown rather than guessed at.
933
+ """
934
+ try:
935
+ print(f"🤖 Starting AI Visibility analysis for: {url}")
936
+ result = await run_ai_visibility_analysis(
937
+ base_url=url,
938
+ max_pages=max_pages,
939
+ max_concurrent=max_concurrent,
940
+ use_ai=use_ai,
941
+ target_query=target_query,
942
+ )
943
+ return result
944
+ except Exception as e:
945
+ print(f"❌ AI Visibility analysis error: {e}")
946
+ return {
947
+ "status": "error",
948
+ "message": str(e)
949
+ }
950
+
951
+ @app.get("/download/{filename}")
952
+ async def download_file(filename: str):
953
+ """Download SEO analysis CSV report"""
954
+ file_path = f"/tmp/{filename}"
955
+ if os.path.exists(file_path):
956
+ return FileResponse(
957
+ file_path,
958
+ media_type='text/csv',
959
+ filename=f"seo_report.csv"
960
+ )
961
+ raise HTTPException(status_code=404, detail="File not found")
962
+
963
+ if __name__ == "__main__":
964
+ import uvicorn
965
+ print("🚀 Starting SEO Analysis API...")
966
+ uvicorn.run(app, host="0.0.0.0", port=7860)
967
+
968
+
969
+
970
+
971
+
972
+
973
+
974
+
975
+
976
+
977
+
978
+
979
+
980
+
981
+
982
+
983
+ # from fastapi import FastAPI, HTTPException
984
+ # from fastapi.responses import FileResponse, HTMLResponse
985
+ # from fastapi.middleware.cors import CORSMiddleware
986
+ # import os
987
+ # import asyncio
988
+ # import uuid
989
+ # import csv
990
+ # from datetime import datetime
991
+
992
+ # # Import your SEO analyzer functions
993
+ # from seo_analyzer import run_seo_analysis_fastapi
994
+
995
+ # app = FastAPI(
996
+ # title="SEO Analysis API",
997
+ # version="2.0",
998
+ # description="Advanced SEO analysis with AI-powered suggestions"
999
+ # )
1000
+
1001
+ # app.add_middleware(
1002
+ # CORSMiddleware,
1003
+ # allow_origins=["*"],
1004
+ # allow_credentials=True,
1005
+ # allow_methods=["*"],
1006
+ # allow_headers=["*"],
1007
+ # )
1008
+
1009
+ # # Simple HTML UI - No static files needed
1010
+ # HTML_TEMPLATE = """
1011
+ # <!DOCTYPE html>
1012
+ # <html>
1013
+ # <head>
1014
+ # <title>SEO Analysis Tool</title>
1015
+ # <style>
1016
+ # * { margin: 0; padding: 0; box-sizing: border-box; }
1017
+ # body { font-family: Arial, sans-serif; background: #f5f5f5; padding: 20px; }
1018
+ # .container { max-width: 1200px; margin: 0 auto; background: white; padding: 30px; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
1019
+ # h1 { color: #333; margin-bottom: 20px; text-align: center; }
1020
+ # .form-group { margin-bottom: 20px; }
1021
+ # label { display: block; margin-bottom: 5px; font-weight: bold; color: #555; }
1022
+ # input[type="text"], input[type="number"] { width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 5px; font-size: 16px; }
1023
+ # .checkbox-group { display: flex; align-items: center; gap: 10px; }
1024
+ # input[type="checkbox"] { width: 20px; height: 20px; }
1025
+ # button { background: #007bff; color: white; border: none; padding: 12px 30px; border-radius: 5px; cursor: pointer; font-size: 16px; margin-right: 10px; }
1026
+ # button:hover { background: #0056b3; }
1027
+ # button:disabled { background: #6c757d; cursor: not-allowed; }
1028
+ # .loading { display: none; color: #007bff; margin: 20px 0; }
1029
+ # .results { margin-top: 30px; display: none; }
1030
+ # .score { font-size: 48px; font-weight: bold; text-align: center; margin: 20px 0; }
1031
+ # .score.good { color: #28a745; }
1032
+ # .score.average { color: #ffc107; }
1033
+ # .score.poor { color: #dc3545; }
1034
+ # .section { margin: 20px 0; padding: 20px; border-radius: 5px; }
1035
+ # .strengths { background: #d4edda; border-left: 4px solid #28a745; }
1036
+ # .faults { background: #f8d7da; border-left: 4px solid #dc3545; }
1037
+ # .suggestions { background: #fff3cd; border-left: 4px solid #ffc107; }
1038
+ # .suggestion-item { margin: 10px 0; padding: 10px; background: white; border-radius: 5px; }
1039
+ # .error { background: #f8d7da; color: #721c24; padding: 15px; border-radius: 5px; margin: 20px 0; }
1040
+ # .download-btn { background: #28a745; color: white; text-decoration: none; padding: 10px 20px; border-radius: 5px; display: inline-block; }
1041
+ # .download-btn:hover { background: #1e7e34; }
1042
+ # .preview { background: #e9ecef; padding: 15px; border-radius: 5px; margin: 10px 0; }
1043
+ # .api-test { background: #d1ecf1; padding: 15px; border-radius: 5px; margin: 10px 0; }
1044
+ # </style>
1045
+ # </head>
1046
+ # <body>
1047
+ # <div class="container">
1048
+ # <h1>🚀 SEO Analysis Tool</h1>
1049
+
1050
+ # <div class="form-group">
1051
+ # <label for="url">Website URL:</label>
1052
+ # <input type="text" id="url" placeholder="https://example.com" value="https://example.com">
1053
+ # </div>
1054
+
1055
+ # <div class="form-group">
1056
+ # <label for="maxPages">Max Pages to Analyze:</label>
1057
+ # <input type="number" id="maxPages" value="3" min="1" max="20">
1058
+ # </div>
1059
+
1060
+ # <div class="form-group">
1061
+ # <label for="maxConcurrent">Max Concurrent Browsers:</label>
1062
+ # <input type="number" id="maxConcurrent" value="1" min="1" max="5">
1063
+ # </div>
1064
+
1065
+ # <div class="form-group checkbox-group">
1066
+ # <input type="checkbox" id="useAI" checked>
1067
+ # <label for="useAI">Enable AI Suggestions</label>
1068
+ # </div>
1069
+
1070
+ # <button onclick="analyzeSEO()">Analyze SEO</button>
1071
+ # <button onclick="testAPI()" style="background: #6c757d;">Test API</button>
1072
+
1073
+ # <div id="loading" class="loading">
1074
+ # 🔍 Analyzing website... This may take a few minutes.
1075
+ # </div>
1076
+
1077
+ # <div id="results" class="results"></div>
1078
+ # </div>
1079
+ # <script>
1080
+ # async function analyzeSEO() {
1081
+ # const url = document.getElementById('url').value;
1082
+ # const maxPages = document.getElementById('maxPages').value;
1083
+ # const maxConcurrent = document.getElementById('maxConcurrent').value;
1084
+ # const useAI = document.getElementById('useAI').checked;
1085
+
1086
+ # if (!url) {
1087
+ # alert('Please enter a website URL');
1088
+ # return;
1089
+ # }
1090
+
1091
+ # const loading = document.getElementById('loading');
1092
+ # const results = document.getElementById('results');
1093
+
1094
+ # loading.style.display = 'block';
1095
+ # results.style.display = 'none';
1096
+ # results.innerHTML = '';
1097
+
1098
+ # try {
1099
+ # const apiUrl = `/analyze?url=${encodeURIComponent(url)}&max_pages=${maxPages}&use_ai=${useAI}&max_concurrent=${maxConcurrent}`;
1100
+ # console.log('Calling API:', apiUrl);
1101
+
1102
+ # const response = await fetch(apiUrl);
1103
+ # const data = await response.json();
1104
+
1105
+ # displayResults(data);
1106
+ # } catch (error) {
1107
+ # results.innerHTML = `<div class="error">Error: ${error.message}</div>`;
1108
+ # results.style.display = 'block';
1109
+ # } finally {
1110
+ # loading.style.display = 'none';
1111
+ # }
1112
+ # }
1113
+
1114
+ # async function testAPI() {
1115
+ # const loading = document.getElementById('loading');
1116
+ # const results = document.getElementById('results');
1117
+
1118
+ # loading.style.display = 'block';
1119
+ # results.style.display = 'none';
1120
+
1121
+ # try {
1122
+ # const response = await fetch('/health');
1123
+ # const data = await response.json();
1124
+
1125
+ # results.innerHTML = `
1126
+ # <div class="api-test">
1127
+ # <h3>✅ API Health Check</h3>
1128
+ # <p><strong>Status:</strong> ${data.status}</p>
1129
+ # <p><strong>Message:</strong> ${data.message}</p>
1130
+ # <p><strong>Timestamp:</strong> ${data.timestamp}</p>
1131
+ # </div>
1132
+ # `;
1133
+ # results.style.display = 'block';
1134
+ # } catch (error) {
1135
+ # results.innerHTML = `<div class="error">API Test Failed: ${error.message}</div>`;
1136
+ # results.style.display = 'block';
1137
+ # } finally {
1138
+ # loading.style.display = 'none';
1139
+ # }
1140
+ # }
1141
+
1142
+ # function displayResults(data) {
1143
+ # const results = document.getElementById('results');
1144
+ # let html = '';
1145
+
1146
+ # if (data.status === 'error') {
1147
+ # html = `<div class="error">${data.message}</div>`;
1148
+ # } else {
1149
+ # // Score display
1150
+ # let scoreClass = 'poor';
1151
+ # if (data.overall_score >= 70) scoreClass = 'good';
1152
+ # else if (data.overall_score >= 50) scoreClass = 'average';
1153
+
1154
+ # html += `
1155
+ # <div class="score ${scoreClass}">${data.overall_score}/100</div>
1156
+ # <p style="text-align: center; margin-bottom: 20px;">
1157
+ # Analyzed <strong>${data.pages_analyzed}</strong> pages from <strong>${data.url}</strong>
1158
+ # </p>
1159
+ # `;
1160
+
1161
+ # // Download button
1162
+ # if (data.csv_download) {
1163
+ # html += `
1164
+ # <div style="text-align: center; margin: 20px 0;">
1165
+ # <a href="${data.csv_download}" class="download-btn">
1166
+ # 📊 Download Full CSV Report
1167
+ # </a>
1168
+ # </div>
1169
+ # `;
1170
+ # }
1171
+
1172
+ # // Strengths
1173
+ # if (data.strengths && data.strengths.length > 0) {
1174
+ # html += `<div class="section strengths"><h3>✅ Strengths</h3>`;
1175
+ # data.strengths.forEach(strength => {
1176
+ # html += `<div class="suggestion-item"><strong>${strength.title}:</strong> ${strength.detail}</div>`;
1177
+ # });
1178
+ # html += `</div>`;
1179
+ # }
1180
+
1181
+ # // Faults
1182
+ # if (data.faults && data.faults.length > 0) {
1183
+ # html += `<div class="section faults"><h3>❌ Issues Found</h3>`;
1184
+ # data.faults.forEach(fault => {
1185
+ # html += `<div class="suggestion-item"><strong>${fault.title}:</strong> ${fault.detail} (Severity: ${fault.severity})</div>`;
1186
+ # });
1187
+ # html += `</div>`;
1188
+ # }
1189
+
1190
+ # // AI Suggestions
1191
+ # if (data.suggestions && data.suggestions.length > 0) {
1192
+ # html += `<div class="section suggestions"><h3>💡 AI Suggestions</h3>`;
1193
+ # data.suggestions.forEach(suggestion => {
1194
+ # html += `<div class="suggestion-item"><strong>${suggestion.title}:</strong> ${suggestion.detail}</div>`;
1195
+ # });
1196
+ # html += `</div>`;
1197
+ # }
1198
+
1199
+ # // Preview - SHOW ALL PAGES NOW with ALL DETAILED METRICS
1200
+ # if (data.results_preview && data.results_preview.length > 0) {
1201
+ # html += `<div class="section"><h3>📄 All Analyzed Pages (${data.results_preview.length})</h3>`;
1202
+ # data.results_preview.forEach((page, index) => {
1203
+ # html += `
1204
+ # <div class="preview">
1205
+ # <p><strong>URL:</strong> ${page.url}</p>
1206
+ # <p><strong>Score:</strong> ${page.seo_score}/100</p>
1207
+ # <p><strong>Title:</strong> ${page.title || 'No title'}</p>
1208
+ # <p><strong>Meta Description:</strong> ${page.meta_description || 'No meta description'}</p>
1209
+ # <p><strong>Word Count:</strong> ${page.word_count}</p>
1210
+ # <p><strong>H1 Count:</strong> ${page.h1_count}</p>
1211
+ # <p><strong>H2 Count:</strong> ${page.h2_count}</p>
1212
+ # <p><strong>H3 Count:</strong> ${page.h3_count || 0}</p>
1213
+ # <p><strong>Heading Order:</strong> ${page.heading_order || 'None'}</p>
1214
+ # <p><strong>Missing Alt Tags:</strong> ${page.missing_alt_tags || 0} of ${page.total_images || 0} images</p>
1215
+ # <p><strong>Total Images:</strong> ${page.total_images || 0}</p>
1216
+ # <p><strong>Small Images (&lt;100px):</strong> ${page.small_images || 0}</p>
1217
+ # <p><strong>Large Images (&gt;2000px):</strong> ${page.large_images || 0}</p>
1218
+ # <p><strong>Ideal Images (100-2000px):</strong> ${page.ideal_images || 0}</p>
1219
+ # <p><strong>Internal Links:</strong> ${page.internal_links || 0}</p>
1220
+ # <p><strong>External Links:</strong> ${page.external_links || 0}</p>
1221
+ # <p><strong>Canonical Tag:</strong> ${page.canonical_tag ? '✅ Yes' : '❌ No'}</p>
1222
+ # <p><strong>Robots Meta:</strong> ${page.robots_meta || 'None'}</p>
1223
+ # <p><strong>Viewport:</strong> ${page.viewport_present ? '✅ Yes' : '❌ No'}</p>
1224
+ # <p><strong>Schema Types:</strong> ${page.schema_types || 'No schema found'}</p>
1225
+ # <p><strong>OpenGraph Tags:</strong> ${page.opengraph_tags || 0}</p>
1226
+ # <p><strong>Twitter Tags:</strong> ${page.twitter_tags || 0}</p>
1227
+ # <p><strong>Readability Score:</strong> ${page.readability_score || 0}/100</p>
1228
+ # <p><strong>Grammar Errors:</strong> ${page.grammar_errors || 0}</p>
1229
+ # <p><strong>Text/HTML Ratio:</strong> ${page.text_to_html_ratio || 0}%</p>
1230
+ # <p><strong>Top Keywords:</strong> ${page.top_keywords || 'None'}</p>
1231
+ # <p><strong>Load Time:</strong> ${page.load_time || 0}ms</p>
1232
+ # </div>
1233
+ # `;
1234
+ # });
1235
+ # html += `</div>`;
1236
+ # }
1237
+ # }
1238
+
1239
+ # results.innerHTML = html;
1240
+ # results.style.display = 'block';
1241
+ # }
1242
+
1243
+ # // Enter key support
1244
+ # document.getElementById('url').addEventListener('keypress', function(e) {
1245
+ # if (e.key === 'Enter') {
1246
+ # analyzeSEO();
1247
+ # }
1248
+ # });
1249
+ # </script>
1250
+ # </body>
1251
+ # </html>
1252
+ # """
1253
+
1254
+ # @app.get("/", response_class=HTMLResponse)
1255
+ # async def root():
1256
+ # return HTML_TEMPLATE
1257
+
1258
+ # @app.get("/health")
1259
+ # async def health_check():
1260
+ # return {
1261
+ # "status": "healthy",
1262
+ # "message": "SEO Analysis API is running",
1263
+ # "timestamp": datetime.now().isoformat()
1264
+ # }
1265
+
1266
+ # @app.get("/analyze")
1267
+ # async def analyze_seo(
1268
+ # url: str,
1269
+ # max_pages: int = 5,
1270
+ # use_ai: bool = True,
1271
+ # max_concurrent: int = 1
1272
+ # ):
1273
+ # """
1274
+ # Analyze a single website for SEO optimization
1275
+
1276
+ # Args:
1277
+ # url: Website URL to analyze
1278
+ # max_pages: Maximum number of pages to analyze (default: 5)
1279
+ # use_ai: Enable AI-powered suggestions (default: True)
1280
+ # max_concurrent: Number of concurrent browsers (default: 1)
1281
+ # """
1282
+ # try:
1283
+ # print(f"🔍 Starting SEO analysis for: {url}")
1284
+
1285
+ # # Run SEO analysis
1286
+ # results, csv_path = await run_seo_analysis_fastapi(
1287
+ # base_url=url,
1288
+ # max_pages=max_pages,
1289
+ # use_ai=use_ai,
1290
+ # max_concurrent=max_concurrent,
1291
+ # download=False
1292
+ # )
1293
+
1294
+ # # Calculate overall metrics
1295
+ # avg_score = 0
1296
+ # total_pages = len(results)
1297
+
1298
+ # if results and 'error' not in results[0]:
1299
+ # avg_score = round(sum(page.get("seo_score", 0) for page in results) / len(results), 1)
1300
+
1301
+ # # Extract strengths and faults
1302
+ # strengths = []
1303
+ # faults = []
1304
+ # suggestions = []
1305
+
1306
+ # if results and 'error' not in results[0]:
1307
+ # # Create complete page summaries for ALL pages with ALL metrics
1308
+ # all_pages_summary = []
1309
+ # for page in results:
1310
+ # page_summary = {
1311
+ # "url": page.get("url", ""),
1312
+ # "title": page.get("title", ""),
1313
+ # "meta_description": page.get("meta_description", ""),
1314
+ # "h1_count": page.get("h1_count", 0),
1315
+ # "h2_count": page.get("h2_count", 0),
1316
+ # "h3_count": page.get("h3_count", 0),
1317
+ # "heading_order": page.get("heading_order", ""),
1318
+ # "missing_alt_tags": page.get("missing_alt_tags", 0),
1319
+ # "total_images": page.get("total_images", 0),
1320
+ # "small_images": page.get("small_images", 0),
1321
+ # "large_images": page.get("large_images", 0),
1322
+ # "ideal_images": page.get("ideal_images", 0),
1323
+ # "internal_links": page.get("internal_links", 0),
1324
+ # "external_links": page.get("external_links", 0),
1325
+ # "canonical_tag": page.get("canonical_tag", False),
1326
+ # "robots_meta": page.get("robots_meta", ""),
1327
+ # "viewport_present": page.get("viewport_present", False),
1328
+ # "schema_types": page.get("schema_types", ""),
1329
+ # "opengraph_tags": page.get("opengraph_tags", 0),
1330
+ # "twitter_tags": page.get("twitter_tags", 0),
1331
+ # "word_count": page.get("word_count", 0),
1332
+ # "readability_score": page.get("readability_score", 0),
1333
+ # "grammar_errors": page.get("grammar_errors", 0),
1334
+ # "text_to_html_ratio": page.get("text_to_html_ratio", 0),
1335
+ # "top_keywords": page.get("top_keywords", ""),
1336
+ # "load_time": page.get("load_time", 0),
1337
+ # "seo_score": page.get("seo_score", 0)
1338
+ # }
1339
+ # all_pages_summary.append(page_summary)
1340
+
1341
+ # # Sort pages by score (highest first)
1342
+ # all_pages_summary.sort(key=lambda x: x["seo_score"], reverse=True)
1343
+
1344
+ # # Analyze strengths
1345
+ # good_titles = sum(1 for page in results if page.get('title') and 45 <= len(page['title']) <= 65)
1346
+ # good_meta = sum(1 for page in results if page.get('meta_description') and 120 <= len(page['meta_description']) <= 155)
1347
+ # mobile_friendly = sum(1 for page in results if page.get('viewport_present'))
1348
+ # has_canonical = sum(1 for page in results if page.get('canonical_tag'))
1349
+ # has_schema = sum(1 for page in results if page.get('schema_types') and page['schema_types'] != "No schema found")
1350
+ # proper_h1 = sum(1 for page in results if page.get('h1_count', 0) == 1)
1351
+ # good_images = sum(1 for page in results if page.get('missing_alt_tags', 0) == 0)
1352
+ # good_wordcount = sum(1 for page in results if page.get('word_count', 0) >= 500)
1353
+
1354
+ # if good_titles > 0:
1355
+ # strengths.append({
1356
+ # "title": "Optimized Title Tags",
1357
+ # "detail": f"{good_titles} pages have well-optimized title tags",
1358
+ # "impact": "high"
1359
+ # })
1360
+
1361
+ # if good_meta > 0:
1362
+ # strengths.append({
1363
+ # "title": "Proper Meta Descriptions",
1364
+ # "detail": f"{good_meta} pages have well-formatted meta descriptions",
1365
+ # "impact": "high"
1366
+ # })
1367
+
1368
+ # if mobile_friendly > 0:
1369
+ # strengths.append({
1370
+ # "title": "Mobile Responsive",
1371
+ # "detail": f"{mobile_friendly} pages are mobile-friendly",
1372
+ # "impact": "high"
1373
+ # })
1374
+
1375
+ # if proper_h1 > 0:
1376
+ # strengths.append({
1377
+ # "title": "Proper H1 Structure",
1378
+ # "detail": f"{proper_h1} pages have correct H1 heading structure",
1379
+ # "impact": "high"
1380
+ # })
1381
+
1382
+ # # Analyze faults
1383
+ # title_issues = sum(1 for page in results if not page.get('title') or len(page.get('title', '')) < 45 or len(page.get('title', '')) > 65)
1384
+ # meta_issues = sum(1 for page in results if not page.get('meta_description') or len(page.get('meta_description', '')) < 120 or len(page.get('meta_description', '')) > 155)
1385
+ # missing_alt = sum(page.get('missing_alt_tags', 0) for page in results)
1386
+ # h1_issues = sum(1 for page in results if page.get('h1_count', 0) != 1)
1387
+ # schema_issues = sum(1 for page in results if not page.get('schema_types') or page['schema_types'] == "No schema found")
1388
+
1389
+ # if title_issues > 0:
1390
+ # faults.append({
1391
+ # "title": "Title Tag Issues",
1392
+ # "detail": f"{title_issues} pages need title optimization",
1393
+ # "severity": "high",
1394
+ # "pages": title_issues
1395
+ # })
1396
+
1397
+ # if meta_issues > 0:
1398
+ # faults.append({
1399
+ # "title": "Meta Description Problems",
1400
+ # "detail": f"{meta_issues} pages need meta description improvements",
1401
+ # "severity": "high",
1402
+ # "pages": meta_issues
1403
+ # })
1404
+
1405
+ # if missing_alt > 0:
1406
+ # faults.append({
1407
+ # "title": "Missing Alt Text",
1408
+ # "detail": f"{missing_alt} images missing alt text",
1409
+ # "severity": "medium",
1410
+ # "pages": sum(1 for page in results if page.get('missing_alt_tags', 0) > 0)
1411
+ # })
1412
+
1413
+ # if h1_issues > 0:
1414
+ # faults.append({
1415
+ # "title": "H1 Structure Issues",
1416
+ # "detail": f"{h1_issues} pages have improper H1 structure",
1417
+ # "severity": "high",
1418
+ # "pages": h1_issues
1419
+ # })
1420
+
1421
+ # # Extract AI suggestions
1422
+ # if results and 'error' not in results[0] and use_ai:
1423
+ # for page in results:
1424
+ # if page.get('ai_suggestions') and page['ai_suggestions'] not in [
1425
+ # "AI suggestions disabled - set valid OPENAI_API_KEY",
1426
+ # "AI disabled - set valid OPENAI_API_KEY",
1427
+ # "AI suggestions disabled - set use_ai=True and OPENAI_API_KEY"
1428
+ # ]:
1429
+ # # Extract key suggestions from AI output
1430
+ # ai_text = page['ai_suggestions']
1431
+
1432
+ # # Take first few lines as suggestions
1433
+ # lines = [line.strip() for line in ai_text.split('\n') if line.strip() and len(line.strip()) > 20]
1434
+ # for line in lines[:3]:
1435
+ # suggestions.append({
1436
+ # "title": "SEO Recommendation",
1437
+ # "detail": line[:150],
1438
+ # "priority": "high"
1439
+ # })
1440
+
1441
+ # # Limit suggestions
1442
+ # suggestions = suggestions[:5]
1443
+
1444
+ # return {
1445
+ # "status": "success",
1446
+ # "url": url,
1447
+ # "pages_analyzed": total_pages,
1448
+ # "overall_score": avg_score,
1449
+ # "strengths": strengths,
1450
+ # "faults": faults,
1451
+ # "suggestions": suggestions,
1452
+ # # Return ALL pages summary with ALL metrics
1453
+ # "results_preview": all_pages_summary,
1454
+ # "csv_download": f"/download/{os.path.basename(csv_path)}" if results and 'error' not in results[0] else None
1455
+ # }
1456
+
1457
+ # except Exception as e:
1458
+ # print(f"❌ Analysis error: {e}")
1459
+ # return {
1460
+ # "status": "error",
1461
+ # "message": str(e)
1462
+ # }
1463
+
1464
+ # @app.get("/download/{filename}")
1465
+ # async def download_file(filename: str):
1466
+ # """Download SEO analysis CSV report"""
1467
+ # file_path = f"/tmp/{filename}"
1468
+ # if os.path.exists(file_path):
1469
+ # return FileResponse(
1470
+ # file_path,
1471
+ # media_type='text/csv',
1472
+ # filename=f"seo_report.csv"
1473
+ # )
1474
+ # raise HTTPException(status_code=404, detail="File not found")
1475
+
1476
+ # if __name__ == "__main__":
1477
+ # import uvicorn
1478
+ # print("🚀 Starting SEO Analysis API...")
1479
+ # uvicorn.run(app, host="0.0.0.0", port=7860)
requirements.txt ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi==0.104.1
2
+ uvicorn==0.24.0
3
+ beautifulsoup4==4.12.2
4
+ aiohttp==3.9.1
5
+ textstat==0.7.3
6
+ playwright==1.40.0
7
+ pandas==2.1.3
8
+ lxml==4.9.3
9
+ requests==2.31.0
10
+ nest-asyncio==1.5.8
11
+ language-tool-python==2.7
12
+ python-multipart==0.0.6
13
+ openai==0.28.1
14
+ httpx==0.24.1
seo_analyzer.py ADDED
@@ -0,0 +1,1532 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import csv
3
+ import json
4
+ import re
5
+ import time
6
+ import uuid
7
+ import asyncio
8
+ import aiohttp
9
+ from bs4 import BeautifulSoup
10
+ from urllib.parse import urljoin, urlparse
11
+ from collections import Counter
12
+ import textstat
13
+ import concurrent.futures
14
+ from dotenv import load_dotenv
15
+ load_dotenv()
16
+
17
+ # Apply nest_asyncio for compatibility
18
+ import nest_asyncio
19
+ nest_asyncio.apply()
20
+ print("✅ nest_asyncio applied for compatibility")
21
+
22
+ # Playwright imports
23
+ from playwright.async_api import async_playwright
24
+ PLAYWRIGHT_AVAILABLE = True
25
+
26
+ # Optional grammar check
27
+ try:
28
+ import language_tool_python
29
+ LT_AVAILABLE = True
30
+ except Exception:
31
+ LT_AVAILABLE = False
32
+ print("⚠️ language_tool_python not available")
33
+
34
+ HEADERS = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}
35
+
36
+ # ==============================
37
+ # SET YOUR OPENAI API KEY HERE
38
+ # ==============================
39
+ OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY")
40
+ if OPENAI_API_KEY:
41
+ print("✅ OpenAI API Key loaded from environment")
42
+ else:
43
+ print("⚠️ OPENAI_API_KEY not set - AI features will be disabled")
44
+
45
+ # ==============================
46
+ # OPENAI CLIENT - COMPATIBLE VERSION (0.28.1)
47
+ # ==============================
48
+ _openai_client = None
49
+ try:
50
+ import openai
51
+ OPENAI_AVAILABLE = True
52
+ _openai_client = openai.OpenAI(api_key=os.environ.get("OPENAI_API_KEY"))
53
+ print("✅ OpenAI client initialized successfully")
54
+ except Exception as e:
55
+ OPENAI_AVAILABLE = False
56
+ print(f"⚠️ OpenAI not available: {e}")
57
+
58
+ # ==============================
59
+ # IMPROVED URL DISCOVERY WITH BETTER ERROR HANDLING
60
+ # ==============================
61
+ async def get_sitemap_links_parallel(base_url):
62
+ """Get all URLs from sitemap with parallel processing - IMPROVED"""
63
+ sitemap_urls = [
64
+ urljoin(base_url, "sitemap.xml"),
65
+ urljoin(base_url, "sitemap_index.xml"),
66
+ urljoin(base_url, "sitemap-0.xml"),
67
+ urljoin(base_url, "sitemap.txt"),
68
+ urljoin(base_url, "sitemap")
69
+ ]
70
+
71
+ async def fetch_sitemap(url):
72
+ try:
73
+ print(f" Trying sitemap: {url}")
74
+ async with aiohttp.ClientSession() as session:
75
+ async with session.get(url, headers=HEADERS, timeout=15) as response:
76
+ if response.status == 200:
77
+ content_type = response.headers.get('content-type', '').lower()
78
+ text = await response.text()
79
+
80
+ # Check if it's XML sitemap
81
+ if 'xml' in content_type or '<?xml' in text.lower():
82
+ soup = BeautifulSoup(text, "xml")
83
+ urls = [loc.text.strip() for loc in soup.find_all("loc") if loc.text.strip()]
84
+ print(f" ✅ Found {len(urls)} URLs in {url}")
85
+ return urls
86
+ # Check if it's text sitemap
87
+ elif 'text/plain' in content_type or '\n' in text:
88
+ urls = [line.strip() for line in text.split('\n') if line.strip() and line.startswith('http')]
89
+ print(f" ✅ Found {len(urls)} URLs in text sitemap")
90
+ return urls
91
+ except Exception as e:
92
+ print(f" ❌ Sitemap {url} failed: {str(e)[:100]}")
93
+ return []
94
+
95
+ # Try all sitemap URLs in parallel
96
+ tasks = [fetch_sitemap(url) for url in sitemap_urls]
97
+ results = await asyncio.gather(*tasks)
98
+
99
+ # Flatten and deduplicate
100
+ all_urls = set()
101
+ for url_list in results:
102
+ if url_list: # Only extend if not None or empty
103
+ all_urls.update(url_list)
104
+
105
+ return list(all_urls)
106
+
107
+ async def discover_urls_parallel(base_url, max_pages=20):
108
+ """Discover URLs through sitemap and light crawling - IMPROVED ERROR HANDLING"""
109
+ print("🔍 Discovering URLs...")
110
+
111
+ try:
112
+ # Get sitemap URLs first (fastest)
113
+ sitemap_urls = await get_sitemap_links_parallel(base_url)
114
+
115
+ if sitemap_urls:
116
+ print(f"📄 Found {len(sitemap_urls)} URLs in sitemap")
117
+
118
+ # FILTER OUT XML FILES - ONLY KEEP HTML PAGES
119
+ html_urls = []
120
+ for url in sitemap_urls:
121
+ # Skip XML files, sitemaps, RSS feeds, etc.
122
+ if any(xml_pattern in url.lower() for xml_pattern in ['.xml', 'sitemap', 'rss', 'feed']):
123
+ continue
124
+ # Skip other non-HTML files
125
+ if any(non_html in url.lower() for non_html in ['.pdf', '.jpg', '.png', '.gif', '.css', '.js']):
126
+ continue
127
+ html_urls.append(url)
128
+
129
+ print(f"🔧 Filtered to {len(html_urls)} HTML pages (removed {len(sitemap_urls) - len(html_urls)} non-HTML files)")
130
+
131
+ if html_urls:
132
+ return html_urls[:max_pages]
133
+ else:
134
+ print("⚠️ No HTML pages found in sitemap, falling back to homepage crawl...")
135
+
136
+ # Fallback: light crawl from homepage - ONLY GET HTML PAGES
137
+ print("🕷️ No valid HTML pages in sitemap, crawling from homepage...")
138
+ async with async_playwright() as p:
139
+ browser = await p.chromium.launch(headless=True)
140
+ context = await browser.new_context()
141
+ page = await context.new_page()
142
+
143
+ print(f" Navigating to {base_url}...")
144
+ await page.goto(base_url, wait_until='domcontentloaded', timeout=30000)
145
+
146
+ # Extract all internal links - FILTER FOR HTML PAGES
147
+ links = await page.evaluate("""(baseDomain) => {
148
+ const allLinks = Array.from(document.links)
149
+ .map(link => link.href)
150
+ .filter(href => href && href.includes(baseDomain))
151
+ .filter(href => !href.includes('#') && !href.includes('javascript:'))
152
+ // FILTER OUT NON-HTML FILES
153
+ .filter(href => !href.includes('.xml') && !href.includes('sitemap') && !href.includes('rss') && !href.includes('feed'))
154
+ .filter(href => !href.match(/\\.(pdf|jpg|png|gif|css|js|json)$/i))
155
+ .slice(0, 50);
156
+ return [...new Set(allLinks)]; // Remove duplicates
157
+ }""", urlparse(base_url).netloc)
158
+
159
+ await browser.close()
160
+
161
+ if links:
162
+ print(f" Found {len(links)} internal HTML links")
163
+ result_urls = [base_url] + links[:max_pages-1]
164
+ return result_urls
165
+ else:
166
+ print(" No internal links found, using only homepage")
167
+ return [base_url]
168
+
169
+ except Exception as e:
170
+ print(f"❌ URL discovery failed: {e}")
171
+ print(" Using fallback: homepage only")
172
+ return [base_url] # Always return at least homepage
173
+
174
+ # ==============================
175
+ # PARALLEL PLAYWRIGHT FETCHER
176
+ # ==============================
177
+ async def fetch_page_playwright(url, browser, timeout=25000):
178
+ """Fetch a single page with Playwright"""
179
+ context = await browser.new_context(
180
+ viewport={'width': 1920, 'height': 1080},
181
+ user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
182
+ java_script_enabled=True
183
+ )
184
+
185
+ # Block unnecessary resources for speed
186
+ await context.route("**/*.{png,jpg,jpeg,gif,svg,webp}", lambda route: route.abort())
187
+ await context.route("**/*.css", lambda route: route.abort())
188
+
189
+ page = await context.new_page()
190
+
191
+ try:
192
+ # Longer timeout
193
+ print(f" 📡 Fetching: {url}")
194
+ await page.goto(url, wait_until='domcontentloaded', timeout=timeout)
195
+
196
+ # Wait for critical content
197
+ await page.wait_for_selector('body', timeout=15000)
198
+
199
+ # Extract comprehensive SEO data
200
+ seo_data = await page.evaluate("""() => {
201
+ // Get all meta tags
202
+ const metas = {};
203
+ document.querySelectorAll('meta').forEach(meta => {
204
+ const name = meta.getAttribute('name') || meta.getAttribute('property');
205
+ if (name) metas[name] = meta.getAttribute('content');
206
+ });
207
+
208
+ // Get all images with detailed info
209
+ const images = Array.from(document.images).map(img => ({
210
+ src: img.src,
211
+ alt: img.alt || '',
212
+ naturalWidth: img.naturalWidth,
213
+ naturalHeight: img.naturalHeight,
214
+ complete: img.complete
215
+ }));
216
+
217
+ // Get all links
218
+ const links = Array.from(document.links).map(link => ({
219
+ href: link.href,
220
+ text: link.textContent?.slice(0, 100) || '',
221
+ rel: link.rel
222
+ }));
223
+
224
+ // Get schema data
225
+ const schemas = [];
226
+ document.querySelectorAll('script[type="application/ld+json"]').forEach(script => {
227
+ try {
228
+ if (script.textContent) {
229
+ const data = JSON.parse(script.textContent);
230
+ schemas.push(data);
231
+ }
232
+ } catch (e) {}
233
+ });
234
+
235
+ return {
236
+ url: window.location.href,
237
+ title: document.title,
238
+ metas: metas,
239
+ description: metas.description || metas['og:description'] || '',
240
+ canonical: document.querySelector('link[rel="canonical"]')?.href || '',
241
+ robots: metas.robots || '',
242
+ viewport: metas.viewport || '',
243
+ h1_count: document.querySelectorAll('h1').length,
244
+ h2_count: document.querySelectorAll('h2').length,
245
+ h3_count: document.querySelectorAll('h3').length,
246
+ images: images,
247
+ links: links,
248
+ schemas: schemas,
249
+ html: document.documentElement.outerHTML,
250
+ opengraph: {
251
+ title: metas['og:title'] || '',
252
+ description: metas['og:description'] || '',
253
+ image: metas['og:image'] || '',
254
+ url: metas['og:url'] || ''
255
+ },
256
+ twitter: {
257
+ title: metas['twitter:title'] || '',
258
+ description: metas['twitter:description'] || '',
259
+ image: metas['twitter:image'] || '',
260
+ card: metas['twitter:card'] || ''
261
+ }
262
+ };
263
+ }""")
264
+
265
+ print(f" ✅ Success: {url}")
266
+ return seo_data
267
+
268
+ except Exception as e:
269
+ print(f" ❌ Failed: {url} - {str(e)[:100]}...")
270
+ return None
271
+ finally:
272
+ await context.close()
273
+
274
+ async def fetch_all_pages_parallel(urls, max_concurrent=1):
275
+ """Fetch multiple pages in parallel"""
276
+ if not urls:
277
+ print("❌ No URLs to fetch!")
278
+ return []
279
+
280
+ print(f"🚀 Launching {max_concurrent} browsers for parallel fetching...")
281
+ print(f"📡 Fetching {len(urls)} pages...")
282
+
283
+ async with async_playwright() as p:
284
+ # Launch browser with optimized settings
285
+ browser = await p.chromium.launch(
286
+ headless=True,
287
+ args=[
288
+ '--disable-gpu',
289
+ '--disable-dev-shm-usage',
290
+ '--disable-setuid-sandbox',
291
+ '--no-first-run',
292
+ '--no-sandbox',
293
+ '--no-zygote',
294
+ '--deterministic-fetch',
295
+ '--max_old_space_size=4096'
296
+ ]
297
+ )
298
+
299
+ # Create semaphore for concurrency control
300
+ semaphore = asyncio.Semaphore(max_concurrent)
301
+
302
+ async def fetch_with_semaphore(url):
303
+ async with semaphore:
304
+ return await fetch_page_playwright(url, browser)
305
+
306
+ # Fetch all pages in parallel with progress
307
+ tasks = [fetch_with_semaphore(url) for url in urls]
308
+ results = []
309
+
310
+ for i, task in enumerate(asyncio.as_completed(tasks)):
311
+ result = await task
312
+ results.append(result)
313
+ if (i + 1) % 2 == 0 or (i + 1) == len(urls):
314
+ print(f" 📊 Progress: {i + 1}/{len(urls)} pages completed")
315
+
316
+ await browser.close()
317
+
318
+ # Filter out failed fetches
319
+ successful_results = [r for r in results if r is not None]
320
+ print(f"✅ Successfully fetched {len(successful_results)} out of {len(urls)} pages")
321
+
322
+ return successful_results
323
+
324
+ # ==============================
325
+ # PARALLEL SEO ANALYSIS - FIXED SCHEMA EXTRACTION
326
+ # ==============================
327
+ async def analyze_pages_parallel(playwright_data_list, domain):
328
+ """Analyze all pages in parallel"""
329
+ if not playwright_data_list:
330
+ print("❌ No data to analyze!")
331
+ return []
332
+
333
+ print("🔬 Analyzing pages in parallel...")
334
+
335
+ def analyze_single_page(seo_data):
336
+ """Analyze a single page's SEO data"""
337
+ try:
338
+ html = seo_data.get('html', '')
339
+ soup = BeautifulSoup(html, 'html.parser')
340
+
341
+ # Extract text content
342
+ text = soup.get_text(separator=" ", strip=True)
343
+
344
+ # Images analysis
345
+ images_data = seo_data.get('images', [])
346
+ total_images = len(images_data)
347
+ missing_alt = len([img for img in images_data if not img.get('alt')])
348
+
349
+ # Links analysis
350
+ links_data = seo_data.get('links', [])
351
+ internal_links = len([link for link in links_data if domain in link.get('href', '')])
352
+ external_links = len([link for link in links_data if domain not in link.get('href', '')])
353
+
354
+ # ==============================
355
+ # FIXED SCHEMA EXTRACTION - PROPERLY INDENTED
356
+ # ==============================
357
+ schemas = seo_data.get('schemas', [])
358
+ schema_types = []
359
+
360
+ for schema in schemas:
361
+ try:
362
+ # Handle different schema formats
363
+ if isinstance(schema, dict):
364
+ # Direct schema object
365
+ if '@type' in schema:
366
+ schema_types.append(schema['@type'])
367
+ # Schema with @graph
368
+ if '@graph' in schema and isinstance(schema['@graph'], list):
369
+ for item in schema['@graph']:
370
+ if isinstance(item, dict) and '@type' in item:
371
+ schema_types.append(item['@type'])
372
+ elif isinstance(schema, list):
373
+ # Array of schemas
374
+ for item in schema:
375
+ if isinstance(item, dict) and '@type' in item:
376
+ schema_types.append(item['@type'])
377
+ except Exception as e:
378
+ print(f" Schema parsing error: {e}")
379
+
380
+ # Also check for microdata and other schema formats in HTML
381
+ try:
382
+ # Check for microdata
383
+ microdata = soup.find_all(attrs={"itemtype": True})
384
+ for item in microdata:
385
+ itemtype = item.get('itemtype', '')
386
+ if itemtype:
387
+ schema_types.append(itemtype.split('/')[-1]) # Get just the type name
388
+
389
+ # Check for other schema script tags
390
+ schema_scripts = soup.find_all('script', type=lambda x: x and 'ld+json' in x)
391
+ for script in schema_scripts:
392
+ try:
393
+ if script.string:
394
+ data = json.loads(script.string)
395
+ if isinstance(data, dict) and '@type' in data:
396
+ schema_types.append(data['@type'])
397
+ elif isinstance(data, list):
398
+ for item in data:
399
+ if isinstance(item, dict) and '@type' in item:
400
+ schema_types.append(item['@type'])
401
+ except:
402
+ pass
403
+
404
+ except Exception as e:
405
+ print(f" HTML schema extraction error: {e}")
406
+
407
+ # Deduplicate schema types
408
+ schema_types = list(set(schema_types))
409
+ # ==============================
410
+ # END OF FIXED SCHEMA EXTRACTION
411
+ # ==============================
412
+
413
+ # Metrics
414
+ try:
415
+ readability_score = textstat.flesch_reading_ease(text)
416
+ except Exception:
417
+ readability_score = 0
418
+
419
+ word_count = len(text.split())
420
+
421
+ # Grammar errors (optional)
422
+ grammar_errors = 0
423
+ if LT_AVAILABLE:
424
+ try:
425
+ tool = language_tool_python.LanguageTool('en-US')
426
+ grammar_errors = len(tool.check(text[:1000]))
427
+ tool.close()
428
+ except Exception:
429
+ pass
430
+
431
+ # Keyword density
432
+ top_keywords = keyword_density(text)
433
+ text_to_html_ratio = round((len(text) / len(html)) * 100, 2) if html else 0
434
+
435
+ # Compile page data
436
+ page = {
437
+ "url": seo_data.get('url', ''),
438
+ "title": seo_data.get('title', ''),
439
+ "meta_description": seo_data.get('description', ''),
440
+ "h1_count": seo_data.get('h1_count', 0),
441
+ "h2_count": seo_data.get('h2_count', 0),
442
+ "h3_count": seo_data.get('h3_count', 0),
443
+ "heading_order": get_heading_order(soup),
444
+ "missing_alt_tags": missing_alt,
445
+ "total_images": total_images,
446
+ "small_images": len([img for img in images_data if img.get('naturalWidth', 0) < 100]),
447
+ "large_images": len([img for img in images_data if img.get('naturalWidth', 0) > 2000]),
448
+ "ideal_images": len([img for img in images_data if 100 <= img.get('naturalWidth', 0) <= 2000]),
449
+ "internal_links": internal_links,
450
+ "external_links": external_links,
451
+ "canonical_tag": bool(seo_data.get('canonical')),
452
+ "robots_meta": seo_data.get('robots', ''),
453
+ "viewport_present": 'width' in seo_data.get('viewport', ''),
454
+ "schema_types": ", ".join(schema_types) if schema_types else "No schema found",
455
+ "opengraph_tags": count_opengraph_tags(seo_data.get('metas', {})),
456
+ "twitter_tags": count_twitter_tags(seo_data.get('metas', {})),
457
+ "word_count": word_count,
458
+ "readability_score": readability_score,
459
+ "grammar_errors": grammar_errors,
460
+ "text_to_html_ratio": text_to_html_ratio,
461
+ "top_keywords": top_keywords,
462
+ "load_time": 0,
463
+ }
464
+
465
+ return page
466
+
467
+ except Exception as e:
468
+ print(f"❌ Analysis error for {seo_data.get('url', 'unknown')}: {e}")
469
+ return None
470
+
471
+ # Run analysis in parallel using ThreadPoolExecutor
472
+ loop = asyncio.get_event_loop()
473
+ with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
474
+ tasks = [
475
+ loop.run_in_executor(executor, analyze_single_page, data)
476
+ for data in playwright_data_list
477
+ ]
478
+ results = await asyncio.gather(*tasks)
479
+
480
+ # Filter out failed analyses
481
+ successful_results = [r for r in results if r is not None]
482
+ print(f"✅ Successfully analyzed {len(successful_results)} pages")
483
+ return successful_results
484
+
485
+ def get_heading_order(soup):
486
+ """Extract heading order from BeautifulSoup"""
487
+ headings = soup.find_all(re.compile('^h[1-6]$'))
488
+ return ", ".join([h.name for h in headings])
489
+
490
+ def count_opengraph_tags(metas):
491
+ """Count OpenGraph tags"""
492
+ return len([k for k in metas.keys() if k.startswith('og:')])
493
+
494
+ def count_twitter_tags(metas):
495
+ """Count Twitter card tags"""
496
+ return len([k for k in metas.keys() if k.startswith('twitter:')])
497
+
498
+ def keyword_density(text):
499
+ """Calculate keyword density"""
500
+ words = re.findall(r'\b\w+\b', (text or "").lower())
501
+ freq = Counter(w for w in words if len(w) > 3)
502
+ total = sum(freq.values()) or 1
503
+ items = sorted([(k, round(v / total * 100, 2)) for k, v in freq.items() if v > 1],
504
+ key=lambda x: -x[1])[:10]
505
+ return ", ".join([f"{k}:{p}%" for k, p in items])
506
+
507
+ # ==============================
508
+ # ULTRA-SPECIFIC AI SUGGESTIONS - WITH EXACT REPLACEMENTS
509
+ # ==============================
510
+ async def generate_page_suggestions_async(page_data):
511
+ """Generate ULTRA-SPECIFIC AI suggestions with exact replacements"""
512
+ api_key = os.environ.get("OPENAI_API_KEY")
513
+
514
+ if not api_key or api_key == "YOUR_OPENAI_API_KEY_HERE" or not OPENAI_AVAILABLE:
515
+ return "AI disabled - set valid OPENAI_API_KEY"
516
+
517
+ # Extract detailed page data
518
+ url = page_data.get('url', 'Unknown URL')
519
+ title = page_data.get('title', '')
520
+ meta_description = page_data.get('meta_description', '')
521
+ seo_score = page_data.get('seo_score', 0)
522
+ h1_count = page_data.get('h1_count', 0)
523
+ h2_count = page_data.get('h2_count', 0)
524
+ h3_count = page_data.get('h3_count', 0)
525
+ word_count = page_data.get('word_count', 0)
526
+ readability_score = page_data.get('readability_score', 0)
527
+ missing_alt_tags = page_data.get('missing_alt_tags', 0)
528
+ total_images = page_data.get('total_images', 0)
529
+ schema_types = page_data.get('schema_types', '')
530
+ internal_links = page_data.get('internal_links', 0)
531
+ external_links = page_data.get('external_links', 0)
532
+ opengraph_tags = page_data.get('opengraph_tags', 0)
533
+ twitter_tags = page_data.get('twitter_tags', 0)
534
+ top_keywords = page_data.get('top_keywords', '')
535
+ heading_order = page_data.get('heading_order', '')
536
+
537
+ # ULTRA-SPECIFIC PROMPT - Demands exact replacements
538
+ prompt = f"""
539
+ You are an expert technical SEO consultant. Analyze this page and provide EXACT, ACTIONABLE recommendations with SPECIFIC REPLACEMENTS.
540
+
541
+ CRITICAL REQUIREMENTS:
542
+ - Provide EXACT replacement text for bad titles, meta descriptions, etc.
543
+ - Give SPECIFIC OpenGraph and Twitter Card markup when missing
544
+ - Provide EXACT schema markup code when missing
545
+ - Give SPECIFIC H1 text when missing
546
+ - Provide EXACT alt text examples for images
547
+
548
+ PAGE DATA:
549
+ URL: {url}
550
+ Current SEO Score: {seo_score}/100
551
+
552
+ CURRENT CONTENT:
553
+ - Title: "{title}" ({len(title)} chars)
554
+ - Meta Description: "{meta_description}" ({len(meta_description)} chars)
555
+ - H1 Count: {h1_count} | H2 Count: {h2_count} | H3 Count: {h3_count}
556
+ - Word Count: {word_count} words
557
+ - Readability: {readability_score}/100
558
+ - Missing Alt Tags: {missing_alt_tags} of {total_images} images
559
+ - Schema: {schema_types}
560
+ - Internal Links: {internal_links} | External Links: {external_links}
561
+ - OpenGraph Tags: {opengraph_tags} | Twitter Cards: {twitter_tags}
562
+ - Top Keywords: {top_keywords}
563
+ - Heading Structure: {heading_order}
564
+
565
+ Provide recommendations in this EXACT format:
566
+
567
+ HIGH IMPACT:
568
+ 1. TITLE OPTIMIZATION:
569
+ Current: "{title}" ({len(title)} chars)
570
+ REPLACE WITH: "[Exact new title text - 55-60 characters]"
571
+
572
+ 2. META DESCRIPTION:
573
+ Current: "{meta_description}" ({len(meta_description)} chars)
574
+ REPLACE WITH: "[Exact new meta description - 150-155 characters]"
575
+
576
+ 3. H1 TAG:
577
+ Current: {h1_count} H1 tags
578
+ ADD THIS EXACT H1: "[Exact H1 text with primary keyword]"
579
+
580
+ MEDIUM IMPACT:
581
+ 4. OPENGRAPH TAGS (Missing {8 - opengraph_tags} tags):
582
+ ADD THIS EXACT MARKUP:
583
+ <meta property="og:title" content="[Exact og:title]">
584
+ <meta property="og:description" content="[Exact og:description]">
585
+ <meta property="og:image" content="[Suggested image URL]">
586
+ <meta property="og:url" content="{url}">
587
+
588
+ 5. TWITTER CARDS (Missing {5 - twitter_tags} tags):
589
+ ADD THIS EXACT MARKUP:
590
+ <meta name="twitter:title" content="[Exact twitter:title]">
591
+ <meta name="twitter:description" content="[Exact twitter:description]">
592
+ <meta name="twitter:image" content="[Suggested image URL]">
593
+ <meta name="twitter:card" content="summary_large_image">
594
+
595
+ 6. SCHEMA MARKUP:
596
+ Current: {schema_types}
597
+ ADD THIS EXACT SCHEMA:
598
+ [Provide complete JSON-LD schema code]
599
+
600
+ LOW IMPACT:
601
+ 7. IMAGE ALT TEXT:
602
+ Missing alt text for {missing_alt_tags} images
603
+ EXAMPLE ALT TEXTS:
604
+ - "[Exact alt text for first image]"
605
+ - "[Exact alt text for second image]"
606
+
607
+ 8. CONTENT IMPROVEMENT:
608
+ Current: {word_count} words, {readability_score}/100 readability
609
+ ADD THIS EXACT CONTENT SECTION:
610
+ "[Specific content to add with exact paragraph]"
611
+
612
+ Provide EXACT text replacements - no generic advice!
613
+ """
614
+
615
+ try:
616
+ print(f" 🤖 Generating ULTRA-SPECIFIC suggestions for: {url[:50]}...")
617
+
618
+ # gpt-5-nano is a reasoning-family model: it only accepts
619
+ # max_completion_tokens (not max_tokens) and only the default
620
+ # temperature (1), so no temperature override is passed.
621
+ # Uses OpenAI 0.28.1 syntax (matches installed SDK version).
622
+ # response = await asyncio.get_event_loop().run_in_executor(
623
+ # None,
624
+ # lambda: openai.ChatCompletion.create(
625
+ # model="gpt-5-nano",
626
+ # messages=[
627
+ # {"role": "system", "content": "You are a technical SEO expert who provides EXACT replacement text and markup. Always give specific examples and complete code snippets. No generic advice allowed."},
628
+ # {"role": "user", "content": prompt}
629
+ # ],
630
+ # max_completion_tokens=800,
631
+ # )
632
+ # )
633
+ response = await asyncio.get_event_loop().run_in_executor(
634
+ None,
635
+ lambda: _openai_client.chat.completions.create(
636
+ model="gpt-5-nano",
637
+ messages=[
638
+ {"role": "system", "content": "You are a technical SEO expert who provides EXACT replacement text and markup. Always give specific examples and complete code snippets. No generic advice allowed."},
639
+ {"role": "user", "content": prompt}
640
+ ],
641
+ max_completion_tokens=6000,
642
+ reasoning_effort="low",
643
+ )
644
+ )
645
+
646
+ ai_suggestion = response.choices[0].message.content.strip()
647
+ print(f" ✅ ULTRA-SPECIFIC suggestions generated for: {url[:50]}...")
648
+ return ai_suggestion
649
+
650
+ except Exception as e:
651
+ print(f" ❌ AI failed for {url[:50]}: {str(e)[:100]}")
652
+ return generate_ultra_specific_fallback(page_data)
653
+
654
+ def generate_ultra_specific_fallback(page_data):
655
+ """Generate ultra-specific fallback suggestions with exact examples"""
656
+ suggestions = []
657
+
658
+ url = page_data.get('url', '')
659
+ title = page_data.get('title', '')
660
+ meta_desc = page_data.get('meta_description', '')
661
+ h1_count = page_data.get('h1_count', 0)
662
+ word_count = page_data.get('word_count', 0)
663
+ missing_alt = page_data.get('missing_alt_tags', 0)
664
+ schema_types = page_data.get('schema_types', '')
665
+ opengraph_tags = page_data.get('opengraph_tags', 0)
666
+ twitter_tags = page_data.get('twitter_tags', 0)
667
+
668
+ # Extract domain for context
669
+ domain = urlparse(url).netloc.replace('www.', '')
670
+ site_name = domain.split('.')[0].title()
671
+
672
+ # HIGH IMPACT - EXACT REPLACEMENTS
673
+ if h1_count == 0:
674
+ page_topic = url.split('/')[-1].replace('-', ' ').title()
675
+ suggestions.append(f"HIGH: ADD EXACT H1: '{page_topic} - Complete Guide | {site_name}'")
676
+
677
+ title_len = len(title)
678
+ if title_len < 45 or title_len > 65:
679
+ page_topic = url.split('/')[-1].replace('-', ' ').title()
680
+ suggestions.append(f"HIGH: REPLACE TITLE: '{page_topic} - Complete {site_name} Guide 2024'")
681
+
682
+ if not meta_desc or len(meta_desc) < 50:
683
+ page_topic = url.split('/')[-1].replace('-', ' ').title()
684
+ suggestions.append(f"HIGH: REPLACE META: 'Learn everything about {page_topic.lower()} with our complete guide. Get expert tips, best practices, and step-by-step instructions from {site_name}.'")
685
+
686
+ # MEDIUM IMPACT - EXACT MARKUP
687
+ if opengraph_tags < 4:
688
+ page_topic = url.split('/')[-1].replace('-', ' ').title()
689
+ suggestions.append(f"MEDIUM: ADD OPENGraph:\n<meta property=\"og:title\" content=\"{page_topic} - {site_name}\">\n<meta property=\"og:description\" content=\"Complete guide to {page_topic.lower()} with expert insights\">\n<meta property=\"og:image\" content=\"https://{domain}/images/{page_topic.lower().replace(' ', '-')}.jpg\">")
690
+
691
+ if twitter_tags < 3:
692
+ page_topic = url.split('/')[-1].replace('-', ' ').title()
693
+ suggestions.append(f"MEDIUM: ADD TWITTER CARDS:\n<meta name=\"twitter:title\" content=\"{page_topic} Guide\">\n<meta name=\"twitter:description\" content=\"Master {page_topic.lower()} with {site_name}'s expert guide\">\n<meta name=\"twitter:card\" content=\"summary_large_image\">")
694
+
695
+ if schema_types == "No schema found":
696
+ page_topic = url.split('/')[-1].replace('-', ' ').title()
697
+ suggestions.append(f"MEDIUM: ADD SCHEMA:\n<script type=\"application/ld+json\">\n{{\n \"@context\": \"https://schema.org\",\n \"@type\": \"Article\",\n \"headline\": \"{page_topic} Complete Guide\",\n \"description\": \"Expert guide to {page_topic.lower()} with best practices\",\n \"author\": {{\n \"@type\": \"Organization\",\n \"name\": \"{site_name}\"\n }}\n}}\n</script>")
698
+
699
+ # LOW IMPACT - EXACT EXAMPLES
700
+ if missing_alt > 0:
701
+ page_topic = url.split('/')[-1].replace('-', ' ').title()
702
+ suggestions.append(f"LOW: ADD ALT TEXTS:\n- \"{page_topic} diagram and explanation\"\n- \"Step-by-step {page_topic.lower()} process visualization\"\n- \"{site_name} {page_topic} tutorial screenshot\"")
703
+
704
+ if word_count < 800:
705
+ suggestions.append(f"LOW: ADD CONTENT SECTION:\n\"In this comprehensive guide, we'll cover the essential aspects of {page_topic.lower()} including best practices, common pitfalls to avoid, and actionable strategies you can implement immediately. Whether you're a beginner or looking to advanced your skills, this guide provides the foundation you need for success.\"")
706
+
707
+ return "\n\n".join(suggestions) if suggestions else "All major elements optimized - focus on internal linking and user experience"
708
+
709
+ async def generate_all_page_suggestions_parallel(pages):
710
+ """Generate ULTRA-SPECIFIC AI suggestions for ALL pages in parallel"""
711
+ suggestions = {}
712
+
713
+ print(f"🤖 Generating ULTRA-SPECIFIC AI suggestions for {len(pages)} pages in parallel...")
714
+
715
+ # Create tasks for ALL pages
716
+ tasks = []
717
+ for i, page in enumerate(pages):
718
+ task = generate_page_suggestions_async(page)
719
+ tasks.append((i, task))
720
+
721
+ # Run ALL AI calls concurrently
722
+ if tasks:
723
+ coroutines = [task for _, task in tasks]
724
+ results = await asyncio.gather(*coroutines, return_exceptions=True)
725
+
726
+ # Map results back to pages
727
+ for result_idx, (page_idx, _) in enumerate(tasks):
728
+ result = results[result_idx]
729
+ if isinstance(result, Exception):
730
+ print(f" ❌ AI failed for page {page_idx}, using ultra-specific fallback")
731
+ suggestions[page_idx] = generate_ultra_specific_fallback(pages[page_idx])
732
+ else:
733
+ suggestions[page_idx] = result
734
+
735
+ return suggestions
736
+
737
+ async def add_comprehensive_suggestions_async(results):
738
+ """Add comprehensive ULTRA-SPECIFIC AI suggestions to all pages"""
739
+ api_key = os.environ.get("OPENAI_API_KEY")
740
+
741
+ if not api_key or api_key == "YOUR_OPENAI_API_KEY_HERE" or not OPENAI_AVAILABLE:
742
+ print("⚠️ OPENAI_API_KEY not set — AI suggestions disabled.")
743
+ for p in results:
744
+ p["ai_suggestions"] = "AI suggestions disabled - set valid OPENAI_API_KEY"
745
+ return
746
+
747
+ print("🚀 Generating comprehensive ULTRA-SPECIFIC AI suggestions for all pages...")
748
+
749
+ # Get ULTRA-SPECIFIC AI suggestions
750
+ suggestions_dict = await generate_all_page_suggestions_parallel(results)
751
+
752
+ # Apply suggestions to pages
753
+ for i, p in enumerate(results):
754
+ ai_suggestion = suggestions_dict.get(i, "No AI suggestions generated")
755
+ p["ai_suggestions"] = ai_suggestion
756
+
757
+
758
+ # ==============================
759
+ # DETECT PAGE TYPE FUNCTION
760
+ # ==============================
761
+ def detect_page_type(url, page_data):
762
+ """Detect if page is homepage, article, category, etc."""
763
+ parsed = urlparse(url)
764
+ path = parsed.path.strip('/')
765
+
766
+ # Check if it's homepage
767
+ if not path or path == '' or path == 'index.html' or path == 'index.php':
768
+ return 'homepage'
769
+
770
+ # Check for common article patterns
771
+ article_patterns = ['/blog/', '/article/', '/news/', '/post/', '/2024/', '/2025/']
772
+ if any(pattern in url for pattern in article_patterns):
773
+ return 'article'
774
+
775
+ # Check for category/listing pages
776
+ category_patterns = ['/category/', '/tag/', '/topic/']
777
+ if any(pattern in url for pattern in category_patterns):
778
+ return 'category'
779
+
780
+ # Default
781
+ return 'standard'
782
+ # ==============================
783
+ # COMPREHENSIVE SCORING FUNCTION - FIXED VERSION
784
+ # ==============================
785
+ def calculate_seo_score(page):
786
+ """Calculate comprehensive SEO score with realistic thresholds"""
787
+ score = 0
788
+ max_score = 100
789
+
790
+ # Detect page type once for use throughout
791
+ url = page.get('url', '')
792
+ page_type = detect_page_type(url, page)
793
+
794
+ # ===== TITLE OPTIMIZATION (10 points) =====
795
+ title = page.get('title', '')
796
+ if title:
797
+ title_len = len(title)
798
+ if 50 <= title_len <= 60: # Perfect
799
+ score += 10
800
+ elif 45 <= title_len <= 65: # Good
801
+ score += 8
802
+ elif 30 <= title_len <= 70: # Acceptable
803
+ score += 6
804
+ elif title_len > 0: # Exists but poor
805
+ score += 3
806
+
807
+ # ===== META DESCRIPTION (8 points) =====
808
+ meta_desc = page.get('meta_description', '')
809
+ if meta_desc:
810
+ meta_len = len(meta_desc)
811
+ if 120 <= meta_len <= 155: # Perfect
812
+ score += 8
813
+ elif 100 <= meta_len <= 160: # Good
814
+ score += 6
815
+ elif 70 <= meta_len <= 170: # Acceptable
816
+ score += 4
817
+ elif meta_len > 0: # Exists but poor
818
+ score += 2
819
+
820
+ # ===== HEADING STRUCTURE (12 points) =====
821
+ h1_count = page.get('h1_count', 0)
822
+ heading_order = page.get('heading_order', '')
823
+
824
+ # H1 Score (6 points)
825
+ if h1_count == 1: # Perfect
826
+ score += 6
827
+ elif h1_count == 0: # Critical
828
+ score += 0
829
+ elif h1_count == 2: # Minor issue
830
+ score += 4
831
+ else: # Multiple H1s
832
+ score += 1
833
+
834
+ # FIXED: Heading Hierarchy (6 points) - More flexible
835
+ if heading_order:
836
+ headings = [h.strip() for h in heading_order.split(',')]
837
+ heading_levels = []
838
+
839
+ for heading in headings:
840
+ if heading.startswith('h'):
841
+ try:
842
+ level = int(heading[1])
843
+ heading_levels.append(level)
844
+ except:
845
+ continue
846
+
847
+ # More flexible heading structure scoring
848
+ has_h1 = 1 in heading_levels
849
+ has_h2 = 2 in heading_levels
850
+ has_h3 = 3 in heading_levels
851
+
852
+ # Check if headings follow a logical order
853
+ if has_h1 and (has_h2 or has_h3):
854
+ score += 6 # Full points for logical structure
855
+ elif has_h1:
856
+ score += 4 # Has H1 but no subheadings
857
+ elif has_h2 or has_h3:
858
+ score += 2 # No H1 but has other headings
859
+
860
+ # ===== IMAGE OPTIMIZATION (15 points) =====
861
+ total_images = page.get('total_images', 0)
862
+ missing_alt_tags = page.get('missing_alt_tags', 0)
863
+ small_images = page.get('small_images', 0)
864
+ large_images = page.get('large_images', 0)
865
+ ideal_images = page.get('ideal_images', 0)
866
+
867
+ # Alt Text Score (5 points)
868
+ if total_images == 0:
869
+ score += 5
870
+ else:
871
+ alt_ratio = (total_images - missing_alt_tags) / total_images
872
+ if alt_ratio >= 0.95:
873
+ score += 5
874
+ elif alt_ratio >= 0.80:
875
+ score += 4
876
+ elif alt_ratio >= 0.60:
877
+ score += 3
878
+ elif alt_ratio >= 0.40:
879
+ score += 2
880
+ elif alt_ratio > 0:
881
+ score += 1
882
+
883
+ # FIXED: Image Size Optimization (5 points) - Forgive small images
884
+ if total_images > 0:
885
+ # Don't penalize small images that might be icons/logos
886
+ # Assume first 2 small images could be logo/favicon/icons
887
+ forgiven_small_images = max(0, small_images - 2)
888
+ adjusted_total = total_images - forgiven_small_images
889
+
890
+ if adjusted_total > 0:
891
+ # Recalculate ideal ratio without penalized small images
892
+ ideal_ratio = ideal_images / adjusted_total if adjusted_total > 0 else 1
893
+
894
+ if ideal_ratio >= 0.7: # Slightly lowered threshold
895
+ score += 5
896
+ elif ideal_ratio >= 0.5:
897
+ score += 4
898
+ elif ideal_ratio >= 0.3:
899
+ score += 3
900
+ elif ideal_ratio >= 0.15:
901
+ score += 2
902
+ else:
903
+ score += 1
904
+ else:
905
+ score += 5 # All images are forgiven (probably icons/logos)
906
+ else:
907
+ score += 5
908
+
909
+ # Image Quantity (5 points)
910
+ if total_images == 0:
911
+ score += 3
912
+ elif 3 <= total_images <= 20:
913
+ score += 5
914
+ elif total_images <= 50:
915
+ score += 4
916
+ elif total_images <= 100:
917
+ score += 3
918
+ else:
919
+ score += 2
920
+
921
+ # ===== FIXED LINK STRUCTURE (10 points) =====
922
+ internal_links = page.get('internal_links', 0)
923
+ external_links = page.get('external_links', 0)
924
+
925
+ # Internal Links (6 points) - More realistic thresholds
926
+ if internal_links >= 30: # Was 80
927
+ score += 6
928
+ elif internal_links >= 20: # Was 60
929
+ score += 5
930
+ elif internal_links >= 10: # Was 40
931
+ score += 4
932
+ elif internal_links >= 5: # Was 20
933
+ score += 3
934
+ elif internal_links >= 3: # Was 10
935
+ score += 2
936
+ elif internal_links >= 1:
937
+ score += 1
938
+
939
+ # External Links (4 points) - Keep as is
940
+ if external_links >= 10:
941
+ score += 4
942
+ elif external_links >= 7:
943
+ score += 3
944
+ elif external_links >= 4:
945
+ score += 2
946
+ elif external_links >= 1:
947
+ score += 1
948
+
949
+ # ===== TECHNICAL SEO (20 points) =====
950
+ # Canonical Tag (3 points)
951
+ if page.get('canonical_tag', False):
952
+ score += 3
953
+
954
+ # Robots Meta (3 points)
955
+ robots_meta = page.get('robots_meta', '')
956
+ if robots_meta:
957
+ robots_lower = robots_meta.lower()
958
+ if 'noindex' not in robots_lower and 'nofollow' not in robots_lower:
959
+ score += 3
960
+ elif 'noindex' in robots_lower:
961
+ score += 0
962
+ else:
963
+ score += 2
964
+ else:
965
+ score += 2
966
+
967
+ # Viewport (3 points)
968
+ if page.get('viewport_present', False):
969
+ score += 3
970
+
971
+ # FIXED: Schema Markup (4 points) - Quality over quantity
972
+ schema_types = page.get('schema_types', '')
973
+ if schema_types and schema_types.strip() and schema_types != "No schema found":
974
+ schema_count = len([s for s in schema_types.split(', ') if s.strip()])
975
+
976
+ # Check for important schema types
977
+ important_schemas = ['Organization', 'WebSite', 'Article', 'Product', 'LocalBusiness']
978
+ has_important = any(schema in schema_types for schema in important_schemas)
979
+
980
+ if has_important and schema_count >= 2:
981
+ score += 4 # Has important schema plus others
982
+ elif has_important:
983
+ score += 3 # Has at least one important schema
984
+ elif schema_count >= 2:
985
+ score += 3 # Multiple schemas even if not "important"
986
+ elif schema_count == 1:
987
+ score += 2 # Has some schema
988
+ else:
989
+ # No schema, but don't penalize too heavily for certain page types
990
+ if page_type not in ['article', 'product']: # Pages that really should have schema
991
+ score += 1 # Small penalty instead of zero
992
+
993
+ # OpenGraph Tags (4 points)
994
+ opengraph_tags = page.get('opengraph_tags', 0)
995
+ if opengraph_tags >= 10:
996
+ score += 4
997
+ elif opengraph_tags >= 7:
998
+ score += 3
999
+ elif opengraph_tags >= 5:
1000
+ score += 2
1001
+ elif opengraph_tags >= 3:
1002
+ score += 1
1003
+
1004
+ # Twitter Cards (3 points)
1005
+ twitter_tags = page.get('twitter_tags', 0)
1006
+ if twitter_tags >= 5:
1007
+ score += 3
1008
+ elif twitter_tags >= 3:
1009
+ score += 2
1010
+ elif twitter_tags >= 1:
1011
+ score += 1
1012
+
1013
+ # ===== CONTENT QUALITY (25 points) =====
1014
+ word_count = page.get('word_count', 0)
1015
+ readability_score = page.get('readability_score', 0)
1016
+ grammar_errors = page.get('grammar_errors', 0)
1017
+ text_to_html_ratio = page.get('text_to_html_ratio', 0)
1018
+ top_keywords = page.get('top_keywords', '')
1019
+
1020
+ # FIXED: Word Count (6 points) - Page-type aware
1021
+ if page_type == 'homepage':
1022
+ # Homepages can be concise
1023
+ if word_count >= 500:
1024
+ score += 6
1025
+ elif word_count >= 300:
1026
+ score += 5
1027
+ elif word_count >= 200:
1028
+ score += 4
1029
+ elif word_count >= 100:
1030
+ score += 3
1031
+ elif word_count >= 50:
1032
+ score += 2
1033
+ else:
1034
+ score += 1
1035
+ elif page_type == 'article':
1036
+ # Articles need depth
1037
+ if word_count >= 2000:
1038
+ score += 6
1039
+ elif word_count >= 1200:
1040
+ score += 5
1041
+ elif word_count >= 800:
1042
+ score += 4
1043
+ elif word_count >= 500:
1044
+ score += 3
1045
+ elif word_count >= 300:
1046
+ score += 2
1047
+ else:
1048
+ score += 1
1049
+ else:
1050
+ # Standard pages
1051
+ if word_count >= 1500:
1052
+ score += 6
1053
+ elif word_count >= 800:
1054
+ score += 5
1055
+ elif word_count >= 500:
1056
+ score += 4
1057
+ elif word_count >= 300:
1058
+ score += 3
1059
+ elif word_count >= 150:
1060
+ score += 2
1061
+ else:
1062
+ score += 1
1063
+
1064
+ # Readability (6 points)
1065
+ if readability_score >= 50:
1066
+ score += 6
1067
+ elif readability_score >= 45:
1068
+ score += 5
1069
+ elif readability_score >= 40:
1070
+ score += 4
1071
+ elif readability_score >= 35:
1072
+ score += 3
1073
+ elif readability_score >= 20:
1074
+ score += 2
1075
+ elif readability_score >= 10:
1076
+ score += 1
1077
+
1078
+ # Grammar (4 points)
1079
+ if grammar_errors == 0:
1080
+ score += 4
1081
+ elif grammar_errors <= 2:
1082
+ score += 3
1083
+ elif grammar_errors <= 5:
1084
+ score += 2
1085
+ elif grammar_errors <= 10:
1086
+ score += 1
1087
+
1088
+ # Text to HTML Ratio (5 points)
1089
+ if text_to_html_ratio >= 25:
1090
+ score += 5
1091
+ elif text_to_html_ratio >= 20:
1092
+ score += 4
1093
+ elif text_to_html_ratio >= 15:
1094
+ score += 3
1095
+ elif text_to_html_ratio >= 10:
1096
+ score += 2
1097
+ elif text_to_html_ratio >= 5:
1098
+ score += 1
1099
+
1100
+ # Keyword Optimization (4 points)
1101
+ if top_keywords:
1102
+ keyword_entries = [k for k in top_keywords.split(', ') if ':' in k and float(k.split(':')[1][:-1]) > 1.0]
1103
+ keyword_count = len(keyword_entries)
1104
+
1105
+ if keyword_count >= 8:
1106
+ score += 4
1107
+ elif keyword_count >= 5:
1108
+ score += 3
1109
+ elif keyword_count >= 3:
1110
+ score += 2
1111
+ elif keyword_count >= 1:
1112
+ score += 1
1113
+
1114
+ return min(score, max_score)
1115
+
1116
+ # def calculate_seo_score(page):
1117
+ # """Calculate comprehensive SEO score"""
1118
+ # score = 0
1119
+ # max_score = 100
1120
+
1121
+ # # ===== TITLE OPTIMIZATION (10 points) =====
1122
+ # title = page.get('title', '')
1123
+ # if title:
1124
+ # title_len = len(title)
1125
+ # if 50 <= title_len <= 60: # Perfect
1126
+ # score += 10
1127
+ # elif 45 <= title_len <= 65: # Good
1128
+ # score += 8
1129
+ # elif 30 <= title_len <= 70: # Acceptable
1130
+ # score += 6
1131
+ # elif title_len > 0: # Exists but poor
1132
+ # score += 3
1133
+
1134
+ # # ===== META DESCRIPTION (8 points) =====
1135
+ # meta_desc = page.get('meta_description', '')
1136
+ # if meta_desc:
1137
+ # meta_len = len(meta_desc)
1138
+ # if 120 <= meta_len <= 155: # Perfect
1139
+ # score += 8
1140
+ # elif 100 <= meta_len <= 160: # Good
1141
+ # score += 6
1142
+ # elif 70 <= meta_len <= 170: # Acceptable
1143
+ # score += 4
1144
+ # elif meta_len > 0: # Exists but poor
1145
+ # score += 2
1146
+
1147
+ # # ===== HEADING STRUCTURE (12 points) =====
1148
+ # h1_count = page.get('h1_count', 0)
1149
+ # heading_order = page.get('heading_order', '')
1150
+
1151
+ # # H1 Score (6 points)
1152
+ # if h1_count == 1: # Perfect
1153
+ # score += 6
1154
+ # elif h1_count == 0: # Critical
1155
+ # score += 0
1156
+ # elif h1_count == 2: # Minor issue
1157
+ # score += 4
1158
+ # else: # Multiple H1s
1159
+ # score += 1
1160
+
1161
+ # # Heading Hierarchy (6 points)
1162
+ # if heading_order:
1163
+ # headings = [h.strip() for h in heading_order.split(',')]
1164
+ # heading_levels = []
1165
+
1166
+ # for heading in headings:
1167
+ # if heading.startswith('h'):
1168
+ # try:
1169
+ # level = int(heading[1])
1170
+ # heading_levels.append(level)
1171
+ # except:
1172
+ # continue
1173
+
1174
+ # if len(heading_levels) >= 3:
1175
+ # has_h1 = 1 in heading_levels
1176
+ # has_h2 = 2 in heading_levels
1177
+ # has_multiple_levels = len(set(heading_levels)) >= 2
1178
+
1179
+ # if has_h1 and has_h2 and has_multiple_levels:
1180
+ # score += 6
1181
+ # elif has_h1 and has_multiple_levels:
1182
+ # score += 4
1183
+ # elif has_h1:
1184
+ # score += 2
1185
+ # elif len(heading_levels) >= 1:
1186
+ # score += 2
1187
+
1188
+ # # ===== IMAGE OPTIMIZATION (15 points) =====
1189
+ # total_images = page.get('total_images', 0)
1190
+ # missing_alt_tags = page.get('missing_alt_tags', 0)
1191
+ # small_images = page.get('small_images', 0)
1192
+ # large_images = page.get('large_images', 0)
1193
+ # ideal_images = page.get('ideal_images', 0)
1194
+
1195
+ # # Alt Text Score (5 points)
1196
+ # if total_images == 0:
1197
+ # score += 5
1198
+ # else:
1199
+ # alt_ratio = (total_images - missing_alt_tags) / total_images
1200
+ # if alt_ratio >= 0.95:
1201
+ # score += 5
1202
+ # elif alt_ratio >= 0.80:
1203
+ # score += 4
1204
+ # elif alt_ratio >= 0.60:
1205
+ # score += 3
1206
+ # elif alt_ratio >= 0.40:
1207
+ # score += 2
1208
+ # elif alt_ratio > 0:
1209
+ # score += 1
1210
+
1211
+ # # Image Size Optimization (5 points)
1212
+ # if total_images > 0:
1213
+ # ideal_ratio = ideal_images / total_images
1214
+ # if ideal_ratio >= 0.8:
1215
+ # score += 5
1216
+ # elif ideal_ratio >= 0.6:
1217
+ # score += 4
1218
+ # elif ideal_ratio >= 0.4:
1219
+ # score += 3
1220
+ # elif ideal_ratio >= 0.2:
1221
+ # score += 2
1222
+ # else:
1223
+ # score += 1
1224
+ # else:
1225
+ # score += 5
1226
+
1227
+ # # Image Quantity (5 points)
1228
+ # if total_images == 0:
1229
+ # score += 3
1230
+ # elif 3 <= total_images <= 20:
1231
+ # score += 5
1232
+ # elif total_images <= 50:
1233
+ # score += 4
1234
+ # elif total_images <= 100:
1235
+ # score += 3
1236
+ # else:
1237
+ # score += 2
1238
+
1239
+ # # ===== LINK STRUCTURE (10 points) =====
1240
+ # internal_links = page.get('internal_links', 0)
1241
+ # external_links = page.get('external_links', 0)
1242
+
1243
+ # # Internal Links (6 points)
1244
+ # if internal_links >= 80:
1245
+ # score += 6
1246
+ # elif internal_links >= 60:
1247
+ # score += 5
1248
+ # elif internal_links >= 40:
1249
+ # score += 4
1250
+ # elif internal_links >= 20:
1251
+ # score += 3
1252
+ # elif internal_links >= 10:
1253
+ # score += 2
1254
+
1255
+ # # External Links (4 points)
1256
+ # if external_links >= 10:
1257
+ # score += 4
1258
+ # elif external_links >= 7:
1259
+ # score += 3
1260
+ # elif external_links >= 4:
1261
+ # score += 2
1262
+
1263
+ # # ===== TECHNICAL SEO (20 points) =====
1264
+ # # Canonical Tag (3 points)
1265
+ # if page.get('canonical_tag', False):
1266
+ # score += 3
1267
+
1268
+ # # Robots Meta (3 points)
1269
+ # robots_meta = page.get('robots_meta', '')
1270
+ # if robots_meta:
1271
+ # robots_lower = robots_meta.lower()
1272
+ # if 'noindex' not in robots_lower and 'nofollow' not in robots_lower:
1273
+ # score += 3
1274
+ # elif 'noindex' in robots_lower:
1275
+ # score += 0
1276
+ # else:
1277
+ # score += 2
1278
+ # else:
1279
+ # score += 2
1280
+
1281
+ # # Viewport (3 points)
1282
+ # if page.get('viewport_present', False):
1283
+ # score += 3
1284
+
1285
+ # # Schema Markup (4 points)
1286
+ # schema_types = page.get('schema_types', '')
1287
+ # if schema_types and schema_types.strip() and schema_types != "No schema found":
1288
+ # schema_count = len([s for s in schema_types.split(', ') if s.strip()])
1289
+ # if schema_count >= 3:
1290
+ # score += 4
1291
+ # elif schema_count >= 2:
1292
+ # score += 3
1293
+ # elif schema_count == 1:
1294
+ # score += 2
1295
+
1296
+ # # OpenGraph Tags (4 points)
1297
+ # opengraph_tags = page.get('opengraph_tags', 0)
1298
+ # if opengraph_tags >= 10:
1299
+ # score += 4
1300
+ # elif opengraph_tags >= 7:
1301
+ # score += 3
1302
+ # elif opengraph_tags >= 5:
1303
+ # score += 2
1304
+ # elif opengraph_tags >= 3:
1305
+ # score += 1
1306
+
1307
+ # # Twitter Cards (3 points)
1308
+ # twitter_tags = page.get('twitter_tags', 0)
1309
+ # if twitter_tags >= 5:
1310
+ # score += 3
1311
+ # elif twitter_tags >= 3:
1312
+ # score += 2
1313
+ # elif twitter_tags >= 1:
1314
+ # score += 1
1315
+
1316
+ # # ===== CONTENT QUALITY (25 points) =====
1317
+ # word_count = page.get('word_count', 0)
1318
+ # readability_score = page.get('readability_score', 0)
1319
+ # grammar_errors = page.get('grammar_errors', 0)
1320
+ # text_to_html_ratio = page.get('text_to_html_ratio', 0)
1321
+ # top_keywords = page.get('top_keywords', '')
1322
+
1323
+ # # Word Count (6 points)
1324
+ # if word_count >= 2000:
1325
+ # score += 6
1326
+ # elif word_count >= 1200:
1327
+ # score += 5
1328
+ # elif word_count >= 800:
1329
+ # score += 4
1330
+ # elif word_count >= 500:
1331
+ # score += 3
1332
+ # elif word_count >= 300:
1333
+ # score += 2
1334
+ # elif word_count >= 150:
1335
+ # score += 1
1336
+
1337
+ # # Readability (6 points)
1338
+ # if readability_score >= 50:
1339
+ # score += 6
1340
+ # elif readability_score >= 45:
1341
+ # score += 5
1342
+ # elif readability_score >= 40:
1343
+ # score += 4
1344
+ # elif readability_score >= 35:
1345
+ # score += 3
1346
+ # elif readability_score >= 20:
1347
+ # score += 2
1348
+ # elif readability_score >= 10:
1349
+ # score += 1
1350
+
1351
+ # # Grammar (4 points)
1352
+ # if grammar_errors == 0:
1353
+ # score += 4
1354
+ # elif grammar_errors <= 2:
1355
+ # score += 3
1356
+ # elif grammar_errors <= 5:
1357
+ # score += 2
1358
+ # elif grammar_errors <= 10:
1359
+ # score += 1
1360
+
1361
+ # # Text to HTML Ratio (5 points)
1362
+ # if text_to_html_ratio >= 25:
1363
+ # score += 5
1364
+ # elif text_to_html_ratio >= 20:
1365
+ # score += 4
1366
+ # elif text_to_html_ratio >= 15:
1367
+ # score += 3
1368
+ # elif text_to_html_ratio >= 10:
1369
+ # score += 2
1370
+ # elif text_to_html_ratio >= 5:
1371
+ # score += 1
1372
+
1373
+ # # Keyword Optimization (4 points)
1374
+ # if top_keywords:
1375
+ # keyword_entries = [k for k in top_keywords.split(', ') if ':' in k and float(k.split(':')[1][:-1]) > 1.0]
1376
+ # keyword_count = len(keyword_entries)
1377
+
1378
+ # if keyword_count >= 8:
1379
+ # score += 4
1380
+ # elif keyword_count >= 5:
1381
+ # score += 3
1382
+ # elif keyword_count >= 3:
1383
+ # score += 2
1384
+ # elif keyword_count >= 1:
1385
+ # score += 1
1386
+
1387
+ # return min(score, max_score)
1388
+
1389
+ # ==============================
1390
+ # ENHANCED MAIN FUNCTION WITH ULTRA-SPECIFIC AI SUGGESTIONS
1391
+ # ==============================
1392
+ async def run_seo_and_suggestions_async(base_url, max_pages=20, tmp_dir="/tmp", use_ai=True, max_concurrent=3):
1393
+ """
1394
+ FULLY PARALLEL SEO analysis with Playwright - ENHANCED WITH ULTRA-SPECIFIC AI SUGGESTIONS
1395
+ """
1396
+ if not base_url:
1397
+ raise ValueError("base_url is required")
1398
+
1399
+ print(f"🎯 Starting ULTRA-SPECIFIC SEO analysis for: {base_url}")
1400
+ print(f"⚡ Optimized: {max_concurrent} concurrent browsers, {max_pages} max pages")
1401
+ print(f"🤖 ULTRA-SPECIFIC AI Suggestions: {'ENABLED' if use_ai and OPENAI_AVAILABLE and os.environ.get('OPENAI_API_KEY') else 'DISABLED'}")
1402
+ start_time = time.time()
1403
+
1404
+ domain = urlparse(base_url).netloc
1405
+
1406
+ try:
1407
+ # STEP 1: Discover URLs in parallel
1408
+ urls = await discover_urls_parallel(base_url, max_pages)
1409
+
1410
+ if not urls:
1411
+ print("❌ No URLs discovered, using homepage only as fallback")
1412
+ urls = [base_url]
1413
+
1414
+ print(f"🔍 Found {len(urls)} URLs to analyze")
1415
+
1416
+ # STEP 2: Fetch all pages in parallel with Playwright
1417
+ playwright_data = await fetch_all_pages_parallel(urls, max_concurrent)
1418
+
1419
+ if not playwright_data:
1420
+ print("❌ No pages could be fetched, creating error report")
1421
+ # Create error report
1422
+ os.makedirs(tmp_dir, exist_ok=True)
1423
+ filename = os.path.join(tmp_dir, f"seo_report_error_{uuid.uuid4().hex}.csv")
1424
+ error_data = [{
1425
+ "url": base_url,
1426
+ "error": "Failed to fetch any pages. Site may be blocking bots or require authentication.",
1427
+ "seo_suggestions": "Check if the site is accessible and not blocking headless browsers."
1428
+ }]
1429
+ with open(filename, "w", newline="", encoding="utf-8") as f:
1430
+ writer = csv.DictWriter(f, fieldnames=error_data[0].keys())
1431
+ writer.writeheader()
1432
+ writer.writerows(error_data)
1433
+ return error_data, filename
1434
+
1435
+ # STEP 3: Analyze all pages in parallel
1436
+ results = await analyze_pages_parallel(playwright_data, domain)
1437
+
1438
+ if not results:
1439
+ print("❌ No pages could be analyzed, creating error report")
1440
+ # Create error report
1441
+ os.makedirs(tmp_dir, exist_ok=True)
1442
+ filename = os.path.join(tmp_dir, f"seo_report_error_{uuid.uuid4().hex}.csv")
1443
+ error_data = [{
1444
+ "url": base_url,
1445
+ "error": "Failed to analyze any pages. There may be issues with the page content.",
1446
+ "seo_suggestions": "Check if the site has proper HTML structure and content."
1447
+ }]
1448
+ with open(filename, "w", newline="", encoding="utf-8") as f:
1449
+ writer = csv.DictWriter(f, fieldnames=error_data[0].keys())
1450
+ writer.writeheader()
1451
+ writer.writerows(error_data)
1452
+ return error_data, filename
1453
+
1454
+ # STEP 4: Calculate SEO scores
1455
+ print("📊 Calculating SEO scores...")
1456
+ for p in results:
1457
+ p["seo_score"] = calculate_seo_score(p)
1458
+
1459
+ # STEP 5: Generate ULTRA-SPECIFIC AI suggestions in parallel
1460
+ if use_ai:
1461
+ await add_comprehensive_suggestions_async(results)
1462
+ else:
1463
+ for p in results:
1464
+ p["ai_suggestions"] = "AI suggestions disabled - set use_ai=True and OPENAI_API_KEY"
1465
+
1466
+ # STEP 6: Save to CSV
1467
+ os.makedirs(tmp_dir, exist_ok=True)
1468
+ filename = os.path.join(tmp_dir, f"seo_report_ultra_specific_{uuid.uuid4().hex}.csv")
1469
+
1470
+ if results:
1471
+ keys = list(results[0].keys())
1472
+ with open(filename, "w", newline="", encoding="utf-8") as f:
1473
+ writer = csv.DictWriter(f, fieldnames=keys)
1474
+ writer.writeheader()
1475
+ writer.writerows(results)
1476
+
1477
+ elapsed_time = time.time() - start_time
1478
+ print(f"✅ ULTRA-SPECIFIC SEO analysis complete! Analyzed {len(results)} pages in {elapsed_time:.1f}s")
1479
+ print(f"📊 Report saved to: {filename}")
1480
+
1481
+ # Count ULTRA-SPECIFIC AI suggestions
1482
+ ultra_specific_count = sum(1 for p in results if p.get('ai_suggestions') and
1483
+ 'AI disabled' not in p.get('ai_suggestions', '') and
1484
+ 'AI Error' not in p.get('ai_suggestions', ''))
1485
+ print(f"🤖 ULTRA-SPECIFIC AI Suggestions: {ultra_specific_count}/{len(results)} pages")
1486
+
1487
+ return results, filename
1488
+
1489
+ except Exception as e:
1490
+ print(f"❌ Unexpected error during SEO analysis: {e}")
1491
+ # Create error report
1492
+ os.makedirs(tmp_dir, exist_ok=True)
1493
+ filename = os.path.join(tmp_dir, f"seo_report_error_{uuid.uuid4().hex}.csv")
1494
+ error_data = [{
1495
+ "url": base_url,
1496
+ "error": f"Unexpected error: {str(e)}",
1497
+ "seo_suggestions": "Please check the website URL and try again."
1498
+ }]
1499
+ with open(filename, "w", newline="", encoding="utf-8") as f:
1500
+ writer = csv.DictWriter(f, fieldnames=error_data[0].keys())
1501
+ writer.writeheader()
1502
+ writer.writerows(error_data)
1503
+ return error_data, filename
1504
+
1505
+ # ==============================
1506
+ # ASYNC WRAPPER FOR FASTAPI
1507
+ # ==============================
1508
+ async def run_seo_analysis_fastapi(base_url, max_pages=5, use_ai=True, max_concurrent=2, download=False):
1509
+ """
1510
+ Async wrapper for FastAPI compatibility
1511
+ """
1512
+ try:
1513
+ results, csv_path = await run_seo_and_suggestions_async(
1514
+ base_url=base_url,
1515
+ max_pages=max_pages,
1516
+ tmp_dir="/tmp",
1517
+ use_ai=use_ai,
1518
+ max_concurrent=max_concurrent
1519
+ )
1520
+ return results, csv_path
1521
+ except Exception as e:
1522
+ filename = f"/tmp/seo_report_error_{uuid.uuid4().hex}.csv"
1523
+ error_data = [{
1524
+ "url": base_url,
1525
+ "error": f"Analysis failed: {str(e)}",
1526
+ "seo_suggestions": "Please check the website URL and try again."
1527
+ }]
1528
+ with open(filename, "w", newline="", encoding="utf-8") as f:
1529
+ writer = csv.DictWriter(f, fieldnames=error_data[0].keys())
1530
+ writer.writeheader()
1531
+ writer.writerows(error_data)
1532
+ return error_data, filename