我也来爬一爬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文件内容解析等方面的内容。

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

相关推荐
超爱吃士力架1 小时前
邀请逻辑
java·linux·后端
AskHarries3 小时前
Spring Cloud OpenFeign快速入门demo
spring boot·后端
数据小爬虫@4 小时前
利用Python爬虫快速获取商品历史价格信息
开发语言·爬虫·python
理想不理想v4 小时前
webpack最基础的配置
前端·webpack·node.js
小白学大数据4 小时前
如何使用Selenium处理JavaScript动态加载的内容?
大数据·javascript·爬虫·selenium·测试工具
isolusion4 小时前
Springboot的创建方式
java·spring boot·后端
zjw_rp5 小时前
Spring-AOP
java·后端·spring·spring-aop
TodoCoder5 小时前
【编程思想】CopyOnWrite是如何解决高并发场景中的读写瓶颈?
java·后端·面试
qq_375872696 小时前
15爬虫:下载器中间件
爬虫