0417 0022
更新
This commit is contained in:
@@ -0,0 +1,293 @@
|
||||
import 'dart:math';
|
||||
import '../model/song_model.dart';
|
||||
|
||||
class RecommendationHelper {
|
||||
|
||||
/// 基于 Java MusicService 逻辑的智能推荐
|
||||
///
|
||||
/// [allSongs]: 全量歌曲库 (SongModel)
|
||||
/// [userMusicList]: 用户成绩列表 (dynamic)
|
||||
/// - 情况A: List<Map>,每个 Map 包含 musicId, level, achievement, dx_rating
|
||||
/// - 情况B: List<Map>,每个 Map 包含 userMusicDetailList (嵌套结构)
|
||||
/// [userRating]: 用户当前总 Rating (DX Rating),用于确定推荐难度区间
|
||||
/// [b35MinRating]: 用户当前 B35 中最低的那首曲子的 Rating (用于判断推分是否有意义)
|
||||
/// [count]: 推荐数量
|
||||
static List<SongModel> getSmartRecommendations({
|
||||
required List<SongModel> allSongs,
|
||||
required List<dynamic> userMusicList,
|
||||
required int userRating,
|
||||
required int b35MinRating,
|
||||
int count = 6,
|
||||
}) {
|
||||
if (allSongs.isEmpty) return [];
|
||||
|
||||
final random = Random();
|
||||
|
||||
// 1. 解析用户成绩,构建映射: Key = "musicId_level", Value = { achievement, rating }
|
||||
// 使用 HashMap 提高查找速度
|
||||
final Map<String, Map<String, dynamic>> playedMap = {};
|
||||
|
||||
for (var group in userMusicList) {
|
||||
if (group is! Map) continue;
|
||||
|
||||
List<dynamic> details = [];
|
||||
|
||||
// 兼容两种数据结构:
|
||||
if (group.containsKey('userMusicDetailList')) {
|
||||
details = group['userMusicDetailList'] as List? ?? [];
|
||||
} else if (group.containsKey('achievement') || group.containsKey('dx_rating')) {
|
||||
// 如果当前 group 本身就是成绩对象
|
||||
details = [group];
|
||||
}
|
||||
|
||||
if (details.isEmpty) continue;
|
||||
|
||||
for (var detail in details) {
|
||||
if (detail is! Map) continue;
|
||||
|
||||
// 提取关键字段
|
||||
final int musicId = detail['musicId'] ?? detail['id'] ?? 0;
|
||||
if(musicId>16000) continue;
|
||||
final int level = detail['level'] ?? detail['levelIndex'] ?? 3; // 默认 Master
|
||||
final int achievement = detail['achievement'] ?? 0;
|
||||
// 确保 rating 是 double
|
||||
final dynamic rawRating = detail['dx_rating'] ?? detail['rating'] ?? 0;
|
||||
final double rating = rawRating is num ? rawRating.toDouble() : 0.0;
|
||||
|
||||
if (musicId == 0) continue;
|
||||
|
||||
String key = "${musicId}_${level}";
|
||||
|
||||
// 只保留该难度下的最高成绩
|
||||
if (!playedMap.containsKey(key) || achievement > (playedMap[key]!['achievement'] as int)) {
|
||||
playedMap[key] = {
|
||||
'musicId': musicId,
|
||||
'level': level,
|
||||
'achievement': achievement,
|
||||
'rating': rating,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 计算推荐难度区间 (基于 Java logic)
|
||||
double minRecommendedLevel = 0.0;
|
||||
|
||||
if (userRating >= 15500) {
|
||||
minRecommendedLevel = 13.6;
|
||||
} else if (userRating >= 15000) {
|
||||
minRecommendedLevel = 13.0;
|
||||
} else if (userRating >= 14500) {
|
||||
minRecommendedLevel = 12.5;
|
||||
} else if (userRating >= 14000) {
|
||||
minRecommendedLevel = 12.0;
|
||||
} else if (userRating >= 13000) {
|
||||
minRecommendedLevel = 11.5;
|
||||
} else if (userRating >= 12000) {
|
||||
minRecommendedLevel = 11.0;
|
||||
} else if (userRating >= 11000) {
|
||||
minRecommendedLevel = 10.5;
|
||||
} else {
|
||||
minRecommendedLevel = 9.0; // 新手保护线
|
||||
}
|
||||
|
||||
List<SongModel> candidatesForImprovement = []; // 用于推分
|
||||
List<SongModel> candidatesNew = []; // 新曲/未玩
|
||||
|
||||
// 用于快速判断是否已加入结果集,避免 O(N^2) 的 contains 检查
|
||||
final Set<int> addedSongIds = {};
|
||||
|
||||
for (var song in allSongs) {
|
||||
// 过滤无效 ID
|
||||
if (song.id < 100) continue;
|
||||
|
||||
// 获取 Master (Level 3) 的定数,如果没有则获取 Expert (Level 2)
|
||||
double? masterLevel = _getSongLevel(song, 3);
|
||||
double? expertLevel = _getSongLevel(song, 2);
|
||||
|
||||
double? targetLevel;
|
||||
int targetLevelIndex = 3; // 默认优先推荐 Master
|
||||
|
||||
if (masterLevel != null) {
|
||||
targetLevel = masterLevel;
|
||||
} else if (expertLevel != null) {
|
||||
targetLevel = expertLevel;
|
||||
targetLevelIndex = 2;
|
||||
} else {
|
||||
continue; // 没有可用难度数据
|
||||
}
|
||||
|
||||
// --- 检查是否已游玩 ---
|
||||
bool isPlayed = false;
|
||||
Map<String, dynamic>? bestChart;
|
||||
|
||||
// 尝试直接匹配 (SD 或 基础ID)
|
||||
String keyDirect = "${song.id}_$targetLevelIndex";
|
||||
if (playedMap.containsKey(keyDirect)) {
|
||||
isPlayed = true;
|
||||
bestChart = playedMap[keyDirect];
|
||||
}
|
||||
// 尝试匹配 DX 偏移 (DX 歌曲 ID 通常 +10000)
|
||||
else {
|
||||
int dxId = song.id + 10000;
|
||||
String keyDx = "${dxId}_$targetLevelIndex";
|
||||
if (playedMap.containsKey(keyDx)) {
|
||||
isPlayed = true;
|
||||
bestChart = playedMap[keyDx];
|
||||
}
|
||||
}
|
||||
|
||||
if (isPlayed && bestChart != null) {
|
||||
// --- 策略 A: 推分逻辑 (Improvement) ---
|
||||
int currentAch = bestChart['achievement'] as int;
|
||||
|
||||
// 如果已经 AP+ (100.5%),跳过
|
||||
if (currentAch >= 1005000) continue;
|
||||
|
||||
// 计算下一个档位的目标达成率
|
||||
double nextTargetAch = 0;
|
||||
const List<double> targets = [97.0, 98.0, 99.0, 99.5, 100.0, 100.5];
|
||||
|
||||
double currentPercent = currentAch / 10000.0;
|
||||
for (double t in targets) {
|
||||
if (currentPercent < t) {
|
||||
nextTargetAch = t;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (nextTargetAch == 0) continue;
|
||||
|
||||
// 计算目标 Rating
|
||||
int targetRating = _calculateRating(targetLevel!, nextTargetAch);
|
||||
|
||||
// 核心判断:如果推分后的 Rating 大于当前 B35 最低分,则值得推荐
|
||||
if (targetRating > b35MinRating) {
|
||||
candidatesForImprovement.add(song);
|
||||
}
|
||||
|
||||
} else {
|
||||
// --- 策略 B: 新曲逻辑 (New) ---
|
||||
// 判断定数是否在用户能力范围内
|
||||
if (targetLevel != null && targetLevel >= minRecommendedLevel && targetLevel <= 15.0) {
|
||||
candidatesNew.add(song);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 混合推荐结果
|
||||
List<SongModel> result = [];
|
||||
|
||||
// 60% 来自“提升空间大”的曲子
|
||||
candidatesForImprovement.shuffle(random);
|
||||
int improveCount = (count * 0.6).round();
|
||||
for (int i = 0; i < improveCount && i < candidatesForImprovement.length; i++) {
|
||||
final song = candidatesForImprovement[i];
|
||||
result.add(song);
|
||||
addedSongIds.add(song.id);
|
||||
}
|
||||
|
||||
// 40% 来自“新曲/潜力曲”
|
||||
candidatesNew.shuffle(random);
|
||||
int remaining = count - result.length;
|
||||
for (int i = 0; i < remaining && i < candidatesNew.length; i++) {
|
||||
final song = candidatesNew[i];
|
||||
// 避免重复添加(虽然逻辑上 New 和 Improve 不重叠,但防万一)
|
||||
if (!addedSongIds.contains(song.id)) {
|
||||
result.add(song);
|
||||
addedSongIds.add(song.id);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果不够,用纯随机补齐
|
||||
if (result.length < count) {
|
||||
// 复制一份并打乱,避免修改原数组
|
||||
List<SongModel> fallback = List.from(allSongs);
|
||||
fallback.shuffle(random);
|
||||
|
||||
for (var s in fallback) {
|
||||
if (result.length >= count) break;
|
||||
// 使用 Set 进行 O(1) 查找,避免 List.contains 的 O(N) 查找
|
||||
if (!addedSongIds.contains(s.id)) {
|
||||
result.add(s);
|
||||
addedSongIds.add(s.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result.take(count).toList();
|
||||
}
|
||||
|
||||
/// 辅助:获取歌曲指定难度的定数 (Level Value)
|
||||
static double? _getSongLevel(SongModel song, int levelIndex) {
|
||||
// levelIndex: 0=Basic, 1=Advanced, 2=Expert, 3=Master, 4=Re:Master
|
||||
|
||||
// 1. 尝试从 SD (Standard) 获取
|
||||
if (song.sd != null && song.sd is Map) {
|
||||
final sdMap = song.sd as Map;
|
||||
// 键可能是字符串 "3" 或整数 3,这里做兼容
|
||||
var data = sdMap["$levelIndex"] ?? sdMap[levelIndex];
|
||||
if (data is Map && data.containsKey("level_value")) {
|
||||
final val = data["level_value"];
|
||||
if (val is num) return val.toDouble();
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 尝试从 DX 获取
|
||||
if (song.dx != null && song.dx is Map) {
|
||||
final dxMap = song.dx as Map;
|
||||
var data = dxMap["$levelIndex"] ?? dxMap[levelIndex];
|
||||
if (data is Map && data.containsKey("level_value")) {
|
||||
final val = data["level_value"];
|
||||
if (val is num) return val.toDouble();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 辅助:根据定数和达成率计算 Rating (完全复刻 Java getRatingChart)
|
||||
static int _calculateRating(double diff, double achievementPercent) {
|
||||
double sys = 22.4;
|
||||
double ach = achievementPercent; // 例如 99.5
|
||||
|
||||
if (ach >= 100.5000) {
|
||||
return (diff * 22.512).floor();
|
||||
}
|
||||
if (ach == 100.4999) {
|
||||
sys = 22.2;
|
||||
} else if (ach >= 100.0000) {
|
||||
sys = 21.6;
|
||||
} else if (ach == 99.9999) {
|
||||
sys = 21.4;
|
||||
} else if (ach >= 99.5000) {
|
||||
sys = 21.1;
|
||||
} else if (ach >= 99.0000) {
|
||||
sys = 20.8;
|
||||
} else if (ach >= 98.0000) {
|
||||
sys = 20.3;
|
||||
} else if (ach >= 97.0000) {
|
||||
sys = 20.0;
|
||||
} else if (ach >= 94.0000) {
|
||||
sys = 16.8;
|
||||
} else if (ach >= 90.0000) {
|
||||
sys = 15.2;
|
||||
} else if (ach >= 80.0000) {
|
||||
sys = 13.6;
|
||||
} else if (ach >= 75.0000) {
|
||||
sys = 12.0;
|
||||
} else if (ach >= 70.0000) {
|
||||
sys = 11.2;
|
||||
} else if (ach >= 60.0000) {
|
||||
sys = 9.6;
|
||||
} else if (ach >= 50.0000) {
|
||||
sys = 8.0;
|
||||
} else {
|
||||
sys = 0.0;
|
||||
}
|
||||
|
||||
if (sys == 0.0) return 0;
|
||||
|
||||
// Java: (int) (diff * sys * achievement / 100)
|
||||
return (diff * sys * ach / 100).floor();
|
||||
}
|
||||
}
|
||||
701
lib/service/sega_service.dart
Normal file
701
lib/service/sega_service.dart
Normal file
@@ -0,0 +1,701 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:dio/io.dart';
|
||||
import 'package:html/parser.dart' as html_parser;
|
||||
import 'package:html/dom.dart' as dom;
|
||||
|
||||
typedef void OnProgress(String status, double? percent);
|
||||
|
||||
class SegaService {
|
||||
static Future<Map<String, dynamic>> fetchAndSync({
|
||||
required Region region,
|
||||
required String segaId,
|
||||
required String password,
|
||||
OnProgress? onProgress,
|
||||
}) async {
|
||||
print("[SegaService] fetchAndSync 开始执行,区域:${region.name}");
|
||||
print("[SegaService] SegaID:$segaId");
|
||||
|
||||
final MaimaiNetClient client;
|
||||
if (region == Region.jp) {
|
||||
client = MaimaiNETJpClient();
|
||||
print("[SegaService] 使用日服客户端");
|
||||
} else {
|
||||
client = MaimaiNETIntlClient();
|
||||
print("[SegaService] 使用国际服客户端");
|
||||
}
|
||||
|
||||
try {
|
||||
onProgress?.call("正在登录 Sega 账号...", null);
|
||||
await client.login(AuthParams(id: segaId, password: password));
|
||||
print("[SegaService] 登录成功");
|
||||
|
||||
onProgress?.call("登录成功,获取用户信息...", null);
|
||||
final userInfo = await client.fetchUserInfo();
|
||||
print("[SegaService] 用户信息获取成功:${userInfo.name}");
|
||||
|
||||
onProgress?.call("开始拉取歌曲成绩...", null);
|
||||
final musicRecords = await client.fetchMusicRecords(onProgress: onProgress);
|
||||
print("[SegaService] 歌曲成绩获取完成,总数:${musicRecords.length}");
|
||||
|
||||
return {
|
||||
"user": userInfo.toJson(),
|
||||
"music": musicRecords.map((e) => e.toJson()).toList(),
|
||||
"region": region.name,
|
||||
};
|
||||
} catch (e) {
|
||||
print("[SegaService] fetchAndSync 发生错误:$e");
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> verifyOnlyLogin({
|
||||
required Region region,
|
||||
required String segaId,
|
||||
required String password,
|
||||
}) async {
|
||||
print("[SegaService] verifyOnlyLogin 开始验证登录,区域:${region.name}");
|
||||
print("[SegaService] 验证账号:$segaId");
|
||||
|
||||
final MaimaiNetClient client;
|
||||
if (region == Region.jp) {
|
||||
client = MaimaiNETJpClient();
|
||||
} else {
|
||||
client = MaimaiNETIntlClient();
|
||||
}
|
||||
try {
|
||||
await client.login(AuthParams(id: segaId, password: password));
|
||||
print("[SegaService] 验证登录成功");
|
||||
final userInfo = await client.fetchUserInfo();
|
||||
print("[SegaService] 验证成功,用户名:${userInfo.name}");
|
||||
|
||||
return {
|
||||
"user": userInfo.toJson(),
|
||||
"music": [],
|
||||
"region": region.name,
|
||||
};
|
||||
} catch (e) {
|
||||
print("[SegaService] 验证登录失败:$e");
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Region { jp, intl }
|
||||
|
||||
class NetImportError implements Exception {
|
||||
final String code;
|
||||
final String message;
|
||||
NetImportError(this.code, [String? msg]) : message = msg ?? code;
|
||||
@override
|
||||
String toString() => 'NetImportError($code): $message';
|
||||
}
|
||||
|
||||
class AuthParams {
|
||||
final String id;
|
||||
final String password;
|
||||
AuthParams({required this.id, required this.password});
|
||||
}
|
||||
|
||||
class SheetInfo {
|
||||
final String songId;
|
||||
final String type;
|
||||
final String difficulty;
|
||||
|
||||
SheetInfo({required this.songId, required this.type, required this.difficulty});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'songId': songId,
|
||||
'type': type,
|
||||
'difficulty': difficulty,
|
||||
};
|
||||
}
|
||||
|
||||
class DxScore {
|
||||
final int achieved;
|
||||
final int total;
|
||||
DxScore({required this.achieved, required this.total});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'achieved': achieved,
|
||||
'total': total,
|
||||
};
|
||||
}
|
||||
|
||||
class Achievement {
|
||||
final int rate;
|
||||
final DxScore dxScore;
|
||||
final List<String> flags;
|
||||
|
||||
Achievement({required this.rate, required this.dxScore, required this.flags});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'rate': rate,
|
||||
'dxScore': dxScore.toJson(),
|
||||
'flags': flags,
|
||||
};
|
||||
}
|
||||
|
||||
class MusicRecord {
|
||||
final SheetInfo sheet;
|
||||
final Achievement achievement;
|
||||
|
||||
MusicRecord({required this.sheet, required this.achievement});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'sheet': sheet.toJson(),
|
||||
'achievement': achievement.toJson(),
|
||||
};
|
||||
}
|
||||
|
||||
class UserInfo {
|
||||
final String avatar;
|
||||
final String name;
|
||||
final Map<String, String> trophy;
|
||||
final String courseIcon;
|
||||
final String classIcon;
|
||||
final String plateUrl;
|
||||
|
||||
UserInfo({
|
||||
required this.avatar,
|
||||
required this.name,
|
||||
required this.trophy,
|
||||
required this.courseIcon,
|
||||
required this.classIcon,
|
||||
required this.plateUrl,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'avatar': avatar,
|
||||
'name': name,
|
||||
'trophy': trophy,
|
||||
'courseIcon': courseIcon,
|
||||
'classIcon': classIcon,
|
||||
'plateUrl': plateUrl,
|
||||
};
|
||||
}
|
||||
|
||||
class URLs {
|
||||
static const Map<String, String> INTL = {
|
||||
"LOGIN_PAGE": "https://lng-tgk-aime-gw.am-all.net/common_auth/login?site_id=maimaidxex&redirect_url=https://maimaidx-eng.com/maimai-mobile/&back_url=https://maima.sega.com/",
|
||||
"LOGIN_ENDPOINT": "https://lng-tgk-aime-gw.am-all.net/common_auth/login/sid",
|
||||
"RECORD_RECENT_PAGE": "https://maimaidx-eng.com/maimai-mobile/record",
|
||||
"RECORD_MUSICS_PAGE": "https://maimaidx-eng.com/maimai-mobile/record/musicGenre/search/",
|
||||
"HOME": "https://maimaidx-eng.com/maimai-mobile/home/",
|
||||
"NAMEPLATE": "https://maimaidx-eng.com/maimai-mobile/collection/nameplate/",
|
||||
};
|
||||
|
||||
static const Map<String, String> JP = {
|
||||
"LOGIN_PAGE": "https://maimaidx.jp/maimai-mobile/",
|
||||
"LOGIN_ENDPOINT": "https://maimaidx.jp/maimai-mobile/submit/",
|
||||
"LOGIN_AIMELIST": "https://maimaidx.jp/maimai-mobile/aimeList/",
|
||||
"LOGIN_AIMELIST_SUBMIT": "https://maimaidx.jp/maimai-mobile/aimeList/submit/?idx=0",
|
||||
"HOMEPAGE": "https://maimaidx.jp/maimai-mobile/home/",
|
||||
"RECORD_RECENT_PAGE": "https://maimaidx.jp/maimai-mobile/record",
|
||||
"RECORD_MUSICS_PAGE": "https://maimaidx.jp/maimai-mobile/record/musicGenre/search/",
|
||||
"NAMEPLATE": "https://maimaidx.jp/maimai-mobile/collection/nameplate/",
|
||||
};
|
||||
|
||||
static const List<String> ERROR_URLS = [
|
||||
"https://maimaidx-eng.com/maimai-mobile/error/",
|
||||
"https://maimaidx.jp/maimai-mobile/error/"
|
||||
];
|
||||
|
||||
static const List<String> MAINTENANCE_TEXTS = [
|
||||
"定期メンテナンス中です",
|
||||
"Sorry, servers are under maintenance."
|
||||
];
|
||||
}
|
||||
|
||||
abstract class MaimaiNetClient {
|
||||
late Dio dio;
|
||||
late Map<String, String> urls;
|
||||
|
||||
final Map<String, String> _cookies = {};
|
||||
|
||||
MaimaiNetClient() {
|
||||
dio = Dio();
|
||||
_setupDio();
|
||||
print("[MaimaiNetClient] 客户端初始化完成");
|
||||
}
|
||||
|
||||
void _setupDio() {
|
||||
print("[MaimaiNetClient] 配置 Dio");
|
||||
|
||||
(dio.httpClientAdapter as IOHttpClientAdapter).createHttpClient = () {
|
||||
final client = HttpClient();
|
||||
client.badCertificateCallback = (X509Certificate cert, String host, int port) => true;
|
||||
return client;
|
||||
};
|
||||
|
||||
dio.interceptors.add(InterceptorsWrapper(
|
||||
onRequest: (options, handler) {
|
||||
print("[DIO] 请求:${options.method} ${options.uri}");
|
||||
if (_cookies.isNotEmpty) {
|
||||
final cookieStr = _cookies.entries.map((e) => '${e.key}=${e.value}').join('; ');
|
||||
options.headers['Cookie'] = cookieStr;
|
||||
}
|
||||
return handler.next(options);
|
||||
},
|
||||
onResponse: (response, handler) {
|
||||
print("[DIO] 响应:${response.statusCode} ${response.requestOptions.uri}");
|
||||
final setCookies = response.headers['set-cookie'];
|
||||
if (setCookies != null) {
|
||||
for (var cookieHeader in setCookies) {
|
||||
if (cookieHeader.contains('=')) {
|
||||
final parts = cookieHeader.split(';')[0].split('=');
|
||||
if (parts.length >= 2) {
|
||||
final key = parts[0].trim();
|
||||
final value = parts.sublist(1).join('=').trim();
|
||||
if (key.isNotEmpty) {
|
||||
_cookies[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return handler.next(response);
|
||||
},
|
||||
onError: (DioException e, handler) {
|
||||
print("[DIO] 请求错误:$e");
|
||||
return handler.next(e);
|
||||
}));
|
||||
|
||||
dio.options.headers = {
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
|
||||
"Accept-Language": "ja;q=0.9,en;q=0.8",
|
||||
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36",
|
||||
"Sec-Fetch-Dest": "document",
|
||||
"Sec-Fetch-Mode": "navigate",
|
||||
"Upgrade-Insecure-Requests": "1",
|
||||
};
|
||||
|
||||
dio.options.connectTimeout = const Duration(seconds: 10);
|
||||
dio.options.receiveTimeout = const Duration(seconds: 10);
|
||||
dio.options.followRedirects = false;
|
||||
dio.options.maxRedirects = 0;
|
||||
dio.options.validateStatus = (status) => true;
|
||||
}
|
||||
|
||||
Future<dom.Document> fetchAsSoupWithRedirects(String url, {Map<String, dynamic>? queryParameters}) async {
|
||||
print("[fetchAsSoup] 开始请求(支持重定向):$url 参数:$queryParameters");
|
||||
|
||||
String currentUrl = url;
|
||||
int redirectCount = 0;
|
||||
const maxRedirects = 5;
|
||||
|
||||
while (redirectCount < maxRedirects) {
|
||||
try {
|
||||
final response = await dio.get(currentUrl, queryParameters: queryParameters);
|
||||
|
||||
if (response.data is String) {
|
||||
checkMaintenance(response.data.toString());
|
||||
}
|
||||
|
||||
if ([301, 302, 303, 307, 308].contains(response.statusCode)) {
|
||||
final location = response.headers.value('location');
|
||||
if (location != null) {
|
||||
print("[fetchAsSoup] 重定向 -> $location");
|
||||
currentUrl = location;
|
||||
redirectCount++;
|
||||
continue;
|
||||
} else {
|
||||
print("[fetchAsSoup] 重定向无 location");
|
||||
throw NetImportError("REDIRECT_WITHOUT_LOCATION");
|
||||
}
|
||||
}
|
||||
|
||||
if (response.statusCode! >= 400) {
|
||||
print("[fetchAsSoup] HTTP 错误:${response.statusCode}");
|
||||
throw NetImportError("HTTP_ERROR_${response.statusCode}", "Failed to fetch $url");
|
||||
}
|
||||
|
||||
print("[fetchAsSoup] 请求完成,解析 HTML");
|
||||
return html_parser.parse(response.data);
|
||||
} on DioException catch (e) {
|
||||
print("[fetchAsSoup] Dio 异常:$e");
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
print("[fetchAsSoup] 重定向次数过多");
|
||||
throw NetImportError("TOO_MANY_REDIRECTS");
|
||||
}
|
||||
|
||||
Future<dom.Document> fetchAsSoup(String url, {Map<String, dynamic>? queryParameters}) async {
|
||||
return fetchAsSoupWithRedirects(url, queryParameters: queryParameters);
|
||||
}
|
||||
|
||||
void checkMaintenance(String text) {
|
||||
for (final maintText in URLs.MAINTENANCE_TEXTS) {
|
||||
if (text.contains(maintText)) {
|
||||
print("[检查维护] 检测到服务器维护!");
|
||||
throw NetImportError("NET_MAINTENANCE");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> login(AuthParams auth);
|
||||
Future<UserInfo> fetchUserInfo();
|
||||
Future<List<MusicRecord>> fetchMusicRecords({OnProgress? onProgress});
|
||||
|
||||
SheetInfo? parseSheetInfo(dom.Element div) {
|
||||
final songIdEl = div.querySelector(".music_name_block");
|
||||
if (songIdEl == null) return null;
|
||||
final songId = songIdEl.text.trim();
|
||||
|
||||
String? type;
|
||||
if (div.querySelector(".music_kind_icon_dx._btn_on") != null) {
|
||||
type = "dx";
|
||||
} else if (div.querySelector(".music_kind_icon_standard._btn_on") != null) {
|
||||
type = "standard";
|
||||
} else {
|
||||
final typeIcon = div.querySelector(".music_kind_icon");
|
||||
if (typeIcon != null) {
|
||||
final src = typeIcon.attributes['src'] ?? '';
|
||||
if (src.contains('music_dx.png')) type = 'dx';
|
||||
else if (src.contains('music_standard.png')) type = 'standard';
|
||||
}
|
||||
}
|
||||
|
||||
String? difficulty;
|
||||
final diffIcon = div.querySelector(".h_20.f_l");
|
||||
if (diffIcon != null) {
|
||||
final src = diffIcon.attributes['src'] ?? '';
|
||||
final match = RegExp(r'diff_(.*)\.png').firstMatch(src);
|
||||
if (match != null) {
|
||||
difficulty = match.group(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (difficulty == "utage") type = "utage";
|
||||
if (songId.isEmpty || type == null || difficulty == null) return null;
|
||||
|
||||
return SheetInfo(songId: songId, type: type, difficulty: difficulty);
|
||||
}
|
||||
|
||||
Achievement parseAchievement(dom.Element div) {
|
||||
final rateEl = div.querySelector(".music_score_block.w_112");
|
||||
final rateStr = rateEl?.text.trim().replaceAll('%', '').replaceAll('.', '') ?? '0';
|
||||
final rate = int.tryParse(rateStr) ?? 0;
|
||||
|
||||
final dxScoreEl = div.querySelector(".music_score_block.w_190");
|
||||
final dxScoreText = dxScoreEl?.text.trim() ?? "0 / 0";
|
||||
final parts = dxScoreText.split('/');
|
||||
final achieved = int.tryParse(parts[0].trim().replaceAll(',', '')) ?? 0;
|
||||
final total = parts.length > 1 ? (int.tryParse(parts[1].trim().replaceAll(',', '')) ?? 0) : 0;
|
||||
|
||||
final flags = <String>[];
|
||||
final flagImages = div.querySelectorAll("form img.f_r");
|
||||
final flagMatchers = {
|
||||
"fullCombo": "fc.png",
|
||||
"fullCombo+": "fcplus.png",
|
||||
"allPerfect": "ap.png",
|
||||
"allPerfect+": "applus.png",
|
||||
"syncPlay": "sync.png",
|
||||
"fullSync": "fs.png",
|
||||
"fullSync+": "fsplus.png",
|
||||
"fullSyncDX": "fsd.png",
|
||||
"fullSyncDX+": "fsdplus.png",
|
||||
};
|
||||
|
||||
for (final img in flagImages) {
|
||||
final src = img.attributes['src'] ?? '';
|
||||
for (final entry in flagMatchers.entries) {
|
||||
if (src.contains(entry.value)) {
|
||||
flags.add(entry.key);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Achievement(
|
||||
rate: rate,
|
||||
dxScore: DxScore(achieved: achieved, total: total),
|
||||
flags: flags,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MaimaiNETJpClient extends MaimaiNetClient {
|
||||
MaimaiNETJpClient() {
|
||||
urls = URLs.JP;
|
||||
print("[MaimaiNETJpClient] 日服客户端初始化");
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> login(AuthParams auth) async {
|
||||
print("[日服客户端] 开始登录流程");
|
||||
|
||||
final soup = await fetchAsSoup(urls["LOGIN_PAGE"]!);
|
||||
final tokenInput = soup.querySelector("input[name='token']");
|
||||
if (tokenInput == null) {
|
||||
print("[日服客户端] 未找到 token,登录失败");
|
||||
throw NetImportError("TOKEN_ERROR");
|
||||
}
|
||||
final token = tokenInput.attributes['value'];
|
||||
print("[日服客户端] 获取 token 成功:$token");
|
||||
|
||||
final loginData = {
|
||||
"segaId": auth.id,
|
||||
"password": auth.password,
|
||||
"save_cookie": "on",
|
||||
"token": token!,
|
||||
};
|
||||
|
||||
final response = await dio.post(
|
||||
urls["LOGIN_ENDPOINT"]!,
|
||||
data: FormData.fromMap(loginData),
|
||||
options: Options(
|
||||
contentType: Headers.formUrlEncodedContentType,
|
||||
),
|
||||
);
|
||||
|
||||
final location = response.headers.value('location');
|
||||
if (location != null && URLs.ERROR_URLS.any((e) => location.contains(e))) {
|
||||
print("[日服客户端] 账号或密码错误");
|
||||
throw NetImportError("INVALID_CREDENTIALS");
|
||||
}
|
||||
|
||||
print("[日服客户端] 登录接口请求成功,处理跳转");
|
||||
await fetchAsSoup(urls["LOGIN_AIMELIST"]!);
|
||||
await fetchAsSoup(urls["LOGIN_AIMELIST_SUBMIT"]!);
|
||||
await fetchAsSoup(urls["HOMEPAGE"]!);
|
||||
print("[日服客户端] 登录流程全部完成");
|
||||
}
|
||||
|
||||
@override
|
||||
Future<UserInfo> fetchUserInfo() async {
|
||||
print("[日服客户端] 获取用户信息");
|
||||
final soup = await fetchAsSoup(urls["HOMEPAGE"]!);
|
||||
final avatarImg = soup.querySelector('img[loading="lazy"].w_112.f_l');
|
||||
final avatar = avatarImg?.attributes['src'] ?? '';
|
||||
final nameEl = soup.querySelector(".name_block");
|
||||
final name = nameEl?.text.trim() ?? '';
|
||||
|
||||
String trophyClass = "Normal";
|
||||
String trophyName = "";
|
||||
final trophyBlock = soup.querySelector("div.trophy_block");
|
||||
if (trophyBlock != null) {
|
||||
for (final c in trophyBlock.classes) {
|
||||
if (["trophy_Normal", "trophy_Silver", "trophy_Gold", "trophy_Rainbow"].contains(c)) {
|
||||
trophyClass = c.replaceFirst('trophy_', '');
|
||||
break;
|
||||
}
|
||||
}
|
||||
final trophyInner = trophyBlock.querySelector(".trophy_inner_block span");
|
||||
if (trophyInner != null) trophyName = trophyInner.text.trim();
|
||||
}
|
||||
|
||||
final courseImg = soup.querySelector('img[src*="course_rank_"]');
|
||||
final courseIcon = courseImg?.attributes['src'] ?? '';
|
||||
final classImg = soup.querySelector('img[src*="class_rank_"]');
|
||||
final classIcon = classImg?.attributes['src'] ?? '';
|
||||
|
||||
String plateUrl = "";
|
||||
try {
|
||||
print("[日服客户端] 获取铭牌");
|
||||
final plateSoup = await fetchAsSoup(urls["NAMEPLATE"]!);
|
||||
final plateImg = plateSoup.querySelector('img[loading="lazy"].w_396.m_r_10');
|
||||
if (plateImg != null) plateUrl = plateImg.attributes['src'] ?? '';
|
||||
} catch (e) {
|
||||
print("[日服客户端] 获取铭牌失败:$e");
|
||||
}
|
||||
|
||||
print("[日服客户端] 用户信息解析完成:$name");
|
||||
return UserInfo(
|
||||
avatar: avatar,
|
||||
name: name,
|
||||
trophy: {'rarity': trophyClass, 'text': trophyName},
|
||||
courseIcon: courseIcon,
|
||||
classIcon: classIcon,
|
||||
plateUrl: plateUrl,
|
||||
);
|
||||
}
|
||||
|
||||
// ====================== 【修复点】只拉一遍,不统计 ======================
|
||||
@override
|
||||
Future<List<MusicRecord>> fetchMusicRecords({OnProgress? onProgress}) async {
|
||||
print("[日服客户端] 开始获取所有歌曲成绩(修复版:仅拉取1次)");
|
||||
final records = <MusicRecord>[];
|
||||
|
||||
final difficulties = [
|
||||
("0", "Basic"),
|
||||
("1", "Advanced"),
|
||||
("2", "Expert"),
|
||||
("3", "Master"),
|
||||
("4", "Remaster"),
|
||||
("10", "Utage"),
|
||||
];
|
||||
|
||||
for (final diff in difficulties) {
|
||||
try {
|
||||
onProgress?.call("正在拉取:${diff.$2}", null);
|
||||
final url = urls["RECORD_MUSICS_PAGE"]!;
|
||||
final soup = await fetchAsSoup(url, queryParameters: {"genre": "99", "diff": diff.$1});
|
||||
final items = soup.querySelectorAll(".w_450.m_15.p_r.f_0");
|
||||
|
||||
for (final item in items) {
|
||||
final sheet = parseSheetInfo(item);
|
||||
if (sheet != null) {
|
||||
final achievement = parseAchievement(item);
|
||||
records.add(MusicRecord(sheet: sheet, achievement: achievement));
|
||||
}
|
||||
}
|
||||
print("[日服客户端] ${diff.$2} 完成,累计:${records.length} 首");
|
||||
} catch (e) {
|
||||
print("[日服客户端] ${diff.$2} 拉取失败:$e");
|
||||
}
|
||||
}
|
||||
|
||||
print("[日服客户端] 所有成绩解析完成,总数:${records.length}");
|
||||
return records;
|
||||
}
|
||||
}
|
||||
|
||||
class MaimaiNETIntlClient extends MaimaiNetClient {
|
||||
MaimaiNETIntlClient() {
|
||||
urls = URLs.INTL;
|
||||
dio.options.headers["Referer"] = URLs.INTL["LOGIN_PAGE"];
|
||||
print("[MaimaiNETIntlClient] 国际服客户端初始化");
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> login(AuthParams auth) async {
|
||||
print("[国际服客户端] 开始登录流程");
|
||||
|
||||
await fetchAsSoup(urls["LOGIN_PAGE"]!);
|
||||
print("[国际服客户端] 访问登录页面成功");
|
||||
|
||||
final loginData = {
|
||||
"sid": auth.id,
|
||||
"password": auth.password,
|
||||
"retention": "1",
|
||||
};
|
||||
|
||||
final response = await dio.post(
|
||||
urls["LOGIN_ENDPOINT"]!,
|
||||
data: FormData.fromMap(loginData),
|
||||
options: Options(
|
||||
contentType: Headers.formUrlEncodedContentType,
|
||||
),
|
||||
);
|
||||
|
||||
final location = response.headers.value('location');
|
||||
if (location != null) {
|
||||
print("[国际服客户端] 登录后跳转:$location");
|
||||
await fetchAsSoupWithRedirects(location);
|
||||
}
|
||||
await fetchAsSoup(urls["HOME"]!);
|
||||
print("[国际服客户端] 登录流程完成");
|
||||
}
|
||||
|
||||
@override
|
||||
Future<UserInfo> fetchUserInfo() async {
|
||||
print("[国际服客户端] 获取用户信息");
|
||||
final soup = await fetchAsSoup(urls["HOME"]!);
|
||||
final avatarImg = soup.querySelector('img[loading="lazy"].w_112');
|
||||
final avatar = avatarImg?.attributes['src'] ?? '';
|
||||
final nameEl = soup.querySelector(".name_block");
|
||||
final name = nameEl?.text.trim() ?? '';
|
||||
|
||||
String trophyClass = "Normal";
|
||||
String trophyName = "";
|
||||
final trophyEl = soup.querySelector("div.trophy_block");
|
||||
if (trophyEl != null) {
|
||||
for (final c in trophyEl.classes) {
|
||||
if (["trophy_Normal", "trophy_Silver", "trophy_Gold", "trophy_Rainbow"].contains(c)) {
|
||||
trophyClass = c.replaceFirst('trophy_', '');
|
||||
break;
|
||||
}
|
||||
}
|
||||
final trophyInner = trophyEl.querySelector(".trophy_inner_block span");
|
||||
if (trophyInner != null) trophyName = trophyInner.text.trim();
|
||||
}
|
||||
|
||||
final courseImg = soup.querySelector('img[src*="course/course_rank_"]');
|
||||
final courseIcon = courseImg?.attributes['src'] ?? '';
|
||||
final classImg = soup.querySelector('img[src*="class/class_rank_"]');
|
||||
final classIcon = classImg?.attributes['src'] ?? '';
|
||||
|
||||
String plateUrl = "";
|
||||
try {
|
||||
print("[国际服客户端] 获取铭牌");
|
||||
final plateSoup = await fetchAsSoup(urls["NAMEPLATE"]!);
|
||||
final plateImg = plateSoup.querySelector('img[loading="lazy"].w_396.m_r_10');
|
||||
if (plateImg != null) plateUrl = plateImg.attributes['src'] ?? '';
|
||||
} catch (e) {
|
||||
print("[国际服客户端] 获取铭牌失败:$e");
|
||||
}
|
||||
|
||||
print("[国际服客户端] 用户信息解析完成:$name");
|
||||
return UserInfo(
|
||||
avatar: avatar,
|
||||
name: name,
|
||||
trophy: {'rarity': trophyClass, 'text': trophyName},
|
||||
courseIcon: courseIcon,
|
||||
classIcon: classIcon,
|
||||
plateUrl: plateUrl,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<MusicRecord>> fetchMusicRecords({OnProgress? onProgress}) async {
|
||||
print("[国际服客户端] 开始获取所有歌曲成绩");
|
||||
final difficulties = [
|
||||
("0", "Basic"),
|
||||
("1", "Advanced"),
|
||||
("2", "Expert"),
|
||||
("3", "Master"),
|
||||
("4", "Remaster"),
|
||||
("10", "Utage"),
|
||||
];
|
||||
|
||||
int total = 0;
|
||||
int current = 0;
|
||||
|
||||
final futures = difficulties.map((diff) async {
|
||||
onProgress?.call("正在并发拉取难度..", null);
|
||||
final url = urls["RECORD_MUSICS_PAGE"]!;
|
||||
final soup = await fetchAsSoup(url, queryParameters: {"genre": "99", "diff": diff.$1});
|
||||
final items = soup.querySelectorAll(".w_450.m_15.p_r.f_0");
|
||||
final list = <MusicRecord>[];
|
||||
|
||||
for (final item in items) {
|
||||
final sheet = parseSheetInfo(item);
|
||||
if (sheet != null) {
|
||||
final achievement = parseAchievement(item);
|
||||
list.add(MusicRecord(sheet: sheet, achievement: achievement));
|
||||
}
|
||||
current++;
|
||||
}
|
||||
return list;
|
||||
});
|
||||
|
||||
final results = await Future.wait(futures);
|
||||
final all = results.expand((e) => e).toList();
|
||||
print("[国际服客户端] 所有成绩解析完成,总数:${all.length}");
|
||||
return all;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> fetchNetRecords(Region region, AuthParams auth) async {
|
||||
print("[fetchNetRecords] 外部接口调用,区域:${region.name}");
|
||||
final MaimaiNetClient client;
|
||||
if (region == Region.jp) {
|
||||
client = MaimaiNETJpClient();
|
||||
} else {
|
||||
client = MaimaiNETIntlClient();
|
||||
}
|
||||
|
||||
await client.login(auth);
|
||||
final userInfo = await client.fetchUserInfo();
|
||||
final musicRecords = await client.fetchMusicRecords();
|
||||
|
||||
return {
|
||||
"user": userInfo.toJson(),
|
||||
"music": musicRecords.map((e) => e.toJson()).toList(),
|
||||
"recent": [],
|
||||
};
|
||||
}
|
||||
@@ -283,4 +283,28 @@ class UserService {
|
||||
throw _getErrorMessage(e);
|
||||
}
|
||||
}
|
||||
// 上传 Sega 成绩数据
|
||||
static Future<Map<String, dynamic>> uploadSegaRating(
|
||||
String token,
|
||||
String segaId,
|
||||
Map<String, dynamic> segaResult,
|
||||
) async {
|
||||
try {
|
||||
final res = await _dio.post(
|
||||
'$baseUrl/api/union/segaReisaRating',
|
||||
queryParameters: {
|
||||
"segaId": segaId,
|
||||
},
|
||||
data: segaResult,
|
||||
options: Options(
|
||||
headers: {
|
||||
"Authorization": token,
|
||||
},
|
||||
),
|
||||
);
|
||||
return Map<String, dynamic>.from(res.data);
|
||||
} on DioException catch (e) {
|
||||
throw _getErrorMessage(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user