@follenfang/fupload 0.0.5 → 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.
package/fupload/SKILL.md CHANGED
@@ -1,8 +1,8 @@
1
1
  ---
2
2
  name: fupload
3
- description: Explicit author-publishing workflow for World of Warcraft plugins, configuration shares, and WA/strings on NewBeeBox, NetEase DD, CurseForge, and Heybox Workshop, including CurseForge author project lookup, plugin ZIP upload, and Heybox client-session version management. Use only when the user explicitly invokes `$fupload`, explicitly asks to use the Fupload Skill, or loads this Skill by path. Do not trigger from ordinary mentions of publishing, NewBeeBox, DD, CurseForge, Heybox, plugins, configurations, or WA.
3
+ description: Explicit author-publishing workflow for World of Warcraft plugins, configuration shares, and WA/strings on NewBeeBox, NetEase DD, CurseForge, and Heybox Workshop, including CurseForge author project lookup, plugin ZIP upload, and Heybox web-session version management. Use only when the user explicitly invokes `$fupload`, explicitly asks to use the Fupload Skill, or loads this Skill by path. Do not trigger from ordinary mentions of publishing, NewBeeBox, DD, CurseForge, Heybox, plugins, configurations, or WA.
4
4
  metadata:
5
- version: "0.0.5"
5
+ version: "0.0.7"
6
6
  ---
7
7
 
8
8
  # Fupload
@@ -69,7 +69,7 @@ For npm package maintenance, use `fupload update` to update the CLI, managed Pyt
69
69
  - Read [references/newbee.md](references/newbee.md) only when the user explicitly selected the third-party Python NewBeeBox channel.
70
70
  - Read [references/dd.md](references/dd.md) only for DD.
71
71
  - Read [references/curseforge.md](references/curseforge.md) for every CurseForge lookup or upload.
72
- - Read [references/blackbox.md](references/blackbox.md) for Heybox Workshop plugin list/detail, metadata edit, version upload/edit/delete, and client-session authentication.
72
+ - Read [references/blackbox.md](references/blackbox.md) for Heybox Workshop plugin list/detail, metadata edit, version upload/edit/delete, and managed web-session authentication.
73
73
  - For Python, run the exact leaf command with `--help` before creating its input. Treat help as the executable schema contract.
74
74
 
75
75
  ## Investigate
@@ -88,7 +88,7 @@ For DD, use the one active task session to read game types/builds and the resour
88
88
 
89
89
  For every existing DD update or edit, GET the detail first and rebuild the official full form before preparing the write. If a legacy record lacks a field now required by the official submit validator, such as `creation_statement`, stop before upload and collect that value. When the missing field belongs to a different action allowlist, plan and confirm the prerequisite edit first, read it back, then run the content update. Do not send `null`, create defaults, or guessed values to bypass the missing field.
90
90
 
91
- For Heybox Workshop, use the signed-in desktop client's local session automatically. `blackbox plugin list`, `get`, and `versions` are read-only; `plugin edit` preserves omitted module fields by reading the complete module first; `plugin update` and `version-edit` upload or reuse a ZIP URL and verify the version readback; `version-delete` waits for asynchronous soft deletion and retries once when the audit state temporarily returns active. Do not accept or print cookies, tokens, signatures, or temporary COS credentials, and do not delete a whole module.
91
+ For Heybox Workshop, use Fuploader's managed Chromium web profile. If the profile is missing or the creator API reports an expired session, Fuploader opens the Workshop login page visibly for the user to complete login, then reuses the same profile headlessly for signed protocol requests. Never read the desktop client config or import another browser profile, and never accept a caller-supplied profile, cookie, token, signature, or endpoint. `blackbox plugin list`, `get`, and `versions` are read-only; `plugin edit` preserves omitted module fields by reading the complete module first; `plugin update` and `version-edit` upload or reuse a ZIP URL and verify the version readback; `version-delete` waits for asynchronous soft deletion and retries once when the audit state temporarily returns active. Do not print cookies, tokens, signatures, or temporary COS credentials, and do not delete a whole module.
92
92
 
93
93
  For DD WA create, collect user choices before generating JSON, then let the Python CLI supply only the official create defaults for omitted form values: seven-day share and purchase lifetimes, `need_buy=false`, category `ui_original`, `Interface/Addons`, empty VIP levels, and version `0`. Submit category IDs as strings even when a discovery response represents them numerically. WA create/update versions contain digits only; update must be numerically greater than the current remote value. Every `!WA:2!` create/update/edit is reparsed by the installed official `WowUIInterface.parseWa` chain, including unchanged edit content. Do not carry create defaults into WA update/edit; omitted existing fields preserve their remote value.
94
94
 
@@ -1,6 +1,8 @@
1
1
  # Heybox Workshop
2
2
 
3
- The blackbox provider reuses the signed-in Heybox desktop client's local login state. It never accepts credentials, cookies, tokens, signatures, or COS temporary credentials as input.
3
+ The blackbox provider uses a Fuploader-managed persistent Chromium profile for the Workshop web session. It probes that profile headlessly. If the profile is missing or expired, it opens the Workshop login page visibly and waits for the user to finish login, then resumes through the same profile in headless mode.
4
+
5
+ The Chromium profile lives under Fuploader's product state directory and is managed with the package's private Python venv. Fuploader does not read the Heybox desktop config or import Chrome, Edge, Electron, or another Playwright profile, and never accepts credentials, profile paths, cookies, tokens, signatures, endpoints, or COS temporary credentials as input. API calls use a fixed allowlisted web protocol and redact authentication and signed-upload material from output.
4
6
 
