当前位置:   article > 正文

摆脱剧荒!教你用 Python 一步步爬取豆瓣电影新榜单

爬取评分,过滤分数低于9.0的

640?wx_fmt=gif

本文以豆瓣电影(非TOP250)为例,从数据爬取、清洗与分析三个维度入手,详解和还原数据爬取到分析的全链路。

640?wx_fmt=jpeg

作者 | 周志鹏

责编 | 郭   芮

旁友,暑假,已经过了一大半了。

这个遥远而炙热的名词,虽然和笔者这个上班狗已经没有任何关系,但在房间穿着裤衩,吹着空调,吃着西瓜,看着电影,依然是假期最好的打开方式。现在裤衩、空调、西瓜都唾手可得,压力全在电影这边了。

关于电影推荐和排行,豆瓣是个好地方,只是电影TOP250排名实在是太经典,经典到有点老套了。笔者想来点新花样,于是按默认的“评分最高”来排序,Emmm,结果好像比较小众:

640?wx_fmt=png

又按年代进行筛选,发现返回的结果和预期差的更远了。

怎么办捏?不如我们自己对豆瓣电影进行更全面的爬取和分析,再DIY评分规则,结合电影上映年代做一个各年代TOP100电影排行榜。


640?wx_fmt=png

数据爬取


1、网址规律探究

听说看的人越多,评分越有说服力,所以我们进入导航页,选择“标记最多”。(虽然标记的多并不完全等于看的多,但也差不多了)

640?wx_fmt=png

要找到网址变化规律,常规的套路就是先右键“审查元素”,然后通过不断的点击“加载更多”刷新页面的方式来找规律。

640?wx_fmt=png

网址规律异常的简单,开头URL不变,每翻一页,start的数值增加20就OK了。

一页是20部电影,开头我们立下的FLAG是要爬取9000部电影,也就是爬取450页。

2、单页解析+循环爬取

豆瓣灰常贴心,每一页都是JSON格式存储的规整数据,爬取和清洗都省了不少事儿:

640?wx_fmt=png

这里我们只需要伪装一下headers里面的user-agent就可以愉快的爬取了:

headers = {'User-Agent':'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/63.0.3239.132 Safari/537.36'}

直接上单页解析的代码:

  1. def parse_base_info(url,headers):
  2.     html = requests.get(url,headers = headers)   
  3.     bs = json.loads(html.text)
  4.     df = pd.DataFrame()
  5.     for i in bs['data']:
  6.         casts = i['casts']  #主演
  7.         cover = i['cover']  #海报
  8.         directors = i['directors']  #导演
  9.         m_id = i['id']  #ID
  10.         rate = i['rate'] #评分
  11.         star = i['star'] #标记人数 
  12.         title = i['title']  #片名
  13.         url = i['url']  #网址
  14.         cache = pd.DataFrame({'主演':[casts],'海报':[cover],'导演':[directors],
  15.                               'ID':[m_id],'评分':[rate],'标记':[star],'片名':[title],'网址':[url]})
  16.         df = pd.concat([df,cache])
  17.     return df

然后我们写一个循环,构造所需的450个基础网址:

  1. #你想爬取多少页,其实这里对应着加载多少次
  2. def format_url(num):
  3.     urls = []
  4.     base_url = 'https://movie.douban.com/j/new_search_subjects?sort=T&range=0,10&tags=%E7%94%B5%E5%BD%B1&start={}'
  5.     for i in range(0,20 * num,20):
  6.         url = base_url.format(i)
  7.         urls.append(url)
  8.     return urls
  9. urls = format_url(450)

两个凑一起,跑起来:

  1. result = pd.DataFrame()
  2. #看爬取了多少页
  3. count = 1
  4. for url in urls:
  5.     df = parse_base_info(url,headers = headers)
  6.     result = pd.concat([result,df])
  7.     time.sleep(random.random() + 2)
  8.     print('I had crawled page of:%d' % count)
  9.     count += 1

一个大号的功夫,包含电影ID、电影名称、主演、导演、评分、标记人数和具体网址的数据已经爬好了:

640?wx_fmt=png

下面,我们还想要批量访问每一部电影,拿到有关电影各星级评分占比等更丰富的信息,后续我们想结合评分分布来进行排序。

3、单部电影详情爬取:

我们打开单部电影的网址,取巧做法是直接右键,查看源代码,看看我们想要的字段在不在源代码中,毕竟,爬静态的源代码是最省力的。

640?wx_fmt=png

电影名称?在的!导演信息?在的!豆瓣评分?还是在的!一通CTRL+F搜索发现,我们所有需要的字段,全部在源代码中。那爬取起来就太简单了,这里我们用xpath来解析:

  1. def parse_movie_info(url,headers = headers,ip = ''):
  2.     if ip == '':
  3.         html = requests.get(url,headers = headers)
  4.     else:
  5.         html = requests.get(url,headers = headers,proxies = ip)
  6.     bs = etree.HTML(html.text)
  7.     #片名
  8.     title = bs.xpath('//div[@id = "wrapper"]/div/h1/span')[0].text  
  9.     #上映时间
  10.     year = bs.xpath('//div[@id = "wrapper"]/div/h1/span')[1].text   
  11.     #电影类型
  12.     m_type = []
  13.     for t in bs.xpath('//span[@property = "v:genre"]'):
  14.         m_type.append(t.text)   
  15.     a = bs.xpath('//div[@id= "info"]')[0].xpath('string()')
  16.     #片长
  17.     m_time =a[a.find('片长: ') + 4:a.find('分钟\n')]  #时长
  18.     #地区
  19.     area = a[a.find('制片国家/地区:') + 9:a.find('\n        语言')]  #地区
  20.     #评分人数
  21.     try:
  22.         people = bs.xpath('//a[@class = "rating_people"]/span')[0].text
  23.     #评分分布
  24.         rating = {}
  25.         rate_count = bs.xpath('//div[@class = "ratings-on-weight"]/div')
  26.         for rate in rate_count:
  27.             rating[rate.xpath('span/@title')[0]] = rate.xpath('span[@class = "rating_per"]')[0].text
  28.     except:
  29.         people = 'None'
  30.         rating = {}
  31.     #简介
  32.     try:
  33.         brief = bs.xpath('//span[@property = "v:summary"]')[0].text.strip('\n                                \u3000\u3000')
  34.     except:
  35.         brief = 'None'
  36.     try:
  37.         hot_comment = bs.xpath('//div[@id = "hot-comments"]/div/div/p/span')[0].text
  38.     except:
  39.         hot_comment = 'None'
  40.     cache = pd.DataFrame({'片名':[title],'上映时间':[year],'电影类型':[m_type],'片长':[m_time],
  41.                           '地区':[area],'评分人数':[people],'评分分布':[rating],'简介':[brief],'热评':[hot_comment],'网址':[url]})
  42.     return cache

第二步我们已经拿到了9000部电影所有的网址,只需写个循环,批量访问就可以了。然鹅,尽管设置了访问时间间隔,爬取上千个页面我们就会发现,豆娘还是会把我们给BAN(禁)掉。

回忆一下,我们没有登录,不需要cookies验证,只是因为频繁的访问骚扰到了豆娘。那这个问题还是比较好解决的,此处不留爷,换个IP就留爷。细心的朋友已经发现了,上面针对单部电影的页面解析,有一个默认IP参数,我们只需要在旧IP被禁后,传入新的IP就可以了。

PS:代理IP如果展开讲篇幅太长,网上有许多免费的IP代理(缺点是可用时间短,不稳定)和付费的IP代理(缺点是不免费)。另外,要强调一下这里我们传入的IP长这样:{'https':'https://115.219.79.103:0000'}

  1. movie_result = pd.DataFrame()
  2. ip = ''  #这里构建自己的IP
  3. count2 = 1
  4. cw = 1
  5. for url,name in zip(result['网址'].values[6000:],result['片名'].values[6000:]):
  6. #for name,url in wrongs.items():
  7.     try:
  8.         cache = parse_movie_info(url,headers = headers,ip = ip)
  9.         movie_result = pd.concat([movie_result,cache])
  10.         #time.sleep(random.random())
  11.         print('我们爬取了第:%d部电影-------%s' % (count2,name))
  12.         count2 += 1
  13.     except:
  14.         print('滴滴滴滴滴,第{}次报错'.format(cw))
  15.         print('ip is:{}'.format(ip))
  16.         cw += 1
  17.         time.sleep(2)
  18.         continue

电影页面数据爬取结果如下:

640?wx_fmt=png

