基于Webmagic的爬取B站用户数据的爬虫
github: https://github.com/Al-assad/Spider-bilibiliUser-active
数据示例样本:http://pan.baidu.com/s/1dFchDZj 验证码:b2fi
学校数据挖掘作业要弄一个聚类分析,我就想不如到我大B站搞搞事情吧,于是开始研究B站用户数据的获取接口;
B站现在大概有1亿左右的有效用户,由于时间的限制,爬取全部的用户对于作业的期限是肯定来不及,于是我选择爬取活跃用户(高关注数和高被关注数用户),B站的用户主页如下:
爬虫设计思路
打开F12调试器后,发现该页面是一个前端渲染页面,通过css或xpath无法获取到动态渲染的节点的数据,于是选择通过JsonPath获取json文件的数据,使用的时FrieFox自带的调试工具该页面的是主要用户数据接口为: http://space.bilibili.com/ajax/member/GetInfo,发送方式为POST,参数为 mid(用户ID);
这里推荐一个很不错的Web调试工具:Fiddler 4;
结果我写得的前嗅程序发现,该接口的反爬虫机制为对IP的接入频率,上限大概为 150~200 次/分钟,解决方式大概有2种:
1. 减低爬虫爬取频率;
2. 使用IP代理池,更具请求频率调整更换IP频率;
出于公共道德考虑,我选择了第一种方式(其实是因为我之前爬取的公共IP池里面大多数IP已经失效,懒得再爬取,况且这也只是一个作业而已就随便应付了);
接下来是解决请求跳转,通过调试工具的抓包,发现用户的关注对象、被关注对象的数据接口为:http://space.bilibili.com/ajax/friend/GetAttentionList?mid=633003&page=1, http://space.bilibili.com/ajax/friend/GetFansList?mid=633003&page=1,请求方式为GET,参数mid为用户ID,page为页数;
在这两个数据接口B站的反爬机制为限制页面访问,客户端最多只能访问前5个page,也就是说page取值为1~5,每page返回的json有20个用户数据对象,也就是一个用户最多只能跳转200个用户(估计B站被爬怕了),这我没想到其他的解决方法,只能暂时这样;
解决了爬取逻辑后,接下来解决数据持久化的问题,由于之后要使用rapidminer进行数据挖掘,于是使用MySQL储存数据,也方便之后对数据进行筛选,ADO层引擎使用JDBC;
爬虫引擎选用黄亿华前辈写的 webmagic ,Webmagic是一个小巧强大、支持多线程,可定制的垂直爬虫引擎,这里给大家安利一个;
部分代码示例
首先我构造一个BiliUser类,方便在内存中储存用户的数据;
接下来是对Webmagic核心类PageProcessor的继承,PageProcessor负责描述对Url的抽取逻辑和跳转逻辑,部分代码如下:
public class BiliPageProcessor implements PageProcessor{
//构建Site对象,指定请求头键值字段
private Site site = Site.me()
.setRetryTimes(3)
.setTimeOut(30000)
.setSleepTime(1800) //跟据试验,http://space.bilibili.com/ajax/member/GetInfo接口有IP接入限制,估计是60s内上限150次
.setCycleRetryTimes(3)
.setUseGzip(true)
.addHeader("Host","space.bilibili.com")
.addHeader("User-Agent","Mozilla/5.0 (Windows NT 10.0; WOW64; rv:52.0) Gecko/20100101 Firefox/52.0")
.addHeader("Accept","application/json, text/plain, */*")
.addHeader("Accept-Language","zh-CN,zh;q=0.8,en-US;q=0.5,en;q=0.3")
.addHeader("Accept-Encoding","gzip, deflate, br")
.addHeader("X-Requested-With","XMLHttpRequest")
.addHeader("Content-Type","application/x-www-form-urlencoded")
.addHeader("Referer","http://space.bilibili.com/10513807/");
private static final long BEGIN_MID = 2705; //开始用户mid
private static final int LIMIT_REQUEST = 5; //目前b站的用户关注和粉丝请求接口,对page的访问限制数为5
private BiliUserDao biliUserDao = new BiliUserDao(); //持久化对象
// private final String TARGET_URL; //用户信息主页请求接口 post
private final String FRIENDS_URL = "http://space.bilibili\\.com/ajax/friend/GetAttentionList\\?mid=\\d+&page=\\d+"; //用户关注信息请求接口 get
private final String FANS_URL = "http://space.bilibili\\.com/ajax/friend/GetFansList\\?mid=\\d+&page=\\d+"; //用户粉丝信息请求接口 get
@Override
public void process(Page page) {
if(page.getUrl().regex(FRIENDS_URL).match() || page.getUrl().regex(FANS_URL).match()){
/*请求url匹配 friends 和 fans 请求接口时,
获取 get请求返回json中的mid数据,并添加用户主页请求到url处理队列*/
List mids = new JsonPathSelector("$.data.list[*].fid").selectList(page.getRawText());
if (CollectionUtils.isNotEmpty(mids)) {
for (String mid : mids) {
//构造用户信息主页的post请求
Request request = createPostRequest(mid);
//添加Request对象到URL请求队列
page.addTargetRequest(request);
}
}
}else{
/*请求url为用户主页请求url时,
通过 post请求返回的json中的目标节点数据,并装载入数据库*/
String pageRawText = page.getRawText();
//跳过连接失败页
if(new JsonPathSelector("$.status").select(pageRawText).equals("false"))
page.setSkip(true);
//使用jsonPath获取json中的有效数据,并装载入BiliUser对象
BiliUser user = new BiliUser();
long mid = Long.parseLong(new JsonPathSelector("$.data.mid").select(pageRawText));
user.setMid(mid);
user.setName(new JsonPathSelector("$.data.name").select(pageRawText));
user.setSex(new JsonPathSelector("$.data.sex").select(pageRawText));
user.setLevel(Integer.parseInt(new JsonPathSelector("$.data.level_info.current_level").select(pageRawText)));
user.setSign(new JsonPathSelector("$.data.sign").select(pageRawText));
user.setFaceUrl( new JsonPathSelector("$.data.face").select(pageRawText));
int friends = Integer.parseInt(new JsonPathSelector("$.data.friend").select(pageRawText));
user.setFriends(friends);
int fans = Integer.parseInt(new JsonPathSelector("$.data.fans").select(pageRawText));
user.setFans(fans);
user.setPlayNum(Integer.parseInt(new JsonPathSelector("$.data.playNum").select(pageRawText)));
user.setBirthday(new JsonPathSelector("$.data.birthday").select(pageRawText));
user.setPlace(new JsonPathSelector("$.data.place").select(pageRawText));
//添加friends列表请求
for(int i=1;i<=((friends/20)>LIMIT_REQUEST ? LIMIT_REQUEST : friends/20);i++){
page.addTargetRequest("http://space.bilibili.com/ajax/friend/GetAttentionList?mid="+mid+"&page="+i);
}
//添加fans列表请求
for(int i=1;i<=((fans/20)>LIMIT_REQUEST ? LIMIT_REQUEST : fans/20);i++){
page.addTargetRequest("http://space.bilibili.com/ajax/friend/GetFansList?mid="+mid+"&page="+i);
}
System.out.println("\n"+user); //控制台打印已抓取的用户信息
biliUserDao.saveUser(user); //保存BiliUser对象到数据库
}
}
@Override
public Site getSite() {
return site;
}
//创建面向用户主页POST请求(http://space.bilibili.com/ajax/member/GetInfo)的Request对象
private static Request createPostRequest(String mid){
//构造post请求数据组和url
Map nameValuePair = new HashMap();
NameValuePair[] values = new NameValuePair[1];
values[0] = new BasicNameValuePair("mid", String.valueOf(mid));
nameValuePair.put("nameValuePair", values);
String url = "http://space.bilibili.com/ajax/member/GetInfo?mid="+mid; //bilibili用户信息获取接口
//构造Request请求对象
Request request = new Request(url);
request.setExtras(nameValuePair);
request.setMethod(HttpConstant.Method.POST);
return request;
}
//运行主方法
public static void main(String[] args){
Spider.create(new BiliPageProcessor())
.addRequest(createPostRequest(BEGIN_MID+"")) //添加一次对BEGIN_MID主页的POST请求
.addUrl("http://space.bilibili.com/ajax/friend/GetFansList?mid="+BEGIN_MID+"&page=1")
.addUrl("http://space.bilibili.com/ajax/friend/GetAttentionList?mid="+BEGIN_MID+"&page=1")
.setDownloader(new MyDownloader())
.thread(2)
.run();
}
}
这里我使用了自己定制的Downloader对象,对HttpClicent的页面下载逻辑进行部分的改写,主要是对于请求频率的控制,为了使得请求频率尽量逼近数据接口的IP限制访问频率,在超过上限的时候请求会得到返回403拒绝服务响应,此时该请求线程挂起30s,再重新发送POST请求;
ADO层的核心类如下,为了提高数据传输效率,我使用预定义模板传输SQL指令(然而整个程序运行的瓶颈其实是被限制的请求频率,就当是良好的代码习惯吧):
public class BiliUserAdo {
public int saveUser(BiliUser biliUser){
DBHelper dbHelper = new DBHelper();
StringBuffer sql = new StringBuffer();
//构造模板sql ,当插入主键重复时,忽略新数据
sql.append("INSERT IGNORE INTO bilibili_user_active(mid,name,sex,level,sign,faceUrl,friends,fans,playNum,birthday,place)")
.append("VALUES(?,?,?,?,?,?,?,?,?,?,?)");
//构造模板填充序列
List sqlValues = new ArrayList();
sqlValues.add(biliUser.getMid()+"");
sqlValues.add(biliUser.getName());
sqlValues.add(biliUser.getSex());
sqlValues.add(biliUser.getLevel()+"");
sqlValues.add(biliUser.getSign());
sqlValues.add(biliUser.getFaceUrl());
sqlValues.add(biliUser.getFriends()+"");
sqlValues.add(biliUser.getFans()+"");
sqlValues.add(biliUser.getPlayNum()+"");
sqlValues.add(biliUser.getBirthday());
sqlValues.add(biliUser.getPlace());
int result = dbHelper.executeUpdate(sql.toString(),sqlValues);
return result;
}
}
这里我构造了一个ADO层辅助类DBHelper,进一步减少代码耦合,方便这整个ADO层的重用;
public class DBHelper {
public static final String driver_class = "com.mysql.jdbc.Driver";
public static final String driver_url = "jdbc:mysql://localhost/spider_bilibili?useunicode=true&characterEncoding=utf8";
public static final String user = "root";
public static final String password = "root";
private static Connection conn ;
private PreparedStatement pst;
private ResultSet rst;
public DBHelper(){
conn = DBHelper.getConnInstance();
}
/**
* 创建数据库连接
* 使用单例模式创建Connection对象,同时保持线程同步
*/
private static synchronized Connection getConnInstance(){
if(conn == null){
try {
Class.forName(driver_class);
conn = DriverManager.getConnection(driver_url,user,password);
} catch (ClassNotFoundException e) {
e.printStackTrace();
} catch (SQLException e) {
e.printStackTrace();
}
System.out.println("Connection successful.");
}
return conn;
}
/**
* close 断开数据库连接
* */
public void close(){
try{
if(conn != null)
DBHelper.conn.close();
if(pst != null)
this.pst.close();
if(rst != null)
this.rst.close();
}catch(SQLException e){
e.printStackTrace();
}
}
/**
* query sql语句运行,使用预定义模板进行
* @return ResultSet
*/
public ResultSet executeQuery(String sql, List sqlValues) {
try {
pst = conn.prepareStatement(sql);
if (sqlValues != null && sqlValues.size() > 0) {
setSqlValues(pst, sqlValues);
}
rst = pst.executeQuery();
} catch (SQLException e) {
e.printStackTrace();
}
return rst;
}
/**
* update sql语句运行,使用预定义模板进行
* @return 执行结果状态码
* */
public int executeUpdate(String sqlTemplate,List sqlValues){
int resultCode = -1;
try{
pst = conn.prepareStatement(sqlTemplate);
if(sqlValues != null && sqlValues.size()>0){
setSqlValues(pst,sqlValues);
}
resultCode = pst.executeUpdate();
}catch(SQLException e){
e.printStackTrace();
}
return resultCode;
}
/**
* 向预定义模板填充键值对
* */
private void setSqlValues(PreparedStatement pst,List sqlValues){
for(int i=0;i
整个项目的代码我放到Github中: https://github.com/Al-assad/Spider-bilibiliUser-active,如果觉得有帮助的话不妨给个Star
后续拓展
以上的代码基于深度爬取的爬虫,其实不能保证爬取到所有用户的数据,针对爬取B站的所有用户数据,其实也很简单,B站的用户id排序是很规律的,从1~10500000(大概),增长步幅为1,大概是后台数据库储存MID字段使用了Auto_Increment设置,当然这其中有大概1/5的mid是空的,我觉得可能数据库的同步回滚问题造成的(据我所知B站用户客户端并没有注销用户的功能);
于是爬取所有用户的url请求逻辑就很明了了,对数据接口 http://space.bilibili.com/ajax/member/GetInfo,参数mid由0开始,不断自增后发送请求,返回404响应就跳过该请求,到达预计上限假设10500000后,连续一定步增mid的请求(如mid自增次数100)返回404响应就判断运行结束,这样就可以遍历到B站几乎所有的用户数据;
简单实现的PageProcessor如下:
public class BiliPageProcessor implements PageProcessor{
//构建Site对象,指定请求头键值字段
private Site site = Site.me()
.setRetryTimes(3)
.setTimeOut(30000)
.setSleepTime(1500) //跟据试验,http://space.bilibili.com/ajax/member/GetInfo接口有IP接入限制,估计是60s内上限150次
.setCycleRetryTimes(3)
.setUseGzip(true)
.addHeader("Host","space.bilibili.com")
.addHeader("User-Agent","Mozilla/5.0 (Windows NT 10.0; WOW64; rv:52.0) Gecko/20100101 Firefox/52.0")
.addHeader("Accept","application/json, text/plain, */*")
.addHeader("Accept-Language","zh-CN,zh;q=0.8,en-US;q=0.5,en;q=0.3")
.addHeader("Accept-Encoding","gzip, deflate, br")
.addHeader("X-Requested-With","XMLHttpRequest")
.addHeader("Content-Type","application/x-www-form-urlencoded")
.addHeader("Referer","http://space.bilibili.com/10513807/");
private static final long BEGIN_MID = 1; //开始用户mid
private static final long END_MID = 100300000; //结束用户mid,(2017-04的估计注册用户数)
private BiliUserAdo biliUserDao = new BiliUserAdo(); //持久化对象
@Override
public void process(Page page) {
String pageRawText = page.getRawText();
//跳过连接失败页
if(new JsonPathSelector("$.status").select(pageRawText).equals("false"))
page.setSkip(true);
//使用jsonPath获取json中的有效数据,并装载入BiliUser对象
BiliUser user = new BiliUser();
user.setMid(Long.parseLong(new JsonPathSelector("$.data.mid").select(pageRawText)));
user.setName(new JsonPathSelector("$.data.name").select(pageRawText));
user.setSex(new JsonPathSelector("$.data.sex").select(pageRawText));
user.setLevel(Integer.parseInt(new JsonPathSelector("$.data.level_info.current_level").select(pageRawText)));
user.setSign(new JsonPathSelector("$.data.sign").select(pageRawText));
user.setFaceUrl( new JsonPathSelector("$.data.face").select(pageRawText));
user.setFriends(Integer.parseInt(new JsonPathSelector("$.data.friend").select(pageRawText)));
user.setFans(Integer.parseInt(new JsonPathSelector("$.data.fans").select(pageRawText)));
user.setPlayNum(Integer.parseInt(new JsonPathSelector("$.data.playNum").select(pageRawText)));
user.setBirthday(new JsonPathSelector("$.data.birthday").select(pageRawText));
user.setPlace(new JsonPathSelector("$.data.place").select(pageRawText));
System.out.println("\n"+user);
biliUserDao.saveUser(user); //保存BiliUser对象到数据库
}
@Override
public Site getSite() {
return site;
}
//运行主方法
public static void main(String[] args){
Spider spider = Spider.create(new BiliPageProcessor());
//添加请求对象序列
long mid;
for(mid = BEGIN_MID; mid < END_MID; mid++){
//构造post请求数据组和url
Map nameValuePair = new HashMap();
NameValuePair[] values = new NameValuePair[1];
values[0] = new BasicNameValuePair("mid", String.valueOf(mid));
nameValuePair.put("nameValuePair", values);
String url = "http://space.bilibili.com/ajax/member/GetInfo?mid="+mid; //bilibili用户信息获取接口
//构造Request请求对象
Request request = new Request(url);
request.setExtras(nameValuePair);
request.setMethod(HttpConstant.Method.POST);
//向Spider对象添加Request对象
spider.addRequest(request);
}
spider.thread(2).run(); //启动60个线程
}
}
Github:https://github.com/Al-assad/Spider-bilibiliuser-full
当然如果使用IP代理池后,URL请求不再是运行效率瓶颈,此时有很多个地方可以进行相应的优化;