一个 Flickr 爬虫

课程 Project 中,需要实现一篇论文。论文中提到 “从 Flickr 上抓取了 1 million 张图片” 。这真真是极好的:Flickr 上的图片质量还是不错的,而且一般没有 NSFW 类型的图片出现。

本来想用之前的几个小爬虫来完成这个任务,但是发现改动量会比较大,那就重新写一个。

想法

使用 Flickr 的 flickr.interestingness.getList 接口,爬取每天最受欢迎的图片。

由于此网站在墙外,如果出现一些未知问题,需要能再次启动,启动后能从停下来的地方继续跑。

分析

特别想用 Redis 当队列。听说那它可以实现分布式?

想了一下,大致使用 “生产者 – 消费者” 模式来完成这个任务。生产者是一个 for 循环,从今天开始向前遍历每一天,从接口里面获取每日图片列表,拿到返回值,解析 Json/XML,放到一个下载队列里面;消费者从下载队列里面获取到图片的 url,开始下载。

Flickr 才诞生多长时间啊?就算它 10 年,10 年 3650 天,一会儿就能遍历完毕了。所以生产者使用单线程就可以了。消费者呢?没啥说的,绝对的多线程。当然,如果喜欢单线程我也不拦着。

图片存放的时候直接使用图片 ID 就可以了。虽然可以按照 Flickr 的规则,使用 farm/server/id 的形式进行存放——无所谓啦~

除了使用 Redis 做下载队列之外,为了达到 “能从摔倒的地方爬起来”,我们还要将生产者当前的 “时间” 计数器再保存到 Redis 中。每次启动的时候读取这个计数器。

实现

首先,不考虑上面的 “跌倒之后再爬起来” 的需求,先实现一个版本,然后再加上 Redis,透明替换原来的 Queue 和 Counter 。

注意:调试的时候需要挂全局梯子(Console 挂梯子好像需要 TUN?)或者改 hosts 。 CN 以外的国家请无视。

import time
import redis
import json
import datetime
import os
from threading import Thread
from queue import Queue
import pickle
from urllib import request
from urllib.parse import urlencode


class Counter:
    def __init__(self):
        self.__date = datetime.date.today()

    def pre(self):
        self.__date -= datetime.timedelta(1)

    def next(self):
        self.__date += datetime.timedelta(1)

    def get_date(self):
        return self.__date.isoformat()


class RedisCounter:
    def __init__(self, name, namespace='counter', **redis_kwargs):
        self.__db = redis.Redis(**redis_kwargs)
        self.key = '%s:%s' % (namespace, name)
        self.__date = self.__db.get(self.key)
        if self.__date is None:
            self.__date = datetime.date.today()
        else:
            self.__date = pickle.loads(self.__date)

    def pre(self):
        self.__date -= datetime.timedelta(1)
        self.__update_to_redis()

    def next(self):
        self.__date += datetime.timedelta(1)
        self.__update_to_redis()

    def __update_to_redis(self):
        self.__db.set(self.key, pickle.dumps(self.__date))

    def get_date(self):
        return self.__date.isoformat()


class RedisQueue:
    """Simple Queue with Redis Backend"""

    def __init__(self, name, namespace='queue', **redis_kwargs):
        """The default connection parameters are: host='localhost', port=6379, db=0"""
        self.__db = redis.Redis(**redis_kwargs)
        self.key = '%s:%s' % (namespace, name)

    def qsize(self):
        """Return the approximate size of the queue."""
        return self.__db.llen(self.key)

    def empty(self):
        """Return True if the queue is empty, False otherwise."""
        return self.qsize() == 0

    def put(self, item):
        """Put item into the queue."""
        self.__db.rpush(self.key, pickle.dumps(item))

    def get(self, block=True, timeout=None):
        """Remove and return an item from the queue.

        If optional args block is true and timeout is None (the default), block
        if necessary until an item is available."""
        if block:
            item = self.__db.blpop(self.key, timeout=timeout)
        else:
            item = self.__db.lpop(self.key)

        if item:
            item = item[1]
        return pickle.loads(item)

    def get_nowait(self):
        """Equivalent to get(False)."""
        return self.get(False)


