webget-cli 0.6.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.
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: webget-cli
|
|
3
|
+
Version: 0.6.0
|
|
4
|
+
Summary: Local search + scrape CLI with an HTTP fast path, optional browser fallback, and authenticated session profiles. Zero API keys.
|
|
5
|
+
Author-email: David Tarigan <tarigansdavid@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/DavidPandleton/webget
|
|
8
|
+
Project-URL: Repository, https://github.com/DavidPandleton/webget
|
|
9
|
+
Project-URL: Changelog, https://github.com/DavidPandleton/webget/blob/main/CHANGELOG.md
|
|
10
|
+
Keywords: scraping,crawling,cli,search,duckduckgo,http
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Requires-Dist: ddgs
|
|
23
|
+
Requires-Dist: httpx
|
|
24
|
+
Requires-Dist: trafilatura
|
|
25
|
+
Requires-Dist: html2text
|
|
26
|
+
Provides-Extra: browser
|
|
27
|
+
Requires-Dist: crawl4ai>=0.9; extra == "browser"
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
30
|
+
Requires-Dist: ruff>=0.6; extra == "dev"
|
|
31
|
+
Dynamic: license-file
|
|
32
|
+
|
|
33
|
+
<div align="center">
|
|
34
|
+
|
|
35
|
+
# webget
|
|
36
|
+
|
|
37
|
+
**Local search + scrape CLI. Zero API keys, unlimited usage.**
|
|
38
|
+
|
|
39
|
+
`webget` is a web acquisition layer for agents and scripts: it routes every URL
|
|
40
|
+
through a strategy ladder (HTTP fast path → Crawl4AI browser → optional
|
|
41
|
+
Firecrawl), and reports *provenance* - where the content came from and whether
|
|
42
|
+
the session that fetched it can be trusted.
|
|
43
|
+
|
|
44
|
+
[](https://github.com/DavidPandleton/webget/actions)
|
|
45
|
+
[](https://pypi.org/project/webget-cli/)
|
|
46
|
+
[](https://www.python.org/)
|
|
47
|
+
[](LICENSE)
|
|
48
|
+
[](https://github.com/DavidPandleton/webget)
|
|
49
|
+
|
|
50
|
+
</div>
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## Why
|
|
55
|
+
|
|
56
|
+
Most scraping tools assume one engine. webget assumes the web is messy:
|
|
57
|
+
|
|
58
|
+
```text
|
|
59
|
+
FETCH_AUTO
|
|
60
|
+
├── http → fast path (httpx + trafilatura/html2text), no browser
|
|
61
|
+
├── crawl4ai → Playwright browser, JS rendering, persistent auth sessions
|
|
62
|
+
└── firecrawl → optional cloud fallback (needs WEBGET_FIRECRAWL_KEY)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Every fetch classifies what it hit - `success`, `login_required`, `challenge`,
|
|
66
|
+
`blocked`, or `error` - and reports it in machine-readable JSON. webget never
|
|
67
|
+
pretends an empty page is success, and it never solves CAPTCHAs or evades
|
|
68
|
+
anti-bot systems; it tells you honestly what happened.
|
|
69
|
+
|
|
70
|
+
## Install
|
|
71
|
+
|
|
72
|
+
Requires Python 3.11+.
|
|
73
|
+
|
|
74
|
+
### PyPI (`webget-cli`)
|
|
75
|
+
|
|
76
|
+
The CLI command is `webget`; the PyPI package name is `webget-cli`
|
|
77
|
+
(the bare `webget` name is taken by an unrelated package).
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
# pip - HTTP fast path + search only (no browser)
|
|
81
|
+
pip install webget-cli
|
|
82
|
+
|
|
83
|
+
# pip - full stack with Crawl4AI/Playwright browser fallback
|
|
84
|
+
pip install "webget-cli[browser]"
|
|
85
|
+
|
|
86
|
+
# uv tool - isolated executable on your PATH
|
|
87
|
+
uv tool install webget-cli --with "webget-cli[browser]"
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
After install, `webget` is available as a command:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
webget --help
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Browser runtime (optional)
|
|
97
|
+
|
|
98
|
+
Crawl4AI drives a Playwright Chromium. `pip install "webget-cli[browser]"`
|
|
99
|
+
installs the Python packages; the browser binary itself is downloaded
|
|
100
|
+
separately:
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
python -m playwright install chromium
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Without the browser extra, `webget` still works for search and plain HTTP
|
|
107
|
+
fetches. A fetch that needs the browser (JS rendering, `--profile` sessions,
|
|
108
|
+
`login`) prints a clear warning telling you how to install it.
|
|
109
|
+
|
|
110
|
+
### From source (development)
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
git clone https://github.com/DavidPandleton/webget
|
|
114
|
+
cd webget
|
|
115
|
+
uv pip install -e ".[dev,browser]"
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Usage
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
webget s "rust async runtime" # search DuckDuckGo (top 5)
|
|
122
|
+
webget u https://example.com # scrape (auto: http -> crawl4ai)
|
|
123
|
+
webget su "llm inference" 5 # search + scrape top 5, parallel
|
|
124
|
+
cat urls.txt | webget u - # batch scrape, one browser instance
|
|
125
|
+
webget fetch https://example.com --json # machine-readable result
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Long aliases: `search` = `s`, `fetch` = `u`, `search-fetch` = `su`.
|
|
129
|
+
|
|
130
|
+
### Options
|
|
131
|
+
|
|
132
|
+
| Flag | Meaning |
|
|
133
|
+
|---|---|
|
|
134
|
+
| `-c, --cookies FILE` | Netscape-format cookie file |
|
|
135
|
+
| `--profile NAME` | Persistent browser profile (auth session) |
|
|
136
|
+
| `-H, --header "K: V"` | Extra header (repeatable) |
|
|
137
|
+
| `-n, --max-chars N` | Max output chars (default: 10000 for `u`, 4000 for `su`) |
|
|
138
|
+
| `--limit N` | Result count for `s`/`su` |
|
|
139
|
+
| `-t, --timeout N` | Per-URL timeout seconds (default 20) |
|
|
140
|
+
| `--fresh` | Bypass cache |
|
|
141
|
+
| `--ttl N` | Cache TTL seconds (default 3600) |
|
|
142
|
+
| `--strategy S` | `auto` \| `http` \| `crawl4ai` \| `firecrawl` |
|
|
143
|
+
| `--no-cache` | Don't read or write the disk cache (private fetch) |
|
|
144
|
+
| `--json` | JSON output with metadata |
|
|
145
|
+
|
|
146
|
+
## Authenticated sessions (profiles)
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
# interactive login: browser opens, YOU log in manually, session persists
|
|
150
|
+
webget login https://campus.example --profile campus
|
|
151
|
+
|
|
152
|
+
# list profiles and their session status
|
|
153
|
+
webget profiles
|
|
154
|
+
webget profiles --json
|
|
155
|
+
|
|
156
|
+
# later fetches reuse the session - even on the HTTP fast path
|
|
157
|
+
webget fetch https://campus.example/dashboard --profile campus --json
|
|
158
|
+
|
|
159
|
+
# log out ONE domain, keep the rest of the profile
|
|
160
|
+
webget logout https://campus.example --profile campus
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
`webget login` never stores passwords and never fills forms. A visible
|
|
164
|
+
browser opens, you authenticate yourself, then press Enter in the terminal and
|
|
165
|
+
webget persists the session. Persistent profiles live in
|
|
166
|
+
`~/.local/share/webget/profiles/<name>`; session cookies are exported to
|
|
167
|
+
`storage_state.json` inside the profile after each browser run, so the fast
|
|
168
|
+
path can reuse them. Secrets are never printed.
|
|
169
|
+
|
|
170
|
+
## JSON output
|
|
171
|
+
|
|
172
|
+
`--json` returns a dict keyed by URL, so batch results are easy to inspect:
|
|
173
|
+
|
|
174
|
+
```json
|
|
175
|
+
{
|
|
176
|
+
"https://campus.example/dashboard": {
|
|
177
|
+
"status": "success",
|
|
178
|
+
"method": "crawl4ai",
|
|
179
|
+
"cached": false,
|
|
180
|
+
"attempts": 1,
|
|
181
|
+
"auth": {
|
|
182
|
+
"profile": "campus",
|
|
183
|
+
"authenticated": true,
|
|
184
|
+
"state": "success"
|
|
185
|
+
},
|
|
186
|
+
"error": null
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Status values: `success | login_required | challenge | blocked | error`.
|
|
192
|
+
|
|
193
|
+
## Status detection rules
|
|
194
|
+
|
|
195
|
+
| Signal | State |
|
|
196
|
+
|---|---|
|
|
197
|
+
| Valid content (≥100 chars) | `success` |
|
|
198
|
+
| HTTP 401, login form, 403 + login markers | `login_required` |
|
|
199
|
+
| Cloudflare / CAPTCHA / "verify you are human" | `challenge` |
|
|
200
|
+
| HTTP 403 generic, 429, "access denied" | `blocked` |
|
|
201
|
+
| DNS failure, timeout, unexpected exception | `error` |
|
|
202
|
+
|
|
203
|
+
## Cache
|
|
204
|
+
|
|
205
|
+
Results are cached in `~/.cache/webget/` (sha1 of url + profile + options,
|
|
206
|
+
TTL 1h, eviction at 500 files). The cache is **content-level, not
|
|
207
|
+
strategy-level**, and **isolated per profile** - public, `campus`, and `work`
|
|
208
|
+
fetches never collide. Failures are never cached.
|
|
209
|
+
|
|
210
|
+
> **Privacy note:** cached content is plaintext JSON on disk. If you fetch
|
|
211
|
+
> authenticated/personal pages, use `--no-cache`.
|
|
212
|
+
|
|
213
|
+
## Development
|
|
214
|
+
|
|
215
|
+
```bash
|
|
216
|
+
make dev # install runtime + dev deps
|
|
217
|
+
make test # pytest (pure logic, no network needed)
|
|
218
|
+
make lint # ruff
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
- Single-file Python (`webget_cli.py`), no build step, runs via `uv run`.
|
|
222
|
+
- Lazy imports: `--strategy http` never pays the Crawl4AI import cost.
|
|
223
|
+
- Crawl4AI 0.9.2's `export_storage_state()` is broken (wrong attribute);
|
|
224
|
+
webget works around it by reaching into `browser_manager` directly.
|
|
225
|
+
|
|
226
|
+
## Contributing
|
|
227
|
+
|
|
228
|
+
Found a bug or have an idea? [Open an issue](https://github.com/DavidPandleton/webget/issues/new/choose) - we have templates. Pull requests welcome, see [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
229
|
+
|
|
230
|
+
## License
|
|
231
|
+
|
|
232
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
webget_cli.py,sha256=8uFcOhfzLqsXYVcl5Txx7j9XueC0Hk9Tl9zxFo-m9eE,37850
|
|
2
|
+
webget_cli-0.6.0.dist-info/licenses/LICENSE,sha256=VsYHVIae8MvdYNtxwuoxewPKFzh-WKQTXoEuRBIq88A,1070
|
|
3
|
+
webget_cli-0.6.0.dist-info/METADATA,sha256=nqoJaxcGvbmCG-0SI9mf6_u3xkRcpPn21UgUXcg8_xE,7918
|
|
4
|
+
webget_cli-0.6.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
5
|
+
webget_cli-0.6.0.dist-info/entry_points.txt,sha256=8q2tUS9EHWgGDRTp1TRf-nmfTlS1uxwQvXD6xuUdFfw,43
|
|
6
|
+
webget_cli-0.6.0.dist-info/top_level.txt,sha256=yf3JNfGQPD_QIV_I7Dp-hw475OSSf4O1AASQPlXM4Es,11
|
|
7
|
+
webget_cli-0.6.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 David Tarigan
|
|
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 @@
|
|
|
1
|
+
webget_cli
|
webget_cli.py
ADDED
|
@@ -0,0 +1,1071 @@
|
|
|
1
|
+
#!/usr/bin/env -S uv run python3
|
|
2
|
+
"""webget - local search + scrape, zero API keys, unlimited usage.
|
|
3
|
+
Usage:
|
|
4
|
+
webget s "query" [n] Search via DuckDuckGo (default 5)
|
|
5
|
+
webget u "https://..." Scrape URL -> markdown (HTTP fast path, falls back)
|
|
6
|
+
webget su "query" [n] Search + scrape top n results (default 3, parallel)
|
|
7
|
+
webget s "q" | webget u - Pipe: pass URL from search via stdin
|
|
8
|
+
(multi-line stdin = batch scrape)
|
|
9
|
+
webget login URL --profile X Open browser, log in manually, persist session
|
|
10
|
+
webget profiles [--json] List profiles and session status
|
|
11
|
+
webget logout URL --profile X Clear auth for one domain, keep the rest
|
|
12
|
+
Aliases: search = s, fetch = u, search-fetch = su
|
|
13
|
+
|
|
14
|
+
Options:
|
|
15
|
+
-c, --cookies FILE Netscape-format cookie file (like curl -b)
|
|
16
|
+
--profile NAME Persistent browser profile (auth session) in
|
|
17
|
+
~/.local/share/webget/profiles/<name>
|
|
18
|
+
-H, --header "K: V" Extra header (repeatable)
|
|
19
|
+
-n, --max-chars N Max output chars (default: 10000 for u, 4000 for su)
|
|
20
|
+
--limit N Result count for s/su (default: 5 / 3)
|
|
21
|
+
-t, --timeout N Per-URL timeout in seconds (default: 20)
|
|
22
|
+
--fresh Bypass cache and re-scrape
|
|
23
|
+
--ttl N Cache TTL in seconds (default: 3600)
|
|
24
|
+
--strategy S Fetch strategy: auto|http|crawl4ai|firecrawl (default auto)
|
|
25
|
+
--no-cache Don't read or write the disk cache (private fetch)
|
|
26
|
+
--headless Run login browser without a window (tests/automation)
|
|
27
|
+
--json Output results as JSON with metadata
|
|
28
|
+
(status/method/cached/auth)
|
|
29
|
+
|
|
30
|
+
Status values: success | login_required | challenge | blocked | error
|
|
31
|
+
|
|
32
|
+
Session management:
|
|
33
|
+
webget login never stores passwords and never fills forms. You log in
|
|
34
|
+
yourself in the opened browser window; webget just persists the session.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
import asyncio
|
|
38
|
+
import hashlib
|
|
39
|
+
import json
|
|
40
|
+
import os
|
|
41
|
+
import re
|
|
42
|
+
import sys
|
|
43
|
+
import time
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def parse_cookie_file(path):
|
|
47
|
+
"""Parse Netscape cookie file -> Playwright-compatible cookie dicts."""
|
|
48
|
+
cookies = []
|
|
49
|
+
with open(os.path.expanduser(path)) as f:
|
|
50
|
+
for line in f:
|
|
51
|
+
line = line.strip()
|
|
52
|
+
if not line or line.startswith("#"):
|
|
53
|
+
continue
|
|
54
|
+
parts = line.split("\t")
|
|
55
|
+
if len(parts) < 7:
|
|
56
|
+
continue
|
|
57
|
+
domain, _domain_flag, path_, secure, expires, name, value = parts[:7]
|
|
58
|
+
cookie = {
|
|
59
|
+
"name": name,
|
|
60
|
+
"value": value,
|
|
61
|
+
"domain": domain,
|
|
62
|
+
"path": path_,
|
|
63
|
+
"secure": secure == "TRUE",
|
|
64
|
+
"httpOnly": False,
|
|
65
|
+
}
|
|
66
|
+
if expires not in ("0", "Session", ""):
|
|
67
|
+
try:
|
|
68
|
+
cookie["expires"] = int(expires)
|
|
69
|
+
except ValueError:
|
|
70
|
+
pass
|
|
71
|
+
cookies.append(cookie)
|
|
72
|
+
return cookies
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def parse_headers(raw_headers):
|
|
76
|
+
"""Parse list of 'Key: Value' strings into a dict."""
|
|
77
|
+
headers = {}
|
|
78
|
+
for h in raw_headers:
|
|
79
|
+
if ":" in h:
|
|
80
|
+
k, v = h.split(":", 1)
|
|
81
|
+
headers[k.strip()] = v.strip()
|
|
82
|
+
return headers
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
CACHE_DIR = os.path.expanduser("~/.cache/webget")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _cache_path(url, cookies, headers, max_chars, profile=None):
|
|
89
|
+
key = hashlib.sha1(
|
|
90
|
+
f"{profile or 'public'}|{url}|{max_chars}|"
|
|
91
|
+
f"{json.dumps(cookies or [], sort_keys=True)}|"
|
|
92
|
+
f"{json.dumps(headers or {}, sort_keys=True)}".encode()
|
|
93
|
+
).hexdigest()
|
|
94
|
+
return os.path.join(CACHE_DIR, key + ".json")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def cache_get(url, cookies, headers, max_chars, ttl, fresh=False, profile=None):
|
|
98
|
+
if fresh:
|
|
99
|
+
return None
|
|
100
|
+
p = _cache_path(url, cookies, headers, max_chars, profile)
|
|
101
|
+
if not os.path.exists(p):
|
|
102
|
+
return None
|
|
103
|
+
if time.time() - os.path.getmtime(p) > ttl:
|
|
104
|
+
return None
|
|
105
|
+
try:
|
|
106
|
+
with open(p) as f:
|
|
107
|
+
return json.load(f)
|
|
108
|
+
except (json.JSONDecodeError, OSError):
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def cache_put(url, cookies, headers, max_chars, data, profile=None):
|
|
113
|
+
os.makedirs(CACHE_DIR, exist_ok=True)
|
|
114
|
+
p = _cache_path(url, cookies, headers, max_chars, profile)
|
|
115
|
+
try:
|
|
116
|
+
with open(p, "w") as f:
|
|
117
|
+
json.dump({**data, "fetched_at": time.time()}, f)
|
|
118
|
+
# simple eviction: keep newest 400 of 500
|
|
119
|
+
try:
|
|
120
|
+
files = [
|
|
121
|
+
os.path.join(CACHE_DIR, f) for f in os.listdir(CACHE_DIR) if f.endswith(".json")
|
|
122
|
+
]
|
|
123
|
+
if len(files) > 500:
|
|
124
|
+
files.sort(key=os.path.getmtime)
|
|
125
|
+
for f in files[:-400]:
|
|
126
|
+
os.remove(f)
|
|
127
|
+
except OSError:
|
|
128
|
+
pass
|
|
129
|
+
except OSError:
|
|
130
|
+
pass # cache is best-effort
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def parse_opts(args):
|
|
134
|
+
"""Extract options from positional args list."""
|
|
135
|
+
cookies = None
|
|
136
|
+
headers_list = []
|
|
137
|
+
max_chars = None
|
|
138
|
+
timeout = None
|
|
139
|
+
fresh = False
|
|
140
|
+
ttl = 3600
|
|
141
|
+
json_out = False
|
|
142
|
+
limit = None
|
|
143
|
+
strategy = "auto"
|
|
144
|
+
profile = None
|
|
145
|
+
no_cache = False
|
|
146
|
+
headless = False
|
|
147
|
+
remaining = []
|
|
148
|
+
i = 0
|
|
149
|
+
while i < len(args):
|
|
150
|
+
if args[i] in ("-c", "--cookies") and i + 1 < len(args):
|
|
151
|
+
cookies = parse_cookie_file(args[i + 1])
|
|
152
|
+
i += 2
|
|
153
|
+
elif args[i] == "--profile" and i + 1 < len(args):
|
|
154
|
+
profile = args[i + 1]
|
|
155
|
+
i += 2
|
|
156
|
+
elif args[i] == "--no-cache":
|
|
157
|
+
no_cache = True
|
|
158
|
+
i += 1
|
|
159
|
+
elif args[i] == "--headless":
|
|
160
|
+
headless = True
|
|
161
|
+
i += 1
|
|
162
|
+
elif args[i] in ("-H", "--header") and i + 1 < len(args):
|
|
163
|
+
headers_list.append(args[i + 1])
|
|
164
|
+
i += 2
|
|
165
|
+
elif args[i] in ("-n", "--max-chars") and i + 1 < len(args):
|
|
166
|
+
max_chars = int(args[i + 1])
|
|
167
|
+
i += 2
|
|
168
|
+
elif args[i] == "--limit" and i + 1 < len(args):
|
|
169
|
+
limit = int(args[i + 1])
|
|
170
|
+
i += 2
|
|
171
|
+
elif args[i] in ("-t", "--timeout") and i + 1 < len(args):
|
|
172
|
+
timeout = int(args[i + 1])
|
|
173
|
+
i += 2
|
|
174
|
+
elif args[i] == "--fresh":
|
|
175
|
+
fresh = True
|
|
176
|
+
i += 1
|
|
177
|
+
elif args[i] == "--ttl" and i + 1 < len(args):
|
|
178
|
+
ttl = int(args[i + 1])
|
|
179
|
+
i += 2
|
|
180
|
+
elif args[i] == "--strategy" and i + 1 < len(args):
|
|
181
|
+
strategy = args[i + 1]
|
|
182
|
+
i += 2
|
|
183
|
+
elif args[i] == "--json":
|
|
184
|
+
json_out = True
|
|
185
|
+
i += 1
|
|
186
|
+
else:
|
|
187
|
+
remaining.append(args[i])
|
|
188
|
+
i += 1
|
|
189
|
+
return (
|
|
190
|
+
remaining,
|
|
191
|
+
cookies,
|
|
192
|
+
parse_headers(headers_list),
|
|
193
|
+
max_chars,
|
|
194
|
+
timeout,
|
|
195
|
+
fresh,
|
|
196
|
+
ttl,
|
|
197
|
+
json_out,
|
|
198
|
+
limit,
|
|
199
|
+
strategy,
|
|
200
|
+
profile,
|
|
201
|
+
no_cache,
|
|
202
|
+
headless,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _extract_markdown(html):
|
|
207
|
+
"""Try trafilatura (clean article text) then html2text (full markdown)."""
|
|
208
|
+
try:
|
|
209
|
+
import trafilatura
|
|
210
|
+
|
|
211
|
+
text = trafilatura.extract(html, include_comments=False, include_tables=True)
|
|
212
|
+
if text and len(text.strip()) > 100:
|
|
213
|
+
return text.strip()
|
|
214
|
+
except Exception: # noqa: BLE001, S110 - extraction libs vary; fall through
|
|
215
|
+
pass
|
|
216
|
+
try:
|
|
217
|
+
import html2text
|
|
218
|
+
|
|
219
|
+
h = html2text.HTML2Text()
|
|
220
|
+
h.ignore_links = False
|
|
221
|
+
h.body_width = 0
|
|
222
|
+
md = h.handle(html).strip()
|
|
223
|
+
return md if len(md) > 50 else ""
|
|
224
|
+
except Exception: # noqa: BLE001 - best-effort extraction, empty is fine
|
|
225
|
+
return ""
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
async def fetch_http(url, max_chars, cookies=None, headers=None, timeout=15):
|
|
229
|
+
"""Fast path: plain HTTP GET + local markdown extraction."""
|
|
230
|
+
import httpx
|
|
231
|
+
|
|
232
|
+
hdrs = {
|
|
233
|
+
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
|
234
|
+
"(KHTML, like Gecko) Chrome/126.0 Safari/537.36"
|
|
235
|
+
}
|
|
236
|
+
if headers:
|
|
237
|
+
hdrs.update(headers)
|
|
238
|
+
cj = {}
|
|
239
|
+
if cookies:
|
|
240
|
+
from urllib.parse import urlparse
|
|
241
|
+
|
|
242
|
+
host = (urlparse(url).hostname or "").lower()
|
|
243
|
+
if host:
|
|
244
|
+
for c in cookies:
|
|
245
|
+
d = (c.get("domain") or "").lstrip(".").lower()
|
|
246
|
+
if d and (host == d or host.endswith("." + d)):
|
|
247
|
+
cj[c["name"]] = c["value"]
|
|
248
|
+
async with httpx.AsyncClient(
|
|
249
|
+
follow_redirects=True, timeout=timeout, headers=hdrs, cookies=cj
|
|
250
|
+
) as client:
|
|
251
|
+
r = await client.get(url)
|
|
252
|
+
ctype = r.headers.get("content-type", "")
|
|
253
|
+
if "html" not in ctype and "text" not in ctype:
|
|
254
|
+
raise RuntimeError(f"not HTML ({ctype or 'unknown'})")
|
|
255
|
+
html = r.text
|
|
256
|
+
title = ""
|
|
257
|
+
m = re.search(r"<title[^>]*>(.*?)</title>", html, re.DOTALL | re.IGNORECASE)
|
|
258
|
+
if m:
|
|
259
|
+
title = re.sub(r"\s+", " ", m.group(1)).strip()
|
|
260
|
+
md = await asyncio.to_thread(_extract_markdown, html)
|
|
261
|
+
return {
|
|
262
|
+
"title": title,
|
|
263
|
+
"markdown": md[:max_chars],
|
|
264
|
+
"status_code": r.status_code,
|
|
265
|
+
"html": html[:8000],
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _warn(msg):
|
|
270
|
+
"""Print a warning to stderr so stdout (JSON) stays clean."""
|
|
271
|
+
print(f"webget: warning: {msg}", file=sys.stderr)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def firecrawl_key():
|
|
275
|
+
return os.environ.get("WEBGET_FIRECRAWL_KEY", "").strip()
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
PROFILE_DIR = os.path.expanduser("~/.local/share/webget/profiles")
|
|
279
|
+
_PROFILE_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+$")
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def profile_dir(name):
|
|
283
|
+
"""Return profile dir, rejecting path traversal / weird names."""
|
|
284
|
+
if not name or not _PROFILE_NAME_RE.match(name) or name in (".", ".."):
|
|
285
|
+
raise SystemExit(f"invalid profile name: {name!r}")
|
|
286
|
+
return os.path.join(PROFILE_DIR, name)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def profile_state_path(profile):
|
|
290
|
+
return os.path.join(profile_dir(profile), "storage_state.json")
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def load_profile_cookies(profile):
|
|
294
|
+
"""Load cookies from a profile's exported Playwright storage state."""
|
|
295
|
+
p = profile_state_path(profile)
|
|
296
|
+
if not os.path.exists(p):
|
|
297
|
+
return None
|
|
298
|
+
try:
|
|
299
|
+
with open(p) as f:
|
|
300
|
+
state = json.load(f)
|
|
301
|
+
return state.get("cookies") or []
|
|
302
|
+
except (json.JSONDecodeError, OSError):
|
|
303
|
+
return None
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _auth_state(result, profile):
|
|
307
|
+
"""Classify auth state from observable signals. Returns (state, authenticated)."""
|
|
308
|
+
md = (result.get("markdown") or "").lower()
|
|
309
|
+
html = (result.get("html") or "").lower()
|
|
310
|
+
status = result.get("status_code")
|
|
311
|
+
text = f"{md} {html}"
|
|
312
|
+
|
|
313
|
+
# Challenge markers (Cloudflare, CAPTCHA, etc.) - highest priority.
|
|
314
|
+
challenge_markers = (
|
|
315
|
+
"just a moment",
|
|
316
|
+
"cf-chl",
|
|
317
|
+
"challenge-platform",
|
|
318
|
+
"captcha",
|
|
319
|
+
"hcaptcha",
|
|
320
|
+
"recaptcha",
|
|
321
|
+
"verify you are human",
|
|
322
|
+
"unusual traffic",
|
|
323
|
+
"attention required",
|
|
324
|
+
"cf-error-details",
|
|
325
|
+
)
|
|
326
|
+
if any(m in text for m in challenge_markers):
|
|
327
|
+
return "challenge", None
|
|
328
|
+
|
|
329
|
+
# Login page / form detection.
|
|
330
|
+
has_password_input = "<input" in html and 'type="password"' in html
|
|
331
|
+
login_words = any(w in text for w in ("log in", "login", "sign in", "signin"))
|
|
332
|
+
# Sites like SION use JS show/hide instead of type=password and label
|
|
333
|
+
# fields as "NIM" / "Username". Catch credential-labeled forms too.
|
|
334
|
+
has_credential_labels = "password" in text and any(
|
|
335
|
+
w in text for w in ("nim", "username", "user id", "email")
|
|
336
|
+
)
|
|
337
|
+
has_show_password = "show password" in text
|
|
338
|
+
if status == 401:
|
|
339
|
+
return "login_required", False
|
|
340
|
+
if has_password_input and login_words:
|
|
341
|
+
return "login_required", False
|
|
342
|
+
if has_credential_labels or has_show_password:
|
|
343
|
+
return "login_required", False
|
|
344
|
+
if status == 403:
|
|
345
|
+
# 403 + login/session markers -> login_required; generic 403 -> blocked
|
|
346
|
+
if login_words or "session" in text or "expired" in text:
|
|
347
|
+
return "login_required", False
|
|
348
|
+
return "blocked", None
|
|
349
|
+
if status == 429 or any(w in text for w in ("access denied", "blocked", "forbidden")):
|
|
350
|
+
return "blocked", None
|
|
351
|
+
|
|
352
|
+
if status and status >= 400:
|
|
353
|
+
return "error", None
|
|
354
|
+
# Success. authenticated is only meaningful when a profile session was used.
|
|
355
|
+
return "success", (True if profile else None)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
async def fetch_firecrawl(url, max_chars, key, timeout=30):
|
|
359
|
+
"""Firecrawl escape hatch: POST /v1/scrape, formats markdown."""
|
|
360
|
+
import httpx
|
|
361
|
+
|
|
362
|
+
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
363
|
+
r = await client.post(
|
|
364
|
+
"https://api.firecrawl.dev/v1/scrape",
|
|
365
|
+
headers={"Authorization": f"Bearer {key}"},
|
|
366
|
+
json={"url": url, "formats": ["markdown"]},
|
|
367
|
+
)
|
|
368
|
+
if r.status_code != 200:
|
|
369
|
+
raise RuntimeError(f"Firecrawl HTTP {r.status_code}: {r.text[:200]}")
|
|
370
|
+
data = r.json().get("data") or {}
|
|
371
|
+
md = data.get("markdown", "") or ""
|
|
372
|
+
if not md:
|
|
373
|
+
raise RuntimeError("Firecrawl empty result")
|
|
374
|
+
meta = data.get("metadata", {}) or {}
|
|
375
|
+
return {
|
|
376
|
+
"title": meta.get("title", ""),
|
|
377
|
+
"markdown": md[:max_chars],
|
|
378
|
+
"status_code": r.status_code,
|
|
379
|
+
"html": "",
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _ladder(strategy, key):
|
|
384
|
+
steps = []
|
|
385
|
+
if strategy in ("auto", "http"):
|
|
386
|
+
steps.append("http")
|
|
387
|
+
if strategy in ("auto", "crawl4ai"):
|
|
388
|
+
steps.append("crawl4ai")
|
|
389
|
+
if strategy in ("auto", "firecrawl") and key:
|
|
390
|
+
steps.append("firecrawl")
|
|
391
|
+
elif strategy == "firecrawl" and not key:
|
|
392
|
+
raise SystemExit("WEBGET_FIRECRAWL_KEY not set")
|
|
393
|
+
if not steps:
|
|
394
|
+
raise SystemExit(f"unknown strategy: {strategy}")
|
|
395
|
+
return steps
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _normalize_hit(hit):
|
|
399
|
+
return {
|
|
400
|
+
"title": hit.get("title", ""),
|
|
401
|
+
"markdown": hit.get("markdown", ""),
|
|
402
|
+
"status": "success",
|
|
403
|
+
"method": "cache",
|
|
404
|
+
"cached": True,
|
|
405
|
+
"attempts": 1,
|
|
406
|
+
"error": None,
|
|
407
|
+
"auth": hit.get("auth") or {"profile": None, "authenticated": None, "state": "success"},
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
async def _crawl4ai_once(crawler, cfg, url, per_url_timeout):
|
|
412
|
+
async def attempt():
|
|
413
|
+
task = asyncio.create_task(crawler.arun(url=url, config=cfg))
|
|
414
|
+
task.add_done_callback(lambda t: t.exception() if not t.cancelled() else None)
|
|
415
|
+
r = await asyncio.wait_for(task, timeout=per_url_timeout)
|
|
416
|
+
if not getattr(r, "success", True):
|
|
417
|
+
raise RuntimeError(getattr(r, "error_message", None) or "crawl failed")
|
|
418
|
+
return r
|
|
419
|
+
|
|
420
|
+
# Policy: retry once on timeout / transient failures; don't retry auth errors.
|
|
421
|
+
def _no_retry(e):
|
|
422
|
+
msg = str(e).lower()
|
|
423
|
+
return any(
|
|
424
|
+
m in msg for m in ("401", "403", "challenge", "captcha", "access denied", "forbidden")
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
try:
|
|
428
|
+
r = await attempt()
|
|
429
|
+
return {
|
|
430
|
+
"title": (r.metadata or {}).get("title", ""),
|
|
431
|
+
"markdown": (r.markdown or ""),
|
|
432
|
+
"status_code": getattr(r, "status_code", None),
|
|
433
|
+
"html": (r.html or "")[:8000],
|
|
434
|
+
}
|
|
435
|
+
except Exception as e:
|
|
436
|
+
if _no_retry(e):
|
|
437
|
+
raise
|
|
438
|
+
await asyncio.sleep(2)
|
|
439
|
+
try:
|
|
440
|
+
r2 = await attempt()
|
|
441
|
+
return {
|
|
442
|
+
"title": (r2.metadata or {}).get("title", ""),
|
|
443
|
+
"markdown": (r2.markdown or ""),
|
|
444
|
+
"status_code": getattr(r2, "status_code", None),
|
|
445
|
+
"html": (r2.html or "")[:8000],
|
|
446
|
+
}
|
|
447
|
+
except Exception: # noqa: BLE001 - surface original error, retry already done
|
|
448
|
+
raise RuntimeError(str(e)) from e
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _effective_cookies(cookies, profile):
|
|
452
|
+
"""Explicit --cookies win; otherwise load session cookies from profile."""
|
|
453
|
+
if cookies is not None:
|
|
454
|
+
return cookies
|
|
455
|
+
if not profile:
|
|
456
|
+
return None
|
|
457
|
+
return load_profile_cookies(profile)
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
async def scrape_many(
|
|
461
|
+
urls,
|
|
462
|
+
max_chars=6000,
|
|
463
|
+
per_url_timeout=20,
|
|
464
|
+
cookies=None,
|
|
465
|
+
headers=None,
|
|
466
|
+
ttl=3600,
|
|
467
|
+
fresh=False,
|
|
468
|
+
strategy="auto",
|
|
469
|
+
profile=None,
|
|
470
|
+
no_cache=False,
|
|
471
|
+
):
|
|
472
|
+
steps = _ladder(strategy, firecrawl_key())
|
|
473
|
+
results = {}
|
|
474
|
+
missing = []
|
|
475
|
+
for u in urls:
|
|
476
|
+
hit = None if no_cache else cache_get(u, cookies, headers, max_chars, ttl, fresh, profile)
|
|
477
|
+
if hit:
|
|
478
|
+
results[u] = _normalize_hit(hit)
|
|
479
|
+
else:
|
|
480
|
+
missing.append(u)
|
|
481
|
+
if not missing:
|
|
482
|
+
return results
|
|
483
|
+
|
|
484
|
+
attempts = {u: 0 for u in missing}
|
|
485
|
+
reasons = {u: [] for u in missing}
|
|
486
|
+
pending = list(missing)
|
|
487
|
+
|
|
488
|
+
async def record(url, method, res=None, exc=None):
|
|
489
|
+
"""Classify one strategy result; return success dict or None (keep climbing)."""
|
|
490
|
+
attempts[url] += 1
|
|
491
|
+
if exc is not None or res is None:
|
|
492
|
+
reasons[url].append(("error", method, str(exc or "no result")))
|
|
493
|
+
return None
|
|
494
|
+
state, authenticated = _auth_state(res, profile)
|
|
495
|
+
if state == "success" and len((res.get("markdown") or "").strip()) >= 100:
|
|
496
|
+
auth = {"profile": profile, "authenticated": authenticated, "state": state}
|
|
497
|
+
out = {
|
|
498
|
+
"title": res.get("title", ""),
|
|
499
|
+
"markdown": res.get("markdown", ""),
|
|
500
|
+
"status": "success",
|
|
501
|
+
"method": method,
|
|
502
|
+
"cached": False,
|
|
503
|
+
"attempts": attempts[url],
|
|
504
|
+
"error": None,
|
|
505
|
+
"auth": auth,
|
|
506
|
+
}
|
|
507
|
+
if not no_cache:
|
|
508
|
+
cache_put(url, cookies, headers, max_chars, out, profile)
|
|
509
|
+
return out
|
|
510
|
+
if state == "success":
|
|
511
|
+
reasons[url].append((state, method, "content too thin"))
|
|
512
|
+
else:
|
|
513
|
+
reasons[url].append((state, method, _auth_message(state, profile)))
|
|
514
|
+
return None
|
|
515
|
+
|
|
516
|
+
# Pass 1: HTTP fast path - no browser involved.
|
|
517
|
+
if "http" in steps:
|
|
518
|
+
|
|
519
|
+
async def http_one(url):
|
|
520
|
+
try:
|
|
521
|
+
res = await fetch_http(
|
|
522
|
+
url,
|
|
523
|
+
max_chars,
|
|
524
|
+
_effective_cookies(cookies, profile),
|
|
525
|
+
headers,
|
|
526
|
+
timeout=per_url_timeout,
|
|
527
|
+
)
|
|
528
|
+
return url, await record(url, "http", res=res)
|
|
529
|
+
except TimeoutError:
|
|
530
|
+
return url, await record(url, "http", exc=TimeoutError("timeout"))
|
|
531
|
+
except Exception as e: # noqa: BLE001 - record reason, ladder continues
|
|
532
|
+
return url, await record(url, "http", exc=e)
|
|
533
|
+
|
|
534
|
+
for url, out in await asyncio.gather(*(http_one(u) for u in pending)):
|
|
535
|
+
if out:
|
|
536
|
+
results[url] = out
|
|
537
|
+
pending = [u for u in pending if u not in results]
|
|
538
|
+
|
|
539
|
+
# Pass 2: Crawl4AI browser - only launched if something still needs it.
|
|
540
|
+
if pending and "crawl4ai" in steps:
|
|
541
|
+
try:
|
|
542
|
+
from crawl4ai import AsyncWebCrawler, BrowserConfig, CrawlerRunConfig
|
|
543
|
+
except ImportError:
|
|
544
|
+
_warn(
|
|
545
|
+
"crawl4ai is not installed; install it with "
|
|
546
|
+
"'pip install webget-cli[browser]' or 'uv tool install "
|
|
547
|
+
"webget-cli --with webget-cli[browser]'"
|
|
548
|
+
)
|
|
549
|
+
for url in pending:
|
|
550
|
+
reasons[url].append(("error", "crawl4ai", "crawl4ai not installed"))
|
|
551
|
+
pending = []
|
|
552
|
+
# Fall through to terminal state below.
|
|
553
|
+
|
|
554
|
+
if pending:
|
|
555
|
+
bc = BrowserConfig(
|
|
556
|
+
use_persistent_context=bool(profile),
|
|
557
|
+
user_data_dir=profile_dir(profile) if profile else None,
|
|
558
|
+
cookies=cookies or None,
|
|
559
|
+
headers=headers or None,
|
|
560
|
+
)
|
|
561
|
+
cfg = CrawlerRunConfig()
|
|
562
|
+
async with AsyncWebCrawler(config=bc, verbose=False) as crawler_ctx:
|
|
563
|
+
|
|
564
|
+
async def crawl_one(url):
|
|
565
|
+
try:
|
|
566
|
+
res = await _crawl4ai_once(crawler_ctx, cfg, url, per_url_timeout)
|
|
567
|
+
res["markdown"] = res.get("markdown", "")[:max_chars]
|
|
568
|
+
return url, await record(url, "crawl4ai", res=res)
|
|
569
|
+
except TimeoutError:
|
|
570
|
+
return url, await record(url, "crawl4ai", exc=TimeoutError("timeout"))
|
|
571
|
+
except Exception as e: # noqa: BLE001 - record reason, ladder continues
|
|
572
|
+
return url, await record(url, "crawl4ai", exc=e)
|
|
573
|
+
|
|
574
|
+
for url, out in await asyncio.gather(*(crawl_one(u) for u in pending)):
|
|
575
|
+
if out:
|
|
576
|
+
results[url] = out
|
|
577
|
+
pending = [u for u in pending if u not in results]
|
|
578
|
+
if profile:
|
|
579
|
+
try:
|
|
580
|
+
# Persist session so future HTTP-path fetches can reuse it.
|
|
581
|
+
# Note: crawl4ai's export_storage_state is broken in 0.9.2
|
|
582
|
+
# (accesses self.default_context on the strategy, which
|
|
583
|
+
# lives on browser_manager instead) - go direct.
|
|
584
|
+
bm = crawler_ctx.crawler_strategy.browser_manager
|
|
585
|
+
if bm and bm.default_context is not None:
|
|
586
|
+
await bm.default_context.storage_state(path=profile_state_path(profile))
|
|
587
|
+
else:
|
|
588
|
+
_warn(
|
|
589
|
+
f"profile '{profile}' used but no browser context "
|
|
590
|
+
"available; session will NOT be persisted"
|
|
591
|
+
)
|
|
592
|
+
except Exception as e: # noqa: BLE001 - warn, don't crash the batch
|
|
593
|
+
_warn(f"failed to persist profile session for '{profile}': {e}")
|
|
594
|
+
|
|
595
|
+
# Pass 3: Firecrawl - optional cloud escape hatch.
|
|
596
|
+
if pending and "firecrawl" in steps:
|
|
597
|
+
|
|
598
|
+
async def fc_one(url):
|
|
599
|
+
try:
|
|
600
|
+
res = await fetch_firecrawl(
|
|
601
|
+
url, max_chars, firecrawl_key(), timeout=per_url_timeout
|
|
602
|
+
)
|
|
603
|
+
return url, await record(url, "firecrawl", res=res)
|
|
604
|
+
except Exception as e: # noqa: BLE001 - record reason, ladder continues
|
|
605
|
+
return url, await record(url, "firecrawl", exc=e)
|
|
606
|
+
|
|
607
|
+
for url, out in await asyncio.gather(*(fc_one(u) for u in pending)):
|
|
608
|
+
if out:
|
|
609
|
+
results[url] = out
|
|
610
|
+
pending = [u for u in pending if u not in results]
|
|
611
|
+
|
|
612
|
+
# Ladder exhausted for the rest: terminal state from all recorded reasons.
|
|
613
|
+
for url in pending:
|
|
614
|
+
state, authenticated, detail = _terminal_state(reasons[url], profile)
|
|
615
|
+
auth = {"profile": profile, "authenticated": authenticated, "state": state}
|
|
616
|
+
results[url] = {
|
|
617
|
+
"title": "",
|
|
618
|
+
"markdown": "",
|
|
619
|
+
"status": state,
|
|
620
|
+
"method": steps[-1],
|
|
621
|
+
"cached": False,
|
|
622
|
+
"attempts": attempts[url],
|
|
623
|
+
"error": detail,
|
|
624
|
+
"auth": auth,
|
|
625
|
+
}
|
|
626
|
+
return results
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def _terminal_state(reasons, profile):
|
|
630
|
+
"""Pick final state from ladder reasons. Priority: challenge > login_required > blocked > error."""
|
|
631
|
+
order = ("challenge", "login_required", "blocked", "error")
|
|
632
|
+
for wanted in order:
|
|
633
|
+
for state, method, detail in reasons:
|
|
634
|
+
if state == wanted:
|
|
635
|
+
authenticated = None
|
|
636
|
+
if state == "login_required":
|
|
637
|
+
authenticated = False
|
|
638
|
+
return state, authenticated, detail
|
|
639
|
+
return "error", None, "; ".join(d for _, _, d in reasons)
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def _auth_message(state, profile):
|
|
643
|
+
if state == "login_required":
|
|
644
|
+
return f"Authenticated session appears expired or unavailable (profile={profile or 'none'})"
|
|
645
|
+
if state == "challenge":
|
|
646
|
+
return "Site requires human verification; automatic acquisition stopped"
|
|
647
|
+
if state == "blocked":
|
|
648
|
+
return "Access blocked by the site (403/429 or access-denied page)"
|
|
649
|
+
return "Unknown fetch failure"
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def search(query, n=5):
|
|
653
|
+
from ddgs import DDGS
|
|
654
|
+
|
|
655
|
+
return [
|
|
656
|
+
{"title": r["title"], "url": r["href"], "snippet": r.get("body", "")}
|
|
657
|
+
for r in DDGS().text(query, max_results=n)
|
|
658
|
+
]
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def _read_json(path):
|
|
662
|
+
"""Sync helper for to_thread: read + parse JSON, raises on bad data."""
|
|
663
|
+
with open(path) as f:
|
|
664
|
+
return json.load(f)
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def _write_json(path, data):
|
|
668
|
+
"""Sync helper for to_thread: write JSON."""
|
|
669
|
+
with open(path, "w") as f:
|
|
670
|
+
json.dump(data, f)
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
# ---------- Phase 4: session management UX ----------
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def _valid_site_url(raw):
|
|
677
|
+
"""Validate a user-supplied site URL. Returns (ok, hostname_or_error)."""
|
|
678
|
+
from urllib.parse import urlparse
|
|
679
|
+
|
|
680
|
+
if not raw or raw.isspace():
|
|
681
|
+
return False, "no site URL given"
|
|
682
|
+
if "://" not in raw:
|
|
683
|
+
raw = "https://" + raw
|
|
684
|
+
u = urlparse(raw)
|
|
685
|
+
if u.scheme not in ("http", "https"):
|
|
686
|
+
return False, f"unsupported scheme: {u.scheme or 'none'}"
|
|
687
|
+
if not u.hostname:
|
|
688
|
+
return False, f"could not parse hostname from {raw!r}"
|
|
689
|
+
host = u.hostname.lower()
|
|
690
|
+
# Reject spaces / anything that is not a plausible hostname or IP.
|
|
691
|
+
if any(ch.isspace() for ch in host):
|
|
692
|
+
return False, f"invalid hostname: {host!r}"
|
|
693
|
+
if not all(ch.isalnum() or ch in ".-_" for ch in host):
|
|
694
|
+
return False, f"invalid hostname: {host!r}"
|
|
695
|
+
return True, host
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def _profile_meta(profile):
|
|
699
|
+
"""Non-sensitive metadata about one profile. Never returns cookie values."""
|
|
700
|
+
d = profile_dir(profile)
|
|
701
|
+
state_p = profile_state_path(profile)
|
|
702
|
+
last_used = None
|
|
703
|
+
status = "unknown"
|
|
704
|
+
size = 0
|
|
705
|
+
for dirpath, _dirnames, filenames in os.walk(d):
|
|
706
|
+
for f in filenames:
|
|
707
|
+
fp = os.path.join(dirpath, f)
|
|
708
|
+
try:
|
|
709
|
+
size += os.path.getsize(fp)
|
|
710
|
+
except OSError:
|
|
711
|
+
pass
|
|
712
|
+
mtime = os.path.getmtime(fp)
|
|
713
|
+
if last_used is None or mtime > last_used:
|
|
714
|
+
last_used = mtime
|
|
715
|
+
if os.path.exists(state_p):
|
|
716
|
+
try:
|
|
717
|
+
with open(state_p) as f:
|
|
718
|
+
state = json.load(f)
|
|
719
|
+
cookies = state.get("cookies") or []
|
|
720
|
+
if cookies:
|
|
721
|
+
now = time.time()
|
|
722
|
+
# Treat cookie as live if it has no expiry or expires in the future.
|
|
723
|
+
live = [
|
|
724
|
+
c
|
|
725
|
+
for c in cookies
|
|
726
|
+
if (c.get("expires") or -1) < 0 or (c.get("expires") or 0) > now
|
|
727
|
+
]
|
|
728
|
+
status = "authenticated" if live else "expired"
|
|
729
|
+
except (json.JSONDecodeError, OSError):
|
|
730
|
+
status = "corrupt"
|
|
731
|
+
return {"profile": profile, "last_used": last_used, "size": size, "status": status}
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def _fmt_age(ts):
|
|
735
|
+
if ts is None:
|
|
736
|
+
return "-"
|
|
737
|
+
diff = time.time() - ts
|
|
738
|
+
if diff < 60:
|
|
739
|
+
return f"{int(diff)}s ago"
|
|
740
|
+
if diff < 3600:
|
|
741
|
+
return f"{int(diff // 60)}m ago"
|
|
742
|
+
if diff < 86400:
|
|
743
|
+
return f"{int(diff // 3600)}h ago"
|
|
744
|
+
return f"{int(diff // 86400)}d ago"
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
def cmd_profiles(json_out):
|
|
748
|
+
if not os.path.isdir(PROFILE_DIR):
|
|
749
|
+
if json_out:
|
|
750
|
+
print("{}")
|
|
751
|
+
else:
|
|
752
|
+
print("PROFILE LAST USED SIZE STATUS\n(no profiles yet)")
|
|
753
|
+
return
|
|
754
|
+
profiles = []
|
|
755
|
+
for name in sorted(os.listdir(PROFILE_DIR)):
|
|
756
|
+
if not os.path.isdir(profile_dir(name)):
|
|
757
|
+
continue
|
|
758
|
+
try:
|
|
759
|
+
profile_dir(name) # validates name; raises SystemExit on bad names
|
|
760
|
+
meta = _profile_meta(name)
|
|
761
|
+
if meta["size"] > 0 or meta["status"] != "unknown":
|
|
762
|
+
profiles.append(meta)
|
|
763
|
+
except SystemExit:
|
|
764
|
+
# Skip malformed dir names that fail validation (e.g. "..").
|
|
765
|
+
continue
|
|
766
|
+
if json_out:
|
|
767
|
+
print(json.dumps({p["profile"]: p for p in profiles}, indent=2))
|
|
768
|
+
return
|
|
769
|
+
print(f"{'PROFILE':<12} {'LAST USED':<12} {'SIZE':>10} STATUS")
|
|
770
|
+
for p in profiles:
|
|
771
|
+
print(
|
|
772
|
+
f"{p['profile']:<12} {_fmt_age(p['last_used']):<12} "
|
|
773
|
+
f"{p['size'] / 1024 / 1024:>8.1f} MB {p['status']}"
|
|
774
|
+
)
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
async def _login_flow(site, profile, headless):
|
|
778
|
+
from playwright.async_api import async_playwright
|
|
779
|
+
|
|
780
|
+
state_p = profile_state_path(profile)
|
|
781
|
+
os.makedirs(profile_dir(profile), exist_ok=True)
|
|
782
|
+
|
|
783
|
+
async with async_playwright() as p:
|
|
784
|
+
context = await p.chromium.launch_persistent_context(
|
|
785
|
+
user_data_dir=profile_dir(profile),
|
|
786
|
+
headless=headless,
|
|
787
|
+
)
|
|
788
|
+
page = context.pages[0] if context.pages else await context.new_page()
|
|
789
|
+
print(f"Opening {site} in a browser with profile '{profile}'...")
|
|
790
|
+
print("Log in manually in the browser window.")
|
|
791
|
+
print("When you are done, come back here and press Enter.")
|
|
792
|
+
try:
|
|
793
|
+
await page.goto(site, wait_until="domcontentloaded", timeout=60000)
|
|
794
|
+
except Exception as e: # noqa: BLE001 - navigation issues shouldn't kill login
|
|
795
|
+
_warn(f"could not navigate to {site}: {e}")
|
|
796
|
+
try:
|
|
797
|
+
await asyncio.to_thread(input, "Press Enter when done: ")
|
|
798
|
+
except EOFError:
|
|
799
|
+
pass # non-interactive stdin (tests) - proceed immediately
|
|
800
|
+
try:
|
|
801
|
+
await context.storage_state(path=state_p)
|
|
802
|
+
print(f"Session persisted for profile '{profile}'.")
|
|
803
|
+
except Exception as e: # noqa: BLE001 - persistence must surface
|
|
804
|
+
_warn(f"failed to persist profile session for '{profile}': {e}")
|
|
805
|
+
await context.close()
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
def cmd_login(site, profile, headless):
|
|
809
|
+
ok, host = _valid_site_url(site)
|
|
810
|
+
if not ok:
|
|
811
|
+
print(f"error: invalid site URL: {host}")
|
|
812
|
+
return 2
|
|
813
|
+
if not profile:
|
|
814
|
+
print("error: --profile NAME is required for login")
|
|
815
|
+
return 2
|
|
816
|
+
try:
|
|
817
|
+
profile_dir(profile)
|
|
818
|
+
except SystemExit as e:
|
|
819
|
+
print(f"error: {e}")
|
|
820
|
+
return 2
|
|
821
|
+
print(f"webget login: profile '{profile}' for {host}")
|
|
822
|
+
asyncio.run(_login_flow(site, profile, headless))
|
|
823
|
+
return 0
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
def _domain_match(cookie_domain, host):
|
|
827
|
+
"""True if cookie_domain (e.g. '.example.com') covers host."""
|
|
828
|
+
d = (cookie_domain or "").lstrip(".").lower()
|
|
829
|
+
h = host.lower()
|
|
830
|
+
return d == h or h.endswith("." + d)
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
def _cookie_belongs_to(cookie_domain, host):
|
|
834
|
+
"""True if cookie_domain is host itself or a subdomain of host.
|
|
835
|
+
|
|
836
|
+
Used for logout pruning: logging out 'campus.example' must also clear
|
|
837
|
+
cookies from '.api.campus.example' (subdomains), while keeping
|
|
838
|
+
'github.com' untouched.
|
|
839
|
+
"""
|
|
840
|
+
d = (cookie_domain or "").lstrip(".").lower()
|
|
841
|
+
h = host.lower()
|
|
842
|
+
return d == h or d.endswith("." + h)
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
def _logout_domain_regex(host):
|
|
846
|
+
"""Regex matching host plus subdomains for Playwright clear_cookies.
|
|
847
|
+
|
|
848
|
+
Matches 'campus.example', '.campus.example', 'api.campus.example',
|
|
849
|
+
'.api.campus.example' but never 'github.com' or 'notevil.com'.
|
|
850
|
+
"""
|
|
851
|
+
import re
|
|
852
|
+
|
|
853
|
+
return re.compile(r"^(\.)?([^.]+\.)*" + re.escape(host) + r"$")
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
async def _logout_flow(site, profile):
|
|
857
|
+
from playwright.async_api import async_playwright
|
|
858
|
+
|
|
859
|
+
ok, host = _valid_site_url(site)
|
|
860
|
+
if not ok:
|
|
861
|
+
return False, host
|
|
862
|
+
state_p = profile_state_path(profile)
|
|
863
|
+
removed = 0
|
|
864
|
+
# 1. Prune the exported storage state (used by the HTTP fast path).
|
|
865
|
+
if os.path.exists(state_p):
|
|
866
|
+
try:
|
|
867
|
+
state = await asyncio.to_thread(_read_json, state_p)
|
|
868
|
+
cookies = state.get("cookies") or []
|
|
869
|
+
kept = [c for c in cookies if not _cookie_belongs_to(c.get("domain", ""), host)]
|
|
870
|
+
removed = len(cookies) - len(kept)
|
|
871
|
+
state["cookies"] = kept
|
|
872
|
+
await asyncio.to_thread(_write_json, state_p, state)
|
|
873
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
874
|
+
return False, f"could not read profile storage state: {e}"
|
|
875
|
+
# 2. Clear the live browser context for that domain (persistent profile).
|
|
876
|
+
async with async_playwright() as p:
|
|
877
|
+
context = await p.chromium.launch_persistent_context(
|
|
878
|
+
user_data_dir=profile_dir(profile), headless=True
|
|
879
|
+
)
|
|
880
|
+
try:
|
|
881
|
+
# Read cookies BEFORE clearing so we can report accurately.
|
|
882
|
+
all_cookies = await context.cookies()
|
|
883
|
+
before = len(all_cookies)
|
|
884
|
+
# Playwright's clear_cookies accepts a regex domain filter. We
|
|
885
|
+
# match host itself plus subdomains (leading-dot cookies like
|
|
886
|
+
# '.campus.example' and '.api.campus.example'), while never
|
|
887
|
+
# touching unrelated domains. No global clear: session cookies
|
|
888
|
+
# survive because we never remove them.
|
|
889
|
+
await context.clear_cookies(domain=_logout_domain_regex(host))
|
|
890
|
+
after_cookies = await context.cookies()
|
|
891
|
+
removed += before - len(after_cookies)
|
|
892
|
+
except Exception as e: # noqa: BLE001 - warn, still done
|
|
893
|
+
_warn(f"could not clear browser cookies for {host}: {e}")
|
|
894
|
+
await context.close()
|
|
895
|
+
return True, removed
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
def cmd_logout(site, profile):
|
|
899
|
+
if not profile:
|
|
900
|
+
print("error: --profile NAME is required for logout")
|
|
901
|
+
return 2
|
|
902
|
+
try:
|
|
903
|
+
profile_dir(profile)
|
|
904
|
+
except SystemExit as e:
|
|
905
|
+
print(f"error: {e}")
|
|
906
|
+
return 2
|
|
907
|
+
ok, host_or_err = _valid_site_url(site)
|
|
908
|
+
if not ok:
|
|
909
|
+
print(f"error: invalid site URL: {host_or_err}")
|
|
910
|
+
return 2
|
|
911
|
+
ok, removed = asyncio.run(_logout_flow(site, profile))
|
|
912
|
+
if not ok:
|
|
913
|
+
print(f"error: {removed}")
|
|
914
|
+
return 1
|
|
915
|
+
print(f"Logged out {host_or_err} from profile '{profile}' ({removed} cookies cleared).")
|
|
916
|
+
print("Other domains in this profile were left untouched.")
|
|
917
|
+
return 0
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
def main():
|
|
921
|
+
args = sys.argv[1:]
|
|
922
|
+
if not args or args[0] in ("-h", "--help"):
|
|
923
|
+
print(__doc__)
|
|
924
|
+
return
|
|
925
|
+
|
|
926
|
+
(
|
|
927
|
+
args,
|
|
928
|
+
cookies,
|
|
929
|
+
headers,
|
|
930
|
+
max_chars_override,
|
|
931
|
+
timeout_override,
|
|
932
|
+
fresh,
|
|
933
|
+
ttl,
|
|
934
|
+
json_out,
|
|
935
|
+
limit,
|
|
936
|
+
strategy,
|
|
937
|
+
profile,
|
|
938
|
+
no_cache,
|
|
939
|
+
headless,
|
|
940
|
+
) = parse_opts(args)
|
|
941
|
+
|
|
942
|
+
if not args:
|
|
943
|
+
print(__doc__)
|
|
944
|
+
return
|
|
945
|
+
|
|
946
|
+
if profile:
|
|
947
|
+
try:
|
|
948
|
+
os.makedirs(profile_dir(profile), exist_ok=True)
|
|
949
|
+
except OSError as e:
|
|
950
|
+
_warn(f"cannot create profile dir for '{profile}': {e}")
|
|
951
|
+
|
|
952
|
+
cmd = args[0]
|
|
953
|
+
if cmd == "search":
|
|
954
|
+
cmd = "s"
|
|
955
|
+
elif cmd == "fetch":
|
|
956
|
+
cmd = "u"
|
|
957
|
+
elif cmd == "search-fetch":
|
|
958
|
+
cmd = "su"
|
|
959
|
+
q = args[1] if len(args) > 1 else ""
|
|
960
|
+
|
|
961
|
+
if cmd == "login":
|
|
962
|
+
sys.exit(cmd_login(q, profile, headless))
|
|
963
|
+
elif cmd == "profiles":
|
|
964
|
+
cmd_profiles(json_out)
|
|
965
|
+
return
|
|
966
|
+
elif cmd == "logout":
|
|
967
|
+
sys.exit(cmd_logout(q, profile))
|
|
968
|
+
|
|
969
|
+
if cmd == "s":
|
|
970
|
+
n = limit or (int(args[2]) if len(args) > 2 else 5)
|
|
971
|
+
for i, r in enumerate(search(q, n=n)):
|
|
972
|
+
print(f"{i + 1}. {r['title']}\n {r['url']}\n {r['snippet'][:200]}\n")
|
|
973
|
+
|
|
974
|
+
elif cmd == "u":
|
|
975
|
+
if q == "-":
|
|
976
|
+
urls = [line.strip() for line in sys.stdin if line.strip()]
|
|
977
|
+
else:
|
|
978
|
+
urls = [q]
|
|
979
|
+
max_chars = max_chars_override or 10000
|
|
980
|
+
timeout = timeout_override or 20
|
|
981
|
+
res = asyncio.run(
|
|
982
|
+
scrape_many(
|
|
983
|
+
urls,
|
|
984
|
+
max_chars=max_chars,
|
|
985
|
+
per_url_timeout=timeout,
|
|
986
|
+
cookies=cookies,
|
|
987
|
+
headers=headers,
|
|
988
|
+
ttl=ttl,
|
|
989
|
+
fresh=fresh,
|
|
990
|
+
strategy=strategy,
|
|
991
|
+
profile=profile,
|
|
992
|
+
no_cache=no_cache,
|
|
993
|
+
)
|
|
994
|
+
)
|
|
995
|
+
if json_out:
|
|
996
|
+
print(json.dumps(res, indent=2))
|
|
997
|
+
return
|
|
998
|
+
for u_ in urls:
|
|
999
|
+
r = res.get(u_, {})
|
|
1000
|
+
tag = r.get("method", "")
|
|
1001
|
+
stat = r.get("status", "")
|
|
1002
|
+
if stat != "success":
|
|
1003
|
+
auth = r.get("auth") or {}
|
|
1004
|
+
print(f"# {u_} [{stat}/{tag}]")
|
|
1005
|
+
print(f"status={stat}")
|
|
1006
|
+
print(f"profile={auth.get('profile') or 'none'}")
|
|
1007
|
+
print(f"message={r.get('error') or 'unknown'}")
|
|
1008
|
+
continue
|
|
1009
|
+
print(f"# {r.get('title', '')} [{stat}/{tag}]")
|
|
1010
|
+
if r.get("error"):
|
|
1011
|
+
print(f"ERROR: {r['error']}")
|
|
1012
|
+
print(f"{r.get('markdown', '')}\n")
|
|
1013
|
+
|
|
1014
|
+
elif cmd == "su":
|
|
1015
|
+
n = limit or (int(args[2]) if len(args) > 2 else 3)
|
|
1016
|
+
max_chars = max_chars_override or 4000
|
|
1017
|
+
timeout = timeout_override or 20
|
|
1018
|
+
results = search(q, n=n)
|
|
1019
|
+
urls = [r["url"] for r in results]
|
|
1020
|
+
scraped = asyncio.run(
|
|
1021
|
+
scrape_many(
|
|
1022
|
+
urls,
|
|
1023
|
+
max_chars=max_chars,
|
|
1024
|
+
per_url_timeout=timeout,
|
|
1025
|
+
cookies=cookies,
|
|
1026
|
+
headers=headers,
|
|
1027
|
+
ttl=ttl,
|
|
1028
|
+
fresh=fresh,
|
|
1029
|
+
strategy=strategy,
|
|
1030
|
+
profile=profile,
|
|
1031
|
+
no_cache=no_cache,
|
|
1032
|
+
)
|
|
1033
|
+
)
|
|
1034
|
+
if json_out:
|
|
1035
|
+
out = {}
|
|
1036
|
+
for i, r in enumerate(results):
|
|
1037
|
+
got = scraped.get(r["url"], {})
|
|
1038
|
+
out[r["url"]] = {
|
|
1039
|
+
"rank": i + 1,
|
|
1040
|
+
"search_title": r["title"],
|
|
1041
|
+
"snippet": r.get("snippet", ""),
|
|
1042
|
+
"scrape_title": got.get("title", ""),
|
|
1043
|
+
"markdown": got.get("markdown", ""),
|
|
1044
|
+
"status": got.get("status", ""),
|
|
1045
|
+
"method": got.get("method", ""),
|
|
1046
|
+
"cached": got.get("cached", False),
|
|
1047
|
+
"attempts": got.get("attempts", 0),
|
|
1048
|
+
"error": got.get("error"),
|
|
1049
|
+
"auth": got.get("auth")
|
|
1050
|
+
or {"profile": None, "authenticated": None, "state": got.get("status", "")},
|
|
1051
|
+
}
|
|
1052
|
+
print(json.dumps(out, indent=2))
|
|
1053
|
+
return
|
|
1054
|
+
for i, r in enumerate(results):
|
|
1055
|
+
got = scraped.get(r["url"], {})
|
|
1056
|
+
stat = got.get("status", "")
|
|
1057
|
+
method = got.get("method", "")
|
|
1058
|
+
err = got.get("error") or ""
|
|
1059
|
+
fail = stat != "success"
|
|
1060
|
+
print(
|
|
1061
|
+
f"\n{'=' * 60}\n## {i + 1}. {r['title']} [{stat}/{method}]{' ' + err if err else ''}\n{'=' * 60}"
|
|
1062
|
+
)
|
|
1063
|
+
print(f"URL: {r['url']}")
|
|
1064
|
+
print(got.get("markdown", "")[:max_chars] if not fail else "(no content)")
|
|
1065
|
+
|
|
1066
|
+
else:
|
|
1067
|
+
print("Unknown command:", cmd)
|
|
1068
|
+
|
|
1069
|
+
|
|
1070
|
+
if __name__ == "__main__":
|
|
1071
|
+
main()
|