Files
UnionApp/lib/service/sega_service.dart
spasolreisa fed18d264a 0417 0022
更新
2026-04-17 00:22:43 +08:00

701 lines
22 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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