1. 程式人生 > 實用技巧 >Python實現某日劇豆瓣小組的帖子和組員資料爬取和資料統計

Python實現某日劇豆瓣小組的帖子和組員資料爬取和資料統計

這次資料統計的目的是找出某長度為12集的日劇從開播(2020-10-09)到結束(2020-12-25)這段時間內,其豆瓣小組的發帖情況,及其組員構成。由於資料是在2021年1月3日開始爬取的,因此無法獲取在放送期間的組員數量變化,只能獲得最終的組員目錄。
主要使用:requests、xpath、pandas、matplotlib、jieba。

資料爬取

需要獲取的資料有:小組帖子目錄頁、帖子詳情頁、組員列表頁、組員詳情頁。由於爬取過程中可能會遇到封ip、連結中斷等情況,因此要一邊爬取一邊持久化儲存。

先定義請求頁面和解析頁面的方法

#!/usr/bin/python
#encoding='utf-8'

from lxml import etree
import requests
import csv

header = ''''''
# 將瀏覽器複製來的headers轉為字典
headers = {}
for i in header.split('\n'):
    headers[i.split(': ')[0]]=i.split(': ')[1]
headers['Accept-Encoding'] = 'gzip'

def get_page(url):
    response = requests.get(url=url, headers=headers)
    page_text = response.text
    print(response)
    return url, page_text, response

def parse_title(page_text):
    tree = etree.HTML(page_text)
    tr_list = tree.xpath('//*[@id="content"]/div/div[1]/div[2]/table//tr')
    titles = []
    detail_urls = []
    for tr in tr_list[1:]:
        item = {}
        item['title'] = tr.xpath('./td[1]/a/@title')[0]
        item['user_link'] = tr.xpath('./td[2]/a/@href')[0]
        item['user_name'] = tr.xpath('./td[2]/a/text()')[0]
        item['comments'] = tr.xpath('./td[3]/text()')
        if item['comments']:
            item['comments'] = item['comments'][0]
        else:
            item['comments'] = '0'
        item['latest_time'] = tr.xpath('./td[4]/text()')[0]
        item['id'] = tr.xpath('./td[1]/a/@href')[0].split('/')[-2]
        titles.append(item)
        detail_url = tr.xpath('./td[1]/a/@href')[0]
        detail_urls.append(detail_url)
    return titles, detail_urls

def parse_details(url, page_text):
    item = {}
    tree = etree.HTML(page_text)
    item['create_time'] = tree.xpath('//*[@id="topic-content"]/div[2]/h3/span[2]/text()')[0]
    body = tree.xpath('//*[@id="link-report"]/div/div//text()')  # //text()表示div下的所有文字
    item['body'] = ''.join(body).strip()
    item['id'] = url.split('/')[-2]
    return item

def parse_members(n, page_text):
    members = []
    tree = etree.HTML(page_text)
    if n == 0:
        li_list = tree.xpath('//*[@id="content"]/div/div[1]/div[3]/div[1]//li')
    else:
        li_list = tree.xpath('//*[@id="content"]/div/div[1]/div[1]/div[1]//li')
    for li in li_list:
        item = {}
        item['user_name'] = li.xpath('./div[2]/a/text()')[0]
        item['user_link'] = li.xpath('./div[2]/a/@href')[0]
        city = li.xpath('./div[2]/span/text()')
        if city:
            item['city'] = city[0][1:-1]
        else:
            item['city'] = ''
        members.append(item)
    return members

def parse_member_details(page_text):
    item = {}
    tree = etree.HTML(page_text)
    item['join_time'] = tree.xpath('//*[@id="profile"]/div/div[2]/div[1]/div/div/text()')[1].strip()
    city = tree.xpath('//*[@id="profile"]/div/div[2]/div[1]/div/a/text()')
    if city:
        item['city'] = city[0]
    else:
        item['city'] = ''
    item['id'] = tree.xpath('//*[@id="profile"]/div/div[2]/div[1]/div/div/text()')[0].strip()
    group_lists = tree.xpath('//*[@id="group"]/dl')
    groups = []
    for group in group_lists:
        groups.append(group.xpath('./dd/a/@href')[0].split('/')[-2])
    item['groups'] = '/'.join(groups)
    return item

獲取帖子目錄和連結

