Мой обзор фильма Douban Crawling Journey

Python рептилия Scrapy

предисловие

Поскольку рекомендательная система на основе машинного обучения была исследована недавно, для обучения моделей ИИ требуется большой объем данных, но в процессе тестирования и проверки моделей из-за отсутствия китайских наборов данных (или их люди проделали большую работу в этом отношении) очень плохо), может использовать только наборы данных зарубежных общедоступных рекомендательных систем, есть известныеНабор данных о рейтингах фильмов MovieLensиDel.icio.us Набор данных рекомендаций по ссылкам, хотя плюсы и минусы рекомендательной модели можно примерно оценить, рассчитав функцию потерь для проведения соответствующих оптимизаций, но из-за различий в локали, культуре и т. д. определенный разрыв между оценками иностранцев того или иного фильма все же существует и наши. , при выводе результата рекомендации, даже если определенному фильму или определенной ссылке на веб-сайт дается высокое сходство, я все еще не уверен, действительно ли результат рекомендации так точен, как вычисляет функция потерь. Поэтому, чтобы иметь китайский набор данных, который можно использовать для обучения, в этой статье записан процесс захвата обзоров фильмов Douban.

анализ сайта

Прежде всего, нам нужно проанализировать сайт, который нужно просканировать.Дубан фильм, в основном с помощью средств отладки поисковой системы или браузера, чтобы увидеть, есть ли API, которые можно использовать, а затем начать анализировать структуру страниц веб-сайта и найти информацию, которую можно извлечь, исходя из предположения, что API не найдены.
Через поисковик нашелПлатформа разработчиков Douban,существуетДубан фильмВ документации есть подробные интерфейсы для получения фильмов, получения рецензий на фильмы и т. д. Как раз когда я думал, что очередной сбор данных станет очень простым, меня все же успокоила следующая картинка.

Если вы зарегистрировались в качестве разработчика Douban до 2015 года, поздравляем, вы можете получать любые данные через API или SDK, предоставляемые Douban.

Получить информацию о фильме

Хотя невозможно получить APIKey, через документацию я все же обнаружил, что некоторые GET-запросы не требуют аутентификации AUTH, то есть наличие или отсутствие APIKey не влияет на использование. Среди них, что нам полезно, так это получитьTOP250电影列表Интерфейс:

http://api.douban.com/v2/movie/top250

Формат, возвращаемый интерфейсом, примерно следующий:

Он содержит некоторую подробную информацию о фильме, которой достаточно для рекомендательной системы.

Кроме того, используя инструменты отладки Chrome,Дубан Фильмы - КатегорияНа этой странице я нашел используемый ими интерфейс JQuery, который также является запросом GET и не требует AUTH.


Подробный интерфейс выглядит следующим образом: вы можете итеративно получить все записи фильмов, изменив start

https://movie.douban.com/j/new_search_subjects?sort=T&range=0,10&tags=&start=20

Этот интерфейс может получить все фильмы, включенные в Douban.После моего теста, когда начало изменено на 10000, возвращаемые данные уже пусты.

Получить информацию о обзоре фильма

Теперь, когда есть метод получения информации о фильме, следующим шагом будет анализ того, как получить информацию о просмотре фильма.
На странице сведений о каждом фильме, напримерТрумэн, мы нашли следующие страницы с подробными сведениями, на которых показаны обзоры фильмов и краткие обзоры для этого фильма соответственно.

https://movie.douban.com/subject/1292064/reviews ##影评页面
https://movie.douban.com/subject/1292064/comments ##短评页面

Как обычно, после использования инструмента отладки, чтобы увидеть, есть ли какой-либо доступный API-интерфейс, я обнаружил, что на этот раз мне не так повезло.Эта страница обзора фильма отображается сервером.

Без интерфейса проанализируем страницу, все же через средство отладки:

Каждый комментарий находится в блоке div элемента обзора, а все комментарии находятся в блоке div списка обзора, мы можем легко найти подробную информацию о каждом комментарии с помощью синтаксиса xpath, ниже приведен оператор xpath всей информации. на него извлекать содержимое, когда пишем краулер

评论列表: "//div[contains(@class,'review-list')]//div[contains(@class,'review-item')]"

