moizshah956 commited on
Commit
83d9586
·
verified ·
1 Parent(s): 97ffd70

Update ai_visibility.py

Browse files
Files changed (1) hide show
  1. ai_visibility.py +1593 -11
ai_visibility.py CHANGED
@@ -1,3 +1,6 @@
 
 
 
1
  """
2
  AI Visibility / AI Search Readiness analysis.
3
 
@@ -26,6 +29,38 @@ 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
@@ -40,6 +75,11 @@ load_dotenv()
40
 
41
  from bs4 import BeautifulSoup
42
 
 
 
 
 
 
43
  from seo_analyzer import (
44
  discover_urls_parallel,
45
  fetch_all_pages_parallel,
@@ -198,6 +238,15 @@ DATE_PATTERNS = [
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,
@@ -220,6 +269,19 @@ def _clamp(v, lo=0, hi=100):
220
  return max(lo, min(hi, v))
221
 
222
 
 
 
 
 
 
 
 
 
 
 
 
 
 
223
  # ==============================
224
  # PAGE TYPE CLASSIFICATION (deterministic)
225
  # ==============================
@@ -233,7 +295,7 @@ def classify_page_type(seo_data, soup, text, word_count, schema_types, links):
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
  )
@@ -506,7 +568,9 @@ def _analyze_entities(seo_data, soup, text, word_count, schema_types, page_type)
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):
@@ -852,6 +916,61 @@ def _analyze_citation_potential(soup, text):
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 = (
@@ -878,15 +997,8 @@ def _analyze_freshness(seo_data, soup, text, page_type):
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
@@ -1460,4 +1572,1474 @@ async def run_ai_visibility_analysis(base_url, max_pages=5, max_concurrent=1, us
1460
  "strengths": strengths,
1461
  "unknown_metrics": unknowns,
1462
  "results_preview": pages_summary,
1463
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+
3
+
4
  """
5
  AI Visibility / AI Search Readiness analysis.
6
 
 
29
  fetch_all_pages_parallel) so pages are only fetched once per run via
30
  Playwright, but it does its own HTML parsing and its own scoring - it never
31
  touches or overrides the existing seo_score / page_summary fields.
