steamsift 1.0.0__tar.gz
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-1.0.0/LICENSE +21 -0
- steamsift-1.0.0/PKG-INFO +156 -0
- steamsift-1.0.0/README.md +113 -0
- steamsift-1.0.0/pyproject.toml +63 -0
- steamsift-1.0.0/setup.cfg +4 -0
- steamsift-1.0.0/steamsift/__init__.py +3 -0
- steamsift-1.0.0/steamsift/__main__.py +4 -0
- steamsift-1.0.0/steamsift/autodetect.py +106 -0
- steamsift-1.0.0/steamsift/calendar.py +145 -0
- steamsift-1.0.0/steamsift/cli.py +908 -0
- steamsift-1.0.0/steamsift/config.py +391 -0
- steamsift-1.0.0/steamsift/data/regions.json +2475 -0
- steamsift-1.0.0/steamsift/db.py +199 -0
- steamsift-1.0.0/steamsift/discover.py +226 -0
- steamsift-1.0.0/steamsift/doctor.py +173 -0
- steamsift-1.0.0/steamsift/engine.py +411 -0
- steamsift-1.0.0/steamsift/i18n.py +929 -0
- steamsift-1.0.0/steamsift/itad.py +455 -0
- steamsift-1.0.0/steamsift/localsteam.py +162 -0
- steamsift-1.0.0/steamsift/net.py +108 -0
- steamsift-1.0.0/steamsift/notify/__init__.py +1 -0
- steamsift-1.0.0/steamsift/notify/cards.py +202 -0
- steamsift-1.0.0/steamsift/notify/channels.py +153 -0
- steamsift-1.0.0/steamsift/notify/events.py +149 -0
- steamsift-1.0.0/steamsift/notify/runner.py +239 -0
- steamsift-1.0.0/steamsift/notify/telegram.py +215 -0
- steamsift-1.0.0/steamsift/prefs.py +111 -0
- steamsift-1.0.0/steamsift/presentation.py +36 -0
- steamsift-1.0.0/steamsift/query.py +445 -0
- steamsift-1.0.0/steamsift/recommender.py +455 -0
- steamsift-1.0.0/steamsift/region.py +152 -0
- steamsift-1.0.0/steamsift/schedule.py +315 -0
- steamsift-1.0.0/steamsift/settings_registry.py +288 -0
- steamsift-1.0.0/steamsift/steam.py +584 -0
- steamsift-1.0.0/steamsift/steamid.py +79 -0
- steamsift-1.0.0/steamsift/tui/__init__.py +4 -0
- steamsift-1.0.0/steamsift/tui/app.py +1180 -0
- steamsift-1.0.0/steamsift/tui/cover.py +70 -0
- steamsift-1.0.0/steamsift/tui/theme.py +47 -0
- steamsift-1.0.0/steamsift/tui/wizard.py +769 -0
- steamsift-1.0.0/steamsift/ui.py +503 -0
- steamsift-1.0.0/steamsift.egg-info/PKG-INFO +156 -0
- steamsift-1.0.0/steamsift.egg-info/SOURCES.txt +62 -0
- steamsift-1.0.0/steamsift.egg-info/dependency_links.txt +1 -0
- steamsift-1.0.0/steamsift.egg-info/entry_points.txt +4 -0
- steamsift-1.0.0/steamsift.egg-info/requires.txt +15 -0
- steamsift-1.0.0/steamsift.egg-info/top_level.txt +1 -0
- steamsift-1.0.0/tests/test_channels.py +240 -0
- steamsift-1.0.0/tests/test_config_i18n.py +63 -0
- steamsift-1.0.0/tests/test_discover.py +125 -0
- steamsift-1.0.0/tests/test_events.py +161 -0
- steamsift-1.0.0/tests/test_filters.py +113 -0
- steamsift-1.0.0/tests/test_history.py +352 -0
- steamsift-1.0.0/tests/test_items.py +86 -0
- steamsift-1.0.0/tests/test_learning.py +68 -0
- steamsift-1.0.0/tests/test_packaging.py +84 -0
- steamsift-1.0.0/tests/test_query.py +209 -0
- steamsift-1.0.0/tests/test_recommender.py +129 -0
- steamsift-1.0.0/tests/test_region.py +71 -0
- steamsift-1.0.0/tests/test_schedule.py +72 -0
- steamsift-1.0.0/tests/test_smarter.py +93 -0
- steamsift-1.0.0/tests/test_steamid_localsteam.py +78 -0
- steamsift-1.0.0/tests/test_tui.py +454 -0
- steamsift-1.0.0/tests/test_wizard.py +215 -0
steamsift-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 sift contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
steamsift-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: steamsift
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Steam deals, sifted: every discount checked against two years of prices. HOT, GREAT, OK, MEH or NOPE.
|
|
5
|
+
Author: sa1ntsinner
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://sa1ntsinner.github.io/sift/
|
|
8
|
+
Project-URL: Source, https://github.com/sa1ntsinner/sift
|
|
9
|
+
Project-URL: Issues, https://github.com/sa1ntsinner/sift/issues
|
|
10
|
+
Keywords: steam,deals,sale,wishlist,recommendations,telegram,cli,tui
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
14
|
+
Classifier: Natural Language :: English
|
|
15
|
+
Classifier: Natural Language :: Russian
|
|
16
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
17
|
+
Classifier: Operating System :: MacOS
|
|
18
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
19
|
+
Classifier: Programming Language :: Python :: 3
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
24
|
+
Classifier: Topic :: Games/Entertainment
|
|
25
|
+
Requires-Python: >=3.11
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
License-File: LICENSE
|
|
28
|
+
Requires-Dist: rich>=13.0.0
|
|
29
|
+
Requires-Dist: requests>=2.28.0
|
|
30
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
31
|
+
Requires-Dist: platformdirs>=4.0
|
|
32
|
+
Requires-Dist: keyring>=24.0
|
|
33
|
+
Requires-Dist: tomli-w>=1.0
|
|
34
|
+
Requires-Dist: textual>=1.0
|
|
35
|
+
Requires-Dist: textual-image>=0.8
|
|
36
|
+
Requires-Dist: pillow>=10.0
|
|
37
|
+
Provides-Extra: dev
|
|
38
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
39
|
+
Requires-Dist: pyyaml>=6; extra == "dev"
|
|
40
|
+
Requires-Dist: build; extra == "dev"
|
|
41
|
+
Requires-Dist: twine; extra == "dev"
|
|
42
|
+
Dynamic: license-file
|
|
43
|
+
|
|
44
|
+
<p align="center">
|
|
45
|
+
<img src="https://raw.githubusercontent.com/sa1ntsinner/sift/main/assets/sift-wordmark.png" alt="sift" width="200" />
|
|
46
|
+
</p>
|
|
47
|
+
|
|
48
|
+
<p align="center"><em>Steam has thousands of discounts. sift tells you which ones are worth it.</em></p>
|
|
49
|
+
|
|
50
|
+
<p align="center">
|
|
51
|
+
<a href="https://sa1ntsinner.github.io/sift/"><img src="https://img.shields.io/badge/website-sift-2bff88?style=flat" alt="Website" /></a>
|
|
52
|
+
|
|
53
|
+
<a href="https://github.com/sa1ntsinner/sift/releases/latest"><img src="https://img.shields.io/github/v/release/sa1ntsinner/sift?style=flat&color=24292f" alt="Latest release" /></a>
|
|
54
|
+
|
|
55
|
+
<img src="https://img.shields.io/badge/Windows%20·%20macOS%20·%20Linux-24292f?style=flat" alt="Windows, macOS, Linux" />
|
|
56
|
+
|
|
57
|
+
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-24292f?style=flat" alt="MIT license" /></a>
|
|
58
|
+
</p>
|
|
59
|
+
|
|
60
|
+
<p align="center">
|
|
61
|
+
<img src="https://raw.githubusercontent.com/sa1ntsinner/sift/main/assets/sift.gif" alt="sift in a terminal: a list of Steam deals with a verdict on each, the filter panel and the wishlist" width="100%" />
|
|
62
|
+
</p>
|
|
63
|
+
|
|
64
|
+
sift is a free terminal app for Steam deals. It reads two years of Steam prices for every discount, checks how often
|
|
65
|
+
the game goes on sale, whether the base price was just raised, whether a big sale is days away and what your Steam
|
|
66
|
+
Family already owns, and gives each deal one word.
|
|
67
|
+
|
|
68
|
+
Most "record lows" on Steam come back every five or six weeks. sift tells those apart from the prices that rarely
|
|
69
|
+
show up, so you buy the right game at the right moment and skip the rest.
|
|
70
|
+
|
|
71
|
+
## Verdicts
|
|
72
|
+
|
|
73
|
+
| | |
|
|
74
|
+
|---|---|
|
|
75
|
+
| **HOT** | A price that rarely comes back: a rare record, the lowest in two years, or a cheaper bundle. |
|
|
76
|
+
| **GREAT** | A record price, but it returns every few weeks. Buy it when you will play it. |
|
|
77
|
+
| **OK** | An ordinary discount for this game. |
|
|
78
|
+
| **MEH** | It has been 12–30% cheaper, or a big Steam sale is under three weeks away. |
|
|
79
|
+
| **NOPE** | It has been much cheaper, or the base price went up just before the sale. |
|
|
80
|
+
|
|
81
|
+
Prices are compared in what you pay, not in percent: $15 at −60% is not a record if the game once sold for $5.
|
|
82
|
+
|
|
83
|
+
## Install
|
|
84
|
+
|
|
85
|
+
**Download** one file from the [latest release](https://github.com/sa1ntsinner/sift/releases/latest):
|
|
86
|
+
`sift-windows-x64.exe`, `sift-macos-arm64` or `sift-linux-x64`. Nothing else to install.
|
|
87
|
+
|
|
88
|
+
**Or with Python 3.11+**
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
uv tool install git+https://github.com/sa1ntsinner/sift # or: pipx install git+https://github.com/sa1ntsinner/sift
|
|
92
|
+
sift
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## First run
|
|
96
|
+
|
|
97
|
+
`sift` opens a three-minute setup. It can look on your computer for your Steam account and Steam Family (only Steam's
|
|
98
|
+
own files, nothing is sent anywhere). You paste two free keys, and sift picks them up from the clipboard as soon as you
|
|
99
|
+
copy them:
|
|
100
|
+
|
|
101
|
+
| | where | |
|
|
102
|
+
|---|---|---|
|
|
103
|
+
| Steam Web API key | [steamcommunity.com/dev/apikey](https://steamcommunity.com/dev/apikey) | domain: `localhost` · confirm in the Steam mobile app |
|
|
104
|
+
| IsThereAnyDeal API key | [isthereanydeal.com/apps/my](https://isthereanydeal.com/apps/my/) | Register app → copy the API key · optional, but it powers the verdicts |
|
|
105
|
+
|
|
106
|
+
Steam only issues API keys to accounts that have spent at least $5 and use Steam Guard Mobile.
|
|
107
|
+
|
|
108
|
+
## Using it
|
|
109
|
+
|
|
110
|
+
The app has four tabs (Deals · Wishlist · Discover · DLC), a verdict on every row and the game's two-year sale history
|
|
111
|
+
next to it. Everything works from the keyboard:
|
|
112
|
+
|
|
113
|
+
| key | | key | |
|
|
114
|
+
|---|---|---|---|
|
|
115
|
+
| `↑ ↓` `j k` | move | `/` | search |
|
|
116
|
+
| `enter` | open in Steam | `f` | filters, saved filters, hidden games |
|
|
117
|
+
| `1–4` `← →` | tabs | `s` | sort |
|
|
118
|
+
| `x` | hide: not for me | `u` | undo |
|
|
119
|
+
| `i` | price history on IsThereAnyDeal | `?` | every key |
|
|
120
|
+
|
|
121
|
+
The same filters work as flags, so a search can be one line:
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
sift --hot -b 10 # only HOT deals under 10 in your store's currency
|
|
125
|
+
sift -g roguelike --deck # roguelikes that run on the Steam Deck
|
|
126
|
+
sift macro add cheap -b 2 --lowest && sift cheap
|
|
127
|
+
sift info "hollow knight" # price, history and verdict for one game
|
|
128
|
+
sift --print # a plain table for scripts; sift -J prints JSON
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Alerts
|
|
132
|
+
|
|
133
|
+
`sift notify setup telegram` (or `discord`, `ntfy`, `desktop`, `email`) sends a few cards a day: HOT prices, your own
|
|
134
|
+
price targets and wishlist records that are about to end. Never the whole sale.
|
|
135
|
+
|
|
136
|
+
Run it on a schedule with `sift notify enable`, keep it running with `sift watch`, or run it in the cloud with
|
|
137
|
+
[GitHub Actions](packaging/github-actions/sift.yml) or [Docker](compose.yaml) while your PC is off.
|
|
138
|
+
|
|
139
|
+
## What it knows
|
|
140
|
+
|
|
141
|
+
- **Your store.** Prices in the currency Steam charges you, in whichever Steam region your account is in.
|
|
142
|
+
- **Your Steam Family.** Shared games stay out of the list, or show with a blue mark if you want them.
|
|
143
|
+
- **Your library.** Owned and hidden games never show up; DLC is listed for the games you own.
|
|
144
|
+
- **Your taste, second.** Deals come first; your play history breaks ties and fills the Discover tab.
|
|
145
|
+
|
|
146
|
+
Everything stays on your computer: keys in the system keychain, settings in a local file. sift talks only to Steam and
|
|
147
|
+
IsThereAnyDeal.
|
|
148
|
+
|
|
149
|
+
## More
|
|
150
|
+
|
|
151
|
+
`sift doctor` checks keys, privacy settings, region and family. `sift config help` lists every setting.
|
|
152
|
+
`sift calendar` shows the Steam sales ahead. The app speaks English and Russian (`sift config set lang ru`).
|
|
153
|
+
|
|
154
|
+
---
|
|
155
|
+
|
|
156
|
+
<p align="center"><sub>MIT license · not affiliated with Valve · price history from <a href="https://isthereanydeal.com">IsThereAnyDeal</a></sub></p>
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/sa1ntsinner/sift/main/assets/sift-wordmark.png" alt="sift" width="200" />
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<p align="center"><em>Steam has thousands of discounts. sift tells you which ones are worth it.</em></p>
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
<a href="https://sa1ntsinner.github.io/sift/"><img src="https://img.shields.io/badge/website-sift-2bff88?style=flat" alt="Website" /></a>
|
|
9
|
+
|
|
10
|
+
<a href="https://github.com/sa1ntsinner/sift/releases/latest"><img src="https://img.shields.io/github/v/release/sa1ntsinner/sift?style=flat&color=24292f" alt="Latest release" /></a>
|
|
11
|
+
|
|
12
|
+
<img src="https://img.shields.io/badge/Windows%20·%20macOS%20·%20Linux-24292f?style=flat" alt="Windows, macOS, Linux" />
|
|
13
|
+
|
|
14
|
+
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-24292f?style=flat" alt="MIT license" /></a>
|
|
15
|
+
</p>
|
|
16
|
+
|
|
17
|
+
<p align="center">
|
|
18
|
+
<img src="https://raw.githubusercontent.com/sa1ntsinner/sift/main/assets/sift.gif" alt="sift in a terminal: a list of Steam deals with a verdict on each, the filter panel and the wishlist" width="100%" />
|
|
19
|
+
</p>
|
|
20
|
+
|
|
21
|
+
sift is a free terminal app for Steam deals. It reads two years of Steam prices for every discount, checks how often
|
|
22
|
+
the game goes on sale, whether the base price was just raised, whether a big sale is days away and what your Steam
|
|
23
|
+
Family already owns, and gives each deal one word.
|
|
24
|
+
|
|
25
|
+
Most "record lows" on Steam come back every five or six weeks. sift tells those apart from the prices that rarely
|
|
26
|
+
show up, so you buy the right game at the right moment and skip the rest.
|
|
27
|
+
|
|
28
|
+
## Verdicts
|
|
29
|
+
|
|
30
|
+
| | |
|
|
31
|
+
|---|---|
|
|
32
|
+
| **HOT** | A price that rarely comes back: a rare record, the lowest in two years, or a cheaper bundle. |
|
|
33
|
+
| **GREAT** | A record price, but it returns every few weeks. Buy it when you will play it. |
|
|
34
|
+
| **OK** | An ordinary discount for this game. |
|
|
35
|
+
| **MEH** | It has been 12–30% cheaper, or a big Steam sale is under three weeks away. |
|
|
36
|
+
| **NOPE** | It has been much cheaper, or the base price went up just before the sale. |
|
|
37
|
+
|
|
38
|
+
Prices are compared in what you pay, not in percent: $15 at −60% is not a record if the game once sold for $5.
|
|
39
|
+
|
|
40
|
+
## Install
|
|
41
|
+
|
|
42
|
+
**Download** one file from the [latest release](https://github.com/sa1ntsinner/sift/releases/latest):
|
|
43
|
+
`sift-windows-x64.exe`, `sift-macos-arm64` or `sift-linux-x64`. Nothing else to install.
|
|
44
|
+
|
|
45
|
+
**Or with Python 3.11+**
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
uv tool install git+https://github.com/sa1ntsinner/sift # or: pipx install git+https://github.com/sa1ntsinner/sift
|
|
49
|
+
sift
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## First run
|
|
53
|
+
|
|
54
|
+
`sift` opens a three-minute setup. It can look on your computer for your Steam account and Steam Family (only Steam's
|
|
55
|
+
own files, nothing is sent anywhere). You paste two free keys, and sift picks them up from the clipboard as soon as you
|
|
56
|
+
copy them:
|
|
57
|
+
|
|
58
|
+
| | where | |
|
|
59
|
+
|---|---|---|
|
|
60
|
+
| Steam Web API key | [steamcommunity.com/dev/apikey](https://steamcommunity.com/dev/apikey) | domain: `localhost` · confirm in the Steam mobile app |
|
|
61
|
+
| IsThereAnyDeal API key | [isthereanydeal.com/apps/my](https://isthereanydeal.com/apps/my/) | Register app → copy the API key · optional, but it powers the verdicts |
|
|
62
|
+
|
|
63
|
+
Steam only issues API keys to accounts that have spent at least $5 and use Steam Guard Mobile.
|
|
64
|
+
|
|
65
|
+
## Using it
|
|
66
|
+
|
|
67
|
+
The app has four tabs (Deals · Wishlist · Discover · DLC), a verdict on every row and the game's two-year sale history
|
|
68
|
+
next to it. Everything works from the keyboard:
|
|
69
|
+
|
|
70
|
+
| key | | key | |
|
|
71
|
+
|---|---|---|---|
|
|
72
|
+
| `↑ ↓` `j k` | move | `/` | search |
|
|
73
|
+
| `enter` | open in Steam | `f` | filters, saved filters, hidden games |
|
|
74
|
+
| `1–4` `← →` | tabs | `s` | sort |
|
|
75
|
+
| `x` | hide: not for me | `u` | undo |
|
|
76
|
+
| `i` | price history on IsThereAnyDeal | `?` | every key |
|
|
77
|
+
|
|
78
|
+
The same filters work as flags, so a search can be one line:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
sift --hot -b 10 # only HOT deals under 10 in your store's currency
|
|
82
|
+
sift -g roguelike --deck # roguelikes that run on the Steam Deck
|
|
83
|
+
sift macro add cheap -b 2 --lowest && sift cheap
|
|
84
|
+
sift info "hollow knight" # price, history and verdict for one game
|
|
85
|
+
sift --print # a plain table for scripts; sift -J prints JSON
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Alerts
|
|
89
|
+
|
|
90
|
+
`sift notify setup telegram` (or `discord`, `ntfy`, `desktop`, `email`) sends a few cards a day: HOT prices, your own
|
|
91
|
+
price targets and wishlist records that are about to end. Never the whole sale.
|
|
92
|
+
|
|
93
|
+
Run it on a schedule with `sift notify enable`, keep it running with `sift watch`, or run it in the cloud with
|
|
94
|
+
[GitHub Actions](packaging/github-actions/sift.yml) or [Docker](compose.yaml) while your PC is off.
|
|
95
|
+
|
|
96
|
+
## What it knows
|
|
97
|
+
|
|
98
|
+
- **Your store.** Prices in the currency Steam charges you, in whichever Steam region your account is in.
|
|
99
|
+
- **Your Steam Family.** Shared games stay out of the list, or show with a blue mark if you want them.
|
|
100
|
+
- **Your library.** Owned and hidden games never show up; DLC is listed for the games you own.
|
|
101
|
+
- **Your taste, second.** Deals come first; your play history breaks ties and fills the Discover tab.
|
|
102
|
+
|
|
103
|
+
Everything stays on your computer: keys in the system keychain, settings in a local file. sift talks only to Steam and
|
|
104
|
+
IsThereAnyDeal.
|
|
105
|
+
|
|
106
|
+
## More
|
|
107
|
+
|
|
108
|
+
`sift doctor` checks keys, privacy settings, region and family. `sift config help` lists every setting.
|
|
109
|
+
`sift calendar` shows the Steam sales ahead. The app speaks English and Russian (`sift config set lang ru`).
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
<p align="center"><sub>MIT license · not affiliated with Valve · price history from <a href="https://isthereanydeal.com">IsThereAnyDeal</a></sub></p>
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=69.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "steamsift"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Steam deals, sifted: every discount checked against two years of prices. HOT, GREAT, OK, MEH or NOPE."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "sa1ntsinner" }]
|
|
14
|
+
keywords = ["steam", "deals", "sale", "wishlist", "recommendations", "telegram", "cli", "tui"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Environment :: Console",
|
|
18
|
+
"Intended Audience :: End Users/Desktop",
|
|
19
|
+
"Natural Language :: English",
|
|
20
|
+
"Natural Language :: Russian",
|
|
21
|
+
"Operating System :: Microsoft :: Windows",
|
|
22
|
+
"Operating System :: MacOS",
|
|
23
|
+
"Operating System :: POSIX :: Linux",
|
|
24
|
+
"Programming Language :: Python :: 3",
|
|
25
|
+
"Programming Language :: Python :: 3.11",
|
|
26
|
+
"Programming Language :: Python :: 3.12",
|
|
27
|
+
"Programming Language :: Python :: 3.13",
|
|
28
|
+
"Programming Language :: Python :: 3.14",
|
|
29
|
+
"Topic :: Games/Entertainment",
|
|
30
|
+
]
|
|
31
|
+
dependencies = [
|
|
32
|
+
"rich>=13.0.0",
|
|
33
|
+
"requests>=2.28.0",
|
|
34
|
+
"python-dotenv>=1.0.0",
|
|
35
|
+
"platformdirs>=4.0",
|
|
36
|
+
"keyring>=24.0",
|
|
37
|
+
"tomli-w>=1.0",
|
|
38
|
+
"textual>=1.0",
|
|
39
|
+
"textual-image>=0.8",
|
|
40
|
+
"pillow>=10.0",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
[project.optional-dependencies]
|
|
44
|
+
dev = ["pytest>=8", "pyyaml>=6", "build", "twine"]
|
|
45
|
+
|
|
46
|
+
[project.urls]
|
|
47
|
+
Homepage = "https://sa1ntsinner.github.io/sift/"
|
|
48
|
+
Source = "https://github.com/sa1ntsinner/sift"
|
|
49
|
+
Issues = "https://github.com/sa1ntsinner/sift/issues"
|
|
50
|
+
|
|
51
|
+
[project.scripts]
|
|
52
|
+
sift = "steamsift.cli:entry"
|
|
53
|
+
steamsift = "steamsift.cli:entry"
|
|
54
|
+
ss = "steamsift.cli:entry"
|
|
55
|
+
|
|
56
|
+
[tool.setuptools]
|
|
57
|
+
packages = ["steamsift", "steamsift.notify", "steamsift.tui"]
|
|
58
|
+
|
|
59
|
+
[tool.setuptools.package-data]
|
|
60
|
+
steamsift = ["data/*.json"]
|
|
61
|
+
|
|
62
|
+
[tool.pytest.ini_options]
|
|
63
|
+
testpaths = ["tests"]
|
|
@@ -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 ""
|
|
@@ -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}]
|