commit c306458c7ecf4119621e2677f4d84fbaf6000331
Author: Guoyiting <654525901@qq.com>
Date: Sat May 30 00:11:55 2026 +0800
完成音乐爬虫项目
diff --git a/logs/crawler.log b/logs/crawler.log
new file mode 100644
index 0000000..0c1d27c
--- /dev/null
+++ b/logs/crawler.log
@@ -0,0 +1,65 @@
+2026-05-29 23:18:06.182 [main] INFO com.music.strategy.NetEaseStrategy - 开始爬取网易云热歌榜,限制 50 首
+2026-05-29 23:18:07.033 [main] ERROR com.music.strategy.NetEaseStrategy - 网易云爬取失败
+java.lang.NullPointerException: Cannot invoke "com.google.gson.JsonObject.getAsJsonArray(String)" because "result" is null
+ at com.music.strategy.NetEaseStrategy.crawl(NetEaseStrategy.java:35)
+ at com.music.command.CrawlCommand.execute(CrawlCommand.java:40)
+ at com.music.controller.CrawlerController.start(CrawlerController.java:52)
+ at com.music.App.main(App.java:17)
+2026-05-29 23:18:07.036 [main] ERROR com.music.command.CrawlCommand - 爬取异常
+com.music.exception.ParseException: 解析网易云数据失败: Cannot invoke "com.google.gson.JsonObject.getAsJsonArray(String)" because "result" is null
+ at com.music.strategy.NetEaseStrategy.crawl(NetEaseStrategy.java:79)
+ at com.music.command.CrawlCommand.execute(CrawlCommand.java:40)
+ at com.music.controller.CrawlerController.start(CrawlerController.java:52)
+ at com.music.App.main(App.java:17)
+Caused by: java.lang.NullPointerException: Cannot invoke "com.google.gson.JsonObject.getAsJsonArray(String)" because "result" is null
+ at com.music.strategy.NetEaseStrategy.crawl(NetEaseStrategy.java:35)
+ ... 3 common frames omitted
+2026-05-29 23:19:31.271 [main] INFO com.music.strategy.NetEaseStrategy - 开始爬取网易云热歌榜,限制 50 首
+2026-05-29 23:19:56.780 [main] INFO com.music.strategy.NetEaseStrategy - 网易云爬取完成,共 50 首
+2026-05-29 23:19:56.805 [main] INFO com.music.command.CrawlCommand - 爬取完成,平台=netease, 数量=50
+2026-05-29 23:21:00.898 [main] INFO com.music.command.AnalyzeCommand - 分析报告已生成,共 50 首歌曲
+2026-05-29 23:21:21.127 [main] INFO com.music.command.SaveCommand - 数据已保存到文件: result.csv
+2026-05-29 23:25:29.304 [main] INFO com.music.strategy.QQStrategy - 开始爬取 QQ 音乐热歌榜,限制 50 首
+2026-05-29 23:25:30.367 [main] INFO com.music.strategy.QQStrategy - QQ音乐爬取完成,共 20 首
+2026-05-29 23:25:30.368 [main] INFO com.music.command.CrawlCommand - 爬取完成,平台=qq, 数量=20
+2026-05-29 23:26:13.206 [main] INFO com.music.strategy.KuGouStrategy - 开始爬取酷狗热歌榜,限制 50 首
+2026-05-29 23:26:13.691 [main] ERROR com.music.strategy.KuGouStrategy - 酷狗爬取失败,使用模拟数据
+com.google.gson.JsonSyntaxException: com.google.gson.stream.MalformedJsonException: Use JsonReader.setLenient(true) to accept malformed JSON at line 1 column 12 path $
+ at com.google.gson.JsonParser.parseReader(JsonParser.java:76)
+ at com.google.gson.JsonParser.parseString(JsonParser.java:51)
+ at com.music.strategy.KuGouStrategy.crawl(KuGouStrategy.java:39)
+ at com.music.command.CrawlCommand.execute(CrawlCommand.java:40)
+ at com.music.controller.CrawlerController.start(CrawlerController.java:52)
+ at com.music.App.main(App.java:17)
+Caused by: com.google.gson.stream.MalformedJsonException: Use JsonReader.setLenient(true) to accept malformed JSON at line 1 column 12 path $
+ at com.google.gson.stream.JsonReader.syntaxError(JsonReader.java:1659)
+ at com.google.gson.stream.JsonReader.checkLenient(JsonReader.java:1465)
+ at com.google.gson.stream.JsonReader.doPeek(JsonReader.java:551)
+ at com.google.gson.stream.JsonReader.peek(JsonReader.java:433)
+ at com.google.gson.JsonParser.parseReader(JsonParser.java:71)
+ ... 5 common frames omitted
+2026-05-29 23:26:13.695 [main] INFO com.music.command.CrawlCommand - 爬取完成,平台=kugou, 数量=8
+2026-05-29 23:27:34.126 [main] INFO com.music.strategy.KuGouStrategy - 开始爬取酷狗热歌榜,限制 50 首
+2026-05-29 23:27:34.611 [main] ERROR com.music.strategy.KuGouStrategy - 酷狗爬取失败,使用模拟数据
+com.google.gson.JsonSyntaxException: com.google.gson.stream.MalformedJsonException: Use JsonReader.setLenient(true) to accept malformed JSON at line 1 column 12 path $
+ at com.google.gson.JsonParser.parseReader(JsonParser.java:76)
+ at com.google.gson.JsonParser.parseString(JsonParser.java:51)
+ at com.music.strategy.KuGouStrategy.crawl(KuGouStrategy.java:39)
+ at com.music.command.CrawlCommand.execute(CrawlCommand.java:40)
+ at com.music.controller.CrawlerController.start(CrawlerController.java:52)
+ at com.music.App.main(App.java:17)
+Caused by: com.google.gson.stream.MalformedJsonException: Use JsonReader.setLenient(true) to accept malformed JSON at line 1 column 12 path $
+ at com.google.gson.stream.JsonReader.syntaxError(JsonReader.java:1659)
+ at com.google.gson.stream.JsonReader.checkLenient(JsonReader.java:1465)
+ at com.google.gson.stream.JsonReader.doPeek(JsonReader.java:551)
+ at com.google.gson.stream.JsonReader.peek(JsonReader.java:433)
+ at com.google.gson.JsonParser.parseReader(JsonParser.java:71)
+ ... 5 common frames omitted
+2026-05-29 23:27:34.613 [main] INFO com.music.command.CrawlCommand - 爬取完成,平台=kugou, 数量=8
+2026-05-29 23:28:00.192 [main] INFO com.music.strategy.KuGouStrategy - 开始爬取酷狗热歌榜,限制 50 首
+2026-05-29 23:28:00.937 [main] INFO com.music.strategy.KuGouStrategy - 酷狗爬取完成,真实数据 22 首
+2026-05-29 23:28:00.939 [main] INFO com.music.command.CrawlCommand - 爬取完成,平台=kugou, 数量=22
+2026-05-29 23:28:29.618 [main] INFO com.music.command.AnalyzeCommand - 分析报告已生成,共 22 首歌曲
+2026-05-29 23:33:25.068 [main] INFO com.music.strategy.NetEaseStrategy - 开始爬取网易云热歌榜,限制 50 首
+2026-05-29 23:33:31.039 [main] INFO com.music.strategy.NetEaseStrategy - 网易云爬取完成,共 50 首
+2026-05-29 23:33:31.042 [main] INFO com.music.command.CrawlCommand - 爬取完成,平台=netease, 数量=50
diff --git a/pom.xml b/pom.xml
new file mode 100644
index 0000000..69ca0ff
--- /dev/null
+++ b/pom.xml
@@ -0,0 +1,59 @@
+
+
+ 4.0.0
+
+ com.musiccrawler
+ music-crawler
+ 1.0-SNAPSHOT
+
+
+ 11
+ 11
+ UTF-8
+
+
+
+
+
+ org.jsoup
+ jsoup
+ 1.16.1
+
+
+
+ com.squareup.okhttp3
+ okhttp
+ 4.12.0
+
+
+
+ com.google.code.gson
+ gson
+ 2.10.1
+
+
+
+ ch.qos.logback
+ logback-classic
+ 1.4.14
+
+
+ org.jsoup
+ jsoup
+ 1.16.1
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ 3.11.0
+
+
+
+
\ No newline at end of file
diff --git a/src/main/java/com/music/App.java b/src/main/java/com/music/App.java
new file mode 100644
index 0000000..ff4d454
--- /dev/null
+++ b/src/main/java/com/music/App.java
@@ -0,0 +1,19 @@
+package com.music;
+
+import com.music.controller.CrawlerController;
+import com.music.repository.SongRepository;
+import com.music.service.AnalyzerService;
+import com.music.strategy.StrategyFactory;
+import com.music.view.ConsoleView;
+
+public class App {
+ public static void main(String[] args) {
+ ConsoleView view = new ConsoleView();
+ SongRepository repository = new SongRepository();
+ StrategyFactory factory = new StrategyFactory();
+ AnalyzerService analyzer = new AnalyzerService();
+
+ CrawlerController controller = new CrawlerController(view, repository, factory, analyzer);
+ controller.start();
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/music/command/AnalyzeCommand.java b/src/main/java/com/music/command/AnalyzeCommand.java
new file mode 100644
index 0000000..81dca40
--- /dev/null
+++ b/src/main/java/com/music/command/AnalyzeCommand.java
@@ -0,0 +1,36 @@
+package com.music.command;
+
+import com.music.repository.SongRepository;
+import com.music.service.AnalyzerService;
+import com.music.view.ConsoleView;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class AnalyzeCommand implements Command {
+ private static final Logger logger = LoggerFactory.getLogger(AnalyzeCommand.class);
+ private final ConsoleView view;
+ private final AnalyzerService analyzerService;
+
+ public AnalyzeCommand(ConsoleView view, AnalyzerService analyzerService) {
+ this.view = view;
+ this.analyzerService = analyzerService;
+ }
+
+ @Override
+ public String getName() {
+ return "analyze";
+ }
+
+ @Override
+ public void execute(String[] args, SongRepository repository) {
+ var songs = repository.getAll();
+ if (songs.isEmpty()) {
+ view.printError("暂无数据,请先执行 crawl 命令爬取歌曲。");
+ return;
+ }
+ view.printInfo("正在分析数据...");
+ var stats = analyzerService.analyze(songs);
+ view.displayAnalysis(stats);
+ logger.info("分析报告已生成,共 {} 首歌曲", songs.size());
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/music/command/Command.java b/src/main/java/com/music/command/Command.java
new file mode 100644
index 0000000..7f0cbeb
--- /dev/null
+++ b/src/main/java/com/music/command/Command.java
@@ -0,0 +1,8 @@
+package com.music.command;
+
+import com.music.repository.SongRepository;
+
+public interface Command {
+ String getName();
+ void execute(String[] args, SongRepository repository);
+}
diff --git a/src/main/java/com/music/command/CrawlCommand.java b/src/main/java/com/music/command/CrawlCommand.java
new file mode 100644
index 0000000..e0a865c
--- /dev/null
+++ b/src/main/java/com/music/command/CrawlCommand.java
@@ -0,0 +1,52 @@
+package com.music.command;
+
+import com.music.exception.CrawlerException;
+import com.music.repository.SongRepository;
+import com.music.strategy.CrawlStrategy;
+import com.music.strategy.StrategyFactory;
+import com.music.view.ConsoleView;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class CrawlCommand implements Command {
+ private static final Logger logger = LoggerFactory.getLogger(CrawlCommand.class);
+ private final ConsoleView view;
+ private final StrategyFactory factory;
+
+ public CrawlCommand(ConsoleView view, StrategyFactory factory) {
+ this.view = view;
+ this.factory = factory;
+ }
+
+ @Override
+ public String getName() {
+ return "crawl";
+ }
+
+ @Override
+ public void execute(String[] args, SongRepository repository) {
+ if (args.length < 2) {
+ view.printError("用法: crawl (netease/qq/kugou)");
+ return;
+ }
+ String platform = args[1];
+ CrawlStrategy strategy = factory.getStrategy(platform);
+ if (strategy == null) {
+ view.printError("不支持的平台: " + platform + ",可选:netease, qq, kugou");
+ return;
+ }
+ view.printInfo("正在爬取 " + platform + " 热歌榜...");
+ try {
+ var songs = strategy.crawl(50); // 爬取前50首
+ repository.addAll(songs);
+ view.printSuccess(String.format("成功爬取 %d 首歌曲", songs.size()));
+ logger.info("爬取完成,平台={}, 数量={}", platform, songs.size());
+ } catch (CrawlerException e) {
+ view.printError("爬取失败: " + e.getMessage());
+ logger.error("爬取异常", e);
+ } catch (Exception e) {
+ view.printError("未知错误: " + e.getMessage());
+ logger.error("未知异常", e);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/music/command/ExitCommand.java b/src/main/java/com/music/command/ExitCommand.java
new file mode 100644
index 0000000..b0f1fe1
--- /dev/null
+++ b/src/main/java/com/music/command/ExitCommand.java
@@ -0,0 +1,23 @@
+package com.music.command;
+
+import com.music.repository.SongRepository;
+import com.music.view.ConsoleView;
+
+public class ExitCommand implements Command {
+ private final ConsoleView view;
+
+ public ExitCommand(ConsoleView view) {
+ this.view = view;
+ }
+
+ @Override
+ public String getName() {
+ return "exit";
+ }
+
+ @Override
+ public void execute(String[] args, SongRepository repository) {
+ view.printSuccess("再见!");
+ System.exit(0);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/music/command/HelpCommand.java b/src/main/java/com/music/command/HelpCommand.java
new file mode 100644
index 0000000..e73b2ad
--- /dev/null
+++ b/src/main/java/com/music/command/HelpCommand.java
@@ -0,0 +1,22 @@
+package com.music.command;
+
+import com.music.repository.SongRepository;
+import com.music.view.ConsoleView;
+
+public class HelpCommand implements Command {
+ private final ConsoleView view;
+
+ public HelpCommand(ConsoleView view) {
+ this.view = view;
+ }
+
+ @Override
+ public String getName() {
+ return "help";
+ }
+
+ @Override
+ public void execute(String[] args, SongRepository repository) {
+ view.printHelp();
+ }
+}
diff --git a/src/main/java/com/music/command/HistoryCommand.java b/src/main/java/com/music/command/HistoryCommand.java
new file mode 100644
index 0000000..75ceb60
--- /dev/null
+++ b/src/main/java/com/music/command/HistoryCommand.java
@@ -0,0 +1,32 @@
+package com.music.command;
+
+import com.music.repository.SongRepository;
+import com.music.view.ConsoleView;
+import java.util.List;
+
+public class HistoryCommand implements Command {
+ private final ConsoleView view;
+ private final List history;
+
+ public HistoryCommand(ConsoleView view, List history) {
+ this.view = view;
+ this.history = history;
+ }
+
+ @Override
+ public String getName() {
+ return "history";
+ }
+
+ @Override
+ public void execute(String[] args, SongRepository repository) {
+ if (history.isEmpty()) {
+ view.println("没有命令历史。");
+ return;
+ }
+ view.println("最近输入的命令:");
+ for (int i = 0; i < history.size(); i++) {
+ view.println(" " + (i + 1) + ". " + history.get(i));
+ }
+ }
+}
diff --git a/src/main/java/com/music/command/ListCommand.java b/src/main/java/com/music/command/ListCommand.java
new file mode 100644
index 0000000..4e6ef22
--- /dev/null
+++ b/src/main/java/com/music/command/ListCommand.java
@@ -0,0 +1,22 @@
+package com.music.command;
+
+import com.music.repository.SongRepository;
+import com.music.view.ConsoleView;
+
+public class ListCommand implements Command {
+ private final ConsoleView view;
+
+ public ListCommand(ConsoleView view) {
+ this.view = view;
+ }
+
+ @Override
+ public String getName() {
+ return "list";
+ }
+
+ @Override
+ public void execute(String[] args, SongRepository repository) {
+ view.displaySongs(repository.getAll());
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/music/command/SaveCommand.java b/src/main/java/com/music/command/SaveCommand.java
new file mode 100644
index 0000000..4f745e7
--- /dev/null
+++ b/src/main/java/com/music/command/SaveCommand.java
@@ -0,0 +1,38 @@
+package com.music.command;
+
+import com.music.repository.SongRepository;
+import com.music.util.CsvUtil;
+import com.music.view.ConsoleView;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class SaveCommand implements Command {
+ private static final Logger logger = LoggerFactory.getLogger(SaveCommand.class);
+ private final ConsoleView view;
+
+ public SaveCommand(ConsoleView view) {
+ this.view = view;
+ }
+
+ @Override
+ public String getName() {
+ return "save";
+ }
+
+ @Override
+ public void execute(String[] args, SongRepository repository) {
+ if (args.length < 2) {
+ view.printError("用法: save <文件名>");
+ return;
+ }
+ String filename = args[1];
+ try {
+ CsvUtil.saveToCsv(repository.getAll(), filename);
+ view.printSuccess("已保存到 " + filename);
+ logger.info("数据已保存到文件: {}", filename);
+ } catch (Exception e) {
+ view.printError("保存失败: " + e.getMessage());
+ logger.error("保存CSV失败", e);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/music/controller/CrawlerController.java b/src/main/java/com/music/controller/CrawlerController.java
new file mode 100644
index 0000000..4ab4bf1
--- /dev/null
+++ b/src/main/java/com/music/controller/CrawlerController.java
@@ -0,0 +1,59 @@
+package com.music.controller;
+
+import com.music.command.*;
+import com.music.repository.SongRepository;
+import com.music.service.AnalyzerService;
+import com.music.strategy.StrategyFactory;
+import com.music.view.ConsoleView;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.*;
+
+public class CrawlerController {
+ private static final Logger logger = LoggerFactory.getLogger(CrawlerController.class);
+ private final Map commands = new HashMap<>();
+ private final ConsoleView view;
+ private final SongRepository repository;
+ private final List history = new ArrayList<>();
+
+ public CrawlerController(ConsoleView view, SongRepository repository,
+ StrategyFactory factory, AnalyzerService analyzer) {
+ this.view = view;
+ this.repository = repository;
+ registerCommand(new HelpCommand(view));
+ registerCommand(new ExitCommand(view));
+ registerCommand(new ListCommand(view));
+ registerCommand(new CrawlCommand(view, factory));
+ registerCommand(new SaveCommand(view));
+ registerCommand(new AnalyzeCommand(view, analyzer));
+ registerCommand(new HistoryCommand(view, history));
+ }
+
+ private void registerCommand(Command cmd) {
+ commands.put(cmd.getName(), cmd);
+ }
+
+ public void start() {
+ view.printSuccess("欢迎使用音乐爬虫系统 (CLI)");
+ view.println("输入 help 查看所有命令。\n");
+ while (true) {
+ String input = view.readLine().trim();
+ if (input.isEmpty()) continue;
+ history.add(input);
+ String[] parts = input.split("\\s+");
+ String cmdName = parts[0].toLowerCase();
+ Command cmd = commands.get(cmdName);
+ if (cmd == null) {
+ view.printError("未知命令: " + cmdName + ",输入 help 查看帮助");
+ continue;
+ }
+ try {
+ cmd.execute(parts, repository);
+ } catch (Exception e) {
+ view.printError("命令执行出错: " + e.getMessage());
+ logger.error("命令执行异常", e);
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/music/exception/CrawlerException.java b/src/main/java/com/music/exception/CrawlerException.java
new file mode 100644
index 0000000..95bb01e
--- /dev/null
+++ b/src/main/java/com/music/exception/CrawlerException.java
@@ -0,0 +1,10 @@
+package com.music.exception;
+
+public class CrawlerException extends Exception {
+ public CrawlerException(String message) {
+ super(message);
+ }
+ public CrawlerException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/music/exception/NetworkException.java b/src/main/java/com/music/exception/NetworkException.java
new file mode 100644
index 0000000..b40b835
--- /dev/null
+++ b/src/main/java/com/music/exception/NetworkException.java
@@ -0,0 +1,10 @@
+package com.music.exception;
+
+public class NetworkException extends CrawlerException {
+ private final String url;
+ public NetworkException(String url, String message, Throwable cause) {
+ super(message, cause);
+ this.url = url;
+ }
+ public String getUrl() { return url; }
+}
diff --git a/src/main/java/com/music/exception/ParseException.java b/src/main/java/com/music/exception/ParseException.java
new file mode 100644
index 0000000..00050ba
--- /dev/null
+++ b/src/main/java/com/music/exception/ParseException.java
@@ -0,0 +1,10 @@
+package com.music.exception;
+
+public class ParseException extends CrawlerException {
+ public ParseException(String message) {
+ super(message);
+ }
+ public ParseException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/src/main/java/com/music/model/Song.java b/src/main/java/com/music/model/Song.java
new file mode 100644
index 0000000..335b59a
--- /dev/null
+++ b/src/main/java/com/music/model/Song.java
@@ -0,0 +1,55 @@
+package com.music.model;
+
+public class Song {
+ private String platform; // netease, qq, kugou
+ private String name;
+ private String artist;
+ private String album;
+ private Integer duration; // 秒
+ private Integer popularity;
+ private String chartType;
+ private Integer rank;
+
+ public Song() {}
+
+ // 全参构造器(方便测试)
+ public Song(String platform, String name, String artist, String album, Integer duration, Integer rank) {
+ this.platform = platform;
+ this.name = name;
+ this.artist = artist;
+ this.album = album;
+ this.duration = duration;
+ this.rank = rank;
+ this.chartType = "热歌榜";
+ }
+
+ // Getters and Setters
+ public String getPlatform() { return platform; }
+ public void setPlatform(String platform) { this.platform = platform; }
+
+ public String getName() { return name; }
+ public void setName(String name) { this.name = name; }
+
+ public String getArtist() { return artist; }
+ public void setArtist(String artist) { this.artist = artist; }
+
+ public String getAlbum() { return album; }
+ public void setAlbum(String album) { this.album = album; }
+
+ public Integer getDuration() { return duration; }
+ public void setDuration(Integer duration) { this.duration = duration; }
+
+ public Integer getPopularity() { return popularity; }
+ public void setPopularity(Integer popularity) { this.popularity = popularity; }
+
+ public String getChartType() { return chartType; }
+ public void setChartType(String chartType) { this.chartType = chartType; }
+
+ public Integer getRank() { return rank; }
+ public void setRank(Integer rank) { this.rank = rank; }
+
+ @Override
+ public String toString() {
+ return String.format("%d. %s - %s [%s]", rank, name, artist, platform);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/music/repository/SongRepository.java b/src/main/java/com/music/repository/SongRepository.java
new file mode 100644
index 0000000..8db53be
--- /dev/null
+++ b/src/main/java/com/music/repository/SongRepository.java
@@ -0,0 +1,36 @@
+package com.music.repository;
+
+import com.music.model.Song;
+import java.util.*;
+
+public class SongRepository {
+ private final List songs = new ArrayList<>();
+
+ public void add(Song song) {
+ if (song == null) {
+ throw new IllegalArgumentException("歌曲不能为 null");
+ }
+ if (song.getName() == null || song.getName().trim().isEmpty()) {
+ throw new IllegalArgumentException("歌曲名不能为空");
+ }
+ songs.add(song);
+ }
+
+ public void addAll(List songList) {
+ for (Song s : songList) {
+ add(s);
+ }
+ }
+
+ public List getAll() {
+ return Collections.unmodifiableList(songs);
+ }
+
+ public int size() {
+ return songs.size();
+ }
+
+ public void clear() {
+ songs.clear();
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/music/service/AnalyzerService.java b/src/main/java/com/music/service/AnalyzerService.java
new file mode 100644
index 0000000..b68be77
--- /dev/null
+++ b/src/main/java/com/music/service/AnalyzerService.java
@@ -0,0 +1,55 @@
+package com.music.service;
+
+import com.music.model.Song;
+import java.util.*;
+import java.util.stream.Collectors;
+
+public class AnalyzerService {
+
+ public Map analyze(List songs) {
+ Map result = new HashMap<>();
+
+ // 基础统计
+ long uniqueSongs = songs.stream()
+ .map(s -> s.getName() + "|" + s.getArtist())
+ .distinct()
+ .count();
+ long duplicateCount = songs.size() - uniqueSongs;
+ long artistCount = songs.stream().map(Song::getArtist).distinct().count();
+
+ result.put("totalSongs", songs.size());
+ result.put("uniqueSongs", uniqueSongs);
+ result.put("duplicateCount", duplicateCount);
+ result.put("artistCount", artistCount);
+
+ // 热门歌手排行
+ Map artistCountMap = songs.stream()
+ .collect(Collectors.groupingBy(Song::getArtist, Collectors.counting()));
+ List> topArtists = new ArrayList<>(artistCountMap.entrySet());
+ topArtists.sort((a, b) -> b.getValue().compareTo(a.getValue()));
+ result.put("topArtists", topArtists);
+
+ // 时长分析
+ double avgDuration = songs.stream().mapToInt(Song::getDuration).average().orElse(0);
+ result.put("avgDuration", avgDuration);
+
+ Song shortest = songs.stream().min(Comparator.comparingInt(Song::getDuration)).orElse(null);
+ Song longest = songs.stream().max(Comparator.comparingInt(Song::getDuration)).orElse(null);
+ result.put("shortestSong", shortest == null ? "无" : String.format("%s (%d秒)", shortest.getName(), shortest.getDuration()));
+ result.put("longestSong", longest == null ? "无" : String.format("%s (%d秒)", longest.getName(), longest.getDuration()));
+
+ // 时长分布
+ Map durationDist = songs.stream()
+ .collect(Collectors.groupingBy(s -> {
+ int min = s.getDuration() / 60;
+ if (min < 3) return "3分钟以下";
+ else if (min < 4) return "3-4分钟";
+ else if (min < 5) return "4-5分钟";
+ else if (min < 6) return "5-6分钟";
+ else return "6分钟以上";
+ }, Collectors.counting()));
+ result.put("durationDistribution", durationDist);
+
+ return result;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/music/strategy/CrawlStrategy.java b/src/main/java/com/music/strategy/CrawlStrategy.java
new file mode 100644
index 0000000..1eb558c
--- /dev/null
+++ b/src/main/java/com/music/strategy/CrawlStrategy.java
@@ -0,0 +1,11 @@
+package com.music.strategy;
+
+import com.music.model.Song;
+import com.music.exception.NetworkException;
+import com.music.exception.ParseException;
+import java.util.List;
+
+public interface CrawlStrategy {
+ boolean supports(String platform);
+ List crawl(int limit) throws NetworkException, ParseException;
+}
\ No newline at end of file
diff --git a/src/main/java/com/music/strategy/KuGouStrategy.java b/src/main/java/com/music/strategy/KuGouStrategy.java
new file mode 100644
index 0000000..3eb74f0
--- /dev/null
+++ b/src/main/java/com/music/strategy/KuGouStrategy.java
@@ -0,0 +1,120 @@
+package com.music.strategy;
+
+import com.music.exception.NetworkException;
+import com.music.exception.ParseException;
+import com.music.model.Song;
+import com.music.util.RetryUtils;
+import org.jsoup.Jsoup;
+import org.jsoup.nodes.Document;
+import org.jsoup.nodes.Element;
+import org.jsoup.select.Elements;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class KuGouStrategy implements CrawlStrategy {
+ private static final Logger logger = LoggerFactory.getLogger(KuGouStrategy.class);
+
+ @Override
+ public boolean supports(String platform) {
+ return "kugou".equalsIgnoreCase(platform);
+ }
+
+ @Override
+ public List crawl(int limit) throws NetworkException, ParseException {
+ logger.info("开始爬取酷狗热歌榜,限制 {} 首", limit);
+ try {
+ // 使用酷狗网页版排行榜(相对稳定)
+ String url = "https://www.kugou.com/yy/rank/home/1-6666.html";
+ Document doc = RetryUtils.retry(() -> fetchDocument(url), 3, 1000);
+
+ // 解析歌曲列表(根据酷狗网页结构调整选择器)
+ Elements songItems = doc.select("#rankWrap .pc_temp_songlist li");
+ if (songItems.isEmpty()) {
+ // 备用选择器
+ songItems = doc.select(".song-list li");
+ }
+ if (songItems.isEmpty()) {
+ logger.warn("未找到歌曲列表,可能网页结构已变化,使用模拟数据");
+ return getMockSongs(limit);
+ }
+
+ List songs = new ArrayList<>();
+ int rank = 1;
+ for (Element item : songItems) {
+ if (rank > limit) break;
+
+ // 歌曲名和歌手:通常在 a 标签内,格式如 "歌曲名 - 歌手"
+ Element nameLink = item.select(".pc_temp_songname a").first();
+ if (nameLink == null) nameLink = item.select("a").first();
+ if (nameLink == null) continue;
+
+ String fullText = nameLink.text();
+ String name = fullText;
+ String artist = "未知歌手";
+ if (fullText.contains("-")) {
+ String[] parts = fullText.split("-", 2);
+ if (parts.length == 2) {
+ name = parts[0].trim();
+ artist = parts[1].trim();
+ }
+ }
+
+ // 时长(格式如 03:45)
+ String durationStr = item.select(".pc_temp_time").text();
+ int durationSeconds = parseDuration(durationStr);
+
+ Song song = new Song();
+ song.setPlatform("kugou");
+ song.setRank(rank);
+ song.setChartType("热歌榜");
+ song.setName(name);
+ song.setArtist(artist);
+ song.setAlbum("酷狗热歌榜");
+ song.setDuration(durationSeconds);
+ songs.add(song);
+
+ rank++;
+ }
+ logger.info("酷狗爬取完成,真实数据 {} 首", songs.size());
+ return songs.isEmpty() ? getMockSongs(limit) : songs;
+ } catch (Exception e) {
+ logger.error("酷狗爬取失败,使用模拟数据", e);
+ return getMockSongs(limit);
+ }
+ }
+
+ private Document fetchDocument(String url) throws Exception {
+ return Jsoup.connect(url)
+ .userAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
+ .header("Referer", "https://www.kugou.com/")
+ .timeout(10000)
+ .get();
+ }
+
+ private int parseDuration(String durationStr) {
+ if (durationStr == null || durationStr.isEmpty()) return 0;
+ try {
+ String[] parts = durationStr.split(":");
+ if (parts.length == 2) {
+ return Integer.parseInt(parts[0]) * 60 + Integer.parseInt(parts[1]);
+ }
+ } catch (NumberFormatException e) {
+ logger.warn("时长解析失败: {}", durationStr);
+ }
+ return 0;
+ }
+
+ private List getMockSongs(int limit) {
+ List songs = new ArrayList<>();
+ String[] names = {"海阔天空", "老男孩", "逆战", "夜曲", "青花瓷", "演员", "消愁", "童话"};
+ String[] artists = {"Beyond", "筷子兄弟", "张杰", "周杰伦", "周杰伦", "薛之谦", "毛不易", "光良"};
+ for (int i = 0; i < Math.min(limit, names.length); i++) {
+ Song song = new Song("kugou", names[i], artists[i], "酷狗精选", 220 + i * 10, i + 1);
+ songs.add(song);
+ }
+ return songs;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/music/strategy/NetEaseStrategy.java b/src/main/java/com/music/strategy/NetEaseStrategy.java
new file mode 100644
index 0000000..8c4a80f
--- /dev/null
+++ b/src/main/java/com/music/strategy/NetEaseStrategy.java
@@ -0,0 +1,142 @@
+package com.music.strategy; // 注意你的包名是 com.music
+
+import com.google.gson.*;
+import com.music.exception.NetworkException;
+import com.music.exception.ParseException;
+import com.music.model.Song;
+import com.music.util.RetryUtils;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.Response;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+public class NetEaseStrategy implements CrawlStrategy {
+ private static final Logger logger = LoggerFactory.getLogger(NetEaseStrategy.class);
+
+ @Override
+ public boolean supports(String platform) {
+ return "netease".equalsIgnoreCase(platform);
+ }
+
+ @Override
+ public List crawl(int limit) throws NetworkException, ParseException {
+ logger.info("开始爬取网易云热歌榜,限制 {} 首", limit);
+ List songs = new ArrayList<>();
+ try {
+ // 使用更稳定的接口:歌单详情 API (歌单 ID: 3778678 是官方热歌榜)
+ String jsonData = RetryUtils.retry(() -> fetchJsonFromUrl(), 3, 1000);
+
+ // 调试:打印前500字符,查看返回结构
+ if (jsonData.length() > 500) {
+ logger.debug("API返回预览: {}", jsonData.substring(0, 500));
+ } else {
+ logger.debug("API返回: {}", jsonData);
+ }
+
+ JsonObject root = JsonParser.parseString(jsonData).getAsJsonObject();
+ int code = root.get("code").getAsInt();
+ if (code != 200) {
+ throw new ParseException("网易云API返回错误码: " + code);
+ }
+
+ // 新版网易云API返回的数据在 "playlist" -> "tracks" 下
+ JsonObject playlist = root.getAsJsonObject("playlist");
+ if (playlist == null) {
+ // 兼容旧版结构:直接 result.tracks
+ JsonObject result = root.getAsJsonObject("result");
+ if (result == null) {
+ throw new ParseException("JSON中既没有 playlist 也没有 result 字段,请检查API返回");
+ }
+ parseTracks(result.getAsJsonArray("tracks"), songs, limit);
+ } else {
+ JsonArray tracks = playlist.getAsJsonArray("tracks");
+ parseTracks(tracks, songs, limit);
+ }
+
+ logger.info("网易云爬取完成,共 {} 首", songs.size());
+ return songs;
+ } catch (Exception e) {
+ logger.error("网易云爬取失败", e);
+ if (e instanceof NetworkException) throw (NetworkException) e;
+ if (e instanceof ParseException) throw (ParseException) e;
+ throw new ParseException("解析网易云数据失败: " + e.getMessage(), e);
+ }
+ }
+
+ private void parseTracks(JsonArray tracks, List songs, int limit) {
+ if (tracks == null) {
+ logger.warn("tracks 数组为空");
+ return;
+ }
+ int count = 0;
+ for (int i = 0; i < tracks.size() && count < limit; i++) {
+ JsonObject track = tracks.get(i).getAsJsonObject();
+ Song song = new Song();
+ song.setPlatform("netease");
+ song.setRank(i + 1);
+ song.setChartType("热歌榜");
+ song.setName(track.get("name").getAsString());
+
+ // 歌手
+ if (track.has("artists")) {
+ JsonArray artists = track.getAsJsonArray("artists");
+ StringBuilder sb = new StringBuilder();
+ for (int j = 0; j < artists.size(); j++) {
+ sb.append(artists.get(j).getAsJsonObject().get("name").getAsString());
+ if (j < artists.size() - 1) sb.append("/");
+ }
+ song.setArtist(sb.toString());
+ } else {
+ song.setArtist("未知歌手");
+ }
+
+ // 专辑
+ if (track.has("album")) {
+ JsonObject album = track.getAsJsonObject("album");
+ song.setAlbum(album.has("name") ? album.get("name").getAsString() : "未知专辑");
+ } else {
+ song.setAlbum("未知专辑");
+ }
+
+ // 时长(毫秒转秒)
+ song.setDuration(track.get("duration").getAsInt() / 1000);
+ songs.add(song);
+ count++;
+ try { Thread.sleep(100); } catch (InterruptedException ignored) {}
+ }
+ }
+
+ private String fetchJsonFromUrl() throws Exception {
+ OkHttpClient client = new OkHttpClient.Builder()
+ .connectTimeout(30, TimeUnit.SECONDS)
+ .readTimeout(30, TimeUnit.SECONDS)
+ .addInterceptor(chain -> {
+ Request original = chain.request();
+ Request request = original.newBuilder()
+ .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
+ .header("Cookie", "os=pc; appver=2.0.2;")
+ .header("Referer", "https://music.163.com/")
+ .method(original.method(), original.body())
+ .build();
+ return chain.proceed(request);
+ })
+ .build();
+ // 使用官方热歌榜歌单 ID: 3778678 的详情接口
+ String url = "https://music.163.com/api/playlist/detail?id=3778678";
+ Request request = new Request.Builder()
+ .url(url)
+ .get()
+ .build();
+ try (Response response = client.newCall(request).execute()) {
+ if (!response.isSuccessful()) {
+ throw new NetworkException(url, "HTTP " + response.code(), null);
+ }
+ return response.body().string();
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/music/strategy/QQStrategy.java b/src/main/java/com/music/strategy/QQStrategy.java
new file mode 100644
index 0000000..8c79586
--- /dev/null
+++ b/src/main/java/com/music/strategy/QQStrategy.java
@@ -0,0 +1,104 @@
+package com.music.strategy;
+
+import com.music.exception.NetworkException;
+import com.music.exception.ParseException;
+import com.music.model.Song;
+import com.music.util.RetryUtils;
+import org.jsoup.Jsoup;
+import org.jsoup.nodes.Document;
+import org.jsoup.nodes.Element;
+import org.jsoup.select.Elements;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class QQStrategy implements CrawlStrategy {
+ private static final Logger logger = LoggerFactory.getLogger(QQStrategy.class);
+
+ @Override
+ public boolean supports(String platform) {
+ return "qq".equalsIgnoreCase(platform);
+ }
+
+ @Override
+ public List crawl(int limit) throws NetworkException, ParseException {
+ logger.info("开始爬取 QQ 音乐热歌榜,限制 {} 首", limit);
+ List songs = new ArrayList<>();
+ try {
+ // 使用重试工具包装网络请求
+ String url = "https://y.qq.com/n/ryqq/toplist/4"; // QQ音乐热歌榜
+ Document doc = RetryUtils.retry(() -> fetchDocument(url), 3, 1000);
+
+ // 解析歌曲列表:选择器基于 QQ 音乐网页结构
+ Elements songItems = doc.select(".songlist__list li");
+ if (songItems.isEmpty()) {
+ logger.warn("未找到歌曲列表,网页结构可能已变化");
+ return songs; // 返回空列表,不抛异常
+ }
+
+ int rank = 1;
+ for (Element item : songItems) {
+ if (rank > limit) break;
+
+ // 歌曲名
+ String name = item.select(".songlist__songname").text();
+ if (name.isEmpty()) {
+ // 备用选择器
+ name = item.select(".songlist__songname_txt").text();
+ }
+ // 歌手
+ String artist = item.select(".songlist__artist").text();
+ if (artist.isEmpty()) {
+ artist = item.select(".songlist__artist_name").text();
+ }
+ // 时长(格式如 03:45)
+ String durationStr = item.select(".songlist__time").text();
+ int durationSeconds = parseDuration(durationStr);
+
+ Song song = new Song();
+ song.setPlatform("qq");
+ song.setRank(rank);
+ song.setChartType("热歌榜");
+ song.setName(name.isEmpty() ? "未知歌曲" : name);
+ song.setArtist(artist.isEmpty() ? "未知歌手" : artist);
+ song.setAlbum("QQ音乐专辑"); // 网页上未直接展示专辑,可留空或后续补充
+ song.setDuration(durationSeconds);
+ songs.add(song);
+
+ logger.debug("QQ音乐: 排名{} {} - {}", rank, name, artist);
+ rank++;
+ }
+ logger.info("QQ音乐爬取完成,共 {} 首", songs.size());
+ return songs;
+ } catch (Exception e) {
+ logger.error("QQ音乐爬取失败", e);
+ if (e instanceof NetworkException) throw (NetworkException) e;
+ if (e instanceof ParseException) throw (ParseException) e;
+ throw new ParseException("解析QQ音乐数据失败: " + e.getMessage(), e);
+ }
+ }
+
+ private Document fetchDocument(String url) throws Exception {
+ return Jsoup.connect(url)
+ .userAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
+ .header("Referer", "https://y.qq.com/")
+ .timeout(10000)
+ .get();
+ }
+
+ private int parseDuration(String durationStr) {
+ if (durationStr == null || durationStr.isEmpty()) return 0;
+ // 格式: "03:45" -> 225秒
+ try {
+ String[] parts = durationStr.split(":");
+ if (parts.length == 2) {
+ return Integer.parseInt(parts[0]) * 60 + Integer.parseInt(parts[1]);
+ }
+ } catch (NumberFormatException e) {
+ logger.warn("时长解析失败: {}", durationStr);
+ }
+ return 0;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/music/strategy/StrategyFactory.java b/src/main/java/com/music/strategy/StrategyFactory.java
new file mode 100644
index 0000000..c6ea8c2
--- /dev/null
+++ b/src/main/java/com/music/strategy/StrategyFactory.java
@@ -0,0 +1,23 @@
+package com.music.strategy;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class StrategyFactory {
+ private final List strategies = new ArrayList<>();
+
+ public StrategyFactory() {
+ strategies.add(new NetEaseStrategy());
+ strategies.add(new QQStrategy());
+ strategies.add(new KuGouStrategy());
+ }
+
+ public CrawlStrategy getStrategy(String platform) {
+ for (CrawlStrategy s : strategies) {
+ if (s.supports(platform)) {
+ return s;
+ }
+ }
+ return null;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/music/util/CsvUtil.java b/src/main/java/com/music/util/CsvUtil.java
new file mode 100644
index 0000000..859fc08
--- /dev/null
+++ b/src/main/java/com/music/util/CsvUtil.java
@@ -0,0 +1,28 @@
+package com.music.util;
+
+import com.music.model.Song;
+import java.io.FileWriter;
+import java.io.PrintWriter;
+import java.util.List;
+
+public class CsvUtil {
+ public static void saveToCsv(List songs, String filename) throws Exception {
+ try (PrintWriter out = new PrintWriter(new FileWriter(filename))) {
+ out.println("排名,歌曲名称,歌手,专辑,时长(秒),平台");
+ for (Song s : songs) {
+ out.printf("%d,\"%s\",\"%s\",\"%s\",%d,%s\n",
+ s.getRank(),
+ escape(s.getName()),
+ escape(s.getArtist()),
+ escape(s.getAlbum()),
+ s.getDuration(),
+ s.getPlatform());
+ }
+ }
+ }
+
+ private static String escape(String str) {
+ if (str == null) return "";
+ return str.replace("\"", "\"\"");
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/music/util/RetryUtils.java b/src/main/java/com/music/util/RetryUtils.java
new file mode 100644
index 0000000..2b9bec3
--- /dev/null
+++ b/src/main/java/com/music/util/RetryUtils.java
@@ -0,0 +1,26 @@
+package com.music.util;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class RetryUtils {
+ private static final Logger logger = LoggerFactory.getLogger(RetryUtils.class);
+
+ @FunctionalInterface
+ public interface ThrowingAction {
+ T call() throws Exception;
+ }
+
+ public static T retry(ThrowingAction action, int maxRetries, long waitMillis) throws Exception {
+ for (int i = 0; i < maxRetries; i++) {
+ try {
+ return action.call();
+ } catch (Exception e) {
+ if (i == maxRetries - 1) throw e;
+ logger.warn("重试 {}/{},等待 {}ms,异常: {}", i + 1, maxRetries, waitMillis, e.getMessage());
+ Thread.sleep(waitMillis);
+ }
+ }
+ throw new IllegalStateException("Unreachable");
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/music/view/ConsoleView.java b/src/main/java/com/music/view/ConsoleView.java
new file mode 100644
index 0000000..b210120
--- /dev/null
+++ b/src/main/java/com/music/view/ConsoleView.java
@@ -0,0 +1,115 @@
+package com.music.view;
+
+import com.music.model.Song;
+import java.util.List;
+import java.util.Map;
+import java.util.Scanner;
+
+public class ConsoleView {
+ private static final String ANSI_GREEN = "\u001B[32m";
+ private static final String ANSI_RED = "\u001B[31m";
+ private static final String ANSI_CYAN = "\u001B[36m";
+ private static final String ANSI_RESET = "\u001B[0m";
+
+ private final Scanner scanner = new Scanner(System.in);
+
+ public void printSuccess(String msg) {
+ System.out.println(ANSI_GREEN + msg + ANSI_RESET);
+ }
+
+ public void printError(String msg) {
+ System.out.println(ANSI_RED + msg + ANSI_RESET);
+ }
+
+ public void printInfo(String msg) {
+ System.out.println(ANSI_CYAN + msg + ANSI_RESET);
+ }
+
+ public void printHelp() {
+ println("\n可用命令:");
+ println(" crawl - 爬取歌曲 (platform: netease, qq, kugou)");
+ println(" list - 显示已爬取的所有歌曲");
+ println(" save - 保存到 CSV 文件 (例如 save data.csv)");
+ println(" analyze - 显示数据分析报告");
+ println(" history - 显示本次会话输入的命令历史");
+ println(" help - 显示本帮助");
+ println(" exit - 退出程序\n");
+ }
+
+ public void displaySongs(List songs) {
+ if (songs.isEmpty()) {
+ println("暂无歌曲数据,请先执行 crawl 命令。");
+ return;
+ }
+ System.out.printf("%-4s %-30s %-20s %-10s %-10s%n",
+ "排名", "歌曲名", "歌手", "时长(秒)", "平台");
+ for (Song s : songs) {
+ System.out.printf("%-4d %-30s %-20s %-10d %-10s%n",
+ s.getRank(),
+ truncate(s.getName(), 28),
+ truncate(s.getArtist(), 18),
+ s.getDuration(),
+ s.getPlatform());
+ }
+ }
+
+ public void displayAnalysis(Map stats) {
+ println("\n" + "=".repeat(60));
+ println("📊 音乐数据分析报告");
+ println("=".repeat(60));
+
+ // 基础统计
+ println("\n📋 【基础统计】");
+ println("-".repeat(40));
+ println(" 总歌曲数: " + stats.get("totalSongs") + " 首");
+ println(" 去重后: " + stats.get("uniqueSongs") + " 首");
+ println(" 重复歌曲: " + stats.get("duplicateCount") + " 首");
+ println(" 涉及歌手: " + stats.get("artistCount") + " 位");
+
+ // 热门歌手排行
+ println("\n🎤 【热门歌手上榜次数排行】");
+ println("-".repeat(40));
+ @SuppressWarnings("unchecked")
+ List> topArtists = (List>) stats.get("topArtists");
+ int rank = 1;
+ for (Map.Entry entry : topArtists) {
+ System.out.printf(" %d. %s: 上榜 %d 次\n", rank++, entry.getKey(), entry.getValue());
+ if (rank > 15) break;
+ }
+
+ // 时长分析
+ println("\n⏱️ 【歌曲时长分析】");
+ println("-".repeat(40));
+ System.out.printf(" 平均时长: %.1f 秒 (%.1f 分钟)\n", stats.get("avgDuration"), (Double)stats.get("avgDuration") / 60);
+ System.out.println(" 最短歌曲: " + stats.get("shortestSong"));
+ System.out.println(" 最长歌曲: " + stats.get("longestSong"));
+
+ // 时长分布
+ println("\n📈 【歌曲时长分布】");
+ println("-".repeat(40));
+ @SuppressWarnings("unchecked")
+ Map durationDist = (Map) stats.get("durationDistribution");
+ durationDist.forEach((range, count) -> {
+ double percentage = count * 100.0 / (Integer)stats.get("totalSongs");
+ System.out.printf(" %s: %d 首 (%.1f%%) ", range, count, percentage);
+ int bar = (int)(percentage / 2);
+ for (int i = 0; i < bar; i++) System.out.print("█");
+ System.out.println();
+ });
+ println("");
+ }
+
+ private String truncate(String str, int maxLen) {
+ if (str == null) return "";
+ if (str.length() <= maxLen) return str;
+ return str.substring(0, maxLen - 3) + "...";
+ }
+
+ public void println(String msg) {
+ System.out.println(msg);
+ }
+
+ public String readLine() {
+ return scanner.nextLine();
+ }
+}
\ No newline at end of file
diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml
new file mode 100644
index 0000000..4dea16e
--- /dev/null
+++ b/src/main/resources/logback.xml
@@ -0,0 +1,23 @@
+
+
+
+ UTF-8
+ %highlight(%level) %d{HH:mm:ss} %logger{20} - %msg%n
+
+
+
+ logs/crawler.log
+
+ logs/crawler.%d{yyyy-MM-dd}.log
+ 7
+
+
+ UTF-8
+ %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/target/classes/com/music/App.class b/target/classes/com/music/App.class
new file mode 100644
index 0000000..951bac3
Binary files /dev/null and b/target/classes/com/music/App.class differ
diff --git a/target/classes/com/music/command/AnalyzeCommand.class b/target/classes/com/music/command/AnalyzeCommand.class
new file mode 100644
index 0000000..4b70962
Binary files /dev/null and b/target/classes/com/music/command/AnalyzeCommand.class differ
diff --git a/target/classes/com/music/command/Command.class b/target/classes/com/music/command/Command.class
new file mode 100644
index 0000000..543631e
Binary files /dev/null and b/target/classes/com/music/command/Command.class differ
diff --git a/target/classes/com/music/command/CrawlCommand.class b/target/classes/com/music/command/CrawlCommand.class
new file mode 100644
index 0000000..4c08c3f
Binary files /dev/null and b/target/classes/com/music/command/CrawlCommand.class differ
diff --git a/target/classes/com/music/command/ExitCommand.class b/target/classes/com/music/command/ExitCommand.class
new file mode 100644
index 0000000..6717265
Binary files /dev/null and b/target/classes/com/music/command/ExitCommand.class differ
diff --git a/target/classes/com/music/command/HelpCommand.class b/target/classes/com/music/command/HelpCommand.class
new file mode 100644
index 0000000..756d18c
Binary files /dev/null and b/target/classes/com/music/command/HelpCommand.class differ
diff --git a/target/classes/com/music/command/HistoryCommand.class b/target/classes/com/music/command/HistoryCommand.class
new file mode 100644
index 0000000..7fa34d6
Binary files /dev/null and b/target/classes/com/music/command/HistoryCommand.class differ
diff --git a/target/classes/com/music/command/ListCommand.class b/target/classes/com/music/command/ListCommand.class
new file mode 100644
index 0000000..ddc7ecf
Binary files /dev/null and b/target/classes/com/music/command/ListCommand.class differ
diff --git a/target/classes/com/music/command/SaveCommand.class b/target/classes/com/music/command/SaveCommand.class
new file mode 100644
index 0000000..c0a3a8f
Binary files /dev/null and b/target/classes/com/music/command/SaveCommand.class differ
diff --git a/target/classes/com/music/controller/CrawlerController.class b/target/classes/com/music/controller/CrawlerController.class
new file mode 100644
index 0000000..e894173
Binary files /dev/null and b/target/classes/com/music/controller/CrawlerController.class differ
diff --git a/target/classes/com/music/exception/CrawlerException.class b/target/classes/com/music/exception/CrawlerException.class
new file mode 100644
index 0000000..15cfb0e
Binary files /dev/null and b/target/classes/com/music/exception/CrawlerException.class differ
diff --git a/target/classes/com/music/exception/NetworkException.class b/target/classes/com/music/exception/NetworkException.class
new file mode 100644
index 0000000..0801760
Binary files /dev/null and b/target/classes/com/music/exception/NetworkException.class differ
diff --git a/target/classes/com/music/exception/ParseException.class b/target/classes/com/music/exception/ParseException.class
new file mode 100644
index 0000000..725f4d6
Binary files /dev/null and b/target/classes/com/music/exception/ParseException.class differ
diff --git a/target/classes/com/music/model/Song.class b/target/classes/com/music/model/Song.class
new file mode 100644
index 0000000..407ac71
Binary files /dev/null and b/target/classes/com/music/model/Song.class differ
diff --git a/target/classes/com/music/repository/SongRepository.class b/target/classes/com/music/repository/SongRepository.class
new file mode 100644
index 0000000..d2926d7
Binary files /dev/null and b/target/classes/com/music/repository/SongRepository.class differ
diff --git a/target/classes/com/music/service/AnalyzerService.class b/target/classes/com/music/service/AnalyzerService.class
new file mode 100644
index 0000000..a6835f5
Binary files /dev/null and b/target/classes/com/music/service/AnalyzerService.class differ
diff --git a/target/classes/com/music/strategy/CrawlStrategy.class b/target/classes/com/music/strategy/CrawlStrategy.class
new file mode 100644
index 0000000..5624776
Binary files /dev/null and b/target/classes/com/music/strategy/CrawlStrategy.class differ
diff --git a/target/classes/com/music/strategy/KuGouStrategy.class b/target/classes/com/music/strategy/KuGouStrategy.class
new file mode 100644
index 0000000..0048ed6
Binary files /dev/null and b/target/classes/com/music/strategy/KuGouStrategy.class differ
diff --git a/target/classes/com/music/strategy/NetEaseStrategy.class b/target/classes/com/music/strategy/NetEaseStrategy.class
new file mode 100644
index 0000000..7737be0
Binary files /dev/null and b/target/classes/com/music/strategy/NetEaseStrategy.class differ
diff --git a/target/classes/com/music/strategy/QQStrategy.class b/target/classes/com/music/strategy/QQStrategy.class
new file mode 100644
index 0000000..7b76eec
Binary files /dev/null and b/target/classes/com/music/strategy/QQStrategy.class differ
diff --git a/target/classes/com/music/strategy/StrategyFactory.class b/target/classes/com/music/strategy/StrategyFactory.class
new file mode 100644
index 0000000..29b7113
Binary files /dev/null and b/target/classes/com/music/strategy/StrategyFactory.class differ
diff --git a/target/classes/com/music/util/CsvUtil.class b/target/classes/com/music/util/CsvUtil.class
new file mode 100644
index 0000000..2c26a37
Binary files /dev/null and b/target/classes/com/music/util/CsvUtil.class differ
diff --git a/target/classes/com/music/util/RetryUtils$ThrowingAction.class b/target/classes/com/music/util/RetryUtils$ThrowingAction.class
new file mode 100644
index 0000000..bdf9931
Binary files /dev/null and b/target/classes/com/music/util/RetryUtils$ThrowingAction.class differ
diff --git a/target/classes/com/music/util/RetryUtils.class b/target/classes/com/music/util/RetryUtils.class
new file mode 100644
index 0000000..5fba799
Binary files /dev/null and b/target/classes/com/music/util/RetryUtils.class differ
diff --git a/target/classes/com/music/view/ConsoleView.class b/target/classes/com/music/view/ConsoleView.class
new file mode 100644
index 0000000..a366cc5
Binary files /dev/null and b/target/classes/com/music/view/ConsoleView.class differ
diff --git a/target/classes/logback.xml b/target/classes/logback.xml
new file mode 100644
index 0000000..4dea16e
--- /dev/null
+++ b/target/classes/logback.xml
@@ -0,0 +1,23 @@
+
+
+
+ UTF-8
+ %highlight(%level) %d{HH:mm:ss} %logger{20} - %msg%n
+
+
+
+ logs/crawler.log
+
+ logs/crawler.%d{yyyy-MM-dd}.log
+ 7
+
+
+ UTF-8
+ %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n
+
+
+
+
+
+
+
\ No newline at end of file