5
7
  The npm installer manages Tencent's official COS SDK in a Fuploader-only Python venv. `fupload update` synchronizes it, the launcher repairs a missing or stale runtime before executing the Python CLI, and `fupload uninstall` removes it. No system-wide `pip install` is required.
6
8
 
@@ -1,3 +1,3 @@
1
1
  """Fupload Python CLI."""
2
2
 
3
- __version__ = "0.0.5"
3
+ __version__ = "0.0.7"
@@ -1,29 +1,43 @@
1
1
  """Heybox Workshop plugin provider."""
2
2
  from __future__ import annotations
3
- import hashlib, json, secrets, time
3
+ import hashlib, json, time, zipfile
4
4
  from pathlib import Path
5
- from urllib.parse import urlencode
6
- from urllib.request import Request, urlopen
5
+ from urllib.parse import urlsplit, urlunsplit
7
6
  from typing import Any, Mapping
8
7
  from .errors import FuploadError, ValidationError
9
- from .blackbox_auth import API_BASE, hkey, load_session
8
+ from .blackbox_web import API_ORIGIN, BlackboxWebSession
9
+
10
+ API_MISC_BASE = "https://api.xiaoheihe.cn"
11
+ API_BASE = API_ORIGIN
10
12
 
11
13
  class Blackbox:
12
- def __init__(self, config: Mapping[str, Any] | None = None, transport=None):
14
+ def __init__(self, config: Mapping[str, Any] | None = None, transport=None, *, web_session=None, web_session_factory=None):
13
15
  self.config = dict(config or {})
14
- self.profile = Path(self.config.get("client_profile") or Path.home() / "AppData/Roaming/heybox-pc-launcher")
15
16
  self._transport = transport
16
- def _request(self, method, path, body=None, query=None):
17
- if self._transport: return self._transport(method, path, body or {}, query or {})
18
- cookies, identity = load_session(self.profile); ts=int(time.time()); nonce=hashlib.md5((str(ts)+secrets.token_hex(16)).encode()).hexdigest().upper()
19
- params={**identity,"version":identity.get("version","1.12.0"),"hkey":hkey(path,ts,nonce),"_time":ts,"_chat_time":int(time.time()*1000),"nonce":nonce,**(query or {})}
20
- headers={"User-Agent":"HeyboxApp/1.12.0","x_xhh_tokenid":cookies["x_xhh_tokenid"],"Cookie":"; ".join(f"{k}={v}" for k,v in cookies.items()),"Content-Type":"application/x-www-form-urlencoded"}
21
- url=API_BASE+path+"?"+urlencode(params); data=urlencode(body or {},doseq=True).encode() if method=="POST" else None
22
- try:
23
- with urlopen(Request(url,data=data,headers=headers,method=method),timeout=60) as r: result=json.loads(r.read().decode())
24
- except Exception as exc: raise FuploadError("Workshop API request failed", endpoint=path, verification_required=method=="POST") from exc
25
- if not isinstance(result,dict) or result.get("status")!="ok": raise FuploadError("Workshop API rejected request", endpoint=path, details={"status":result.get("status") if isinstance(result,dict) else None})
26
- return result
17
+ self._web_session = web_session
18
+ self._web_session_factory = web_session_factory or BlackboxWebSession
19
+
20
+ def close(self):
21
+ if self._web_session is not None:
22
+ self._web_session.close()
23
+ self._web_session = None
24
+
25
+ def __enter__(self):
26
+ return self
27
+
28
+ def __exit__(self, *args):
29
+ self.close()
30
+
31
+ def _request(self, method, path, body=None, query=None, *, base=API_BASE):
32
+ if self._transport:
33
+ if base != API_BASE:
34
+ raise ValidationError("API origin is fixed to Workshop web protocol", path="$.base")
35
+ return self._transport(method, path, body or {}, query or {})
36
+ if base != API_BASE:
37
+ raise ValidationError("API origin is fixed to Workshop web protocol", path="$.base")
38
+ if self._web_session is None:
39
+ self._web_session = self._web_session_factory()
40
+ return self._web_session.request(method, path, body=body, query=query)
27
41
  @staticmethod
28
42
  def _result(payload): return payload.get("result") or {}
29
43
  def execute_read(self, resource: str, action: str, args: Any):
@@ -44,15 +58,18 @@ class Blackbox:
44
58
  rows=self._result(self._request("GET","/wow/open_platform/module/list/")).get("moduleList") or []
45
59
  return {"total_count":len(rows),"plugins":[self._redact(x) for x in rows if isinstance(x,dict)]}
46
60
  def plugin_get(self,module_id:int):
61
+ detail=self._module_detail(module_id)
62
+ return {"module":self._redact(detail),"versions":[self._redact(x) for x in self._version_rows(module_id)]}
63
+ def _module_detail(self,module_id:int):
47
64
  detail=self._result(self._request("GET","/wow/open_platform/module/detail/",query={"moduleId":module_id}))
