使用selenium+requests爬取豆瓣小组讨论列表

获取本文代码 · 我的GitHub
注:这个项目的代码会在我的GitHub持续优化、更新,而在本文中的代码则是最初版本的代码。

豆瓣小组

豆瓣有一个“小组”模块,有一些小组中会发布很多租房信息。在这里找租房信息的好处就在于,可以避免被那些第三方平台的中介忽悠,有更多的机会直接联系上房东,或有转租、寻求合租需求的人。

但是目前豆瓣租房小组存在的问题就是,信息高度不标准化,每一个人发布的信息的格式都各不相同,想要根据一些条件搜索到自己真正需要的信息比较困难,比如无法根据租金、地段、房型等条件去过滤,只能人工一个个去看,看一天下来,整个人都晕了,还不一定能找到中意的房子。

所以想到,搞一个爬虫呗,很多租房小组还是很活跃的,每天更新的信息量巨大,让人目不暇接,搞个爬虫自动化去爬取这些数据,并做一些简单的筛选,最终呈现在自己眼前,让自己找房子更有效率。

爬虫用到的技术点

  • 使用selenium模拟登录,获取cookie,基本用法可以参见我的另一篇文章:使用selenium+requests登录网页并持久化cookie
  • 使用requests库+cookie发送请求,获取数据。
  • 使用lxml库和xpath语法解析网页数据,整理数据。
  • 使用jinja2模板引擎渲染数据到HTML网页中,结构化地展示出来。

完整代码

下面的代码爬取了一个豆瓣租房小组的1000条讨论列表,从中筛选出了含有某些关键词的条目。假设将下面的代码保存在spider.py文件,则运行方式为:python spider.py 豆瓣用户名 豆瓣用户密码 讨论起始位置 要爬取的条数,代码中有详细的注释:

# coding:utf-8
# 豆瓣爬虫核心方法
from __future__ import unicode_literals
from selenium import webdriver
import requests
import time
import json
from lxml import etree
import random
from operator import itemgetter
from jinja2 import Environment, FileSystemLoader

import sys
reload(sys)
sys.setdefaultencoding('utf-8')

class DoubanSpider(object):
    '''
    豆瓣爬虫
    '''
    def __init__(self, user_name, password, headless = False):
        '''
        初始化
        :param user_name: 豆瓣登录用户名
        :param password: 豆瓣登录用户密码
        :param headless: 是否显示webdriver浏览器窗口
        :return: None
        '''
        self.user_name = user_name
        self.password = password
        self.headless = headless

        # 登录
        self.login()
        
    def login(self):
        '''
        登录,并持久化cookie
        :return: None
        '''
        # 豆瓣登录页面URL
        login_url = 'https://www.douban.com/accounts/login'

        # 获取chrome的配置
        opt = webdriver.ChromeOptions()
        # 在运行的时候不弹出浏览器窗口
        if self.headless:
            opt.set_headless()

        # 获取driver对象
        self.driver = webdriver.Chrome(chrome_options = opt)
        # 打开登录页面
        self.driver.get(login_url)

        print '[login] opened login page...'

        # 向浏览器发送用户名、密码,并点击登录按钮
        self.driver.find_element_by_name('form_email').send_keys(self.user_name)
        self.driver.find_element_by_name('form_password').send_keys(self.password)
        # 多次登录需要输入验证码,这里给一个手工输入验证码的时间
        time.sleep(6)
        self.driver.find_element_by_class_name('btn-submit').submit()
        print '[login] submited...'
        # 等待2秒钟
        time.sleep(2)

        # 创建一个requests session对象
        self.session = requests.Session()
        # 从driver中获取cookie列表(是一个列表,列表的每个元素都是一个字典)
        cookies = self.driver.get_cookies()
        # 把cookies设置到session中
        for cookie in cookies:
            self.session.cookies.set(cookie['name'],cookie['value'])

    def get_page_source(self, url):
        '''
        获取浏览器窗口中的页面HTML内容
        :param url: 网页链接
        :return: 网页页面HTML内容
        '''
        self.driver.get(url)
        page_source = self.driver.page_source
        print '[get_page_source] page_source head 100 char = {}'.format(page_source[:100])
        return page_source
    
    def get(self, url, params = None):
        '''
        向一个url发送get请求,返回response对象
        :param url: 网页链接
        :param params: URL参数字典
        :return: 发送请求后获取的response对象
        '''
        # 等待一个随机的时间,防止被封IP,这里随机等待0~6秒,亲测可有效地避免触发豆瓣的反爬虫机制
        time.sleep(6 * random.random())
        resp = self.session.get(url, params = params, headers = self.get_headers())

        if resp:
            print '[get] url = {0}, status_code = {1}'.format(url, resp.status_code)
            resp.encoding = 'utf-8'
            # 这里很重要,每次发送请求后,都更新session的cookie,防止cookie过期
            if resp.cookies.get_dict():
                self.session.update(resp.cookies)
                print '[get] updated cookies, new cookies = {0}'.format(resp.cookies.get_dict())
            return resp
        else:
            print '[get] url = {0}, response is None'.format(url)
            return None
    def get_html(self,url, params = None):
        '''
        获取一个url对应的页面的HTML代码
        :param url: 网页链接
        :param params: URL参数字典
        :return: 网页的HTML代码
        '''
        resp = self.get(url)
        if resp:
            return resp.text
        else:
            return ''
    def get_headers(self):
        '''
        随机获取一个headers
        '''
        user_agents =  ['Mozilla/5.0 (Windows NT 6.1; rv:2.0.1) Gecko/20100101 Firefox/4.0.1','Mozilla/5.0 (Windows; U; Windows NT 6.1; en-us) AppleWebKit/534.50 (KHTML, like Gecko) Version/5.1 Safari/534.50','Opera/9.80 (Windows NT 6.1; U; en) Presto/2.8.131 Version/11.11']
        headers = {'User-Agent':random.choice(user_agents)}
        return headers

