AuthorBot Cursor commited on
Commit
6b1b2a7
·
1 Parent(s): 04333bf

fix: Apple audiobook prices + honest format labels on scan

Browse files

- Query iTunes entity=ebook and entity=audiobook for Apple Books format_prices (R-PP-014)
- Stop inventing Hardcover for ThriftBooks/AbeBooks/Bookshop single prices; use JSON-LD bookFormat when present, else list_price only (R-PP-010)
- Keep Kobo labeled as eBook (digital-only)
- Bump presence scan cache to v12; 24 unit tests pass

Co-authored-by: Cursor <cursoragent@cursor.com>

.agent/PLATFORM_PRICING_RULES.md CHANGED
@@ -107,10 +107,11 @@ Amazon HTML: US-locale headers + cookie (`i18n-prefs=USD`) to avoid PKR/wrong cu
107
  - **R-185:** Missing price must not fail presence scan (fail open)
108
  - **R-186:** Amazon price fetch must use US-locale headers
109
  - **R-187:** Platform scan cache key version bumped when price/credential logic changes materially
110
- - **R-PP-010:** Scan enrichment MUST write `format_prices` when parsers find ≥1 format; MUST NOT wipe a richer existing map with a thinner scan result
111
  - **R-PP-011:** `fetch_listing_format_prices` fetches HTML exactly once and passes the raw HTML to format-price parsers — no double HTTP round-trips per listing
112
  - **R-PP-012:** Discovery platforms (goodreads, open_library, google_books) return `format_prices: {}` from enrichment; their single `list_price` is preserved as-is
113
  - **R-PP-013:** `fetch_listing_price` remains a backward-compatible thin wrapper over `fetch_listing_format_prices` returning only the primary formatted string
 
114
 
115
  ## 11. File map
116
 
 
107
  - **R-185:** Missing price must not fail presence scan (fail open)
108
  - **R-186:** Amazon price fetch must use US-locale headers
109
  - **R-187:** Platform scan cache key version bumped when price/credential logic changes materially
110
+ - **R-PP-010:** Scan enrichment MUST write `format_prices` when parsers find ≥1 format; MUST NOT wipe a richer existing map with a thinner scan result. Single-price fallback MUST NOT invent a binding label — Kobo may use eBook; ThriftBooks / AbeBooks / Bookshop use JSON-LD `bookFormat` when present, otherwise leave `format_prices` empty and keep `list_price` only.
111
  - **R-PP-011:** `fetch_listing_format_prices` fetches HTML exactly once and passes the raw HTML to format-price parsers — no double HTTP round-trips per listing
112
  - **R-PP-012:** Discovery platforms (goodreads, open_library, google_books) return `format_prices: {}` from enrichment; their single `list_price` is preserved as-is
113
  - **R-PP-013:** `fetch_listing_price` remains a backward-compatible thin wrapper over `fetch_listing_format_prices` returning only the primary formatted string
114
+ - **R-PP-014:** Apple Books enrichment MUST query iTunes `entity=ebook` and `entity=audiobook` so both digital formats can populate `format_prices` when available
115
 
116
  ## 11. File map
117
 
app/services/platform_presence.py CHANGED
@@ -39,7 +39,7 @@ logger = structlog.get_logger(__name__)
39
 
40
  _SCAN_TIMEOUT = 10.0
41
  _MAX_CONCURRENT = 6
42
- _SCAN_CACHE_PREFIX = "platform_scan:v11:"
43
  _SCAN_CACHE_TTL = 21_600 # 6h
44
 
45
  Status = Literal["verified", "likely", "not_found", "checking", "error", "skipped"]
 
39
 
40
  _SCAN_TIMEOUT = 10.0
41
  _MAX_CONCURRENT = 6
42
+ _SCAN_CACHE_PREFIX = "platform_scan:v12:"
43
  _SCAN_CACHE_TTL = 21_600 # 6h
44
 
45
  Status = Literal["verified", "likely", "not_found", "checking", "error", "skipped"]
