agent-wire-cli 0.2.1__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.
Files changed (87) hide show
  1. agent_wire/__init__.py +1 -0
  2. agent_wire/__main__.py +3 -0
  3. agent_wire/assets/SKILL.md +26 -0
  4. agent_wire/assets/cursor_rule.mdc +13 -0
  5. agent_wire/assets/hermes_skill.md +28 -0
  6. agent_wire/assets/windsurf_rules.md +14 -0
  7. agent_wire/assets/wire-worker.md +20 -0
  8. agent_wire/auth/__init__.py +0 -0
  9. agent_wire/auth/browser_paths.py +143 -0
  10. agent_wire/auth/cookie_vault.py +181 -0
  11. agent_wire/auth/rate_limiter.py +26 -0
  12. agent_wire/cache/__init__.py +0 -0
  13. agent_wire/cache/ring_buffer.py +94 -0
  14. agent_wire/cache/sqlite_ledger.py +132 -0
  15. agent_wire/channels/__init__.py +41 -0
  16. agent_wire/channels/base.py +249 -0
  17. agent_wire/channels/tier1_global/__init__.py +0 -0
  18. agent_wire/channels/tier1_global/github.py +371 -0
  19. agent_wire/channels/tier1_global/hackernews.py +135 -0
  20. agent_wire/channels/tier1_global/reddit.py +371 -0
  21. agent_wire/channels/tier1_global/rss.py +129 -0
  22. agent_wire/channels/tier1_global/search.py +160 -0
  23. agent_wire/channels/tier1_global/stackoverflow.py +94 -0
  24. agent_wire/channels/tier1_global/web.py +98 -0
  25. agent_wire/channels/tier1_global/wikipedia.py +91 -0
  26. agent_wire/channels/tier1_global/x_twitter.py +380 -0
  27. agent_wire/channels/tier1_global/youtube.py +223 -0
  28. agent_wire/channels/tier2_pro/__init__.py +0 -0
  29. agent_wire/channels/tier2_pro/archive_bypass.py +66 -0
  30. agent_wire/channels/tier2_pro/arxiv_papers.py +220 -0
  31. agent_wire/channels/tier2_pro/devto.py +114 -0
  32. agent_wire/channels/tier2_pro/fediverse.py +181 -0
  33. agent_wire/channels/tier2_pro/hubs.py +161 -0
  34. agent_wire/channels/tier2_pro/linkedin.py +146 -0
  35. agent_wire/channels/tier2_pro/markets.py +207 -0
  36. agent_wire/channels/tier2_pro/meta_social.py +104 -0
  37. agent_wire/channels/tier2_pro/news.py +173 -0
  38. agent_wire/channels/tier2_pro/polymarket.py +105 -0
  39. agent_wire/channels/tier2_pro/publishing.py +84 -0
  40. agent_wire/channels/tier2_pro/stocktwits.py +79 -0
  41. agent_wire/channels/tier3_regional/bilibili.py +171 -0
  42. agent_wire/channels/tier3_regional/boss_zhipin.py +35 -0
  43. agent_wire/channels/tier3_regional/douyin_tiktok.py +85 -0
  44. agent_wire/channels/tier3_regional/v2ex.py +70 -0
  45. agent_wire/channels/tier3_regional/wechat.py +67 -0
  46. agent_wire/channels/tier3_regional/xiaohongshu.py +58 -0
  47. agent_wire/channels/tier3_regional/xiaoyuzhou.py +70 -0
  48. agent_wire/channels/tier3_regional/xueqiu.py +77 -0
  49. agent_wire/channels/tier3_regional/zhihu.py +57 -0
  50. agent_wire/cli.py +403 -0
  51. agent_wire/compression/__init__.py +0 -0
  52. agent_wire/compression/anchor_slicer.py +45 -0
  53. agent_wire/compression/bm25_slicer.py +151 -0
  54. agent_wire/compression/dam_formatter.py +74 -0
  55. agent_wire/compression/url_compactor.py +45 -0
  56. agent_wire/config.py +52 -0
  57. agent_wire/engine/__init__.py +0 -0
  58. agent_wire/engine/challenge.py +58 -0
  59. agent_wire/engine/http_pool.py +136 -0
  60. agent_wire/engine/stealth_browser.py +90 -0
  61. agent_wire/engine/waterfall.py +159 -0
  62. agent_wire/firewall/__init__.py +0 -0
  63. agent_wire/firewall/inbound_shield.py +116 -0
  64. agent_wire/firewall/outbound_privacy.py +120 -0
  65. agent_wire/installers/__init__.py +0 -0
  66. agent_wire/installers/client_installer.py +315 -0
  67. agent_wire/media/__init__.py +0 -0
  68. agent_wire/media/audio_transcriber.py +77 -0
  69. agent_wire/media/subtitle_cleaner.py +102 -0
  70. agent_wire/models.py +60 -0
  71. agent_wire/research/__init__.py +1 -0
  72. agent_wire/research/brief.py +227 -0
  73. agent_wire/research/briefing.py +41 -0
  74. agent_wire/research/cluster.py +128 -0
  75. agent_wire/research/engine.py +632 -0
  76. agent_wire/research/library.py +74 -0
  77. agent_wire/research/planner.py +232 -0
  78. agent_wire/research/scoring.py +89 -0
  79. agent_wire/research/store.py +149 -0
  80. agent_wire/research/watch.py +70 -0
  81. agent_wire/router.py +107 -0
  82. agent_wire/server.py +468 -0
  83. agent_wire_cli-0.2.1.dist-info/METADATA +258 -0
  84. agent_wire_cli-0.2.1.dist-info/RECORD +87 -0
  85. agent_wire_cli-0.2.1.dist-info/WHEEL +4 -0
  86. agent_wire_cli-0.2.1.dist-info/entry_points.txt +4 -0
  87. agent_wire_cli-0.2.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,132 @@
