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
+ [![CI](https://img.shields.io/github/actions/workflow/status/DavidPandleton/webget/ci.yml?label=CI&logo=github)](https://github.com/DavidPandleton/webget/actions)
45
+ [![PyPI](https://img.shields.io/pypi/v/webget-cli.svg)](https://pypi.org/project/webget-cli/)
46
+ [![Python](https://img.shields.io/badge/python-3.11%2B-blue?logo=python&logoColor=white)](https://www.python.org/)
47
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
48
+ [![Stars](https://img.shields.io/github/stars/DavidPandleton/webget?style=social)](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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ webget = webget_cli:main
@@ -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()