diff --git a/backend/src/alembic/versions/d4e7f2a8b1c5_add_giveaway_entries_count.py b/backend/src/alembic/versions/d4e7f2a8b1c5_add_giveaway_entries_count.py new file mode 100644 index 0000000..55c8804 --- /dev/null +++ b/backend/src/alembic/versions/d4e7f2a8b1c5_add_giveaway_entries_count.py @@ -0,0 +1,41 @@ +"""add entries count to giveaways + +The scraper has always parsed the entry count from listing pages; store it so +the UI can show and filter by chance-to-win (copies / entries). + +Revision ID: d4e7f2a8b1c5 +Revises: c8d2e5f1a3b9 +Create Date: 2026-07-18 17:00:00.000000 + +""" +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = 'd4e7f2a8b1c5' +down_revision: str | Sequence[str] | None = 'c8d2e5f1a3b9' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + with op.batch_alter_table('giveaways', schema=None) as batch_op: + batch_op.add_column( + sa.Column( + 'entries', + sa.Integer(), + nullable=False, + server_default='0', + comment='Entry count as of the last scan (0 = none yet or unknown)', + ) + ) + + +def downgrade() -> None: + """Downgrade schema.""" + with op.batch_alter_table('giveaways', schema=None) as batch_op: + batch_op.drop_column('entries') diff --git a/backend/src/api/routers/giveaways.py b/backend/src/api/routers/giveaways.py index 35662ef..0b0adb3 100644 --- a/backend/src/api/routers/giveaways.py +++ b/backend/src/api/routers/giveaways.py @@ -102,6 +102,8 @@ async def get_active_giveaways( giveaway_service: GiveawayServiceDep, min_score: int | None = Query(default=None, ge=0, le=10, description="Minimum review score (0-10)"), is_safe: bool | None = Query(default=None, description="Filter by safety status (true=safe, false=unsafe)"), + min_chance: float | None = Query(default=None, ge=0.01, le=100, description="Minimum win chance in percent"), + ending_within: int | None = Query(default=None, ge=1, description="Only giveaways ending within this many minutes"), limit: int = Query(default=50, ge=1, le=200, description="Maximum results"), offset: int = Query(default=0, ge=0, description="Number of records to skip"), ) -> dict[str, Any]: @@ -112,7 +114,8 @@ async def get_active_giveaways( Success response with list of active giveaways """ giveaways = await giveaway_service.get_active_giveaways( - limit=limit, offset=offset, min_score=min_score, is_safe=is_safe + limit=limit, offset=offset, min_score=min_score, is_safe=is_safe, + min_chance=min_chance, ending_within_minutes=ending_within, ) # Enrich with game data (thumbnails, reviews) @@ -139,6 +142,8 @@ async def get_active_giveaways( ) async def get_wishlist_giveaways( giveaway_service: GiveawayServiceDep, + min_chance: float | None = Query(default=None, ge=0.01, le=100, description="Minimum win chance in percent"), + ending_within: int | None = Query(default=None, ge=1, description="Only giveaways ending within this many minutes"), limit: int = Query(default=50, ge=1, le=200, description="Maximum results"), offset: int = Query(default=0, ge=0, description="Number of records to skip"), ) -> dict[str, Any]: @@ -148,7 +153,10 @@ async def get_wishlist_giveaways( Returns: Success response with list of wishlist giveaways """ - giveaways = await giveaway_service.giveaway_repo.get_wishlist(limit=limit, offset=offset) + giveaways = await giveaway_service.giveaway_repo.get_wishlist( + limit=limit, offset=offset, + min_chance=min_chance, ending_within_minutes=ending_within, + ) # Enrich with game data (thumbnails, reviews) giveaways = await giveaway_service.enrich_giveaways_with_game_data(giveaways) diff --git a/backend/src/api/schemas/giveaway.py b/backend/src/api/schemas/giveaway.py index b92ab7f..6f8d710 100644 --- a/backend/src/api/schemas/giveaway.py +++ b/backend/src/api/schemas/giveaway.py @@ -7,7 +7,7 @@ from datetime import datetime from typing import Any -from pydantic import BaseModel, Field, field_serializer +from pydantic import BaseModel, Field, computed_field, field_serializer class GiveawayBase(BaseModel): @@ -51,6 +51,12 @@ class GiveawayBase(BaseModel): ge=1, examples=[1], ) + entries: int = Field( + default=0, + description="Entry count as of the last scan (0 = none yet or unknown)", + ge=0, + examples=[250], + ) end_time: datetime | None = Field( default=None, description="When the giveaway ends (UTC)", @@ -112,6 +118,24 @@ class GiveawayResponse(GiveawayBase): description="Internal giveaway ID", examples=[123], ) + + @computed_field(description="Win chance in percent (copies/entries*100) as of the last scan") + @property + def win_chance(self) -> float: + """Chance to win in percent; 100 when nobody has entered yet. + + Adaptive precision: two decimals for ordinary odds, four for + long shots so huge giveaways don't all collapse to 0%. + """ + if self.entries <= 0: + return 100.0 + raw = self.copies * 100.0 / self.entries + if raw >= 100.0: + return 100.0 + if raw >= 1.0: + return round(raw, 2) + return max(round(raw, 4), 0.0001) + discovered_at: datetime = Field( ..., description="When giveaway was first discovered (UTC)", diff --git a/backend/src/models/giveaway.py b/backend/src/models/giveaway.py index 4df3c09..e5a2e0c 100644 --- a/backend/src/models/giveaway.py +++ b/backend/src/models/giveaway.py @@ -113,6 +113,11 @@ class Giveaway(Base, TimestampMixin): default=1, comment="Number of copies available", ) + entries: Mapped[int] = mapped_column( + Integer, + default=0, + comment="Entry count as of the last scan (0 = none yet or unknown)", + ) end_time: Mapped[datetime | None] = mapped_column( DateTime, nullable=True, diff --git a/backend/src/repositories/giveaway.py b/backend/src/repositories/giveaway.py index 9a6e5b0..f5d9d7a 100644 --- a/backend/src/repositories/giveaway.py +++ b/backend/src/repositories/giveaway.py @@ -96,9 +96,35 @@ async def get_by_code(self, code: str) -> Giveaway | None: result = await self.session.execute(query) return result.scalar_one_or_none() + def _browse_filter_conditions( + self, now: datetime, min_chance: float | None, ending_within_minutes: int | None + ) -> list[Any]: + """Shared UI browse-filter conditions (chance to win, time remaining). + + A giveaway with no recorded entries yet always passes the chance + filter — its chance is effectively 100%. + """ + conditions: list[Any] = [] + + if min_chance is not None and min_chance > 0: + conditions.append( + or_( + self.model.entries == 0, + self.model.copies * 100.0 / self.model.entries >= min_chance, + ) + ) + + if ending_within_minutes is not None and ending_within_minutes > 0: + conditions.append( + self.model.end_time <= now + timedelta(minutes=ending_within_minutes) + ) + + return conditions + async def get_active( self, limit: int | None = None, offset: int = 0, min_score: int | None = None, - is_safe: bool | None = None + is_safe: bool | None = None, min_chance: float | None = None, + ending_within_minutes: int | None = None, ) -> list[Giveaway]: """ Get all active (non-expired) giveaways. @@ -111,6 +137,9 @@ async def get_active( offset: Number of records to skip (for pagination) min_score: Minimum review score (0-10) to filter by is_safe: Filter by safety status (True=safe only, False=unsafe only, None=all) + min_chance: Minimum win chance in percent (copies/entries*100); + giveaways with no recorded entries yet always pass + ending_within_minutes: Only giveaways ending within this many minutes Returns: List of active giveaways, ordered by end_time (soonest first) @@ -133,6 +162,10 @@ async def get_active( if is_safe is not None: conditions.append(self.model.is_safe == is_safe) # noqa: E712 + conditions.extend( + self._browse_filter_conditions(now, min_chance, ending_within_minutes) + ) + # If min_score is specified, join with Game table and filter # Games default to review_score=0 when unknown if min_score is not None and min_score > 0: @@ -365,7 +398,8 @@ async def get_entered( return list(result.scalars().all()) async def get_wishlist( - self, limit: int | None = None, offset: int | None = None + self, limit: int | None = None, offset: int | None = None, + min_chance: float | None = None, ending_within_minutes: int | None = None, ) -> list[Giveaway]: """ Get active wishlist giveaways. @@ -373,6 +407,8 @@ async def get_wishlist( Args: limit: Maximum number to return offset: Number of records to skip + min_chance: Minimum win chance in percent (copies/entries*100) + ending_within_minutes: Only giveaways ending within this many minutes Returns: List of wishlist giveaways that are still active (not expired) @@ -387,6 +423,7 @@ async def get_wishlist( self.model.is_wishlist == True, # noqa: E712 self.model.is_hidden == False, # noqa: E712 (self.model.end_time == None) | (self.model.end_time > now), # noqa: E711 + *self._browse_filter_conditions(now, min_chance, ending_within_minutes), ) .order_by(self.model.end_time.asc()) ) diff --git a/backend/src/services/giveaway_query.py b/backend/src/services/giveaway_query.py index 7542986..7d924f3 100644 --- a/backend/src/services/giveaway_query.py +++ b/backend/src/services/giveaway_query.py @@ -156,7 +156,8 @@ async def evaluate_and_get_eligible( async def get_active_giveaways( self, limit: int | None = None, offset: int = 0, min_score: int | None = None, - is_safe: bool | None = None + is_safe: bool | None = None, min_chance: float | None = None, + ending_within_minutes: int | None = None, ) -> list[Giveaway]: """ Get all active (non-expired) giveaways. @@ -166,6 +167,8 @@ async def get_active_giveaways( offset: Number of records to skip (for pagination) min_score: Minimum review score (0-10) to filter by is_safe: Filter by safety status (True=safe only, False=unsafe only, None=all) + min_chance: Minimum win chance in percent (copies/entries*100) + ending_within_minutes: Only giveaways ending within this many minutes Returns: List of active giveaways @@ -173,7 +176,10 @@ async def get_active_giveaways( Example: >>> active = await service.get_active_giveaways(limit=20, offset=40, min_score=7, is_safe=True) """ - return await self.giveaway_repo.get_active(limit=limit, offset=offset, min_score=min_score, is_safe=is_safe) + return await self.giveaway_repo.get_active( + limit=limit, offset=offset, min_score=min_score, is_safe=is_safe, + min_chance=min_chance, ending_within_minutes=ending_within_minutes, + ) async def get_all_giveaways( self, limit: int | None = None, offset: int = 0 diff --git a/backend/src/services/giveaway_sync.py b/backend/src/services/giveaway_sync.py index d5ecc88..3386419 100644 --- a/backend/src/services/giveaway_sync.py +++ b/backend/src/services/giveaway_sync.py @@ -240,6 +240,7 @@ async def sync_entered_giveaways(self, pages: int = 1) -> int: url=url, game_name=entry["game_name"], price=entry.get("price", 0), + entries=entry.get("entries", 0), game_id=entry.get("game_id"), end_time=entry.get("end_time"), is_entered=True, @@ -273,6 +274,7 @@ async def _create_giveaway(self, ga_data: dict) -> Giveaway: game_name=ga_data["game_name"], price=ga_data["price"], copies=ga_data.get("copies", 1), + entries=ga_data.get("entries", 0), end_time=ga_data.get("end_time"), game_id=ga_data.get("game_id"), is_wishlist=ga_data.get("is_wishlist", False), @@ -291,6 +293,10 @@ async def _update_giveaway(self, giveaway: Giveaway, ga_data: dict) -> None: # Update mutable fields giveaway.end_time = ga_data.get("end_time", giveaway.end_time) + # Entry counts move constantly; keep the latest scan's value. + if ga_data.get("entries") is not None: + giveaway.entries = ga_data["entries"] + # Update game_id if we found it and didn't have it before if ga_data.get("game_id") and not giveaway.game_id: giveaway.game_id = ga_data["game_id"] diff --git a/backend/src/utils/steamgifts_parser.py b/backend/src/utils/steamgifts_parser.py index f49d1c1..735fa56 100644 --- a/backend/src/utils/steamgifts_parser.py +++ b/backend/src/utils/steamgifts_parser.py @@ -183,14 +183,15 @@ def parse_giveaway_element(element: Any) -> dict[str, Any] | None: if match: copies = int(match.group(1)) - # Extract entries count + # Extract entries count. The links row is a div (not a span) containing + # e.g. "1,234 entries 5 comments" — match digits with thousands commas. entries = 0 - entries_element = element.find("span", class_="giveaway__links") + entries_element = element.find("div", class_="giveaway__links") if entries_element: - entries_text = entries_element.text.strip() - match = re.search(r"(\d+)\s+entries", entries_text) + entries_text = entries_element.get_text(" ", strip=True) + match = re.search(r"([\d,]+)\s+entr", entries_text) if match: - entries = int(match.group(1)) + entries = int(match.group(1).replace(",", "")) # Extract end time time_element = element.find("span", {"data-timestamp": True}) diff --git a/backend/tests/unit/test_repositories_giveaway.py b/backend/tests/unit/test_repositories_giveaway.py index 76621bd..acfb88e 100644 --- a/backend/tests/unit/test_repositories_giveaway.py +++ b/backend/tests/unit/test_repositories_giveaway.py @@ -91,6 +91,82 @@ async def test_get_active_returns_only_active(test_db): assert active[0].code == "ACTIVE1" +@pytest.mark.asyncio +async def test_get_active_min_chance_filter(test_db): + """min_chance keeps giveaways with copies/entries*100 >= threshold, + and always keeps ones with no recorded entries.""" + async with test_db() as session: + repo = GiveawayRepository(session) + now = utcnow() + future = now + timedelta(hours=24) + + # 1 copy / 50 entries = 2% + await repo.create(code="GOOD", game_name="A", price=10, + url="http://x/1", end_time=future, copies=1, entries=50) + # 1 copy / 10000 entries = 0.01% + await repo.create(code="LONGSHOT", game_name="B", price=10, + url="http://x/2", end_time=future, copies=1, entries=10000) + # No entries recorded yet -> always passes + await repo.create(code="FRESH", game_name="C", price=10, + url="http://x/3", end_time=future, copies=1, entries=0) + await session.commit() + + codes = {g.code for g in await repo.get_active(min_chance=1.0)} + assert codes == {"GOOD", "FRESH"} + + # Threshold at exactly 0.01 keeps the longshot too + codes = {g.code for g in await repo.get_active(min_chance=0.01)} + assert codes == {"GOOD", "LONGSHOT", "FRESH"} + + +@pytest.mark.asyncio +async def test_get_active_ending_within_filter(test_db): + """ending_within_minutes keeps only giveaways ending soon enough.""" + async with test_db() as session: + repo = GiveawayRepository(session) + now = utcnow() + + await repo.create(code="SOON", game_name="A", price=10, + url="http://x/1", end_time=now + timedelta(hours=2)) + await repo.create(code="LATER", game_name="B", price=10, + url="http://x/2", end_time=now + timedelta(hours=48)) + await session.commit() + + codes = {g.code for g in await repo.get_active(ending_within_minutes=360)} + assert codes == {"SOON"} + + codes = {g.code for g in await repo.get_active(ending_within_minutes=4320)} + assert codes == {"SOON", "LATER"} + + +@pytest.mark.asyncio +async def test_get_wishlist_browse_filters(test_db): + """The wishlist listing honors min_chance and ending_within_minutes.""" + async with test_db() as session: + repo = GiveawayRepository(session) + now = utcnow() + + await repo.create(code="WSOON", game_name="A", price=10, url="http://x/1", + end_time=now + timedelta(hours=2), is_wishlist=True, + copies=1, entries=10) # 10% + await repo.create(code="WCROWD", game_name="B", price=10, url="http://x/2", + end_time=now + timedelta(hours=2), is_wishlist=True, + copies=1, entries=5000) # 0.02% + await repo.create(code="WLATER", game_name="C", price=10, url="http://x/3", + end_time=now + timedelta(hours=48), is_wishlist=True, + copies=1, entries=10) + await session.commit() + + codes = {g.code for g in await repo.get_wishlist(min_chance=1.0)} + assert codes == {"WSOON", "WLATER"} + + codes = {g.code for g in await repo.get_wishlist(ending_within_minutes=360)} + assert codes == {"WSOON", "WCROWD"} + + codes = {g.code for g in await repo.get_wishlist(min_chance=1.0, ending_within_minutes=360)} + assert codes == {"WSOON"} + + @pytest.mark.asyncio async def test_get_active_excludes_hidden(test_db): """Test getting active giveaways excludes hidden ones.""" diff --git a/backend/tests/unit/test_schemas_giveaway.py b/backend/tests/unit/test_schemas_giveaway.py index e88386e..531b9f7 100644 --- a/backend/tests/unit/test_schemas_giveaway.py +++ b/backend/tests/unit/test_schemas_giveaway.py @@ -92,6 +92,28 @@ def test_giveaway_response(): assert giveaway.discovered_at is not None +def test_giveaway_response_win_chance(): + """win_chance = copies/entries*100, capped at 100, 100 when no entries.""" + def mk(copies, entries): + return GiveawayResponse( + id=1, code="GA1", url="test", game_name="G", price=50, + copies=copies, entries=entries, discovered_at=utcnow(), + ) + + assert mk(1, 0).win_chance == 100.0 # nobody entered yet + assert mk(1, 4).win_chance == 25.0 + assert mk(1, 10000).win_chance == 0.01 + assert mk(5, 2).win_chance == 100.0 # more copies than entries -> capped + assert mk(1, 3).win_chance == 33.33 # rounded to 2 decimals + # Long shots keep meaningful precision instead of collapsing to 0% + assert mk(1, 25000).win_chance == 0.004 + assert mk(1, 80000).win_chance == 0.0013 + assert mk(1, 10_000_000).win_chance == 0.0001 # display floor + + # win_chance is serialized in API payloads + assert mk(1, 4).model_dump()["win_chance"] == 25.0 + + def test_giveaway_list(): """Test GiveawayList.""" giveaway1 = GiveawayResponse( diff --git a/backend/tests/unit/test_steamgifts_parser.py b/backend/tests/unit/test_steamgifts_parser.py index 78eb65e..8eb1ba4 100644 --- a/backend/tests/unit/test_steamgifts_parser.py +++ b/backend/tests/unit/test_steamgifts_parser.py @@ -41,6 +41,8 @@ def test_ads_excluded_real_giveaway_kept(self, wishlist_html): assert ga["is_wishlist"] is True assert ga["game_id"] == 2525380 assert ga["end_time"] is not None + # The links row is a div: "455 entries 2 comments" on this capture. + assert ga["entries"] == 455 def test_mark_wishlist_false(self, wishlist_html): giveaways = parser.parse_giveaway_list(wishlist_html, mark_wishlist=False) @@ -83,6 +85,30 @@ def test_empty_page(self): assert parser.extract_xsrf_token("
") is None assert parser.parse_giveaway_game_id("") is None + def test_entries_with_thousands_separator(self): + html = """ + + """ + [ga] = parser.parse_giveaway_list(html) + assert ga["entries"] == 1234 + + def test_single_entry_singular(self): + html = """ +