我也来爬一爬12306 - Day5 时刻表
本文是《我也来爬一爬12306》系列文件中,第五天的内容,本系列文章的起始文章是:
概述
在前一天(第四天)中,我们已经获取了当前铁路路网中,所有车次的信息。今天的目标,就是基于这些车次,获取其实际的列车代码,并获得预计时刻表中的内容。
网页分析-车次列车编码
之所以需要这样操作,是在中国铁路的业务设定中,车次本身只是一个编号,实际的发车时的具体列车的运行时刻,是和发车时间和运行状态相关的。对于这个具体的列车,就设计了另一个列车代码,可以看成这个车次的一个实例。这样的设计,更加灵活一点。比如调整运行时间,可以支持单双日使用不同的发到站等等。
为了达成目标,我们还是需要先来研究一下,如果在12306网站上进行查询,是怎样操作的。经过研究,发现在首页上,是不能直接找到相关的信息的(从车次找到列车代码)。我们只能找到,在首页上,有一个“信息查询”的板块,里面有一个子板块“时刻表”(下图)。看这个页面布局,可以想到,可以通过输入日期和车次,就应该可以查询这个日期和车次相关的信息,然后对车次进行遍历,获得完整的实际列车运营的信息。
在网页上实际进行操作的时候,笔者在这个页面发现了一个bug。就是输入日期和车次,点击“查询”后,网页就会进入查询的状态,但无法结束。但实际上,这个查询请求已经被执行,并且返回了结果。可能是浏览器和代码兼容的问题,这个网页不能正常的处理这个状态。这个情况,不影响我们进行业务方面的分析。
这个查询接口的请求响应的主要信息如下:
// 请求信息
Request URL: https://search.12306.cn/search/v1/train/search?keyword=K22&date=20240802
Request Method: GET
Status Code: 200 OK
Remote Address: 36.147.57.7:443
Referrer Policy: strict-origin-when-cross-origin
// 响应内容
{
"status": true,
"errorMsg": "",
"data": [
{
"date": "20240802",
"from_station": "南宁",
"station_train_code": "K22",
"to_station": "北京西",
"total_num": "29",
"train_no": "7100000K2231"
},
{
"date": "20240802",
"from_station": "昆明",
"station_train_code": "K229",
"to_station": "厦门北",
"total_num": "22",
"train_no": "800000K23233"
},
...
// 响应时间
Initial Connection: 141.99ms
Server Response: 148ms
Content Download: 0.85ms
从上面的分析我们可以看到:
- 这个操作的查询地址是: search.12306.cn/search/v1/t…
- 和12306.cn的主应用系统的主机名不同,有理由认为它是一个独立的查询系统
- 查询参数包括: date(日期,格式为YYYYMMDD),keyword(关键字,可以是车次,如K22)
- 查询方式,使用HTTP GET方式,并使用标准的queryString路径格式
- 查询操作使用模糊查询,将查询所有信息内包括“K22”这个字符串的内容,一般情况下,我们就取第一个匹配项目(完全匹配)就可以了
- 查询的结果,是一个列车的列表,其中带有列车编码,车次,起始站等信息
- 这里的total_num,应该是余票数量,我们这个应用不关心这个信息(可能有人关心?)
随后,笔者还使用了另一个时间(2024年8月5号)来进行了查询,看到K22次的列车编码和8月2号相同,基本确认了列车编码在一段时间内,就是车次的代码,并对应一套列车时刻的想法。
网页分析-列车时刻表
在上一步的分析中,我们已经可以成功的获得车次对应的列车编码,这个编码就可以用于进一步获取实际的列车时刻表。
但遗憾的是,在首页和前面那个信息查询页面,也找不到可以直接使用这个代码进行查询时刻表的地方,实际上,这是一个“技术”参数,就是程序和软件使用而非用户使用的,我们只能通过分析程序执行的代码,来看到对其的运用。所幸,笔者在首页的"车票"板块,发现了有列车时刻表的功能。再进行深入分析,发现它实际上是在用户选择车次之后,执行的一个接口化的信息查询(下图),这里面就使用了这个车次所对应的列车代码和发车日期。
这个查询接口的相关信息如下:
// 请求信息
Request URL: https://kyfw.12306.cn/otn/czxx/queryByTrainNo?train_no=850000K85830&from_station_telecode=LZJ&to_station_telecode=CMW&depart_date=2024-08-02
Request Method: GET
Status Code: 200 OK
Remote Address: 223.111.139.16:443
Referrer Policy: strict-origin-when-cross-origin
// 响应内容
{
"validateMessagesShowId": "_validatorMessage",
"status": true,
"httpstatus": 200,
"data": {
"data": [
{
"station_name": "兰州",
"train_class_name": "快速",
"isChina": "1",
"service_type": "2",
"end_station_name": "成都西",
"stopover_time": "----",
"country_code": "",
"isEnabled": true,
"country_name": "",
"arrive_time": "----",
"start_station_name": "兰州",
"station_train_code": "K858",
"start_time": "19:20",
"station_no": "01"
},
{
"arrive_time": "20:49",
"station_name": "渭源",
"isChina": "1",
"start_time": "20:52",
"stopover_time": "3分钟",
"station_no": "02",
"country_code": "",
"country_name": "",
"isEnabled": true
},
...
{
"arrive_time": "06:29",
"station_name": "成都西",
"isChina": "1",
"start_time": "06:29",
"stopover_time": "----",
"station_no": "07",
"country_code": "",
"country_name": "",
"isEnabled": true
}
// 时间线
Server Response: 71.13ms
Content Download: 0.45ms
这个查询操作的要点是:
- 接口地址: kyfw.12306.cn/otn/czxx/qu…
- 查询方法: HTTP GET, queryString格式
- 查询参数: train_no(列车编码), from_station_telecode(起点站,电报码,如LZJ),to_station_telecode(终点站,电报码,如CMW),depart_date:(发车日期,格式YYYY-MM-DD,如2024-08-02)
- 查询结果: 车站列表,有序,包括序号、车站名称、到点、开点等等(响应内容中的数据结构)
从命名上来看,它的意思是使用列车代码来进行查询。至此,这个查询结果,应该已经能够满足系统设计的目标了。
这样,我们就有了一个大体的实现思路,就是先基于车次和日期,查询列车代码;再使用列车代码和日期,获取当前当次车的列车时刻。然后基于这个思路,我们就可以具体实现相关的操作代码了。
代码实现
前面已经基本上明确了这个查询过程,在12306上实现和操作的方式。下面就可以基于这个方式,使用代码来实现信息获取、处理和存储的过程。也基本上分为两个阶段,获取列车编码和获取时刻表。
列车编码
本模块用于获取列车编码,并写入数据库。
const
SQL_TRAIN_STATIONS = `
select tcode, sum(tcount) tcount
from ${DB.TABLE_STATIONS} S join ${DB.TABLE_STRAINS} T on T.scode = S.scode
group by 1 order by 2 desc`,
SQL_ADD_TRAIN_NUM = `
insert into ${DB.TABLE_TRAINUM} (trainum, tcode, tdate, startcode, endcode) values (?,?,?,?,?)
on conflict do nothing
`;
const getTrainNumbers = async(trainCode, trainDate)=>{
// load trains for station
let urlSearch = tlConfig.URL_SEARCH.replace("$1",trainCode).replace("$2", trainDate);
let rdata = await tlGet(urlSearch);
try {
rdata = JSON.parse(rdata);
if (rdata?.data) return rdata.data;
} catch (error) {
console.log(error);
console.log(rdata);
}
return null;
};
const loadTrainNumbers = async()=>{
// code name dictinary
let ndictionary = await stationCode();
// console.log(ndictionary); return;
// load stations
let svalue,rdata,tlist = await dbQuery(SQL_TRAIN_STATIONS);
// console.log(tlist); return;
// query station's trains by request 12306
let tdate = new Date(Date.now() + 86400*1000).toISOString().slice(0,10).replaceAll("-","");
for(const train of tlist) {
await sleep(2000 + Math.random() * 2000);
rdata = await getTrainNumbers(train.tcode, tdate);
if (!rdata || rdata?.length == 0) continue;
// first row
rdata = rdata[0];
// update trainnum
await dbExec(SQL_ADD_TRAIN_NUM,[rdata.train_no, rdata.station_train_code, rdata.date, ndictionay[rdata.from_station], ndictionary[rdata.to_station]]);
console.log(rdata);
// break; // just 4 test;
};
};
这个操作的要点如下:
- 先从数据库中,遍历车站
- 使用Search接口
- 请求方法是 HTTP GET,标准queryString格式
- 请求参数是keyword和date,可以使用车次作为keyword
- 日期可以使用当前查询的第二天,理论上每一天都有一个时刻表,但我们只处理第二天的,认为它是当前列表编码的最新版本
- 响应的结果是基于keyword查询的带有列表编码的数组
- 由于是模糊查询,所以列表可能有多个记录,取第一个(完全匹配)
- 使用查询结果,录入数据库的tl_trainums数据表
- 为了方便编码和维护,将遍历列车车次的操作,和获取车次对应列车编码的操作,分为了两个独立的逻辑部分
列车时刻表
这一步就是基于前一步获取的列车编码,结合日期,查询列车当日的列车时刻表。
const
SQL_TRAINS = `select trainum, tdate, startcode, endcode from ${DB.TABLE_TRAINUM} `,
SQL_DEL_SCHDULE = `delete from ${DB.TABLE_SCHDULE} where trainum = ? `,
SQL_ADD_SCHDULE = `insert into ${DB.TABLE_SCHDULE} (trainum, iorder, scode, atime ) values (?,?,?,?) `;
// url = "https://kyfw.12306.cn/otn/czxx/queryByTrainNo?train_no=85000K261811&from_station_telecode=LZJ&to_station_telecode=CMW&depart_date=2024-07-09";
const getSchdule = async (train)=>{
let tdate = [train.tdate.slice(0,4),train.tdate.slice(4,6),train.tdate.slice(6,8)].join("-");
let url = tlConfig.URL_SCHDULE
.replace("$1", train.trainum)
.replace("$2", train.startcode)
.replace("$3", train.endcode)
.replace("$4", tdate);
// console.log("URL",url);
try {
let rdata = await tlGet(url);
// console.log(rdata);
rdata = JSON.parse(rdata);
if (rdata?.status == true) return rdata.data.data;
// let tlist = rdata.data.data;
} catch (err) {
console.log(err);
}
return null;
};
const loadTrains = async()=>{
// station dic
let ndictionay = await stationCode();
// load stations
let rdata,tlist = await dbQuery(SQL_TRAINS);
// query station's trains by request 12306
for(const train of tlist) {
// sleep
await sleep(2000 + Math.random() * 2000);
rdata = await getSchdule(train);
if (!rdata || rdata?.length == 0) continue;
// should update traninfo start, end, time,
// clear data
await dbExec(SQL_DEL_SCHDULE,[train.trainum]);
// insert or update schdule
let scode, station, istart, itime1,itime2,ilength = rdata.length;
for (let i = 1; i<= ilength; i++) {
station = rdata[i-1];
if (i == ilength) i = 99; // last station
if (i == 1) {
[ itime1, itime2] = station.start_time.split(":");
istart = parseInt(itime1)*60 + parseInt(itime2);
itime1 = istart;
} else {
[ itime1, itime2] = station.arrive_time.split(":");
itime1 = parseInt(itime1)*60 + parseInt(itime2) - istart;
}
// station code
scode = ndictionay[station.station_name];
// console.log(train.trainum, i, scode, itime1,station.station_name);
await dbExec(SQL_ADD_SCHDULE,[train.trainum, i, scode, itime1]);
};
console.log("Schdule:", train.trainum);
// break; // 4 test;
};
};
这个代码模块的要点在于:
- 遍历车次编码列表
- 应用 queryByTrainNo 这个接口
- 接口方法HTTP GET, queryString格式
- 参数是列车代码、起始终点车站代码、日期(注意格式的转换)
- 查询的结果是一个车站信息的列表,将其转换后存入数据库
- 为了保证数据始终最新,在每个车次时刻表插入前,清除以前的数据(考虑到序号变化,可能不能直接更新信息)
- 使用sleep降低无效请求概率
- 为了方便操作和维护,将遍历列车编码的操作,和实际获取时刻的操作,分为了两个独立的逻辑部分
小结
在本日的工作中,我们研究了基于12306网站和相关的数据查询接口,先使用车次结合日期,查询列车代码和起始点车站;然后使用列车代码、始发终点车站、日期信息等获取时刻表信息,然后将结果保存在数据库当中。
转载自:https://juejin.cn/post/7401415643982037046