使用WebMagic+ActiveMQ+Quartz實現全國城鎮天氣自動更新的API介面開發

zifangsky發表於2019-03-04

一 簡介

我在之前的某個專案中需要用到天氣介面,但是遍觀網上的天氣API要麼是收費的要麼有使用次數或者頻率的限制。因此我決定根據網上的專業天氣網站結合爬蟲技術自己開發一套天氣自動定時抓取更新的API介面

(1)技術依賴:

  • SSM(Spring+Spring MVC+Mybatis):專案基本架構
  • WebMagic:輕量型爬蟲框架,用於抓取每個城鎮的天氣以及抓取免費代理IP
  • ActiveMQ:訊息中介軟體,用於在定時更新全國城鎮天氣時將每個城鎮天氣的更新任務壓入訊息佇列中,之後再使用多個消費者去消費這些訊息(PS:多個訊息消費者同時更新這些城鎮的天氣)
  • Quartz:定時排程,用於設定每隔多久更新一次天氣;每隔多久抓取一次代理IP;每隔多久檢測一下資料庫中的代理IP是否仍然有效
  • Apache CXF:用於對外發布SOAP風格和RESTFul風格的介面

(2)環境依賴:

  • JDK7及以上
  • Tomcat7及以上
  • ActiveMQ-5.14.1及以上
  • MYSQL5及以上

注:專案中使用到的SQL檔案:raw.githubusercontent.com/zifangsky/W…

(3)專案執行:

i)原始碼下載:

全部程式碼已經開源,專案地址是:github.com/zifangsky/W…
PS:希望感興趣的朋友給我來一波star,謝謝!

ii)配置依賴環境:

首先編譯原始碼,生成war包並將war包放到Tomcat中。接著分別啟動執行MYSQL和ActiveMQ

修改配置檔案中對應的MYSQL和ActiveMQ的連線引數以及定時任務的更新頻率,配置檔案的路徑是:src/main/env/dev/

iii)執行專案:

啟動Tomcat之後,根據前面設定的定時任務將會在指定時間開始執行天氣更新任務、代理IP獲取任務以及程式碼IP的可用性檢測任務

(4)RESTful介面:

除了一些基本的SOAP風格的介面之外,我還對外發布了4個RESTful風格的介面。它們分別是:

i)隨機返回一個可用的代理IP:

http://localhost:7080/WeatherSpider/services/rest/proxyIpService/getRandomOne

ii)返回當前所有可用的代理IP:

http://localhost:7080/WeatherSpider/services/rest/proxyIpService/getAll
其效果如下:

使用WebMagic+ActiveMQ+Quartz實現全國城鎮天氣自動更新的API介面開發
當前所有可用代理IP

注:可以在這個網站對json字串進行格式化:json.cn/

iii)根據城鎮CODE返回一個城鎮天氣:

http://localhost:7080/WeatherSpider/services/rest/weatherService/getWeatherByStationCode?stationCode=101060404
其效果如下:

使用WebMagic+ActiveMQ+Quartz實現全國城鎮天氣自動更新的API介面開發
根據CODE查詢天氣詳情

iv)根據城鎮名稱模糊查詢,返回所有匹配的城鎮天氣:

http://localhost:7080/WeatherSpider/services/rest/weatherService/getWeatherByStationName?stationName=朝陽
其效果如下:

使用WebMagic+ActiveMQ+Quartz實現全國城鎮天氣自動更新的API介面開發
根據關鍵字模糊查詢天氣詳情

在上面我介紹了這個天氣API小專案的一些基本情況,以及如何根據原始碼在本地執行,最後還介紹了幾個對外發布的RESTful風格的介面。在接下來的篇幅中我將介紹如何來開發這樣的天氣API以及代理IP池API,感興趣的同學可以跟我繼續往下看下去


二 全國城鎮天氣自動更新API開發

(1)天氣資料來源選取:

要想實現用爬蟲抓取全國城鎮的天氣,那麼我們首先需要選取一個合適的天氣資料來源。在經過了簡單對比之後,我最終選擇了中國天氣網作為我爬蟲的資料來源

隨便查詢一個地區的天氣,我們可以發現它的天氣詳情頁面一般是這種格式:
http://www.weather.com.cn/weather/101010300.shtml

對於後面的那串數字我暫且稱作每個城鎮的CODE,其值為:省CODE+市CODE +縣(區)CODE

關於這個數字是如何組合起來的,接下來我們一起來分析:

