Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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')
12 changes: 10 additions & 2 deletions backend/src/api/routers/giveaways.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand All @@ -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)
Expand All @@ -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]:
Expand All @@ -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)
Expand Down
26 changes: 25 additions & 1 deletion backend/src/api/schemas/giveaway.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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)",
Expand Down Expand Up @@ -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)",
Expand Down
5 changes: 5 additions & 0 deletions backend/src/models/giveaway.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
41 changes: 39 additions & 2 deletions backend/src/repositories/giveaway.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
Expand All @@ -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:
Expand Down Expand Up @@ -365,14 +398,17 @@ 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.

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)
Expand All @@ -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())
)
Expand Down
10 changes: 8 additions & 2 deletions backend/src/services/giveaway_query.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -166,14 +167,19 @@ 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

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
Expand Down
6 changes: 6 additions & 0 deletions backend/src/services/giveaway_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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),
Expand All @@ -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"]
Expand Down
11 changes: 6 additions & 5 deletions backend/src/utils/steamgifts_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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})
Expand Down
76 changes: 76 additions & 0 deletions backend/tests/unit/test_repositories_giveaway.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
Loading
Loading