0417 0022

更新
This commit is contained in:
spasolreisa
2026-04-17 00:22:43 +08:00
parent 9ce601aa8d
commit fed18d264a
12 changed files with 1924 additions and 210 deletions

View 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": [],
};
}