steamsift 1.0.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.

Potentially problematic release.


This version of steamsift might be problematic. Click here for more details.

steamsift/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """sift — taste-aware Steam deals for you and your Steam Family."""
2
+
3
+ __version__ = "1.0.0"
steamsift/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import entry
2
+
3
+ if __name__ == "__main__":
4
+ entry()
@@ -0,0 +1,106 @@
1
+ """What setup can find on this computer, only when the user agrees: Steam accounts, Steam Family, keys.
2
+
3
+ Nothing is sent anywhere. We read Steam's own text files (config/loginusers.vdf, userdata/*/localconfig.vdf)
4
+ and, while a key step is open, the clipboard, so a key copied from the browser fills itself in.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import re
9
+ import shutil
10
+ import subprocess
11
+ import sys
12
+ from dataclasses import dataclass, field
13
+
14
+ from .localsteam import ci_get, family_from_client, parse_vdf, steam_roots
15
+
16
+ STEAM_KEY = re.compile(r"\b[0-9A-F]{32}\b")
17
+ ITAD_KEY = re.compile(r"\b[0-9a-f]{40}\b")
18
+
19
+
20
+ @dataclass
21
+ class Account:
22
+ steam_id: str
23
+ persona: str
24
+ recent: bool = False
25
+ stamp: int = 0
26
+
27
+
28
+ @dataclass
29
+ class Found:
30
+ steam_root: str = ""
31
+ accounts: list[Account] = field(default_factory=list)
32
+ family: dict = field(default_factory=dict)
33
+
34
+ @property
35
+ def account(self) -> Account | None:
36
+ return self.accounts[0] if self.accounts else None
37
+
38
+
39
+ def accounts() -> list[Account]:
40
+ """Accounts that signed in to Steam on this PC, the one used last first."""
41
+ out: dict[str, Account] = {}
42
+ for root in steam_roots():
43
+ path = root / "config" / "loginusers.vdf"
44
+ try:
45
+ users = ci_get(parse_vdf(path.read_text(encoding="utf-8", errors="ignore")), "users") or {}
46
+ except OSError:
47
+ continue
48
+ for sid, u in users.items() if isinstance(users, dict) else []:
49
+ if not (isinstance(u, dict) and str(sid).isdigit() and len(str(sid)) == 17):
50
+ continue
51
+ stamp = ci_get(u, "Timestamp") or "0"
52
+ out.setdefault(sid, Account(sid, ci_get(u, "PersonaName") or "", ci_get(u, "MostRecent") == "1",
53
+ int(stamp) if str(stamp).isdigit() else 0))
54
+ return sorted(out.values(), key=lambda a: (not a.recent, -a.stamp))
55
+
56
+
57
+ def scan() -> Found:
58
+ roots = steam_roots()
59
+ found = Found(steam_root=str(roots[0]) if roots else "", accounts=accounts())
60
+ if found.account:
61
+ found.family = family_from_client(found.account.steam_id)
62
+ return found
63
+
64
+
65
+ def clipboard_text() -> str:
66
+ """The clipboard as text, or '' when it can't be read (no display, no tool, not text)."""
67
+ try:
68
+ if sys.platform == "win32":
69
+ return _windows_clipboard()
70
+ for cmd in (["pbpaste"], ["wl-paste", "--no-newline"], ["xclip", "-selection", "clipboard", "-o"],
71
+ ["xsel", "--clipboard", "--output"]):
72
+ if shutil.which(cmd[0]):
73
+ return subprocess.run(cmd, capture_output=True, text=True, timeout=2).stdout
74
+ except (OSError, subprocess.SubprocessError, ValueError):
75
+ pass
76
+ return ""
77
+
78
+
79
+ def _windows_clipboard() -> str:
80
+ import ctypes
81
+ from ctypes import wintypes
82
+ user32, kernel32 = ctypes.windll.user32, ctypes.windll.kernel32
83
+ user32.GetClipboardData.restype = wintypes.HANDLE
84
+ kernel32.GlobalLock.argtypes = [wintypes.HGLOBAL]
85
+ kernel32.GlobalLock.restype = wintypes.LPVOID
86
+ kernel32.GlobalUnlock.argtypes = [wintypes.HGLOBAL]
87
+ CF_UNICODETEXT = 13
88
+ if not user32.OpenClipboard(None):
89
+ return ""
90
+ try:
91
+ handle = user32.GetClipboardData(CF_UNICODETEXT)
92
+ if not handle:
93
+ return ""
94
+ ptr = kernel32.GlobalLock(handle)
95
+ try:
96
+ return ctypes.wstring_at(ptr) if ptr else ""
97
+ finally:
98
+ kernel32.GlobalUnlock(handle)
99
+ finally:
100
+ user32.CloseClipboard()
101
+
102
+
103
+ def key_in(text: str, kind: str) -> str:
104
+ """The first Steam (32 hex, upper case) or ITAD (40 hex, lower case) key in `text`."""
105
+ m = (STEAM_KEY if kind == "steam" else ITAD_KEY).search(text or "")
106
+ return m.group(0) if m else ""
steamsift/calendar.py ADDED
@@ -0,0 +1,145 @@
1
+ """Steam sale calendar, parsed from Valve's public partner page (seasonal sales + themed fests).
2
+
3
+ Used for "wait — the Summer Sale starts in 9 days" hints and for fests matching your taste.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import datetime as dt
8
+ import html as htmllib
9
+ import re
10
+ from dataclasses import dataclass, field
11
+
12
+ from .db import DAY, Cache
13
+ from .net import SESSION
14
+
15
+ URL = "https://partner.steamgames.com/doc/marketing/upcoming_events"
16
+ _cache = Cache("calendar")
17
+
18
+ # Themed fests -> the Steam tags whose games usually get discounted during them.
19
+ FEST_TAGS: dict[str, list[str]] = {
20
+ "Racing Fest": ["Racing", "Driving", "Automobile Sim"],
21
+ "Steam Scream": ["Horror", "Psychological Horror", "Survival Horror"],
22
+ "Real-Time Strategy Fest": ["RTS", "Strategy"],
23
+ "Fighting Fest": ["Fighting", "2D Fighter", "3D Fighter", "Beat 'em up"],
24
+ "Rhythm Fest": ["Rhythm", "Music"],
25
+ "Couch Co-Op Fest": ["Local Co-Op", "Local Multiplayer"],
26
+ "Cooking Fest": ["Cooking"],
27
+ "Shop Keeper Fest": ["Management", "Economy"],
28
+ "Dinos vs. Robots Fest": ["Dinosaurs", "Robots", "Mechs"],
29
+ "Witch Fest": ["Magic", "Witch"],
30
+ "Mountaineering Fest": ["Climbing"],
31
+ "Medieval Fest": ["Medieval"],
32
+ "Auto-Battler RPG Fest": ["Auto Battler"],
33
+ "Survival Fest": ["Survival"],
34
+ "Open World Survival Craft Fest": ["Open World Survival Craft"],
35
+ "Simulation Fest": ["Simulation"],
36
+ "Puzzle Fest": ["Puzzle"],
37
+ "Sports Fest": ["Sports"],
38
+ "Strategy Fest": ["Strategy"],
39
+ }
40
+
41
+ _MONTHS = {m: i for i, m in enumerate(
42
+ ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"], 1)}
43
+
44
+
45
+ @dataclass
46
+ class Event:
47
+ name: str
48
+ kind: str # "sale" (seasonal, store-wide) | "fest" (themed)
49
+ start: dt.date
50
+ end: dt.date
51
+ tags: list[str] = field(default_factory=list)
52
+
53
+ def to_json(self) -> dict:
54
+ return {"name": self.name, "kind": self.kind, "start": self.start.isoformat(),
55
+ "end": self.end.isoformat(), "tags": self.tags}
56
+
57
+ @classmethod
58
+ def from_json(cls, d: dict) -> "Event":
59
+ return cls(d["name"], d["kind"], dt.date.fromisoformat(d["start"]), dt.date.fromisoformat(d["end"]),
60
+ d.get("tags", []))
61
+
62
+
63
+ def _date(text: str) -> dt.date | None:
64
+ m = re.match(r"(\d{1,2})\s+([A-Za-z]+),?\s+(\d{4})", text.strip())
65
+ if not m:
66
+ return None
67
+ month = _MONTHS.get(m.group(2)[:3].lower())
68
+ return dt.date(int(m.group(3)), month, int(m.group(1))) if month else None
69
+
70
+
71
+ def _plain(fragment: str) -> str:
72
+ return re.sub(r"\s+", " ", htmllib.unescape(re.sub(r"<[^>]+>", " ", fragment))).strip()
73
+
74
+
75
+ def parse(page: str) -> list[Event]:
76
+ events: list[Event] = []
77
+ text = _plain(page)
78
+ for m in re.finditer(r"(Spring|Summer|Autumn|Winter) Sale (\d{4})\s*\|?\s*(\d{1,2} [A-Za-z]+,? \d{4})\s*[-–]\s*"
79
+ r"(\d{1,2} [A-Za-z]+,? \d{4})", text):
80
+ start, end = _date(m.group(3)), _date(m.group(4))
81
+ if start and end:
82
+ events.append(Event(f"Steam {m.group(1)} Sale", "sale", start, end))
83
+
84
+ # Themed fests live in tables that follow "<year> Fests" headings.
85
+ year = None
86
+ for m in re.finditer(r"(20\d\d) Fests|<tr[^>]*>(.*?)</tr>", page, re.S):
87
+ if m.group(1):
88
+ year = int(m.group(1))
89
+ continue
90
+ if year is None:
91
+ continue
92
+ cells = [_plain(c) for c in re.findall(r"<t[dh][^>]*>(.*?)</t[dh]>", m.group(2), re.S)]
93
+ if len(cells) < 2:
94
+ continue
95
+ d = re.match(r"([A-Za-z]{3})[a-z]*\.? (\d{1,2})\s*[-–]?\s*(?:([A-Za-z]{3})[a-z]*\.? )?(\d{1,2})$", cells[0])
96
+ if not d:
97
+ continue
98
+ m1, d1 = _MONTHS.get(d.group(1).lower()), int(d.group(2))
99
+ m2, d2 = _MONTHS.get((d.group(3) or d.group(1)).lower()), int(d.group(4))
100
+ if not m1 or not m2:
101
+ continue
102
+ start = dt.date(year, m1, d1)
103
+ end = dt.date(year + (1 if m2 < m1 else 0), m2, d2)
104
+ name = cells[1]
105
+ tags = next((v for k, v in FEST_TAGS.items() if k.lower() in name.lower()), [])
106
+ events.append(Event(name, "fest", start, end, tags))
107
+
108
+ uniq = {(e.name, e.start): e for e in events}
109
+ return sorted(uniq.values(), key=lambda e: e.start)
110
+
111
+
112
+ def events() -> list[Event]:
113
+ cached = _cache.get("events", 7 * DAY)
114
+ if cached is not None:
115
+ return [Event.from_json(e) for e in cached]
116
+ try:
117
+ r = SESSION.get(URL, timeout=15)
118
+ evs = parse(r.text) if r.status_code == 200 else []
119
+ except Exception: # noqa: BLE001 - the calendar is a nice-to-have
120
+ evs = []
121
+ if evs:
122
+ _cache.set("events", [e.to_json() for e in evs])
123
+ return evs
124
+
125
+
126
+ def current_sale(today: dt.date | None = None, evs: list[Event] | None = None) -> Event | None:
127
+ today = today or dt.date.today()
128
+ return next((e for e in (evs if evs is not None else events())
129
+ if e.kind == "sale" and e.start <= today <= e.end), None)
130
+
131
+
132
+ def next_sale(today: dt.date | None = None, within_days: int = 45, evs: list[Event] | None = None) -> Event | None:
133
+ today = today or dt.date.today()
134
+ return next((e for e in (evs if evs is not None else events())
135
+ if e.kind == "sale" and today < e.start <= today + dt.timedelta(days=within_days)), None)
136
+
137
+
138
+ def fests_for(taste_tags: list[str], today: dt.date | None = None, within_days: int = 60,
139
+ evs: list[Event] | None = None) -> list[Event]:
140
+ """Upcoming or running themed fests whose tags overlap your strongest taste tags."""
141
+ today = today or dt.date.today()
142
+ want = {t.lower() for t in taste_tags}
143
+ return [e for e in (evs if evs is not None else events())
144
+ if e.kind == "fest" and e.end >= today and e.start <= today + dt.timedelta(days=within_days)
145
+ and want & {t.lower() for t in e.tags}]