dataapp-resource-proxy 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.
@@ -0,0 +1,15 @@
1
+ """Client for DataOS managed runtime resources."""
2
+
3
+ from .client import ResourceProxy, ResourceTransport
4
+ from .errors import ResourceProxyError
5
+ from .handles import BrowserResource, CaptchaPluginGrant, CaptchaTask, ProxyResource
6
+
7
+ __all__ = [
8
+ "BrowserResource",
9
+ "CaptchaTask",
10
+ "CaptchaPluginGrant",
11
+ "ProxyResource",
12
+ "ResourceProxy",
13
+ "ResourceProxyError",
14
+ "ResourceTransport",
15
+ ]
@@ -0,0 +1,65 @@
1
+ """Command-line helpers for local DataApp resource configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import getpass
7
+ import os
8
+ import warnings
9
+ from collections.abc import Sequence
10
+
11
+ from ._local_config import (
12
+ load_project_config,
13
+ resolve_config_environment,
14
+ save_local_config,
15
+ )
16
+ from .errors import ResourceProxyError
17
+
18
+
19
+ def _configure() -> int:
20
+ try:
21
+ project = load_project_config(os.environ)
22
+ environment = resolve_config_environment(os.environ, project)
23
+ except ResourceProxyError as error:
24
+ print(f"配置错误:{error}")
25
+ return 2
26
+ if environment is None:
27
+ print(
28
+ "未找到当前项目的 .dataapp-sdk.json。请在下载的 SDK 项目根目录运行,"
29
+ "或为独立项目显式设置 DATAAPP_ENVIRONMENT。"
30
+ )
31
+ return 2
32
+ print("平台托管 IP 代理和验证码服务会按实际用量产生资源费用。")
33
+ if project is not None:
34
+ print(f"请从当前平台账户中心获取 API Key:{project.api_keys_url}")
35
+ answer = input("确认在本地调试中使用这些收费资源?[y/N] ").strip().lower()
36
+ if answer not in {"y", "yes"}:
37
+ print("未保存配置,也不会发起任何托管资源请求。")
38
+ return 1
39
+ try:
40
+ with warnings.catch_warnings():
41
+ warnings.simplefilter("error", getpass.GetPassWarning)
42
+ api_key = getpass.getpass("请粘贴账户中心的 API Key(输入不会显示):").strip()
43
+ except (EOFError, KeyboardInterrupt, getpass.GetPassWarning):
44
+ print("当前终端无法安全隐藏输入;未保存配置。请在交互式终端重新运行。")
45
+ return 2
46
+ if not api_key:
47
+ print("API Key 不能为空;未保存配置。")
48
+ return 2
49
+ path = save_local_config(api_key, environment=environment)
50
+ print(f"配置已安全保存到 {path},文件权限已设置为 600。")
51
+ return 0
52
+
53
+
54
+ def main(argv: Sequence[str] | None = None) -> int:
55
+ parser = argparse.ArgumentParser(prog="python -m dataapp_resource_proxy")
56
+ commands = parser.add_subparsers(dest="command", required=True)
57
+ commands.add_parser("configure", help="configure local managed resources securely")
58
+ options = parser.parse_args(argv)
59
+ if options.command == "configure":
60
+ return _configure()
61
+ return 2
62
+
63
+
64
+ if __name__ == "__main__":
65
+ raise SystemExit(main())
@@ -0,0 +1,286 @@
1
+ """Project-bound defaults and private local managed-resource credentials."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import stat
8
+ import tempfile
9
+ from collections.abc import Mapping
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import Any
13
+ from urllib.parse import urlsplit
14
+
15
+ from .errors import ResourceProxyError
16
+
17
+ _CONFIG_ENV = "DATAAPP_LOCAL_CONFIG"
18
+ _CONFIG_FILENAME = "local-resources.json"
19
+ _PROJECT_CONFIG_FILENAME = ".dataapp-sdk.json"
20
+ _PROJECT_CONFIG_MAX_BYTES = 64 * 1024
21
+ _LEGACY_CONFIG_KEYS = frozenset({"schema_version", "DATAAPP_API_KEY"})
22
+ _SCOPED_CONFIG_KEYS = frozenset({"schema_version", "environment", "DATAAPP_API_KEY"})
23
+ _PROJECT_CONFIG_KEYS = frozenset(
24
+ {"schema_version", "environment", "api_keys_url", "resource_proxy"}
25
+ )
26
+ _RESOURCE_PROXY_KEYS = frozenset({"domestic_url", "overseas_url"})
27
+ _ENVIRONMENTS = frozenset({"bazhuayu_pre", "bazhuayu", "octoparse_pre", "octoparse"})
28
+ _PROFILE_ENVIRONMENTS = {
29
+ "bazhuayu_pre": "bazhuayu_pre",
30
+ "bazhuayu_overseas_pre": "bazhuayu_pre",
31
+ "bazhuayu": "bazhuayu",
32
+ "bazhuayu_overseas": "bazhuayu",
33
+ "octoparse_pre": "octoparse_pre",
34
+ "octoparse": "octoparse",
35
+ }
36
+ _PLATFORM_HOST_SUFFIXES = (".bazhuayu.com", ".octoparse.com")
37
+ _PORTAL_HOST_SUFFIXES = {
38
+ "bazhuayu": ".bazhuayu.com",
39
+ "octoparse": ".octoparse.com",
40
+ }
41
+
42
+
43
+ @dataclass(frozen=True, slots=True)
44
+ class ProjectSdkConfig:
45
+ """Non-secret defaults shipped with one downloaded project."""
46
+
47
+ environment: str
48
+ api_keys_url: str
49
+ domestic_url: str
50
+ overseas_url: str
51
+
52
+
53
+ def load_project_config(
54
+ environ: Mapping[str, str] | None = None,
55
+ *,
56
+ directory: Path | None = None,
57
+ ) -> ProjectSdkConfig | None:
58
+ """Load strict project defaults from the current project root, if present."""
59
+ del environ # Reserved for a future explicit, platform-controlled path.
60
+ path = (Path.cwd() if directory is None else directory) / _PROJECT_CONFIG_FILENAME
61
+ try:
62
+ info = path.lstat()
63
+ except FileNotFoundError:
64
+ return None
65
+ except OSError as error:
66
+ raise _configuration_error(
67
+ f"cannot inspect {_PROJECT_CONFIG_FILENAME} ({type(error).__name__})"
68
+ ) from None
69
+ if stat.S_ISLNK(info.st_mode) or not stat.S_ISREG(info.st_mode):
70
+ raise _configuration_error(f"{_PROJECT_CONFIG_FILENAME} must be a regular file")
71
+ if info.st_size > _PROJECT_CONFIG_MAX_BYTES:
72
+ raise _configuration_error(f"{_PROJECT_CONFIG_FILENAME} is too large")
73
+ try:
74
+ payload = json.loads(path.read_text(encoding="utf-8"))
75
+ except (OSError, UnicodeError, ValueError) as error:
76
+ raise _configuration_error(
77
+ f"cannot load {_PROJECT_CONFIG_FILENAME} ({type(error).__name__})"
78
+ ) from None
79
+ if (
80
+ not isinstance(payload, dict)
81
+ or set(payload) != _PROJECT_CONFIG_KEYS
82
+ or payload.get("schema_version") != 1
83
+ or payload.get("environment") not in _ENVIRONMENTS
84
+ or not isinstance(payload.get("api_keys_url"), str)
85
+ or not isinstance(payload.get("resource_proxy"), dict)
86
+ or set(payload["resource_proxy"]) != _RESOURCE_PROXY_KEYS
87
+ or not isinstance(payload["resource_proxy"].get("domestic_url"), str)
88
+ or not isinstance(payload["resource_proxy"].get("overseas_url"), str)
89
+ ):
90
+ raise _configuration_error(f"{_PROJECT_CONFIG_FILENAME} has an unsupported format")
91
+ environment = payload["environment"]
92
+ api_keys_url = _validated_platform_url(
93
+ payload["api_keys_url"], environment=environment, api_keys=True
94
+ )
95
+ domestic_url = _validated_platform_url(
96
+ payload["resource_proxy"]["domestic_url"], environment=environment
97
+ )
98
+ overseas_url = _validated_platform_url(
99
+ payload["resource_proxy"]["overseas_url"], environment=environment
100
+ )
101
+ return ProjectSdkConfig(
102
+ environment=environment,
103
+ api_keys_url=api_keys_url,
104
+ domestic_url=domestic_url,
105
+ overseas_url=overseas_url,
106
+ )
107
+
108
+
109
+ def resolve_config_environment(
110
+ environ: Mapping[str, str], project: ProjectSdkConfig | None
111
+ ) -> str | None:
112
+ """Resolve and validate the canonical account environment for local config."""
113
+ current = environ.get("DATAAPP_ENVIRONMENT") or None
114
+ legacy = environ.get("DATAOS_ENVIRONMENT") or None
115
+ if current and legacy and current != legacy:
116
+ raise _configuration_error(
117
+ "DATAAPP_ENVIRONMENT conflicts with deprecated DATAOS_ENVIRONMENT"
118
+ )
119
+ profile = current or legacy
120
+ if profile is not None and profile not in _PROFILE_ENVIRONMENTS:
121
+ raise _configuration_error("DATAAPP_ENVIRONMENT is not supported")
122
+ explicit = _PROFILE_ENVIRONMENTS.get(profile) if profile is not None else None
123
+ if project is not None and explicit is not None and explicit != project.environment:
124
+ raise _configuration_error(
125
+ "DATAAPP_ENVIRONMENT conflicts with the downloaded project environment"
126
+ )
127
+ return project.environment if project is not None else explicit
128
+
129
+
130
+ def local_config_path(
131
+ environ: Mapping[str, str] | None = None, *, environment: str | None = None
132
+ ) -> Path:
133
+ """Return the private config path without creating it."""
134
+ env = os.environ if environ is None else environ
135
+ explicit = env.get(_CONFIG_ENV)
136
+ if explicit:
137
+ return Path(explicit).expanduser()
138
+ config_root = env.get("XDG_CONFIG_HOME")
139
+ root = Path(config_root).expanduser() if config_root else Path.home() / ".config"
140
+ parent = root / "bazhuayu-dataapp"
141
+ return (
142
+ parent / environment / _CONFIG_FILENAME
143
+ if environment is not None
144
+ else parent / _CONFIG_FILENAME
145
+ )
146
+
147
+
148
+ def load_local_config(
149
+ environ: Mapping[str, str], *, environment: str | None = None
150
+ ) -> dict[str, str]:
151
+ """Load one environment-bound value-only config with private permissions."""
152
+ path = local_config_path(environ, environment=environment)
153
+ try:
154
+ info = path.lstat()
155
+ except FileNotFoundError:
156
+ return {}
157
+ except OSError as error:
158
+ raise _configuration_error(
159
+ f"cannot read local managed-resource configuration ({type(error).__name__})"
160
+ ) from None
161
+ if stat.S_ISLNK(info.st_mode) or not stat.S_ISREG(info.st_mode):
162
+ raise _configuration_error("local managed-resource configuration must be a regular file")
163
+ if stat.S_IMODE(info.st_mode) & 0o077:
164
+ raise _configuration_error(
165
+ "local managed-resource configuration is not private; run "
166
+ "`python -m dataapp_resource_proxy configure` to repair it"
167
+ )
168
+ try:
169
+ payload = json.loads(path.read_text(encoding="utf-8"))
170
+ except (OSError, UnicodeError, ValueError) as error:
171
+ raise _configuration_error(
172
+ f"cannot load local managed-resource configuration ({type(error).__name__})"
173
+ ) from None
174
+ if environment is None:
175
+ valid = (
176
+ isinstance(payload, dict)
177
+ and set(payload) == _LEGACY_CONFIG_KEYS
178
+ and payload.get("schema_version") == 1
179
+ )
180
+ else:
181
+ valid = (
182
+ isinstance(payload, dict)
183
+ and set(payload) == _SCOPED_CONFIG_KEYS
184
+ and payload.get("schema_version") == 2
185
+ and payload.get("environment") == environment
186
+ )
187
+ if (
188
+ not valid
189
+ or not isinstance(payload.get("DATAAPP_API_KEY"), str)
190
+ or not payload["DATAAPP_API_KEY"].strip()
191
+ ):
192
+ raise _configuration_error(
193
+ "local managed-resource configuration does not match the current environment; "
194
+ "run `python -m dataapp_resource_proxy configure` to replace it"
195
+ )
196
+ return {"DATAAPP_API_KEY": payload["DATAAPP_API_KEY"]}
197
+
198
+
199
+ def save_local_config(
200
+ api_key: str,
201
+ environ: Mapping[str, str] | None = None,
202
+ *,
203
+ environment: str | None = None,
204
+ ) -> Path:
205
+ """Atomically store the local API key in a mode-600 environment-bound file."""
206
+ if not isinstance(api_key, str) or not api_key.strip():
207
+ raise ValueError("API Key must not be empty")
208
+ env = os.environ if environ is None else environ
209
+ path = local_config_path(env, environment=environment)
210
+ parent_existed = path.parent.exists()
211
+ path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
212
+ if not env.get(_CONFIG_ENV) and parent_existed:
213
+ os.chmod(path.parent, 0o700)
214
+ payload: dict[str, Any]
215
+ if environment is None:
216
+ payload = {"schema_version": 1, "DATAAPP_API_KEY": api_key.strip()}
217
+ else:
218
+ payload = {
219
+ "schema_version": 2,
220
+ "environment": environment,
221
+ "DATAAPP_API_KEY": api_key.strip(),
222
+ }
223
+ descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent, text=True)
224
+ try:
225
+ os.fchmod(descriptor, 0o600)
226
+ with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
227
+ descriptor = -1
228
+ json.dump(payload, stream, ensure_ascii=False)
229
+ stream.write("\n")
230
+ stream.flush()
231
+ os.fsync(stream.fileno())
232
+ os.replace(temporary, path)
233
+ os.chmod(path, 0o600)
234
+ finally:
235
+ if descriptor >= 0:
236
+ os.close(descriptor)
237
+ try:
238
+ os.unlink(temporary)
239
+ except FileNotFoundError:
240
+ pass
241
+ return path
242
+
243
+
244
+ def _validated_platform_url(value: str, *, environment: str, api_keys: bool = False) -> str:
245
+ raw = value.strip()
246
+ parsed = urlsplit(raw)
247
+ hostname = (parsed.hostname or "").lower()
248
+ try:
249
+ port = parsed.port
250
+ except ValueError:
251
+ port = -1
252
+ if (
253
+ parsed.scheme != "https"
254
+ or not hostname
255
+ or port not in {None, 443}
256
+ or parsed.username is not None
257
+ or parsed.password is not None
258
+ or parsed.query
259
+ or parsed.fragment
260
+ or not any(hostname.endswith(suffix) for suffix in _PLATFORM_HOST_SUFFIXES)
261
+ ):
262
+ raise _configuration_error(f"{_PROJECT_CONFIG_FILENAME} contains an unsafe platform URL")
263
+ labels = hostname.split(".")
264
+ is_pre_host = any(label == "pre" or label.startswith("pre-") for label in labels)
265
+ if environment.endswith("_pre") != is_pre_host:
266
+ raise _configuration_error(f"{_PROJECT_CONFIG_FILENAME} mixes pre and production endpoints")
267
+ if api_keys:
268
+ brand = environment.removesuffix("_pre")
269
+ portal_suffix = _PORTAL_HOST_SUFFIXES[brand]
270
+ if hostname != portal_suffix.removeprefix(".") and not hostname.endswith(portal_suffix):
271
+ raise _configuration_error(
272
+ f"{_PROJECT_CONFIG_FILENAME} API Keys URL does not match its environment"
273
+ )
274
+ if parsed.path.rstrip("/") != "/console/account-center/api-keys":
275
+ raise _configuration_error(
276
+ f"{_PROJECT_CONFIG_FILENAME} contains an invalid API Keys URL"
277
+ )
278
+ elif parsed.path not in {"", "/"}:
279
+ raise _configuration_error(
280
+ f"{_PROJECT_CONFIG_FILENAME} contains an invalid Resource Proxy URL"
281
+ )
282
+ return raw.rstrip("/")
283
+
284
+
285
+ def _configuration_error(message: str) -> ResourceProxyError:
286
+ return ResourceProxyError(message, code="configuration-error")