极简数据抓取教程:山水济南,Say "I love you" with data

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接: https://blog.csdn.net/xufive/article/details/99722362

1. 前言

济南,始终是一座不温不火、慢慢腾腾的城市,一如生活在她怀抱中的市井百姓:闲适、从容。也有人说她土气、落后,但我始终觉得,她很美,而且美得独一无二,美得沁人心脾。作为北方城市,济南有山、有水、有泉,背靠黄河,有深厚的历史和文化底蕴。生活在这座城市,我很荣幸。

空闲时间,我喜欢逛逛济南的大街小巷、看看济南的山山水水。曾经拍了很多的照片,也写过赞美她的诗。
在这里插入图片描述在这里插入图片描述在这里插入图片描述

你的美
弥散在清晨护城河氤氲的水面上
附着在午后玛瑙泉缓缓升起的气泡里

你的美
回响在兴国禅寺的暮鼓晨钟里
飘荡在百年教堂的穹顶钟楼上

你的美
渲染了九如山漫山的红叶
点亮了七星台璀璨的星空

五龙潭的樱花,是你如花的笑靥
百花洲的垂柳,是你妙曼的身姿
长河落日,是你无尽的爱
鹊华烟雨,是你温柔的吻

我将,深深地
永远地,爱你

但是,作为程序员,我觉得还是应该用数据对她说出我的爱。本文完整演示了从济南市城乡水务局网站爬取历年来趵突泉、黑虎泉地下水位数据,并绘制出水位变化曲线。全部代码涉及到sqlite、optparse、Requests、datetime、lxml、re、numpy、matplotlib等众多模块的使用, 希望对Python初学者有一点裨益。

2. 数据源协议分析

这是提供济南市城乡水务局地下水位数据的网站,借助于FireFox提供的网络分析工具,我们很容易搞明白抓取数据的url和method,以及请求头和发送的数据,还可以查看应答的数据格式。详见下面的截图。

在这里插入图片描述在这里插入图片描述

3. 使用轻量型数据库Sqlite存取数据

我选择使用Sqlite来保存数据,并提供数据查询服务。数据表只需要一个,结构很简单,只要日期、趵突泉水位、黑虎泉水位三个字段就OK。创建数据库连接对象的时候,构造函数会先检测数据库文件是否存在,如果不存在,则在连接之后,先调用建表方法_create_table(),创建数据表。我把全部的数据库代码贴在下面,看官可以复制并以 waterdb.py 为名保存成文件。

waterdb.py

import os
import sqlite3

class WaterDB:
    """水位数据库"""
    
    def __init__(self):
        """构造函数"""

        fn_db = 'spring.db'
        is_db = os.path.exists(fn_db)
        
        self._conn = sqlite3.connect(fn_db)
        self._cur = self._conn.cursor()
        if not is_db:
            self._create_table()

    def _create_table(self):
        """创建表spring,共3个字段:date(日期)、bt(趵突泉水位)、hh(黑虎泉水位)"""

        sql = '''CREATE TABLE spring(
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                date DATE,
                bt REAL,
                hh REAL
              )'''
        
        self._execute(sql)
        self._conn.commit()

    def _execute(self, sql, args=()):
        """运行SQ语句"""
        
        if isinstance(args, list):  # 批量执行SQL语句,此时parameter是list,其元素是tuple
            self._cur.executemany(sql, args)
        else:  # 单次执行SQL语句,此时parameter是tuple或者None
            self._cur.execute(sql, args)
            
        if sql.split()[0].upper() != 'SELECT':  # 非select语句,则自动执行commit()
            self._conn.commit()
        
        return self._cur.fetchall()

    def close(self):
        """关闭数据库连接"""

        self._cur.close()
        self._conn.close()

    def append(self, data):
        """插入水位数据"""

        sql = 'INSERT INTO spring (date, bt, hh) values (?, ?, ?)'
        self._execute(sql, data)
        
    def dedup(self):
        """去除各个字段完全重复的数据,只保留id最小的记录"""
        
        self._execute('delete from spring where id not in(select min(id) from spring group by date, bt, hh)')
    
    def rectify(self, err_list):
        """更新已知的日期错误"""

        for item in err_list:
           if item[3]:
               sql = 'update spring set date=? where date=? and bt=? and hh=?'
               self._execute(sql, (item[3], item[0], item[1], item[2]))
           else:
               sql = 'delete from spring where date=? and bt=? and hh=?'
               self._execute(sql, (item[0], item[1], item[2]))
    
    def fill(self, missing_list):
        """补缺"""
        
        for item in missing_list:
            res = self._execute('select * from spring where date=? and bt=? and hh=?', (item[0], item[1], item[2]))
            if not res:
                sql = 'insert into spring (date, bt, hh) values (?, ?, ?)'
                self._execute(sql, item)
    
    def stat(self):
        """统计信息:数据总数、最早数据日期、最新数据日期"""

        total = 0
        date_first = None
        date_last = None
        
        res = self._execute('select date from spring order by date')
        if res:
            total = len(res)
            date_first = res[0][0]
            date_last = res[-1][0]
            
        return total, date_first, date_last

    def get_data(self, date1, date2=None):
        """取得指定日期或日期范围的水位数据"""
        
        if date2:
            return self._execute('select * from spring where date>=? and date<=? order by date', (date1, date2))
        else:
            return self._execute('select * from spring where date= ?', (date1,))

