fifa-panini 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.
- fifa_panini-1.0.0/LICENSE +21 -0
- fifa_panini-1.0.0/PKG-INFO +66 -0
- fifa_panini-1.0.0/README.md +52 -0
- fifa_panini-1.0.0/fifa_panini/__init__.py +19 -0
- fifa_panini-1.0.0/fifa_panini/__main__.py +9 -0
- fifa_panini-1.0.0/fifa_panini/cli.py +26 -0
- fifa_panini-1.0.0/fifa_panini/commands/__init__.py +1 -0
- fifa_panini-1.0.0/fifa_panini/commands/config.py +9 -0
- fifa_panini-1.0.0/fifa_panini/commands/daily_play.py +128 -0
- fifa_panini-1.0.0/fifa_panini/commands/panini/__init__.py +36 -0
- fifa_panini-1.0.0/fifa_panini/commands/panini/bf_code.py +162 -0
- fifa_panini-1.0.0/fifa_panini/commands/panini/claim.py +86 -0
- fifa_panini-1.0.0/fifa_panini/commands/panini/info.py +95 -0
- fifa_panini-1.0.0/fifa_panini/commands/panini/move.py +126 -0
- fifa_panini-1.0.0/fifa_panini/commands/panini/open_pack.py +91 -0
- fifa_panini-1.0.0/fifa_panini/commands/panini/stickers.py +99 -0
- fifa_panini-1.0.0/fifa_panini/commands/panini/swap.py +271 -0
- fifa_panini-1.0.0/fifa_panini/commands/panini/try_code.py +72 -0
- fifa_panini-1.0.0/fifa_panini/config.example.toml +21 -0
- fifa_panini-1.0.0/fifa_panini/utils/__init__.py +1 -0
- fifa_panini-1.0.0/fifa_panini/utils/actions.py +48 -0
- fifa_panini-1.0.0/fifa_panini/utils/codes.py +93 -0
- fifa_panini-1.0.0/fifa_panini/utils/panini.py +85 -0
- fifa_panini-1.0.0/fifa_panini/utils/stickers.py +48 -0
- fifa_panini-1.0.0/fifa_panini.egg-info/PKG-INFO +66 -0
- fifa_panini-1.0.0/fifa_panini.egg-info/SOURCES.txt +41 -0
- fifa_panini-1.0.0/fifa_panini.egg-info/dependency_links.txt +1 -0
- fifa_panini-1.0.0/fifa_panini.egg-info/entry_points.txt +2 -0
- fifa_panini-1.0.0/fifa_panini.egg-info/requires.txt +3 -0
- fifa_panini-1.0.0/fifa_panini.egg-info/top_level.txt +1 -0
- fifa_panini-1.0.0/pyproject.toml +79 -0
- fifa_panini-1.0.0/setup.cfg +4 -0
- fifa_panini-1.0.0/tests/test_bf_code.py +322 -0
- fifa_panini-1.0.0/tests/test_claim.py +287 -0
- fifa_panini-1.0.0/tests/test_config.py +23 -0
- fifa_panini-1.0.0/tests/test_daily_play.py +356 -0
- fifa_panini-1.0.0/tests/test_info.py +210 -0
- fifa_panini-1.0.0/tests/test_move.py +255 -0
- fifa_panini-1.0.0/tests/test_open_pack.py +243 -0
- fifa_panini-1.0.0/tests/test_release_version.py +34 -0
- fifa_panini-1.0.0/tests/test_stickers.py +282 -0
- fifa_panini-1.0.0/tests/test_swap.py +750 -0
- fifa_panini-1.0.0/tests/test_try_code.py +293 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Filipe Pina
|
|
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.
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fifa-panini
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: CLI for trying FIFA Panini promo codes against an endpoint
|
|
5
|
+
Author-email: Filipe Pina <shelf-corncob-said@duck.com>
|
|
6
|
+
Project-URL: Homepage, https://github.com/fopina/fifa-panini-pack-finder
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Dist: classyclick>=1.0.3
|
|
11
|
+
Requires-Dist: requests>=2.33.0
|
|
12
|
+
Requires-Dist: tqdm>=4.67.1
|
|
13
|
+
Dynamic: license-file
|
|
14
|
+
|
|
15
|
+
# fifa-panini
|
|
16
|
+
|
|
17
|
+
CLI for trying FIFA Panini promo codes against the API.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
pip install fifa-panini
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
$ fifa-panini panini --cookie 'session=value; other=value' try-code --dry-run VD13-V24K-ABCD
|
|
29
|
+
DRY RUN POST https://paninicollection.fifa.com/api/unlock_pack.json code=VD13-V24K-ABCD
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Brute-force the final block from a known-used code base:
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
$ fifa-panini panini --cookie 'session=value; other=value' bf-code --dry-run --code-base VD13-V24K-0000
|
|
36
|
+
DRY RUN POST https://paninicollection.fifa.com/api/unlock_pack.json code=VD13-V24K-AAAA
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Claim the current daily Panini packs:
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
$ fifa-panini panini --cookie 'session=value; other=value' claim
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Fetch the daily promo code from FIFA Play:
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
$ fifa-panini daily-play --cookie 'session=value; other=value'
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Persist settings in the classyclick config file:
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
$ fifa-panini config -e
|
|
55
|
+
$ fifa-panini panini bf-code --dry-run
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
>>> from fifa_panini.commands.bf_code import BfCode
|
|
60
|
+
>>> BfCode(code_base='VD13-V24K-0000').next_code()
|
|
61
|
+
'VD13-V24K-AAAA'
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Build
|
|
65
|
+
|
|
66
|
+
Check out [CONTRIBUTING.md](CONTRIBUTING.md)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# fifa-panini
|
|
2
|
+
|
|
3
|
+
CLI for trying FIFA Panini promo codes against the API.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
pip install fifa-panini
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
$ fifa-panini panini --cookie 'session=value; other=value' try-code --dry-run VD13-V24K-ABCD
|
|
15
|
+
DRY RUN POST https://paninicollection.fifa.com/api/unlock_pack.json code=VD13-V24K-ABCD
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Brute-force the final block from a known-used code base:
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
$ fifa-panini panini --cookie 'session=value; other=value' bf-code --dry-run --code-base VD13-V24K-0000
|
|
22
|
+
DRY RUN POST https://paninicollection.fifa.com/api/unlock_pack.json code=VD13-V24K-AAAA
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Claim the current daily Panini packs:
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
$ fifa-panini panini --cookie 'session=value; other=value' claim
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Fetch the daily promo code from FIFA Play:
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
$ fifa-panini daily-play --cookie 'session=value; other=value'
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Persist settings in the classyclick config file:
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
$ fifa-panini config -e
|
|
41
|
+
$ fifa-panini panini bf-code --dry-run
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
>>> from fifa_panini.commands.bf_code import BfCode
|
|
46
|
+
>>> BfCode(code_base='VD13-V24K-0000').next_code()
|
|
47
|
+
'VD13-V24K-AAAA'
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Build
|
|
51
|
+
|
|
52
|
+
Check out [CONTRIBUTING.md](CONTRIBUTING.md)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from typing import TYPE_CHECKING
|
|
3
|
+
|
|
4
|
+
if TYPE_CHECKING:
|
|
5
|
+
from typing import Tuple, Union
|
|
6
|
+
|
|
7
|
+
VERSION_TUPLE = Tuple[Union[int, str], ...]
|
|
8
|
+
else:
|
|
9
|
+
VERSION_TUPLE = object
|
|
10
|
+
|
|
11
|
+
version: str
|
|
12
|
+
__version__: str
|
|
13
|
+
__version_tuple__: VERSION_TUPLE
|
|
14
|
+
version_tuple: VERSION_TUPLE
|
|
15
|
+
|
|
16
|
+
__version__ = version = '1.0.0'
|
|
17
|
+
__version_tuple__ = version_tuple = tuple(
|
|
18
|
+
int(part) if part.isdigit() else part for part in re.split(r'\.|(?<=[0-9])(?=[a-zA-Z])', version)
|
|
19
|
+
)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import classyclick
|
|
4
|
+
import classyclick.helpers
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from . import __version__
|
|
8
|
+
|
|
9
|
+
CONFIG_EXAMPLE_PATH = Path(__file__).with_name('config.example.toml')
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CLI(classyclick.helpers.ConfigFileMixin, classyclick.Group):
|
|
13
|
+
"""Tools for testing FIFA Panini promo codes."""
|
|
14
|
+
|
|
15
|
+
__config__ = classyclick.Group.Config(
|
|
16
|
+
name='fifa-panini',
|
|
17
|
+
decorators=[click.version_option(version=__version__)],
|
|
18
|
+
)
|
|
19
|
+
CONFIG_DEFAULT_NAME = 'fifa-panini'
|
|
20
|
+
CONFIG_EXAMPLE_PATH = CONFIG_EXAMPLE_PATH
|
|
21
|
+
|
|
22
|
+
def __call__(self):
|
|
23
|
+
self.load_config()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
classyclick.helpers.discover_commands(f'{__package__}.commands')
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI command modules discovered by classyclick."""
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from urllib.request import Request, urlopen
|
|
3
|
+
|
|
4
|
+
import classyclick
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from ..cli import CLI
|
|
8
|
+
from ..utils.codes import CodeResponse
|
|
9
|
+
from ..utils.panini import validate_cookie_header
|
|
10
|
+
|
|
11
|
+
DEFAULT_DAILY_PLAY_ENDPOINT = 'https://play.fifa.com/api/en/gamezone/panini/code'
|
|
12
|
+
DEFAULT_REQUEST_TIMEOUT = 30.0
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DailyPlay(CLI.Command):
|
|
16
|
+
"""Fetch the current FIFA Play daily promo code."""
|
|
17
|
+
|
|
18
|
+
cookie: str = classyclick.Option(
|
|
19
|
+
'-c',
|
|
20
|
+
'--cookie',
|
|
21
|
+
help='Complete Cookie header value copied from the daily Play code browser request.',
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
def __call__(self):
|
|
25
|
+
self.validate_settings()
|
|
26
|
+
|
|
27
|
+
response = self.send_code()
|
|
28
|
+
payload = self.response_payload(response)
|
|
29
|
+
if self.code_generation_is_pending(payload):
|
|
30
|
+
response = self.send_code(method='POST')
|
|
31
|
+
payload = self.response_payload(response)
|
|
32
|
+
|
|
33
|
+
self.print_payload(payload)
|
|
34
|
+
|
|
35
|
+
def validate_settings(self):
|
|
36
|
+
if not self.cookie:
|
|
37
|
+
raise click.ClickException(
|
|
38
|
+
'Missing required setting(s): cookie. Pass them as options or save them in the config file.'
|
|
39
|
+
)
|
|
40
|
+
validate_cookie_header(self.cookie)
|
|
41
|
+
|
|
42
|
+
def print_response(self, response):
|
|
43
|
+
self.print_payload(self.response_payload(response))
|
|
44
|
+
|
|
45
|
+
@staticmethod
|
|
46
|
+
def response_payload(response):
|
|
47
|
+
try:
|
|
48
|
+
payload = json.loads(response.text)
|
|
49
|
+
except json.JSONDecodeError as error:
|
|
50
|
+
raise click.ClickException(f'Response was not valid JSON: {error}') from error
|
|
51
|
+
|
|
52
|
+
if not isinstance(payload, dict):
|
|
53
|
+
raise click.ClickException('Response JSON must be an object.')
|
|
54
|
+
|
|
55
|
+
return payload
|
|
56
|
+
|
|
57
|
+
def print_payload(self, payload):
|
|
58
|
+
errors = payload.get('errors')
|
|
59
|
+
if errors:
|
|
60
|
+
raise click.ClickException(f'Response errors: {self.format_errors(errors)}')
|
|
61
|
+
|
|
62
|
+
code = self.daily_code(payload)
|
|
63
|
+
if not code:
|
|
64
|
+
raise click.ClickException('Response did not include a daily promo code.')
|
|
65
|
+
|
|
66
|
+
click.echo(code)
|
|
67
|
+
|
|
68
|
+
@staticmethod
|
|
69
|
+
def code_generation_is_pending(payload):
|
|
70
|
+
success = payload.get('success')
|
|
71
|
+
if not isinstance(success, dict):
|
|
72
|
+
return False
|
|
73
|
+
|
|
74
|
+
panini_code = success.get('paniniCode')
|
|
75
|
+
if not isinstance(panini_code, dict):
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
return 'code' in panini_code and panini_code.get('code') is None
|
|
79
|
+
|
|
80
|
+
@staticmethod
|
|
81
|
+
def daily_code(payload):
|
|
82
|
+
success = payload.get('success')
|
|
83
|
+
if not isinstance(success, dict):
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
panini_code = success.get('paniniCode')
|
|
87
|
+
if not isinstance(panini_code, dict):
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
code = panini_code.get('code')
|
|
91
|
+
if not isinstance(code, str):
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
code = code.strip()
|
|
95
|
+
return code or None
|
|
96
|
+
|
|
97
|
+
@staticmethod
|
|
98
|
+
def format_errors(errors):
|
|
99
|
+
if isinstance(errors, list):
|
|
100
|
+
return ', '.join(DailyPlay.format_error(error) for error in errors)
|
|
101
|
+
return DailyPlay.format_error(errors)
|
|
102
|
+
|
|
103
|
+
@staticmethod
|
|
104
|
+
def format_error(error):
|
|
105
|
+
if isinstance(error, str):
|
|
106
|
+
return error
|
|
107
|
+
return json.dumps(error, separators=(',', ':'))
|
|
108
|
+
|
|
109
|
+
def send_code(self, method='GET'):
|
|
110
|
+
request = Request(
|
|
111
|
+
DEFAULT_DAILY_PLAY_ENDPOINT,
|
|
112
|
+
headers={
|
|
113
|
+
'Cookie': self.cookie,
|
|
114
|
+
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:152.0) Gecko/20100101 Firefox/152.0',
|
|
115
|
+
'Accept': '*/*',
|
|
116
|
+
'Accept-Language': 'en-GB,en;q=0.9',
|
|
117
|
+
},
|
|
118
|
+
method=method,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
with urlopen(request, timeout=DEFAULT_REQUEST_TIMEOUT) as response:
|
|
123
|
+
return CodeResponse(
|
|
124
|
+
text=response.read().decode('utf-8', errors='replace'),
|
|
125
|
+
headers=dict(response.headers.items()),
|
|
126
|
+
)
|
|
127
|
+
except OSError as error:
|
|
128
|
+
raise click.ClickException(f'Request failed for {DEFAULT_DAILY_PLAY_ENDPOINT}: {error}') from error
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import classyclick
|
|
2
|
+
|
|
3
|
+
from ...cli import CLI
|
|
4
|
+
from ...utils.panini import (
|
|
5
|
+
API_ENDPOINT,
|
|
6
|
+
PANINI_API_ENDPOINT_META_KEY,
|
|
7
|
+
PANINI_COOKIE_META_KEY,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
'API_ENDPOINT',
|
|
12
|
+
'PANINI_API_ENDPOINT_META_KEY',
|
|
13
|
+
'PANINI_COOKIE_META_KEY',
|
|
14
|
+
'Panini',
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Panini(CLI.SubGroup):
|
|
19
|
+
"""Try and brute-force FIFA Panini promo codes."""
|
|
20
|
+
|
|
21
|
+
cookie: str = classyclick.Option(
|
|
22
|
+
'-c',
|
|
23
|
+
'--cookie',
|
|
24
|
+
help='Complete Cookie header value copied from the browser request.',
|
|
25
|
+
)
|
|
26
|
+
api_endpoint: str = classyclick.Option(
|
|
27
|
+
'--api-endpoint',
|
|
28
|
+
default=API_ENDPOINT,
|
|
29
|
+
show_default=True,
|
|
30
|
+
help='Panini API base URL.',
|
|
31
|
+
)
|
|
32
|
+
ctx: classyclick.Context = classyclick.Context()
|
|
33
|
+
|
|
34
|
+
def __call__(self):
|
|
35
|
+
self.ctx.meta[PANINI_COOKIE_META_KEY] = self.cookie
|
|
36
|
+
self.ctx.meta[PANINI_API_ENDPOINT_META_KEY] = self.api_endpoint
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import time
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import classyclick
|
|
6
|
+
import click
|
|
7
|
+
from tqdm import tqdm
|
|
8
|
+
|
|
9
|
+
from ...utils.codes import (
|
|
10
|
+
CODE_ALREADY_USED_MARKER,
|
|
11
|
+
DEFAULT_REQUEST_DELAY,
|
|
12
|
+
DEFAULT_STATE_FILE,
|
|
13
|
+
INVALID_CODE_MARKER,
|
|
14
|
+
SUFFIX_ALPHABET,
|
|
15
|
+
TOTAL_SUFFIXES,
|
|
16
|
+
CodeMethodsMixin,
|
|
17
|
+
)
|
|
18
|
+
from ...utils.panini import (
|
|
19
|
+
DEFAULT_REQUEST_TIMEOUT,
|
|
20
|
+
validate_cookie_header,
|
|
21
|
+
)
|
|
22
|
+
from . import PANINI_API_ENDPOINT_META_KEY, PANINI_COOKIE_META_KEY, Panini
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class BfCode(CodeMethodsMixin, Panini.Command):
|
|
26
|
+
"""Brute-force FIFA Panini promo codes by iterating the final block."""
|
|
27
|
+
|
|
28
|
+
cookie: str = classyclick.ContextMeta(PANINI_COOKIE_META_KEY)
|
|
29
|
+
api_endpoint: str = classyclick.ContextMeta(PANINI_API_ENDPOINT_META_KEY)
|
|
30
|
+
code_base: str = classyclick.Option(
|
|
31
|
+
help='Promo code base in XXXX-XXXX-XXXX format; the final block is iterated.',
|
|
32
|
+
)
|
|
33
|
+
state_file: Path = classyclick.Option(
|
|
34
|
+
'-s',
|
|
35
|
+
default=Path(DEFAULT_STATE_FILE),
|
|
36
|
+
show_default=True,
|
|
37
|
+
help='Path used to save resume state.',
|
|
38
|
+
)
|
|
39
|
+
dry_run: bool = classyclick.Option(help='Print the request that would be attempted.')
|
|
40
|
+
request_timeout: float = classyclick.Option(
|
|
41
|
+
'--timeout',
|
|
42
|
+
default=DEFAULT_REQUEST_TIMEOUT,
|
|
43
|
+
show_default=True,
|
|
44
|
+
help='HTTP request timeout in seconds.',
|
|
45
|
+
)
|
|
46
|
+
request_delay: float = classyclick.Option(
|
|
47
|
+
'--request-delay',
|
|
48
|
+
default=DEFAULT_REQUEST_DELAY,
|
|
49
|
+
show_default=True,
|
|
50
|
+
help='Delay between requests in seconds.',
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
def __call__(self):
|
|
54
|
+
self.validate_settings()
|
|
55
|
+
|
|
56
|
+
if self.dry_run:
|
|
57
|
+
code = self.next_code()
|
|
58
|
+
tqdm.write(f'DRY RUN POST {self.unlock_pack_endpoint} code={code}')
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
self.check_code_base()
|
|
62
|
+
start = self.start_suffix()
|
|
63
|
+
with tqdm(total=TOTAL_SUFFIXES, initial=start, unit='code') as progress:
|
|
64
|
+
for code in self.iter_codes(start):
|
|
65
|
+
progress.set_description(f'Testing {code}')
|
|
66
|
+
self.save_state(code, self.suffix_from_code(code))
|
|
67
|
+
response = self.send_code(code)
|
|
68
|
+
|
|
69
|
+
if INVALID_CODE_MARKER not in response.text:
|
|
70
|
+
tqdm.write(f'Stopping at {code}: response did not contain {INVALID_CODE_MARKER}')
|
|
71
|
+
self.print_response(response)
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
self.save_state(code, self.suffix_from_code(code) + 1)
|
|
75
|
+
progress.update()
|
|
76
|
+
if self.request_delay:
|
|
77
|
+
time.sleep(self.request_delay)
|
|
78
|
+
|
|
79
|
+
raise click.ClickException(f'All {TOTAL_SUFFIXES} suffixes were tried without a non-invalid response.')
|
|
80
|
+
|
|
81
|
+
def validate_settings(self):
|
|
82
|
+
missing = [name for name in ('code_base', 'cookie') if not getattr(self, name)]
|
|
83
|
+
if missing:
|
|
84
|
+
raise click.ClickException(
|
|
85
|
+
f'Missing required setting(s): {", ".join(missing)}. '
|
|
86
|
+
f'Pass them as options or save them in the config file.'
|
|
87
|
+
)
|
|
88
|
+
validate_cookie_header(self.cookie)
|
|
89
|
+
self.validate_code(self.code_base, '--code-base')
|
|
90
|
+
if self.request_delay < 0:
|
|
91
|
+
raise click.ClickException('--request-delay must be greater than or equal to 0.')
|
|
92
|
+
|
|
93
|
+
def check_code_base(self):
|
|
94
|
+
response = self.send_code(self.code_base)
|
|
95
|
+
if CODE_ALREADY_USED_MARKER not in response.text:
|
|
96
|
+
print(response.text)
|
|
97
|
+
raise click.ClickException(
|
|
98
|
+
f'Code base check failed for {self.code_base}: expected response to contain {CODE_ALREADY_USED_MARKER}.'
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
def iter_codes(self, start=None):
|
|
102
|
+
if start is None:
|
|
103
|
+
start = self.start_suffix()
|
|
104
|
+
for suffix in range(start, TOTAL_SUFFIXES):
|
|
105
|
+
yield self.format_code(suffix)
|
|
106
|
+
|
|
107
|
+
def next_code(self):
|
|
108
|
+
start = self.start_suffix()
|
|
109
|
+
if start >= TOTAL_SUFFIXES:
|
|
110
|
+
raise click.ClickException(f'All {TOTAL_SUFFIXES} suffixes have already been tried for this code base.')
|
|
111
|
+
|
|
112
|
+
return self.format_code(start)
|
|
113
|
+
|
|
114
|
+
def start_suffix(self):
|
|
115
|
+
state = self.load_state()
|
|
116
|
+
if state is None:
|
|
117
|
+
return 0
|
|
118
|
+
|
|
119
|
+
if state.get('code_base') != self.code_base:
|
|
120
|
+
return 0
|
|
121
|
+
|
|
122
|
+
next_suffix = int(state.get('next_suffix', 0))
|
|
123
|
+
return min(next_suffix, TOTAL_SUFFIXES)
|
|
124
|
+
|
|
125
|
+
def load_state(self):
|
|
126
|
+
state_file = self.state_path
|
|
127
|
+
if not state_file.exists():
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
with state_file.open() as state:
|
|
132
|
+
return json.load(state)
|
|
133
|
+
except (OSError, json.JSONDecodeError) as error:
|
|
134
|
+
raise click.ClickException(f'Could not read state file {state_file}: {error}') from error
|
|
135
|
+
|
|
136
|
+
def save_state(self, code, next_suffix):
|
|
137
|
+
state = {
|
|
138
|
+
'code_base': self.code_base,
|
|
139
|
+
'current_code': code,
|
|
140
|
+
'next_suffix': next_suffix,
|
|
141
|
+
}
|
|
142
|
+
state_file = self.state_path
|
|
143
|
+
try:
|
|
144
|
+
state_file.parent.mkdir(parents=True, exist_ok=True)
|
|
145
|
+
with state_file.open('w') as file:
|
|
146
|
+
json.dump(state, file, indent=2)
|
|
147
|
+
file.write('\n')
|
|
148
|
+
except OSError as error:
|
|
149
|
+
raise click.ClickException(f'Could not write state file {state_file}: {error}') from error
|
|
150
|
+
|
|
151
|
+
def suffix_from_code(self, code):
|
|
152
|
+
suffix = 0
|
|
153
|
+
for character in code[-4:]:
|
|
154
|
+
suffix = suffix * len(SUFFIX_ALPHABET) + SUFFIX_ALPHABET.index(character)
|
|
155
|
+
return suffix
|
|
156
|
+
|
|
157
|
+
def format_code(self, suffix):
|
|
158
|
+
characters = []
|
|
159
|
+
for _ in range(4):
|
|
160
|
+
suffix, index = divmod(suffix, len(SUFFIX_ALPHABET))
|
|
161
|
+
characters.append(SUFFIX_ALPHABET[index])
|
|
162
|
+
return f'{self.code_base[:-4]}{"".join(reversed(characters))}'
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import datetime as dt
|
|
2
|
+
|
|
3
|
+
import classyclick
|
|
4
|
+
import click
|
|
5
|
+
|
|
6
|
+
from ...utils.actions import action, action_with_status, error_message, parse_action_list
|
|
7
|
+
from ...utils.panini import (
|
|
8
|
+
DEFAULT_REQUEST_TIMEOUT,
|
|
9
|
+
PaniniClient,
|
|
10
|
+
validate_cookie_header,
|
|
11
|
+
)
|
|
12
|
+
from . import PANINI_API_ENDPOINT_META_KEY, PANINI_COOKIE_META_KEY, Panini
|
|
13
|
+
|
|
14
|
+
CLAIM_PACKS_PATH = 'receive_daily_packs.json'
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Claim(Panini.Command):
|
|
18
|
+
"""Claim the current FIFA Panini daily packs."""
|
|
19
|
+
|
|
20
|
+
cookie: str = classyclick.ContextMeta(PANINI_COOKIE_META_KEY)
|
|
21
|
+
api_endpoint: str = classyclick.ContextMeta(PANINI_API_ENDPOINT_META_KEY)
|
|
22
|
+
dry_run: bool = classyclick.Option(help='Print the request that would be attempted.')
|
|
23
|
+
request_timeout: float = classyclick.Option(
|
|
24
|
+
'--timeout',
|
|
25
|
+
default=DEFAULT_REQUEST_TIMEOUT,
|
|
26
|
+
show_default=True,
|
|
27
|
+
help='HTTP request timeout in seconds.',
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def panini_client(self):
|
|
32
|
+
return PaniniClient(self.cookie, self.api_endpoint, self.request_timeout)
|
|
33
|
+
|
|
34
|
+
def __call__(self):
|
|
35
|
+
self.validate_settings()
|
|
36
|
+
|
|
37
|
+
if self.dry_run:
|
|
38
|
+
click.echo(f'DRY RUN POST {self.panini_client.endpoint(CLAIM_PACKS_PATH)} json={{}}')
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
response = self.panini_client.post_json(CLAIM_PACKS_PATH, request_name='claim packs')
|
|
42
|
+
self.print_response(response)
|
|
43
|
+
|
|
44
|
+
def validate_settings(self):
|
|
45
|
+
if not self.cookie:
|
|
46
|
+
raise click.ClickException(
|
|
47
|
+
'Missing required setting(s): cookie. Pass them as options or save them in the config file.'
|
|
48
|
+
)
|
|
49
|
+
validate_cookie_header(self.cookie)
|
|
50
|
+
|
|
51
|
+
def print_response(self, response):
|
|
52
|
+
actions = parse_action_list(response)
|
|
53
|
+
|
|
54
|
+
receive_daily_packs = action_with_status(actions, 'receive_daily_packs')
|
|
55
|
+
if not receive_daily_packs:
|
|
56
|
+
raise click.ClickException('Response did not include receive daily pack information.')
|
|
57
|
+
|
|
58
|
+
next_packs_message = self.next_packs_message(actions)
|
|
59
|
+
|
|
60
|
+
error_message = self.error_message(receive_daily_packs)
|
|
61
|
+
if error_message:
|
|
62
|
+
message = error_message
|
|
63
|
+
if next_packs_message:
|
|
64
|
+
message = f'{message}. {next_packs_message}'
|
|
65
|
+
raise click.ClickException(message)
|
|
66
|
+
|
|
67
|
+
received_packs = self.action(actions, 'received_packs')
|
|
68
|
+
if not received_packs:
|
|
69
|
+
raise click.ClickException('Response did not include received pack information.')
|
|
70
|
+
|
|
71
|
+
click.echo(f'Claimed packs: {received_packs.get("amount", 0)}')
|
|
72
|
+
click.echo(f'Total packs: {received_packs.get("total_packs", 0)}')
|
|
73
|
+
if next_packs_message:
|
|
74
|
+
click.echo(next_packs_message)
|
|
75
|
+
|
|
76
|
+
def next_packs_message(self, actions):
|
|
77
|
+
daily_packs_status = self.action(actions, 'daily_packs_status')
|
|
78
|
+
new_packs_in_sec = daily_packs_status.get('new_packs_in_sec')
|
|
79
|
+
if new_packs_in_sec is None:
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
available_at = dt.datetime.now().astimezone() + dt.timedelta(seconds=new_packs_in_sec)
|
|
83
|
+
return f'New packs available at: {available_at.strftime("%Y-%m-%d %H:%M:%S %Z").rstrip()}'
|
|
84
|
+
|
|
85
|
+
action = staticmethod(action)
|
|
86
|
+
error_message = staticmethod(error_message)
|