32
+
33
+ ============================================================================
34
+ CHANGELOG (bugfixes applied in this revision)
35
+ ============================================================================
36
+ 1. FRESHNESS DATE PARSING (the main reported bug): `_analyze_freshness`
37
+ correctly *detected* dates in formats like "November 5, 2024" or
38
+ "5 November 2024" via DATE_PATTERNS, but the parsing step only tried
39
+ strict ISO formats ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%d"). Any date that
40
+ wasn't already ISO-formatted failed to parse, so `content_age_days`
41
+ stayed None and `freshness_status` silently fell through to "unknown"
42
+ even though a date was clearly visible on the page. This made the
43
+ "freshness" score None/0 on the vast majority of real-world sites,
44
+ since most CMSs render human-readable dates, not ISO strings.
45
+ FIX: added a broad list of strptime formats plus a dateutil fallback
46
+ (fuzzy=True) so any commonly-rendered date string parses correctly.
47
+
48
+ 2. ANSWER COVERAGE UNDER-DETECTION: `_analyze_answerability` only counted
49
+ a heading as "question-style" if its text ended with a literal "?".
50
+ Real FAQ/help content very often uses headings like "How to reset your
51
+ password" with no question mark, so `question_count` stayed 0 and
52
+ `answer_coverage` was reported as None (shown as 0 downstream) even on
53
+ pages with clear Q&A structure.
54
+ FIX: headings are now also matched if they start with a common question
55
+ word/phrase (how/what/why/when/where/can/do/does/is/are/should/will),
56
+ in addition to the original "ends with ?" check.
57
+
58
+ 3. Restored the truncated end of the file (the `unknown_metrics` key in
59
+ the final returned dict) - the last document pasted in this
60
+ conversation was cut off mid-line.
61
+
62
+ No other scoring logic, weights, or public behavior was changed.
63
+ ============================================================================
64
  """
65
 
66
  import os
 
75
 
76
  from bs4 import BeautifulSoup
77
 
78
+ try:
79
+ from dateutil import parser as _dateutil_parser
80
+ except Exception:
81
+ _dateutil_parser = None
82
+
83
  from seo_analyzer import (
84
  discover_urls_parallel,
85
  fetch_all_pages_parallel,
 
238
  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),
239
  ]
240
 
241
+ # FIX (#2): question headings previously only matched if the text ended in
242
+ # a literal "?". Real-world FAQ/help headings are frequently phrased as
243
+ # imperative/declarative "questions" without a trailing "?" (e.g. "How to
244
+ # reset your password"). This pattern catches those too.
245
+ QUESTION_START_PATTERN = re.compile(
246
+ r'^(how|what|why|when|where|who|which|can|could|do|does|did|is|are|was|were|should|will|would)\b',
247
+ re.I,
248
+ )
249
+
250
  FIRST_HAND_PATTERNS = re.compile(
251
  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',
252
  re.I,
 
269
  return max(lo, min(hi, v))
270
 
271
 
272
+ def _is_question_heading(heading_text):
273
+ """FIX (#2): a heading counts as 'question-style' if it ends with '?'
274
+ OR starts with a common question word/phrase. Broadens detection beyond
275
+ the original ends-with-'?' -only check so real FAQ/help headings that
276
+ omit the question mark are still picked up."""
277
+ t = (heading_text or "").strip()
278
+ if not t:
279
+ return False
280
+ if t.endswith("?"):
281
+ return True
282
+ return bool(QUESTION_START_PATTERN.match(t))
283
+
284
+
285
  # ==============================
286
  # PAGE TYPE CLASSIFICATION (deterministic)
287
  # ==============================
 
295
  lower_types = [t.lower() for t in schema_types]
296
 
297
  heading_tags = soup.find_all(re.compile("^h[1-6]$"))
298
+ question_headings = sum(1 for h in heading_tags if _is_question_heading(h.get_text(strip=True)))
299
  breadcrumb_present = bool(soup.find(attrs={"class": re.compile("breadcrumb", re.I)})) or any(
300
  "breadcrumblist" in t for t in lower_types
301
  )
 
568
  # ==============================
569
  def _analyze_answerability(soup, text):
570
  heading_tags = soup.find_all(re.compile("^h[1-6]$"))
571
+ # FIX (#2): use the broadened _is_question_heading check instead of the
572
+ # original "ends with literal '?'" -only test.
573
+ question_headings = [h for h in heading_tags if _is_question_heading(h.get_text(strip=True))]
574
  question_count = len(question_headings)
575
 
576
  def _next_text_len(tag):
 
916
  # CATEGORY 10: FRESHNESS (fresh / stale / very_stale / unknown - never
917
  # "guessed outdated")
918
  # ==============================
919
+ def _parse_flexible_date(raw_date):
920
+ """FIX (#1 - the reported bug): the original implementation only tried
921
+ two strict ISO strptime formats, so any human-readable date detected by
922
+ DATE_PATTERNS (e.g. "November 5, 2024", "5 November 2024") failed to
923
+ parse and silently produced freshness_status == 'unknown' on almost
924
+ every real page. This now tries a broad set of explicit formats first
925
+ (fast, no dependency needed) and falls back to dateutil's fuzzy parser
926
+ for anything else. Returns a timezone-aware UTC datetime, or None.
927
+ """
928
+ if not raw_date:
929
+ return None
930
+
931
+ from datetime import datetime, timezone
932
+
933
+ candidate = raw_date.strip()
934
+
935
+ explicit_formats = (
936
+ "%Y-%m-%dT%H:%M:%S%z",
937
+ "%Y-%m-%dT%H:%M:%S",
938
+ "%Y-%m-%d",
939
+ "%B %d, %Y", # November 5, 2024
940
+ "%B %d %Y", # November 5 2024
941
+ "%d %B %Y", # 5 November 2024
942
+ "%b %d, %Y", # Nov 5, 2024
943
+ "%b %d %Y", # Nov 5 2024
944
+ "%d %b %Y", # 5 Nov 2024
945
+ "%m/%d/%Y",
946
+ "%d/%m/%Y",
947
+ )
948
+
949
+ # Normalize a trailing 'Z' (UTC designator) so the '%z'-less formats work.
950
+ normalized = candidate[:19].replace("Z", "") if len(candidate) >= 19 else candidate
951
+
952
+ for fmt in explicit_formats:
953
+ for attempt in (candidate, normalized):
954
+ try:
955
+ parsed = datetime.strptime(attempt, fmt)
956
+ if parsed.tzinfo is None:
957
+ parsed = parsed.replace(tzinfo=timezone.utc)
958
+ return parsed
959
+ except Exception:
960
+ continue
961
+
962
+ if _dateutil_parser is not None:
963
+ try:
964
+ parsed = _dateutil_parser.parse(candidate, fuzzy=True)
965
+ if parsed.tzinfo is None:
966
+ parsed = parsed.replace(tzinfo=timezone.utc)
967
+ return parsed
968
+ except Exception:
969
+ return None
970
+
971
+ return None
972
+
973
+
974
  def _analyze_freshness(seo_data, soup, text, page_type):
975
  metas = seo_data.get("metas", {}) or {}
976
  last_updated = (
 
997
  if last_updated:
998
  try:
999
  from datetime import datetime, timezone
1000
+ parsed = _parse_flexible_date(last_updated)
 
 
 
 
 
 
1001
  if parsed:
 
1002
  content_age_days = (datetime.now(timezone.utc) - parsed).days
1003
  except Exception:
1004
  content_age_days = None
 
1572
  "strengths": strengths,
1573
  "unknown_metrics": unknowns,
1574
  "results_preview": pages_summary,
1575
+ }
1576
+
1577
+
1578
+
1579
+
1580
+
1581
+
1582
+
1583
+ # """
1584
+ # AI Visibility / AI Search Readiness analysis.
1585
+
1586
+ # Estimates how well a page is structured to be understood, retrieved, cited,
1587
+ # summarized and recommended by AI-powered search systems (ChatGPT, Google AI
1588
+ # Overviews, Perplexity, Claude, etc). These are NOT official ranking factors of
1589
+ # any AI system - they are deterministic, measurable proxy signals derived from
1590
+ # the page's HTML/text plus (optionally) a lightweight LLM pass for the handful
1591
+ # of metrics that cannot be reliably computed with parsing alone.
1592
+
1593
+ # This module reports an "AI Readiness Score" - whether a page is technically
1594
+ # and semantically prepared for AI systems. It does NOT claim to measure actual
1595
+ # observed visibility in AI answers/citations (query coverage, citation rate,
1596
+ # etc) - that requires live AI-query data this crawler does not have, so that
1597
+ # field is always returned as "not_measured" rather than guessed at.
1598
+
1599
+ # Key design principles (see README/PR notes for the full rationale):
1600
+ # - Pages are classified by type (homepage, article, product, ...) and only
1601
+ # scored against metrics that are relevant to that type.
1602
+ # - Missing evidence is reported as "unknown" / null, never coerced to a low
1603
+ # score. Unknown != bad.
1604
+ # - Weights are centralized and configurable, per page type.
1605
+
1606
+ # This module is intentionally decoupled from seo_analyzer.py's SEO scoring.
1607
+ # It reuses the same crawling primitives (discover_urls_parallel /
1608
+ # fetch_all_pages_parallel) so pages are only fetched once per run via
1609
+ # Playwright, but it does its own HTML parsing and its own scoring - it never
1610
+ # touches or overrides the existing seo_score / page_summary fields.
1611
+ # """
1612
+
1613
+ # import os
1614
+ # import re
1615
+ # import json
1616
+ # import asyncio
1617
+ # from collections import Counter
1618
+ # from urllib.parse import urlparse
1619
+ # from urllib.request import urlopen, Request
1620
+ # from dotenv import load_dotenv
1621
+ # load_dotenv()
1622
+
1623
+ # from bs4 import BeautifulSoup
1624
+
1625
+ # from seo_analyzer import (
1626
+ # discover_urls_parallel,
1627
+ # fetch_all_pages_parallel,
1628
+ # OPENAI_AVAILABLE,
1629
+ # )
1630
+
1631
+ # try:
1632
+ # import openai
1633
+ # except Exception:
1634
+ # openai = None
1635
+
1636
+ # # SECURITY FIX: never hardcode API keys in source. Load from environment.
1637
+ # # Set this in your shell / deployment config, e.g.:
1638
+ # # export OPENAI_API_KEY="sk-..."
1639
+ # # If it's unset, `use_ai=True` calls will simply fall back to the
1640
+ # # deterministic-only scoring path (see _llm_semantic_enhance below).
1641
+ # OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY")
1642
+ # # Reused across calls so we're not re-instantiating the client every request.
1643
+ # _openai_client = None
1644
+
1645
+
1646
+ # def _get_openai_client():
1647
+ # global _openai_client
1648
+ # if _openai_client is None and openai is not None and OPENAI_API_KEY:
1649
+ # _openai_client = openai.OpenAI(api_key=OPENAI_API_KEY)
1650
+ # return _openai_client
1651
+
1652
+
1653
+ # # ==============================
1654
+ # # PAGE TYPES
1655
+ # # ==============================
1656
+ # PAGE_TYPES = [
1657
+ # "homepage", "portal", "article", "blog_post", "news", "product",
1658
+ # "product_category", "documentation", "forum", "review", "comparison",
1659
+ # "organization", "landing_page", "service_page", "directory",
1660
+ # "search_page", "unknown",
1661
+ # ]
1662
+
1663
+ # # ==============================
1664
+ # # CENTRALIZED SCORING CONFIGURATION
1665
+ # # ==============================
1666
+ # # Base ("default") weights - used for page types without a specific profile.
1667
+ # # Categories are the 8 "AI Readiness" pillars. Weights sum to 1.0.
1668
+ # DEFAULT_WEIGHTS = {
1669
+ # "semantic": 0.20,
1670
+ # "content_answerability": 0.25,
1671
+ # "entity": 0.15,
1672
+ # "trust": 0.15,
1673
+ # "citation": 0.10,
1674
+ # "retrieval": 0.05,
1675
+ # "structured_data": 0.05,
1676
+ # "freshness": 0.05,
1677
+ # }
1678
+
1679
+ # # Per-page-type overrides. Only categories that differ from DEFAULT_WEIGHTS
1680
+ # # need to be listed - the profile is merged over the default and renormalized.
1681
+ # PAGE_TYPE_WEIGHT_OVERRIDES = {
1682
+ # "homepage": {
1683
+ # "semantic": 0.15, "content_answerability": 0.10, "entity": 0.25,
1684
+ # "trust": 0.15, "citation": 0.05, "retrieval": 0.20,
1685
+ # "structured_data": 0.05, "freshness": 0.05,
1686
+ # },
1687
+ # "portal": {
1688
+ # "semantic": 0.15, "content_answerability": 0.10, "entity": 0.25,
1689
+ # "trust": 0.15, "citation": 0.05, "retrieval": 0.20,
1690
+ # "structured_data": 0.05, "freshness": 0.05,
1691
+ # },
1692
+ # "article": {
1693
+ # "semantic": 0.20, "content_answerability": 0.25, "entity": 0.10,
1694
+ # "trust": 0.15, "citation": 0.15, "retrieval": 0.05,
1695
+ # "structured_data": 0.05, "freshness": 0.05,
1696
+ # },
1697
+ # "blog_post": {
1698
+ # "semantic": 0.20, "content_answerability": 0.25, "entity": 0.10,
1699
+ # "trust": 0.15, "citation": 0.15, "retrieval": 0.05,
1700
+ # "structured_data": 0.05, "freshness": 0.05,
1701
+ # },
1702
+ # "news": {
1703
+ # "semantic": 0.18, "content_answerability": 0.22, "entity": 0.12,
1704
+ # "trust": 0.15, "citation": 0.13, "retrieval": 0.05,
1705
+ # "structured_data": 0.05, "freshness": 0.10,
1706
+ # },
1707
+ # "documentation": {
1708
+ # "semantic": 0.15, "content_answerability": 0.30, "entity": 0.10,
1709
+ # "trust": 0.10, "citation": 0.10, "retrieval": 0.10,
1710
+ # "structured_data": 0.10, "freshness": 0.05,
1711
+ # },
1712
+ # "product": {
1713
+ # "semantic": 0.10, "content_answerability": 0.20, "entity": 0.15,
1714
+ # "trust": 0.10, "citation": 0.05, "retrieval": 0.10,
1715
+ # "structured_data": 0.25, "freshness": 0.05,
1716
+ # },
1717
+ # "product_category": {
1718
+ # "semantic": 0.10, "content_answerability": 0.15, "entity": 0.15,
1719
+ # "trust": 0.10, "citation": 0.05, "retrieval": 0.15,
1720
+ # "structured_data": 0.25, "freshness": 0.05,
1721
+ # },
1722
+ # "forum": {
1723
+ # "semantic": 0.15, "content_answerability": 0.20, "entity": 0.10,
1724
+ # "trust": 0.15, "citation": 0.15, "retrieval": 0.10,
1725
+ # "structured_data": 0.05, "freshness": 0.10,
1726
+ # },
1727
+ # "review": {
1728
+ # "semantic": 0.15, "content_answerability": 0.20, "entity": 0.10,
1729
+ # "trust": 0.20, "citation": 0.15, "retrieval": 0.05,
1730
+ # "structured_data": 0.05, "freshness": 0.10,
1731
+ # },
1732
+ # "comparison": {
1733
+ # "semantic": 0.18, "content_answerability": 0.22, "entity": 0.12,
1734
+ # "trust": 0.15, "citation": 0.13, "retrieval": 0.05,
1735
+ # "structured_data": 0.05, "freshness": 0.10,
1736
+ # },
1737
+ # "organization": {
1738
+ # "semantic": 0.15, "content_answerability": 0.10, "entity": 0.25,
1739
+ # "trust": 0.25, "citation": 0.05, "retrieval": 0.10,
1740
+ # "structured_data": 0.05, "freshness": 0.05,
1741
+ # },
1742
+ # "service_page": {
1743
+ # "semantic": 0.15, "content_answerability": 0.15, "entity": 0.20,
1744
+ # "trust": 0.20, "citation": 0.05, "retrieval": 0.10,
1745
+ # "structured_data": 0.10, "freshness": 0.05,
1746
+ # },
1747
+ # "landing_page": {
1748
+ # "semantic": 0.15, "content_answerability": 0.10, "entity": 0.20,
1749
+ # "trust": 0.20, "citation": 0.05, "retrieval": 0.15,
1750
+ # "structured_data": 0.10, "freshness": 0.05,
1751
+ # },
1752
+ # }
1753
+
1754
+
1755
+ # def get_weights_for_page_type(page_type):
1756
+ # profile = dict(DEFAULT_WEIGHTS)
1757
+ # profile.update(PAGE_TYPE_WEIGHT_OVERRIDES.get(page_type, {}))
1758
+ # total = sum(profile.values()) or 1.0
1759
+ # return {k: v / total for k, v in profile.items()}
1760
+
1761
+
1762
+ # # Per-page-type relevance map: which "trust/answerability" sub-signals are
1763
+ # # actually meaningful for this page type. Signals not listed are treated as
1764
+ # # not_applicable (excluded from scoring) rather than penalized when absent.
1765
+ # TYPES_WHERE_AUTHOR_RELEVANT = {
1766
+ # "article", "blog_post", "news", "review", "comparison", "documentation", "forum",
1767
+ # }
1768
+ # TYPES_WHERE_FRESHNESS_RELEVANT = {
1769
+ # "article", "blog_post", "news", "review", "comparison", "documentation", "forum", "product",
1770
+ # }
1771
+ # TYPES_WHERE_FAQ_RELEVANT = {
1772
+ # "article", "blog_post", "documentation", "product", "product_category", "service_page", "landing_page",
1773
+ # }
1774
+ # TYPES_WHERE_PRODUCT_SCHEMA_RELEVANT = {"product", "product_category"}
1775
+ # TYPES_WHERE_ARTICLE_SCHEMA_RELEVANT = {"article", "blog_post", "news"}
1776
+
1777
+ # DATE_PATTERNS = [
1778
+ # re.compile(r'\b(19|20)\d{2}-\d{2}-\d{2}\b'),
1779
+ # 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),
1780
+ # 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),
1781
+ # ]
1782
+
1783
+ # FIRST_HAND_PATTERNS = re.compile(
1784
+ # 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',
1785
+ # re.I,
1786
+ # )
1787
+
1788
+ # SOURCE_ATTRIBUTION_PATTERNS = re.compile(
1789
+ # r'\b(according to|source:|cited by|reported by|study by|research (from|by)|as reported)\b', re.I
1790
+ # )
1791
+
1792
+
1793
+ # def _words(text):
1794
+ # return re.findall(r"[A-Za-z']+", text or "")
1795
+
1796
+
1797
+ # def _pct(n, d):
1798
+ # return round((n / d) * 100, 1) if d else 0.0
1799
+
1800
+
1801
+ # def _clamp(v, lo=0, hi=100):
1802
+ # return max(lo, min(hi, v))
1803
+
1804
+
1805
+ # # ==============================
1806
+ # # PAGE TYPE CLASSIFICATION (deterministic)
1807
+ # # ==============================
1808
+ # def classify_page_type(seo_data, soup, text, word_count, schema_types, links):
1809
+ # """Classify the page using URL structure, schema, and content-structure
1810
+ # signals. Returns {"type": str, "confidence": float, "signals": [...]}.
1811
+ # No LLM call - this must be fast and run for every page.
1812
+ # """
1813
+ # url = seo_data.get("url", "")
1814
+ # path = urlparse(url).path.strip("/").lower()
1815
+ # lower_types = [t.lower() for t in schema_types]
1816
+
1817
+ # heading_tags = soup.find_all(re.compile("^h[1-6]$"))
1818
+ # question_headings = sum(1 for h in heading_tags if h.get_text(strip=True).endswith("?"))
1819
+ # breadcrumb_present = bool(soup.find(attrs={"class": re.compile("breadcrumb", re.I)})) or any(
1820
+ # "breadcrumblist" in t for t in lower_types
1821
+ # )
1822
+ # has_price = bool(re.search(r'(\$|USD|EUR|£|₹)\s?\d', text or ""))
1823
+ # add_to_cart = bool(re.search(r'add to cart|buy now|add to bag|add to trolley', text or "", re.I))
1824
+ # nav_link_count = len(links)
1825
+
1826
+ # votes = Counter()
1827
+ # signals = []
1828
+
1829
+ # def vote(t, n, reason):
1830
+ # votes[t] += n
1831
+ # signals.append(f"{t}+{n}:{reason}")
1832
+
1833
+ # # --- URL structure signals ---
1834
+ # if path == "":
1835
+ # vote("homepage", 3, "root path")
1836
+ # if re.search(r'\b(blog|article|post)\b', path):
1837
+ # vote("blog_post", 2, "url path")
1838
+ # if re.search(r'\bnews\b', path):
1839
+ # vote("news", 2, "url path")
1840
+ # if re.search(r'\b(docs?|documentation|guide|help|kb|support|wiki)\b', path):
1841
+ # vote("documentation", 2, "url path")
1842
+ # if re.search(r'\b(product|item|shop|store)\b', path):
1843
+ # vote("product", 2, "url path")
1844
+ # if re.search(r'\b(category|collection|catalog|categories)\b', path):
1845
+ # vote("product_category", 2, "url path")
1846
+ # if re.search(r'\b(forum|thread|topic|community|discussion)\b', path):
1847
+ # vote("forum", 2, "url path")
1848
+ # if re.search(r'\breview', path):
1849
+ # vote("review", 2, "url path")
1850
+ # if re.search(r'\b(vs|compare|comparison|alternatives)\b', path):
1851
+ # vote("comparison", 2, "url path")
1852
+ # if re.search(r'\b(about|company|who-we-are|team)\b', path):
1853
+ # vote("organization", 2, "url path")
1854
+ # if re.search(r'\b(pricing|services|solutions|features)\b', path):
1855
+ # vote("service_page", 1, "url path")
1856
+ # if re.search(r'\bsearch\b', path) or "q=" in urlparse(url).query:
1857
+ # vote("search_page", 2, "url/query")
1858
+ # if re.search(r'\b(directory|listings?)\b', path):
1859
+ # vote("directory", 1, "url path")
1860
+ # if re.search(r'\b(landing|lp)\b', path):
1861
+ # vote("landing_page", 1, "url path")
1862
+
1863
+ # # --- schema.org signals ---
1864
+ # if "product" in lower_types:
1865
+ # vote("product", 3, "Product schema")
1866
+ # if any(t in lower_types for t in ("article", "newsarticle", "blogposting")):
1867
+ # vote("article" if "newsarticle" not in lower_types else "news", 3, "Article-family schema")
1868
+ # if "organization" in lower_types and path == "":
1869
+ # vote("organization", 1, "Organization schema on root")
1870
+ # if "faqpage" in lower_types:
1871
+ # vote("documentation", 1, "FAQPage schema")
1872
+ # if any(t in lower_types for t in ("itemlist", "collectionpage")):
1873
+ # vote("portal", 2, "ItemList/CollectionPage schema")
1874
+ # if "webpage" in lower_types and path == "":
1875
+ # vote("homepage", 1, "WebPage schema on root")
1876
+
1877
+ # # --- structural/content signals ---
1878
+ # if nav_link_count >= 40 and word_count < 800:
1879
+ # vote("portal", 2, "high link density, low unique text")
1880
+ # if path == "" and nav_link_count >= 25:
1881
+ # vote("homepage", 2, "root path with heavy navigation")
1882
+ # if word_count >= 500 and heading_tags and not has_price:
1883
+ # vote("article", 1, "substantial prose content")
1884
+ # if has_price and add_to_cart:
1885
+ # vote("product", 3, "price + add-to-cart")
1886
+ # elif has_price:
1887
+ # vote("product", 1, "price present")
1888
+ # if question_headings >= 3:
1889
+ # vote("documentation", 1, "multiple question-style headings")
1890
+ # vote("forum", 1, "multiple question-style headings")
1891
+ # if breadcrumb_present and word_count > 300:
1892
+ # vote("article", 1, "breadcrumb + substantial content")
1893
+
1894
+ # if not votes:
1895
+ # return {"type": "unknown", "confidence": 0.3, "signals": []}
1896
+
1897
+ # page_type, top_votes = votes.most_common(1)[0]
1898
+ # total_votes = sum(votes.values())
1899
+ # confidence = round(min(0.98, 0.35 + (top_votes / max(1, total_votes)) * 0.6), 2)
1900
+ # return {"type": page_type, "confidence": confidence, "signals": signals}
1901
+
1902
+
1903
+ # # ==============================
1904
+ # # CATEGORY 1: TOPIC & SEMANTIC UNDERSTANDING (query-independent by default)
1905
+ # # ==============================
1906
+ # def _analyze_semantic(seo_data, soup, text, word_count, target_query=None):
1907
+ # title = (seo_data.get("title") or "").strip()
1908
+ # description = (seo_data.get("description") or "").strip()
1909
+ # h1s = [h.get_text(" ", strip=True) for h in soup.find_all("h1")]
1910
+ # h1_text = " ".join(h1s)
1911
+
1912
+ # title_words = set(w.lower() for w in _words(title) if len(w) > 3)
1913
+ # h1_words = set(w.lower() for w in _words(h1_text) if len(w) > 3)
1914
+ # desc_words = set(w.lower() for w in _words(description) if len(w) > 3)
1915
+
1916
+ # # topic_clarity: title exists, has an h1, and they share vocabulary.
1917
+ # # This does NOT require an external reference query.
1918
+ # topic_clarity = 0
1919
+ # if title:
1920
+ # topic_clarity += 40
1921
+ # if h1_text:
1922
+ # topic_clarity += 30
1923
+ # if title_words and h1_words:
1924
+ # overlap = len(title_words & h1_words) / max(1, len(title_words | h1_words))
1925
+ # topic_clarity += round(overlap * 30)
1926
+ # topic_clarity = _clamp(topic_clarity)
1927
+
1928
+ # # topic_coherence: does title/description/h1 vocabulary agree with itself
1929
+ # # (internal consistency proxy, not "relevance" to any external query).
1930
+ # all_pairs = [p for p in [
1931
+ # (title_words, desc_words),
1932
+ # (title_words, h1_words),
1933
+ # (desc_words, h1_words),
1934
+ # ] if p[0] and p[1]]
1935
+ # if all_pairs:
1936
+ # sims = [len(a & b) / max(1, len(a | b)) for a, b in all_pairs]
1937
+ # topic_coherence = _clamp(round(sum(sims) / len(sims) * 100))
1938
+ # else:
1939
+ # topic_coherence = 0
1940
+
1941
+ # # content_completeness: word count + heading coverage + list/table presence
1942
+ # heading_count = len(soup.find_all(re.compile("^h[1-6]$")))
1943
+ # completeness = 0
1944
+ # if word_count >= 1000:
1945
+ # completeness += 40
1946
+ # elif word_count >= 500:
1947
+ # completeness += 30
1948
+ # elif word_count >= 300:
1949
+ # completeness += 15
1950
+ # if heading_count >= 3:
1951
+ # completeness += 30
1952
+ # elif heading_count >= 1:
1953
+ # completeness += 15
1954
+ # if soup.find_all(["ul", "ol", "table"]):
1955
+ # completeness += 15
1956
+ # if description:
1957
+ # completeness += 15
1958
+ # content_completeness = _clamp(completeness)
1959
+
1960
+ # # content_depth: vocabulary richness + paragraph count
1961
+ # paragraphs = [p.get_text(" ", strip=True) for p in soup.find_all("p")]
1962
+ # paragraphs = [p for p in paragraphs if p]
1963
+ # unique_words = set(w.lower() for w in _words(text))
1964
+ # richness = _pct(len(unique_words), max(1, word_count))
1965
+ # depth = 0
1966
+ # depth += min(40, round(richness))
1967
+ # depth += min(30, len(paragraphs) * 2)
1968
+ # depth += min(30, heading_count * 5)
1969
+ # content_depth = _clamp(depth)
1970
+
1971
+ # result = {
1972
+ # "topic_clarity": topic_clarity,
1973
+ # "topic_coherence": topic_coherence,
1974
+ # "content_completeness": content_completeness,
1975
+ # "content_depth": content_depth,
1976
+ # "semantic_relevance": None,
1977
+ # "semantic_relevance_status": "requires_target_query",
1978
+ # "search_intent_match": None,
1979
+ # "search_intent_match_status": "requires_target_query",
1980
+ # }
1981
+
1982
+ # # These two metrics genuinely require a reference query/keyword to mean
1983
+ # # anything. Without one they are reported as unknown, not guessed at.
1984
+ # if target_query:
1985
+ # query_words = set(w.lower() for w in _words(target_query) if len(w) > 2)
1986
+ # if query_words:
1987
+ # corpus_pairs = [
1988
+ # (query_words, title_words),
1989
+ # (query_words, h1_words),
1990
+ # (query_words, set(w.lower() for w in _words(text)[:400])),
1991
+ # ]
1992
+ # sims = [len(a & b) / max(1, len(a)) for a, b in corpus_pairs if a]
1993
+ # semantic_relevance = _clamp(round((sum(sims) / len(sims)) * 100)) if sims else 0
1994
+ # result["semantic_relevance"] = semantic_relevance
1995
+ # result["semantic_relevance_status"] = "measured"
1996
+
1997
+ # intent_markers = ["how to", "what is", "why", "best", "guide", "review", "vs", "top ", "buy", "price"]
1998
+ # lowered_query = target_query.lower()
1999
+ # matched_intent = next((m for m in intent_markers if m in lowered_query), None)
2000
+ # overlap_in_content = len(query_words & set(w.lower() for w in _words(text))) / max(1, len(query_words))
2001
+ # search_intent_match = _clamp(round(overlap_in_content * 100))
2002
+ # if matched_intent in ("how to", "guide") and soup.find_all(["ol", "ul"]):
2003
+ # search_intent_match = _clamp(search_intent_match + 15)
2004
+ # result["search_intent_match"] = search_intent_match
2005
+ # result["search_intent_match_status"] = "measured"
2006
+
2007
+ # return result
2008
+
2009
+
2010
+ # # ==============================
2011
+ # # CATEGORY 2: ENTITY UNDERSTANDING
2012
+ # # ==============================
2013
+ # def _analyze_entities(seo_data, soup, text, word_count, schema_types, page_type):
2014
+ # # Proper-noun style heuristic: capitalized word sequences not at sentence start.
2015
+ # # This works from visible text/headings/title/links - it does NOT require
2016
+ # # JSON-LD to detect entities. Schema (below) only boosts confidence.
2017
+ # proper_noun_seqs = re.findall(r'(?<!\. )(?<!^)\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+){0,3})\b', text or "")
2018
+ # title_seqs = re.findall(r'\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+){0,3})\b', seo_data.get("title") or "")
2019
+ # heading_seqs = []
2020
+ # for h in soup.find_all(re.compile("^h[1-6]$")):
2021
+ # heading_seqs.extend(re.findall(r'\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+){0,3})\b', h.get_text(" ", strip=True)))
2022
+
2023
+ # entity_counter = Counter(s.strip() for s in proper_noun_seqs + title_seqs + heading_seqs if len(s.strip()) > 2)
2024
+ # entity_count = len(entity_counter)
2025
+
2026
+ # if word_count < 20:
2027
+ # entity_clarity = None
2028
+ # entity_clarity_status = "insufficient_text"
2029
+ # else:
2030
+ # entity_clarity = _clamp(round(_pct(entity_count, max(1, word_count // 20))))
2031
+ # entity_clarity_status = "measured"
2032
+
2033
+ # if entity_counter:
2034
+ # top_mentions = entity_counter.most_common(1)[0][1]
2035
+ # entity_consistency = _clamp(round(_pct(top_mentions, sum(entity_counter.values()))))
2036
+ # else:
2037
+ # entity_consistency = 0
2038
+
2039
+ # lower_types = [t.lower() for t in schema_types]
2040
+ # organization_entity_present = bool(
2041
+ # {"organization", "corporation", "localbusiness"} & set(lower_types)
2042
+ # ) or bool(soup.find(attrs={"itemtype": re.compile("Organization", re.I)})) or bool(
2043
+ # soup.find("footer") and re.search(r'\b(Inc\.|LLC|Ltd\.|Corporation|Corp\.)\b', text or "")
2044
+ # )
2045
+
2046
+ # author_entity_present = bool(
2047
+ # seo_data.get("metas", {}).get("author")
2048
+ # ) or bool(soup.find(attrs={"rel": "author"})) or bool(
2049
+ # soup.find(class_=re.compile("author|byline", re.I))
2050
+ # ) or "person" in lower_types
2051
+
2052
+ # product_entity_present = "product" in lower_types or bool(
2053
+ # re.search(r'\b(SKU|model number|add to cart)\b', text or "", re.I)
2054
+ # )
2055
+ # place_entity_present = bool(re.search(r'\b\d{5}(-\d{4})?\b', text or "")) and bool(
2056
+ # re.search(r'\b(Street|Ave|Avenue|Road|Blvd|City|Country)\b', text or "", re.I)
2057
+ # )
2058
+ # brand_entity_present = organization_entity_present or bool(
2059
+ # re.search(r'\b(®|™)\b', text or "")
2060
+ # )
2061
+
2062
+ # entity_types_present = [
2063
+ # t for t, present in [
2064
+ # ("organization", organization_entity_present),
2065
+ # ("person", author_entity_present),
2066
+ # ("product", product_entity_present),
2067
+ # ("place", place_entity_present),
2068
+ # ("brand", brand_entity_present),
2069
+ # ] if present
2070
+ # ]
2071
+
2072
+ # return {
2073
+ # "entity_count": entity_count,
2074
+ # "entity_clarity": entity_clarity,
2075
+ # "entity_clarity_status": entity_clarity_status,
2076
+ # "entity_consistency": entity_consistency,
2077
+ # "entity_types": entity_types_present,
2078
+ # "organization_entity_present": organization_entity_present,
2079
+ # "author_entity_present": author_entity_present,
2080
+ # "product_entity_present": product_entity_present,
2081
+ # "place_entity_present": place_entity_present,
2082
+ # "brand_entity_present": brand_entity_present,
2083
+ # }
2084
+
2085
+
2086
+ # # ==============================
2087
+ # # CATEGORY 3: ANSWERABILITY
2088
+ # # ==============================
2089
+ # def _analyze_answerability(soup, text):
2090
+ # heading_tags = soup.find_all(re.compile("^h[1-6]$"))
2091
+ # question_headings = [h for h in heading_tags if h.get_text(strip=True).endswith("?")]
2092
+ # question_count = len(question_headings)
2093
+
2094
+ # def _next_text_len(tag):
2095
+ # sib = tag.find_next_sibling()
2096
+ # hops = 0
2097
+ # while sib is not None and hops < 3:
2098
+ # content = sib.get_text(" ", strip=True)
2099
+ # if content:
2100
+ # return len(_words(content))
2101
+ # sib = sib.find_next_sibling()
2102
+ # hops += 1
2103
+ # return 0
2104
+
2105
+ # answered = sum(1 for h in question_headings if _next_text_len(h) >= 15)
2106
+ # questions_answered = answered
2107
+ # answer_coverage = _pct(answered, question_count) if question_count else None
2108
+
2109
+ # first_p = soup.find("p")
2110
+ # first_p_text = first_p.get_text(" ", strip=True) if first_p else ""
2111
+ # direct_answer_presence = bool(first_p_text) and 40 <= len(first_p_text) <= 400
2112
+
2113
+ # definition_presence = bool(
2114
+ # re.search(r'\b\w+\s+(is|are|refers to|means)\s+(a|an|the)\b', text or "", re.I)
2115
+ # ) or bool(soup.find("dfn")) or bool(soup.find("dl"))
2116
+
2117
+ # faq_heading = soup.find(
2118
+ # lambda t: t.name in ("h1", "h2", "h3") and re.search(r"faq|frequently asked", t.get_text(" ", strip=True), re.I)
2119
+ # )
2120
+ # faq_coverage = 100 if faq_heading else (_clamp(question_count * 20) if question_count else 0)
2121
+
2122
+ # return {
2123
+ # "question_count": question_count,
2124
+ # "questions_answered": questions_answered,
2125
+ # "answer_coverage": answer_coverage,
2126
+ # "direct_answer_presence": direct_answer_presence,
2127
+ # "definition_presence": definition_presence,
2128
+ # "faq_coverage": faq_coverage,
2129
+ # }
2130
+
2131
+
2132
+ # # ==============================
2133
+ # # CATEGORY 4: INFORMATION QUALITY
2134
+ # # ==============================
2135
+ # def _analyze_information_quality(soup, text):
2136
+ # sentences = re.split(r'(?<=[.!?])\s+', text or "")
2137
+ # stat_pattern = re.compile(r'\b\d+([.,]\d+)?\s?(%|percent)?\b')
2138
+ # factual_sentences = [s for s in sentences if re.search(r'\d', s) and stat_pattern.search(s)]
2139
+ # factual_claims = len(factual_sentences)
2140
+
2141
+ # claims_with_sources = sum(
2142
+ # 1 for s in factual_sentences if SOURCE_ATTRIBUTION_PATTERNS.search(s)
2143
+ # )
2144
+ # claims_with_sources_ratio = _pct(claims_with_sources, max(1, factual_claims)) if factual_claims else None
2145
+
2146
+ # original_info_hits = len(FIRST_HAND_PATTERNS.findall(text or ""))
2147
+ # original_information = _clamp(min(100, original_info_hits * 25))
2148
+ # first_hand_experience = original_information
2149
+
2150
+ # return {
2151
+ # "factual_claims": factual_claims,
2152
+ # "claims_with_sources": claims_with_sources,
2153
+ # "claims_with_sources_ratio": claims_with_sources_ratio,
2154
+ # "original_information": original_information,
2155
+ # "first_hand_experience": first_hand_experience,
2156
+ # }
2157
+
2158
+
2159
+ # # ==============================
2160
+ # # CATEGORY 5: TRUST / AUTHORITY
2161
+ # # ==============================
2162
+ # def _analyze_trust(seo_data, soup, text, links, page_type):
2163
+ # author_relevant = page_type in TYPES_WHERE_AUTHOR_RELEVANT
2164
+
2165
+ # author_bio_hit = bool(re.search(r'\b(PhD|M\.?D\.?|certified|expert|years of experience|founder|CEO|specialist)\b', text or "", re.I))
2166
+ # if author_relevant:
2167
+ # author_expertise = 70 if author_bio_hit else 0
2168
+ # author_expertise_status = "measured"
2169
+ # else:
2170
+ # author_expertise = None
2171
+ # author_expertise_status = "not_applicable"
2172
+ # author_credentials = author_bio_hit
2173
+
2174
+ # hrefs = [l.get("href", "") for l in links]
2175
+ # about_page_present = any(re.search(r'/about', h, re.I) for h in hrefs)
2176
+ # contact_page_present = any(re.search(r'/contact', h, re.I) for h in hrefs)
2177
+ # email_present = bool(re.search(r'[\w.+-]+@[\w-]+\.[\w.-]+', text or ""))
2178
+ # phone_present = bool(re.search(r'(\+?\d[\d\s().-]{7,}\d)', text or ""))
2179
+ # contact_information_present = contact_page_present or email_present or phone_present
2180
+
2181
+ # privacy_present = any(re.search(r'privacy', h, re.I) for h in hrefs)
2182
+ # terms_present = any(re.search(r'terms', h, re.I) for h in hrefs)
2183
+ # https_present = str(seo_data.get("url", "")).startswith("https://")
2184
+
2185
+ # trust_signal_flags = [about_page_present, contact_information_present, privacy_present, terms_present, https_present]
2186
+ # trust_signals = sum(trust_signal_flags)
2187
+
2188
+ # organization_transparency = _clamp(_pct(trust_signals, len(trust_signal_flags)))
2189
+
2190
+ # return {
2191
+ # "author_expertise": author_expertise,
2192
+ # "author_expertise_status": author_expertise_status,
2193
+ # "author_credentials": author_credentials if author_relevant else None,
2194
+ # "organization_transparency": organization_transparency,
2195
+ # "about_page_present": about_page_present,
2196
+ # "contact_information_present": contact_information_present,
2197
+ # "trust_signals": trust_signals,
2198
+ # }
2199
+
2200
+
2201
+ # # ==============================
2202
+ # # CATEGORY 6: STRUCTURED DATA (supporting signal, not dominant)
2203
+ # # ==============================
2204
+ # def _extract_schema_types(seo_data, soup):
2205
+ # schemas = seo_data.get("schemas", [])
2206
+ # schema_types = []
2207
+ # schema_valid = True
2208
+
2209
+ # for schema in schemas:
2210
+ # try:
2211
+ # if isinstance(schema, dict):
2212
+ # if "@type" in schema:
2213
+ # t = schema["@type"]
2214
+ # schema_types.extend(t if isinstance(t, list) else [t])
2215
+ # if "@graph" in schema and isinstance(schema["@graph"], list):
2216
+ # for item in schema["@graph"]:
2217
+ # if isinstance(item, dict) and "@type" in item:
2218
+ # schema_types.append(item["@type"])
2219
+ # elif isinstance(schema, list):
2220
+ # for item in schema:
2221
+ # if isinstance(item, dict) and "@type" in item:
2222
+ # schema_types.append(item["@type"])
2223
+ # except Exception:
2224
+ # schema_valid = False
2225
+
2226
+ # schema_types = list(dict.fromkeys(str(t) for t in schema_types))
2227
+ # return schema_types, schema_valid
2228
+
2229
+
2230
+ # def _analyze_structured_data(seo_data, schema_types, schema_valid, page_type):
2231
+ # lower_types = [t.lower() for t in schema_types]
2232
+ # schema_present = len(schema_types) > 0
2233
+ # organization_schema = any("organization" in t for t in lower_types)
2234
+ # article_schema = any("article" in t for t in lower_types)
2235
+ # product_schema = any("product" in t for t in lower_types)
2236
+ # faq_schema = any("faq" in t for t in lower_types)
2237
+ # breadcrumb_schema = any("breadcrumb" in t for t in lower_types)
2238
+
2239
+ # # completeness: how many of the schema types *relevant to this page type*
2240
+ # # are present, rather than expecting every type on every page.
2241
+ # relevant_types = {"organization"}
2242
+ # if page_type in TYPES_WHERE_ARTICLE_SCHEMA_RELEVANT:
2243
+ # relevant_types.add("article")
2244
+ # if page_type in TYPES_WHERE_PRODUCT_SCHEMA_RELEVANT:
2245
+ # relevant_types.add("product")
2246
+ # if page_type in TYPES_WHERE_FAQ_RELEVANT:
2247
+ # relevant_types.add("faq")
2248
+ # present_map = {
2249
+ # "organization": organization_schema, "article": article_schema,
2250
+ # "product": product_schema, "faq": faq_schema,
2251
+ # }
2252
+ # relevant_present = sum(1 for t in relevant_types if present_map.get(t))
2253
+ # schema_completeness = _clamp(round(_pct(relevant_present, max(1, len(relevant_types)))))
2254
+
2255
+ # org_schema_name = None
2256
+ # for schema in seo_data.get("schemas", []) or []:
2257
+ # if isinstance(schema, dict) and str(schema.get("@type", "")).lower() == "organization":
2258
+ # org_schema_name = schema.get("name")
2259
+ # break
2260
+ # domain = urlparse(seo_data.get("url", "")).netloc.replace("www.", "").split(".")[0]
2261
+ # schema_entity_alignment = bool(
2262
+ # org_schema_name and domain and domain.lower() in str(org_schema_name).lower()
2263
+ # )
2264
+
2265
+ # return {
2266
+ # "schema_present": schema_present,
2267
+ # "schema_types": ", ".join(schema_types) if schema_types else "not_detected",
2268
+ # "schema_valid": schema_valid,
2269
+ # "schema_completeness": schema_completeness,
2270
+ # "schema_entity_alignment": schema_entity_alignment,
2271
+ # "organization_schema": organization_schema,
2272
+ # "article_schema": article_schema,
2273
+ # "product_schema": product_schema,
2274
+ # "faq_schema": faq_schema,
2275
+ # "breadcrumb_schema": breadcrumb_schema,
2276
+ # }
2277
+
2278
+
2279
+ # # ==============================
2280
+ # # CATEGORY 7: RETRIEVAL / CRAWLABILITY
2281
+ # # ==============================
2282
+ # def _fetch_raw_html_sync(url, timeout=6):
2283
+ # """Cheap plain-HTTP GET (no browser) used only to compare against the
2284
+ # Playwright-rendered HTML, so we can tell whether critical content is
2285
+ # server-rendered or injected by JavaScript. This is a single lightweight
2286
+ # request per page, not a second full crawl."""
2287
+ # try:
2288
+ # req = Request(url, headers={"User-Agent": "Mozilla/5.0 (compatible; AIVisibilityBot/1.0)"})
2289
+ # with urlopen(req, timeout=timeout) as resp:
2290
+ # raw = resp.read(2_000_000)
2291
+ # return raw.decode("utf-8", errors="ignore")
2292
+ # except Exception:
2293
+ # return None
2294
+
2295
+
2296
+ # async def _analyze_retrieval(seo_data, word_count, text, html):
2297
+ # robots = (seo_data.get("robots") or "").lower()
2298
+ # indexable = "noindex" not in robots
2299
+ # robots_allowed = indexable # proxy: only meta robots is checked, robots.txt is not fetched
2300
+
2301
+ # canonical = seo_data.get("canonical") or ""
2302
+ # domain = urlparse(seo_data.get("url", "")).netloc
2303
+ # canonical_valid = bool(canonical) and (domain == "" or domain in canonical)
2304
+
2305
+ # content_accessible = word_count > 0
2306
+ # renderable_content = word_count >= 50
2307
+
2308
+ # url = seo_data.get("url", "")
2309
+ # raw_html = None
2310
+ # if url:
2311
+ # raw_html = await asyncio.get_event_loop().run_in_executor(None, _fetch_raw_html_sync, url)
2312
+
2313
+ # content_rendering = {
2314
+ # "raw_word_count": None,
2315
+ # "rendered_word_count": word_count,
2316
+ # "rendering_dependency_ratio": None,
2317
+ # "critical_content_server_rendered": None,
2318
+ # "retrieval_status": "unknown",
2319
+ # }
2320
+
2321
+ # if raw_html is not None:
2322
+ # raw_soup = BeautifulSoup(raw_html, "html.parser")
2323
+ # raw_text = raw_soup.get_text(separator=" ", strip=True)
2324
+ # raw_word_count = len(_words(raw_text))
2325
+ # content_rendering["raw_word_count"] = raw_word_count
2326
+ # ratio = _pct(raw_word_count, max(1, word_count))
2327
+ # content_rendering["rendering_dependency_ratio"] = ratio
2328
+ # content_rendering["critical_content_server_rendered"] = raw_word_count >= 50 and ratio >= 60
2329
+ # if not content_accessible:
2330
+ # content_rendering["retrieval_status"] = "poor"
2331
+ # elif content_rendering["critical_content_server_rendered"]:
2332
+ # content_rendering["retrieval_status"] = "good"
2333
+ # elif raw_word_count >= 50:
2334
+ # content_rendering["retrieval_status"] = "limited"
2335
+ # else:
2336
+ # content_rendering["retrieval_status"] = "js_dependent"
2337
+ # else:
2338
+ # # Couldn't do the plain-HTTP comparison (blocked, timeout, etc).
2339
+ # # We do NOT penalize the page for this - it's a measurement gap.
2340
+ # content_rendering["retrieval_status"] = "unknown"
2341
+
2342
+ # return {
2343
+ # "indexable": indexable,
2344
+ # "robots_allowed": robots_allowed,
2345
+ # "canonical_valid": canonical_valid,
2346
+ # "content_accessible": content_accessible,
2347
+ # "renderable_content": renderable_content,
2348
+ # "content_rendering": content_rendering,
2349
+ # }
2350
+
2351
+
2352
+ # # ==============================
2353
+ # # CATEGORY 8: CONTENT STRUCTURE
2354
+ # # ==============================
2355
+ # def _analyze_content_structure(soup):
2356
+ # heading_tags = soup.find_all(re.compile("^h[1-6]$"))
2357
+ # levels = []
2358
+ # for h in heading_tags:
2359
+ # try:
2360
+ # levels.append(int(h.name[1]))
2361
+ # except Exception:
2362
+ # continue
2363
+
2364
+ # heading_structure_score = 0
2365
+ # if 1 in levels:
2366
+ # heading_structure_score += 40
2367
+ # if 2 in levels:
2368
+ # heading_structure_score += 30
2369
+ # if levels == sorted(levels):
2370
+ # heading_structure_score += 30
2371
+ # heading_structure_score = _clamp(heading_structure_score)
2372
+
2373
+ # paragraphs = [p.get_text(" ", strip=True) for p in soup.find_all("p")]
2374
+ # paragraphs = [p for p in paragraphs if p]
2375
+ # if paragraphs:
2376
+ # avg_len = sum(len(_words(p)) for p in paragraphs) / len(paragraphs)
2377
+ # paragraph_clarity = 100 if 15 <= avg_len <= 40 else _clamp(100 - abs(avg_len - 27) * 3)
2378
+ # else:
2379
+ # paragraph_clarity = 0
2380
+
2381
+ # list_usage = len(soup.find_all(["ul", "ol"])) > 0
2382
+ # table_usage = len(soup.find_all("table")) > 0
2383
+ # definition_sections = bool(soup.find("dl")) or bool(soup.find("dfn"))
2384
+ # summary_present = bool(soup.find(
2385
+ # lambda t: t.name in ("h1", "h2", "h3") and re.search(r"summary|tl;?dr|key takeaways", t.get_text(" ", strip=True), re.I)
2386
+ # ))
2387
+
2388
+ # return {
2389
+ # "heading_structure_score": heading_structure_score,
2390
+ # "paragraph_clarity": round(paragraph_clarity),
2391
+ # "list_usage": list_usage,
2392
+ # "table_usage": table_usage,
2393
+ # "definition_sections": definition_sections,
2394
+ # "summary_present": summary_present,
2395
+ # }
2396
+
2397
+
2398
+ # # ==============================
2399
+ # # CATEGORY 9: CITATION POTENTIAL
2400
+ # # (based on original data/quotes/attribution actually present in the
2401
+ # # content, not on raw external-link counting)
2402
+ # # ==============================
2403
+ # def _analyze_citation_potential(soup, text):
2404
+ # numbers = re.findall(r'\b\d+(?:[.,]\d+)?%?\b', text or "")
2405
+ # unique_data_points = len(set(numbers))
2406
+ # statistics_present = unique_data_points > 0
2407
+
2408
+ # original_research = bool(FIRST_HAND_PATTERNS.search(text or ""))
2409
+
2410
+ # blockquotes = soup.find_all("blockquote")
2411
+ # quoted_sentences = re.findall(r'"[^"]{20,200}"', text or "")
2412
+ # quotable_statements = len(blockquotes) + len(quoted_sentences)
2413
+
2414
+ # source_attribution = len(SOURCE_ATTRIBUTION_PATTERNS.findall(text or ""))
2415
+
2416
+ # score = 0
2417
+ # score += min(35, unique_data_points * 3)
2418
+ # score += 25 if original_research else 0
2419
+ # score += min(20, quotable_statements * 5)
2420
+ # score += min(20, source_attribution * 10)
2421
+ # citation_potential = _clamp(score)
2422
+
2423
+ # return {
2424
+ # "unique_data_points": unique_data_points,
2425
+ # "statistics_present": statistics_present,
2426
+ # "original_research": original_research,
2427
+ # "quotable_statements": quotable_statements,
2428
+ # "source_attribution": source_attribution,
2429
+ # "citation_potential": citation_potential,
2430
+ # }
2431
+
2432
+
2433
+ # # ==============================
2434
+ # # CATEGORY 10: FRESHNESS (fresh / stale / very_stale / unknown - never
2435
+ # # "guessed outdated")
2436
+ # # ==============================
2437
+ # def _analyze_freshness(seo_data, soup, text, page_type):
2438
+ # metas = seo_data.get("metas", {}) or {}
2439
+ # last_updated = (
2440
+ # metas.get("article:modified_time")
2441
+ # or metas.get("article:published_time")
2442
+ # or metas.get("date")
2443
+ # )
2444
+
2445
+ # if not last_updated:
2446
+ # time_tag = soup.find("time", attrs={"datetime": True})
2447
+ # if time_tag:
2448
+ # last_updated = time_tag.get("datetime")
2449
+
2450
+ # if not last_updated:
2451
+ # for pattern in DATE_PATTERNS:
2452
+ # match = pattern.search(text or "")
2453
+ # if match:
2454
+ # last_updated = match.group(0)
2455
+ # break
2456
+
2457
+ # date_visible = bool(last_updated)
2458
+ # content_age_days = None
2459
+
2460
+ # if last_updated:
2461
+ # try:
2462
+ # from datetime import datetime, timezone
2463
+ # parsed = None
2464
+ # for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%d"):
2465
+ # try:
2466
+ # parsed = datetime.strptime(last_updated[:19].replace("Z", ""), fmt)
2467
+ # break
2468
+ # except Exception:
2469
+ # continue
2470
+ # if parsed:
2471
+ # parsed = parsed.replace(tzinfo=timezone.utc)
2472
+ # content_age_days = (datetime.now(timezone.utc) - parsed).days
2473
+ # except Exception:
2474
+ # content_age_days = None
2475
+
2476
+ # if not date_visible:
2477
+ # freshness_status = "unknown"
2478
+ # freshness_reason = "No reliable publication/update date detected on the page."
2479
+ # elif isinstance(content_age_days, int):
2480
+ # # FIX: previously the 365< age <=730 and >730 branches both produced
2481
+ # # "stale", making the 730-day boundary dead code. Now a genuine
2482
+ # # "very_stale" tier exists for content older than 2 years.
2483
+ # if content_age_days <= 365:
2484
+ # freshness_status = "fresh"
2485
+ # elif content_age_days <= 730:
2486
+ # freshness_status = "stale"
2487
+ # else:
2488
+ # freshness_status = "very_stale"
2489
+ # freshness_reason = f"Date detected: content is {content_age_days} days old."
2490
+ # else:
2491
+ # freshness_status = "unknown"
2492
+ # freshness_reason = "A date-like string was found but could not be reliably parsed."
2493
+
2494
+ # return {
2495
+ # "last_updated": last_updated or "not_detected",
2496
+ # "content_age_days": content_age_days,
2497
+ # "update_frequency": "not_detected", # requires historical crawl data, unavailable here
2498
+ # "date_visible": date_visible,
2499
+ # "freshness_status": freshness_status,
2500
+ # "freshness_reason": freshness_reason,
2501
+ # "freshness_relevant": page_type in TYPES_WHERE_FRESHNESS_RELEVANT,
2502
+ # }
2503
+
2504
+
2505
+ # # ==============================
2506
+ # # CATEGORY 11: BRAND / ENTITY CONSISTENCY
2507
+ # # ==============================
2508
+ # def _analyze_brand_consistency(seo_data, soup, text, schema_types):
2509
+ # domain = urlparse(seo_data.get("url", "")).netloc.replace("www.", "")
2510
+ # brand_guess = domain.split(".")[0] if domain else ""
2511
+
2512
+ # title = (seo_data.get("title") or "")
2513
+ # footer = soup.find("footer")
2514
+ # footer_text = footer.get_text(" ", strip=True) if footer else ""
2515
+
2516
+ # brand_in_title = bool(brand_guess) and brand_guess.lower() in title.lower()
2517
+ # brand_in_footer = bool(brand_guess) and brand_guess.lower() in footer_text.lower()
2518
+ # brand_in_body = bool(brand_guess) and brand_guess.lower() in (text or "").lower()
2519
+ # brand_name_consistency = _clamp(sum([brand_in_title, brand_in_footer, brand_in_body]) * 33)
2520
+
2521
+ # org_schema_name = None
2522
+ # for schema in seo_data.get("schemas", []) or []:
2523
+ # if isinstance(schema, dict) and str(schema.get("@type", "")).lower() == "organization":
2524
+ # org_schema_name = schema.get("name")
2525
+ # break
2526
+
2527
+ # company_information_consistency = _clamp(
2528
+ # 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)
2529
+ # )
2530
+
2531
+ # author_meta = (seo_data.get("metas", {}) or {}).get("author", "")
2532
+ # byline = soup.find(class_=re.compile("author|byline", re.I))
2533
+ # byline_text = byline.get_text(" ", strip=True) if byline else ""
2534
+ # author_information_consistency = _clamp(
2535
+ # 70 if (author_meta and byline_text and author_meta.lower() in byline_text.lower())
2536
+ # else (40 if (author_meta or byline_text) else 0)
2537
+ # )
2538
+
2539
+ # return {
2540
+ # "brand_name_consistency": brand_name_consistency,
2541
+ # "company_information_consistency": company_information_consistency,
2542
+ # "author_information_consistency": author_information_consistency,
2543
+ # }
2544
+
2545
+
2546
+ # # ==============================
2547
+ # # CATEGORY SCORE ROLLUPS (unknown values excluded, weight redistributed)
2548
+ # # ==============================
2549
+ # def _avg_known(*values):
2550
+ # """Average only the non-None values. Returns None if all are unknown -
2551
+ # the caller decides how to treat that (usually: exclude from rollup)."""
2552
+ # known = [v for v in values if v is not None]
2553
+ # if not known:
2554
+ # return None
2555
+ # return round(sum(known) / len(known))
2556
+
2557
+
2558
+ # def _rollup_scores(m, page_type):
2559
+ # weights = get_weights_for_page_type(page_type)
2560
+
2561
+ # semantic_parts = [m["topic_clarity"], m["topic_coherence"], m["content_completeness"], m["content_depth"]]
2562
+ # if m.get("semantic_relevance") is not None:
2563
+ # semantic_parts.append(m["semantic_relevance"])
2564
+ # if m.get("search_intent_match") is not None:
2565
+ # semantic_parts.append(m["search_intent_match"])
2566
+ # semantic = _avg_known(*semantic_parts)
2567
+
2568
+ # content_answerability = _avg_known(
2569
+ # m["content_completeness"],
2570
+ # m["answer_coverage"],
2571
+ # 100 if m["direct_answer_presence"] else 0,
2572
+ # 100 if m["definition_presence"] else 0,
2573
+ # m["faq_coverage"] if page_type in TYPES_WHERE_FAQ_RELEVANT or m["question_count"] > 0 else None,
2574
+ # _clamp(min(100, m["factual_claims"] * 10)),
2575
+ # )
2576
+
2577
+ # entity_parts = [m["entity_consistency"]]
2578
+ # if m.get("entity_clarity") is not None:
2579
+ # entity_parts.append(m["entity_clarity"])
2580
+ # entity_parts.append(100 if m["organization_entity_present"] else 0)
2581
+ # if page_type in TYPES_WHERE_AUTHOR_RELEVANT:
2582
+ # entity_parts.append(100 if m["author_entity_present"] else 0)
2583
+ # entity = _avg_known(*entity_parts)
2584
+
2585
+ # trust_parts = [
2586
+ # m["organization_transparency"],
2587
+ # 100 if m["about_page_present"] else 0,
2588
+ # 100 if m["contact_information_present"] else 0,
2589
+ # ]
2590
+ # if m.get("author_expertise") is not None:
2591
+ # trust_parts.append(m["author_expertise"])
2592
+ # trust = _avg_known(*trust_parts)
2593
+
2594
+ # citation = m["citation_potential"]
2595
+
2596
+ # retrieval_parts = [
2597
+ # 100 if m["indexable"] else 0,
2598
+ # 100 if m["canonical_valid"] else 0,
2599
+ # 100 if m["content_accessible"] else 0,
2600
+ # ]
2601
+ # rendering_status = m.get("content_rendering", {}).get("retrieval_status")
2602
+ # if rendering_status == "good":
2603
+ # retrieval_parts.append(100)
2604
+ # elif rendering_status == "limited":
2605
+ # retrieval_parts.append(60)
2606
+ # elif rendering_status == "js_dependent":
2607
+ # retrieval_parts.append(30)
2608
+ # # "unknown" contributes nothing - not penalized, not rewarded.
2609
+ # retrieval = _avg_known(*retrieval_parts)
2610
+
2611
+ # schema_hits = sum([
2612
+ # m["organization_schema"], m["article_schema"], m["product_schema"],
2613
+ # m["faq_schema"], m["breadcrumb_schema"],
2614
+ # ])
2615
+ # structured_data = _clamp(
2616
+ # m["schema_completeness"] * 0.6 + (20 if m["schema_valid"] and m["schema_present"] else 0) + schema_hits * 4
2617
+ # )
2618
+
2619
+ # if m["freshness_status"] == "fresh":
2620
+ # if isinstance(m["content_age_days"], int):
2621
+ # freshness = 100 if m["content_age_days"] <= 90 else 75
2622
+ # else:
2623
+ # freshness = 75
2624
+ # elif m["freshness_status"] == "stale":
2625
+ # freshness = 30
2626
+ # elif m["freshness_status"] == "very_stale":
2627
+ # freshness = 10
2628
+ # else:
2629
+ # freshness = None # unknown - excluded from weighted rollup entirely
2630
+
2631
+ # components = {
2632
+ # "semantic": semantic,
2633
+ # "content_answerability": content_answerability,
2634
+ # "entity": entity,
2635
+ # "trust": trust,
2636
+ # "citation": citation,
2637
+ # "retrieval": retrieval,
2638
+ # "structured_data": structured_data,
2639
+ # "freshness": freshness,
2640
+ # }
2641
+
2642
+ # # Weighted average over KNOWN components only; unknown components'
2643
+ # # weight is redistributed proportionally rather than counted as 0.
2644
+ # known_weight = sum(weights[k] for k, v in components.items() if v is not None)
2645
+ # if known_weight <= 0:
2646
+ # ai_readiness_score = 0
2647
+ # else:
2648
+ # ai_readiness_score = round(
2649
+ # sum(components[k] * weights[k] for k in components if components[k] is not None) / known_weight
2650
+ # )
2651
+ # ai_readiness_score = _clamp(ai_readiness_score)
2652
+
2653
+ # return {
2654
+ # "semantic_score": semantic,
2655
+ # "content_answerability_score": content_answerability,
2656
+ # "entity_score": entity,
2657
+ # "trust_score": trust,
2658
+ # "citation_potential_score": citation,
2659
+ # "retrieval_score": retrieval,
2660
+ # "structured_data_score": structured_data,
2661
+ # "freshness_score": freshness,
2662
+ # "ai_readiness_score": ai_readiness_score,
2663
+ # # legacy alias kept for the existing frontend/API consumers
2664
+ # "ai_visibility_score": ai_readiness_score,
2665
+ # "weights_used": weights,
2666
+ # }
2667
+
2668
+
2669
+ # # ==============================
2670
+ # # OPTIONAL LLM ENHANCEMENT (semantic-only, reuses existing OpenAI setup)
2671
+ # # ==============================
2672
+ # async def _llm_semantic_enhance(seo_data, text, deterministic, target_query=None):
2673
+ # # FIX: previously used the removed openai<1.0 `openai.ChatCompletion.create`
2674
+ # # API, which raises AttributeError on any openai-python>=1.0 install and
2675
+ # # silently fell back to deterministic-only scoring every time. Now uses
2676
+ # # the current client-based API (openai.OpenAI().chat.completions.create).
2677
+ # client = _get_openai_client()
2678
+ # if not OPENAI_AVAILABLE or client is None:
2679
+ # return None
2680
+
2681
+ # excerpt = (text or "")[:2000]
2682
+ # query_line = f"TARGET QUERY: {target_query}\n" if target_query else ""
2683
+ # prompt = f"""You are assessing AI-search readiness of a web page (not traditional SEO).
2684
+ # Given the page title, meta description and a content excerpt, score each item 0-100 based on how easily
2685
+ # an AI system could understand and summarize this page. Return ONLY valid JSON with these exact keys:
2686
+ # topic_clarity, content_completeness, entity_clarity, citation_potential, original_information{"," if target_query else ""}
2687
+ # {"semantic_relevance, search_intent_match" if target_query else ""}
2688
+
2689
+ # {query_line}TITLE: {seo_data.get('title', '')}
2690
+ # META DESCRIPTION: {seo_data.get('description', '')}
2691
+ # CONTENT EXCERPT: {excerpt}
2692
+ # """
2693
+ # try:
2694
+ # response = await asyncio.get_event_loop().run_in_executor(
2695
+ # None,
2696
+ # lambda: client.chat.completions.create(
2697
+ # model="gpt-4o-mini",
2698
+ # messages=[
2699
+ # {"role": "system", "content": "You output only strict JSON, no prose, no markdown fences."},
2700
+ # {"role": "user", "content": prompt},
2701
+ # ],
2702
+ # max_tokens=300,
2703
+ # temperature=0.3,
2704
+ # ),
2705
+ # )
2706
+ # raw = response.choices[0].message.content.strip()
2707
+ # raw = re.sub(r"^```(json)?|```$", "", raw.strip(), flags=re.I).strip()
2708
+ # data = json.loads(raw)
2709
+ # keys = ["topic_clarity", "content_completeness", "entity_clarity", "citation_potential", "original_information"]
2710
+ # if target_query:
2711
+ # keys += ["semantic_relevance", "search_intent_match"]
2712
+ # cleaned = {}
2713
+ # for k in keys:
2714
+ # v = data.get(k)
2715
+ # if isinstance(v, (int, float)):
2716
+ # cleaned[k] = _clamp(round(v))
2717
+ # return cleaned or None
2718
+ # except Exception as e:
2719
+ # print(f" AI visibility LLM enhancement failed: {str(e)[:120]}")
2720
+ # return None
2721
+
2722
+
2723
+ # def _blend(deterministic_value, llm_value):
2724
+ # if llm_value is None:
2725
+ # return deterministic_value
2726
+ # if deterministic_value is None:
2727
+ # return llm_value
2728
+ # return round((deterministic_value + llm_value) / 2)
2729
+
2730
+
2731
+ # # ==============================
2732
+ # # PER-PAGE ORCHESTRATION
2733
+ # # ==============================
2734
+ # async def analyze_page_ai_visibility(seo_data, domain, use_ai=False, target_query=None):
2735
+ # """Compute the full ai_visibility metric set for one already-fetched page."""
2736
+ # empty_scores = {
2737
+ # "semantic_score": None, "content_answerability_score": None, "entity_score": None,
2738
+ # "trust_score": None, "citation_potential_score": None, "retrieval_score": None,
2739
+ # "structured_data_score": None, "freshness_score": None,
2740
+ # "ai_readiness_score": 0, "ai_visibility_score": 0, "weights_used": {},
2741
+ # }
2742
+ # try:
2743
+ # html = seo_data.get("html", "")
2744
+ # soup = BeautifulSoup(html, "html.parser")
2745
+ # text = soup.get_text(separator=" ", strip=True)
2746
+ # word_count = len(_words(text))
2747
+ # links = seo_data.get("links", [])
2748
+
2749
+ # schema_types, schema_valid = _extract_schema_types(seo_data, soup)
2750
+ # page_type_info = classify_page_type(seo_data, soup, text, word_count, schema_types, links)
2751
+ # page_type = page_type_info["type"]
2752
+
2753
+ # m = {}
2754
+ # m.update(_analyze_semantic(seo_data, soup, text, word_count, target_query))
2755
+ # m.update(_analyze_entities(seo_data, soup, text, word_count, schema_types, page_type))
2756
+ # m.update(_analyze_answerability(soup, text))
2757
+ # m.update(_analyze_information_quality(soup, text))
2758
+ # m.update(_analyze_trust(seo_data, soup, text, links, page_type))
2759
+ # m.update(_analyze_structured_data(seo_data, schema_types, schema_valid, page_type))
2760
+ # m.update(await _analyze_retrieval(seo_data, word_count, text, html))
2761
+ # m.update(_analyze_content_structure(soup))
2762
+ # m.update(_analyze_citation_potential(soup, text))
2763
+ # m.update(_analyze_freshness(seo_data, soup, text, page_type))
2764
+ # m.update(_analyze_brand_consistency(seo_data, soup, text, schema_types))
2765
+
2766
+ # if use_ai:
2767
+ # llm_result = await _llm_semantic_enhance(seo_data, text, m, target_query)
2768
+ # if llm_result:
2769
+ # for k, v in llm_result.items():
2770
+ # if k in m:
2771
+ # m[k] = _blend(m[k], v)
2772
+
2773
+ # scores = _rollup_scores(m, page_type)
2774
+
2775
+ # # Build unknown/not-applicable metric lists for transparency.
2776
+ # unknown_metrics = []
2777
+ # if m.get("semantic_relevance_status") == "requires_target_query":
2778
+ # unknown_metrics.append({"metric": "semantic_relevance", "reason": "No target keyword/query was supplied for this analysis."})
2779
+ # if m.get("freshness_status") == "unknown":
2780
+ # unknown_metrics.append({"metric": "freshness", "reason": m.get("freshness_reason", "No date detected.")})
2781
+ # if m.get("author_expertise_status") == "not_applicable":
2782
+ # unknown_metrics.append({"metric": "author_expertise", "reason": f"Not applicable for page type '{page_type}'."})
2783
+ # if m.get("content_rendering", {}).get("retrieval_status") == "unknown":
2784
+ # unknown_metrics.append({"metric": "retrieval_rendering", "reason": "Could not fetch raw HTML to compare against rendered content."})
2785
+
2786
+ # page_ai_visibility = {
2787
+ # "page_type": page_type,
2788
+ # "page_type_confidence": page_type_info["confidence"],
2789
+ # "topic_clarity": m["topic_clarity"],
2790
+ # "semantic_relevance": m["semantic_relevance"],
2791
+ # "content_completeness": m["content_completeness"],
2792
+ # "entity_clarity": m["entity_clarity"],
2793
+ # "answer_coverage": m["answer_coverage"],
2794
+ # "factual_information": m["factual_claims"],
2795
+ # "original_information": m["original_information"],
2796
+ # "author_expertise": m["author_expertise"],
2797
+ # "schema_quality": scores["structured_data_score"],
2798
+ # "crawlability": scores["retrieval_score"],
2799
+ # "content_structure": m["heading_structure_score"],
2800
+ # "citation_potential": m["citation_potential"],
2801
+ # "freshness_status": m["freshness_status"],
2802
+ # "freshness": scores["freshness_score"],
2803
+ # "ai_readiness_score": scores["ai_readiness_score"],
2804
+ # "ai_visibility_score": scores["ai_visibility_score"],
2805
+ # }
2806
+
2807
+ # return {
2808
+ # "url": seo_data.get("url", ""),
2809
+ # "title": seo_data.get("title", ""),
2810
+ # "page_type": page_type,
2811
+ # "page_type_confidence": page_type_info["confidence"],
2812
+ # "raw_metrics": m,
2813
+ # "scores": scores,
2814
+ # "unknown_metrics": unknown_metrics,
2815
+ # "ai_visibility": page_ai_visibility,
2816
+ # }
2817
+ # except Exception as e:
2818
+ # print(f"AI visibility analysis error for {seo_data.get('url', 'unknown')}: {e}")
2819
+ # return {
2820
+ # "url": seo_data.get("url", ""),
2821
+ # "title": seo_data.get("title", ""),
2822
+ # "page_type": "unknown",
2823
+ # "page_type_confidence": 0,
2824
+ # "raw_metrics": {},
2825
+ # "scores": empty_scores,
2826
+ # "unknown_metrics": [],
2827
+ # "ai_visibility": {
2828
+ # "page_type": "unknown", "page_type_confidence": 0,
2829
+ # "topic_clarity": 0, "semantic_relevance": None, "content_completeness": 0,
2830
+ # "entity_clarity": None, "answer_coverage": None, "factual_information": 0,
2831
+ # "original_information": 0, "author_expertise": None, "schema_quality": 0,
2832
+ # "crawlability": 0, "content_structure": 0, "citation_potential": 0,
2833
+ # "freshness_status": "unknown", "freshness": None,
2834
+ # "ai_readiness_score": 0, "ai_visibility_score": 0,
2835
+ # },
2836
+ # "error": str(e),
2837
+ # }
2838
+
2839
+
2840
+ # # ==============================
2841
+ # # ISSUE / STRENGTH GENERATION (evidence-based, page-type-aware)
2842
+ # # ==============================
2843
+ # def _build_issues_and_strengths(page_results):
2844
+ # issues = []
2845
+ # strengths = []
2846
+ # unknowns = []
2847
+
2848
+ # for p in page_results:
2849
+ # s = p["scores"]
2850
+ # m = p["raw_metrics"]
2851
+ # url = p["url"]
2852
+ # page_type = p.get("page_type", "unknown")
2853
+ # if not m:
2854
+ # continue
2855
+
2856
+ # # --- Answerability: only relevant where questions/FAQ genuinely matter ---
2857
+ # if page_type in TYPES_WHERE_FAQ_RELEVANT or m.get("question_count", 0) > 0:
2858
+ # ca_score = s.get("content_answerability_score")
2859
+ # if ca_score is not None and ca_score < 50:
2860
+ # issues.append({
2861
+ # "title": "Missing clear answers to common questions",
2862
+ # "severity": "high" if ca_score < 25 else "medium",
2863
+ # "page": url, "metric": "content_answerability_score", "current_value": ca_score,
2864
+ # "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.",
2865
+ # "recommended_fix": "Add direct, concise answers (40-300 chars) immediately after question-style headings, and consider an FAQ section.",
2866
+ # })
2867
+ # elif ca_score is not None and ca_score >= 70:
2868
+ # strengths.append({"page": url, "title": "Strong answerability", "detail": f"Content & answerability score {ca_score}/100."})
2869
+
2870
+ # # --- Entity ---
2871
+ # if s.get("entity_score") is not None:
2872
+ # if s["entity_score"] < 40:
2873
+ # issues.append({
2874
+ # "title": "Weak entity information",
2875
+ # "severity": "medium", "page": url, "metric": "entity_score", "current_value": s["entity_score"],
2876
+ # "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.",
2877
+ # "recommended_fix": "Mention your organization/brand and product names explicitly and consistently, and add Organization/Person schema.",
2878
+ # })
2879
+ # elif s["entity_score"] >= 70:
2880
+ # strengths.append({"page": url, "title": "Strong entity clarity", "detail": f"Entity score {s['entity_score']}/100."})
2881
+
2882
+ # # --- Trust / author (only where relevant to page type) ---
2883
+ # if page_type in TYPES_WHERE_AUTHOR_RELEVANT:
2884
+ # if not m.get("author_entity_present") or (m.get("author_expertise") in (0, None)):
2885
+ # issues.append({
2886
+ # "title": "No author expertise information detected",
2887
+ # "severity": "medium", "page": url, "metric": "author_expertise", "current_value": m.get("author_expertise"),
2888
+ # "explanation": f"No author byline or credentials were found on this {page_type} page, where authorship signals matter for trust.",
2889
+ # "recommended_fix": "Add a visible author byline with credentials, or an author bio linking to their expertise.",
2890
+ # })
2891
+
2892
+ # # --- Citation potential ---
2893
+ # if s.get("citation_potential_score") is not None and s["citation_potential_score"] < 40:
2894
+ # issues.append({
2895
+ # "title": "Low citation potential",
2896
+ # "severity": "low", "page": url, "metric": "citation_potential_score", "current_value": s["citation_potential_score"],
2897
+ # "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.",
2898
+ # "recommended_fix": "Add original statistics, data points, or quotable expert statements with clear sourcing.",
2899
+ # })
2900
+ # elif s.get("citation_potential_score", 0) >= 70:
2901
+ # strengths.append({"page": url, "title": "Strong citation potential", "detail": f"Citation potential score {s['citation_potential_score']}/100."})
2902
+
2903
+ # # --- Retrieval / JS dependency: only flagged when there's actual evidence content is JS-gated ---
2904
+ # rendering = m.get("content_rendering", {})
2905
+ # if rendering.get("retrieval_status") == "js_dependent":
2906
+ # issues.append({
2907
+ # "title": "Critical content is not present in server-rendered HTML",
2908
+ # "severity": "medium", "page": url, "metric": "retrieval_rendering",
2909
+ # "current_value": rendering.get("rendering_dependency_ratio"),
2910
+ # "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.",
2911
+ # "recommended_fix": "Ensure key content is present in server-rendered HTML (SSR) or a no-JS fallback.",
2912
+ # })
2913
+ # elif rendering.get("retrieval_status") == "good":
2914
+ # strengths.append({"page": url, "title": "Content is server-rendered", "detail": "Critical text content is present without executing JavaScript."})
2915
+
2916
+ # # --- Content structure ---
2917
+ # if m.get("heading_structure_score", 0) < 50:
2918
+ # issues.append({
2919
+ # "title": "Poor content structure",
2920
+ # "severity": "low", "page": url, "metric": "heading_structure_score", "current_value": m.get("heading_structure_score", 0),
2921
+ # "explanation": f"Heading structure score is {m.get('heading_structure_score', 0)}/100.",
2922
+ # "recommended_fix": "Use a single H1 followed by a logical H2/H3 hierarchy so AI systems can segment the content.",
2923
+ # })
2924
+
2925
+ # # --- Structured data: only an issue when a relevant schema type is actually missing ---
2926
+ # relevant_missing = []
2927
+ # if page_type in TYPES_WHERE_ARTICLE_SCHEMA_RELEVANT and not m.get("article_schema"):
2928
+ # relevant_missing.append("Article")
2929
+ # if page_type in TYPES_WHERE_PRODUCT_SCHEMA_RELEVANT and not m.get("product_schema"):
2930
+ # relevant_missing.append("Product")
2931
+ # if page_type in TYPES_WHERE_FAQ_RELEVANT and m.get("question_count", 0) >= 2 and not m.get("faq_schema"):
2932
+ # relevant_missing.append("FAQPage")
2933
+ # if relevant_missing:
2934
+ # issues.append({
2935
+ # "title": "Missing structured data",
2936
+ # "severity": "medium", "page": url, "metric": "schema_present", "current_value": m.get("schema_types"),
2937
+ # "explanation": f"No {'/'.join(relevant_missing)} schema was detected, though it is relevant for a '{page_type}' page.",
2938
+ # "recommended_fix": f"Add {'/'.join(relevant_missing)} JSON-LD structured data.",
2939
+ # })
2940
+
2941
+ # # --- Freshness: unknown is reported separately, never as "outdated" ---
2942
+ # if m.get("freshness_status") == "unknown" and page_type in TYPES_WHERE_FRESHNESS_RELEVANT:
2943
+ # unknowns.append({"page": url, "metric": "freshness", "reason": "No publish/update date could be detected on this page, where freshness is typically relevant."})
2944
+ # elif m.get("freshness_status") in ("stale", "very_stale"):
2945
+ # issues.append({
2946
+ # "title": "Content appears stale",
2947
+ # "severity": "low" if m.get("freshness_status") == "stale" else "medium",
2948
+ # "page": url, "metric": "freshness_status", "current_value": m.get("content_age_days"),
2949
+ # "explanation": f"A reliable date was detected and the content is {m.get('content_age_days')} days old.",
2950
+ # "recommended_fix": "Review and update the content, then refresh the visible date / article:modified_time meta tag.",
2951
+ # })
2952
+
2953
+ # for u in p.get("unknown_metrics", []):
2954
+ # unknowns.append({"page": url, **u})
2955
+
2956
+ # severity_order = {"critical": 0, "high": 1, "medium": 2, "low": 3}
2957
+ # issues.sort(key=lambda x: severity_order.get(x["severity"], 4))
2958
+ # return issues[:30], strengths[:20], unknowns[:30]
2959
+
2960
+
2961
+ # # ==============================
2962
+ # # TOP-LEVEL ENTRYPOINT
2963
+ # # ==============================
2964
+ # async def run_ai_visibility_analysis(base_url, max_pages=5, max_concurrent=1, use_ai=False, target_query=None):
2965
+ # """
2966
+ # Fetches pages (once) and computes AI-readiness metrics for each.
2967
+
2968
+ # Returns a dict with an `ai_readiness_score` (transparent 0-100 proxy
2969
+ # score for how well pages are prepared to be understood/cited by AI
2970
+ # systems) plus per-page results, page-type classification, issues,
2971
+ # strengths, and unknown metrics. `actual_ai_visibility` is always
2972
+ # "not_measured" - this crawler has no access to real AI-query/citation
2973
+ # data, so it never fabricates one.
2974
+ # """
2975
+ # if not base_url:
2976
+ # raise ValueError("base_url is required")
2977
+
2978
+ # domain = urlparse(base_url).netloc
2979
+
2980
+ # urls = await discover_urls_parallel(base_url, max_pages)
2981
+ # if not urls:
2982
+ # urls = [base_url]
2983
+
2984
+ # playwright_data = await fetch_all_pages_parallel(urls, max_concurrent)
2985
+ # if not playwright_data:
2986
+ # return {
2987
+ # "status": "error",
2988
+ # "message": "Failed to fetch any pages. Site may be blocking bots or require authentication.",
2989
+ # }
2990
+
2991
+ # page_results = []
2992
+ # for seo_data in playwright_data:
2993
+ # result = await analyze_page_ai_visibility(seo_data, domain, use_ai=use_ai, target_query=target_query)
2994
+ # page_results.append(result)
2995
+
2996
+ # valid_scores = [p["scores"]["ai_readiness_score"] for p in page_results if p.get("raw_metrics")]
2997
+ # overall_score = round(sum(valid_scores) / len(valid_scores)) if valid_scores else 0
2998
+
2999
+ # def _avg(key):
3000
+ # vals = [p["scores"][key] for p in page_results if p.get("raw_metrics") and p["scores"].get(key) is not None]
3001
+ # return round(sum(vals) / len(vals)) if vals else None
3002
+
3003
+ # category_averages = {
3004
+ # "semantic_score": _avg("semantic_score"),
3005
+ # "content_answerability_score": _avg("content_answerability_score"),
3006
+ # "entity_score": _avg("entity_score"),
3007
+ # "trust_score": _avg("trust_score"),
3008
+ # "citation_potential_score": _avg("citation_potential_score"),
3009
+ # "retrieval_score": _avg("retrieval_score"),
3010
+ # "structured_data_score": _avg("structured_data_score"),
3011
+ # "freshness_score": _avg("freshness_score"),
3012
+ # }
3013
+
3014
+ # page_type_breakdown = dict(Counter(p.get("page_type", "unknown") for p in page_results))
3015
+
3016
+ # issues, strengths, unknowns = _build_issues_and_strengths(page_results)
3017
+
3018
+ # pages_summary = [
3019
+ # {
3020
+ # "url": p["url"],
3021
+ # "title": p["title"],
3022
+ # **p["ai_visibility"],
3023
+ # }
3024
+ # for p in page_results
3025
+ # ]
3026
+
3027
+ # return {
3028
+ # "status": "success",
3029
+ # "url": base_url,
3030
+ # "pages_analyzed": len(page_results),
3031
+ # "page_type_breakdown": page_type_breakdown,
3032
+ # "target_query": target_query,
3033
+ # "ai_readiness_score": overall_score,
3034
+ # # legacy alias for existing frontend/API consumers
3035
+ # "ai_visibility_score": overall_score,
3036
+ # "actual_ai_visibility": {
3037
+ # "status": "not_measured",
3038
+ # "reason": "Real AI-query citation/mention data is not available to this crawler. This score reflects readiness proxies only, not observed visibility.",
3039
+ # },
3040
+ # "category_scores": category_averages,
3041
+ # "issues": issues,
3042
+ # "strengths": strengths,
3043
+ # "unknown_metrics": unknowns,
3044
+ # "results_preview": pages_summary,
3045
+ # }