我也来爬一爬12306 - Day2 主页和车站

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

《我也来爬一爬12306 - Day1 总体规划》

概述

先来明确一下,在第二天里,我们打算和可以做什么。

在第一天的总体规划里,我们设计的主要目标,就是获取和记录列车时刻表。但实际上,这个目标还是比较复杂的,它可能需要多个环节和步骤来达成。所以一般笔者并不建议一开始就从整体上来处理,而是尽量将其分解成为一些比较简单的、相对比较独立的任务和环节,循序渐进,并且在过程中增强对系统和业务的理解,并且进行优化和整合,最后才能在整体上,更宏观、有机和合理的把握和理解全局。

以本任务为例,如果我们的目标是列车时刻表的话,就可以从这个角度出发进行分解。列车时刻表中的信息包括车次在各车站的到达和发车时间,就是说包括了一个车站的列表。所以,作为基础信息,我们首先应当有一个中国铁路车站的信息表,最基础的数据就应当包括车站的名称和车站的编码,此处是电报码。而且这个车站列表是可以独立出来的,它可以和车次、线路和实际的车次运行,没有直接的关系。我们可以以此作为入口来展开整个项目。

当然车站的信息远不止如此,比如车站的等级、英文名称、地理位置等等。由于现在不知道基于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,其真实地址是:

www.12306.cn/index/scrip...

所以,现在这个机制,就保证了,只要它还使用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文件内容解析等方面的内容。

下一步,我们将会讨论,如何将这些信息,写入到一个关系数据库当中。考虑到今天的内容已经比较多了,我们把这个工作放在第三天来处理。

相关推荐
间彧几秒前
在多环境(开发、测试、生产)下,如何管理不同的Docker Compose配置?
后端
间彧1 分钟前
如何为Docker Compose中的服务配置健康检查,确保服务真正可用?
后端
间彧6 分钟前
Docker Compose和Kubernetes在编排服务时有哪些核心区别?
后端
间彧11 分钟前
如何在实际项目中集成Arthas Tunnel Server实现Kubernetes集群的远程诊断?
后端
brzhang1 小时前
读懂 MiniMax Agent 的设计逻辑,然后我复刻了一个MiniMax Agent
前端·后端·架构
草明1 小时前
Go 的 IO 多路复用
开发语言·后端·golang
蓝-萧1 小时前
Plugin ‘mysql_native_password‘ is not loaded`
java·后端
故事不长丨2 小时前
【Java SpringBoot+Vue 实现视频文件上传与存储】
java·javascript·spring boot·vscode·后端·vue·intellij-idea
9ilk2 小时前
【仿RabbitMQ的发布订阅式消息队列】--- 前置技术
分布式·后端·中间件·rabbitmq
书中自有妍如玉2 小时前
Node.Js Express Sqlite3 接口开发
node.js·express