Initial commit: Vuelato - buscador de vuelos
Some checks failed
ci / ci (22, ubuntu-latest) (push) Has been cancelled

Nuxt 4 + Supabase + Flightics API. Incluye búsqueda de vuelos,
inspiraciones, watchlist, tracking de precios y mapa interactivo.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Martinez
2026-04-10 23:37:06 +02:00
commit b8906efc80
122 changed files with 37809 additions and 0 deletions

300
server/utils/flightics.ts Normal file
View File

@@ -0,0 +1,300 @@
const BASE_URL = 'https://www.flightics.com/api/v1'
const USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36'
const HEADERS = {
'Content-Type': 'application/json; charset=utf-8',
'User-Agent': USER_AGENT,
'sec-ch-ua': '"Chromium";v="147", "Not.A/Brand";v="8"',
'sec-ch-ua-mobile': '?0',
'sec-ch-ua-platform': '"macOS"',
'Referer': 'https://www.flightics.com/'
}
// --- Types ---
export interface PassengersCount {
adult: number
child: number
infant: number
}
export interface StopConfig {
locations: string[]
stayRange: { min: number, max: number }
stayDateRange: { begin: string, end: string }
continueFromAny: boolean
}
export interface SearchParams {
departures: string[]
local: string
departureDateInterval: { begin: string, end: string }
stops: StopConfig[]
endInSameLocation: boolean
maxStops: number | null
fixStopsOrder: boolean
stopLength: { min: number, max: number, isSet: boolean }
maxResults: number
passengersCount: PassengersCount
}
export interface Company {
name: string | null
code: string
icao: string
}
export interface Segment {
id: string
departureCode: string
departureCity: string
departureDate: string
departureTimestamp: number
departureDateUtc: string
departureUtcTimestamp: number
arrivalCode: string
arrivalCity: string
arrivalDate: string
arrivalTimestamp: number
arrivalDateUtc: string
arrivalUtcTimestamp: number
number: number
company: Company
isHidden: boolean
}
export interface Leg {
segments: Segment[]
}
export interface Trip {
legs: Leg[]
totalCost: number
bookingToken: string
currency: string
deepLink: string | null
}
export interface SearchResponse {
trips: Trip[]
notComplete: boolean
contractVersion: number
responseId: string
}
export interface DetailResponse {
trip: Trip
}
export interface Country {
isoCode2: string
isoCode3: string
nameEng: string
nameNative: string
phonePreselection: string
}
export interface InspirationItem {
from: string
to: string[]
minPrice: number
minStops: number
}
export interface InspirationsResponse {
items: InspirationItem[]
}
// --- API: Search ---
export async function searchTrips(params: SearchParams): Promise<SearchResponse> {
return $fetch(`${BASE_URL}/trips/search`, {
method: 'POST',
headers: HEADERS,
body: params
})
}
/**
* Poll search until notComplete is false or maxPolls reached.
* The API returns partial results with notComplete: true.
* Re-calling with the same params fetches more results.
*/
export async function searchTripsComplete(params: SearchParams, maxPolls: number = 3): Promise<SearchResponse> {
let allTrips: Trip[] = []
let lastResponse: SearchResponse | null = null
for (let i = 0; i < maxPolls; i++) {
const res = await searchTrips(params)
allTrips = [...allTrips, ...res.trips]
lastResponse = res
if (!res.notComplete) break
// Small delay between polls
await new Promise(r => setTimeout(r, 500))
}
return {
trips: allTrips,
notComplete: lastResponse?.notComplete ?? false,
contractVersion: lastResponse?.contractVersion ?? 0,
responseId: lastResponse?.responseId ?? ''
}
}
// --- API: Trip Detail & Check ---
export async function getTripDetail(bookingToken: string, local: string, passengersCount: PassengersCount): Promise<DetailResponse> {
return $fetch(`${BASE_URL}/trip/detail`, {
method: 'POST',
headers: HEADERS,
body: { bookingToken, local, passengersCount }
})
}
export async function checkTrip(bookingToken: string, local: string, passengersCount: PassengersCount): Promise<DetailResponse> {
return $fetch(`${BASE_URL}/trip/check`, {
method: 'POST',
headers: HEADERS,
body: { bookingToken, local, passengersCount }
})
}
// --- API: Inspirations ---
export async function getInspirations(from: string, take: number = 100, locale: string = 'en'): Promise<InspirationsResponse> {
return $fetch(`${BASE_URL}/inspirations/search`, {
method: 'GET',
headers: { 'User-Agent': USER_AGENT },
query: { from, take, locale }
})
}
// --- API: Weekend Search ---
export async function searchWeekendTrips(params: SearchParams): Promise<SearchResponse> {
return $fetch(`${BASE_URL}/trips/weekend/search`, {
method: 'POST',
headers: HEADERS,
body: params
})
}
// --- API: Route Flights ---
export interface RouteFlightsResponse {
contractVersion: number
returnResults: {
trips: Trip[]
}[]
}
export async function getRouteFlights(from: string, to: string, locale: string = 'en', passengersCount?: PassengersCount): Promise<RouteFlightsResponse> {
return $fetch(`${BASE_URL}/route-flights`, {
method: 'POST',
headers: HEADERS,
body: { from, to, locale, passengersCount }
})
}
// --- API: Multi-City Inspirations ---
export interface MultiCityInspirationItem {
from: string
stops: string[]
minPrice: number
}
export interface MultiCityInspirationsResponse {
currency: { name: string, code: string, symbol: string }
items: MultiCityInspirationItem[]
}
export async function getMultiCityInspirations(startLocationsCodes: string[], locale: string = 'en', take: number = 20): Promise<MultiCityInspirationsResponse> {
return $fetch(`${BASE_URL}/inspirations/search-multi-city`, {
method: 'POST',
headers: HEADERS,
body: { startLocationsCodes, locale, take }
})
}
// --- API: Locations ---
export interface Airport {
id: string
iata: string
icao: string
nameEng: string
lat: number
lon: number
cityId: string
timeZone: string
timeZoneOffset: string
}
export interface City {
id: string
name: string
nameEng: string
countryId: string
lat: number
lon: number
}
export interface LocationCountry {
id: string
name: string
nameEng: string
isoCode2: string
}
export interface LocationsResponse {
contractVersion: number
locale: string
airports: Airport[]
cities: City[]
countries: LocationCountry[]
}
export async function getLocations(): Promise<LocationsResponse> {
return $fetch(`${BASE_URL}/locations`, {
method: 'GET',
headers: { 'User-Agent': USER_AGENT }
})
}
// --- API: Route Data ---
export async function getRouteData(departureCode: string, arrivalCode: string): Promise<any> {
return $fetch(`${BASE_URL}/route-data`, {
method: 'GET',
headers: { 'User-Agent': USER_AGENT },
query: { departureCode, arrivalCode }
})
}
// --- API: Countries ---
export async function getCountries(): Promise<{ countries: Country[] }> {
return $fetch(`${BASE_URL}/countries`, {
method: 'GET',
headers: { 'User-Agent': USER_AGENT }
})
}
// --- API: Booking ---
export async function getBookingInfo(bookingToken: string, passengersCount: PassengersCount): Promise<any> {
return $fetch(`${BASE_URL}/booking/info`, {
method: 'POST',
headers: HEADERS,
body: { bookingToken, passengersCount }
})
}
export async function initBooking(bookingToken: string, passengersCount: PassengersCount): Promise<any> {
return $fetch(`${BASE_URL}/booking/init`, {
method: 'POST',
headers: HEADERS,
body: { bookingToken, passengersCount }
})
}

