Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:39:48 +08:00

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,
};