if __name__ == '__main__':
    pass

3. 数据抓取函数

本项目用到了很多模块,导入方式先一并写在这里,后面就不逐一导入了。

import re, optparse
import requests
from datetime import datetime, timedelta
from lxml import etree

import numpy as np
import matplotlib.dates as mdates
import matplotlib.pyplot as plt
import matplotlib.ticker as ticker

from waterdb import WaterDB

plt.rcParams['font.sans-serif'] = ['FangSong']  # 设置默认字体
plt.rcParams['axes.unicode_minus'] = False  # 解决保存图像时'-'显示为方块的问题

我选择使用requests模块完成抓取。Requests 是用Python语言编写的,基于 urllib,但比 urllib 更加方便,也更加 pythonic。下面这个抓取函数,每次抓取45条数据,只要传递一个从最新数据起始的编号,就返回从该编号开始的45天的水位数据的xml文本。

def spider(id):
    """抓取单页水位数据,返回html文本"""
    
    html = requests.post(
        url = 'http://jnwater.jinan.gov.cn/module/web/jpage/dataproxy.jsp?startrecord=%d&endrecord=%d&perpage=15'%(id, id+45),
        headers = {'User-Agent': 'Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 6.1)'},
        data = {
            'col': '1',
            'appid': '1',
            'webid': '23',
            'path': '/',
            'columnid': '22802',
            'sourceContentType': '1',
            'unitid': '56254',
            'permissiontype': '0'
        }
    )
    
    return html.text

4. 数据解析函数

数据解析函数使用了lxml 解析模块。因为下载下来的原始数据不规范(有的把"月"写成了“目”,“日”写成了“曰”,甚至缺失),提取水位数据时,使用了正则表达式。

def parse_html(html):
    """解析html文本,返回解析结果"""

    parse_html = etree.HTML(html)
    items = parse_html.xpath('/html/body/datastore/recordset/record')
    
    data = list()
    p_date = re.compile(r'(\d{4})\D+(\d{1,2})\D+(\d{1,2})')  # 匹配年月日数字部分的正则表达式
    for item in items:
        date, bt, hh = item.xpath('string(.)').strip().split('  ')[::2]
        year, month, day = p_date.findall(date)[0]
        date = '%s-%02d-%02d'%(year, int(month), int(day))
        bt, hh = bt[:-1].replace(',','.'), hh[:-1].replace(',','.')
        try:
            bt, hh = float(bt[:5]), float(hh[:5])
            data.append((date, bt, hh))
        except:
            pass

    return data

5. 抓取全部数据

下面这个函数的功能是:抓取数据至指定日期,并解析入库。grab() 需要两个参数:water_db 是数据库连接对象,deadline表示抓取截止日期。网站的最早数据日期是2012年5月2日,首次抓取时,deadline = ‘2012-05-02’,当补齐数据时,deadline可以指定为数据库已有的最新数据日期。