class DoubanDiscussionSpider(DoubanSpider):
    '''
    豆瓣小组讨论话题爬虫
    '''
    def __init__(self, user_name, password, group_name, headless = False):
        '''
        初始化
        :param user_name: 豆瓣登录用户名
        :param password: 豆瓣登录用户密码
        :param group_name: 豆瓣小组名称
        :param headless: 是否显示webdriver浏览器窗口
        :return: None
        '''
        super(DoubanDiscussionSpider,self).__init__(user_name, password, headless)
        self.group_name = group_name

        # 豆瓣小组讨论列表URL模板
        self.url_tpl = 'https://www.douban.com/group/{group_name}/discussion?start={start}&limit={limit}'.format(group_name = self.group_name,start = '{start}', limit = '{limit}')
        print '[__init__] url = {0}'.format(self.url_tpl)

    def get_discussion_list(self, start=0, limit=100, filter = []):
        '''
        获取讨论列表
        :param start: 开始条目数,默认值为0
        :param limit: 总条数,默认值为100,最大值也为100
        :param filter: 关键词列表,只过滤出标题或详情中含有关键词列表中关键词的项
        :return: 话题讨论内容字典列表
        '''
        list_url = self.url_tpl.format(start = start, limit = limit)
        page_html = self.get_html(list_url)
        
        html = etree.HTML(page_html)
        # 解析话题讨论列表
        trs = html.xpath('//*[@class="olt"]/tr')[1:]
        # 话题字典列表
        topics = []
        # 已结被添加的topic link列表,用于去重
        added_links = []
        for tr in trs:
            title = tr.xpath('./td[1]/a/text()')[0].strip()
            link = tr.xpath('./td[1]/a/@href')[0].strip()
            # 继续解析话题详情页面,从中解析出发布时间、描述详情
            topic_page_html = self.get_html(link)
            topic = etree.HTML(topic_page_html)
            # 发布时间字符串
            post_time_str = topic.xpath('//*[@class="topic-doc"]/h3[1]/span[2]/text()')[0].strip()
            # 详情
            detail = topic.xpath('//*[@class="topic-content"]')[0].xpath('string(.)').strip()

            # 根据关键词过滤
            if filter and not self.contains(title, filter) and not self.contains(detail, filter):
                continue
            
            topic_dict = {}
            topic_dict['title'] = title
            topic_dict['link'] = link
            if link in added_links:
                continue
            else:
                added_links.append(link)
            
            topic_dict['post_time_str'] = post_time_str
            topic_dict['post_time'] = time.mktime(time.strptime(post_time_str,'%Y-%m-%d %H:%M:%S'))
            topic_dict['detail'] = detail
            topics.append(topic_dict)
            print '[get_discussion_list] parse topic: {0} finished'.format(link)
        print '[get_discussion_list] get all topics finished, count of topics = {0}'.format(len(topics))
        # 对topics按照发布时间排序(降序)
        topics = sorted(topics, key = itemgetter('post_time'), reverse = True)
        return topics
    def get_discussion_list_cyclely(self, start = 0, limit = 100, filter = []):
        '''
        循环获取讨论列表
        :param start: 开始条目数,默认值为0
        :param limit: 总条数,默认值为100
        :param filter: 关键词列表,只过滤出标题或详情中含有关键词列表中关键词的项
        :return: 话题讨论内容字典列表
        '''
        topics = []
        if limit <= 100:
            topics = self.get_discussion_list(start, limit, filter)
        else:
            for start in range(start, limit, 100):
                topics.extend(self.get_discussion_list(start, 100, filter))
        print '[get_discussion_list_cyclely] get all topics finished, count of topics = {0}'.format(len(topics))
        # 对topics按照发布时间排序(降序)
        topics = sorted(topics, key = itemgetter('post_time'), reverse = True)
        return topics

    def contains(self, text, filter):
        '''
        判断一个字符串中是否包含了一个关键词列表中的至少一个关键词
        :param text: 字符串
        :param filter: 关键词字符串列表
        :return: bool
        '''
        for kw in filter:
            if kw in text:
                return True
        return False

    def render_topics(self, topics):
        '''
        把topic列表内容渲染到HTML文件中
        :param topics: topic列表
        :return: None
        '''
        env = Environment(loader = FileSystemLoader('E:/code/py-project/DoubanHouse/'))
        tpl = env.get_template('topics_tpl.html')
        with open('topics.html','w+') as fout:
            render_content = tpl.render(topics = topics)
            fout.write(render_content)
        print '[render_topics] render finished'
        
