中國鐵路 12306 官方未公開開放 API,但其前端網頁呼叫的內部介面可被逆向用於查詢車次、餘票與票價。本文提供一套完整的 PHP 實作,包含車站代碼對照表、查詢邏輯、CURL 請求封裝與結果格式化輸出。
因篇幅極大,此處僅節錄關鍵結構:
<?php
return [
"北京" => "BJP",
"北京西" => "BXP",
"上海" => "SHH",
"上海虹橋" => "AOH",
"廣州" => "GZQ",
"深圳北" => "IOQ",
"成都東" => "ICW",
"西安北" => "EAY",
// ... 超過 3000 個車站
"鄭州東" => "ZAF",
"武漢" => "WHN",
];
?>
維護建議:定期從 12306 官網 station_name.js 同步更新,或建立資料表由排程自動同步。
<?php
/**
* 12306 火車票查詢類別
* 支援:車次查詢、餘票查詢、票價查詢
*/
class Train12306
{
private const BASE_URL = 'https://kyfw.12306.cn/otn';
private const USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36';
private const REFERER = 'https://kyfw.12306.cn/otn/lcxxcx/init';
private $stationMap;
private $ch;
public function __construct($stationFile = __DIR__ . '/name.php')
{
$this->stationMap = include $stationFile;
$this->initCurl();
}
private function initCurl()
{
$this->ch = curl_init();
curl_setopt_array($this->ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_SSL_VERIFYHOST => false,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_USERAGENT => self::USER_AGENT,
CURLOPT_REFERER => self::REFERER,
CURLOPT_ENCODING => 'gzip,deflate',
CURLOPT_HTTPHEADER => [
'Accept: */*',
'Accept-Language: zh-CN,zh;q=0.9',
'Host: kyfw.12306.cn',
'Connection: keep-alive',
],
]);
}
/**
* 統一 GET 請求並解析 JSON
*/
private function get($endpoint, array $params = [])
{
$url = self::BASE_URL . $endpoint . '?' . http_build_query($params);
curl_setopt($this->ch, CURLOPT_URL, $url);
$response = curl_exec($this->ch);
if ($response === false) {
throw new RuntimeException('CURL 錯誤: ' . curl_error($this->ch));
}
$data = json_decode($response, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new RuntimeException('JSON 解析失敗: ' . json_last_error_msg());
}
return $data;
}
/**
* 解析日期參數:支援 "mm-dd" 或 "yyyy-mm-dd"
*/
private function parseDate($dateStr = '')
{
if (empty($dateStr)) {
return date('Y-m-d');
}
$parts = explode('-', $dateStr);
if (count($parts) === 2) {
return date('Y') . '-' . $dateStr; // 補上当年年份
}
if (count($parts) === 3) {
return $dateStr;
}
throw new InvalidArgumentException('日期格式錯誤,請使用 mm-dd 或 yyyy-mm-dd');
}
/**
* 查詢車次與餘票
*/
public function queryTrains($fromStation, $toStation, $date = '')
{
$fromCode = $this->stationMap[$fromStation] ?? null;
$toCode = $this->stationMap[$toStation] ?? null;
if (!$fromCode || !$toCode) {
throw new InvalidArgumentException('車站名稱錯誤或不支援:' . $fromStation . ' / ' . $toStation);
}
$date = $this->parseDate($date);
// 1. 查車次與餘票
$listData = $this->get('/lcxxcx/query', [
'purpose_codes' => 'ADULT',
'queryDate' => $date,
'from_station' => $fromCode,
'to_station' => $toCode,
]);
if (empty($listData['status']) || empty($listData['data']['datas'])) {
return [];
}
$trains = $listData['data']['datas'];
// 2. 逐筆查票價並合併
foreach ($trains as &$train) {
$priceData = $this->get('/leftTicket/queryTicketPrice', [
'train_no' => $train['train_no'],
'from_station_no' => $train['from_station_no'],
'to_station_no' => $train['to_station_no'],
'seat_types' => $train['seat_types'],
'train_date' => $date,
]);
$price = $priceData['data'] ?? [];
// 座位類型代碼映射
$seatMap = [
'gr_num' => ['A6', '高級軟臥'],
'qt_num' => ['OT', '其他'],
'rw_num' => ['A4', '軟臥'],
'rz_num' => ['A2', '軟座'],
'tz_num' => ['P', '特等座'],
'wz_num' => ['WZ', '無座'],
'yw_num' => ['A3', '硬臥'],
'yz_num' => ['A1', '硬座'],
'ze_num' => ['O', '二等座'],
'zy_num' => ['M', '一等座'],
'swz_num'=> ['A9', '商務座/特等座'],
];
foreach ($seatMap as $field => [$priceKey, $label]) {
$num = $train[$field] ?? '--';
$cost = $price[$priceKey] ?? '';
// 12306 回傳「有」或數字,無票時為「無」或空
$train[$field] = $num . ($cost ? " ($cost)" : '');
}
}
return $trains;
}
public function __destruct()
{
if ($this->ch) curl_close($this->ch);
}
}
<?php
require_once 'train.php';
try {
$query = new Train12306();
// 查詢北京 → 天津,明天(預設今天)
$trains = $query->queryTrains('北京', '天津', '01-03');
if (empty($trains)) {
echo '查無車次或當日停駛';
exit;
}
foreach ($trains as $t) {
echo "=== 車次: {$t['station_train_code']} ===" . PHP_EOL;
echo "出發: {$t['start_time']} {$t['start_station_name']} → 抵達: {$t['arrive_time']} {$t['end_station_name']}" . PHP_EOL;
echo "歷時: {$t['lishi']}" . PHP_EOL;
// 輸出有票的座位
$seats = [
'swz_num'=>'商務座', 'tz_num'=>'特等座', 'zy_num'=>'一等座',
'ze_num'=>'二等座', 'gr_num'=>'高級軟臥', 'rw_num'=>'軟臥',
'yw_num'=>'硬臥', 'rz_num'=>'軟座', 'yz_num'=>'硬座', 'wz_num'=>'無座'
];
foreach ($seats as $field => $label) {
$val = $t[$field] ?? '--';
if ($val !== '--' && !str_contains($val, '無') && !str_contains($val, '--')) {
echo " $label: $val" . PHP_EOL;
}
}
echo PHP_EOL;
}
} catch (Exception $e) {
echo '查詢失敗: ' . $e->getMessage();
}
| 功能 | 端點 | 關鍵參數 |
|---|---|---|
| 車次/餘票查詢 | /lcxxcx/query | purpose_codes, queryDate, from_station, to_station |
| 票價查詢 | /leftTicket/queryTicketPrice | train_no, from_station_no, to_station_no, seat_types, train_date |
| 車站代碼 JS | /resources/js/framework/station_name.js | 定期同步更新 name.php |
| 代碼 | 座位類型 | 備註 |
|---|---|---|
| A9 / P | 商務座 / 特等座 | 高鐵專用 |
| M | 一等座 | 高鐵/動車 |
| O | 二等座 | 高鐵/動車 |
| A6 | 高級軟臥 | 部分直達特快 |
| A4 | 軟臥 | 普快/特快 |
| A3 | 硬臥 | 普快/特快 |
| A2 | 軟座 | 動車/城際 |
| A1 | 硬座 | 普快/特快 |
| WZ | 無座 | 站票 |
curl_multi 或 Swoole 協程並行查詢 10+ 筆票價,大幅降低延遲| 現象 | 可能原因 | 解決方案 |
|---|---|---|
| 回傳空資料/狀態 false | Cookie 過期、Referer 驗證、IP 封鎖 | 更新 Cookie、檢查 Header、更換代理 IP |
| 車站找不到代碼 | name.php 版本過舊 | 從 station_name.js 同步最新資料 |
| 票價欄位為空 | 該車次無此座位類型、或售罄 | 正常現象,前端需判斷顯示「--」或「售罄」 |
| SSL 憑證驗證失敗 | 本地 CA 憑證庫過舊 | CURLOPT_SSL_VERIFYPEER = false(僅測試環境) |
本文提供了完整的 PHP 12306 查詢實作,涵蓋車站代碼管理、雙 API 串接、CURL 封裝、異常處理與生產環境優化建議。核心難點在於 12306 的反爬蟲機制與介面穩定性,實際部署時需配合代理池、快取策略與監控告警,並嚴格遵守法律法規與平台規範。