"""Match Humble titles against an owned Steam library.
Humble and Steam rarely spell a title identically — "Game GOTY" against
"Game: Game of the Year Edition", trademark symbols, differing punctuation —
so ownership is decided by fuzzy comparison rather than equality.
Getting this wrong is expensive in both directions. A missed match spends one
of Steam's ten hourly *failed* activations on a game the account already owns;
a false match silently skips a key that could have been redeemed.
"""
from __future__ import annotations
from dataclasses import dataclass
from rapidfuzz import fuzz, process, utils
[docs]
@dataclass(frozen=True, slots=True)
class MatchDecision:
"""The outcome of comparing one title against the owned library.
Attributes:
app_id: Steam application id of the best candidate, if any.
app_name: Name of that application.
score: Similarity score from 0 to 100.
confident: Whether the score clears the confirmation threshold.
"""
app_id: int | None
app_name: str | None
score: int
confident: bool
@property
def matched(self) -> bool:
"""Whether any candidate cleared the match threshold."""
return self.app_id is not None
[docs]
class OwnershipMatcher:
"""Decides whether a Humble title is already owned on Steam.
Args:
owned: Mapping of Steam app id to application name.
threshold: Score above which a title counts as a candidate match.
confirm_threshold: Score at or above which a match needs no review.
"""
def __init__(
self,
owned: dict[int, str],
*,
threshold: int = 70,
confirm_threshold: int = 95,
) -> None:
"""Build a matcher over an owned library."""
self._owned = dict(owned)
self._threshold = threshold
self._confirm_threshold = confirm_threshold
# rapidfuzz matches against a parallel list, so keep ids aligned.
#
# An app whose name is unknown still answers ownership by id, which is
# authoritative when Humble supplied one. It is left out of the fuzzy
# candidates because there is nothing to compare a title against.
self._app_ids = [app_id for app_id, name in self._owned.items() if name]
self._names = [self._owned[app_id] for app_id in self._app_ids]
[docs]
def match(self, title: str, *, steam_app_id: int | None = None) -> MatchDecision:
"""Find the best owned application matching ``title``.
A Steam app id reported by Humble is authoritative and short-circuits
the fuzzy comparison entirely.
Args:
title: Humble display title.
steam_app_id: Steam app id from Humble, when present.
Returns:
The best :class:`MatchDecision`; unmatched when nothing clears
the threshold.
"""
if steam_app_id is not None and steam_app_id in self._owned:
return MatchDecision(
app_id=steam_app_id,
app_name=self._owned[steam_app_id],
score=100,
confident=True,
)
if not title or not self._names:
return MatchDecision(None, None, 0, confident=False)
# `default_process` lowercases and strips punctuation, so casing and
# trademark noise do not depress the score.
#
# token_set_ratio treats a subset as perfect — "Portal" scores 100
# against "Portal 2" — so it is used only to gather candidates. The
# winner is then chosen by token_sort_ratio, which penalizes the extra
# words and so prefers "Portal" itself when the account owns both.
candidates = process.extract(
title,
self._names,
scorer=fuzz.token_set_ratio,
processor=utils.default_process,
score_cutoff=self._threshold,
limit=None,
)
if not candidates:
return MatchDecision(None, None, 0, confident=False)
name, _score, index = max(
candidates,
key=lambda item: (
fuzz.token_sort_ratio(title, item[0], processor=utils.default_process),
item[1],
),
)
refined = int(fuzz.token_sort_ratio(title, name, processor=utils.default_process))
if refined < self._threshold:
# The candidate only cleared the subset-tolerant score. Reporting a
# match here would skip the key as owned on the strength of a score
# the caller already deemed too low.
return MatchDecision(None, None, refined, confident=False)
return MatchDecision(
app_id=self._app_ids[index],
app_name=name,
score=refined,
confident=refined >= self._confirm_threshold,
)
[docs]
def candidates(self, title: str, *, limit: int = 5) -> list[MatchDecision]:
"""Return the closest owned applications for manual review.
Args:
title: Humble display title.
limit: Maximum number of candidates.
Returns:
Candidate matches, best first.
"""
if not title or not self._names:
return []
results = process.extract(
title,
self._names,
scorer=fuzz.token_set_ratio,
processor=utils.default_process,
score_cutoff=self._threshold,
limit=limit,
)
# Report the same refined score `match()` uses. token_set_ratio alone
# rates a subset as perfect ("Portal" against "Portal 2"), which would
# present a false candidate as confident.
decisions = [
MatchDecision(
app_id=self._app_ids[index],
app_name=name,
score=int(fuzz.token_sort_ratio(title, name, processor=utils.default_process)),
confident=False,
)
for name, _score, index in results
]
return sorted(
(
MatchDecision(d.app_id, d.app_name, d.score, d.score >= self._confirm_threshold)
for d in decisions
),
key=lambda d: d.score,
reverse=True,
)
__all__ = ["MatchDecision", "OwnershipMatcher"]