# 請求並解析標題頁
p = 0
titles = []
detail_urls = []
while p < 250:
    url = 'https://www.douban.com/group/706910/discussion?start=%s' % p
    r_url, page_text = get_page(url)
    t, d = parse_title(page_text)
    titles = titles + t
    detail_urls = detail_urls + d
    p += 25
    print(p)
# 對標題頁資訊持久化儲存
left = pd.DataFrame(titles).drop_duplicates()
left.to_csv('./titles_new.csv')
# 對詳情頁url去重,並持久化儲存
detail_urls = list(set(detail_urls))
with open('./detail_urls.txt', 'w', encoding='utf-8') as f:
    f.write(str(detail_urls))

獲取帖子正文

# 請求並解析詳情頁
with open('detail_urls.txt', 'r', encoding='utf-8') as f:
    detail_urls = f.read().strip('[').strip(']').split(', ')
a = 0
while 1:
    if a >= len(detail_urls):
        break
    details = []
    url = detail_urls[a].strip("'")
    print(url)
    try:
        d_url, page_text = get_page(url)
        c = parse_details(d_url, page_text)
        details.append(a)
        details.append(c['create_time'])
        details.append(c['body'])
        details.append(c['id'])
        print('文章序號%s, %s' % (a+16847, d_url))
        # 對詳情頁資訊持久化儲存
        with open('details.csv', 'a', encoding='utf-8', newline='') as f:
            w = csv.writer(f)
            w.writerow(details)
        a += 1
    except requests.exceptions.TooManyRedirects as e:
        print('ip已被封')
        break
    except Exception as e:
        print(e)
        break

獲取組員列表

# 獲取組員url
n = 0
while 1:
    if n > 39970:
        break
    print(n)
    url = 'https://www.douban.com/group/706910/members?start=%s' % n
    u, page_text = get_page(url)
    members = parse_members(n, page_text)
    with open('members.csv', 'a', encoding='utf-8', newline='') as f:
        w = csv.writer(f)
        for member in members:
            w.writerow(member.values())
    n += 35

獲取組員詳情

# 獲取組員詳情頁
count = 1
with open('./members.csv', 'r', encoding='utf-8') as f:
    r = csv.reader(f)
    for row in r:
        if count <= 38066:
            count += 1
            continue
        # 跳過已登出的賬號
        elif row[0] == '[已登出]':
            count += 1
            continue
        url = row[1]
        u, page_text, response = get_page(url)
        member_detail = parse_member_details(page_text)
        with open('member_details.csv', 'a', encoding='utf-8', newline='') as f:
            w = csv.writer(f)
            w.writerow(member_detail.values())
        print(count)
        count += 1

發帖和迴應資料統計

爬取的帖子目錄:

爬取的帖子正文:

import csv
import pandas as pd
import matplotlib.pyplot as plt

# 資料清洗

# 由於原始資料中有的日期有時間沒年份,有的有年份沒時間,因此把年份補齊、時間去除。因為爬取資料時用了兩臺電腦一起爬,因此順便把它們合併到一個檔案中
with open('./titles.csv', 'r', encoding='utf-8') as f1:
   r_old = csv.reader(f1)
   with open('./titles_new.csv', 'r', encoding='utf-8') as f2:
       r_new = csv.reader(f2)
       with open('./all_titles.csv', 'w', encoding='utf-8', newline='') as f:
           r = csv.writer(f)
           for row in r_new:
               if row[5][:2] == '01':
                   row[5] = '2021-' + row[5].split(' ')[0]  # 將時間去掉
               r.writerow(row[1:])  # row[1:]是為了去掉序號
           for row in r_old:
               if row[5][:2] == '01':
                   row[5] = '2021-' + row[5].split(' ')[0]
               r.writerow(row[1:])