def grab(water_db, deadline):
    """抓取数据,每次一页(45条),直到页内包含截止日期"""
    
    id = 1
    flag = True
    while flag:
        html = spider(id)
        data = parse_html(html)
        water_db.append(data)
        if deadline in [item[0] for item in data]:
            flag = False
        
        id += 45
        print('.', end='', flush=True)
    
    print()

6. 数据检查清洗

这个网站的数据有不少错误,有缺失,也有重复,因此数据抓取抓取完成后,需要对数据做清洗。有时候,我们也需要对数据的连续性做检查。我把这些功能封装在了一个函数里面。

def spring_verify(water_db):
    """数据检查"""

    # 去除重复数据
    water_db.dedup()
    
    # 更新已知的日期错误
    err_list = [
        ('2010-10-10', 28.22, 28.17, '2017-10-10'),
        ('2012-01-31', 28.57, 28.51, '2013-01-31'),
        ('2012-12-03', 28.88, 28.86, '2016-12-03'),
        ('2012-12-30', 28.68, 28.66, '2016-12-30'),
        ('2014-09-09', 28.24, 28.18, '2015-09-09'),
        ('2015-02-25', 27.99, 27.92, '2018-02-25'),
        ('2016-02-17', 28.50, 28.46, '2017-02-17'),
        ('2016-06-03', 28.09, 28.02, '2014-06-03'),
        ('2017-02-14', 27.98, 27.91, '2018-02-14'),
        ('2017-07-19', 28.25, 28.20, None)
    ]
    water_db.rectify(err_list)
    
    # 补缺
    missing_list = [
        ('2014-03-11', 28.52, 28.42),
        ('2016-11-05', 28.95, 28.96),
        ('2016-11-22', 28.90, 28.89),
        ('2017-03-29', 28.09, 28.03)
    ]
    water_db.fill(missing_list)
    
    # 数据检查
    lost_list = list()  # 数据缺失记录
    repeat_dict = dict()  # 数据重复记录
    
    total, date_first, date_last = water_db.stat()  # 数据总数、最早数据日期、最新数据日期
    if date_first and date_last:
        date_start = datetime.strptime(date_first, '%Y-%m-%d')
        date_stop = datetime.strptime(date_last, '%Y-%m-%d')
        
        while date_start <= date_stop:
            date = date_start.strftime('%Y-%m-%d')
            result = water_db.get_data(date)
            if len(result) == 0:  # 数据缺失
                lost_list.append(date)
            elif len(result) > 1:  # 数据重复
                repeat_dict.update({date: [(item[2],item[3]) for item in result]})
            
            date_start += timedelta(days=1)
    
    print('------------------------------------------')
    print(u'*** 数据检查报告 ***')
    print('------------------------------------------')
    print(u' * 数据总数:%d条'%total)
    if date_first and date_last:
        print(u' * 最早日期: %s'%date_first)
        print(u' * 最新日期: %s'%date_last)
    print(u' * 缺失数据:%d条'%len(lost_list))
    for item in lost_list[:15]:
        print(u'   - %s'%item)
    if len(lost_list) > 15:
        print(u'   - ...')
    print(u' * 重复数据:%d天'%len(repeat_dict))
    for date in repeat_dict:
        print(u'   - %s: '%date)
        for item in repeat_dict[date]:
            try:
                print(u'     > %.02f, %.02f'%(item[0], item[1]))
            except:
                print(date, item)
    print()

7. 数据可视化

数据可视化,稍微麻烦一点。我设计的功能是这样的:根据给出的日期范围,绘制水位变化曲线。如果日期范围不超过一年,还可以同时绘制历史同期数据。这个工作分成两个函数,一个处理数据,一个使用matplotlib绘图。

处理数据函数需要4个参数:数据库连接对象、开始日期、截止日期、是否需要历史同期数据。