评论ID: ".//div[@class='main-bd']//div[@class='review-short']/@data-rid"
作者头像: "./header[@class='main-hd']//a[@class='avator']//img/@src "
作者昵称: ".//header//a[@class='name']/text()"
推荐程度(评分): ".//header//span[contains(@class,'main-title-rating')]/@title"
影评标题: ".//div[@class='main-bd']//h2//a/text()"
影评摘要: ".//div[@class='main-bd']//div[@class='short-content']/text()"
影评详情页链接: ".//div[@class='main-bd']//h2//a/@href"

Среди них мы не можем получить конкретную оценку напрямую, а можем получить только конкретное текстовое описание.После моей проверки конкретные соответствующие отношения выглядят следующим образом:

'力荐': 5,
'推荐': 4,
'还行': 3,
'较差': 2,
'很差': 1,  

В последующем процессе написания кода мы преобразуем его в соответствующую рейтинговую информацию в соответствии с этим соответствием.

внедрить обходчик

Теперь, когда анализ почти завершен и необходимая нам информация в основном доступна, мы приступим к реализации конкретного краулера.Мы используем Scrapy, фреймворк краулера Python, чтобы упростить процесс разработки краулера. Установка Scrapy и построение среды VirtualEnv подробно обсуждаться не будут, и они не входят в рамки данной статьи.Адрес документа Scrapy на китайском языке

Создать проект проекта

##创建DoubanSpider工程
scrapy startproject Douban

Созданный каталог проекта выглядит примерно так:

в:

    spiders: 爬虫文件夹,存放具体的爬虫代码,我们待会要编写的两个爬虫(电影信息和影评信息)就需要放在这个文件夹下
    items.py: 模型类,所有需要结构化的数据都要预先在此文件中定义
    middlewares.py: 中间件类,scrapy的核心之一,我们会用到其中的downloadMiddleware,
    pipelines.py: 管道类,数据的输出管理,是存数据库还是存文件在这里决定
    settings.py: 设置类,一些全局的爬虫设置,如果每个爬虫需要有自定义的地方,可以在爬虫中直接设置custom_settings属性

Сканер информации о фильмах

Поскольку интерфейс API можно использовать для получения информации о фильме, странице здесь не нужно использовать поисковый робот для обработки данных.

Создайте новый файл movie.py в пауках, чтобы определить наш поисковый робот.

Поскольку мы сканируем фильмы через API-интерфейс, нам не нужен последующий анализ, поэтому наш краулер может напрямую наследовать Spider.

определить сканер

class MovieSpider(Spider):
    name = 'movie' #爬虫名称
    allow_dominas = ["douban.com"] #允许的域名
    
    #自定义的爬虫设置,会覆盖全局setting中的设置
    custom_settings = {
        "ITEM_PIPELINES": {
            'Douban.pipelines.MoviePipeline': 300
        },
        "DEFAULT_REQUEST_HEADERS": {
            'accept': 'application/json, text/javascript, */*; q=0.01',
            'accept-encoding': 'gzip, deflate',
            'accept-language': 'zh-CN,zh;q=0.8,en;q=0.6,zh-TW;q=0.4',
            'referer': 'https://mm.taobao.com/search_tstar_model.htm?spm=719.1001036.1998606017.2.KDdsmP',
            'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/48.0.2564.97 Safari/537.36',
            'x-requested-with': 'XMLHttpRequest',
        },
        "ROBOTSTXT_OBEY":False #需要忽略ROBOTS.TXT文件
    }

пользовательская настройка,ITEM_PIPELINESУказывает интерфейс канала, используемый для вывода данных после получения данных.
DEFAULT_REQUEST_HEADERSЭто позволяет нашему Пауку маскироваться под браузер, чтобы предотвратить его перехват Douban.
иROBOTSTXT_OBEYЭто сделано для того, чтобы наш поисковый робот проигнорировал предупреждение файла ROBOTS.txt.

Далее черезstart_requestСообщите сканеру, какую ссылку сканировать:


    def start_requests(self):
        url = '''https://movie.douban.com/j/new_search_subjects?sort=T&range=0,10&tags=&start={start}'''
        requests = []
        for i in range(500):
            request = Request(url.format(start=i*20), callback=self.parse_movie)
            requests.append(request)
        return requests

