Add PeruHop provider with stage-based timetable and optional activity parsing
All checks were successful
Build and Push Image / docker-build-and-push (push) Successful in 2m28s

This commit is contained in:
2026-02-24 23:27:16 -05:00
parent 4af96244ba
commit 118aee639e
2 changed files with 621 additions and 1 deletions

View File

@@ -1,8 +1,11 @@
import type { TourProvider } from './types.js'; import type { TourProvider } from './types.js';
import { GAdventuresProvider } from './g-adventures.js'; import { GAdventuresProvider } from './g-adventures.js';
import { PeruHopProvider } from './peru-hop.js';
const PROVIDERS: Record<string, TourProvider> = { const PROVIDERS: Record<string, TourProvider> = {
'g adventures': GAdventuresProvider 'g adventures': GAdventuresProvider,
'peru hop': PeruHopProvider,
peruhop: PeruHopProvider
}; };
export function getProviderForOperator(operatorName: string): TourProvider | null { export function getProviderForOperator(operatorName: string): TourProvider | null {

View File

@@ -0,0 +1,617 @@
import type { TourDay, TourDayPlan, TourDetail, TourProvider, TourSearchResult } from './types.js';
const BASE = 'https://bushop.com';
const PASS_LIST_URL = `${BASE}/peru/passes/`;
const CATALOG_TTL_MS = 15 * 60 * 1000;
let cachedCatalog: { expiresAt: number; results: TourSearchResult[] } | null = null;
interface StageVariant {
code: string;
label: string;
lines: string[];
}
interface ParsedStage {
stageNumber: number;
stageLabel?: string;
commonLines: string[];
variants: StageVariant[];
}
interface StageRowItem {
description: string;
time: string;
isActivity: boolean;
isWaypoint: boolean;
}
function decodeHtml(input: string): string {
return input
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>');
}
function stripTags(input: string): string {
return decodeHtml(
input
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
.replace(/<[^>]+>/g, ' ')
).replace(/\s+/g, ' ');
}
function slugToTitle(slug: string): string {
return slug
.replace(/[-_]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.replace(/\b\w/g, (s) => s.toUpperCase());
}
function normalizeStageLabel(label: string): string {
const cleaned = label.replace(/\s+/g, ' ').trim();
if (!cleaned) return cleaned;
// Convert the all-caps labels used on PeruHop timetable rows into readable title text.
const words = cleaned.toLowerCase().split(' ');
const minorWords = new Set(['a', 'an', 'and', 'at', 'by', 'for', 'in', 'of', 'on', 'the', 'to']);
return words
.map((word, index) => {
if (index > 0 && index < words.length - 1 && minorWords.has(word)) return word;
return word.charAt(0).toUpperCase() + word.slice(1);
})
.join(' ');
}
function normalizePassUrl(href: string): string | null {
const trimmed = href.trim();
if (!trimmed) return null;
let url: URL;
try {
url = new URL(trimmed.startsWith('http') ? trimmed : `${BASE}${trimmed}`);
} catch {
return null;
}
if (!url.pathname.startsWith('/peru/passes/')) return null;
const parts = url.pathname.split('/').filter(Boolean);
if (parts.length < 3) return null;
const slug = parts[2];
if (!slug || slug === 'passes') return null;
return `${BASE}/peru/passes/${slug}/`;
}
function extractPassUrls(html: string): string[] {
const urls = new Set<string>();
const linkRegex = /href=["']([^"'#]+)["']/gi;
let match: RegExpExecArray | null;
while ((match = linkRegex.exec(html)) !== null) {
const normalized = normalizePassUrl(match[1]);
if (normalized) urls.add(normalized);
}
return Array.from(urls);
}
function extractPageTitle(html: string, fallback: string): string {
const h1 = html.match(/<h1[^>]*>([\s\S]*?)<\/h1>/i)?.[1];
const title = h1 ? stripTags(h1) : '';
return title.trim() || fallback;
}
function htmlToLines(html: string): string[] {
const withBreaks = html
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/(p|div|li|h1|h2|h3|h4|tr|section)>/gi, '\n');
const text = decodeHtml(
withBreaks
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
.replace(/<[^>]+>/g, ' ')
)
.replace(/[ \t\r\f\v]+/g, ' ')
.replace(/\n{2,}/g, '\n');
return text
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
}
function extractStageLabelsFromHtml(html: string): Map<number, string> {
const labels = new Map<number, string>();
const stageLabelRegex =
/<td[^>]*class=["'][^"']*\btb-hop\b[^"']*["'][^>]*>[\s\S]*?HOP STAGE\s*(\d+)[\s\S]*?<\/td>[\s\S]*?<tr[^>]*class=["'][^"']*\bborder-hopstage\b[^"']*["'][^>]*>[\s\S]*?<td[^>]*class=["'][^"']*\bhopstage-description\b[^"']*["'][^>]*>([\s\S]*?)<\/td>/gi;
let match: RegExpExecArray | null;
while ((match = stageLabelRegex.exec(html)) !== null) {
const stageNumber = parseInt(match[1], 10);
const rawLabel = stripTags(match[2]).trim();
if (!Number.isNaN(stageNumber) && rawLabel) {
labels.set(stageNumber, normalizeStageLabel(rawLabel));
}
}
return labels;
}
function extractClassList(attrs: string): string[] {
const classValue = attrs.match(/\bclass=["']([^"']*)["']/i)?.[1] ?? '';
return classValue
.split(/\s+/)
.map((value) => value.trim())
.filter(Boolean);
}
function extractStageItemsFromHtml(html: string): Map<number, StageRowItem[]> {
const byStage = new Map<number, StageRowItem[]>();
const tokenRegex =
/<td[^>]*class=["'][^"']*\btb-hop\b[^"']*["'][^>]*>[\s\S]*?HOP STAGE\s*(\d+)[\s\S]*?<\/td>|<tr[^>]*class=["'][^"']*\bhopstage-item\b[^"']*["'][^>]*>([\s\S]*?)<\/tr>/gi;
let currentStageNumber: number | null = null;
let match: RegExpExecArray | null;
while ((match = tokenRegex.exec(html)) !== null) {
if (match[1]) {
currentStageNumber = parseInt(match[1], 10);
if (!Number.isNaN(currentStageNumber) && !byStage.has(currentStageNumber)) {
byStage.set(currentStageNumber, []);
}
continue;
}
if (!match[2] || currentStageNumber == null) continue;
const tdRegex = /<td\b([^>]*)>([\s\S]*?)<\/td>/gi;
const cells: Array<{ attrs: string; content: string }> = [];
let tdMatch: RegExpExecArray | null;
while ((tdMatch = tdRegex.exec(match[2])) !== null) {
cells.push({ attrs: tdMatch[1] ?? '', content: tdMatch[2] ?? '' });
}
if (cells.length < 2) continue;
const description = stripTags(cells[0].content).replace(/\s+/g, ' ').trim();
const time = stripTags(cells[1].content).replace(/\s+/g, ' ').trim();
if (!description || !time) continue;
const classesA = extractClassList(cells[0].attrs);
const classesB = extractClassList(cells[1].attrs);
const isMiddleA = classesA.includes('hopstage-dayhour-middle');
const isMiddleB = classesB.includes('hopstage-dayhour-middle');
const isActivity = isMiddleA && isMiddleB;
const isWaypoint =
(classesA.includes('hopstage-dayhour') || classesB.includes('hopstage-dayhour')) &&
!isActivity;
const list = byStage.get(currentStageNumber) ?? [];
list.push({ description, time, isActivity, isWaypoint });
byStage.set(currentStageNumber, list);
}
return byStage;
}
function splitActivityTitleAndDescription(text: string): {
title: string;
description: string | null;
} {
const normalized = text.replace(/\s+/g, ' ').trim();
const dashMatch = normalized.match(/\s[-]\s/);
if (!dashMatch || dashMatch.index == null) return { title: normalized, description: null };
const title = normalized.slice(0, dashMatch.index).trim();
const description = normalized.slice(dashMatch.index + dashMatch[0].length).trim();
return { title, description: description || null };
}
function extractOptionalActivitiesFromHtml(html: string): Map<number, TourDayPlan[]> {
const byStage = new Map<number, TourDayPlan[]>();
const tokenRegex =
/<td[^>]*class=["'][^"']*\btb-hop\b[^"']*["'][^>]*>[\s\S]*?HOP STAGE\s*(\d+)[\s\S]*?<\/td>|<table[^>]*class=["'][^"']*\bactivities\b[^"']*["'][^>]*>([\s\S]*?)<\/table>/gi;
let currentStageNumber: number | null = null;
let match: RegExpExecArray | null;
while ((match = tokenRegex.exec(html)) !== null) {
if (match[1]) {
currentStageNumber = parseInt(match[1], 10);
if (!Number.isNaN(currentStageNumber) && !byStage.has(currentStageNumber)) {
byStage.set(currentStageNumber, []);
}
continue;
}
if (!match[2] || currentStageNumber == null) continue;
const paragraphRegex = /<p\b[^>]*>([\s\S]*?)<\/p>/gi;
let paragraphMatch: RegExpExecArray | null;
let currentLocation: string | null = null;
while ((paragraphMatch = paragraphRegex.exec(match[2])) !== null) {
const paragraphHtml = paragraphMatch[1] ?? '';
const paragraphText = decodeHtml(
paragraphHtml
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
)
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.join(' ')
.trim();
if (!paragraphText) continue;
const mainActivityMatch = paragraphText.match(/^Main activity in\s+([^:]+):\s*(.*)$/i);
let activityBlob = '';
if (mainActivityMatch) {
currentLocation = mainActivityMatch[1].trim();
activityBlob = mainActivityMatch[2].trim();
} else if (currentLocation) {
activityBlob = paragraphText;
} else {
continue;
}
if (!activityBlob) continue;
const { title, description } = splitActivityTitleAndDescription(activityBlob);
if (!title) continue;
const notesParts = [
currentLocation ? `Optional activity in ${currentLocation}.` : null,
description
].filter(Boolean);
const plans = byStage.get(currentStageNumber) ?? [];
plans.push({
type: 'activity',
title,
notes: notesParts.join(' '),
isOptional: true
});
byStage.set(currentStageNumber, plans);
}
}
return byStage;
}
function extractBusTimetableLines(html: string): string[] {
const headingMatch = html.match(/<h[1-6][^>]*>\s*(Bus Timetable|Horarios)\s*<\/h[1-6]>/i);
if (!headingMatch || headingMatch.index == null) return [];
const afterHeading = html.slice(headingMatch.index);
const nextHeading = afterHeading.match(/<h[1-6][^>]*>/i);
const chunk = nextHeading?.index ? afterHeading.slice(0, nextHeading.index) : afterHeading;
return htmlToLines(chunk);
}
function parseStages(lines: string[]): ParsedStage[] {
const stages: ParsedStage[] = [];
let current: ParsedStage | null = null;
let activeVariantCode: string | null = null;
for (const rawLine of lines) {
const line = rawLine.replace(/\s+/g, ' ').trim();
const stageMatch = line.match(/HOP STAGE\s+(\d+)/i);
if (stageMatch) {
const stageToken = stageMatch[0];
const beforeStage = line.slice(0, stageMatch.index ?? 0).trim();
const afterStage = line.slice((stageMatch.index ?? 0) + stageToken.length).trim();
const stageLabel = afterStage.replace(/^[:\-\s]+/, '').trim();
if (current) stages.push(current);
current = {
stageNumber: parseInt(stageMatch[1], 10),
stageLabel: stageLabel || undefined,
commonLines: [],
variants: []
};
activeVariantCode = null;
// Preserve useful text that appears on the same line as the stage marker.
if (beforeStage) current.commonLines.push(beforeStage);
continue;
}
if (!current) continue;
const variantMatch = line.match(/^([A-Z]):\s*(.+)$/);
if (variantMatch) {
const code = variantMatch[1];
const label = variantMatch[2].trim();
if (!current.variants.find((v) => v.code === code)) {
current.variants.push({ code, label, lines: [] });
}
activeVariantCode = code;
continue;
}
if (activeVariantCode) {
const variant = current.variants.find((v) => v.code === activeVariantCode);
if (variant) variant.lines.push(line);
else current.commonLines.push(line);
} else {
current.commonLines.push(line);
}
}
if (current) stages.push(current);
return stages;
}
function listVariants(stages: ParsedStage[]): Array<{ code: string; label: string }> {
const map = new Map<string, string>();
for (const stage of stages) {
for (const variant of stage.variants) {
if (!map.has(variant.code)) map.set(variant.code, variant.label);
}
}
return Array.from(map.entries()).map(([code, label]) => ({ code, label }));
}
function normalizeStopLabel(value: string): string {
return value
.replace(/\((arrive|depart)\)/gi, '')
.replace(/\((arrive\/depart)\)/gi, '')
.replace(/\b(arrive|depart)\b/gi, '')
.replace(/\b(?:\d{1,2}:\d{2})\b/g, '')
.replace(/\s+/g, ' ')
.trim();
}
type WaypointType = 'depart' | 'arrive' | 'arrive_depart' | 'unknown';
function parseWaypointType(description: string): WaypointType {
if (/\(arrive\/depart\)/i.test(description)) return 'arrive_depart';
if (/\(depart\)/i.test(description)) return 'depart';
if (/\(arrive\)/i.test(description)) return 'arrive';
return 'unknown';
}
function stripWaypointMarkers(description: string): string {
return description
.replace(/\(arrive\/depart\)/gi, '')
.replace(/\((arrive|depart)\)/gi, '')
.replace(/\s+/g, ' ')
.trim();
}
function normalizeActivityTitle(description: string): string {
return description
.replace(/\(arrive\/depart\)/gi, '')
.replace(/\((arrive|depart)\)/gi, '')
.replace(/\s+/g, ' ')
.trim();
}
function plansFromStageItems(items: StageRowItem[]): TourDayPlan[] {
const plans: TourDayPlan[] = [];
const waypoints = items.filter((item) => item.isWaypoint);
const activities = items.filter((item) => item.isActivity);
for (const activity of activities) {
const title = normalizeActivityTitle(activity.description) || activity.description;
plans.push({
type: 'activity',
title,
notes: `${activity.time} ${activity.description}`,
isOptional: false
});
}
let activeDeparture: {
location: string;
time: string;
rawDescription: string;
type: WaypointType;
} | null = null;
for (const waypoint of waypoints) {
const type = parseWaypointType(waypoint.description);
const location = stripWaypointMarkers(waypoint.description);
if (!location) continue;
const node = {
location,
time: waypoint.time,
rawDescription: waypoint.description,
type
};
const canDepart = type === 'depart' || type === 'arrive_depart';
const canArrive = type === 'arrive' || type === 'arrive_depart';
if (canArrive && activeDeparture) {
plans.push({
type: 'transport',
title: `Bus: ${activeDeparture.location} -> ${node.location}`,
notes: `${activeDeparture.time} - ${node.time} ${activeDeparture.rawDescription} to ${node.rawDescription}`,
transportFields: {
transport_kind: 'other',
start_location: activeDeparture.location,
end_location: node.location,
start_time: activeDeparture.time,
end_time: node.time
}
});
activeDeparture = type === 'arrive_depart' ? node : null;
continue;
}
if (canDepart) {
activeDeparture = node;
}
}
return plans;
}
function plansFromStage(stage: ParsedStage, variantCode?: string): TourDayPlan[] {
const variant = variantCode ? stage.variants.find((v) => v.code === variantCode) : null;
const lines = [...stage.commonLines, ...(variant?.lines ?? [])];
if (lines.length === 0) return [];
const plans: TourDayPlan[] = [];
for (const line of lines) {
const activityMatch = line.match(/^Main activity in\s+(.+)$/i);
if (activityMatch) {
plans.push({
type: 'activity',
title: `Main activity in ${activityMatch[1].trim()}`,
notes: line,
isOptional: false
});
continue;
}
const rangeMatch = line.match(/^(\d{1,2}:\d{2})\s*-\s*(\d{1,2}:\d{2})\s+(.+)$/i);
if (rangeMatch) {
const startTime = rangeMatch[1];
const endTime = rangeMatch[2];
const routeText = rangeMatch[3].trim();
const parts = routeText.split(/\s+to\s+/i);
if (parts.length >= 2) {
const startLocation = normalizeStopLabel(parts[0]);
const endLocation = normalizeStopLabel(parts.slice(1).join(' to '));
plans.push({
type: 'transport',
title: `Bus: ${startLocation} -> ${endLocation}`,
notes: line,
transportFields: {
transport_kind: 'other',
start_location: startLocation,
end_location: endLocation,
start_time: startTime,
end_time: endTime
}
});
}
continue;
}
const activityTimeMatch = line.match(/^(\d{1,2}:\d{2})\s+(.+)$/i);
if (activityTimeMatch) {
const title = normalizeStopLabel(activityTimeMatch[2]);
if (!title) continue;
plans.push({
type: 'activity',
title,
notes: line,
isOptional: false
});
}
}
return plans;
}
function parseProviderId(id: string): { slug: string; variantCode?: string } {
const [slugRaw, variantRaw] = id.split('::');
return {
slug: slugRaw?.trim().replace(/^\/+|\/+$/g, '') ?? '',
variantCode: variantRaw?.trim() || undefined
};
}
async function buildCatalog(): Promise<TourSearchResult[]> {
const now = Date.now();
if (cachedCatalog && cachedCatalog.expiresAt > now) return cachedCatalog.results;
const passListRes = await fetch(PASS_LIST_URL);
if (!passListRes.ok) return [];
const passListHtml = await passListRes.text();
const passUrls = extractPassUrls(passListHtml);
const results: TourSearchResult[] = [];
for (const passUrl of passUrls) {
const slug = passUrl.split('/').filter(Boolean).at(-1) ?? '';
try {
const res = await fetch(passUrl);
if (!res.ok) continue;
const html = await res.text();
const title = extractPageTitle(html, slugToTitle(slug));
const stages = parseStages(extractBusTimetableLines(html));
const variants = listVariants(stages);
if (variants.length > 1) {
for (const variant of variants) {
results.push({
id: `${slug}::${variant.code}`,
title: `${title}${variant.label}`
});
}
} else {
results.push({ id: slug, title });
}
} catch {
results.push({ id: slug, title: slugToTitle(slug) });
}
}
const deduped = Array.from(
new Map(results.map((result) => [result.id, result] as const)).values()
).sort((a, b) => a.title.localeCompare(b.title));
cachedCatalog = {
expiresAt: now + CATALOG_TTL_MS,
results: deduped
};
return deduped;
}
export const PeruHopProvider: TourProvider = {
name: 'PeruHop',
search: async (query: string): Promise<TourSearchResult[]> => {
const catalog = await buildCatalog();
const q = query.trim().toLowerCase();
if (!q) return catalog.slice(0, 100);
return catalog.filter((r) => r.title.toLowerCase().includes(q)).slice(0, 100);
},
getDetail: async (id: string): Promise<TourDetail | null> => {
const { slug, variantCode } = parseProviderId(id);
if (!slug) return null;
const url = `${BASE}/peru/passes/${slug}/`;
const res = await fetch(url);
if (!res.ok) return null;
const html = await res.text();
const title = extractPageTitle(html, slugToTitle(slug));
const metaDescription = html.match(
/<meta[^>]+name=["']description["'][^>]+content=["']([^"']+)["'][^>]*>/i
)?.[1];
const description = metaDescription ? decodeHtml(metaDescription).trim() : '';
const timetableLines = extractBusTimetableLines(html);
let stages = parseStages(timetableLines);
if (stages.length === 0) {
// Fallback: some pass pages don't keep timetable under a clean heading block.
stages = parseStages(htmlToLines(html));
}
const stageLabels = extractStageLabelsFromHtml(html);
const stageItems = extractStageItemsFromHtml(html);
const stageOptionalActivities = extractOptionalActivitiesFromHtml(html);
for (const stage of stages) {
const htmlLabel = stageLabels.get(stage.stageNumber);
if (htmlLabel) stage.stageLabel = htmlLabel;
}
const variants = listVariants(stages);
const selectedVariant = variantCode ?? variants[0]?.code;
const selectedVariantLabel = selectedVariant
? variants.find((v) => v.code === selectedVariant)?.label
: undefined;
const days: TourDay[] = stages.map((stage, index) => {
const basePlans = stageItems.get(stage.stageNumber)?.length
? plansFromStageItems(stageItems.get(stage.stageNumber) ?? [])
: plansFromStage(stage, selectedVariant);
const optionalPlans = stageOptionalActivities.get(stage.stageNumber) ?? [];
const dayPlans = [...basePlans, ...optionalPlans];
const dayTitleBase = stage.stageLabel
? `Hop Stage ${stage.stageNumber}: ${stage.stageLabel}`
: `Hop Stage ${stage.stageNumber}`;
const dayTitle = selectedVariantLabel
? `${dayTitleBase}${selectedVariantLabel}`
: dayTitleBase;
return {
dayNumber: index + 1,
title: dayTitle,
description: '',
dayPlans: dayPlans.length > 0 ? dayPlans : undefined
};
});
const detailTitle =
selectedVariantLabel && variants.length > 1 ? `${title}${selectedVariantLabel}` : title;
return {
id,
title: detailTitle,
description,
days
};
}
};