640?wx_fmt=png

数据清洗


1、基本信息表和电影内容表合并

base_info表里面是我们批量抓取的电影基本信息,movie_info则是我们进入每一部电影,获取到的感兴趣字段汇总,后面的分析是需要依赖两张表进行的,所以我们合并之:

640?wx_fmt=png

2、电影年份数据清洗:

我们发现之前爬取的上映时间数据不够规整,前面都带了一个“-”:

640?wx_fmt=png

要把前面多余的符号去掉,但发现无论怎么用str.replace返回的都是Nan,原来这里pandas把所有数字默认成负的,所以只需要把这一列所有数字乘-1即可:

640?wx_fmt=png

3、评分分布规整:

最终我们是希望能够把电影整体评分(如某电影8.9分)和不同评分等级(5星的占比70%)结合起来分析的。而刚才爬取评分数据的时候,为了偷懒,用的是一个字典把各评分等级和对应的占比给包起来了,然鹅,pandas默认把他当成了字符串,不能直接当做字典处理:

640?wx_fmt=png

灵光一闪?这种字典形式的字符串,用JSON解析一下不就变字典了?HAVE A TRY:

640?wx_fmt=png

结果,疯狂报错:

640?wx_fmt=png

报错貌似在提示我们是最外围的引号错误导致了问题,目前我们用的是双引号("{'a':1}")难道只能用单引号('{'a':1}')?先试试吧:

640?wx_fmt=png

报错解决了。接下来,我们把字典形式的评分拆成多列,例如每个星级对应一列,且百分比的格式变成数值型的,写个循环函数,用apply应用一下即可:

  1. #把单列字典的评分分布转化成分开的5列,且每一列是数值型的
  2. def get_rate(x,types):
  3.     try:
  4.         return float(x[types].strip('%'))
  5.     except:
  6.         pass
  7. movie_combine['5星'] = movie_combine['format_评分'].apply(get_rate,types = '力荐')
  8. movie_combine['4星'] = movie_combine['format_评分'].apply(get_rate,types = '推荐')
  9. movie_combine['3星'] = movie_combine['format_评分'].apply(get_rate,types = '还行')
  10. movie_combine['2星'] = movie_combine['format_评分'].apply(get_rate,types = '较差')
  11. movie_combine['1星'] = movie_combine['format_评分'].apply(get_rate,types = '很差')

现在我们的数据长这样的:

640?wx_fmt=png

OK,清洗到此告一段落。


640?wx_fmt=png

数据分析


大家还记得开头的FLAG吗?我们要制作各年代TOP100电影排行榜。所以直接按照年代划分电影,然后按照电影评分排个序不就完事了!

然鹅这听起来有点话糙理也糙。如果只按照电影的总的评分来排序,会忽视掉内部评分细节的差异性,举个例子,搏击俱乐部:

640?wx_fmt=png

总评分9.0分,打出5星好评的占比60.9%,4星的有30.5%。

640?wx_fmt=png

同为9分佳作,给美丽心灵打出5星好评的有56.0%,和搏击俱乐部相比少了4.9%,而4星的人数则高出了6%。可以不负责任的做一个概括:两部都是9分经典,但观众给搏击俱乐部的5星倾向要高于美丽心灵。

GET到这个点,我们就可以对电影评分排序制定一个简单的规则:先按照总评分排序,然后再对比5星人数占比,如果一样就对比4星,以此类推。这个评分排序逻辑用PYTHON做起来不要太简单,一行代码就搞定:

  1. #按照总评分,5星评分人数占比,4星占比,3星..依次类推
  2. movie_combine.sort_values(['评分','5星','4星','3星','2星','1星'],ascending = False,inplace = True)

但是仔细看排序结果,我们会发现这样排序的一些小瑕疵,一些高分电影其实是比较小众的,比如“剧院魅影:25周年纪念演出”和“悲惨世界:25周年纪念演唱会”等。

而我们想要找的,是人民群众所喜闻乐见的电影排名,这里只有通过评分人数来代表人民的数量,我们先看一看所有电影的评分人数分布:

640?wx_fmt=png

评分人数跨度极大,为了减少极值对于平均的影响,就让中位数来衡量人民群众是否喜闻乐见,所以我们只留下大于中位数的评分。

640?wx_fmt=png

接着,看看历年电影数量分布情况:

640?wx_fmt=png

