Spaces:
Running
Running
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:
|
| 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 (
|
| 464 |
if platform_id == "apple_books":
|
| 465 |
-
|
| 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 =
|
| 562 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 563 |
return FormatPriceHit(
|
| 564 |
-
format_prices=
|
| 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."""
|