From 118aee639e1499caff6a9f96c90d9f4beff11019 Mon Sep 17 00:00:00 2001 From: Shaun Campbell Date: Tue, 24 Feb 2026 23:27:16 -0500 Subject: [PATCH] Add PeruHop provider with stage-based timetable and optional activity parsing --- src/lib/server/admin/providers/index.ts | 5 +- src/lib/server/admin/providers/peru-hop.ts | 617 +++++++++++++++++++++ 2 files changed, 621 insertions(+), 1 deletion(-) create mode 100644 src/lib/server/admin/providers/peru-hop.ts diff --git a/src/lib/server/admin/providers/index.ts b/src/lib/server/admin/providers/index.ts index d87068f..ab86ce9 100644 --- a/src/lib/server/admin/providers/index.ts +++ b/src/lib/server/admin/providers/index.ts @@ -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 = { - 'g adventures': GAdventuresProvider + 'g adventures': GAdventuresProvider, + 'peru hop': PeruHopProvider, + peruhop: PeruHopProvider }; export function getProviderForOperator(operatorName: string): TourProvider | null { diff --git a/src/lib/server/admin/providers/peru-hop.ts b/src/lib/server/admin/providers/peru-hop.ts new file mode 100644 index 0000000..022b1f1 --- /dev/null +++ b/src/lib/server/admin/providers/peru-hop.ts @@ -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(//gi, ' ') + .replace(//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(); + 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(/]*>([\s\S]*?)<\/h1>/i)?.[1]; + const title = h1 ? stripTags(h1) : ''; + return title.trim() || fallback; +} + +function htmlToLines(html: string): string[] { + const withBreaks = html + .replace(//gi, '\n') + .replace(/<\/(p|div|li|h1|h2|h3|h4|tr|section)>/gi, '\n'); + const text = decodeHtml( + withBreaks + .replace(//gi, ' ') + .replace(//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 { + const labels = new Map(); + const stageLabelRegex = + /]*class=["'][^"']*\btb-hop\b[^"']*["'][^>]*>[\s\S]*?HOP STAGE\s*(\d+)[\s\S]*?<\/td>[\s\S]*?]*class=["'][^"']*\bborder-hopstage\b[^"']*["'][^>]*>[\s\S]*?]*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 { + const byStage = new Map(); + const tokenRegex = + /]*class=["'][^"']*\btb-hop\b[^"']*["'][^>]*>[\s\S]*?HOP STAGE\s*(\d+)[\s\S]*?<\/td>|]*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 = /]*)>([\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 { + const byStage = new Map(); + const tokenRegex = + /]*class=["'][^"']*\btb-hop\b[^"']*["'][^>]*>[\s\S]*?HOP STAGE\s*(\d+)[\s\S]*?<\/td>|]*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 = /]*>([\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(//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(/]*>\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(/]*>/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(); + 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 { + 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 => { + 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 => { + 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( + /]+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 + }; + } +};