webget-cli 0.9.0__tar.gz → 0.10.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {webget_cli-0.9.0 → webget_cli-0.10.0}/PKG-INFO +1 -1
- {webget_cli-0.9.0 → webget_cli-0.10.0}/pyproject.toml +3 -2
- {webget_cli-0.9.0 → webget_cli-0.10.0}/tests/test_adversarial_mcp.py +5 -5
- {webget_cli-0.9.0 → webget_cli-0.10.0}/tests/test_login_flow.py +1 -1
- {webget_cli-0.9.0 → webget_cli-0.10.0}/tests/test_mcp_leak_review.py +2 -2
- {webget_cli-0.9.0 → webget_cli-0.10.0}/tests/test_mcp_profile.py +3 -3
- {webget_cli-0.9.0 → webget_cli-0.10.0}/tests/test_mcp_server.py +8 -8
- webget_cli-0.10.0/webget/__init__.py +144 -0
- webget_cli-0.10.0/webget/cache.py +187 -0
- webget_cli-0.10.0/webget/cli.py +377 -0
- webget_cli-0.10.0/webget/firecrawl.py +60 -0
- webget_cli-0.10.0/webget/http.py +175 -0
- webget_cli-0.10.0/webget/ladder.py +576 -0
- webget_cli-0.10.0/webget/profile.py +409 -0
- webget_cli-0.10.0/webget/search.py +37 -0
- webget_cli-0.10.0/webget/ssrf.py +232 -0
- {webget_cli-0.9.0 → webget_cli-0.10.0}/webget_cli.egg-info/PKG-INFO +1 -1
- {webget_cli-0.9.0 → webget_cli-0.10.0}/webget_cli.egg-info/SOURCES.txt +9 -0
- {webget_cli-0.9.0 → webget_cli-0.10.0}/webget_cli.egg-info/entry_points.txt +1 -1
- {webget_cli-0.9.0 → webget_cli-0.10.0}/webget_cli.egg-info/top_level.txt +1 -0
- webget_cli-0.10.0/webget_cli.py +48 -0
- webget_cli-0.9.0/webget_cli.py +0 -1762
- {webget_cli-0.9.0 → webget_cli-0.10.0}/LICENSE +0 -0
- {webget_cli-0.9.0 → webget_cli-0.10.0}/README.md +0 -0
- {webget_cli-0.9.0 → webget_cli-0.10.0}/setup.cfg +0 -0
- {webget_cli-0.9.0 → webget_cli-0.10.0}/tests/test_adversarial_auth.py +0 -0
- {webget_cli-0.9.0 → webget_cli-0.10.0}/tests/test_adversarial_cache.py +0 -0
- {webget_cli-0.9.0 → webget_cli-0.10.0}/tests/test_adversarial_concurrency.py +0 -0
- {webget_cli-0.9.0 → webget_cli-0.10.0}/tests/test_adversarial_http.py +0 -0
- {webget_cli-0.9.0 → webget_cli-0.10.0}/tests/test_adversarial_ssrf.py +0 -0
- {webget_cli-0.9.0 → webget_cli-0.10.0}/tests/test_auth_review.py +0 -0
- {webget_cli-0.9.0 → webget_cli-0.10.0}/tests/test_browser_ssrf.py +0 -0
- {webget_cli-0.9.0 → webget_cli-0.10.0}/tests/test_cache_review.py +0 -0
- {webget_cli-0.9.0 → webget_cli-0.10.0}/tests/test_concurrency_review.py +0 -0
- {webget_cli-0.9.0 → webget_cli-0.10.0}/tests/test_extraction_markdown.py +0 -0
- {webget_cli-0.9.0 → webget_cli-0.10.0}/tests/test_firecrawl_policy.py +0 -0
- {webget_cli-0.9.0 → webget_cli-0.10.0}/tests/test_integration_ladder.py +0 -0
- {webget_cli-0.9.0 → webget_cli-0.10.0}/tests/test_mcp_smoke.py +0 -0
- {webget_cli-0.9.0 → webget_cli-0.10.0}/tests/test_security_review.py +0 -0
- {webget_cli-0.9.0 → webget_cli-0.10.0}/tests/test_size_review.py +0 -0
- {webget_cli-0.9.0 → webget_cli-0.10.0}/tests/test_webget.py +0 -0
- {webget_cli-0.9.0 → webget_cli-0.10.0}/webget_cli.egg-info/dependency_links.txt +0 -0
- {webget_cli-0.9.0 → webget_cli-0.10.0}/webget_cli.egg-info/requires.txt +0 -0
- {webget_cli-0.9.0 → webget_cli-0.10.0}/webget_mcp.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: webget-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.10.0
|
|
4
4
|
Summary: Local search + scrape CLI with an HTTP fast path, optional browser fallback, and authenticated session profiles. Zero API keys.
|
|
5
5
|
Author-email: David Tarigan <tarigansdavid@gmail.com>
|
|
6
6
|
License: Apache-2.0
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "webget-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.10.0"
|
|
8
8
|
description = "Local search + scrape CLI with an HTTP fast path, optional browser fallback, and authenticated session profiles. Zero API keys."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -39,11 +39,12 @@ mcp = ["fastmcp>=2"]
|
|
|
39
39
|
dev = ["pytest>=8", "ruff>=0.6"]
|
|
40
40
|
|
|
41
41
|
[project.scripts]
|
|
42
|
-
webget = "
|
|
42
|
+
webget = "webget.cli:main"
|
|
43
43
|
webget-mcp = "webget_mcp:main"
|
|
44
44
|
|
|
45
45
|
[tool.setuptools]
|
|
46
46
|
py-modules = ["webget_cli", "webget_mcp"]
|
|
47
|
+
packages = ["webget"]
|
|
47
48
|
|
|
48
49
|
[tool.pytest.ini_options]
|
|
49
50
|
testpaths = ["tests"]
|
|
@@ -58,7 +58,7 @@ class TestMalformedArguments:
|
|
|
58
58
|
return res
|
|
59
59
|
|
|
60
60
|
res = _run(run())
|
|
61
|
-
assert res.
|
|
61
|
+
assert res.isError
|
|
62
62
|
|
|
63
63
|
|
|
64
64
|
class TestInvalidURLs:
|
|
@@ -72,7 +72,7 @@ class TestInvalidURLs:
|
|
|
72
72
|
return res
|
|
73
73
|
|
|
74
74
|
res = _run(run())
|
|
75
|
-
assert "error" in res.content[0].text or res.
|
|
75
|
+
assert "error" in res.content[0].text or res.isError
|
|
76
76
|
|
|
77
77
|
|
|
78
78
|
class TestRepeatedCalls:
|
|
@@ -86,7 +86,7 @@ class TestRepeatedCalls:
|
|
|
86
86
|
"fetch",
|
|
87
87
|
{"url": "https://example.com", "strategy": "http", "no_cache": True},
|
|
88
88
|
)
|
|
89
|
-
if res.
|
|
89
|
+
if res.isError:
|
|
90
90
|
return "ERROR"
|
|
91
91
|
return "OK"
|
|
92
92
|
|
|
@@ -106,7 +106,7 @@ class TestRepeatedCalls:
|
|
|
106
106
|
for _ in range(5)
|
|
107
107
|
]
|
|
108
108
|
)
|
|
109
|
-
return [r.
|
|
109
|
+
return [r.isError for r in results]
|
|
110
110
|
|
|
111
111
|
assert _run(run()) == [False] * 5
|
|
112
112
|
|
|
@@ -133,7 +133,7 @@ class TestInputCaps:
|
|
|
133
133
|
return res
|
|
134
134
|
|
|
135
135
|
res = _run(run())
|
|
136
|
-
assert "must be between" in res.content[0].text or res.
|
|
136
|
+
assert "must be between" in res.content[0].text or res.isError
|
|
137
137
|
|
|
138
138
|
|
|
139
139
|
class TestToolFailureIsolation:
|
|
@@ -133,7 +133,7 @@ def test_mcp_login_tool_persists_and_fetch_uses_it(server, tmp_path):
|
|
|
133
133
|
|
|
134
134
|
res, authed, anon = asyncio.run(asyncio.wait_for(run(), timeout=90))
|
|
135
135
|
res_text = res.content[0].text if res.content else ""
|
|
136
|
-
assert not res.
|
|
136
|
+
assert not res.isError, res_text
|
|
137
137
|
assert '"status":"success"' in res_text, res_text
|
|
138
138
|
assert '"profile":"mcplogin"' in res_text
|
|
139
139
|
|
|
@@ -64,7 +64,7 @@ class TestNoSecretLeakage:
|
|
|
64
64
|
return res
|
|
65
65
|
|
|
66
66
|
res = _run(run())
|
|
67
|
-
if res.
|
|
67
|
+
if res.isError:
|
|
68
68
|
return # error path: no payload to leak, still fine
|
|
69
69
|
text = res.content[0].text
|
|
70
70
|
hits = _scan(text)
|
|
@@ -121,7 +121,7 @@ class TestServerRecovery:
|
|
|
121
121
|
bad = await session.call_tool(
|
|
122
122
|
"fetch", {"url": "https://example.com", "strategy": "bogus"}
|
|
123
123
|
)
|
|
124
|
-
assert not bad.
|
|
124
|
+
assert not bad.isError or "error" in bad.content[0].text
|
|
125
125
|
good = await session.call_tool(
|
|
126
126
|
"fetch", {"url": "https://example.com", "strategy": "http", "no_cache": True}
|
|
127
127
|
)
|
|
@@ -79,7 +79,7 @@ def test_mcp_fetch_with_profile_uses_session(tmp_path):
|
|
|
79
79
|
ok, anon = asyncio.run(asyncio.wait_for(run(), timeout=60))
|
|
80
80
|
ok_text = ok.content[0].text if ok.content else ""
|
|
81
81
|
anon_text = anon.content[0].text if anon.content else ""
|
|
82
|
-
assert not ok.
|
|
82
|
+
assert not ok.isError, ok_text
|
|
83
83
|
assert '"status":"success"' in ok_text, ok_text
|
|
84
84
|
assert "you are authenticated" in ok_text, ok_text
|
|
85
85
|
assert '"authenticated":true' in ok_text, ok_text # session was used
|
|
@@ -105,7 +105,7 @@ def test_mcp_fetch_unknown_profile_is_hard_error(tmp_path):
|
|
|
105
105
|
return res
|
|
106
106
|
|
|
107
107
|
res = asyncio.run(asyncio.wait_for(run(), timeout=60))
|
|
108
|
-
assert not res.
|
|
108
|
+
assert not res.isError
|
|
109
109
|
assert "profile 'ghost' not found" in res.content[0].text
|
|
110
110
|
|
|
111
111
|
|
|
@@ -150,7 +150,7 @@ def test_mcp_fetch_enriched_output_present(tmp_path):
|
|
|
150
150
|
anon_text = anon.content[0].text if anon.content else ""
|
|
151
151
|
|
|
152
152
|
# Authenticated: standard assertions
|
|
153
|
-
assert not ok.
|
|
153
|
+
assert not ok.isError, ok_text
|
|
154
154
|
assert '"status":"success"' in ok_text
|
|
155
155
|
# auth
|
|
156
156
|
assert '"auth"' in ok_text
|
|
@@ -53,7 +53,7 @@ def test_invalid_strategy_returns_error_not_crash():
|
|
|
53
53
|
return res
|
|
54
54
|
|
|
55
55
|
res = _run(run())
|
|
56
|
-
assert not res.
|
|
56
|
+
assert not res.isError
|
|
57
57
|
assert "error" in res.content[0].text
|
|
58
58
|
|
|
59
59
|
|
|
@@ -72,7 +72,7 @@ def test_firecrawl_without_key_returns_error_not_crash():
|
|
|
72
72
|
return res
|
|
73
73
|
|
|
74
74
|
res = _run(run())
|
|
75
|
-
assert not res.
|
|
75
|
+
assert not res.isError
|
|
76
76
|
assert "error" in res.content[0].text
|
|
77
77
|
|
|
78
78
|
|
|
@@ -95,7 +95,7 @@ def test_server_stays_alive_after_bad_calls():
|
|
|
95
95
|
return res
|
|
96
96
|
|
|
97
97
|
res = _run(run())
|
|
98
|
-
assert not res.
|
|
98
|
+
assert not res.isError
|
|
99
99
|
assert "success" in res.content[0].text
|
|
100
100
|
|
|
101
101
|
|
|
@@ -118,7 +118,7 @@ def test_fetch_invalid_profile_returns_error_not_crash():
|
|
|
118
118
|
return res, ok
|
|
119
119
|
|
|
120
120
|
res, ok = _run(run())
|
|
121
|
-
assert not res.
|
|
121
|
+
assert not res.isError
|
|
122
122
|
assert "invalid profile name" in res.content[0].text
|
|
123
123
|
assert "success" in ok.content[0].text # server alive after the bad call
|
|
124
124
|
|
|
@@ -139,7 +139,7 @@ def test_fetch_nonexistent_profile_returns_error():
|
|
|
139
139
|
return res
|
|
140
140
|
|
|
141
141
|
res = _run(run())
|
|
142
|
-
assert not res.
|
|
142
|
+
assert not res.isError
|
|
143
143
|
assert "profile 'ghost' not found" in res.content[0].text
|
|
144
144
|
|
|
145
145
|
|
|
@@ -161,9 +161,9 @@ def test_search_fetch_invalid_profile_returns_error_not_crash():
|
|
|
161
161
|
return res, ok
|
|
162
162
|
|
|
163
163
|
res, ok = _run(run())
|
|
164
|
-
assert not res.
|
|
164
|
+
assert not res.isError
|
|
165
165
|
assert "invalid profile name" in res.content[0].text
|
|
166
|
-
assert not ok.
|
|
166
|
+
assert not ok.isError # server alive after the bad call
|
|
167
167
|
|
|
168
168
|
|
|
169
169
|
def test_list_profiles_tool_metadata_only(tmp_path):
|
|
@@ -189,7 +189,7 @@ def test_list_profiles_tool_metadata_only(tmp_path):
|
|
|
189
189
|
return res
|
|
190
190
|
|
|
191
191
|
res = _run(run())
|
|
192
|
-
assert not res.
|
|
192
|
+
assert not res.isError
|
|
193
193
|
text = res.content[0].text
|
|
194
194
|
assert "sion" in text
|
|
195
195
|
assert "SUPERSECRET" not in text # cookie values never exposed
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""webget - local search + scrape, zero API keys, unlimited usage.
|
|
2
|
+
|
|
3
|
+
A package refactor of the original single-file webget_cli.py. Public API
|
|
4
|
+
is re-exported here so callers can do `from webget import fetch_http,
|
|
5
|
+
scrape_many, ...` instead of importing internal modules.
|
|
6
|
+
|
|
7
|
+
Sub-modules:
|
|
8
|
+
- cache - disk cache + cookie/header parsing
|
|
9
|
+
- ssrf - private-IP guard for HTTP + browser paths
|
|
10
|
+
- profile - persistent browser-profile (auth session) management
|
|
11
|
+
- http - fast-path HTTP fetch + markdown extraction
|
|
12
|
+
- firecrawl - optional cloud escape-hatch fetch
|
|
13
|
+
- search - DuckDuckGo text search + atomic JSON helpers
|
|
14
|
+
- ladder - HTTP -> Crawl4AI -> Firecrawl orchestration
|
|
15
|
+
- cli - argparse entry point
|
|
16
|
+
|
|
17
|
+
The legacy single-file import `import webget_cli as webget` keeps
|
|
18
|
+
working via the compatibility shim at ./webget_cli.py.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from .cache import (
|
|
23
|
+
_CACHE_SWEEP_TTL,
|
|
24
|
+
CACHE_DIR,
|
|
25
|
+
_cache_path,
|
|
26
|
+
cache_get,
|
|
27
|
+
cache_put,
|
|
28
|
+
parse_cookie_file,
|
|
29
|
+
parse_headers,
|
|
30
|
+
)
|
|
31
|
+
from .cli import main, parse_opts
|
|
32
|
+
from .firecrawl import fetch_firecrawl, firecrawl_key
|
|
33
|
+
from .http import MAX_RESPONSE_BYTES, ResponseTooLarge, _extract_markdown, fetch_http
|
|
34
|
+
from .ladder import (
|
|
35
|
+
_DEFAULT_CONCURRENCY,
|
|
36
|
+
_STRATEGY_MEMORY_TTL,
|
|
37
|
+
_auth_message,
|
|
38
|
+
_crawl4ai_once,
|
|
39
|
+
_ladder,
|
|
40
|
+
_learn_strategy,
|
|
41
|
+
_load_strategy_memory,
|
|
42
|
+
_normalize_hit,
|
|
43
|
+
_reorder_steps_by_domain,
|
|
44
|
+
_save_strategy_memory,
|
|
45
|
+
_strategy_memory_path,
|
|
46
|
+
_terminal_state,
|
|
47
|
+
scrape_many,
|
|
48
|
+
)
|
|
49
|
+
from .profile import (
|
|
50
|
+
_PROFILE_NAME_RE,
|
|
51
|
+
PROFILE_DIR,
|
|
52
|
+
_auth_state,
|
|
53
|
+
_cookie_belongs_to,
|
|
54
|
+
_domain_match,
|
|
55
|
+
_effective_cookies,
|
|
56
|
+
_fmt_age,
|
|
57
|
+
_login_flow,
|
|
58
|
+
_logout_domain_regex,
|
|
59
|
+
_logout_flow,
|
|
60
|
+
_profile_meta,
|
|
61
|
+
_profile_root,
|
|
62
|
+
_prune_storage_cookies,
|
|
63
|
+
_valid_site_url,
|
|
64
|
+
_wait_for_session_cookies,
|
|
65
|
+
_warn,
|
|
66
|
+
list_profiles,
|
|
67
|
+
load_profile_cookies,
|
|
68
|
+
profile_dir,
|
|
69
|
+
profile_exists,
|
|
70
|
+
profile_state_path,
|
|
71
|
+
)
|
|
72
|
+
from .search import _read_json, _write_json, search
|
|
73
|
+
from .ssrf import (
|
|
74
|
+
SSRFError,
|
|
75
|
+
_guard_browser_routes,
|
|
76
|
+
_hostname_private,
|
|
77
|
+
_ip_is_private,
|
|
78
|
+
_is_private_target,
|
|
79
|
+
_private_ip_for,
|
|
80
|
+
_request_body_bytes,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# Sorted to satisfy ruff RUF022; module grouping lives in the imports above.
|
|
84
|
+
__all__ = [
|
|
85
|
+
"CACHE_DIR",
|
|
86
|
+
"MAX_RESPONSE_BYTES",
|
|
87
|
+
"PROFILE_DIR",
|
|
88
|
+
"_CACHE_SWEEP_TTL",
|
|
89
|
+
"_DEFAULT_CONCURRENCY",
|
|
90
|
+
"_PROFILE_NAME_RE",
|
|
91
|
+
"_STRATEGY_MEMORY_TTL",
|
|
92
|
+
"ResponseTooLarge",
|
|
93
|
+
"SSRFError",
|
|
94
|
+
"_auth_message",
|
|
95
|
+
"_auth_state",
|
|
96
|
+
"_cache_path",
|
|
97
|
+
"_cookie_belongs_to",
|
|
98
|
+
"_crawl4ai_once",
|
|
99
|
+
"_domain_match",
|
|
100
|
+
"_effective_cookies",
|
|
101
|
+
"_extract_markdown",
|
|
102
|
+
"_fmt_age",
|
|
103
|
+
"_guard_browser_routes",
|
|
104
|
+
"_hostname_private",
|
|
105
|
+
"_ip_is_private",
|
|
106
|
+
"_is_private_target",
|
|
107
|
+
"_ladder",
|
|
108
|
+
"_learn_strategy",
|
|
109
|
+
"_load_strategy_memory",
|
|
110
|
+
"_login_flow",
|
|
111
|
+
"_logout_domain_regex",
|
|
112
|
+
"_logout_flow",
|
|
113
|
+
"_normalize_hit",
|
|
114
|
+
"_private_ip_for",
|
|
115
|
+
"_profile_meta",
|
|
116
|
+
"_profile_root",
|
|
117
|
+
"_prune_storage_cookies",
|
|
118
|
+
"_read_json",
|
|
119
|
+
"_reorder_steps_by_domain",
|
|
120
|
+
"_request_body_bytes",
|
|
121
|
+
"_save_strategy_memory",
|
|
122
|
+
"_strategy_memory_path",
|
|
123
|
+
"_terminal_state",
|
|
124
|
+
"_valid_site_url",
|
|
125
|
+
"_wait_for_session_cookies",
|
|
126
|
+
"_warn",
|
|
127
|
+
"_write_json",
|
|
128
|
+
"cache_get",
|
|
129
|
+
"cache_put",
|
|
130
|
+
"fetch_firecrawl",
|
|
131
|
+
"fetch_http",
|
|
132
|
+
"firecrawl_key",
|
|
133
|
+
"list_profiles",
|
|
134
|
+
"load_profile_cookies",
|
|
135
|
+
"main",
|
|
136
|
+
"parse_cookie_file",
|
|
137
|
+
"parse_headers",
|
|
138
|
+
"parse_opts",
|
|
139
|
+
"profile_dir",
|
|
140
|
+
"profile_exists",
|
|
141
|
+
"profile_state_path",
|
|
142
|
+
"scrape_many",
|
|
143
|
+
"search",
|
|
144
|
+
]
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Cache + cookie/header parsing for webget.
|
|
2
|
+
|
|
3
|
+
Disk cache lives at CACHE_DIR and is keyed by (profile, url, max_chars,
|
|
4
|
+
sorted cookies, sorted headers) so semantically-identical states share one
|
|
5
|
+
entry. Writes are atomic (per-writer unique tmp + os.replace) and the cache
|
|
6
|
+
self-evicts via two-phase sweep when over the cap.
|
|
7
|
+
|
|
8
|
+
CACHE_DIR is resolved lazily through a getter that consults the
|
|
9
|
+
top-level webget package first, falling back to the package default.
|
|
10
|
+
This keeps `monkeypatch.setattr(webget_cli, "CACHE_DIR", ...)` working
|
|
11
|
+
in the test suite: the canonical location for the path is the shim's
|
|
12
|
+
module attribute, and package sub-modules follow whatever the shim
|
|
13
|
+
currently exposes.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import hashlib
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import tempfile
|
|
21
|
+
import time
|
|
22
|
+
|
|
23
|
+
# Package default; tests that patch webget_cli.CACHE_DIR take precedence
|
|
24
|
+
# because _cache_dir() looks up the parent package's attribute at call time.
|
|
25
|
+
_CACHE_DIR_DEFAULT = os.path.expanduser("~/.cache/webget")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _cache_dir():
|
|
29
|
+
"""Resolve the active cache directory, honoring runtime overrides.
|
|
30
|
+
|
|
31
|
+
Tests patch `webget_cli.CACHE_DIR` (the shim module attribute) to
|
|
32
|
+
point at a tmp dir. Sub-modules in the package consult THIS function
|
|
33
|
+
so the same override applies everywhere without circular imports.
|
|
34
|
+
"""
|
|
35
|
+
try:
|
|
36
|
+
import webget_cli as _shim
|
|
37
|
+
|
|
38
|
+
return _shim.CACHE_DIR
|
|
39
|
+
except (ImportError, AttributeError):
|
|
40
|
+
return _CACHE_DIR_DEFAULT
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# Backwards-compatible module attr: defaults to the package default, but
|
|
44
|
+
# assigning to `webget.cache.CACHE_DIR` only affects THIS module. Tests
|
|
45
|
+
# use `webget_cli.CACHE_DIR` (the shim) as the override hook, which is
|
|
46
|
+
# picked up via _cache_dir() above.
|
|
47
|
+
CACHE_DIR = _CACHE_DIR_DEFAULT
|
|
48
|
+
|
|
49
|
+
# Entries older than this can never be served by any reasonable ttl and are
|
|
50
|
+
# swept on write. 30 days matches the default CLI ttl.
|
|
51
|
+
_CACHE_SWEEP_TTL = 3600 * 24 * 30
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def parse_cookie_file(path):
|
|
55
|
+
"""Parse Netscape cookie file -> Playwright-compatible cookie dicts."""
|
|
56
|
+
cookies = []
|
|
57
|
+
with open(os.path.expanduser(path)) as f:
|
|
58
|
+
for line in f:
|
|
59
|
+
line = line.strip()
|
|
60
|
+
if not line or line.startswith("#"):
|
|
61
|
+
continue
|
|
62
|
+
parts = line.split("\t")
|
|
63
|
+
if len(parts) < 7:
|
|
64
|
+
continue
|
|
65
|
+
domain, _domain_flag, path_, secure, expires, name, value = parts[:7]
|
|
66
|
+
cookie = {
|
|
67
|
+
"name": name,
|
|
68
|
+
"value": value,
|
|
69
|
+
"domain": domain,
|
|
70
|
+
"path": path_,
|
|
71
|
+
"secure": secure == "TRUE",
|
|
72
|
+
"httpOnly": False,
|
|
73
|
+
}
|
|
74
|
+
if expires not in ("0", "Session", ""):
|
|
75
|
+
try:
|
|
76
|
+
cookie["expires"] = int(expires)
|
|
77
|
+
except ValueError:
|
|
78
|
+
pass
|
|
79
|
+
cookies.append(cookie)
|
|
80
|
+
return cookies
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def parse_headers(raw_headers):
|
|
84
|
+
"""Parse list of 'Key: Value' strings into a dict."""
|
|
85
|
+
headers = {}
|
|
86
|
+
for h in raw_headers:
|
|
87
|
+
if ":" in h:
|
|
88
|
+
k, v = h.split(":", 1)
|
|
89
|
+
headers[k.strip()] = v.strip()
|
|
90
|
+
return headers
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _cache_path(url, cookies, headers, max_chars, profile=None):
|
|
94
|
+
# Cookies and headers are sorted so semantically identical states (same
|
|
95
|
+
# cookie set in different order) share one cache entry.
|
|
96
|
+
ck = sorted(cookies or [], key=lambda c: (c.get("domain", ""), c.get("name", "")))
|
|
97
|
+
hd = {k: v for k, v in sorted((headers or {}).items())}
|
|
98
|
+
key = hashlib.sha1(
|
|
99
|
+
f"{profile or 'public'}|{url}|{max_chars}|"
|
|
100
|
+
f"{json.dumps(ck, sort_keys=True)}|"
|
|
101
|
+
f"{json.dumps(hd, sort_keys=True)}".encode()
|
|
102
|
+
).hexdigest()
|
|
103
|
+
return os.path.join(_cache_dir(), key + ".json")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def cache_get(url, cookies, headers, max_chars, ttl, fresh=False, profile=None):
|
|
107
|
+
if fresh:
|
|
108
|
+
return None
|
|
109
|
+
p = _cache_path(url, cookies, headers, max_chars, profile)
|
|
110
|
+
if not os.path.exists(p):
|
|
111
|
+
return None
|
|
112
|
+
if time.time() - os.path.getmtime(p) > ttl:
|
|
113
|
+
return None
|
|
114
|
+
try:
|
|
115
|
+
with open(p) as f:
|
|
116
|
+
return json.load(f)
|
|
117
|
+
except (json.JSONDecodeError, OSError):
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def cache_put(url, cookies, headers, max_chars, data, profile=None):
|
|
122
|
+
cache_dir = _cache_dir()
|
|
123
|
+
os.makedirs(cache_dir, exist_ok=True)
|
|
124
|
+
p = _cache_path(url, cookies, headers, max_chars, profile)
|
|
125
|
+
tmp = None
|
|
126
|
+
try:
|
|
127
|
+
# Atomic write: each writer gets its OWN unique tmp file (mkstemp),
|
|
128
|
+
# then an atomic rename. A shared "<path>.tmp" would let concurrent
|
|
129
|
+
# writers interleave bytes in the same file, so the rename would
|
|
130
|
+
# publish a corrupt document. mkstemp guarantees uniqueness per
|
|
131
|
+
# writer and lives in the same directory (same filesystem, so
|
|
132
|
+
# os.replace stays atomic).
|
|
133
|
+
fd, tmp = tempfile.mkstemp(
|
|
134
|
+
dir=os.path.dirname(p), prefix=os.path.basename(p) + ".", suffix=".tmp"
|
|
135
|
+
)
|
|
136
|
+
with os.fdopen(fd, "w") as f:
|
|
137
|
+
json.dump({**data, "fetched_at": time.time()}, f)
|
|
138
|
+
os.replace(tmp, p)
|
|
139
|
+
tmp = None # consumed by the rename
|
|
140
|
+
# Eviction (lazy: only scans when actually over the cap, so the hot
|
|
141
|
+
# write path stays cheap). Drop expired entries first - they can
|
|
142
|
+
# never be served - then keep newest 80% if still over. The cap is
|
|
143
|
+
# tunable via WEBGET_CACHE_MAX because a 500-file ceiling makes
|
|
144
|
+
# bulk crawls (thousands of URLs) thrash: every write evicts
|
|
145
|
+
# useful entries.
|
|
146
|
+
try:
|
|
147
|
+
cap = int(os.environ.get("WEBGET_CACHE_MAX", "5000"))
|
|
148
|
+
except ValueError:
|
|
149
|
+
cap = 5000
|
|
150
|
+
try:
|
|
151
|
+
files = [
|
|
152
|
+
os.path.join(cache_dir, f)
|
|
153
|
+
for f in os.listdir(cache_dir)
|
|
154
|
+
if f.endswith(".json") and f != "strategy_memory.json"
|
|
155
|
+
]
|
|
156
|
+
if len(files) > cap:
|
|
157
|
+
now = time.time()
|
|
158
|
+
live = []
|
|
159
|
+
for fp in files:
|
|
160
|
+
try:
|
|
161
|
+
age = now - os.path.getmtime(fp)
|
|
162
|
+
except OSError:
|
|
163
|
+
continue
|
|
164
|
+
if age > _CACHE_SWEEP_TTL:
|
|
165
|
+
try:
|
|
166
|
+
os.remove(fp)
|
|
167
|
+
except OSError:
|
|
168
|
+
pass
|
|
169
|
+
else:
|
|
170
|
+
live.append(fp)
|
|
171
|
+
if len(live) > cap:
|
|
172
|
+
live.sort(key=os.path.getmtime)
|
|
173
|
+
for f in live[: len(live) - int(cap * 0.8)]:
|
|
174
|
+
try:
|
|
175
|
+
os.remove(f)
|
|
176
|
+
except OSError:
|
|
177
|
+
pass
|
|
178
|
+
except OSError:
|
|
179
|
+
pass
|
|
180
|
+
except OSError:
|
|
181
|
+
pass # cache is best-effort
|
|
182
|
+
finally:
|
|
183
|
+
if tmp is not None:
|
|
184
|
+
try:
|
|
185
|
+
os.remove(tmp)
|
|
186
|
+
except OSError:
|
|
187
|
+
pass
|