1. 项目总体要求
1.1 网站后端
核心需求:选取3~5个有代表性的新闻网站建立爬虫,针对不同网站的新闻页面进行分析,爬取出编码、标题、作者、时间、关键词、摘要、内容、来源等结构化信息,存储在数据库中。
技术要求:采用Node.JS实现网络爬虫。
1.2 网站前端
核心需求:建立网站提供对爬取内容的分项全文搜索,给出所查关键词的时间热度分析。
技术要求:采用HTML+JS实现前端。
1.3 注意事项
本项目的实现过程主要基于Web编程的实验课课件内容,在此基础上利用理论课课件内容进行衔接与修饰。本文在涉及到直接使用实验课课件内容处可能讲述的较为简略,不再赘述,因此建议在阅读本文之前先熟悉一下所有实验课课件。
2. 网站后端实现
2.1 目标网站分析
我选择的目标网站是懂球帝(https://dongqiudi.com)和虎扑体育(https://www.hupu.com),它们是体育新闻领域中的两个代表性网站,具有用户基数大、内容更新快的特点,适合作为爬虫爬取对象。
目标网站分析主要分为两部分:先进行种子页面分析,解析出新闻链接;再进行新闻页面分析,解析出编码、标题、作者、时间、关键词、内容、来源等结构化数据。
目标网站分析主要指页面元素检查,可以通过在网页点击右键选择“检查”选项或按“F12”键查看源码。
2.1.1 懂球帝
2.1.1.1 种子页面分析
由于网站首页的可爬取新闻链接数量过少,我选择可爬取新闻链接数量更多的动态分区(https://dongqiudi.com/articles)作为种子页面。
通过检查种子页面的元素可知,所有的新闻链接都位于a标签中,所以设计新闻链接的爬取格式如下。
var seedURL_format = "$('a')";
此处的a标签并没有显示href属性,但在后续使用爬虫尝试对其进行爬取时,可以成功获取位于href属性中的具体的新闻链接,对于这个问题目前还未找到一个合理的解释,读者可以将自己的思考结果发在评论区。新闻链接的格式如下。
通过查看多个新闻页面可知,新闻链接都具有“/”加上7位数字加上“.html”的形式,因此可以设计并利用如下的正则表达式对爬取的新闻链接进行筛选。
var url_reg = /\/(\d{7}).html/;
2.1.1.2 新闻页面分析
在新闻页面中,可以通过检查元素确定编码方式并依次找到标题、作者、时间、内容、关键词对应的标签特征,例如class属性定义的元素类名,从而设计相对应的元素读取格式。
编码:
可知新闻页面采用的是utf-8编码。
标题:
可知标题位于class属性为“news-title”的<h1>标签中,设计如下的标题爬取格式。
var title_format = "$('.news-title').text()";
作者、时间:
可知作者和时间都位于class属性为“tips”的<p>标签中,其中,作者位于<p>标签中的<span>标签中,时间具有“yyyy-MM-dd HH:mm”的形式,设计如下的作者及时间爬取格式和时间筛选正则表达式。
var author_format = "$('.tips span').text()";
var date_format = "$('.tips').text()";
var regExp = /\d{4}-\d{2}-\d{2} \d{2}:\d{2}/;
内容:
可知内容位于class属性为“con”的<div>标签中,设计如下的内容爬取格式。
var content_format = "$('.con').text()";
关键词:
这里选取页面中的相关标签作为关键词,因为相关标签这一栏提供了与新闻最为相关的球员名和球队名,可以辅助用户快速判断是否是自己感兴趣的新闻。可知所有关键词均位于一个描述列表中,具体位于class属性为“name”的<p>标签中,设计如下的关键词爬取格式。
var keywords_format = "$('.tag-item .name').text()";
2.1.2 虎扑体育
2.1.1.1 种子页面分析
我选择网站首页(https://www.hupu.com)作为种子页面。
通过检查种子页面的元素可知,所有的新闻链接都位于a标签中,所以设计新闻链接的爬取格式如下。
var seedURL_format = "$('a')";
此处的a标签显示了href属性,所以可以获取位于href属性中的具体的新闻链接。通过查看a标签的href属性可知,新闻链接都具有“/”加上8位数字加上“.html”的形式,因此可以设计并利用如下的正则表达式对爬取的新闻链接进行筛选。
var url_reg = /\/(\d{8}).html/;
2.1.1.2 新闻页面分析
在新闻页面中,可以通过检查元素确定编码方式并依次找到标题、作者、时间、内容、关键词对应的标签特征,例如class属性定义的元素类名,从而设计相对应的元素读取格式。
编码:
可知新闻页面采用的是utf-8编码。
标题:
可知标题位于class属性为“index_name__M5qqs”的<h1>标签中,设计如下的标题爬取格式。
var title_format = "$('.index_name__M5qqs').text()";
作者:
可知作者位于class属性为“post-user_post-user-comp-info-top-name__N3D4w”的<a>标签中,设计如下的作者爬取格式。
var author_format = "$('.post-user_post-user-comp-info-top-name__N3D4w').text()";
时间:
可知时间位于class属性为“post-user_post-user-comp-info-top-time__k9K2U”的<span>标签中,且时间具有“yyyy-MM-dd HH:mm:ss”的形式,设计如下的时间爬取格式和时间筛选正则表达式。
var date_format = "$('.post-user_post-user-comp-info-top-time__k9K2U').text()";
var regExp = /\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/;
内容:
可知内容位于class属性为“thread-content-detail”的<div>标签中,设计如下的内容爬取格式。
var content_format = "$('.thread-content-detail').text()";
关键词:
这里选取新闻发表的分区名称作为关键词,对用户来说可以起到初步筛选作用。可知关键词位于class属性为“post-user_post-user-comp-info-bottom-link__BMF8U”的<a>标签中,设计如下的关键词爬取格式。
var keywords_format = "$('.post-user_post-user-comp-info-bottom-link__BMF8U').text()";
2.2 网络爬虫编写
2.2.1 MySQL数据库
我下载的是MySQL Community Server 8.0.33的免安装版,同时还下载了官方可视化工具MySQL Workbench 8.0 CE。
新建一个crawl数据库后,再创建三张表,本项目实际使用了fetches_1、fetches_2这两张表,表fetches_1用于存储懂球帝爬取内容,表fetches_2用于存储虎扑体育爬取内容。
2.2.2 Node.JS网络爬虫
对于懂球帝和虎扑体育,编写爬虫的思路大致相同:每次读取种子页面,分析出种子页面中的新闻链接后,爬取所有新闻链接的内容;通过分析新闻页面,解析出编码、标题、作者、时间、关键词、内容等结构化内容;将非重复的结构化内容保存至MySQL数据库(来自两个目标网站的数据分别单独保存在一张表中)。我在六天内每12小时定时爬取,共爬取了11次,最终共爬取到了约1300条记录。
在爬虫编写过程中,主要用到了request、cheerio、iconv-lite、node-schedule这四个模块,需要提前自行下载并引入。特别注意如果要使用自己新建的表,需要在代码相应位置修改表名。
2.2.2.1 懂球帝
var myRequest = require('request');
var myCheerio = require('cheerio');
var myIconv = require('iconv-lite');
var mysql = require('./mysql.js');
var schedule = require('node-schedule');
var source_name = "懂球帝";
var myEncoding = "utf-8";
var seedURL = "https://dongqiudi.com/articles/";
// 将要爬取数据的格式(通过查看页面源代码获得)
var seedURL_format = "$('a')";
var title_format = "$('.news-title').text()";
var content_format = "$('.con').text()";
var keywords_format = "$('.tag-item .name').text()";
var date_format = "$('.tips').text()";
var author_format = "$('.tips span').text()";
var url_reg = /\/(\d{7}).html/;
var regExp = /\d{4}-\d{2}-\d{2} \d{2}:\d{2}/;
// 防止网站屏蔽我的爬虫
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模块异步fetch 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)
};
// 处理种子页面
function seedget() {
request(seedURL, function(err, res, body) { // 读取种子页面
// 用iconv转换编码,获取种子页面html
var html = myIconv.decode(body, myEncoding);
// 用cheerio解析种子页面html
var $ = myCheerio.load(html, { decodeEntities: true });
// 获取种子页面里所有的新闻页面a链接
var seedurl_news;
try {
seedurl_news = eval(seedURL_format);
} catch (e) { console.log('url列表所处的html块识别出错:' + e) };
seedurl_news.each(function(i, e) {
var myURL = "";
try {
// 获取新闻页面的url
var href = "";
href = $(e).attr("href");
if (href == undefined) {
return;
}
if (href && (href.toLowerCase().indexOf("http://") >= 0 || href.toLowerCase().indexOf("https://") >= 0)) {
myURL = href;
} // 以"http://"开头的url
else if (href.startsWith("//")) {
myURL = "http:" + href;
} // 以"//"开头的url
else {
var lastIndex = seedURL.lastIndexOf("/");
myURL = seedURL.substr(0, seedURL.lastIndexOf("/", lastIndex - 1)) + href;
} // 其他形式的url
} catch (e) { console.log('识别种子页面中的新闻链接出错:' + e) }
// 检验获取的url是否符合新闻页面url的正则表达式
if (!url_reg.test(myURL)) return;
// 在数据库中查询url是否已经存在
var fetch_url_Sql = 'select url from fetches_1 where url=?';
var fetch_url_Sql_Params = [myURL];
mysql.query(fetch_url_Sql, fetch_url_Sql_Params, function(qerr, vals, fields) {
if (vals.length > 0) { // 若存在,无需存入数据库
console.log('URL duplicate!')
} else newsGet(myURL); // 若不存在,读取新闻页面的具体信息
});
});
});
};
// 处理新闻页面
function newsGet(myURL) {
request(myURL, function(err, res, body) { // 读取新闻页面
// 用iconv转换编码,获取新闻页面html
var html_news = myIconv.decode(body, myEncoding);
// 用cheerio解析新闻页面html
var $ = myCheerio.load(html_news, { decodeEntities: true });
myhtml = html_news;
console.log("转码读取成功:" + myURL);
// 动态执行format字符串,构建json对象准备写入数据库
var fetch = {};
fetch.title = "";
fetch.content = "";
fetch.keywords = "";
fetch.author = "";
fetch.publish_date = "";
fetch.url = myURL;
fetch.source_name = source_name;
fetch.source_encoding = myEncoding;
fetch.crawltime = new Date();
// 获取标题
if (title_format == "") {
fetch.title = "";
}
else {
fetch.title = eval(title_format);
}
// 获取内容
if (content_format == "") {
fetch.content = "";
}
else { // 去除作者信息
fetch.content = eval(content_format).replace("\r\n" + fetch.author, "").trim();
}
// 获取关键词
if (keywords_format == "") { // 若没有关键词,以source_name填充
fetch.keywords = source_name;
}
else {
// fetch.keywords = eval(keywords_format);
// 上面爬取的关键词之间无空格隔开,下面的爬取方法可以将关键词以空格隔开
const nameElements = $('.relate-tag-list .name');
const names = nameElements.map((index, element) => {
return $(element).text();
}).get();
fetch.keywords = names.join(' ');
}
// 获取作者
if (!eval(author_format)) {
fetch.author = source_name;
}
else {
fetch.author = eval(author_format);
}
if (fetch.author.startsWith(" 懂球号作者: ")) { // 如果是懂球号作者,去除其前缀
fetch.author = fetch.author.substring(8);
}
// 获取刊登日期
if (!eval(date_format)) {
fetch.publish_date = eval(date_format);
}
if(fetch.publish_date) {
fetch.publish_date = regExp.exec(fetch.publish_date)[0];
}
else {
fetch.publish_date = new Date().toLocaleDateString().split('/').join('-');
}
fetch.publish_date = fetch.publish_date.replace("年", "-");
fetch.publish_date = fetch.publish_date.replace("月", "-");
fetch.publish_date = fetch.publish_date.replace("日", "");
fetch.publish_date = new Date(fetch.publish_date).toLocaleDateString().split('/').join('-');
// 将新闻页面信息写入数据库
if (fetch.title != "") {
var fetchAddSql = 'INSERT INTO fetches_1(url,source_name,source_encoding,title,' +
'keywords,author,publish_date,crawltime,content) VALUES(?,?,?,?,?,?,?,?,?)';
var fetchAddSql_Params = [fetch.url, fetch.source_name, fetch.source_encoding,
fetch.title, fetch.keywords, fetch.author, fetch.publish_date,
fetch.crawltime.toLocaleDateString().split('/').join('-'), fetch.content];
mysql.query(fetchAddSql, fetchAddSql_Params, function(qerr, vals, fields) {
if (qerr) {
console.log(qerr);
}
});
}
});
}
// 定时爬虫
var rule = new schedule.RecurrenceRule();
var times = [9, 21]; // 每天2次自动执行(9点和21点)
var times2 = 0; // 每次整点执行
rule.hour = times;
rule.minute = times2;
schedule.scheduleJob(rule, function() {
seedget();
});
2.2.2.2 虎扑体育
var myRequest = require('request');
var myCheerio = require('cheerio');
var myIconv = require('iconv-lite');
var mysql = require('./mysql.js');
var schedule = require('node-schedule');
var source_name = "虎扑体育";
var myEncoding = "utf-8";
var seedURL = "https://www.hupu.com/";
// 将要爬取数据的格式(通过检页面源代码获得)
var seedURL_format = "$('a')";
var title_format = "$('.index_name__M5qqs').text()";
var content_format = "$('.thread-content-detail').text()";
var keywords_format = "$('.post-user_post-user-comp-info-bottom-link__BMF8U').text()";
var date_format = "$('.post-user_post-user-comp-info-top-time__k9K2U').text()";
var author_format = "$('.post-user_post-user-comp-info-top-name__N3D4w').text()";
var url_reg = /\/(\d{8}).html/;
var regExp = /\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/;
// 防止网站屏蔽我的爬虫
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模块异步fetch 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)
};
// 处理种子页面
function seedget() {
request(seedURL, function(err, res, body) { // 读取种子页面
// 用iconv转换编码,获取种子页面html
var html = myIconv.decode(body, myEncoding);
// 用cheerio解析种子页面html
var $ = myCheerio.load(html, { decodeEntities: true });
// 获取种子页面里所有的新闻页面a链接
var seedurl_news;
try {
seedurl_news = eval(seedURL_format);
} catch (e) { console.log('url列表所处的html块识别出错:' + e) };
seedurl_news.each(function(i, e) {
var myURL = "";
try {
// 获取新闻页面的url
var href = "";
href = $(e).attr("href");
if (href == undefined) {
return;
}
if (href && (href.toLowerCase().indexOf("http://") >= 0 || href.toLowerCase().indexOf("https://") >= 0)) {
myURL = href;
} // 以"http://"开头的url
else if (href.startsWith("//")) {
myURL = "http:" + href;
} // 以"//"开头的url
else {
var lastIndex = seedURL.lastIndexOf("/");
myURL = seedURL.substr(0, seedURL.lastIndexOf("/", lastIndex - 1)) + href;
} // 其他形式的url
} catch (e) { console.log('识别种子页面中的新闻链接出错:' + e) }
// 检验获取的url是否符合新闻页面url的正则表达式
if (!url_reg.test(myURL)) return;
// 在数据库中查询url是否已经存在
var fetch_url_Sql = 'select url from fetches_2 where url=?';
var fetch_url_Sql_Params = [myURL];
mysql.query(fetch_url_Sql, fetch_url_Sql_Params, function(qerr, vals, fields) {
if (vals.length > 0) { // 若存在,无需存入数据库
console.log('URL duplicate!')
} else newsGet(myURL); // 若不存在,读取新闻页面的具体信息
});
});
});
};
// 处理新闻页面
function newsGet(myURL) {
request(myURL, function(err, res, body) { // 读取新闻页面
// 用iconv转换编码,获取新闻页面html
var html_news = myIconv.decode(body, myEncoding);
// 用cheerio解析新闻页面html
var $ = myCheerio.load(html_news, { decodeEntities: true });
myhtml = html_news;
console.log("转码读取成功:" + myURL);
// 动态执行format字符串,构建json对象准备写入数据库
var fetch = {};
fetch.title = "";
fetch.content = "";
fetch.keywords = "";
fetch.author = "";
fetch.publish_date = "";
fetch.url = myURL;
fetch.source_name = source_name;
fetch.source_encoding = myEncoding;
fetch.crawltime = new Date();
// 获取标题
if (title_format == "") {
fetch.title = "";
}
else {
fetch.title = eval(title_format);
}
// 获取内容
if (content_format == "") {
fetch.content = "";
}
else { // 去除作者信息
fetch.content = eval(content_format).replace("\r\n" + fetch.author, "").trim();
}
// 获取关键词
if (keywords_format == "") { // 若没有关键词,以source_name填充
fetch.keywords = source_name;
}
else {
fetch.keywords = eval(keywords_format);
fetch.keywords = fetch.keywords.trim().split(/\s+/)[0]; //保留分区名称
}
// 获取作者
if (!eval(author_format)) {
fetch.author = source_name;
}
else {
fetch.author = eval(author_format);
}
// 获取刊登日期
if (!eval(date_format)) {
fetch.publish_date = eval(date_format);
}
if(fetch.publish_date) {
fetch.publish_date = regExp.exec(fetch.publish_date)[0];
}
else {
fetch.publish_date = new Date().toLocaleDateString().split('/').join('-');
}
fetch.publish_date = fetch.publish_date.replace("年", "-");
fetch.publish_date = fetch.publish_date.replace("月", "-");
fetch.publish_date = fetch.publish_date.replace("日", "");
fetch.publish_date = new Date(fetch.publish_date).toLocaleDateString().split('/').join('-');
// 将新闻页面信息写入数据库
if (fetch.title != "") {
var fetchAddSql = 'INSERT INTO fetches_2(url,source_name,source_encoding,title,' +
'keywords,author,publish_date,crawltime,content) VALUES(?,?,?,?,?,?,?,?,?)';
var fetchAddSql_Params = [fetch.url, fetch.source_name, fetch.source_encoding,
fetch.title, fetch.keywords, fetch.author, fetch.publish_date,
fetch.crawltime.toLocaleDateString().split('/').join('-'), fetch.content];
mysql.query(fetchAddSql, fetchAddSql_Params, function(qerr, vals, fields) {
if (qerr) {
console.log(qerr);
}
});
}
});
}
// 定时爬虫
var rule = new schedule.RecurrenceRule();
var times = [9, 21]; // 每天2次自动执行(9点和21点)
var times2 = 0; // 每次整点执行
rule.hour = times;
rule.minute = times2;
schedule.scheduleJob(rule, function() {
seedget();
});
3. 网站前端实现
3.1 网站页面设计
网站页面由网站标题、搜索框、导航栏、搜索结果四部分组成。
3.1.1 网站标题
我利用PPT将网站标题制作成艺术字并转为图片形式放入网页。
<div class="title-container">
<img id="title" src="images/title.png" alt="Title">
</div>
css文件中设置网站标题的位置和大小。
.title-container {
display: flex;
justify-content: center;
margin-top: 30px;
}
#title {
width: 500px;
}
3.1.2 搜索框
我实现了对多个查询条件的复合查询功能。靠左的三个块为输入框:前两个输入框为文本输入框,都用于输入标题关键字,搜索时会根据标题关键字筛选标题符合的记录;第三个输入框为提供选项菜单的表单控件,用于输入时间范围,搜索时会根据时间范围筛选时间符合的记录。
<div class="search-box">
<form>
<input type="text" id="input-box1" name="title_text1" autocomplete="on" placeholder="标题关键词1">
<input type="text" id="input-box2" name="title_text2" autocomplete="on" placeholder="标题关键词2">
<select id="select-date" name="date-range">
<option value="">全部</option>
<option value="2023-07-18">07-18</option>
<option value="2023-07-17">07-17</option>
<option value="2023-07-16">07-16</option>
<option value="2023-07-15">07-15</option>
<option value="2023-07-14">07-14</option>
<option value="2023-07-13">07-13</option>
</select>
</form>
</div>
css文件中设置输入框的位置、大小和背景。
#input-box1 {
/* 位置 */
position: absolute;
left: 32%;
top: 30%;
margin: 0 auto;
overflow: hidden;
/* 大小 */
width: 140px;
height: 45px;
border: none;
/* 背景 */
background-color: white;
box-shadow: none;
color: black;
font-size: 16px;
line-height: 34px;
}
#input-box2 {
/* 位置 */
position: absolute;
left: 42%;
top: 30%;
margin: 0 auto;
overflow: hidden;
/* 大小 */
width: 140px;
height: 45px;
border: none;
/* 背景 */
background-color: white;
box-shadow: none;
color: black;
font-size: 16px;
line-height: 34px;
}
#select-date {
/* 位置 */
position: absolute;
left: 52%;
top: 30%;
margin: 0 auto;
overflow: hidden;
/* 大小 */
width: 135px;
height: 47px;
border: none;
/* 背景 */
background-color: white;
box-shadow: none;
color: black;
font-size: 16px;
line-height: 34px;
}
靠右的两个块为功能按钮:第一个是“查询”按钮,点击后会根据输入框中的内容在数据库中查询,默认是从懂球帝对应的表中查询;第二个是“重置”按钮,点击后会将前两个输入框中的内容全部清空,将第三个输入框中的内容重置为默认。
<div>
<input class="form-submit" type="button" value="查询">
<button class="reset" type="button" onclick="reset()">重置</button>
</div>
<script>
$(document).ready(function() {
$(".form-submit").click(function() {
searchDqd();
});
});
function reset() {
document.getElementById("input-box1").value = "";
document.getElementById("input-box2").value = "";
document.getElementById("select-date").value = "";
}
</script>
css文件中设置功能按钮的位置、大小和边框,以及用户将光标悬停在按钮上时触发的特殊样式。
.form-submit {
/* 位置 */
position: absolute;
top: 30%;
left: 62%;
margin: 0;
padding: 0;
/* 大小 */
width: 55px;
height: 45px;
border: none;
font-size: 16px;
/* 边框 */
border-radius: 2px;
background: lightblue;
line-height: 26px;
cursor: pointer;
}
.form-submit:hover {
background: rgb(0, 160, 255);
}
.reset {
/* 位置 */
position: absolute;
top: 30%;
left: 66%;
margin: 0;
padding: 0;
/* 大小 */
width: 55px;
height: 45px;
border: none;
font-size: 16px;
/* 边框 */
border-radius: 2px;
background: lightgray;
line-height: 26px;
cursor: pointer;
}
.reset:hover {
background: gray;
}
3.1.3 导航栏
导航栏包含三大功能模块:查看来源为懂球帝的相关新闻,查看来源为虎扑体育的相关新闻,以及查看搜索关键字的热度分析图。点击一个功能模块,会触发相应功能,并将结果展示在导航栏右侧区域内。
<div class="nav">
<ul id="nav-bar">
<li onclick="searchDqd()">懂球帝<br><img id="dqd-logo" src="images/dqd-logo.jpg" alt="懂球帝"></li>
<li onclick="searchHupu()">虎扑体育<br><img id="hupu-logo" src="images/hupu-logo.png" alt="虎扑体育"></li>
<li onclick="heatAnalyze()">热度分析<br><img id="heat-analyze" src="images/heat-analyze.jpg" alt="热度分析"></li>
</ul>
</div>
如果选择查看来源为懂球帝的相关新闻,首先根据标题关键词和时间范围,从表fetches_1请求得到符合要求的记录的url、title、keywords、author、publish_date字段。对于每条记录,我需要处理这五个字段,得到最终显示在搜索结果中的标题、关键词、作者和刊登日期四部分。对于标题部分,将title字段放入一个a标签中,并将url字段设为a标签的href属性,这样就可以点击标题直接跳转到对应的新闻页面。对于关键词部分,因为keywords字段可能包含很过关键词不利于网页展示效果,所以只取前五个关键词。作者部分直接显示author字段的值。对于刊登日期部分,如果直接显示publish_date字段的值,会发现末尾有如T16:00:00.000Z的格式,即为UTC时间,这样的时间格式不符合我们的读取习惯,而且过长会影响网页展示效果,所以需要先转换日期格式。
<script>
function searchDqd() {
$(".nav").empty().append('<ul id="nav-bar">' +
'<li class="active" onclick="searchDqd()">懂球帝<br><img id="dqd-logo" src="images/dqd-logo.jpg" alt="懂球帝"></li>' +
'<li onclick="searchHupu()">虎扑体育<br><img id="hupu-logo" src="images/hupu-logo.png" alt="虎扑体育"></li>' +
'<li onclick="heatAnalyze()">热度分析<br><img id="heat-analyze" src="images/heat-analyze.jpg" alt="热度分析"></li>' +
'</ul>');
$.get('/process_get?tablename=fetches_1&type=normal&title1=' + $("#input-box1:text").val() + '&title2=' + $("#input-box2:text").val() + '&date=' + $("#select-date").val(), function(data) {
$("#record").empty();
$("#record").append('<thead><tr><th>标题</th>' +
'<th>关键词</th><th>作者</th><th onclick="sortTable()">刊登日期</th></tr></thead>');
for (let list of data) {
let table = '<tbody><tr><td>';
let index = 0;
let url = "";
Object.values(list).forEach(element => {
if (index == 0) {
url = element;
}
else {
if (index == 1) { // 将网页url加在标题上
table += ('<a href="' + list.url + '" target="_blank">' + element + '</a>' + '</td><td>');
}
else if (index == 2) { // 只显示前五个关键词
keywords = element.split(" ");
if (keywords.length > 5) {
tmp = "";
for (let i = 0; i < 5; i++) {
tmp += keywords[i] + " ";
}
element = tmp.trim();
}
table += (element + '</td><td>');
}
else if (index == 4) { // 转换日期格式
let date = new Date(element);
let year = date.getFullYear();
let month = (date.getMonth() + 1).toString().padStart(2, '0');
let day = date.getDate().toString().padStart(2, '0');
element = year + '-' + month + '-' + day;
table += (element + '</td>');
}
else {
table += (element + '</td><td>');
}
}
index++;
});
$("#record").append(table + '</tr></tbody>');
}
});
}
</script>
如果选择查看来源为虎扑体育的相关新闻,操作流程基本相同,不同的是从表fetches_2中请求记录。此外,由于此时关键词只有一个,所以无需对关键词进行筛选。
<script>
function searchHupu() {
$(".nav").empty().append('<ul id="nav-bar">' +
'<li onclick="searchDqd()">懂球帝<br><img id="dqd-logo" src="images/dqd-logo.jpg" alt="懂球帝"></li>' +
'<li class="active" onclick="searchHupu()">虎扑体育<br><img id="hupu-logo" src="images/hupu-logo.png" alt="虎扑体育"></li>' +
'<li onclick="heatAnalyze()">热度分析<br><img id="heat-analyze" src="images/heat-analyze.jpg" alt="热度分析"></li>' +
'</ul>');
$.get('/process_get?tablename=fetches_2&type=normal&title1=' + $("#input-box1:text").val() + '&title2=' + $("#input-box2:text").val() + '&date=' + $("#select-date").val(), function(data) {
$("#record").empty();
$("#record").append('<thead><tr><th>标题</th>' +
'<th>关键词</th><th>作者</th><th onclick="sortTable()">刊登日期</th></tr></thead>');
for (let list of data) {
let table = '<tbody><tr><td>';
let index = 0;
let url = "";
Object.values(list).forEach(element => {
if (index == 0) {
url = element;
}
else {
if (index == 1) { // 将网页url加在标题上
table += ('<a href="' + list.url + '" target="_blank">' + element + '</a>' + '</td><td>');
}
else if (index == 4) { // 转换日期格式
let date = new Date(element);
let year = date.getFullYear();
let month = (date.getMonth() + 1).toString().padStart(2, '0');
let day = date.getDate().toString().padStart(2, '0');
element = year + '-' + month + '-' + day;
table += (element + '</td>');
}
else {
table += (element + '</td><td>');
}
}
index++;
});
$("#record").append(table + '</tr></tbody>');
}
});
}
</script>
我实现了对搜索结果按刊登日期字段进行排序,没有选择其他字段是因为按其他字段排序的意义都不是很大。第一次点击时将升序排列,再次点击时将将降序排列。
<script>
function sortTable() {
var table, rows, switching, i, x, y, shouldSwitch, dir, switchcount = 0;
table = document.getElementById("record");
switching = true;
// 设置升序排列
dir = "asc";
while (switching) {
// 设置循环结束标记
switching = false;
rows = table.rows;
for (i = 1; i < (rows.length - 1); i++) {
// 设置元素是否调换位置
shouldSwitch = false;
x = rows[i].getElementsByTagName("TD")[3];
y = rows[i + 1].getElementsByTagName("TD")[3];
if (dir == "asc") {
if (x.innerHTML.toLowerCase() > y.innerHTML.toLowerCase()) {
shouldSwitch = true;
break;
}
} else if (dir == "desc") {
if (x.innerHTML.toLowerCase() < y.innerHTML.toLowerCase()) {
shouldSwitch = true;
break;
}
}
}
if (shouldSwitch) {
// 如果元素调换位置设置为true,则进行对调操作
rows[i].parentNode.insertBefore(rows[i + 1], rows[i]);
switching = true;
// 每次对调完成时,将switchcount增加1
switchcount ++;
} else { // 如果完成所有元素的排序且 direction 设置为 "asc",这时就将 direction 设置为 "desc" 并再次执行循环
if (switchcount == 0 && dir == "asc") {
dir = "desc";
switching = true;
}
}
}
}
</script>
如果选择查看搜索关键字的热度分析图,首先根据标题关键词,从表fetches_1和fetches_2请求得到符合要求的记录的createtime字段。对这些记录按照createtime字段分类统计数量。再利用Echarts绘图,我选择绘制的是一张折线图与饼图的组合图,折线代表总新闻数量随时间的变化情况,饼图表示每一时间点上,来源为懂球帝和虎扑体育的新闻各自所占的比例。通过查看这样的组合图,我们能够详细具体地观察所查关键字的热度变化情况。
<header>
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/echarts@4.7.0/dist/echarts.min.js"></script>
</header>
<script type="text/javascript">
function heatAnalyze() {
$(".nav").empty().append('<ul id="nav-bar">' +
'<li onclick="searchDqd()">懂球帝<br><img id="dqd-logo" src="images/dqd-logo.jpg" alt="懂球帝"></li>' +
'<li onclick="searchHupu()">虎扑体育<br><img id="hupu-logo" src="images/hupu-logo.png" alt="虎扑体育"></li>' +
'<li class="active" onclick="heatAnalyze()">热度分析<br><img id="heat-analyze" src="images/heat-analyze.jpg" alt="热度分析"></li>' +
'</ul>');
$(".block").empty().append('<div id="heat-graph" style="margin-left:0px;margin-top:0px;height:100%;"></div>');
var myChart = echarts.init(document.getElementById("heat-graph"));
option = null;
function fetchData(cb) {
/* 对懂球帝新闻进行统计 */
var data_list1 = new Array(10).fill(0);
$.get('/process_get?tablename=fetches_1&type=heat&title1=' + $("#input-box1:text").val() + '&title2=' + $("#input-box2:text").val(), function(data1) {
for (let list of data1) {
Object.values(list).forEach(element => {
// 转换日期时间格式
var date = new Date(element);
var year = date.getFullYear();
var month = (date.getMonth() + 1).toString().padStart(2, '0');
var day = date.getDate().toString().padStart(2, '0');
var hours = date.getHours().toString().padStart(2, '0');
var minutes = date.getMinutes().toString().padStart(2, '0');
var seconds = date.getSeconds().toString().padStart(2, '0');
element = year + '-' + month + '-' + day + ' ' + hours + ':' + minutes + ':' + seconds;
if (element.startsWith("2023-07-13 21")) {
data_list1[0] += 1;
}
else if (element.startsWith("2023-07-14 09")) {
data_list1[1] += 1;
}
else if (element.startsWith("2023-07-14 21")) {
data_list1[2] += 1;
}
else if (element.startsWith("2023-07-15 09")) {
data_list1[3] += 1;
}
else if (element.startsWith("2023-07-15 21")) {
data_list1[4] += 1;
}
else if (element.startsWith("2023-07-16 09")) {
data_list1[5] += 1;
}
else if (element.startsWith("2023-07-16 21")) {
data_list1[6] += 1;
}
else if (element.startsWith("2023-07-17 09")) {
data_list1[7] += 1;
}
else if (element.startsWith("2023-07-17 21")) {
data_list1[8] += 1;
}
else if (element.startsWith("2023-07-18 09")) {
data_list1[9] += 1;
}
});
}
});
/* 对虎扑体育新闻进行统计 */
var data_list2 = new Array(10).fill(0);
$.get('/process_get?tablename=fetches_2&type=heat&title1=' + $("#input-box1:text").val() + '&title2=' + $("#input-box2:text").val(), function(data2) {
for (let list of data2) {
Object.values(list).forEach(element => {
// 转换日期时间格式
var date = new Date(element);
var year = date.getFullYear();
var month = (date.getMonth() + 1).toString().padStart(2, '0');
var day = date.getDate().toString().padStart(2, '0');
var hours = date.getHours().toString().padStart(2, '0');
var minutes = date.getMinutes().toString().padStart(2, '0');
var seconds = date.getSeconds().toString().padStart(2, '0');
element = year + '-' + month + '-' + day + ' ' + hours + ':' + minutes + ':' + seconds;
if (element.startsWith("2023-07-13 21")) {
data_list2[0] += 1;
}
else if (element.startsWith("2023-07-14 09")) {
data_list2[1] += 1;
}
else if (element.startsWith("2023-07-14 21")) {
data_list2[2] += 1;
}
else if (element.startsWith("2023-07-15 09")) {
data_list2[3] += 1;
}
else if (element.startsWith("2023-07-15 21")) {
data_list2[4] += 1;
}
else if (element.startsWith("2023-07-16 09")) {
data_list2[5] += 1;
}
else if (element.startsWith("2023-07-16 21")) {
data_list2[6] += 1;
}
else if (element.startsWith("2023-07-17 09")) {
data_list2[7] += 1;
}
else if (element.startsWith("2023-07-17 21")) {
data_list2[8] += 1;
}
else if (element.startsWith("2023-07-18 09")) {
data_list2[9] += 1;
}
});
}
});
// 通过 setTimeout 模拟异步加载
setTimeout(function () {
cb({
categories: ["07-13\n21:00", "07-14\n09:00", "07-14\n21:00", "07-15\n09:00", "07-15\n21:00",
"07-16\n09:00", "07-16\n21:00", "07-17\n09:00", "07-17\n21:00", "07-18\n09:00"],
data: data_list1.map(function(v, i) {
return v + data_list2[i];
}),
list1 : data_list1,
list2 : data_list2
});
}, 2000);
}
var option = {
title: {
text: '词条热度分析图',
textStyle: {
fontWeight:'bold',
fontSize: 24,
},
x: 'center',
top: '10'
},
tooltip: {},
legend: [{
data:['新闻数量'],
textStyle: {
fontWeight:'bold',
fontSize: 18,
},
itemHeight: 18,
top: '60',
right: '40'
},
{
data:['懂球帝', '虎扑体育'],
textStyle: {
fontWeight:'bold',
fontSize: 18,
},
itemHeight: 18,
top: '90',
right: '40',
orient: 'vertical'
}],
xAxis: {
data: []
},
yAxis: {},
series: []
};
myChart.showLoading();
fetchData(function (data) {
myChart.hideLoading();
myChart.setOption({
xAxis: {
data: data.categories
},
series: getSeriesData(data.categories, data.data, data.list1, data.list2)
});
});;
if (option && typeof option === "object") {
myChart.setOption(option, true);
}
function getSeriesData(categories, lineData, data_list1, data_list2) {
var seriesData = [];
seriesData.push(
{
name: '新闻数量',
type: 'line',
data: lineData,
tooltip: {
formatter: '时间 : {b}<br/>{a} : {c} 条',
}
}
);
for (var i = 0; i < categories.length; i++) {
seriesData.push({
name: categories[i],
type: 'pie',
radius: '15%',
center: [(i * 8 + 14) + '%', '78%'],
label: {
normal: {
formatter: '{c}',
position: 'inside'
}
},
data: [
{ name: '懂球帝', value: data_list1[i] },
{ name: '虎扑体育', value: data_list2[i] },
]
});
}
return seriesData;
}
}
</script>
css文件中设置导航栏各功能模块的位置、大小和图标,当前被选中的功能模块会显示绿色背景。
.nav {
position: relative;
margin-top: 0px;
margin-left: 0px;
height: 100%;
}
.nav ul {
list-style-type: none;
margin: 0;
padding: 0;
width: 120px;
background-color: rgba(241,241,241,0.8);
position: fixed;
height: 100%;
overflow: auto;
}
.nav li {
display: block;
color: #000;
font-size: 18px;
padding: 8px 16px;
text-decoration: none;
text-align: center;
}
.nav li.active {
background-color: rgba(76,175,80,0.8);
color: white;
}
.nav li:hover:not(.active) {
background-color: rgba(85,85,85,0.8);
color: white;
}
.nav li img {
width: 80px;
padding: 8px 0px;
border-radius: 30px;
display: flex;
justify-content: center;
}
3.1.4 搜索结果
我固定了搜索结果的展示区域,当选择导航栏的前两个功能模块时,展示的是以表格形式呈现的新闻条目,当选择导航栏的第三个功能模块时,会先将表格去除,然后在相同位置展示利用Echarts绘制的热度分析图。
<div class="block" style="margin-left:120px;height:432px;overflow:auto;">
<table id="record"></table>
</div>
css文件中设置以表格形式呈现的搜索结果的位置、大小和背景,以及字号和颜色。已访问的新闻链接会由蓝色变为紫色,方便标注用户的浏览历史记录。
a:link {
color: blue; /* 未访问的链接颜色 */
}
a:visited {
color: blueviolet; /* 已访问的链接颜色 */
}
a:hover {
color: darkblue; /* 鼠标移动到链接的颜色 */
text-decoration: underline;
}
.block {
background-color: rgba(255, 255, 255, 0.8);
}
#record {
position: relative;
margin-top: 0px;
margin-left: 0px;
width: 100%;
height: 100%;
border: 1px solid #000000;
border-collapse: collapse;
}
#record tr th {
position: sticky;
top: 0px;
background-color: rgb(81, 130, 187);
color: #ffffff;
border-bottom-width: 0;
height: 28px;
font-size: 14px;
}
#record td {
color: #000000;
font-size: 13px;
}
#record th, #record td {
padding: 5px 10px;
font-weight: bold;
border: 1px solid #000000;
}
3.2 网站框架构建
我主要利用express构建网站框架,具体的代码文件组织结构如下。3.1节主要介绍了search.html和style.css文件。
其中./routes文件夹下包含两个路由文件:index.js和user.js,index.js文件需要根据3.1.3节中提到的数据请求语句进行修改。首先根据传入的type参数,判断是来自热度分析的请求还是来自正常关键字搜索的请求,编写不同的sql语句。对于来自热度分析的请求,利用like操作符根据一个(或多个)标题关键字进行模糊查询,只需要选择符合要求的记录的createtime字段。对于来自正常关键字搜索的请求,利用like操作符根据一个(或多个)标题关键字和时间范围进行模糊查询,需要选择符合要求的记录的url、title、keywords、author、publish_date字段。
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) {
// sql字符串和参数
if (request.query.type === "heat") { // 热度分析的请求格式
var fetchSql = "select createtime " +
"from " + request.query.tablename + " where title like '%" + request.query.title1 + "%' and title like '%" + request.query.title2 + "%'";
}
else { // 正常关键字搜索的请求格式
var fetchSql = "select url,title,keywords,author,publish_date " +
"from " + request.query.tablename + " where title like '%" + request.query.title1 + "%' and title like '%" + request.query.title2 + "%' and publish_date like '%" + request.query.date + "%'";
}
mysql.query(fetchSql, function(err, result, fields) {
response.writeHead(200, {
"Content-Type": "application/json"
});
response.write(JSON.stringify(result));
response.end();
});
});
module.exports = router;
3.3 网站效果展示
Web编程期末项目——网站演示
4. 不足与展望
4.1 增加爬虫目标网站数量
除了懂球帝和虎扑体育,我一开始还选择了腾讯体育(https://sports.qq.com)和央视体育(https://sports.cctv.com)作为目标网站,但从腾讯体育爬取的新闻相关数据均为乱码,切换编码方式也无法解决问题,而从央视体育一次性只能爬取较少新闻,所以我放弃了爬取这两个网站。未来可以对这两个网站继续进行研究,设计有效的爬虫。
4.2 对搜索结果进行分页显示
由于目前爬取的新闻数量不多,所以我没有实现分页显示功能。当数据库中的记录数量很大时,符合搜索要求的记录数量也会较多,这时分页显示就会很有必要。