import javax.imageio.ImageIO; import java.awt.*; import java.awt.image.BufferedImage; import java.io.*; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Duration; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.*; import java.util.List; import java.util.regex.Matcher; import java.util.regex.Pattern; /** * ========================================================== * * 功能: * 1. 天气爬虫 * 2. 空气质量爬虫 * 3. 日出日落爬虫 * 4. 湿度爬虫 * 5. 风速风向爬虫 * * 技术: * √ Java面向对象 * √ 抽象类 * √ 泛型 * √ 多态 * √ MVC思想 * √ Command模式 * √ Strategy模式 * √ 数据可视化 * √ 文件持久化 * ========================================================== */ /* ========================================================== 1. 自定义异常 ========================================================== */ class CrawlerException extends Exception { public CrawlerException(String message) { super(message); } public CrawlerException(String message, Throwable cause) { super(message, cause); } } /* ========================================================== 2. 泛型接口 ========================================================== */ interface DataCrawler { T crawlData(K key) throws CrawlerException; Map parseData(T rawData); void saveData(K key, Map data) throws CrawlerException; } interface DataVisualizer { void generateVisualization(String title, T data); } /* ========================================================== 3. 保存策略 ========================================================== */ interface SaveStrategy { void save(String fileName, String content) throws IOException; } class TxtSaveStrategy implements SaveStrategy { @Override public void save(String fileName, String content) throws IOException { File folder = new File("data"); if (!folder.exists()) { folder.mkdirs(); } File file = new File(folder, fileName); try (BufferedWriter writer = new BufferedWriter( new FileWriter(file) )) { writer.write(content); } } } /* ========================================================== 4. 抽象爬虫父类 ========================================================== */ abstract class AbstractDataCrawler implements DataCrawler { protected Map rawDataMap = new HashMap<>(); protected Map> parsedDataMap = new LinkedHashMap<>(); protected SaveStrategy saveStrategy = new TxtSaveStrategy(); protected String doHttpGet(String url) throws CrawlerException { try { HttpClient client = HttpClient.newBuilder() .connectTimeout( Duration.ofSeconds(10) ) .build(); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(url)) .GET() .build(); HttpResponse response = client.send( request, HttpResponse.BodyHandlers.ofString() ); if (response.statusCode() != 200) { throw new CrawlerException( "HTTP请求失败:" + response.statusCode() ); } return response.body(); } catch (Exception e) { throw new CrawlerException( "网络请求异常", e ); } } protected abstract String getApiUrl(K key); } /* ========================================================== 5. 城市坐标工具类 ========================================================== */ class CityCoordinateUtil { public static final Map CITY_MAP = new LinkedHashMap<>(); static { CITY_MAP.put( "西安", new String[]{"34.2644", "108.9497"} ); CITY_MAP.put( "成都", new String[]{"30.5728", "104.0668"} ); CITY_MAP.put( "兰州", new String[]{"36.0611", "103.8343"} ); CITY_MAP.put( "乌鲁木齐", new String[]{"43.8256", "87.6168"} ); } } /* ========================================================== 6. 天气爬虫 ========================================================== */ class WeatherCrawler extends AbstractDataCrawler implements DataVisualizer>> { @Override public String crawlData(String city) throws CrawlerException { System.out.println( "🌤️ 正在爬取天气:" + city ); String rawData = doHttpGet(getApiUrl(city)); rawDataMap.put(city, rawData); return rawData; } @Override public Map parseData( String rawData ) { Map result = new HashMap<>(); List temps = new ArrayList<>(); Matcher matcher = Pattern.compile( "\"temperature_2m\":\\[([^\\]]+)]" ).matcher(rawData); if (matcher.find()) { String[] arr = matcher.group(1).split(","); for (String s : arr) { try { temps.add( Double.parseDouble( s.trim() ) ); } catch (Exception ignored) { } } } result.put("temps", temps); return result; } @Override public void saveData( String city, Map data ) throws CrawlerException { parsedDataMap.put(city, data); try { saveStrategy.save( city + "_weather.txt", data.toString() ); } catch (IOException e) { throw new CrawlerException( "天气保存失败", e ); } } @Override protected String getApiUrl(String city) { String[] latLon = CityCoordinateUtil.CITY_MAP.get(city); return String.format( "https://api.open-meteo.com/v1/forecast?" + "latitude=%s&longitude=%s" + "&hourly=temperature_2m", latLon[0], latLon[1] ); } @Override public void generateVisualization( String city, Map> data ) { ChartUtil.drawLineChart( city + " 温度折线图", (List) data.get("temps"), "data/" + city + "_weather.png", Color.RED ); } } /* ========================================================== 7. 空气质量爬虫 ========================================================== */ class AirQualityCrawler extends AbstractDataCrawler implements DataVisualizer>> { @Override public String crawlData(String city) throws CrawlerException { System.out.println( "🌫️ 正在爬取AQI:" + city ); String rawData = doHttpGet(getApiUrl(city)); rawDataMap.put(city, rawData); return rawData; } @Override public Map parseData( String rawData ) { Map result = new HashMap<>(); Matcher matcher = Pattern.compile("\"aqi\":(\\d+)") .matcher(rawData); int aqi = matcher.find() ? Integer.parseInt( matcher.group(1) ) : -1; List list = new ArrayList<>(); list.add((double) aqi); result.put("aqi", list); return result; } @Override public void saveData( String city, Map data ) throws CrawlerException { parsedDataMap.put(city, data); try { saveStrategy.save( city + "_aqi.txt", data.toString() ); } catch (IOException e) { throw new CrawlerException( "AQI保存失败", e ); } } @Override protected String getApiUrl(String city) { String[] latLon = CityCoordinateUtil.CITY_MAP.get(city); return "https://api.waqi.info/feed/geo:" + latLon[0] + ";" + latLon[1] + "/?token=demo"; } @Override public void generateVisualization( String city, Map> data ) { ChartUtil.drawBarChart( city + " AQI柱状图", (List) data.get("aqi"), "data/" + city + "_aqi.png", Color.BLUE ); } } /* ========================================================== 8. 日出日落爬虫 ========================================================== */ class SunriseCrawler extends AbstractDataCrawler implements DataVisualizer>> { @Override public String crawlData(String city) throws CrawlerException { System.out.println( "🌅 正在爬取日出日落:" + city ); String rawData = doHttpGet(getApiUrl(city)); rawDataMap.put(city, rawData); return rawData; } @Override public Map parseData( String rawData ) { Map result = new HashMap<>(); Matcher sunriseMatcher = Pattern.compile( "\"sunrise\":\"([^\"]+)\"" ).matcher(rawData); Matcher sunsetMatcher = Pattern.compile( "\"sunset\":\"([^\"]+)\"" ).matcher(rawData); String sunrise = sunriseMatcher.find() ? sunriseMatcher.group(1) : ""; String sunset = sunsetMatcher.find() ? sunsetMatcher.group(1) : ""; double duration = calculateHours( sunrise, sunset ); List list = new ArrayList<>(); list.add(duration); result.put("duration", list); return result; } private double calculateHours( String sunrise, String sunset ) { try { int sunriseHour = Integer.parseInt( sunrise.substring(11, 13) ); int sunsetHour = Integer.parseInt( sunset.substring(11, 13) ); return sunsetHour - sunriseHour; } catch (Exception e) { return 0; } } @Override public void saveData( String city, Map data ) throws CrawlerException { parsedDataMap.put(city, data); try { saveStrategy.save( city + "_sunrise.txt", data.toString() ); } catch (IOException e) { throw new CrawlerException( "日出日落保存失败", e ); } } @Override protected String getApiUrl(String city) { String[] latLon = CityCoordinateUtil.CITY_MAP.get(city); return "https://api.sunrise-sunset.org/json?" + "lat=" + latLon[0] + "&lng=" + latLon[1] + "&formatted=0"; } @Override public void generateVisualization( String city, Map> data ) { ChartUtil.drawBarChart( city + " 日照时长图", (List) data.get("duration"), "data/" + city + "_sunrise.png", Color.ORANGE ); } } /* ========================================================== 9. 湿度爬虫 ========================================================== */ class HumidityCrawler extends AbstractDataCrawler implements DataVisualizer>> { @Override public String crawlData(String city) throws CrawlerException { System.out.println( "💧 正在爬取湿度:" + city ); String rawData = doHttpGet(getApiUrl(city)); rawDataMap.put(city, rawData); return rawData; } @Override public Map parseData( String rawData ) { Map result = new HashMap<>(); List humidityList = new ArrayList<>(); Matcher matcher = Pattern.compile( "\"relativehumidity_2m\":\\[([^\\]]+)]" ).matcher(rawData); if (matcher.find()) { String[] arr = matcher.group(1).split(","); for (String s : arr) { try { humidityList.add( Double.parseDouble( s.trim() ) ); } catch (Exception ignored) { } } } result.put("humidity", humidityList); return result; } @Override public void saveData( String city, Map data ) throws CrawlerException { parsedDataMap.put(city, data); try { saveStrategy.save( city + "_humidity.txt", data.toString() ); } catch (IOException e) { throw new CrawlerException( "湿度保存失败", e ); } } @Override protected String getApiUrl(String city) { String[] latLon = CityCoordinateUtil.CITY_MAP.get(city); return String.format( "https://api.open-meteo.com/v1/forecast?" + "latitude=%s&longitude=%s" + "&hourly=relativehumidity_2m", latLon[0], latLon[1] ); } @Override public void generateVisualization( String city, Map> data ) { ChartUtil.drawLineChart( city + " 湿度折线图", (List) data.get("humidity"), "data/" + city + "_humidity.png", Color.CYAN ); } } /* ========================================================== 10. 风速风向爬虫 ========================================================== */ class WindCrawler extends AbstractDataCrawler implements DataVisualizer>> { @Override public String crawlData(String city) throws CrawlerException { System.out.println( "💨 正在爬取风速风向:" + city ); String rawData = doHttpGet(getApiUrl(city)); rawDataMap.put(city, rawData); return rawData; } @Override public Map parseData( String rawData ) { Map result = new HashMap<>(); List windList = new ArrayList<>(); Matcher matcher = Pattern.compile( "\"windspeed_10m\":\\[([^\\]]+)]" ).matcher(rawData); if (matcher.find()) { String[] arr = matcher.group(1).split(","); for (String s : arr) { try { windList.add( Double.parseDouble( s.trim() ) ); } catch (Exception ignored) { } } } result.put("wind", windList); return result; } @Override public void saveData( String city, Map data ) throws CrawlerException { parsedDataMap.put(city, data); try { saveStrategy.save( city + "_wind.txt", data.toString() ); } catch (IOException e) { throw new CrawlerException( "风速保存失败", e ); } } @Override protected String getApiUrl(String city) { String[] latLon = CityCoordinateUtil.CITY_MAP.get(city); return String.format( "https://api.open-meteo.com/v1/forecast?" + "latitude=%s&longitude=%s" + "&hourly=windspeed_10m", latLon[0], latLon[1] ); } @Override public void generateVisualization( String city, Map> data ) { ChartUtil.drawLineChart( city + " 风速折线图", (List) data.get("wind"), "data/" + city + "_wind.png", Color.MAGENTA ); } } /* ========================================================== 11. 图表工具类(核心修改部分) ========================================================== */ class ChartUtil { // 坐标轴字体样式 private static final Font AXIS_FONT = new Font("微软雅黑", Font.PLAIN, 12); // 刻度间隔(控制标注密度) private static final int X_AXIS_TICK_INTERVAL = 5; private static final int Y_AXIS_TICK_COUNT = 6; public static void drawLineChart( String title, List data, String path, Color color ) { drawChart( title, data, path, color, true ); } public static void drawBarChart( String title, List data, String path, Color color ) { drawChart( title, data, path, color, false ); } private static void drawChart( String title, List data, String path, Color color, boolean lineMode ) { try { int width = 1000; int height = 500; BufferedImage image = new BufferedImage( width, height, BufferedImage.TYPE_INT_RGB ); Graphics2D g = image.createGraphics(); // 开启抗锯齿,让文字和线条更清晰 g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); // 1. 绘制背景 g.setColor(Color.WHITE); g.fillRect(0, 0, width, height); // 2. 绘制标题 g.setColor(Color.BLACK); g.setFont(new Font("微软雅黑", Font.BOLD, 24)); g.drawString(title, 320, 40); // 3. 绘制坐标轴 g.drawLine(80, 400, 900, 400); // X轴 g.drawLine(80, 80, 80, 400); // Y轴 // 4. 计算数据范围 double maxValue = data.stream().mapToDouble(Double::doubleValue).max().orElse(1); double minValue = data.stream().mapToDouble(Double::doubleValue).min().orElse(0); // 保证Y轴从0开始(更符合常规图表逻辑) minValue = Math.min(minValue, 0); double valueRange = maxValue - minValue; // 避免除零 if (valueRange == 0) valueRange = 1; // 5. 绘制坐标轴标注(核心新增逻辑) drawAxisLabels(g, data, minValue, maxValue, valueRange); // 6. 绘制数据图形(折线/柱状图) g.setColor(color); int prevX = 0; int prevY = 0; for (int i = 0; i < data.size(); i++) { int x = 100 + i * 30; // 修正Y轴计算逻辑(适配minValue) int y = (int) (400 - ((data.get(i) - minValue) / valueRange * 250)); if (lineMode) { // 折线图:绘制数据点和连线 g.fillOval(x - 3, y - 3, 6, 6); if (i > 0) { g.drawLine(prevX, prevY, x, y); } prevX = x; prevY = y; } else { // 柱状图:绘制柱子 g.fillRect(x - 10, y, 20, 400 - y); } } // 7. 释放资源并保存图片 g.dispose(); ImageIO.write(image, "png", new File(path)); System.out.println("📈 图表生成成功:" + path); } catch (Exception e) { System.err.println("❌ 图表生成失败:" + e.getMessage()); } } /** * 绘制坐标轴标注(面向对象封装:单一职责) * @param g 绘图上下文 * @param data 原始数据 * @param minValue 数据最小值 * @param maxValue 数据最大值 * @param valueRange 数据范围(max - min) */ private static void drawAxisLabels(Graphics2D g, List data, double minValue, double maxValue, double valueRange) { // 设置坐标轴字体 g.setFont(AXIS_FONT); g.setColor(Color.DARK_GRAY); // ===== 绘制Y轴标注(数值刻度)===== double yTickStep = valueRange / (Y_AXIS_TICK_COUNT - 1); for (int i = 0; i < Y_AXIS_TICK_COUNT; i++) { // 计算刻度值 double tickValue = minValue + (yTickStep * i); // 计算刻度在图片中的Y坐标 int yPos = (int) (400 - (i * 250 / (Y_AXIS_TICK_COUNT - 1))); // 绘制刻度线 g.drawLine(75, yPos, 80, yPos); // 绘制刻度文字(保留1位小数,保证可读性) String label = String.format("%.1f", tickValue); // 文字左对齐,居中显示 FontMetrics fm = g.getFontMetrics(); int textX = 70 - fm.stringWidth(label); int textY = yPos + (fm.getAscent() / 2); g.drawString(label, textX, textY); } // ===== 绘制X轴标注(索引刻度)===== for (int i = 0; i < data.size(); i += X_AXIS_TICK_INTERVAL) { // 计算刻度在图片中的X坐标 int xPos = 100 + i * 30; // 绘制刻度线 g.drawLine(xPos, 400, xPos, 405); // 绘制刻度文字(数据索引) String label = String.valueOf(i); FontMetrics fm = g.getFontMetrics(); int textX = xPos - (fm.stringWidth(label) / 2); int textY = 400 + fm.getAscent() + 5; g.drawString(label, textX, textY); } // ===== 绘制坐标轴名称 ===== g.setFont(new Font("微软雅黑", Font.PLAIN, 14)); // X轴名称(数据点索引) g.drawString("数据点索引", 450, 450); // Y轴名称(数值) // 旋转文字:Y轴名称垂直显示 g.rotate(Math.toRadians(-90), 40, 240); g.drawString("数值", 40, 240); // 恢复旋转角度 g.rotate(Math.toRadians(90), 40, 240); } } /* ========================================================== 12. Command模式 ========================================================== */ interface Command { void execute(); } abstract class AbstractCommand implements Command { protected CrawlerPlatform platform; protected String city; public AbstractCommand( CrawlerPlatform platform, String city ) { this.platform = platform; this.city = city; } } class WeatherCommand extends AbstractCommand { public WeatherCommand( CrawlerPlatform platform, String city ) { super(platform, city); } @Override public void execute() { platform.runCrawler("weather", city); } } class AQICommand extends AbstractCommand { public AQICommand( CrawlerPlatform platform, String city ) { super(platform, city); } @Override public void execute() { platform.runCrawler("aqi", city); } } class SunriseCommand extends AbstractCommand { public SunriseCommand( CrawlerPlatform platform, String city ) { super(platform, city); } @Override public void execute() { platform.runCrawler("sunrise", city); } } class HumidityCommand extends AbstractCommand { public HumidityCommand( CrawlerPlatform platform, String city ) { super(platform, city); } @Override public void execute() { platform.runCrawler("humidity", city); } } class WindCommand extends AbstractCommand { public WindCommand( CrawlerPlatform platform, String city ) { super(platform, city); } @Override public void execute() { platform.runCrawler("wind", city); } } /* ========================================================== 13. 平台控制器 ========================================================== */ class CrawlerPlatform { private final Map> crawlerMap = new HashMap<>(); public void registerCrawler( String type, DataCrawler crawler ) { crawlerMap.put(type, crawler); System.out.println( "✅ 注册爬虫:" + type ); } @SuppressWarnings("unchecked") public void runCrawler( String type, K key ) { try { DataCrawler crawler = (DataCrawler) crawlerMap.get(type); T rawData = crawler.crawlData(key); Map parsedData = crawler.parseData(rawData); crawler.saveData( key, parsedData ); if (crawler instanceof DataVisualizer) { ((DataVisualizer>>) crawler) .generateVisualization( key.toString(), convertMap(parsedData) ); } } catch (Exception e) { System.err.println( "❌ 爬虫执行失败:" + e.getMessage() ); } } private Map> convertMap( Map map ) { Map> result = new HashMap<>(); for (String key : map.keySet()) { if (map.get(key) instanceof List) { result.put( key, (List) map.get(key) ); } } return result; } } /* ========================================================== 14. CLI视图 ========================================================== */ class CLIView { public void showMenu() { System.out.println("\n=============================="); System.out.println(" 城市环境综合数据爬虫平台"); System.out.println("=============================="); System.out.println("1. 天气爬虫"); System.out.println("2. 空气质量爬虫"); System.out.println("3. 日出日落爬虫"); System.out.println("4. 湿度爬虫"); System.out.println("5. 风速风向爬虫"); System.out.println("6. 执行全部功能"); System.out.println("0. 退出系统"); System.out.println("=============================="); } } /* ========================================================== 15. 主程序 ========================================================== */ public class WeatherMain { public static void main(String[] args) { System.out.println( "🚀 系统启动时间:" + LocalDateTime.now() .format( DateTimeFormatter.ofPattern( "yyyy-MM-dd HH:mm:ss" ) ) ); Scanner scanner = new Scanner(System.in); CLIView view = new CLIView(); CrawlerPlatform platform = new CrawlerPlatform(); platform.registerCrawler( "weather", new WeatherCrawler() ); platform.registerCrawler( "aqi", new AirQualityCrawler() ); platform.registerCrawler( "sunrise", new SunriseCrawler() ); platform.registerCrawler( "humidity", new HumidityCrawler() ); platform.registerCrawler( "wind", new WindCrawler() ); List cities = new ArrayList<>( CityCoordinateUtil.CITY_MAP.keySet() ); while (true) { view.showMenu(); System.out.print("请输入操作:"); int choice = scanner.nextInt(); if (choice == 0) { System.out.println( "👋 系统退出" ); break; } switch (choice) { case 1: for (String city : cities) { new WeatherCommand( platform, city ).execute(); } break; case 2: for (String city : cities) { new AQICommand( platform, city ).execute(); } break; case 3: for (String city : cities) { new SunriseCommand( platform, city ).execute(); } break; case 4: for (String city : cities) { new HumidityCommand( platform, city ).execute(); } break; case 5: for (String city : cities) { new WindCommand( platform, city ).execute(); } break; case 6: for (String city : cities) { new WeatherCommand( platform, city ).execute(); new AQICommand( platform, city ).execute(); new SunriseCommand( platform, city ).execute(); new HumidityCommand( platform, city ).execute(); new WindCommand( platform, city ).execute(); } break; default: System.out.println( "⚠️ 输入错误" ); } } scanner.close(); System.out.println( "🎉 系统运行结束" ); } }