48
- versions=self._result(self._request("GET","/wow/open_platform/module_version/list/",query={"moduleId":module_id,"offset":0,"limit":100}))
49
- return {"module":self._redact(detail.get("module") or detail),"versions":[self._redact(x) for x in (versions.get("versionList") or [])]}
65
+ module=detail.get("module") or detail
66
+ return module if isinstance(module,dict) else {}
50
67
  def module_edit(self,doc):
51
68
  aliases={"module_id":"id","logo_url":"logoUrl","category_ids":"categoryIds","official_url":"officialUrl","core_folders":"coreFolders"}
52
69
  normalized={aliases.get(k,k):v for k,v in doc.items() if k not in {"schema","dry_run"}}
53
70
  if "id" not in normalized: raise ValidationError("id is required",path="$.id")
54
71
  module_id = int(normalized["id"])
55
- current = self.plugin_get(module_id)["module"]
72
+ current = self._module_detail(module_id)
56
73
  # The web client sends a complete module object; preserve omitted fields.
57
74
  defaults = {"name":"", "logoUrl":"", "id":module_id, "categoryIds":[],
58
75
  "type":1, "desc":"", "official":"", "officialUrl":"", "coreFolders":""}
@@ -61,37 +78,42 @@ class Blackbox:
61
78
  fields["id"] = module_id
62
79
  if isinstance(fields.get("coreFolders"),list): fields["coreFolders"]=",".join(map(str,fields["coreFolders"]))
63
80
  response=self._request("POST","/wow/open_platform/module/update/",body=fields)
64
- actual=self.plugin_get(module_id)["module"]
65
- mismatches=[]
66
- for key,wanted in ((aliases.get(key,key), value) for key,value in normalized.items() if key in aliases or key in fields):
67
- observed=actual.get(key)
68
- if key=="coreFolders" and isinstance(observed,list): wanted=[x for x in str(wanted).split(",") if x]
69
- if observed != wanted: mismatches.append(key)
70
- if mismatches: raise FuploadError("module update readback mismatch",kind="verification_required",verification_required=True,details={"fields":mismatches})
81
+ expected={aliases.get(key,key):value for key,value in normalized.items() if key in aliases or key in fields}
82
+ self._wait_module(module_id,expected)
71
83
  return {"accepted":True,"verified":True,"module_id":module_id,"response":self._redact(response)}
72
84
  def version_upsert(self,doc):
73
85
  aliases={"module_id":"moduleId","version_id":"versionId","game_versions":"gameVersions","file_url":"fileUrl"}
74
86
  normalized={aliases.get(k,k):v for k,v in doc.items() if k not in {"schema","dry_run"}}
75
- if "file" in normalized and "fileUrl" not in normalized:
76
- normalized["fileUrl"]=self.upload_zip(int(normalized["moduleId"]),str(normalized["file"]))["url"]
87
+ upload_result = None
88
+ if "file" in normalized:
89
+ upload_result=self.upload_zip(int(normalized["moduleId"]),str(normalized["file"]))
90
+ normalized["fileUrl"]=upload_result["url"]
77
91
  if "fileUrl" not in normalized and "versionId" in normalized:
78
92
  existing = self._find_version(int(normalized["moduleId"]), int(normalized["versionId"]))
79
- if existing and existing.get("fileUrlHeybox"):
80
- normalized["fileUrl"] = existing["fileUrlHeybox"]
93
+ current_url=self._archive_url(existing or {})
94
+ if current_url: normalized["fileUrl"]=current_url
81
95
  required=("moduleId","name","type","gameVersions","fileUrl")
82
96
  missing=[k for k in required if k not in normalized]
83
97
  if missing: raise ValidationError("missing field(s): %s"%", ".join(missing))
84
98
  games=normalized["gameVersions"] if isinstance(normalized["gameVersions"],list) else [x for x in str(normalized["gameVersions"]).split(",") if x]
85
99
  body={"moduleId":int(normalized["moduleId"]),"name":normalized["name"],"type":int(normalized["type"]),"gameVersions":",".join(map(str,games)),"fileUrl":normalized["fileUrl"]}
86
100
  if "versionId" in normalized: body["versionId"]=int(normalized["versionId"])
101
+ existing_ids={self._id_value(row.get("id")) for row in self._version_rows(body["moduleId"])} if "versionId" not in body else set()
87
102
  response=self._request("POST","/wow/open_platform/module_version/upsert/",body=body)
88
- found=self._wait_version(body["moduleId"],body.get("versionId"),body["name"],
89
- expected={"name":body["name"],"type":body["type"],"gameVersions":list(map(str,games))})
90
- mismatches=[key for key,wanted in {"name":body["name"],"type":body["type"],"gameVersions":list(map(str,games))}.items() if found.get(key)!=wanted]
91
- if not found.get("fileUrlHeybox"):
103
+ response_result=self._result(response)
104
+ response_id=(response_result.get("versionId") or response_result.get("id")) if isinstance(response_result,dict) else None
105
+ target_id=body.get("versionId") or (self._id_value(response_id) if response_id is not None else None)
106
+ expected = {"name":body["name"],"type":body["type"],"gameVersions":list(map(str,games))}
107
+ found=self._wait_version(body["moduleId"],target_id,body["name"],excluded_ids=existing_ids,
108
+ expected=expected,expected_archive=body["fileUrl"])
109
+ mismatches=[key for key,wanted in {"name":body["name"],"type":body["type"],"gameVersions":list(map(str,games))}.items() if self._comparable(key,found.get(key))!=self._comparable(key,wanted)]
110
+ if self._comparable("fileUrl", self._archive_url(found)) != self._comparable("fileUrl", body["fileUrl"]):
92
111
  mismatches.append("fileUrl")
