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.
Files changed (44) hide show
  1. {webget_cli-0.9.0 → webget_cli-0.10.0}/PKG-INFO +1 -1
  2. {webget_cli-0.9.0 → webget_cli-0.10.0}/pyproject.toml +3 -2
  3. {webget_cli-0.9.0 → webget_cli-0.10.0}/tests/test_adversarial_mcp.py +5 -5
  4. {webget_cli-0.9.0 → webget_cli-0.10.0}/tests/test_login_flow.py +1 -1
  5. {webget_cli-0.9.0 → webget_cli-0.10.0}/tests/test_mcp_leak_review.py +2 -2
  6. {webget_cli-0.9.0 → webget_cli-0.10.0}/tests/test_mcp_profile.py +3 -3
  7. {webget_cli-0.9.0 → webget_cli-0.10.0}/tests/test_mcp_server.py +8 -8
  8. webget_cli-0.10.0/webget/__init__.py +144 -0
  9. webget_cli-0.10.0/webget/cache.py +187 -0
  10. webget_cli-0.10.0/webget/cli.py +377 -0
  11. webget_cli-0.10.0/webget/firecrawl.py +60 -0
  12. webget_cli-0.10.0/webget/http.py +175 -0
  13. webget_cli-0.10.0/webget/ladder.py +576 -0
  14. webget_cli-0.10.0/webget/profile.py +409 -0
  15. webget_cli-0.10.0/webget/search.py +37 -0
  16. webget_cli-0.10.0/webget/ssrf.py +232 -0
  17. {webget_cli-0.9.0 → webget_cli-0.10.0}/webget_cli.egg-info/PKG-INFO +1 -1
  18. {webget_cli-0.9.0 → webget_cli-0.10.0}/webget_cli.egg-info/SOURCES.txt +9 -0
  19. {webget_cli-0.9.0 → webget_cli-0.10.0}/webget_cli.egg-info/entry_points.txt +1 -1
  20. {webget_cli-0.9.0 → webget_cli-0.10.0}/webget_cli.egg-info/top_level.txt +1 -0
  21. webget_cli-0.10.0/webget_cli.py +48 -0
  22. webget_cli-0.9.0/webget_cli.py +0 -1762
  23. {webget_cli-0.9.0 → webget_cli-0.10.0}/LICENSE +0 -0
  24. {webget_cli-0.9.0 → webget_cli-0.10.0}/README.md +0 -0
  25. {webget_cli-0.9.0 → webget_cli-0.10.0}/setup.cfg +0 -0
  26. {webget_cli-0.9.0 → webget_cli-0.10.0}/tests/test_adversarial_auth.py +0 -0
  27. {webget_cli-0.9.0 → webget_cli-0.10.0}/tests/test_adversarial_cache.py +0 -0
  28. {webget_cli-0.9.0 → webget_cli-0.10.0}/tests/test_adversarial_concurrency.py +0 -0
  29. {webget_cli-0.9.0 → webget_cli-0.10.0}/tests/test_adversarial_http.py +0 -0
  30. {webget_cli-0.9.0 → webget_cli-0.10.0}/tests/test_adversarial_ssrf.py +0 -0
  31. {webget_cli-0.9.0 → webget_cli-0.10.0}/tests/test_auth_review.py +0 -0
  32. {webget_cli-0.9.0 → webget_cli-0.10.0}/tests/test_browser_ssrf.py +0 -0
  33. {webget_cli-0.9.0 → webget_cli-0.10.0}/tests/test_cache_review.py +0 -0
  34. {webget_cli-0.9.0 → webget_cli-0.10.0}/tests/test_concurrency_review.py +0 -0
  35. {webget_cli-0.9.0 → webget_cli-0.10.0}/tests/test_extraction_markdown.py +0 -0
  36. {webget_cli-0.9.0 → webget_cli-0.10.0}/tests/test_firecrawl_policy.py +0 -0
  37. {webget_cli-0.9.0 → webget_cli-0.10.0}/tests/test_integration_ladder.py +0 -0
  38. {webget_cli-0.9.0 → webget_cli-0.10.0}/tests/test_mcp_smoke.py +0 -0
  39. {webget_cli-0.9.0 → webget_cli-0.10.0}/tests/test_security_review.py +0 -0
  40. {webget_cli-0.9.0 → webget_cli-0.10.0}/tests/test_size_review.py +0 -0
  41. {webget_cli-0.9.0 → webget_cli-0.10.0}/tests/test_webget.py +0 -0
  42. {webget_cli-0.9.0 → webget_cli-0.10.0}/webget_cli.egg-info/dependency_links.txt +0 -0
  43. {webget_cli-0.9.0 → webget_cli-0.10.0}/webget_cli.egg-info/requires.txt +0 -0
  44. {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.9.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.9.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 = "webget_cli:main"
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.is_error
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.is_error
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.is_error:
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.is_error for r in results]
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.is_error
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.is_error, res_text
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.is_error:
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.is_error or "error" in bad.content[0].text
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.is_error, ok_text
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.is_error
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.is_error, ok_text
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.is_error
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.is_error
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.is_error
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.is_error
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.is_error
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.is_error
164
+ assert not res.isError
165
165
  assert "invalid profile name" in res.content[0].text
166
- assert not ok.is_error # server alive after the bad call
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.is_error
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