diff --git a/cli-manifest.json b/cli-manifest.json index b708dd7..18a4beb 100644 --- a/cli-manifest.json +++ b/cli-manifest.json @@ -18067,6 +18067,68 @@ "modulePath": "semanticscholar/search.js", "sourceFile": "semanticscholar/search.js" }, + { + "site": "skyscanner", + "name": "flights", + "description": "Skyscanner visible round-trip flight results from a warmed browser session", + "access": "read", + "domain": "www.skyscanner.com", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "origin", + "type": "str", + "required": true, + "positional": true, + "help": "Skyscanner origin route code, for example nyca" + }, + { + "name": "destination", + "type": "str", + "required": true, + "positional": true, + "help": "Skyscanner destination route code, for example lond" + }, + { + "name": "depart-date", + "type": "str", + "required": true, + "help": "Outbound date as YYYY-MM-DD" + }, + { + "name": "return-date", + "type": "str", + "required": true, + "help": "Return date as YYYY-MM-DD" + }, + { + "name": "limit", + "type": "int", + "default": 10, + "required": false, + "help": "Maximum flight rows to return (1-30)" + } + ], + "columns": [ + "rank", + "priceText", + "airlines", + "outboundTime", + "outboundRoute", + "outboundDuration", + "outboundStops", + "returnTime", + "returnRoute", + "returnDuration", + "returnStops", + "url" + ], + "type": "js", + "modulePath": "skyscanner/flights.js", + "sourceFile": "skyscanner/flights.js", + "navigateBefore": false + }, { "site": "slock", "name": "attachment-download", diff --git a/clis/skyscanner/flights.js b/clis/skyscanner/flights.js new file mode 100644 index 0000000..ac472a8 --- /dev/null +++ b/clis/skyscanner/flights.js @@ -0,0 +1,205 @@ +import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@agentrhq/webcmd/errors'; +import { cli, Strategy } from '@agentrhq/webcmd/registry'; + +const HOST = 'www.skyscanner.com'; +const MAX_LIMIT = 30; + +function normalizeText(value) { + return String(value ?? '').replace(/\s+/g, ' ').trim(); +} + +function parseLimit(raw, fallback = 10) { + if (raw === undefined || raw === null || raw === '') return fallback; + const value = Number(raw); + if (!Number.isInteger(value)) { + throw new ArgumentError(`--limit must be an integer between 1 and ${MAX_LIMIT}, got ${JSON.stringify(raw)}`); + } + if (value < 1 || value > MAX_LIMIT) { + throw new ArgumentError(`--limit must be between 1 and ${MAX_LIMIT}, got ${value}`); + } + return value; +} + +function normalizeRouteCode(raw, label) { + const value = String(raw ?? '').trim().toLowerCase(); + if (!value) throw new ArgumentError(`${label} is required`); + if (!/^[a-z0-9-]+$/.test(value)) { + throw new ArgumentError(`${label} must be a Skyscanner route code like "nyca" or "lond"`); + } + return value; +} + +function normalizeDate(raw, label) { + const value = String(raw ?? '').trim(); + const match = value.match(/^(\d{4})-(\d{2})-(\d{2})$/); + if (!match) throw new ArgumentError(`${label} must use YYYY-MM-DD format`); + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + const date = new Date(Date.UTC(year, month - 1, day)); + if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) { + throw new ArgumentError(`${label} is not a valid calendar date`); + } + return `${String(year).slice(2)}${match[2]}${match[3]}`; +} + +function buildFlightsUrl({ origin, destination, departDate, returnDate }) { + const from = normalizeRouteCode(origin, 'origin'); + const to = normalizeRouteCode(destination, 'destination'); + const outbound = normalizeDate(departDate, '--depart-date'); + const inbound = normalizeDate(returnDate, '--return-date'); + return `https://${HOST}/transport/flights/${from}/${to}/${outbound}/${inbound}/`; +} + +function absoluteUrl(href, baseUrl) { + const value = String(href ?? '').trim(); + if (!value) return null; + try { + return new URL(value, baseUrl || 'https://www.skyscanner.com').href; + } catch { + return null; + } +} + +function unique(values) { + const seen = new Set(); + const result = []; + for (const value of values.map(normalizeText).filter(Boolean)) { + if (seen.has(value)) continue; + seen.add(value); + result.push(value); + } + return result; +} + +function squashRuns(values) { + const result = []; + for (const value of values.map(normalizeText).filter(Boolean)) { + if (result[result.length - 1] === value) continue; + result.push(value); + } + return result; +} + +function extractFlightsFromDocument(doc, limit = 10) { + const bodyText = normalizeText(doc?.body?.textContent ?? ''); + const pageUrl = doc?.location?.href || doc?.URL || 'https://www.skyscanner.com'; + if (/\/captcha(?:-|\/)|captcha|verify you are human|human verification|access to this page has been denied/i.test(`${pageUrl} ${bodyText.slice(0, 2000)}`)) { + return { blocked: true, rows: [] }; + } + + const tickets = Array.from(doc.querySelectorAll('[data-testid="ticket"]')); + const rows = []; + for (const ticket of tickets) { + if (rows.length >= limit) break; + const text = normalizeText(ticket.textContent || ''); + const priceMatches = text.match(/\$[\d,]+(?:\.\d{2})?/g) || []; + const priceText = priceMatches[priceMatches.length - 1] || null; + const airlineAlts = Array.from(ticket.querySelectorAll('img[alt]')).map((img) => img.getAttribute('alt')); + const airlines = unique(airlineAlts).join(', ') || null; + const spanTexts = Array.from(ticket.querySelectorAll('span')).map((span) => normalizeText(span.textContent)); + const times = squashRuns(spanTexts.filter((value) => /^\d{1,2}:\d{2}\s*(AM|PM)$/i.test(value))); + const airportCodes = squashRuns(spanTexts.filter((value) => /^[A-Z]{3}$/.test(value))); + const durations = squashRuns(spanTexts.filter((value) => /^\d+h\s+\d{2}$/.test(value))); + const stops = squashRuns(spanTexts.filter((value) => /^(Direct|1 stop|2\+ stops)$/i.test(value))); + const link = ticket.closest('a[href]') || ticket.querySelector('a[href]'); + const url = absoluteUrl(link?.getAttribute('href'), pageUrl); + + if (!priceText || !airlines) continue; + const row = { + rank: 0, + priceText, + airlines, + outboundTime: times[0] && times[1] ? `${times[0]}-${times[1]}` : null, + outboundRoute: airportCodes.length >= 2 ? `${airportCodes[0]}-${airportCodes[1]}` : null, + outboundDuration: durations[0] || null, + outboundStops: stops[0] || null, + returnTime: times[2] && times[3] ? `${times[2]}-${times[3]}` : null, + returnRoute: airportCodes.length >= 4 ? `${airportCodes[2]}-${airportCodes[3]}` : (airportCodes.length >= 3 ? `${airportCodes[1]}-${airportCodes[2]}` : null), + returnDuration: durations[1] || null, + returnStops: stops[1] || stops[0] || null, + url, + }; + if (!row.outboundTime || !row.outboundRoute || !row.outboundDuration || !row.returnTime || !row.returnRoute || !row.returnDuration || !row.url) { + continue; + } + row.rank = rows.length + 1; + rows.push(row); + } + return { blocked: false, rows }; +} + +function buildExtractScript(limit) { + return `(() => { + const extractFlightsFromDocument = ${extractFlightsFromDocument.toString()}; + const normalizeText = ${normalizeText.toString()}; + const absoluteUrl = ${absoluteUrl.toString()}; + const unique = ${unique.toString()}; + const squashRuns = ${squashRuns.toString()}; + return extractFlightsFromDocument(document, ${limit}); + })()`; +} + +async function readFlightsFromPage(page, limit, timeoutSeconds = 20) { + let lastResult = null; + for (let second = 0; second <= timeoutSeconds; second += 1) { + const result = await page.evaluate(buildExtractScript(limit)); + if (result && typeof result === 'object') { + lastResult = result; + if (result.blocked || (Array.isArray(result.rows) && result.rows.length > 0)) { + return result; + } + } + if (second < timeoutSeconds) await page.wait(1); + } + return lastResult; +} + +cli({ + site: 'skyscanner', + name: 'flights', + access: 'read', + description: 'Skyscanner visible round-trip flight results from a warmed browser session', + domain: HOST, + strategy: Strategy.UI, + navigateBefore: false, + args: [ + { name: 'origin', positional: true, required: true, help: 'Skyscanner origin route code, for example nyca' }, + { name: 'destination', positional: true, required: true, help: 'Skyscanner destination route code, for example lond' }, + { name: 'depart-date', required: true, help: 'Outbound date as YYYY-MM-DD' }, + { name: 'return-date', required: true, help: 'Return date as YYYY-MM-DD' }, + { name: 'limit', type: 'int', default: 10, help: `Maximum flight rows to return (1-${MAX_LIMIT})` }, + ], + columns: ['rank', 'priceText', 'airlines', 'outboundTime', 'outboundRoute', 'outboundDuration', 'outboundStops', 'returnTime', 'returnRoute', 'returnDuration', 'returnStops', 'url'], + func: async (page, kwargs) => { + const limit = parseLimit(kwargs.limit); + const url = buildFlightsUrl({ + origin: kwargs.origin, + destination: kwargs.destination, + departDate: kwargs['depart-date'], + returnDate: kwargs['return-date'], + }); + + await page.goto(url, { waitUntil: 'load', settleMs: 2000 }); + const result = await readFlightsFromPage(page, limit); + if (!result || typeof result !== 'object') { + throw new CommandExecutionError('Skyscanner flight extraction returned an unreadable response'); + } + if (result.blocked) { + throw new AuthRequiredError(HOST, 'Skyscanner requires browser verification. Open this route in CloakBrowser, solve the CAPTCHA, then rerun the command.'); + } + const rows = Array.isArray(result.rows) ? result.rows : []; + if (!rows.length) { + throw new EmptyResultError('skyscanner flights', 'No visible flight cards were found. The page may still be loading, blocked, or Skyscanner changed its layout.'); + } + return rows; + }, +}); + +export const __test__ = { + buildFlightsUrl, + extractFlightsFromDocument, + parseLimit, + readFlightsFromPage, + squashRuns, +}; diff --git a/clis/skyscanner/flights.test.js b/clis/skyscanner/flights.test.js new file mode 100644 index 0000000..a142c9f --- /dev/null +++ b/clis/skyscanner/flights.test.js @@ -0,0 +1,148 @@ +import { JSDOM } from 'jsdom'; +import { describe, expect, it, vi } from 'vitest'; +import { AuthRequiredError, EmptyResultError } from '@agentrhq/webcmd/errors'; +import { getRegistry } from '@agentrhq/webcmd/registry'; +import './flights.js'; +import { __test__ } from './flights.js'; + +const command = getRegistry().get('skyscanner/flights'); + +function createPage(evaluateResult) { + return { + goto: vi.fn().mockResolvedValue(undefined), + wait: vi.fn().mockResolvedValue(undefined), + evaluate: vi.fn().mockResolvedValue(evaluateResult), + }; +} + +describe('skyscanner flights command metadata', () => { + it('registers the browser-backed flights command', () => { + expect(command).toBeDefined(); + expect(command.site).toBe('skyscanner'); + expect(command.name).toBe('flights'); + expect(command.access).toBe('read'); + expect(command.browser).toBe(true); + expect(command.strategy).toBe('ui'); + expect(command.columns).toEqual([ + 'rank', + 'priceText', + 'airlines', + 'outboundTime', + 'outboundRoute', + 'outboundDuration', + 'outboundStops', + 'returnTime', + 'returnRoute', + 'returnDuration', + 'returnStops', + 'url', + ]); + }); +}); + +describe('skyscanner flights helpers', () => { + it('builds the observed route URL from Skyscanner route codes and ISO dates', () => { + expect(__test__.buildFlightsUrl({ + origin: 'NYCA', + destination: 'lond', + departDate: '2026-08-01', + returnDate: '2026-08-08', + })).toBe('https://www.skyscanner.com/transport/flights/nyca/lond/260801/260808/'); + }); + + it('rejects bad dates and limits without silent clamp', () => { + expect(() => __test__.buildFlightsUrl({ + origin: 'nyca', + destination: 'lond', + departDate: '2026-02-30', + returnDate: '2026-08-08', + })).toThrow('--depart-date is not a valid calendar date'); + expect(() => __test__.parseLimit(0)).toThrow('--limit must be between 1 and 30, got 0'); + expect(() => __test__.parseLimit(31)).toThrow('--limit must be between 1 and 30, got 31'); + expect(() => __test__.parseLimit('abc')).toThrow('--limit must be an integer'); + }); + + it('extracts visible flight cards from Skyscanner ticket DOM', () => { + const dom = new JSDOM(` + +
+ British Airways + 9:25 PM9:25 PMEWREWR + 7h 05Direct + 9:30 AM9:30 AMLHRLHR + British Airways + 4:35 PM4:35 PMLHRLHR + 7h 50Direct + 7:25 PM7:25 PMEWREWR + 12 deals from$892 +
+
+ `, { url: 'https://www.skyscanner.com/transport/flights/nyca/lond/260801/260808/' }); + + expect(__test__.extractFlightsFromDocument(dom.window.document, 10)).toEqual({ + blocked: false, + rows: [{ + rank: 1, + priceText: '$892', + airlines: 'British Airways', + outboundTime: '9:25 PM-9:30 AM', + outboundRoute: 'EWR-LHR', + outboundDuration: '7h 05', + outboundStops: 'Direct', + returnTime: '4:35 PM-7:25 PM', + returnRoute: 'LHR-EWR', + returnDuration: '7h 50', + returnStops: 'Direct', + url: 'https://www.skyscanner.com/transport_deeplink/4.0/US/en-US/USD/british-airways/example', + }], + }); + }); + + it('detects captcha pages as blocked instead of returning empty rows', () => { + const dom = new JSDOM('Verify you are human to continue', { + url: 'https://www.skyscanner.com/sttc/px/captcha-v2/index.html', + }); + expect(__test__.extractFlightsFromDocument(dom.window.document, 10)).toEqual({ blocked: true, rows: [] }); + }); +}); + +describe('skyscanner flights execution', () => { + it('navigates to the constructed route and returns rows from the extractor', async () => { + const page = createPage({ + blocked: false, + rows: [{ rank: 1, priceText: '$892', airlines: 'British Airways' }], + }); + + await expect(command.func(page, { + origin: 'nyca', + destination: 'lond', + 'depart-date': '2026-08-01', + 'return-date': '2026-08-08', + limit: 1, + })).resolves.toEqual([{ rank: 1, priceText: '$892', airlines: 'British Airways' }]); + expect(page.goto).toHaveBeenCalledWith( + 'https://www.skyscanner.com/transport/flights/nyca/lond/260801/260808/', + { waitUntil: 'load', settleMs: 2000 }, + ); + }); + + it('throws auth-required for Skyscanner verification pages', async () => { + const page = createPage({ blocked: true, rows: [] }); + await expect(command.func(page, { + origin: 'nyca', + destination: 'lond', + 'depart-date': '2026-08-01', + 'return-date': '2026-08-08', + })).rejects.toBeInstanceOf(AuthRequiredError); + }); + + it('throws empty-result when the visible page has no flight cards', async () => { + const page = createPage({ blocked: false, rows: [] }); + await expect(command.func(page, { + origin: 'nyca', + destination: 'lond', + 'depart-date': '2026-08-01', + 'return-date': '2026-08-08', + })).rejects.toBeInstanceOf(EmptyResultError); + }); +});