美文网首页Python学习资料
scrapy简书技术博客爬虫

scrapy简书技术博客爬虫

作者: hellodyp | 来源:发表于2018-09-05 17:16 被阅读98次

公司业务使然,很久没使用scrapy了,最近复习一下框架知识,顺带体验一下强大的scrapy调度能力

简书的it类博客很多,开始只是获取it类三十个首页推荐的大v博主,再获取大v关注的博主,没想到程序运行起来就一直不停的跑,两张表三天左右时间,一张三百多万条,另一张三十多万条数据,感觉能把简书大部分博主的信息都拿下来,手动笑哭

程序在公司的测试服务器上跑,后来服务器公司用,程序就停掉了


image.png

业务从0开始,创建scrapy项目:
scrapy新建一个爬虫项目,在终端敲如下命令即可:

scrapy startproject Jianshu

cd到Jianshu项目中,生成一个爬虫:

scrapy genspider jianshublog "www.jianshu.com"

这种方式生成的是常规爬虫
因为要从推荐的三十个博主中提取出关注者,所以我们使用scapy的crawlspider模式,生成crawlspider的shell命令如下:

 scrapy genspider –t crawl jianshublog "www.jianshu.com"

项目的目录就用网图来展示一下吧:

img.png

对照一下我们之前创建的项目:


image.png

核心要关注的是spiders目录下的jianshublog.py,主要的抓取逻辑都在这里,讲核心逻辑之前先把配置理一下,items里面定义要抓取的结构化数据类型,博主定义了两个结构化数据,分别对应简书作者信息以及简书作者的文章信息:


image.png

从当前页面中,提取博主信息、文章信息:
博主信息:作者、关注、简介、粉丝、文章数、主页链接、字数、收获喜欢
文章信息:标题、概要、文章链接、作者
分析好之后,就可以定义items文件啦:

class JianshuItem(scrapy.Item):
    # user字段对应info字段的author
    user = scrapy.Field()
    articles = scrapy.Field()
    current_page = scrapy.Field()
    url = scrapy.Field()
    article_list = scrapy.Field()


class JianshuUserInfo(scrapy.Item):
    author = scrapy.Field()
    url = scrapy.Field()
    following = scrapy.Field()
    followers = scrapy.Field()
    article = scrapy.Field()
    chars = scrapy.Field()
    collection = scrapy.Field()
    author_abstract = scrapy.Field()

middleware中做一些请求干预处理,比如加代理、随机请求头之类
pipelines管道中获取爬虫的结构化返回数据,然后入库或写文件或做其他处理

现在实现核心的spider部分:
简书首页,点击it互联网,点开推荐作者:

image.png
稍微抓包分析一下就可以找到url了:
https://www.jianshu.com/collections/14/side_list
返回的数据是json:
{"id":2509688,"slug":"12532d36e4da",
"nickname":"阿里云云栖社区",
"avatar_source":"http://upload.jianshu.io/users/upload_avatars/2509688/e55553f1-0544-4bde-ad4d-48bcfc01e856.png",
"collection_name":"@IT·互联网",
"total_likes_count":35058,
"total_wordage":3896361,
"is_following_user":true,
"is_followed_by_user":false}

再稍做分析一下,我们就可以通过slug字段,获取到作者的主页了:
简书作者主页的格式也是有规律的,很简单:
https://www.jianshu.com/users/.../following
把slug提取出来组装到上面的url模板就可以:

def get_start_urls():
    url = 'https://www.jianshu.com/collections/14/side_list'
    headers = 'Mozilla/5.0(Windows;U;WindowsNT6.1;en-us)' \
              'AppleWebKit/534.50(KHTML,likeGecko)Version/5.1Safari/534.50'
    headers = {'User-Agent': headers}

    resp = requests.get(url, headers=headers)
    json_con = json.loads(resp.content.decode())
    recommended_users_list = json_con.get('recommended_users')

    for con in recommended_users_list:
        base_url = 'https://www.jianshu.com/users/{}/following'
        part = con.get('slug')
        if part:
            url = base_url.format(part)
            yield url
            # ?page=4
            page = 2
            url += '?page={}'
            has_content = [1]
            while len(has_content) > 0:
                resp = requests.get(url.format(page), headers=headers)
                html = resp.content.decode()
                has_content = etree.HTML(html).xpath('//div[@class="info"]/a[@class="name"]')
                # print('获取start_urls中...', url.format(page))
                yield url.format(page)
                page += 1