開啟這個網站的某個分站,如:http://bj.weather.com.cn/。從頁面可以看出有一個三級聯動的天氣查詢下拉框,根據我以往的經驗這個頁面應該會非同步請求一些資料介面。通過在瀏覽器中按F12之後,觀察Network介面,果然發現了資料請求連結:

使用WebMagic+ActiveMQ+Quartz實現全國城鎮天氣自動更新的API介面開發
天氣資料來源選取

總結一下它的規律就是:

i)全國省、直轄市列表:

http://js.weather.com.cn/data/city3jdata/china.html
其返回值如下:
{"10101":"北京","10102":"上海","10103":"天津","10104":"重慶","10105":"黑龍江","10106":"吉林","10107":"遼寧","10108":"內蒙古","10109":"河北","10110":"山西","10111":"陝西","10112":"山東","10113":"新疆","10114":"西藏","10115":"青海","10116":"甘肅","10117":"寧夏","10118":"河南","10119":"江蘇","10120":"湖北","10121":"浙江","10122":"安徽","10123":"福建","10124":"江西","10125":"湖南","10126":"貴州","10127":"四川","10128":"廣東","10129":"雲南","10130":"廣西","10131":"海南","10132":"香港","10133":"澳門","10134":"臺灣"}

ii)某個省的市級列表:

比如吉林省它的市級列表的請求介面是:http://js.weather.com.cn/data/city3jdata/provshi/10106.html
其返回值如下:
{"01":"長春","02":"吉林","03":"延邊","04":"四平","05":"通化","06":"白城","07":"遼源","08":"松原","09":"白山"}

iii)某個市的縣(區)級列表:

市級CODE為省級CODE 加上上面對應的CODE
比如長春市它的縣級列表的請求介面是:http://js.weather.com.cn/data/city3jdata/station/1010601.html
其返回值如下:
{"01":"長春","02":"農安","03":"德惠","04":"九臺","05":"榆樹","06":"雙陽"}

iv)某個縣(區)天氣詳情:

很顯然,某個縣(區)的天氣詳情的請求地址是:http://www.weather.com.cn/weather/101060101.shtml

關於全國所有縣(區)的CODE 我們根據上面的規律寫一段簡單的程式碼迴圈遍歷即可獲得,最後需要將這些所有獲取到的CODE儲存到資料庫中。每當我們更新全國所有地區天氣時,我們使用爬蟲迴圈請求對應的地址獲取資料並儲存到資料庫中即可

(2)使用webmagic獲取天氣詳情:

關於天氣資料的選擇,我初步選擇了獲取最近7天的天氣以及24小時內分時段的天氣,它們在頁面中的位置如下圖所示:

使用WebMagic+ActiveMQ+Quartz實現全國城鎮天氣自動更新的API介面開發
天氣資料詳情頁面

接下來就是使用webmagic爬蟲框架,採用XPath這種HTML節點定位方式獲取上圖中指定的資料即可

注:關於webmagic的一些基本使用可以參考我之前寫的這篇文章:www.zifangsky.cn/853.html

關鍵部分程式碼如下:

package cn.zifangsky.spider;

import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

import cn.zifangsky.model.WeatherWeather;
import us.codecraft.webmagic.Page;
import us.codecraft.webmagic.Site;
import us.codecraft.webmagic.processor.PageProcessor;
import us.codecraft.webmagic.selector.Html;
import us.codecraft.webmagic.selector.Selectable;

public class WeatherSpider implements PageProcessor{

    private Site site = Site.me().setTimeOut(20000).setRetryTimes(3)
            .setSleepTime(2000).setCharset("UTF-8");

    @Override
    public Site getSite() {
        Set<Integer> acceptStatCode = new HashSet<>();
        acceptStatCode.add(200);
        site = site.setAcceptStatCode(acceptStatCode).addHeader("Accept-Encoding", "/")
                .setUserAgent(UserAgentUtils.radomUserAgent());

        return site;
    }

    @Override
    public void process(Page page) {
        //最近7天天氣
        Selectable sevenStr = page.getHtml().xpath("//div[@id='7d']/ul[@class='t clearfix']");
        //分時段天氣
        Selectable hourStr = page.getHtml().xpath("//div[@id='7d']/script");
        //最近24小時整體情況
//        Selectable t24Str = page.getHtml().xpath("//div[@class='left fl']/script");

        WeatherWeather weather = new WeatherWeather();
        weather.setHour(handleHourStr(hourStr));;

        List<String> list = handleSevenDays(sevenStr);
        if(list != null && list.size() == 7){
            weather.setToday(list.get(0));
            weather.setNextday(list.get(1));
            weather.setNext2day(list.get(2));
            weather.setNext3day(list.get(3));
            weather.setNext4day(list.get(4));
            weather.setNext5day(list.get(5));
            weather.setNext6day(list.get(6));
        }
        page.putField("weather", weather);
        page.putField("stationCode", page.getUrl().regex("(\\d+).shtml",1));
    }

