1.整体思路框架
笔者计划爬取虎扑网的NBA有关赛事信息。
- 观察到虎扑网址的赛事信息的URL中有时间字符串,所以计划设置一个时间变量,通过引用
'date-utils'
模块不断减少日期,达成爬取之前不同日期的赛事信息的目的; - 使用
'request'
模块向目标URL发送请求;使用'iconv-lite'
模块对网页源代码进行解码; - 再利用
'cheerio'
模块充当选择器,选择有可利用信息的标签或元素,并获取信息; - 然后用
mysql
将数据存入本地数据库; - 最后建立我们的前端检索网站,采用
express
框架将数据库中的信息以整齐的表格形式呈现。
2.爬虫js代码实现过程
调入一系列要用到的模块包:
var myRequest = require('request');//访问url网站
var myIconv = require('iconv-lite');//解码网页源代码
var myCheerio = require('cheerio');//解析网页源代码的选择器工具
var mysql = require('./mysql.js');//数据库文件(具体文件内容在后文)
require('date-utils');//对时间格式进行操作
然后定义我们要爬取的目标页面url,以及页面的编码规则。观察到虎扑NBA网的编码是“utf-8”,所以将编码定义为"utf-8"。
var seedURL = "https://nba.hupu.com/schedule/";
var myEncoding = 'utf-8';
观察要爬取的网站的url结构:
观察到我们可以通过在seedURL字符串的基础上通过添加"YYYY-MM-DD"格式的时间字符串,得到那一天开始之后的一周内的所有比赛信息,图中显示的“数据统计”是一个个超链接,我们可以获取其中超链接地址,用request函数进行访问,爬取某场比赛的详细信息。
实现控制比赛时间范围。
const weeks = 50;//要爬取几周内的比赛
var time = new Date(2021,3,26);//最近的一周的周一
for(var i = 0; i < weeks; i++){
var thisTime = time.addWeeks(-1);
var url = seedURL + thisTime.toFormat('YYYY-MM-DD');
console.log('即将爬取:' + url);
getCompitition(url);
}
其中weeks变量用以控制for循环执行的次数,addWeeks函数能将目标date对象的日期7天7天地改变,参数表示改变的次数,因此对一开始生成的time执行addWeeks(-1)操作
,让其不断减少一周,然后用toFormat函数将其时间格式变为"YYYY-MM-DD"格式,然后将seedURL和时间字符串相加,得到单次循环爬取的目标url地址。进入url后,遍历每场比赛,找到数据统计的url,再进行爬取详细数据。
这里有一点需要注意:生成日期时输入的月份是从0开始的,我一开始生成的是2021,4,26,结果爬出来一大堆未进行的在五月的比赛,后来发现这个问题。
访问url的request函数构建。
为了防止网页阻止爬虫,我将对网页的请求包装成一台电脑的请求。
var headers = {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/39.0.2171.65 Safari/537.36'
}
//利用'request'模块构建request函数,访问目标url,并在回调函数返回网页源码
function request(url, callback) {
var options = {
url: url,
encoding: null,
//proxy: 'http://x.x.x.x:8080',
headers: headers,
timeout: 10000
}
myRequest(options, callback)
};
getCompitition()函数的构建:
function getCompitition(url){
request(url, function(err, response, body){
if(err) throw err;//如果出错,抛出错误
var html = myIconv.encode(body, myEncoding);//用utf-8解码
var $ = myCheerio.load(html, { decodeEntities: true });//申明$变量,使用选择器操作,与jQuery语法几乎一致
var compttion = {};
var detailURL = '';
var flag = 0;//考虑网页代码布局
$(".players_table tbody a[target='_blank']").each(function(index){ //获取各场比赛球队总得分情况
switch(flag){
case 0:compttion.awayTeam = $(this).text();
flag++;
return;
case 1:compttion.homeTeam = $(this).text();
flag++;
return;
case 2:detailURL = $(this).attr('href');
flag = 0;
break;
}
var content = {};
content.awayTeam = compttion.awayTeam;
content.homeTeam = compttion.homeTeam;
if(reg_URL.test(detailURL))
getDetails(detailURL, content); //通过超链接读取比赛详细数据
else
console.log('非法url:' + detailURL);
});
});
}
上源代码图,再进行讲解:
(没有缩进的代码可读性太差了,所以我将代码复制到vscode中进行分析,部分代码如下)
可以发现该网页只有比赛的大体信息,因此笔者只爬取了比赛队伍名字(前者为客场队伍,后者为主场队伍)和数据统计的url。然后使用cheerio选择器通过’players_table’的类找到这个表格,然后进入下一级的tbody标签,再tbody中遍历target属性为’_blank’的a标签(超链接标签),从而得到详细数据。
但是细心的读者可以发现,每三个这样的a标签才表示一场比赛,难道不用在a标签的上一级标签中做遍历吗?这里笔者讨了个巧,定义了一个flag变量,初始值为0,然后每次进入each函数就进入switch函数体,
switch(flag){
case 0:compttion.awayTeam = $(this).text();
flag++;
return;
case 1:compttion.homeTeam = $(this).text();
flag++;
return;
case 2:detailURL = $(this).attr('href');
flag = 0;
break;
}
对于flag=0的情况,我们将text内容存在compttion的awayTeam属性里,然后flag+1,再return进入下一个each循环(each函数做return;操作相当于return true;,相当于循环的continue;而做return false操作则直接结束函数遍历,相当于循环的break);flag=1的时候同理;flag=2时,我们知道遍历到第三个a标签了,可以直接用attr方法获取’href’标签的属性值,这个属性值就是数据统计的url,因此我们存在变量detailURL中。
然后将compttion中的内容做封装,打包成content对象,然后传入下一个读取超链接的函数,使我们在下一个函数中可以访问到这里读取到的主客场队伍名。
为了保证detailURL是合法的比赛详细数据网址,我们定义了一个正则表达式,用以判断该url是否合法。
var reg_URL = /^https:\/\/nba.hupu.com\/games\/boxscore\/(\d{6})$/;
实现爬取详细信息,也即getDetails()函数的实现。
function getDetails(detailURL, content){
request(detailURL, function(err, response, body) {
if(err) throw err;
var dhtml = myIconv.encode(body, myEncoding);
var $ = myCheerio.load(dhtml, { decodeEntities: true });
const $AT = $('#J_away_content tbody');
const $HT = $('#J_home_content tbody');
const $AScore = $AT.children('.title').eq(2);
const $ARate = $AT.children('.title').eq(3);
const $HScore = $HT.children('.title').eq(2);
const $HRate = $HT.children('.title').eq(3);
const $infOfCom = $('.about_fonts');
var awayDetail = new Array(12), homeDetail = new Array(12);
for(var k = 0; k < 12; k++){
if(k < 3){
awayDetail[k] = $AScore.children().eq(k+3).text().replace(reg, '');
homeDetail[k] = $HScore.children().eq(k+3).text().replace(reg, '');
awayDetail[k] += ' rate: ' + $ARate.children().eq(k+3).text().replace(reg, '');
homeDetail[k] += ' rate: ' + $HRate.children().eq(k+3).text().replace(reg, '');
}else {
awayDetail[k] = parseInt($AScore.children().eq(k+3).text().replace(reg, ''));
homeDetail[k] = parseInt($HScore.children().eq(k+3).text().replace(reg, ''));
}
}
//数组的0到11分别是:投篮,3分,罚球,前场,后场,篮板,助攻,犯规,抢断,失误,封盖,总得分
const dateOfCom = $infOfCom.find('.time_f').text().substr(3);
const timeCost = $infOfCom.find('.consumTime').text().substr(3);
const location = $infOfCom.find('.arena').text().substr(3);
const numsOfAd = $infOfCom.find('.peopleNum').text().substr(3);
const ARecord = $('.team_a').find('.message').children().eq(2).text().replace(reg, '').substr(3, 6);
const HRecord = $('.team_b').find('.message').children().eq(2).text().replace(reg, '').substr(3, 6);
const At_t = content.awayTeam + dateOfCom;
const Ht_t = content.homeTeam + dateOfCom;
}
});
}
同样,我们用request目标url访问,解码和选择器操作与之前一致,在这里我封装了一系列$…变量,这是综合考虑页面源代码布局后采取的。先观察一下’数据统计’页面的布局以及代码布局(以鹈鹕对尼克斯的4月19日比赛为例):
笔者希望的是爬取到开赛时间、耗时、比赛地址、上座人数以及每个队伍的赛季总战绩,投篮,3分,罚球,前场,后场,篮板,助攻,犯规,抢断,失误,封盖,总得分这些信息。
分析数据统计页面的源代码结构。
这是关于比赛的总体详细信息,我们可以锁定到’about_fonts’的class属性,利用cheerio选择器爬取这里的四个信息。需要注意的是,这里的class="about_fonts clearfix"中间的空格并不属于类的名称,这里空格的意思是说这个div标签同时属于about_fonts和clearfix两个标签。选择器中间加空格的意思是进入子标签,因此类的名字绝对不会是有空格的。我在初了解cheerio选择器的时候就以为这个整体是标签的类名称,然后总是报error。上万能的CSDN搜索的结果如下:
于是我便这样使用了,结果得到了好多我不需要的信息,还有好多空信息。这让我察觉加两个点并不是正确解决方法。于是我又继续查阅文章,直到发现了这个:
至此我才理解,加了空格是说明它同时属于两个类,所以只要选择一个能锁定到这个标签的类就好了,我选择了about_fonts标签,因为它在这个页面的源代码中是唯一的。
所以我用以下代码得到比赛的大背景信息:
const dateOfCom = $infOfCom.find('.time_f').text().substr(3);
const timeCost = $infOfCom.find('.consumTime').text().substr(3);
const location = $infOfCom.find('.arena').text().substr(3);
const numsOfAd = $infOfCom.find('.peopleNum').text().substr(3);
const ARecord = $('.team_a').find('.message').children().eq(2).text().replace(reg, '').substr(3, 6);
const HRecord = $('.team_b').find('.message').children().eq(2).text().replace(reg, '').substr(3, 6);
得分以及犯规数据的爬取。
先观察一下详细数据表格的源代码布局:
加上缩进并且收缩条目之后,表格的结构显得十分清晰。tr标签大部分都是对某一个球员的各项得分情况作统计,而我们的目的只是获取球队在该场比赛各项的总得分或者总次数。观察到“统计”一栏处于class为’title’的第3个tr标签中,而rate指标是在第4个,所以我们通过对标签的子类集合(通过children函数获取)作索(通过eq函数)引操作,锁定信息所在标签。
const $AT = $('#J_away_content tbody');//Away Team
const $HT = $('#J_home_content tbody');//Home Team
const $AScore = $AT.children('.title').eq(2);//Away team Scores
const $ARate = $AT.children('.title').eq(3);//Away team Rate
const $HScore = $HT.children('.title').eq(2);//Home team Scores
const $HRate = $HT.children('.title').eq(3);//Home team Rate
const $infOfCom = $('.about_fonts');//information of competition
然后,观察tr标签中数据的详细分布:
发现可以用循环体内的eq()函数遍历td标签,并将数据存进数组,然后观察网页表格的纵向指标,将数组元素一一对应到各项得分或者次数上。因此我们有了以下循环操作:
var awayDetail = new Array(12), homeDetail = new Array(12);
for(var k = 0; k < 12; k++){
if(k < 3){
awayDetail[k] = $AScore.children().eq(k+3).text().replace(reg, '');
homeDetail[k] = $HScore.children().eq(k+3).text().replace(reg, '');
awayDetail[k] += ' rate: ' + $ARate.children().eq(k+3).text().replace(reg, '');
homeDetail[k] += ' rate: ' + $HRate.children().eq(k+3).text().replace(reg, '');
}else {
awayDetail[k] = parseInt($AScore.children().eq(k+3).text().replace(reg, ''));
homeDetail[k] = parseInt($HScore.children().eq(k+3).text().replace(reg, ''));
}
}
//数组的0到11分别是:投篮,3分,罚球,前场,后场,篮板,助攻,犯规,抢断,失误,封盖,总得分
这里我将投篮、3分、罚球三个指标的得分以及命中率指标字符串拼接了起来,用以直观体现得分情况以及命中情况。然后,对于其他指标,我对字符串用parseInt()函数作处理,得到number类型的数据,存在数组中。
完成
至此完成了爬取网页数据的工程。
3. 存储比赛信息于mysql数据库
接下来实现用mysql存储爬取的信息。
创建mycrawl数据库,并创建一个存储数据的表格:
create database mycrawl;
use mycrawl;
CREATE TABLE `myfetches` (
`id_myFetches` int(11) NOT NULL AUTO_INCREMENT,
`team_time` varchar(40) DEFAULT NULL,
`url` varchar(200) DEFAULT NULL,
`team` varchar(10) DEFAULT NULL,
`ground` varchar(2) DEFAULT NULL,
`scores` int(4) DEFAULT NULL,
`record` varchar(20) DEFAULT NULL,
`oppo` varchar(10) DEFAULT NULL,
`ground_op` varchar(2) DEFAULT NULL,
`opScores` int(4) DEFAULT NULL,
`opRecord` varchar(20) DEFAULT NULL,
`time` varchar(30) DEFAULT NULL,
`timeCost` varchar(10) DEFAULT NULL,
`location` varchar(20) DEFAULT NULL,
`numsOfAd` varchar(10) DEFAULT NULL,
`shoot` varchar(20) DEFAULT NULL,
`3point` varchar(20) DEFAULT NULL,
`penalty` varchar(20) DEFAULT NULL,
`frontcourt` int(4) DEFAULT NULL,
`backcourt` int(4) DEFAULT NULL,
`backboard` int(4) DEFAULT NULL,
`assist` int(4) DEFAULT NULL,
`foul` int(4) DEFAULT NULL,
`steal` int(4) DEFAULT NULL,
`mistake` int(4) DEFAULT NULL,
`cover` int(4) DEFAULT NULL,
PRIMARY KEY (`id_myFetches`),
UNIQUE KEY `id_myFetches_UNIQUE` (`id_myFetches`),
UNIQUE KEY `team_time_UNIQUE` (`team_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
还需要一个mysql.js文件,用以连接数据库与我们的js代码。
var mysql = require("mysql");
var pool = mysql.createPool({
host: '127.0.0.1',
user: 'root',
password: 'root',
database: 'myCrawl'
});
var query = function(sql, sqlparam, callback) {
pool.getConnection(function(err, conn) {
if (err) {
callback(err, null, null);
} else {
conn.query(sql, sqlparam, function(qerr, vals, fields) {
conn.release(); //释放连接
callback(qerr, vals, fields); //事件驱动回调
});
}
});
};
var query_noparam = function(sql, callback) {
pool.getConnection(function(err, conn) {
if (err) {
callback(err, null, null);
} else {
conn.query(sql, function(qerr, vals, fields) {
conn.release(); //释放连接
callback(qerr, vals, fields); //事件驱动回调
});
}
});
};
exports.query = query;
exports.query_noparam = query_noparam;
在js中将变量的值存入数据库
我们的初步构想是对于一场比赛的主客场队伍,存入数据库两条记录,对于某一条数据,各项指标如代码所示。为了避免重复存入数据,我们必须对每个记录设置一个独一无二的指标。我本来打算使用爬取网站的url作为unique的指标,但是对于同一场比赛的主客场队伍,它们的url是一样的,因此无法实现预期目标。于是我想到用“队伍名”+“比赛时间”的形式作为独特标签,因为一支队伍不可能在同一时间进行两场比赛。因此我设置了'team_time'
信息,作为每条信息的unique数据。
然后我们在js代码中加入这一段代码:
if(timeCost != '暂无统计'){
var fetchAddSql = 'INSERT INTO myFetches(team_time,url,team,ground,scores,record,oppo,ground_op,opScores,opRecord,'+
'time,timeCost,location,numsOfAd,shoot,3point,penalty,frontcourt,backcourt,backboard,assist,foul,steal,mistake,cover) '+
'VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)';
var fetchAddSql_A = [At_t,detailURL,content.awayTeam,'客场',awayDetail[11],ARecord,content.homeTeam,'主场',homeDetail[11],
HRecord,dateOfCom,timeCost,location,numsOfAd,awayDetail[0],awayDetail[1],awayDetail[2],awayDetail[3],
awayDetail[4],awayDetail[5],awayDetail[6],awayDetail[7],awayDetail[8],awayDetail[9],awayDetail[10]];
var fetchAddSql_H = [Ht_t,detailURL,content.homeTeam,'主场',homeDetail[11],HRecord,content.awayTeam,'客场',awayDetail[11],
ARecord,dateOfCom,timeCost,location,numsOfAd,homeDetail[0],homeDetail[1],homeDetail[2],homeDetail[3],
homeDetail[4],homeDetail[5],homeDetail[6],homeDetail[7],homeDetail[8],homeDetail[9],homeDetail[10]];
//执行sql,数据库中fetch表里的team_time属性是unique的,不会把重复的team_time内容写入数据库
mysql.query(fetchAddSql, fetchAddSql_A, function(qerr, vals, fields) {
if (qerr) {
console.log(qerr);
}
});
mysql.query(fetchAddSql, fetchAddSql_H, function(qerr, vals, fields) {
if (qerr) {
console.log(qerr);
}
}); //mysql写入
}
最开始判断比赛是不是已经结束了,因为被之前爬取一大堆未开始的比赛弄怕了(手动捂脸),然后将数据一一对应写入数据库的各项指标,然后存入数据库两条记录。
我们可以在数据库中看一下我们存储的数据:
select team,team,ground,scores,record,oppo,ground_op,opScores,opRecord,time,timeCost,location,numsOfAd from myfetches limit 100;
得到结果如下:
4. 用express框架构建前端实现搜索和表格信息展示
接下来笔者希望构建一个前端网页,后端连接我们的数据库,然后通过输入球队名字,找到该球队的所有比赛记录。在这里利用了express脚手架
来实现。
建立与mysql的连接
var express = require('express');
var router = express.Router();
var mysql = require('../mysql.js')
/* GET home page. */
router.get('/', function(req, res, next) {
res.render('index', { title: 'Express' });
});
router.get('/process_get', function(request, response) {
var fetchSql = "select team,ground,scores,record,oppo,ground_op,"+
"opScores,opRecord,time,timeCost,location,numsOfAd,shoot,3point,penalty,"+
"frontcourt,backcourt,backboard,assist,foul,steal,mistake,cover "+
"from myfetches where team like '%"+request.query.title+"%'";
mysql.query(fetchSql, function(err, result, fields) {
response.writeHead(200, {
"Content-Type": "application/json"
});
response.write(JSON.stringify(result));
response.end();
});
});
module.exports = router;
这里是将前端获得的输入请求get到,然后进入数据库检索,基本语法是:
select *** from myfetches where team like '%?%';
然后将我们的结果进行JSON文件格式处理,展示在前端。
前端我们利用express构建一个表格框架,然后将后端的数据信息整齐地展示出来。前端代码如下:
<!DOCTYPE html>
<html>
<header>
<script src="https://cdn.bootcss.com/jquery/3.4.1/jquery.js"></script>
</header>
<body>
<form>
<br> 要搜索的队伍名:<input type="text" name="team_text">
<input id = 'submit' class="form-submit" type="button" value="查询有关战绩">
</form>
<div class="cardLayout" style="margin: 10px 0px">
<table width="100%" id="record2"></table>
</div>
<script>
$(document).ready(function() {
$("input:button").click(function() {
$.get('/process_get?title=' + $("input:text").val(), function(data) {
$("#record2").empty();
$("#record2").append('<tr class="cardLayout"><td>队伍</td><td>场1'+
'</td><td>队伍得分</td><td>队伍总战绩</td><td>对手</td><td>场2</td><td>'+
'对手得分</td><td>对手总战绩</td><td>比赛时间</td><td>耗时</td><td>地点</td><td>'+
'观众数</td><td>投篮</td><td>3分</td><td>罚球</td><td>前场</td><td>后场</td><td>'+
'篮板</td><td>助攻</td><td>犯规</td><td>抢断</td><td>失误</td><td>封盖</td></tr>');
for (let list of data) {
let table = '<tr class="cardLayout"><td>';
Object.values(list).forEach(element => {
table += (element + '</td><td>');
});
$("#record2").append(table + '</td></tr>');
}
});
});
});
</script>
</body>
</html>
这里构建了一个表格,将数据一一整齐地展示在前端页面。
接下来就是展示成果的时候了!
(由于url标签过于冗杂,便没有在表格展示)
5.总结
这是笔者实现的第一次爬虫,中间熬过好多个夜,也有好多次调了半天仍然一大堆error的情况,但是最终成功时的喜悦确实语言无法表达的。这次经历也让我学到了很多。不单单是Javascript和mysql的知识,更多的是细心和耐心。对于信息量巨大的互联网空间,学会细心寻找有利用价值的数据是有必要的;对于刚开始学习的一门语言,学会耐心理解复杂冗长的代码语法、模块使用函数是值得的,因为一切复杂的背后都是人类利用麻木的机器代替工作的为难,因为程序语言不允许二义性,所以我相信,程序语言复杂的背后还是有智慧的设计感的。