9b395f5cc3
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
Build Chrome Extension / build (push) Has been cancelled
Trigger Website Rebuild (Docs Updated) / dispatch (push) Has been cancelled
352 lines
13 KiB
JavaScript
352 lines
13 KiB
JavaScript
import {
|
|
ArgumentError,
|
|
CommandExecutionError,
|
|
EmptyResultError,
|
|
} from '@jackwener/opencli/errors';
|
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
|
|
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
|
|
function normalizePositiveInt(value, defaultValue, label, max) {
|
|
const raw = value ?? defaultValue;
|
|
const n = Number(raw);
|
|
if (!Number.isInteger(n) || n <= 0) {
|
|
throw new ArgumentError(`${label} must be a positive integer`);
|
|
}
|
|
if (typeof max === 'number' && n > max) {
|
|
throw new ArgumentError(`${label} must be <= ${max}`);
|
|
}
|
|
return n;
|
|
}
|
|
|
|
function normalizeNonNegativeInt(value, defaultValue, label, max) {
|
|
const raw = value ?? defaultValue;
|
|
const n = Number(raw);
|
|
if (!Number.isInteger(n) || n < 0) {
|
|
throw new ArgumentError(`${label} must be a non-negative integer`);
|
|
}
|
|
if (typeof max === 'number' && n > max) {
|
|
throw new ArgumentError(`${label} must be <= ${max}`);
|
|
}
|
|
return n;
|
|
}
|
|
|
|
function normalizeDate(value, label) {
|
|
const v = String(value || '').trim();
|
|
if (!v) {
|
|
throw new ArgumentError(`${label} is required (YYYY-MM-DD)`);
|
|
}
|
|
if (!DATE_RE.test(v)) {
|
|
throw new ArgumentError(`${label} must be YYYY-MM-DD, got ${JSON.stringify(value)}`);
|
|
}
|
|
const [year, month, day] = v.split('-').map(Number);
|
|
const d = new Date(Date.UTC(year, month - 1, day));
|
|
if (
|
|
Number.isNaN(d.getTime()) ||
|
|
d.getUTCFullYear() !== year ||
|
|
d.getUTCMonth() !== month - 1 ||
|
|
d.getUTCDate() !== day
|
|
) {
|
|
throw new ArgumentError(`${label} is not a valid calendar date: ${v}`);
|
|
}
|
|
return v;
|
|
}
|
|
|
|
function normalizeCurrency(value) {
|
|
if (value == null || value === '') return '';
|
|
const v = String(value).trim().toUpperCase();
|
|
if (!/^[A-Z]{3}$/.test(v)) {
|
|
throw new ArgumentError(`currency must be a 3-letter ISO code (e.g. USD, JPY, CNY), got ${JSON.stringify(value)}`);
|
|
}
|
|
return v;
|
|
}
|
|
|
|
const ALLOWED_LANGS = new Set([
|
|
'en-us', 'en-gb', 'zh-cn', 'zh-tw', 'ja', 'ko', 'de', 'fr', 'es', 'it',
|
|
'pt-br', 'pt-pt', 'ru', 'th', 'vi', 'tr', 'pl', 'nl', 'ar',
|
|
]);
|
|
|
|
function normalizeLang(value) {
|
|
if (value == null || value === '') return '';
|
|
const v = String(value).trim().toLowerCase();
|
|
if (!ALLOWED_LANGS.has(v)) {
|
|
throw new ArgumentError(`lang must be one of: ${[...ALLOWED_LANGS].join(', ')}`);
|
|
}
|
|
return v;
|
|
}
|
|
|
|
function hasPositiveResultCount(text) {
|
|
const value = String(text || '').replace(/\u00a0/g, ' ');
|
|
const resultCount = value.match(/\b([1-9][0-9,.\s]*)\s+(?:properties|property|stays|stay|hotels|hotel)\b/i);
|
|
if (!resultCount) return false;
|
|
const digits = resultCount[1].replace(/\D/g, '');
|
|
return Boolean(digits) && Number(digits) > 0;
|
|
}
|
|
|
|
function buildSearchUrl({
|
|
destination,
|
|
checkin,
|
|
checkout,
|
|
adults,
|
|
rooms,
|
|
children,
|
|
offset,
|
|
currency,
|
|
lang,
|
|
}) {
|
|
const file = lang ? `searchresults.${lang}.html` : 'searchresults.html';
|
|
const params = new URLSearchParams();
|
|
params.set('ss', destination);
|
|
params.set('checkin', checkin);
|
|
params.set('checkout', checkout);
|
|
params.set('group_adults', String(adults));
|
|
params.set('no_rooms', String(rooms));
|
|
params.set('group_children', String(children));
|
|
if (offset > 0) params.set('offset', String(offset));
|
|
if (currency) params.set('selected_currency', currency);
|
|
return `https://www.booking.com/${file}?${params.toString()}`;
|
|
}
|
|
|
|
const EXTRACTOR = `
|
|
(() => {
|
|
const trim = (v) => (v == null ? '' : String(v).replace(/\\s+/g, ' ').trim());
|
|
const cards = Array.from(document.querySelectorAll('[data-testid=property-card]'));
|
|
|
|
// Detect blocking / captcha pages: no cards but body shows a verification prompt.
|
|
if (cards.length === 0) {
|
|
const text = [
|
|
(document.title || ''),
|
|
(document.body && document.body.innerText) || '',
|
|
(location && location.pathname) || '',
|
|
].join(' ');
|
|
const blocked = /captcha|challenge|verify\\s*you\\s*are|access\\s*denied|forbidden|robot|unusual\\s*traffic/i.test(text);
|
|
const totalEl = document.querySelector('h1');
|
|
const totalText = trim(totalEl && totalEl.textContent);
|
|
return { ok: true, items: [], blocked, totalText };
|
|
}
|
|
|
|
const items = cards.map((card) => {
|
|
const titleEl = card.querySelector('[data-testid=title]');
|
|
const link = card.querySelector('a[data-testid=title-link]');
|
|
const href = (link && link.href) || '';
|
|
let country = '';
|
|
let slug = '';
|
|
let canonicalUrl = '';
|
|
try {
|
|
const u = new URL(href, 'https://www.booking.com');
|
|
const m = u.pathname.match(/^\\/hotel\\/([a-z]{2})\\/([^./]+)/);
|
|
if (m) {
|
|
country = m[1];
|
|
slug = m[2];
|
|
canonicalUrl = 'https://www.booking.com/hotel/' + country + '/' + slug + '.html';
|
|
}
|
|
} catch (_) {}
|
|
|
|
const reviewTextRaw = trim(card.querySelector('[data-testid=review-score]')?.textContent);
|
|
// Booking renders the score twice (a11y + visual), text reads like "Scored 8.6 8.6 Very Good 6,151 reviews"
|
|
// or "评分8.68.6很棒 6,151条住客点评". Take only the first numeric occurrence.
|
|
const scoreMatch = reviewTextRaw.match(/(\\d{1,2})\\.(\\d)/);
|
|
const reviewScore = scoreMatch ? Number(scoreMatch[1] + '.' + scoreMatch[2]) : null;
|
|
|
|
const countMatch = reviewTextRaw.match(/([0-9][0-9,]*)\\s*(?:reviews|reseñas|avis|recensioni|条住客点评|条评论|レビュー|리뷰)/i);
|
|
const reviewCount = countMatch ? Number(countMatch[1].replace(/,/g, '')) : null;
|
|
|
|
// Star rating: aria-label often "5 out of 5" / "4 星 (满分 5 星)" / "Hôtel 4 étoiles"
|
|
let starRating = null;
|
|
const starEl = card.querySelector('[data-testid=rating-stars], [data-testid=quality-rating]');
|
|
if (starEl) {
|
|
const aria = starEl.getAttribute('aria-label') || starEl.textContent || '';
|
|
const m = aria.match(/(\\d)(?:\\s*(?:out of|\\/|星|颗星|stars?|étoiles?)|\\s*$)/i);
|
|
if (m) starRating = Number(m[1]);
|
|
if (starRating == null) {
|
|
const count = starEl.querySelectorAll('svg, [aria-hidden=true]').length;
|
|
if (count >= 1 && count <= 5) starRating = count;
|
|
}
|
|
}
|
|
|
|
const priceEl = card.querySelector('[data-testid=price-and-discounted-price]');
|
|
const priceText = trim(priceEl && priceEl.textContent);
|
|
|
|
// currency symbol → ISO best-effort
|
|
const currencySymbolMap = {
|
|
'$': 'USD', 'US$': 'USD', 'A$': 'AUD', 'C$': 'CAD', 'HK$': 'HKD',
|
|
'€': 'EUR', '£': 'GBP', '¥': 'JPY', '¥': 'CNY', '₹': 'INR', '₩': 'KRW',
|
|
'CN¥': 'CNY', 'CN¥': 'CNY', 'NT$': 'TWD', 'S$': 'SGD',
|
|
};
|
|
let priceCurrency = '';
|
|
let priceAmount = null;
|
|
const sym = priceText.match(/(US\\$|A\\$|C\\$|HK\\$|NT\\$|S\\$|CN¥|CN¥|[$€£¥¥₹₩])/);
|
|
if (sym) priceCurrency = currencySymbolMap[sym[1]] || '';
|
|
const num = priceText.replace(/,/g, '').match(/(\\d+(?:\\.\\d+)?)/);
|
|
if (num) priceAmount = Number(num[1]);
|
|
|
|
return {
|
|
name: trim(titleEl?.textContent),
|
|
country,
|
|
slug,
|
|
url: canonicalUrl,
|
|
distance: trim(card.querySelector('[data-testid=distance]')?.textContent),
|
|
review_score: reviewScore,
|
|
review_count: reviewCount,
|
|
star_rating: starRating,
|
|
price_currency: priceCurrency,
|
|
price_amount: priceAmount,
|
|
recommended_room: trim(card.querySelector('[data-testid=recommended-units]')?.textContent),
|
|
};
|
|
});
|
|
|
|
const totalEl = document.querySelector('h1');
|
|
const totalText = trim(totalEl && totalEl.textContent);
|
|
return { ok: true, items, blocked: false, totalText };
|
|
})()
|
|
`;
|
|
|
|
cli({
|
|
site: 'booking',
|
|
name: 'search',
|
|
description: 'Search Booking.com hotels by destination and dates (server-rendered card scrape).',
|
|
access: 'read',
|
|
example: 'opencli booking search Tokyo --checkin 2026-06-15 --checkout 2026-06-17 -f yaml',
|
|
domain: 'www.booking.com',
|
|
strategy: Strategy.PUBLIC,
|
|
browser: true,
|
|
args: [
|
|
{ name: 'destination', required: true, positional: true, help: 'Destination keyword (city, district, or hotel name)' },
|
|
{ name: 'checkin', required: true, help: 'Check-in date YYYY-MM-DD' },
|
|
{ name: 'checkout', required: true, help: 'Check-out date YYYY-MM-DD' },
|
|
{ name: 'adults', type: 'int', default: 2, help: 'Number of adults (1-30)' },
|
|
{ name: 'rooms', type: 'int', default: 1, help: 'Number of rooms (1-30)' },
|
|
{ name: 'children', type: 'int', default: 0, help: 'Number of children (0-10)' },
|
|
{ name: 'currency', required: false, help: 'Force result currency (e.g. USD, JPY, CNY)' },
|
|
{ name: 'lang', required: false, help: 'Force result language (e.g. en-us, zh-cn, ja)' },
|
|
{ name: 'limit', type: 'int', default: 25, help: 'Max rows to return (1-100; Booking pages 25 per request)' },
|
|
{ name: 'offset', type: 'int', default: 0, help: 'Result offset for pagination (multiple of 25)' },
|
|
],
|
|
columns: [
|
|
'rank',
|
|
'name',
|
|
'country',
|
|
'slug',
|
|
'star_rating',
|
|
'review_score',
|
|
'review_count',
|
|
'price_amount',
|
|
'price_currency',
|
|
'distance',
|
|
'recommended_room',
|
|
'url',
|
|
],
|
|
func: async (page, kwargs) => {
|
|
const destination = String(kwargs.destination || '').trim();
|
|
if (!destination) throw new ArgumentError('destination is required');
|
|
const checkin = normalizeDate(kwargs.checkin, 'checkin');
|
|
const checkout = normalizeDate(kwargs.checkout, 'checkout');
|
|
if (checkin >= checkout) {
|
|
throw new ArgumentError(`checkout (${checkout}) must be after checkin (${checkin})`);
|
|
}
|
|
const adults = normalizePositiveInt(kwargs.adults, 2, 'adults', 30);
|
|
const rooms = normalizePositiveInt(kwargs.rooms, 1, 'rooms', 30);
|
|
const children = normalizeNonNegativeInt(kwargs.children, 0, 'children', 10);
|
|
const currency = normalizeCurrency(kwargs.currency);
|
|
const lang = normalizeLang(kwargs.lang);
|
|
const limit = normalizePositiveInt(kwargs.limit, 25, 'limit', 100);
|
|
const offset = normalizeNonNegativeInt(kwargs.offset, 0, 'offset', 1000);
|
|
|
|
const url = buildSearchUrl({ destination, checkin, checkout, adults, rooms, children, offset, currency, lang });
|
|
|
|
try {
|
|
await page.goto(url);
|
|
} catch (err) {
|
|
throw new CommandExecutionError(`Failed to load Booking.com search page: ${err?.message || err}`);
|
|
}
|
|
|
|
// Booking lazy-loads price cells; wait for at least the first card price to settle.
|
|
try {
|
|
await page.wait('selector', '[data-testid=property-card]', { timeoutMs: 20000 });
|
|
} catch (_) {
|
|
// selector wait is best-effort — extractor handles empty case explicitly
|
|
}
|
|
|
|
let raw;
|
|
try {
|
|
raw = await page.evaluate(EXTRACTOR);
|
|
} catch (err) {
|
|
throw new CommandExecutionError(`Failed to extract Booking.com cards: ${err?.message || err}`);
|
|
}
|
|
|
|
if (raw && typeof raw === 'object' && raw.data && raw.session) {
|
|
raw = raw.data;
|
|
}
|
|
if (!raw || typeof raw !== 'object') {
|
|
throw new CommandExecutionError('Booking.com page returned no extractable data');
|
|
}
|
|
if (raw.blocked) {
|
|
throw new CommandExecutionError('Booking.com served a verification / captcha page; retry later or change profile');
|
|
}
|
|
|
|
if (raw.ok !== true) {
|
|
throw new CommandExecutionError('Booking.com extractor returned an invalid status');
|
|
}
|
|
if (!Array.isArray(raw.items)) {
|
|
throw new CommandExecutionError('Booking.com extractor returned malformed items');
|
|
}
|
|
|
|
const items = raw.items;
|
|
if (items.length === 0) {
|
|
const totalText = String(raw.totalText || '').trim();
|
|
if (hasPositiveResultCount(totalText)) {
|
|
throw new CommandExecutionError(
|
|
`Booking.com page declared results but no property cards were parsed: ${totalText}`,
|
|
);
|
|
}
|
|
throw new EmptyResultError(
|
|
`booking search ${JSON.stringify(destination)}`,
|
|
totalText
|
|
? `No hotels rendered (${totalText}). Try a broader destination, different dates, or check the URL in a browser.`
|
|
: 'No hotels rendered. Try a broader destination, different dates, or check the URL in a browser.',
|
|
);
|
|
}
|
|
|
|
return items.slice(0, limit).map((it, i) => {
|
|
if (!it || typeof it !== 'object') {
|
|
throw new CommandExecutionError('Booking.com extractor returned malformed hotel row');
|
|
}
|
|
const name = String(it.name || '').trim();
|
|
const country = String(it.country || '').trim();
|
|
const slug = String(it.slug || '').trim();
|
|
const urlValue = String(it.url || '').trim();
|
|
const expectedUrl = country && slug
|
|
? `https://www.booking.com/hotel/${country}/${slug}.html`
|
|
: '';
|
|
if (!name || !/^[a-z]{2}$/.test(country) || !slug || urlValue !== expectedUrl) {
|
|
throw new CommandExecutionError('Booking.com hotel row is missing stable name/url identity');
|
|
}
|
|
return {
|
|
rank: offset + i + 1,
|
|
name,
|
|
country,
|
|
slug,
|
|
star_rating: it.star_rating,
|
|
review_score: it.review_score,
|
|
review_count: it.review_count,
|
|
price_amount: it.price_amount,
|
|
price_currency: it.price_amount == null ? '' : (currency || it.price_currency || ''),
|
|
distance: it.distance,
|
|
recommended_room: it.recommended_room,
|
|
url: urlValue,
|
|
};
|
|
});
|
|
},
|
|
});
|
|
|
|
export const __test__ = {
|
|
normalizePositiveInt,
|
|
normalizeNonNegativeInt,
|
|
normalizeDate,
|
|
normalizeCurrency,
|
|
normalizeLang,
|
|
hasPositiveResultCount,
|
|
buildSearchUrl,
|
|
EXTRACTOR,
|
|
};
|