93
112
  if mismatches: raise FuploadError("version upsert readback mismatch",kind="verification_required",verification_required=True,details={"fields":mismatches})
94
- return {"accepted":True,"verified":True,"module_id":body["moduleId"],"version_id":found.get("id"),"readback":self._redact(found),"response":self._redact(response)}
113
+ result={"accepted":True,"verified":True,"module_id":body["moduleId"],"version_id":found.get("id"),"readback":self._redact(found),"response":self._redact(response)}
114
+ if upload_result:
115
+ result["upload"]={key:upload_result[key] for key in ("protocol","bytes","sha256") if key in upload_result}
116
+ return result
95
117
  def version_delete(self,doc):
96
118
  version_id=doc.get("versionId",doc.get("version_id")); module_id=doc.get("moduleId",doc.get("module_id"))
97
119
  if version_id is None or module_id is None: raise ValidationError("versionId and moduleId are required")
@@ -106,33 +128,107 @@ class Blackbox:
106
128
  retry = True
107
129
  response = self._request("POST","/wow/open_platform/module_version/delete/",body={"versionId":int(version_id),"moduleId":int(module_id)})
108
130
  found = self._wait_version(int(module_id),int(version_id),None,deleted=True)
131
+ time.sleep(settle)
132
+ current = self._find_version(int(module_id), int(version_id))
133
+ if current is not None and current.get("auditState") != 4:
134
+ raise FuploadError("version delete did not remain stable",kind="verification_required",verification_required=True)
109
135
  return {"accepted":True,"verified":True,"module_id":int(module_id),"version_id":int(version_id),"audit_state":found.get("auditState") if found else None,"retry":retry,"response":self._redact(response)}
110
136
  def _version_rows(self,module_id):
111
- return self._result(self._request("GET","/wow/open_platform/module_version/list/",query={"moduleId":module_id,"offset":0,"limit":100})).get("versionList") or []
137
+ limit = int(self.config.get("version_page_size", 100))
138
+ max_pages = int(self.config.get("version_max_pages", 100))
139
+ rows = []
140
+ page_fingerprints = set()
141
+ for page_index in range(max_pages):
142
+ offset = page_index * limit
143
+ result = self._result(self._request(
144
+ "GET", "/wow/open_platform/module_version/list/",
145
+ query={"moduleId":module_id,"offset":offset,"limit":limit},
146
+ ))
147
+ page = result.get("versionList") or []
148
+ if not isinstance(page, list):
149
+ raise FuploadError("version list response is invalid", kind="verification_required", verification_required=True)
150
+ fingerprint = tuple(self._id_value(item.get("id")) for item in page if isinstance(item, Mapping))
151
+ if page and fingerprint in page_fingerprints:
152
+ raise FuploadError("version list pagination did not advance", kind="verification_required", verification_required=True)
153
+ page_fingerprints.add(fingerprint)
154
+ rows.extend(item for item in page if isinstance(item, dict))
155
+ total = next((result.get(key) for key in ("totalCount", "total_count", "total", "count") if isinstance(result.get(key), int)), None)
156
+ if not page or len(page) < limit or (total is not None and len(rows) >= total):
157
+ return rows
158
+ raise FuploadError("version list pagination exceeded the supported limit", kind="verification_required", verification_required=True)
112
159
 
113
160
  def _find_version(self, module_id, version_id):
114
- return next((x for x in self._version_rows(module_id) if x.get("id") == version_id), None)
115
- def _wait_version(self,module_id,version_id,name,deleted=False,expected=None):
116
- for attempt in range(int(self.config.get("verify_attempts",20))):
161
+ wanted=self._id_value(version_id)
162
+ return next((x for x in self._version_rows(module_id) if self._id_value(x.get("id"))==wanted),None)
163
+ def _wait_module(self,module_id,expected):
164
+ attempts=int(self.config.get("verify_attempts",30))
165
+ mismatches=list(expected)
166
+ for attempt in range(attempts):
167
+ actual=self._module_detail(module_id)
168
+ mismatches=[key for key,wanted in expected.items() if self._comparable(key,actual.get(key))!=self._comparable(key,wanted)]
169
+ if not mismatches: return actual
170
+ if attempt+1<attempts: time.sleep(float(self.config.get("verify_interval",2)))
171
+ raise FuploadError("module update readback mismatch",kind="verification_required",verification_required=True,details={"fields":mismatches})
172
+ def _wait_version(self,module_id,version_id,name,deleted=False,expected=None,excluded_ids=None,expected_archive=None):
173
+ excluded_ids=excluded_ids or set()
174
+ for attempt in range(int(self.config.get("verify_attempts",30))):
117
175
  rows=self._version_rows(module_id)
