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
All checks were successful
Build and Push Image / docker-build-and-push (push) Successful in 2m28s
This commit is contained in:
@@ -1,8 +1,11 @@
|
||||
import type { TourProvider } from './types.js';
|
||||
import { GAdventuresProvider } from './g-adventures.js';
|
||||
import { PeruHopProvider } from './peru-hop.js';
|
||||
|
||||
const PROVIDERS: Record<string, TourProvider> = {
|
||||
'g adventures': GAdventuresProvider
|
||||
'g adventures': GAdventuresProvider,
|
||||
'peru hop': PeruHopProvider,
|
||||
peruhop: PeruHopProvider
|
||||
};
|
||||
|
||||
export function getProviderForOperator(operatorName: string): TourProvider | null {
|
||||
|
||||
617
src/lib/server/admin/providers/peru-hop.ts
Normal file
617
src/lib/server/admin/providers/peru-hop.ts
Normal 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(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/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
|
||||
};
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user