ilidl 0.1.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.
- ilidl-0.1.0/.github/workflows/publish.yml +24 -0
- ilidl-0.1.0/.gitignore +35 -0
- ilidl-0.1.0/CONTRIBUTING.md +43 -0
- ilidl-0.1.0/LICENSE +21 -0
- ilidl-0.1.0/PKG-INFO +112 -0
- ilidl-0.1.0/README.md +81 -0
- ilidl-0.1.0/ilidl/__init__.py +9 -0
- ilidl-0.1.0/ilidl/auth.py +219 -0
- ilidl-0.1.0/ilidl/cli.py +214 -0
- ilidl-0.1.0/ilidl/client.py +254 -0
- ilidl-0.1.0/ilidl/config.py +40 -0
- ilidl-0.1.0/ilidl/exceptions.py +17 -0
- ilidl-0.1.0/ilidl/models.py +52 -0
- ilidl-0.1.0/ilidl/parser.py +186 -0
- ilidl-0.1.0/ilidl/py.typed +0 -0
- ilidl-0.1.0/pyproject.toml +53 -0
- ilidl-0.1.0/tests/fixtures/receipt_gb_sample.json +1 -0
- ilidl-0.1.0/tests/test_auth.py +97 -0
- ilidl-0.1.0/tests/test_cli.py +347 -0
- ilidl-0.1.0/tests/test_client.py +367 -0
- ilidl-0.1.0/tests/test_config.py +38 -0
- ilidl-0.1.0/tests/test_models.py +69 -0
- ilidl-0.1.0/tests/test_parser.py +85 -0
- ilidl-0.1.0/uv.lock +291 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags: ["v*"]
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
publish:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
permissions:
|
|
11
|
+
contents: read
|
|
12
|
+
id-token: write
|
|
13
|
+
environment: pypi
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
16
|
+
with:
|
|
17
|
+
persist-credentials: false
|
|
18
|
+
- uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
|
19
|
+
with:
|
|
20
|
+
enable-cache: false
|
|
21
|
+
- run: uv build
|
|
22
|
+
- uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
|
|
23
|
+
with:
|
|
24
|
+
packages-dir: dist/
|
ilidl-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.pyc
|
|
4
|
+
*.pyo
|
|
5
|
+
*.egg-info/
|
|
6
|
+
dist/
|
|
7
|
+
build/
|
|
8
|
+
|
|
9
|
+
# Virtual environment
|
|
10
|
+
.venv/
|
|
11
|
+
|
|
12
|
+
# Testing / linting caches
|
|
13
|
+
.pytest_cache/
|
|
14
|
+
.ruff_cache/
|
|
15
|
+
.coverage
|
|
16
|
+
|
|
17
|
+
# Playwright
|
|
18
|
+
.playwright-mcp/
|
|
19
|
+
|
|
20
|
+
# IDE
|
|
21
|
+
.idea/
|
|
22
|
+
.vscode/
|
|
23
|
+
*.swp
|
|
24
|
+
|
|
25
|
+
# Debug artifacts
|
|
26
|
+
*.png
|
|
27
|
+
|
|
28
|
+
# Claude
|
|
29
|
+
.claude/
|
|
30
|
+
|
|
31
|
+
# OS
|
|
32
|
+
.DS_Store
|
|
33
|
+
|
|
34
|
+
# Local plans (not for remote)
|
|
35
|
+
docs/superpowers/
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
Thanks for your interest in contributing to iLidl!
|
|
4
|
+
|
|
5
|
+
## Setup
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
git clone https://github.com/RoryDotGG/iLidl.git
|
|
9
|
+
cd iLidl
|
|
10
|
+
uv venv && source .venv/bin/activate
|
|
11
|
+
uv pip install -e ".[dev,auth]"
|
|
12
|
+
playwright install chromium
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Development
|
|
16
|
+
|
|
17
|
+
Run tests:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
uv run pytest tests/ -q
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Lint and format:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
uv run ruff check .
|
|
27
|
+
uv run ruff format .
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Submitting changes
|
|
31
|
+
|
|
32
|
+
1. Fork the repository
|
|
33
|
+
2. Create a feature branch (`git checkout -b feat/my-feature`)
|
|
34
|
+
3. Make your changes with tests
|
|
35
|
+
4. Ensure `ruff check .` and `ruff format --check .` pass
|
|
36
|
+
5. Commit using [conventional commits](https://www.conventionalcommits.org/) (e.g. `feat:`, `fix:`, `chore:`)
|
|
37
|
+
6. Open a pull request against `main`
|
|
38
|
+
|
|
39
|
+
## Notes
|
|
40
|
+
|
|
41
|
+
- UK receipts are the primary focus. Other countries may work but are untested.
|
|
42
|
+
- The Lidl Plus API is undocumented and may change without notice.
|
|
43
|
+
- Keep the `App-Version` header realistic. The API rejects fake versions.
|
ilidl-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 RoryDotGG
|
|
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.
|
ilidl-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ilidl
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Unofficial Lidl Plus API client
|
|
5
|
+
Project-URL: Homepage, https://github.com/RoryDotGG/iLidl
|
|
6
|
+
Project-URL: Repository, https://github.com/RoryDotGG/iLidl
|
|
7
|
+
Project-URL: Issues, https://github.com/RoryDotGG/iLidl/issues
|
|
8
|
+
Author: RoryDotGG
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: api-client,coupons,lidl,lidl-plus,receipts
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Internet
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.12
|
|
22
|
+
Requires-Dist: click
|
|
23
|
+
Requires-Dist: httpx
|
|
24
|
+
Requires-Dist: tomli-w
|
|
25
|
+
Provides-Extra: auth
|
|
26
|
+
Requires-Dist: playwright; extra == 'auth'
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
29
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# iLidl
|
|
33
|
+
|
|
34
|
+
Unofficial Python client and CLI for the Lidl Plus API. Fetch receipts, parse item details, and manage coupons.
|
|
35
|
+
|
|
36
|
+
## Installation
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install ilidl
|
|
40
|
+
|
|
41
|
+
# Or with uv
|
|
42
|
+
uv add ilidl
|
|
43
|
+
|
|
44
|
+
# With auth support (requires Playwright for login)
|
|
45
|
+
pip install "ilidl[auth]"
|
|
46
|
+
playwright install chromium
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Authentication
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
ilidl login
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Launches a headless browser to complete the Lidl Plus OAuth flow. You'll be prompted for your phone number and a verification code. A refresh token is saved to `~/.config/ilidl/config.toml` for future use.
|
|
56
|
+
|
|
57
|
+
## CLI Usage
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
# Receipts
|
|
61
|
+
ilidl receipt latest # Most recent receipt
|
|
62
|
+
ilidl receipt latest --json # As JSON
|
|
63
|
+
ilidl receipt <id> # Specific receipt
|
|
64
|
+
ilidl receipts # List all receipts
|
|
65
|
+
ilidl receipts --from 2026-03-01 # Filter by date
|
|
66
|
+
ilidl receipts --to 2026-03-18
|
|
67
|
+
|
|
68
|
+
# Coupons
|
|
69
|
+
ilidl coupons list # List available coupons
|
|
70
|
+
ilidl coupons activate <id> # Activate a coupon
|
|
71
|
+
ilidl coupons activate --all # Activate all coupons
|
|
72
|
+
ilidl coupons deactivate <id> # Deactivate a coupon
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
All commands support `--json` for machine-readable output.
|
|
76
|
+
|
|
77
|
+
## Library Usage
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
from ilidl import LidlClient
|
|
81
|
+
|
|
82
|
+
client = LidlClient(refresh_token="...", country="GB", language="en")
|
|
83
|
+
|
|
84
|
+
# Get latest receipt with parsed items
|
|
85
|
+
receipt = client.latest_receipt()
|
|
86
|
+
for item in receipt.items:
|
|
87
|
+
print(f"{item.name}: {item.price}")
|
|
88
|
+
|
|
89
|
+
# List coupons
|
|
90
|
+
for coupon in client.coupons():
|
|
91
|
+
print(f"{coupon.title} (active: {coupon.is_activated})")
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Configuration
|
|
95
|
+
|
|
96
|
+
Stored at `~/.config/ilidl/config.toml`:
|
|
97
|
+
|
|
98
|
+
```toml
|
|
99
|
+
[auth]
|
|
100
|
+
refresh_token = "..."
|
|
101
|
+
|
|
102
|
+
[account]
|
|
103
|
+
language = "en"
|
|
104
|
+
country = "GB"
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Notes
|
|
108
|
+
|
|
109
|
+
- UK receipts are returned as HTML by the API and parsed into structured items using `data-*` attributes.
|
|
110
|
+
- The `App-Version` header must be a realistic value (currently `16.45.5`). The API rejects fake versions.
|
|
111
|
+
- API v2 is used for receipt lists, v3 for receipt detail.
|
|
112
|
+
- This project replaces the upstream `lidl-plus` library which is broken on modern Python.
|
ilidl-0.1.0/README.md
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# iLidl
|
|
2
|
+
|
|
3
|
+
Unofficial Python client and CLI for the Lidl Plus API. Fetch receipts, parse item details, and manage coupons.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install ilidl
|
|
9
|
+
|
|
10
|
+
# Or with uv
|
|
11
|
+
uv add ilidl
|
|
12
|
+
|
|
13
|
+
# With auth support (requires Playwright for login)
|
|
14
|
+
pip install "ilidl[auth]"
|
|
15
|
+
playwright install chromium
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Authentication
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
ilidl login
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Launches a headless browser to complete the Lidl Plus OAuth flow. You'll be prompted for your phone number and a verification code. A refresh token is saved to `~/.config/ilidl/config.toml` for future use.
|
|
25
|
+
|
|
26
|
+
## CLI Usage
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
# Receipts
|
|
30
|
+
ilidl receipt latest # Most recent receipt
|
|
31
|
+
ilidl receipt latest --json # As JSON
|
|
32
|
+
ilidl receipt <id> # Specific receipt
|
|
33
|
+
ilidl receipts # List all receipts
|
|
34
|
+
ilidl receipts --from 2026-03-01 # Filter by date
|
|
35
|
+
ilidl receipts --to 2026-03-18
|
|
36
|
+
|
|
37
|
+
# Coupons
|
|
38
|
+
ilidl coupons list # List available coupons
|
|
39
|
+
ilidl coupons activate <id> # Activate a coupon
|
|
40
|
+
ilidl coupons activate --all # Activate all coupons
|
|
41
|
+
ilidl coupons deactivate <id> # Deactivate a coupon
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
All commands support `--json` for machine-readable output.
|
|
45
|
+
|
|
46
|
+
## Library Usage
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from ilidl import LidlClient
|
|
50
|
+
|
|
51
|
+
client = LidlClient(refresh_token="...", country="GB", language="en")
|
|
52
|
+
|
|
53
|
+
# Get latest receipt with parsed items
|
|
54
|
+
receipt = client.latest_receipt()
|
|
55
|
+
for item in receipt.items:
|
|
56
|
+
print(f"{item.name}: {item.price}")
|
|
57
|
+
|
|
58
|
+
# List coupons
|
|
59
|
+
for coupon in client.coupons():
|
|
60
|
+
print(f"{coupon.title} (active: {coupon.is_activated})")
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Configuration
|
|
64
|
+
|
|
65
|
+
Stored at `~/.config/ilidl/config.toml`:
|
|
66
|
+
|
|
67
|
+
```toml
|
|
68
|
+
[auth]
|
|
69
|
+
refresh_token = "..."
|
|
70
|
+
|
|
71
|
+
[account]
|
|
72
|
+
language = "en"
|
|
73
|
+
country = "GB"
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Notes
|
|
77
|
+
|
|
78
|
+
- UK receipts are returned as HTML by the API and parsed into structured items using `data-*` attributes.
|
|
79
|
+
- The `App-Version` header must be a realistic value (currently `16.45.5`). The API rejects fake versions.
|
|
80
|
+
- API v2 is used for receipt lists, v3 for receipt detail.
|
|
81
|
+
- This project replaces the upstream `lidl-plus` library which is broken on modern Python.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Unofficial Lidl Plus API client."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import version
|
|
4
|
+
|
|
5
|
+
from ilidl.client import LidlClient
|
|
6
|
+
from ilidl.models import Coupon, Discount, Item, Receipt, Store
|
|
7
|
+
|
|
8
|
+
__all__ = ["LidlClient", "Coupon", "Discount", "Item", "Receipt", "Store"]
|
|
9
|
+
__version__ = version("ilidl")
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Playwright-based OAuth2 PKCE authentication for Lidl Plus."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import contextlib
|
|
5
|
+
import getpass
|
|
6
|
+
import hashlib
|
|
7
|
+
import re
|
|
8
|
+
import secrets
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from ilidl.config import Config
|
|
14
|
+
from ilidl.exceptions import AuthError
|
|
15
|
+
|
|
16
|
+
AUTH_API = "https://accounts.lidl.com"
|
|
17
|
+
CLIENT_ID = "LidlPlusNativeClient"
|
|
18
|
+
REDIRECT_URI = "com.lidlplus.app://callback"
|
|
19
|
+
SCOPES = "openid profile offline_access lpprofile lpapis"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _generate_pkce() -> tuple[str, str]:
|
|
23
|
+
"""Generate PKCE code_verifier and code_challenge."""
|
|
24
|
+
verifier = secrets.token_urlsafe(64)
|
|
25
|
+
digest = hashlib.sha256(verifier.encode()).digest()
|
|
26
|
+
challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
|
|
27
|
+
return verifier, challenge
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _build_auth_url(challenge: str, country: str, language: str) -> str:
|
|
31
|
+
"""Build the OAuth authorization URL."""
|
|
32
|
+
params = (
|
|
33
|
+
f"client_id={CLIENT_ID}"
|
|
34
|
+
f"&response_type=code"
|
|
35
|
+
f"&scope={SCOPES.replace(' ', '+')}"
|
|
36
|
+
f"&redirect_uri={REDIRECT_URI.replace(':', '%3A').replace('/', '%2F')}"
|
|
37
|
+
f"&code_challenge={challenge}"
|
|
38
|
+
f"&code_challenge_method=S256"
|
|
39
|
+
f"&Country={country.upper()}"
|
|
40
|
+
f"&language={language.lower()}-{country.upper()}"
|
|
41
|
+
)
|
|
42
|
+
return f"{AUTH_API}/connect/authorize?{params}"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _exchange_code(code: str, verifier: str) -> dict[str, Any]:
|
|
46
|
+
"""Exchange authorization code for tokens."""
|
|
47
|
+
secret = base64.b64encode(f"{CLIENT_ID}:secret".encode()).decode()
|
|
48
|
+
resp = httpx.post(
|
|
49
|
+
f"{AUTH_API}/connect/token",
|
|
50
|
+
headers={
|
|
51
|
+
"Authorization": f"Basic {secret}",
|
|
52
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
53
|
+
},
|
|
54
|
+
data={
|
|
55
|
+
"grant_type": "authorization_code",
|
|
56
|
+
"code": code,
|
|
57
|
+
"redirect_uri": REDIRECT_URI,
|
|
58
|
+
"code_verifier": verifier,
|
|
59
|
+
},
|
|
60
|
+
timeout=30,
|
|
61
|
+
)
|
|
62
|
+
try:
|
|
63
|
+
resp.raise_for_status()
|
|
64
|
+
except httpx.HTTPStatusError as exc:
|
|
65
|
+
msg = f"Token exchange failed: {resp.status_code} {resp.text}"
|
|
66
|
+
raise AuthError(msg) from exc
|
|
67
|
+
return resp.json()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _dbg(debug: bool, page: Any, name: str, msg: str = "") -> None:
|
|
71
|
+
"""Save a debug screenshot and optionally print a message."""
|
|
72
|
+
if not debug:
|
|
73
|
+
return
|
|
74
|
+
page.screenshot(path=f"/tmp/ilidl_{name}.png")
|
|
75
|
+
if msg:
|
|
76
|
+
print(f"DEBUG {msg}")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def login(config: Config, *, debug: bool = False) -> str:
|
|
80
|
+
"""Run interactive login via phone number. Returns refresh token."""
|
|
81
|
+
try:
|
|
82
|
+
from playwright.sync_api import sync_playwright
|
|
83
|
+
except ImportError as e:
|
|
84
|
+
msg = "Playwright is required for login. Install with: uv pip install 'ilidl[auth]'"
|
|
85
|
+
raise AuthError(msg) from e
|
|
86
|
+
|
|
87
|
+
verifier, challenge = _generate_pkce()
|
|
88
|
+
url = _build_auth_url(challenge, config.country, config.language)
|
|
89
|
+
|
|
90
|
+
phone = input("Phone number (local, e.g. 7415148717): ").strip()
|
|
91
|
+
password = getpass.getpass("Password: ")
|
|
92
|
+
|
|
93
|
+
with sync_playwright() as p:
|
|
94
|
+
browser = p.chromium.launch(headless=True)
|
|
95
|
+
page = browser.new_page()
|
|
96
|
+
|
|
97
|
+
code: str = ""
|
|
98
|
+
|
|
99
|
+
def handle_request(request):
|
|
100
|
+
nonlocal code
|
|
101
|
+
req_url = request.url
|
|
102
|
+
if debug:
|
|
103
|
+
print(f"DEBUG request: {req_url[:120]}")
|
|
104
|
+
if req_url.startswith("com.lidlplus.app://"):
|
|
105
|
+
match = re.search(r"code=([^&]+)", req_url)
|
|
106
|
+
if match:
|
|
107
|
+
code = match.group(1)
|
|
108
|
+
if debug:
|
|
109
|
+
print(f"DEBUG got code: {code}")
|
|
110
|
+
|
|
111
|
+
def handle_response(response):
|
|
112
|
+
nonlocal code
|
|
113
|
+
# Check for 302 redirects to the app callback
|
|
114
|
+
if code:
|
|
115
|
+
return
|
|
116
|
+
location = response.headers.get("location", "")
|
|
117
|
+
if location.startswith("com.lidlplus.app://"):
|
|
118
|
+
match = re.search(r"code=([^&]+)", location)
|
|
119
|
+
if match:
|
|
120
|
+
code = match.group(1)
|
|
121
|
+
if debug:
|
|
122
|
+
print(f"DEBUG got code from redirect: {code}")
|
|
123
|
+
|
|
124
|
+
page.on("request", handle_request)
|
|
125
|
+
page.on("response", handle_response)
|
|
126
|
+
|
|
127
|
+
page.goto(url)
|
|
128
|
+
page.wait_for_load_state("networkidle")
|
|
129
|
+
_dbg(debug, page, "01_welcome", f"url: {page.url}")
|
|
130
|
+
|
|
131
|
+
# Click "Log in" on the welcome page
|
|
132
|
+
page.locator("button:has-text('Log in'), a:has-text('Log in')").first.click(timeout=5000)
|
|
133
|
+
page.wait_for_load_state("networkidle")
|
|
134
|
+
_dbg(debug, page, "02_login_form", "on login form")
|
|
135
|
+
|
|
136
|
+
# Switch to phone number login
|
|
137
|
+
page.locator("button:has-text('phone number'), a:has-text('phone number')").first.click(
|
|
138
|
+
timeout=5000
|
|
139
|
+
)
|
|
140
|
+
page.wait_for_load_state("networkidle")
|
|
141
|
+
_dbg(debug, page, "03_phone_form", f"url: {page.url}")
|
|
142
|
+
|
|
143
|
+
# Fill local phone number (country code is pre-selected)
|
|
144
|
+
phone_field = page.locator(
|
|
145
|
+
"input[type='tel'], input[name='PhoneNumber'], input[name='phoneNumber']"
|
|
146
|
+
).first
|
|
147
|
+
phone_field.fill(phone)
|
|
148
|
+
|
|
149
|
+
# Fill password
|
|
150
|
+
page.locator("input[type='password']").first.fill(password)
|
|
151
|
+
_dbg(debug, page, "04_filled")
|
|
152
|
+
|
|
153
|
+
# Submit and wait for navigation or page change
|
|
154
|
+
login_url = page.url
|
|
155
|
+
page.locator("button:has-text('Log in'), button[type='submit']").first.click()
|
|
156
|
+
|
|
157
|
+
# Wait for URL to change or for an error/verification
|
|
158
|
+
# element to appear
|
|
159
|
+
with contextlib.suppress(TimeoutError):
|
|
160
|
+
page.wait_for_function(
|
|
161
|
+
f"window.location.href !== '{login_url}' || "
|
|
162
|
+
"document.querySelector("
|
|
163
|
+
' \'[name="VerificationCode"],'
|
|
164
|
+
" .alert-danger,"
|
|
165
|
+
" .error-message'"
|
|
166
|
+
")",
|
|
167
|
+
timeout=15000,
|
|
168
|
+
)
|
|
169
|
+
page.wait_for_load_state("networkidle")
|
|
170
|
+
_dbg(debug, page, "05_after_submit", f"url: {page.url}")
|
|
171
|
+
|
|
172
|
+
# Check for error message
|
|
173
|
+
if not code:
|
|
174
|
+
error_el = page.locator(
|
|
175
|
+
".alert-danger, .error-message, [class*='error'], [class*='Error']"
|
|
176
|
+
)
|
|
177
|
+
if error_el.first.is_visible(timeout=2000):
|
|
178
|
+
error_text = error_el.first.text_content()
|
|
179
|
+
_dbg(debug, page, "05b_error")
|
|
180
|
+
browser.close()
|
|
181
|
+
msg = f"Login failed: {error_text}"
|
|
182
|
+
raise AuthError(msg)
|
|
183
|
+
|
|
184
|
+
# Check for verification code prompt
|
|
185
|
+
if not code:
|
|
186
|
+
verify_field = page.locator(
|
|
187
|
+
"[name='VerificationCode'], input[name='code'], input[inputmode='numeric']"
|
|
188
|
+
)
|
|
189
|
+
try:
|
|
190
|
+
if verify_field.first.is_visible(timeout=10000):
|
|
191
|
+
_dbg(debug, page, "06_verify_prompt")
|
|
192
|
+
otp = input("Enter verification code: ").strip()
|
|
193
|
+
verify_field.first.fill(otp)
|
|
194
|
+
page.locator(
|
|
195
|
+
"button[type='submit'], "
|
|
196
|
+
"button:has-text('Submit'), "
|
|
197
|
+
"button:has-text('Verify')"
|
|
198
|
+
).first.click()
|
|
199
|
+
|
|
200
|
+
# Wait for redirect after verify
|
|
201
|
+
with contextlib.suppress(TimeoutError):
|
|
202
|
+
page.wait_for_url("**/callback*", timeout=15000)
|
|
203
|
+
page.wait_for_timeout(3000)
|
|
204
|
+
_dbg(debug, page, "07_after_verify", f"url: {page.url}")
|
|
205
|
+
except Exception as exc:
|
|
206
|
+
if debug:
|
|
207
|
+
print(f"DEBUG verify error: {exc}")
|
|
208
|
+
_dbg(debug, page, "08_error")
|
|
209
|
+
|
|
210
|
+
browser.close()
|
|
211
|
+
|
|
212
|
+
if not code:
|
|
213
|
+
msg = "Login failed: could not extract authorization code"
|
|
214
|
+
raise AuthError(msg)
|
|
215
|
+
|
|
216
|
+
tokens = _exchange_code(code, verifier)
|
|
217
|
+
config.refresh_token = tokens["refresh_token"]
|
|
218
|
+
config.save()
|
|
219
|
+
return tokens["refresh_token"]
|