steamsift 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.
- steamsift/__init__.py +3 -0
- steamsift/__main__.py +4 -0
- steamsift/autodetect.py +106 -0
- steamsift/calendar.py +145 -0
- steamsift/cli.py +908 -0
- steamsift/config.py +391 -0
- steamsift/data/regions.json +2475 -0
- steamsift/db.py +199 -0
- steamsift/discover.py +226 -0
- steamsift/doctor.py +173 -0
- steamsift/engine.py +411 -0
- steamsift/i18n.py +929 -0
- steamsift/itad.py +455 -0
- steamsift/localsteam.py +162 -0
- steamsift/net.py +108 -0
- steamsift/notify/__init__.py +1 -0
- steamsift/notify/cards.py +202 -0
- steamsift/notify/channels.py +153 -0
- steamsift/notify/events.py +149 -0
- steamsift/notify/runner.py +239 -0
- steamsift/notify/telegram.py +215 -0
- steamsift/prefs.py +111 -0
- steamsift/presentation.py +36 -0
- steamsift/query.py +445 -0
- steamsift/recommender.py +455 -0
- steamsift/region.py +152 -0
- steamsift/schedule.py +315 -0
- steamsift/settings_registry.py +288 -0
- steamsift/steam.py +584 -0
- steamsift/steamid.py +79 -0
- steamsift/tui/__init__.py +4 -0
- steamsift/tui/app.py +1180 -0
- steamsift/tui/cover.py +70 -0
- steamsift/tui/theme.py +47 -0
- steamsift/tui/wizard.py +769 -0
- steamsift/ui.py +503 -0
- steamsift-0.1.0.dist-info/METADATA +158 -0
- steamsift-0.1.0.dist-info/RECORD +42 -0
- steamsift-0.1.0.dist-info/WHEEL +5 -0
- steamsift-0.1.0.dist-info/entry_points.txt +4 -0
- steamsift-0.1.0.dist-info/licenses/LICENSE +21 -0
- steamsift-0.1.0.dist-info/top_level.txt +1 -0
steamsift/__init__.py
ADDED
steamsift/__main__.py
ADDED
steamsift/autodetect.py
ADDED
|
@@ -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}]
|