Python网络爬虫1 - 爬取网易LOFTER图片

LOFTER是网易出品的优质轻博客,灵感源于国外的tumblr,但比之更加文艺,更加本地化。本人非常喜欢LOFTER的UI设计,以及其中的优质用户和内容,似乎网易并不擅长推广,所以受众并不广泛。这都是题外话,本文主要记录作者近期学习python3并用之爬取LOFTER用户图片的过程和成果,与大家交流分享。

本文将以本人litreily博客为例说明整个爬取过程

分析LOFTER站点

在爬取站点之前,首先需要分析站点的关键信息有哪些,如果给自己提问,可能会有以下问题:

  1. 用户的主页网址格式是?
  2. 用户博客链接的格式是?
  3. 每篇博客内的图片链接的格式是?
  4. 不同用户的主页模板不同,是否可以按同样方法抓取博客信息?
  5. 用户的博客数量巨大,主页以什么方式分页?
  6. 有没有归档页面方便爬取(大多数网站都有归档页面)?

当然,这些问题不是一下子就能想出来,可以在探索网页内容的过程逐步展开,并思考下一步该考虑的问题,下面针对各个问题对主页进行探索分析。

主页信息

主页: http://[username].lofter.com

litreily

从主页可以看到归档的链接,暂时不管。不同的用户,其主页所用模板不尽一致,LOFTER为提供了大量精美的主页模板,以满足不同用户的需求:

lofter templet

然而,正是因为所用模板不同,其网页内容格式也不同,这个从不同模板中图片的位置,大小,图片信息等就可以看出来。相同的资源,不同的展示方式,就好像同样一件艺术品,既可以摆放在玻璃框中,也可以悬挂在高空。

当然这不是本文重点,这里只是为了说明不同用户的主页信息展示不一样,会给爬虫爬取带来一定影响。

分页信息

点击主页尾部的下一页,可以跳转至下一页,除首页和末页外,都会有上一页和下一页的链接,这里就给了我们一个提示,我们可以先抓取首页信息,然后从中抓取到下一页的链接,然后不断获取下一页的博客信息。

pages

或者更简单点,看网址栏中的网址格式:

分页: http://[username].lofter.com/?page=[pageNumber]&t=[timeStamp]

直接使用for循环修改page值,逐页爬取博客信息。这貌似是个不错的想法,好,假设这样可行,那我们来分析每一页的信息。

post link

如上图所示,首先找到博文永久链接http://litreily.lofter.com/post/44fbca_1265bb3e

针对含有图片的某一篇博文,litreily所用模板中会出现两次博文链接(见图中红框标注的两处),倘若我们使用正则表达式:

re.findall(r'"http://.*lofter.com/post/[\w_]*"', html)

将对每篇博文匹配出两个一样的链接,这可不是我们想要的,那咋整,匹配完再把重复的删了?不至于这么麻烦,细看两处链接前后信息,可以看到两处链接的class属性不一致,好办了,咱改改正则:

re.findall(r'<a class="img" href="(http://.*lofter.com/post/[\w_]*)">', html)

好像可以了,这不就可以按页抓取博客链接,然后接着分析每篇博文信息不就好了么。我原本就想这么干,可是当我查看了不同用户的排版以及相应的链接信息后,整个人都不好了,一千个用户就是一千个哈姆雷特啊。如果你发现有统一解析所有用户模板信息的方法,那肯定是你看的模板不够多。

所以呢,这条路是走不通了,至少我没再往这条路上走。打道回府,只不过重头再来,路漫漫其修远兮,吾将上下而求索。

靠,说了半天,原来走不通,那你说个毛线!!!淡定淡定,都是文明人,后面的风景很远,额不是,,,是很美,请耐心等待...

归档页信息

归档页:http://[username].lofter.com/view

好了,还记得前面说的归档吧,归档可是个好东西,它把所有博文都按日期归档,最主要的是,所有用户的归档页面都是同一个模板,不管大V小v还是普通老百姓,真的是一视同仁。剩下的问题就是如何从归档页抓取每篇博客的真实路径

archive