app/services/platform_pricing.py CHANGED
@@ -18,7 +18,9 @@ from app.services.book_url_scraper import (
18
  _browser_headers,
19
  _extract_amazon_all_format_prices,
20
  _extract_bn_all_format_prices,
 
21
  _google_books_api_params,
 
22
  _parse_amazon_html,
23
  _score_itunes_item,
24
  )
@@ -460,55 +462,9 @@ async def fetch_listing_format_prices(
460
  fmt_map: dict[str, str] = {}
461
  return FormatPriceHit(format_prices=fmt_map, formatted="Free", currency="USD")
462
 
463
- # --- Apple Books via iTunes API (already multi-format aware) ---
464
  if platform_id == "apple_books":
465
- book_id = _apple_book_id(listing_url)
466
- title = (ctx.title if ctx else None) or ""
467
- author = (ctx.author if ctx else None) or ""
468
- fmt_map = {}
469
- primary_fmt = ""
470
- currency = "USD"
471
- try:
472
- for params in (
473
- *(([{"id": book_id, "entity": "ebook"}] if book_id else [])),
474
- {"term": f"{title} {author}".strip(), "entity": "ebook", "limit": "5", "country": "US"} if title else None,
475
- ):
476
- if params is None:
477
- continue
478
- r = await client.get(
479
- "https://itunes.apple.com/" + ("lookup" if "id" in params else "search"),
480
- params=params,
481
- timeout=_API_TIMEOUT,
482
- )
483
- if r.status_code != 200:
484
- continue
485
- results = r.json().get("results") or []
486
- for item in results:
487
- if not _score_itunes_item(item, title, author):
488
- continue
489
- raw_price = item.get("formattedPrice") or item.get("trackPrice")
490
- if raw_price is None:
491
- continue
492
- amt_str = str(raw_price)
493
- m = re.search(r"([0-9]+(?:\.[0-9]{2})?)", amt_str.replace(",", ""))
494
- if m:
495
- amount = float(m.group(1))
496
- currency = str(item.get("currency") or "USD").upper()
497
- formatted_str = format_price(amount, currency)
498
- fmt_map["eBook"] = formatted_str
499
- primary_fmt = formatted_str
500
- break
501
- if fmt_map:
502
- break
503
- except Exception as exc:
504
- logger.debug("Apple format price fetch failed", error=str(exc)[:120])
505
- if not fmt_map:
506
- return None
507
- return FormatPriceHit(
508
- format_prices=canonical_format_prices(fmt_map, platform_id),
509
- formatted=primary_fmt,
510
- currency=currency,
511
- )
512
 
513
  # --- Credential-based platforms ---
514
  if credential is not None:
@@ -555,13 +511,22 @@ async def fetch_listing_format_prices(
555
  currency="USD",
556
  )
557
 
558
- # Fallback to single price
559
  single = extract_price_from_html(platform_id, html)
560
  if single:
561
- fmt_key = "eBook" if platform_id in {"kobo"} else "Hardcover"
562
- norm = canonical_format_prices({fmt_key: single.formatted}, platform_id)
 
 
 
 
 
 
 
 
 
563
  return FormatPriceHit(
564
- format_prices=norm,
565
  formatted=single.formatted,
566
  currency=single.currency,
567
  )
@@ -572,3 +537,139 @@ async def fetch_listing_format_prices(
572
  error=str(exc)[:120],
573
  )
574
  return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  _browser_headers,
19
  _extract_amazon_all_format_prices,
20
  _extract_bn_all_format_prices,
21
+ _extract_json_ld,
22
  _google_books_api_params,
23
+ _normalize_retail_format_label,
24
  _parse_amazon_html,
25
  _score_itunes_item,
26
  )
 
462
  fmt_map: dict[str, str] = {}
463
  return FormatPriceHit(format_prices=fmt_map, formatted="Free", currency="USD")
464
 
465
+ # --- Apple Books via iTunes API (eBook + Audiobook) ---
466
  if platform_id == "apple_books":
467
+ return await _fetch_apple_format_prices(client, listing_url, ctx)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
468
 
469
  # --- Credential-based platforms ---
470
  if credential is not None:
 
511
  currency="USD",
512
  )
513
 
