用数据分析的手段,看2019年CSDN博客之星总评选

1. 前言

万众瞩目的CSDN博客之星总评选投票活动渐入佳境,竞争趋于白热化。入选前200名的博主们火力全开,使出了浑身解数,通过各种渠道拉票。一时间,CSDN刷爆了各大自媒体。无论是吃瓜群众,还是摇旗呐喊、擂鼓助威的亲友团,无不赞叹:CSDN这一波广告创意,真高!

俗话说,外行看热闹,内行看门道。姑且让亲友团、粉丝团飞一会儿,我来给吃瓜群众介绍一下,如何用数据分析的手段,剖析今年的CSDN博客之星总评选,并用机器学习的方式预测后续的竞争结果。本文纯粹出于技术交流之目的,绝无恶意揣测诽谤他人之意图;所谓预测,亦出于游戏之心态,只为博同学们一笑耳。

1. 数据抓取——分享一个调度服务框架

做数据分析,首先得有数据。看2019年CSDN博客之星总评选,就要从活动网站上抓取数据。抓数据不难,同学们都会,但要抓历史数据,就需要费功夫了。因为活动网站上没有提供历史数据下载服务,我们只能不断地以固定时间间隔访问网站,并将数据记录下来。这就需要一个长期工作的服务程序。通常,执行定时任务的服务程序,都是基于调度服务的框架。

APScheduler 是我最喜欢的一个用于调度服务的模块,其全称是 Advanced Python Scheduler。这是一个轻量级的 Python 定时任务调度框架,功能非常强大。单说这个模块的话,洋洋洒洒可以写万字以上,但本文的重点不是 APScheduler,所以这里直接分享一个APScheduler 的应用实例。虽然只有区区70余行代码,依然非常实用、健壮。APScheduler 的安装很简单:

python -m pip install apscheduler

下面的程序启动之后,每10分钟从2019年CSDN博客之星总评选活动网站抓取一次数据,以博主编号为文件名,保存在和服务程序同级的 data 文件夹下,文件格式如下:

天元浪子
2020-01-15 11:10:00,1,10512
2020-01-15 11:20:00,1,10517
2020-01-15 11:30:00,1,10527
… …

scheduler_catch.py

# coding:utf-8

"""定时数据抓取服务"""

import os, re, json, time
from datetime import datetime
import urllib.request
from apscheduler.schedulers.blocking import BlockingScheduler

def start_service():
    """启动数据抓取调度服务"""
    
    scheduler = BlockingScheduler()
    scheduler.add_job(TimeJobHandler,
        args = (),
        trigger = "cron",
        second = "0",
        minute = "0/10",
        hour = "*",
        day = "*",
        month = "*",
        day_of_week = "*",
        year = "*",
        misfire_grace_time = 60
    )
                               
    scheduler.start()
    
def TimeJobHandler():
    url = "http://m234140.nofollow.ax.mvote.cn/action/viewvotewxorderlist.html?voteguid=43ced329-3a4b-0a5d-a13c-f088cf8eafef"
    res = urllib.request.urlopen(url)
    now = datetime.now()
    html = res.read().decode("utf-8")
    itemMatch(html, now)
    
def itemMatch(content, now):
    cwd = os.getcwd()
    path = os.path.join(cwd, "data")
    if not os.path.isdir(path):
        os.makedirs(path)
    
    content = content.replace("\r\n", "").replace("\n", "").replace("\r", "").replace("\"", "'")
    p = re.compile("第(\d+)名")
    col0 = p.findall(content) # 第N名
    p = re.compile("<span class='optt'><a href='.*?'>(.*?)</a></span>", )
    col1 = p.findall(content) # 当前名次
    p = re.compile("<td class='' style='width:80px'>(\d+)票</td>")
    col2 = p.findall(content) # 当前票数
    
    newName = []
    for i, name in enumerate(col1):
        if "(点此进入个人页)" in name:
            ns = name.split(u"(")
            name = ns[0]
        name = name.strip()
        ns = name.split(".")
        num = ns[0]
        truename = "".join(ns[1:]).strip()
        filename = os.path.join(path, num + ".txt")
        
        if os.path.isfile(filename):
            f = open(filename, 'a')
        else:
            f = open(filename, 'w')
            f.write(truename + "\n")
        
        f.write(now.strftime("%Y-%m-%d %X") + "," + col0[i] + "," + col2[i] + "\n")
        f.close()
    
if __name__ == '__main__':
    start_service()

2. 数据处理——基于Numpy的预处理

数据处理过程中导入用到两个模块,先统一写在这里,后面讲解时就不再处处导入了:

import numpy as np
from datetime import datetime, timedelta

2.1 读取指定编号博主的投票数据