上面代码使用requests库,请求出种子url,值得一提的是函数中使用了yield返回,所以这是一个特殊的函数:生成器
生成器是一种特殊的迭代器,可以把生成器当成可迭代对象来使用,以上就是种子url的提取

拿到种子url就可以使用scrapy来提取其中的内容啦,首先从作者主页提取作者关注的博主:

class JianshublogSpider(CrawlSpider):
    name = 'jianshublog'
    allowed_domains = ['jianshu.com']
    start_urls = [url for url in get_start_urls()]

    rules = (
        # 获取关注列表中的简述作者
        Rule(LinkExtractor(restrict_xpaths='//div[@class="info"]/a[@class="name"]'), callback='parse_item',
             follow=False),
    )

scrapy会自动请求start_url中的种子url,然后通过rules中的规则提取出新的url,交给callback来处理

所以callback的逻辑就很重要了:
在callback中,首先提取被关注者的个人信息,然后再提取被关注者的所有文章,最后再提取被关注者的关注者,到这里,整个程序就回稍微复杂一点咯,方法内有多个逻辑处理还有递归调用

scrapy是一个基于twisted框架的异步多任务框架,这样可以大大提升效率,经常使用就会知道,在spider中都是通过yield返回数据或下一个请求,这样整体就类似于一个生成器,通过调度模块实现高效并发请求:

  def parse_item(self, response):
        # 进入被关注者文章列表 提取文章 被关注本人信息 提取被关注者的关注
        item = response.meta.get('item')
        item = item or JianshuItem()

        print(response.url, 'parse_item')
        first = re.search(r"order_by=shared_at", response.url)
        if not first:
            # 提取关注本人信息
            item1 = JianshuUserInfo()
            item1["url"] = response.url
            item1["author"] = response.xpath('//div[@class="title"]/a/text()').extract_first()
            item1["following"] = response.xpath(
                '//div[@class="meta-block"]/a[contains(@href,"following")]/p/text()').extract_first()
            item1["followers"] = response.xpath(
                '//div[@class="meta-block"]/a[contains(@href,"followers")]/p/text()').extract_first()
            item1["article"] = response.xpath('//div[@class="meta-block"]/a/p/text()').extract()
            if len(item1["article"]) == 3:
                item1["article"] = int(item1["article"][-1])
            else:
                item1["article"] = 0
            item1["chars"] = response.xpath('//div[@class="meta-block"]/p/text()').extract()
            if len(item1["chars"]) == 2:
                    chars = item1["chars"]
                    item1["chars"] = chars[0]
                    item1["collection"] = chars[1]
            else:
                item1["chars"] = ''
            item1["author_abstract"] = response.xpath('//div[@class="js-intro"]/text()').extract_first()
            if item1["author_abstract"]:
                item1["author_abstract"] = item1["author_abstract"][:500]
            yield item1
            item["url"] = response.url
            item['articles'] = 0
            item['article_list'] = []
            item["user"] = item1["author"]
            if item1["article"]:
                # print('获取到文章数量为', item1["article"], response.url)
                item['articles'] = item1["article"]

            # 提取被关注者关注的所有人
            slug = re.search(r'https://www.jianshu.com/u/(.*)', response.url)
            url = 'https://www.jianshu.com/users/{}/following'
            if slug:
                slug = slug.group(1)
                url = url.format(slug)
                print('提取到被关注者的关注列表', url)
                yield scrapy.Request(url,
                                     callback=self.parse_following_list)

        # 提取被关注者所有的文章
        try:
            if not item['articles']:
                print('作者还没开始写文章.......................................', item["user"])
                return
            div_list = response.xpath('//div[@class="content"]')
            # 文章列表
            article_list = item['article_list']
            item['user'] = response.xpath('//div[@class="title"]/a[@class="name"]/text()').extract_first()
            for div in div_list:
                inner = dict()
                inner['article_title'] = div.xpath('./a[@class="title"]/text()').extract_first()
                if inner['article_title']:
                    inner['article_title'] = inner['article_title'][:120]
                inner['article_link'] = div.xpath('./a[@class="title"]/@href').extract_first()
                inner['article_abstract'] = div.xpath('./p[@class="abstract"]/text()').extract_first()
                if inner['article_abstract']:
                    inner['article_abstract'].strip()
                if inner['article_title'] or inner['article_link'] or inner['article_abstract']:
                    article_list.append(inner)
        except Exception as e:
            logger.error(str(e))

        # 每个页面9篇文章
        if int(item['articles']) <= 9:
            # print('当前博客页仅有{}篇文章'.format(item['articles']))
            yield item
            return
        else:
            current_page = item.get('current_page')
            if not current_page:
                item['current_page'] = 2
            else:
                item['current_page'] += 1

            page = int(item['articles']/9)
            if item['articles'] % 9:
                page += 1
            if item['current_page'] <= page:
                url = item['url']
                # page = 2
                part = '?order_by=shared_at&page={}'
                next_url = url + part
                url = next_url.format(item['current_page'])
                print('now request {}'.format(url))
                yield scrapy.Request(url,
                                     callback=self.parse_item,
                                     meta={"item": deepcopy(item)}
                                     )
            else:
                print('当前博客文章提取完毕', response.url)
                yield item
                return