514
+ # Fallback to single price — label only when binding is known (R-PP-010)
515
  single = extract_price_from_html(platform_id, html)
516
  if single:
517
+ fmt_key = _single_price_format_key(platform_id, html)
518
+ if fmt_key:
519
+ norm = canonical_format_prices(
520
+ {fmt_key: single.formatted}, platform_id,
521
+ )
522
+ return FormatPriceHit(
523
+ format_prices=norm,
524
+ formatted=single.formatted,
525
+ currency=single.currency,
526
+ )
527
+ # Unknown binding: keep list_price only, do not invent a format label
528
  return FormatPriceHit(
529
+ format_prices={},
530
  formatted=single.formatted,
531
  currency=single.currency,
532
  )
 
537
  error=str(exc)[:120],
538
  )
539
  return None
540
+
541
+
542
+ def _itunes_price_from_item(item: dict) -> tuple[str, str] | None:
543
+ """Return (formatted_price, currency) from an iTunes lookup/search item."""
544
+ raw_price = item.get("formattedPrice")
545
+ if raw_price:
546
+ m = re.search(r"([0-9]+(?:\.[0-9]{2})?)", str(raw_price).replace(",", ""))
547
+ if m:
548
+ currency = str(item.get("currency") or "USD").upper()
549
+ return format_price(float(m.group(1)), currency), currency
550
+ if item.get("trackPrice") is not None:
551
+ try:
552
+ amount = float(item["trackPrice"])
553
+ except (TypeError, ValueError):
554
+ return None
555
+ currency = str(item.get("currency") or "USD").upper()
556
+ return format_price(amount, currency), currency
557
+ return None
558
+
559
+
560
+ async def _fetch_apple_format_prices(
561
+ client: httpx.AsyncClient,
562
+ listing_url: str,
563
+ ctx: PriceContext | None,
564
+ ) -> FormatPriceHit | None:
565
+ """Fetch eBook and Audiobook prices from iTunes (free API)."""
566
+ from app.services.platform_catalog import canonical_format_prices, primary_list_price
567
+
568
+ book_id = _apple_book_id(listing_url)
569
+ title = (ctx.title if ctx else None) or ""
570
+ author = (ctx.author if ctx else None) or ""
571
+ fmt_map: dict[str, str] = {}
572
+ currency = "USD"
573
+
574
+ async def _query(entity: str, format_key: str) -> None:
575
+ nonlocal currency
576
+ if format_key in fmt_map:
577
+ return
578
+ queries: list[tuple[str, dict]] = []
579
+ if book_id:
580
+ queries.append(("lookup", {"id": book_id, "entity": entity}))
581
+ if title:
582
+ queries.append((
583
+ "search",
584
+ {
585
+ "term": f"{title} {author}".strip(),
586
+ "entity": entity,
587
+ "limit": "8",
588
+ "country": "US",
589
+ },
590
+ ))
591
+ for endpoint, params in queries:
592
+ try:
593
+ r = await client.get(
594
+ f"https://itunes.apple.com/{endpoint}",
595
+ params=params,
596
+ timeout=_API_TIMEOUT,
597
+ )
598
+ if r.status_code != 200:
599
+ continue
600
+ results = r.json().get("results") or []
601
+ if not results:
602
+ continue
603
+ if endpoint == "lookup":
604
+ candidates = results
605
+ else:
606
+ scored = [
607
+ (item, _score_itunes_item(item, title, author))
608
+ for item in results
609
+ ]
610
+ scored = [(i, s) for i, s in scored if s >= 4.0]
611
+ if not scored:
612
+ continue
613
+ candidates = [max(scored, key=lambda x: x[1])[0]]
614
+ for item in candidates:
615
+ hit = _itunes_price_from_item(item)
616
+ if hit:
617
+ formatted_str, currency = hit
618
+ fmt_map[format_key] = formatted_str
619
+ return
620
+ except Exception as exc:
621
+ logger.debug(
622
+ "Apple format price fetch failed",
623
+ entity=entity,
624
+ error=str(exc)[:120],
625
+ )
626
+
627
+ try:
628
+ await _query("ebook", "eBook")
629
+ await _query("audiobook", "Audiobook")
630
+ except Exception as exc:
631
+ logger.debug("Apple format price fetch failed", error=str(exc)[:120])
632
+
633
+ if not fmt_map:
634
+ return None
635
+ norm = canonical_format_prices(fmt_map, "apple_books")
636
+ primary = primary_list_price(norm, "", next(iter(fmt_map.values()), ""))
637
+ return FormatPriceHit(
638
+ format_prices=norm,
639
+ formatted=primary or next(iter(fmt_map.values())),
640
+ currency=currency,
641
+ )
642
+
643
+
644
+ def _book_format_from_json_ld(html: str) -> str:
645
+ """Return a canonical format label from schema.org bookFormat/binding, or ''."""
646
+ for block in _extract_json_ld(html):
647
+ raw = str(block.get("bookFormat") or block.get("binding") or "").strip()
648
+ if not raw:
649
+ continue
650
+ # schema.org URLs like https://schema.org/Hardcover
651
+ if "/" in raw:
652
+ raw = raw.rsplit("/", 1)[-1]
653
+ label = _normalize_retail_format_label(raw)
654
+ if label in {
655
+ "Hardcover", "Paperback", "Mass Market Paperback", "eBook", "Audiobook",
656
+ }:
657
+ return label
658
+ return ""
659
+
660
+
661
+ def _single_price_format_key(platform_id: str, html: str) -> str:
662
+ """Choose a format label for a single list price, or '' if unknown.
663
+
664
+ Never invent Hardcover for multi-binding used-book stores (R-PP-010).
665
+ Kobo is digital-only so eBook is safe. JSON-LD bookFormat wins when present.
666
+ """
667
+ if platform_id == "kobo":
668
+ return "eBook"
669
+ detected = _book_format_from_json_ld(html)
670
+ if detected:
671
+ return detected
672
+ # Multi-binding retailers: omit label rather than guessing Hardcover
673
+ if platform_id in {"thriftbooks", "abebooks", "bookshop"}:
674
+ return ""
675
+ return detected
tests/unit/test_platform_pricing.py CHANGED
@@ -7,6 +7,8 @@ import pytest
7
  from app.services.platform_pricing import (
8
  FormatPriceHit,
9
  PriceContext,
 
 
10
  extract_price_from_html,
11
  fetch_apple_books_price,
12
  fetch_google_books_price,
@@ -158,6 +160,128 @@ class TestFetchListingFormatPrices:
158
  assert hit is not None
159
  assert hit.formatted == "Free"
160
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
161
 
162
  class TestReplaceScanPreservesRichMap:
163
  """replace_from_scan should not clobber a richer existing format_prices map."""
 
7
  from app.services.platform_pricing import (
8
  FormatPriceHit,
9
  PriceContext,
10
+ _book_format_from_json_ld,
11
+ _single_price_format_key,
12
  extract_price_from_html,
13
  fetch_apple_books_price,
14
  fetch_google_books_price,
 
160
  assert hit is not None
161
  assert hit.formatted == "Free"
162
 
163
+ @pytest.mark.asyncio
164
+ async def test_apple_returns_ebook_and_audiobook(self):
165
+ """iTunes ebook + audiobook entities both populate format_prices."""
166
+ ebook_resp = MagicMock()
167
+ ebook_resp.status_code = 200
168
+ ebook_resp.json = MagicMock(return_value={
169
+ "results": [{
170
+ "trackName": "All We Say",
171
+ "artistName": "Test Author",
172
+ "formattedPrice": "$14.99",
173
+ "currency": "USD",
174
+ "description": "A long enough description for scoring boost " * 3,
175
+ }],
176
+ })
177
+ audio_resp = MagicMock()
178
+ audio_resp.status_code = 200
179
+ audio_resp.json = MagicMock(return_value={
180
+ "results": [{
181
+ "trackName": "All We Say",
182
+ "artistName": "Test Author",
183
+ "formattedPrice": "$28.00",
184
+ "currency": "USD",
185
+ "description": "A long enough description for scoring boost " * 3,
186
+ }],
187
+ })
188
+ client = AsyncMock()
189
+
190
+ async def _get(url, params=None, timeout=None):
191
+ entity = (params or {}).get("entity", "")
192
+ return audio_resp if entity == "audiobook" else ebook_resp
193
+
194
+ client.get = AsyncMock(side_effect=_get)
195
+ ctx = PriceContext(title="All We Say", author="Test Author")
196
+ hit = await fetch_listing_format_prices(
197
+ client,
198
+ "apple_books",
199
+ "https://books.apple.com/us/book/id123",
200
+ ctx,
201
+ )
202
+ assert hit is not None
203
+ assert hit.format_prices.get("eBook") == "$14.99"
204
+ assert hit.format_prices.get("Audiobook") == "$28.00"
205
+
206
+ @pytest.mark.asyncio
207
+ async def test_thriftbooks_unlabeled_single_price(self):
208
+ """ThriftBooks single price without bookFormat stays out of format_prices."""
209
+ html = (
210
+ "A" * 600
211
+ + '<span class="Item-price">$12.50</span>'
212
+ )
213
+ resp = MagicMock()
214
+ resp.status_code = 200
215
+ resp.text = html
216
+ client = AsyncMock()
217
+ client.get = AsyncMock(return_value=resp)
218
+
219
+ hit = await fetch_listing_format_prices(
220
+ client, "thriftbooks", "https://www.thriftbooks.com/w/foo/1/",
221
+ )
222
+ assert hit is not None
223
+ assert hit.formatted == "$12.50"
224
+ assert hit.format_prices == {}
225
+
226
+ @pytest.mark.asyncio
227
+ async def test_kobo_keeps_ebook_label(self):
228
+ """Kobo is digital-only — single price is labeled eBook."""
229
+ html = (
230
+ "A" * 600
231
+ + 'itemprop="price" content="9.99"'
232
+ )
233
+ resp = MagicMock()
234
+ resp.status_code = 200
235
+ resp.text = html
236
+ client = AsyncMock()
237
+ client.get = AsyncMock(return_value=resp)
238
+
239
+ hit = await fetch_listing_format_prices(
240
+ client, "kobo", "https://www.kobo.com/ebook/foo",
241
+ )
242
+ assert hit is not None
243
+ assert hit.format_prices.get("eBook") == "$9.99"
244
+
245
+ @pytest.mark.asyncio
246
+ async def test_json_ld_binding_labels_single_price(self):
247
+ """AbeBooks with JSON-LD Hardcover labels the single price correctly."""
248
+ html = (
249
+ "A" * 400
250
+ + '<script type="application/ld+json">'
251
+ + '{"@type":"Book","bookFormat":"Hardcover",'
252
+ + '"offers":{"price":"18.00","priceCurrency":"USD"}}'
253
+ + "</script>"
254
+ + 'itemprop="price" content="18.00"'
255
+ )
256
+ resp = MagicMock()
257
+ resp.status_code = 200
258
+ resp.text = html
259
+ client = AsyncMock()
260
+ client.get = AsyncMock(return_value=resp)
261
+
262
+ hit = await fetch_listing_format_prices(
263
+ client, "abebooks", "https://www.abebooks.com/book/123",
264
+ )
265
+ assert hit is not None
266
+ assert hit.format_prices.get("Hardcover") == "$18.00"
267
+
268
+
269
+ class TestSinglePriceFormatKey:
270
+ def test_kobo_is_ebook(self):
271
+ assert _single_price_format_key("kobo", "") == "eBook"
272
+
273
+ def test_thriftbooks_without_json_ld_is_empty(self):
274
+ assert _single_price_format_key("thriftbooks", "<html></html>") == ""
275
+
276
+ def test_json_ld_hardcover(self):
277
+ html = (
278
+ '<script type="application/ld+json">'
279
+ '{"bookFormat":"https://schema.org/Hardcover"}'
280
+ "</script>"
281
+ )
282
+ assert _book_format_from_json_ld(html) == "Hardcover"
283
+ assert _single_price_format_key("abebooks", html) == "Hardcover"
284
+
285
 
286
  class TestReplaceScanPreservesRichMap:
287
  """replace_from_scan should not clobber a richer existing format_prices map."""