Python GEO scoring
score_page()scores an HTML page across the same seven fixed-weight GEO dimensions, using the same weights, as the npm package's ContentAnalyzer— the dimension keys and weights are vendored verbatim from the npm package's published scoring-weights.json. What's original to the Python package is the per-dimension arithmetic: code written to match each dimension's published description, not a port of unpublished TypeScript internals.
This page covers the Python-specific score_page() API. For the weight rationale shared across both ecosystems, see the scoring methodology page.
The seven dimensions
| Dimension | Key (snake_case) | Weight |
|---|---|---|
| Answer placement | answer_front_loading | 0.20 |
| Authority signals (E-E-A-T) | eeat_signals | 0.20 |
| Structure | heading_structure | 0.15 |
| Structured data | schema_coverage | 0.15 |
| Factual density | fact_density | 0.10 |
| Semantic clarity | snippability | 0.10 |
| Crawler accessibility | crawler_accessibility | 0.10 |
crawler_accessibilityis a hard gate, not just the lowest-weighted differentiator: if the page's meta robots tag blocks indexing (noindex/none), score_page() forces overall_score to 0regardless of the other six dimensions — per that dimension's own published description ("zeroes out every other dimension's value"), not a bug in the arithmetic.
Note the naming split: the vendored scoring_weights.jsonfile uses the npm package's camelCase keys (answerFrontLoading, eeatSignals, …); ai_visibility.scoring_weights translates them to the snake_case keys shown above at load time, so no camelCase leaks into the Python API anywhere.
score_page() API
def score_page(
html: str,
url: str | None = None,
config: ScoringConfig | None = None,
has_llms_txt: bool | None = None,
has_ai_txt: bool | None = None,
) -> ScoreResult: ...has_llms_txt/has_ai_txt feed the crawler_accessibility dimension. score_page() deliberately makes no HTTP requests itself — pass these explicitly if you already know the answer, or use the CLI's audit command, which checks both over HTTP for you. Leaving both unset scores that dimension at a neutral 50 with an INFOfinding noting it wasn't checked, rather than penalizing a page for information the scorer was never given.
Override individual dimension weights with ScoringConfig — every field defaults to the vendored weight above when left None:
from ai_visibility import score_page
from ai_visibility.types import ScoringConfig
result = score_page(
page_html,
has_llms_txt=True,
has_ai_txt=False,
config=ScoringConfig(schema_coverage_weight=0.25, fact_density_weight=0.05),
)Custom weights aren't re-normalized to sum to 1.0 automatically — if you raise one dimension's weight, lower another's by the same amount, or overall_scorewill drift outside the usual 0–100 range's intended calibration.
ScoreResult shape
@dataclass
class ScoreResult:
overall_score: int # 0-100 (forced to 0 on a hard crawler-access block)
dimension_scores: dict[str, int] # one entry per dimension above, each 0-100
findings: list[Finding] = field(default_factory=list)
@dataclass
class Finding:
severity: Severity # Enum: CRITICAL | WARNING | INFO
message: str
fix: str | None = None
dimension: str | None = None # which dimension this finding belongs toresult = score_page(page_html, has_llms_txt=True)
print(result.overall_score) # 72
print(result.dimension_scores) # {"answer_front_loading": 100, "eeat_signals": 40, ...}
for finding in result.findings:
if finding.severity.value == "critical":
print(finding.message, "→", finding.fix)Want a plain-English summary and severity-ordered recommendations instead of raw scores? Use analyze_content() — it wraps score_page() with exactly that.
Integrating scoring into CI/CD
There's no built-in CI gate flag in the Python CLI's audit command (see the CLI reference) — the straightforward path in a Python project is a pytest test that fetches or renders the page under test and asserts a minimum score:
import httpx
import pytest
from ai_visibility import score_page
MIN_SCORE = 60
@pytest.mark.parametrize("path", ["/", "/pricing", "/blog/launch-post"])
def test_page_meets_minimum_geo_score(path):
response = httpx.get(f"https://staging.example.com{path}", timeout=10)
response.raise_for_status()
result = score_page(response.text, has_llms_txt=True, has_ai_txt=False)
assert result.overall_score >= MIN_SCORE, (
f"{path} scored {result.overall_score}/100 (min {MIN_SCORE}): "
+ "; ".join(f.message for f in result.findings if f.severity.value == "critical")
)For a Django/Flask/FastAPI app under test, render through the framework's own test client instead of a live HTTP fetch — no network dependency, and it exercises the exact HTML your app actually produces:
from django.test import TestCase
from ai_visibility import score_page
class GeoScoreTests(TestCase):
def test_homepage_meets_minimum_score(self):
response = self.client.get("/")
result = score_page(response.content.decode(), has_llms_txt=True)
self.assertGreaterEqual(result.overall_score, 60)Vendored weights
ai_visibility.scoring_weights.get_scoring_dimensions() and get_default_weights() read scoring_weights.json, shipped inside the package and vendored verbatim from the npm package's dist/scoring-weights.json — see the npm scoring page for the exact published file shape:
from ai_visibility.scoring_weights import get_scoring_dimensions, get_default_weights
get_default_weights()
# {"answer_front_loading": 0.2, "eeat_signals": 0.2, "heading_structure": 0.15,
# "schema_coverage": 0.15, "fact_density": 0.1, "snippability": 0.1,
# "crawler_accessibility": 0.1}
for dim in get_scoring_dimensions():
print(dim.key, dim.label, dim.weight, dim.description)Two files are vendored this way in total — crawlers.json (see the crawler registry) and scoring_weights.json— both copied from the same npm release rather than reimplemented, checked before every Python package release against the npm package's currently published version so the two packages don't silently drift apart.
See the CLI reference for running this scorer from the command line against a live URL, and the API reference for the full score_page()/ScoringConfig signatures.