python爬虫结合cron替我逛豆瓣

背景

一直以来都是自己一个人玩吉他。最开始是指弹,一个人即是正义。后来接触电吉他,渐渐觉得和别人一同演奏也是很愉悦的事情。转行后第一份工作稳定下来之后,又搞了把电吉他自娱自乐,有一天突然就想看能不能找一个业余乐队,周末一起high。

怎么找呢,线下没明显的途径,线上无非豆瓣、贴吧、企鹅群,最终我选择了豆瓣。于是先上豆瓣,搜“乐队”,在小组分类下,找几个合适(主要是人多)的小组点进去,找上海地区乐队招吉他手的相关帖子,再打开看详情,乐队风格对不对味,对成员有没有硬性要求等。把每个小组里的前几页都翻了一遍,只找到一个合适的,但帖子发的早,人家已经不招了。之后的一段时间,每天或者隔几天晚上下班后都会重复上面的步骤,不同的是,只需要关注第一页前几篇。

这样重复了几次,感觉太蠢。重复性的筛选+豆瓣+会点python,自然想到爬虫。再加上买了云服务器,可以配置cron定时运行爬虫脚本,并把更新的结果用邮件通知到我。

码起来

main函数

代码即整体思路:

def main():
    result = []                     #初始化结果数组
    for group in GROUPS:            #循环处理每个小组(首页)链接
        posts = getPosts(group)     #获取当前小组的所有帖子,每个结果包含帖子标题和帖子链接
        result += myFilter(posts)   #筛选帖子,结果追加至结果数组
    if len(result) > 0:
        record(result)              #当结果数组不为空时,记录并通知

获取

爬虫的核心,没什么特殊的

import requests
from bs4 import BeautifulSoup as bs

def getPosts(group):
    r = requests.get(group)
    ans = []
    if (r.status_code == 200):
        soup = bs(r.text, 'html.parser')
        ans = soup.select('table.olt tr')
    return ans

筛选

我的过滤条件是这样的:
1. 置顶帖不看
2. 标题包含“上海+乐队”或“上海+吉他”或“日系+吉他”这样的关键词组合, 同时不包含“金属”,“核”,“视觉”中的任何一个关键词

个人喜好,求同存异

KW_SETS = [                         #包含关键词组合的list, 每个关键词组合也是一个list
    '上海 吉他'.split(' '),
    '上海 乐队'.split(' '),
    '日系 吉他'.split(' '),
]

EXCLUDE = '金属 核 视觉'.split(' ')

def judge(title):                   #对帖子标题进行关键词/排除词检查
    for ex in EXCLUDE:
        if title.find(ex) > -1:
            return False            #只要发现需要排除的关键词就返回False
    hasOne = False                  #记录是否在KW_SETS中匹配到至少一个关键词组合
    for kw_set in KW_SETS:
        flag = True                 #记录某个关键词组合中的关键词是否全部被包含
        for kw in kw_set:
            flag = flag and title.find(kw) > -1
        hasOne = hasOne or flag
    return hasOne

def myFilter(posts):
    ans = []
    for post in posts:
        title = post.select('td.title') #获取到的是一个list
        if title:
            if title[0].select('span.pl'): #找到这个元素就说明是置顶帖,跳过
                continue
            a = title[0].find('a')  #获取到包含标题和链接的a标签, 可能为空
            if a and judge(a.attrs['title']):
               #标题存在且符合要求, 记入结果
                ans.append({'title': a.attrs['title'], 'href': a.attrs['href']})
    return ans

记录及通知

代码略有点长,整体思路就是先尝试读取已有的(上一次的)记录文件,然后比较上一次的记录和本次记录,如果本次记录里有“新发现”,再执行写入及通知操作。

import os, json, smtplib
from email.mime.text import MIMEText
from email.header import Header

RECORD = 'record.json'              #记录文件的文件名
SENDMAIL = True                     #是否发送邮件通知, 调试非邮件部分时我会设为False