def read_by_no(no, start_dt, end_dt):
    """读取指定编号博主的投票数据"""
    
    with open('data/%d.txt'%no, 'r', encoding='utf-8') as fp:
        lines = fp.readlines()
        
    name = lines[0].strip()
    dt_list = list()
    rank_list = list()
    votes_list = list()
    for line in lines[1:]:
        dt, rank, votes = line.strip().split(',')
        if (not start_dt or start_dt and dt >= start_dt) and (not end_dt or end_dt and dt <= end_dt):
            dt_list.append(datetime.strptime(dt, '%Y-%m-%d %H:%M:%S'))
            rank_list.append(int(rank))
            votes_list.append(int(votes))
    
    return name, dt_list, rank_list, votes_list

2.2 取得指定时刻得票数量前n名的博主序号

def get_top(n, end_dt):
    """取得end_dt时刻前n名序号"""
    
    ranks = list()
    for i in range(1, 202):
        with open('data/%d.txt'%i, 'r', encoding='utf-8') as fp:
            lines = fp.readlines()
        
        for j in range(1, len(lines)):
            dt, rank, votes = lines[j].strip().split(',')
            if dt > end_dt:
                break
        dt, rank, votes = lines[j-1].strip().split(',')
        ranks.append((i, int(rank)))
    
    ranks.sort(key=lambda x:x[1])
    return [item[0] for item in ranks][:n]

3. 数据分析——基于matplotlib绘图

绘图函数需要导入matplotlib模块,并设置中文字体。先统一写在这里,后面讲解时就不再处处导入了:

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

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

3.1 绘制指定编号博主的投票曲线

def plot_votes(no, start_dt=None, end_dt=None):
    """绘制指定编号博主的投票曲线"""
    
    name, dt_list, rank_list, votes_list = read_by_no(no, start_dt, end_dt)
    
    plt.figure('2019年CSDN博客之星总评选统计图表', facecolor='#f4f4f4', figsize=(15, 8))
    plt.title('2019年CSDN博客之星总评选%s10分钟得票数量统计曲线'%name, fontsize=20)
    plt.grid(linestyle=':') # 辅助网格
    plt.plot(dt_list, votes_list) # 绘制数据
    plt.gca().xaxis.set_major_formatter(mdates.DateFormatter('%m-%d %H:%M')) # 格式化时间轴标注
    plt.gcf().autofmt_xdate() # 优化标注(自动倾斜)
    #plt.savefig('image/得票数量统计曲线.png') # 保存为文件
    plt.show()

plot_votes(168)

绘制效果如下:
在这里插入图片描述

3.2 绘制指定编号博主的10分钟投票增量曲线

def plot_delta(no, start_dt=None, end_dt=None):
    """绘制指定编号博主的10分钟投票增量曲线"""
    
    name, dt_list, rank_list, votes_list = read_by_no(no, start_dt, end_dt)
    
    plt.figure('2019年CSDN博客之星总评选统计图表', facecolor='#f4f4f4', figsize=(15, 8))
    plt.title('2019年CSDN博客之星总评选%s10分钟投票增量统计曲线'%name, fontsize=20)
    plt.grid(linestyle=':') # 辅助网格
    votes_list.insert(0, votes_list[0])
    votes_list = np.diff(np.array(votes_list))
    plt.plot(dt_list, votes_list, color='g') # 绘制数据
    plt.gca().xaxis.set_major_formatter(mdates.DateFormatter('%m-%d %H:%M')) # 格式化时间轴标注
    plt.gcf().autofmt_xdate() # 优化标注(自动倾斜)
    #plt.savefig('image/投票增量曲线.png') # 保存为文件
    plt.show()

sdt = '2020-01-13 00:00:00'
edt = '2020-01-15 00:00:00'
plot_delta(168, start_dt=sdt, end_dt=edt)

绘制效果如下:
在这里插入图片描述

3.3 将多位博主得票数据绘制在一张图上

函数 get_top(n, end_dt) 返回 end_dt 时刻得票数量排名前 n 位的博主的序号列表。遍历这个列表,我们可以把多位博主的投票曲线或者投票增量曲线,画在同一张图上。这里就不一一给出代码了,直接贴出效果图:
在这里插入图片描述
在这里插入图片描述

3.4 绘制日增量柱状图,打印markdown格式的位次变化表

函数 get_top(n, end_dt) 返回 end_dt 时刻得票数量排名前 n 位的博主的序号列表。遍历这个列表,我们可以把多位博主的投票曲线或者投票增量曲线,画在同一张图上。效果如下:

这里就不一一给出代码了。接下来,我们再学习画柱状图,用以分析TOP20的博主们每天的投票增量。

