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> 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> 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 toJson() => { 'songId': songId, 'type': type, 'difficulty': difficulty, }; } class DxScore { final int achieved; final int total; DxScore({required this.achieved, required this.total}); Map toJson() => { 'achieved': achieved, 'total': total, }; } class Achievement { final int rate; final DxScore dxScore; final List flags; Achievement({required this.rate, required this.dxScore, required this.flags}); Map toJson() => { 'rate': rate, 'dxScore': dxScore.toJson(), 'flags': flags, }; } class MusicRecord { final SheetInfo sheet; final Achievement achievement; MusicRecord({required this.sheet, required this.achievement}); Map toJson() => { 'sheet': sheet.toJson(), 'achievement': achievement.toJson(), }; } class UserInfo { final String avatar; final String name; final Map 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 toJson() => { 'avatar': avatar, 'name': name, 'trophy': trophy, 'courseIcon': courseIcon, 'classIcon': classIcon, 'plateUrl': plateUrl, }; } class URLs { static const Map 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 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 ERROR_URLS = [ "https://maimaidx-eng.com/maimai-mobile/error/", "https://maimaidx.jp/maimai-mobile/error/" ]; static const List MAINTENANCE_TEXTS = [ "定期メンテナンス中です", "Sorry, servers are under maintenance." ]; } abstract class MaimaiNetClient { late Dio dio; late Map urls; final Map _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 fetchAsSoupWithRedirects(String url, {Map? 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 fetchAsSoup(String url, {Map? 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 login(AuthParams auth); Future fetchUserInfo(); Future> 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 = []; 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 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 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> fetchMusicRecords({OnProgress? onProgress}) async { print("[日服客户端] 开始获取所有歌曲成绩(修复版:仅拉取1次)"); final records = []; 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 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 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> 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 = []; 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> 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": [], }; }