def record(result):
    doWrite = False                 #是否写入记录并通知
    if os.path.isfile(RECORD):      #存在已有的记录文件
       with open(RECORD, 'r') as r: #读取记录文件
           try:
               inPut = json.loads(r.read()) #转为list
           except:
               inPut = []
           if len(inPut) > 0:
               for res in result:
                   found = False    #当前条目res是否能在上次的记录中找到,即是否有“新发现”
                   for old in inPut:    #内层循环将当前条目res与上次记录的所有条目逐一比对
                       if res['href'] == old['href']:   #判断依据是链接地址
                           found = True #找到,做好标记并退出内层循环
                           break
                   if not found:
                       doWrite = True   #只要有一个“新发现”即可退出外层循环
                       break
           else:
               doWrite = True       #上一次的记录为空或格式有误
    else:
        doWrite = True              #上一次的记录不存在

    if doWrite:
        print('something new, recording')
        outPut = json.dumps(result, sort_keys=True, indent=4, ensure_ascii=False)   #将本次记录list格式化为json字符串
        with open(RECORD, 'w') as w:
            try:
                w.write(outPut)     #写入记录文件
            except Exception as e:
                print(e)
        if SENDMAIL:
            addr = 'username@sina.com'
            server = smtplib.SMTP('smtp.sina.com', 587)
            server.starttls()
            server.set_debuglevel(1)
            server.login(addr, 'password')
            html = '<html><body><ol>'
            for r in result:
                html += '<li><a href="'+r['href']+'">'+r['title']+'</a></li>'
            html += '</ol></body></html>'
            msg = MIMEText(html, 'html', 'utf-8')
            msg['From'] = Header(addr)
            msg['Subject'] = Header('News on your interest', 'utf-8')
            server.sendmail(addr, [addr], msg.as_string())
            server.quit()
    else:
        print('nothing new')

发送邮件部分基本就是照搬廖雪峰的教程,所以不作注释了。smtp端口配置可能因邮件服务商而异,我一开始用的是163邮箱,25端口,在本地测试可以发送成功,放到阿里云上就不行了,尝试别的端口也不行。最后用新浪邮箱成功了。另外邮件头信息里一定要有From,且与发件人实际邮箱地址一致,否则也发不出去。

发送邮件一开始打算是用cron自带的发送邮件功能,那样的话需要通过bash脚本实现判断是否有“新发现”,方法是执行python脚本前获取记录文件的修改日期,执行后再获取一次,两者不相等就说明python脚本有写入操作,即有新发现。后来才突然想起python本身就可以发送邮件。

另外新旧记录比对的部分,一开始的思路是按每行一个条目的规则写入,读取时一行行读入字符串,与本次记录比对。后来才想到json这个标准库,方便了不少。

bash脚本

不在bash脚本里判断是否有“新发现”的话就简单了:

# run.sh
`/usr/bin/python3 crawl.py > log.txt`

前面的’/usr/bin/python3’是python3的可执行文件路径,如果命令行输入python打开的就是python3的话就只写’python’即可。后面加一个’> log.txt’会把python脚本执行过程中的屏幕输出(print输出、报错信息等)写入到log.txt中。写入是覆盖写入,也就是只能记录前一次的屏幕输出,不过也够了。

之后需要为当前用户添加该脚本的执行权限,命令行输入:

chmod +x run.sh

cron配置

直接编辑/etc/crontab:

SHELL=/bin/sh
PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin
# 不发送邮件加入这一行:
MAILTO=""

# m h dom mon dow user  command
17 *    * * *   root    cd / && run-parts --report /etc/cron.hourly
25 6    * * *   root    test -x /usr/sbin/anacron || ( cd / && run-parts --report /etc/cron.daily )
47 6    * * 7   root    test -x /usr/sbin/anacron || ( cd / && run-parts --report /etc/cron.weekly )
52 6    1 * *   root    test -x /usr/sbin/anacron || ( cd / && run-parts --report /etc/cron.monthly )
# 以上为系统自带任务,加入一行:
0 18    * * *   pixel   export LANG="en_US.UTF-8"; cd /home/pixel/python/crawl_douban && ./run.sh
#

每天18点执行一次。注意首先要cd到python脚本所在目录,再执行bash脚本。另外在我电脑上测试时,前面那句 export LANG="en_US.UTF-8"; 是必须的,否则python执行时会报错:’ascii’ codec can’t encode characters in position 91-94: ordinal not in range(128),而直接从命令行运行python脚本不会报错。搜了好几种解决方案,最后只有这个管用。但是放到我的服务器上运行并不需要这句,好像和系统编码有关,不深究了。

之后重新载入cron服务:

service cron reload

成果

开始定时任务之后,第一个18点我收到了第一封邮件,之后只会在有新帖出现后收到新邮件。到目前为止,任务跑了半个多月,只有一次更新。

邮件里链接直达比较方便,不方便的是一眼看不出哪个是新帖,之后再改。

发表评论

您的电子邮箱地址不会被公开。 必填项已用*标注

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据