先来看看归档页面的结构吧,博客按月份归档,每篇博客仅显示首张图片缩略图或纯文本。然后F12打开调试工具,如下图所示,每个月份对应一个<div class="m-filecnt m-filecnt-1">这样的节点,每个月份节点包含了本月所有博客的入口信息,一篇博客对应一个id号,以及一个博客的相对路径id神马的不用关心,重点就是这个相对路径,有了它不就有了博客的绝对路径了么。

  • 相对路径:"/post/44fbca_1265bb3e"
  • 绝对路径:http://[username].lofter.com/post/44fbca_1265bb3e

archive structure

这样看来,那岂不是只要抓取这一个归档页面就可以抓到所有的博客路径了呢?呵呵,真的这么容易吗?显然不大可能,当我们下拉页面时,归档信息将动态加载刷新,没错,是动态的!!!意料之中的猝不及防

接着我在Chrome浏览器中Ctrl+U看了看网页的源码(太长这就不放图了),果然不出所料,动态数据在源码中是木有的,只有一个脚本在那静静的躺着,躺着,躺着。。。难道就要放弃了吗,当然不!只要是网络通信,就必然有请求包和响应包

那现在的问题就是,动态网页的数据是如果获取到的?动态数据的真实请求是什么?抓包看看呗,打开浏览器调试工具中的Network,刷新归档页,看看页面加载过程,找到真实请求,这个很好找,这类请求的后缀一般不会是png,jpg,gif,js,css等,而且多半是POST包,并且会出现在一堆图片请求的前面。

post

好了,找到了,就是上面这货。现在归档数据请求的链接有了,确实是POST包,同样,请求包的头部信息headers和请求参数request payload也有了。

request values

现在的关键问题是,这些请求包中的参数都是干嘛的?经我多方尝试、猜测与观察,总结如下:

callCount=1       # 固定
scriptSessionId=${scriptSessionId}187   # 固定
httpSessionId=    # 固定
c0-scriptName=ArchiveBean       # 固定
c0-methodName=getArchivePostByTime      # 固定
c0-id=0           # 固定
c0-param0=number:4520906        # 用户ID,可从用户主页源码获取
c0-param1=number:1521342313224  # 时间戳,最最最关键参数!
c0-param2=number:50       # 单次请求博客篇数,可以按需求修改
c0-param3=boolean:false   # 固定
batchId=822456            # 6位随机数,爬取时可以固定

所以我们模拟请求包的时候就按这个来就可以了,至于时间戳怎么获取,请求包的headers如何确定,后面会有详述。

下面我们来看看请求后得到的响应包长啥样,look,就下面这个,看到没,**permalink**, 千呼万唤始出来啊,这不就是我们想要的博客固定路径了么。响应包并不是html文件,而是一组数据,我觉着归档页包含的那个脚本就是根据这个数据文件进而请求首张图片信息或文本信息的,当然这是我的猜测了,有兴趣的可以去看看那个脚本。

post response

有了这组数据,咱就可以获取每次请求得到的博客路径列表,进而逐一爬取博客内的图片链接了。

到此处为止,归档页的信息就分析完了,我们已经知道该发送怎样的请求包去获取归档数据,与此同时,我们也知道了从归档页如何获取每篇博客的真实路径。

下面就来看看当我们知道博客路径并抓取后,该如何获取每篇博客正文内的图片链接。

博客页信息

博客: http://[username].lofter.com/post/******_********

以上面获取的博客 http://litreily.lofter.com/post/33a459_1230cb50 为例,大部分博客内的图片都不止一张,这也是必须访问博客页本身的主要原因,好了照旧查看页面元素。

blog pictures

可以发现每篇博客内所有图片的大图链接都是上图框选中这样的,都有着同样的属性bigimgsrc,并且是博客页面唯一的。由于每篇博客源码内包含了该篇博客所有的图片链接,所以当我们获取了某篇博客的html文件后,便可以使用正则表达式获取所有图片链接。

至此,我们已经掌握了爬取lofter单用户博客图片所需的所有信息,是时候确定爬取方案了。

确定爬取方案

