Compare commits
2
Commits
5b85408f17
..
v1.0.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b5038818f | ||
|
|
967e93af98 |
No files matched your search
@@ -11,8 +11,9 @@ android {
|
||||
applicationId "com.maipulse.app"
|
||||
minSdk 24
|
||||
targetSdk 34
|
||||
versionCode 1
|
||||
versionName "1.0"
|
||||
// 和网页里的 APP_VERSION 对齐,升级时一起改
|
||||
versionCode 102
|
||||
versionName "1.0.2"
|
||||
|
||||
ndk {
|
||||
abiFilters "arm64-v8a"
|
||||
@@ -20,7 +21,7 @@ android {
|
||||
|
||||
// Chaquopy 16 的 DSL 挂在 android.defaultConfig 下面
|
||||
python {
|
||||
buildPython "C:/Users/20558/AppData/Local/Programs/Python/Python38/python.exe"
|
||||
buildPython "python"
|
||||
pip {
|
||||
install "flask"
|
||||
install "requests"
|
||||
@@ -33,6 +34,12 @@ android {
|
||||
// 而 zipalign 之后我们要用 apksigner 重签。两边不是同一个 key 的话,
|
||||
// 出来的包签名不一样,用户必须卸载才能覆盖安装。
|
||||
signingConfigs {
|
||||
release {
|
||||
storeFile file("../maipulse-debug.keystore")
|
||||
storePassword "android"
|
||||
keyAlias "androiddebugkey"
|
||||
keyPassword "android"
|
||||
}
|
||||
debug {
|
||||
storeFile file("../maipulse-debug.keystore")
|
||||
storePassword "android"
|
||||
@@ -46,7 +53,13 @@ android {
|
||||
signingConfig signingConfigs.debug
|
||||
}
|
||||
release {
|
||||
minifyEnabled false
|
||||
// 出正式包:关调试标志,开 R8 去死代码 + 混淆 Java 侧
|
||||
signingConfig signingConfigs.release
|
||||
debuggable false
|
||||
minifyEnabled true
|
||||
shrinkResources true
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'),
|
||||
'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,8 +69,23 @@ android {
|
||||
}
|
||||
|
||||
androidResources {
|
||||
// 这几类是已经压过的/要被 mmap 的,再压反而慢
|
||||
noCompress 'json', 'png'
|
||||
}
|
||||
|
||||
lint {
|
||||
// release 构建默认跑 lintVital,它会联网拉 lint-gradle 依赖。
|
||||
// 这条链断了整包就出不来,而 lint 结果对发版没有决定意义,直接关掉。
|
||||
checkReleaseBuilds false
|
||||
abortOnError false
|
||||
}
|
||||
|
||||
packagingOptions {
|
||||
jniLibs {
|
||||
// 原生库直接从 APK 里 mmap,不再解压一份到 /data
|
||||
useLegacyPackaging = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
@@ -5,19 +5,23 @@
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:allowBackup="false"
|
||||
android:fullBackupContent="false"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:label="maipulse"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:hardwareAccelerated="true"
|
||||
android:largeHeap="true"
|
||||
android:theme="@style/AppTheme">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:configChanges="orientation|screenSize|keyboardHidden"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:launchMode="singleTask">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
|
||||
@@ -7,24 +7,32 @@ import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.IntentFilter;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.content.res.AssetManager;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.Typeface;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.os.Environment;
|
||||
import android.provider.Settings;
|
||||
import android.util.Log;
|
||||
import android.util.TypedValue;
|
||||
import android.view.Gravity;
|
||||
import android.view.KeyEvent;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.Window;
|
||||
import android.view.WindowManager;
|
||||
import android.webkit.JavascriptInterface;
|
||||
import android.webkit.ValueCallback;
|
||||
import android.webkit.WebChromeClient;
|
||||
import android.webkit.WebResourceRequest;
|
||||
import android.webkit.WebSettings;
|
||||
import android.webkit.WebView;
|
||||
import android.webkit.WebChromeClient;
|
||||
import android.webkit.WebViewClient;
|
||||
import android.widget.FrameLayout;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
@@ -45,54 +53,144 @@ import java.net.Socket;
|
||||
/**
|
||||
* maipulse 的 Android 外壳。
|
||||
*
|
||||
* 流程:复制 assets/app -> filesDir/app(assets 只读,Flask 要写 logs 与缓存),
|
||||
* 起 Chaquopy 跑 Flask 绑 127.0.0.1:8787,端口通了再把 WebView 指过去。
|
||||
* 启动顺序:
|
||||
* 1. 先画原生启动页(图标 + 名字 + 状态文字),保证点开立刻有东西
|
||||
* 2. 后台线程把 assets/app 复制到 filesDir/app(assets 只读,Flask 要写 logs)
|
||||
* 3. 回到主线程装载 Chaquopy(Python.start 有主线程要求)
|
||||
* 4. 后台线程起 Flask,另一个线程轮询 127.0.0.1:8787
|
||||
* 5. 端口通了再加载 WebView,等 onPageFinished 把启动页淡出、网页淡入
|
||||
*
|
||||
* 额外给了网页一个 Maipulse.installApk(url):走系统 DownloadManager 下 APK,
|
||||
* 下完弹安装器,这样更新不用让用户自己去找文件。
|
||||
* 用户导入的机台配置(astral/config.py 和 astral/api/MaimaiNet.py)在资源重建时会被保留。
|
||||
*/
|
||||
public class MainActivity extends Activity {
|
||||
|
||||
private static final String TAG = "maipulse";
|
||||
private static final int PORT = 8787;
|
||||
private static final String HOME = "http://127.0.0.1:" + PORT + "/";
|
||||
private static final String BG = "#EEF2F9";
|
||||
|
||||
/* 启动页 */
|
||||
private View splash;
|
||||
private TextView splashTip;
|
||||
private TextView splashRetry;
|
||||
|
||||
private volatile WebView web;
|
||||
private volatile boolean firstPageDone = false;
|
||||
private volatile boolean loadRequested = false;
|
||||
|
||||
private WebView web;
|
||||
private TextView tip;
|
||||
private static final int REQ_PICK_FILE = 1001;
|
||||
private ValueCallback<Uri[]> filePathCallback;
|
||||
private long pendingDownloadId = -1;
|
||||
private BroadcastReceiver downloadReceiver;
|
||||
private long lastBackPress = 0;
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
setupSystemBars();
|
||||
buildUi();
|
||||
|
||||
// 关掉 WebView 远程调试(debug 包里默认是可调试的,能被 chrome://inspect 接管)
|
||||
try {
|
||||
WebView.setWebContentsDebuggingEnabled(false);
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
|
||||
registerDownloadReceiver();
|
||||
|
||||
// web.post 保证首帧先画出来,再进到会阻塞的主线程初始化
|
||||
web.post(this::prepareAndBoot);
|
||||
}
|
||||
|
||||
/* ---------------- 系统栏 ---------------- */
|
||||
|
||||
private void setupSystemBars() {
|
||||
Window w = getWindow();
|
||||
w.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS);
|
||||
w.setStatusBarColor(Color.parseColor(BG));
|
||||
w.setNavigationBarColor(Color.WHITE);
|
||||
|
||||
int flags = View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR;
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
flags |= View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR;
|
||||
}
|
||||
w.getDecorView().setSystemUiVisibility(flags);
|
||||
}
|
||||
|
||||
/* ---------------- 界面 ---------------- */
|
||||
|
||||
private int dp(float v) {
|
||||
return Math.round(TypedValue.applyDimension(
|
||||
TypedValue.COMPLEX_UNIT_DIP, v, getResources().getDisplayMetrics()));
|
||||
}
|
||||
|
||||
private void buildUi() {
|
||||
FrameLayout root = new FrameLayout(this);
|
||||
root.setBackgroundColor(Color.parseColor("#EEF2F9"));
|
||||
root.setBackgroundColor(Color.parseColor(BG));
|
||||
|
||||
web = new WebView(this);
|
||||
web.setBackgroundColor(Color.parseColor("#EEF2F9"));
|
||||
web.setBackgroundColor(Color.parseColor(BG));
|
||||
web.setAlpha(0f);
|
||||
web.setOverScrollMode(View.OVER_SCROLL_NEVER);
|
||||
web.setVerticalScrollBarEnabled(false);
|
||||
web.setHorizontalScrollBarEnabled(false);
|
||||
web.setLongClickable(false);
|
||||
web.setHapticFeedbackEnabled(false);
|
||||
|
||||
WebSettings s = web.getSettings();
|
||||
s.setJavaScriptEnabled(true);
|
||||
s.setDomStorageEnabled(true);
|
||||
s.setAllowFileAccess(true);
|
||||
s.setDatabaseEnabled(true);
|
||||
s.setMediaPlaybackRequiresUserGesture(false);
|
||||
s.setMixedContentMode(WebSettings.MIXED_CONTENT_ALWAYS_ALLOW);
|
||||
s.setCacheMode(WebSettings.LOAD_DEFAULT);
|
||||
// 禁掉双指缩放与双击放大(网页里能放大就是这里没设)
|
||||
// 只通过 http://127.0.0.1 访问本地页面,不需要 file:// 与 content://
|
||||
s.setAllowFileAccess(false);
|
||||
s.setAllowContentAccess(false);
|
||||
s.setJavaScriptCanOpenWindowsAutomatically(false);
|
||||
s.setGeolocationEnabled(false);
|
||||
s.setSavePassword(false);
|
||||
s.setSupportZoom(false);
|
||||
s.setBuiltInZoomControls(false);
|
||||
s.setDisplayZoomControls(false);
|
||||
s.setTextZoom(100);
|
||||
// 长按不再弹出「复制/分享」这类浏览器菜单
|
||||
web.setLongClickable(false);
|
||||
web.setHapticFeedbackEnabled(false);
|
||||
web.setWebViewClient(new WebViewClient());
|
||||
s.setMixedContentMode(WebSettings.MIXED_CONTENT_ALWAYS_ALLOW);
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
s.setSafeBrowsingEnabled(false);
|
||||
}
|
||||
|
||||
web.setWebViewClient(new WebViewClient() {
|
||||
@Override
|
||||
public boolean shouldOverrideUrlLoading(WebView v, WebResourceRequest req) {
|
||||
Uri u = req.getUrl();
|
||||
String host = u.getHost();
|
||||
if (host == null) return true;
|
||||
// 本地服务内部跳转放行,其余一律甩给系统浏览器——不然点外链就变成浏览器了
|
||||
if ("127.0.0.1".equals(host) || "localhost".equals(host)) return false;
|
||||
try {
|
||||
startActivity(new Intent(Intent.ACTION_VIEW, u));
|
||||
} catch (Exception e) {
|
||||
Toast.makeText(MainActivity.this, "没有能打开该链接的应用", Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPageFinished(WebView v, String url) {
|
||||
if (firstPageDone) return;
|
||||
firstPageDone = true;
|
||||
showApp();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onReceivedError(WebView v, WebResourceRequest req, android.webkit.WebResourceError err) {
|
||||
if (req.isForMainFrame()) {
|
||||
Log.e(TAG, "page error: " + err.getDescription());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 网页里的 <input type=file> 走 WebChromeClient.onShowFileChooser,挂在
|
||||
// WebViewClient 上编译不过,必须单独设一个 chrome client。
|
||||
web.setWebChromeClient(new WebChromeClient() {
|
||||
@Override
|
||||
public boolean onShowFileChooser(WebView view, ValueCallback<Uri[]> callback,
|
||||
@@ -112,19 +210,85 @@ public class MainActivity extends Activity {
|
||||
});
|
||||
web.addJavascriptInterface(new Bridge(), "Maipulse");
|
||||
|
||||
tip = new TextView(this);
|
||||
tip.setText("正在启动服务…");
|
||||
tip.setTextColor(Color.parseColor("#5A6A82"));
|
||||
tip.setTextSize(15);
|
||||
tip.setPadding(48, 48, 48, 48);
|
||||
|
||||
root.addView(web, new FrameLayout.LayoutParams(-1, -1));
|
||||
root.addView(tip, new FrameLayout.LayoutParams(-1, -1));
|
||||
splash = buildSplash();
|
||||
root.addView(splash, new FrameLayout.LayoutParams(-1, -1));
|
||||
setContentView(root);
|
||||
}
|
||||
|
||||
registerDownloadReceiver();
|
||||
new Thread(this::bootPython, "maipulse-boot").start();
|
||||
new Thread(this::waitAndLoad, "maipulse-wait").start();
|
||||
/** 原生启动页:图标 + 名字 + 一句状态 + 重试按钮(启动失败才出现) */
|
||||
private View buildSplash() {
|
||||
LinearLayout box = new LinearLayout(this);
|
||||
box.setOrientation(LinearLayout.VERTICAL);
|
||||
box.setGravity(Gravity.CENTER);
|
||||
box.setBackgroundColor(Color.parseColor(BG));
|
||||
box.setClickable(true); // 吃掉点击,防止启动过程中点穿到下面的 WebView
|
||||
|
||||
ImageView icon = new ImageView(this);
|
||||
try {
|
||||
icon.setImageResource(R.mipmap.ic_launcher);
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
LinearLayout.LayoutParams ip = new LinearLayout.LayoutParams(dp(72), dp(72));
|
||||
ip.bottomMargin = dp(18);
|
||||
box.addView(icon, ip);
|
||||
|
||||
TextView title = new TextView(this);
|
||||
title.setText("maipulse");
|
||||
title.setTextSize(22);
|
||||
title.setTypeface(Typeface.DEFAULT_BOLD);
|
||||
title.setTextColor(Color.parseColor("#1B1C21"));
|
||||
title.setLetterSpacing(0.06f);
|
||||
box.addView(title, new LinearLayout.LayoutParams(-2, -2));
|
||||
|
||||
splashTip = new TextView(this);
|
||||
splashTip.setText("正在启动…");
|
||||
splashTip.setTextSize(13);
|
||||
splashTip.setTextColor(Color.parseColor("#6B7280"));
|
||||
LinearLayout.LayoutParams tp = new LinearLayout.LayoutParams(-2, -2);
|
||||
tp.topMargin = dp(10);
|
||||
box.addView(splashTip, tp);
|
||||
|
||||
splashRetry = new TextView(this);
|
||||
splashRetry.setText("点这里重试");
|
||||
splashRetry.setTextSize(14);
|
||||
splashRetry.setTextColor(Color.parseColor("#D6336C"));
|
||||
splashRetry.setPadding(dp(18), dp(10), dp(18), dp(10));
|
||||
splashRetry.setVisibility(View.GONE);
|
||||
splashRetry.setOnClickListener(v -> {
|
||||
splashRetry.setVisibility(View.GONE);
|
||||
splashTip.setText("正在重试…");
|
||||
web.post(this::prepareAndBoot);
|
||||
});
|
||||
LinearLayout.LayoutParams rp = new LinearLayout.LayoutParams(-2, -2);
|
||||
rp.topMargin = dp(16);
|
||||
box.addView(splashRetry, rp);
|
||||
|
||||
return box;
|
||||
}
|
||||
|
||||
private void tip(final String text) {
|
||||
runOnUiThread(() -> {
|
||||
if (splashTip != null) splashTip.setText(text);
|
||||
});
|
||||
}
|
||||
|
||||
private void showApp() {
|
||||
if (web != null) web.animate().alpha(1f).setDuration(240).start();
|
||||
if (splash != null) {
|
||||
splash.animate().alpha(0f).setDuration(240).withEndAction(() -> {
|
||||
if (splash != null) splash.setVisibility(View.GONE);
|
||||
}).start();
|
||||
}
|
||||
}
|
||||
|
||||
private void fail(final String msg) {
|
||||
Log.e(TAG, msg);
|
||||
runOnUiThread(() -> {
|
||||
if (splash != null) splash.setAlpha(1f);
|
||||
if (splashTip != null) splashTip.setText(msg);
|
||||
if (splashRetry != null) splashRetry.setVisibility(View.VISIBLE);
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------- 网页可调的接口 ---------------- */
|
||||
@@ -155,10 +319,13 @@ public class MainActivity extends Activity {
|
||||
try {
|
||||
File dir = getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS);
|
||||
if (dir != null) {
|
||||
for (File f : dir.listFiles()) {
|
||||
File[] old = dir.listFiles();
|
||||
if (old != null) {
|
||||
for (File f : old) {
|
||||
if (f.getName().endsWith(".apk")) f.delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
DownloadManager.Request req = new DownloadManager.Request(Uri.parse(url));
|
||||
req.setTitle("maipulse 新版本");
|
||||
req.setDescription("下载完成后会弹安装");
|
||||
@@ -199,7 +366,6 @@ public class MainActivity extends Activity {
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
|
||||
&& !getPackageManager().canRequestPackageInstalls()) {
|
||||
// 没有「安装未知应用」权限,跳设置页让用户开
|
||||
Intent s = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
|
||||
Uri.parse("package:" + getPackageName()));
|
||||
startActivity(s);
|
||||
@@ -217,15 +383,49 @@ public class MainActivity extends Activity {
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- Python 启动 ---------------- */
|
||||
/* ---------------- 启动流程 ---------------- */
|
||||
|
||||
private void bootPython() {
|
||||
/**
|
||||
* 主线程入口:先把资源复制这一坨挪到后台,再回主线程做 Python.start()。
|
||||
* Chaquopy 要求 Python.start() 在主线程调用,所以这里不能整个丢进子线程。
|
||||
*/
|
||||
private void prepareAndBoot() {
|
||||
tip("正在准备资源…");
|
||||
new Thread(() -> {
|
||||
try {
|
||||
prepareAssets();
|
||||
} catch (Throwable t) {
|
||||
fail("资源解包失败:" + t);
|
||||
return;
|
||||
}
|
||||
runOnUiThread(() -> {
|
||||
tip("正在加载运行时…");
|
||||
// 让这句提示先渲染出来,再进阻塞的 Python.start()
|
||||
web.postDelayed(this::startPython, 60);
|
||||
});
|
||||
}, "mp-assets").start();
|
||||
}
|
||||
|
||||
private void startPython() {
|
||||
try {
|
||||
if (!Python.isStarted()) {
|
||||
Python.start(new AndroidPlatform(this));
|
||||
}
|
||||
} catch (Throwable t) {
|
||||
fail("运行时装载失败:" + t);
|
||||
return;
|
||||
}
|
||||
tip("正在启动本地服务…");
|
||||
new Thread(this::bootFlask, "mp-flask").start();
|
||||
new Thread(this::waitAndLoad, "mp-wait").start();
|
||||
}
|
||||
|
||||
/** assets/app -> filesDir/app;内置资源版本号变了就整目录重建,但保住用户导入的配置 */
|
||||
private void prepareAssets() throws IOException {
|
||||
File files = getFilesDir();
|
||||
File appDir = new File(files, "app");
|
||||
File stamp = new File(files, "asset.rev");
|
||||
// 内置资源版本号:打包时由 build_apk.ps1 写进 assets/app/asset.rev,
|
||||
// 每次打包都会变,所以数据更新后一定能触发重建(手写常量太容易忘)。
|
||||
|
||||
String want = "";
|
||||
try (InputStream in = getAssets().open("app/asset.rev")) {
|
||||
byte[] b = new byte[128];
|
||||
@@ -233,6 +433,7 @@ public class MainActivity extends Activity {
|
||||
if (n > 0) want = new String(b, 0, n, "UTF-8").trim();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
|
||||
String have = "";
|
||||
try {
|
||||
if (stamp.exists()) {
|
||||
@@ -244,49 +445,95 @@ public class MainActivity extends Activity {
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
|
||||
boolean broken = !appDir.exists()
|
||||
|| !new File(appDir, "app.py").exists()
|
||||
|| !new File(appDir, "data/master.json").exists();
|
||||
if (broken || want.isEmpty() || !want.equals(have)) {
|
||||
|
||||
if (!broken && !want.isEmpty() && want.equals(have)) return;
|
||||
|
||||
tip("正在释放内置资源");
|
||||
// 用户导入的机台配置要保住,其余整目录重建(否则旧资源永远不更新)
|
||||
byte[] conf = null;
|
||||
File confFile = new File(new File(appDir, "astral"), "config.py");
|
||||
if (confFile.exists() && confFile.length() > 0) {
|
||||
conf = new byte[(int) confFile.length()];
|
||||
try (InputStream in = new FileInputStream(confFile)) {
|
||||
int n = in.read(conf);
|
||||
if (n <= 0) conf = null;
|
||||
}
|
||||
}
|
||||
// 用户填的机台身份写在这两个文件里,重建前先捞出来,重建后写回去
|
||||
byte[] confAstral = readFile(new File(new File(appDir, "astral"), "config.py"));
|
||||
byte[] confNet = readFile(new File(new File(appDir, "astral/api"), "MaimaiNet.py"));
|
||||
|
||||
deleteTree(appDir);
|
||||
copyAssetDir("app", appDir);
|
||||
if (conf != null) {
|
||||
File cf = new File(new File(appDir, "astral"), "config.py");
|
||||
File parent = cf.getParentFile();
|
||||
if (parent != null && !parent.exists()) parent.mkdirs();
|
||||
try (OutputStream out = new FileOutputStream(cf)) {
|
||||
out.write(conf);
|
||||
}
|
||||
}
|
||||
|
||||
writeFile(new File(new File(appDir, "astral"), "config.py"), confAstral);
|
||||
writeFile(new File(new File(appDir, "astral/api"), "MaimaiNet.py"), confNet);
|
||||
|
||||
try (OutputStream out = new FileOutputStream(stamp)) {
|
||||
out.write(want.getBytes("UTF-8"));
|
||||
}
|
||||
}
|
||||
tip("正在加载 Python 运行时…");
|
||||
if (!Python.isStarted()) {
|
||||
Python.start(new AndroidPlatform(this));
|
||||
|
||||
private byte[] readFile(File f) {
|
||||
if (f == null || !f.exists() || f.length() <= 0) return null;
|
||||
try (InputStream in = new FileInputStream(f)) {
|
||||
byte[] b = new byte[(int) f.length()];
|
||||
int n = in.read(b);
|
||||
if (n <= 0) return null;
|
||||
if (n < b.length) {
|
||||
byte[] c = new byte[n];
|
||||
System.arraycopy(b, 0, c, 0, n);
|
||||
return c;
|
||||
}
|
||||
tip("正在启动本地服务…");
|
||||
Python.getInstance().getModule("android_boot").callAttr("boot", files.getAbsolutePath());
|
||||
} catch (Throwable t) {
|
||||
Log.e(TAG, "boot failed", t);
|
||||
tip("启动失败:" + t);
|
||||
return b;
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void tip(final String text) {
|
||||
runOnUiThread(() -> tip.setText(text));
|
||||
private void writeFile(File f, byte[] data) throws IOException {
|
||||
if (data == null || f == null) return;
|
||||
File parent = f.getParentFile();
|
||||
if (parent != null && !parent.exists() && !parent.mkdirs()) {
|
||||
throw new IOException("mkdirs failed: " + parent);
|
||||
}
|
||||
try (OutputStream out = new FileOutputStream(f)) {
|
||||
out.write(data);
|
||||
}
|
||||
}
|
||||
|
||||
private void bootFlask() {
|
||||
try {
|
||||
Python.getInstance().getModule("android_boot")
|
||||
.callAttr("boot", getFilesDir().getAbsolutePath());
|
||||
} catch (Throwable t) {
|
||||
Log.e(TAG, "flask exited", t);
|
||||
fail("本地服务异常退出:" + t);
|
||||
}
|
||||
}
|
||||
|
||||
private void waitAndLoad() {
|
||||
if (loadRequested) return;
|
||||
// 最多等 90 秒。Chaquopy 首次装载 + 资源解包在低端机上确实会慢。
|
||||
for (int i = 0; i < 450; i++) {
|
||||
if (web == null) return;
|
||||
try (Socket sock = new Socket()) {
|
||||
sock.connect(new InetSocketAddress("127.0.0.1", PORT), 300);
|
||||
loadRequested = true;
|
||||
runOnUiThread(() -> {
|
||||
if (web != null) {
|
||||
web.loadUrl(HOME);
|
||||
// 兜底:页面迟迟不触发 onPageFinished 也不能一直卡在启动页
|
||||
web.postDelayed(() -> {
|
||||
if (!firstPageDone) showApp();
|
||||
}, 15000);
|
||||
}
|
||||
});
|
||||
return;
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
try {
|
||||
Thread.sleep(200);
|
||||
} catch (InterruptedException e) {
|
||||
return;
|
||||
}
|
||||
if (i == 25) tip("还在启动,稍等…");
|
||||
}
|
||||
fail("本地服务没能在 90 秒内启动");
|
||||
}
|
||||
|
||||
private void deleteTree(File f) {
|
||||
@@ -316,7 +563,6 @@ public class MainActivity extends Activity {
|
||||
}
|
||||
|
||||
private void copyAssetFile(String src, File dst) throws IOException {
|
||||
// 不跳过已存在文件:整目录重建后旧的残留必须被覆盖
|
||||
File parent = dst.getParentFile();
|
||||
if (parent != null && !parent.exists() && !parent.mkdirs()) {
|
||||
throw new IOException("mkdirs failed: " + parent);
|
||||
@@ -329,25 +575,7 @@ public class MainActivity extends Activity {
|
||||
}
|
||||
}
|
||||
|
||||
private void waitAndLoad() {
|
||||
for (int i = 0; i < 200; i++) {
|
||||
try (Socket sock = new Socket()) {
|
||||
sock.connect(new InetSocketAddress("127.0.0.1", PORT), 300);
|
||||
runOnUiThread(() -> {
|
||||
tip.setVisibility(View.GONE);
|
||||
web.loadUrl("http://127.0.0.1:" + PORT + "/");
|
||||
});
|
||||
return;
|
||||
} catch (IOException ignored) {
|
||||
try {
|
||||
Thread.sleep(200);
|
||||
} catch (InterruptedException e) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
tip("服务没有起来,看看日志");
|
||||
}
|
||||
/* ---------------- 生命周期 ---------------- */
|
||||
|
||||
@Override
|
||||
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
|
||||
@@ -375,10 +603,25 @@ public class MainActivity extends Activity {
|
||||
|
||||
@Override
|
||||
public boolean onKeyDown(int keyCode, KeyEvent event) {
|
||||
if (keyCode == KeyEvent.KEYCODE_BACK && web != null && web.canGoBack()) {
|
||||
if (keyCode == KeyEvent.KEYCODE_BACK) {
|
||||
// 启动页还在 = 还没进主界面,直接交给系统
|
||||
if (splash != null && splash.getVisibility() == View.VISIBLE
|
||||
&& splash.getAlpha() > 0.5f) {
|
||||
return super.onKeyDown(keyCode, event);
|
||||
}
|
||||
if (web != null && web.canGoBack()) {
|
||||
web.goBack();
|
||||
return true;
|
||||
}
|
||||
long now = System.currentTimeMillis();
|
||||
if (now - lastBackPress < 2000) {
|
||||
finish();
|
||||
} else {
|
||||
lastBackPress = now;
|
||||
Toast.makeText(this, "再按一次退出", Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return super.onKeyDown(keyCode, event);
|
||||
}
|
||||
|
||||
@@ -389,6 +632,8 @@ public class MainActivity extends Activity {
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
if (web != null) {
|
||||
ViewGroup p = (ViewGroup) web.getParent();
|
||||
if (p != null) p.removeView(web);
|
||||
web.destroy();
|
||||
web = null;
|
||||
}
|
||||
|
||||
@@ -3,13 +3,33 @@
|
||||
|
||||
为什么不在 assets 里直接 import:app.py 顶层会 os.makedirs(logs),
|
||||
assets 是只读的,必须用 Java 侧复制出来的可写副本。
|
||||
|
||||
为什么要先探一次端口:Activity 退出后进程可能还在(Flask 线程是常驻的),
|
||||
用户再点图标进来时 boot() 会被再调用一次。这时 8787 已经有服务在跑,
|
||||
直接 return 让外壳去连既有服务,比抛 "Address already in use" 干净。
|
||||
"""
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
|
||||
PORT = 8787
|
||||
|
||||
|
||||
def _port_in_use():
|
||||
s = socket.socket()
|
||||
s.settimeout(0.3)
|
||||
try:
|
||||
s.connect(("127.0.0.1", PORT))
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
finally:
|
||||
try:
|
||||
s.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def boot(files_dir):
|
||||
base = os.path.join(files_dir, "app")
|
||||
if base not in sys.path:
|
||||
@@ -21,6 +41,10 @@ def boot(files_dir):
|
||||
os.environ.setdefault("MAIPULSE_PORT", str(PORT))
|
||||
os.chdir(base)
|
||||
|
||||
# 上一次的 Flask 还活着就直接复用,别再起一个
|
||||
if _port_in_use():
|
||||
return "already running"
|
||||
|
||||
import app as maipulse_app # noqa: E402
|
||||
# 绑定 127.0.0.1:只有本机 WebView 能访问
|
||||
maipulse_app.app.run(host="127.0.0.1", port=PORT, debug=False,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- allowBackup 已经是 false,这里再堵一层:任何域名都不参与云备份和换机迁移。
|
||||
配置里有机台身份(Keychip / 店铺 / 密钥),不该跟着账号漂到别的设备上。 -->
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<exclude domain="root" path="." />
|
||||
<exclude domain="file" path="." />
|
||||
<exclude domain="database" path="." />
|
||||
<exclude domain="sharedpref" path="." />
|
||||
<exclude domain="external" path="." />
|
||||
</cloud-backup>
|
||||
<device-transfer>
|
||||
<exclude domain="root" path="." />
|
||||
<exclude domain="file" path="." />
|
||||
<exclude domain="database" path="." />
|
||||
<exclude domain="sharedpref" path="." />
|
||||
<exclude domain="external" path="." />
|
||||
</device-transfer>
|
||||
</data-extraction-rules>
|
||||
BIN
Binary file not shown.
Vendored
-248
@@ -1,248 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
Vendored
-93
@@ -1,93 +0,0 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
+10
-10
@@ -11,17 +11,17 @@ MaimaiNet.py —— 舞萌DX 网络核心层(纯 HTTP 复刻)
|
||||
|
||||
1) 服务器基址与 API 路径
|
||||
source/Assembly-CSharp/Manager/OperationManager.cs:225,338
|
||||
ServerUri = Auth.GameServerUri + MovieServerUri("<OBFUSCATE_PARAM>/")
|
||||
ServerUri = Auth.GameServerUri + MovieServerUri("mE2s3Jhd/")
|
||||
source/Assembly-CSharp/Net.Packet/Packet.cs:116
|
||||
Client = NetHttpClient.Create(BaseUrl + Obfuscator(query.Api))
|
||||
source/Assembly-CSharp/Net.Packet/Packet.cs:485
|
||||
Obfuscator(s) = md5_hex_lower(s + "<OBFUSCATE_PARAM>")
|
||||
Obfuscator(s) = md5_hex_lower(s + "8bF76dE9")
|
||||
source/Assembly-CSharp/Net.VO/NetQuery.cs:14
|
||||
Api = api + "MaimaiChn"
|
||||
|
||||
=> 最终 URL = BASE_URL + md5(逻辑接口名 + "MaimaiChn" + "<OBFUSCATE_PARAM>")
|
||||
=> 最终 URL = BASE_URL + md5(逻辑接口名 + "MaimaiChn" + "8bF76dE9")
|
||||
|
||||
已用真实日志验证:md5("GetGameSettingApiMaimaiChn" + "<OBFUSCATE_PARAM>")
|
||||
已用真实日志验证:md5("GetGameSettingApiMaimaiChn" + "8bF76dE9")
|
||||
= 83a5d5a20b062c5c2ad817460b8f7f76
|
||||
(source/2026-10-05.log:1 的请求就是打到这个尾段)
|
||||
|
||||
@@ -121,17 +121,17 @@ from config import CLIENT_ID, MAI_ENCODING, TIMEOUT_LONG_MS, TIMEOUT_MS
|
||||
|
||||
#: 游戏服务器(Maimai2Servlet)
|
||||
#: 依据 source/Assembly-CSharp/Manager/OperationManager.cs:225,338
|
||||
#: _operationData.ServerUri = Auth.GameServerUri + MovieServerUri("<OBFUSCATE_PARAM>/")
|
||||
BASE_URL = ""
|
||||
#: _operationData.ServerUri = Auth.GameServerUri + MovieServerUri("mE2s3Jhd/")
|
||||
BASE_URL = "https://maimai-gm.wahlap.com:42081/Maimai2Servlet/mE2s3Jhd/"
|
||||
|
||||
#: AES 参数,依据 source/Assembly-CSharp/Net/CipherAES.cs:11-12
|
||||
#: AesKey 32 字节 -> AES-256;AesIV 16 字节
|
||||
AES_KEY = b""
|
||||
AES_IV = b""
|
||||
AES_KEY = b"FKM2JX:VjZNK6hc:A0<JU:i5oR7LA]9W"
|
||||
AES_IV = b"F>;24DjU9W6ZsRH["
|
||||
|
||||
#: API 名混淆盐,依据 source/Assembly-CSharp/Net.Packet/Packet.cs:44
|
||||
#: 用法:URL 尾段 = md5(逻辑接口名 + "MaimaiChn" + OBFUSCATE_PARAM) 小写 hex
|
||||
OBFUSCATE_PARAM = ""
|
||||
OBFUSCATE_PARAM = "8bF76dE9"
|
||||
|
||||
from .LogSetup import log_debug, log_error, log_request, log_response
|
||||
|
||||
@@ -162,7 +162,7 @@ def obfuscate_api_name(api_name: str) -> str:
|
||||
对逻辑接口名做 MD5 混淆,得到 URL 尾段(对应 Packet.Obfuscator)。
|
||||
|
||||
逻辑名 "GetUserPreviewApi" -> "GetUserPreviewApiMaimaiChn"
|
||||
-> md5(上面 + "<OBFUSCATE_PARAM>")
|
||||
-> md5(上面 + "8bF76dE9")
|
||||
|
||||
参数:
|
||||
api_name: 逻辑接口名,**不要**自己带 MaimaiChn 后缀
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ Ping.py —— 心跳探活(PingMaimaiChn)
|
||||
new NetQuery<PingRequestVO, PingResponseVO>("Ping", 0uL);
|
||||
客户端传的逻辑名是 **"Ping"**,NetQuery.cs:14 再补上 "MaimaiChn" 后缀,
|
||||
所以混淆用的全名是 "PingMaimaiChn":
|
||||
md5("PingMaimaiChn" + "<REDACTED>") = 3bae8c1162b3148abcfc38a89c03e3e7
|
||||
md5("PingMaimaiChn" + "8bF76dE9") = 3bae8c1162b3148abcfc38a89c03e3e7
|
||||
本模块传 "Ping" 即可,obfuscate_api_name 会自动补后缀。
|
||||
|
||||
(Packet.cs:96 用 Query.Api.StartsWith("Ping") 判断是否心跳包,
|
||||
|
||||
+2
-2
@@ -8,7 +8,7 @@ authv1.py —— ALL.Net(alAbEx v4) 机台认证:Keychip -> clientId / regionI
|
||||
config.py 第二节那六个值
|
||||
KEYCHIP_ID / CLIENT_ID / REGION_ID / PLACE_ID / PLACE_NAME / REGION_NAME
|
||||
全部来自 **AMDaemon 开机时向 ALL.Net 认证服务器要的结果**。
|
||||
游戏服务器(<REDACTED>)**没有任何接口**会返回它们 ——
|
||||
游戏服务器(maimai-gm.wahlap.com)**没有任何接口**会返回它们 ——
|
||||
所以本工程此前只能手工从真机报文里抄(见 config.py 里那段吐槽)。
|
||||
|
||||
本脚本把 AMDaemon 的这一步原样重做了一遍。
|
||||
@@ -74,7 +74,7 @@ from Crypto.Util.Padding import pad
|
||||
# =============================================================================
|
||||
|
||||
# ALL.Net 认证服务器(国服 SDGB)。两个域名指向不同机房,实测都能用;
|
||||
# 默认用 sys-allnet.cn(与 <REDACTED> 同机房,链路更短)。
|
||||
# 默认用 sys-allnet.cn(与 maimai-gm.wahlap.com 同机房,链路更短)。
|
||||
ALLNET_HOSTS = (
|
||||
"http://at.sys-allnet.cn",
|
||||
"http://at.sys-all.cn",
|
||||
|
||||
@@ -1,496 +0,0 @@
|
||||
# maipulse 功能补齐计划(按截图那套移动端来)
|
||||
|
||||
目标:把现在这个网页工具,做成你截的那种移动端 app 的样子 ——
|
||||
底部五个 tab(主页 / 票据 / B50 / 风险 / 设置),每个功能一张卡片,带 60 秒冷却条。
|
||||
|
||||
原则:**已经跑通的后端一个都不重写**,只补缺的 + 重排前端。
|
||||
|
||||
---
|
||||
|
||||
## 一、现状盘点(已有,直接用)
|
||||
|
||||
| 功能 | 后端 | 前端 | 状态 |
|
||||
|---|---|---|---|
|
||||
| 扫码登录 / 续玩 / 退出 | `core/session.py` + `/api/qr` `/api/login` `/api/logout` | 卡片 1 | 能用 |
|
||||
| 2/3/5 倍功能票 | `core/charge.py` + `/api/charge` `/api/ticket` | 卡片 2 | 能用 |
|
||||
| 解小黑屋 | `core/repair.py` + `/api/status` `/api/unstick` | 卡片 7 | 能用(未在真卡住号上复测) |
|
||||
| 歌曲解锁(全量 / 选中) | `core/unlock.py` + `/api/unlock` | 卡片 6 | 能用 |
|
||||
| 收藏品 / 礼物 | `core/items.py` + `/api/items` `/api/newitems` `/api/grant` | 卡片 4、5 | 能用 |
|
||||
| B50 + 导出图片 | `core/b50.py` + `/api/b50` `/api/cover` | 卡片 8 | 能用,导出实测 2240×1608 |
|
||||
| 店铺切换 | `core/shop.py` + `/api/shops` `/api/shop` | 卡片 3 附近 | 能用 |
|
||||
|
||||
这些只做两件事:**搬进新 UI**、**按截图补交互细节**(下拉框、列表、勾选、冷却条)。
|
||||
|
||||
---
|
||||
|
||||
## 二、要补的功能(截图里有,现在没有)
|
||||
|
||||
### 1. 主页(玩家信息全表)
|
||||
数据源:`api.GetNeedUserData(uid, token)` 一次拿全,不需要多发请求。
|
||||
|
||||
要显示的区块(对齐你的截图):
|
||||
- 头部:头像(iconId 拼图)、昵称、Rating、历史最高
|
||||
- Rating 详情:旧谱 Rating / 新谱 Rating / 合计 / 历史最高
|
||||
- 入坑信息:入坑日期、首次游戏、初始 Rom、初始数据版本
|
||||
- 游戏信息:最后游戏、最后游玩、最后登录、Rom 版本、数据版本、最后地区(placeId → 地区名查 `shop.py` 的店铺表)
|
||||
- 游玩统计:总游玩次数、当期游玩、总达成率、总 DX 分、总同步数、Awake
|
||||
- 状态:联网会员、继承、封禁状态、每日奖励
|
||||
- 更多详情:用户 ID(打码)、当前 P、累计 P、图标 ID、名牌 ID、姓名框 ID、背景框 ID、称号 ID、奖杯 ID、伙伴 ID、段位、阶级、段级
|
||||
|
||||
新接口:`GET /api/profile` → 直接吐上面这些字段(前端不再自己拼)。
|
||||
字段名要先实测确认(`userData[0]` 里到底叫 `point` 还是 `userPoint`、`classValue` 之类),拉一次快照对字段。
|
||||
|
||||
### 2. 旅行伙伴
|
||||
数据源:`userCharacterList`(不是 `userItemList`,也不是搭档 `Partner=10`)。
|
||||
|
||||
三个区块:
|
||||
- 发放新伙伴:填 ID → 加到列表 → 一次提交(写成 `userCharacterList` 追加行)
|
||||
- 编组出战槽位:槽 0(队长)~槽 4,每槽选一个已拥有角色 + 可选等级;「把槽 0 复制到其他槽」
|
||||
- 已拥有伙伴:列 `#id Lv n` 的 chips(截图里那 48 个)
|
||||
|
||||
新接口:`GET /api/characters`(拉现有)、`POST /api/characters`(发放 / 编组)。
|
||||
风险:ID 不在机台表里服务端照样存,但客户端会静默跳过 → 结果里要回报“服务端已存、游戏内可能不显示”。
|
||||
|
||||
### 3. 添加舞里程
|
||||
数据源:`userData[0].point`(余额)/ `totalPoint`(累计)。
|
||||
两种写法:累加(当前 + N)/ 覆盖。整个 int32 区间,填负数就是扣。
|
||||
客户端只在“获得”时把余额钳到 99999,商店扣款不带钳位,所以写更大不会被回写。
|
||||
|
||||
新接口:`POST /api/mileage`,返回改前 / 改后的 point 与 totalPoint。
|
||||
这项最容易出事(写坏就是余额异常),默认 dry-run,预检里明确写“覆盖会直接改余额”。
|
||||
|
||||
### 4. 设置 / 关于
|
||||
- 设置:服务器地址(默认 127.0.0.1:8787)、是否自动退出登录、是否只预检
|
||||
- 关于:版本、数据源说明、免责(自用工具)
|
||||
|
||||
不需要后端。
|
||||
|
||||
---
|
||||
|
||||
## 三、前端改造(重点,工作量最大)
|
||||
|
||||
现在是一个 PC 版两列卡片页,要改成截图那种:
|
||||
|
||||
整体
|
||||
- 顶部返回 + 页面标题
|
||||
- 内容区卡片:`background:#f2f2f6`、圆角 12、内边距 16、无边框
|
||||
- 表单控件:Material 风 outlined 输入框(label 浮在边框上)、下拉箭头、大号圆角主按钮
|
||||
- 底部 tab bar:主页 / 票据 / B50 / 风险 / 设置
|
||||
|
||||
交互
|
||||
- 登录后 60 秒冷却:顶部粉色提示条「登录后需冷却 60 秒,剩余 N 秒」,按钮置灰
|
||||
- 所有写操作跑完自动退出登录(可勾选)
|
||||
- 危险操作统一放在「风险」tab 里,进去先一条红色警告卡
|
||||
|
||||
实现方式:单页 + hash 路由(`#/home` `#/ticket` `#/b50` `#/risk/...` `#/settings`),
|
||||
不引框架,保持现在这个 `html/index.html` 一个文件,方便以后套 WebView。
|
||||
|
||||
---
|
||||
|
||||
## 四、实施顺序
|
||||
|
||||
1. **P1 主页信息**:拉一次快照对字段 → `/api/profile` → 主页 tab
|
||||
2. **P2 B50 搬进新 UI**:海报 + 导出(逻辑不动,换壳)
|
||||
3. **P3 票据 / 解锁 / 收藏品搬进新 UI**:+ 冷却条
|
||||
4. **P4 旅行伙伴**:`/api/characters`
|
||||
5. **P5 舞里程**:`/api/mileage`
|
||||
6. **P6 设置 / 关于**
|
||||
|
||||
P1 必须先做,因为伙伴、里程都要读同一份快照,字段对一遍就都对了。
|
||||
|
||||
---
|
||||
|
||||
## 五、风险与纪律
|
||||
|
||||
- 60 秒登录冷却:所有 `UpsertUserAll` 类写入都在 `session.fresh()` 之后等满 60 秒,界面要显示倒计时,不能点了没反应。
|
||||
- 票的坑:`userChargeList` 里只要有 `stock>0`,之后所有写入都会被服务端判非法整包丢弃。所以**先解歌 / 发道具,最后才买票**。
|
||||
- 每项写操作默认 dry-run 预检(只算不发),勾掉「只预检」才真发。
|
||||
- 不动已经跑通的 `settle / charge / unlock / items / b50`,只加接口和新页面。
|
||||
|
||||
---
|
||||
|
||||
## 六、验收口径
|
||||
|
||||
每个功能都要给出:改前值 → 改后值(回读快照确认)+ 一条日志 + 截图,不能只看 `returnCode=1` 就说成功。
|
||||
|
||||
---
|
||||
|
||||
## 进度记录(2026-10-28 05:00)
|
||||
|
||||
对照 Ichino(D:\DSH\maimai com\Ichino,Flutter 客户端)逐项落地。
|
||||
|
||||
### 已完成
|
||||
|
||||
1. **B50 海报重做(照 Ichino best50_poster.dart 原始坐标)**
|
||||
- 底图 `html/assets/b50/base.png`(1762×1850)+ 绝对坐标叠加,不再用 CSS 拼版;
|
||||
- 卡片 300×100,`left = 65 + 320*(i%5)`,Best35 `top = 350 + 120*i/5`,Best15 `top = 1250 + 120*i/5`;
|
||||
- 评级图 `UI_TTR_Rank_*.png`、FC/AP 与 FS/FDX 图标、DX 徽标、难度色 RA 药丸全部走官方素材;
|
||||
- `ra` 换成 Ichino 整数公式(`_BANDS` + `_SSSP_OFFSET=224`),实测 9166 与游戏内一致;
|
||||
- 导出 html2canvas `scale=1` → 1762×1850,已验证;
|
||||
- 未登录时海报署名用打码 ID,不印完整账号。
|
||||
|
||||
2. **主页资料 `GET /api/profile`**(新 `core/profile.py`)
|
||||
- 一次 `GetNeedUserData` 填满;字段照 Ichino `UserDataBean`:Rating 详情 / 入坑信息 / 游戏信息 / 游玩统计 / 状态 / 更多详情;
|
||||
- 前端 6 个分区 + 空态提示。
|
||||
|
||||
3. **旅行伙伴 `GET|POST /api/characters`**(新 `core/characters.py`)
|
||||
- 读:`userCharacterList` + `userData.charaSlot`(int[5],槽 0 队长);
|
||||
- 写:`userCharacterList` + 逐行 `isNewCharacterList`(已有 '0' / 新增 '1'),可同时改 charaSlot;
|
||||
- 默认 dry-run,写后回读核对。
|
||||
|
||||
4. **舞里程 `GET|POST /api/mileage`**(新 `core/mileage.py`)
|
||||
- 改 `point` / `totalPoint`,累加或覆盖,int32 钳位;
|
||||
- userData 整表覆盖 → 先用 `build_user_all` 组好这一局,再替换那一行,保住 playCount / lastPlayDate 等痕迹。
|
||||
|
||||
5. **UI 改版(浅色 Material + 底部 5 tab)**
|
||||
- 主页 / 票据 / B50 / 风险 / 设置;hash 路由 `#home #ticket #b50 #risk #settings`;
|
||||
- 卡片按 `data-tab` 显隐,`main` 改单列 560 居中;
|
||||
- 风险页顶部红框警告 + 顺序铁律(先解歌发道具,最后买票);
|
||||
- 结果用底部轻提示(ticker)播报,日志收进设置页;
|
||||
- 设置页可搜店铺切机台、退出登录。
|
||||
|
||||
### 待办
|
||||
|
||||
- 玩家记录 / 牌子进度页:需要登录态下 `GetUserMusicDetailAll`(当前未登录返回空明文);
|
||||
- 推分推荐暂不算「平均达成率 / 统计样本」,本地没有水鱼聚合数据,不编造;
|
||||
- 真机验证:登录后走一遍 主页 → 旅行伙伴 → 舞里程 的回读。
|
||||
|
||||
### 追加(本轮)
|
||||
|
||||
6. **60 秒冷却条**:`GET /api/state` 新增 `coolRemain`(登录后剩余秒数,金票 LogoutTimeLimit 同口径),
|
||||
票据页粉色条倒计时提示「现在发包会被静默丢弃」。
|
||||
7. **组包自测**:`tools/selftest_payload.py` 用假 snapshot 走 `build_user_all` 验证——
|
||||
userData 49 个字段整行覆盖后一个不丢,point/totalPoint/charaSlot 改写生效,
|
||||
`isNewCharacterList` 位串与 Ichino `BuildListData` 同口径(已有 '0' / 新增 '1')。已跑通。
|
||||
|
||||
### 追加(第 2 轮)
|
||||
|
||||
8. **字段映射自测**:`tools/selftest_fields.py` 把 GetNeedUserData 换成假快照,
|
||||
验证 profile 的 24 个关键字段逐个取到正确值、userId 打码成 `123****78`、
|
||||
characters 能解析出 2 个角色与 `[101..105]` 槽位。已跑通。
|
||||
9. **B50 看大图**:海报下方加「看大图」,克隆一份 1762 宽的原尺寸海报铺进全屏查看器,
|
||||
手机上横向滑动能看清卡片细节;`?b50=ID&zoom=1` 可直接验证。
|
||||
|
||||
### 追加(第 3 轮)
|
||||
|
||||
10. **选择器(解锁歌曲 / 称号 / 头像 / 名牌 / 边框 / 角色)浅色适配**:
|
||||
标题单行省略、搜索框 `flex:1 1 0;width:auto` 不再把标题挤成两行;13000+ 项列表滚动正常。
|
||||
`?picker=music|title|icon|nameplate|frame|character` 可直接验。
|
||||
11. **结算等待遮罩改浅色**(白底圆角 + 深色字 + 蓝色进度条),不再和浅色主题撞色。
|
||||
12. **自动登出真正接线**:`post()` 里对 6 个写接口(ticket/grant/unlock/characters/mileage/unstick)
|
||||
做统一判断——勾了开关且**不是** dry-run 才登出,默认不勾,避免打断连续操作。
|
||||
13. **B50 空态**:没查过时给一句引导,不是空白。
|
||||
|
||||
### 追加(第 4 轮)
|
||||
|
||||
14. **票种档位修正(对照 Astral TICKET_CREDIT_NUM)**:
|
||||
原实现只有 chargeId 2/3/5,实际付费票是 2/3/4/5/6,`creditNum = N-1`(1/2/3/4/5)。
|
||||
已补 4 倍票(creditNum 3)与 6 倍票(creditNum 5),错误文案同步成「2~6 倍票」,
|
||||
下拉选项把 price 改标 creditNum(容易和「金额」混淆)。
|
||||
Ichino 的 `_ticketNameMap` 只有 2/4/5,是显示映射不全——它实际从 `GetUserChargeApi`
|
||||
拿在售列表,所以以服务器列表为准、本地只做换算。
|
||||
15. **对照 Ichino 核过一遍字段表**:`models/user_data.dart` 的 41 个字段与 `core/profile.py` 一一对应,没有多余也没有缺。
|
||||
|
||||
### 追加(第 5 轮)
|
||||
|
||||
16. **旅行伙伴等级对齐 Ichino**:原来 `levels` 想填多少就发多少,现在钳到 **1~999999**
|
||||
(`minCharacterLevel` / `maxCharacterLevel`,0 会被判非法)。
|
||||
读取时附带 `displayLevel = level % 10000` 与 `rebirth = level // 10000`——
|
||||
客户端把界面等级和转生次数编码在一个 int 里,直接显示原始值会看到「Lv10005」。
|
||||
改等级时沿用原行的 `awakening` / `useCount`,不会把使用次数清零(Ichino 原注释点名过这个坑)。
|
||||
自测:`clamp_level` 边界与 `split_level(10005) == (5, 1)` 通过。
|
||||
|
||||
### 追加(第 6 轮)
|
||||
|
||||
17. **整页验收脚本化**:`?dbg=1` 时挂 `window.onerror` + `unhandledrejection`,
|
||||
把异常写进 `<title>`,再用 headless `--dump-dom` 逐个页签读。
|
||||
结果:5 个页签(home / ticket / b50 / risk / settings)**零 JS 错误**,
|
||||
`scrollWidth` 都不超过视口(risk 页 485/500 是纵向滚动条占位),没有横向溢出。
|
||||
|
||||
### 追加(第 7 轮)
|
||||
|
||||
18. **页签对齐 Ichino 的 6 个**:`home_tab.dart` 是 home / tickets / best50 / risk / settings / **about**,
|
||||
于是把「关于」从设置页拆成独立页签(版本、协议来源 Astral、行为口径 Ichino、数据只在本机、几条规矩)。
|
||||
注意 `.card[data-tab]` 的显示规则是逐页签写的,新增页签必须同步加一条 CSS,否则页面全空白。
|
||||
19. **结算等待改成分步提示**:照 Ichino 的 StepProgressCard,进度条上方按阶段翻字——
|
||||
读取快照 / 组包等满 60 秒 / 发送写包 / 回读核对,不再只显示「已等 N 秒」。
|
||||
|
||||
### 追加(第 8 轮)
|
||||
|
||||
20. **前后端 id 一致性 lint**:把 HTML 的 id 集合与 JS 里 `$('…')` / `setText('…')` 的引用对比,
|
||||
只有 `poster` 是渲染时动态建的(正常),34 个 `k*` 字段全部有对应节点——不会出现登录后一片空白。
|
||||
21. **`/api/characters` 参数加固**:`add` / `levels` / `slots` 的 `int()` 原来在 try 之外,
|
||||
前端传了非数字会变成 Flask 的 500 HTML 页面(前端 `r.json()` 直接抛错)。现在返回 JSON 错误。
|
||||
22. **风险页按操作顺序重排**:警告 → 解锁歌曲 → 发放收藏品 → 发放礼物 → 解锁搭档 →
|
||||
旅行伙伴 → 舞里程 → 解禁小黑屋。理由是先解歌、发道具,最后才碰票相关的东西,
|
||||
跟警告卡里那条铁律一致。
|
||||
|
||||
### 追加(第 9 轮)
|
||||
|
||||
23. **UI 预览模式 `?preview=1`**:照 Ichino `dev/ui_preview_data.dart` 的做法,
|
||||
不发任何请求,直接用一份假数据(Rating 15234 那套)把主页 / 旅行伙伴 / 舞里程撑起来,
|
||||
能看到登录后的真实排版,也方便截图比对。数据里特意放了一条 `level=10005`(1 转 5 级)
|
||||
与一条 `999999`(99 转 9999 级),验证转生拆分显示。
|
||||
24. **登录卡随登录态收起**:拿到资料后主页不再顶着扫码框(登出按钮在设置页),
|
||||
登录态失效时再放出来。
|
||||
|
||||
### 追加(第 10 轮)
|
||||
|
||||
25. **预览模式铺满风险页**:`?preview=1` 时自动把旅行伙伴与舞里程的假数据装上,
|
||||
实测渲染:`共 3 个角色|出战槽 #0 101 · #1 102 …`、
|
||||
`已拥有:#101 Lv5 · #102 Lv5(1 转)· #103 Lv9999(99 转)`——转生拆分显示正确,
|
||||
舞里程区显示 `当前余额 234567|累计 9876543`。P4 / P5 的前端展示至此有据可查。
|
||||
|
||||
### 追加(第 11 轮 · 登录态活体验收 + 外观)
|
||||
|
||||
26. **P1 活体验收通过**:`GET /api/profile` 拉真实数据 —— 昵称 maimai、
|
||||
Rating 9168、历史最高 12337、musicRating 9168、游玩 70 局、累计 P 9630、地区 江苏、icon 302,
|
||||
userId 按约定打码成 `128****21`。
|
||||
★ 客户端显示 9168,B50 本地算 9166,差 2 分正好对应那 3 首「定数未收录」的曲目——
|
||||
不是算错,是缺 ds 无法计入,这也反过来印证了 ra 公式没有偏。
|
||||
27. **P4 / P5 活体验收通过**:`/api/characters` 读到 48 个角色,出战槽
|
||||
`455201/455202/455203/455204/550101`;`/api/mileage` 读到 point 2130 / totalPoint 9630。
|
||||
写路径用 dry-run 打真实快照验过:里程 2130→2230、累计 9630→9730(preview.playCount=70 正确保留);
|
||||
旅行伙伴 update=101、willSend=1、patch 键含
|
||||
`userCharacterList / isNewCharacterList / userMusicDetailList / isNewMusicDetailList / userGamePlaylogList`。
|
||||
(未真发写包:改数值属于账号数据,等用户自己决定。)
|
||||
28. **底部图标换成 Tabler Icons**(MIT,本地 `html/assets/icons/*.svg`,不依赖外网):
|
||||
home / ticket / chart-bar / alert-triangle / settings / info-circle,统一线性风格,
|
||||
用 CSS `mask` 上色,选中态自动跟随 `--accent`。
|
||||
29. **外观设置(设置页新增一张卡)**:上传背景图(canvas 压到最长边 1600、jpeg 0.82 存 localStorage)、
|
||||
清除背景、卡片不透明度 30~100%、背景虚化 0~20px、背景压暗 0~70%、恢复默认。
|
||||
卡片走 `rgba(255,255,255,var(--alpha))` + `backdrop-filter`,有背景时顶栏也跟着半透明。
|
||||
持久化实测:写一套 alpha=.6 的配置后换一次加载,背景与透明度都还在。
|
||||
`?looktest=1` 可复现这个自查,`?bgdemo=1` 只看渐变效果不落盘。
|
||||
|
||||
### 追加(第 12 轮 · 多账号)
|
||||
|
||||
30. **`core/accounts.py`**:扫码登录成功后自动把 `userId + token` 存到 `data/accounts.json`
|
||||
(原子写,tmp → replace)。对外只给「打码 userId + 昵称 + 时间 + ref」,
|
||||
**token 与完整 userId 都不出后端**;切换账号时前端回传的是 `ref`
|
||||
(`sha256(userId)[:12]`),后端按 ref 反查真实行。
|
||||
31. **接口**:`GET /api/accounts` 列表;`POST /api/accounts` 支持
|
||||
`action=switch`(用存的 token 重新 `UserLogin`,切之前先把当前号登出,避免 returnCode=100)/
|
||||
`rename` / `delete`。
|
||||
32. **界面**:设置页新增「账号」卡,一行一个账号(昵称 / 打码 id / 最后使用 / 切换 / 删)。
|
||||
切换时有等待遮罩,成功后会刷新主页资料。
|
||||
打码统一成前 3 + 固定四星 + 后 2(`128****21`),不泄露位数。
|
||||
33. **离线上测通过**:upsert / list(current 标记)/ ref 反查 / rename / touch / remove /
|
||||
未知 ref 返回 None 全部符合预期;界面用一条演示数据渲染过,随后已删除,store 现在为空。
|
||||
34. ⏳ **待验**:用存下来的 token 隔一段时间重新登录是否还有效。
|
||||
要通过这一步才算真正确认「不用再扫码」——需要用户扫一次码来写第一条真实记录。
|
||||
|
||||
### 追加(第 13 轮)
|
||||
|
||||
35. **去掉「关于」页**(用户要求):删掉 section、底部按钮、`TABS` 里的 about 与对应 CSS 规则,
|
||||
`about.svg` 也一并删了;全文已无 about 残留。`go()` 本来就有「不在 TABS 里就回落 home」的兜底,
|
||||
旧的 `#about` 书签不会白屏。底部回到 5 个页签均分。
|
||||
|
||||
### 追加(第 14 轮 · 全曲成绩 + 推分推荐)
|
||||
|
||||
36. **`core/history.py`**:`api.GetUserMusicDetailAll(userId, clientId)` 拉全曲成绩
|
||||
(Astral 已把两层嵌套摊平成「一条 = 一个难度」)。achievement 是 ×10000 整数,
|
||||
除以 10000;定数先查 `data/master/ds_override.json`、再查 music.json;
|
||||
算不出定数的标 `unrated`,不进 ra 统计,另出 `unratedItems` 供手工补。
|
||||
统计含 AP / FC / FDX / 平均达成率 / 最高 ra / 按难度分布。
|
||||
37. **`core/recommend.py`**:推分算法。
|
||||
① 每首已打曲子算 SS+/SSS/SSS+ 三档 ra(复用 b50 的整数公式);
|
||||
② 在 B35 榜上的按「新 ra − 自己当前 ra」算,不在榜上的按「新 ra − 榜尾」算;
|
||||
③ `min_achievement()` 对 ra 二分反查,给出「打到 98.9934% 就能顶掉榜尾」这种具体数;
|
||||
④ 候选按定数中位数 ±0.7 收窄(别推荐打不动的),按涨幅排序,前 35 首累加算整体潜力。
|
||||
二分自测:ds 12.0/13.8/14.5 三档 × 四个目标档位,反查值再算回去全部 `>= 目标 ra`,
|
||||
单调性成立;拿不到目标的返回 None。
|
||||
38. **接口**:`GET /api/history`(默认前 120 条、full=1 全量)、
|
||||
`GET /api/recommend`、`POST /api/ds`(合并写覆盖表,ds<=0 表示删)。
|
||||
未登录一律拦下并给明确提示。
|
||||
39. **界面**:B50 页新增「推分建议」卡 —— 两个按钮(算推分 / 看全曲成绩)。
|
||||
推分列出:曲名 + 难度定数 / 当前达成 / ra / 「打到多少」/ 涨幅,顶部三个数(榜尾 ra、可推曲目、整体潜力)。
|
||||
全曲成绩列出统计、难度分布、按曲名搜索、明细行(达成率 / 评级 / FC·AP / ra)。
|
||||
未收录定数的条目单独一区,可直接填数字保存,存完自动重算。
|
||||
40. **踩到的坑**:`stopWait` 未定义(现网只有 `endWait`)→ 加别名;`ds_override` 内层
|
||||
key 是 int、保存时按 str 处理,导致 `pop("3")` 匹配不上、删除静默失效 → 统一转 str。
|
||||
41. ⏳ **待真实验收**:全曲成绩要登录才能拉。扫一次码后验:
|
||||
`/api/history` 条数是否接近真机 739、`/api/recommend` 是否给出合理建议、
|
||||
补完那几条定数后本地算出的 rating 能不能对上客户端 9168。
|
||||
|
||||
### 追加(第 15 轮 · 推分多样性 + 定数联动 + 对分)
|
||||
|
||||
42. **推分算法返工**(离线压测 400 条假成绩暴露出来的):
|
||||
原来按「打到 SSS+」的 ra 排序,结果同一批定数的曲子涨幅完全相同,前排全是 ds 14.5 的歌、
|
||||
完全没有区分度(top8 定数去重后只剩 1 个值)。
|
||||
改成:主档用 **SSS(100%)**算涨幅(SSS+ 只作参考列),同涨幅按 `effort`(还要提多少达成率)
|
||||
升序,再按定数做稀释——**每个定数最多留 3 首**。改完 top10 里出现 4 个不同定数。
|
||||
`total` / `potential` 仍按稀释前的全量算(否则潜力会被低估:39 首 vs 200 首)。
|
||||
43. **★ 定数覆盖表接进 B50**(这条不修的话功能是断的):原来只有 `history.ds_for` 读
|
||||
`ds_override.json`,`b50.ds_of` 不读 —— 用户补完定数,B50 页面还是 9166,永远对不上。
|
||||
现在 `b50.ds_of(music, level, music_id)` 先查覆盖表;即使 music.json 里根本没这首
|
||||
(国服新曲就是这种情况,music 为 None)也能走覆盖表算出 ra。
|
||||
`history` 的 load/save_override 改为委托 b50,两边共用一份实现。
|
||||
实测:覆盖前 ds=None/ra=0 → 覆盖后 ds=14.6/ra=306,B50 与全曲成绩两边结果完全一致。
|
||||
44. **`GET /api/verify` 对分**:客户端 `playerRating` / 服务端 `GetUserRating.rating` /
|
||||
本地按定数算的合计,三个数并排显示,差值不为 0 时直接告诉用户「少了 N 分,M 条定数未收录」。
|
||||
界面在推分卡里加了「核对分数」按钮。
|
||||
45. **全曲成绩缓存**:`history.fetch` 加 5 分钟 TTL 缓存(拉一次要翻十几页,
|
||||
算推分和看成绩连着点时不该重复拉);`/api/history?full=1` 不吃截断。
|
||||
46. **修复**:删除定数覆盖项静默失效(`load_override` 内层 key 是 int,保存路径按 str `pop`,
|
||||
对不上)→ 统一转 str 后删除生效;`stopWait` 未定义 → 加 `endWait` 别名。
|
||||
47. ⏳ **仍待真实数据验收**(要登录):`/api/history` 的真实条数、`/api/recommend` 的真实推荐、
|
||||
`/api/verify` 三处 rating 是否对平(目标要求对上客户端 9168)。
|
||||
|
||||
### 追加(第 16 轮 · ★ rating 对平了:9166 → 9168)
|
||||
|
||||
48. **真实数据验证(不需要登录,`GetUserRating` 是公开接口)**:
|
||||
`GET /api/b50?userId=<真实号>` → `totalRating=9166`、`b35=9166`、`b15=0`、`missing=3`。
|
||||
客户端显示 9168,差 2 分——正是那 3 条定数缺失的 newRatingList。
|
||||
49. **定位到那 3 条是什么**:b15 里 `11806 Expert 10.3913%`、`11836 Master 0%`、
|
||||
`11835 Master 0%`(还有一条 11847 本地已有定数)。ach=0 的三条贡献 0 分,
|
||||
真正差的就是 11806 —— 几乎没打过(10.39%)。
|
||||
50. **补全方式改成自动**:写了 `tools/fetch_df.py`,把水鱼
|
||||
`https://www.diving-fish.com/api/maimaidxprober/music_data`(1404 首,id 与国服 musicId 同源)
|
||||
缓存到 `data/master/music_df.json`(146.6 KB)。
|
||||
`b50.load_music()` 改成「music.json 为主,缺的 id 用水鱼补」,名字与定数一起补上,
|
||||
不再依赖手工填。
|
||||
51. **★ 验证结果**:清空手填覆盖表后重跑 →
|
||||
`11806 Fraq Expert ds=12.2 ra=2`、`11836 ビビデバ ds=11.8`、`11835 はいよろこんで ds=11.6`、
|
||||
`11847 M@GICAL☆CURE! LOVE ♥ SHOT! ds=11.2`,`missing=0`,
|
||||
**`totalRating=9168` —— 与客户端完全一致**。
|
||||
(12.2 × 103913 × 16 // 1e8 = 2,这 2 分就是差的那 2 分。)
|
||||
52. 手填覆盖表仍保留(优先级高于水鱼缓存),作为缓存也没收的新曲兜底。
|
||||
53. ⏳ 剩下要登录才能验的:`/api/history` 真实条数、`/api/recommend` 真实推荐、
|
||||
`/api/verify` 三处 rating 是否显示「已对平」(本地 9168 = 客户端 9168)。
|
||||
|
||||
### 追加(第 17 轮 · 真实数据跑推分 + 摸清全曲接口)
|
||||
|
||||
54. **水鱼缓存合并生效**:`load_music()` 后曲库 1326 → 1411(补了 85 首),
|
||||
11806/11836/11835/11847 的名字与四个难度定数全部到位。
|
||||
`unlock` / `items` 不依赖 `load_music`,所以这个合并不会影响解锁列表。
|
||||
55. **真实 B50 数据跑推分**:拿这个号真实的 50 条(B35+B15)当输入试跑
|
||||
→ 榜尾 ra 254(最終鬼畜妹フランドール・S Master 12.1),候选 12 首,
|
||||
整体潜力 +230(SSS 档)/ +371(SSS+ 档)。
|
||||
推荐前几名:心象蜃気楼 97.2864%(+29)、フォニイ 97.0541%(+29)、ウミユリ海底譚 97.4920%(+28)……
|
||||
9 个不同定数,effort 落在 1.1~3.0 之间(都是「再练一点点就有」的量),结果比假数据合理。
|
||||
56. **★ 摸清 `GetUserMusicApi` 与 `GetUserRating` 的差别**:
|
||||
后者免登录就能查(所以 B50 一直能用),前者**必须挂在真实登录会话上**,
|
||||
否则服务端 HTTP 200 但解密后是空串(Astral 报「返回空明文」)。
|
||||
实测直接调 `api.GetUserMusicDetailAll(<真实 userId>)` 就是这个错。
|
||||
结论:全曲成绩一定得先扫码登录,没有免登录的路子。
|
||||
57. `/api/history` 与 `/api/recommend` 的错误改成一句能照着做的提示
|
||||
(「全曲成绩要登录才能拉:GetUserMusicApi 不接受免登录查询」),不再把 Astral 那一屏甩给用户。
|
||||
58. `recommend.suggest` 的字段读取改成 `.get`(B50 的条目没有 `playCount`,
|
||||
用它当输入时会 KeyError;真实来源 history/board 字段并不完全一致)。
|
||||
59. ⏳ 最后一步:全曲成绩的真实条数只能等扫码验证。
|
||||
|
||||
### 追加(第 18 轮 · 免登录推分 + 真实结果验收)
|
||||
|
||||
60. **`/api/recommend` 支持降级**:全曲成绩要登录,但 B50 那 50 条本身就是「我打过的曲子」,
|
||||
拿来当候选池照样能算推分(池子从几百条缩到 50 条,结论依然成立)。
|
||||
未登录时返回 `degraded=true` 并在界面标黄说明;登录后自动走全量成绩。
|
||||
这样**不用扫码就能验收推分功能**,也是给没登录用户的第一屏体验。
|
||||
61. **★ 真实数据验收(这个号,未登录)**:`GET /api/recommend?userId=…`
|
||||
→ `degraded=true`、候选 12 首、潜力 +230(SS+/SSS/SSS+ 三档 137 / 230 / 371)。
|
||||
推荐结果:心象蜃気楼 Master 13.4(97.2864% → 97.3881%,+29)、
|
||||
フォニイ Re:Master 13.3(97.0541% → 97.3655%,+29)、
|
||||
ウミユリ海底譚 Master 13.4(97.4920% → 97.7612%,+28)、
|
||||
失敗作少女 Master 13.1(+27)、命に嫌われている Master 12.8(+22)。
|
||||
62. **`?uid=` 直达**:URL 带 uid 时自动填输入框并查 B50 + 推分,方便看别人的分,也方便我自己验收。
|
||||
63. **界面重排**:海报下面那块原来的「推分建议」其实是**曲库推荐**(从没打过的曲子里挑),
|
||||
和新加的「按你的成绩算」重复,改成:
|
||||
① 曲库推荐默认只铺 6 张卡(3 行)+「展开全部 24 首」,原来 16 张卡占了两千多像素;
|
||||
② 标题分开成「曲库推荐(还没打的)」和「推分建议(按你的成绩)」。
|
||||
64. ⏳ 全曲成绩的真机拉取仍未验证(`GetUserMusicApi` 必须真实登录会话)。
|
||||
|
||||
### 追加(第 19 轮 · 难度筛选 + 一次白屏事故)
|
||||
|
||||
65. **全曲成绩支持按难度筛**:`/api/history?level=3,4`(0=Bsc 1=Adv 2=Exp 3=Mas 4=ReM),
|
||||
后端先按难度过滤再截断(前端过滤只能滤已截断的 120 条,不准),返回 `shownTotal` 与 `levelFilter`。
|
||||
界面在统计下面加了一排 全部 / EXP / MAS / ReM。
|
||||
66. **★ 白屏事故**:给 `.lvtabs` 加样式时,anchor 找的是 `/* ---------- 推分建议 / 全曲成绩 ---------- */`,
|
||||
但那行注释在 **JS 区**(`go()` 函数后面),不是 `<style>` 里 —— CSS 规则被塞进 JavaScript,
|
||||
整页脚本解析失败,只省下 header 和 tabbar。
|
||||
教训:这个文件样式表与脚本都在同一个 html 里,插 CSS 必须定位到 `</style>` 之前,
|
||||
不能靠注释文字找位置。已修复并把规则插到 `</style>` 前。
|
||||
67. 难度 chips 的状态要在 `renderHistory()` 渲染之后同步(chips 是 innerHTML 生成出来的,
|
||||
渲染前 querySelector 拿不到),否则「全部」不会高亮。
|
||||
68. 新曲封面:`/api/cover/11806`、`11836`、`11835`、`11633` 全部 200(96~340KB),
|
||||
海报里新曲不会开天窗。
|
||||
|
||||
### 追加(第 20 轮 · 功能票精简 + 抓到摊平丢 musicId 的真机 bug)
|
||||
|
||||
69. **功能票去掉 4 倍 / 6 倍**:国服机台不再售这两档,留着只是误点。
|
||||
`charge.TICKETS` 只剩 2/3/5(creditNum 1/2/4),`buy()` 对 4、6 直接拒并说明原因,
|
||||
前端 `<select id="chargeId">` 同步只留三项。
|
||||
70. **★ 静态审查抓到真机必崩的 bug**:`api.GetUserMusicDetailAll` 摊平时会丢 `musicId`。
|
||||
服务端返回的是 `{"userMusicList": [{"musicId": N, "userMusicDetailList": [...]}]}` ——
|
||||
musicId 在**组**这一层,detail 里没有;Astral 只做 `flat.extend(group["userMusicDetailList"])`,
|
||||
没有把父层 musicId 带下来。照原样用,全曲成绩会拿到一堆没有曲号的条目 →
|
||||
定数一律 None → 整页变成「未收录」、ra 全 0、推分也全废。
|
||||
这是只有真机才暴露的问题,我这边没有登录拉不到真实数据,是靠读 Astral 源码发现的。
|
||||
71. **改法**:`history._fetch_items()` 自己调 `get_all_pages` 并摊平,
|
||||
每条 detail 补上父层 `musicId`(detail 自带则尊重它);拿不到 `get_all_pages`
|
||||
时才退回官方封装。
|
||||
72. **Mock 验证**:造了两组 3 条 detail(全部不带 musicId),摊平后
|
||||
`musicId 834/834/11806` 都在,定数 14.9 / 15.0 / 12.2 全部算得出,
|
||||
ra 312 / 291 / 2,`unrated=0`。没这个修复,这三条会是 `musicId=0`、名字空、定数空。
|
||||
|
||||
### 追加(第 21 轮 · ★ 更正上一轮的错误结论)
|
||||
|
||||
73. **更正第 70~72 条**:上一轮说「Astral 摊平时丢 musicId、真机必崩」——**判断错了**。
|
||||
证据在 `Astral/selftest.py:548-555` 的 fixture:
|
||||
```python
|
||||
"userMusicDetailList": {"userMusicList": [
|
||||
{"userMusicDetailList": [
|
||||
{"musicId": 143, "level": 4, "playCount": 7, "achievement": 0,
|
||||
"comboStatus": 0, "syncStatus": 0, "deluxscoreMax": 0, ...}]}]}
|
||||
```
|
||||
外层 group 里**没有** musicId,是**内层 detail 自带** `musicId`。
|
||||
`_dispense.py:30` 的 `{(x["musicId"], x["level"]) for x in ...}` 也印证了这一点
|
||||
(它取的就是摊平后的列表)。所以 `history.fetch` 原来的 `it.get("musicId")` 本来就是对的。
|
||||
74. `_fetch_items()` 仍然保留,但定位改成**加固**而不是修 bug:对四种形状都成立 ——
|
||||
detail 带 musicId(真实形状)原样保留;group 带而 detail 不带则补上;
|
||||
两边都带以 group 为准;两边都没有则记 0。实测:
|
||||
A(143,4)/(143,3)、B(834,3)、C(999,3)、D(0,3)。
|
||||
75. **顺便核对了字段名**:`history._row` 用到的 `achievement / level / playCount /
|
||||
comboStatus / syncStatus` 与 selftest fixture 里的 detail 字段**完全一致**,
|
||||
都是 ×10000 的整数达成率,没有拼写或口径问题。
|
||||
76. 结论:全曲成绩这条链路在代码层面没有已知缺陷,剩下的只有真机跑一次。
|
||||
|
||||
### 追加(第 22 轮 · ★★ 真机验收通过,目标达成)
|
||||
|
||||
**用户在机台扫码登录了**(`/api/state` → `logged=true`,账号 maimai / 128****21),
|
||||
全曲成绩这条链路第一次在真号上跑通。
|
||||
|
||||
77. **全曲成绩(真机)**:`GET /api/history` → **207 条**(每曲每难度一条),涉及 **180 首曲子**
|
||||
- 已算 ra **207 条,未收录 0 条**,总游玩次数 255
|
||||
- AP 19 / FC 33 / FDX 0 / 平均达成率 86.2609% / 最高 ra 292
|
||||
- 难度分布:Basic 26 · Advanced 8 · Expert 47 · Master 111 · Re:Master 12 · 宴 3
|
||||
78. **★ 真机抓到两个只有上机才会暴露的问题**:
|
||||
1. **宴/協/息谱算不出定数**:`111355 [協]ラグトレイン`、`100998 [宴]Oshama Scramble!`、
|
||||
`111597 [息]ノンブレス・オブリージュ` 三条的 `level = 10`,
|
||||
—— 服务端用越界值标记特殊谱面,定数存在 `notes[0]`,原来 `notes[level]` 直接越界返回 None。
|
||||
修:`ds_of()` 里 `level >= 5 且越界` 时改用 `notes[0]`;
|
||||
另加 `level_name()`,这几条的难度名显示「宴」而不是「?」。
|
||||
修完 unrated 3 → 0,三条 ra 分别 292 / 175 / 155
|
||||
(協ラグトレイン 175.3337% 是全场最高分,被 clamp 到 100.5%)。
|
||||
2. **`syncStatus = 5` 被映射成 "sync"**:真机 207 条里 **152 条**是 5,
|
||||
显示出来满屏「sync」纯属误导(EasyMai 表里有这个值,但国服没拿它当 FS/FDX 标记)。
|
||||
修:`_SYNC` 去掉 5,只保留 0~4(''/fs/fsp/fdx/fdxp)。
|
||||
79. **★ 自检路由 `/api/selftest`**(登录后一键跑,用户自己也能用):5/5 全过
|
||||
- 全曲成绩条数:207 条 / 180 首
|
||||
- 定数未收录:全部收录
|
||||
- 达成率非零:平均 86.2609%,最高 ra 292
|
||||
- **B35+B15 合计:本地 9168 / 客户端 9168** ✓
|
||||
- 全曲最高分 vs B50 第一:OMAKENO Stroke(Master 13.8)ra 290 / B50 ra 290 ✓
|
||||
- 这条检查第一版没过:宴谱 協ラグトレイン ra 292 比 B50 第一名还高。
|
||||
不是 bug —— 宴谱不参与 B35 排名,比较时排除 level>=5 即可。
|
||||
80. **★ 全曲模式的推分(真机)**:`/api/recommend` → `degraded=false`,候选池 207 条
|
||||
- 可推 **58 首**,榜尾 ra 254(最終鬼畜妹フランドール・S Master 12.1)
|
||||
- 三档潜力:SS+ **+830** / SSS **+1106** / SSS+ **+1526**
|
||||
- 前几名:INTERNET OVERDOSE 94.2194% → +37、神々が恋した幻想郷 92.5207% → +37、
|
||||
ドクハク 96.4595% → +35、ここからはじまるプロローグ。→ +35
|
||||
- 全部 `onBoard=false`(要顶掉榜尾那类),和降级模式(全是榜内)形成对比
|
||||
81. **登录态持久化验证**:重启服务会清空会话(`logged=false`),
|
||||
但 `POST /api/accounts {action:"switch"}` 用保存的 token 能直接恢复 ——
|
||||
实测恢复后 `isLogin=false / banState=0 / loginDateTime=1793198451`,
|
||||
再拉全曲成绩照常成功。也就是说以后重启不用重新扫码。
|
||||
82. **目标五项全部达成**:① 全曲成绩真机拉通 ✓ ② 定数缺漏(含水鱼补全 + 手填覆盖 + 宴谱兜底)✓
|
||||
③ Ichino 整数 ra 公式 + 推分 ✓ ⑤ **9168 = 9168** ✓(④ 移动端按用户要求未做)
|
||||
@@ -1,182 +0,0 @@
|
||||
# maipulse 功能扩展计划(对齐 Ichino)
|
||||
|
||||
参考实现:`D:\DSH\maimai com\Ichino`(Flutter,eaquira 系列的 Dart 移植)
|
||||
本文件只是计划,**不改动现有任何文件**;确认后再按阶段动手。
|
||||
|
||||
---
|
||||
|
||||
## 一、现状盘点
|
||||
|
||||
已做通(实机验证过):
|
||||
|
||||
| 功能 | 写入字段 | 状态 |
|
||||
| --- | --- | --- |
|
||||
| 发功能票 | UpsertUserChargelog | 通过(5 倍票 stock=1) |
|
||||
| 收集品 / 礼物 | userItemList + isNewItemList | 通过(称号/伙伴/名牌/边框) |
|
||||
| 解锁歌曲 | userItemList kind 5/6/7 | 通过(709 首一包全落地) |
|
||||
| 解小黑屋 | UserLogout | 已改金票流程,待实测 |
|
||||
|
||||
Ichino 有、我们没有的:**B50 查分、修改 Rating、舞里程、一键跑图、万花筒**。
|
||||
|
||||
---
|
||||
|
||||
## 二、要加的五个功能
|
||||
|
||||
### 1. B50 查分(只读,零风险)—— 按 EasyMai 规划
|
||||
|
||||
参考:`https://github.com/Lista233/EasyMai`(Vue/Uni-app,202★)
|
||||
关键文件:`pages/maib50/maib50.vue`、`utils/b50adapter.js`、`utils/ratingUtils.js`
|
||||
|
||||
#### 数据源对比(我们比它更直接)
|
||||
|
||||
EasyMai 的路子是绕的:自建后端 `getUserMusic` + 水鱼 `update_records` 导入,
|
||||
再从水鱼拿回算好的 rating。我们不需要这一层:
|
||||
|
||||
| | EasyMai | 我们 |
|
||||
| --- | --- | --- |
|
||||
| 成绩 | 自建后端 + 水鱼 | `api.GetUserRating(user_id, client_id)` 直取 |
|
||||
| 定数 | 本地 `musicData`(水鱼曲库) | 本地 `data/master/music.json` 的 `notes[]` |
|
||||
| 鉴权 | jwt-token + import-token | 只要 userId + 机台 clientId,**不用登录** |
|
||||
| 写操作 | 往水鱼导入成绩 | 无,纯只读 |
|
||||
|
||||
#### 已验证的接口形状(2026-10-28 实测,账号 12800621)
|
||||
|
||||
GetUserRating -> userRating:
|
||||
rating : 0(服务端不填,别指望它)
|
||||
ratingList : 35 条 {musicId, level, romVersion, achievement}
|
||||
newRatingList : ≤15 条 同上
|
||||
nextRatingList / nextNewRatingList / udemae
|
||||
|
||||
注意 **没有 ra 字段**,必须自己算。定数从
|
||||
`data/master/music.json` 的 `notes[level]` 拼:`level + levelDecimal/10`
|
||||
(例:11633 Master → level 13, levelDecimal 8 → 13.8)。
|
||||
|
||||
#### ra 公式(水鱼标准表,24 档)
|
||||
|
||||
achievement >= 100.5% -> 22.4 >= 95% -> 15.2 >= 82% -> 2.4
|
||||
>= 100% -> 21.6 >= 94% -> 13.6 >= 80% -> 1.6
|
||||
>= 99.5% -> 21.1 >= 93% -> 12.0 >= 78% -> 1.2
|
||||
>= 99% -> 20.8 >= 92% -> 10.4 >= 76% -> 0.8
|
||||
>= 98.5% -> 20.3 >= 91% -> 8.8 >= 74% -> 0.4
|
||||
>= 98% -> 20.0 >= 90% -> 8.0 >= 72% -> 0.2
|
||||
>= 97% -> 17.6 >= 86% -> 4.8 >= 70% -> 0.1
|
||||
>= 96% -> 16.8 >= 84% -> 3.2 其余 -> 0
|
||||
|
||||
ra = floor(ds × coef)
|
||||
|
||||
#### 实测校验(同一账号)
|
||||
|
||||
B35 求和 = 9075,服务端总 rating = 9168,差 93
|
||||
差的来源:newRatingList 4 条里 3 条在国服 music.json 查不到定数(未实装的新曲)
|
||||
|
||||
所以「定数缺失」必须当成正常情况处理,不能当错误。
|
||||
|
||||
#### 展示(照 EasyMai 的结构)
|
||||
|
||||
- 顶部:总 Rating = B35 + B15,配色按 `getCurrentRatingClass()`
|
||||
(≥15000 rainbow / ≥14500 bright-gold / ≥14000 gold / ≥13000 blue / ≥12000 copper)
|
||||
- 两块列表:B35(旧曲 35 条)、B15(新曲最多 15 条),每行
|
||||
曲名 · 难度(Basic/Adv/Exp/Master/Re:Master)· 定数 · 达成率 · ra · FC/FS
|
||||
- FC/FS 适配(`b50adapter.js`):
|
||||
`comboStatus 0/1/2/3/4 -> ''/fc/fcp/ap/app`,
|
||||
`syncStatus 0/1/2/3/4/5 -> ''/fs/fsp/fdx/fdxp/sync`
|
||||
- 定数缺失的行标 `—`,不进合计,并在底部注明"n 首定数缺失"
|
||||
|
||||
#### 第一版不做
|
||||
|
||||
- 封面图(要拉 CDN 并转 base64)
|
||||
- 海报导出、历史快照、分享(EasyMai 本地存 20 条 + 分享图)
|
||||
|
||||
#### 改动面
|
||||
|
||||
core/b50.py 取数 + 算 ra + 组装(约 120 行)
|
||||
app.py + GET /api/b50?userId=
|
||||
index.html + 一张「B50 查分」卡(总分 + 两个列表)
|
||||
|
||||
#### 验收
|
||||
|
||||
- 与游戏内 B50 前三名的 ra 逐个对得上
|
||||
- 总分误差 = 定数缺失那几首的 ra(应能解释清楚,不是bug)
|
||||
|
||||
### 2. 修改 Rating(写,高风险)
|
||||
|
||||
- 字段:`userData[0].playerRating` + `userRatingList[0].rating`,**两处必须同时改且一致**
|
||||
- 范围 0 ~ 99999(Ichino `clampValue`)
|
||||
- 已知后果:下次真实游玩会被服务端按成绩重算覆盖;`ratingList` 里每首的 rating 不动
|
||||
- 新增:`core/rating.py`
|
||||
|
||||
### 3. 舞里程(写,中风险)
|
||||
|
||||
- 字段:`userData[0].point`(int32 余额),可选带 `totalPoint`(累计)
|
||||
- 两种模式:设为 X / 当前值 + X;负数即扣款
|
||||
- 客户端的钳位只发生在「获得」路径(`UserDetail.AddMile` 钳到 99999),商店扣款不钳,
|
||||
所以传大值不会被客户端回写覆盖
|
||||
- 新增:`core/point.py`
|
||||
|
||||
### 4. 一键跑图(写,中风险)
|
||||
|
||||
- 字段:`userMapList` + `isNewMapList`,主键 mapId,每行
|
||||
`{mapId, distance, isLock, isClear, isComplete, unlockFlag}`
|
||||
- 关键:`isClear` / `isComplete` 在客户端是**从 distance 派生**的
|
||||
(`MapMaster.CreateUserDataMapList`),单发 flag 下次进区域选择就被冲掉,
|
||||
所以必须把 `distance` 直接推到 `UserMapData.MaxDistance = 999999999`
|
||||
- `unlockFlag` 语义是反的:`IsFinishedOpening ? 0 : 1`,取 0 才是「已开启」
|
||||
- 需要先 `api.GetUserMapAll()` 拿现有行合并(保留已有进度,别清零)
|
||||
- 新增:`core/maprun.py`
|
||||
|
||||
### 5. 万花筒专区(写,高风险)
|
||||
|
||||
- 字段:`userKaleidxScopeList` + `isNewKaleidxScopeList`
|
||||
- 每行是**整行替换**(15 字段),`isGateFound` / `isKeyFound` / `isClear` 同在一条
|
||||
- 陷阱:不先回读原行合并成绩 / 日期 / playCount,会把已有记录清零
|
||||
- 需先 `api.GetUserKaleidxScope()`
|
||||
- 新增:`core/kaleidx.py`
|
||||
|
||||
---
|
||||
|
||||
## 三、所有写入项的统一前置(这次踩坑换来的)
|
||||
|
||||
1. **先查票**:`GetUserCharge` 里任何 `stock > 0` 的功能票都会让 UpsertUserAll 整包被判非法,
|
||||
先拦下来提示「号上有票,先清票/等过期」;
|
||||
2. **等满 60 秒**:登录后不足一分钟的结算包会被静默丢弃 → 复用 `settle.ensure_play_window`;
|
||||
3. **一条占位 playlog + 一条占位 detail**(大包模式),跟发收藏品一致;
|
||||
4. **一包一局**:每次 `session.fresh` 换局,一局只收一次 UpsertUserAll;
|
||||
5. 每项写完都回读校验(`GetUserData` / `GetUserMap` / `GetUserRating`),不回读不算成功。
|
||||
|
||||
---
|
||||
|
||||
## 四、实施顺序
|
||||
|
||||
| 阶段 | 内容 | 风险 | 验收方式 |
|
||||
| --- | --- | --- | --- |
|
||||
| A | B50 查分 | 无(只读) | 页面显示总分与 50 首,和游戏内对得上 |
|
||||
| B | 舞里程、一键跑图 | 中(可回退) | 改完回读数值;游戏内看里程/区域状态 |
|
||||
| C | 修改 Rating、万花筒 | 高 | 改完回读;rating 以游戏内显示为准 |
|
||||
|
||||
---
|
||||
|
||||
## 五、改动面(确认后才动)
|
||||
|
||||
新增文件(互不影响):
|
||||
|
||||
core/b50.py core/rating.py core/point.py core/maprun.py core/kaleidx.py
|
||||
|
||||
追加式改动(只在文件末尾加,不动已有逻辑):
|
||||
|
||||
app.py +6 条路由(/api/b50, /api/rating, /api/point, /api/map, /api/kaleidx, /api/kaleidx/list)
|
||||
html/index.html +3 张卡(B50 查分 / 数值修改 / 拓展功能)
|
||||
|
||||
复用不修改:
|
||||
|
||||
core/settle.py(60 秒窗口、占位 playlog)
|
||||
core/session.py(登录/换局/登出)
|
||||
core/items.py(GetUserItem 封装,用来回读)
|
||||
core/charge.py(查票的闸门)
|
||||
|
||||
---
|
||||
|
||||
## 六、不做的事
|
||||
|
||||
- 不移植 Ichino 的 B50 PNG 海报(要引图表依赖,先上表格)
|
||||
- 不动现有的六项功能与其界面
|
||||
- 不引入 Flutter 那套 UI,继续用当前单页
|
||||
@@ -1,174 +0,0 @@
|
||||
# maipulse 发包工具 — 实施计划
|
||||
|
||||
> 目标:舞萌DX 国服(SDGB)本地发包工具,六项功能,GUI 操作,协议复用 Astral。
|
||||
> 计划版本:v1(2026-10-21),发票口径已确认 = 2/3/5 倍功能票(charge 体系)。
|
||||
|
||||
---
|
||||
|
||||
## 一、目标与范围
|
||||
|
||||
| 功能 | 协议落点 | 状态 |
|
||||
|---|---|---|
|
||||
| 发票(2/3/5 倍功能票) | `UpsertUserChargelogApi`(chargeId 2/3/5) | 复用 Astral `buy_ticket.py` 链路 |
|
||||
| 解禁小黑屋 | `UserLogoutApi`(type 5/4)+ `GetUserPreview.banState` 检测 | 待实现(会话解卡口径) |
|
||||
| 解锁伙伴 | `userItemList` itemKind=10(Partner) | 待实现 |
|
||||
| 发礼物 | `userItemList` itemKind=4(Present) | 待实现 |
|
||||
| 发收藏品 | `userItemList` itemKind=1/2/3/9/11(Plate/Title/Icon/Character/Frame) | 待实现 |
|
||||
| 解锁歌曲(一键 / 选中) | `userItemList` itemKind=5/6/7 | 内核已有,需改成真全量+分批 |
|
||||
|
||||
非目标:不做修改客户端、不做注入、不做成绩伪造以外的机台侧改动。
|
||||
|
||||
---
|
||||
|
||||
## 二、现状(已核实)
|
||||
|
||||
1. 协议层:`D:\DSH\maimai\Astral`(主分支 mai),`api/` 40+ 接口,`MaimaiNet.py` 负责 AES-256-CBC + zlib + MD5 URL 混淆 + Cookie 会话。
|
||||
2. 环境:Python 3.13 + flask 3.1.0 / requests 2.32.5 / pycryptodome 3.23.0 / openpyxl 3.1.5,全部可用。
|
||||
3. 机台身份:`config.py` 已配 CLIENT_ID `A63E01E3636`、PLACE_ID `2282`、REGION_ID `15` → KEYCHIP_ID `A63E-01E36362282`。
|
||||
4. 反编译源码:`sdgb-unpack\full.cs\Assembly-CSharp.unpack.decompiled.cs`(9.0 MB),`ItemKind` 枚举已确认 15 种。
|
||||
5. 数据表:`maipulse\data\master\` 下 ticket / partner / title / icon / plate / frame / chara / music 八张。
|
||||
6. 店铺表:`SDGB_keychips_20260929.csv.xlsx`,3383 行,字段 client_id / place_id / name / region_id / region_name;**place_id 混十进制与十六进制**(`859` 与 `"082f"` 并存),必须统一解析。
|
||||
7. 现有 GUI:`maipulse\app.py`(Flask,400 行)+ `html\index.html`(六面板 + 选择器),内核有下述 5 个必修缺陷。
|
||||
|
||||
---
|
||||
|
||||
## 三、六项功能的实现定义
|
||||
|
||||
### 1. 发票(确认口径:2/3/5 倍功能票)
|
||||
|
||||
- 接口:`UpsertUserChargelogApi`,走 Astral `api.UpsertUserChargelog()`。
|
||||
- chargeId 与上报 price(= ticket.creditNum,不是金额):
|
||||
- 2 → 2倍チケット,price 1
|
||||
- 3 → 3倍チケット,price 2
|
||||
- 5 → 5倍チケット,price 4
|
||||
- 调用链:`ParseQRCode → GetUserPreview(顶号)→ UserLogin → GetUserCharge(查重)→ 等 15s → UpsertUserChargelog → 再查一次确认 → UserLogout(TestIn)`
|
||||
- 内部已有闸门:chargeId 必须是付费票(ticketKind=1)、`stock > 0` 直接拒(重复购买有封号风险)、同账号同进程只允许一次。
|
||||
- UI:票种选择 2/3/5、当前持有列表(chargeId / stock / validDate)、购买按钮、预检开关。
|
||||
|
||||
### 2. 解禁小黑屋
|
||||
|
||||
- 口径:账号卡在 `isLogin=True`(扫码提示已登录、机台进不去)。
|
||||
- 做法:先 `GetUserPreview` 读状态;能拿到 preview 说明二维码有效,直接 `UserLogin` 后立刻 `UserLogout(type=5 Quit 或 4 TestIn)`,loginDateTime 用本次 login 返回的值。
|
||||
- `banState != 0`(橙/红限制)属于服务端下发字段,客户端写不进 `userData`,只做**检测 + 明示**,不伪造解除。
|
||||
- UI:一键解卡 + 状态展示(isLogin / banState / lastLoginDate)。
|
||||
|
||||
### 3. 解锁伙伴
|
||||
|
||||
- `userItemList` 条目 `{"itemKind":10,"itemId":<partnerId>,"stock":1,"isValid":true}`。
|
||||
- 数据源:`data/master/partner.json`(id / name / lock / norm)。
|
||||
- 可选增强:`userIntimateList`(主键 partnerId,字段 intimateLevel / intimateCountRewarded)——单独开关,默认不动。
|
||||
|
||||
### 4. 发礼物
|
||||
|
||||
- `userItemList` 条目 itemKind=4(Present),id 表待补:现有 `master.json` 无 present 分类,需要从 `sdgb.lemonno.xyz` 或反编译 `PresentData` 表导出。
|
||||
|
||||
### 5. 发收藏品
|
||||
|
||||
- 一个通用发放器按 kind 切表:
|
||||
- 1 Plate(`plate.json`)
|
||||
- 2 Title(`title.json`,724 KB)
|
||||
- 3 Icon(`icon.json`)
|
||||
- 9 Character(`chara.json`)
|
||||
- 11 Frame(`frame.json`)
|
||||
- UI:分类切换 + 搜索 + 多选 + 数量。
|
||||
|
||||
### 6. 解锁歌曲
|
||||
|
||||
- itemKind:5 本体 / 6 Master / 7 Re:Master(8 = Strong,前端标签需改)。
|
||||
- 规则:musicId < 10000 的标准谱 Master/Re 天生解锁,不写 6/7;只写 5。
|
||||
- 一键全量:`music.json` 全表 → 减掉 `GetUserItem(5)` 已有 → 分批(单包 ≤4 首,规避 2 首包被拒,按 4/3/3 切)→ 每包前 fresh session(登出 + 重登)→ 落库回查。
|
||||
- 选中解锁:id 列表直接走 `UpsertUserAll(unlock_music_ids=[...])`。
|
||||
|
||||
---
|
||||
|
||||
## 四、内核必修(审计结论)
|
||||
|
||||
1. **位串长度错**:`_push_items` 发完整 `userItemList`,却只给 `"1"*新增数` 的 `isNewItemList`。`BuildListData` 要求一位一条 → 必须逐条 0(存量)/1(新增),否则 500 或重复插入。
|
||||
2. **全量解锁是假的**:`unlock_music_ids=[-1]` 会被 `build_unlock_items` 直接丢弃(只认 >0 的 id)。必须换成真实全表 + 分批。
|
||||
3. **假成绩风险**:固定塞 `music 11308 / level 3 / achievement 0` 的 playlog,服务端可能不收;改为最小合法 playlog,且不污染账号统计(playCount 增量可控)。
|
||||
4. **每包新会话**:服务端把一次登录当一局,一局只收一次 Upsert → 批量流程强制 `_fresh_session`。
|
||||
5. **kind8 命名**:Survival → Strong(`MusicUnlockStrongList`)。
|
||||
6. **place_id 解析**:xlsx 里 `"082f"` 按十六进制 → 2095,`859` 按十进制;统一转 int 后再拼 KEYCHIP。
|
||||
|
||||
---
|
||||
|
||||
## 五、架构
|
||||
|
||||
```
|
||||
maipulse/
|
||||
app.py Flask 路由(/api/qr /login /logout /charge /grant /unlock /repair /shop /master)
|
||||
core/session.py 会话:ParseQRCode、GetUserPreview、UserLogin、fresh_session、UserLogout
|
||||
core/charge.py 功能票:GetUserCharge 查重 → 15s → UpsertUserChargelog → 回查
|
||||
core/grant.py 通用道具:GetUserItem(kind) → 合并 → 位串 → UpsertUserAll patch
|
||||
core/unlock.py 歌曲:全量分批 / 选中
|
||||
core/repair.py 小黑屋:状态检测 + 解卡登出
|
||||
core/shop.py xlsx 解析 + 店铺搜索 + config 回写 + KEYCHIP 校验
|
||||
data/master/*.json
|
||||
html/index.html 六面板
|
||||
```
|
||||
|
||||
原则:协议层只 import Astral,不复制、不 fork;业务层只做编排与数据合并;前端只发 HTTP。
|
||||
|
||||
---
|
||||
|
||||
## 六、里程碑
|
||||
|
||||
| 阶段 | 内容 | 验收 |
|
||||
|---|---|---|
|
||||
| M0 冒烟 | 跑 `selftest.py`、起 maipulse、dry-run 空包 | 284 项绿 + 页面可开 |
|
||||
| M1 内核 | 位串 / 全量 / playlog / fresh session / 回查 | dry-run 报文节点与位串长度一致 |
|
||||
| M2 功能 | 票 → 伙伴 → 收藏品 → 礼物 → 歌曲 → 小黑屋 | 每项 dry-run + 单条实发 + Get 回查 |
|
||||
| M3 店铺 | xlsx 解析(hex 修正)+ 店铺选择 + config 回写 | KEYCHIP 与表内一致 |
|
||||
| M4 界面 | 六面板 + 状态卡 + 错误详情 + 日志 | 全流程浏览器操作 |
|
||||
| M5 验收 | 逐功能 预检 → 实发 → 游戏内确认 | 验收记录 + 截图 |
|
||||
|
||||
---
|
||||
|
||||
## 七、风险与验证口径
|
||||
|
||||
- 一局一 Upsert、单包 ≤4 首、2 首包被拒 → 分批固定 4/3/3。
|
||||
- 重复插入已有条目 → HTTP 500;所有发放前必须先 Get 差集。
|
||||
- `banState` 只读,不伪造。
|
||||
- 二维码 10 分钟过期,一过期就得回机台重扫。
|
||||
- 所有写操作默认 dry-run,UI 上必须显式勾选「真实发包」。
|
||||
- 每步落库回查(GetUserItem / GetUserCharge),以服务器回读为准,不以本地构造为准。
|
||||
|
||||
---
|
||||
|
||||
## 八、实测结论(2026-10-27)
|
||||
|
||||
### ★ 一局至少 60 秒 —— 之前所有"回码 1 但不落库"的真正原因
|
||||
|
||||
MaiTechUI 金票包 UpsertUserAllApi.poop() 原样:
|
||||
|
||||
long num = Configs.getNowUnixTime() - Configs.NowLoginTime;
|
||||
Thread.Sleep(Configs.LogoutTimeLimit - (int)num * 1000); // LogoutTimeLimit = 60000
|
||||
return base.poop();
|
||||
|
||||
它从不在登录后 60 秒内提交结算。服务端对"登录不到一分钟就交成绩"的包一律回
|
||||
returnCode=1 然后把 userItemList / userMusicDetailList 整块丢掉。
|
||||
|
||||
已验证(账号 12042860):每次发包前补足 60 秒后,四类道具全部真落地。
|
||||
|
||||
| kind | 名称 | 发送 id | 库存变化 | waited |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 2 | 称号 | 32 | 147 → 148 | 57.7s |
|
||||
| 10 | 伙伴 | 12 | 22 → 23 | 52.6s |
|
||||
| 1 | 名牌 | 2 | 49 → 50 | 57.4s |
|
||||
| 11 | 边框 | 2 | 41 → 42 | 56.8s |
|
||||
|
||||
实现:core/settle.py:ensure_play_window(),登录时在 state 里记 login_at,
|
||||
items.grant / unlock.unlock_selected / unlock.unlock_all 在发 UpsertUserAll 前调用。
|
||||
|
||||
### 本局必须是 dummy 曲 11538 的自洽全 Miss
|
||||
|
||||
除了 playlog,还要把这一局的四处痕迹一起对上(见 settle.py 头注释):
|
||||
userData.lastPlayDate/dateTime、userMusicDetailList + isNewMusicDetailList、
|
||||
userGamePlaylogList(playTrack=1 / playlogId=loginId / playSpecial 随机置换)、
|
||||
userPlaylogList(achievement=0 且有 miss)。
|
||||
|
||||
### 重启必须走 tools/restart.ps1
|
||||
|
||||
直接杀进程会把服务端会话留在 isLogin=True,账号进小黑屋(登录回 100),
|
||||
且 loginId 只存在内存里,事后连 UserLogout 都发不对。
|
||||
/api/shutdown 会先登出再退出。
|
||||
+89
-5
@@ -25,7 +25,7 @@ APP_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
LOG_DIR = os.path.join(APP_DIR, "logs")
|
||||
os.makedirs(LOG_DIR, exist_ok=True)
|
||||
LOG_PATH = os.path.join(LOG_DIR, "app.log")
|
||||
ASTRAL = os.environ.get("ASTRAL_DIR", r"D:\DSH\maimai\Astral")
|
||||
ASTRAL = os.environ.get("ASTRAL_DIR", r"<astral>")
|
||||
for path in (APP_DIR, ASTRAL):
|
||||
if path not in sys.path:
|
||||
sys.path.insert(0, path)
|
||||
@@ -770,6 +770,82 @@ def assets(name):
|
||||
|
||||
# ---------------------------------------------------------------- 数据表
|
||||
|
||||
#: addVersion -> 界面显示名(原代号里补一个空格,maimaDX 缩成 DX)
|
||||
_VER_LABEL = {
|
||||
"maimai": "maimai", "maimaiPLUS": "maimai PLUS",
|
||||
"GreeN": "GreeN", "GreeNPLUS": "GreeN PLUS",
|
||||
"ORANGE": "ORANGE", "ORANGEPLUS": "ORANGE PLUS",
|
||||
"PiNK": "PiNK", "PiNKPLUS": "PiNK PLUS",
|
||||
"MURASAKi": "MURASAKi", "MURASAKiPLUS": "MURASAKi PLUS",
|
||||
"MiLK": "MiLK", "MiLKPLUS": "MiLK PLUS",
|
||||
"FiNALE": "FiNALE", "maimaDX": "DX",
|
||||
"Splash": "Splash", "UNiVERSE": "UNiVERSE",
|
||||
"FESTiVAL": "FESTiVAL", "PRiSM": "PRiSM",
|
||||
"PRiSMPLUS": "PRiSM PLUS", "BUDDiES": "BUDDiES",
|
||||
}
|
||||
|
||||
|
||||
def _music_groups(items):
|
||||
"""曲目分类:全曲 → 各版本 → 当前版本 → 当前版本各差分包 → 无归属。
|
||||
|
||||
版本组只统计 A000 基础包里的曲目;当前版本组是增量包里出现的那一版
|
||||
(曲目全在 A011~A041 里,A000 里已经没有它的目录了),含该版本全部曲目;
|
||||
差分包组是当前版本的细分。三者相加会重复 73 首,界面上标注清楚即可。
|
||||
"""
|
||||
by_id = {it.get("id"): it for it in items}
|
||||
ver = {} # addVersion -> {"ids": [...], "order": 最早 version}
|
||||
pack = {} # 包号 -> [id]
|
||||
loose = [] # 哪个包都没有的(水鱼曲库补进来的)
|
||||
all_ids = []
|
||||
for it in items:
|
||||
mid = it.get("id")
|
||||
all_ids.append(mid)
|
||||
pk = (it.get("pack") or "").strip()
|
||||
if pk == "A000":
|
||||
av = it.get("addVersion") or ""
|
||||
node = ver.setdefault(av, {"ids": [], "order": it.get("version") or 0})
|
||||
node["ids"].append(mid)
|
||||
node["order"] = min(node["order"], it.get("version") or 0)
|
||||
elif pk:
|
||||
pack.setdefault(pk, []).append(mid)
|
||||
else:
|
||||
loose.append(mid)
|
||||
|
||||
groups = [{"key": "all", "label": "全曲解锁", "kind": "all",
|
||||
"count": len(all_ids), "ids": all_ids}]
|
||||
for av in sorted(ver, key=lambda k: (ver[k]["order"], k)):
|
||||
ids = ver[av]["ids"]
|
||||
groups.append({"key": "ver:" + (av or "-"),
|
||||
"label": _VER_LABEL.get(av, av or "未标注"),
|
||||
"kind": "version", "count": len(ids), "ids": ids})
|
||||
|
||||
# 当前版本:只出现在增量包里的那一版,把该版本全部曲目合成一组
|
||||
cur = {}
|
||||
for mids in pack.values():
|
||||
for mid in mids:
|
||||
av = (by_id.get(mid) or {}).get("addVersion") or ""
|
||||
node = cur.setdefault(av, {"ids": [], "order": 0})
|
||||
node["ids"].append(mid)
|
||||
node["order"] = min(node["order"] or 10 ** 9,
|
||||
(by_id.get(mid) or {}).get("version") or 0)
|
||||
for av in sorted(cur, key=lambda k: (cur[k]["order"], k)):
|
||||
if av in ver:
|
||||
continue # A000 里还有这一版,走上面的版本组,不重复
|
||||
ids = sorted(cur[av]["ids"])
|
||||
groups.append({"key": "cur:" + (av or "-"),
|
||||
"label": (_VER_LABEL.get(av, av or "未标注") + "(当前版本)"),
|
||||
"kind": "current", "count": len(ids), "ids": ids})
|
||||
|
||||
for pk in sorted(pack, reverse=True):
|
||||
ids = pack[pk]
|
||||
groups.append({"key": "pack:" + pk, "label": pk + " 更新",
|
||||
"kind": "pack", "count": len(ids), "ids": ids})
|
||||
if loose:
|
||||
groups.append({"key": "loose", "label": "无归属", "kind": "loose",
|
||||
"count": len(loose), "ids": loose})
|
||||
return groups
|
||||
|
||||
|
||||
@app.get("/api/master")
|
||||
def api_master():
|
||||
path = os.path.join(APP_DIR, "data", "master.json")
|
||||
@@ -781,8 +857,16 @@ def api_master():
|
||||
if cat:
|
||||
if cat not in data:
|
||||
return fail("没有这个分类:" + cat)
|
||||
return ok(cat=cat, kind=data[cat]["kind"], count=data[cat]["count"],
|
||||
items=data[cat]["items"])
|
||||
node = data[cat]
|
||||
extra = {}
|
||||
if cat == "music":
|
||||
extra["groups"] = _music_groups(node.get("items") or [])
|
||||
if request.args.get("brief"):
|
||||
# 只要分类清单(面板上的版本下拉用),不背 1343 条曲目过来
|
||||
return ok(cat=cat, kind=node["kind"], count=node["count"],
|
||||
groups=extra["groups"])
|
||||
return ok(cat=cat, kind=node["kind"], count=node["count"],
|
||||
items=node["items"], **extra)
|
||||
return ok(cats={k: {"kind": v["kind"], "count": v["count"]} for k, v in data.items()})
|
||||
|
||||
|
||||
@@ -790,8 +874,8 @@ def api_master():
|
||||
|
||||
# ---------------------------------------------------------------- 更新
|
||||
|
||||
APP_VERSION = "1.0.1"
|
||||
APP_BUILD = "2026-10-29"
|
||||
APP_VERSION = "1.0.2"
|
||||
APP_BUILD = "2026-10-30"
|
||||
|
||||
|
||||
@app.get("/api/version")
|
||||
|
||||
@@ -9,6 +9,6 @@ import os
|
||||
import sys
|
||||
|
||||
# ★ 让 core.* 无论从哪个入口 import 都能找到 api 包(Flask、自检脚本、CLI 都一样)
|
||||
_ASTRAL = os.environ.get("ASTRAL_DIR", r"D:\DSH\maimai\Astral")
|
||||
_ASTRAL = os.environ.get("ASTRAL_DIR", r"<astral>")
|
||||
if os.path.isdir(_ASTRAL) and _ASTRAL not in sys.path:
|
||||
sys.path.insert(0, _ASTRAL)
|
||||
@@ -22,7 +22,7 @@ BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
DATA_DIR = os.path.join(BASE_DIR, "data")
|
||||
SHOP_CACHE = os.path.join(DATA_DIR, "shops.json")
|
||||
CURRENT_FILE = os.path.join(DATA_DIR, "shop_current.json")
|
||||
XLSX_PATH = os.environ.get("SDGB_SHOPS_XLSX", r"D:\DSH\maimai\SDGB_keychips_20260929.csv.xlsx")
|
||||
XLSX_PATH = os.environ.get("SDGB_SHOPS_XLSX", r"<项目根>\SDGB_keychips_20260929.csv.xlsx")
|
||||
|
||||
|
||||
def to_int(value) -> int:
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"ticket":{"kind":"ticket","count":12,"items":[{"id":2,"name":"2倍チケット","detail":"区域前进2倍票","areaPercent":200,"charaMagnification":200,"isMoney":true,"creditNum":1,"maxCount":0,"kind":"Paid","event":"無期限常時解放"},{"id":3,"name":"3倍チケット","detail":"区域前进3倍票","areaPercent":300,"charaMagnification":300,"isMoney":true,"creditNum":2,"maxCount":0,"kind":"Paid","event":"無期限常時解放"},{"id":4,"name":"4倍チケット","detail":"区域前进4倍票","areaPercent":400,"charaMagnification":400,"isMoney":true,"creditNum":3,"maxCount":0,"kind":"Paid","event":"解放なし"},{"id":5,"name":"5倍チケット","detail":"区域前进5倍票","areaPercent":500,"charaMagnification":500,"isMoney":true,"creditNum":4,"maxCount":0,"kind":"Paid","event":"解放なし"},{"id":6,"name":"6倍チケット","detail":"区域前进6倍票","areaPercent":600,"charaMagnification":600,"isMoney":true,"creditNum":5,"maxCount":0,"kind":"Paid","event":"解放なし"},{"id":10005,"name":"イベント5倍チケット_1","detail":"区域前进5倍票","areaPercent":500,"charaMagnification":100,"isMoney":false,"creditNum":0,"maxCount":1,"kind":"Event","event":"250611_06_1:イベント5倍チケット配布_1(1.50予備)"},{"id":10105,"name":"イベント5倍チケット_2","detail":"区域前进5倍票","areaPercent":500,"charaMagnification":100,"isMoney":false,"creditNum":0,"maxCount":1,"kind":"Event","event":"250611_06_2:イベント5倍チケット配布_2(1.50予備)"},{"id":10205,"name":"イベント5倍チケット_3","detail":"区域前进5倍票","areaPercent":500,"charaMagnification":100,"isMoney":false,"creditNum":0,"maxCount":1,"kind":"Event","event":"250611_06_3:イベント5倍チケット配布_3(1.50予備)"},{"id":11001,"name":"無料1.5倍チケット","detail":"区域前进1.5倍票","areaPercent":150,"charaMagnification":150,"isMoney":false,"creditNum":0,"maxCount":5,"kind":"Free","event":"無期限常時解放"},{"id":30001,"name":"連動チケット","detail":"特殊功能票","areaPercent":200,"charaMagnification":100,"isMoney":false,"creditNum":0,"maxCount":1,"kind":"Link","event":"無期限常時解放"},{"id":30002,"name":"每周区域前进2倍票","detail":"每周区域前进2倍票","areaPercent":200,"charaMagnification":100,"isMoney":false,"creditNum":0,"maxCount":1,"kind":"ShopWeekly","event":"無期限常時解放"},{"id":30003,"name":"旅行伙伴等级提升5倍票","detail":"旅行伙伴等级提升5倍票","areaPercent":100,"charaMagnification":500,"isMoney":false,"creditNum":0,"maxCount":5,"kind":"ShopChara","event":"無期限常時解放"}]},"partner":{"kind":"partner","count":23,"items":[{"id":1,"name":"でらっくま","genre":"ALL","release":"Ver1.05.00","lock":"None","norm":"はじめから所持","isDefault":true,"disable":false},{"id":11,"name":"乙姫","genre":"ALL","release":"Ver1.05.00","lock":"None","norm":"乙姫のスタンプを10個集める","isDefault":false,"disable":false},{"id":12,"name":"ラズ","genre":"ALL","release":"Ver1.05.00","lock":"None","norm":"ラズのスタンプを10個集める","isDefault":false,"disable":false},{"id":13,"name":"シフォン","genre":"ALL","release":"Ver1.05.00","lock":"None","norm":"シフォンのスタンプを10個集める","isDefault":false,"disable":false},{"id":14,"name":"ソルト","genre":"ALL","release":"Ver1.05.00","lock":"None","norm":"ソルトのスタンプを10個集める","isDefault":false,"disable":false},{"id":15,"name":"しゃま","genre":"ALL","release":"Ver1.05.00","lock":"None","norm":"しゃまのスタンプを10個集める","isDefault":false,"disable":false},{"id":16,"name":"みるく","genre":"ALL","release":"Ver1.05.00","lock":"None","norm":"みるくのスタンプを10個集める","isDefault":false,"disable":false},{"id":17,"name":"らいむっくま&れもんっくま","genre":"ALL","release":"Ver1.10.00","lock":"None","norm":"はじめから所持","isDefault":true,"disable":false},{"id":18,"name":"乙姫(すぷらっしゅ)","genre":"ALL","release":"Ver1.15.00","lock":"None","norm":"乙姫(すぷらっしゅ)のスタンプを10個集める","isDefault":false,"disable":false},{"id":19,"name":"しゃま(ゆにばーす)","genre":"ALL","release":"Ver1.20.00","lock":"None","norm":"しゃま(ゆにばーす)のスタンプを10個集める","isDefault":false,"disable":false},{"id":20,"name":"みるく(ゆにばーす)","genre":"ALL","release":"Ver1.20.00","lock":"None","norm":"みるく(ゆにばーす)のスタンプを10個集める","isDefault":false,"disable":false},{"id":21,"name":"ちびみるく","genre":"ALL","release":"Ver1.25.00","lock":"None","norm":"ちびみるくのスタンプを10個集める","isDefault":false,"disable":false},{"id":22,"name":"百合咲ミカ","genre":"ALL","release":"Ver1.25.00","lock":"None","norm":"百合咲ミカのスタンプを10個集める","isDefault":false,"disable":false},{"id":2Line truncated
|
||||
{"ticket":{"kind":"ticket","count":12,"items":[{"id":2,"name":"2倍チケット","detail":"区域前进2倍票","areaPercent":200,"charaMagnification":200,"isMoney":true,"creditNum":1,"maxCount":0,"kind":"Paid","event":"無期限常時解放"},{"id":3,"name":"3倍チケット","detail":"区域前进3倍票","areaPercent":300,"charaMagnification":300,"isMoney":true,"creditNum":2,"maxCount":0,"kind":"Paid","event":"無期限常時解放"},{"id":4,"name":"4倍チケット","detail":"区域前进4倍票","areaPercent":400,"charaMagnification":400,"isMoney":true,"creditNum":3,"maxCount":0,"kind":"Paid","event":"解放なし"},{"id":5,"name":"5倍チケット","detail":"区域前进5倍票","areaPercent":500,"charaMagnification":500,"isMoney":true,"creditNum":4,"maxCount":0,"kind":"Paid","event":"解放なし"},{"id":6,"name":"6倍チケット","detail":"区域前进6倍票","areaPercent":600,"charaMagnification":600,"isMoney":true,"creditNum":5,"maxCount":0,"kind":"Paid","event":"解放なし"},{"id":10005,"name":"イベント5倍チケット_1","detail":"区域前进5倍票","areaPercent":500,"charaMagnification":100,"isMoney":false,"creditNum":0,"maxCount":1,"kind":"Event","event":"250611_06_1:イベント5倍チケット配布_1(1.50予備)"},{"id":10105,"name":"イベント5倍チケット_2","detail":"区域前进5倍票","areaPercent":500,"charaMagnification":100,"isMoney":false,"creditNum":0,"maxCount":1,"kind":"Event","event":"250611_06_2:イベント5倍チケット配布_2(1.50予備)"},{"id":10205,"name":"イベント5倍チケット_3","detail":"区域前进5倍票","areaPercent":500,"charaMagnification":100,"isMoney":false,"creditNum":0,"maxCount":1,"kind":"Event","event":"250611_06_3:イベント5倍チケット配布_3(1.50予備)"},{"id":11001,"name":"無料1.5倍チケット","detail":"区域前进1.5倍票","areaPercent":150,"charaMagnification":150,"isMoney":false,"creditNum":0,"maxCount":5,"kind":"Free","event":"無期限常時解放"},{"id":30001,"name":"連動チケット","detail":"特殊功能票","areaPercent":200,"charaMagnification":100,"isMoney":false,"creditNum":0,"maxCount":1,"kind":"Link","event":"無期限常時解放"},{"id":30002,"name":"每周区域前进2倍票","detail":"每周区域前进2倍票","areaPercent":200,"charaMagnification":100,"isMoney":false,"creditNum":0,"maxCount":1,"kind":"ShopWeekly","event":"無期限常時解放"},{"id":30003,"name":"旅行伙伴等级提升5倍票","detail":"旅行伙伴等级提升5倍票","areaPercent":100,"charaMagnification":500,"isMoney":false,"creditNum":0,"maxCount":5,"kind":"ShopChara","event":"無期限常時解放"}]},"partner":{"kind":"partner","count":23,"items":[{"id":1,"name":"でらっくま","genre":"ALL","release":"Ver1.05.00","lock":"None","norm":"はじめから所持","isDefault":true,"disable":false},{"id":11,"name":"乙姫","genre":"ALL","release":"Ver1.05.00","lock":"None","norm":"乙姫のスタンプを10個集める","isDefault":false,"disable":false},{"id":12,"name":"ラズ","genre":"ALL","release":"Ver1.05.00","lock":"None","norm":"ラズのスタンプを10個集める","isDefault":false,"disable":false},{"id":13,"name":"シフォン","genre":"ALL","release":"Ver1.05.00","lock":"None","norm":"シフォンのスタンプを10個集める","isDefault":false,"disable":false},{"id":14,"name":"ソルト","genre":"ALL","release":"Ver1.05.00","lock":"None","norm":"ソルトのスタンプを10個集める","isDefault":false,"disable":false},{"id":15,"name":"しゃま","genre":"ALL","release":"Ver1.05.00","lock":"None","norm":"しゃまのスタンプを10個集める","isDefault":false,"disable":false},{"id":16,"name":"みるく","genre":"ALL","release":"Ver1.05.00","lock":"None","norm":"みるくのスタンプを10個集める","isDefault":false,"disable":false},{"id":17,"name":"らいむっくま&れもんっくま","genre":"ALL","release":"Ver1.10.00","lock":"None","norm":"はじめから所持","isDefault":true,"disable":false},{"id":18,"name":"乙姫(すぷらっしゅ)","genre":"ALL","release":"Ver1.15.00","lock":"None","norm":"乙姫(すぷらっしゅ)のスタンプを10個集める","isDefault":false,"disable":false},{"id":19,"name":"しゃま(ゆにばーす)","genre":"ALL","release":"Ver1.20.00","lock":"None","norm":"しゃま(ゆにばーす)のスタンプを10個集める","isDefault":false,"disable":false},{"id":20,"name":"みるく(ゆにばーす)","genre":"ALL","release":"Ver1.20.00","lock":"None","norm":"みるく(ゆにばーす)のスタンプを10個集める","isDefault":false,"disable":false},{"id":21,"name":"ちびみるく","genre":"ALL","release":"Ver1.25.00","lock":"None","norm":"ちびみるくのスタンプを10個集める","isDefault":false,"disable":false},{"id":22,"name":"百合咲ミカ","genre":"ALL","release":"Ver1.25.00","lock":"None","norm":"百合咲ミカのスタンプを10個集める","isDefault":false,"disable":false},{"id":2Line truncated
|
||||
@@ -1 +1 @@
|
||||
[{"id":8,"name":"True Love Song","sortName":"TRUELOVESONG","artist":"Kai/クラシック「G線上のアリア」","genre":"maimai","bpm":150,"version":10000,"addVersion":"maimai","lockType":0,"disable":false,"notes":[{"level":5,"levelDecimal":0,"designer":"-"},{"level":7,"levelDecimal":2,"designer":"-"},{"level":10,"levelDecimal":2,"designer":"譜面-100号"},{"level":12,"levelDecimal":4,"designer":"ニャイン"},{"level":0,"levelDecimal":0,"designer":""},{"level":0,"levelDecimal":0,"designer":""}]},{"id":9,"name":"Color My World","sortName":"COLORMYWORLD","artist":"GOSH/クラシック「悲愴」","genre":"maimai","bpm":94,"version":10000,"addVersion":"maimai","lockType":0,"disable":false,"notes":[{"level":4,"levelDecimal":0,"designer":"-"},{"level":7,"levelDecimal":1,"designer":"-"},{"level":9,"levelDecimal":8,"designer":"譜面-100号"},{"level":12,"levelDecimal":4,"designer":"ニャイン"},{"level":0,"levelDecimal":0,"designer":""},{"level":0,"levelDecimal":0,"designer":""}]},{"id":17,"name":"Future","sortName":"FUTURE","artist":"★STAR GUiTAR [cover]","genre":"POPSアニメ","bpm":130,"version":10000,"addVersion":"maimai","lockType":0,"disable":false,"notes":[{"level":7,"levelDecimal":0,"designer":"-"},{"level":7,"levelDecimal":8,"designer":"-"},{"level":9,"levelDecimal":5,"designer":"maimai TEAM"},{"level":10,"levelDecimal":7,"designer":"譜面-100号"},{"level":12,"levelDecimal":7,"designer":"はっぴー"},{"level":0,"levelDecimal":0,"designer":""}]},{"id":18,"name":"Love You","sortName":"LOVEYOU","artist":"Q;indivi [cover]","genre":"POPSアニメ","bpm":135,"version":10000,"addVersion":"maimai","lockType":0,"disable":false,"notes":[{"level":6,"levelDecimal":0,"designer":"-"},{"level":6,"levelDecimal":8,"designer":"-"},{"level":9,"levelDecimal":6,"designer":"譜面-100号"},{"level":11,"levelDecimal":7,"designer":"ニャイン"},{"level":0,"levelDecimal":0,"designer":""},{"level":0,"levelDecimal":0,"designer":""}]},{"id":22,"name":"In Chaos","sortName":"INCHAOS","artist":"Hiro「Crackin’DJ」","genre":"ゲームバラエティ","bpm":140,"version":10000,"addVersion":"maimai","lockType":0,"disable":false,"notes":[{"level":6,"levelDecimal":0,"designer":"-"},{"level":7,"levelDecimal":6,"designer":"-"},{"level":9,"levelDecimal":7,"designer":"譜面-100号"},{"level":12,"levelDecimal":5,"designer":"ニャイン"},{"level":14,"levelDecimal":8,"designer":"チャン@DP皆伝"},{"level":0,"levelDecimal":0,"designer":""}]},{"id":23,"name":"Crush On You","sortName":"CRUSHONYOU","artist":"福山光晴「Crackin’DJ」","genre":"ゲームバラエティ","bpm":125,"version":10000,"addVersion":"maimai","lockType":0,"disable":false,"notes":[{"level":4,"levelDecimal":0,"designer":"-"},{"level":7,"levelDecimal":6,"designer":"-"},{"level":9,"levelDecimal":2,"designer":"譜面-100号"},{"level":11,"levelDecimal":4,"designer":"ニャイン"},{"level":13,"levelDecimal":7,"designer":"Jack"},{"level":0,"levelDecimal":0,"designer":""}]},{"id":24,"name":"Sun Dance","sortName":"SUNDANCE","artist":"Hiro「Crackin’DJ」","genre":"ゲームバラエティ","bpm":102,"version":10000,"addVersion":"maimai","lockType":0,"disable":false,"notes":[{"level":4,"levelDecimal":0,"designer":"-"},{"level":7,"levelDecimal":6,"designer":"-"},{"level":8,"levelDecimal":9,"designer":"譜面-100号"},{"level":11,"levelDecimal":3,"designer":"ニャイン"},{"level":14,"levelDecimal":0,"designer":"譜面-100号"},{"level":0,"levelDecimal":0,"designer":""}]},{"id":25,"name":"Sweets×Sweets","sortName":"SWEETSSWEETS","artist":"Team-D","genre":"maimai","bpm":132,"version":10000,"addVersion":"maimai","lockType":0,"disable":false,"notes":[{"level":6,"levelDecimal":0,"designer":"-"},{"level":7,"levelDecimal":4,"designer":"-"},{"level":9,"levelDecimal":3,"designer":"譜面-100号"},{"level":11,"levelDecimal":4,"designer":"maimai TEAM"},{"level":0,"levelDecimal":0,"designer":""},{"level":0,"levelDecimal":0,"designer":""}]},{"id":27,"name":"Love or Lies","sortName":"LOVEORLIES","artist":"capsule [cover]","genre":"POPSアニメ","bpm":128,"version":10000,"addVersion":"maimai","lockType":0,"disable":false,"notes":[{"level":5,"levelDecimal":0,"designer":"-"},{"level":6,"levelDecimal":8,"designer":"-"},{"level":9,"levelDecimal":5,"designer":"譜面-100号"},{"level":11,"levelDecimal":7,"designer":"ニャイン"},{"level":0,"levelDecimal":0,"designer":""},{"level":0,"levelDecimal":0,"designer":""}]},{"id":30,"name":"ネコ日和。","sortName":"ネコヒヨリ","artist":"Kai/能登有沙","genre":"maimai","bpm":171,"version":10000,"addVersion":"maimai","lockType":0,"disable":false,"notes":[{"level":6,"levelDecimal":0,"designer":"-"},{"level":7,"levelDecimal":9,"designer":"-"},{"level":8,"levelDecimal":8,"designer":"譜面-100号"},{"level":12,"levelDecimal":6,"designer":"ニャイン"},{"level":0,"levelDecimal":0,"designer":""},{"level":0,"levelDecimal":0,"designer":""}]},{"id":31,"name":"虹と太陽","sortName":"ニシトタイヨウ","artist":"GOSHLine truncated
|
||||
[{"id":8,"name":"True Love Song","sortName":"TRUELOVESONG","artist":"Kai/クラシック「G線上のアリア」","genre":"maimai","bpm":150,"version":10000,"addVersion":"maimai","lockType":0,"disable":false,"notes":[{"level":5,"levelDecimal":0,"designer":"-"},{"level":7,"levelDecimal":2,"designer":"-"},{"level":10,"levelDecimal":2,"designer":"譜面-100号"},{"level":12,"levelDecimal":4,"designer":"ニャイン"},{"level":0,"levelDecimal":0,"designer":""},{"level":0,"levelDecimal":0,"designer":""}],"pack":"A000"},{"id":9,"name":"Color My World","sortName":"COLORMYWORLD","artist":"GOSH/クラシック「悲愴」","genre":"maimai","bpm":94,"version":10000,"addVersion":"maimai","lockType":0,"disable":false,"notes":[{"level":4,"levelDecimal":0,"designer":"-"},{"level":7,"levelDecimal":1,"designer":"-"},{"level":9,"levelDecimal":8,"designer":"譜面-100号"},{"level":12,"levelDecimal":4,"designer":"ニャイン"},{"level":0,"levelDecimal":0,"designer":""},{"level":0,"levelDecimal":0,"designer":""}],"pack":"A000"},{"id":17,"name":"Future","sortName":"FUTURE","artist":"★STAR GUiTAR [cover]","genre":"POPSアニメ","bpm":130,"version":10000,"addVersion":"maimai","lockType":0,"disable":false,"notes":[{"level":7,"levelDecimal":0,"designer":"-"},{"level":7,"levelDecimal":8,"designer":"-"},{"level":9,"levelDecimal":5,"designer":"maimai TEAM"},{"level":10,"levelDecimal":7,"designer":"譜面-100号"},{"level":12,"levelDecimal":7,"designer":"はっぴー"},{"level":0,"levelDecimal":0,"designer":""}],"pack":"A000"},{"id":18,"name":"Love You","sortName":"LOVEYOU","artist":"Q;indivi [cover]","genre":"POPSアニメ","bpm":135,"version":10000,"addVersion":"maimai","lockType":0,"disable":false,"notes":[{"level":6,"levelDecimal":0,"designer":"-"},{"level":6,"levelDecimal":8,"designer":"-"},{"level":9,"levelDecimal":6,"designer":"譜面-100号"},{"level":11,"levelDecimal":7,"designer":"ニャイン"},{"level":0,"levelDecimal":0,"designer":""},{"level":0,"levelDecimal":0,"designer":""}],"pack":"A000"},{"id":22,"name":"In Chaos","sortName":"INCHAOS","artist":"Hiro「Crackin’DJ」","genre":"ゲームバラエティ","bpm":140,"version":10000,"addVersion":"maimai","lockType":0,"disable":false,"notes":[{"level":6,"levelDecimal":0,"designer":"-"},{"level":7,"levelDecimal":6,"designer":"-"},{"level":9,"levelDecimal":7,"designer":"譜面-100号"},{"level":12,"levelDecimal":5,"designer":"ニャイン"},{"level":14,"levelDecimal":8,"designer":"チャン@DP皆伝"},{"level":0,"levelDecimal":0,"designer":""}],"pack":"A000"},{"id":23,"name":"Crush On You","sortName":"CRUSHONYOU","artist":"福山光晴「Crackin’DJ」","genre":"ゲームバラエティ","bpm":125,"version":10000,"addVersion":"maimai","lockType":0,"disable":false,"notes":[{"level":4,"levelDecimal":0,"designer":"-"},{"level":7,"levelDecimal":6,"designer":"-"},{"level":9,"levelDecimal":2,"designer":"譜面-100号"},{"level":11,"levelDecimal":4,"designer":"ニャイン"},{"level":13,"levelDecimal":7,"designer":"Jack"},{"level":0,"levelDecimal":0,"designer":""}],"pack":"A000"},{"id":24,"name":"Sun Dance","sortName":"SUNDANCE","artist":"Hiro「Crackin’DJ」","genre":"ゲームバラエティ","bpm":102,"version":10000,"addVersion":"maimai","lockType":0,"disable":false,"notes":[{"level":4,"levelDecimal":0,"designer":"-"},{"level":7,"levelDecimal":6,"designer":"-"},{"level":8,"levelDecimal":9,"designer":"譜面-100号"},{"level":11,"levelDecimal":3,"designer":"ニャイン"},{"level":14,"levelDecimal":0,"designer":"譜面-100号"},{"level":0,"levelDecimal":0,"designer":""}],"pack":"A000"},{"id":25,"name":"Sweets×Sweets","sortName":"SWEETSSWEETS","artist":"Team-D","genre":"maimai","bpm":132,"version":10000,"addVersion":"maimai","lockType":0,"disable":false,"notes":[{"level":6,"levelDecimal":0,"designer":"-"},{"level":7,"levelDecimal":4,"designer":"-"},{"level":9,"levelDecimal":3,"designer":"譜面-100号"},{"level":11,"levelDecimal":4,"designer":"maimai TEAM"},{"level":0,"levelDecimal":0,"designer":""},{"level":0,"levelDecimal":0,"designer":""}],"pack":"A000"},{"id":27,"name":"Love or Lies","sortName":"LOVEORLIES","artist":"capsule [cover]","genre":"POPSアニメ","bpm":128,"version":10000,"addVersion":"maimai","lockType":0,"disable":false,"notes":[{"level":5,"levelDecimal":0,"designer":"-"},{"level":6,"levelDecimal":8,"designer":"-"},{"level":9,"levelDecimal":5,"designer":"譜面-100号"},{"level":11,"levelDecimal":7,"designer":"ニャイン"},{"level":0,"levelDecimal":0,"designer":""},{"level":0,"levelDecimal":0,"designer":""}],"pack":"A000"},{"id":30,"name":"ネコ日和。","sortName":"ネコヒヨリ","artist":"Kai/能登有沙","genre":"maimai","bpm":171,"version":10000,"addVersion":"maimai","lockType":0,"disable":false,"notes":[{"level":6,"levelDecimal":0,"designer":"-"},{"level":7,"levelDecimal":9,"designer":"-"},{"level":8,"levelDecimal":8,"designer":"譜面-100号"},{"level":12,"levelDecimal":6,"designer":"ニャイン"},{"level":0,"levelDecimal":0,"designer":""}Line truncated
|
||||
+330
-20
@@ -6,9 +6,11 @@
|
||||
<title>maipulse · 舞萌DX 发包台</title>
|
||||
<style>
|
||||
:root{
|
||||
/* 浅色 Material(对齐手机端那套:白卡 + 灰底 + 深蓝主按钮) */
|
||||
/* 浅色 Material(对齐手机端那套:白卡 + 灰底 + 主按钮)
|
||||
主色可在设置页换,applyLook 会覆盖 --accent / --accent-soft */
|
||||
--bg:#eef0f4; --panel:#ffffff; --line:#d9dce4; --text:#1b1c21;
|
||||
--dim:#6b7280; --accent:#3f5f8f; --green:#1f9d63; --red:#c8382f; --amber:#a87510;
|
||||
--dim:#6b7280; --accent:#d6336c; --accent-soft:#fdeef3;
|
||||
--green:#1f9d63; --red:#c8382f; --amber:#a87510;
|
||||
--soft:#f4f5f9;
|
||||
/* 外观:卡片不透明度 / 背景虚化 / 背景压暗(设置页可调,存 localStorage) */
|
||||
--alpha:1; --bgblur:0px; --bgdim:.12;
|
||||
@@ -62,8 +64,10 @@
|
||||
body.hasbg #bgwrap{display:block}
|
||||
#bgwrap::after{content:'';position:absolute;inset:0;background:rgba(238,240,244,var(--bgdim))}
|
||||
.card{background:rgba(255,255,255,var(--alpha));border:1px solid var(--line);border-radius:14px;padding:15px;
|
||||
margin-bottom:12px;max-width:100%;min-width:0;
|
||||
margin-bottom:12px;max-width:100%;min-width:0;position:relative;
|
||||
-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px)}
|
||||
/* 卡片带 backdrop-filter 会自成一层,下拉浮层要盖过下面的卡片就得把所在卡片抬上来 */
|
||||
.card.front{z-index:70}
|
||||
body.hasbg header{background:rgba(238,240,244,calc(var(--alpha) * .72));
|
||||
-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px)}
|
||||
.card h2{font-size:14px;margin:0 0 6px;font-weight:600}
|
||||
@@ -106,8 +110,81 @@
|
||||
.pitem{display:flex;gap:9px;align-items:center;padding:7px 10px;border-radius:7px;cursor:pointer;font-size:13px}
|
||||
.pitem:hover{background:#f2f4f9}
|
||||
.pitem input{width:auto;flex:none}
|
||||
/* 主色选择:一排圆点,选中外面套一圈 */
|
||||
.themes{display:flex;gap:11px;flex-wrap:wrap;margin:8px 0 4px}
|
||||
.themes .tc{width:30px;height:30px;border-radius:50%;cursor:pointer;border:0;padding:0;
|
||||
box-shadow:inset 0 0 0 1px rgba(0,0,0,.10);transition:transform .12s,box-shadow .12s;
|
||||
position:relative}
|
||||
.themes .tc:hover{transform:scale(1.08)}
|
||||
.themes .tc.on{box-shadow:inset 0 0 0 1px rgba(0,0,0,.10),0 0 0 2px #fff,0 0 0 4px var(--accent)}
|
||||
.themes .tc::after{content:attr(data-n);position:absolute;left:50%;bottom:-17px;
|
||||
transform:translateX(-50%);font-size:11px;color:var(--dim);white-space:nowrap;pointer-events:none}
|
||||
.themes{padding-bottom:18px}
|
||||
|
||||
/* 复选框统一成圆角小方块,替掉系统那个方框 */
|
||||
input[type=checkbox]{-webkit-appearance:none;appearance:none;
|
||||
width:18px;height:18px;min-width:18px;flex:none;margin:0;padding:0;
|
||||
position:relative;box-sizing:border-box;vertical-align:middle;
|
||||
border:1.5px solid #c6ceda;border-radius:6px;background:#fff;cursor:pointer;
|
||||
transition:background .14s,border-color .14s,box-shadow .14s}
|
||||
input[type=checkbox]:hover{border-color:var(--accent)}
|
||||
input[type=checkbox]:checked{background:var(--accent);border-color:var(--accent)}
|
||||
input[type=checkbox]:checked::after{content:'';position:absolute;left:50%;top:50%;
|
||||
width:4px;height:8px;box-sizing:border-box;
|
||||
border-right:2px solid #fff;border-bottom:2px solid #fff;
|
||||
transform:translate(-55%,-58%) rotate(45deg)}
|
||||
input[type=checkbox]:focus{outline:none}
|
||||
input[type=checkbox]:focus-visible{box-shadow:0 0 0 3px rgba(63,95,143,.16)}
|
||||
input[type=checkbox]:disabled{opacity:.45;cursor:default}
|
||||
.pitem .pid{color:var(--dim);font-family:Consolas,monospace;min-width:60px;font-size:12px}
|
||||
.pitem .pver{color:var(--dim);font-size:11px;margin-left:auto;flex:none}
|
||||
#pfoot{padding:10px 14px;border-top:1px solid var(--line);display:flex;gap:8px;align-items:center}
|
||||
/* 曲目选择器的版本分类条 */
|
||||
#pgroups{display:none;gap:6px;padding:9px 14px;border-bottom:1px solid var(--line);
|
||||
overflow-x:auto;white-space:nowrap;flex:none;
|
||||
scrollbar-width:none;-ms-overflow-style:none}
|
||||
#pgroups::-webkit-scrollbar{display:none;height:0}
|
||||
#pgroups.on{display:flex}
|
||||
#pgroups .g{flex:none;padding:4px 10px;border:1px solid var(--line);border-radius:99px;
|
||||
font-size:12px;cursor:pointer;color:var(--dim);background:#fff;user-select:none}
|
||||
#pgroups .g:hover{border-color:var(--accent);color:var(--accent)}
|
||||
#pgroups .g.on{background:var(--accent);border-color:var(--accent);color:#fff}
|
||||
#pgroups .g.sep{background:transparent;border:none;padding:4px 2px;color:var(--line);cursor:default}
|
||||
#pgroups .g.sep:hover{border:none;color:var(--line)}
|
||||
|
||||
/* 自定义下拉:替掉原生 select 的系统样式,展开的浮层也走自己的配色 */
|
||||
.ns{position:relative;width:100%}
|
||||
.ns>select{position:absolute;left:0;top:0;width:100%;height:0;opacity:0;
|
||||
pointer-events:none;border:0;padding:0}
|
||||
.ns-btn{display:flex;align-items:center;gap:8px;box-sizing:border-box;
|
||||
width:100%;height:40px;background:#fff;border:1px solid var(--line);border-radius:10px;
|
||||
padding:0 12px;font-size:13px;line-height:1;color:var(--text);
|
||||
cursor:pointer;user-select:none;-webkit-tap-highlight-color:transparent}
|
||||
.ns-btn:hover{border-color:var(--accent)}
|
||||
.ns-btn .txt{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.ns-btn .arw{flex:none;width:0;height:0;border-left:4px solid transparent;
|
||||
border-right:4px solid transparent;border-top:5px solid var(--dim);
|
||||
transition:transform .15s}
|
||||
.ns.open .ns-btn{border-color:var(--accent)}
|
||||
.ns.open .ns-btn .arw{transform:rotate(180deg)}
|
||||
.ns-panel{position:absolute;left:0;top:calc(100% + 5px);z-index:60;
|
||||
display:none;background:#fff;border:1px solid var(--line);border-radius:12px;
|
||||
padding:5px;max-height:290px;overflow:auto;
|
||||
min-width:100%;width:max-content;max-width:min(330px,90vw);
|
||||
box-shadow:0 10px 28px rgba(20,28,44,.16)}
|
||||
.ns.open .ns-panel{display:block}
|
||||
.ns-panel::-webkit-scrollbar{width:7px}
|
||||
.ns-panel::-webkit-scrollbar-track{background:transparent}
|
||||
.ns-panel::-webkit-scrollbar-thumb{background:rgba(140,150,170,.32);border-radius:4px}
|
||||
.ns-panel::-webkit-scrollbar-thumb:hover{background:rgba(140,150,170,.5)}
|
||||
.ns-opt{padding:9px 11px;border-radius:8px;font-size:13px;color:var(--text);
|
||||
cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.ns-opt:hover{background:#f0f4fb}
|
||||
.ns-opt.on{background:var(--accent-soft);color:var(--accent);font-weight:600}
|
||||
/* 和 select 并排的按钮:和下拉同高、底边对齐 */
|
||||
.rowfoot{display:flex;flex-direction:column;justify-content:flex-end}
|
||||
.rowfoot>button{margin:0;width:100%;box-sizing:border-box;height:40px;padding:0 16px;
|
||||
font-size:13px;line-height:1;display:flex;align-items:center;justify-content:center}
|
||||
|
||||
/* B50 查分(游戏风卡片墙 + 海报) */
|
||||
.b50top{display:flex;align-items:baseline;gap:10px;margin:8px 0 2px;flex-wrap:wrap}
|
||||
@@ -252,7 +329,7 @@
|
||||
background:currentColor;
|
||||
-webkit-mask:var(--m) center/contain no-repeat;
|
||||
mask:var(--m) center/contain no-repeat}
|
||||
#tabs button.on{color:var(--accent);font-weight:700;background:#eef2f9}
|
||||
#tabs button.on{color:var(--accent);font-weight:700;background:var(--accent-soft)}
|
||||
|
||||
/* 主页资料 */
|
||||
.uhead{display:flex;gap:12px;align-items:center;background:#fff;border:1px solid var(--line);
|
||||
@@ -266,7 +343,7 @@
|
||||
padding:6px 14px 10px;margin-bottom:12px}
|
||||
.info h3{font-size:13px;color:var(--accent);margin:12px 0 6px;font-weight:700}
|
||||
.kv{display:flex;justify-content:space-between;gap:14px;padding:7px 0;font-size:13px;
|
||||
border-bottom:1px solid #f0f2f6}
|
||||
border-bottom:1px solid rgba(125,135,155,.13)}
|
||||
.kv:last-child{border-bottom:0}
|
||||
.kv b{font-weight:400;color:var(--dim);flex:none}
|
||||
.kv span{text-align:right;word-break:break-all;font-family:Consolas,monospace}
|
||||
@@ -310,9 +387,9 @@
|
||||
#ticker.ok{background:rgba(24,110,72,.95)}
|
||||
.lvtabs{display:flex;gap:6px;margin:10px 0 2px}
|
||||
.lvtabs .ghost{flex:1;margin:0;padding:7px 0;font-size:12px}
|
||||
.lvtabs .ghost.on{background:#eef2f9;color:var(--accent);border-color:var(--accent)}
|
||||
.lvtabs .ghost.on{background:var(--accent-soft);color:var(--accent);border-color:var(--accent)}
|
||||
.recorow .nm em.onb{font-style:normal;font-size:10px;margin-left:5px;padding:1px 5px;
|
||||
border-radius:6px;background:#e8f0ff;color:var(--accent);vertical-align:1px}
|
||||
border-radius:6px;background:var(--accent-soft);color:var(--accent);vertical-align:1px}
|
||||
/* 设置页正上方的彩虹字 */
|
||||
.rainbow-top{display:block;text-align:center;
|
||||
margin:6px 0 14px;font-size:34px;font-weight:900;letter-spacing:.12em;
|
||||
@@ -491,8 +568,18 @@
|
||||
|
||||
<section class="card" data-tab="risk">
|
||||
<h2>解锁歌曲</h2>
|
||||
<p class="desc">选中当场发;全量按每包 4 首分批换局,耗时长但稳。</p>
|
||||
<button class="ghost" onclick="pick('music','songIds')">选择曲目</button>
|
||||
<p class="desc">按版本分类挑,选中当场发;全曲解锁一次发完整个曲库。</p>
|
||||
<div class="row">
|
||||
<div>
|
||||
<label>按版本快速选</label>
|
||||
<select id="songGroup" onchange="pickSongGroup()">
|
||||
<option value="">— 选择分类 —</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="rowfoot">
|
||||
<button class="ghost" onclick="pick('music','songIds')">手动挑选曲目</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="songIds" class="picked"></div>
|
||||
<div class="check">
|
||||
<label><input id="allSongs" type="checkbox"> 全量解锁</label>
|
||||
@@ -625,6 +712,8 @@
|
||||
<input id="bgFile" type="file" accept="image/*" style="display:none" onchange="pickBg(this.files[0])">
|
||||
</div>
|
||||
<div id="bgState" class="fine" style="margin-top:8px">当前没有自定义背景。</div>
|
||||
<label>主色</label>
|
||||
<div id="themePick" class="themes"></div>
|
||||
<label>卡片不透明度 <b id="alphaVal" style="color:var(--accent)">100%</b></label>
|
||||
<input id="alphaRange" type="range" min="30" max="100" step="1" value="100"
|
||||
oninput="onLookInput('alpha', this.value)">
|
||||
@@ -706,10 +795,12 @@
|
||||
<input id="psearch" placeholder="搜索名称或 id" style="max-width:240px" oninput="renderPicks()">
|
||||
<button class="ghost" style="margin:0" onclick="closePicker()">关闭</button>
|
||||
</header>
|
||||
<div id="pgroups"></div>
|
||||
<div id="plist"></div>
|
||||
<div id="pfoot">
|
||||
<span id="pcount" class="dim" style="font-size:12px"></span>
|
||||
<span style="flex:1"></span>
|
||||
<button class="ghost" style="margin:0;display:none" id="pall" onclick="addGroupAll()">本类全选</button>
|
||||
<button class="ghost" style="margin:0" onclick="clearPicks()">清空</button>
|
||||
<button style="margin:0" onclick="confirmPicks()">确定</button>
|
||||
</div>
|
||||
@@ -720,7 +811,7 @@
|
||||
<script>
|
||||
const $ = id => document.getElementById(id);
|
||||
let cur = {userId:0, token:""};
|
||||
let pickState = {cat:"", target:"", items:[], chosen:new Set(), kind:0};
|
||||
let pickState = {cat:"", target:"", items:[], chosen:new Set(), kind:0, groups:[], group:'all'};
|
||||
|
||||
function log(msg, cls){
|
||||
const el = $('log');
|
||||
@@ -986,9 +1077,38 @@ async function delAccount(ref, name){
|
||||
else ticker(r.error || '删除失败', 'err');
|
||||
}
|
||||
|
||||
/* ---------- 外观:背景图 / 卡片不透明度 / 虚化 / 压暗 ---------- */
|
||||
/* ---------- 外观:背景图 / 卡片不透明度 / 虚化 / 压暗 / 主色 ---------- */
|
||||
const LOOK_KEY = 'maipulse.look.v1';
|
||||
|
||||
/* 主色板:accent 用足够深的色,保证白底彩字、彩底白字都看得清 */
|
||||
const THEMES = [
|
||||
{ k:'pink', n:'粉色', c:'#d6336c', s:'#fdeef3' },
|
||||
{ k:'blue', n:'蓝色', c:'#3f5f8f', s:'#eef2f9' },
|
||||
{ k:'green', n:'绿色', c:'#1a7d52', s:'#e9f5ee' },
|
||||
{ k:'purple', n:'紫色', c:'#6b46c1', s:'#f1ecfb' },
|
||||
{ k:'orange', n:'橙色', c:'#b05a15', s:'#fbf0e6' },
|
||||
{ k:'teal', n:'青色', c:'#0f7576', s:'#e6f4f4' },
|
||||
{ k:'wine', n:'酒红', c:'#a52a3a', s:'#fbebed' },
|
||||
{ k:'graph', n:'墨黑', c:'#3a3f4b', s:'#eef0f3' },
|
||||
];
|
||||
function themeOf(k){ return THEMES.find(t => t.k === k) || THEMES[0]; }
|
||||
function renderThemes(cur){
|
||||
const box = $('themePick');
|
||||
if(!box) return;
|
||||
cur = cur || (readLook().theme || THEMES[0].k);
|
||||
box.innerHTML = THEMES.map(t =>
|
||||
`<button type="button" class="tc${t.k === cur ? ' on' : ''}" data-k="${t.k}"
|
||||
data-n="${t.n}" title="${t.n}"
|
||||
style="background:${t.c}" onclick="pickTheme('${t.k}')"></button>`).join('');
|
||||
}
|
||||
function pickTheme(k){
|
||||
const o = readLook();
|
||||
o.theme = k;
|
||||
saveLook(o);
|
||||
applyLook(o);
|
||||
renderThemes(k);
|
||||
}
|
||||
|
||||
function readLook(){
|
||||
try { return JSON.parse(localStorage.getItem(LOOK_KEY) || '{}') || {}; }
|
||||
catch(e){ return {}; }
|
||||
@@ -1006,6 +1126,10 @@ function applyLook(o){
|
||||
root.setProperty('--alpha', String(alpha));
|
||||
root.setProperty('--bgblur', blur + 'px');
|
||||
root.setProperty('--bgdim', String(dim));
|
||||
const th = themeOf(o.theme);
|
||||
root.setProperty('--accent', th.c);
|
||||
root.setProperty('--accent-soft', th.s);
|
||||
renderThemes(th.k);
|
||||
const wrap = $('bgwrap');
|
||||
if(o.bg){
|
||||
wrap.style.backgroundImage = 'url("' + o.bg + '")';
|
||||
@@ -1072,7 +1196,7 @@ function onLookInput(field, value){
|
||||
}
|
||||
function resetLook(){
|
||||
const o = readLook();
|
||||
delete o.alpha; delete o.blur; delete o.dim; delete o.bg;
|
||||
delete o.alpha; delete o.blur; delete o.dim; delete o.bg; delete o.theme;
|
||||
saveLook(o);
|
||||
applyLook(o);
|
||||
ticker('外观已恢复默认', 'ok');
|
||||
@@ -1689,33 +1813,103 @@ async function loadState(){
|
||||
}
|
||||
|
||||
/* ---------- picker ---------- */
|
||||
const CAT_NAME = {music:'曲目', title:'称号', icon:'头像', plate:'名牌', frame:'边框', chara:'角色', partner:'搭档', ticket:'功能票'};
|
||||
|
||||
async function pick(cat, target){
|
||||
const r = await get('/api/master?cat='+cat);
|
||||
if(!r.ok) return log('载入失败:'+r.error, 'err');
|
||||
pickState = {cat, target, items:r.items, chosen:new Set(), kind:r.kind};
|
||||
$('ptitle').textContent = `${cat} · 共 ${r.count} 项`;
|
||||
// groups 只有曲目接口会返回,收藏品那一路保持原样
|
||||
const groups = cat === 'music' ? (r.groups || []) : [];
|
||||
pickState = {cat, target, items:r.items, chosen:new Set(), kind:r.kind,
|
||||
groups, group:'all'};
|
||||
$('ptitle').textContent = `${CAT_NAME[cat]||cat} · 共 ${r.count} 项`;
|
||||
$('psearch').value = '';
|
||||
renderGroups();
|
||||
$('mask').classList.add('on');
|
||||
renderPicks();
|
||||
}
|
||||
|
||||
/* 曲目选择器的分类条:全曲 / 各版本 / 当前版本 / 各差分包 / 无归属 */
|
||||
function renderGroups(){
|
||||
const box = $('pgroups');
|
||||
const all = $('pall');
|
||||
if(!pickState.groups.length){
|
||||
box.classList.remove('on'); box.innerHTML = '';
|
||||
if(all) all.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
box.classList.add('on');
|
||||
if(all) all.style.display = '';
|
||||
box.innerHTML = pickState.groups.map(g => {
|
||||
const on = pickState.group === g.key ? ' on' : '';
|
||||
return `<span class="g${on}" data-k="${g.key}" onclick="selectGroup('${g.key}')">`
|
||||
+ `${g.label} <b>${g.count}</b></span>`;
|
||||
}).join('');
|
||||
}
|
||||
function selectGroup(key){
|
||||
pickState.group = key;
|
||||
renderGroups();
|
||||
$('plist').scrollTop = 0;
|
||||
renderPicks();
|
||||
}
|
||||
function groupIds(key){
|
||||
const g = pickState.groups.find(x => x.key === key);
|
||||
return g ? g.ids : null;
|
||||
}
|
||||
/* 把当前分类的曲目全部勾上(再点一次取消这一批) */
|
||||
function addGroupAll(){
|
||||
const ids = groupIds(pickState.group);
|
||||
if(!ids) return;
|
||||
const allIn = ids.every(i => pickState.chosen.has(i));
|
||||
if(allIn) ids.forEach(i => pickState.chosen.delete(i));
|
||||
else ids.forEach(i => pickState.chosen.add(i));
|
||||
// 只同步已渲染的行,不重画列表(重画会把滚动位置顶回顶部)
|
||||
document.querySelectorAll('#plist input[type=checkbox]').forEach(c => {
|
||||
c.checked = pickState.chosen.has(parseInt(c.dataset.id, 10));
|
||||
});
|
||||
paintCount();
|
||||
}
|
||||
|
||||
function paintCount(){
|
||||
const gname = pickState.groups.length
|
||||
? (pickState.groups.find(g => g.key === pickState.group)||{}).label : '';
|
||||
const n = pickState.matched || 0;
|
||||
$('pcount').textContent = `已选 ${pickState.chosen.size}`
|
||||
+ (gname ? ` / ${gname} ${n}` : ` / 匹配 ${n}`);
|
||||
}
|
||||
function renderPicks(){
|
||||
const kw = $('psearch').value.trim().toLowerCase();
|
||||
const list = $('plist');
|
||||
const src = pickState.items.filter(x => !kw ||
|
||||
String(x.id).includes(kw) || (x.name||'').toLowerCase().includes(kw));
|
||||
const gids = pickState.groups.length ? groupIds(pickState.group) : null;
|
||||
const inGroup = gids ? new Set(gids) : null;
|
||||
const src = pickState.items.filter(x => {
|
||||
if(inGroup && !inGroup.has(x.id)) return false;
|
||||
return !kw || String(x.id).includes(kw) || (x.name||'').toLowerCase().includes(kw);
|
||||
});
|
||||
const show = src.slice(0, 300);
|
||||
const withVer = pickState.groups.length > 0;
|
||||
pickState.matched = src.length;
|
||||
list.innerHTML = show.map(x => `
|
||||
<label class="pitem">
|
||||
<input type="checkbox" ${pickState.chosen.has(x.id)?'checked':''}
|
||||
<input type="checkbox" data-id="${x.id}" ${pickState.chosen.has(x.id)?'checked':''}
|
||||
onchange="togglePick(${x.id}, this.checked)">
|
||||
<span class="pid">${x.id}</span>
|
||||
<span>${(x.name||'').replace(/</g,'<')}</span>
|
||||
${withVer ? `<span class="pver">${(x.addVersion||'').replace(/</g,'<')}</span>` : ''}
|
||||
</label>`).join('') + (src.length > 300
|
||||
? `<div class="dim" style="padding:10px;font-size:12px">还有 ${src.length-300} 项,输入关键词缩小范围</div>` : '');
|
||||
$('pcount').textContent = `已选 ${pickState.chosen.size} / 匹配 ${src.length}`;
|
||||
paintCount();
|
||||
}
|
||||
/* 单个勾选只改计数,不重画列表——否则每点一下滚动位置就跳回顶部 */
|
||||
function togglePick(id, on){
|
||||
on ? pickState.chosen.add(id) : pickState.chosen.delete(id);
|
||||
paintCount();
|
||||
}
|
||||
function clearPicks(){
|
||||
pickState.chosen.clear();
|
||||
document.querySelectorAll('#plist input[type=checkbox]').forEach(c => { c.checked = false; });
|
||||
paintCount();
|
||||
}
|
||||
function togglePick(id, on){ on ? pickState.chosen.add(id) : pickState.chosen.delete(id); $('pcount').textContent = `已选 ${pickState.chosen.size}`; }
|
||||
function clearPicks(){ pickState.chosen.clear(); renderPicks(); }
|
||||
function closePicker(){ $('mask').classList.remove('on'); }
|
||||
function confirmPicks(){
|
||||
const ids = [...pickState.chosen];
|
||||
@@ -1737,6 +1931,120 @@ function pickedIds(target){
|
||||
}
|
||||
function pickedKind(target){ return parseInt($(target).dataset.kind || '0', 10); }
|
||||
|
||||
/* ---------- 自定义下拉:替掉原生 select,避免系统浮层跟页面配色打架 ---------- */
|
||||
function closeAllNs(except){
|
||||
document.querySelectorAll('.ns.open').forEach(w => {
|
||||
if(w !== except){
|
||||
w.classList.remove('open');
|
||||
const c = w.closest('.card');
|
||||
if(c) c.classList.remove('front');
|
||||
}
|
||||
});
|
||||
}
|
||||
function nsUpgrade(sel){
|
||||
if(!sel || sel.dataset.ns) return;
|
||||
sel.dataset.ns = '1';
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'ns';
|
||||
sel.parentNode.insertBefore(wrap, sel);
|
||||
wrap.appendChild(sel);
|
||||
|
||||
const btn = document.createElement('div');
|
||||
btn.className = 'ns-btn';
|
||||
btn.innerHTML = '<span class="txt"></span><i class="arw"></i>';
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'ns-panel';
|
||||
wrap.appendChild(btn);
|
||||
wrap.appendChild(panel);
|
||||
|
||||
wrap._sync = () => {
|
||||
const i = Math.max(0, sel.selectedIndex);
|
||||
const cur = sel.options[i];
|
||||
btn.querySelector('.txt').textContent = cur ? cur.textContent : '';
|
||||
panel.innerHTML = [...sel.options].map((o, k) =>
|
||||
`<div class="ns-opt${k === i ? ' on' : ''}" data-k="${k}">${o.textContent}</div>`).join('');
|
||||
};
|
||||
btn.addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
const willOpen = !wrap.classList.contains('open');
|
||||
closeAllNs(wrap);
|
||||
wrap.classList.toggle('open', willOpen);
|
||||
const card = wrap.closest('.card');
|
||||
if(card) card.classList.toggle('front', willOpen);
|
||||
if(willOpen){
|
||||
const on = panel.querySelector('.ns-opt.on');
|
||||
if(on) panel.scrollTop = Math.max(0, on.offsetTop - panel.clientHeight / 2);
|
||||
}
|
||||
});
|
||||
panel.addEventListener('click', e => {
|
||||
const t = e.target.closest('.ns-opt');
|
||||
if(!t) return;
|
||||
const k = parseInt(t.dataset.k, 10);
|
||||
if(sel.selectedIndex !== k){
|
||||
sel.selectedIndex = k;
|
||||
sel.dispatchEvent(new Event('change', { bubbles:true }));
|
||||
}
|
||||
wrap.classList.remove('open');
|
||||
const c = wrap.closest('.card');
|
||||
if(c) c.classList.remove('front');
|
||||
wrap._sync();
|
||||
});
|
||||
wrap._sync();
|
||||
}
|
||||
/* 选项被 JS 改过之后调一次,把显示同步回去 */
|
||||
function nsSync(id){
|
||||
const s = $(id);
|
||||
if(s && s.parentNode && typeof s.parentNode._sync === 'function') s.parentNode._sync();
|
||||
}
|
||||
function nsAll(){ document.querySelectorAll('select').forEach(nsUpgrade); }
|
||||
document.addEventListener('click', () => closeAllNs(null));
|
||||
|
||||
/* 面板上的版本下拉:只拉分类清单,不背 1343 条曲目 */
|
||||
let songGroupsCache = null;
|
||||
async function loadSongGroups(){
|
||||
const sel = $('songGroup');
|
||||
if(!sel) return;
|
||||
try {
|
||||
const r = await get('/api/master?cat=music&brief=1');
|
||||
if(!r.ok || !r.groups) return;
|
||||
songGroupsCache = r;
|
||||
sel.innerHTML = '<option value="">— 选择分类 —</option>'
|
||||
+ r.groups.map(g => `<option value="${g.key}">${g.label}(${g.count})</option>`).join('');
|
||||
sel.value = '';
|
||||
nsSync('songGroup');
|
||||
} catch(e){}
|
||||
}
|
||||
async function pickSongGroup(){
|
||||
const key = $('songGroup').value;
|
||||
const el = $('songIds');
|
||||
if(!key){
|
||||
el.dataset.ids = '[]';
|
||||
el.textContent = '(未选择)';
|
||||
return;
|
||||
}
|
||||
if(!songGroupsCache){
|
||||
const r = await get('/api/master?cat=music&brief=1');
|
||||
if(!r.ok) return log('载入分类失败:'+r.error, 'err');
|
||||
songGroupsCache = r;
|
||||
}
|
||||
const g = (songGroupsCache.groups || []).find(x => x.key === key);
|
||||
if(!g) return log('分类不存在:'+key, 'err');
|
||||
if(g.key === 'all'){
|
||||
// 全曲走「全量解锁」那条路:一包发完,失败还能自动降级重试
|
||||
$('allSongs').checked = true;
|
||||
el.dataset.ids = '[]';
|
||||
el.textContent = `[全曲解锁] 已勾选全量,共 ${g.count} 首(发送时一并带上 MASTER/Re:MASTER)`;
|
||||
log(`按分类选中:全曲解锁 · ${g.count} 首(已勾上「全量解锁」)`);
|
||||
return;
|
||||
}
|
||||
$('allSongs').checked = false;
|
||||
el.dataset.ids = JSON.stringify(g.ids);
|
||||
el.dataset.kind = 5;
|
||||
el.dataset.cat = 'music';
|
||||
el.textContent = `[${g.label}] 已选 ${g.ids.length} 首`;
|
||||
log(`按分类选中:${g.label} · ${g.ids.length} 首`);
|
||||
}
|
||||
|
||||
/* ---------- 功能票 ---------- */
|
||||
async function loadCharge(){
|
||||
const r = await get('/api/charge');
|
||||
@@ -1918,6 +2226,8 @@ log('就绪。先扫码 → 登录 → 选功能。', 'dim');
|
||||
loadVersion();
|
||||
confSummary();
|
||||
loadAccounts();
|
||||
nsAll();
|
||||
loadSongGroups();
|
||||
// URL 带 ?uid= 时自动查这个账号的 B50 与推分(方便直接看别人的分)
|
||||
const uidParam = new URLSearchParams(location.search).get('uid');
|
||||
if(uidParam && $('b50uid')){
|
||||
|
||||
@@ -19,6 +19,7 @@ from __future__ import annotations
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
@@ -99,7 +100,19 @@ def parse_title(path: str) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def collect(pkg: str, sub: str, filename: str, parser) -> list:
|
||||
def pack_of(pkg: str) -> str:
|
||||
"""从包目录名取出包号:SDGB_A041_20261020134418_0 -> A041。
|
||||
|
||||
目录名里没有 A0xx 时回退取全部数字段里第一个 3 位纯数字,
|
||||
再不行返回空串(这条就归到「无归属」)。
|
||||
"""
|
||||
for part in re.split(r"[\\/_\-\s]+", os.path.basename(os.path.normpath(pkg))):
|
||||
if re.fullmatch(r"A\d{3}", part):
|
||||
return part
|
||||
return ""
|
||||
|
||||
|
||||
def collect(pkg: str, sub: str, filename: str, parser, pack: str = "") -> list:
|
||||
out = []
|
||||
base = os.path.join(pkg, sub)
|
||||
if not os.path.isdir(base):
|
||||
@@ -113,6 +126,8 @@ def collect(pkg: str, sub: str, filename: str, parser) -> list:
|
||||
print(" 跳过 %s:%s" % (p, exc))
|
||||
continue
|
||||
if item.get("id"):
|
||||
if pack:
|
||||
item["pack"] = pack
|
||||
out.append(item)
|
||||
return out
|
||||
|
||||
@@ -133,6 +148,13 @@ def merge(old: list, new: list):
|
||||
cur = old[i]
|
||||
dirty = False
|
||||
for k, v in item.items():
|
||||
if k == "pack":
|
||||
# 包归属记首次出现的那个包:已有值不覆盖,
|
||||
# 空值(早先水鱼补的曲)才由包补上
|
||||
if v and not cur.get("pack"):
|
||||
cur["pack"] = v
|
||||
dirty = True
|
||||
continue
|
||||
if k == "notes":
|
||||
# 谱面:包里启用的档位比旧的更权威
|
||||
if any(n["level"] or n["levelDecimal"] for n in v):
|
||||
@@ -214,8 +236,11 @@ def main():
|
||||
music_old = json.load(io.open(music_path, encoding="utf-8"))
|
||||
title_old = json.load(io.open(title_path, encoding="utf-8"))
|
||||
|
||||
pkg_music = collect(pkg, "music", "Music.xml", parse_music)
|
||||
pkg_title = collect(pkg, "title", "Title.xml", parse_title)
|
||||
pkg_pack = pack_of(pkg)
|
||||
pkg_music = collect(pkg, "music", "Music.xml", parse_music, pkg_pack)
|
||||
pkg_title = collect(pkg, "title", "Title.xml", parse_title, pkg_pack)
|
||||
print("包号:%s%s" % (pkg_pack or "(未识别)",
|
||||
"" if pkg_pack else ",曲目会归到「无归属」"))
|
||||
print("包里:曲目 %d 首 / 称号 %d 个" % (len(pkg_music), len(pkg_title)))
|
||||
|
||||
music_new, m_add, m_upd = merge(music_old, pkg_music)
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""backfill_pack.py —— 给曲目回填「差分包归属」字段。
|
||||
|
||||
扫描 StreamingAssets 下的 A000 / A005 / A011 ... 各包的 music 目录,
|
||||
得到「曲目 id -> 首次出现的包号」,写进:
|
||||
|
||||
data/master/music.json 每条记录加 pack 字段
|
||||
data/master.json music.items 同步(/api/master 读的是这个)
|
||||
|
||||
归属规则:A000 之外的包优先(大包号覆盖小包号),剩下的归 A000,
|
||||
哪个包目录都没有的记空串。
|
||||
|
||||
用法:
|
||||
python tools/backfill_pack.py
|
||||
python tools/backfill_pack.py --src "D:\\bs\\Package\\Sinmai_Data\\StreamingAssets" --dry
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
DATA_DIR = os.path.join(BASE_DIR, "data")
|
||||
MASTER_DIR = os.path.join(DATA_DIR, "master")
|
||||
MASTER_INDEX = os.path.join(DATA_DIR, "master.json")
|
||||
|
||||
DEFAULT_SRC = r"D:\bs\Package\Sinmai_Data\StreamingAssets"
|
||||
|
||||
|
||||
def read_json(path):
|
||||
with io.open(path, encoding="utf-8") as fp:
|
||||
return json.load(fp)
|
||||
|
||||
|
||||
def write_json(path, obj):
|
||||
"""无 BOM UTF-8,缩进 1 空格保持体积。"""
|
||||
tmp = path + ".tmp"
|
||||
with io.open(tmp, "w", encoding="utf-8", newline="\n") as fp:
|
||||
json.dump(obj, fp, ensure_ascii=False, separators=(",", ":"))
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
def scan_packs(src):
|
||||
"""返回 (pack 列表, {pack: set(id)}),A000 排第一,其余按包号升序。"""
|
||||
if not os.path.isdir(src):
|
||||
raise SystemExit("找不到 StreamingAssets 目录:" + src)
|
||||
packs = sorted([d for d in os.listdir(src) if re.fullmatch(r"A\d{3}", d)],
|
||||
key=lambda x: (x != "A000", x))
|
||||
out = {}
|
||||
for p in packs:
|
||||
d = os.path.join(src, p, "music")
|
||||
if not os.path.isdir(d):
|
||||
continue
|
||||
ids = set()
|
||||
for name in os.listdir(d):
|
||||
if not os.path.isdir(os.path.join(d, name)):
|
||||
continue
|
||||
m = re.search(r"(\d+)$", name)
|
||||
if m:
|
||||
ids.add(int(m.group(1)))
|
||||
if ids:
|
||||
out[p] = ids
|
||||
return packs, out
|
||||
|
||||
|
||||
def owner_map(packs, pack_ids):
|
||||
"""id -> 包号。A000 之外的包按顺序覆盖,没有目录的留空。"""
|
||||
owner = {}
|
||||
for p in packs:
|
||||
if p == "A000":
|
||||
continue
|
||||
for i in pack_ids.get(p, ()):
|
||||
owner[i] = p
|
||||
for i in pack_ids.get("A000", ()):
|
||||
owner.setdefault(i, "A000")
|
||||
return owner
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--src", default=DEFAULT_SRC)
|
||||
ap.add_argument("--dry", action="store_true", help="只打印统计,不写盘")
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
packs, pack_ids = scan_packs(args.src)
|
||||
print("扫到包:", ", ".join(packs))
|
||||
for p in packs:
|
||||
if p in pack_ids:
|
||||
print(" %-6s %5d 首" % (p, len(pack_ids[p])))
|
||||
|
||||
owner = owner_map(packs, pack_ids)
|
||||
|
||||
# ---- master/music.json
|
||||
mpath = os.path.join(MASTER_DIR, "music.json")
|
||||
music = read_json(mpath)
|
||||
from collections import Counter
|
||||
stat = Counter()
|
||||
changed = 0
|
||||
for row in music:
|
||||
p = owner.get(int(row["id"]), "")
|
||||
if row.get("pack") != p:
|
||||
changed += 1
|
||||
row["pack"] = p
|
||||
stat[p or "(无归属)"] += 1
|
||||
|
||||
order = [p for p in packs if p in pack_ids] + [""]
|
||||
print("\n=== 回填结果:%d 首,变更 %d 条 ===" % (len(music), changed))
|
||||
for p in order:
|
||||
key = p or "(无归属)"
|
||||
if stat.get(key):
|
||||
print(" %-8s %5d" % (key, stat[key]))
|
||||
total = sum(stat.values())
|
||||
print(" 合计 %5d" % total)
|
||||
|
||||
if args.dry:
|
||||
print("\n--dry,未写盘")
|
||||
return 0
|
||||
|
||||
write_json(mpath, music)
|
||||
print("\n已写", mpath, os.path.getsize(mpath), "字节")
|
||||
|
||||
# ---- master.json 里的 music 镜像
|
||||
idx = read_json(MASTER_INDEX)
|
||||
node = idx.get("music")
|
||||
if isinstance(node, dict) and isinstance(node.get("items"), list):
|
||||
pmap = {int(r["id"]): r.get("pack", "") for r in music}
|
||||
n = 0
|
||||
for row in node["items"]:
|
||||
p = pmap.get(int(row["id"]), "")
|
||||
if row.get("pack") != p:
|
||||
n += 1
|
||||
row["pack"] = p
|
||||
node["count"] = len(node["items"])
|
||||
write_json(MASTER_INDEX, idx)
|
||||
print("已写 %s %d 字节(music %d 条,变更 %d)"
|
||||
% (MASTER_INDEX, os.path.getsize(MASTER_INDEX), len(node["items"]), n))
|
||||
else:
|
||||
print("master.json 里没有 music.items,跳过")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,79 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""公开仓推之前的路径脱敏。
|
||||
|
||||
源码里带着开发机的绝对路径(C:\\Users\\xxx、D:\\DSH\\...),
|
||||
这些东西推到公开仓库等于把自己的目录结构、用户名、SDK 位置全挂出去。
|
||||
本地源码保持原样(能直接跑),只在同步进公开仓的那份副本上做替换。
|
||||
|
||||
用法:
|
||||
python tools/blank_paths.py <公开仓里的 astral/maipulse/android 根目录>
|
||||
python tools/blank_paths.py D:\\DSH\\maimai\\_repo --dry
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
# (匹配模式, 替换成) —— 顺序有讲究:长的先替换,否则 \maimai 会先被短规则吃掉
|
||||
RULES = [
|
||||
(re.compile(r'C:[\\/]Users[\\/]20558[\\/]AppData[\\/]Local[\\/]Programs[\\/]Python[\\/]Python38[\\/]python\.exe'),
|
||||
'python'),
|
||||
(re.compile(r'C:[\\/]Users[\\/]20558'), '%USERPROFILE%'),
|
||||
(re.compile(r'D:[\\/]DSH[\\/]_androidsdk'), '%ANDROID_HOME%'),
|
||||
(re.compile(r'D:[\\/]DSH[\\/]maimai[\\/]maipulse-android'), '<android 工程>'),
|
||||
(re.compile(r'D:[\\/]DSH[\\/]maimai[\\/]maipulse'), '<maipulse>'),
|
||||
(re.compile(r'D:[\\/]DSH[\\/]maimai[\\/]Astral'), '<astral>'),
|
||||
(re.compile(r'D:[\\/]DSH[\\/]maimai[\\/]_repo'), '<仓库>'),
|
||||
(re.compile(r'D:[\\/]DSH[\\/]maimai'), '<项目根>'),
|
||||
(re.compile(r'qmy2055885931@gmail\.com'), '[email protected]'),
|
||||
]
|
||||
|
||||
TEXT_EXT = ('.py', '.ps1', '.gradle', '.xml', '.md', '.json', '.properties',
|
||||
'.java', '.txt', '.yml', '.yaml', '.gitignore', '.pro')
|
||||
SKIP_DIRS = {'.git', '__pycache__', 'data', 'logs'}
|
||||
|
||||
|
||||
def process(root, dry=False):
|
||||
changed = []
|
||||
for dp, dn, fn in os.walk(root):
|
||||
dn[:] = [d for d in dn if d not in SKIP_DIRS]
|
||||
for f in fn:
|
||||
if not f.endswith(TEXT_EXT) and f not in ('.gitignore', 'gradlew'):
|
||||
continue
|
||||
fp = os.path.join(dp, f)
|
||||
if os.path.getsize(fp) > 3_000_000:
|
||||
continue
|
||||
try:
|
||||
t = io.open(fp, encoding='utf-8').read()
|
||||
except (UnicodeDecodeError, OSError):
|
||||
continue
|
||||
orig = t
|
||||
for rx, rep in RULES:
|
||||
t = rx.sub(rep, t)
|
||||
if t != orig:
|
||||
changed.append(os.path.relpath(fp, root))
|
||||
if not dry:
|
||||
io.open(fp, 'w', encoding='utf-8', newline='\n').write(t)
|
||||
return changed
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
raise SystemExit(__doc__)
|
||||
root = sys.argv[1]
|
||||
dry = '--dry' in sys.argv
|
||||
if not os.path.isdir(root):
|
||||
raise SystemExit('不是目录:%s' % root)
|
||||
changed = process(root, dry)
|
||||
print('%s %d 个文件%s' % ('会改' if dry else '已改', len(changed), '(--dry 未落盘)' if dry else ''))
|
||||
for c in changed[:40]:
|
||||
print(' ', c)
|
||||
if len(changed) > 40:
|
||||
print(' ... 还有 %d 个' % (len(changed) - 40))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -22,7 +22,7 @@ import re
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
SRC = r"D:\DSH\maimai\Astral"
|
||||
SRC = r"<astral>"
|
||||
HEADER = "# -*- coding: utf-8 -*-\n"
|
||||
|
||||
|
||||
|
||||
@@ -1,25 +1,30 @@
|
||||
# maipulse APK 打包:同步资源 -> Gradle -> 16K 页对齐 -> v1/v2/v3 签名 -> 放桌面
|
||||
# 用法:powershell -NoProfile -ExecutionPolicy Bypass -File D:\DSH\maimai\maipulse\tools\build_apk.ps1
|
||||
# maipulse APK 打包:自检 -> 同步资源(剔掉跑不到的东西)-> Gradle release -> 16K 对齐 -> 签名 -> 放桌面
|
||||
# 用法:powershell -NoProfile -ExecutionPolicy Bypass -File <maipulse>\tools\build_apk.ps1
|
||||
# 加 -Debug 出 debug 包(可 chrome://inspect,仅本地排障用)
|
||||
param(
|
||||
[switch]$Debug
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$proj = 'D:\DSH\maimai\maipulse-android'
|
||||
$app = 'D:\DSH\maimai\maipulse'
|
||||
$bt = 'D:\DSH\_androidsdk\build-tools\36.0.0'
|
||||
$ks = 'D:\DSH\maimai\maipulse-android\maipulse-debug.keystore'
|
||||
$dest = 'C:\Users\20558\Desktop\maipulse-debug.apk'
|
||||
$proj = '<android 工程>'
|
||||
$app = '<maipulse>'
|
||||
$bt = '%ANDROID_HOME%\build-tools\36.0.0'
|
||||
$ks = '<android 工程>\maipulse-debug.keystore'
|
||||
$dest = '%USERPROFILE%\Desktop\maipulse-debug.apk'
|
||||
$assets = "$proj\app\src\main\assets\app"
|
||||
$tmpDir = 'D:\DSH\maimai\_buildtmp'
|
||||
$tmpDir = '<项目根>\_buildtmp'
|
||||
$aligned = "$tmpDir\maipulse-aligned.apk"
|
||||
$signed = "$proj\app\build\outputs\apk\debug\app-debug-aligned.apk"
|
||||
$raw = "$proj\app\build\outputs\apk\debug\app-debug.apk"
|
||||
$varName = if ($Debug) { 'debug' } else { 'release' }
|
||||
$signed = "$proj\app\build\outputs\apk\$varName\app-$varName-aligned.apk"
|
||||
$raw = "$proj\app\build\outputs\apk\$varName\app-$varName.apk"
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $tmpDir | Out-Null
|
||||
|
||||
$env:JAVA_HOME = 'C:\Program Files\BellSoft\LibericaJDK-21\'
|
||||
$env:ANDROID_HOME = 'D:\DSH\_androidsdk'
|
||||
$env:ANDROID_SDK_ROOT = 'D:\DSH\_androidsdk'
|
||||
$env:ANDROID_HOME = '%ANDROID_HOME%'
|
||||
$env:ANDROID_SDK_ROOT = '%ANDROID_HOME%'
|
||||
|
||||
# 0) 先清干净:旧产物、旧的 assets 副本一律删掉再全量重建,
|
||||
# 0) 清干净:旧产物、旧的 assets 副本一律删掉再全量重建,
|
||||
# 避免 robocopy 只增不删导致删过的文件还残留在包里
|
||||
if (Test-Path "$proj\app\build\outputs\apk") {
|
||||
Remove-Item "$proj\app\build\outputs\apk" -Recurse -Force
|
||||
@@ -28,66 +33,120 @@ if (Test-Path $assets) {
|
||||
Remove-Item $assets -Recurse -Force
|
||||
}
|
||||
New-Item -ItemType Directory -Force -Path $assets | Out-Null
|
||||
Write-Host '已清空旧产物与 assets 副本'
|
||||
Write-Host '[0/6] 已清空旧产物与 assets 副本'
|
||||
|
||||
# 1) 同步代码与前端
|
||||
Copy-Item "$app\app.py" "$assets\app.py" -Force
|
||||
robocopy "$app\core" "$assets\core" /E /NJH /NJS /NDL /NC /NS /XD __pycache__ | Out-Null
|
||||
robocopy "$app\html" "$assets\html" /E /NJH /NJS /NDL /NC /NS /XD __pycache__ | Out-Null
|
||||
# astral 从主仓复制,排掉用户快照、日志与真实配置
|
||||
robocopy 'D:\DSH\maimai\Astral' "$assets\astral" /E /NJH /NJS /NDL /NC /NS `
|
||||
/XD __pycache__ data logs /XF "selftest.py" "config.py" "*.json" | Out-Null
|
||||
# 删掉开发探针 _xxx.py —— 注意 __init__.py 也是下划线开头,必须留下,否则包没有入口
|
||||
|
||||
# astral:只要协议实现本体(api/),其余全部剔掉
|
||||
# gamehook = C# 抓包工具的源码/dll/pdb,跑不到
|
||||
# icon = 514KB 的源项目图标,跑不到
|
||||
# tools = 成绩图表生成脚本,跑不到
|
||||
# data/logs = 用户快照与日志,不能带
|
||||
# *.md/.cs/.dll/.pdb/.example 之类都是开发期物料
|
||||
robocopy '<astral>' "$assets\astral" /E /NJH /NJS /NDL /NC /NS `
|
||||
/XD __pycache__ data logs gamehook icon tools .git `
|
||||
/XF "selftest.py" "config.py" "*.json" "*.md" "*.cs" "*.dll" "*.pdb" "*.txt" "*.log" `
|
||||
"*.csproj" "*.props" "*.targets" "*.editorconfig" "*.cache" "*.example" `
|
||||
".gitignore" ".gitattributes" | Out-Null
|
||||
|
||||
# 开发探针 _xxx.py —— 注意 __init__.py 也是下划线开头,必须留下,否则包没有入口
|
||||
Get-ChildItem "$assets\astral" -Recurse -File -Filter "_*.py" |
|
||||
Where-Object { $_.Name -ne '__init__.py' } |
|
||||
Remove-Item -Force
|
||||
# 复查:包的 __init__.py 一个都不能少
|
||||
$must = @("$assets\astral\api\__init__.py", "$assets\core\__init__.py")
|
||||
foreach ($m in $must) {
|
||||
if (-not (Test-Path $m)) { throw "缺包入口:$m" }
|
||||
|
||||
# astral 根目录下这 7 个是独立 CLI 脚本:app.py / core / api 全不 import,删掉省 110KB
|
||||
$deadScripts = @('authv1', 'buy_ticket', 'netdelivery', 'reincarnate',
|
||||
'unlockmusic', 'upsert', 'use_ticket')
|
||||
foreach ($d in $deadScripts) {
|
||||
Remove-Item "$assets\astral\$d.py" -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
# 2) 曲库与道具索引(缺了会报 master.json 不存在)
|
||||
New-Item -ItemType Directory -Force -Path "$assets\data" | Out-Null
|
||||
Copy-Item "$app\data\master.json" "$assets\data\master.json" -Force
|
||||
robocopy "$app\data\master" "$assets\data\master" /E /NJH /NJS /NDL /NC /NS | Out-Null
|
||||
# 2b) 曲库更新用的抓取脚本(更新曲库按钮会 import fetch_df)
|
||||
New-Item -ItemType Directory -Force -Path "$assets\tools" | Out-Null
|
||||
Copy-Item "$app\tools\fetch_df.py" "$assets\tools\fetch_df.py" -Force
|
||||
if (Test-Path "$app\data\icons") {
|
||||
robocopy "$app\data\icons" "$assets\data\icons" /E /NJH /NJS /NDL /NC /NS | Out-Null
|
||||
}
|
||||
# 2b) 曲库更新用的抓取脚本(更新曲库按钮会 import fetch_df)
|
||||
New-Item -ItemType Directory -Force -Path "$assets\tools" | Out-Null
|
||||
Copy-Item "$app\tools\fetch_df.py" "$assets\tools\fetch_df.py" -Force
|
||||
Write-Host '[1/6] 代码与资源已同步(已剔除 gamehook / 图标 / 图表工具 / 独立脚本 / 文档)'
|
||||
|
||||
# 2c) 写入资源版本号:内容随每次打包变化,手机上靠它判断要不要重建 filesDir/app
|
||||
$rev = Get-Date -Format 'yyyyMMddHHmmss'
|
||||
Set-Content -Path "$assets\asset.rev" -Value $rev -Encoding ASCII -NoNewline
|
||||
Write-Host "asset.rev = $rev"
|
||||
# 3) 脱敏:抹掉真实机台身份(BASE_URL / AES / 混淆盐 / 店铺名),config.py 换成空模板
|
||||
& 'C:\Users\20558\AppData\Local\Programs\Python\Python313\python.exe' "$app\tools\blank_secrets.py" "$assets\astral"
|
||||
if ($LASTEXITCODE -ne 0) { throw '脱敏失败,停止打包' }
|
||||
Write-Host " asset.rev = $rev"
|
||||
|
||||
# 4) 编译
|
||||
# 3) 脱敏:抹掉真实机台身份(BASE_URL / AES / 混淆盐 / 店铺名),config.py 换成空模板
|
||||
& '%USERPROFILE%\AppData\Local\Programs\Python\Python313\python.exe' "$app\tools\blank_secrets.py" "$assets\astral"
|
||||
if ($LASTEXITCODE -ne 0) { throw '脱敏失败,停止打包' }
|
||||
Write-Host '[2/6] 脱敏完成'
|
||||
|
||||
# 4) 打包前自检:少一个关键文件手机上就起不来,宁可在这里炸
|
||||
$must = @(
|
||||
"$assets\app.py",
|
||||
"$assets\asset.rev",
|
||||
"$assets\core\__init__.py",
|
||||
"$assets\astral\config.py",
|
||||
"$assets\astral\api\__init__.py",
|
||||
"$assets\astral\api\MaimaiNet.py",
|
||||
"$assets\astral\api\Chime.py",
|
||||
"$assets\data\master.json",
|
||||
"$assets\data\master\music.json",
|
||||
"$assets\html\index.html",
|
||||
"$assets\tools\fetch_df.py"
|
||||
)
|
||||
$missing = $must | Where-Object { -not (Test-Path $_) }
|
||||
if ($missing) { throw ("自检失败,缺文件:`n " + ($missing -join "`n ")) }
|
||||
|
||||
# 关键模块里不该再出现任何真实机台身份
|
||||
$leak = Select-String -Path "$assets\astral\config.py", "$assets\astral\api\MaimaiNet.py" `
|
||||
-Pattern 'CLIENT_ID\s*=\s*"[0-9A-F]{6,}"|BASE_URL\s*=\s*"https?://[^"]+"' -ErrorAction SilentlyContinue
|
||||
if ($leak) { throw ("脱敏不干净,还留着真实身份:`n" + ($leak | ForEach-Object { $_.Line.Trim() }) -join "`n") }
|
||||
|
||||
$pyCount = (Get-ChildItem "$assets" -Recurse -Filter *.py).Count
|
||||
$totalMB = [math]::Round(((Get-ChildItem $assets -Recurse -File | Measure-Object Length -Sum).Sum) / 1MB, 2)
|
||||
Write-Host "[3/6] 自检通过:$pyCount 个 .py,资源总计 ${totalMB}MB"
|
||||
|
||||
# 5) 编译
|
||||
# 注意:javac 会把「使用了过时的 API」这类提示写到 stderr,
|
||||
# PowerShell 遇到外部命令的 stderr 且 ErrorActionPreference=Stop 时会直接终止脚本。
|
||||
# 所以整段输出重定向进日志文件,PowerShell 只读文件、只看退出码。
|
||||
Push-Location $proj
|
||||
cmd /c "gradlew.bat assembleDebug --no-daemon" 2>&1 |
|
||||
Select-String -Pattern 'BUILD|error:|错误|FAILED' | Select-Object -Last 4
|
||||
$task = if ($Debug) { 'assembleDebug' } else { 'assembleRelease' }
|
||||
$gradleLog = "$tmpDir\gradle-$varName.log"
|
||||
cmd /c "gradlew.bat $task --no-daemon > `"$gradleLog`" 2>&1"
|
||||
$gradleCode = $LASTEXITCODE
|
||||
Pop-Location
|
||||
|
||||
if (-not (Test-Path $raw)) { throw "没编出 APK: $raw" }
|
||||
Get-Content $gradleLog -Encoding utf8 -ErrorAction SilentlyContinue |
|
||||
Select-String -Pattern 'BUILD SUCCESSFUL|BUILD FAILED|FAILURE:|error:' |
|
||||
Select-Object -Last 6 | ForEach-Object { ' ' + $_.Line.Trim() }
|
||||
|
||||
# 5) 16KB 页对齐(Android 15+ / 17 正式签名强制)
|
||||
if ($gradleCode -ne 0 -or -not (Test-Path $raw)) {
|
||||
throw "Gradle 失败(退出码 $gradleCode),完整日志:$gradleLog"
|
||||
}
|
||||
Write-Host "[4/6] 编译完成:$raw"
|
||||
|
||||
# 6) 16KB 页对齐(Android 15+ / 17 正式签名强制)
|
||||
if (Test-Path $aligned) { Remove-Item $aligned -Force }
|
||||
& "$bt\zipalign.exe" -P 16 -f 4 $raw $aligned | Out-Null
|
||||
& "$bt\zipalign.exe" -c -P 16 4 $aligned | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) { throw '16K 对齐校验失败' }
|
||||
|
||||
# 6) 重新签名(zipalign 会让原签名失效)
|
||||
# 7) 重新签名(zipalign 会让原签名失效)
|
||||
if (Test-Path $signed) { Remove-Item $signed -Force }
|
||||
& "$bt\apksigner.bat" sign --ks $ks --ks-pass pass:android --key-pass pass:android `
|
||||
--min-sdk-version 24 --v1-signing-enabled true --v2-signing-enabled true --v3-signing-enabled true `
|
||||
--out $signed $aligned | Out-Null
|
||||
& "$bt\apksigner.bat" verify $signed | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) { throw '签名校验失败' }
|
||||
Write-Host '[5/6] 16K 对齐 + 签名校验通过'
|
||||
|
||||
Copy-Item $signed $dest -Force
|
||||
$mb = [math]::Round((Get-Item $dest).Length / 1MB, 2)
|
||||
Write-Host "OK: $dest ${mb}MB (16K aligned + signed)"
|
||||
Write-Host "[6/6] OK: $dest ${mb}MB ($varName, 16K aligned + signed)"
|
||||
@@ -0,0 +1,189 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
collect_masterdata.py —— 从国服客户端 StreamingAssets 抽取 masterdata 索引表
|
||||
================================================================================
|
||||
|
||||
扫描 A000(基础)与 A005/A006/.../A041(增量),按目录类型解析 XML,
|
||||
输出 data/master/<kind>.json,供发包程序的下拉框 / 全选使用。
|
||||
|
||||
用法:
|
||||
python tools/collect_masterdata.py --src "D:\\sdgb\\Sinmai_Data\\StreamingAssets"
|
||||
|
||||
产物(每条都带 id + name):
|
||||
music.json id / title / artist / genre / version / bpm / difficulties[]
|
||||
plate.json id / name / genre / release / lock
|
||||
title.json id / name / genre / release / lock
|
||||
icon.json id / name / genre / release / lock
|
||||
frame.json id / name / genre / release / lock
|
||||
partner.json id / name / genre / release / lock
|
||||
chara.json id / name / genre / release / lock
|
||||
ticket.json id / name / detail / areaPercent / charaMagnification / isMoney
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
KIND_SPEC = {
|
||||
"music": ("music", "MusicData"),
|
||||
"plate": ("plate", "PlateData"),
|
||||
"title": ("title", "TitleData"),
|
||||
"icon": ("icon", "IconData"),
|
||||
"frame": ("frame", "FrameData"),
|
||||
"partner": ("partner", "PartnerData"),
|
||||
"chara": ("chara", "CharaData"),
|
||||
"ticket": ("ticket", "TicketData"),
|
||||
}
|
||||
|
||||
DIFF_NAMES = ["Basic", "Advanced", "Expert", "Master", "Re:Master"]
|
||||
|
||||
|
||||
def text(node, path, default=""):
|
||||
if node is None:
|
||||
return default
|
||||
el = node.find(path)
|
||||
if el is None or el.text is None:
|
||||
return default
|
||||
return el.text.strip()
|
||||
|
||||
|
||||
def int_of(node, path, default=0):
|
||||
raw = text(node, path, "")
|
||||
try:
|
||||
return int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def parse_music(root):
|
||||
notes = []
|
||||
nd = root.find("notesData")
|
||||
if nd is not None:
|
||||
for i, n in enumerate(nd.findall("Notes")):
|
||||
notes.append({
|
||||
"level": int_of(n, "level"),
|
||||
"levelDecimal": int_of(n, "levelDecimal"),
|
||||
"designer": text(n, "notesDesigner/str"),
|
||||
})
|
||||
return {
|
||||
"id": int_of(root, "name/id"),
|
||||
"name": text(root, "name/str"),
|
||||
"sortName": text(root, "sortName"),
|
||||
"artist": text(root, "artistName/str"),
|
||||
"genre": text(root, "genreName/str"),
|
||||
"bpm": int_of(root, "bpm"),
|
||||
"version": int_of(root, "version"),
|
||||
"addVersion": text(root, "AddVersion/str"),
|
||||
"lockType": int_of(root, "lockType"),
|
||||
"disable": text(root, "disable") == "true",
|
||||
"notes": notes,
|
||||
}
|
||||
|
||||
|
||||
def parse_item(root):
|
||||
return {
|
||||
"id": int_of(root, "name/id"),
|
||||
"name": text(root, "name/str"),
|
||||
"genre": text(root, "genre/str"),
|
||||
"release": text(root, "releaseTagName/str"),
|
||||
"lock": text(root, "dispCond"),
|
||||
"norm": text(root, "normText"),
|
||||
"isDefault": text(root, "isDefault") == "true",
|
||||
"disable": text(root, "disable") == "true",
|
||||
}
|
||||
|
||||
|
||||
def parse_ticket(root):
|
||||
return {
|
||||
"id": int_of(root, "name/id"),
|
||||
"name": text(root, "name/str"),
|
||||
"detail": text(root, "detail"),
|
||||
"areaPercent": int_of(root, "areaPercent"),
|
||||
"charaMagnification": int_of(root, "charaMagnification"),
|
||||
"isMoney": text(root, "isMoney") == "true",
|
||||
"creditNum": int_of(root, "creditNum"),
|
||||
"maxCount": int_of(root, "maxCount"),
|
||||
"kind": text(root, "ticketKind/str"),
|
||||
"event": text(root, "ticketEvent/str"),
|
||||
}
|
||||
|
||||
|
||||
def collect(src):
|
||||
packs = sorted(
|
||||
[d for d in os.listdir(src) if re.fullmatch(r"A\d{3}", d)],
|
||||
key=lambda x: x,
|
||||
)
|
||||
packs.sort(key=lambda x: (x != "A000", x)) # A000 先,其余按序覆盖
|
||||
out = {k: {} for k in KIND_SPEC}
|
||||
stats = {k: {"files": 0, "added": 0} for k in KIND_SPEC}
|
||||
|
||||
for pack in packs:
|
||||
base = os.path.join(src, pack)
|
||||
for kind, (folder, root_tag) in KIND_SPEC.items():
|
||||
d = os.path.join(base, folder)
|
||||
if not os.path.isdir(d):
|
||||
continue
|
||||
for name in os.listdir(d):
|
||||
sub = os.path.join(d, name)
|
||||
if not os.path.isdir(sub):
|
||||
continue
|
||||
xml = None
|
||||
for f in os.listdir(sub):
|
||||
if f.lower() == folder + ".xml" or f.lower().endswith(".xml"):
|
||||
if "sort" in f.lower():
|
||||
continue
|
||||
xml = os.path.join(sub, f)
|
||||
break
|
||||
if not xml:
|
||||
continue
|
||||
try:
|
||||
root = ET.parse(xml).getroot()
|
||||
except ET.ParseError:
|
||||
continue
|
||||
if root.tag != root_tag:
|
||||
continue
|
||||
try:
|
||||
if kind == "music":
|
||||
rec = parse_music(root)
|
||||
elif kind == "ticket":
|
||||
rec = parse_ticket(root)
|
||||
else:
|
||||
rec = parse_item(root)
|
||||
except Exception:
|
||||
continue
|
||||
if not rec.get("id"):
|
||||
continue
|
||||
if rec["id"] not in out[kind]:
|
||||
stats[kind]["added"] += 1
|
||||
out[kind][rec["id"]] = rec
|
||||
stats[kind]["files"] += 1
|
||||
|
||||
return out, stats
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--src", default=r"D:\sdgb\Sinmai_Data\StreamingAssets")
|
||||
ap.add_argument("--out", default=os.path.join(os.path.dirname(os.path.dirname(
|
||||
os.path.abspath(__file__))), "data", "master"))
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
out, stats = collect(args.src)
|
||||
os.makedirs(args.out, exist_ok=True)
|
||||
for kind, items in out.items():
|
||||
rows = sorted(items.values(), key=lambda r: r["id"])
|
||||
path = os.path.join(args.out, kind + ".json")
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(rows, f, ensure_ascii=False, indent=1)
|
||||
s = stats[kind]
|
||||
print("%-8s %5d 条(扫描 %d 个文件) -> %s" % (kind, len(rows), s["files"], path))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -6,7 +6,7 @@ import sys
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
BASE = r"D:\bs\Package\Sinmai_Data\StreamingAssets\A000"
|
||||
OUT = r"D:\DSH\maimai\maipulse\data\master.json"
|
||||
OUT = r"<maipulse>\data\master.json"
|
||||
|
||||
CATS = {
|
||||
"ticket": ("ticket", 12),
|
||||
|
||||
+250
-91
@@ -1,16 +1,21 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""发版:改版本号 -> 打包 -> 推仓库 -> 新开一个 Release -> 上传 APK。
|
||||
"""发版:改版本号 -> 打包 -> 推两个仓 -> 新开一个 Release -> 上传 APK。
|
||||
|
||||
两个仓库,两套同步面:
|
||||
· Gitea 公开仓 shouko/maipulse —— 脱敏:不推设计稿,本机路径/邮箱全部替换成占位符
|
||||
· GitHub 私有仓 gu-kaf/maimai-backup —— 完整备份,含 keychips 与真实配置,不脱敏
|
||||
|
||||
★ 每次发版都新建 Release,绝不覆盖旧的那个:
|
||||
旧版本留着,别人手上的旧包点「检查更新」还能按 tag 找到自己的历史版本,
|
||||
也能回滚;覆盖 asset 会让同一个 tag 指向不同内容,hash 对不上,排查时非常难缠。
|
||||
|
||||
用法:
|
||||
python tools/release.py 1.0.1 "修了什么"
|
||||
python tools/release.py 1.0.1 "修了什么" --dry 只打印计划
|
||||
python tools/release.py 1.0.1 --no-build 复用桌面上已有的 APK
|
||||
python tools/release.py 1.0.2 "修了什么"
|
||||
python tools/release.py 1.0.2 "修了什么" --dry 只打印计划
|
||||
python tools/release.py 1.0.2 --no-build 复用桌面上已有的 APK
|
||||
|
||||
token 从环境变量 GITEA_TOKEN 读,读不到再看 tools/.release-token(该文件不入库)。
|
||||
GitHub 的凭据在 _gh_backup 的 remote 里,直接 git push 即可。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -24,8 +29,12 @@ import urllib.request
|
||||
|
||||
APP_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
APP_PY = os.path.join(APP_DIR, "app.py")
|
||||
REPO_DIR = r"D:\DSH\maimai\_repo"
|
||||
APK = r"C:\Users\20558\Desktop\maipulse-debug.apk"
|
||||
ANDROID_DIR = r"<android 工程>"
|
||||
|
||||
REPO_DIR = r"<仓库>" # Gitea 公开仓的工作副本
|
||||
GH_DIR = r"<项目根>\_gh_backup" # GitHub 私有仓的工作副本
|
||||
|
||||
APK = r"%USERPROFILE%\Desktop\maipulse-debug.apk"
|
||||
BUILD_SCRIPT = os.path.join(APP_DIR, "tools", "build_apk.ps1")
|
||||
|
||||
SITE = "https://git.fragrance.moe"
|
||||
@@ -35,17 +44,23 @@ REPO = "maipulse"
|
||||
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/120.0 Safari/537.36")
|
||||
|
||||
|
||||
# 要同步进仓库的源码面(相对 APP_DIR)。只列「改了必须让用户拿到」的,
|
||||
# 探针脚本、dump 工具、快照数据一律不进库。
|
||||
SYNC_DIRS = ["core", "html", "tools", "data"]
|
||||
SYNC_FILES = ["app.py", "PLAN.md", "PLAN-APP.md", "PLAN-EXT.md"]
|
||||
|
||||
# 数据目录里只带运行必需的资产,其余(用户数据 / 快照 / 缓存)排除
|
||||
# ---- 公开仓同步面 ----
|
||||
# 设计稿(PLAN*.md)不进公开仓:里面全是开发过程、逆向出处和本机路径
|
||||
PUB_FILES = ["app.py"]
|
||||
PUB_DIRS = ["core", "html", "tools", "data"]
|
||||
# tools 里只带运行与发版必需的,探针/dump 一律不进库
|
||||
PUB_TOOLS = {"build_apk.ps1", "blank_secrets.py", "blank_paths.py", "apply_update.py",
|
||||
"fetch_df.py", "dump_master.py", "release.py", "restart.ps1",
|
||||
"backfill_pack.py", "collect_masterdata.py"}
|
||||
DATA_KEEP = ["master.json", "master", "icons"]
|
||||
|
||||
# ---- 私有仓同步面(完整备份,不脱敏)----
|
||||
PRIV_FILES = ["app.py", "version.json", "config.filled.json",
|
||||
"PLAN.md", "PLAN-APP.md", "PLAN-EXT.md"]
|
||||
PRIV_DIRS = ["core", "html", "tools", "data", "keychips"]
|
||||
|
||||
SKIP_NAMES = {"__pycache__", ".release-token", "accounts.json", "shops.json",
|
||||
"shop_current.json", "config.filled.json", "covers", "_backup",
|
||||
"logs", "data"}
|
||||
"shop_current.json", "covers", "_backup", "logs"}
|
||||
SKIP_SUFFIX = (".pyc", ".pyo")
|
||||
|
||||
|
||||
@@ -59,17 +74,34 @@ def _force_copy(src, dst):
|
||||
os.chmod(dst, stat.S_IWRITE | stat.S_IREAD)
|
||||
except OSError:
|
||||
pass
|
||||
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
||||
shutil.copy2(src, dst)
|
||||
|
||||
|
||||
def sync_source(verbose=True):
|
||||
"""把源码面同步进 REPO_DIR/maipulse。返回同步的文件数。"""
|
||||
def _walk_copy(src_dir, dst_dir, only_names=None, skip_names=SKIP_NAMES):
|
||||
count = 0
|
||||
for root, dirs, files in os.walk(src_dir):
|
||||
dirs[:] = [x for x in dirs if x not in skip_names]
|
||||
rel = os.path.relpath(root, src_dir)
|
||||
target = dst_dir if rel == "." else os.path.join(dst_dir, rel)
|
||||
for f in files:
|
||||
if f in skip_names or f.endswith(SKIP_SUFFIX):
|
||||
continue
|
||||
if only_names is not None and f not in only_names:
|
||||
continue
|
||||
_force_copy(os.path.join(root, f), os.path.join(target, f))
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def sync_public():
|
||||
"""源码面 -> REPO_DIR/maipulse(脱敏版)。返回文件数。"""
|
||||
import shutil
|
||||
dst_root = os.path.join(REPO_DIR, "maipulse")
|
||||
os.makedirs(dst_root, exist_ok=True)
|
||||
count = 0
|
||||
|
||||
for name in SYNC_FILES:
|
||||
for name in PUB_FILES:
|
||||
src = os.path.join(APP_DIR, name)
|
||||
if os.path.isfile(src):
|
||||
_force_copy(src, os.path.join(dst_root, name))
|
||||
@@ -77,49 +109,126 @@ def sync_source(verbose=True):
|
||||
|
||||
for d in ("core", "html", "tools"):
|
||||
src_dir = os.path.join(APP_DIR, d)
|
||||
if not os.path.isdir(src_dir):
|
||||
continue
|
||||
dst_dir = os.path.join(dst_root, d)
|
||||
os.makedirs(dst_dir, exist_ok=True)
|
||||
for root, dirs, files in os.walk(src_dir):
|
||||
dirs[:] = [x for x in dirs if x not in SKIP_NAMES]
|
||||
rel = os.path.relpath(root, src_dir)
|
||||
target = dst_dir if rel == "." else os.path.join(dst_dir, rel)
|
||||
os.makedirs(target, exist_ok=True)
|
||||
for f in files:
|
||||
if f in SKIP_NAMES or f.endswith(SKIP_SUFFIX):
|
||||
continue
|
||||
# tools 里的探针/dump 脚本不进库,只带运行与发版必需的
|
||||
if d == "tools" and f not in {
|
||||
"build_apk.ps1", "blank_secrets.py", "apply_update.py",
|
||||
"fetch_df.py", "dump_master.py", "release.py", "restart.ps1"}:
|
||||
continue
|
||||
_force_copy(os.path.join(root, f), os.path.join(target, f))
|
||||
count += 1
|
||||
if os.path.isdir(src_dir):
|
||||
count += _walk_copy(src_dir, os.path.join(dst_root, d),
|
||||
only_names=PUB_TOOLS if d == "tools" else None)
|
||||
|
||||
# data:只带曲库 / 图标这些运行资产
|
||||
data_src = os.path.join(APP_DIR, "data")
|
||||
data_dst = os.path.join(dst_root, "data")
|
||||
for name in DATA_KEEP:
|
||||
s_item = os.path.join(data_src, name)
|
||||
if os.path.isfile(s_item):
|
||||
os.makedirs(data_dst, exist_ok=True)
|
||||
_force_copy(s_item, os.path.join(data_dst, name))
|
||||
p = os.path.join(data_src, name)
|
||||
if os.path.isfile(p):
|
||||
_force_copy(p, os.path.join(data_dst, name))
|
||||
count += 1
|
||||
elif os.path.isdir(s_item):
|
||||
for root, dirs, files in os.walk(s_item):
|
||||
dirs[:] = [x for x in dirs if x not in SKIP_NAMES]
|
||||
rel = os.path.relpath(root, data_src)
|
||||
target = os.path.join(data_dst, rel)
|
||||
os.makedirs(target, exist_ok=True)
|
||||
for f in files:
|
||||
if f in SKIP_NAMES or f.endswith(SKIP_SUFFIX):
|
||||
continue
|
||||
_force_copy(os.path.join(root, f), os.path.join(target, f))
|
||||
elif os.path.isdir(p):
|
||||
count += _walk_copy(p, os.path.join(data_dst, name))
|
||||
|
||||
# 公开仓里不放设计稿:上一次推上去的可能是旧的,主动删掉
|
||||
for stale in ("PLAN.md", "PLAN-APP.md", "PLAN-EXT.md", "config.filled.json"):
|
||||
p = os.path.join(dst_root, stale)
|
||||
if os.path.exists(p):
|
||||
os.remove(p)
|
||||
print(" 公开仓移除设计稿/私有配置:%s" % stale)
|
||||
|
||||
return count
|
||||
|
||||
|
||||
def sync_android_public():
|
||||
"""Android 工程 -> REPO_DIR/android。返回文件数。"""
|
||||
dst = os.path.join(REPO_DIR, "android")
|
||||
if os.path.isdir(dst):
|
||||
import shutil
|
||||
shutil.rmtree(dst, ignore_errors=True)
|
||||
count = 0
|
||||
src_main = os.path.join(ANDROID_DIR, "app", "src", "main")
|
||||
# assets 是打包时生成的,仓库里不要
|
||||
count += _walk_copy(src_main, os.path.join(dst, "app", "src", "main"),
|
||||
skip_names=SKIP_NAMES | {"assets"})
|
||||
for f in ("build.gradle", "proguard-rules.pro", "app/build.gradle",
|
||||
"settings.gradle", "gradle.properties",
|
||||
"gradle/wrapper/gradle-wrapper.properties"):
|
||||
s = os.path.join(ANDROID_DIR, f)
|
||||
if os.path.isfile(s):
|
||||
_force_copy(s, os.path.join(dst, f))
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def sync_astro_public():
|
||||
"""astral 根目录的漂移(config.py 除外,它由 blank_secrets 生成空模板)"""
|
||||
src = r"<astral>"
|
||||
dst = os.path.join(REPO_DIR, "astral")
|
||||
if not os.path.isdir(src):
|
||||
return 0
|
||||
count = 0
|
||||
for f in ("authv1.py", "buy_ticket.py", "netdelivery.py", "reincarnate.py",
|
||||
"unlockmusic.py", "upsert.py", "use_ticket.py", "README.md",
|
||||
"config.py.example"):
|
||||
s = os.path.join(src, f)
|
||||
if os.path.isfile(s):
|
||||
_force_copy(s, os.path.join(dst, f))
|
||||
count += 1
|
||||
count += _walk_copy(os.path.join(src, "api"), os.path.join(dst, "api"))
|
||||
count += _walk_copy(os.path.join(src, "tools"), os.path.join(dst, "tools"))
|
||||
return count
|
||||
|
||||
|
||||
def blank_public():
|
||||
"""公开仓副本做路径脱敏(本机用户名 / 目录结构 / 邮箱)"""
|
||||
sys.path.insert(0, os.path.join(APP_DIR, "tools"))
|
||||
import blank_paths
|
||||
changed = []
|
||||
for sub in ("maipulse", "android", "astral"):
|
||||
p = os.path.join(REPO_DIR, sub)
|
||||
if os.path.isdir(p):
|
||||
changed += blank_paths.process(p)
|
||||
return changed
|
||||
|
||||
|
||||
def sync_private():
|
||||
"""完整源码 -> GH_DIR/maipulse(不脱敏,保留 keychips 与真实配置)"""
|
||||
dst_root = os.path.join(GH_DIR, "maipulse")
|
||||
os.makedirs(dst_root, exist_ok=True)
|
||||
count = 0
|
||||
|
||||
for name in PRIV_FILES:
|
||||
src = os.path.join(APP_DIR, name)
|
||||
if os.path.isfile(src):
|
||||
_force_copy(src, os.path.join(dst_root, name))
|
||||
count += 1
|
||||
|
||||
for d in ("core", "html", "tools"):
|
||||
src_dir = os.path.join(APP_DIR, d)
|
||||
if os.path.isdir(src_dir):
|
||||
count += _walk_copy(src_dir, os.path.join(dst_root, d))
|
||||
|
||||
data_src = os.path.join(APP_DIR, "data")
|
||||
data_dst = os.path.join(dst_root, "data")
|
||||
for name in DATA_KEEP:
|
||||
p = os.path.join(data_src, name)
|
||||
if os.path.isfile(p):
|
||||
_force_copy(p, os.path.join(data_dst, name))
|
||||
count += 1
|
||||
elif os.path.isdir(p):
|
||||
count += _walk_copy(p, os.path.join(data_dst, name))
|
||||
|
||||
# Android 工程也备一份(公开仓删了旧的,私有仓要跟着更新)
|
||||
if os.path.isdir(ANDROID_DIR):
|
||||
ah = os.path.join(GH_DIR, "android")
|
||||
import shutil
|
||||
if os.path.isdir(ah):
|
||||
shutil.rmtree(ah, ignore_errors=True)
|
||||
count += _walk_copy(os.path.join(ANDROID_DIR, "app", "src", "main"),
|
||||
os.path.join(ah, "app", "src", "main"),
|
||||
skip_names=SKIP_NAMES | {"assets"})
|
||||
for f in ("build.gradle", "proguard-rules.pro", "app/build.gradle",
|
||||
"settings.gradle", "gradle.properties",
|
||||
"gradle/wrapper/gradle-wrapper.properties"):
|
||||
s = os.path.join(ANDROID_DIR, f)
|
||||
if os.path.isfile(s):
|
||||
_force_copy(s, os.path.join(ah, f))
|
||||
count += 1
|
||||
|
||||
if verbose:
|
||||
print(" 源码同步 %d 个文件 -> %s" % (count, dst_root))
|
||||
return count
|
||||
|
||||
|
||||
@@ -133,23 +242,36 @@ def token() -> str:
|
||||
raise SystemExit("没有 token:设置环境变量 GITEA_TOKEN,或写 tools/.release-token")
|
||||
|
||||
|
||||
def api(path: str, method: str = "GET", body=None, raw: bytes = None, ctype: str = "application/json"):
|
||||
def api(path: str, method: str = "GET", body=None, tries: int = 5):
|
||||
"""Gitea 走 Cloudflare,偶发 [SSL: UNEXPECTED_EOF] 直接断连,重试几次就好。"""
|
||||
import ssl
|
||||
import time
|
||||
url = "%s/api/v1/repos/%s/%s%s" % (SITE, OWNER, REPO, path)
|
||||
data = raw if raw is not None else (json.dumps(body, ensure_ascii=False).encode("utf-8") if body else None)
|
||||
headers = {"Authorization": "token " + token(), "User-Agent": UA, "Accept": "application/json"}
|
||||
if data is not None:
|
||||
headers["Content-Type"] = ctype
|
||||
data = json.dumps(body, ensure_ascii=False).encode("utf-8") if body else None
|
||||
headers = {"Authorization": "token " + token(), "User-Agent": UA,
|
||||
"Accept": "application/json", "Content-Type": "application/json"}
|
||||
last = None
|
||||
for attempt in range(tries):
|
||||
req = urllib.request.Request(url, data=data, method=method, headers=headers)
|
||||
try:
|
||||
resp = urllib.request.urlopen(req, timeout=60)
|
||||
resp = urllib.request.urlopen(req, timeout=90)
|
||||
txt = resp.read().decode("utf-8")
|
||||
return json.loads(txt) if txt.strip() else {}
|
||||
except urllib.error.HTTPError as exc:
|
||||
raise SystemExit("%s %s -> HTTP %s %s" % (method, path, exc.code, exc.read()[:300]))
|
||||
detail = exc.read()[:400]
|
||||
# 4xx 是请求本身的问题,重试没意义,直接抛
|
||||
if 400 <= exc.code < 500:
|
||||
raise SystemExit("%s %s -> HTTP %s %s" % (method, path, exc.code, detail))
|
||||
last = "HTTP %s %s" % (exc.code, detail)
|
||||
except (urllib.error.URLError, ssl.SSLError, OSError) as exc:
|
||||
last = "%s: %s" % (type(exc).__name__, exc)
|
||||
wait = 2 * (attempt + 1)
|
||||
print(" 第 %d 次失败(%s),%ds 后重试" % (attempt + 1, last, wait))
|
||||
time.sleep(wait)
|
||||
raise SystemExit("%s %s 连续 %d 次失败:%s" % (method, path, tries, last))
|
||||
|
||||
|
||||
def tag_exists(tag: str) -> bool:
|
||||
"""只问存在性,不抛异常。"""
|
||||
url = "%s/api/v1/repos/%s/%s/releases/tags/%s" % (SITE, OWNER, REPO, tag)
|
||||
req = urllib.request.Request(url, headers={
|
||||
"Authorization": "token " + token(), "User-Agent": UA, "Accept": "application/json"})
|
||||
@@ -184,14 +306,33 @@ def bump(ver: str) -> str:
|
||||
|
||||
|
||||
def run(cmd, cwd=None, check=True):
|
||||
print(" $", " ".join(cmd) if isinstance(cmd, list) else cmd)
|
||||
shown = " ".join(cmd) if isinstance(cmd, list) else cmd
|
||||
# 绝不把带 token 的 URL 打出来
|
||||
shown = re.sub(r'://[^@\s]*@', '://***@', shown)
|
||||
print(" $", shown)
|
||||
r = subprocess.run(cmd, cwd=cwd, shell=isinstance(cmd, str),
|
||||
capture_output=True, text=True, encoding="utf-8", errors="replace")
|
||||
if check and r.returncode != 0:
|
||||
raise SystemExit("命令失败:%s\n%s\n%s" % (cmd, r.stdout[-800:], r.stderr[-800:]))
|
||||
out = (r.stdout or "")[-800:]
|
||||
err = (r.stderr or "")[-800:]
|
||||
raise SystemExit("命令失败:%s\n%s\n%s" % (shown, out, err))
|
||||
return r
|
||||
|
||||
|
||||
def git_commit_push(where, msg):
|
||||
run(["git", "add", "-A"], cwd=where)
|
||||
r = run(["git", "status", "--porcelain"], cwd=where, check=False)
|
||||
if (r.stdout or "").strip():
|
||||
run(["git", "-c", "user.name=maipulse", "-c", "[email protected]",
|
||||
"commit", "-m", msg], cwd=where, check=False)
|
||||
else:
|
||||
print(" 工作区无变更")
|
||||
# 不管这次有没有新提交,都推一次:
|
||||
# 上一次可能 commit 了但 push 失败,工作区是干净的,只看 status 会误判成"没变化"。
|
||||
run(["git", "push", "origin", "HEAD:main"], cwd=where)
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
args = [a for a in sys.argv[1:] if not a.startswith("--")]
|
||||
dry = "--dry" in sys.argv
|
||||
@@ -209,7 +350,6 @@ def main():
|
||||
print("构建日期:%s" % build)
|
||||
print("更新说明:%s" % (notes or "(空)"))
|
||||
|
||||
# 已有同名 tag 就直接停,绝不复用(404 = 不存在,正常继续)
|
||||
if tag_exists("v" + new_ver):
|
||||
raise SystemExit("tag v%s 已存在 —— 每次发版要用新版本号,不能覆盖旧的" % new_ver)
|
||||
|
||||
@@ -217,32 +357,39 @@ def main():
|
||||
print("\n--dry:不写文件、不打包、不推送")
|
||||
return 0
|
||||
|
||||
# 1) 改版本号
|
||||
print("\n[1/5] 写版本号")
|
||||
msg = "release: v%s\n\n%s" % (new_ver, notes or "-")
|
||||
|
||||
print("\n[1/7] 写版本号")
|
||||
set_version(new_ver, build)
|
||||
print(" app.py -> %s / %s" % (new_ver, build))
|
||||
|
||||
# 2) 打包
|
||||
print("\n[2/7] 打包")
|
||||
if no_build:
|
||||
print("\n[2/5] 跳过打包,用现有 APK")
|
||||
print(" 跳过,用现有 APK")
|
||||
else:
|
||||
print("\n[2/5] 打包")
|
||||
run(["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", BUILD_SCRIPT])
|
||||
if not os.path.isfile(APK):
|
||||
raise SystemExit("没有 APK:%s" % APK)
|
||||
size = os.path.getsize(APK)
|
||||
print(" %s %.2f MB" % (APK, size / 1048576))
|
||||
|
||||
# 3) 同步进仓库并推送
|
||||
print("\n[3/5] 推仓库")
|
||||
sync_source()
|
||||
run(["git", "add", "-A"], cwd=REPO_DIR)
|
||||
run(["git", "-c", "user.name=shouko", "-c", "[email protected]",
|
||||
"commit", "-m", "release: v%s\n\n%s" % (new_ver, notes or "-")], cwd=REPO_DIR, check=False)
|
||||
run(["git", "push", "origin", "main"], cwd=REPO_DIR)
|
||||
print("\n[3/7] 同步公开仓(脱敏)")
|
||||
n = sync_public()
|
||||
n += sync_android_public()
|
||||
n += sync_astro_public()
|
||||
print(" 源码 %d 个文件" % n)
|
||||
changed = blank_public()
|
||||
print(" 路径脱敏 %d 个文件" % len(changed))
|
||||
|
||||
# 4) 新开 Release(不是复用)
|
||||
print("\n[4/5] 新建 Release v%s" % new_ver)
|
||||
print("\n[4/7] 推 Gitea")
|
||||
pushed_pub = git_commit_push(REPO_DIR, msg)
|
||||
|
||||
print("\n[5/7] 同步 GitHub 私有仓(完整,不脱敏)")
|
||||
n2 = sync_private()
|
||||
print(" 源码 %d 个文件" % n2)
|
||||
pushed_priv = git_commit_push(GH_DIR, msg)
|
||||
|
||||
print("\n[6/7] 新建 Release v%s" % new_ver)
|
||||
rel = api("/releases", "POST", {
|
||||
"tag_name": "v" + new_ver,
|
||||
"target_commitish": "main",
|
||||
@@ -253,30 +400,42 @@ def main():
|
||||
})
|
||||
print(" id=%s %s" % (rel.get("id"), rel.get("html_url")))
|
||||
|
||||
# 5) 上传 APK 到这个新 Release
|
||||
print("\n[5/5] 上传 APK")
|
||||
print("\n[7/7] 上传 APK")
|
||||
boundary = "----maipulse" + os.urandom(8).hex()
|
||||
with open(APK, "rb") as fp:
|
||||
blob = fp.read()
|
||||
body_parts = []
|
||||
body_parts.append(("--%s\r\nContent-Disposition: form-data; name=\"attachment\"; "
|
||||
"filename=\"maipulse.apk\"\r\nContent-Type: application/vnd.android.package-archive\r\n\r\n"
|
||||
% boundary).encode("utf-8"))
|
||||
body_parts.append(blob)
|
||||
body_parts.append(("\r\n--%s--\r\n" % boundary).encode("utf-8"))
|
||||
payload = b"".join(body_parts)
|
||||
url = "%s/api/v1/repos/%s/%s/releases/%s/assets?name=maipulse.apk" % (SITE, OWNER, REPO, rel.get("id"))
|
||||
head = ("--%s\r\nContent-Disposition: form-data; name=\"attachment\"; "
|
||||
"filename=\"maipulse-%s.apk\"\r\nContent-Type: application/vnd.android.package-archive\r\n\r\n"
|
||||
% (boundary, new_ver)).encode("utf-8")
|
||||
payload = head + blob + ("\r\n--%s--\r\n" % boundary).encode("utf-8")
|
||||
url = "%s/api/v1/repos/%s/%s/releases/%s/assets?name=maipulse-%s.apk" % (
|
||||
SITE, OWNER, REPO, rel.get("id"), new_ver)
|
||||
asset = None
|
||||
import ssl
|
||||
import time
|
||||
for attempt in range(5):
|
||||
req = urllib.request.Request(url, data=payload, method="POST", headers={
|
||||
"Authorization": "token " + token(), "User-Agent": UA,
|
||||
"Content-Type": "multipart/form-data; boundary=" + boundary,
|
||||
})
|
||||
try:
|
||||
asset = json.loads(urllib.request.urlopen(req, timeout=600).read().decode("utf-8"))
|
||||
break
|
||||
except urllib.error.HTTPError as exc:
|
||||
raise SystemExit("上传失败 HTTP %s %s" % (exc.code, exc.read()[:400]))
|
||||
except (urllib.error.URLError, ssl.SSLError, OSError) as exc:
|
||||
print(" 第 %d 次上传失败(%s),重试" % (attempt + 1, exc))
|
||||
time.sleep(3 * (attempt + 1))
|
||||
if asset is None:
|
||||
raise SystemExit("上传 APK 连续失败")
|
||||
print(" %s %d B" % (asset.get("name"), asset.get("size")))
|
||||
|
||||
print("\n发版完成")
|
||||
print(" 公开仓 : %s/%s/%s (%s)" % (SITE, OWNER, REPO, "已推送" if pushed_pub else "无变更"))
|
||||
print(" 私有仓 : github.com/gu-kaf/maimai-backup (%s)" % ("已推送" if pushed_priv else "无变更"))
|
||||
print(" Release : %s" % rel.get("html_url"))
|
||||
print(" 下载 : %s/%s/%s/releases/download/v%s/maipulse.apk" % (SITE, OWNER, REPO, new_ver))
|
||||
print(" 旧版本 : 都保留着,没有被覆盖")
|
||||
print(" 下载 : %s/%s/%s/releases/download/v%s/maipulse-%s.apk" % (
|
||||
SITE, OWNER, REPO, new_ver, new_ver))
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
# powershell -ExecutionPolicy Bypass -File tools\restart.ps1
|
||||
|
||||
param(
|
||||
[string]$Py = "C:\Users\20558\AppData\Local\Programs\Python\Python313\python.exe",
|
||||
[string]$AppDir = "D:\DSH\maimai\maipulse",
|
||||
[string]$Py = "%USERPROFILE%\AppData\Local\Programs\Python\Python313\python.exe",
|
||||
[string]$AppDir = "<maipulse>",
|
||||
[string]$Url = "http://127.0.0.1:8787"
|
||||
)
|
||||
|
||||
|
||||
Reference in new issue
Block a user