Поскольку мы анализировали веб-сайт ранее, когда параметр запуска достигает 10000, мы не можем получить данные, поэтому здесь мы напрямую используем это число, чтобы получить все ссылки в цикле.

Затем проанализируйте содержимое, возвращаемое каждым интерфейсом:


def parse_movie(self, response):
    jsonBody = json.loads(response.body)
    subjects = jsonBody['data']
    movieItems = []
    for subject in subjects:
        item = MovieItem()
        item['id'] = int(subject['id'])
        item['title'] = subject['title']
        item['rating'] = float(subject['rate'])
        item['alt'] = subject['url']
        item['image'] = subject['cover']
        movieItems.append(item)
    return movieItems

В запросе мы указываем метод parse_movie для анализа возвращаемого содержимого. Здесь нам нужно использовать элемент, определенный в items.py. Конкретный элемент выглядит следующим образом:

Определить элемент

#定义你需要获取的数据
class MovieItem(scrapy.Item):
    id = scrapy.Field()
    title = scrapy.Field()
    rating = scrapy.Field()
    genres = scrapy.Field()
    original_title = scrapy.Field()
    alt = scrapy.Field()
    image = scrapy.Field()
    year = scrapy.Field()

После того, как элементы будут возвращены в Scrapy, Scrapy вызовет тот, который мы указали в custom_setting передDouban.pipelines.MoviePipelineДля обработки полученного элемента MoviePipeline определяется в файле pipe.py, детали следующие:

Определить конвейеры

class MoviePipeline(object):

    movieInsert = '''insert into movies(id,title,rating,genres,original_title,alt,image,year) values ('{id}','{title}','{rating}','{genres}','{original_title}','{alt}','{image}','{year}')'''

    def process_item(self, item, spider):

        id = item['id']
        sql = 'select * from movies where id=%s'% id
        self.cursor.execute(sql)
        results = self.cursor.fetchall()
        if len(results) > 0:
            rating = item['rating']
            sql = 'update movies set rating=%f' % rating
            self.cursor.execute(sql)
        else:
            sqlinsert = self.movieInsert.format(
                id=item['id'],
                title=pymysql.escape_string(item['title']),
                rating=item['rating'],
                genres=item.get('genres'),
                original_title=item.get('original_title'),
                alt=pymysql.escape_string(item.get('alt')),
                image=pymysql.escape_string(item.get('image')),
                year=item.get('year')
            )
            self.cursor.execute(sqlinsert)
        return item

    def open_spider(self, spider):
        self.connect = pymysql.connect('localhost','root','******','douban', charset='utf8', use_unicode=True)
        self.cursor = self.connect.cursor()
        self.connect.autocommit(True)


    def close_spider(self, spider):
        self.cursor.close()
        self.connect.close()

В этом конвейере мы вставляем полученный элемент каждый раз в определенную таблицу данных, подключаясь к базе данных mysql.

запустить сканер

Введите в командной строке:

scrapy crawl movie


сканер обзоров фильмов

Со сканером обзоров фильмов намного сложнее, потому что мы получаем информацию о фильмах непосредственно через интерфейс.Формат данных, возвращаемых этим интерфейсом, является единым, и в основном нет ненормальной ситуации, и количество фильмов ограничено, и его можно проползли за короткое время.После этого антикраулер Дубана не сработает, и они будут сталкиваться в процессе написания краулера рецензии на фильм.

логика сканера

class ReviewSpider(Spider):
    name = "review"
    allow_domain = ['douban.com']
    custom_settings = {
        "ITEM_PIPELINES": {
            'Douban.pipelines.ReviewPipeline': 300
        },
        "DEFAULT_REQUEST_HEADERS": {
            'connection':'keep-alive',
            'Upgrade-Insecure-Requests':'1',
            'DNT':1,
            'Accept':'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8',
            'Accept-Encoding':'gzip, deflate, br',
            'Accept-Language':'zh-CN,zh;q=0.9,en;q=0.8,zh-TW;q=0.7',
            'Cookie':'bid=wpnjOBND4DA; ll="118159"; __utmc=30149280;',            'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_5) AppleWebKit/537.36 (KHTML, like Gecko) '
                          'Chrome/67.0.3396.87 Safari/537.36',
        },
        "ROBOTSTXT_OBEY": False,
        # "DOWNLOAD_DELAY": 1,
        "RETRY_TIMES": 9,
        "DOWNLOAD_TIMEOUT": 10
    }