118
- found=next((x for x in rows if (version_id is not None and x.get("id")==version_id) or (version_id is None and x.get("name")==name)),None)
176
+ if version_id is not None:
177
+ candidates=[x for x in rows if self._id_value(x.get("id"))==self._id_value(version_id)]
178
+ else:
179
+ candidates=[x for x in rows if x.get("name")==name and self._id_value(x.get("id")) not in excluded_ids]
180
+ if len(candidates)>1: raise FuploadError("version write readback is ambiguous",kind="verification_required",verification_required=True)
181
+ found=candidates[0] if candidates else None
119
182
  matches_expected = found and all(
120
- (list(map(str, found.get(key) or [])) if key == "gameVersions" else found.get(key)) == wanted
183
+ self._comparable(key,found.get(key))==self._comparable(key,wanted)
121
184
  for key, wanted in (expected or {}).items()
122
185
  )
186
+ if matches_expected and expected_archive is not None:
187
+ matches_expected = self._comparable("fileUrl", self._archive_url(found)) == self._comparable("fileUrl", expected_archive)
123
188
  if (deleted and (found is None or found.get("auditState")==4)) or (not deleted and found and matches_expected): return found or {}
124
- if attempt+1<int(self.config.get("verify_attempts",20)): time.sleep(float(self.config.get("verify_interval",2)))
189
+ if attempt+1<int(self.config.get("verify_attempts",30)): time.sleep(float(self.config.get("verify_interval",2)))
125
190
  raise FuploadError("version write was not confirmed by readback",kind="verification_required",verification_required=True)
126
191
  @staticmethod
127
192
  def _redact(value):
128
- if isinstance(value,dict): return {k:("<redacted>" if any(s in k.lower() for s in ("token","secret","cookie","nonce","hkey")) else Blackbox._redact(v)) for k,v in value.items()}
129
- if isinstance(value,list): return [Blackbox._redact(v) for v in value]
193
+ if isinstance(value,dict):
194
+ markers=("token","secret","cookie","nonce","hkey","pkey","credential","signature","authorization","authentication","device_id","signed_url","upload_url","presigned")
195
+ return {k:("<redacted>" if any(s in str(k).lower() for s in markers) else Blackbox._redact(v)) for k,v in value.items()}
196
+ if isinstance(value,(list,tuple)): return [Blackbox._redact(v) for v in value]
197
+ if isinstance(value,str):
198
+ parsed=urlsplit(value)
199
+ if parsed.scheme in {"http","https"} and parsed.netloc and parsed.query:
200
+ return urlunsplit((parsed.scheme,parsed.netloc,parsed.path,"<redacted>",parsed.fragment))
201
+ return value
202
+
203
+ @staticmethod
204
+ def _id_value(value):
205
+ try: return int(value)
206
+ except (TypeError,ValueError): return value
207
+ @staticmethod
208
+ def _archive_url(row): return row.get("fileUrlHeybox") or row.get("fileUrl") or row.get("file_url")
209
+ @staticmethod
210
+ def _comparable(key,value):
211
+ if key in {"categoryIds","gameVersions","coreFolders"}:
212
+ if isinstance(value,str): items=[item for item in value.split(",") if item]
213
+ elif isinstance(value,(list,tuple,set)): items=list(value)
214
+ else: items=[] if value is None else [value]
215
+ normalized=[]
216
+ for item in items:
217
+ if isinstance(item,Mapping): item=item.get("id",item.get("value"))
218
+ if item not in (None, ""):
219
+ normalized.append(str(item))
220
+ return tuple(sorted(normalized))
221
+ if key in {"id","type"}: return Blackbox._id_value(value)
130
222
  return value
131
223
 
132
224
  def upload_zip(self, module_id: int, file_path: str, *, dry_run=False):
133
225
  path=Path(file_path)
134
226
  if not path.is_file(): raise ValidationError("file does not exist",path="$.file")
227
+ if not zipfile.is_zipfile(path): raise ValidationError("file must be a valid ZIP archive",path="$.file")
135
228
  if dry_run: return {"dry_run":True,"bytes":path.stat().st_size,"sha256":hashlib.sha256(path.read_bytes()).hexdigest()}
229
+ return self._upload_zip_legacy(path)
230
+
231
+ def _upload_zip_legacy(self, path: Path):
136
232
  size_mb=path.stat().st_size/1024/1024
137
233
  token=self._result(self._request("POST","/wow/cos/upload/token/",body={"jsonData":json.dumps({"upload_infos":[{"filename":path.name,"file_size":size_mb,"type":"module"}]},separators=(",",":"))}))
138
234
  info=token["info"]; f=token["files"][0]; creds=info["Credentials"].get("Credentials",info["Credentials"])
@@ -148,4 +244,4 @@ class Blackbox:
148
244
  cos=CosS3Client(CosConfig(Region=info.get("region") or "ap-shanghai",SecretId=creds["TmpSecretID"],SecretKey=creds["TmpSecretKey"],Token=creds["Token"],Scheme="https"))
149
245
  with path.open("rb") as stream: cos.put_object(Bucket=info["bucket"],Key=f["key"],Body=stream)
150
246
  except Exception as exc: raise FuploadError("COS upload failed",verification_required=True) from exc
