mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-10-06 01:45:31 -03:00
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.
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
<!DOCTYPE html>
|
||||
<!--
|
||||
Trimmed stand-in for a CivitAI model page, mirroring the shape verified against
|
||||
civitai.com / civitai.red. Values are synthetic; the structure (unrelated query
|
||||
first, then the model.getById query, tombstones included) is what matters.
|
||||
-->
|
||||
<html>
|
||||
<head>
|
||||
<title>Fixture model</title>
|
||||
</head>
|
||||
<body>
|
||||
<script id="__NEXT_DATA__" type="application/json">
|
||||
{
|
||||
"props": {
|
||||
"pageProps": {
|
||||
"trpcState": {
|
||||
"json": {
|
||||
"queries": [
|
||||
{
|
||||
"queryKey": [["common", "getEntityAccess"], { "entityId": 1001 }],
|
||||
"state": { "data": [] }
|
||||
},
|
||||
{
|
||||
"queryKey": [
|
||||
["model", "getById"],
|
||||
{ "input": { "id": 4242, "browsingLevel": 1 }, "type": "query" }
|
||||
],
|
||||
"state": {
|
||||
"data": {
|
||||
"id": 4242,
|
||||
"name": "Fixture model",
|
||||
"modelVersions": [
|
||||
{
|
||||
"id": 1001,
|
||||
"name": "permanent paid",
|
||||
"canDownload": false,
|
||||
"paidAccess": {
|
||||
"endsAt": null,
|
||||
"timeframeDays": null,
|
||||
"terms": {
|
||||
"download": { "price": 5000 },
|
||||
"generation": { "price": 100, "trialLimit": 5 }
|
||||
},
|
||||
"sale": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 1002,
|
||||
"name": "timed gate on sale",
|
||||
"canDownload": false,
|
||||
"paidAccess": {
|
||||
"endsAt": "2999-10-10T13:10:17.404Z",
|
||||
"timeframeDays": 7,
|
||||
"terms": {
|
||||
"download": { "price": 125 },
|
||||
"generation": { "trialLimit": 10 },
|
||||
"acceptsBlueBuzz": true
|
||||
},
|
||||
"sale": {
|
||||
"listTerms": { "download": { "price": 125 } },
|
||||
"buyerTerms": { "download": { "price": 100 } },
|
||||
"endsAt": "2999-09-01T00:00:00.000Z",
|
||||
"discountType": "percent",
|
||||
"discountAmount": 20
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 1003,
|
||||
"name": "timed gate with no recorded end",
|
||||
"canDownload": false,
|
||||
"paidAccess": {
|
||||
"endsAt": null,
|
||||
"timeframeDays": 7,
|
||||
"terms": { "download": { "price": 300 } },
|
||||
"sale": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 1004,
|
||||
"name": "lapsed gate (tombstone)",
|
||||
"canDownload": true,
|
||||
"paidAccess": {
|
||||
"endsAt": "2000-08-27T16:56:57.438Z",
|
||||
"timeframeDays": 3,
|
||||
"terms": { "download": { "price": 200 } },
|
||||
"sale": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 1005,
|
||||
"name": "never gated",
|
||||
"canDownload": true,
|
||||
"paidAccess": null
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,91 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user