parse_item就是主要实现逻辑,在作者主页刷新下一页的时候,简书后台会发起ajax请求,请求的url中会带有order_by=shared_at字符串,我们以此来判断本次请求是否为当前作者第一次调用parse_item方法,如果是第一次调用,就提取作者的个人信息然后返回给管道,接着获取当前作者关注的所有人,当前作者的关注页面在新的回调方法中实现,先略过

下一步提取当前作者的所有文章,首先通过之前获取的作者信息,如果作者没有文章,跳过此步,如果作者有文章,提取文章,并计算出作者文章占用的页数,保存的当前页数和总页数,依次把所有页面的文章提取出来

关注者页面包含作者所有的关注者,这里使用另一个callback来提取:

def parse_following_list(self, response):

        item3 = response.meta.get('item')
        # 判断是否是关注者列表的第一页
        not_first = re.search(r'\?page=', response.url)
        if not not_first:
            item3 = dict()
            item3['page'] = 2
            url = response.url
            url += '?page={}'
            url = url.format(item3['page'])
            # url中内容第一次调用默认请求查看下一页
            yield scrapy.Request(url,
                                 callback=self.parse_following_list,
                                 meta={"item": deepcopy(item3)})

        # 判断是否能从响应中取出关注者 如果可以继续请求下一个关注列表页 反之主url的关注列表提取完成
        has_content = response.xpath('//div[@class="info"]/a[@class="name"]').extract()
        if len(has_content) > 0 and not_first:
            item3['page'] += 1

            # https://www.jianshu.com/users/(.*?)/following?page=
            url = re.search(r'/users/(.*?)/following', response.url)
            if url:
                part = url.group(1)
                url = 'https://www.jianshu.com/users/{}/following?page='.format(part)
                url += str(item3['page'])
                print('提取关注者列表的下一页组装url成功', url, 'a'*50)
                yield scrapy.Request(url,
                                     callback=self.parse_following_list,
                                     meta={"item": deepcopy(item3)})
            else:
                logger.error('组装url产生异常:{}'.format(response.url))
        else:
            # 当前方法任务完成退出
            # print('当前被关注者的关注列表提取完毕', 'ok'*25)
            return

        # 关注列表工厂 提取出所有的被关注者
        urls = response.xpath('//div[@class="info"]/a[@class="name"]/@href').extract()
        urls = ['https://www.jianshu.com' + u for u in urls if urls]
        # print(response.url, 'parse_following_list')
        for url in urls:
            # print('提取工厂提取到url列表再次调用item提取方法', url)
            yield scrapy.Request(url,
                                 callback=self.parse_item)
image.png