151
- return {"url":"https://%s/%s"%(info["host"],f["key"]),"bytes":path.stat().st_size,"sha256":hashlib.sha256(path.read_bytes()).hexdigest()}
247
+ return {"url":"https://%s/%s"%(info["host"],f["key"]),"bytes":path.stat().st_size,"sha256":hashlib.sha256(path.read_bytes()).hexdigest(),"protocol":"legacy"}
@@ -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
@@ -82,45 +82,68 @@ function readMarker(root) {
82
82
  }
83
83
  }
84
84
 
85
- function probeRuntime(executable, run = spawnSync) {
85
+ function runtimeEnvironment(root, env) {
86
+ return {
87
+ ...env,
88
+ PLAYWRIGHT_BROWSERS_PATH: path.join(root, "browsers"),
89
+ };
90
+ }
91
+
92
+ function probeRuntime(executable, root, env, run = spawnSync) {
86
93
  if (!fs.existsSync(executable)) {
87
94
  return null;
88
95
  }
89
96
  const result = run(executable, [
90
97
  "-c",
91
- "import importlib.metadata,sys; import qcloud_cos; print('.'.join(map(str,sys.version_info[:3]))); print(importlib.metadata.version('cos-python-sdk-v5'))",
92
- ], { encoding: "utf8", shell: false, windowsHide: true });
98
+ "import importlib.metadata,json,pathlib,sys; import qcloud_cos; from playwright.sync_api import sync_playwright; p=sync_playwright().start(); executable=p.chromium.executable_path; p.stop(); print(json.dumps({'python_version': '.'.join(map(str,sys.version_info[:3])), 'cos_version': importlib.metadata.version('cos-python-sdk-v5'), 'playwright_version': importlib.metadata.version('playwright'), 'chromium_executable': executable})); sys.exit(0 if pathlib.Path(executable).is_file() else 1)",
99
+ ], {
100
+ encoding: "utf8",
101
+ env: runtimeEnvironment(root, env),
102
+ shell: false,
103
+ windowsHide: true,
104
+ });
93
105
  if (result.status !== 0) {
94
106
  return null;
95
107
  }
96
- const [pythonVersion, dependencyVersion] = result.stdout.trim().split(/\r?\n/);
97
- const match = pythonVersion?.match(/^(\d+)\.(\d+)\.(\d+)$/);
98
- if (!match || !dependencyVersion) {
108
+ let details;
109
+ try {
110
+ details = JSON.parse(result.stdout.trim());
111
+ } catch {
99
112
  return null;
100
113
  }
101
- return { version: match.slice(1).map(Number), dependencyVersion };
114
+ const match = details.python_version?.match(/^(\d+)\.(\d+)\.(\d+)$/);
115
+ if (!match || !details.cos_version || !details.playwright_version || !details.chromium_executable) {
116
+ return null;
117
+ }
118
+ return {
119
+ version: match.slice(1).map(Number),
120
+ dependencyVersion: details.cos_version,
121
+ playwrightVersion: details.playwright_version,
122
+ chromiumExecutable: details.chromium_executable,
123
+ };
102
124
  }
103
125
 