По сравнению со сканером, который получает информацию о фильме, в custom_setting есть несколько дополнительных настроек:
RETRY_TIMES: используется для управления максимальным количеством повторных попыток, потому что у Douban есть механизм защиты от сканирования, когда IP-адрес имеет слишком много посещений, он ограничивает доступ к IP-адресу, поэтому, чтобы обойти этот механизм, мы используем IP-адрес прокси для обхода соответствующая страница, каждое сканирование Если вы берете страницу, измените IP один раз, но из-за неравномерного качества IP-адреса прокси, заряд может быть лучше, но он все равно будет существовать.Во избежание возникновения игнорирования страницы поскольку прокси не может подключиться, мы устанавливаем это значение. , когда количество повторных попыток больше установленного значения, а страница все еще не получена, соединение будет пропущено. Если качество IP-адреса вашего прокси плохое, увеличьте количество раз здесь.
DOWNLOAD_TIMEOUT: время ожидания загрузки, по умолчанию составляет 60 секунд, здесь изменено на 10 секунд, чтобы ускорить общую скорость сканирования, из-за RETRY_TIMES время оценки RETRY составляет 1 минуту, если таких проблемных страниц много, тогда все сканирование процесс будет очень долгим.
DOWNLOAD_DELAY: задержка загрузки, если у вас все еще есть доступ к 403 после использования прокси-IP, установите это значение, потому что определенный IP-адрес, который слишком часто посещает страницу, активирует механизм предотвращения сканирования Douban.

  def start_requests(self):
        #从数据库中找到所有的moviesId
        self.connect = pymysql.connect('localhost','root','******','douban', charset='utf8', use_unicode=True)
        self.cursor = self.connect.cursor()
        self.connect.autocommit(True)
        sql = "select id,current_page,total_page from movies"
        self.cursor.execute(sql)
        results = self.cursor.fetchall()
        url_format = '''https://movie.douban.com/subject/{movieId}/reviews?start={offset}'''
        for row in results:
            movieId = row[0]
            current_page = row[1]
            total_page = row[2]
            if current_page != total_page: ##说明评论没有爬完
                url = url_format.format(movieId=movieId, offset=current_page*20)
                request = Request(url, callback=self.parse_review, meta={'movieId': movieId}, dont_filter=True)
                yield request

Как обычно, мы сообщаем Scrapy начальную ссылку URL для сканирования в start_request, Согласно нашему предыдущему анализу, формат адреса страницы обзора фильма:

https://movie.douban.com/subject/{movieId}/reviews?start={offset}

Для movieId наш предыдущий поисковый робот уже собрал информацию обо всех фильмах, поэтому мы сначала получаем всю захваченную информацию о фильмах, запрашивая базу данных, получаем из нее movieId, а затем создаем ссылку на страницу.

url = url_format.format(movieId=movieId, offset=current_page*20)