    /**
     * 處理分時段天氣
     * @param hourStr
     * @return 
     */
    private String handleHourStr(Selectable hourStr) {
        String result = hourStr.regex("1d.*?(\\[.*?\\])",1).replace("&quot;", "\"").toString();

        if(result != null){
            return result;
        }else{
            return "";
        }
    }

    /**
     * 處理最近7天天氣
     * @param sevenStr
     * @return 最近7天天氣格式化之後的集合
     */
    private List<String> handleSevenDays(Selectable sevenStr) {
        List<String> sevenDays = sevenStr.xpath("//ul[@class='t clearfix']/li").all();
        List<String> result = new ArrayList<>();

        if(sevenDays != null && sevenDays.size() > 0){
            for(String day : sevenDays){
                Html temp = Html.create(day);
                StringBuffer stringBuffer = new StringBuffer();
                stringBuffer.append(temp.xpath("//h1/text()").toString());
                stringBuffer.append("," + temp.xpath("//p[@class='wea']/text()").toString());
                stringBuffer.append("," + temp.xpath("//p[@class='tem']/allText()").toString());

                List<String> windList = temp.xpath("//p[@class='win']/em/span").all();
                String windStr = ",";
                if(windList !=null && windList.size() > 0){
                    for(String win : windList){
                        Html winHtml = Html.create(win);
                        windStr = windStr + winHtml.xpath("//span/@title") + "/";
                    }
                }
                stringBuffer.append(windStr.substring(0, windStr.length()-1));
                stringBuffer.append("," + temp.xpath("//p[@class='win']/i/text()").toString());

                result.add(stringBuffer.toString());
            }
        }

        return result;
    }
}複製程式碼

在獲取到需要的資料之後,接下來做的工作就是資料的持久化。根據資料庫中是否存在該城市的天氣資料來決定是插入還是更新天氣資料:

package cn.zifangsky.spider;

import javax.annotation.Resource;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import cn.zifangsky.manager.WeatherStationManager;
import cn.zifangsky.manager.WeatherWeatherManager;
import cn.zifangsky.model.WeatherWeather;
import us.codecraft.webmagic.ResultItems;
import us.codecraft.webmagic.Task;
import us.codecraft.webmagic.pipeline.Pipeline;

/**
 * 自定義Pipeline處理抓取的資料
 * @author zifangsky
 *
 */
@Component("customPipeline")
public class CustomPipeline implements Pipeline {
    @Autowired
    private WeatherStationManager stationManager;

    @Resource(name="weatherWeatherManager")
    private WeatherWeatherManager weatherManager;

    /**
     * 儲存資料
     */
    @Override
    public void process(ResultItems resultItems, Task task) {
        WeatherWeather weather = resultItems.get("weather");
        Long stationId = stationManager.selectIdByCode(resultItems.get("stationCode").toString());
        if(stationId != null){
            weather.setStationId(stationId);
            WeatherWeather oldWeather = weatherManager.selectByStationId(stationId);
            if(oldWeather == null){
                weatherManager.insertSelective(weather);
            }else{
                weather.setId(oldWeather.getId());
                weatherManager.updateByPrimaryKeySelective(weather);
            }

        }

    }

}複製程式碼

注:到了這一步我們就可以寫一個簡單的單元測試來測試上面的爬蟲程式碼是否如我們預期那樣抓取到了指定的資料:

package cn.zifangsky.test.spider;

import javax.annotation.Resource;

import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;

import cn.zifangsky.manager.CrawlManager;
import cn.zifangsky.spider.CustomPipeline;
import cn.zifangsky.spider.WeatherSpider;
import us.codecraft.webmagic.model.OOSpider;

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(locations={"classpath:context/context.xml","classpath:context/context_activemq.xml"})
public class TestSpider{
    @Resource(name="crawlManager")
    private CrawlManager crawlManager;

    @Resource(name="customPipeline")
    private CustomPipeline customPipeline;