104
- function inspectRuntime({ root, platform, requirementsHash, run }) {
126
+ function inspectRuntime({ root, platform, requirementsHash, env, run }) {
105
127
  const marker = readMarker(root);
106
128
  if (marker?.schema !== PYTHON_RUNTIME_SCHEMA || marker.requirements_sha256 !== requirementsHash) {
107
129
  return null;
108
130
  }
109
131
  const command = pythonRuntimeExecutable(root, platform);
110
- const probe = probeRuntime(command, run);
132
+ const probe = probeRuntime(command, root, env, run);
111
133
  if (!probe || probe.version[0] !== 3 || probe.version[1] < 9) {
112
134
  return null;
113
135
  }
114
- return { command, args: [], version: probe.version, dependencyVersion: probe.dependencyVersion };
136
+ return { command, args: [], ...probe };
115
137
  }
116
138
 
117
139
  function bounded(value) {
118
140
  return String(value || "").trim().slice(0, 4000);
119
141
  }
120
142
 
121
- function runChecked(run, command, args, message) {
143
+ function runChecked(run, command, args, message, options = {}) {
122
144
  const result = run(command, args, {
123
145
  encoding: "utf8",
146
+ ...options,
124
147
  shell: false,
125
148
  windowsHide: true,
126
149
  });
@@ -191,7 +214,7 @@ export function ensurePythonRuntime({
191
214
  const parent = path.dirname(root);
192
215
  const lock = acquireRuntimeLock(root);
193
216
  try {
194
- const current = inspectRuntime({ root, platform, requirementsHash, run });
217
+ const current = inspectRuntime({ root, platform, requirementsHash, env, run });
195
218
  if (current) {
196
219
  return { status: "current", root, requirements, python: current };
197
220
  }
@@ -205,13 +228,19 @@ export function ensurePythonRuntime({
205
228
  const backup = path.join(parent, `.python-backup-${nonce}`);
206
229
  fs.mkdirSync(parent, { recursive: true });
207
230
  let movedOld = false;
231
+ let created;
208
232
  try {
209
233
  runChecked(run, base.command, [...base.args, "-m", "venv", staging], "Could not create the Fuploader Python runtime");
210
234
  const stagingPython = pythonRuntimeExecutable(staging, platform);
211
235
  runChecked(run, stagingPython, [
212
236
  "-m", "pip", "install", "--disable-pip-version-check", "--no-input", "--requirement", requirements,
213
237
  ], "Could not install Fuploader Python dependencies");
214
- const installed = probeRuntime(stagingPython, run);
238
+ runChecked(run, stagingPython, [
239
+ "-m", "playwright", "install", "chromium",
240
+ ], "Could not install Fuploader Chromium", {
241
+ env: runtimeEnvironment(staging, env),
242
+ });
243
+ const installed = probeRuntime(stagingPython, staging, env, run);
215
244
  if (!installed) {
216
245
  throw new Error("The Fuploader Python runtime did not pass its dependency probe.");
217
246
  }
@@ -220,6 +249,8 @@ export function ensurePythonRuntime({
220
249
  requirements_sha256: requirementsHash,
221
250
  python_version: installed.version.join("."),
222
251
  dependency_version: installed.dependencyVersion,
252
+ playwright_version: installed.playwrightVersion,
253
+ chromium_executable: path.relative(staging, installed.chromiumExecutable),
223
254
  });
224
255
  if (fs.existsSync(root)) {
225
256
  fs.renameSync(root, backup);
@@ -234,16 +265,21 @@ export function ensurePythonRuntime({
234
265
  }
235
266
  throw error;
236
267
  }
268
+ created = inspectRuntime({ root, platform, requirementsHash, env, run });
269
+ if (!created) {
270
+ fs.rmSync(root, { recursive: true, force: true });
271
+ if (movedOld) {
272
+ fs.renameSync(backup, root);
273
+ movedOld = false;
274
+ }
275
+ throw new Error("The installed Fuploader Python runtime failed final validation.");
276
+ }
237
277
  if (movedOld) {
238
278
  fs.rmSync(backup, { recursive: true, force: true });
239
279
  }
240
280
  } finally {
241
281
  fs.rmSync(staging, { recursive: true, force: true });
242
282
  }
243
- const created = inspectRuntime({ root, platform, requirementsHash, run });
244
- if (!created) {
245
- throw new Error("The installed Fuploader Python runtime failed final validation.");
246
- }
247
283
  return { status: "installed", root, requirements, python: created };
248
284
  } finally {
249
285
  lock.release();
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "schema": "fupload.npm-skill-manifest.v1",
3
3
  "package_name": "@follenfang/fupload",
4
- "package_version": "0.0.5",
5
- "skill_version": "0.0.5",
6
- "tree_sha256": "0a6d0f91197fbaaff3266c7b78f6f894eb8eac38bdf899581909344393d79afd",
4
+ "package_version": "0.0.7",
5
+ "skill_version": "0.0.7",
6
+ "tree_sha256": "f2693bc26ecd3f9bab3ed691a2f372c5702b6ac5f72f52880fc6cee40ca90314",
7
7
  "files": [
8
8
  {
9
9
  "path": "agents/openai.yaml",
@@ -77,8 +77,8 @@
77
77
  },
78
78
  {
79
79
  "path": "references/blackbox.md",
80
- "bytes": 2357,
81
- "sha256": "022563a7611a5877761df71074ed347ef51fd27f735da7926cbb96d3b781dcc6"
80
+ "bytes": 2966,
81
+ "sha256": "ea95e8826af8e54c82343c404f9c97a611ab8ec3fb0282b561907c50169e4307"
82
82
  },
83
83
  {
84
84
  "path": "references/curseforge.md",
@@ -108,22 +108,22 @@
108
108
  {
109
109
  "path": "scripts/fupload_cli/__init__.py",
110
110
  "bytes": 49,
111
- "sha256": "219d361d9b7f13dc9ff7f9eab399b0696fd3de9adcd6ecae755f5724262f12e6"
111
+ "sha256": "b44c52f9e6b2989921c93580a400ddcd3dd3e5c1cc787e9984baf0dabdfd93e7"
112
112
  },
113
113
  {
114
- "path": "scripts/fupload_cli/blackbox_auth.py",
115
- "bytes": 2874,
116
- "sha256": "e018a33dce4babd1dc739af68c58499412474c7e1f1e3c0640dc53dbe46fce29"
114
+ "path": "scripts/fupload_cli/blackbox_web.py",
115
+ "bytes": 19392,
116
+ "sha256": "4a849fb297f045b81d261f4456542c165a3c0d9a77f963dbfcbc1c9dad8fec65"
117
117
  },
118
118
  {
119
119
  "path": "scripts/fupload_cli/blackbox.py",
120
- "bytes": 12499,
121
- "sha256": "5b46c38f8c5f8a352f261f2c3751b51f16827f62410d2124e0147073e8308350"
120
+ "bytes": 17089,
121
+ "sha256": "4487141898ba6a080d2120c1df9ad83c290528e9b462acb22d6c0f89dbbd117c"
122
122
  },
123
123
  {
124
124
  "path": "scripts/fupload_cli/cli.py",
125
- "bytes": 25664,
126
- "sha256": "d1327783aa0e4e27fcc1f71a80cd090f661feaf2e226688b2efa6a36b9645073"
125
+ "bytes": 25982,
126
+ "sha256": "79c5de939e3a7e7d0ed49d79fcce6c22e78c1a5abcf8eae5422dbd78f7c7bf2d"
127
127
  },
128
128
  {
129
129
  "path": "scripts/fupload_cli/curseforge.py",
@@ -167,8 +167,8 @@
167
167
  },
168
168
  {
169
169
  "path": "scripts/fupload_cli/schema.py",
170
- "bytes": 36268,
171
- "sha256": "db788d2422c0f6b50ce2795a3df75d5bf1e23934ba4211a64198de06931492a5"
170
+ "bytes": 37399,
171
+ "sha256": "5538eaf099bf9e7cae5f8142faecf827d41da698ae574b5597d17756da23e331"
172
172
  },
173
173
  {
174
174
  "path": "scripts/fupload_cli/transport.py",
@@ -187,8 +187,8 @@
187
187
  },
188
188
  {
189
189
  "path": "SKILL.md",
190
- "bytes": 22890,
191
- "sha256": "937bbee45d18e8ef257a8071f63951aede681628bb951e40793de81d763f85f3"
190
+ "bytes": 23242,
191
+ "sha256": "941f2c5f615dded9fcfe086516a9bf903f54db52351815c38d26c2691f71793b"
192
192
  }
193
193
  ]
194
194
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@follenfang/fupload",
3
- "version": "0.0.5",
3
+ "version": "0.0.7",
4
4
  "description": "Install and run the Fuploader Agent Skill and Python CLI.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,54 +0,0 @@
1
- """Authentication helpers for the Heybox desktop Workshop client."""
2
- from __future__ import annotations
3
- import hashlib, json, secrets, sqlite3, time
4
- from pathlib import Path
5
- from urllib.parse import parse_qs, urlparse
6
- from .errors import FuploadError
7
-
8
- API_BASE = "https://workshopapi.xiaoheihe.cn"
9
- _ALPHABET = "AB45STUVWZEFGJ6CH01D237IXYPQRKLMN89"
10
-
11
- def _n8(value: str, limit: int) -> str:
12
- table = _ALPHABET[:limit]
13
- return "".join(table[ord(ch) % len(table)] for ch in value)
14
- def _i8(value: str) -> str:
15
- return "".join(_ALPHABET[ord(ch) % len(_ALPHABET)] for ch in value)
16
- def _interleave(parts):
17
- return "".join(part[i] for i in range(max(map(len, parts))) for part in parts if i < len(part))
18
- def _mix(values):
19
- def p(x): return ((x << 1) ^ 27) & 255 if x & 128 else (x << 1) & 255
20
- def hm(x): return p(x) ^ x
21
- def qg(x): return hm(p(x))
22
- def dx(x): return qg(hm(p(x)))
23
- def mw(x): return dx(x) ^ qg(x) ^ hm(x)
24
- a,b,c,d,*rest=values
25
- return [mw(a)^dx(b)^qg(c)^hm(d), hm(a)^mw(b)^dx(c)^qg(d), qg(a)^hm(b)^mw(c)^dx(d), dx(a)^qg(b)^hm(c)^mw(d), *rest]
26
- def hkey(path: str, timestamp: int, nonce: str) -> str:
27
- normalized = "/" + "/".join(x for x in path.split("/") if x) + "/"
28
- digest = hashlib.md5(_interleave([_n8(str(timestamp), -2), _i8(normalized), _i8(nonce)]).encode()).hexdigest()
29
- return _n8(digest[:5], -4) + "%02d" % (sum(_mix([ord(x) for x in digest[-6:]])) % 100)
30
-
31
- def load_session(profile: Path | None = None):
32
- profile = profile or Path.home() / "AppData/Roaming/heybox-pc-launcher"
33
- db = profile / "Network/Cookies"
34
- try:
35
- with sqlite3.connect("file:%s?mode=ro" % db, uri=True) as con:
36
- rows = con.execute("select name,value from cookies where host_key like '%xiaoheihe.cn'").fetchall()
37
- except (OSError, sqlite3.Error) as exc:
38
- raise FuploadError("Heybox desktop Cookie DB is missing", kind="authentication_error") from exc
39
- cookies = {str(k): str(v) for k,v in rows if k in {"user_heybox_id","user_pkey","x_xhh_tokenid"} and v}
40
- if not {"user_heybox_id","user_pkey","x_xhh_tokenid"} <= set(cookies):
41
- raise FuploadError("Heybox desktop login state is incomplete", kind="authentication_error")
42
- identity = {}
43
- scope = profile / "sentry/scope_v3.json"
44
- try:
45
- crumbs = json.loads(scope.read_text(encoding="utf-8")).get("scope",{}).get("breadcrumbs",[])
46
- for crumb in reversed(crumbs):
47
- raw = crumb.get("data",{}).get("url","")
48
- if "x_app=heybox_pc" in raw:
49
- query = parse_qs(urlparse(raw).query)
50
- allowed = {"x_client_type","x_os_type","x_app","version","exe_version","os_version","device_id","channel","heybox_id"}
51
- identity = {k:v[-1] for k,v in query.items() if k in allowed and v}; break
52
- except (OSError, ValueError, TypeError):
53
- pass
54
- return cookies, identity