Files
ComfyUI-Lora-Manager/tests/utils/test_civitai_page_prices.py
T
Will Miao ad2402724b feat(update): track buzz prices and alert below a threshold
CivitAI's public API deliberately omits prices — paidAccess is trimmed to
{permanent, endsAt} because "pricing belongs to the purchase flow" — but the
public model page embeds the site's own model.getById result, including
paidAccess.terms, in its server-rendered payload. That is read anonymously
(no API key, no internal endpoint, no forged Origin), one request per gated
model, so only the ~2% of models that actually carry a gate pay for it.

- optional capture, off by default: price_tracking_enabled,
  price_alert_threshold_buzz (0 = alert on "became free" only) and
  price_check_ttl_hours; prices refresh on their own TTL and immediately when a
  gate changes, and a failed fetch keeps the stored price instead of blanking it
- versions that stop carrying a gate are marked free (persisted gate_lapsed_at)
  and gate transitions are reported as events on the refresh response, so a
  version already in the library can announce that it became free
- price_alert_state plus a price_drop edge event; new
  GET /api/lm/{type}/updates/price-alerts lists what is under the threshold
- versions tab shows the price (effective, with the list price struck through
  and a Blue Buzz note) and a Free Now badge; an update check toasts the
  transitions in one message
- the parser and the alerts query are unit-tested against a trimmed page
  fixture, and every route definition is now asserted to resolve to a handler

Plan, verification notes and the deviations from it are in
docs/plans/paid-model-price-tracking.md.
2026-10-04 08:53:24 +08:00

92 lines
3.0 KiB
Python

"""Tests for the CivitAI model page price parser.
The fixture mirrors a real page payload; the cases that matter are the ones that
decide whether a user gets a price at all, and whether a lapsed gate is mistaken
for a live one.
"""
from pathlib import Path
import pytest
from py.utils.civitai_page_prices import MAX_PAGE_BYTES, parse_model_page_prices
FIXTURE = Path(__file__).parent / "fixtures" / "civitai_model_page_paid.html"
@pytest.fixture(scope="module")
def fixture_html() -> str:
return FIXTURE.read_text(encoding="utf-8")
def test_parses_prices_for_gated_versions(fixture_html):
prices = parse_model_page_prices(fixture_html)
assert prices is not None
# Lapsed gates and never-gated versions must not appear.
assert set(prices) == {1001, 1002, 1003}
def test_permanent_gate_price(fixture_html):
prices = parse_model_page_prices(fixture_html)
permanent = prices[1001]
assert permanent["price_buzz"] == 5000
assert permanent["list_price_buzz"] == 5000
assert permanent["generation_price_buzz"] == 100
assert permanent["accepts_blue_buzz"] is False
assert permanent["price_sale_ends_at"] is None
def test_timed_gate_uses_sale_price_as_effective(fixture_html):
prices = parse_model_page_prices(fixture_html)
timed = prices[1002]
# The stored/list price stays visible for a strikethrough, the effective price
# is what a buyer pays now.
assert timed["price_buzz"] == 100
assert timed["list_price_buzz"] == 125
assert timed["accepts_blue_buzz"] is True
assert timed["price_sale_ends_at"] == "2999-09-01T00:00:00.000Z"
# Generation is bundled with the download tier here (`trialLimit` only).
assert timed["generation_price_buzz"] is None
def test_timed_gate_without_recorded_end_is_still_priced(fixture_html):
prices = parse_model_page_prices(fixture_html)
assert prices[1003]["price_buzz"] == 300
assert prices[1003]["generation_price_buzz"] is None
@pytest.mark.parametrize(
"html",
[
None,
"",
"<html><body>no payload here</body></html>",
'<script id="__NEXT_DATA__" type="application/json">{not json</script>',
# Valid JSON, but not the shape we need.
'<script id="__NEXT_DATA__" type="application/json">{"props":{}}</script>',
# Model query present, but no versions.
'<script id="__NEXT_DATA__" type="application/json">'
'{"props":{"pageProps":{"trpcState":{"json":{"queries":['
'{"queryKey":[["model","getById"]],"state":{"data":{}}}]}}}}}</script>',
],
)
def test_unusable_payloads_return_none(html):
assert parse_model_page_prices(html) is None
def test_oversized_page_is_skipped():
oversized = "x" * (MAX_PAGE_BYTES + 1)
assert parse_model_page_prices(oversized) is None
def test_unrelated_first_query_does_not_win(fixture_html):
"""Query order varies per page; selection is by procedure name."""
prices = parse_model_page_prices(fixture_html)
assert prices is not None
assert 1001 in prices