Facebook Java爬虫获取视频数据
前言部分
前置说明
截止到本文发表前,该爬虫方法因为htmlunit的问题,已经无法正常运行,由于本人后续不再维护相关功能,所以不会修复此问题。如果是迫切需要解决方案的读者可以忽略本文,寻求其它解决方案。如果对此 方案略感兴趣,可以阅读源码和方法进行参考。
另笔者并不是专业爬虫开发,因此本文是以Java开发尝试实现的一个简单程序。使用到的工具为htmlunit + jsoup。该方案,主要实现的功能是:获取到指定频道下的所有视频的详细信息(并不下载视频文件)。获取到的数据结构如下所示(部分长数据做了删减以###标识,在前言文章中有全部数据以供参考),读者可以参考并筛选是否有自己需要的字段信息。另外,如果读者有其他解决方案,期待在评论区留言,不吝赐教。
源码地址:GitHub
{
"node": {
"feed_unit": {
"__typename": "Story",
"encrypted_tracking": "AZXEE###",
"viewability_config": [
8,
5
],
"attachments": [
{
"media": {
"__typename": "Video",
"publish_time": 1532093178,
"container_story": null,
"play_count": 32,
"total_posts": 1,
"post_play_count": 32,
"feedback": {
"id": "ZmVlZGJhY2s6MjQ5MDAxNjkyMzY3OTYz",
"top_reactions": {
"edges": [
{
"i18n_reaction_count": "13",
"node": {
"localized_name": "\u3044\u3044\u306d\uff01",
"reaction_type": "LIKE",
"id": "1635855486666999"
}
}
]
},
"i18n_reaction_count": "13",
"important_reactors": {
"nodes": [
]
},
"reaction_count": {
"count": 13
},
"viewer_actor": null,
"viewer_feedback_reaction_info": null
},
"id": "249001692367963",
"image": {
"uri": "https://scontent-nrt1-1.x###"
},
"has_viewer_watched_show_video": false,
"is_live_streaming": false,
"url": "https://www.facebook.com/JIMSKO07/videos/249001692367963/",
"video_channel": {
"__typename": "VirtualVideosChannel",
"id": "dmlkZW9DaGFubmVsOjM4NzQzNzg4ODEwNjMwMToyNDkwMDE2OTIzNjc5NjM="
},
"owner": {
"__typename": "Page",
"id": "210402066227926",
"name": "Racing jaya"
},
"is_show_video": false,
"playable_duration_in_ms": 58942,
"viewer_last_play_position_ms": null,
"broadcast_status": null,
"smart_preview_video": {
"id": "249003012367831",
"original_width": 400,
"original_height": 224,
"broadcaster_origin": null,
"broadcast_status": null,
"is_live_streaming": false,
"is_looping": true,
"loop_count": 23,
"is_spherical": false,
"permalink_url": "https://www.facebook.com/JIMSKO07/videos/249003012367831/",
"captions_url": null,
"dash_prefetch_experimental": [
"478219935963026v"
],
"can_use_oz": true,
"playable_url_dash": "https://www.facebook.com/video/playback/dash_mpd_debug.mpd?v=249003012367831&dummy=.mpd",
"dash_manifest": "\u003C?xm###",
"min_quality_preference": null,
"playable_url": "https://video-nrt1-1.xx.fbcdn.net/v/t42.9040-2/37336605_19181526###",
"playable_url_quality_hd": null,
"autoplay_gating_result": "gatekeeper",
"viewer_autoplay_setting": "default_autoplay",
"can_autoplay": false,
"drm_info": "{\"drm_helper\":null,\"video_license_uri_map\":{},\"graph_###",
"captions_settings": null
},
"playable_duration": 59,
"live_viewer_count_read_only": 0,
"breaking_status": false,
"is_premiere": false,
"is_subscribed_to_live_video_schedule": false,
"broadcast_schedule": null,
"name": "",
"savable_description": {
"text": "",
"image_ranges": [
],
"inline_style_ranges": [
],
"aggregated_ranges": [
],
"ranges": [
]
},
"playlist_video_channel": {
"__typename": "Page",
"id": "210402066227926"
},
"video_view_model": {
"episode_number": null
},
"sotto_content": null
}
}
],
"attached_story": null,
"id": "UzpfSTIxMDQwMjA2NjIyNzkyNjpWSzoyNDkwMDE2OTIzNjc5NjM="
},
"__typename": "VideoHomeFeedUnitSectionComponent"
},
"cursor": null
}
一、开始前的准备
(1)网络环境
请确保代码运行的机器可以访问外网或者可以使用的代理。
(2)添加maven依赖
<dependency>
<groupId>net.sourceforge.htmlunit</groupId>
<artifactId>htmlunit</artifactId>
<version>2.25</version>
</dependency>
<dependency>
<groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId>
<version>1.9.2</version>
</dependency>
(3)关于Facebook数据
请注意视频频道地址:https://www.facebook.com/watch/cnliziqi/
经过观察多个频道,可以得出结论:“watch”后的字符串“cnliziqi”是唯一值,即可作为channel_id(当然,实际上Facebook肯定有真正意义上的channel_id,只是这里我们为了方便这样称呼)。
二、相关概念
(1)关于HtmlUnit
关于HtmlUnit的相关概念可以参考下边的博文介绍,笔者不再额外表述。
HtmlUnit的使用
简介
HtmlUnit是一个*面浏览器Java程序。它为HTML文档建模,提供了调用页面、填写表单、单击链接等操作的API。就跟你在浏览器里做的操作一样。HtmlUnit不错的JavaScript支持(不断改进),甚至可以使用相当复杂的AJAX库,根据配置的不同模拟Chrome、Firefox或Internet Explorer等浏览器。
HtmlUnit通常用于测试或从web站点检索信息。
HtmlUnit使用场景
httpClient的局限性
我们一般可以使用apache的HttpClient组件进行HTML页面信息的获取,HttpClient实现的http请求返回的响应一般是纯文本的document页面,即最原始的html页面。对于一个静态的html页面来说,使用httpClient足够将我们所需要的信息爬取出来了。但是对于现在越来越多的动态网页来说,更多的数据是通过异步JS代码获取并渲染到的,最开始的html页面是不包含这部分数据的。
通过HtmlUnit库,加载一个完整的Html页面,然后就可以将其转换成我们常用的字串格式,用其他工具如Jsoup来获取其中的元素了。当然也可以直接在HtmlUnit提供的对象中获取网页元素,甚至是操作如按钮、表单等控件。除了不能像可见浏览器一样用鼠标键盘浏览网页之外,我们可以用HtmlUnit来模拟操作其他的一切操作,像登录网站,撰写博客等等都是可以完成的。当然网页内容爬取是最简单的一个应用了。
————————————————
版权声明:本文为CSDN博主「私念Moposion」的原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/qq_40908515/article/details/90674287
(2) 关于Jsoup
关于Jsoup,做过java爬虫的应该都了解过,它是一个用来解析静态页面的HTML解析器,在本文中主要用来解析从Facebook的频道首页中解析出一些我们需要的数据。本文不再赘述其相关概念和用法,详细可参考其官方文档:
https://www.open-open.com/jsoup/
(3)Facebook的数据问题(Web端)
笔者在分析过Facebook首页及其数据加载流程后发现,其首页视频数据除了前几条数据是跟随页面一起加载进来的,剩下的数据(指下拉加载)是通过接口请求加载出来的,也就是说并不能通过jsoup直接请求页面然后解析dom。
当笔者下拉加载更多的视频数据时,动态请求的分页接口如下所示:
其中,前六条随着首页加载进来的数据也并不是直接出现在dom元素中,是需要其他js函数的加载才有。因此,我们就需要一个能够异步加载的工具(即HtmlUnit),等获取数据的js运行完成后再找到我们需要的数据。后边的数据,我们就通过视频分页接口自己去获取了。
三、程序实例
(1)根据频道id获取该频道下的所有视频
前面已经提到,一个频道的全量视频数据其实是分为两部分:
1、与首页一起加载出的,最新的几条视频记录
2、下拉刷新,通过接口获得的历史视频记录
所以我们这里也要分两个步骤去获取。请参考下面代码:
/**
* @desc 获取首页数据
* @param channelId channelId (ex: cnliziqi)
* @return 关键数据节点信息
*/
public String getVideoByChannelId(String channelId) {
try {
Thread.sleep(RandomUtils.nextLong(3, 8) * 1000);
// 构造WebClient 模拟Chrome 浏览器
WebClient webClient = new WebClient(BrowserVersion.CHROME);
// 支持JavaScript
webClient.getOptions().setJavaScriptEnabled(true);
webClient.getOptions().setCssEnabled(false);
webClient.getOptions().setActiveXNative(false);
webClient.getOptions().setCssEnabled(false);
webClient.getOptions().setThrowExceptionOnScriptError(false);
webClient.getOptions().setThrowExceptionOnFailingStatusCode(false);
webClient.getOptions().setTimeout(15000);
String host = "127.0.0.1";
String port = "1087";
System.setProperty("http.proxyHost", host);
System.setProperty("http.proxyPort", port);
System.setProperty("https.proxyHost", host);
System.setProperty("https.proxyPort", port);
ProxyConfig proxyConfig = new ProxyConfig();
proxyConfig.setProxyHost(host);
proxyConfig.setProxyPort(Integer.parseInt(port));
// 设置WebClient的代理
webClient.getOptions().setProxyConfig(proxyConfig);
// 拼接你要访问的视频首页地址
HtmlPage rootPage = webClient.getPage("https://www.facebook.com/watch/" + channelId + "/");
//设置一个运行JavaScript的时间
webClient.waitForBackgroundJavaScript(5000);
String html = rootPage.asXml();
Document document = Jsoup.parse(html);
if (document == null) {
LogUtil.error("获取facebook页面失败.channelId:" + channelId);
return null;
}
Elements scripts = document.getElementsByTag("script");
if (AppUtil.isNull(scripts)) {
LogUtil.error("获取facebook页面失败.channelId:" + channelId);
return null;
}
String sourceStr = null;
for (Element script : scripts) {
// 只截取script节点的前面一部分,节省匹配时间
String scriptHtml = script.html().substring(0, Math.min(script.html().length(), 200));
// 前几条视频所在的节点,可以使用浏览器分析dom找到
if (scriptHtml.contains("bigPipe.onPageletArrive({bootloadable:{\"CometVideoHomeSottoCatalogNonSubscriberUpsellSection")) {
sourceStr = script.html();
break;
}
}
// 获取实际视频信息
return executeHomePageData(sourceStr);
} catch (Exception e) {
LogUtil.error(e);
}
return null;
}
/**
* @desc 处理首页的数据
* @param sourceStr 首页的节点数据
* @return 最终获取的视频信息
*/
private String executeHomePageData(String sourceStr) {
if (AppUtil.isNull(sourceStr)) {
return "获取首页数据为空";
}
try {
// 获取首页数据
int sourceStart = sourceStr.indexOf("section_components:{edges:") + 26;
int sourceEnd = sourceStr.indexOf(",page_info:{has_next_page");
String jsonData = sourceStr.substring(sourceStart, sourceEnd);
JSONArray jsonArray = JSONArray.parseArray(jsonData);
if (AppUtil.isNull(jsonArray)) {
LogUtil.error("解析首页数据失败.");
return "";
}
// 尝试通过获取的数据保存视频
List<FacebookVideo> facebookVideoList = saveVideoBySourceData(jsonArray);
if (AppUtil.isNull(facebookVideoList)) {
return "";
}
List<FacebookVideo> resultList = new ArrayList<>(facebookVideoList);
// 尝试通过分页获取全部信息
int cursorStart = sourceStr.indexOf("end_cursor") + 12;
String cursorStr = sourceStr.substring(cursorStart, cursorStart + 120);
int sectionIdStart = sourceStr.indexOf("WWW_PLAYLIST_VIDEO_LIST") - 21;
String sectionId = sourceStr.substring(sectionIdStart - 48, sectionIdStart);
Map<String, Object> map = new HashMap<>();
// 构造接口请求参数,每次请求一百条数据
String var = "{\"count\":100,\"cursor\":\"cursorStr\",\"scale\":1,\"sectionID\":\"sourceSectionId\",\"useDefaultActor\":false}";
String var1 = var.replace("cursorStr", cursorStr);
var1 = var1.replace("sourceSectionId", sectionId);
map.put("variables", var1);
map.put("doc_id", "2651465051639629");
// 主要在此请求中的代理设置(参考方法实现)
String dataJson = HttpClientUtil.doPostForm("https://www.facebook.com/api/graphql/", map);
if (AppUtil.isNull(dataJson)) {
LogUtil.error("通过Facebook-API获取数据失败.mapInfo:[" + JSONObject.toJSONString(map) + "]");
return JSONObject.toJSONString(resultList);
}
while (true) {
// 防止被反扒机制限制,使用自动随机睡眠一定时间后再请求接口
Thread.sleep(RandomUtils.nextLong(3, 8) * 1000);
JSONObject jsonObject = JSONObject.parseObject(dataJson);
JSONArray dataArray = jsonObject.getJSONObject("data").getJSONObject("node")
.getJSONObject("section_components").getJSONArray("edges");
// 保存视频信息
List<FacebookVideo> videoList = saveVideoBySourceData(dataArray);
if (AppUtil.isNull(videoList)) {
break;
} else {
resultList.addAll(videoList);
}
JSONObject pageInfo = jsonObject.getJSONObject("data").getJSONObject("node")
.getJSONObject("section_components").getJSONObject("page_info");
if (!pageInfo.getBoolean("has_next_page")) {
break;
}
// 下一次请求需要上一次请求的一个参数
String var2 = var.replace("cursorStr", pageInfo.getString("end_cursor"));
var2 = var2.replace("sourceSectionId", sectionId);
map.put("variables", var2);
dataJson = HttpClientUtil.doPostForm("https://www.facebook.com/api/graphql/", map);
}
return JSONObject.toJSONString(resultList);
} catch (Exception e) {
LogUtil.error(e);
}
return "";
}
/**
* @desc 根据json 数组解析需要的数据
* @param jsonArray json数组
* @return 构造好的数据
*/
private List<FacebookVideo> saveVideoBySourceData(JSONArray jsonArray) {
List<FacebookVideo> arrayList = new ArrayList<>();
try {
for (Object o : jsonArray) {
JSONObject data = (JSONObject) o;
JSONArray attachments = data.getJSONObject("node").getJSONObject("feed_unit").getJSONArray("attachments");
if (AppUtil.isNull(attachments)) {
continue;
}
JSONObject attachment = (JSONObject) attachments.get(0);
FacebookVideo video = new FacebookVideo();
video.setVideoId(attachment.getJSONObject("media").getString("id"));
String title = attachment.getJSONObject("media").getString("name");
String mainBody = attachment.getJSONObject("media").getJSONObject("savable_description").getString("text");
if (AppUtil.isNull(title) && AppUtil.isNull(mainBody)) {
continue;
}
String thumbnail = attachment.getJSONObject("media").getJSONObject("image").getString("uri");
Long publishTime = attachment.getJSONObject("media").getLong("publish_time");
if (publishTime == null) {
continue;
}
video.setThumbnail(thumbnail);
video.setDescription(mainBody);
video.setReleaseTime(new Date(publishTime * 1000));
arrayList.add(video);
}
} catch (Exception e) {
LogUtil.error(e);
}
return arrayList;
}
(2)根据频道id+视频id获取单条视频信息
/**
* @desc 根据video id获取视频详情
* @param videoId 视频id(注意 此video组成实际为 频道 id + video id ex:cnliziqi/videos/3080176115398147)
* @return 视频详情
*/
public String getVideoDetailByVideoId(String videoId) {
if (AppUtil.isNull(videoId)) {
return null;
}
try {
// 注意多条记录请求时的反爬
// Thread.sleep(RandomUtils.nextLong(3, 8) * 1000);
// 构造WebClient 模拟Chrome 浏览器
WebClient webClient = new WebClient(BrowserVersion.CHROME);
// 支持JavaScript
webClient.getOptions().setJavaScriptEnabled(true);
webClient.getOptions().setCssEnabled(false);
webClient.getOptions().setActiveXNative(false);
webClient.getOptions().setCssEnabled(false);
webClient.getOptions().setThrowExceptionOnScriptError(false);
webClient.getOptions().setThrowExceptionOnFailingStatusCode(false);
webClient.getOptions().setTimeout(15000);
String host = "127.0.0.1";
String port = "1087";
System.setProperty("http.proxyHost", host);
System.setProperty("http.proxyPort", port);
System.setProperty("https.proxyHost", host);
System.setProperty("https.proxyPort", port);
ProxyConfig proxyConfig = new ProxyConfig();
proxyConfig.setProxyHost(host);
proxyConfig.setProxyPort(Integer.parseInt(port));
webClient.getOptions().setProxyConfig(proxyConfig);
HtmlPage rootPage = webClient.getPage("https://www.facebook.com/" + videoId + "/");
webClient.waitForBackgroundJavaScript(5000);
String html = rootPage.asXml();
Document document = Jsoup.parse(html);
if (document == null) {
return null;
}
// 从document中解析缩略图地址
Elements imageEle = document.getElementsByAttributeValue("name", "twitter:image");
if (AppUtil.isNull(imageEle)) {
return null;
}
String thumbnail = imageEle.get(0).attr("content");
// 解析需要的信息(此数据包含作者信息,但缺少简介,因此需要额外获取)
Elements jsonEle = document.getElementsByAttributeValue("type", "application/ld+json");
if (AppUtil.isNull(jsonEle)) {
return null;
}
String cdata = jsonEle.get(0).childNodes().get(0).toString();
JSONObject sourceJson = JSONObject.parseObject(StringUtils.substringBetween(cdata, "//<![CDATA[", "//]]>"));
// 上传时间
String uploadDate = sourceJson.getString("uploadDate");
if (AppUtil.isNull(uploadDate)) {
if (jsonEle.size() < 2) {
return null;
}
sourceJson = JSONObject.parseObject(jsonEle.get(1).text());
uploadDate = sourceJson.getString("uploadDate");
if (AppUtil.isNull(uploadDate)) {
return null;
}
}
String title = StringUtils.substringBefore(sourceJson.getString("name"), "|");
String description = sourceJson.getString("description");
String channelUrl = sourceJson.getJSONObject("publisher").getString("url");
String channelId = channelUrl.split("/")[3];
FacebookVideo video = FacebookVideo.builder()
.videoId(videoId)
.channelId(channelId)
.description(description)
.releaseTime(DateUtil.parseDate(uploadDate, DateUtil.YYYY_MM_DD_T_HH_MM_SS_ZZ))
.thumbnail(thumbnail)
.build();
return JSONObject.toJSONString(video);
} catch (Exception e) {
LogUtil.error(e);
}
return null;
}