济南,始终是一座不温不火、慢慢腾腾的城市,一如生活在她怀抱中的市井百姓:闲适、从容。也有人说她土气、落后,但我始终觉得,她很美,而且美得独一无二,美得沁人心脾。作为北方城市,济南有山、有水、有泉,背靠黄河,有深厚的历史和文化底蕴。生活在这座城市,我很荣幸。
空闲时间,我喜欢逛逛济南的大街小巷、看看济南的山山水水。曾经拍了很多的照片,也写过赞美她的诗。
你的美
弥散在清晨护城河氤氲的水面上
附着在午后玛瑙泉缓缓升起的气泡里
你的美
回响在兴国禅寺的暮鼓晨钟里
飘荡在百年教堂的穹顶钟楼上
你的美
渲染了九如山漫山的红叶
点亮了七星台璀璨的星空
五龙潭的樱花,是你如花的笑靥
百花洲的垂柳,是你妙曼的身姿
长河落日,是你无尽的爱
鹊华烟雨,是你温柔的吻
我将,深深地
永远地,爱你
但是,作为程序员,我觉得还是应该用数据对她说出我的爱。本文完整演示了从济南市城乡水务局网站爬取历年来趵突泉、黑虎泉地下水位数据,并绘制出水位变化曲线。全部代码涉及到sqlite、optparse、Requests、datetime、lxml、re、numpy、matplotlib等众多模块的使用, 希望能给ython初学者一点启发。
这是提供济南市城乡水务局地下水位数据的网站,借助于FireFox提供的网络分析工具,我们很容易搞明白抓取数据的url和method,以及请求头和发送的数据,还可以查看应答的数据格式。详见下面的截图。
我选择使用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
本项目用到了很多模块,导入方式先一并写在这里,后面就不逐一导入了。
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
数据解析函数使用了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
下面这个函数的功能是:抓取数据至指定日期,并解析入库。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()
这个网站的数据有不少错误,有缺失,也有重复,因此数据抓取抓取完成后,需要对数据做清洗。有时候,我们也需要对数据的连续性做检查。我把这些功能封装在了一个函数里面。
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()
数据可视化,稍微麻烦一点。我设计的功能是这样的:根据给出的日期范围,绘制水位变化曲线。如果日期范围不超过一年,还可以同时绘制历史同期数据。这个工作分成两个函数,一个处理数据,一个使用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()
我打算使用 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()
PS > py -3 .\jnspring.py -p -s20190101 -e20191231 -H3 -b
PS > py -3 .\jnspring.py -p -s20130101 -e20131231
近期有很多朋友通过私信咨询有关python学习问题。为便于交流,我在CSDN的app上创建了一个小组,名为“python作业辅导小组”,面向python初学者,为大家提供咨询服务、辅导python作业。欢迎有兴趣的同学扫码加入。
CSDN 不止为我们提供了这样一个交流平台,还经常推出各类技术交流活动。近期我将在 GeekTalk 栏目,和 Python 新手共同探讨如何快速成长为基础扎实、功力强大的程序员。CSDN 还为这个活动提供了一些纪念品。如果有兴趣,请扫码加入,或者直接点此进入。