首先,根据给定的username获取uid作为POST请求包数据中的一分子;然后,循环执行以下步骤直至全部爬取完成

  1. 生成或更新归档页请求数据
  2. 模拟归档页面发送POST请求
  3. 解析响应数据并获取博客链接
  4. 逐一爬取博客内容
  5. 解析博客内容并获取图片链接
  6. 逐一下载图片至本地

代码实现

方案确定好了,那就撸起袖子加油干吧!

依赖库

  • requests

Only one! 没错,依赖的第三方库就这一个,怎么装咱这就不说了

获取用户ID

用户ID,确切的说是用户博客的唯一ID,是归档页请求报文中的参数之一,通过查看主页源码找到了相应的字符串,所以只要用request.get抓取首页然后匹配ID字符串就可以了,代码如下:

def _get_blogid(username):
    try:
        html = requests.get('http://%s.lofter.com' % username)
        id_reg = r'src="http://www.lofter.com/control\?blogId=(.*)"'
        blogid = re.search(id_reg, html.text).group(1)
        print('The blogid of %s is: %s' % (username, blogid))
        return blogid
    except Exception as e:
        print('get blogid from http://%s.lofter.com failed' % username)
        print('please check your username.')
        exit(1)

生成POST请求数据

根据前面归档页的分析,我们知道POST请求中除了一些固定参数外,还有用户ID,时间戳timestamp以及单次请求的博客篇数N需要确定,而ID已经在前面已经获取到了;博客篇数可以自定义一个数,如40;最后就剩下时间戳了。

经过多次尝试发现,这个时间戳timestamp是所有参数中唯一一个需要在每次请求中不断更新的参数。那么它更新的依据是什么呢?每篇博客都对应着一个timestamp,而且是博客的发布时间,每次请求后得到的最后一篇博客的timestamp就可以作为下一次请求的timestamp。为什么呢,因为我多次实验发现,在给定一个timestamp并发送POST请求后,服务器会以请求参数中的时间戳为起点按时间顺序往前检索出指定篇数(如:40)的博客信息

响应包的博客信息中包含了每篇博客的时间戳,所以每次获取响应包后,只要解析出响应包中最后一篇博客的时间戳,就可以作为下一次请求中的时间戳。

根据以上分析,可以写出获取时间戳的函数如下:

# time_pattern: re.compile('s%d\.time=(.*);s.*type' % (query_number-1))
def _get_timestamp(html, time_pattern):
    if not html:
        timestamp = round(time.time() * 1000)  # first timestamp(ms)
    else:
        timestamp = time_pattern.search(html).group(1)
    return str(timestamp)

注意,首次请求的时间戳可以直接使用当前系统时间(ms)

发送POST请求包

POST请求包的url是固定的,data就是前面获取到的所有请求参数,headers如下:

headers = {
        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/65.0.3325.146 Safari/537.36',
        'Host': username + '.lofter.com',
        'Referer': 'http://%s.lofter.com/view' % username,
        'Accept-Encoding': 'gzip, deflate'
    }

其中,User-Agent用于模拟浏览器请求,后面三个参数最好都加上,否则可能无法请求成功。POST请求其实就是一条语句requests.post,具体实现如下:

def _get_html(url, data, headers):
    try:
        html = requests.post(url, data, headers = headers)
    except Exception as e:
        print("get %s failed\n%s" % (url, str(e)))
        return None
    finally:
        pass
    return html

解析POST响应包

在获取响应包的文本html后,便可从中获取本次请求得到的所有博客的相对路径,然后生成绝对路径,进而逐一抓取博客原文,从原文中抓取所有图链。

# get urls of blogs: s3.permalink="44fbca_19a6b1b"
new_blogs = blog_url_pattern.findall(html)
num_new_blogs = len(new_blogs)
num_blogs += num_new_blogs

if num_new_blogs != 0:
    print('NewBlogs:%d\tTotalBolgs:%d' % (num_new_blogs, num_blogs))
    # get imgurls from new_blogs
    imgurls = []
    for blog in new_blogs:
        imgurls.extend(_get_imgurls(username, blog, headers))
    num_imgs += len(imgurls)