# 把資料讀取為DataFrame
titles = pd.read_csv('./all_titles.csv', encoding='utf-8', parse_dates=True)
details = pd.read_csv('./details.csv', encoding='utf-8', index_col='index', parse_dates=True, dtype={'id': str})
# 因有些帖子只有圖片沒有文字,因此將body的空值替換為'無',其他欄位的空值保留
details.fillna({'body': '無'}, inplace=True)
# 將標題頁資料和正文頁資料合併
articles = pd.merge(titles, details, on='id', how='inner')
# 將有缺失資料的值刪除
articles.dropna(how='any', inplace=True)
# 將字串轉為日期、數字型別
articles['latest_time'] = pd.to_datetime(articles['latest_time'])
articles['create_time'] = pd.to_datetime(articles['create_time'])
articles['comments'] = pd.to_numeric(articles['comments'])
# 拆分日期和小時
articles['create_time_date'] = articles['create_time'].dt.date
articles['create_time_hour'] = articles['create_time'].dt.hour
# 按最新迴應時間排序。如果axis=0則by="列名";如果axis=1則by="行名"。
articles.sort_values(by='latest_time', axis=0, inplace=True, ascending=False)
# 有重複id的帖子的話,只保留最新的資料,這一步完成後表格大小為[16906 rows x 8 columns]
articles.drop_duplicates(subset='id', inplace=True, keep='first')
# 統計每日發帖量和帖子迴應量的走勢

# 按日期分組並對其他列計數
new_art = articles.groupby('create_time_date').count()
# 按日期分組並對評論數加總
total_comments = articles.groupby('create_time_date').sum()
art_comments = pd.merge(new_art, total_comments, on='create_time_date', validate='1:1')
print(art_comments[['id', 'comments_y']])
# 畫圖
plt.plot(art_comments.index, art_comments['id'], color='red', label='articles')
plt.bar(art_comments.index, art_comments['comments_y'], color='blue', label='comments')
plt.xticks(art_comments.index, rotation='vertical')
plt.show()


在上面資料的基礎上統計發帖時刻

# 統計發帖時間
hours = articles.groupby(articles['create_time_hour']).count()
plt.bar(hours.index, hours['title'], color='blue')
plt.xticks(hours.index, rotation='vertical')
plt.show()


可以看出,最多人發帖的時間是凌晨12點.(該劇為深夜檔,放送時間在凌晨1點)

篩選出某些日期的帖子標題

# 找出某些日期的帖子
articles = articles.set_index('create_time_date')
articles.index = pd.to_datetime(articles.index)
pd.set_option('display.max_rows', None)
print(articles.loc['2020-12-09':'2020-12-09', ['title']])

在上面資料的基礎上統計發帖使用者的分佈

# 發帖使用者的分佈
art_users = articles.groupby('user_link').count().sort_values('title', ascending=False)
print(art_users)


結果顯示共有4425名組員釋出過帖子,發帖最多的一位共發了256個帖子。

獲取發帖最多的人的所有帖子標題

# 找出發帖最多的前20位都在發什麼帖子
# 先獲取發帖排名
art_users = articles.groupby('user_name').count().sort_values('title', ascending=False)
# 每次用print(articles.loc[articles['col_name'] == 'col_value'])的方法篩選資料都報KeyError,因此先把要篩選的欄位設為index再篩選
articles = articles.set_index('user_name')
pd.set_option('display.max_rows', None)
a = 1
# 用發帖排名的user_name作為篩選條件
for name in art_users.index[:20]:
   print('No.%s' % a, articles.loc[name, ['title']])
   a += 1

通過觀察,得出:
發帖數量排名第1的使用者主要搬運劇組人員的社交媒體推文,排名第4的使用者主要發詳細的劇情分析長文,排名第13的使用者主要發同人文,排名第14的使用者主要搬運演員和劇組的採訪、花絮等,排名第18的使用者喜歡分析拍攝手法、構圖、打光等,排名第20的使用者主要搬運B站等視訊網站的相關影視剪輯作品,其餘排名的使用者都主要發劇情和演員的相關討論。

帖子正文分詞統計

使用jieba對帖子正文中的中文進行分詞,統計詞頻。

import csv, re
import pandas as pd
import jieba

# 將一些名詞加入詞典
jieba.add_word('黑澤')
jieba.add_word('黑澤優一')
jieba.add_word('優一')
jieba.add_word('黑安')
jieba.add_word('安達')
jieba.add_word('安妲己')
jieba.add_word('東電')
jieba.add_word('赤楚衛二')
jieba.add_word('赤楚')
jieba.add_word('町田啟太')
jieba.add_word('町田')
jieba.add_word('蒙布朗')
jieba.add_word('抹布洗')
jieba.add_word('馬布洗')
jieba.add_word('馬不洗')