Поскольку процесс сканирования обзоров фильмов Douban очень долгий, в середине будут возникать различные проблемы, которые приведут к неожиданному завершению работы сканера, поэтому нам нужен механизм, позволяющий сканеру продолжить сканирование с того места, где он остановился в прошлый раз. Текущая_страница и общая_страница для этой цели.Да, в последующем процессе анализа данных, каждый раз, когда страница анализируется, количество страниц текущей страницы будет сохранено, чтобы предотвратить непредвиденные ситуации.

    def parse_review(self, response):
        movieId = response.request.meta['movieId']
        review_list = response.xpath("//div[contains(@class,'review-list')]//div[contains(@class,'review-item')]")
        for review in review_list:
            item = ReviewItem()
            item['id'] = review.xpath(".//div[@class='main-bd']//div[@class='review-short']/@data-rid").extract()[0]
            avator = review.xpath(".//header//a[@class='avator']/@href").extract()[0]
            item['username'] = avator.split('/')[-2]
            item['avatar'] = review.xpath("./header[@class='main-hd']//a[@class='avator']//img/@src").extract()[0]
            item['nickname'] = review.xpath(".//header//a[@class='name']/text()").extract()[0]
            item['movieId'] = movieId
            rate = review.xpath(".//header//span[contains(@class,'main-title-rating')]/@title").extract()
            if len(rate)>0:
                rate = rate[0]
                item['rating'] = RATING_DICT.get(rate)
                item['create_time'] = review.xpath(".//header//span[@class='main-meta']/text()").extract()[0]
                item['title'] = review.xpath(".//div[@class='main-bd']//h2//a/text()").extract()[0]
                item['alt'] = review.xpath(".//div[@class='main-bd']//h2//a/@href").extract()[0]
                summary = review.xpath(".//div[@class='main-bd']//div[@class='short-content']/text()").extract()[0]
                item['summary'] = summary.strip().replace('\n', '').replace('\xa0(','')
                yield item

        current_page = response.xpath("//span[@class='thispage']/text()").extract()
        total_page = response.xpath("//span[@class='thispage']/@data-total-page").extract()
        paginator = response.xpath("//div[@class='paginator']").extract()
        if len(paginator) == 0 and len(review_list): ##不存在导航条,但是评论列表存在,说明评论只有一页

            sql = "update movies set current_page = 1, total_page=1 where id='%s'" % movieId
            self.cursor.execute(sql)

        elif len(paginator) and len(review_list):
            current_page = int(current_page[0])
            total_page = int(total_page[0])
            sql = "update movies set current_page = %d, total_page=%d where id='%s'" % (current_page, total_page, movieId)
            self.cursor.execute(sql)
            if current_page != total_page:
                url_format = '''https://movie.douban.com/subject/{movieId}/reviews?start={offset}'''
                next_request = Request(url_format.format(movieId=movieId, offset=current_page*20),
                                       callback=self.parse_review,
                                       dont_filter=True, meta={'movieId': movieId})
                yield next_request

        else:
            yield response.request

Затем проанализируйте функцию анализа, сбор данных DoubanItem не будет дополнительно введен, а конкретное содержимое можно легко определить с помощью оператора xpath, использованного в предыдущем анализе.
MovieId передается в стартовой ссылке через мета-атрибут запроса.Конечно, вы можете найти место, содержащее movieId, проанализировав веб-страницу.

current_page = response.xpath("//span[@class='thispage']/text()").extract()
total_page = response.xpath("//span[@class='thispage']/@data-total-page").extract()
paginator = response.xpath("//div[@class='paginator']").extract()

Функция приведенного выше базового кода в основном состоит в том, чтобы получить нижнюю панель навигации на странице обзора фильма.

Однако есть две ситуации, в которых эта панель навигации не может быть получена:

1. 当某个电影的评论不足20条时,也就是只有一页评论。
2. 当触发了豆瓣的反爬虫的机制时,返回的页面并不是评论页面,而是一个验证页面,自然也找不到导航条

Итак, в следующем коде я использую эти переменные для оценки описанных выше ситуаций:

1. 情况1时,不需要继续爬取剩下的评论,直接将current_page和total_page设置为1保存到movie表即可
2. 情况2时,由于此时触发了反爬虫机制,返回的页面没有我们的数据,如果我们直接忽略掉的话,会损失大量的数据(这种情况很常见),所以我们就干脆再试一次,返回request,让Scrapy重新爬取这个页面,因为每次重新爬取都会换一个新的代理IP,所以我们有很大概率下次抓取就是正常的。此处有一点需要注意:因为Scrapy默认会过滤掉重复请求,所以我们需要在构造Request的时候讲dont_filter参数设置为True,让其不要过滤重复链接。
3. 正常情况时,通过xpath语法获取的下一页评论的链接地址然后构造一个request交给Scrapy继续爬取

Промежуточное ПО для загрузки обзоров фильмов

Как упоминалось выше, при сканировании страниц обзора фильмов вам необходимо использовать IP-адрес прокси-сервера, чтобы обойти механизм предотвращения сканирования Douban.Конкретные настройки прокси-сервера должны быть установлены в DownloadMiddleware.

class DoubanDownloaderMiddleware(object):
# Not all methods need to be defined. If a method is not defined,
# scrapy acts as if the downloader middleware does not modify the
# passed objects.