def get_plot_data(water_db, start, stop, history):
    """取得绘图数据"""
    
    # 数据日期范围:start_date ~ stop_date
    total, date_first, date_last = water_db.stat()  # 数据总数、最早数据日期、最新数据日期
    start_date = datetime.strptime(start, '%Y%m%d') if options.start else datetime.strptime(date_first, '%Y-%m-%d')
    stop_date = datetime.strptime(stop, '%Y%m%d') if options.end else datetime.strptime(date_last, '%Y-%m-%d')
    total_days = (stop_date-start_date).days + 1
    
    # 日期序列:result['date']
    result = dict()
    result.update({'date': [start_date+timedelta(days=i) for i in range(total_days)]})
    result.update({'line': list()})
    
    # 判断是否包含2月29日
    leap = 0
    for d in result['date']:
        if d.month == 2 and d.day == 29:
            leap = 1
    
    # 确定是否需要历史同期数据
    if total_days > (365 + leap):  # 日期范围超过一年,则忽略历史同期
        history = 0
    
    # 以日期序列的年份作为名称
    start_y, stop_y = start_date.year, stop_date.year
    name = '%d'%start_y if start_y == stop_y else '%d-%d'%(start_y, stop_y)
    
    # 取得数据日期范围内的数据
    d, bt, hh = list(), list(), list()
    for item in water_db.get_data(start_date.strftime('%Y-%m-%d'), stop_date.strftime('%Y-%m-%d')):
        d.append(item[1])
        bt.append(item[2])
        hh.append(item[3])
    
    # 水位数据对齐日期序列,无数据则补np.nan
    a = [0 for i in range((datetime.strptime(d[0], '%Y-%m-%d')-start_date).days)]
    b = [0 for i in range((stop_date-datetime.strptime(d[-1], '%Y-%m-%d')).days)]
    bt, hh = np.array(a+bt+b), np.array(a+hh+b)
    bt[bt==0] = np.nan
    hh[hh==0] = np.nan
    
    result['line'].append({'name':name, 'bt':bt, 'hh':hh})
    
    # 取得历史同期数据
    for i in range(history):
        start_y, start_m, start_d = start_date.year-i-1, start_date.month, start_date.day
        stop_y, stop_m, stop_d = stop_date.year-i-1, stop_date.month, stop_date.day
        star_str, stop_str = '%d-%02d-%02d'%(start_y,start_m,start_d), '%d-%02d-%02d'%(stop_y,stop_m,stop_d)
        start_d = datetime.strptime(star_str, '%Y-%m-%d')
        stop_d = datetime.strptime(stop_str, '%Y-%m-%d')
        
        if stop_str < '2012-05-02':
            break
        
        name = '%d'%start_y if start_y == stop_y else '%d-%d'%(start_y, stop_y)
        days = (stop_d-start_d).days + 1
        
        d, bt, hh = list(), list(), list()
        for item in water_db.get_data(star_str, stop_str):
            d.append(item[1])
            bt.append(item[2])
            hh.append(item[3])
        
        if days > total_days:  # 历史同期范围内有2月29日,则需要剔除该日
            leap = False 
            for i in range(len(d)):
                if '-02-29' in d[i]:
                    leap = True
                    break
            if leap:
                d.pop(i)
                bt.pop(i)
                hh.pop(i)
            
        elif days < total_days:  # 日期序列内有2月29日,则历史同期需要在对应位置插入一个nan
            leap = False 
            for i in range(1, len(d)):
                if '-03-01' in d[i]:
                    leap = True
                    break
            if leap:
                d.insert(i, '')
                bt.insert(i, 0)
                hh.insert(i, 0)
        
        y0, m0, d0 = d[0].split('-')
        y1, m1, d1 = d[-1].split('-')
        
        d0 = datetime.strptime('%d-%s-%s'%(start_date.year, m0, d0), '%Y-%m-%d')
        d1 = datetime.strptime('%d-%s-%s'%(stop_date.year, m1, d1), '%Y-%m-%d')
        
        a = [0 for i in range((d0-start_date).days)]
        b = [0 for i in range((stop_date-d1).days)]
        bt, hh = np.array(a+bt+b), np.array(a+hh+b)
        bt[bt==0] = np.nan
        hh[hh==0] = np.nan
        
        result['line'].append({'name':name, 'bt':bt, 'hh':hh})
    
    return result

绘图函数使用 get_plot_data() 返回的数据绘图。当需要绘制历史同期时,默认只使用黑虎泉水位数据(mode=True),若mode为False,则使用趵突泉水位数据。

