commit
c306458c7e
53 changed files with 1226 additions and 0 deletions
@ -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 |
||||
@ -0,0 +1,59 @@ |
|||||
|
<?xml version="1.0" encoding="UTF-8"?> |
||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0" |
||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" |
||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 |
||||
|
http://maven.apache.org/xsd/maven-4.0.0.xsd"> |
||||
|
<modelVersion>4.0.0</modelVersion> |
||||
|
|
||||
|
<groupId>com.musiccrawler</groupId> |
||||
|
<artifactId>music-crawler</artifactId> |
||||
|
<version>1.0-SNAPSHOT</version> |
||||
|
|
||||
|
<properties> |
||||
|
<maven.compiler.source>11</maven.compiler.source> |
||||
|
<maven.compiler.target>11</maven.compiler.target> |
||||
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> |
||||
|
</properties> |
||||
|
|
||||
|
<dependencies> |
||||
|
<!-- Jsoup 解析 HTML --> |
||||
|
<dependency> |
||||
|
<groupId>org.jsoup</groupId> |
||||
|
<artifactId>jsoup</artifactId> |
||||
|
<version>1.16.1</version> |
||||
|
</dependency> |
||||
|
<!-- OkHttp 网络请求 --> |
||||
|
<dependency> |
||||
|
<groupId>com.squareup.okhttp3</groupId> |
||||
|
<artifactId>okhttp</artifactId> |
||||
|
<version>4.12.0</version> |
||||
|
</dependency> |
||||
|
<!-- Gson 解析 JSON --> |
||||
|
<dependency> |
||||
|
<groupId>com.google.code.gson</groupId> |
||||
|
<artifactId>gson</artifactId> |
||||
|
<version>2.10.1</version> |
||||
|
</dependency> |
||||
|
<!-- Logback 日志 --> |
||||
|
<dependency> |
||||
|
<groupId>ch.qos.logback</groupId> |
||||
|
<artifactId>logback-classic</artifactId> |
||||
|
<version>1.4.14</version> |
||||
|
</dependency> |
||||
|
<dependency> |
||||
|
<groupId>org.jsoup</groupId> |
||||
|
<artifactId>jsoup</artifactId> |
||||
|
<version>1.16.1</version> |
||||
|
</dependency> |
||||
|
</dependencies> |
||||
|
|
||||
|
<build> |
||||
|
<plugins> |
||||
|
<plugin> |
||||
|
<groupId>org.apache.maven.plugins</groupId> |
||||
|
<artifactId>maven-compiler-plugin</artifactId> |
||||
|
<version>3.11.0</version> |
||||
|
</plugin> |
||||
|
</plugins> |
||||
|
</build> |
||||
|
</project> |
||||
@ -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(); |
||||
|
} |
||||
|
} |
||||
@ -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()); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,8 @@ |
|||||
|
package com.music.command; |
||||
|
|
||||
|
import com.music.repository.SongRepository; |
||||
|
|
||||
|
public interface Command { |
||||
|
String getName(); |
||||
|
void execute(String[] args, SongRepository repository); |
||||
|
} |
||||
@ -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 <platform> (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); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
@ -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(); |
||||
|
} |
||||
|
} |
||||
@ -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<String> history; |
||||
|
|
||||
|
public HistoryCommand(ConsoleView view, List<String> 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)); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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()); |
||||
|
} |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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<String, Command> commands = new HashMap<>(); |
||||
|
private final ConsoleView view; |
||||
|
private final SongRepository repository; |
||||
|
private final List<String> 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); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
@ -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; } |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,36 @@ |
|||||
|
package com.music.repository; |
||||
|
|
||||
|
import com.music.model.Song; |
||||
|
import java.util.*; |
||||
|
|
||||
|
public class SongRepository { |
||||
|
private final List<Song> 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<Song> songList) { |
||||
|
for (Song s : songList) { |
||||
|
add(s); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public List<Song> getAll() { |
||||
|
return Collections.unmodifiableList(songs); |
||||
|
} |
||||
|
|
||||
|
public int size() { |
||||
|
return songs.size(); |
||||
|
} |
||||
|
|
||||
|
public void clear() { |
||||
|
songs.clear(); |
||||
|
} |
||||
|
} |
||||
@ -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<String, Object> analyze(List<Song> songs) { |
||||
|
Map<String, Object> 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<String, Long> artistCountMap = songs.stream() |
||||
|
.collect(Collectors.groupingBy(Song::getArtist, Collectors.counting())); |
||||
|
List<Map.Entry<String, Long>> 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<String, Long> 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; |
||||
|
} |
||||
|
} |
||||
@ -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<Song> crawl(int limit) throws NetworkException, ParseException; |
||||
|
} |
||||
@ -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<Song> 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<Song> 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<Song> getMockSongs(int limit) { |
||||
|
List<Song> 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; |
||||
|
} |
||||
|
} |
||||
@ -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<Song> crawl(int limit) throws NetworkException, ParseException { |
||||
|
logger.info("开始爬取网易云热歌榜,限制 {} 首", limit); |
||||
|
List<Song> 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<Song> 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(); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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<Song> crawl(int limit) throws NetworkException, ParseException { |
||||
|
logger.info("开始爬取 QQ 音乐热歌榜,限制 {} 首", limit); |
||||
|
List<Song> 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; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,23 @@ |
|||||
|
package com.music.strategy; |
||||
|
|
||||
|
import java.util.ArrayList; |
||||
|
import java.util.List; |
||||
|
|
||||
|
public class StrategyFactory { |
||||
|
private final List<CrawlStrategy> 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; |
||||
|
} |
||||
|
} |
||||
@ -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<Song> 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("\"", "\"\""); |
||||
|
} |
||||
|
} |
||||
@ -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> { |
||||
|
T call() throws Exception; |
||||
|
} |
||||
|
|
||||
|
public static <T> T retry(ThrowingAction<T> 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"); |
||||
|
} |
||||
|
} |
||||
@ -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> - 爬取歌曲 (platform: netease, qq, kugou)"); |
||||
|
println(" list - 显示已爬取的所有歌曲"); |
||||
|
println(" save <filename> - 保存到 CSV 文件 (例如 save data.csv)"); |
||||
|
println(" analyze - 显示数据分析报告"); |
||||
|
println(" history - 显示本次会话输入的命令历史"); |
||||
|
println(" help - 显示本帮助"); |
||||
|
println(" exit - 退出程序\n"); |
||||
|
} |
||||
|
|
||||
|
public void displaySongs(List<Song> 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<String, Object> 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<Map.Entry<String, Long>> topArtists = (List<Map.Entry<String, Long>>) stats.get("topArtists"); |
||||
|
int rank = 1; |
||||
|
for (Map.Entry<String, Long> 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<String, Long> durationDist = (Map<String, Long>) 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(); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,23 @@ |
|||||
|
<configuration> |
||||
|
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender"> |
||||
|
<encoder> |
||||
|
<charset>UTF-8</charset> |
||||
|
<pattern>%highlight(%level) %d{HH:mm:ss} %logger{20} - %msg%n</pattern> |
||||
|
</encoder> |
||||
|
</appender> |
||||
|
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"> |
||||
|
<file>logs/crawler.log</file> |
||||
|
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy"> |
||||
|
<fileNamePattern>logs/crawler.%d{yyyy-MM-dd}.log</fileNamePattern> |
||||
|
<maxHistory>7</maxHistory> |
||||
|
</rollingPolicy> |
||||
|
<encoder> |
||||
|
<charset>UTF-8</charset> |
||||
|
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern> |
||||
|
</encoder> |
||||
|
</appender> |
||||
|
<root level="INFO"> |
||||
|
<appender-ref ref="CONSOLE"/> |
||||
|
<appender-ref ref="FILE"/> |
||||
|
</root> |
||||
|
</configuration> |
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -0,0 +1,23 @@ |
|||||
|
<configuration> |
||||
|
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender"> |
||||
|
<encoder> |
||||
|
<charset>UTF-8</charset> |
||||
|
<pattern>%highlight(%level) %d{HH:mm:ss} %logger{20} - %msg%n</pattern> |
||||
|
</encoder> |
||||
|
</appender> |
||||
|
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"> |
||||
|
<file>logs/crawler.log</file> |
||||
|
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy"> |
||||
|
<fileNamePattern>logs/crawler.%d{yyyy-MM-dd}.log</fileNamePattern> |
||||
|
<maxHistory>7</maxHistory> |
||||
|
</rollingPolicy> |
||||
|
<encoder> |
||||
|
<charset>UTF-8</charset> |
||||
|
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern> |
||||
|
</encoder> |
||||
|
</appender> |
||||
|
<root level="INFO"> |
||||
|
<appender-ref ref="CONSOLE"/> |
||||
|
<appender-ref ref="FILE"/> |
||||
|
</root> |
||||
|
</configuration> |
||||
Loading…
Reference in new issue