parse_following_list方法首先判断,当前作者是否是第一次调用方法,如果是,则默认请求关注者列表的下一页(不管关注者列表页数是否大于一页)
接下来判断当前页面是否有内容(即关注者页面是否有被关注者)
如果有内容,继续递归调用本方法请求下一页
接下来提取出当前页面中的所有被关注者,再次组装url并调用parse_item方法,到这里整个程序的复杂性就体现出来了,两个callback都是生产者又同时也是消费者,他们彼此调用同时自身递归调用

数据通过字典在scrapy请求对象meta参数中传递,众所周知python中列表、字典都是通过引用赋值,为防止字典的引用在不同的方法中操作是产生混淆,这里每次传递字典的时候使用深拷贝,deepcopy可以解决引用传递的问题

返回的数据在管道中处理:

def get_con():
    host = 'localhost'
    pwd = '123'
    return pymysql.connect(host=host, port=3306, db='jianshu', user='abc', password=pwd, charset='utf8mb4')

class JianshuPipeline(object):

    def process_item(self, item, spider):
        if isinstance(item, JianshuUserInfo):
            item = dict(item)
            author = item.get('author')
            url = item.get('url')
            following = item.get('following')
            followers = item.get('followers')
            article = item.get('article')
            chars = item.get('chars')
            collection = item.get('collection')
            author_abstract = item.get('author_abstract')
            con = get_con()
            cur = con.cursor()
            params = [author, url, following, followers, article, chars, collection, author_abstract]
            # print(params)
            insert_sql = 'insert jianshu_user_info values(0,%s,%s,%s,%s,%s,%s,%s,%s);'
            res = cur.execute(insert_sql, params)
            con.commit()
            cur.close()
            con.close()
            return item

        if isinstance(item, JianshuItem):
            item = dict(item)
            user = item.get('user')
            articles = item.get('articles')
            current_page = item.get('current_page')
            url = item.get('url')
            article_list = item.get('article_list')
            print('item print', [user, articles, current_page, url, len(article_list)])
            con = get_con()
            cur = con.cursor()
            id_sql = 'select id from jianshu_user_info where author=%s'
            cur.execute(id_sql, [user])
            rst = cur.fetchone()
            id = rst[0]
            for article in article_list:
                abstract = article.get('article_abstract')
                title = article.get('article_title')
                link = article.get('article_link')
                params = [user, title, link, abstract, id]
                insert_sql = 'insert jianshu_user_article values(0,%s,%s,%s,%s,%s);'
                cur.execute(insert_sql, params)
            con.commit()
            cur.close()
            con.close()
            return item

数据库采用mysql,创建库的时候注意使用'utf8mb4'编码,兼容一些特殊编码,根据返回的不同item插入不同的表中,注意jianshu_user_article表中有一个字段usr_id, 该字段表示文章作者信息的表id

以下是数据创建脚本:

create database jianshu charset=utf8mb4;


create table jianshu_user_article(
    id int(16) unsigned auto_increment primary key not null,
    author varchar(20),
    title varchar(120),
    link varchar(64),
    abstract varchar(120),
    usr_id int(16));

create table jianshu_user_info(
     id int(16) unsigned auto_increment primary key not null,
     author varchar(20),
     url varchar(64),
     following varchar(16),
     followers varchar(16),
     article varchar(16),
     chars varchar(16),
     collection varchar(16),
     author_abstract varchar(500));

最后贴上settings部分配置:

CONCURRENT_REQUESTS = 16

DOWNLOADER_MIDDLEWARES = {
    'jianshu.middlewares.ProxyMiddleware': 100
}

ITEM_PIPELINES = {
   'jianshu.pipelines.JianshuPipeline': 300,
}

并发请求数量是默认的16个,中间件以及管道也要在设置中开启,爬这么多数据,User-Agent、代理也必须要加上

整个项目就完成啦

相关文章

网友评论

  • WangNing_寧:你这个有进行模拟登陆吗?项目的源码方便分享一份吗?
    WangNing_寧:@hellodyp 嗯,到时麻烦留个git链接
    hellodyp:@WangNing_寧 爬简书的简单信息是不用模拟登录的,源码我过段时间上传到git上,你可以把文章里的代码合并一下基本上就和源码差不多了

本文标题:scrapy简书技术博客爬虫

本文链接:https://www.haomeiwen.com/subject/vfsawftx.html