@follenfang/fupload 0.0.6 → 0.0.7

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,522 @@
1
+ """Persistent browser session and protocol for Heybox Workshop."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import secrets
9
+ import sys
10
+ import time
11
+ from enum import Enum
12
+ from pathlib import Path
13
+ from typing import Any, Callable, Mapping
14
+ from urllib.parse import parse_qs, urlencode, urlsplit
15
+
16
+ from .errors import FuploadError, redact
17
+
18
+
19
+ WORKSHOP_URL = "https://open.xiaoheihe.cn/zh_cn/workshop"
20
+ LOGIN_ORIGIN = "https://login.xiaoheihe.cn"
21
+ API_ORIGIN = "https://workshopapi.xiaoheihe.cn"
22
+ API_ROUTES = frozenset({
23
+ ("GET", "/wow/open_platform/module/list/"),
24
+ ("GET", "/wow/open_platform/module/detail/"),
25
+ ("POST", "/wow/open_platform/module/update/"),
26
+ ("GET", "/wow/open_platform/module_version/list/"),
27
+ ("POST", "/wow/open_platform/module_version/upsert/"),
28
+ ("POST", "/wow/open_platform/module_version/delete/"),
29
+ ("POST", "/wow/cos/upload/token/"),
30
+ })
31
+ API_PATHS = frozenset(path for _method, path in API_ROUTES)
32
+ WEB_QUERY_KEYS = frozenset({
33
+ "_time", "app", "device_id", "heybox_id", "hkey", "nonce", "os_type",
34
+ "version", "web_version", "x_app", "x_client_type", "x_os_type",
35
+ "x_xhh_tokenid",
36
+ })
37
+ _ALPHABET = "AB45STUVWZEFGJ6CH01D237IXYPQRKLMN89"
38
+ _INTERACTIVE_STATUSES = frozenset({
39
+ "lack_token", "show_captcha", "name_verify", "need_alipay_verify",
40
+ "need_bind_phone", "need_phone_code",
41
+ })
42
+ _SECRET_MARKERS = (
43
+ "token", "secret", "cookie", "nonce", "hkey", "pkey", "credential",
44
+ "signature", "authorization", "authentication", "password", "device_id",
45
+ "signed_url", "upload_url", "presigned",
46
+ )
47
+
48
+
49
+ class WebSessionState(str, Enum):
50
+ HEADLESS_PROBE = "headless_probe"
51
+ HEADED_LOGIN = "headed_login"
52
+ READY = "ready"
53
+ EXPIRED = "expired"
54
+ FAILED = "failed"
55
+
56
+
57
+ def managed_profile_path() -> Path:
58
+ """Return the private browser profile owned by Fuploader."""
59
+ if os.name == "nt":
60
+ base = Path(os.environ.get("LOCALAPPDATA") or (Path.home() / "AppData/Local"))
61
+ return base / "Fuploader" / "blackbox-chromium"
62
+ return Path.home() / ".fupload" / "blackbox-chromium"
63
+
64
+
65
+ def managed_state_path() -> Path:
66
+ return managed_profile_path().parent / "blackbox-web-state.json"
67
+
68
+
69
+ def _n8(value: str, limit: int) -> str:
70
+ table = _ALPHABET[:limit]
71
+ return "".join(table[ord(char) % len(table)] for char in value)
72
+
73
+
74
+ def _i8(value: str) -> str:
75
+ return "".join(_ALPHABET[ord(char) % len(_ALPHABET)] for char in value)
76
+
77
+
78
+ def _interleave(parts: list[str]) -> str:
79
+ width = max(map(len, parts))
80
+ return "".join(part[index] for index in range(width) for part in parts if index < len(part))
81
+
82
+
83
+ def _mix(values: list[int]) -> list[int]:
84
+ def p(value: int) -> int:
85
+ return ((value << 1) ^ 27) & 255 if value & 128 else (value << 1) & 255
86
+
87
+ def hm(value: int) -> int:
88
+ return p(value) ^ value
89
+
90
+ def qg(value: int) -> int:
91
+ return hm(p(value))
92
+
93
+ def dx(value: int) -> int:
94
+ return qg(hm(p(value)))
95
+
96
+ def mw(value: int) -> int:
97
+ return dx(value) ^ qg(value) ^ hm(value)
98
+
99
+ a, b, c, d, *rest = values
100
+ return [
101
+ mw(a) ^ dx(b) ^ qg(c) ^ hm(d),
102
+ hm(a) ^ mw(b) ^ dx(c) ^ qg(d),
103
+ qg(a) ^ hm(b) ^ mw(c) ^ dx(d),
104
+ dx(a) ^ qg(b) ^ hm(c) ^ mw(d),
105
+ *rest,
106
+ ]
107
+
108
+
109
+ def web_hkey(path: str, timestamp: int, nonce: str) -> str:
110
+ normalized = "/" + "/".join(part for part in path.split("/") if part) + "/"
111
+ source = _interleave([_n8(str(timestamp), -2), _i8(normalized), _i8(nonce)])
112
+ digest = hashlib.md5(source.encode()).hexdigest()
113
+ return _n8(digest[:5], -4) + f"{sum(_mix([ord(char) for char in digest[-6:]])) % 100:02d}"
114
+
115
+
116
+ def redact_recursive(value: Any) -> Any:
117
+ """Remove browser credentials and signed query strings from diagnostics."""
118
+ if isinstance(value, Mapping):
119
+ result = {}
120
+ for key, item in value.items():
121
+ if any(marker in str(key).lower() for marker in _SECRET_MARKERS):
122
+ result[key] = "<redacted>"
123
+ else:
124
+ result[key] = redact_recursive(item)
125
+ return result
126
+ if isinstance(value, (list, tuple, set)):
127
+ return [redact_recursive(item) for item in value]
128
+ if isinstance(value, str):
129
+ parsed = urlsplit(value)
130
+ if parsed.scheme in {"http", "https"} and parsed.netloc and parsed.query:
131
+ return f"{parsed.scheme}://{parsed.netloc}{parsed.path}?<redacted>"
132
+ return redact(value)
133
+ return value
134
+
135
+
136
+ class BlackboxWebSession:
137
+ """Own a persistent Chromium profile and expose the Workshop protocol."""
138
+
139
+ def __init__(
140
+ self,
141
+ *,
142
+ login_timeout: float = 300,
143
+ poll_interval: float = 1,
144
+ browser_launcher: Callable[[bool, Path], Any] | None = None,
145
+ sleep: Callable[[float], None] = time.sleep,
146
+ monotonic: Callable[[], float] = time.monotonic,
147
+ ) -> None:
148
+ self.state = WebSessionState.HEADLESS_PROBE
149
+ self.login_timeout = max(0.0, float(login_timeout))
150
+ self.poll_interval = max(0.0, float(poll_interval))
151
+ self._browser_launcher = browser_launcher
152
+ self._sleep = sleep
153
+ self._monotonic = monotonic
154
+ self._context = None
155
+ self._playwright = None
156
+
157
+ @property
158
+ def profile_path(self) -> Path:
159
+ return managed_profile_path()
160
+
161
+ def close(self) -> None:
162
+ self._close_context()
163
+
164
+ def __enter__(self) -> "BlackboxWebSession":
165
+ return self
166
+
167
+ def __exit__(self, *args: Any) -> None:
168
+ self.close()
169
+
170
+ def ensure_ready(self) -> None:
171
+ if self.state == WebSessionState.READY and self._context is not None:
172
+ return
173
+ self._close_context()
174
+ self._set_state(WebSessionState.HEADLESS_PROBE)
175
+ try:
176
+ self._context = self._launch(headless=True)
177
+ self._open_workshop(self._context)
178
+ if self._probe(self._context):
179
+ self._set_state(WebSessionState.READY)
180
+ return
181
+ self._set_state(WebSessionState.EXPIRED, "web session requires login")
182
+ self._login()
183
+ except FuploadError:
184
+ if self.state not in {WebSessionState.EXPIRED, WebSessionState.HEADED_LOGIN}:
185
+ self._set_state(WebSessionState.FAILED, "browser session failed")
186
+ raise
187
+ except Exception as exc:
188
+ self._set_state(WebSessionState.FAILED, "browser session failed")
189
+ raise self._error("Workshop browser session failed", exc) from exc
190
+
191
+ def request(
192
+ self,
193
+ method: str,
194
+ path: str,
195
+ body: Mapping[str, Any] | None = None,
196
+ query: Mapping[str, Any] | None = None,
197
+ ) -> dict[str, Any]:
198
+ method, path = self._validate_request(method, path)
199
+ self.ensure_ready()
200
+ payload, response = self._protocol(self._context, method, path, body, query)
201
+ if self._authenticated(payload, response):
202
+ return payload
203
+ if not self._needs_interaction(payload, response):
204
+ raise self._response_error(path, payload, response, verification_required=method == "POST")
205
+
206
+ self._set_state(WebSessionState.EXPIRED, "web session expired")
207
+ if method == "POST":
208
+ raise self._response_error(path, payload, response, verification_required=True)
209
+ self._login()
210
+ payload, response = self._protocol(self._context, method, path, body, query)
211
+ if self._authenticated(payload, response):
212
+ return payload
213
+ if self._needs_interaction(payload, response):
214
+ self._set_state(WebSessionState.EXPIRED, "web session remained expired")
215
+ raise self._response_error(path, payload, response)
216
+
217
+ def _login(self) -> None:
218
+ self._close_context()
219
+ self._set_state(WebSessionState.HEADED_LOGIN)
220
+ try:
221
+ context = self._launch(headless=False)
222
+ self._context = context
223
+ self._open_workshop(context)
224
+ deadline = self._monotonic() + self.login_timeout
225
+ while True:
226
+ if self._headed_window_closed(context):
227
+ self._set_state(WebSessionState.EXPIRED, "headed login window closed")
228
+ raise FuploadError(
229
+ "Workshop login window was closed",
230
+ kind="authentication_error",
231
+ stage="headed_login",
232
+ )
233
+ try:
234
+ ready = self._probe(context)
235
+ except FuploadError as exc:
236
+ if exc.business_code != "network_error":
237
+ raise
238
+ ready = False
239
+ if ready:
240
+ break
241
+ if self._monotonic() >= deadline:
242
+ self._set_state(WebSessionState.EXPIRED, "headed login timed out")
243
+ raise FuploadError(
244
+ "Workshop web login timed out",
245
+ kind="authentication_error",
246
+ stage="headed_login",
247
+ )
248
+ self._sleep(self.poll_interval)
249
+
250
+ self._close_context()
251
+ self._set_state(WebSessionState.HEADLESS_PROBE)
252
+ self._context = self._launch(headless=True)
253
+ self._open_workshop(self._context)
254
+ if not self._probe(self._context):
255
+ self._set_state(WebSessionState.EXPIRED, "web login did not persist")
256
+ raise FuploadError(
257
+ "Workshop web login did not persist",
258
+ kind="authentication_error",
259
+ stage="headless_probe",
260
+ )
261
+ self._set_state(WebSessionState.READY)
262
+ except FuploadError:
263
+ self._close_context()
264
+ raise
265
+ except Exception as exc:
266
+ self._set_state(WebSessionState.FAILED, "headed login failed")
267
+ self._close_context()
268
+ raise self._error("Workshop headed login failed", exc) from exc
269
+
270
+ def _launch(self, *, headless: bool) -> Any:
271
+ profile = self.profile_path
272
+ profile.mkdir(parents=True, exist_ok=True)
273
+ if self._browser_launcher is not None:
274
+ return self._browser_launcher(headless, profile)
275
+ try:
276
+ from playwright.sync_api import sync_playwright
277
+ except ImportError as exc:
278
+ raise FuploadError(
279
+ "Playwright is required for Workshop web login",
280
+ kind="environment_error",
281
+ stage="browser_launch",
282
+ details={"dependency": "playwright"},
283
+ ) from exc
284
+ try:
285
+ self._playwright = sync_playwright().start()
286
+ return self._playwright.chromium.launch_persistent_context(
287
+ user_data_dir=str(profile),
288
+ headless=headless,
289
+ )
290
+ except Exception:
291
+ if self._playwright is not None:
292
+ self._playwright.stop()
293
+ self._playwright = None
294
+ raise
295
+
296
+ @staticmethod
297
+ def _open_workshop(context: Any) -> None:
298
+ page = context.pages[0] if getattr(context, "pages", None) else context.new_page()
299
+ page.goto(WORKSHOP_URL, wait_until="domcontentloaded")
300
+ current = str(getattr(page, "url", "") or "")
301
+ if current and not BlackboxWebSession._allowed_navigation(current):
302
+ raise FuploadError(
303
+ "Workshop browser navigated outside the fixed login flow",
304
+ kind="authentication_error",
305
+ stage="browser_navigation",
306
+ )
307
+
308
+ def _probe(self, context: Any) -> bool:
309
+ payload, response = self._protocol(
310
+ context, "GET", "/wow/open_platform/module/list/", None, None,
311
+ )
312
+ if self._authenticated(payload, response):
313
+ modules = (payload.get("result") or {}).get("moduleList")
314
+ if isinstance(modules, list):
315
+ return True
316
+ raise self._response_error(
317
+ "/wow/open_platform/module/list/", payload, response,
318
+ message="Workshop readiness response is invalid",
319
+ )
320
+ if self._needs_interaction(payload, response):
321
+ return False
322
+ raise self._response_error("/wow/open_platform/module/list/", payload, response)
323
+
324
+ @staticmethod
325
+ def _protocol(
326
+ context: Any,
327
+ method: str,
328
+ path: str,
329
+ body: Mapping[str, Any] | None,
330
+ query: Mapping[str, Any] | None,
331
+ ) -> tuple[dict[str, Any], Any]:
332
+ caller_query = dict(query or {})
333
+ reserved = sorted(WEB_QUERY_KEYS.intersection(caller_query))
334
+ if reserved:
335
+ raise FuploadError(
336
+ "Workshop query overrides a managed protocol field",
337
+ kind="validation_error",
338
+ stage="protocol_validation",
339
+ details={"fields": reserved},
340
+ )
341
+ cookie_rows = context.cookies([WORKSHOP_URL, API_ORIGIN])
342
+ cookies = {str(item.get("name")): str(item.get("value") or "") for item in cookie_rows}
343
+ heybox_id = cookies.get("user_heybox_id") or cookies.get("heybox_id") or ""
344
+ risk_token = cookies.get("x_xhh_tokenid") or ""
345
+ timestamp = int(time.time())
346
+ nonce = hashlib.md5(
347
+ f"{timestamp}{time.time_ns()}{secrets.token_hex(8)}".encode(),
348
+ ).hexdigest().upper()
349
+ params = {
350
+ "app": "heybox",
351
+ "heybox_id": heybox_id,
352
+ "os_type": "web",
353
+ "x_app": "heybox_website",
354
+ "x_client_type": "weboutapp",
355
+ "x_os_type": BlackboxWebSession._platform_name(),
356
+ "web_version": "",
357
+ "device_id": risk_token,
358
+ "version": "999.0.4",
359
+ "hkey": web_hkey(path, timestamp + 1, nonce),
360
+ "_time": timestamp,
361
+ "nonce": nonce,
362
+ "x_xhh_tokenid": risk_token,
363
+ **caller_query,
364
+ }
365
+ url = API_ORIGIN + path
366
+ headers = {"Referer": WORKSHOP_URL}
367
+ try:
368
+ if method == "GET":
369
+ response = context.request.get(url, params=params, headers=headers)
370
+ else:
371
+ response = context.request.post(
372
+ url,
373
+ params=params,
374
+ data=urlencode(dict(body or {}), doseq=True),
375
+ headers={
376
+ **headers,
377
+ "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
378
+ },
379
+ )
380
+ except Exception as exc:
381
+ raise FuploadError(
382
+ "Workshop web request failed",
383
+ kind="operation_failed",
384
+ stage="web_protocol",
385
+ endpoint=path,
386
+ business_code="network_error",
387
+ verification_required=method == "POST",
388
+ details=redact_recursive({"error": str(exc)}),
389
+ ) from exc
390
+ try:
391
+ payload = response.json()
392
+ except Exception:
393
+ payload = {"status": "network_error"}
394
+ return (payload if isinstance(payload, dict) else {}), response
395
+
396
+ @staticmethod
397
+ def _authenticated(payload: Mapping[str, Any], response: Any) -> bool:
398
+ return bool(getattr(response, "ok", False)) and payload.get("status") == "ok"
399
+
400
+ @staticmethod
401
+ def _needs_interaction(payload: Mapping[str, Any], response: Any) -> bool:
402
+ return (
403
+ payload.get("status") in {"login", "relogin", "unauthorized", *_INTERACTIVE_STATUSES}
404
+ or getattr(response, "status", 0) in {401, 403}
405
+ )
406
+
407
+ @staticmethod
408
+ def _validate_request(method: str, path: str) -> tuple[str, str]:
409
+ normalized_method = str(method).upper()
410
+ if not isinstance(path, str) or (normalized_method, path) not in API_ROUTES:
411
+ raise FuploadError(
412
+ "Workshop protocol route is not allowed",
413
+ kind="validation_error",
414
+ stage="protocol_validation",
415
+ )
416
+ return normalized_method, path
417
+
418
+ @staticmethod
419
+ def _allowed_navigation(url: str) -> bool:
420
+ parsed = urlsplit(url)
421
+ origin = f"{parsed.scheme}://{parsed.netloc}"
422
+ if origin == "https://open.xiaoheihe.cn":
423
+ return parsed.path in {"/zh_cn/workshop", "/zh_cn/workshop/"}
424
+ if origin != LOGIN_ORIGIN or parsed.path != "/":
425
+ return False
426
+ query = parse_qs(parsed.query)
427
+ return (
428
+ query.get("origin") == ["heybox_open"]
429
+ and query.get("redirect_url") in ([WORKSHOP_URL], [WORKSHOP_URL + "/"])
430
+ )
431
+
432
+ @staticmethod
433
+ def _headed_window_closed(context: Any) -> bool:
434
+ pages = getattr(context, "pages", None)
435
+ if pages is None:
436
+ return False
437
+ if not pages:
438
+ return True
439
+ return all(callable(getattr(page, "is_closed", None)) and page.is_closed() for page in pages)
440
+
441
+ @staticmethod
442
+ def _platform_name() -> str:
443
+ if os.name == "nt":
444
+ return "Windows"
445
+ if sys.platform == "darwin":
446
+ return "macOS"
447
+ return "Linux"
448
+
449
+ def _set_state(self, state: WebSessionState, reason: str | None = None) -> None:
450
+ self.state = state
451
+ path = managed_state_path()
452
+ path.parent.mkdir(parents=True, exist_ok=True)
453
+ payload = {
454
+ "schema": "fupload.blackbox.web-state.v1",
455
+ "state": state.value,
456
+ "updated_at": int(time.time()),
457
+ }
458
+ if reason:
459
+ payload["reason"] = redact(reason)
460
+ temporary = path.with_suffix(path.suffix + ".tmp")
461
+ temporary.write_text(json.dumps(payload, sort_keys=True) + "\n", encoding="utf-8")
462
+ temporary.replace(path)
463
+
464
+ def _close_context(self) -> None:
465
+ context, self._context = self._context, None
466
+ if context is not None:
467
+ try:
468
+ context.close()
469
+ except Exception:
470
+ pass
471
+ playwright, self._playwright = self._playwright, None
472
+ if playwright is not None:
473
+ try:
474
+ playwright.stop()
475
+ except Exception:
476
+ pass
477
+
478
+ @staticmethod
479
+ def _response_error(
480
+ path: str,
481
+ payload: Mapping[str, Any],
482
+ response: Any,
483
+ *,
484
+ message: str = "Workshop web request was rejected",
485
+ verification_required: bool = False,
486
+ ) -> FuploadError:
487
+ status = payload.get("status")
488
+ is_auth = BlackboxWebSession._needs_interaction(payload, response)
489
+ return FuploadError(
490
+ message,
491
+ kind="authentication_error" if is_auth else "operation_failed",
492
+ stage="web_protocol",
493
+ endpoint=path,
494
+ http_status=getattr(response, "status", None),
495
+ business_code=status,
496
+ verification_required=verification_required,
497
+ details=redact_recursive({"response": payload}),
498
+ )
499
+
500
+ @staticmethod
501
+ def _error(message: str, exc: Exception) -> FuploadError:
502
+ return FuploadError(
503
+ message,
504
+ kind="environment_error",
505
+ stage="browser_session",
506
+ details=redact_recursive({"error": str(exc)}),
507
+ )
508
+
509
+
510
+ __all__ = [
511
+ "API_ORIGIN",
512
+ "API_PATHS",
513
+ "API_ROUTES",
514
+ "WEB_QUERY_KEYS",
515
+ "WORKSHOP_URL",
516
+ "BlackboxWebSession",
517
+ "WebSessionState",
518
+ "managed_profile_path",
519
+ "managed_state_path",
520
+ "redact_recursive",
521
+ "web_hkey",
522
+ ]
@@ -206,7 +206,7 @@ def _blackbox_tree(platforms: argparse._SubParsersAction) -> None:
206
206
  root = platforms.add_parser(
207
207
  "blackbox",
208
208
  help="Heybox Workshop plugin management",
209
- description="Reuse the signed-in Heybox desktop client login state; no credential input is accepted.",
209
+ description="Use a managed Heybox Workshop web session; an interactive browser opens when login is required.",
210
210
  )
211
211
  groups = root.add_subparsers(dest="resource_command", required=True)
212
212
  plugin = groups.add_parser("plugin", help="Heybox Workshop plugin metadata and versions").add_subparsers(dest="action_command", required=True)
@@ -281,17 +281,27 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
281
281
  write_output(platform, operation, _dry_run_data(doc, schema.name), dry_run=True)
282
282
  return 0
283
283
  provider = NewBee() if platform == "newbee" else (DD() if platform == "dd" else (Blackbox() if platform == "blackbox" else CurseForge()))
284
- if platform == "dd":
285
- data = provider.execute_write(resource, action, doc, getattr(args, "session", None))
286
- else:
287
- data = provider.execute_write(resource, action, doc)
284
+ try:
285
+ if platform == "dd":
286
+ data = provider.execute_write(resource, action, doc, getattr(args, "session", None))
287
+ else:
288
+ data = provider.execute_write(resource, action, doc)
289
+ finally:
290
+ close = getattr(provider, "close", None)
291
+ if close:
292
+ close()
288
293
  write_output(platform, operation, data)
289
294
  return 0
290
295
  provider = NewBee() if platform == "newbee" else (DD() if platform == "dd" else (Blackbox() if platform == "blackbox" else CurseForge()))
291
- if platform == "dd":
292
- data = provider.execute_read(resource, action, args, getattr(args, "session", None))
293
- else:
294
- data = provider.execute_read(resource, action, args)
296
+ try:
297
+ if platform == "dd":
298
+ data = provider.execute_read(resource, action, args, getattr(args, "session", None))
299
+ else:
300
+ data = provider.execute_read(resource, action, args)
301
+ finally:
302
+ close = getattr(provider, "close", None)
303
+ if close:
304
+ close()
295
305
  write_output(platform, operation, data)
296
306
  return 0
297
307
  except (FuploadError, OSError, ValueError) as exc:
@@ -93,7 +93,7 @@ class Schema:
93
93
  return checked
94
94
 
95
95
  def _validate_conditionals(self, value: Dict[str, Any]) -> None:
96
- for name in ("id", "mod_id", "file_id", "content_id", "source_id", "module_id", "game_version_id", "cloud_id"):
96
+ for name in ("id", "mod_id", "file_id", "content_id", "source_id", "module_id", "version_id", "game_version_id", "cloud_id"):
97
97
  if name in value and isinstance(value[name], int) and value[name] <= 0:
98
98
  raise ValidationError("must be greater than zero", path="$.%s" % name)
99
99
  if value.get("public") is True and value.get("submit_for_review") is not True:
@@ -340,6 +340,17 @@ class Schema:
340
340
  for field_name in ("game_versions", "game_version_names"):
341
341
  if field_name in value:
342
342
  raise ValidationError("must be omitted when parent_file_id is set", path="$.%s" % field_name)
343
+ if self.name.startswith("fupload.v1.blackbox"):
344
+ scalar_array("game_versions", (str,), "array must contain nonempty game-version strings")
345
+ if "game_versions" in value and any(not item.strip() for item in value["game_versions"]):
346
+ raise ValidationError("array must contain nonempty game-version strings", path="$.game_versions")
347
+ if "category_ids" in value and any(isinstance(item, bool) or not isinstance(item, int) or item <= 0 for item in value["category_ids"]):
348
+ raise ValidationError("array must contain positive integer IDs", path="$.category_ids")
349
+ scalar_array("core_folders", (str,), "array must contain nonempty folder names")
350
+ if "core_folders" in value and any(not item.strip() for item in value["core_folders"]):
351
+ raise ValidationError("array must contain nonempty folder names", path="$.core_folders")
352
+ if "file" in value and not zipfile.is_zipfile(value["file"]):
353
+ raise ValidationError("file must be a valid ZIP archive", path="$.file")
343
354
 
344
355
 
345
356
  def f(type_name: str, **kwargs: Any) -> Field:
@@ -559,12 +570,12 @@ register("blackbox", "plugin", "edit", required({
559
570
  }, ("id",)))
560
571
  register("blackbox", "plugin", "update", required({
561
572
  "module_id": f("integer"), "name": f("string", nonempty=True), "type": f("integer", choices=(1, 2, 3)),
562
- "game_versions": f("array", nonempty=True), "file": f("string", local_file=True), "file_url": f("string"),
573
+ "game_versions": f("array", nonempty=True), "file": f("string", local_file=True), "file_url": f("string", nonempty=True),
563
574
  }, ("module_id", "name", "type", "game_versions", "file")))
564
575
  register("blackbox", "version", "edit", required({
565
576
  "version_id": f("integer"), "module_id": f("integer"), "name": f("string", nonempty=True),
566
577
  "type": f("integer", choices=(1, 2, 3)), "game_versions": f("array", nonempty=True),
567
- "file": f("string", local_file=True), "file_url": f("string"),
578
+ "file": f("string", local_file=True), "file_url": f("string", nonempty=True),
568
579
  }, ("version_id", "module_id", "name", "type", "game_versions")))
569
580
  register("blackbox", "version", "delete", required({
570
581
  "version_id": f("integer"), "module_id": f("integer"),
@@ -1 +1,2 @@
1
1
  cos-python-sdk-v5==1.9.44
2
+ playwright==1.62.0