    @Test
    public void testWeatherCrawl(){
        OOSpider.create(new WeatherSpider()).addPipeline(customPipeline)
        .addUrl("http://www.weather.com.cn/weather/101060101.shtml")
        .thread(1)
        .run();
        crawlManager.weatherCrawl("101060101");
    }

}複製程式碼

為了方便後面使用非同步訊息更新全國所有城市的天氣,因此我將上面的爬蟲程式碼封裝成了一個天氣獲取方法:

package cn.zifangsky.manager;

public interface CrawlManager {

    /**
     * 天氣爬蟲
     * @param stationCode 縣城(區)的CODE
     */
    public void weatherCrawl(String stationCode);

    ...
}複製程式碼

其實現類是:

package cn.zifangsky.manager.impl;

import javax.annotation.Resource;

import org.springframework.stereotype.Service;

import cn.zifangsky.manager.CrawlManager;
import cn.zifangsky.spider.CustomPipeline;
import cn.zifangsky.spider.WeatherSpider;
import us.codecraft.webmagic.model.OOSpider;

@Service("crawlManager")
public class CrawlManagerImpl implements CrawlManager {

    @Resource(name="customPipeline")
    private CustomPipeline customPipeline;

    ...

    @Override
    public void weatherCrawl(String stationCode) {
        OOSpider.create(new WeatherSpider()).addPipeline(customPipeline)
        .addUrl("http://www.weather.com.cn/weather/" + stationCode + ".shtml")
        .thread(1)
        .run();
    }

    ...
}複製程式碼

(3)使用非同步訊息更新全國所有城鎮天氣:

在上面的程式碼中我們實現瞭如何抓取指定城鎮的天氣並實現其資料的持久化。那麼當我們設定定時任務後,需要在一天的指定時間點更新全國所有城鎮的天氣該怎麼做呢?

在這裡一個很容易想到的思路是:首先從資料庫中取出所有的城鎮CODE,接著遍歷呼叫上面的天氣更新方法逐個更新每個城鎮的天氣。但是從中國天氣網獲取到的全國城鎮一共有2600多個,如果使用單執行緒逐個更新的話那將變得非常緩慢。相反,使用ActiveMQ將每個城鎮的天氣更新指令當做一個訊息,通過訊息佇列的形式不僅可以指定每個消費者的併發數同時還可以同時部署多個消費者形成訊息佇列叢集

注:

  • 要想建立訊息佇列叢集需要將我程式碼中的定時排程和訊息佇列這兩個功能點分開到兩個不同專案中才行
  • 關於ActiveMQ相關的基本用法可以參考我之前的這篇文章:www.zifangsky.cn/815.html

天氣更新生產者程式碼:

package cn.zifangsky.activemq.producer;

import javax.annotation.Resource;

import org.springframework.jms.core.JmsTemplate;
import org.springframework.stereotype.Component;

@Component("weatherUpdateSender")
public class WeatherUpdateSender {

    @Resource(name="jmsQueueTemplate")
    private JmsTemplate jmsTemplate;

    /**
     * 城鎮天氣更新傳送者
     * 向接收者傳送需要更新的城鎮天氣的stationCode
     * @param queueName 天氣更新佇列的名稱
     * @param stationCode 城鎮程式碼
     */
    public void updateWeather(String queueName,final String stationCode){
        jmsTemplate.convertAndSend(queueName, stationCode);
    }
}複製程式碼

天氣更新消費者程式碼:

package cn.zifangsky.activemq.consumer;

import javax.annotation.Resource;

import org.springframework.stereotype.Component;

import cn.zifangsky.manager.CrawlManager;

@Component("weatherUpdateReceiver")
public class WeatherUpdateReceiver{
    @Resource(name="crawlManager")
    private CrawlManager crawlManager;

    /**
     * 接收訊息並處理
     * @param stationCode
     */
    public void handle(String stationCode){
        //更新天氣
        crawlManager.weatherCrawl(stationCode);
    }

}複製程式碼

注:對應的activeMQ的配置檔案可以參考這裡:github.com/zifangsky/W…

(4)使用Quartz控制全國城鎮天氣更新頻率:

關鍵程式碼是:

package cn.zifangsky.job;

import java.text.Format;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.List;

import javax.annotation.Resource;

import org.apache.log4j.Logger;
import org.quartz.JobExecutionContext;
import org.quartz.JobExecutionException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.quartz.QuartzJobBean;

import cn.zifangsky.activemq.producer.WeatherUpdateSender;
import cn.zifangsky.mapper.WeatherStationMapper;
import cn.zifangsky.model.WeatherStation;