1
+ """L2 on-disk cache: per-domain winning waterfall stage + 500-row circular HTTP cache."""
2
+
3
+ import hashlib
4
+ import sqlite3
5
+ import time
6
+ from pathlib import Path
7
+
8
+ import zstandard
9
+
10
+ ROUTE_TTL_S = 24 * 3600
11
+
12
+ # rowid breaks updated_at ties (many inserts within one second) so the oldest row goes first.
13
+ SCHEMA = """
14
+ CREATE TABLE IF NOT EXISTS domain_routes (
15
+ domain TEXT PRIMARY KEY,
16
+ winning_stage INTEGER NOT NULL,
17
+ cf_clearance TEXT,
18
+ user_agent TEXT,
19
+ updated_at INTEGER NOT NULL
20
+ );
21
+ CREATE TABLE IF NOT EXISTS http_etag_cache (
22
+ url_hash TEXT PRIMARY KEY,
23
+ url TEXT NOT NULL,
24
+ etag TEXT,
25
+ last_modified TEXT,
26
+ content_hash TEXT NOT NULL,
27
+ compressed_body BLOB,
28
+ updated_at INTEGER NOT NULL
29
+ );
30
+ CREATE TRIGGER IF NOT EXISTS limit_etag_cache_size
31
+ AFTER INSERT ON http_etag_cache
32
+ WHEN (SELECT COUNT(*) FROM http_etag_cache) > 500
33
+ BEGIN
34
+ DELETE FROM http_etag_cache
35
+ WHERE url_hash IN (
36
+ SELECT url_hash FROM http_etag_cache
37
+ ORDER BY updated_at ASC, rowid ASC
38
+ LIMIT (SELECT COUNT(*) - 500 FROM http_etag_cache)
39
+ );
40
+ END;
41
+ """
42
+
43
+
44
+ class Ledger:
45
+ def __init__(self, path: Path):
46
+ self._db = sqlite3.connect(path)
47
+ self._db.execute("PRAGMA journal_mode=WAL")
48
+ self._db.execute("PRAGMA synchronous=NORMAL")
49
+ self._db.executescript(SCHEMA)
50
+ self._zc = zstandard.ZstdCompressor(level=3)
51
+ self._zd = zstandard.ZstdDecompressor()
52
+
53
+ def close(self) -> None:
54
+ self._db.close()
55
+
56
+ def get_winning_stage(self, domain: str, ttl_s: int = ROUTE_TTL_S) -> int | None:
57
+ row = self._db.execute(
58
+ "SELECT winning_stage FROM domain_routes WHERE domain = ? AND updated_at > ?",
59
+ (domain, int(time.time()) - ttl_s),
60
+ ).fetchone()
61
+ return row[0] if row else None
62
+
63
+ def get_route(self, domain: str, ttl_s: int = ROUTE_TTL_S) -> dict | None:
64
+ row = self._db.execute(
65
+ "SELECT winning_stage, cf_clearance, user_agent FROM domain_routes"
66
+ " WHERE domain = ? AND updated_at > ?",
67
+ (domain, int(time.time()) - ttl_s),
68
+ ).fetchone()
69
+ if row is None:
70
+ return None
71
+ return {"stage": row[0], "cf_clearance": row[1], "user_agent": row[2]}
72
+
73
+ def routes(self) -> list[tuple[str, int]]:
74
+ return self._db.execute(
75
+ "SELECT domain, winning_stage FROM domain_routes ORDER BY updated_at DESC"
76
+ ).fetchall()
77
+
78
+ def clear_route(self, domain: str) -> None:
79
+ with self._db:
80
+ self._db.execute("DELETE FROM domain_routes WHERE domain = ?", (domain,))
81
+
82
+ def set_winning_stage(
83
+ self,
84
+ domain: str,
85
+ stage: int,
86
+ cf_clearance: str | None = None,
87
+ user_agent: str | None = None,
88
+ ) -> None:
89
+ with self._db:
90
+ self._db.execute(
91
+ "INSERT OR REPLACE INTO domain_routes VALUES (?, ?, ?, ?, ?)",
92
+ (domain, stage, cf_clearance, user_agent, int(time.time())),
93
+ )
94
+
95
+ def get_cached(self, url: str) -> dict | None:
96
+ row = self._db.execute(
97
+ "SELECT etag, last_modified, content_hash, compressed_body"
98
+ " FROM http_etag_cache WHERE url_hash = ?",
99
+ (_url_hash(url),),
100
+ ).fetchone()
101
+ if row is None:
102
+ return None
103
+ etag, last_modified, content_hash, blob = row
104
+ body = self._zd.decompress(blob) if blob is not None else None
105
+ return {
106
+ "etag": etag,
107
+ "last_modified": last_modified,
108
+ "content_hash": content_hash,
109
+ "body": body,
110
+ }
111
+
112
+ def put_cached(
113
+ self,
114
+ url: str,
115
+ content_hash: str,
116
+ body: bytes | None = None,
117
+ etag: str | None = None,
118
+ last_modified: str | None = None,
119
+ ) -> None:
120
+ blob = self._zc.compress(body) if body is not None else None
121
+ with self._db:
122
+ self._db.execute(
123
+ "INSERT OR REPLACE INTO http_etag_cache VALUES (?, ?, ?, ?, ?, ?, ?)",
124
+ (_url_hash(url), url, etag, last_modified, content_hash, blob, int(time.time())),
125
+ )
126
+
127
+ def cache_rows(self) -> int:
128
+ return self._db.execute("SELECT COUNT(*) FROM http_etag_cache").fetchone()[0]
129
+
130
+
131
+ def _url_hash(url: str) -> str:
132
+ return hashlib.sha256(url.encode()).hexdigest()
@@ -0,0 +1,41 @@
1
+ """Lazy channel registry: modules import on first use so `wire serve` starts fast."""
2
+
3
+ import importlib
4
+
5
+ MODULES = {
6
+ "web": "tier1_global.web",
7
+ "search": "tier1_global.search",
8
+ "github": "tier1_global.github",
9
+ "reddit": "tier1_global.reddit",
10
+ "x": "tier1_global.x_twitter",
11
+ "youtube": "tier1_global.youtube",
12
+ "hackernews": "tier1_global.hackernews",
13
+ "stackoverflow": "tier1_global.stackoverflow",
14
+ "wikipedia": "tier1_global.wikipedia",
15
+ "rss": "tier1_global.rss",
16
+ "arxiv": "tier2_pro.arxiv_papers",
17
+ "linkedin": "tier2_pro.linkedin",
18
+ "hubs": "tier2_pro.hubs",
19
+ "markets": "tier2_pro.markets",
20
+ "meta_social": "tier2_pro.meta_social",
21
+ "publishing": "tier2_pro.publishing",
22
+ "fediverse": "tier2_pro.fediverse",
23
+ "archive": "tier2_pro.archive_bypass",
24
+ "polymarket": "tier2_pro.polymarket",
25
+ "news": "tier2_pro.news",
26
+ "stocktwits": "tier2_pro.stocktwits",
27
+ "devto": "tier2_pro.devto",
28
+ "bilibili": "tier3_regional.bilibili",
29
+ "xiaohongshu": "tier3_regional.xiaohongshu",
30
+ "wechat": "tier3_regional.wechat",
31
+ "zhihu": "tier3_regional.zhihu",
32
+ "v2ex": "tier3_regional.v2ex",
33
+ "douyin_tiktok": "tier3_regional.douyin_tiktok",
34
+ "xiaoyuzhou": "tier3_regional.xiaoyuzhou",
35
+ "xueqiu": "tier3_regional.xueqiu",
36
+ "boss_zhipin": "tier3_regional.boss_zhipin",
37
+ }
38
+
39
+
40
+ def channel_class(name: str):
41
+ return importlib.import_module(f"agent_wire.channels.{MODULES[name]}").CHANNEL
@@ -0,0 +1,249 @@
1
+ """Channel interface, shared context and helpers used by every platform module."""
2
+
3
+ import html as htmllib
4
+ import json
5
+ import re
6
+ from dataclasses import dataclass, field
7
+ from datetime import UTC, datetime, timedelta
8
+ from typing import ClassVar
9
+
10
+ from selectolax.lexbor import LexborHTMLParser
11
+
12
+ from agent_wire.engine.http_pool import Fetched, HttpPool
13
+ from agent_wire.engine.waterfall import Waterfall
14
+ from agent_wire.firewall.inbound_shield import clean_html
15
+ from agent_wire.firewall.outbound_privacy import parse_json
16
+ from agent_wire.models import WireDocument, WireSearchHit
17
+
18
+
19
+ class WireUnsupported(Exception):
20
+ """The channel can't do this (yet, or without a login); the message says what to do."""
21
+
22
+
23
+ @dataclass
24
+ class Context:
25
+ pool: HttpPool
26
+ waterfall: Waterfall
27
+ vault: object | None = None
28
+ ledger: object | None = None
29
+ _channels: dict = field(default_factory=dict)
30
+
31
+ def channel(self, name: str) -> "Channel":
32
+ if name not in self._channels:
33
+ from agent_wire.channels import channel_class
34
+
35
+ self._channels[name] = channel_class(name)(self)
36
+ return self._channels[name]
37
+
38
+
39
+ class Channel:
40
+ name: ClassVar[str] = ""
41
+
42
+ def __init__(self, ctx: Context):
43
+ self.ctx = ctx
44
+
45
+ async def read(
46
+ self, url: str, *, query: str | None = None, section: str | None = None,
47
+ include_comments: bool = True,
48
+ ) -> WireDocument: # fmt: skip
49
+ raise WireUnsupported(f"{self.name} can't read URLs")
50
+
51
+ async def search(
52
+ self, query: str, *, limit: int = 5, sort: str = "relevance"
53
+ ) -> list[WireSearchHit]:
54
+ raise WireUnsupported(f"{self.name} has no search; try platforms=['web']")
55
+
56
+ async def feed(self, target: str, *, sort: str = "hot", limit: int = 10) -> WireDocument:
57
+ raise WireUnsupported(f"{self.name} has no feeds")
58
+
59
+ async def me(self, target: str = "latest_posts", *, limit: int = 5) -> WireDocument:
60
+ raise WireUnsupported(f"{self.name} has no account inspection")
61
+
62
+ # ---- helpers -----------------------------------------------------------------------------
63
+ async def fetch(self, url: str, **kw) -> Fetched:
64
+ kw.setdefault("channel", self.name)
65
+ return await self.ctx.waterfall.fetch(url, **kw)
66
+
67
+ async def fetch_json(self, url: str, **kw):
68
+ """API call: no archive/browser stages; identity keys stripped (outbound layer 3)."""
69
+ kw.setdefault("archive", False)
70
+ kw.setdefault("browser", False)
71
+ f = await self.fetch(url, **kw)
72
+ return parse_json(f.content), f
73
+
74
+ def doc(self, f: Fetched | None, url: str, **fields) -> WireDocument:
75
+ if f is not None:
76
+ fields.setdefault("stage", f.stage)
77
+ fields.setdefault("auth", f.auth)
78
+ fields.setdefault("raw_bytes", len(f.content))
79
+ return WireDocument(url=url, channel=self.name, **fields)
80
+
81
+
82
+ def ts(value) -> datetime | None:
83
+ """Epoch seconds/ms or ISO-8601 -> aware datetime."""
84
+ if value in (None, ""):
85
+ return None
86
+ try:
87
+ if isinstance(value, int | float) or str(value).isdigit():
88
+ v = float(value)
89
+ return datetime.fromtimestamp(v / 1000 if v > 1e11 else v, UTC)
90
+ return datetime.fromisoformat(str(value))
91
+ except (ValueError, OSError, OverflowError):
92
+ return None
93
+
94
+
95
+ _REL = re.compile(r"(\d+)\s*(second|minute|hour|day|week|month|year)s?\s+ago", re.IGNORECASE)
96
+ _REL_S = {"second": 1, "minute": 60, "hour": 3600, "day": 86400, "week": 7 * 86400,
97
+ "month": 30 * 86400, "year": 365 * 86400} # fmt: skip
98
+
99
+
100
+ def relative_ts(text: str | None, now: datetime | None = None) -> datetime | None:
101
+ """'3 weeks ago' / 'Streamed 2 days ago' -> approximate aware datetime."""
102
+ m = _REL.search(text or "")
103
+ if not m:
104
+ return None
105
+ return (now or datetime.now(UTC)) - timedelta(seconds=int(m[1]) * _REL_S[m[2].lower()])
106
+
107
+
108
+ def listing(items: list[dict]) -> str:
109
+ """Compact DAM list for feeds/search: one line per item plus an optional indented snippet."""
110
+ lines = []
111
+ for i, it in enumerate(items, 1):
112
+ bits = [b for b in (it.get("meta") or ()) if b not in (None, "")]
113
+ title = " ".join(str(it.get("title") or it.get("url")).split()) # newlines break the link
114
+ head = f"{i}. [{title}]({it.get('url')})"
115
+ lines.append(head + (f" · {' · '.join(map(str, bits))}" if bits else ""))
116
+ if snippet := (it.get("snippet") or "").strip():
117
+ lines.append(" " + " ".join(snippet.split())[:280])
118
+ return "\n".join(lines)
119
+
120
+
121
+ def compact_count(text: str | int | None) -> int:
122
+ """'1.2K' / '3,400' / '2M likes' -> int."""
123
+ if text is None:
124
+ return 0
125
+ if isinstance(text, int):
126
+ return text
127
+ m = re.search(r"([\d.,]+)\s*([KMB万亿])?", str(text), re.IGNORECASE)
128
+ if not m:
129
+ return 0
130
+ num = float(m[1].replace(",", "")) if m[1].replace(",", "").replace(".", "").isdigit() else 0
131
+ mult = {"K": 1e3, "M": 1e6, "B": 1e9, "万": 1e4, "亿": 1e8}.get((m[2] or "").upper(), 1)
132
+ return int(num * mult)
133
+
134
+
135
+ # ---- HTML -> compact Markdown -------------------------------------------------------------------
136
+ _BLOCK = frozenset(
137
+ {"p", "div", "section", "article", "main", "header", "footer", "figure", "details", "summary",
138
+ "dl", "dd", "dt", "tr", "table", "tbody", "thead"}
139
+ ) # fmt: skip
140
+ _SKIP = frozenset({"img", "svg", "picture", "video", "audio", "iframe", "button", "form", "nav"})
141
+ _WS = re.compile(r"[ \t\r\f\v]+")
142
+ _BLANKS = re.compile(r"\n{3,}")
143
+
144
+
145
+ def html_to_markdown(html: str, *, links: bool = True) -> str:
146
+ """Untrusted HTML fragment -> Markdown, after the inbound shield removed hidden nodes."""
147
+ if not html:
148
+ return ""
149
+ tree = LexborHTMLParser(clean_html(html))
150
+ root = tree.body or tree.root
151
+ out = _md(root, links) if root else ""
152
+ return _BLANKS.sub("\n\n", "\n".join(ln.rstrip() for ln in out.splitlines())).strip()
153
+
154
+
155
+ def _md(node, links: bool) -> str:
156
+ parts = []
157
+ for child in node.iter(include_text=True):
158
+ tag = child.tag
159
+ if tag == "-text":
160
+ parts.append(
161
+ _WS.sub(" ", htmllib.unescape(child.text_content or "")).replace("\n", " ")
162
+ )
163
+ elif tag in _SKIP or tag.startswith("-"):
164
+ continue
165
+ elif tag == "pre":
166
+ parts.append(f"\n\n```\n{child.text(deep=True).strip(chr(10))}\n```\n\n")
167
+ elif tag == "code":
168
+ parts.append(f"`{child.text(deep=True)}`")
169
+ elif tag == "br":
170
+ parts.append("\n")
171
+ elif tag in ("h1", "h2", "h3", "h4", "h5", "h6"):
172
+ parts.append(f"\n\n{'#' * int(tag[1])} {_md(child, links).strip()}\n\n")
173
+ elif tag == "li":
174
+ parts.append(f"\n- {_md(child, links).strip()}")
175
+ elif tag in ("ul", "ol"):
176
+ parts.append(f"\n{_md(child, links)}\n")
177
+ elif tag == "blockquote":
178
+ inner = _md(child, links).strip()
179
+ parts.append("\n\n" + "\n".join(f"> {ln}" for ln in inner.splitlines()) + "\n\n")
180
+ elif tag == "a":
181
+ text = _md(child, links).strip()
182
+ href = child.attributes.get("href") or ""
183
+ use = links and href.startswith("http") and text and text != href
184
+ parts.append(f"[{text}]({href})" if use else text or href)
185
+ elif tag in ("td", "th"):
186
+ parts.append(f" {_md(child, links).strip()} |")
187
+ elif tag == "tr":
188
+ parts.append(f"\n|{_md(child, links)}")
189
+ elif tag in _BLOCK:
190
+ parts.append(f"\n\n{_md(child, links).strip()}\n\n")
191
+ else:
192
+ parts.append(_md(child, links))
193
+ return "".join(parts)
194
+
195
+
196
+ def html_text(html: str) -> str:
197
+ return " ".join(LexborHTMLParser(clean_html(html)).text(separator=" ").split()) if html else ""
198
+
199
+
200
+ # ---- structured data embedded in pages ----------------------------------------------------------
201
+ _JSON = json.JSONDecoder()
202
+
203
+
204
+ def meta_tags(html: str) -> dict[str, str]:
205
+ """og:/twitter:/name= meta tags, lower-cased keys."""
206
+ out = {}
207
+ for m in LexborHTMLParser(html).css("meta[property], meta[name]"):
208
+ key = (m.attributes.get("property") or m.attributes.get("name") or "").lower()
209
+ if key and (content := m.attributes.get("content")):
210
+ out.setdefault(key, htmllib.unescape(content))
211
+ return out
212
+
213
+
214
+ def json_ld(html: str) -> list[dict]:
215
+ items = []
216
+ for node in LexborHTMLParser(html).css('script[type="application/ld+json"]'):
217
+ try:
218
+ data = json.loads(node.text())
219
+ except ValueError:
220
+ continue
221
+ for d in data if isinstance(data, list) else [data]:
222
+ if isinstance(d, dict):
223
+ items += d["@graph"] if isinstance(d.get("@graph"), list) else [d]
224
+ return items
225
+
226
+
227
+ def script_json(html: str, selector: str):
228
+ """JSON inside a <script> matched by CSS selector (e.g. script#__NEXT_DATA__)."""
229
+ node = LexborHTMLParser(html).css_first(selector)
230
+ if not node:
231
+ return None
232
+ try:
233
+ return json.loads(node.text())
234
+ except ValueError:
235
+ return None
236
+
237
+
238
+ def assigned_json(html: str, var: str, *, js_undefined: bool = False):
239
+ """The object literal assigned to `var` in inline JS (`window.__INITIAL_STATE__ = {...}`)."""
240
+ m = re.search(rf"{re.escape(var)}\s*=\s*", html)
241
+ if not m:
242
+ return None
243
+ text = html[m.end() :]
244
+ if js_undefined: # JS-only literal some sites leave in their state dumps
245
+ text = re.sub(r"([:\[,]\s*)undefined(?=\s*[,}\]])", r"\1null", text)
246
+ try:
247
+ return _JSON.raw_decode(text)[0]
248
+ except ValueError:
249
+ return None
File without changes