likes
comments
collection
share

我也来爬一爬12306 - Day5 时刻表

作者站长头像
站长
· 阅读数 26

本文是《我也来爬一爬12306》系列文件中,第五天的内容,本系列文章的起始文章是:

概述

在前一天(第四天)中,我们已经获取了当前铁路路网中,所有车次的信息。今天的目标,就是基于这些车次,获取其实际的列车代码,并获得预计时刻表中的内容。

网页分析-车次列车编码

之所以需要这样操作,是在中国铁路的业务设定中,车次本身只是一个编号,实际的发车时的具体列车的运行时刻,是和发车时间和运行状态相关的。对于这个具体的列车,就设计了另一个列车代码,可以看成这个车次的一个实例。这样的设计,更加灵活一点。比如调整运行时间,可以支持单双日使用不同的发到站等等。

为了达成目标,我们还是需要先来研究一下,如果在12306网站上进行查询,是怎样操作的。经过研究,发现在首页上,是不能直接找到相关的信息的(从车次找到列车代码)。我们只能找到,在首页上,有一个“信息查询”的板块,里面有一个子板块“时刻表”(下图)。看这个页面布局,可以想到,可以通过输入日期和车次,就应该可以查询这个日期和车次相关的信息,然后对车次进行遍历,获得完整的实际列车运营的信息。

在网页上实际进行操作的时候,笔者在这个页面发现了一个bug。就是输入日期和车次,点击“查询”后,网页就会进入查询的状态,但无法结束。但实际上,这个查询请求已经被执行,并且返回了结果。可能是浏览器和代码兼容的问题,这个网页不能正常的处理这个状态。这个情况,不影响我们进行业务方面的分析。

我也来爬一爬12306 - Day5 时刻表

这个查询接口的请求响应的主要信息如下:

// 请求信息
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号相同,基本确认了列车编码在一段时间内,就是车次的代码,并对应一套列车时刻的想法。

网页分析-列车时刻表

在上一步的分析中,我们已经可以成功的获得车次对应的列车编码,这个编码就可以用于进一步获取实际的列车时刻表。

但遗憾的是,在首页和前面那个信息查询页面,也找不到可以直接使用这个代码进行查询时刻表的地方,实际上,这是一个“技术”参数,就是程序和软件使用而非用户使用的,我们只能通过分析程序执行的代码,来看到对其的运用。所幸,笔者在首页的"车票"板块,发现了有列车时刻表的功能。再进行深入分析,发现它实际上是在用户选择车次之后,执行的一个接口化的信息查询(下图),这里面就使用了这个车次所对应的列车代码和发车日期。

我也来爬一爬12306 - Day5 时刻表

这个查询接口的相关信息如下:

// 请求信息
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
评论
请登录