/**
 * 天氣定時更新任務
 * @author zifangsky
 *
 */
public class WeatherUpdateJob extends QuartzJobBean{
    private static Logger logger = Logger.getLogger(WeatherUpdateJob.class);

    @Value("${activemq.queue.weather}")
    private String weatherQueueName;

    @Resource(name="weatherUpdateSender")
    private WeatherUpdateSender weatherUpdateSender;

    @Autowired
    WeatherStationMapper weatherStationMapper;

    @Override
    protected void executeInternal(JobExecutionContext jobExecutionContext) throws JobExecutionException {
        Date current = new Date();
        Format format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        System.out.println("開始執行天氣定時更新任務,Date:" + format.format(current));
        logger.debug("開始執行天氣定時更新任務,Date: " + format.format(current));

        List<WeatherStation> list = weatherStationMapper.selectAll();
        if(list != null && list.size() > 0){
            for(WeatherStation station : list){
                weatherUpdateSender.updateWeather(weatherQueueName, station.getCode());
            }

        }
    }

}複製程式碼

注:關於Quartz的基本用法可以參考我之前的這篇文章:www.zifangsky.cn/846.html

(5)對外發布webservice介面:

關於Apache CXF實現webservice的基本用法可以參考我之前的這兩篇文章:

這裡的關鍵程式碼是:

package cn.zifangsky.webservice;

import java.util.List;

import javax.jws.WebMethod;
import javax.jws.WebParam;
import javax.jws.WebService;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;
import javax.ws.rs.core.MediaType;
import javax.xml.ws.Holder;

import org.springframework.context.annotation.Scope;

import cn.zifangsky.common.PageInfo;
import cn.zifangsky.model.WeatherWeather;
import cn.zifangsky.model.bo.WeatherWeatherBO;

@Scope("prototype")
@WebService
public interface WeatherWeatherService {
    @WebMethod
    public int deleteByPrimaryKey(@WebParam(name="id") Long id);

    @WebMethod
    public int insert(@WebParam(name="weather") WeatherWeather weather);

    @WebMethod
    public int insertSelective(@WebParam(name="weather") WeatherWeather weather);

    @WebMethod
    public WeatherWeather selectByPrimaryKey(@WebParam(name="id") Long id);

    @WebMethod
    public int updateByPrimaryKeySelective(@WebParam(name="weather") WeatherWeather weather);

    @WebMethod
    public int updateByPrimaryKey(@WebParam(name="weather") WeatherWeather weather);
    /**
     * 查詢資料總數
     * @return
     */
    @WebMethod
    public Long findAllCount(@WebParam(name="weather") WeatherWeather weather);

    /**
     * 分頁查詢
     * @param pageInfo
     * @param city
     * @return
     */
    @WebMethod
    public List<WeatherWeather> findAll(@WebParam(name="pageInfoHolder",mode=WebParam.Mode.INOUT) Holder<PageInfo> pageInfoHolder,@WebParam(name="weather") WeatherWeather weather);

    /**
     * 通過城鎮Code查詢天氣
     * @param stationCode
     * @return
     */
    @WebMethod
    public WeatherWeatherBO selectByStationCode(@WebParam(name="stationCode") String stationCode);

    /**
     * 通過城鎮名字查詢天氣(模糊查詢)
     * @param stationName
     * @return
     */
    @WebMethod
    public List<WeatherWeatherBO> selectByStationName(@WebParam(name="stationName") String stationName);

    /**
     * 通過城鎮Code查詢天氣,RESTful介面
     * @param stationCode
     * @return
     */
    @GET
    @Path("/getWeatherByStationCode")
    @Produces(MediaType.APPLICATION_JSON)
    public WeatherWeatherBO selectByStationCodeRest(@QueryParam("stationCode") String stationCode);

    /**
     * 通過城鎮名字查詢天氣(模糊查詢),RESTful介面
     * @param stationName
     * @return
     */
    @GET
    @Path("/getWeatherByStationName")
    @Produces(MediaType.APPLICATION_JSON)
    public List<WeatherWeatherBO> selectByStationNameRest(@QueryParam("stationName") String stationName);
}複製程式碼

它對應的實現類略,請自行參考原始碼


三 代理IP池API開發

關於免費代理IP的獲取我選擇了兩個資料來源,它們分別是:

當然,後面具體的程式碼實現過程其實是跟上面的天氣API開發過程是差不多的,因此我這裡就不多說了,需要自己嘗試的同學可以根據上面的實現思路自行參考原始碼即可

相關文章