微博社交网络图:爬虫+可视化
原文链接 https://bigborg.github.io/2017/04/11/WeiboCraw/
注:以下为加速网络访问所做的原文缓存,经过重新格式化,可能存在格式方面的问题,或偶有遗漏信息,请以原文为准。
微博爬虫 + 社交网络图可视化
项目地址:WeiboSocialNetwork
先展示下结果再来解释代码:
首先有个R语言生成的 html
- 里面可以看到爬取的数据大致信息,比如爬取的有5017个用户,而0到1,1到2级的关注关系一共有5784个。
- 男性用户5003,女性13,还有个应该是空白的字符串。天,基本全是男性啊。。。就算扩展到二级而全是男性啊。。。被这比例吓到了,不过后面在可视化的图上大概看了下数据应该是没错的。。。
- R 里可以很方便的调出你关注的人的关注列表哦,最后还有个被共同关注数最多的用户,好绕,就是统计 你和你直接关注的人 的共同关注,不包括二级底下的关注哦,因为没爬,再爬数据就太多了。
使用 Cytoscape 进行可视化的结果: 整个社交网络图,包括了二级关注,是不是很像烟花都要炸了:
在 Cytoscape 里还可以用节点的属性设置元素颜色,用层级设置颜色就能很直观的区分出你,你的一级关注,二级关注。该图中红色为本人,蓝色为一级关注,其余为二级关注。什嘛,看不清?其实用 Cytoscape 是可交互的,可以放大缩小,可以拖拽节点,并且可以生成svg哦!用svg存打开也是可以放大到看的清每个用户的昵称的。为什么不直接插入svg?我怕我的好友会杀了我。。。
用用户的性别设置颜色就能很清楚地挑出女性用户了,下图中蓝色为男性,红色为女性。全是蓝的有没有。。。
Cytoscape 社交网络图还有个很不错的用处,可以选择你的好友,然后右键选中所有直接邻居,这样就能看到你的某个特定好友都关注什么了。
Cytoscape 使用上是有UI的,所以不需要代码哦,直接把R代码生成的 Networkdata.csv 导入就好了,注意设置好每个列的属性就好了。具体使用使用可以去官网查找文档。
代码运行
WeiboCrawler.py 首先需要在 WeiboCrawler.py 文件里填上你的用户名,密码。也可以更改爬取的深度,但不建议更改毕竟两层数据量就挺大了,再大可视化的时候也不好弄了。
process.py 这个主要是整理好数据,写入到csv文件里方便R语言读入。直接运行就好。
Location.py 这是用来搜索地理位置获取坐标的,只是放着要是要进行地图上的可视化可以使用,地图可视化可以参照我的招聘信息scrpay项目。里面使用了高德地图api,所以要用的话需要自己去申请key哦。
Rvisualization.Rmd 本来想用 R 可视化来着,不过数据量太大了,用 R 可视化的网络图节点全都重叠在一起了。该文件其实主要是进一步处理数据,调整好格式以供 Cytoscape 导入。
使用python 3, selenium调用火狐浏览器进行登录操作,登录完成后把 cookie 导入到 requests 的 session 里,之后使用 requests 进行爬取,这样就避免了全部使用 selenium 导致的效率低问题,同时又省去了逆向微博的登录机制。确保安装好 python 和 R 需要的依赖包,还有记得打开 Mongod 服务。依次运行完 WeiboCrawler.py, process.py 和 Rvisualization.Rmd,这样就有了一份简略的分析报告(html格式)生成,同时生成 Networkdata.csv 文件供 Cytoscape 导入。
爬虫代码
主要介绍下爬虫代码。WeiboCrawler.py 里实现了个 Weibo 类。以下一个个介绍方法
init, login
class Weibo(object):
def __init__(self, username, password, mysession, collection):
self.username = username
self.password = password
self.mysession = mysession
self.collection = collection
self.firefox = Firefox()
def login(self):
if "cookies" not in os.listdir():
self.firefox.get("http://www.weibo.com/")
input("Press any key when login page finishes loading:")
usernameinput = self.firefox.find_element_by_id("loginname")
passwordinput = self.firefox.find_element_by_name("password")
submit = self.firefox.find_element_by_xpath("//span[@node-type='submitStates']")
usernameinput.click()
usernameinput.send_keys(self.username)
passwordinput.click()
passwordinput.send_keys(self.password)
submit.click()
input("Press any key when you are logged in:")
self.userid = re.findall('/u/(\d+)/home', self.firefox.current_url)[0]
cookies = self.firefox.get_cookies()
with open("cookies","wb") as f:
f.write(pickle.dumps(cookies))
with open("userid", "w") as f:
f.write(self.userid)
else:
with open("cookies","rb") as f:
cookies = pickle.loads(f.read())
with open("userid", "r") as f:
self.userid = f.read()
for cookie in cookies:
self.mysession.cookies.set(cookie['name'], cookie['value'])
return True
实例化时候需要提供用户名,密码,requests的Session对象,pymongo的集合。init时会用 Selenium 打开 Firefox 浏览器。
login函数用于登录,首先检查是否存在文件“cookies”,没有才登录,登录后保存一份 cookie 省得每次都需要登录。登录时火狐自动打开微博登录页面,当页面加载完成时候输入任意字符,代码就会自动进行登录,登录完成后再输入任意键继续。此处 selenium 使用还算比较简单,find_element_by_* 方法提供了多种选择来选择元素。在页面元素上可以通过 send_keys 实现输入,click 点击。主要是 cookie 的导出导入,firefox 使用 get_cookies 进行导出。requests session 使用 session.cookies.set(name,value) 来设置 cookie。
crawl
def crawl(self, layer=2, start_layer=None):
if start_layer==None:
self.craw_following_meta()
start_layer=1
for i in range(start_layer,layer):
count = collection.find({"layer":i, "following":{"$size":0}}).count()
query = collection.find({"layer":i, "following":{"$size":0}})
for j, ele in enumerate(query):
print("{0} of {1} users processed at layer {2}".format(j,count,i))
try:
self.other_user_following(ele['_id'], i)
time.sleep(1)
except Exception:
with open("Failed", "a") as f:
f.write("Failed user:" + str(ele['_id'])+'at leyer' + str(i) + '\n')
print("Failed User:" + str(ele['_id']))
crawl 方法是实际运行爬虫的入口方法,layer代表爬取的深度,start_layer为开始爬取的深度,不建议修改参数,因为两层的数据量就挺大了。该方法内对 mongodb 数据库的读取使用了 {"layer":i, "following":{"$size":0}} 的选择器,即读取 i 层并且还未爬取关注列表的用户进行爬取,这样每次开始运行就可以从上次停止的进度继续了。
parse_html_from_js, parse_pages
def parse_html_from_js(self, text, ns):
followingstr = re.findall(r'{"ns":"' +ns+ '",.*}', text)[0]
followingdict = json.loads(followingstr)
htmlstr = followingdict['html']
tree = etree.HTML(htmlstr)
return tree
@staticmethod
def parse_pages(tree):
href = tree.xpath('//div[@class="W_pages"]/a[@bpfilter]/@href')[0]
href_pattern = re.sub('page=(\d+)', 'page={page}', href)
num_pages = len(tree.xpath('//div[@class="W_pages"]/a')) - 2 # Don't know what happen if only only one page is available
return href_pattern, num_pages
其实这里 parse_html_from_js 也应该是静态方法的,只是先这样写了也在别处调用了就懒得改了。parse_html_from_js 方法主要是处理微博返回的数据,生成 etree 对象。微博爬取返回的并不是你看到的页面,而是用 js 生成页面,实际 html 是在 js 的参数里的。所以需要先用正则把 html 对应的字符串拿出来再生成etree。因为微博页面都是这种风格,所以会频繁使用故独立成函数,不同页面对应的 ns 则会不同,如底下的截图:
parse_pages 则是从 etree 中获取关注列表对应的网址,不过我关注数是三页,没观察过关注页只有一页的情况是否规则相同。
craw_following_meta, other_user_following
def craw_following_meta(self):
resp = self.mysession.get("http://weibo.com/{0}/follow".format(self.userid))
nike = re.findall(r"CONFIG\['nick'\]='(.*)'", resp.text)[0]
useravatar = re.findall(r"CONFIG\['avatar_large'\]='(.*)'", resp.text)[0]
try:
self.collection.insert_one({"_id":self.userid, "nike":nike, "avatar":useravatar,'layer':0, 'following':[]})
except Exception as e:
print("user already exists")
pass
tree = self.parse_html_from_js(resp.text, 'pl.relation.myFollow.index')
href_pattern, num_pages = Weibo.parse_pages(tree)
for page in range(1,num_pages+1):
url = "http://www.weibo.com" + href_pattern.format(page=str(page))
resp = self.mysession.get(url)
tree = self.parse_html_from_js(resp.text, 'pl.relation.myFollow.index')
for img in tree.xpath("//img[@usercard]"):
avatar=img.xpath("@src")[0]
nike=img.xpath('@title')[0]
userid=img.xpath("@usercard")[0][3:]
try:
self.collection.update({"_id":self.userid},{"$addToSet":{"following":userid}})
self.collection.insert_one({"_id":userid, 'avatar':avatar, 'nike':nike,'layer':1, 'following':[]})
except Exception:
print("user already exists")
def other_user_following(self, uid, current_layer):
print(uid.center(60,"-"))
resp = self.mysession.get("http://weibo.com/u/{uid}".format(uid=uid))
tree = self.parse_html_from_js(resp.text, "pl.content.homeFeed.index")
weiboLevel = tree.xpath("//div[@class='PCD_person_info']/descendant::a[contains(@class,'W_icon_level')]/span")[0].text[3:]
if len(tree.xpath("//span[contains(@class,'ficon_cd_place')]"))>0:
location = tree.xpath("//span[@class='item_text W_fl']")[1].text.strip()
else:
location = None
header = self.parse_html_from_js(resp.text,"pl.header.head.index")
gender = header.xpath("//span[@class='icon_bed']/a/i/@class")[0]
if "female" in gender:
gender="female"
else:
gender="male"
try:
self.collection.update({"_id":uid},{"$set":{"weiboLevel":weiboLevel,"gender":gender, "location":location}})
except:
print("fail to update user profile")
page_id = re.findall("\['page_id'\]='(\d*)'", resp.text)[0]
url = "http://weibo.com/p/{0}/follow".format(page_id)
resp = self.mysession.get(url)
ns = "pl.content.followTab.index"
tree = self.parse_html_from_js(resp.text,ns)
href_pattern, num_pages = Weibo.parse_pages(tree)
for page in range(1, num_pages+1):
url = "http://www.weibo.com" + href_pattern.format(page=str(page))
resp = self.mysession.get(url)
ns = "pl.content.followTab.index"
tree = self.parse_html_from_js(resp.text, ns)
for img in tree.xpath("//img[@usercard]"):
dl = img.getparent().getparent().getparent()
location = dl.xpath("dd[1]/div[3]/span/text()")[0]
gender = 'female' in dl.xpath("dd[1]/div[1]/a[3]/i/@class")
if gender:
gender="female"
else:
gender="male"
userid = re.findall('id=(\d+)', img.xpath("@usercard")[0])[0]
avatar = img.xpath("@src")[0]
nike = img.xpath("@alt")[0]
try:
self.collection.update({"_id":uid},{"$addToSet":{"following":userid}})
self.collection.insert_one({"_id":userid, "gender":gender, "location":location, "avatar":avatar, "nike":nike, "layer":current_layer+1, "following":[]})
print("user:" + nike)
except:
print("user already exists")
该方法是从代码使用者本人开始爬取的,因为页面规则跟其它用户的关注页面不同,所以分成两个函数。其实这里也没什么好说的,都是些xpath和pymongo,就讲重点~比如xpath的“contains(@class,'ficon_cd_place')”用法,可以用于使用了多个类的元素。还有 mongodb 的 $addToSet 操作符,可以防止关注列表重复。不过其实这里还可以更改,因为此处只插入了用户的id,但是我们可视化的时候需要的是用户名,所以其实这里完全可以把关注用户的用户名一并加上,比如这样:
{
"userid":userid,
"username":username
}
以这样的形式作为一个元素插入进行存储,可以避免后期读取的时候频繁进行跨表查询,毕竟内嵌就是 Mongodb 用来提升效率的一个方法,不然都用成 mysql 了。。。
Weibo类整体使用
if __name__ == "__main__":
from requests.packages.urllib3.util.retry import Retry
from requests.adapters import HTTPAdapter
mysession = Session()
retries = Retry(total=5,backoff_factor=3)
mysession.mount('http://', HTTPAdapter(max_retries=retries))
connection = pymongo.MongoClient()
db = connection.weibo
collection = db.users
weibo = Weibo("Use your username", "Use your password", mysession, collection)
weibo.login()
weibo.crawl()
都写好了,只要改下用户名,密码就好了,实例化后只需要调用 login 和 crawl 方法。