本文是《我也来爬一爬12306》系列文件中,第二天的内容,本系列文章的起始文章是:
概述
先来明确一下,在第二天里,我们打算和可以做什么。
在第一天的总体规划里,我们设计的主要目标,就是获取和记录列车时刻表。但实际上,这个目标还是比较复杂的,它可能需要多个环节和步骤来达成。所以一般笔者并不建议一开始就从整体上来处理,而是尽量将其分解成为一些比较简单的、相对比较独立的任务和环节,循序渐进,并且在过程中增强对系统和业务的理解,并且进行优化和整合,最后才能在整体上,更宏观、有机和合理的把握和理解全局。
以本任务为例,如果我们的目标是列车时刻表的话,就可以从这个角度出发进行分解。列车时刻表中的信息包括车次在各车站的到达和发车时间,就是说包括了一个车站的列表。所以,作为基础信息,我们首先应当有一个中国铁路车站的信息表,最基础的数据就应当包括车站的名称和车站的编码,此处是电报码。而且这个车站列表是可以独立出来的,它可以和车次、线路和实际的车次运行,没有直接的关系。我们可以以此作为入口来展开整个项目。
当然车站的信息远不止如此,比如车站的等级、英文名称、地理位置等等。由于现在不知道基于12306的系统的相关信息查询,可以做到什么程度,我们先实现最基础的结构规划,就是编码和名称。
所以,我们第二天的任务,可以设定为从12306中,获取中国铁路所有车站的基本信息,并且将它录入一个数据库中,为后续工作做准备。
12306主页分析
前面已经明确了当前的任务和目的就是车站列表和基本信息。现在需要一个入口和起点,基于项目的整体设定,显然这个起点,就是中国铁路售票系统的官方网站:12306。使用浏览器访问其官方的网址12306.cn,就可以看到其HTML的主页面。当然,我们感兴趣的其实是其中的数据和信息,所以我们需要打开浏览器的开发工具来对其进行分析(图)。
这里面已经有了很多重要的信息,我们可以运用:
- 12306的基础域名是 12306.cn,但实际上,页面会重定向到 www.12306.cn/index/
- 这个地址应该才是页面最终的实际地址,如果使用HTTP模拟请求,则需要将其作为基础地址(而非12306.cn)
- 在主页中,我们可以看到很多车站的信息,但实际分析原始的HTML内容,却发现没有相关的内容,以此可以初步判断,这部分内容是基于一些数据动态生成的
- 一般情况下,这种处理是在页面基本结构加载完成后,会启动一个HTTP请求,从一个网络接口获取这类数据,解析后填充到列表中,但经过分析发现12306主页没有类似的结构
- 然后,分析其他加载项目,发现了 station_name_new_v10052.js,我们可以合理推断,这个js文件与车站信息有关
- 打开这个文件,果然发现,它确实是一个js代码,但里面就只有一个内容,就是声明了一个station_names变量,内容是一个字符串
- 可以合理推测,12306是通过加载这个js文件,就可以获取车站列表的变量,然后进行解析后,才在页面上生成那些车站的列表
- 车站信息js文件的大小是166kB,无缓存加载时间742ms
基础库
在了解了12306主页相关车站信息的基本实现方式后,我们就可以知晓,这里需要使用一个HTTP请求,来获取主页内容,并进行分析后,获取车站相关js文件的URL地址和内容,进一步分析其中的内容,得到车站列表和相关信息。这些过程,都需要我们先实现一个HTTP数据请求和处理的机制。考虑到这个机制可能也会在后续使用,所以,将其封装成为一个库文件的形式,应该是一种比较好的做法。
此外,考虑到系统的可配置和移植的特性,针对当前项目,可以将相关的配置信息,也包含在这个库中。实际上,相关数据库的通用操作的相关代码,也会在后续加入库中。逻辑上,所有可以共用的操作,公共字典和函数,都应当封装到这个库中。基于简化的考虑,这个项目中,笔者只需要规划和使用一个库文件,所以看起来这个库的功能会比较杂一点,在实际的大型项目中,并不推荐这种做法,而是最后按照功能和业务模块进行划分。
因此,这类笔者首先编写和实现了一个tl_lib.js文件,将HTTP请求和简单处理的机制进行了封装。内容如下:
tl_lib.js
const
https = require("https"),
fs = require('node:fs'),
sqlite3 = require('sqlite3').verbose(),
SQDB = new sqlite3.Database( __dirname+'/tldb.sqlite3'),
URL_HOME = "https://www.12306.cn/index/", // first page for 12306
URL_ENPOINT = "https://kyfw.12306.cn/otn",
URL_STATIONS = URL_ENPOINT + "/resources/js/framework/station_name.js",
URL_TRAINS = URL_ENPOINT + "/leftTicket/query/js/query/train_list.js",
// 列车编号 trainum , 出发车站电报码start 到达车站电报码end 出发日期tdate
URL_SCHDULE = URL_ENPOINT + "/czxx/queryByTrainNo?train_no=$1&from_station_telecode=$2&to_station_telecode=$3&depart_date=$4",
URL_QUERYCC = "https://www.12306.cn/index/otn/zwdch/queryCC",
URL_SEARCH = "https://search.12306.cn/search/v1/train/search?keyword=$1&date=$2";
// 配置信息
const tlConfig = {
URL_HOME,
URL_QUERYCC,
URL_SEARCH,
URL_SCHDULE
}
// http请求
// .... 后续章节讨论
// 数据库操作
// .... 后续内容讨论
// 模块导出
module.exports = { sleep,tlConfig, tlGet, tlPost, dbQuery, dbExec, DB, stationCode };
可以看到,里面的内容主要包括:
- 一些URL地址常数,这些都是对12306网站和工作模式进行分析得到的内容,包括后续的一些数据接口
- 可导出的配置信息
- 通用的HTTP请求封装和处理
- 通用的数据库操作封装和处理
- 模块导出
基于本系统规划和操作原则,不使用可配置的用户信息。
HTTP请求标准方法
HTTP请求方法,是基础库模块的一个重要的内容,它基于nodejs的HTTP模块来构建,增加了相关的头信息和响应处理相关的内容,并且没有第三方依赖。这部分内容的代码如下:
tl_lib.js
...
const STD_HEADER = {
"Accept": "text/html, application/json",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) ",
};
const handelRes = (resp, cb)=>{
let data = [];
// A chunk of data has been received.
resp
.on('data', chunk => data.push(chunk))
.on('end', () => cb(Buffer.concat(data).toString()));
}
const tlPost = async (url, pdata)=> new Promise((r,j)=>{
url = url.replace("https://",""); // all https
let host = url.split("/")[0];
let path = url.slice(host.length);
// get port and host
port = host.split(":")[1] || 443;
host = host.split(":")[0];
// form encode
const fmBody = Object.keys(pdata)
.map(k=> encodeURI(k) +"="+ encodeURI(pdata[k]))
.join("&");
let req = https.request({
host, port, path,
method: "POST",
headers: { ...STD_HEADER,
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
"Content-Length": Buffer.byteLength(fmBody),
}
}, (resp)=> handelRes(resp, r))
.on("error", (err) => {
console.log("Error: " + err.message);
r(null);
});
req.write(Buffer.from(fmBody));
req.end();
});
const tlGet = async (url)=> new Promise((r,j)=>{
https.get(url,{
headers: { ...STD_HEADER }
}, (resp)=> handelRes(resp, r))
.on("error", (err) => {
console.log("Error: " + err);
r(null);
});
});
...
这里的主要内容,就是对nodejs标准的HTTP模块进行了一个简单的封装和定制。主要分为GET和POST两种请求方式。
需要稍微注意一点的是,这里的HTTP请求,使用了一个设定好的Header,这是笔者在研究和测试过程中发现的,需要设置特定的信息,请求和调用才能够正确的执行。
这些特定的信息,首先是需要需要设置用户代理的类型,就是一个浏览器客户端;其次是设置响应内容可以接受的数据格式(mime-type),为text/html或者application/json。
此外,12306的信息提交Post接口,是比较传统的模式,它的数据体封装格式是Form( "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8"),也需要在请求头中设置指定。并且内容也需要使用encodeURI的模式。为了方便使用和一致性,在库文件中都进行了相关的实现和封装。
主要配置信息
同样存在于tl_lib.js文件中,相关的主要配置信息如下(后面几天的工作可能都会用到):
tl_lib.js
URL_HOME = "https://www.12306.cn/index/", // 12306实际主页
URL_ENPOINT = "https://kyfw.12306.cn/otn",
URL_STATIONS = URL_ENPOINT + "/resources/js/framework/station_name.js",
URL_TRAINS = URL_ENPOINT + "/leftTicket/query/js/query/train_list.js",
// 列车编号 trainum , 出发车站电报码start 到达车站电报码end 出发日期tdate
URL_SCHDULE = URL_ENPOINT + "/czxx/queryByTrainNo?train_no=$1&from_station_telecode=$2&to_station_telecode=$3&depart_date=$4",
URL_QUERYCC = "https://www.12306.cn/index/otn/zwdch/queryCC",
URL_SEARCH = "https://search.12306.cn/search/v1/train/search?keyword=$1&date=$2";
// https://kyfw.12306.cn/otn/?leftTicketDTO.train_date=2024-07-09&leftTicketDTO.from_station=LZJ&leftTicketDTO.to_station=CDW&purpose_codes=ADULT
// config information for export
const tlConfig = {
URL_HOME,
URL_QUERYCC,
URL_SEARCH,
URL_SCHDULE
}
这里面,最主要的是12306的实际主页地址,和搜索接口地址(后面车站车次查询会用到),这些内容都是基于对12306页面应用进行分析后得到的结果,关于这些分析的过程,后面具体实现环节中有详细的表述。
HTML内容解析
前面的HTTP请求库代码中,我们可以看到,它可以访问一个HTTP地址,并获取响应的内容。实际上,这个方法并不关心响应的内容格式是怎么样的,完全作为一个普通的文本来处理。
但我们已经知道,12306的主页是一个标准的HTML文件。车站js文件地址的内容,也是作为一个节点,在这个文件当中的。所以,我们需要把这个文件从HTML文本内容中提取出来。
不知道是什么原因,Nodejs没有内置XML文本解析的功能。因为要处理很多复杂的情况,笔者也懒得自己开发一个,这里就直接使用了一个第三方库: htmlparser2。
使用前需要先安装一下这个npm:
npm i htmlparser2 --save
在程序中相关的代码如下:
tl1.js
....
let URL_STATIONS = null;
const htmlparser2= require("htmlparser2");
const parser = new htmlparser2.Parser({
onopentag(name, attributes) {
if (name === "script" && attributes?.src?.indexOf(TXT_STATIONS) > -1) {
URL_STATIONS = tlConfig.URL_HOME + attributes.src;
}
},
ontext(text) {
// console.log("-->", text);
},
onclosetag(tagname) {
if (tagname === "script") {
}
},
});
// 调用方式 hcontent是html页面内容
(async()=>{
parser.write(hcontent);
parser.end();
})();
这个解析库的使用方式是:
- 引用htmlparser2解析库
- 创建一个解析器实例
- 重新实例的onopentag代码,检查是否当前节点是script并且src(源地址)包含"station_name"
- 如果包含目标名称,则获取源地址,并处理成为真实的网络地址(URL_STATIONS)
- 获取主页HTML内容后,调用解析器实例的write方法来处理这个内容,并调用end结束
- 需要注意这里都使用异步机制
- 后续可以进一步使用相同的HTTP方法获取该地址js文件���内容
车站数据获取
在准备好了HTTP请求库后,我们就可以使用这个库,来发起HTTP请求,并从12306网站上,获取我们需要的信息了。我们在这一步的目标就是,使用程序访问12306主页,查找station_names的js文件地址,然后再加载和解析这个文件的内容(不是执行),生成一个车站信息的列表。
之所以不直接使用这个js文件的地址,是考虑到这个内容可能会发生变换,因为它的命名方式,就是一个带有版本的文件名,版本号是v10053,其真实地址是:
所以,现在这个机制,就保证了,只要它还使用js文件来存储车站列表,并且命名的模式不变的话,我们就可以获得这个地址,并进一步获取其对应的实际内容。
后续的操作,就是使用同样的HTTP请求,来加载这个JS文件,并解析其中的内容,输出车站的列表。这一系列操作的相关代码如下:
tl1.js
// 主页面内容
let hcontent = await tlGet(tlConfig.URL_HOME);
// 主页内容解析,获得js文件地址
(async()=>{
parser.write(hcontent);
parser.end();
})();
// 车站js文件内容,解析和处理
hcontent = await tlGet(URL_STATIONS);
let vlist, svalue, scode, pyname, rcode, istring,
rlist = new Set(),
slist = hcontent.split("@").slice(1);
// console.log(slist);
svalue = slist.map(v=>{
vlist = v.split("|");
rlist.add(`("${vlist[6]}","${vlist[7]}")`);
return `("${vlist[2]}","${vlist[1]}","${vlist[3]}","${vlist[6]}")`;
}).join(',');
/* station_name.js内容大致是:
var station_names = '@bjb|北京北|VAP|beijingbei|bjb|0|0357|北京|||@bjd|北京东|BOP|beijingdong|bjd|...
*/
这里的关键是,看清楚这个js中的内容和格式。作为一个普通的字符串,而不是将其作为js变量内容来处理。这里的输出,并不是一个常规的对象数组,而是一个格式化后的字符串,主要用于后续的SQL操作。但概念是相同的。
至此,我们已经顺利的从12306主页入手,获取了当前路网的车站列表,并转换成为了一个结构化数据数组。在实际后续的操作中,最终的形态,是一个SQL Values的值子句,用于SQL插入操作。我们将在明天的工作中看到这一点。
小结
本文是系列文章的第二天的内容。主要讨论了12306主页面内容和工作方式分析,项目的主要配置信息,HTTP请求和处理程序库,HTML页面内容分析,车站JS文件内容解析等方面的内容。
下一步,我们将会讨论,如何将这些信息,写入到一个关系数据库当中。考虑到今天的内容已经比较多了,我们把这个工作放在第三天来处理。