def sample():
    '''
    测试
    '''
    # 豆瓣账号用户名、密码
    user_name = sys.argv[1]
    password = sys.argv[2]
    # 起始位置
    start = int(sys.argv[3])
    # 打算爬取的小组讨论条数
    limit = int(sys.argv[4])
    # 小组名称
    group_name = 'nanshanzufang'
    # 创建爬虫spider对象
    spider = DoubanDiscussionSpider(user_name, password, group_name)
    
    # 获取当前小组的话题列表,按照关键词列表过滤内容,只有讨论的标题或详情中包含这个列表中至少一个关键词的时候,才保留
    filter = ['主卧','主人','独卫','甲醛','大卧','独立卫生间']
    topics = spider.get_discussion_list_cyclely(start,limit, filter)
    # 将topics列表内容渲染到HTML表格中
    spider.render_topics(topics)

if __name__ == '__main__':
    sample()
    print 'end'

代码中用的的jinja2模板文件topics_tpl.html的HTML代码如下:




{%- for topic in topics -%}

{% endfor %}
标题 发布时间 链接
{{topic['title']}} {{topic['post_time_str']}} {{topic['link']}}

运行上面python代码,最终得到的渲染出来的topics.html文件的内容是这样的:

使用selenium+requests爬取豆瓣小组讨论列表_第1张图片

过程中遇到的坑和不足之处

  • 首先就是一开始,一下子在十几秒钟之内发送了几百上千个请求 ,过于频繁,被豆瓣封了账号、封了IP,十分悲催。后来痛定思痛,采用了一个简单的策略,就解决了这个问题,那就是在每次请求之间设置随机几秒的间隔时间,就有效地避免了被豆瓣反爬虫机制当成“机器”封账号和IP。
  • 每次启动程序,都需手工输入验证码,然后登录,比较不方便。以后有时间可以优化为自动识别验证码图片、自动填写验证码。

你可能感兴趣的:(使用selenium+requests爬取豆瓣小组讨论列表)