backloggd-py 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- backloggd/__init__.py +74 -0
- backloggd/analytics/__init__.py +3 -0
- backloggd/analytics/matcher.py +152 -0
- backloggd/cache.py +621 -0
- backloggd/client.py +791 -0
- backloggd/exceptions.py +34 -0
- backloggd/models/__init__.py +31 -0
- backloggd/models/comparison.py +31 -0
- backloggd/models/game.py +83 -0
- backloggd/models/journal.py +23 -0
- backloggd/models/library.py +157 -0
- backloggd/models/list.py +28 -0
- backloggd/models/review.py +23 -0
- backloggd/models/user.py +48 -0
- backloggd/parsers/__init__.py +29 -0
- backloggd/parsers/game_page.py +90 -0
- backloggd/parsers/games.py +149 -0
- backloggd/parsers/journal.py +94 -0
- backloggd/parsers/lists.py +112 -0
- backloggd/parsers/profile.py +124 -0
- backloggd/parsers/reviews.py +222 -0
- backloggd/parsers/search.py +112 -0
- backloggd/parsers/social.py +54 -0
- backloggd/py.typed +1 -0
- backloggd/sync_client.py +794 -0
- backloggd/utils.py +134 -0
- backloggd_py-0.1.0.dist-info/METADATA +409 -0
- backloggd_py-0.1.0.dist-info/RECORD +30 -0
- backloggd_py-0.1.0.dist-info/WHEEL +4 -0
- backloggd_py-0.1.0.dist-info/licenses/LICENSE +21 -0
backloggd/__init__.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Backloggd Python SDK and Taste Matcher."""
|
|
2
|
+
|
|
3
|
+
from backloggd.client import BackloggdClient
|
|
4
|
+
from backloggd.sync_client import Backloggd
|
|
5
|
+
from backloggd.cache import CacheManager
|
|
6
|
+
from backloggd.utils import load_cookies, build_games_url
|
|
7
|
+
from backloggd.exceptions import (
|
|
8
|
+
BackloggdError,
|
|
9
|
+
ChallengeResponseError,
|
|
10
|
+
RateLimitError,
|
|
11
|
+
ResourceNotFoundError,
|
|
12
|
+
UserNotFoundError,
|
|
13
|
+
GameNotFoundError,
|
|
14
|
+
ListNotFoundError,
|
|
15
|
+
ReviewNotFoundError,
|
|
16
|
+
)
|
|
17
|
+
from backloggd.models import (
|
|
18
|
+
GameDetails,
|
|
19
|
+
GameEntry,
|
|
20
|
+
GameSearchResult,
|
|
21
|
+
GameStatsSummary,
|
|
22
|
+
GameStatus,
|
|
23
|
+
MatchComparison,
|
|
24
|
+
RatingDisagreement,
|
|
25
|
+
Review,
|
|
26
|
+
UserLibrary,
|
|
27
|
+
UserList,
|
|
28
|
+
UserListSummary,
|
|
29
|
+
UserProfile,
|
|
30
|
+
UserStats,
|
|
31
|
+
UserSummary,
|
|
32
|
+
JournalEntry,
|
|
33
|
+
)
|
|
34
|
+
from backloggd.analytics.matcher import calculate_similarity_raw, compare_libraries
|
|
35
|
+
|
|
36
|
+
__version__ = "0.1.0"
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
# Clients, Cache & Utilities
|
|
40
|
+
"Backloggd", # Synchronous client (no async/await needed)
|
|
41
|
+
"BackloggdClient", # Async client
|
|
42
|
+
"CacheManager",
|
|
43
|
+
"load_cookies",
|
|
44
|
+
"build_games_url",
|
|
45
|
+
# Models
|
|
46
|
+
"GameEntry",
|
|
47
|
+
"GameDetails",
|
|
48
|
+
"GameSearchResult",
|
|
49
|
+
"GameStatsSummary",
|
|
50
|
+
"GameStatus",
|
|
51
|
+
"Review",
|
|
52
|
+
"UserList",
|
|
53
|
+
"UserListSummary",
|
|
54
|
+
"UserLibrary",
|
|
55
|
+
"UserProfile",
|
|
56
|
+
"UserStats",
|
|
57
|
+
"UserSummary",
|
|
58
|
+
"JournalEntry",
|
|
59
|
+
"MatchComparison",
|
|
60
|
+
"RatingDisagreement",
|
|
61
|
+
# Analytics
|
|
62
|
+
"calculate_similarity_raw",
|
|
63
|
+
"compare_libraries",
|
|
64
|
+
# Exceptions
|
|
65
|
+
"BackloggdError",
|
|
66
|
+
"ChallengeResponseError",
|
|
67
|
+
"RateLimitError",
|
|
68
|
+
"ResourceNotFoundError",
|
|
69
|
+
"UserNotFoundError",
|
|
70
|
+
"GameNotFoundError",
|
|
71
|
+
"ListNotFoundError",
|
|
72
|
+
"ReviewNotFoundError",
|
|
73
|
+
]
|
|
74
|
+
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Taste matching, rating agreements, and recommendation algorithms."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from typing import Optional
|
|
5
|
+
from backloggd.models.game import GameEntry
|
|
6
|
+
from backloggd.models.library import UserLibrary
|
|
7
|
+
from backloggd.models.comparison import MatchComparison, RatingDisagreement
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def calculate_similarity_raw(
|
|
11
|
+
ratings1: dict[str, Optional[float]],
|
|
12
|
+
ratings2: dict[str, Optional[float]],
|
|
13
|
+
k_shrinkage: int = 10,
|
|
14
|
+
) -> tuple[float, float, float]:
|
|
15
|
+
"""Calculate library overlap, rating agreement, and combined similarity score.
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
tuple[float, float, float]: (final_score, library_score, rating_score)
|
|
19
|
+
"""
|
|
20
|
+
if not ratings1 or not ratings2:
|
|
21
|
+
return 0.0, 0.0, 0.0
|
|
22
|
+
|
|
23
|
+
shared_slugs = ratings1.keys() & ratings2.keys()
|
|
24
|
+
if not shared_slugs:
|
|
25
|
+
return 0.0, 0.0, 0.0
|
|
26
|
+
|
|
27
|
+
# 1. Library Overlap (Szymkiewicz-Simpson & Jaccard blend)
|
|
28
|
+
total_unique = len(ratings1.keys() | ratings2.keys())
|
|
29
|
+
jaccard = len(shared_slugs) / total_unique if total_unique > 0 else 0.0
|
|
30
|
+
overlap_coeff = len(shared_slugs) / min(len(ratings1), len(ratings2))
|
|
31
|
+
library_score = 0.5 * jaccard + 0.5 * overlap_coeff # [0.0, 1.0]
|
|
32
|
+
|
|
33
|
+
# 2. Rating Agreement on mutually rated games
|
|
34
|
+
rated_pairs = [
|
|
35
|
+
(ratings1[s], ratings2[s])
|
|
36
|
+
for s in shared_slugs
|
|
37
|
+
if ratings1[s] is not None and ratings2[s] is not None
|
|
38
|
+
]
|
|
39
|
+
n_rated = len(rated_pairs)
|
|
40
|
+
if n_rated == 0:
|
|
41
|
+
return round(library_score * 100, 2), round(library_score * 100, 2), 0.0
|
|
42
|
+
|
|
43
|
+
# Absolute agreement
|
|
44
|
+
mae = sum(abs(r1 - r2) for r1, r2 in rated_pairs) / n_rated
|
|
45
|
+
mae_sim = max(0.0, 1.0 - (mae / 4.5))
|
|
46
|
+
|
|
47
|
+
# Pearson relative correlation
|
|
48
|
+
mean1 = sum(r1 for r1, _ in rated_pairs) / n_rated
|
|
49
|
+
mean2 = sum(r2 for _, r2 in rated_pairs) / n_rated
|
|
50
|
+
diffs1 = [r1 - mean1 for r1, _ in rated_pairs]
|
|
51
|
+
diffs2 = [r2 - mean2 for _, r2 in rated_pairs]
|
|
52
|
+
mag1 = math.sqrt(sum(d * d for d in diffs1))
|
|
53
|
+
mag2 = math.sqrt(sum(d * d for d in diffs2))
|
|
54
|
+
|
|
55
|
+
if mag1 > 0 and mag2 > 0:
|
|
56
|
+
pearson = sum(a * b for a, b in zip(diffs1, diffs2)) / (mag1 * mag2)
|
|
57
|
+
pearson_sim = (pearson + 1.0) / 2.0
|
|
58
|
+
raw_rating_sim = 0.5 * pearson_sim + 0.5 * mae_sim
|
|
59
|
+
else:
|
|
60
|
+
raw_rating_sim = mae_sim
|
|
61
|
+
|
|
62
|
+
# Confidence shrinkage discount
|
|
63
|
+
confidence = n_rated / (n_rated + k_shrinkage)
|
|
64
|
+
rating_score = confidence * raw_rating_sim
|
|
65
|
+
|
|
66
|
+
final_score = (0.30 * library_score + 0.70 * rating_score) * 100
|
|
67
|
+
return (
|
|
68
|
+
round(final_score, 2),
|
|
69
|
+
round(library_score * 100, 2),
|
|
70
|
+
round(rating_score * 100, 2),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def compare_libraries(
|
|
75
|
+
lib1: UserLibrary,
|
|
76
|
+
lib2: UserLibrary,
|
|
77
|
+
k_shrinkage: int = 10,
|
|
78
|
+
recommendation_min_rating: float = 4.0,
|
|
79
|
+
) -> MatchComparison:
|
|
80
|
+
"""Perform full taste comparison between two user libraries."""
|
|
81
|
+
ratings1 = lib1.to_ratings_dict()
|
|
82
|
+
ratings2 = lib2.to_ratings_dict()
|
|
83
|
+
|
|
84
|
+
final_score, lib_score, rate_score = calculate_similarity_raw(
|
|
85
|
+
ratings1, ratings2, k_shrinkage=k_shrinkage
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
shared_slugs = lib1.slugs & lib2.slugs
|
|
89
|
+
mutually_rated_slugs = [
|
|
90
|
+
s for s in shared_slugs
|
|
91
|
+
if ratings1[s] is not None and ratings2[s] is not None
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
# Mutual Favorites (both rated >= 4.0)
|
|
95
|
+
mutual_favorites: list[GameEntry] = []
|
|
96
|
+
for s in mutually_rated_slugs:
|
|
97
|
+
r1, r2 = ratings1[s], ratings2[s]
|
|
98
|
+
if r1 is not None and r2 is not None and r1 >= 4.0 and r2 >= 4.0:
|
|
99
|
+
mutual_favorites.append(lib1[s])
|
|
100
|
+
mutual_favorites.sort(
|
|
101
|
+
key=lambda g: ((ratings1[g.slug] or 0) + (ratings2[g.slug] or 0)),
|
|
102
|
+
reverse=True,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# Disagreements (highest absolute rating difference)
|
|
106
|
+
disagreements: list[RatingDisagreement] = []
|
|
107
|
+
for s in mutually_rated_slugs:
|
|
108
|
+
r1, r2 = ratings1[s], ratings2[s]
|
|
109
|
+
if r1 is not None and r2 is not None:
|
|
110
|
+
diff = abs(r1 - r2)
|
|
111
|
+
if diff >= 1.5: # Significant disagreement
|
|
112
|
+
disagreements.append(
|
|
113
|
+
RatingDisagreement(
|
|
114
|
+
game=lib1[s],
|
|
115
|
+
user1_rating=r1,
|
|
116
|
+
user2_rating=r2,
|
|
117
|
+
difference=round(diff, 2),
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
disagreements.sort(key=lambda d: d.difference, reverse=True)
|
|
121
|
+
|
|
122
|
+
# Recommendations: games User 2 loved (>= 4.0) that User 1 has not rated or logged
|
|
123
|
+
recs_for_user1: list[GameEntry] = [
|
|
124
|
+
g for g in lib2
|
|
125
|
+
if g.rating is not None
|
|
126
|
+
and g.rating >= recommendation_min_rating
|
|
127
|
+
and (g.slug not in lib1 or not lib1[g.slug].is_rated)
|
|
128
|
+
]
|
|
129
|
+
recs_for_user1.sort(key=lambda g: g.rating or 0, reverse=True)
|
|
130
|
+
|
|
131
|
+
# Recommendations: games User 1 loved (>= 4.0) that User 2 has not rated or logged
|
|
132
|
+
recs_for_user2: list[GameEntry] = [
|
|
133
|
+
g for g in lib1
|
|
134
|
+
if g.rating is not None
|
|
135
|
+
and g.rating >= recommendation_min_rating
|
|
136
|
+
and (g.slug not in lib2 or not lib2[g.slug].is_rated)
|
|
137
|
+
]
|
|
138
|
+
recs_for_user2.sort(key=lambda g: g.rating or 0, reverse=True)
|
|
139
|
+
|
|
140
|
+
return MatchComparison(
|
|
141
|
+
user1=lib1.username,
|
|
142
|
+
user2=lib2.username,
|
|
143
|
+
similarity_score=final_score,
|
|
144
|
+
library_score=lib_score,
|
|
145
|
+
rating_score=rate_score,
|
|
146
|
+
shared_games_count=len(shared_slugs),
|
|
147
|
+
mutually_rated_count=len(mutually_rated_slugs),
|
|
148
|
+
mutual_favorites=mutual_favorites,
|
|
149
|
+
disagreements=disagreements,
|
|
150
|
+
recommendations_for_user1=recs_for_user1,
|
|
151
|
+
recommendations_for_user2=recs_for_user2,
|
|
152
|
+
)
|