node.js和mysql实现爬虫虎扑网NBA赛事信息

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的知识,更多的是细心和耐心。对于信息量巨大的互联网空间,学会细心寻找有利用价值的数据是有必要的;对于刚开始学习的一门语言,学会耐心理解复杂冗长的代码语法、模块使用函数是值得的,因为一切复杂的背后都是人类利用麻木的机器代替工作的为难,因为程序语言不允许二义性,所以我相信,程序语言复杂的背后还是有智慧的设计感的。

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值