# 開啟存放正文/標題的csv檔案
with open('./details.csv', 'r', encoding='utf-8') as f:
   r = csv.reader(f)
   r.__next__()
   for row in r:
       # 為了方便後續統計,只保留中文字元
       regStr = r".*?([\u4E00-\u9FA5]+).*?"
       text = '/'.join(re.findall(regStr, row[2]))
       result = jieba.lcut(text)
       if result:
           with open('./words_body.csv', 'a', encoding='utf-8', newline='') as f:
               w = csv.writer(f)
               for word in result:
                   # 由於單字詞多為沒有實際意義的詞如‘和’‘很’‘我’等,因此只保留長度大於一的詞
                   if len(word) > 1:
                       # 寫入帖子日期和單詞
                       w.writerow([row[1].split(' ')[0], word])
# 獲取以分好的詞
words = pd.read_csv('./words_body.csv', names=['create_time', 'word'], index_col='create_time', parse_dates=True)
# 過濾掉部分高頻的無統計意義的詞
exclude_list = ['自己', '沒有', '覺得', '一個', '這個', '還是', '真的', '知道', '所以', '因為', '就是', '不是', '這麼', '這樣', '但是', '我們']
words = words[~words['word'].isin(exclude_list)]
# 統計每集播出後一週內帖子正文中出現的高頻詞
words['count'] = 1
ep1 = words.loc['2020-10-09':'2020-10-15'].groupby('word').count().sort_values('count', ascending=False).head(15)
ep2 = words.loc['2020-10-16':'2020-10-22'].groupby('word').count().sort_values('count', ascending=False).head(15)
ep3 = words.loc['2020-10-23':'2020-10-29'].groupby('word').count().sort_values('count', ascending=False).head(15)
ep4 = words.loc['2020-10-30':'2020-11-05'].groupby('word').count().sort_values('count', ascending=False).head(15)
ep5 = words.loc['2020-11-06':'2020-11-12'].groupby('word').count().sort_values('count', ascending=False).head(15)
ep6 = words.loc['2020-11-12':'2020-11-19'].groupby('word').count().sort_values('count', ascending=False).head(15)
ep7 = words.loc['2020-11-20':'2020-11-26'].groupby('word').count().sort_values('count', ascending=False).head(15)
ep8 = words.loc['2020-11-27':'2020-12-03'].groupby('word').count().sort_values('count', ascending=False).head(15)
ep9 = words.loc['2020-12-04':'2020-12-10'].groupby('word').count().sort_values('count', ascending=False).head(15)
ep10 = words.loc['2020-12-11':'2020-12-17'].groupby('word').count().sort_values('count', ascending=False).head(15)
ep11 = words.loc['2020-12-18':'2020-12-24'].groupby('word').count().sort_values('count', ascending=False).head(15)
ep12 = words.loc['2020-12-25':'2020-12-31'].groupby('word').count().sort_values('count', ascending=False).head(15)

對帖子標題分詞統計

由於記錄標題的csv檔案中沒有建立日期,因此在上面合併後的articles DataFrame的基礎上對帖子標題進行分詞

# 對帖子標題分詞
# 從合併好的DataFrame中獲取建立日期和標題這兩列的值
for date, title in articles[['create_time_date', 'title']].values:
   regStr = r".*?([\u4E00-\u9FA5]+).*?"
   text = '/'.join(re.findall(regStr, title))
   result = jieba.lcut(text)
   if result:
       with open('./words_title.csv', 'a', encoding='utf-8', newline='') as f:
           w = csv.writer(f)
           for word in result:
               # 由於單字詞多為沒有實際意義的詞如‘和’‘很’‘我’等,因此只保留長度大於一的詞
               if len(word) > 1:
                   # 寫入帖子日期和單詞
                   w.writerow([date, word])

然後用上面統計正文分詞的方法統計標題的分詞,發現根據標題分詞的結果每集差異更大,更少無意義的詞的影響,更能表現討論走向的變化,因此決定採用根據標題分詞統計的結果

組員資料統計

爬取的組員列表資料:

爬取的組員詳情頁資料:

由於沒有使用者加入小組的時間和先後順序,因此這裡通過使用者加入小組的個數和加入豆瓣的時間來判斷使用者是否為了追劇而加入豆瓣。
這部劇開播日期為2020-10-09,由於組員列表是從該劇的小組成員中獲取的,因此可以認為在2020-10-09日後加入豆瓣且只加入了1個小組(即該劇小組,id為706910)的組員是為了追劇而加入豆瓣的。
在開播後加入豆瓣但加入了不止一個小組的使用者中,如果加入的小組只有該劇小組和兩個主演的小組(id分別為709065和711425,小組建立日期都在開播後)的話,也認為是為了追劇而加入小組的。
而在開播前加入豆瓣,但也只加入了該劇相關的3個小組中的一或幾個的組員,可以認為是之前只用豆瓣的其他板塊、為了追劇開始使用豆瓣小組,或者是很久不用豆瓣、為了追劇而回歸豆瓣的使用者。

