序章
PM最近问我要网易云的歌手的热门歌曲的信息,作为数据分析。说起网络爬虫我们都不陌生,我们分析网站的HTML的格式和URL的通用格式来写相应的算法。然后请求对应的URL来获取HTML字符串,因此总的来说,爬虫的本质就是请求和字符解析。
一.分析页面布局。
首先我们来分析网易云音乐的HTML构成(多图预警)。我们来看网易云的歌手的网页构成。
图1、图2中分别标注了三个地方,是我们分析一个网页的时候,需要注意的地方。
1.分析URL
2.分析菜单列表。
很容易的能够看到,网易云歌手的分布是按照【类别】来分的。我们在获取每个歌手的信息的时候,又id来表示的,其中图2中地址栏中的地址进行分析:http://music.163.com/discover/artist/cat?id=1001&inital=65
1,有前端知识的同学肯定知道,地址栏中的 “#”是前端的路由。所以我们再实际操作的时候,需要把#去掉。在地址中,http://music.163.com/discover/artist/cat?id=1001&inital=65,id=1001表示的是歌手的类别ID,inital=65表示的是歌手的按照字母排序的歌手名字类别ID。
详细来说
id=1001 表示的是 【华语男歌手】
inital=65 表示的是 【首字母为A的名字】
(读者可以访问http://music.163.com/discover/artist/cat?id=1001&inital=65查看一下网页的源码。)
我们可以通过获取分析HTML源码来获取页面上的所有的【类别ID】和【歌手名字类别ID】,进而获取所有的【歌手列表页面】
例如:
如下图
图中很容一分析的页面上的歌手类别ID,华语男歌手ID为 1001、华语女歌手ID为1002、华语组合/乐队ID为1003。
同理我们很容分析出来歌手的名字类别ID如图4
很容易得知:热门歌手的ID=-1、首字母为A的ID=65、首字母为B的ID=66、首字母为C的ID=67。
由上图的分析很容易知道,假如类别有N个,名字类别有M个。最后组合在一起的URL一共有N*M个。
整体思路我们分析完了。
接下来该分析源码,处理字符串来获取了我们想要的类别ID 和 名字类别ID。
/**
* 发送请求
* @param null $url 请求的URL
* @return bool|string 返回的信息
*/
function sendRequest($url=null){
$handle = null;
if (empty($url)){
$handle = fopen($this->url, "rb");
}else{
$handle = fopen($url, "rb");
}
$contents = stream_get_contents($handle);
fclose($handle);
return $contents;
}
/**
* 获取歌手歌曲主页的URL
* @param $html_str
* @return array
*/
function getSingerHomeUrl($html_str){
$typeMap = [];
$rltArr = [];
$urlRsltArr = [];
$dom = HtmlDomParser::str_get_html($html_str);
$elems = $dom->find('li');
foreach ($elems as $key => $value){
$aas = $value->find('a');
foreach ($aas as $k=>$v){
if (strpos(trim($v->href),'discover/artist/cat') !== false ){
if (strpos($v->href,'initial') !== false){
$rltArr[] = BASE_URL.$v->href;
}else{
if (is_numeric(trim($v->href,'/discover/artist/cat?id='))){
$typeMap[trim($v->href,'/discover/artist/cat?id=')] = $v->text();
}
}
}
}
}
foreach ($typeMap as $id=>$type){
foreach ($rltArr as $url){
$urlRsltArr[$type][] = str_replace('1001',$id,$url);
}
}
return $urlRsltArr;
}
其中我们用到了一个工具HtmlDomParser,这是一个专门用来解析HTML的SDK,我们可以在github上很容易搜到,在这里我用的是composer来管理这些第三方框架的。
composer require sunra/php-simple-html-dom-parser
既然说到HtmlDomParser,我就多说一句。
传进去html字符串,获取一个DOM对象
$dom = HtmlDomParser::str_get_html($html_str);
find()方法是以选择器的方式来获取其中的某一个元素的,返回的是一个数组。
$dom->find($selector);
其中有一个坑,我们一般会用var_dump()来打印一个对象。但是对于这个工具来说,这样是无法打印出我们想要的东西的。
这个HTML解析工具为我们封装了一个dump(),我只需要$dom->dump()就可以调试打印了。
其中有一个BASE_URL,这个是我定义的一个常量。
define('BASE_URL','http://music.163.com');
define('MV_BASE_URL','http://music.163.com/mv?id=');
define('SONG_BASE_URL','http://music.163.com/song?id=');
define('APP_PATH',__DIR__);
define('DATA_PATH',APP_PATH.'/data');
这些路径很容易看懂吧。接下来会用到。
通过上述的代码我们封装了好了方法,我们需要一个初始URL来驱动我们整个爬虫的运行。
$url = 'http://music.163.com/discover/artist/cat?id=1001&initial=65';
接下来我们调用我们的方法
$url = 'http://music.163.com/discover/artist/cat?id=1001&initial=65';
$html_str = sendRequest($url);
$arr = getSingerHomeUrl($html_str);
二.分析歌手页面源码
可以从图5看出,整个歌手的数据分为两个部分。
第一部分:带图片的。
第二部分:不带图片。
接下来我们需要做的就是分析这两者的源码。
我们分析两者的源码可以得到如下的结论。
1、a标签的herf的值包含“/artist?id=”
2、并且歌手的id为的值为数字
3、歌手的主页的url为 http://music.163.com/artist?id=xxxx
很容易得到下面的代码
/**
* 获取歌手的信息,通过解析HTML字符串
* @param $html_str HTML字符串
* @param null $type 歌手类型
* @return array
*/
public function getSingerInfo(){
$file_path = APP_PATH.'/all/singer.log';
if (!file_exists($file_path)){
touch($file_path);
}
$html_str = $this->sendRequest();
$typeUrlsMap = $this->getSingerHomeUrl($html_str);
foreach ($typeUrlsMap as $type
=>$urls){
foreach ($urls as $url){
//***这个段代码为什么这样写后续在进行讲解***
//***先立一个flag***
//-------------FLAG1--------------
$home_html_str = $this->sendRequest($url);
do{
$home_html_str = $this->sendRequest($url);
if (empty($home_html_str)) {
Log::addLog('账号被封,证在等待解封,当前的时间戳'.time(),Log::WARNING);
sleep(1);
}
}while(empty($home_html_str));
//-------------FLAG1--------------
Log::addLog('正常运行['.$type.']......',Log::WARNING);
$this->getSingerInfoByHomeHtml($home_html_str,$type,$url);
}
}
}
/**
* 获取歌手的信息,通过解析HTML字符串
* @param $html_str HTML字符串
* @param null $type 歌手类型
* @return array
*/
function getSingerInfoByHomeHtml($html_str,$type=null,$url=null){
$dom = HtmlDomParser::str_get_html($html_str);
$elems = $dom->find('li');
$rltSingerArr = [];
foreach ($elems as $key => $value){
$aas = $value->find('a');
foreach ($aas as $k=>$v){
//根据上面分析我们很容易得到每个歌手的首页地址
$id = trim(trim($v->href),"/artist?id=");
if (strpos(trim($v->href),'/artist?id=') !== false && is_numeric($id) && !empty($v->text())){
$name = $v->text();
$href = BASE_URL.trim($v->href);
$singerInfoArr = [$id,$name,'',$href,'1090',$type];
$rltStr = implode("\t",$singerInfoArr);
$rltSingerArr[] = $rltStr;
//将获取的歌手信息写入文件
file_put_contents(APP_PATH.'/all/singer.log',$rltStr.PHP_EOL,FILE_APPEND) && $this->getSongAndMvInfo($href,$id);
}
}
}
return $rltSingerArr;
}
//我们以同样的方法获取到每一首歌的URL构成
//拼接处每一首歌的URL,然后获取他的详细信息
//在上面的函数里
function getSongAndMvInfo($url=null,$id=null){
$html_str = null;
do{
$html_str = $this->sendRequest($url);
if (empty($html_str)) {
Log::addLog('请求歌手主页账号被封['.$url.'],waitting.....'.time(),Log::WARNING);
sleep(1);
}
}while(empty($html_str));
Log::addLog('歌手获取URL正常运行['.$url.']',Log::WARNING);
$dom = HtmlDomParser::str_get_html($html_str);
$elems = $dom->find('textarea');
$jsonArr = [];
$arr = [];
foreach ($elems as $elem_a){
if ($elem_a->style == "display:none;" && !$elem_a->has_child() && empty($elem_a->id) && empty($elem_a->name)){
$jsonStr = $elem_a->text();
$jsonArr = $this->jsonToArray($jsonStr);
$this->formatOutput($jsonArr,APP_PATH.'/all/song.log',$id);
}
}
}
//格式化输出
function formatOutput($jsonArr,$fileName,$id){
if (!file_exists($fileName)){
touch($fileName);
}
foreach ($jsonArr as $song){
$arr = [$song['id'],$song['name'],$id,'',SONG_BASE_URL.$song['id'],'1090','',MV_BASE_URL.$song['mvid']];
file_put_contents($fileName,implode("\t",$arr).PHP_EOL,FILE_APPEND) ;
}
}
function jsonToArray($json_str,$isObj=true){
return json_decode($json_str,$isObj);
}
解释一下FLAG1
在实际测试中,发现网易云会进行封IP,因为我们请求太频繁,但是在封了账号以后,会在在几秒以后解封账号。
总结一下:
1、分析网页构成。
2、设计字符获取算法。
后期优化
上面存在的问题
1、直接阻塞式的获取歌手和歌曲的信息
2、如果中间出现了问题,还要从头开始爬,无法从断点处继续爬。
针对上述的问题得出解决方案:
1、多进程异步处理。
先获取歌手信息,存入多个文件,或者数据库。然后主进程开启多个子进程进行处理。
2、设置异常函数句柄,在回调函数里面保存状态。
register_shutdown_function(callback $funcName)
还需要进一步完善。