class ImageFinder(Thread):
    def __init__(self, date_start=None, database=None, page_per_day = 5):
        super(ImageFinder, self).__init__()
        self.base_url = "https://api.flickr.com/services/rest/"
        self.__date = date_start
        self.__db = database
        self.__page_per_day = page_per_day

    def run(self):
        while True:
            if self.__db.qsize() < 10:
                try:
                    for page_num in range(self.__page_per_day):
                        self.__parse_page(self.__date.get_date(), page_num)
                except Exception as e:
                    pass
                self.__date.pre()

    def __parse_page(self, day, page_num):
        header, param = self.__class__.__param_gen(day, 
                                                    page = page_num,
                                                    per_page = 500)
        print(self.base_url + "?" + param.decode('utf-8'))
        data = json.loads(request.urlopen(self.base_url, param, timeout=5).read().decode('utf-8'))
        if "photos" in data.keys():
            for item in data["photos"]["photo"]:
                image_url = "https://farm{farm}.staticflickr.com/{server}/{id}_{secret}_b.jpg".format(
                    farm=item['farm'],
                    server=item['server'],
                    id=item['id'],
                    secret=item['secret']
                )
                self.__db.put({"id": item['id'], "url": image_url})

    @classmethod
    def __param_gen(cls, day, **kwargs):
        headers = {
            "Connection": "keep-alive",
            "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/45.0.2454.85 Safari/537.36",
            "Accept": "*/*",
            "Accept-Encoding": "gzip, deflate, sdch",
            "DNT": "1",
            "Accept-Language": "zh-CN,zh;q=0.8,en;q=0.6,zh-TW;q=0.4"
        }
        request_parameter = {
            "format": "json",
            "method": "flickr.interestingness.getList",
            "api_key": "ab9bdd8345f528d08adf806d41712de0",
            "nojsoncallback": "1"
        }
        request_parameter["date"] = day
        for key, value in kwargs:
            request_parameter[key] = value
        return headers, urlencode(request_parameter).encode()


class DataSaver(Thread):
    def __init__(self, database=None, base_dir=""):
        super(DataSaver, self).__init__()
        self.base_dir = base_dir
        self.__db = database

    def run(self):
        while True:
            record = self.__db.get()
            try:
                request.urlretrieve(record["url"], os.path.join("result", record["id"] + ".jpg"))
            except Exception as e:
                print(e)


def main():
    counter = RedisCounter('flickr_counter')
    queue = RedisQueue('flickr_queue')
    # counter = Counter()
    # queue = Queue()
    image_finder = ImageFinder(counter, queue)
    image_finder.start()
    for i in range(5):
        ds = DataSaver(queue)
        ds.start()


if '__main__' == __name__:
    main()

API KEY 的获取

我们没有必要为了这个需求再去注册一个 Flickr 应用。在这个 API 调用示例页面里面就可以获取到。

点击 “Call Method”,下方会有一个 URL,找到 api_key,复制出来。

结果

1 个生产者和 5 个消费者,经过一晚上的艰苦爬行,墙外服务器上的程序顺利把硬盘撑爆(20G 硬盘)报错挂掉。墙内我的工作电脑顺利从 2016 年 12 月 1 日枚举到了 1986 年某月某日,收获图片 10W 余张,总大小 26G 左右。

这下有素材了。可以进行后续的分类和聚类工作了。

自我批评

上面的爬虫有一个先天缺陷:没有对 ID 进行判重,导致一些图片可能会被下载多次。不想改了,完成任务就好。是吧?

而且,通过看 API 文档,我们可以发现,每日不仅仅有 500 张图片。其实每日推荐图片有 500×10 张。这也就是论文中说 1 million 的缘故吧。~~懒得再加一层循环了……~~循环已加入,但未验证。

参考资料

  1. Automatic Content-Aware Color and Tone Stylization, CVPR 2016
  2. Flickr 服務:Flickr API: flickr.interestingness.getList
  3. Python3 Redis 使用说明