import pandas as pd
import matplotlib.pyplot as plt
import matplotlib

# 資料清洗
member_details = pd.read_csv('./member_details.csv', encoding='utf-8')
members = pd.read_csv('./members.csv', encoding='utf-8')
# 組員列表中的id藏在url中,因此把id從url中提取出來
members['id'] = members['url'].str.split('/').str[-2]
# 組員詳情中的加入日期為’2019-07-31加入‘的格式,這裡只保留日期
member_details['join_time'] = member_details['join_time'].str[:-2]
# 把組員列表和組員詳情頁通過id合併
member_info = pd.merge(members, member_details, on='id', how='inner')
member_info.drop_duplicates('id', inplace=True)
# 將加入日期轉為datetime型別
member_info['join_time'] = pd.to_datetime(member_info['join_time'])
# 將groups欄位中的組號拆分成多行
count_groups = member_info.drop('groups', axis=1).join(member_info['groups'].str.split('/', expand=True).stack().reset_index(level=1, drop=True).rename('count_group'))
# 數出每個人分別加入了多少個小組:因為join_time後面還要用到,不能被計數,所以把group欄位單獨拎出來groupby
count_groups = count_groups['count_group'].groupby(count_groups['id']).count()
member_info = member_info.join(count_groups, on='id')
# 找出加入時間晚於2020-10-09並且加入了1個小組的組員
time_count = member_info[(member_info['join_time']>='2020-10-09')&(member_info['count_group']<2)]
print(time_count[['join_time', 'groups']])

但是這個方法只能找出加入的小組數固定的結果,很難得出是否加入的是該劇相關的3個小組中的一或幾個的結果,因此這裡換個方法,改為先找出除了這個3個小組以外,還加入了別的小組的人數,然後反推有多少人只加入了該劇相關的小組。

# 將groups欄位中的組號拆分成多行
split_groups = member_info.drop('groups', axis=1).join(member_info['groups'].str.split('/', expand=True).stack().reset_index(level=1, drop=True).rename('group'))
# 找出除了這3個小組之外還加入了別的小組的人的id
related_groups = ['709065', '711425', '706910']
old_user = split_groups[~split_groups['group'].isin(related_groups)]['id'].drop_duplicates()
# 將id在old_user中的使用者去掉,剩下的為只加入了該劇相關小組的人
new_user = member_info[~member_info['id'].isin(old_user.values)]
# 設定為列印所有列
pd.set_option('display.max_columns', None)
print(new_user)


結果顯示,為了追劇而加入豆瓣或迴歸豆瓣的使用者的總和為4754人。

在這個基礎上,找出為了追劇而加入豆瓣(即加入日期在2020-10-09之後)的使用者人數。

print(new_user[new_user['join_time']>='2020-10-09'])


結果顯示為了追劇而加入豆瓣的使用者有1788人。

綜上,在該小組的39878名使用者中,有1788人為了追劇而加入豆瓣,有4754-1788=2966人為了追劇而使用豆瓣小組或迴歸豆瓣。新加入或迴歸豆瓣的人數佔總小組人數的約11.9%,大部分小組成員都是原本就有使用豆瓣小組的習慣的。

小組成員的地域分佈

# 因為很多使用者沒有填寫地區,因此先把空值替換為’未知‘
member_info.fillna('未知', inplace=True)
city = member_info.groupby('city_x').count().sort_values('id', ascending=False)
pd.set_option('display.max_rows', None)
print(city['id'])
plt.pie(city['id'].values, labels=city.index)
# 設定字型為楷體,否則無法顯示中文
matplotlib.rcParams['font.sans-serif'] = ['KaiTi']
plt.show()


放送期間帖子數量變化和高頻詞總結圖

有兩位主演頭像的日期為劇集的放送日期,藍色資料為發帖數量,橙色資料為這些帖子所獲得的評論數量。可以看出,帖子大多集中在劇集放送日當天,平日的發帖數量相對較少。