cli-web-codewiki 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.
- cli_web/codewiki/__init__.py +3 -0
- cli_web/codewiki/__main__.py +6 -0
- cli_web/codewiki/codewiki_cli.py +142 -0
- cli_web/codewiki/commands/__init__.py +0 -0
- cli_web/codewiki/commands/chat.py +46 -0
- cli_web/codewiki/commands/repos.py +90 -0
- cli_web/codewiki/commands/wiki.py +267 -0
- cli_web/codewiki/core/__init__.py +0 -0
- cli_web/codewiki/core/client.py +224 -0
- cli_web/codewiki/core/exceptions.py +74 -0
- cli_web/codewiki/core/models.py +91 -0
- cli_web/codewiki/core/rpc/__init__.py +0 -0
- cli_web/codewiki/core/rpc/decoder.py +86 -0
- cli_web/codewiki/core/rpc/encoder.py +32 -0
- cli_web/codewiki/core/rpc/types.py +27 -0
- cli_web/codewiki/tests/__init__.py +0 -0
- cli_web/codewiki/tests/test_core.py +725 -0
- cli_web/codewiki/tests/test_e2e.py +411 -0
- cli_web/codewiki/utils/__init__.py +0 -0
- cli_web/codewiki/utils/config.py +14 -0
- cli_web/codewiki/utils/doctor.py +188 -0
- cli_web/codewiki/utils/helpers.py +67 -0
- cli_web/codewiki/utils/mcp_server.py +290 -0
- cli_web/codewiki/utils/output.py +11 -0
- cli_web/codewiki/utils/repl_skin.py +486 -0
- cli_web_codewiki-0.1.0.dist-info/METADATA +14 -0
- cli_web_codewiki-0.1.0.dist-info/RECORD +30 -0
- cli_web_codewiki-0.1.0.dist-info/WHEEL +5 -0
- cli_web_codewiki-0.1.0.dist-info/entry_points.txt +2 -0
- cli_web_codewiki-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""HTTP client for Code Wiki batchexecute API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from .exceptions import (
|
|
11
|
+
AuthError,
|
|
12
|
+
CodeWikiError,
|
|
13
|
+
NetworkError,
|
|
14
|
+
NotFoundError,
|
|
15
|
+
RateLimitError,
|
|
16
|
+
ServerError,
|
|
17
|
+
)
|
|
18
|
+
from .models import ChatResponse, Repository, WikiPage, WikiSection
|
|
19
|
+
from .rpc.decoder import decode_response
|
|
20
|
+
from .rpc.encoder import build_url, encode_request
|
|
21
|
+
from .rpc.types import DEFAULT_HEADERS, RPCMethod
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class CodeWikiClient:
|
|
25
|
+
"""Client for Google Code Wiki batchexecute API."""
|
|
26
|
+
|
|
27
|
+
def __init__(self) -> None:
|
|
28
|
+
self._http = httpx.Client(
|
|
29
|
+
headers=DEFAULT_HEADERS,
|
|
30
|
+
timeout=30.0,
|
|
31
|
+
follow_redirects=True,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
def close(self) -> None:
|
|
35
|
+
self._http.close()
|
|
36
|
+
|
|
37
|
+
def __enter__(self) -> CodeWikiClient:
|
|
38
|
+
return self
|
|
39
|
+
|
|
40
|
+
def __exit__(self, *exc) -> None:
|
|
41
|
+
self.close()
|
|
42
|
+
|
|
43
|
+
def _call(self, rpc_id: str, params: list) -> Any:
|
|
44
|
+
"""Make a batchexecute RPC call."""
|
|
45
|
+
url = build_url(rpc_id)
|
|
46
|
+
body = encode_request(rpc_id, params)
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
resp = self._http.post(
|
|
50
|
+
url,
|
|
51
|
+
content=body.encode("utf-8"),
|
|
52
|
+
)
|
|
53
|
+
except httpx.ConnectError as exc:
|
|
54
|
+
raise NetworkError(f"Connection failed: {exc}") from exc
|
|
55
|
+
except httpx.TimeoutException as exc:
|
|
56
|
+
raise NetworkError(f"Request timed out: {exc}") from exc
|
|
57
|
+
|
|
58
|
+
if resp.status_code in (401, 403):
|
|
59
|
+
raise AuthError("Authentication required", recoverable=False)
|
|
60
|
+
if resp.status_code == 404:
|
|
61
|
+
raise NotFoundError("Resource not found")
|
|
62
|
+
if resp.status_code == 429:
|
|
63
|
+
retry = resp.headers.get("Retry-After")
|
|
64
|
+
raise RateLimitError(
|
|
65
|
+
"Rate limited by Code Wiki",
|
|
66
|
+
retry_after=float(retry) if retry else None,
|
|
67
|
+
)
|
|
68
|
+
if resp.status_code >= 500:
|
|
69
|
+
raise ServerError(f"Server error: {resp.status_code}", status_code=resp.status_code)
|
|
70
|
+
if resp.status_code >= 400:
|
|
71
|
+
raise CodeWikiError(f"HTTP {resp.status_code}: {resp.text[:200]}")
|
|
72
|
+
|
|
73
|
+
return decode_response(resp.content, rpc_id)
|
|
74
|
+
|
|
75
|
+
# ── Repos ───────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
def featured_repos(self) -> list[Repository]:
|
|
78
|
+
"""Get featured repositories from the homepage."""
|
|
79
|
+
raw = self._call(RPCMethod.FEATURED_REPOS, [])
|
|
80
|
+
if not raw:
|
|
81
|
+
return []
|
|
82
|
+
# Response is [[repo1, repo2, ...]] — unwrap outer array
|
|
83
|
+
items = (
|
|
84
|
+
raw[0] if isinstance(raw[0], list) and raw[0] and isinstance(raw[0][0], list) else raw
|
|
85
|
+
)
|
|
86
|
+
repos = []
|
|
87
|
+
for item in items:
|
|
88
|
+
if not isinstance(item, list) or len(item) < 6:
|
|
89
|
+
continue
|
|
90
|
+
slug = item[0] or ""
|
|
91
|
+
meta = item[3] if len(item) > 3 and isinstance(item[3], list) else [None, ""]
|
|
92
|
+
info = item[5] if len(item) > 5 and isinstance(item[5], list) else []
|
|
93
|
+
repos.append(
|
|
94
|
+
Repository(
|
|
95
|
+
slug=slug,
|
|
96
|
+
github_url=meta[1] if len(meta) > 1 else "",
|
|
97
|
+
description=info[0] if len(info) > 0 else "",
|
|
98
|
+
avatar_url=info[1] if len(info) > 1 else "",
|
|
99
|
+
stars=info[2] if len(info) > 2 else 0,
|
|
100
|
+
)
|
|
101
|
+
)
|
|
102
|
+
return repos
|
|
103
|
+
|
|
104
|
+
def search_repos(self, query: str, limit: int = 25, offset: int = 0) -> list[Repository]:
|
|
105
|
+
"""Search for repositories."""
|
|
106
|
+
raw = self._call(RPCMethod.SEARCH_REPOS, [query, limit, query, offset])
|
|
107
|
+
if not raw:
|
|
108
|
+
return []
|
|
109
|
+
# Response is [[repo1, repo2, ...]] — unwrap outer array
|
|
110
|
+
items = (
|
|
111
|
+
raw[0] if isinstance(raw[0], list) and raw[0] and isinstance(raw[0][0], list) else raw
|
|
112
|
+
)
|
|
113
|
+
repos = []
|
|
114
|
+
for item in items:
|
|
115
|
+
if not isinstance(item, list) or len(item) < 6:
|
|
116
|
+
continue
|
|
117
|
+
slug = item[0] or ""
|
|
118
|
+
meta = item[3] if isinstance(item[3], list) else [None, ""]
|
|
119
|
+
ts = item[4] if isinstance(item[4], list) else [None, None]
|
|
120
|
+
info = item[5] if isinstance(item[5], list) else []
|
|
121
|
+
updated = None
|
|
122
|
+
if ts and ts[0]:
|
|
123
|
+
try:
|
|
124
|
+
updated = datetime.fromtimestamp(ts[0])
|
|
125
|
+
except (ValueError, OSError, TypeError):
|
|
126
|
+
pass
|
|
127
|
+
repos.append(
|
|
128
|
+
Repository(
|
|
129
|
+
slug=slug,
|
|
130
|
+
github_url=meta[1] if len(meta) > 1 else "",
|
|
131
|
+
description=info[0] if len(info) > 0 else "",
|
|
132
|
+
avatar_url=info[1] if len(info) > 1 else "",
|
|
133
|
+
stars=info[2] if len(info) > 2 else 0,
|
|
134
|
+
updated_at=updated,
|
|
135
|
+
)
|
|
136
|
+
)
|
|
137
|
+
return repos
|
|
138
|
+
|
|
139
|
+
# ── Wiki ────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
def get_wiki(self, repo_slug: str) -> WikiPage:
|
|
142
|
+
"""Get the full wiki page for a repository."""
|
|
143
|
+
github_url = f"https://github.com/{repo_slug}"
|
|
144
|
+
raw = self._call(RPCMethod.WIKI_PAGE, [github_url])
|
|
145
|
+
if not raw:
|
|
146
|
+
raise NotFoundError(f"Wiki not found for {repo_slug}")
|
|
147
|
+
|
|
148
|
+
top = raw[0] if isinstance(raw, list) and raw else []
|
|
149
|
+
if not isinstance(top, list) or len(top) < 2:
|
|
150
|
+
raise NotFoundError(f"Wiki not found for {repo_slug}")
|
|
151
|
+
|
|
152
|
+
repo_info = top[0] if isinstance(top[0], list) else []
|
|
153
|
+
sections_raw = top[1] if isinstance(top[1], list) else []
|
|
154
|
+
ts = top[4] if len(top) > 4 and isinstance(top[4], list) else [None, None]
|
|
155
|
+
|
|
156
|
+
commit_hash = repo_info[1] if len(repo_info) > 1 else ""
|
|
157
|
+
updated = None
|
|
158
|
+
if ts and ts[0]:
|
|
159
|
+
try:
|
|
160
|
+
updated = datetime.fromtimestamp(ts[0])
|
|
161
|
+
except (ValueError, OSError, TypeError):
|
|
162
|
+
pass
|
|
163
|
+
|
|
164
|
+
meta = raw[1] if len(raw) > 1 and isinstance(raw[1], list) else []
|
|
165
|
+
has_wiki = meta[1] if len(meta) > 1 else False
|
|
166
|
+
|
|
167
|
+
sections = []
|
|
168
|
+
for sec in sections_raw:
|
|
169
|
+
if not isinstance(sec, list) or len(sec) < 2:
|
|
170
|
+
continue
|
|
171
|
+
code_refs = []
|
|
172
|
+
if len(sec) > 3 and isinstance(sec[3], list):
|
|
173
|
+
for ref in sec[3]:
|
|
174
|
+
if isinstance(ref, list) and ref:
|
|
175
|
+
code_refs.append(ref[0])
|
|
176
|
+
sections.append(
|
|
177
|
+
WikiSection(
|
|
178
|
+
title=sec[0] or "",
|
|
179
|
+
level=sec[1] if len(sec) > 1 else 1,
|
|
180
|
+
description=sec[2] if len(sec) > 2 else "",
|
|
181
|
+
code_refs=code_refs,
|
|
182
|
+
content=sec[4] if len(sec) > 4 else "",
|
|
183
|
+
)
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
repo = Repository(
|
|
187
|
+
slug=repo_slug,
|
|
188
|
+
github_url=github_url,
|
|
189
|
+
commit_hash=commit_hash,
|
|
190
|
+
updated_at=updated,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
return WikiPage(repo=repo, sections=sections, has_wiki=has_wiki)
|
|
194
|
+
|
|
195
|
+
# ── Chat ────────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
def chat(
|
|
198
|
+
self,
|
|
199
|
+
question: str,
|
|
200
|
+
repo_slug: str,
|
|
201
|
+
history: list[tuple[str, str]] | None = None,
|
|
202
|
+
) -> ChatResponse:
|
|
203
|
+
"""Ask Gemini a question about a repository.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
question: The user's question.
|
|
207
|
+
repo_slug: Repository slug (e.g. "excalidraw/excalidraw").
|
|
208
|
+
history: Optional conversation history as [(text, role), ...].
|
|
209
|
+
"""
|
|
210
|
+
messages = []
|
|
211
|
+
if history:
|
|
212
|
+
for text, role in history:
|
|
213
|
+
messages.append([text, role])
|
|
214
|
+
messages.append([question, "user"])
|
|
215
|
+
|
|
216
|
+
github_url = f"https://github.com/{repo_slug}"
|
|
217
|
+
params = [messages, [None, github_url]]
|
|
218
|
+
|
|
219
|
+
raw = self._call(RPCMethod.CHAT, params)
|
|
220
|
+
if not raw:
|
|
221
|
+
raise NotFoundError(f"No response for {repo_slug}")
|
|
222
|
+
|
|
223
|
+
answer = raw[0] if isinstance(raw, list) and raw else str(raw)
|
|
224
|
+
return ChatResponse(answer=answer, repo_slug=repo_slug)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Domain-specific exception hierarchy for cli-web-codewiki."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class CodeWikiError(Exception):
|
|
7
|
+
"""Base exception for all Code Wiki errors."""
|
|
8
|
+
|
|
9
|
+
def to_dict(self) -> dict:
|
|
10
|
+
return {"error": True, "code": error_code_for(self), "message": str(self)}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AuthError(CodeWikiError):
|
|
14
|
+
"""Authentication failure (unlikely for public API, but kept for completeness)."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, message: str, recoverable: bool = True):
|
|
17
|
+
self.recoverable = recoverable
|
|
18
|
+
super().__init__(message)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class RateLimitError(CodeWikiError):
|
|
22
|
+
"""Rate limited by the server."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, message: str, retry_after: float | None = None):
|
|
25
|
+
self.retry_after = retry_after
|
|
26
|
+
super().__init__(message)
|
|
27
|
+
|
|
28
|
+
def to_dict(self) -> dict:
|
|
29
|
+
d = super().to_dict()
|
|
30
|
+
if self.retry_after is not None:
|
|
31
|
+
d["retry_after"] = self.retry_after
|
|
32
|
+
return d
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class NetworkError(CodeWikiError):
|
|
36
|
+
"""Network connectivity failure."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ServerError(CodeWikiError):
|
|
40
|
+
"""Server returned 5xx."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, message: str, status_code: int = 500):
|
|
43
|
+
self.status_code = status_code
|
|
44
|
+
super().__init__(message)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class NotFoundError(CodeWikiError):
|
|
48
|
+
"""Requested resource not found (404 or empty RPC result)."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class RPCError(CodeWikiError):
|
|
52
|
+
"""Batchexecute RPC-level error."""
|
|
53
|
+
|
|
54
|
+
def __init__(self, message: str, code: int | None = None):
|
|
55
|
+
self.code = code
|
|
56
|
+
super().__init__(message)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
EXCEPTION_CODE_MAP: dict[type, str] = {
|
|
60
|
+
AuthError: "AUTH_EXPIRED",
|
|
61
|
+
RateLimitError: "RATE_LIMITED",
|
|
62
|
+
NotFoundError: "NOT_FOUND",
|
|
63
|
+
ServerError: "SERVER_ERROR",
|
|
64
|
+
NetworkError: "NETWORK_ERROR",
|
|
65
|
+
RPCError: "RPC_ERROR",
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def error_code_for(exc: Exception) -> str:
|
|
70
|
+
"""Return standardized error code string for JSON output."""
|
|
71
|
+
for cls, code in EXCEPTION_CODE_MAP.items():
|
|
72
|
+
if isinstance(exc, cls):
|
|
73
|
+
return code
|
|
74
|
+
return "INTERNAL_ERROR"
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Data models for Code Wiki CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Repository:
|
|
11
|
+
"""A GitHub repository on Code Wiki."""
|
|
12
|
+
|
|
13
|
+
slug: str
|
|
14
|
+
github_url: str
|
|
15
|
+
description: str = ""
|
|
16
|
+
avatar_url: str = ""
|
|
17
|
+
stars: int = 0
|
|
18
|
+
commit_hash: str = ""
|
|
19
|
+
updated_at: datetime | None = None
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def org(self) -> str:
|
|
23
|
+
parts = self.slug.split("/")
|
|
24
|
+
return parts[0] if len(parts) >= 2 else ""
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def name(self) -> str:
|
|
28
|
+
parts = self.slug.split("/")
|
|
29
|
+
return parts[1] if len(parts) >= 2 else self.slug
|
|
30
|
+
|
|
31
|
+
def to_dict(self) -> dict:
|
|
32
|
+
return {
|
|
33
|
+
"slug": self.slug,
|
|
34
|
+
"github_url": self.github_url,
|
|
35
|
+
"description": self.description,
|
|
36
|
+
"avatar_url": self.avatar_url,
|
|
37
|
+
"stars": self.stars,
|
|
38
|
+
"commit_hash": self.commit_hash,
|
|
39
|
+
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class WikiSection:
|
|
45
|
+
"""A section in a Code Wiki page."""
|
|
46
|
+
|
|
47
|
+
title: str
|
|
48
|
+
level: int
|
|
49
|
+
description: str = ""
|
|
50
|
+
code_refs: list[str] = field(default_factory=list)
|
|
51
|
+
content: str = ""
|
|
52
|
+
|
|
53
|
+
def to_dict(self) -> dict:
|
|
54
|
+
return {
|
|
55
|
+
"title": self.title,
|
|
56
|
+
"level": self.level,
|
|
57
|
+
"description": self.description,
|
|
58
|
+
"code_refs": self.code_refs,
|
|
59
|
+
"content": self.content,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class WikiPage:
|
|
65
|
+
"""A full Code Wiki page for a repository."""
|
|
66
|
+
|
|
67
|
+
repo: Repository
|
|
68
|
+
sections: list[WikiSection] = field(default_factory=list)
|
|
69
|
+
has_wiki: bool = False
|
|
70
|
+
|
|
71
|
+
def to_dict(self) -> dict:
|
|
72
|
+
return {
|
|
73
|
+
"repo": self.repo.to_dict(),
|
|
74
|
+
"sections": [s.to_dict() for s in self.sections],
|
|
75
|
+
"section_count": len(self.sections),
|
|
76
|
+
"has_wiki": self.has_wiki,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class ChatResponse:
|
|
82
|
+
"""A Gemini chat response."""
|
|
83
|
+
|
|
84
|
+
answer: str
|
|
85
|
+
repo_slug: str
|
|
86
|
+
|
|
87
|
+
def to_dict(self) -> dict:
|
|
88
|
+
return {
|
|
89
|
+
"answer": self.answer,
|
|
90
|
+
"repo": self.repo_slug,
|
|
91
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Decode batchexecute RPC responses from Code Wiki."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from ..exceptions import RPCError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _strip_prefix(raw: bytes | str) -> str:
|
|
12
|
+
"""Remove the )]}' anti-XSSI prefix."""
|
|
13
|
+
text = raw.decode("utf-8", errors="replace") if isinstance(raw, bytes) else raw
|
|
14
|
+
if text.startswith(")]}'"):
|
|
15
|
+
text = text[4:]
|
|
16
|
+
return text.lstrip("\n")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _parse_chunks(text: str) -> list[list]:
|
|
20
|
+
"""Extract JSON array chunks from the batchexecute response.
|
|
21
|
+
|
|
22
|
+
The response interleaves numeric length hints with JSON arrays.
|
|
23
|
+
We use raw_decode to reliably find array boundaries.
|
|
24
|
+
"""
|
|
25
|
+
decoder = json.JSONDecoder()
|
|
26
|
+
chunks: list[list] = []
|
|
27
|
+
pos = 0
|
|
28
|
+
length = len(text)
|
|
29
|
+
|
|
30
|
+
while pos < length:
|
|
31
|
+
# Skip whitespace and numeric length lines
|
|
32
|
+
while pos < length and text[pos] in " \t\r\n":
|
|
33
|
+
pos += 1
|
|
34
|
+
if pos >= length:
|
|
35
|
+
break
|
|
36
|
+
|
|
37
|
+
# Skip numeric length hints
|
|
38
|
+
if text[pos].isdigit():
|
|
39
|
+
while pos < length and text[pos] not in "\n":
|
|
40
|
+
pos += 1
|
|
41
|
+
continue
|
|
42
|
+
|
|
43
|
+
# Try to decode a JSON array
|
|
44
|
+
if text[pos] == "[":
|
|
45
|
+
try:
|
|
46
|
+
obj, end = decoder.raw_decode(text, pos)
|
|
47
|
+
chunks.append(obj)
|
|
48
|
+
pos = end
|
|
49
|
+
except json.JSONDecodeError:
|
|
50
|
+
pos += 1
|
|
51
|
+
else:
|
|
52
|
+
pos += 1
|
|
53
|
+
|
|
54
|
+
return chunks
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def decode_response(raw: bytes | str, rpc_id: str) -> Any:
|
|
58
|
+
"""Decode a batchexecute response and extract the result for rpc_id.
|
|
59
|
+
|
|
60
|
+
Returns the parsed inner JSON, or None if the result is null.
|
|
61
|
+
Raises RPCError or AuthError on protocol-level errors.
|
|
62
|
+
"""
|
|
63
|
+
text = _strip_prefix(raw)
|
|
64
|
+
chunks = _parse_chunks(text)
|
|
65
|
+
|
|
66
|
+
for chunk in chunks:
|
|
67
|
+
if not isinstance(chunk, list) or not chunk:
|
|
68
|
+
continue
|
|
69
|
+
for entry in chunk:
|
|
70
|
+
if not isinstance(entry, list) or len(entry) < 2:
|
|
71
|
+
continue
|
|
72
|
+
|
|
73
|
+
# Check for error entries
|
|
74
|
+
if entry[0] == "er" and len(entry) >= 2:
|
|
75
|
+
err_data = entry[1] if len(entry) > 1 else None
|
|
76
|
+
raise RPCError(f"RPC error: {err_data}", code=err_data)
|
|
77
|
+
|
|
78
|
+
# Look for the result matching our rpc_id
|
|
79
|
+
if entry[0] == "wrb.fr" and entry[1] == rpc_id:
|
|
80
|
+
raw_result = entry[2] if len(entry) > 2 else None
|
|
81
|
+
if raw_result is None:
|
|
82
|
+
return None
|
|
83
|
+
# Double-parse: result is a JSON string inside JSON
|
|
84
|
+
return json.loads(raw_result)
|
|
85
|
+
|
|
86
|
+
return None
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Encode batchexecute RPC requests for Code Wiki."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from urllib.parse import urlencode
|
|
7
|
+
|
|
8
|
+
from .types import BATCHEXECUTE_URL
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def build_url(rpc_id: str) -> str:
|
|
12
|
+
"""Build the full batchexecute URL with query parameters."""
|
|
13
|
+
params = {
|
|
14
|
+
"rpcids": rpc_id,
|
|
15
|
+
"source-path": "/",
|
|
16
|
+
"hl": "en",
|
|
17
|
+
"rt": "c",
|
|
18
|
+
}
|
|
19
|
+
return f"{BATCHEXECUTE_URL}?{urlencode(params)}"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def encode_request(rpc_id: str, params: list) -> str:
|
|
23
|
+
"""Encode an RPC call into a form-encoded f.req body.
|
|
24
|
+
|
|
25
|
+
The batchexecute wire format wraps each call as:
|
|
26
|
+
[[[rpc_id, json_string_params, null, "generic"]]]
|
|
27
|
+
|
|
28
|
+
No CSRF token needed — Code Wiki is fully public.
|
|
29
|
+
"""
|
|
30
|
+
inner = [rpc_id, json.dumps(params), None, "generic"]
|
|
31
|
+
freq = json.dumps([[inner]])
|
|
32
|
+
return urlencode({"f.req": freq})
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""RPC method IDs and constants for Code Wiki batchexecute API."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class RPCMethod:
|
|
5
|
+
"""Batchexecute RPC method identifiers."""
|
|
6
|
+
|
|
7
|
+
FEATURED_REPOS = "nm8Fsb"
|
|
8
|
+
WIKI_PAGE = "VSX6ub"
|
|
9
|
+
SEARCH_REPOS = "vyWDAf"
|
|
10
|
+
CHAT = "EgIxfe"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
BATCHEXECUTE_URL = "https://codewiki.google/_/BoqAngularSdlcAgentsUi/data/batchexecute"
|
|
14
|
+
|
|
15
|
+
BASE_URL = "https://codewiki.google"
|
|
16
|
+
|
|
17
|
+
DEFAULT_HEADERS = {
|
|
18
|
+
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
|
|
19
|
+
"x-same-domain": "1",
|
|
20
|
+
"Origin": BASE_URL,
|
|
21
|
+
"Referer": f"{BASE_URL}/",
|
|
22
|
+
"User-Agent": (
|
|
23
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
|
24
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
25
|
+
"Chrome/146.0.0.0 Safari/537.36"
|
|
26
|
+
),
|
|
27
|
+
}
|
|
File without changes
|