CrawlPodScan your site

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

DimensionKey (snake_case)Weight
Answer placementanswer_front_loading0.20
Authority signals (E-E-A-T)eeat_signals0.20
Structureheading_structure0.15
Structured dataschema_coverage0.15
Factual densityfact_density0.10
Semantic claritysnippability0.10
Crawler accessibilitycrawler_accessibility0.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 to
usage
result = 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:

tests/test_geo_score.py
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:

Django example — tests/test_geo_score.py
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.