以上代码便是获取POST的响应包html后的解析操作,其中_get_imurls是用于抓取博客原文并解析出所有图链的函数。

def _get_imgurls(username, blog, headers):
    blog_url = 'http://%s.lofter.com/post/%s' % (username, blog)
    blog_html = requests.get(blog_url, headers = headers).text
    imgurls = re.findall(r'bigimgsrc="(.*?)"', blog_html)
    print('Blog\t%s\twith %d\tpictures' % (blog_url, len(imgurls)))
    return imgurls

下载图片

def _capture_images(imgurl, path):
    headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/65.0.3325.146 Safari/537.36'}
    for i in range(1,3):
        try:
            image_request = requests.get(imgurl, headers = headers, timeout = 20)
            if image_request.status_code == 200:
                open(path, 'wb').write(image_request.content)
                break
        except requests.exceptions.ConnectionError as e:
            print('\tGet %s failed\n\terror:%s' % (imgurl, e))
            if i == 1:
                imgurl = re.sub('^http://img.*?\.','http://img.',imgurl)
                print('\tRetry ' + imgurl)
            else:
                print('\tRetry fail')
        except Exception as e:
            print(e)
        finally:
            pass

有了图链,最后的工作当然是下载图片了,上面这段代码便是用来下载图片的,headers是为了模拟浏览器访问。那为什么要尝试下载两次呢?因为我在抓取过程中,有时候会出现抓取失败的情况,并显示以下错误信息:

'Connection aborted.', RemoteDisconnected('Remote end closed connection without response'

所以在Retry前先将图链对应的host稍加修改,这样可以保证更高的成功率,但并不能完全避免。对于下载失败的情况,可能是:

  1. 被反爬了(极大可能)
  2. 网络通信不畅(可能性低)
  3. 图链失效
  4. 服务器出毛病了

有时候,同样一个图链,过一段时间去抓就好了,或者换个网络就好了。我猜测是被反爬,但证据不足,所以只能降低爬取频率,比如每发送接收一次POST请求便sleep10s左右,但还是会有失败的情况,如果大家有更好的意见,欢迎交流。目前情况,正常情况100%爬取完全没问题,异常情况90%以上吧。

主循环

好了,其它零碎的代码就不多说了,爬虫主循环流程如下,其实就是以上步骤的整合:

  1. 爬取归档页面指定篇数query_number的博文链接new_blogs
  2. 逐个爬取博文blog数据,获取每篇blog的所有大图链接imgurls
  3. 逐个爬取大图链接imgurls,下载图片至本地目录
  4. 判断是否已爬取完所有博文
    • 若已爬完,则显示爬取成果信息,并退出
    • 若未爬完,则更新请求包中的时间戳timestamp,返回第1步继续爬取新的博文

完整代码

#!/usr/bin/env python
# -*- coding:utf-8 -*-
# date: 2018.03.07
"""Capture pictures from lofter with username."""

import re
import os
import platform

import requests

import time
import random


def _get_path(username):
    path = {
        'Windows': 'D:/litreily/Pictures/python/lofter/' + username,
        'Linux': '/mnt/d/litreily/Pictures/python/lofter/' + username
    }.get(platform.system())

    if not os.path.isdir(path):
        os.makedirs(path)
    return path


def _get_html(url, data, headers):
    try:
        html = requests.post(url, data, headers = headers)
    except Exception as e:
        print("get %s failed\n%s" % (url, str(e)))
        return None
    finally:
        pass
    return html


def _get_blogid(username):
    try:
        html = requests.get('http://%s.lofter.com' % username)
        id_reg = r'src="http://www.lofter.com/control\?blogId=(.*)"'
        blogid = re.search(id_reg, html.text).group(1)
        print('The blogid of %s is: %s' % (username, blogid))
        return blogid
    except Exception as e:
        print('get blogid from http://%s.lofter.com failed' % username)
        print('please check your username.')
        exit(1)


def _get_timestamp(html, time_pattern):
    if not html:
        timestamp = round(time.time() * 1000)  # first timestamp(ms)
    else:
        timestamp = time_pattern.search(html).group(1)
    return str(timestamp)


def _get_imgurls(username, blog, headers):
    blog_url = 'http://%s.lofter.com/post/%s' % (username, blog)
    blog_html = requests.get(blog_url, headers = headers).text
    imgurls = re.findall(r'bigimgsrc="(.*?)"', blog_html)
    print('Blog\t%s\twith %d\tpictures' % (blog_url, len(imgurls)))
    return imgurls


def _capture_images(imgurl, path):
    headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/65.0.3325.146 Safari/537.36'}
    for i in range(1,3):
        try:
            image_request = requests.get(imgurl, headers = headers, timeout = 20)
            if image_request.status_code == 200:
                open(path, 'wb').write(image_request.content)
                break
        except requests.exceptions.ConnectionError as e:
            print('\tGet %s failed\n\terror:%s' % (imgurl, e))
            if i == 1:
                imgurl = re.sub('^http://img.*?\.','http://img.',imgurl)
                print('\tRetry ' + imgurl)
            else:
                print('\tRetry fail')
        except Exception as e:
            print(e)
        finally:
            pass


def _create_query_data(blogid, timestamp, query_number):
    data = {'callCount':'1',
    'scriptSessionId':'${scriptSessionId}187',
    'httpSessionId':'',
    'c0-scriptName':'ArchiveBean',
    'c0-methodName':'getArchivePostByTime',
    'c0-id':'0',
    'c0-param0':'number:' + blogid,
    'c0-param1':'number:' + timestamp,
    'c0-param2':'number:' + query_number,
    'c0-param3':'boolean:false',
    'batchId':'123456'}
    return data


def main():
    # prepare paramters
    username = 'litreily'
    blogid = _get_blogid(username)
    query_number = 40
    time_pattern = re.compile('s%d\.time=(.*);s.*type' % (query_number-1))
    blog_url_pattern = re.compile(r's[\d]*\.permalink="([\w_]*)"')

    # creat path to save imgs
    path = _get_path(username)

    # parameters of post packet
    url = 'http://%s.lofter.com/dwr/call/plaincall/ArchiveBean.getArchivePostByTime.dwr' % username
    data = _create_query_data(blogid, _get_timestamp(None, time_pattern), str(query_number))
    headers = {
        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/65.0.3325.146 Safari/537.36',
        'Host': username + '.lofter.com',
        'Referer': 'http://%s.lofter.com/view' % username,
        'Accept-Encoding': 'gzip, deflate'
    }

    num_blogs = 0
    num_imgs = 0
    index_img = 0
    print('------------------------------- start line ------------------------------')
    while True:
        html = _get_html(url, data, headers).text
        # get urls of blogs: s3.permalink="44fbca_19a6b1b"
        new_blogs = blog_url_pattern.findall(html)
        num_new_blogs = len(new_blogs)
        num_blogs += num_new_blogs

        if num_new_blogs != 0:
            print('NewBlogs:%d\tTotalBolgs:%d' % (num_new_blogs, num_blogs))
            # get imgurls from new_blogs
            imgurls = []
            for blog in new_blogs:
                imgurls.extend(_get_imgurls(username, blog, headers))
            num_imgs += len(imgurls)

            # download imgs
            for imgurl in imgurls:
                index_img += 1
                paths = '%s/%d.%s' % (path, index_img, re.search(r'(jpg|png|gif)', imgurl).group(0))
                print('{}\t{}'.format(index_img, paths))
                _capture_images(imgurl, paths)

        if num_new_blogs != query_number:
            print('------------------------------- stop line -------------------------------')
            print('capture complete!')
            print('captured blogs:%d images:%d' % (num_blogs, num_imgs))
            print('download path:' + path)
            print('-------------------------------------------------------------------------')
            break

        data['c0-param1'] = 'number:' + _get_timestamp(html, time_pattern)
        print('The next TimeStamp is : %s\n' % data['c0-param1'].split(':')[1])
        # wait a few second
        time.sleep(random.randint(5,10))


if __name__ == '__main__':
    main()

爬取测试

lofter spider

pictures

说在最后