Skip to content

Commit 1b74833

Browse files
authored
feat: capture and parse Despar store-scoped fixture (#78)
* testdata: add Despar store-scoped flyer fixture * feat: add deterministic Despar fixture parser * test: verify deterministic Despar fixture parsing * docs: record Despar store fixture spike
1 parent 842900a commit 1b74833

4 files changed

Lines changed: 327 additions & 0 deletions

File tree

docs/despar-store-fixture-spike.md

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
# Despar store-scoped fixture spike
2+
3+
## Status
4+
5+
Issue #77.
6+
7+
Recommendation: **promote_to_adapter_implementation**.
8+
9+
## Source
10+
11+
Official public flyer URL:
12+
13+
```text
14+
https://www.despar.it/it/volantino-digitale/191/
15+
```
16+
17+
Observed store identity from the same store/flyer identity:
18+
19+
```text
20+
store_id: 191
21+
store_name: Interspar Montebelluna
22+
store_address: Via Schiavonesca Priula, 64
23+
store_locality: Montebelluna (TV)
24+
```
25+
26+
Observed campaign:
27+
28+
```text
29+
Sconti dal 20% al 50%
30+
valid_from: 2026-08-13
31+
valid_to: 2026-08-26
32+
```
33+
34+
The public flyer source rendered Montebelluna locality together with the store-scoped `/191/` URL and exposed real offer rows for that campaign.
35+
36+
## Captured fixture
37+
38+
Committed fixture:
39+
40+
```text
41+
fixtures/despar/store-191-flyer-2026-08-13.txt
42+
```
43+
44+
SHA-256:
45+
46+
```text
47+
54607c3e32d3984d68b6889c522cd17486c31361a8e781f1447c5abe24edaf17
48+
```
49+
50+
The fixture is a deterministic raw excerpt of observed source facts. It deliberately preserves source text rather than translating the content into the canonical grocery schema.
51+
52+
## Real observed offers in fixture
53+
54+
```text
55+
Riso Carnaroli Scotti | 1 kg | 2,49 € al pz.
56+
Birra Speciale Pedavena | 500 ml | 1,29 € al pz.
57+
Olio Extra Vergine di oliva Grezzo Il Casolare Farchioni | 1 L | 9,49 € | 7,59 € al pz. | Sconto extra App -20%
58+
```
59+
60+
These rows demonstrate deterministic availability of:
61+
62+
- product identity;
63+
- package/quantity text;
64+
- current price text;
65+
- previous/base price text where present;
66+
- explicit promotion text where present;
67+
- campaign validity;
68+
- store/locality scope.
69+
70+
## Parser contract
71+
72+
`grocery_deal_intelligence.despar_fixture` parses only the committed fixture representation.
73+
74+
It does not:
75+
76+
- access the network;
77+
- infer missing values;
78+
- decide canonical promotion semantics;
79+
- decide loyalty requirements;
80+
- perform canonical admission;
81+
- invoke AI.
82+
83+
The parser returns immutable dataclasses and retains price/promotion strings as source evidence. `parse_euro_price()` is deliberately narrow and only converts an explicitly present euro amount to `Decimal`.
84+
85+
## Deterministic boundary
86+
87+
```text
88+
official store-scoped Despar flyer
89+
90+
raw source excerpt + SHA-256
91+
92+
deterministic parser
93+
94+
source evidence only
95+
```
96+
97+
## Verification
98+
99+
Tests cover:
100+
101+
- fixture SHA-256 identity;
102+
- store and campaign metadata;
103+
- real offer parsing;
104+
- previous/current price preservation;
105+
- promotion-text preservation;
106+
- source-text non-mutation;
107+
- malformed/missing input rejection.
108+
109+
## Recommendation
110+
111+
**promote_to_adapter_implementation**
112+
113+
The source surface has now demonstrated all minimum properties required for a real retailer adapter implementation spike:
114+
115+
- publicly addressable store-scoped source;
116+
- explicit store identity/locality;
117+
- explicit campaign validity;
118+
- reproducible real offer evidence;
119+
- deterministic parsing without AI;
120+
- no need to synthesize provenance from separate browsing context.
121+
122+
A production adapter remains a separate issue and must decide retrieval cadence, raw-response preservation, failure behavior, field mapping, and canonical admission independently.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
source_url: https://www.despar.it/it/volantino-digitale/191/
2+
store_id: 191
3+
store_name: Interspar Montebelluna
4+
store_address: Via Schiavonesca Priula, 64
5+
store_locality: Montebelluna (TV)
6+
campaign_title: Sconti dal 20% al 50%
7+
valid_from: 2026-08-13
8+
valid_to: 2026-08-26
9+
offer: Riso Carnaroli Scotti | 1 kg | 2,49 € al pz.
10+
offer: Birra Speciale Pedavena | 500 ml | 1,29 € al pz.
11+
offer: Olio Extra Vergine di oliva Grezzo Il Casolare Farchioni | 1 L | 9,49 € | 7,59 € al pz. | Sconto extra App -20%
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass
4+
from decimal import Decimal
5+
from pathlib import Path
6+
from typing import Iterable
7+
8+
9+
@dataclass(frozen=True)
10+
class DesparOffer:
11+
product_name: str
12+
package_text: str
13+
price_texts: tuple[str, ...]
14+
promotion_text: str | None
15+
16+
17+
@dataclass(frozen=True)
18+
class DesparFixture:
19+
source_url: str
20+
store_id: str
21+
store_name: str
22+
store_address: str
23+
store_locality: str
24+
campaign_title: str
25+
valid_from: str
26+
valid_to: str
27+
offers: tuple[DesparOffer, ...]
28+
29+
30+
def _parse_offer(parts: list[str]) -> DesparOffer:
31+
if len(parts) < 3:
32+
raise ValueError("Despar offer line must contain product, package, and price")
33+
34+
product_name = parts[0]
35+
package_text = parts[1]
36+
tail = parts[2:]
37+
38+
promotion_text = None
39+
if tail and tail[-1].lower().startswith(("sconto ", "offerta ")):
40+
promotion_text = tail[-1]
41+
tail = tail[:-1]
42+
43+
if not tail:
44+
raise ValueError("Despar offer line must contain at least one price")
45+
46+
return DesparOffer(
47+
product_name=product_name,
48+
package_text=package_text,
49+
price_texts=tuple(tail),
50+
promotion_text=promotion_text,
51+
)
52+
53+
54+
def parse_despar_fixture_text(text: str) -> DesparFixture:
55+
metadata: dict[str, str] = {}
56+
offers: list[DesparOffer] = []
57+
58+
for raw_line in text.splitlines():
59+
line = raw_line.strip()
60+
if not line:
61+
continue
62+
if line.startswith("offer: "):
63+
parts = [part.strip() for part in line.removeprefix("offer: ").split("|")]
64+
offers.append(_parse_offer(parts))
65+
continue
66+
67+
if ": " not in line:
68+
raise ValueError(f"Unrecognized Despar fixture line: {line}")
69+
key, value = line.split(": ", 1)
70+
metadata[key] = value
71+
72+
required = (
73+
"source_url",
74+
"store_id",
75+
"store_name",
76+
"store_address",
77+
"store_locality",
78+
"campaign_title",
79+
"valid_from",
80+
"valid_to",
81+
)
82+
missing = [key for key in required if not metadata.get(key)]
83+
if missing:
84+
raise ValueError(f"Missing Despar fixture metadata: {', '.join(missing)}")
85+
if not offers:
86+
raise ValueError("Despar fixture must contain at least one offer")
87+
88+
return DesparFixture(
89+
source_url=metadata["source_url"],
90+
store_id=metadata["store_id"],
91+
store_name=metadata["store_name"],
92+
store_address=metadata["store_address"],
93+
store_locality=metadata["store_locality"],
94+
campaign_title=metadata["campaign_title"],
95+
valid_from=metadata["valid_from"],
96+
valid_to=metadata["valid_to"],
97+
offers=tuple(offers),
98+
)
99+
100+
101+
def load_despar_fixture(path: str | Path) -> DesparFixture:
102+
return parse_despar_fixture_text(Path(path).read_text(encoding="utf-8"))
103+
104+
105+
def parse_euro_price(price_text: str) -> Decimal:
106+
token = price_text.split("€", 1)[0].strip().split()[-1]
107+
return Decimal(token.replace(".", "").replace(",", "."))

tests/test_despar_fixture.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
from __future__ import annotations
2+
3+
import hashlib
4+
from decimal import Decimal
5+
from pathlib import Path
6+
7+
import pytest
8+
9+
from grocery_deal_intelligence.despar_fixture import (
10+
load_despar_fixture,
11+
parse_despar_fixture_text,
12+
parse_euro_price,
13+
)
14+
15+
16+
FIXTURE = Path("fixtures/despar/store-191-flyer-2026-08-13.txt")
17+
EXPECTED_SHA256 = "54607c3e32d3984d68b6889c522cd17486c31361a8e781f1447c5abe24edaf17"
18+
19+
20+
def test_fixture_hash_is_stable():
21+
payload = FIXTURE.read_bytes()
22+
assert hashlib.sha256(payload).hexdigest() == EXPECTED_SHA256
23+
24+
25+
def test_parses_store_scope_and_campaign_metadata():
26+
parsed = load_despar_fixture(FIXTURE)
27+
28+
assert parsed.source_url == "https://www.despar.it/it/volantino-digitale/191/"
29+
assert parsed.store_id == "191"
30+
assert parsed.store_name == "Interspar Montebelluna"
31+
assert parsed.store_address == "Via Schiavonesca Priula, 64"
32+
assert parsed.store_locality == "Montebelluna (TV)"
33+
assert parsed.campaign_title == "Sconti dal 20% al 50%"
34+
assert parsed.valid_from == "2026-08-13"
35+
assert parsed.valid_to == "2026-08-26"
36+
37+
38+
def test_parses_real_offer_without_inventing_fields():
39+
parsed = load_despar_fixture(FIXTURE)
40+
offer = parsed.offers[0]
41+
42+
assert offer.product_name == "Riso Carnaroli Scotti"
43+
assert offer.package_text == "1 kg"
44+
assert offer.price_texts == ("2,49 € al pz.",)
45+
assert offer.promotion_text is None
46+
assert parse_euro_price(offer.price_texts[0]) == Decimal("2.49")
47+
48+
49+
def test_preserves_multiple_prices_and_promotion_text():
50+
parsed = load_despar_fixture(FIXTURE)
51+
offer = parsed.offers[2]
52+
53+
assert offer.product_name == "Olio Extra Vergine di oliva Grezzo Il Casolare Farchioni"
54+
assert offer.package_text == "1 L"
55+
assert offer.price_texts == ("9,49 €", "7,59 € al pz.")
56+
assert offer.promotion_text == "Sconto extra App -20%"
57+
assert parse_euro_price(offer.price_texts[0]) == Decimal("9.49")
58+
assert parse_euro_price(offer.price_texts[1]) == Decimal("7.59")
59+
60+
61+
def test_parser_is_read_only_for_source_text():
62+
text = FIXTURE.read_text(encoding="utf-8")
63+
original = text[:]
64+
65+
parse_despar_fixture_text(text)
66+
67+
assert text == original
68+
69+
70+
def test_rejects_missing_required_metadata():
71+
with pytest.raises(ValueError, match="Missing Despar fixture metadata"):
72+
parse_despar_fixture_text("offer: Example | 1 kg | 1,00 € al pz.\n")
73+
74+
75+
def test_rejects_offer_without_price():
76+
text = """source_url: https://example.test/
77+
store_id: 1
78+
store_name: Test
79+
store_address: Test
80+
store_locality: Test
81+
campaign_title: Test
82+
valid_from: 2026-01-01
83+
valid_to: 2026-01-02
84+
offer: Product | 1 kg
85+
"""
86+
with pytest.raises(ValueError, match="product, package, and price"):
87+
parse_despar_fixture_text(text)

0 commit comments

Comments
 (0)