电脑端网页版微博的处理相对复杂,先从最简单的移动端开始。因为微博系统限制,移动端只能查看前20页关注和粉丝信息,所以对于关注或粉丝超过200的用户,只能获取部分粉丝和部分关注的信息。
用户主页的链接有3种形式
//u//个性域名
文章目录
1. UID2. 基本信息3. 关注和粉丝4. 基本链接和HTML5. 数据保存6. 完整代码7. 测试1. UID
所以打开用户主页的时候链接不一定含有UID,还需要重新获取。方法就是获取这个标签href的属性值
<a href="/1744395855/info">资料</a>
查找这一标签与一般的查找稍有不同,因为只能确定标签名是a,属性名是href,属性值的第一个字符是/,最后五个字符是/info,中间的数字正是我们要查找的内容。这是正则表达式表现的时间,恰好BeautifulSoup的查找函数支持正则表达式。
from bs4 import BeautifulSoup as bsimport regex # 正则表达式def getUid(soup: bs):"""soup是用户主页html解析的结果,BeautifulSoup的实例"""addr = soup.find(name='a', attrs={'href', pile(r"/\S*/info")})if addr:return addr['href'].split('/')[1]print('uid 查找失败')return None
2. 基本信息
进入用户资料页,URL很有特点:
/uid/info
进入资料页面的前提是得到uid,所以上一步非常重要。
资料页面包含了用户的基本信息和个性域名。页面显示的都是基本的文本,在同一个标签下,用<br>换行分隔。从基本信息的规律发现,信息的排版规律,均为(除了认证信息)“属性:属性值”的格式。利用find(text=*)函数可以查找对应文本。所以定义一个函数获得这些内容(除生日,需特殊处理)。
basicInfoType = {'nickname': '昵称','identity': '认证','sex': '性别','location': '地区','description': '简介'}def getBasicInfo(soup: bs, infoType: str) -> str:"""soup是信息页html解析返回结果,BeautifulSoup的实例infoType: 昵称, 认证, 性别, 地区, 简介"""if infoType.lower() not in basicInfoType:raise ValueError('wrong basic infomation type\n' + str(basicInfoType)) pattern = basicInfoType[infoType] + ':*'infoSection = soup.find(text=pile(pattern))if infoSection:info = infoSection.split(':')[1:]basicInfo = ''for item in info:basicInfo += itemreturn basicInforeturn ''
这里也用了正则表达式来匹配一行文字。
在这一页中,获得文本的方法基本相同,仅仅是一些项要多处理一次。个性域名信息与前面方法一致——找到个性域名URL所在的行,对文本进行“/”分割。字符串"手机版:/个性域名"进行“/”分割后得到列表[“手机版https:”, “”, “”, “个性域名”],个性域名是该列表的最后一个元素,用索引-1取出即可。实际上,假如一个用户没有设置个性域名,那么在“其他信息”这一栏将会出现:
电脑版:/u/uid手机版:/u/uid
这种情况下,分割字符串得到的列表元素个数为5。因此,正确获得个性域名的前提是列表元素个数是4。
def getCustomDomain(soup):"""soup是信息页html解析返回结果,BeautifulSoup的实例"""addr = soup.find(text=pile(r"手机版:/*"))#print(addr)if addr:if len(urlSection := addr.split('/')) == 4:return urlSection[-1]return ''
NOTE:上面的代码使用了“:=”运算符。“:=”运算符称作海象运算符(walrus operator),是Python 3.8的新特性。海象运算符不仅能让代码更简洁和增加可读性,还能提高运算速度。该运算符的作用是把右边表达式的值赋值给左边的变量,左边的变量还能进行下一步运算。以判断列表元素是否大于5,如果大于5则输出元素个数为例
# a是一个列表# 以前的写法n = len(a)if n > 5:print(n)# 或者if len(a) > 5:print(len(a))# 海象运算符的写法if (n := len(a)) > 5:print(n)
例子说明应用海象运算符可以减少代码或避免重复调用。另一个有趣的例子是
print((a:=(1+(b:=2+(c:=3+(d:=4+(e:=5+6))))))<5) # Falseprint(a,b,c,d,e) # 21 20 18 15 11
获得出生日期的方法基本相同,不过需要添加额外的判断,简单判断是否是有效的日期,判断日期形式(年、年-月、年-月-日、月-日)
def getBirthdate(soup):"""soup是信息页html解析的返回结果,BeautifulSoup的实例"""date = soup.find(text=pile(r"生日:*"))if date:dateBlock = date.split(':')[-1].split('-')if len(dateBlock) == 3:return int(dateBlock[0]), int(dateBlock[1]), int(dateBlock[2])if len(dateBlock) == 2:return int(dateBlock[0]), int(dateBlock[1])if len(dateBlock) == 1:return int(dateBlock[0])return None
主页和资料页信息抓取代码如下
def getUserHomepageInfo(person, mobile=False): # mobile用来注明是否是移动端网页homeURL = homepageUrl(person, mobile)print(homeURL)soup = getHtml(url=homeURL, headers=header)if uid := getUid(soup):person.uid = uid person.realFansNum = soup.find(name='a', attrs={'href': '/'+person.oid+'/fans'}).text.split('[')[1][:-1]person.realFocusNum = soup.find(name='a', attrs={'href': '/'+person.oid+'/follow'}).text.split('[')[1][:-1]infoURL = infoPageUrl(person)infoSoup = getHtml(url=infoURL, headers=header)if (customDomain := getCustomDomain(infoSoup)):person.CustomDomain = customDomainif (date := getBirthdate(infoSoup)):try:if len(date) == 3:person.birthYear, person.birthMonth, person.birthDay = dateif len(date) == 2:if date[0] > 12:person.birthYear, person.birthMonth = dateelse:person.birthMonth, person.birthDay = dateif len(date) == 1:person.birthYear = dateexcept:passif (name := getBasicInfo(infoSoup, 'nickname')):person.name = nameif (identity := getBasicInfo(infoSoup, 'identity')):person.identity = identityif (sex := getBasicInfo(infoSoup, 'sex')):person.sex = sexif (description := getBasicInfo(infoSoup, 'description')):person.description = descriptionif (location := getBasicInfo(infoSoup, 'location')):person.location = locationprint(person.name + " 主页信息抓取成功\n-----------------------------------------------")
3. 关注和粉丝
关注和粉丝信息的页面结构相同,所以只要写一个函数就可以完成两个类似的任务。首先分析URL的特点,打开任意一个移动端网页版微博的关注页和粉丝页。关注URL,有以下两种形式
关注: /uid/follow?page=页码 粉丝: /uid/fans?page=页码
很容易就能打开相应的页面。再来看系统允许我们查看多少页
写一个函数获得可以迭代的次数
def getPageNum(soup):"""soup是关注页或粉丝页html的解析的返回结果,BeautifulSoup的实例"""return int(soup.find(name='input', attrs={'name': 'mp'})['value'])
同样地,F12检查
每一个用户的主页链接出现两次(红色方框),并且父标签都是<td>、属性都是valign=“top”,所以每个关注(粉丝)用户信息出现两次。获取的方法比较简单,用findAll函数找出所有标签,后以step=2逐个提取。从红色方框中也能看到,用户主页URL不一定包含UID(优先展示个性域名),所以第一步获得UID很重要。其实这里使用了一种麻烦的方法,因为每条信息都出现两次。观察图中第一个<td>标签,它还有style="width: 52px"属性。还有一个更简单的方案是蓝色方框的内容,这个表情是“关注他”按钮的链接,每个用户只出现一次,而且链接中已经包含了uid。如果利用这个标签的信息,第一步获取uid就是不必要的。
利用红色方框标签信息代码
def getRelation(person, relation, mobile=False):if not (relation := relation.lower()) in ['focus', 'fans']:raise ValueError('参数必须是 [\'focus\',\'fans\']')if relation == 'focus':pageUrlFunc = focusPageUrladdFunc = person.addFocuselse:pageUrlFunc = fansPageUrladdFunc = person.addFansurl = pageUrlFunc(person, 1, mobile)soup = getHtml(url=url, headers=header)pageNum = getPageNum(soup)for page in range(1, pageNum+1):url = pageUrlFunc(person, page, mobile)soup = getHtml(url=url, headers=header)blank = ' ' if page < 10 else ''print('正在抓取第', str(page)+blank, '页信息')memberList = soup.findAll(name='td', attrs={'valign': 'top'})for i in range(1, len(memberList), 2):memberInfo = memberList[i].find(name='a')name = memberInfo.textuid = memberInfo['href'].split('/')[-1]addFunc(WeiboUser(name=name, uid=uid))print(person.name + ': ' + relation + " 信息抓取成功\n-------------------------------------------")
4. 基本链接和HTML
def homepageUrl(person, mobile=False):if mobile:pofix = ''if person.uid:pofix = person.uidelif person.customDomain:pofix = person.customDomainelse:raise RuntimeError('缺少必要信息')return '/' + pofixif oid := person.oid:return '/u/' + oidif pid := person.pageId:return '/p/' + pidif domain := person.customDomain:return '/' + domaindef focusPageUrl(person, page=1, mobile=False):if mobile:return '/' + person.uid + '/follow?page=' + str(page)return '/p/' + person.pageId + '/follow?page=' + str(page)def fansPageUrl(person, page=1, mobile=False):if mobile:return '/' + person.uid + '/fans?page=' + str(page)return '/p/' + person.pageId + '/follow?relate=fans&page=' + str(page)def getHtml(url, headers):response = requests.get(url=url, headers=headers)if (html := response.text):return bs(html, 'lxml')print('无内容,正在重新请求')getHtml(url, headers)
getHtml函数接收URL和请求头,返回经过BeautifulSoup实例。因为不登录微博,我们看不到用户的关注,所以请求头里应该包含登录信息,cookie正是包含登录信息的一项。打开浏览器登录微博,按F12进入开发者工具,选中网络(Network)
把cookie所有内容保存下来。为了模拟浏览器浏览,把user-agent也保存下来。
cookie = '**************'userAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ****'header = {'User-Agent': userAgent,'cookie': cookie}
除此之外,getHtml函数还可能进入递归。有时候因为各种原因,请求没有响应。getHtml函数调用自身相当于可以多请求几次直到有返回结果。但是没必要一直请求(python也不允许一直递归,不能超过递归深度),设置递归深度可以实现这一功能,在文件的开头设置
import syssys.setrecursionlimit(10) #设置递归深度
在程序最后,恢复默认递归深度(998)。(递归深度10时,matplotlib包导入失败)
sys.setrecursionlimit(998)
高频地请求可能会导致访问被限制,在getHtml函数里添加命令,使每次请求前暂停一段时间,模拟人的操作。
import timepauseTime = 1 # 1秒def getHtml(url, headers):# *** 其他 ***time.sleep(pauseTime)# *** 其他 ***
5. 数据保存
使用类作为用户的模板可以提高代码的可读性,每创建一个用户的时候只要创建一个实例。准备工作已经实现微博用户类的创建。
6. 完整代码
import requestsfrom bs4 import BeautifulSoup as bsfrom Person import WeiboUser # 准备工作import regeximport timeimport sysimport ossys.setrecursionlimit(10) # 设置递归深度,不必多次请求同一个页面pauseTime = 1basicInfoType = {'nickname': '昵称','identity': '认证','sex': '性别','location': '地区','description': '简介'}cookie = '************************************'userAgent = '********************************'header = {'User-Agent': userAgent,'cookie': cookie}def homepageUrl(person, mobile=False):if mobile:pofix = ''if person.uid:pofix = person.uidelif person.customDomain:pofix = person.customDomainelse:raise RuntimeError('neccessary information is needed')return '/' + pofixif oid := person.oid:return '/u/' + oidif pid := person.pageId:return '/p/' + pidif domain := person.customDomain:return '/' + domaindef focusPageUrl(person, page=1, mobile=False):if mobile:return '/' + person.oid + '/follow?page=' + str(page)return '/p/' + person.pageId + '/follow?page=' + str(page)def fansPageUrl(person, page=1, mobile=False):if mobile:return '/' + person.oid + '/fans?page=' + str(page)return '/p/' + person.pageId + '/follow?relate=fans&page=' + str(page)def getHtml(url, headers):time.sleep(pauseTime)response = requests.get(url=url, headers=headers)if (html := response.text):return bs(html, 'lxml')print('无内容,正在重新请求')getHtml(url, headers)def getInfoFromText(soup, tagName, attrs):return soup.find(name=tagName, attrs=attrs).textdef getInfoFromAttr(soup, searchTagName, searchAttr, targetedAttrName):return soup.find(name=searchTagName, attrs=searchAttr)[targetedAttrName].textdef getPageNum(soup):return int(soup.find(name='input', attrs={'name': 'mp'})['value'])def getUid(soup):addr = soup.find(name='a', attrs={'href': pile(r"\S*/info")})print(addr)if addr:return addr['href'].split('/')[1]print('uid查找失败 跳过')return Nonedef infoPageUrl(person):return '/' + person.uid + '/info'def getCustomDomain(soup):"""soup: html of info page"""addr = soup.find(text=pile(r"手机版:/*"))#print(addr)if addr:if len(urlSection := addr.split('/')) == 4:return urlSection[-1]return ''def getBirthdate(soup):"""html of info page"""date = soup.find(text=pile(r"生日:*"))if date:dateBlock = date.split(':')[-1].split('-')if len(dateBlock) == 3:return int(dateBlock[0]), int(dateBlock[1]), int(dateBlock[2])if len(dateBlock) == 2:return int(dateBlock[0]), int(dateBlock[1])if len(dateBlock) == 1:return int(dateBlock[0])return Nonedef getBasicInfo(soup, infoType):"""html of info pageinfoType: 昵称, 认证, 性别, 地区, 简介"""if infoType.lower() not in basicInfoType:raise ValueError('wrong basic infomation type\n' + str(basicInfoType)) pattern = basicInfoType[infoType] + ':*'infoSection = soup.find(text=pile(pattern))if infoSection:info = infoSection.split(':')[1:]basicInfo = ''for item in info:basicInfo += itemreturn basicInforeturn ''def getUserHomepageInfo(person, mobile=False): # mobile用来注明是否是移动端网页homeURL = homepageUrl(person, mobile)print(homeURL)soup = getHtml(url=homeURL, headers=header)if uid := getUid(soup):person.uid = uid person.realFansNum = soup.find(name='a', attrs={'href': '/'+person.oid+'/fans'}).text.split('[')[1][:-1]person.realFocusNum = soup.find(name='a', attrs={'href': '/'+person.oid+'/follow'}).text.split('[')[1][:-1]infoURL = infoPageUrl(person)infoSoup = getHtml(url=infoURL, headers=header)if (customDomain := getCustomDomain(infoSoup)):person.CustomDomain = customDomainif (date := getBirthdate(infoSoup)):try:if len(date) == 3:person.birthYear, person.birthMonth, person.birthDay = dateif len(date) == 2:if date[0] > 12:person.birthYear, person.birthMonth = dateelse:person.birthMonth, person.birthDay = dateif len(date) == 1:person.birthYear = dateexcept:passif (name := getBasicInfo(infoSoup, 'nickname')):person.name = nameif (identity := getBasicInfo(infoSoup, 'identity')):person.identity = identityif (sex := getBasicInfo(infoSoup, 'sex')):person.sex = sexif (description := getBasicInfo(infoSoup, 'description')):person.description = descriptionif (location := getBasicInfo(infoSoup, 'location')):person.location = locationprint(person.name + " 主页信息抓取成功\n-----------------------------------------------")def getRelation(person, relation, mobile=False):if not (relation := relation.lower()) in ['focus', 'fans']:raise ValueError('argument relation must be in [\'focus\',\'fans\']')if relation == 'focus':pageUrlFunc = focusPageUrladdFunc = person.addFocuselse:pageUrlFunc = fansPageUrladdFunc = person.addFansurl = pageUrlFunc(person, 1, mobile)soup = getHtml(url=url, headers=header)pageNum = getPageNum(soup)for page in range(1, pageNum+1):url = pageUrlFunc(person, page, mobile)soup = getHtml(url=url, headers=header)blank = ' ' if page < 10 else ''print('正在抓取第', str(page)+blank, '页信息')memberList = soup.findAll(name='td', attrs={'valign': 'top'})for i in range(1, len(memberList), 2):memberInfo = memberList[i].find(name='a')name = memberInfo.textuid = memberInfo['href'].split('/')[-1]addFunc(WeiboUser(name=name, uid=uid))print(person.name + ': ' + relation + " 信息抓取成功\n-------------------------------------------")def userInformation(person, mobile=True):getUserHomepageInfo(person, mobile)getRelation(person, 'focus', mobile)getRelation(person, 'fans', mobile)def test(person, func, mobile=True):url = func(person, mobile=mobile)return getHtml(url, header)lijian = WeiboUser(uid = '1744395855')userInformation(lijian)sys.setrecursionlimit(998)
尽管如此,有时候访问还是被限制。暂停时间设置为2秒或以上使得获取数据很慢。在技术上应该可以通过以下方式改进:
使用代理。不断地更换ip使用多个账号。不断更换cookie更换User-Agent。不断更改user-agent,fakeuseragent包提供该功能。
7. 测试
从获得的199个粉丝数据中统计性别比例,其中有130个信息填写为女性
说明在最新的199个粉丝中,女性粉丝占比比较大。
从199个粉丝中统计填写了生日并且出生年份在1970-的人数,00-04年龄段最多,号称90后和号称00后的最多。但在这组统计中只有81个有效的数据。