pyrepowiki-cli 0.1.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.
- pyrepowiki_cli-0.1.0.dist-info/METADATA +875 -0
- pyrepowiki_cli-0.1.0.dist-info/RECORD +28 -0
- pyrepowiki_cli-0.1.0.dist-info/WHEEL +4 -0
- pyrepowiki_cli-0.1.0.dist-info/entry_points.txt +2 -0
- repowiki/__init__.py +3 -0
- repowiki/cli.py +43 -0
- repowiki/services/__init__.py +1 -0
- repowiki/services/codewiki/__init__.py +5 -0
- repowiki/services/codewiki/boq.py +57 -0
- repowiki/services/codewiki/cli.py +315 -0
- repowiki/services/codewiki/client.py +237 -0
- repowiki/services/codewiki/wiki.py +162 -0
- repowiki/services/deepwiki/__init__.py +5 -0
- repowiki/services/deepwiki/cli.py +851 -0
- repowiki/services/deepwiki/client.py +164 -0
- repowiki/services/deepwiki/codemap.py +74 -0
- repowiki/services/deepwiki/devin.py +355 -0
- repowiki/services/zread/__init__.py +5 -0
- repowiki/services/zread/cli.py +653 -0
- repowiki/services/zread/client.py +414 -0
- repowiki/services/zread/flight.py +116 -0
- repowiki/shared/__init__.py +1 -0
- repowiki/shared/async_.py +15 -0
- repowiki/shared/export.py +36 -0
- repowiki/shared/model.py +29 -0
- repowiki/shared/output.py +204 -0
- repowiki/shared/repo.py +45 -0
- repowiki/shared/save.py +27 -0
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
"""ZreadClient: RSC scraping + REST metadata + ask talk over httpx."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import random
|
|
8
|
+
import ssl
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from repowiki.services.zread.flight import (
|
|
13
|
+
WikiInfo,
|
|
14
|
+
Page,
|
|
15
|
+
parse_wiki_data,
|
|
16
|
+
rewrite_callouts,
|
|
17
|
+
strip_frontmatter,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
BASE = "https://zread.ai"
|
|
21
|
+
USER_AGENT = (
|
|
22
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
|
|
23
|
+
"(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
|
|
24
|
+
)
|
|
25
|
+
_TRANSIENT_STATUS = {408, 425, 429, 500, 502, 503, 504, 520, 521, 522, 523, 524, 525, 526, 527, 528, 529}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ZreadError(Exception):
|
|
29
|
+
"""Base error for Zread."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ZreadConnectionError(ZreadError):
|
|
33
|
+
"""Failed to reach zread.ai."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ZreadNotFoundError(ZreadError):
|
|
37
|
+
"""Repo/page not indexed or not found on zread.ai."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ZreadChallengeError(ZreadError):
|
|
41
|
+
"""Cloudflare challenge or rate-limit (403/429/503)."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _split(repo: str) -> tuple[str, str]:
|
|
45
|
+
owner, _, name = repo.partition("/")
|
|
46
|
+
if not name:
|
|
47
|
+
raise ValueError(f"invalid repo: {repo!r}")
|
|
48
|
+
return owner, name
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _backoff(attempt: int) -> float:
|
|
52
|
+
return min(2 ** attempt + random.uniform(0, 1.5), 30.0)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _is_cert_error(exc: BaseException) -> bool:
|
|
56
|
+
while exc is not None:
|
|
57
|
+
if isinstance(exc, ssl.SSLCertVerificationError):
|
|
58
|
+
return True
|
|
59
|
+
exc = exc.__cause__ or exc.__context__
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _error_detail(resp: httpx.Response) -> str:
|
|
64
|
+
try:
|
|
65
|
+
data = resp.json()
|
|
66
|
+
except ValueError:
|
|
67
|
+
data = None
|
|
68
|
+
if isinstance(data, dict):
|
|
69
|
+
for key in ("msg", "detail", "message", "error"):
|
|
70
|
+
value = data.get(key)
|
|
71
|
+
if isinstance(value, str) and value.strip():
|
|
72
|
+
return value.strip()
|
|
73
|
+
return resp.text.strip()[:500]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _unwrap(resp: httpx.Response) -> object:
|
|
77
|
+
try:
|
|
78
|
+
result = resp.json()
|
|
79
|
+
except json.JSONDecodeError as exc:
|
|
80
|
+
raise ZreadError("invalid JSON response from Zread") from exc
|
|
81
|
+
if not isinstance(result, dict) or result.get("code") != 0:
|
|
82
|
+
msg = result.get("msg") if isinstance(result, dict) else "unknown"
|
|
83
|
+
raise ZreadError(f"Zread API error: {msg}")
|
|
84
|
+
return result.get("data")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _parse_sse_body(lines: list[str]) -> str:
|
|
88
|
+
event = None
|
|
89
|
+
finished: list[str] = []
|
|
90
|
+
chunks: list[str] = []
|
|
91
|
+
for line in lines:
|
|
92
|
+
line = line.strip()
|
|
93
|
+
if not line:
|
|
94
|
+
continue
|
|
95
|
+
if line.startswith("event:"):
|
|
96
|
+
event = line[6:].strip()
|
|
97
|
+
if event == "finish":
|
|
98
|
+
break
|
|
99
|
+
if event == "error":
|
|
100
|
+
raise ZreadError("talk stream returned an error event")
|
|
101
|
+
elif line.startswith("data:"):
|
|
102
|
+
try:
|
|
103
|
+
data = json.loads(line[5:].strip())
|
|
104
|
+
except json.JSONDecodeError:
|
|
105
|
+
continue
|
|
106
|
+
if event == "round_finish":
|
|
107
|
+
finished.append(data.get("text", ""))
|
|
108
|
+
elif event == "answer":
|
|
109
|
+
chunks.append(data.get("text", ""))
|
|
110
|
+
text = "\n".join(finished) or "".join(chunks)
|
|
111
|
+
return text.strip()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class ZreadClient:
|
|
115
|
+
"""Stateless client; language/model/token are fixed per instance."""
|
|
116
|
+
|
|
117
|
+
def __init__(
|
|
118
|
+
self,
|
|
119
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
120
|
+
lang: str = "en",
|
|
121
|
+
model: str = "glm-5.1",
|
|
122
|
+
token: str | None = None,
|
|
123
|
+
retries: int = 5,
|
|
124
|
+
) -> None:
|
|
125
|
+
self._transport = transport
|
|
126
|
+
self._lang = lang
|
|
127
|
+
self._model = model
|
|
128
|
+
self._token = token
|
|
129
|
+
self._retries = retries
|
|
130
|
+
|
|
131
|
+
def _http(self, timeout: float) -> httpx.AsyncClient:
|
|
132
|
+
return httpx.AsyncClient(
|
|
133
|
+
timeout=timeout,
|
|
134
|
+
verify=True,
|
|
135
|
+
headers={"User-Agent": USER_AGENT},
|
|
136
|
+
cookies={"X-Locale": self._lang},
|
|
137
|
+
transport=self._transport,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
def _headers(self, extra: dict | None = None) -> dict:
|
|
141
|
+
h = {"x-locale": self._lang}
|
|
142
|
+
if extra:
|
|
143
|
+
h.update(extra)
|
|
144
|
+
return h
|
|
145
|
+
|
|
146
|
+
async def _request(
|
|
147
|
+
self,
|
|
148
|
+
method: str,
|
|
149
|
+
url: str,
|
|
150
|
+
*,
|
|
151
|
+
timeout: float,
|
|
152
|
+
headers: dict | None = None,
|
|
153
|
+
params: dict | None = None,
|
|
154
|
+
json_body: dict | None = None,
|
|
155
|
+
) -> httpx.Response:
|
|
156
|
+
async with self._http(timeout) as http:
|
|
157
|
+
for attempt in range(1, self._retries + 1):
|
|
158
|
+
try:
|
|
159
|
+
resp = await http.request(
|
|
160
|
+
method, url, headers=headers, params=params, json=json_body,
|
|
161
|
+
)
|
|
162
|
+
except httpx.TransportError as exc:
|
|
163
|
+
message = f"Failed to connect to Zread: {exc}"
|
|
164
|
+
if _is_cert_error(exc):
|
|
165
|
+
message += (
|
|
166
|
+
" (TLS certificate verification failed; set SSL_CERT_FILE "
|
|
167
|
+
"to your CA bundle to trust a proxy/mirror)"
|
|
168
|
+
)
|
|
169
|
+
if attempt >= self._retries or _is_cert_error(exc):
|
|
170
|
+
raise ZreadConnectionError(message) from exc
|
|
171
|
+
await asyncio.sleep(_backoff(attempt))
|
|
172
|
+
continue
|
|
173
|
+
if resp.status_code == 429 and attempt < self._retries:
|
|
174
|
+
wait = _backoff(attempt)
|
|
175
|
+
try:
|
|
176
|
+
wait = max(wait, float(resp.headers.get("Retry-After", "")))
|
|
177
|
+
except ValueError:
|
|
178
|
+
pass
|
|
179
|
+
await asyncio.sleep(wait)
|
|
180
|
+
continue
|
|
181
|
+
if resp.status_code in _TRANSIENT_STATUS and attempt < self._retries:
|
|
182
|
+
await asyncio.sleep(_backoff(attempt))
|
|
183
|
+
continue
|
|
184
|
+
if resp.status_code in (403, 429, 503):
|
|
185
|
+
raise ZreadChallengeError(f"Zread returned HTTP {resp.status_code} (Cloudflare)")
|
|
186
|
+
if resp.status_code == 404:
|
|
187
|
+
raise ZreadNotFoundError(f"Zread returned HTTP 404 for {url}")
|
|
188
|
+
if resp.status_code in _TRANSIENT_STATUS:
|
|
189
|
+
raise ZreadConnectionError(
|
|
190
|
+
f"Zread returned HTTP {resp.status_code} after {self._retries} attempts"
|
|
191
|
+
)
|
|
192
|
+
if not resp.is_success:
|
|
193
|
+
detail = _error_detail(resp)
|
|
194
|
+
raise ZreadError(
|
|
195
|
+
f"Zread returned HTTP {resp.status_code} for {url}"
|
|
196
|
+
+ (f": {detail}" if detail else "")
|
|
197
|
+
)
|
|
198
|
+
return resp
|
|
199
|
+
raise ZreadError(f"Zread request failed after {self._retries} attempts")
|
|
200
|
+
|
|
201
|
+
async def repo_info(self, repo: str) -> dict:
|
|
202
|
+
owner, name = _split(repo)
|
|
203
|
+
resp = await self._request(
|
|
204
|
+
"GET", f"{BASE}/api/v1/repo/github/{owner}/{name}",
|
|
205
|
+
timeout=30.0, headers=self._headers(),
|
|
206
|
+
)
|
|
207
|
+
return _unwrap(resp) or {}
|
|
208
|
+
|
|
209
|
+
async def search_repos(self, query: str) -> list:
|
|
210
|
+
resp = await self._request(
|
|
211
|
+
"GET", f"{BASE}/api/v1/repo", timeout=30.0,
|
|
212
|
+
params={"q": query}, headers=self._headers(),
|
|
213
|
+
)
|
|
214
|
+
return _unwrap(resp) or []
|
|
215
|
+
|
|
216
|
+
async def trending(self) -> list:
|
|
217
|
+
resp = await self._request(
|
|
218
|
+
"GET", f"{BASE}/api/v1/public/repo/trending",
|
|
219
|
+
timeout=30.0, headers=self._headers(),
|
|
220
|
+
)
|
|
221
|
+
return _unwrap(resp) or []
|
|
222
|
+
|
|
223
|
+
async def recommend(self, topic: str = "") -> dict:
|
|
224
|
+
params = {"topic": topic} if topic else None
|
|
225
|
+
resp = await self._request(
|
|
226
|
+
"GET", f"{BASE}/api/v1/repo/recommend", timeout=30.0,
|
|
227
|
+
params=params, headers=self._headers(),
|
|
228
|
+
)
|
|
229
|
+
return _unwrap(resp) or {}
|
|
230
|
+
|
|
231
|
+
async def submit(self, repo: str) -> dict:
|
|
232
|
+
if not self._token:
|
|
233
|
+
raise ZreadError(
|
|
234
|
+
"ZREAD_TOKEN is required for submit. Get it from zread.ai after login: "
|
|
235
|
+
"run JSON.parse(localStorage.getItem('CGX_AUTH_STORAGE')).state.token in "
|
|
236
|
+
"the DevTools console, then export ZREAD_TOKEN."
|
|
237
|
+
)
|
|
238
|
+
resp = await self._request(
|
|
239
|
+
"POST", f"{BASE}/api/v1/public/repo/submit", timeout=30.0,
|
|
240
|
+
# The endpoint 400s without notification_email; upstream sends this placeholder.
|
|
241
|
+
json_body={"name_or_path": repo, "notification_email": "example@zread.ai"},
|
|
242
|
+
headers=self._headers({"Authorization": f"Bearer {self._token}"}),
|
|
243
|
+
)
|
|
244
|
+
return _unwrap(resp) or {}
|
|
245
|
+
|
|
246
|
+
async def search_wiki(self, repo: str, query: str) -> list:
|
|
247
|
+
info = await self.repo_info(repo)
|
|
248
|
+
wiki_id = str(info.get("wiki_id") or "")
|
|
249
|
+
if not wiki_id:
|
|
250
|
+
raise ZreadNotFoundError(f"no wiki_id for {repo}")
|
|
251
|
+
resp = await self._request(
|
|
252
|
+
"GET", f"{BASE}/api/v1/wiki/{wiki_id}/search", timeout=30.0,
|
|
253
|
+
params={"q": query}, headers=self._headers(),
|
|
254
|
+
)
|
|
255
|
+
return _unwrap(resp) or []
|
|
256
|
+
|
|
257
|
+
async def refresh(self, repo: str) -> dict:
|
|
258
|
+
info = await self.repo_info(repo)
|
|
259
|
+
repo_id = str(info.get("repo_id") or "")
|
|
260
|
+
if not repo_id:
|
|
261
|
+
raise ZreadNotFoundError(f"no repo_id for {repo}")
|
|
262
|
+
await self._request(
|
|
263
|
+
"POST", f"{BASE}/api/v1/repo/{repo_id}/refresh", timeout=30.0,
|
|
264
|
+
headers=self._headers(),
|
|
265
|
+
)
|
|
266
|
+
return {"repo_id": repo_id, "ok": True}
|
|
267
|
+
|
|
268
|
+
async def eta(self) -> dict:
|
|
269
|
+
resp = await self._request(
|
|
270
|
+
"GET", f"{BASE}/api/v1/repo/eta", timeout=30.0, headers=self._headers(),
|
|
271
|
+
)
|
|
272
|
+
return _unwrap(resp) or {}
|
|
273
|
+
|
|
274
|
+
async def github_head(self, repo: str) -> dict:
|
|
275
|
+
"""Return the GitHub HEAD commit for a repo: ``{"sha": ..., "when": ...}``."""
|
|
276
|
+
owner, name = _split(repo)
|
|
277
|
+
resp = await self._request(
|
|
278
|
+
"GET", f"https://api.github.com/repos/{owner}/{name}/commits/HEAD",
|
|
279
|
+
timeout=30.0, headers=self._headers(),
|
|
280
|
+
)
|
|
281
|
+
try:
|
|
282
|
+
commit = resp.json()
|
|
283
|
+
except json.JSONDecodeError as exc:
|
|
284
|
+
raise ZreadError("invalid JSON response from GitHub") from exc
|
|
285
|
+
if not isinstance(commit, dict):
|
|
286
|
+
raise ZreadError("unexpected GitHub response")
|
|
287
|
+
committer = commit.get("commit", {}).get("committer") or {}
|
|
288
|
+
when = committer.get("date") if isinstance(committer, dict) else None
|
|
289
|
+
return {"sha": commit.get("sha") or "", "when": when}
|
|
290
|
+
|
|
291
|
+
async def read_file(
|
|
292
|
+
self, repo_id: str, path: str,
|
|
293
|
+
start: int | None = None, end: int | None = None,
|
|
294
|
+
) -> str:
|
|
295
|
+
item = {"path": path}
|
|
296
|
+
if start is not None:
|
|
297
|
+
item["start_line"] = start
|
|
298
|
+
if end is not None:
|
|
299
|
+
item["end_line"] = end
|
|
300
|
+
resp = await self._request(
|
|
301
|
+
"POST", f"{BASE}/api/v1/repo/{repo_id}/files", timeout=30.0,
|
|
302
|
+
json_body={"files": [item]}, headers=self._headers(),
|
|
303
|
+
)
|
|
304
|
+
files = _unwrap(resp)
|
|
305
|
+
if not isinstance(files, list) or not files:
|
|
306
|
+
raise ZreadNotFoundError(f"file not found: {path}")
|
|
307
|
+
info = files[0]
|
|
308
|
+
snippet = info.get("snippet")
|
|
309
|
+
if isinstance(snippet, dict) and snippet.get("content") is not None:
|
|
310
|
+
return snippet["content"]
|
|
311
|
+
return info.get("content", "")
|
|
312
|
+
|
|
313
|
+
async def outline(self, repo: str) -> tuple[WikiInfo, list[Page]]:
|
|
314
|
+
wiki_id = await self._wiki_id(repo)
|
|
315
|
+
resp = await self._request(
|
|
316
|
+
"GET", f"{BASE}/api/v1/wiki/{wiki_id}", timeout=30.0, headers=self._headers(),
|
|
317
|
+
)
|
|
318
|
+
data = _unwrap(resp)
|
|
319
|
+
if not isinstance(data, dict):
|
|
320
|
+
raise ZreadNotFoundError(f"no wiki found for {repo}")
|
|
321
|
+
wiki_info, pages = parse_wiki_data(data)
|
|
322
|
+
if not pages:
|
|
323
|
+
raise ZreadNotFoundError(f"no wiki found for {repo}")
|
|
324
|
+
return wiki_info, pages
|
|
325
|
+
|
|
326
|
+
async def page(self, repo: str, slug: str | None = None) -> str:
|
|
327
|
+
wiki_id = await self._wiki_id(repo)
|
|
328
|
+
if slug is None:
|
|
329
|
+
_, pages = await self.outline(repo)
|
|
330
|
+
if not pages:
|
|
331
|
+
raise ZreadNotFoundError(f"no wiki pages for {repo}")
|
|
332
|
+
slug = pages[0].slug
|
|
333
|
+
resp = await self._request(
|
|
334
|
+
"GET", f"{BASE}/api/v1/wiki/{wiki_id}/page/{slug}", timeout=30.0,
|
|
335
|
+
headers=self._headers(),
|
|
336
|
+
)
|
|
337
|
+
data = _unwrap(resp)
|
|
338
|
+
if not isinstance(data, dict):
|
|
339
|
+
raise ZreadNotFoundError(f"no markdown found for {repo}/{slug}")
|
|
340
|
+
content = data.get("content")
|
|
341
|
+
if not isinstance(content, str) or not content.strip():
|
|
342
|
+
raise ZreadNotFoundError(f"no markdown found for {repo}/{slug}")
|
|
343
|
+
return rewrite_callouts(strip_frontmatter(content))
|
|
344
|
+
|
|
345
|
+
async def _wiki_id(self, repo: str) -> str:
|
|
346
|
+
info = await self.repo_info(repo)
|
|
347
|
+
wiki_id = str(info.get("wiki_id") or "")
|
|
348
|
+
if not wiki_id:
|
|
349
|
+
raise ZreadNotFoundError(f"no wiki_id for {repo}")
|
|
350
|
+
return wiki_id
|
|
351
|
+
|
|
352
|
+
async def ask(self, repo: str, question: str) -> str:
|
|
353
|
+
if not self._token:
|
|
354
|
+
raise ZreadError(
|
|
355
|
+
"ZREAD_TOKEN is required for ask. Get it from zread.ai after login: "
|
|
356
|
+
"run JSON.parse(localStorage.getItem('CGX_AUTH_STORAGE')).state.token in "
|
|
357
|
+
"the DevTools console, then export ZREAD_TOKEN."
|
|
358
|
+
)
|
|
359
|
+
info = await self.repo_info(repo)
|
|
360
|
+
repo_id = str(info.get("repo_id") or "")
|
|
361
|
+
wiki_id = str(info.get("wiki_id") or "")
|
|
362
|
+
if not repo_id or not wiki_id:
|
|
363
|
+
raise ZreadNotFoundError(f"{repo} is not indexed on zread.ai")
|
|
364
|
+
_, pages = await self.outline(repo)
|
|
365
|
+
if not pages:
|
|
366
|
+
raise ZreadNotFoundError(f"{repo} has no wiki pages")
|
|
367
|
+
talk_id = await self._create_talk()
|
|
368
|
+
return await self._send_message(talk_id, question, wiki_id, pages[0].page_id, repo_id)
|
|
369
|
+
|
|
370
|
+
async def _create_talk(self) -> str:
|
|
371
|
+
resp = await self._request(
|
|
372
|
+
"POST", f"{BASE}/api/v1/talk", timeout=30.0,
|
|
373
|
+
json_body={"query": " "},
|
|
374
|
+
headers=self._headers({"Authorization": f"Bearer {self._token}"}),
|
|
375
|
+
)
|
|
376
|
+
data = _unwrap(resp) or {}
|
|
377
|
+
talk_id = data.get("talk_id")
|
|
378
|
+
if not talk_id:
|
|
379
|
+
raise ZreadError("talk creation returned no talk_id")
|
|
380
|
+
return talk_id
|
|
381
|
+
|
|
382
|
+
async def _send_message(
|
|
383
|
+
self, talk_id: str, question: str,
|
|
384
|
+
wiki_id: str, page_id: str, repo_id: str,
|
|
385
|
+
) -> str:
|
|
386
|
+
url = f"{BASE}/api/v1/talk/{talk_id}/message"
|
|
387
|
+
headers = self._headers({
|
|
388
|
+
"Authorization": f"Bearer {self._token}",
|
|
389
|
+
"Accept": "text/event-stream",
|
|
390
|
+
})
|
|
391
|
+
context = {"wiki": {"page_id": page_id, "wiki_id": wiki_id}}
|
|
392
|
+
if repo_id:
|
|
393
|
+
context["repo"] = {"repo_id": repo_id}
|
|
394
|
+
body = {
|
|
395
|
+
"parent_message_id": "",
|
|
396
|
+
"query": question,
|
|
397
|
+
"context": context,
|
|
398
|
+
"model": self._model,
|
|
399
|
+
}
|
|
400
|
+
async with self._http(timeout=120.0) as http:
|
|
401
|
+
async with http.stream("POST", url, headers=headers, json=body) as resp:
|
|
402
|
+
if resp.status_code in (403, 429, 503):
|
|
403
|
+
raise ZreadChallengeError(f"Zread returned HTTP {resp.status_code}")
|
|
404
|
+
if resp.status_code == 404:
|
|
405
|
+
raise ZreadNotFoundError(f"talk not found: {talk_id}")
|
|
406
|
+
if not resp.is_success:
|
|
407
|
+
await resp.aread()
|
|
408
|
+
detail = _error_detail(resp)
|
|
409
|
+
raise ZreadError(
|
|
410
|
+
f"Zread returned HTTP {resp.status_code} for {url}"
|
|
411
|
+
+ (f": {detail}" if detail else "")
|
|
412
|
+
)
|
|
413
|
+
lines = [line async for line in resp.aiter_lines()]
|
|
414
|
+
return _parse_sse_body(lines)
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Models and pure-function transforms for zread.ai wiki data."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Page:
|
|
11
|
+
page_id: str
|
|
12
|
+
slug: str
|
|
13
|
+
topic: str
|
|
14
|
+
section: str = ""
|
|
15
|
+
group: str = ""
|
|
16
|
+
order: int = 0
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class WikiInfo:
|
|
21
|
+
wiki_id: str = ""
|
|
22
|
+
repo_id: str = ""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
_CALLOUT_RE = re.compile(r"<Cgx(?P<kind>[A-Za-z]+)>(?P<body>.*?)</Cgx(?P=kind)>", re.S)
|
|
26
|
+
_FENCE_OPEN_RE = re.compile(r"^\s{0,3}(```+|~~~+)")
|
|
27
|
+
|
|
28
|
+
_CALLOUT_KIND = {
|
|
29
|
+
"tip": "TIP",
|
|
30
|
+
"note": "NOTE",
|
|
31
|
+
"info": "NOTE",
|
|
32
|
+
"warn": "WARNING",
|
|
33
|
+
"warning": "WARNING",
|
|
34
|
+
"caution": "CAUTION",
|
|
35
|
+
"important": "CAUTION",
|
|
36
|
+
"danger": "CAUTION",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def parse_wiki_data(data: dict) -> tuple[WikiInfo, list[Page]]:
|
|
41
|
+
"""Build ``(WikiInfo, pages)`` from the ``GET /api/v1/wiki/{wiki_id}`` body."""
|
|
42
|
+
info = data.get("info") if isinstance(data.get("info"), dict) else {}
|
|
43
|
+
wiki_info = WikiInfo(
|
|
44
|
+
wiki_id=str(info.get("wiki_id") or ""),
|
|
45
|
+
repo_id=str(info.get("repo_id") or ""),
|
|
46
|
+
)
|
|
47
|
+
pages = []
|
|
48
|
+
raw_pages = data.get("pages") if isinstance(data.get("pages"), list) else []
|
|
49
|
+
for i, p in enumerate(raw_pages):
|
|
50
|
+
if not isinstance(p, dict):
|
|
51
|
+
continue
|
|
52
|
+
slug = (p.get("slug") or "").strip()
|
|
53
|
+
if not slug:
|
|
54
|
+
continue
|
|
55
|
+
pages.append(
|
|
56
|
+
Page(
|
|
57
|
+
page_id=str(p.get("page_id") or ""),
|
|
58
|
+
slug=slug,
|
|
59
|
+
topic=(p.get("topic") or slug).strip(),
|
|
60
|
+
section=(p.get("section") or "").strip(),
|
|
61
|
+
group=(p.get("group") or "").strip(),
|
|
62
|
+
order=int(p.get("order", i) or 0),
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
pages.sort(key=lambda p: p.order)
|
|
66
|
+
for i, p in enumerate(pages):
|
|
67
|
+
p.order = i
|
|
68
|
+
return wiki_info, pages
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _code_spans(md: str) -> list[tuple[int, int]]:
|
|
72
|
+
spans = []
|
|
73
|
+
pos = 0
|
|
74
|
+
fence = None
|
|
75
|
+
start = 0
|
|
76
|
+
for line in md.splitlines(keepends=True):
|
|
77
|
+
stripped = line.rstrip("\n")
|
|
78
|
+
if fence is None:
|
|
79
|
+
m = _FENCE_OPEN_RE.match(stripped)
|
|
80
|
+
if m:
|
|
81
|
+
fence = m.group(1)[0] * 3
|
|
82
|
+
start = pos
|
|
83
|
+
elif stripped.strip().startswith(fence):
|
|
84
|
+
spans.append((start, pos + len(line)))
|
|
85
|
+
fence = None
|
|
86
|
+
pos += len(line)
|
|
87
|
+
if fence is not None:
|
|
88
|
+
spans.append((start, len(md)))
|
|
89
|
+
return spans
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _in_spans(idx: int, spans: list[tuple[int, int]]) -> bool:
|
|
93
|
+
return any(a <= idx < b for a, b in spans)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def rewrite_callouts(md: str) -> str:
|
|
97
|
+
"""Convert ``<CgxTip>…</CgxTip>`` shells to GitHub ``> [!TIP]`` blocks."""
|
|
98
|
+
spans = _code_spans(md)
|
|
99
|
+
|
|
100
|
+
def sub(m: re.Match) -> str:
|
|
101
|
+
if _in_spans(m.start(), spans):
|
|
102
|
+
return m.group(0)
|
|
103
|
+
kind = _CALLOUT_KIND.get(m.group("kind").lower(), "NOTE")
|
|
104
|
+
body = m.group("body").strip()
|
|
105
|
+
if not body:
|
|
106
|
+
return ""
|
|
107
|
+
lines = "\n".join(f"> {ln}".rstrip() for ln in body.split("\n"))
|
|
108
|
+
return f"> [!{kind}]\n{lines}"
|
|
109
|
+
|
|
110
|
+
return _CALLOUT_RE.sub(sub, md)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def strip_frontmatter(md: str) -> str:
|
|
114
|
+
"""Remove a leading ``---\n…\n---`` YAML frontmatter block."""
|
|
115
|
+
m = re.match(r"^---\n.*?\n---\n\n?", md, re.S)
|
|
116
|
+
return md[m.end():] if m else md
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Shared plumbing for all repowiki services."""
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Synchronous bridge for async client calls.
|
|
2
|
+
|
|
3
|
+
Named ``async_`` (not ``async``) because ``async`` is a Python keyword and a
|
|
4
|
+
module by that name cannot be reached with a normal ``import`` statement.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def run_async(coro: Any) -> Any:
|
|
14
|
+
"""Run an async coroutine synchronously."""
|
|
15
|
+
return asyncio.run(coro)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Export wiki pages as Markdown files plus llms.txt / README.md indexes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _safe_name(title: str, index: int) -> str:
|
|
10
|
+
"""Turn a page title into a filesystem-safe ``NN-name.md`` filename."""
|
|
11
|
+
slug = re.sub(r"[^\w\-]+", "-", title.strip()).strip("-").lower() or "page"
|
|
12
|
+
return f"{index:02d}-{slug}.md"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def export_pages(out_dir: str, repo: str, pages: list[tuple[str, str]], full: str) -> int:
|
|
16
|
+
"""Write one file per page plus ``llms.txt`` / ``README.md`` / ``llms-full.txt``.
|
|
17
|
+
|
|
18
|
+
``pages`` is a list of ``(title, markdown)``; ``full`` is the complete
|
|
19
|
+
concatenated markdown. ``llms.txt`` and ``README.md`` carry the same page
|
|
20
|
+
index (``README.md`` for GitHub auto-rendering). Returns the page count.
|
|
21
|
+
"""
|
|
22
|
+
out = Path(out_dir)
|
|
23
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
24
|
+
entries: list[tuple[str, str]] = []
|
|
25
|
+
for i, (title, markdown) in enumerate(pages):
|
|
26
|
+
fname = _safe_name(title, i)
|
|
27
|
+
(out / fname).write_text(markdown, encoding="utf-8")
|
|
28
|
+
entries.append((title, fname))
|
|
29
|
+
index = ["# " + repo, ""]
|
|
30
|
+
for title, fname in entries:
|
|
31
|
+
index.append(f"- [{title}]({fname})")
|
|
32
|
+
index_text = "\n".join(index) + "\n"
|
|
33
|
+
(out / "llms.txt").write_text(index_text, encoding="utf-8")
|
|
34
|
+
(out / "README.md").write_text(index_text, encoding="utf-8")
|
|
35
|
+
(out / "llms-full.txt").write_text(full + "\n", encoding="utf-8")
|
|
36
|
+
return len(entries)
|
repowiki/shared/model.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Shared result types for wiki backends."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class Reference:
|
|
10
|
+
file_path: str
|
|
11
|
+
range_start: int | None = None
|
|
12
|
+
range_end: int | None = None
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class SourceFile:
|
|
17
|
+
repo: str
|
|
18
|
+
path: str
|
|
19
|
+
content: str
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class Answer:
|
|
24
|
+
body: str
|
|
25
|
+
summary: str | None = None
|
|
26
|
+
references: list[Reference] = field(default_factory=list)
|
|
27
|
+
sources: list[SourceFile] = field(default_factory=list)
|
|
28
|
+
stats: dict[str, float] = field(default_factory=dict)
|
|
29
|
+
query_id: str | None = None
|