直到2000年初,筛选后的电影年上映数才逼近200,更早时期的电影好像20年加起来还不到100部。为了让结果更加直观,我们来按年代统计电影的上映时间。这里涉及到给每部电影上映时间进行归类,有点棘手啊...

绞尽脑细胞,终于找到了一个比较讨巧的办法,先构造年代标签,再借用cut函数按十年的间隔切分上映时间,最后把标签传入参数。

640?wx_fmt=png

得勒!数据直观的反映出各年代上映量,20世纪80年代前真的是少得可怜。看到这里,不由想到我们最开始立的那个“制作年代TOP100榜单”的FLAG,因为早期电影量的贫乏,是完全站不住脚的了。

不慌,一个优秀的数据分析师,一定是本着具体问题具体分析的精神来调整FLAG的:

  • 基于年代上映量数据,我们从20世纪30年代开始制作排名;

  • 为了避免有些年代电影过少,优化成各年代TOP 10%的电影推荐;

  • 同时,为了避免近年电影过多,每个年代推荐的上限数不超过100部。

看到这三个条件,连一向自傲的潘大师(pandas)都不禁长叹了口气。然鹅大师之所以是大师,就是因为在他眼里没有什么是不可能的。思考1分钟后,确定了灵活筛选的套路:

  1. final_rank = pd.DataFrame()
  2. for century,count in zip(century_f.index,century_f.values):    
  3.     f1 = movie_f2.loc[movie_f['年代'] == century,:]    
  4.     #1000部以下的,取TOP10%    
  5.     if count < 1000:        
  6.         return_num = int(count * 0.1)    
  7.     #1000部以上的,取前100部   
  8.     else:        
  9.         return_num = 100    
  10.     f2 = f1.iloc[:return_num,:]    
  11.     final_rank = pd.concat([final_rank,f2])

根据上一步构造的century_f变量,结合每个年代上映电影量,不足1000部的筛选前10%,超过1000部的只筛选前100部,结果,就呼之而出了。

在附上代码和榜单之前,我预感到大部分旁友是和我一样懒的(不会仔细看榜单),所以先整理出各年代TOP5电影(有些年代不足TOP5),做一个精华版的历史电影排行榜奉上:

640?wx_fmt=png

从峰回路转、结尾让人大呼牛逼的《控方证人》,到为无罪真理而辩的《十二怒汉》,再到家庭为重不怒自威的《教父》系列、重新诠释希望和坚韧的《肖申克的救赎》以及将励志提升到新高度的《阿甘正传》。(笔者阅片尚浅,榜单上只看过这些)

每一部好的电影,都是一块从高空坠落的石头,它总能在人们的心湖上激起水花和涟漪,引起人们对生活、社会以及人性的思考。而烂片,就是从高空坠落的空矿泉水瓶,它坠势汹汹,但最终只会浮在水面,让看过的人心存芥蒂,感觉灵魂受到污染。

有了新的电影排名榜单,再也不用担心剧荒了。

爬取、清洗、分析每一步详解代码和完整的电影排序名单,详见:https://github.com/seizeeveryday/DA-cases/tree/master/DoubanMovies。

作者:周志鹏,2年数据分析,深切感受到数据分析的有趣和学习过程中缺少案例的无奈,遂新开公众号「数据不吹牛」,定期更新数据分析相关技巧和有趣案例(含实战数据集),欢迎大家关注交流。

声明:本文为作者投稿,版权归其所有。

【END】

640?wx_fmt=jpeg

热 文 推 荐 

不满 C 的繁琐,崛起的 Python 正在蚕食编程语言界!

CPU 是如何工作的?

☞阿里拟 20 亿美元收购网易考拉;联通 5G 套餐最低 190 元;Rust 1.37.0 发布 | 极客头条

《乐队的夏天》很酷?程序员式的摇滚才燃爆了!

只需要支付0.5元就可以撤回交易?这下可坑苦DApp了……

从原理到代码,轻松深入逻辑回归模型!

@程序员,“10倍工程师”都在追这四大AI风向

常见的Hadoop十大应用误解

行!这下 CSDN 玩大了!粉丝:太良心

640?wx_fmt=png

你点的每个“在看”,我都认真当成了喜欢

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/代码探险家/article/detail/967812
推荐阅读
相关标签
  

闽ICP备14008679号