research-assistant 0.1.0
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.
- package/LICENSE +21 -0
- package/README-ZH.md +115 -0
- package/README.md +124 -0
- package/npm/bin/research-assistant.js +70 -0
- package/npm/scripts/postinstall.js +112 -0
- package/package.json +38 -0
- package/pyproject.toml +84 -0
- package/src/research_assistant/__init__.py +7 -0
- package/src/research_assistant/__main__.py +6 -0
- package/src/research_assistant/assets/researcher_persona.md +94 -0
- package/src/research_assistant/assets/skills/research-assistant/SKILL.md +248 -0
- package/src/research_assistant/cli.py +155 -0
- package/src/research_assistant/commands/__init__.py +7 -0
- package/src/research_assistant/commands/ask.py +54 -0
- package/src/research_assistant/commands/doctor.py +201 -0
- package/src/research_assistant/commands/fetch.py +99 -0
- package/src/research_assistant/commands/locate.py +71 -0
- package/src/research_assistant/commands/search.py +161 -0
- package/src/research_assistant/commands/setup.py +249 -0
- package/src/research_assistant/commands/skills.py +60 -0
- package/src/research_assistant/config.py +258 -0
- package/src/research_assistant/errors.py +106 -0
- package/src/research_assistant/fetch/__init__.py +20 -0
- package/src/research_assistant/fetch/browser.py +613 -0
- package/src/research_assistant/fetch/cfbypass.py +318 -0
- package/src/research_assistant/fetch/html2md.py +44 -0
- package/src/research_assistant/fetch/normal.py +69 -0
- package/src/research_assistant/fetch/search_engine.py +324 -0
- package/src/research_assistant/fetch/snapshot.py +50 -0
- package/src/research_assistant/http.py +118 -0
- package/src/research_assistant/installer.py +270 -0
- package/src/research_assistant/locate/__init__.py +5 -0
- package/src/research_assistant/locate/engine.py +289 -0
- package/src/research_assistant/loginstate/__init__.py +10 -0
- package/src/research_assistant/loginstate/daemon.py +145 -0
- package/src/research_assistant/loginstate/daemon_cli.py +21 -0
- package/src/research_assistant/loginstate/daemonctl.py +116 -0
- package/src/research_assistant/loginstate/extension/background.js +167 -0
- package/src/research_assistant/loginstate/extension/manifest.json +15 -0
- package/src/research_assistant/loginstate/extension/popup.html +31 -0
- package/src/research_assistant/loginstate/extension/popup.js +33 -0
- package/src/research_assistant/output.py +140 -0
- package/src/research_assistant/providers/__init__.py +10 -0
- package/src/research_assistant/providers/base.py +93 -0
- package/src/research_assistant/providers/browser.py +91 -0
- package/src/research_assistant/providers/context7.py +171 -0
- package/src/research_assistant/providers/exa.py +353 -0
- package/src/research_assistant/providers/firecrawl.py +644 -0
- package/src/research_assistant/providers/openai_compat.py +293 -0
- package/src/research_assistant/providers/registry.py +62 -0
- package/src/research_assistant/providers/tavily.py +346 -0
- package/src/research_assistant/proxy.py +58 -0
- package/src/research_assistant/targets.py +66 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""配置存储(TOML,~/.research-assistant/config.toml,D3)。
|
|
2
|
+
|
|
3
|
+
schema 见 backend-design data-model.md §2.1:
|
|
4
|
+
schema_version = 1
|
|
5
|
+
[[provider]] # N 个,按 type 区分
|
|
6
|
+
type / base_url / api_key / model / timeout / concurrency(仅 locate)
|
|
7
|
+
[proxy] url(空=自动检测,显式=覆盖,ADR-0007)
|
|
8
|
+
[browser] channel / extension_status / daemon_port / profile_strategy
|
|
9
|
+
|
|
10
|
+
读写:tomllib 读(3.11+ 内置,3.10 回退 tomli);tomli_w 写(setup 持久化)。
|
|
11
|
+
Key 同时支持环境变量覆盖(RA_<TYPE>_API_KEY 等),env 优先于 config 文件。
|
|
12
|
+
真实 key 绝不进源码——只从此文件或环境读取(§10.4 安全红线)。
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
import sys
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
# tomllib 3.11+ 内置,3.10 用 tomli
|
|
24
|
+
if sys.version_info >= (3, 11):
|
|
25
|
+
import tomllib # type: ignore[import-not-found]
|
|
26
|
+
else: # pragma: no cover - 3.10 分支
|
|
27
|
+
import tomli as tomllib # type: ignore[import-not-found,no-redef]
|
|
28
|
+
|
|
29
|
+
import tomli_w
|
|
30
|
+
|
|
31
|
+
from .errors import ConfigError
|
|
32
|
+
|
|
33
|
+
SCHEMA_VERSION = 1
|
|
34
|
+
CONFIG_DIR_ENV = "RESEARCH_ASSISTANT_HOME"
|
|
35
|
+
DEFAULT_CONFIG_DIR = Path.home() / ".research-assistant"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def config_dir() -> Path:
|
|
39
|
+
"""配置根目录:env 覆盖 → 默认 ~/.research-assistant。"""
|
|
40
|
+
env = os.getenv(CONFIG_DIR_ENV)
|
|
41
|
+
if env:
|
|
42
|
+
return Path(env).expanduser()
|
|
43
|
+
return DEFAULT_CONFIG_DIR
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def config_path(override: str | os.PathLike[str] | None = None) -> Path:
|
|
47
|
+
if override:
|
|
48
|
+
return Path(override).expanduser()
|
|
49
|
+
return config_dir() / "config.toml"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def profiles_dir() -> Path:
|
|
53
|
+
"""per-call 临时浏览器 profile 根目录(ADR-0005)。"""
|
|
54
|
+
return config_dir() / ".profiles"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def logs_dir() -> Path:
|
|
58
|
+
return config_dir() / ".logs"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
# 数据类
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class ProviderConfig:
|
|
68
|
+
"""单个 provider 的连接配置。"""
|
|
69
|
+
|
|
70
|
+
type: str
|
|
71
|
+
base_url: str = ""
|
|
72
|
+
api_key: str = ""
|
|
73
|
+
model: str = ""
|
|
74
|
+
timeout: int = 30
|
|
75
|
+
concurrency: int = 8 # 仅 locate 用
|
|
76
|
+
|
|
77
|
+
def merged_env(self) -> "ProviderConfig":
|
|
78
|
+
"""返回被环境变量覆盖后的副本(env 优先)。"""
|
|
79
|
+
prefix = f"RA_{self.type.upper()}"
|
|
80
|
+
out = ProviderConfig(
|
|
81
|
+
type=self.type,
|
|
82
|
+
base_url=self.base_url,
|
|
83
|
+
api_key=self.api_key,
|
|
84
|
+
model=self.model,
|
|
85
|
+
timeout=self.timeout,
|
|
86
|
+
concurrency=self.concurrency,
|
|
87
|
+
)
|
|
88
|
+
env_base = os.getenv(f"{prefix}_BASE_URL")
|
|
89
|
+
env_key = os.getenv(f"{prefix}_API_KEY")
|
|
90
|
+
env_model = os.getenv(f"{prefix}_MODEL")
|
|
91
|
+
env_timeout = os.getenv(f"{prefix}_TIMEOUT")
|
|
92
|
+
env_concurrency = os.getenv(f"{prefix}_CONCURRENCY")
|
|
93
|
+
if env_base:
|
|
94
|
+
out.base_url = env_base
|
|
95
|
+
if env_key:
|
|
96
|
+
out.api_key = env_key
|
|
97
|
+
if env_model:
|
|
98
|
+
out.model = env_model
|
|
99
|
+
if env_timeout:
|
|
100
|
+
try:
|
|
101
|
+
out.timeout = int(env_timeout)
|
|
102
|
+
except ValueError:
|
|
103
|
+
pass
|
|
104
|
+
if env_concurrency:
|
|
105
|
+
try:
|
|
106
|
+
out.concurrency = int(env_concurrency)
|
|
107
|
+
except ValueError:
|
|
108
|
+
pass
|
|
109
|
+
return out
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass
|
|
113
|
+
class ProxyConfig:
|
|
114
|
+
url: str = "" # 空 = 自动检测系统代理(ADR-0007)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass
|
|
118
|
+
class BrowserConfig:
|
|
119
|
+
channel: str = "msedge" # msedge | chrome
|
|
120
|
+
extension_status: str = "missing" # installed | missing
|
|
121
|
+
daemon_port: int = 17890 # cookie daemon HTTP 端口(WS 同端口,ADR-0006)
|
|
122
|
+
profile_strategy: str = "per_call_temp" # ADR-0005
|
|
123
|
+
max_browser_instances: int = 3 # 并发浏览器进程上限(跨 CLI 限流,防无限开)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass
|
|
127
|
+
class Config:
|
|
128
|
+
schema_version: int = SCHEMA_VERSION
|
|
129
|
+
providers: list[ProviderConfig] = field(default_factory=list)
|
|
130
|
+
proxy: ProxyConfig = field(default_factory=ProxyConfig)
|
|
131
|
+
browser: BrowserConfig = field(default_factory=BrowserConfig)
|
|
132
|
+
path: str = ""
|
|
133
|
+
|
|
134
|
+
# ---- provider 查询 ----
|
|
135
|
+
def provider(self, ptype: str) -> ProviderConfig | None:
|
|
136
|
+
"""按 type 取 provider(环境变量覆盖后)。未配置返回 None。"""
|
|
137
|
+
for p in self.providers:
|
|
138
|
+
if p.type == ptype:
|
|
139
|
+
return p.merged_env()
|
|
140
|
+
# config 中无、但环境变量齐全时也能用(允许纯 env 配置)
|
|
141
|
+
env_prefix = f"RA_{ptype.upper()}_"
|
|
142
|
+
if any(k.startswith(env_prefix) for k in os.environ):
|
|
143
|
+
return ProviderConfig(type=ptype).merged_env()
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
def require_provider(self, ptype: str) -> ProviderConfig:
|
|
147
|
+
p = self.provider(ptype)
|
|
148
|
+
if p is None:
|
|
149
|
+
raise ConfigError(
|
|
150
|
+
f"未配置 provider: {ptype}",
|
|
151
|
+
provider=ptype,
|
|
152
|
+
details={"hint": "运行 `research-assistant setup` 或在 config.toml 添加 [[provider]]"},
|
|
153
|
+
)
|
|
154
|
+
return p
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# ---------------------------------------------------------------------------
|
|
158
|
+
# 读写
|
|
159
|
+
# ---------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _load_raw(path: Path) -> dict[str, Any]:
|
|
163
|
+
if not path.exists():
|
|
164
|
+
return {}
|
|
165
|
+
try:
|
|
166
|
+
with path.open("rb") as f:
|
|
167
|
+
return tomllib.load(f)
|
|
168
|
+
except tomllib.TOMLDecodeError as e:
|
|
169
|
+
raise ConfigError(f"配置文件解析失败: {path}: {e}") from e
|
|
170
|
+
except OSError as e:
|
|
171
|
+
raise ConfigError(f"无法读取配置文件: {path}: {e}") from e
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def load(path: str | os.PathLike[str] | None = None) -> Config:
|
|
175
|
+
"""加载配置。文件不存在返回空 Config(首次使用)。"""
|
|
176
|
+
p = config_path(path)
|
|
177
|
+
raw = _load_raw(p)
|
|
178
|
+
cfg = Config(schema_version=int(raw.get("schema_version", SCHEMA_VERSION)), path=str(p))
|
|
179
|
+
|
|
180
|
+
for item in raw.get("provider", []):
|
|
181
|
+
if not isinstance(item, dict) or "type" not in item:
|
|
182
|
+
continue
|
|
183
|
+
cfg.providers.append(
|
|
184
|
+
ProviderConfig(
|
|
185
|
+
type=str(item["type"]),
|
|
186
|
+
base_url=str(item.get("base_url", "")),
|
|
187
|
+
api_key=str(item.get("api_key", "")),
|
|
188
|
+
model=str(item.get("model", "")),
|
|
189
|
+
timeout=int(item.get("timeout", 30)),
|
|
190
|
+
concurrency=int(item.get("concurrency", 8)),
|
|
191
|
+
)
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
proxy_raw = raw.get("proxy", {})
|
|
195
|
+
if isinstance(proxy_raw, dict):
|
|
196
|
+
cfg.proxy = ProxyConfig(url=str(proxy_raw.get("url", "")))
|
|
197
|
+
|
|
198
|
+
browser_raw = raw.get("browser", {})
|
|
199
|
+
if isinstance(browser_raw, dict):
|
|
200
|
+
# env RA_BROWSER_MAX_INSTANCES 优先于 config 文件
|
|
201
|
+
env_max = os.getenv("RA_BROWSER_MAX_INSTANCES")
|
|
202
|
+
max_inst = (
|
|
203
|
+
int(env_max)
|
|
204
|
+
if env_max and env_max.isdigit()
|
|
205
|
+
else int(browser_raw.get("max_browser_instances", 3))
|
|
206
|
+
)
|
|
207
|
+
cfg.browser = BrowserConfig(
|
|
208
|
+
channel=str(browser_raw.get("channel", "msedge")),
|
|
209
|
+
extension_status=str(browser_raw.get("extension_status", "missing")),
|
|
210
|
+
daemon_port=int(browser_raw.get("daemon_port", 17890)),
|
|
211
|
+
profile_strategy=str(browser_raw.get("profile_strategy", "per_call_temp")),
|
|
212
|
+
max_browser_instances=max_inst,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
return cfg
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def save(cfg: Config, path: str | os.PathLike[str] | None = None) -> Path:
|
|
219
|
+
"""把 Config 持久化为 TOML(setup 用,幂等更新)。"""
|
|
220
|
+
p = config_path(path)
|
|
221
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
222
|
+
data: dict[str, Any] = {
|
|
223
|
+
"schema_version": cfg.schema_version,
|
|
224
|
+
"provider": [
|
|
225
|
+
{
|
|
226
|
+
"type": pc.type,
|
|
227
|
+
"base_url": pc.base_url,
|
|
228
|
+
"api_key": pc.api_key,
|
|
229
|
+
"model": pc.model,
|
|
230
|
+
"timeout": pc.timeout,
|
|
231
|
+
**({"concurrency": pc.concurrency} if pc.type == "locate" else {}),
|
|
232
|
+
}
|
|
233
|
+
for pc in cfg.providers
|
|
234
|
+
],
|
|
235
|
+
"proxy": {"url": cfg.proxy.url},
|
|
236
|
+
"browser": {
|
|
237
|
+
"channel": cfg.browser.channel,
|
|
238
|
+
"extension_status": cfg.browser.extension_status,
|
|
239
|
+
"daemon_port": cfg.browser.daemon_port,
|
|
240
|
+
"profile_strategy": cfg.browser.profile_strategy,
|
|
241
|
+
"max_browser_instances": cfg.browser.max_browser_instances,
|
|
242
|
+
},
|
|
243
|
+
}
|
|
244
|
+
try:
|
|
245
|
+
with p.open("wb") as f:
|
|
246
|
+
f.write(tomli_w.dumps(data).encode("utf-8"))
|
|
247
|
+
except OSError as e:
|
|
248
|
+
raise ConfigError(f"无法写入配置文件: {p}: {e}") from e
|
|
249
|
+
return p
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def mask_secret(value: str) -> str:
|
|
253
|
+
"""遮蔽 key(doctor --show-config 用,§Security)。"""
|
|
254
|
+
if not value:
|
|
255
|
+
return ""
|
|
256
|
+
if len(value) <= 8:
|
|
257
|
+
return "***"
|
|
258
|
+
return f"{value[:4]}{'*' * (len(value) - 8)}{value[-4:]}"
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""统一错误体系(横切约定 #1,api-contract.md §4)。
|
|
2
|
+
|
|
3
|
+
错误体(stdout 固定 JSON,不受 --output 影响):
|
|
4
|
+
{ "error": { "code": "...", "message": "...", "provider": "...", "details": {...} } }
|
|
5
|
+
|
|
6
|
+
退出码(语义化,跨命令一致):
|
|
7
|
+
0 成功 | 1 INTERNAL 其他 | 2 ARGS 参数错 | 3 CONFIG 配置错
|
|
8
|
+
4 NETWORK 网络失败(含 PROVIDER 远端失败)| 5 ANTIBOT 反爬失败
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
# 退出码
|
|
16
|
+
EXIT_OK = 0
|
|
17
|
+
EXIT_INTERNAL = 1
|
|
18
|
+
EXIT_ARGS = 2
|
|
19
|
+
EXIT_CONFIG = 3
|
|
20
|
+
EXIT_NETWORK = 4
|
|
21
|
+
EXIT_ANTIBOT = 5
|
|
22
|
+
|
|
23
|
+
# 错误 code 枚举 → 退出码映射
|
|
24
|
+
CODE_TO_EXIT: dict[str, int] = {
|
|
25
|
+
"ARGS": EXIT_ARGS,
|
|
26
|
+
"CONFIG": EXIT_CONFIG,
|
|
27
|
+
"NETWORK": EXIT_NETWORK,
|
|
28
|
+
"ANTIBOT": EXIT_ANTIBOT,
|
|
29
|
+
"PROVIDER": EXIT_NETWORK, # 远端 provider 应用层失败,归入网络类(4)
|
|
30
|
+
"INTERNAL": EXIT_INTERNAL,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ResearchAssistantError(Exception):
|
|
35
|
+
"""所有 research-assistant 业务错误的基类。
|
|
36
|
+
|
|
37
|
+
Attributes:
|
|
38
|
+
code: 错误枚举(ARGS/CONFIG/NETWORK/ANTIBOT/PROVIDER/INTERNAL)。
|
|
39
|
+
message: 人类可读信息。
|
|
40
|
+
provider: 出错的 provider 名(可选)。
|
|
41
|
+
details: 附加结构化细节(可选,进入 error.details)。
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
code: str = "INTERNAL"
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
message: str,
|
|
49
|
+
*,
|
|
50
|
+
provider: str | None = None,
|
|
51
|
+
details: dict[str, Any] | None = None,
|
|
52
|
+
) -> None:
|
|
53
|
+
super().__init__(message)
|
|
54
|
+
self.message = message
|
|
55
|
+
self.provider = provider
|
|
56
|
+
self.details = details
|
|
57
|
+
|
|
58
|
+
def exit_code(self) -> int:
|
|
59
|
+
return CODE_TO_EXIT.get(self.code, EXIT_INTERNAL)
|
|
60
|
+
|
|
61
|
+
def to_json(self) -> dict[str, Any]:
|
|
62
|
+
err: dict[str, Any] = {"code": self.code, "message": self.message}
|
|
63
|
+
if self.provider:
|
|
64
|
+
err["provider"] = self.provider
|
|
65
|
+
if self.details:
|
|
66
|
+
err["details"] = self.details
|
|
67
|
+
return {"error": err}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class ArgsError(ResearchAssistantError):
|
|
71
|
+
code = "ARGS"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class ConfigError(ResearchAssistantError):
|
|
75
|
+
code = "CONFIG"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class NetworkError(ResearchAssistantError):
|
|
79
|
+
code = "NETWORK"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class AntibotError(ResearchAssistantError):
|
|
83
|
+
code = "ANTIBOT"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class ProviderError(ResearchAssistantError):
|
|
87
|
+
"""远端 provider 返回了错误响应(4xx/5xx 等),与传输层 NETWORK 区分。"""
|
|
88
|
+
|
|
89
|
+
code = "PROVIDER"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def wrap_provider_http_error(provider: str, exc: Exception) -> ResearchAssistantError:
|
|
93
|
+
"""把 httpx/网络异常归一成 NetworkError / ProviderError。
|
|
94
|
+
|
|
95
|
+
传输层失败(连接/超时/DNS/TLS)→ NETWORK;其余按 INTERNAL 兜底。
|
|
96
|
+
httpx.HTTPStatusError 由各 provider 自行映射为 ProviderError(带状态码 details)。
|
|
97
|
+
"""
|
|
98
|
+
import httpx
|
|
99
|
+
|
|
100
|
+
if isinstance(exc, httpx.TimeoutException):
|
|
101
|
+
return NetworkError(f"{provider}: 请求超时 ({exc})", provider=provider)
|
|
102
|
+
if isinstance(exc, (httpx.ConnectError, httpx.ConnectTimeout)):
|
|
103
|
+
return NetworkError(f"{provider}: 无法连接 ({exc})", provider=provider)
|
|
104
|
+
if isinstance(exc, httpx.HTTPError):
|
|
105
|
+
return NetworkError(f"{provider}: 网络错误 ({exc})", provider=provider)
|
|
106
|
+
return ResearchAssistantError(f"{provider}: 内部错误 ({exc})", provider=provider)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""fetch 包:跨工具爬取增强(普通接口 → 失败回退浏览器,PM §4.2)。"""
|
|
2
|
+
|
|
3
|
+
from .html2md import html_to_md
|
|
4
|
+
from .normal import fetch_normal
|
|
5
|
+
from .browser import fetch_with_browser, browser_probe
|
|
6
|
+
from .snapshot import write_snapshot
|
|
7
|
+
from . import cfbypass
|
|
8
|
+
|
|
9
|
+
# 注意:search_engine 子模块的入口函数也叫 search_engine,不在此重导出——否则
|
|
10
|
+
# `from ..fetch import search_engine` 拿到函数后会覆盖系统绑定的子模块(Python 同名陷阱)。
|
|
11
|
+
# 调用方直接 from ..fetch.search_engine import search_engine。
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"html_to_md",
|
|
15
|
+
"fetch_normal",
|
|
16
|
+
"fetch_with_browser",
|
|
17
|
+
"browser_probe",
|
|
18
|
+
"write_snapshot",
|
|
19
|
+
"cfbypass",
|
|
20
|
+
]
|