@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 +4 -4
- package/fupload/references/blackbox.md +3 -1
- package/fupload/scripts/fupload_cli/__init__.py +1 -1
- package/fupload/scripts/fupload_cli/blackbox.py +142 -46
- package/fupload/scripts/fupload_cli/blackbox_web.py +522 -0
- package/fupload/scripts/fupload_cli/cli.py +19 -9
- package/fupload/scripts/fupload_cli/schema.py +14 -3
- package/npm/lib/python-requirements.txt +1 -0
- package/npm/lib/python.mjs +53 -17
- package/npm/skill-manifest.json +17 -17
- package/package.json +1 -1
- package/fupload/scripts/fupload_cli/blackbox_auth.py +0 -54
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
|
|
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
|
+
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
|
|
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
|
|
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
|
|
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,29 +1,43 @@
|
|
|
1
1
|
"""Heybox Workshop plugin provider."""
|
|
2
2
|
from __future__ import annotations
|
|
3
|
-
import hashlib, json,
|
|
3
|
+
import hashlib, json, time, zipfile
|
|
4
4
|
from pathlib import Path
|
|
5
|
-
from urllib.parse import
|
|
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 .
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
49
|
-
return
|
|
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.
|
|
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
|
-
|
|
65
|
-
|
|
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
|
-
|
|
76
|
-
|
|
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
|
-
|
|
80
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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
|
-
(
|
|
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",
|
|
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):
|
|
129
|
-
|
|
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="
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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"),
|
package/npm/lib/python.mjs
CHANGED
|
@@ -82,45 +82,68 @@ function readMarker(root) {
|
|
|
82
82
|
}
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
function
|
|
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]))
|
|
92
|
-
], {
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
108
|
+
let details;
|
|
109
|
+
try {
|
|
110
|
+
details = JSON.parse(result.stdout.trim());
|
|
111
|
+
} catch {
|
|
99
112
|
return null;
|
|
100
113
|
}
|
|
101
|
-
|
|
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: [],
|
|
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
|
-
|
|
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();
|
package/npm/skill-manifest.json
CHANGED
|
@@ -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
|
-
"skill_version": "0.0.
|
|
6
|
-
"tree_sha256": "
|
|
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":
|
|
81
|
-
"sha256": "
|
|
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": "
|
|
111
|
+
"sha256": "b44c52f9e6b2989921c93580a400ddcd3dd3e5c1cc787e9984baf0dabdfd93e7"
|
|
112
112
|
},
|
|
113
113
|
{
|
|
114
|
-
"path": "scripts/fupload_cli/
|
|
115
|
-
"bytes":
|
|
116
|
-
"sha256": "
|
|
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":
|
|
121
|
-
"sha256": "
|
|
120
|
+
"bytes": 17089,
|
|
121
|
+
"sha256": "4487141898ba6a080d2120c1df9ad83c290528e9b462acb22d6c0f89dbbd117c"
|
|
122
122
|
},
|
|
123
123
|
{
|
|
124
124
|
"path": "scripts/fupload_cli/cli.py",
|
|
125
|
-
"bytes":
|
|
126
|
-
"sha256": "
|
|
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":
|
|
171
|
-
"sha256": "
|
|
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":
|
|
191
|
-
"sha256": "
|
|
190
|
+
"bytes": 23242,
|
|
191
|
+
"sha256": "941f2c5f615dded9fcfe086516a9bf903f54db52351815c38d26c2691f71793b"
|
|
192
192
|
}
|
|
193
193
|
]
|
|
194
194
|
}
|
package/package.json
CHANGED
|
@@ -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
|