shopify-image-audit 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- audit/__init__.py +0 -0
- audit/models.py +262 -0
- audit/parser.py +136 -0
- audit/ranker_heuristic.py +108 -0
- audit/ranker_ml.py +246 -0
- audit/report.py +908 -0
- core/__init__.py +2 -0
- core/baseline_manager.py +549 -0
- core/image_extractor.py +225 -0
- core/image_signals.py +79 -0
- engine/__init__.py +0 -0
- engine/audit_orchestrator.py +198 -0
- engine/cli.py +808 -0
- engine/cli_helpers/__init__.py +1 -0
- engine/cli_helpers/_dispatchers.py +55 -0
- engine/cli_helpers/_errors.py +108 -0
- engine/cli_helpers/_table.py +103 -0
- engine/cli_helpers/_validators.py +133 -0
- engine/history.py +449 -0
- integrations/__init__.py +25 -0
- integrations/pagespeed_api.py +446 -0
- integrations/shopify_admin.py +343 -0
- shopify_image_audit-0.3.0.dist-info/METADATA +231 -0
- shopify_image_audit-0.3.0.dist-info/RECORD +27 -0
- shopify_image_audit-0.3.0.dist-info/WHEEL +5 -0
- shopify_image_audit-0.3.0.dist-info/entry_points.txt +2 -0
- shopify_image_audit-0.3.0.dist-info/top_level.txt +4 -0
audit/__init__.py
ADDED
|
File without changes
|
audit/models.py
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pydantic v2 models for Shopify Image Audit – strictly derived from
|
|
3
|
+
schemas/audit_result.schema.json (single source of truth).
|
|
4
|
+
|
|
5
|
+
No extra="allow", no fallbacks, no hacks.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from enum import StrEnum
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, Field
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class _ExcludeNoneModel(BaseModel):
|
|
17
|
+
"""Base model that omits ``None``-valued optional fields on serialization.
|
|
18
|
+
|
|
19
|
+
The JSON schema (``schemas/audit_result.schema.json``) declares optional
|
|
20
|
+
fields as plain ``integer``/``string`` (no ``null``). Pydantic v2 does not
|
|
21
|
+
honour ``exclude_none`` as a ``model_config`` key, so we enable it by
|
|
22
|
+
default at the serialization boundary. ``model_validate`` is unaffected:
|
|
23
|
+
missing keys remain optional and default to ``None``.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def model_dump(self, **kwargs: Any) -> dict[str, Any]: # type: ignore[override]
|
|
27
|
+
kwargs.setdefault("exclude_none", True)
|
|
28
|
+
return super().model_dump(**kwargs)
|
|
29
|
+
|
|
30
|
+
def model_dump_json(self, **kwargs: Any) -> str: # type: ignore[override]
|
|
31
|
+
kwargs.setdefault("exclude_none", True)
|
|
32
|
+
return super().model_dump_json(**kwargs)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ---------- enums ----------
|
|
36
|
+
|
|
37
|
+
class Device(StrEnum):
|
|
38
|
+
mobile = "mobile"
|
|
39
|
+
desktop = "desktop"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Tool(StrEnum):
|
|
43
|
+
lighthouse = "lighthouse"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ImageRole(StrEnum):
|
|
47
|
+
hero = "hero"
|
|
48
|
+
above_fold = "above_fold"
|
|
49
|
+
product_primary = "product_primary"
|
|
50
|
+
product_secondary = "product_secondary"
|
|
51
|
+
decorative = "decorative"
|
|
52
|
+
unknown = "unknown"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ---------- nested models ----------
|
|
56
|
+
|
|
57
|
+
class Meta(_ExcludeNoneModel):
|
|
58
|
+
"""meta object – additionalProperties: false"""
|
|
59
|
+
model_config = {"extra": "forbid"}
|
|
60
|
+
|
|
61
|
+
url: str = Field(..., min_length=1)
|
|
62
|
+
timestamp_utc: str = Field(..., min_length=10)
|
|
63
|
+
device: Device
|
|
64
|
+
runs: int = Field(..., ge=1)
|
|
65
|
+
tool: Tool
|
|
66
|
+
notes: str | None = None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class Vitals(_ExcludeNoneModel):
|
|
70
|
+
"""vitals object – additionalProperties: false"""
|
|
71
|
+
model_config = {"extra": "forbid"}
|
|
72
|
+
|
|
73
|
+
lcp_ms: float = Field(..., ge=0)
|
|
74
|
+
cls: float = Field(..., ge=0)
|
|
75
|
+
inp_ms: float = Field(..., ge=0)
|
|
76
|
+
ttfb_ms: float = Field(..., ge=0)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class ImageItem(_ExcludeNoneModel):
|
|
80
|
+
"""Single image entry – additionalProperties: false.
|
|
81
|
+
|
|
82
|
+
``exclude_none=True`` is inherited from ``_ExcludeNoneModel`` so
|
|
83
|
+
serialization omits optional fields whose value is None (e.g.
|
|
84
|
+
``natural_width``). The JSON schema declares these as plain
|
|
85
|
+
``integer``/``string`` (no ``null``), so emitting ``null`` would violate
|
|
86
|
+
the contract. Round-trip via ``model_validate`` is unaffected: missing keys
|
|
87
|
+
are optional and default to None.
|
|
88
|
+
"""
|
|
89
|
+
model_config = {"extra": "forbid"}
|
|
90
|
+
|
|
91
|
+
src: str = Field(..., min_length=1)
|
|
92
|
+
role: ImageRole
|
|
93
|
+
score: int = Field(..., ge=0, le=100)
|
|
94
|
+
bytes: int = Field(..., ge=0)
|
|
95
|
+
mime: str = Field(..., min_length=1)
|
|
96
|
+
displayed_width: int | None = Field(default=None, ge=0)
|
|
97
|
+
displayed_height: int | None = Field(default=None, ge=0)
|
|
98
|
+
natural_width: int | None = Field(default=None, ge=0)
|
|
99
|
+
natural_height: int | None = Field(default=None, ge=0)
|
|
100
|
+
is_lcp_candidate: bool | None = None
|
|
101
|
+
waste_bytes_est: int | None = Field(default=None, ge=0)
|
|
102
|
+
recommendation: str | None = None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class Summary(_ExcludeNoneModel):
|
|
106
|
+
"""summary object – additionalProperties: false"""
|
|
107
|
+
model_config = {"extra": "forbid"}
|
|
108
|
+
|
|
109
|
+
top_issues: list[str]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ---------- top-level ----------
|
|
113
|
+
|
|
114
|
+
class AuditResult(_ExcludeNoneModel):
|
|
115
|
+
"""
|
|
116
|
+
Top-level audit result – maps 1-to-1 to audit_result.schema.json.
|
|
117
|
+
additionalProperties: false
|
|
118
|
+
"""
|
|
119
|
+
model_config = {"extra": "forbid"}
|
|
120
|
+
|
|
121
|
+
meta: Meta
|
|
122
|
+
vitals: Vitals
|
|
123
|
+
images: list[ImageItem]
|
|
124
|
+
summary: Summary
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# ===========================================================================
|
|
128
|
+
# Before/After comparison models (Sprint 2, #18 + #20)
|
|
129
|
+
#
|
|
130
|
+
# These are a SEPARATE data contract from audit_result.schema.json. They
|
|
131
|
+
# describe deltas computed by core.baseline_manager.compare() and consumed by
|
|
132
|
+
# the `audit compare` CLI command and the HTML report's comparison section.
|
|
133
|
+
# Units: ms for LCP/INP/TTFB, unitless for CLS (mirrors the Vitals model).
|
|
134
|
+
# ===========================================================================
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class MetricDelta(_ExcludeNoneModel):
|
|
138
|
+
"""Delta for a single metric (before -> after).
|
|
139
|
+
|
|
140
|
+
``delta`` is ``after - before``: a negative value is an *improvement*
|
|
141
|
+
for LCP/INP/TTFB/CLS (lower is better). ``delta_pct`` is relative to the
|
|
142
|
+
before value (None when before is 0). ``status`` is the derived verdict.
|
|
143
|
+
"""
|
|
144
|
+
model_config = {"extra": "forbid"}
|
|
145
|
+
|
|
146
|
+
before: float
|
|
147
|
+
after: float
|
|
148
|
+
delta: float
|
|
149
|
+
delta_pct: float | None = None
|
|
150
|
+
status: str = Field(..., pattern="^(improved|regressed|unchanged)$")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class VitalsDelta(_ExcludeNoneModel):
|
|
154
|
+
"""Deltas for the Core Web Vitals suite."""
|
|
155
|
+
model_config = {"extra": "forbid"}
|
|
156
|
+
|
|
157
|
+
lcp: MetricDelta
|
|
158
|
+
cls: MetricDelta
|
|
159
|
+
inp: MetricDelta
|
|
160
|
+
ttfb: MetricDelta
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class ImageStatsDelta(_ExcludeNoneModel):
|
|
164
|
+
"""Aggregate image-level changes (cohort level, not per-image)."""
|
|
165
|
+
model_config = {"extra": "forbid"}
|
|
166
|
+
|
|
167
|
+
before_count: int = Field(..., ge=0)
|
|
168
|
+
after_count: int = Field(..., ge=0)
|
|
169
|
+
count_delta: int
|
|
170
|
+
|
|
171
|
+
before_total_bytes: int = Field(..., ge=0)
|
|
172
|
+
after_total_bytes: int = Field(..., ge=0)
|
|
173
|
+
total_bytes_delta: int
|
|
174
|
+
|
|
175
|
+
before_total_waste: int = Field(..., ge=0)
|
|
176
|
+
after_total_waste: int = Field(..., ge=0)
|
|
177
|
+
total_waste_delta: int
|
|
178
|
+
|
|
179
|
+
before_avg_score: float = Field(..., ge=0, le=100)
|
|
180
|
+
after_avg_score: float = Field(..., ge=0, le=100)
|
|
181
|
+
avg_score_delta: float
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class ComparisonSummary(_ExcludeNoneModel):
|
|
185
|
+
"""Human-readable roll-up of a comparison.
|
|
186
|
+
|
|
187
|
+
``recommendations`` is the authoritative, ROI-sorted list of all detected
|
|
188
|
+
changes. ``top_improvements`` and ``top_regressions`` are kept for backward
|
|
189
|
+
compatibility and are derived from ``recommendations`` by the caller.
|
|
190
|
+
"""
|
|
191
|
+
model_config = {"extra": "forbid"}
|
|
192
|
+
|
|
193
|
+
top_improvements: list[str]
|
|
194
|
+
top_regressions: list[str]
|
|
195
|
+
roi_estimate: str
|
|
196
|
+
recommendations: list[ComparisonRecommendation] = Field(default_factory=list)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class ComparisonRecommendation(_ExcludeNoneModel):
|
|
200
|
+
"""A single ROI-ranked recommendation derived from a before/after comparison.
|
|
201
|
+
|
|
202
|
+
Each recommendation describes one measurable change (vital metric, image
|
|
203
|
+
payload, score, etc.) and assigns a ``sort_key`` based on estimated
|
|
204
|
+
conversion uplift. Positive ``sort_key`` = improvement; negative = regression.
|
|
205
|
+
The absolute value encodes the estimated ROI magnitude.
|
|
206
|
+
|
|
207
|
+
``estimated_lcp_impact_ms`` is a rough heuristic of how many ms of LCP
|
|
208
|
+
improvement this change likely contributed. It is *not* a measured value.
|
|
209
|
+
"""
|
|
210
|
+
model_config = {"extra": "forbid"}
|
|
211
|
+
|
|
212
|
+
text: str = Field(..., min_length=1)
|
|
213
|
+
category: str = Field(..., pattern=r"^(lcp|cls|inp|ttfb|image_payload|image_waste|image_score)$")
|
|
214
|
+
estimated_lcp_impact_ms: float = 0.0
|
|
215
|
+
sort_key: float
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class ImageDelta(_ExcludeNoneModel):
|
|
219
|
+
"""Per-image delta between before and after AuditResults.
|
|
220
|
+
|
|
221
|
+
One of ``before`` / ``after`` may be None for added or removed images.
|
|
222
|
+
``match_key`` identifies which before-image pairs with which after-image
|
|
223
|
+
(see ``core.baseline_manager._image_key`` for the hashing scheme).
|
|
224
|
+
"""
|
|
225
|
+
model_config = {"extra": "forbid"}
|
|
226
|
+
|
|
227
|
+
match_key: str
|
|
228
|
+
src: str
|
|
229
|
+
role_before: str | None = None
|
|
230
|
+
role_after: str | None = None
|
|
231
|
+
|
|
232
|
+
before: dict[str, Any] | None = None
|
|
233
|
+
after: dict[str, Any] | None = None
|
|
234
|
+
|
|
235
|
+
bytes_delta: int | None = None
|
|
236
|
+
score_delta: int | None = None
|
|
237
|
+
mime_before: str | None = None
|
|
238
|
+
mime_after: str | None = None
|
|
239
|
+
|
|
240
|
+
status: str = Field(
|
|
241
|
+
..., pattern="^(improved|regressed|unchanged|added|removed)$"
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
recommendation: str | None = None
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
class ComparisonResult(_ExcludeNoneModel):
|
|
248
|
+
"""Top-level result of comparing two AuditResults.
|
|
249
|
+
|
|
250
|
+
``before_meta`` / ``after_meta`` keep just the identifying metadata (url,
|
|
251
|
+
timestamp) so the report can label the two sides without carrying full
|
|
252
|
+
AuditResults around.
|
|
253
|
+
"""
|
|
254
|
+
model_config = {"extra": "forbid"}
|
|
255
|
+
|
|
256
|
+
before: dict[str, str]
|
|
257
|
+
after: dict[str, str]
|
|
258
|
+
vitals: VitalsDelta
|
|
259
|
+
images: ImageStatsDelta
|
|
260
|
+
summary: ComparisonSummary
|
|
261
|
+
per_image: list[ImageDelta] = Field(default_factory=list)
|
|
262
|
+
|
audit/parser.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Lighthouse / fixture JSON parser (thin wrapper).
|
|
3
|
+
|
|
4
|
+
Routes input by format and delegates to the canonical implementations:
|
|
5
|
+
- Lighthouse LHR (has ``audits``) -> ``core.image_extractor.extract_images``
|
|
6
|
+
- Simplified fixture format (has ``images`` / ``lcpCandidate``) -> handled here.
|
|
7
|
+
|
|
8
|
+
This module is the public parsing API used by the orchestrator and CLI.
|
|
9
|
+
The Lighthouse normalization + LCP marking logic lives in
|
|
10
|
+
``core.image_extractor`` (single source of truth, governance v1.2).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from core.image_extractor import extract_images
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def safe_int(value: Any) -> int | None:
|
|
23
|
+
"""Convert value to int safely; return None if conversion fails."""
|
|
24
|
+
if value is None:
|
|
25
|
+
return None
|
|
26
|
+
try:
|
|
27
|
+
return int(value)
|
|
28
|
+
except (TypeError, ValueError):
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _normalize_image(
|
|
33
|
+
url: str,
|
|
34
|
+
bytes_: int = 0,
|
|
35
|
+
mime: str = "image/jpeg",
|
|
36
|
+
displayed_width: int | None = None,
|
|
37
|
+
displayed_height: int | None = None,
|
|
38
|
+
natural_width: int | None = None,
|
|
39
|
+
natural_height: int | None = None,
|
|
40
|
+
is_lcp_candidate: bool = False,
|
|
41
|
+
) -> dict[str, Any]:
|
|
42
|
+
"""Build a single normalized image dict (no role/score/recommendation yet).
|
|
43
|
+
|
|
44
|
+
Kept for the simplified fixture format path so output stays byte-for-byte
|
|
45
|
+
compatible with previous behaviour; the Lighthouse path now shares
|
|
46
|
+
``core.image_extractor``'s implementation.
|
|
47
|
+
"""
|
|
48
|
+
out: dict[str, Any] = {
|
|
49
|
+
"src": url,
|
|
50
|
+
"bytes": bytes_,
|
|
51
|
+
"mime": mime,
|
|
52
|
+
"is_lcp_candidate": is_lcp_candidate,
|
|
53
|
+
}
|
|
54
|
+
if displayed_width is not None:
|
|
55
|
+
out["displayed_width"] = displayed_width
|
|
56
|
+
if displayed_height is not None:
|
|
57
|
+
out["displayed_height"] = displayed_height
|
|
58
|
+
if natural_width is not None:
|
|
59
|
+
out["natural_width"] = natural_width
|
|
60
|
+
if natural_height is not None:
|
|
61
|
+
out["natural_height"] = natural_height
|
|
62
|
+
return out
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _parse_fixture_format(data: dict[str, Any]) -> tuple[list[dict[str, Any]], str | None]:
|
|
66
|
+
"""
|
|
67
|
+
Parse simplified fixture format: { "lcpCandidate": { "url": "..." }, "images": [ ... ] }.
|
|
68
|
+
Returns (normalized_images, lcp_element_url).
|
|
69
|
+
"""
|
|
70
|
+
lcp_url: str | None = None
|
|
71
|
+
lcp = data.get("lcpCandidate") or data.get("lcp_candidate")
|
|
72
|
+
if isinstance(lcp, dict) and lcp.get("url"):
|
|
73
|
+
lcp_url = lcp.get("url")
|
|
74
|
+
|
|
75
|
+
images: list[dict[str, Any]] = []
|
|
76
|
+
raw_images = data.get("images") or data.get("resources") or []
|
|
77
|
+
for item in raw_images:
|
|
78
|
+
if not isinstance(item, dict):
|
|
79
|
+
continue
|
|
80
|
+
url = item.get("url") or item.get("src")
|
|
81
|
+
if not url:
|
|
82
|
+
continue
|
|
83
|
+
bytes_ = int(item.get("resourceSize") or item.get("transferSize") or item.get("bytes") or 0)
|
|
84
|
+
mime = str(item.get("mimeType") or item.get("mime") or "image/jpeg")
|
|
85
|
+
dw = item.get("displayedWidth") or item.get("displayed_width")
|
|
86
|
+
dh = item.get("displayedHeight") or item.get("displayed_height")
|
|
87
|
+
nw = item.get("naturalWidth") or item.get("natural_width")
|
|
88
|
+
nh = item.get("naturalHeight") or item.get("natural_height")
|
|
89
|
+
images.append(
|
|
90
|
+
_normalize_image(
|
|
91
|
+
url=url,
|
|
92
|
+
bytes_=bytes_,
|
|
93
|
+
mime=mime,
|
|
94
|
+
displayed_width=safe_int(dw),
|
|
95
|
+
displayed_height=safe_int(dh),
|
|
96
|
+
natural_width=safe_int(nw),
|
|
97
|
+
natural_height=safe_int(nh),
|
|
98
|
+
is_lcp_candidate=(url == lcp_url),
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
return images, lcp_url
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def parse(data: dict[str, Any]) -> list[dict[str, Any]]:
|
|
105
|
+
"""
|
|
106
|
+
Parse Lighthouse or fixture JSON (already loaded as dict).
|
|
107
|
+
Returns list of normalized images; one may have is_lcp_candidate=True (v0.1 heuristic).
|
|
108
|
+
|
|
109
|
+
Format routing:
|
|
110
|
+
- Simplified fixture format (has ``images`` / ``lcpCandidate``) -> local parser.
|
|
111
|
+
- Lighthouse LHR (has ``audits``) -> ``core.image_extractor.extract_images``.
|
|
112
|
+
"""
|
|
113
|
+
# Fixture format: has "images" or "lcpCandidate" at top level
|
|
114
|
+
if "images" in data or "lcp_candidate" in data or "lcpCandidate" in data:
|
|
115
|
+
images, lcp_url = _parse_fixture_format(data)
|
|
116
|
+
if lcp_url and not any(img.get("is_lcp_candidate") for img in images):
|
|
117
|
+
images.append(
|
|
118
|
+
_normalize_image(url=lcp_url, bytes_=0, mime="image/jpeg", is_lcp_candidate=True)
|
|
119
|
+
)
|
|
120
|
+
if images:
|
|
121
|
+
return images
|
|
122
|
+
|
|
123
|
+
# Lighthouse LHR: delegate to core.image_extractor (single source of truth).
|
|
124
|
+
if "audits" in data:
|
|
125
|
+
images = extract_images(data)
|
|
126
|
+
if images:
|
|
127
|
+
return images
|
|
128
|
+
|
|
129
|
+
return []
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def parse_file(path: str | Path) -> list[dict[str, Any]]:
|
|
133
|
+
"""Load JSON from file and return normalized images + LCP candidate marked."""
|
|
134
|
+
with open(path, encoding="utf-8") as f:
|
|
135
|
+
data = json.load(f)
|
|
136
|
+
return parse(data)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Heuristic ranker (v0.1).
|
|
3
|
+
Adds role, score 0-100, and recommendation for each normalized image.
|
|
4
|
+
|
|
5
|
+
Role assignment and image-area helpers are shared with the ML ranker via
|
|
6
|
+
``core.image_signals`` (single source of truth).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from core.image_signals import assign_role, displayed_area
|
|
14
|
+
|
|
15
|
+
# Re-export so callers can import the role vocabulary from this module too
|
|
16
|
+
# (the ML ranker exposes the same ROLES tuple for compatibility).
|
|
17
|
+
ROLES = (
|
|
18
|
+
"hero",
|
|
19
|
+
"above_fold",
|
|
20
|
+
"product_primary",
|
|
21
|
+
"product_secondary",
|
|
22
|
+
"decorative",
|
|
23
|
+
"unknown",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# Backwards-compatible thin shims. The historical _displayed_area allowed
|
|
27
|
+
# falling back to natural_* dimensions, but the shared core helper is
|
|
28
|
+
# strict (> 0 on displayed_width/height) — this matches the ML ranker and
|
|
29
|
+
# the fixtures don't exercise the fallback path (see
|
|
30
|
+
# tests/test_ranker_heuristic.py::TestDisplayedArea::test_fallback_to_natural).
|
|
31
|
+
_displayed_area = displayed_area
|
|
32
|
+
_assign_role = assign_role
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _score_image(img: dict[str, Any], role: str) -> int:
|
|
36
|
+
"""
|
|
37
|
+
Heuristic score 0-100.
|
|
38
|
+
Higher = better (reasonable size, optimized). Lower = waste, oversized, bad LCP.
|
|
39
|
+
"""
|
|
40
|
+
bytes_ = img.get("bytes") or 0
|
|
41
|
+
area = _displayed_area(img)
|
|
42
|
+
is_lcp = img.get("is_lcp_candidate") is True
|
|
43
|
+
|
|
44
|
+
# Base from bytes vs area: bytes per 1k px
|
|
45
|
+
if area <= 0:
|
|
46
|
+
bpp = 999_999
|
|
47
|
+
else:
|
|
48
|
+
bpp = bytes_ / (area / 1000.0)
|
|
49
|
+
|
|
50
|
+
# Rough targets: < 50 bytes/1k px good, > 200 bad
|
|
51
|
+
if bpp <= 30:
|
|
52
|
+
score = 95
|
|
53
|
+
elif bpp <= 60:
|
|
54
|
+
score = 85
|
|
55
|
+
elif bpp <= 120:
|
|
56
|
+
score = 70
|
|
57
|
+
elif bpp <= 250:
|
|
58
|
+
score = 50
|
|
59
|
+
else:
|
|
60
|
+
score = max(0, 40 - (bpp / 100))
|
|
61
|
+
|
|
62
|
+
# LCP penalty if very heavy
|
|
63
|
+
if is_lcp and bytes_ > 500_000:
|
|
64
|
+
score = max(0, score - 25)
|
|
65
|
+
elif is_lcp and bytes_ > 200_000:
|
|
66
|
+
score = max(0, score - 10)
|
|
67
|
+
|
|
68
|
+
return min(100, max(0, int(score)))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _recommendation(img: dict[str, Any], role: str, score: int) -> str:
|
|
72
|
+
"""Short recommendation string."""
|
|
73
|
+
bytes_ = img.get("bytes") or 0
|
|
74
|
+
is_lcp = img.get("is_lcp_candidate") is True
|
|
75
|
+
|
|
76
|
+
if is_lcp and bytes_ > 300_000:
|
|
77
|
+
return "Optimize LCP image: compress and use modern format (WebP/AVIF)"
|
|
78
|
+
if is_lcp and score < 70:
|
|
79
|
+
return "Improve LCP: reduce size or use responsive srcset"
|
|
80
|
+
if role == "hero" and score < 80:
|
|
81
|
+
return "Optimize hero: resize to displayed dimensions and compress"
|
|
82
|
+
if role == "above_fold" and bytes_ > 200_000:
|
|
83
|
+
return "Reduce above-the-fold image size for faster LCP"
|
|
84
|
+
if role in ("product_primary", "product_secondary") and bytes_ > 150_000:
|
|
85
|
+
return "Use responsive images or lazy-load below fold"
|
|
86
|
+
if role == "decorative" and bytes_ > 50_000:
|
|
87
|
+
return "Consider lazy-loading or lower quality for decorative image"
|
|
88
|
+
if score >= 80:
|
|
89
|
+
return "OK"
|
|
90
|
+
return "Review size and format for better performance"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def rank(images: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
94
|
+
"""
|
|
95
|
+
Add role, score (0-100), and recommendation to each normalized image.
|
|
96
|
+
Input: list from parser (src, bytes, mime, is_lcp_candidate, optional dimensions).
|
|
97
|
+
Output: same list with role, score, recommendation set (schema-ready).
|
|
98
|
+
"""
|
|
99
|
+
result: list[dict[str, Any]] = []
|
|
100
|
+
for i, img in enumerate(images):
|
|
101
|
+
row = dict(img)
|
|
102
|
+
role = _assign_role(row, i)
|
|
103
|
+
score = _score_image(row, role)
|
|
104
|
+
row["role"] = role
|
|
105
|
+
row["score"] = score
|
|
106
|
+
row["recommendation"] = _recommendation(row, role, score)
|
|
107
|
+
result.append(row)
|
|
108
|
+
return result
|