View File

@@ -0,0 +1,16 @@
import { hash as ohash } from 'ohash'
/**
* Genera un hash determinista de los parametros de busqueda.
* Normaliza el objeto: ordena keys recursivamente y elimina campos
* irrelevantes (_poll, maxResults) para que busquedas equivalentes
* produzcan el mismo hash.
*/
export function computeSearchHash(params: Record<string, unknown>): string {
const normalized = { ...params }
// Campos que no afectan los resultados de la busqueda
delete normalized._poll
delete normalized.maxResults
return ohash(normalized)
}

View File

@@ -0,0 +1,25 @@
import { createClient } from '@supabase/supabase-js'
import type { SupabaseClient } from '@supabase/supabase-js'
let _client: SupabaseClient | null = null
/**
* Cliente Supabase con service_role para uso fuera de event handlers (ej: worker plugin).
* Bypasses RLS — usar solo en contextos server-side confiables.
*/
export function getSupabaseAdmin(): SupabaseClient {
if (_client) return _client
const url = process.env.SUPABASE_URL
const key = process.env.SUPABASE_SERVICE_ROLE_KEY
if (!url || !key) {
throw new Error('SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY not configured')
}
_client = createClient(url, key, {
auth: { autoRefreshToken: false, persistSession: false }
})
return _client
}

62
server/utils/wikidata.ts Normal file
View File

@@ -0,0 +1,62 @@
const SPARQL_ENDPOINT = 'https://query.wikidata.org/sparql'
export interface WikidataAirline {
iata: string
icao: string | null
name: string
logoUrl: string | null
website: string | null
}
/**
* Fetch all airlines from Wikidata via SPARQL.
* Returns deduplicated list by IATA code (first logo wins).
*/
export async function getAirlinesFromWikidata(): Promise<WikidataAirline[]> {
const query = `
SELECT ?airlineLabel ?iata ?icao ?logo ?website WHERE {
?airline wdt:P31 wd:Q46970.
?airline wdt:P229 ?iata.
OPTIONAL { ?airline wdt:P230 ?icao. }
OPTIONAL { ?airline wdt:P154 ?logo. }
OPTIONAL { ?airline wdt:P856 ?website. }
SERVICE wikibase:label { bd:serviceParam wikibase:language "es,en". }
}
`
const data = await $fetch<{ results: { bindings: Record<string, { value: string }>[] } }>(
SPARQL_ENDPOINT,
{
query: { query, format: 'json' },
headers: {
'Accept': 'application/sparql-results+json',
'User-Agent': 'Vuelato/1.0 (https://github.com/vuelato; contact@vuelato.com)'
}
}
)
// Deduplicate by IATA code (keep first entry with logo if available)
const map = new Map<string, WikidataAirline>()
for (const row of data.results.bindings) {
const iata = row.iata?.value
if (!iata) continue
const existing = map.get(iata)
const logoUrl = row.logo?.value || null
const website = row.website?.value || null
// Keep entry with logo/website over entry without
if (!existing || (!existing.logoUrl && logoUrl) || (!existing.website && website)) {
map.set(iata, {
iata,
icao: row.icao?.value || existing?.icao || null,
name: row.airlineLabel?.value || existing?.name || '',
logoUrl: logoUrl || existing?.logoUrl || null,
website: website || existing?.website || null
})
}
}
return Array.from(map.values())
}