File size: 5,279 Bytes
7d7841f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
from app.benchmarks import build_benchmark_comparison
from app.models import AgentReadinessSummary, AuditResult, AuditScope, CheckResult, PillarMax, PillarScores
from app.checks.locked import locked_checks
from app.presets import ResolvedScope, _resolve_legacy

_PILLAR_MAX = PillarMax(off_site=30, scrapability=40, seo=30)

# keep the API version constant in one place so the same value flows
# through scoring, models, and OpenAPI metadata. Bumping this is the contract
# signal used by agents to detect additive changes vs. breaking ones.
API_VERSION = "1.0"

_PILLAR_CHECKS: dict[str, list[str]] = {
    "off_site": [
        "social",
        "wikipedia",
        "earned_mentions_backlinks",
        "owned_social_presence",
        "social_traction_reviews",
        "ai_citation_share",
    ],
    "scrapability": [
        "robots_txt",
        "bot_access",
        "html_structure",
        "schema_ld",
        "llms_txt",
        "ssr",
        "machine_surfaces",
        "extraction_fidelity",
        "agent_task_simulation",
    ],
    "seo": [
        "pagespeed",
        "canonical",
        "indexing",
        "search_discovery",
        "multi_engine_index_coverage",
        "core_web_vitals",
        "keyword_competitor_gap",
    ],
}


def _pillar_score(checks: list[CheckResult], pillar: str, cap: int) -> int:
    total = sum(c.score for c in checks if c.pillar == pillar)
    return min(total, cap)


def _scope_label(scope: ResolvedScope) -> str:
    """Compose the user-facing scope label.

    Preset wins: when a preset is applied, use its display label verbatim.
    Otherwise, fall back to the legacy "General website + extras" string so
    older callers keep seeing familiar copy.
    """

    if scope.preset:
        return scope.preset_label

    legacy = _resolve_legacy(
        scope.include_protocols,
        scope.include_account_auth,
        scope.include_ecommerce,
    )
    return legacy.preset_label


def build_result(
    url: str,
    checks: list[CheckResult],
    agent_readiness: AgentReadinessSummary,
    scope: ResolvedScope | None = None,
    include_protocols: bool = False,
    include_account_auth: bool = False,
    include_ecommerce: bool = False,
) -> AuditResult:
    """Assemble the final AuditResult from individual check results.

    The ``scope`` argument is the preferred path. The three legacy
    booleans remain supported for backwards-compatible callers (and are what
    the existing tests use); when ``scope`` is None, the legacy booleans are
    promoted into a synthesised :class:`ResolvedScope` so the rest of the
    pipeline only has one source of truth.
    """

    if scope is None:
        scope = _resolve_legacy(
            include_protocols, include_account_auth, include_ecommerce
        )

    checks = checks + locked_checks(scope.include_ecommerce)
    included_optional_surfaces = [
        "Core discoverability",
        "Content accessibility",
        "Bot access control",
        "Structured data",
        "Search discovery hints",
    ]
    excluded_optional_surfaces = []
    if scope.include_protocols:
        included_optional_surfaces.append("API and protocol discovery")
    else:
        excluded_optional_surfaces.append("API and protocol discovery")
    if scope.include_account_auth:
        included_optional_surfaces.append("Account/auth discovery")
    else:
        excluded_optional_surfaces.append("Account/auth discovery")
    if scope.include_ecommerce:
        included_optional_surfaces.extend(["Commerce protocol metadata", "Catalog JSON"])
    else:
        excluded_optional_surfaces.extend(["Commerce protocol metadata", "Catalog JSON"])

    audit_scope = AuditScope(
        include_protocols=scope.include_protocols,
        include_account_auth=scope.include_account_auth,
        include_ecommerce=scope.include_ecommerce,
        label=_scope_label(scope),
        included_optional_surfaces=included_optional_surfaces,
        excluded_optional_surfaces=excluded_optional_surfaces,
        preset_applied=scope.preset,
        overrides_applied=dict(scope.overrides),
        included_families=list(scope.included_families),
        excluded_families=list(scope.excluded_families),
        machine_surfaces_scope=scope.machine_surfaces,
    )
    pillar_scores = PillarScores(
        off_site=_pillar_score(checks, "off_site", _PILLAR_MAX.off_site),
        scrapability=_pillar_score(checks, "scrapability", _PILLAR_MAX.scrapability),
        seo=_pillar_score(checks, "seo", _PILLAR_MAX.seo),
    )
    overall = pillar_scores.off_site + pillar_scores.scrapability + pillar_scores.seo

    sorted_checks = sorted(
        checks,
        key=lambda c: (c.max_score - c.score) / max({"low": 1, "medium": 2, "high": 3}.get(c.effort, 2), 1),
        reverse=True,
    )

    return AuditResult(
        api_version=API_VERSION,
        url=url,
        scope=audit_scope,
        overall_score=overall,
        pillar_scores=pillar_scores,
        pillar_max=_PILLAR_MAX,
        agent_readiness=agent_readiness,
        benchmark=build_benchmark_comparison(
            checks,
            scope.include_protocols,
            scope.include_account_auth,
            scope.include_ecommerce,
        ),
        checks=sorted_checks,
    )