japan-transfer-mcp
Version:
Model Context Protocol (MCP) server for J-Route Planner
413 lines (412 loc) • 12.9 kB
JavaScript
import * as cheerio from 'cheerio';
/**
* HTMLテキストを解析して乗換案内の検索結果を返す
* @param html ジョルダンの検索結果HTML
* @returns 解析された検索結果
*/
export function parseRouteSearchResult(html) {
const $ = cheerio.load(html);
// 検索結果の主要コンテナを特定
const $results = $('#results.js_routeBlocks');
if ($results.length === 0) {
throw new Error('検索結果が見つかりません。HTMLの構造が期待されたものと異なる可能性があります。');
}
// 各経路を解析
const routes = parseRoutes($, $results);
return {
routes,
searchTime: new Date().toISOString()
};
}
/**
* 各経路を解析
*/
function parseRoutes($, $results) {
const routes = [];
$results.find('.bk_result').each((index, routeElement) => {
const route = parseRoute($, $(routeElement), index + 1);
if (route) {
routes.push(route);
}
});
return routes;
}
/**
* 個別の経路を解析
*/
function parseRoute($, $route, routeIndex) {
try {
const id = $route.attr('id') || `route_${routeIndex}`;
const routeNumber = routeIndex;
// 評価タグを解析
const tags = parseRouteTags($route);
// 時刻情報を解析
const timeInfo = parseTimeInfo($route);
// 料金情報を解析
const fareInfo = parseFareInfo($route);
// 所要時間を解析
const totalTime = parseTotalTime($route);
// 乗換回数を解析
const transfers = parseTransfers($route);
// 距離を解析
const totalDistance = parseDistance($route);
// CO2情報を解析
const co2Info = parseCO2Info($route);
// 経路セグメントを解析
const segments = parseRouteSegments($route);
// 路線注意事項を解析
const routeNotices = parseRouteNotices($route);
return {
id,
routeNumber,
tags,
timeInfo,
fareInfo,
totalTime,
transfers,
totalDistance,
co2Info,
segments,
routeNotices
};
}
catch (error) {
console.warn(`経路 ${routeIndex} の解析に失敗:`, error);
return null;
}
}
/**
* 経路の評価タグを解析
*/
function parseRouteTags($route) {
const tags = [];
$route.find('.hyouka').each((_, el) => {
const $el = $route.parent().find(el);
const title = $el.attr('title') || '';
const text = $el.text().trim();
if (title === '早い' || text === '早') {
tags.push({ type: 'fast', label: '早い' });
}
else if (title === '楽' || text === '楽') {
tags.push({ type: 'comfortable', label: '楽' });
}
else if ($el.hasClass('hyouka_car')) {
tags.push({ type: 'car', label: '車' });
}
});
return tags;
}
/**
* 時刻情報を解析
*/
function parseTimeInfo($route) {
const $timeElement = $route.find('.data_tm');
const timeText = $timeElement.text();
// 日付情報がある場合の処理
const dateElements = $timeElement.find('.ymd');
if (dateElements.length > 0) {
// 発時刻と着時刻を個別に抽出
const timeMatches = timeText.match(/(\d{1,2}:\d{2})/g);
if (timeMatches && timeMatches.length >= 2) {
return {
departure: timeMatches[0],
arrival: timeMatches[1]
};
}
}
// 通常の形式での解析
const timeMatch = timeText.match(/\((\d{1,2}:\d{2})\)発.*\((\d{1,2}:\d{2})\)着/);
if (timeMatch) {
return {
departure: timeMatch[1],
arrival: timeMatch[2]
};
}
// 分表示の場合
const altTimeMatch = timeText.match(/(\d{1,2}:\d{2})発\s*→\s*(\d{1,2}:\d{2})着/);
if (altTimeMatch) {
return {
departure: altTimeMatch[1],
arrival: altTimeMatch[2]
};
}
return {
departure: '',
arrival: ''
};
}
/**
* 料金情報を解析
*/
function parseFareInfo($route) {
// より具体的なセレクターを使用
const fareElement = $route.find('.data_line_1 .data_total dd b');
if (fareElement.length === 0) {
// フォールバック: 従来のセレクター
const fareText = $route.find('.data_total dd').text();
const fareMatch = fareText.match(/([\d,]+)円/);
const total = fareMatch ? parseInt(fareMatch[1].replace(/,/g, '')) : 0;
const additionalInfo = fareText.includes('+') ?
fareText.split('+')[1]?.trim() : undefined;
return {
total,
additionalInfo
};
}
// 新しいセレクターで取得
const fareText = fareElement.text();
const fareMatch = fareText.match(/([\d,]+)円/);
const total = fareMatch ? parseInt(fareMatch[1].replace(/,/g, '')) : 0;
// 追加情報を取得(b要素の後にあるテキスト)
const ddElement = $route.find('.data_line_1 .data_total dd');
const fullText = ddElement.text();
const additionalInfo = fullText.includes('+') ?
fullText.split('+')[1]?.trim() : undefined;
return {
total,
additionalInfo
};
}
/**
* 所要時間を解析
*/
function parseTotalTime($route) {
const timeText = $route.find('.data_total-time dd').text();
// 時間と分の両方がある場合 (例: 3時間59分)
const hourMinuteMatch = timeText.match(/(\d+)時間(\d+)分/);
if (hourMinuteMatch) {
const hours = parseInt(hourMinuteMatch[1]);
const minutes = parseInt(hourMinuteMatch[2]);
return hours * 60 + minutes;
}
// 分のみの場合 (例: 22分)
const minuteMatch = timeText.match(/(\d+)分/);
if (minuteMatch) {
return parseInt(minuteMatch[1]);
}
// 時間のみの場合 (例: 2時間) - 念のため
const hourMatch = timeText.match(/(\d+)時間/);
if (hourMatch) {
return parseInt(hourMatch[1]) * 60;
}
return 0;
}
/**
* 乗換回数を解析
*/
function parseTransfers($route) {
const transferText = $route.find('.data_norikae-num dd').text();
const transferMatch = transferText.match(/(\d+)回/);
return transferMatch ? parseInt(transferMatch[1]) : 0;
}
/**
* 距離を解析
*/
function parseDistance($route) {
const distanceText = $route.find('dl').filter((_, el) => $route.parent().find(el).find('dt').text() === '距離').find('dd').text();
const distanceMatch = distanceText.match(/(\d+\.?\d*)km/);
return distanceMatch ? parseFloat(distanceMatch[1]) : undefined;
}
/**
* CO2情報を解析
*/
function parseCO2Info($route) {
const co2Element = $route.find('.data_norikae-eco dd');
const co2Text = co2Element.text();
if (!co2Text)
return undefined;
const amountMatch = co2Text.match(/(\d+\.?\d*[a-zA-Z]+)/);
const reductionMatch = co2Text.match(/(\d+\.?\d*%)\s*削減/);
if (amountMatch) {
return {
amount: amountMatch[1],
reductionRate: reductionMatch ? reductionMatch[1] : undefined,
comparison: co2Text.includes('自動車比') ? '自動車比' : undefined
};
}
return undefined;
}
/**
* 経路セグメントを解析
*/
function parseRouteSegments($route) {
const segments = [];
$route.find('.route table tr').each((_, row) => {
const $row = $route.parent().find(row);
// ヘッダー行をスキップ
if ($row.find('th').length > 0)
return;
// 駅行の場合
if ($row.hasClass('eki')) {
const station = parseStationInfo($row);
if (station) {
segments.push({
type: 'station',
station
});
}
}
// 交通手段行の場合
if ($row.hasClass('rosen')) {
const transport = parseTransportInfo($row);
if (transport) {
segments.push({
type: 'transport',
transport
});
}
}
});
return segments;
}
/**
* 駅情報を解析
*/
function parseStationInfo($row) {
const nameElement = $row.find('.nm strong');
const name = nameElement.text().trim();
if (!name)
return null;
// 駅タイプの判定
let type = 'transfer';
if ($row.hasClass('eki_s'))
type = 'start';
else if ($row.hasClass('eki_e'))
type = 'end';
// 天気情報
const weatherImg = $row.find('.tenki');
const weather = parseWeatherInfo(weatherImg);
// プラットフォーム情報
const platform = $row.find('.ph div').text().trim() || undefined;
// サービス情報
const services = parseStationServices($row);
return {
name,
type,
weather,
platform,
services
};
}
/**
* 交通手段情報を解析
*/
function parseTransportInfo($row) {
const lineNameElement = $row.find('.rn a, .rn div');
const lineName = lineNameElement.text().trim();
if (!lineName)
return null;
// 交通手段タイプの判定
let type = 'train';
const iconSrc = $row.find('.gf img').attr('src') || '';
if ($row.hasClass('k_walk'))
type = 'walk';
else if (iconSrc.includes('nr2.gif'))
type = 'subway';
else if (iconSrc.includes('nr5.gif'))
type = 'bus';
else if (iconSrc.includes('nr13.gif'))
type = 'car';
// 時刻情報
const timeText = $row.find('.tm').text();
// 括弧ありの場合 (20:51)-(20:58) と括弧なしの場合 20:51-20:58 の両方に対応
const timeMatch = timeText.match(/\(?(\d{1,2}:\d{2})\)?-\(?(\d{1,2}:\d{2})\)?/) ||
timeText.match(/(\d{1,2}:\d{2})-(\d{1,2}:\d{2})/);
const durationMatch = timeText.match(/(\d+)分/);
const timeInfo = {
departure: timeMatch ? timeMatch[1] : '',
arrival: timeMatch ? timeMatch[2] : '',
duration: durationMatch ? parseInt(durationMatch[1]) : 0
};
// 料金情報
const fareText = $row.find('.fr').text();
const fareMatch = fareText.match(/(\d+)円/);
const fare = fareMatch ? parseInt(fareMatch[1]) : undefined;
// 距離情報
const distance = $row.find('.km').text().trim() || undefined;
// 運行会社の抽出
const operatorMatch = lineName.match(/\[(.*?)\]/);
const operator = operatorMatch ? operatorMatch[1] : undefined;
// 方面・行先の抽出
const direction = lineName.includes('行') ?
lineName.split('(')[1]?.replace(')', '') : undefined;
return {
type,
lineName,
direction,
operator,
timeInfo,
fare,
distance
};
}
/**
* 天気情報を解析
*/
function parseWeatherInfo($weatherImg) {
const src = $weatherImg.attr('src') || '';
const alt = $weatherImg.attr('alt') || '';
if (!src)
return undefined;
let condition = 'sunny';
if (src.includes('cloudy'))
condition = 'cloudy';
else if (src.includes('rainy'))
condition = 'rainy';
else if (src.includes('snowy'))
condition = 'snowy';
return {
condition,
iconUrl: src,
description: alt
};
}
/**
* 駅サービス情報を解析
*/
function parseStationServices($row) {
const services = [];
$row.find('.nrk-route-tbl__ekilink a').each((_, link) => {
const $link = $row.parent().find(link);
const className = $link.attr('class') || '';
const text = $link.text().trim();
const url = $link.attr('href');
let type = 'map';
if (className.includes('time'))
type = 'timetable';
else if (className.includes('rosenzu'))
type = 'route_map';
else if (className.includes('kounai'))
type = 'floor_plan';
else if (className.includes('coupon'))
type = 'coupon';
else if (className.includes('gourmet'))
type = 'gourmet';
else if (text.includes('出口'))
type = 'exit_info';
services.push({
type,
name: text,
url
});
});
return services;
}
/**
* 路線注意事項を解析
*/
function parseRouteNotices($route) {
const notices = [];
$route.find('.nrb_unk tr').each((_, row) => {
const $row = $route.parent().find(row);
const title = $row.find('td').text().trim();
if (title) {
notices.push({
type: 'route_change',
title,
description: title
});
}
});
return notices;
}