def plot_votes_delta(n, dt):
    """绘制日增量柱状图,打印markdown格式的位次变化表"""
    
    last_dt = datetime.strptime(dt, '%Y-%m-%d %H:%M:%S') - timedelta(days=1)
    last_dt = last_dt.strftime('%Y-%m-%d %X')
    no_list = get_top(n, dt)
    names, dv = list(), list()
    table_str = '|编号|博主|位次|升降|总得票数|日增票数|\n|:---:|:---:|:---:|:---:|:---:|:---:|\n'
    for i in range(n):
        no = no_list[i]
        name, dt_list, rank_list, votes_list = read_by_no(no, last_dt, dt)
        dvotes = votes_list[-1]-votes_list[0]
        drank = rank_list[0]-rank_list[-1]
        
        if drank > 0:
            drank_str = '<font color="red">↑%d</font>'%drank
        elif drank < 0:
            drank_str = '<font color="green">↓%d</font>'%abs(drank)
        else:
            drank_str = '-'
            
        table_str += '|%d|%s|%d|%s|%d|%d|\n'%(no, name, i+1, drank_str, votes_list[-1], dvotes)
        names.append(name)
        dv.append(dvotes)
    
    print(table_str)
    
    color=[(np.random.random(),np.random.random(),np.random.random()) for i in range(n)]
    plt.figure('2019年CSDN博客之星总评选统计图表', facecolor='#f4f4f4', figsize=(15, 12))
    plt.title('2019年CSDN博客之星总评选TOP%d(截至%s)日增投票数量柱状图'%(n, dt), fontsize=20)
    plt.barh(names[::-1], dv[::-1], align="center", height=0.5, alpha=1.0, color=color)
    plt.gcf().autofmt_xdate()
    plt.savefig('image/日增投票数量柱状图%s.png'%dt[:10])
    plt.show()

sdt = '2020-01-13 00:00:00'
edt = '2020-01-15 00:00:00'
plot_votes_delta(20, edt)

绘制效果如下:
在这里插入图片描述
markdown格式的位次变化表:

编号 博主 位次 升降 总得票数 日增票数
168 天元浪子 1 - 9770 1795
22 Eastmount 2 - 8366 1062
25 Programer Cat 3 - 7597 1357
23 _YourBatman 4 ↑3 7037 1806
176 小傅哥 5 ↓1 6994 1513
200 DrogoZhang 6 ↓1 6803 1436
127 Mike__Jiang 7 ↓1 6741 1503
57 lilongsy 8 ↑3 6166 1577
201 刘望舒 9 - 6158 1197
95 敖丶丙 10 - 6033 1234
21 程序猿DD 11 ↑1 5910 1883
79 沉默王二 12 ↓4 5882 683
76 唯有坚持不懈 13 ↑2 5037 1258
82 人工智能博士 14 - 4597 776
68 十步杀一人_千里不留行 15 ↓2 4349 387
20 段智华 16 - 4094 915
69 不脱发的程序猿 17 - 3748 1016
66 lynnlovemin 18 - 3528 862
103 Vam的金豆之路 19 - 3120 578
70 狂野小青年 20 ↑2 3072 741

4. 趋势预测——最小二乘法拟合多项式

关于最小二乘法实现多项式拟合,请参考我的另一篇博文:《从寻找谷神星的过程,谈最小二乘法实现多项式拟合》。下面我们以168号博主最近4天的投票数据,用最小二乘法拟合多项式,对天元浪子未来一天的投票结果做出趋势预测。

def predict(no, start_dt, end_dt):
    """趋势预测"""
    
    name, dt_list, rank_list, votes_list = read_by_no(168, start_dt, end_dt)
    _y = votes_list = np.array(votes_list)
    _x = np.arange(_y.shape[0])
    x = np.arange(_y.shape[0]+144) # 预测24小时后得票数量
   
    g3 = np.poly1d(np.polyfit(_x, _y, 3))
    g4 = np.poly1d(np.polyfit(_x, _y, 4))

    loss3 = np.sum(np.square(g3(_x)-_y))/_y.shape[0]
    loss4 = np.sum(np.square(g4(_x)-_y))/_y.shape[0]
    
    plt.figure('2019年CSDN博客之星总评选统计图表', facecolor='#f4f4f4', figsize=(15, 8))
    plt.title('2019年CSDN博客之星总评选%s24小时趋势预测'%name, fontsize=20)
    plt.plot(_x, _y, label='原始数据')
    #plt.plot(x, g3(x), label='3次多项式,预测24小时后得票数量:%d'%int(g3(x)[-1]))
    plt.plot(x, g4(x), label='4次多项式,预测24小时后得票数量:%d'%int(g4(x)[-1]))
    #plt.plot(_x, g3(_x), label='3次多项式,误差%0.4f'%loss3)
    plt.plot(_x, g4(_x), label='4次多项式,误差%0.4f'%loss4)

    plt.legend()
    plt.show()

经过比较,4次多项式拟合结果误差最小。预测结果如下:
在这里插入图片描述

发布了83 篇原创文章 · 获赞 7961 · 访问量 95万+

猜你喜欢

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