ip_list = None

@classmethod
def from_crawler(cls, crawler):
    # This method is used by Scrapy to create your spiders.
    s = cls()
    crawler.signals.connect(s.spider_opened, signal=signals.spider_opened)
    return s

def process_request(self, request, spider):
    # Called for each request that goes through the downloader
    # middleware.

    # Must either:
    # - return None: continue processing this request
    # - or return a Response object
    # - or return a Request object
    # - or raise IgnoreRequest: process_exception() methods of
    #   installed downloader middleware will be called

    if self.ip_list is None or len(self.ip_list) == 0:
        response = requests.request('get','http://api3.xiguadaili.com/ip/?tid=555688914990728&num=10&protocol=https').text
        self.ip_list = response.split('\r\n')

    ip = random.choice(self.ip_list)
    request.meta['proxy'] = "https://"+ip
    print("当前proxy:%s" % ip)
    self.ip_list.remove(ip)
    return None

def process_response(self, request, response, spider):
    # Called with the response returned from the downloader.
    # Must either;
    # - return a Response object
    # - return a Request object
    # # - or raise IgnoreRequest

    if response.status == 403:
        res = parse.urlparse(request.url)
        res = parse.parse_qs(res.query)
        url = res.get('r')
        if url and len(url) > 0 :
            request = request.replace(url=res['r'][0])
        return request

    return response

В основном необходимо реализовать две функции: process_request и process_response.Первая вызывается Scrapy перед сканированием каждой страницы, а вторая вызывается после сканирования каждой страницы.
В первом методе мы получаем интерфейс, вызывая IP-адрес онлайн-прокси, получаем IP-адрес прокси, а затем устанавливаем атрибут прокси запроса для достижения функции замены прокси.Конечно, вы также можете прочитать IP-адрес прокси через файл.
В последнем методе мы оцениваем код состояния как 403, потому что этот код состояния указывает на то, что текущий запрос запрещен к обнаружению и запрещен антикраулерами, и все, что нам нужно сделать, это переупаковать адрес запрещенного запроса и освободить его. в очередь сканирования Scrapy.

Обзор фильма Пункт

class ReviewItem(scrapy.Item):
id = scrapy.Field()
username = scrapy.Field()
nickname = scrapy.Field()
avatar = scrapy.Field()
movieId = scrapy.Field()
rating = scrapy.Field()
create_time = scrapy.Field()
title = scrapy.Field()
summary = scrapy.Field()
alt = scrapy.Field()

Нечего сказать, просто напишите, что вы хотите сохранить

Обзор фильмов


class ReviewPipeline(object):

    reviewInsert = '''insert into reviews(id,username,nickname,avatar,summary,title,movieId,rating,create_time,alt) values ("{id}","{username}", "{nickname}","{avatar}", "{summary}","{title}","{movieId}","{rating}","{create_time}","{alt}")'''

    def process_item(self, item, spider):
        sql_insert = self.reviewInsert.format(
            id=item['id'],
            username=pymysql.escape_string(item['username']),
            nickname=pymysql.escape_string(item['nickname']),
            avatar=pymysql.escape_string(item['avatar']),
            summary=pymysql.escape_string(item['summary']),
            title=pymysql.escape_string(item['title']),
            rating=item['rating'],
            movieId=item['movieId'],
            create_time=pymysql.escape_string(item['create_time']),
            alt=pymysql.escape_string(item['alt'])
        )
        print("SQL:", sql_insert)
        self.cursor.execute(sql_insert)
        return item

    def open_spider(self, spider):
        self.connect = pymysql.connect('localhost','root','******','douban', charset='utf8', use_unicode=True)
        self.cursor = self.connect.cursor()
        self.connect.autocommit(True)


    def close_spider(self, spider):
        self.cursor.close()
        self.connect.close()
和之前的电影的pipeline类似,就是基本的数据库写操作。

запустить сканер

scrapy crawl review

Когда я закончил писать эту статью, сканеры обзоров фильмов все еще ползали:

Глядя на базу данных, уже есть 97W данных:


Если вы считаете, что моя статья полезна для вас, пожалуйста, поддержите кубок☕️