def plot(data, mode):
    """绘图"""
    
    plt.figure('WaterLevel', facecolor='#f4f4f4', figsize=(15, 8))
    plt.title(u'济南地下水位变化曲线图', fontsize=20)
    plt.grid(linestyle=':')
    plt.annotate(u'单位:米', xy=(0,0), xytext=(0.1,0.9), xycoords='figure fraction')
    
    plt.gca().xaxis.set_major_formatter(mdates.DateFormatter('%Y-%m-%d'))
    plt.gca().xaxis.set_major_locator(ticker.MultipleLocator(len(data['date'])/20))
    
    if len(data['line']) == 1:
        plt.plot(data['date'], data['line'][0]['bt'], color='#ff7f0e', label=u'趵突泉')
        plt.plot(data['date'], data['line'][0]['hh'], color='#2ca02c', label=u'黑虎泉')
    else:        
        for item in data['line']:
            if mode:
                plt.plot(data['date'], item['hh'], label=u'黑虎泉(%s)'%item['name'])
            else:
                plt.plot(data['date'], item['bt'], label=u'趵突泉(%s)'%item['name'])
    
    plt.legend(loc='best')    
    plt.gcf().autofmt_xdate()
    plt.show()

8. 组装在一起

我打算使用 optparse 模块,构造了一个linux风格的使用界面,以常规GNU/POSIX语法指定选项。函数 parse_args() 实现了这个规划。

def parse_args():
    """获取参数"""

    parser = optparse.OptionParser()
    
    help = u"检查数据"
    parser.add_option('-v', '--verify', action='store_const', const='verify', dest='cmd', default='verify', help=help)

    help = u"补齐数据"
    parser.add_option('-f', '--fix', action='store_const', const='fix', dest='cmd', help=help)

    help = u"绘制水位线变化图"
    parser.add_option('-p', '--plot', action='store_const', const='plot', dest='cmd', help=help)

    help = u"选择绘图开始日期(格式为YYYYMMDD),默认最早数据日期"
    parser.add_option('-s', '--start', action="store", default=None, help=help)

    help = u"选择绘图结束日期(格式为YYYYMMDD),默认最新数据日期"
    parser.add_option('-e', '--end', action="store", default=None, help=help)

    help = u"设置是否绘制历史同期数据 (参数为数字),默认不绘制"
    parser.add_option('-H', action="store", dest="history", default=0, help=help)

    help = u"选择趵突泉,默认选择黑虎泉"
    parser.add_option('-b', action="store_false", dest="mode", default=True, help=help)

    return parser.parse_args()

用户界面如下:

PS > py -3 .\jnspring.py -h
Usage: jnspring.py [options]

Options:
  -h, --help            show this help message and exit
  -v, --verify          检查数据
  -f, --fix             补齐数据
  -p, --plot            绘制水位线变化图
  -s START, --start=START
                        选择绘图开始日期(格式为YYYYMMDD),默认最早数据日期
  -e END, --end=END     选择绘图结束日期(格式为YYYYMMDD),默认最新数据日期
  -H HISTORY            设置是否绘制历史同期数据 (参数为数字),默认不绘制
  -b                    选择趵突泉,默认选择黑虎泉

主程序:

main():
    options, args = parse_args()  # 获取命令和参数
    if options.cmd == 'verify':  # 检查数据
        water_db = WaterDB()
        spring_verify(water_db)
        water_db.close()
    elif options.cmd == 'fix':  # 补齐数据
        water_db = WaterDB()
        deadline = water_db.stat()[2]  # 最新数据日期
        if not deadline:
            deadline ='2012-05-13'
        grab(water_db, deadline)
        spring_verify(water_db)
        water_db.close()
    elif options.cmd == 'plot':  # 数据可视化
        water_db = WaterDB()
        data = get_plot_data(water_db, options.start, options.end, int(options.history))
        plot(data, mode=options.mode)
        water_db.close()

9. 效果展示

PS > py -3 .\jnspring.py -p -s20190101 -e20191231 -H3 -b

在这里插入图片描述

PS > py -3 .\jnspring.py -p -s20130101 -e20131231

在这里插入图片描述

猜你喜欢

转载自blog.csdn.net/xufive/article/details/99722362