// ==UserScript== // @name Airbnb Listing Score // @author AZ // @description Wilson lower-bound + page-relative scoring with color-coded score pills, text dimming, sort controls, and all-pages loading // @downloadURL https://raw.githubusercontent.com/hamidzr/user-scripts/refs/heads/master/userscripts/airbnb.com/airbnb-score.user.js // @grant none // @match https://www.airbnb.com/* // @namespace https://latentbyte.com/products // @run-at document-idle // @updateURL https://raw.githubusercontent.com/hamidzr/user-scripts/refs/heads/master/userscripts/airbnb.com/airbnb-score.user.js // @version 1.12.6 // ==/UserScript== 'use strict'; (() => { // src/lib/dom.ts var waitForEl = (selector, timeoutMs = 5000) => { return new Promise((resolve, reject) => { const el = document.querySelector(selector); if (el) return resolve(el); const endTime = Date.now() + timeoutMs; const observer = new MutationObserver(() => { const el = document.querySelector(selector); if (el) { observer.disconnect(); resolve(el); } else if (Date.now() > endTime) { observer.disconnect(); reject(new Error(`waitForEl("${selector}") timed out after ${timeoutMs}ms`)); } }); observer.observe(document.body, { childList: true, subtree: true }); setTimeout(() => { observer.disconnect(); const el = document.querySelector(selector); if (el) resolve(el); else reject(new Error(`waitForEl("${selector}") timed out after ${timeoutMs}ms`)); }, timeoutMs); }); }; var POLL_INTERVAL = 100; var pollUntil = (fn, timeoutMs) => { const endTime = Date.now() + timeoutMs; return new Promise((resolve, reject) => { const check = async () => { try { const result = await fn(); if (result !== null && result !== undefined) { resolve(result); } else if (Date.now() > endTime) { reject(new Error("timeout")); } else { setTimeout(check, POLL_INTERVAL); } } catch (error) { reject(error); } }; check(); }); }; var injectCSS = (id, css) => { let style = document.getElementById(id); if (style) { style.textContent = css; return style; } style = document.createElement("style"); style.id = id; style.textContent = css; (document.head ?? document.documentElement).appendChild(style); return style; }; var simClick = (el) => { el.click(); el.dispatchEvent(new MouseEvent("mousedown", { bubbles: true })); el.dispatchEvent(new MouseEvent("mouseup", { bubbles: true })); el.dispatchEvent(new MouseEvent("click", { bubbles: true })); }; // src/lib/dom-observe.ts var DEFAULT_INIT = { childList: true, subtree: true }; var observeDomChanges = (run, opts) => { let timer = null; let disconnectTimer = null; let currentTarget = opts?.root ?? null; let currentInit = opts?.observerInit ?? DEFAULT_INIT; const disconnect = () => { if (timer) { window.clearTimeout(timer); timer = null; } if (disconnectTimer) { window.clearTimeout(disconnectTimer); disconnectTimer = null; } observer.disconnect(); }; const refreshDisconnectTimer = () => { if (disconnectTimer) window.clearTimeout(disconnectTimer); if (!opts?.disconnectAfterMs) return; disconnectTimer = window.setTimeout(() => { disconnect(); }, opts.disconnectAfterMs); }; const scheduleRun = () => { if (timer) window.clearTimeout(timer); const debounceMs = opts?.debounceMs ?? 0; if (debounceMs <= 0) { run(); refreshDisconnectTimer(); return; } timer = window.setTimeout(() => { timer = null; run(); refreshDisconnectTimer(); }, debounceMs); }; const observer = new MutationObserver((mutations) => { if (opts?.shouldRun && !opts.shouldRun(mutations)) return; scheduleRun(); }); const observe = (target, options) => { const nextTarget = target ?? currentTarget ?? document.body; if (!nextTarget) return; currentTarget = nextTarget; currentInit = options ?? currentInit; observer.disconnect(); observer.observe(nextTarget, currentInit); refreshDisconnectTimer(); }; if (opts?.runImmediately) scheduleRun(); return { disconnect, observe }; }; // src/lib/page-lifecycle.ts var KEY = "__lbLocationChangeController__"; var scheduleListener = (listener) => { const nextHref = window.location.href; if (nextHref === listener.lastHref) return; listener.lastHref = nextHref; if (listener.timer) window.clearTimeout(listener.timer); if (listener.debounceMs <= 0) { listener.callback(); return; } listener.timer = window.setTimeout(() => { listener.timer = null; listener.callback(); }, listener.debounceMs); }; var ensureController = () => { const existing = window[KEY]; if (existing) return existing; const pushState = history.pushState.bind(history); const replaceState = history.replaceState.bind(history); const controller = { listeners: new Set, pushState, replaceState, notify: () => { controller.listeners.forEach((listener) => { scheduleListener(listener); }); }, restore: () => { history.pushState = pushState; history.replaceState = replaceState; window.removeEventListener("popstate", controller.onPopState); if ("navigation" in window) { window.navigation.removeEventListener("navigatesuccess", controller.onPopState); } delete window[KEY]; }, onPopState: () => { controller.notify(); } }; history.pushState = (...args) => { pushState(...args); controller.notify(); }; history.replaceState = (...args) => { replaceState(...args); controller.notify(); }; window.addEventListener("popstate", controller.onPopState); if ("navigation" in window) { window.navigation.addEventListener("navigatesuccess", controller.onPopState); } window[KEY] = controller; return controller; }; var watchLocationChange = (callback, opts) => { const controller = ensureController(); const listener = { callback, debounceMs: opts?.debounceMs ?? 0, lastHref: window.location.href, timer: null }; controller.listeners.add(listener); return () => { if (listener.timer) window.clearTimeout(listener.timer); controller.listeners.delete(listener); if (controller.listeners.size === 0) controller.restore(); }; }; // src/airbnb.com/airbnb-search-resolver.ts var LOCATION_BUTTON_SELECTOR = '[data-testid="little-search-location"]'; var LOCATION_INPUT_SELECTOR = 'input[name="query"][data-testid="structured-search-input-field-query"]'; var LOCATION_OPTION_SELECTOR = '[role="option"][data-testid^="option-"]'; var SEARCH_BUTTON_SELECTOR = '[data-testid="structured-search-input-search-button"]'; var ATTEMPT_PREFIX = "ab-score-direct-search-resolve:"; var sleep = (ms) => new Promise((resolve) => { window.setTimeout(resolve, ms); }); var normalizeQueryValue = (value) => value?.trim() ?? ""; var hasStructuredLocationParams = (url) => { return ["search_type", "location_bb", "acp_id"].some((key) => normalizeQueryValue(url.searchParams.get(key))); }; var getAttemptKey = (url) => { const copy = new URL(url.toString()); copy.searchParams.delete("cursor"); copy.searchParams.delete("pagination_search"); return `${ATTEMPT_PREFIX}${copy.pathname}?${copy.searchParams.toString()}`; }; var hasAttemptedResolution = (url) => { return window.sessionStorage.getItem(getAttemptKey(url)) === "1"; }; var markAttemptedResolution = (url) => { window.sessionStorage.setItem(getAttemptKey(url), "1"); }; var getFirstLocationOption = () => { const options = Array.from(document.querySelectorAll(LOCATION_OPTION_SELECTOR)); return options.find((option) => option.offsetParent !== null) ?? options[0] ?? null; }; var shouldResolveDirectSearchLocation = (url) => { if (!/^\/s\/.+/.test(url.pathname)) return false; if (!normalizeQueryValue(url.searchParams.get("query"))) return false; if (hasStructuredLocationParams(url)) return false; return true; }; var openLocationSearch = async () => { if (getFirstLocationOption()) return; await pollUntil(async () => { const existingInput = document.querySelector(LOCATION_INPUT_SELECTOR); if (existingInput) return existingInput; const locationButton = document.querySelector(LOCATION_BUTTON_SELECTOR); if (!locationButton) return null; simClick(locationButton); await sleep(200); return document.querySelector(LOCATION_INPUT_SELECTOR); }, 7000); await pollUntil(() => getFirstLocationOption(), 7000); }; var submitResolvedLocation = async () => { const option = await pollUntil(() => getFirstLocationOption(), 7000); simClick(option); await sleep(200); const searchButton = document.querySelector(SEARCH_BUTTON_SELECTOR) ?? await waitForEl(SEARCH_BUTTON_SELECTOR, 5000); simClick(searchButton); }; var maybeResolveDirectSearchLocation = async () => { const url = new URL(window.location.href); if (!shouldResolveDirectSearchLocation(url)) return false; if (hasAttemptedResolution(url)) return false; markAttemptedResolution(url); try { await openLocationSearch(); await submitResolvedLocation(); return true; } catch (error) { console.warn("[Airbnb Score] direct search resolution failed", error); return false; } }; // src/airbnb.com/airbnb-score.css var airbnb_score_default = `.ab-score-item { transition: opacity 120ms ease; } [data-testid='card-container']:hover .ab-score-item { opacity: 1 !important; } .ab-pill-row { position: absolute; bottom: 8px; left: 8px; z-index: 10; display: flex; gap: 5px; align-items: center; pointer-events: none; } .ab-score-pill { padding: 3px 8px; border-radius: 12px; font-size: 12px; font-weight: 700; color: #000; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35); } .ab-photo-idx { padding: 2px 6px; border-radius: 10px; font-size: 11px; font-weight: 600; background: rgba(0, 0, 0, 0.55); color: #fff; display: none; } #ab-sort-bar { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; padding: 8px 2px; margin-bottom: 10px; font-size: 12px; font-family: inherit; } #ab-sort-bar .ab-sort-label { font-weight: 600; color: #222; margin-right: 2px; } .ab-value-model-ctrl { display: inline-flex; align-items: center; gap: 6px; } .ab-value-model-select { padding: 4px 10px; border-radius: 20px; border: 1px solid #b0b0b0; background: #fff; color: #222; cursor: pointer; font-size: 12px; font-weight: 500; font-family: inherit; } .ab-value-model-select:hover { background: #f7f7f7; } #ab-sort-bar .ab-sort-btn { padding: 4px 10px; border-radius: 20px; border: 1px solid #b0b0b0; background: #fff; color: #222; cursor: pointer; font-size: 12px; font-weight: 500; transition: background 80ms, border-color 80ms; white-space: nowrap; } #ab-sort-bar .ab-sort-btn:hover { background: #f0f0f0; } #ab-sort-bar .ab-sort-btn.ab-active { background: #222; color: #fff; border-color: #222; } #ab-sort-bar .ab-divider { width: 1px; height: 18px; background: #ddd; margin: 0 2px; } #ab-load-all-btn { padding: 4px 12px; border-radius: 20px; border: 1px solid #e00; background: #fff; color: #c00; cursor: pointer; font-size: 12px; font-weight: 600; transition: background 80ms; white-space: nowrap; } #ab-load-all-btn:hover:not(:disabled) { background: #fff0f0; } #ab-load-all-btn:disabled { opacity: 0.6; cursor: default; } #ab-load-all-btn.ab-done { border-color: #060; color: #060; } #ab-agg-view-btn { padding: 4px 12px; border-radius: 20px; border: 1px solid #1f5ba8; background: #fff; color: #1f5ba8; cursor: pointer; font-size: 12px; font-weight: 600; transition: background 80ms; white-space: nowrap; } #ab-agg-view-btn:hover:not(:disabled) { background: #eef5ff; } #ab-agg-view-btn:disabled { opacity: 0.65; cursor: default; } body.ab-agg-open { overflow: hidden; } .ab-agg-overlay { position: fixed; inset: 0; z-index: 2147483646; background: #fff; display: flex; flex-direction: column; font-family: -apple-system, BlinkMacSystemFont, Helvetica, Arial, sans-serif; } .ab-agg-panel { width: 100%; height: 100%; display: flex; flex-direction: column; overflow: hidden; } .ab-agg-header { flex-shrink: 0; display: flex; align-items: center; gap: 12px; padding: 14px 24px; border-bottom: 1px solid #ebebeb; background: #fff; } .ab-agg-heading { font-size: 15px; font-weight: 600; color: #222; flex: 1; } .ab-agg-subheading { font-size: 13px; color: #717171; } .ab-agg-actions { display: flex; gap: 8px; align-items: center; } .ab-agg-tabs { display: flex; gap: 6px; align-items: center; } .ab-agg-tab { border: 1px solid #b0b0b0; background: #fff; color: #222; border-radius: 999px; padding: 6px 12px; font-size: 13px; font-weight: 600; cursor: pointer; } .ab-agg-tab:hover:not(:disabled) { background: #f7f7f7; } .ab-agg-tab.ab-active { background: #222; border-color: #222; color: #fff; } .ab-agg-tab:disabled { opacity: 0.5; cursor: default; } .ab-agg-refresh { border: 1px solid #b0b0b0; background: #fff; color: #222; border-radius: 8px; padding: 6px 12px; font-size: 13px; font-weight: 500; cursor: pointer; transition: background 80ms; } .ab-agg-refresh:hover { background: #f7f7f7; } .ab-agg-close { border: none; background: transparent; color: #222; border-radius: 50%; width: 34px; height: 34px; display: flex; align-items: center; justify-content: center; font-size: 20px; line-height: 1; cursor: pointer; transition: background 80ms; padding: 0; } .ab-agg-close:hover { background: #f0f0f0; } /* --- sort bar inside overlay --- */ .ab-agg-sort-bar { flex-shrink: 0; display: flex; align-items: center; gap: 6px; padding: 8px 24px; border-bottom: 1px solid #ebebeb; background: #fff; font-size: 12px; font-family: inherit; } .ab-agg-map-hint { color: #717171; margin-left: 4px; white-space: nowrap; } .ab-agg-map-zoom-ctrl { display: inline-flex; align-items: center; gap: 5px; color: #717171; white-space: nowrap; } .ab-agg-map-zoom-ctrl input { width: 52px; padding: 4px 6px; border: 1px solid #b0b0b0; border-radius: 8px; color: #222; font: inherit; } .ab-agg-cols-ctrl { display: flex; align-items: center; gap: 4px; margin-left: auto; } /* --- grid: flows naturally with auto-fill columns --- */ .ab-agg-grid { flex: 1; overflow-y: auto; padding: 24px; display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 32px 20px; align-content: start; } .ab-agg-sort-bar[hidden], .ab-agg-grid[hidden], .ab-agg-map-view[hidden] { display: none !important; } .ab-agg-map-view { flex: 1; min-height: 0; display: flex; flex-direction: column; background: #f7f7f7; } .ab-agg-map-toolbar { flex-shrink: 0; display: flex; align-items: center; gap: 8px; padding: 10px 24px; border-top: 1px solid #ebebeb; border-bottom: 1px solid #ebebeb; background: #fff; font-size: 12px; } .ab-agg-map-summary { color: #717171; margin-right: 4px; } .ab-agg-map-toolbar button, .ab-agg-map-toolbar select, .ab-agg-map-toolbar input { border: 1px solid #b0b0b0; border-radius: 8px; background: #fff; color: #222; font: inherit; height: 28px; } .ab-agg-map-toolbar button { padding: 0 10px; cursor: pointer; } .ab-agg-map-toolbar button:hover { background: #f7f7f7; } .ab-agg-map-toolbar input { width: 52px; padding: 0 6px; } .ab-agg-map-label-ctrl { display: inline-flex; align-items: center; gap: 5px; color: #717171; } .ab-agg-map-viewport { position: relative; flex: 1; min-height: 360px; overflow: hidden; background: #d9e4dd; cursor: grab; touch-action: none; user-select: none; } .ab-agg-map-viewport.is-panning { cursor: grabbing; } .ab-agg-map-tiles, .ab-agg-map-pins { position: absolute; inset: 0; } .ab-agg-map-tiles img { position: absolute; width: 256px; height: 256px; user-select: none; } .ab-agg-map-pin { position: absolute; z-index: 5; transform: translate(-50%, -100%); min-width: 42px; height: 28px; padding: 0 8px; border: 2px solid #fff; border-radius: 999px; color: #fff; font-size: 12px; font-weight: 800; line-height: 1; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); cursor: pointer; } .ab-agg-map-pin::after { content: ''; position: absolute; left: 50%; bottom: -7px; width: 10px; height: 10px; background: inherit; border-right: 2px solid #fff; border-bottom: 2px solid #fff; transform: translateX(-50%) rotate(45deg); } .ab-agg-map-pin:hover, .ab-agg-map-pin:focus { z-index: 8; outline: none; transform: translate(-50%, -100%) scale(1.08); } .ab-agg-map-preview { position: absolute; left: 18px; bottom: 18px; z-index: 20; width: min(360px, calc(100% - 36px)); padding: 12px; border-radius: 18px; background: rgba(255, 255, 255, 0.94); box-shadow: 0 8px 30px rgba(0, 0, 0, 0.25); opacity: 0; transform: translateY(8px); pointer-events: none; transition: opacity 120ms ease, transform 120ms ease; } .ab-agg-map-preview.is-open { opacity: 1; transform: translateY(0); pointer-events: auto; } .ab-agg-map-card-tile .ab-agg-photo { border-radius: 12px; } @media (max-width: 560px) { .ab-agg-grid { padding: 16px; gap: 20px 12px; } .ab-agg-header { padding: 12px 16px; } .ab-agg-sort-bar { padding: 8px 16px; } } /* --- card tile: no container styling, just photo + text (matches Airbnb) --- */ .ab-agg-tile { position: relative; cursor: pointer; background: transparent; display: block; text-decoration: none; color: inherit; } /* photo */ .ab-agg-photo { position: relative; width: 100%; aspect-ratio: 20 / 13; background: #e8e8e8; border-radius: 12px; overflow: hidden; } .ab-agg-strip { position: absolute; top: 0; left: 0; width: 100%; height: 100%; display: flex; overflow: hidden; scroll-behavior: auto; } .ab-agg-img { min-width: 100%; height: 100%; object-fit: cover; flex-shrink: 0; display: block; } .ab-agg-map { position: absolute; inset: 0; z-index: 12; opacity: 0; background: #d8d8d8; transition: opacity 120ms ease; pointer-events: none; } .ab-agg-map iframe { width: 100%; height: 100%; border: 0; display: block; filter: saturate(0.95) contrast(1.02); pointer-events: none; } body.ab-option-held .ab-agg-tile:hover .ab-agg-map { opacity: 1; } body.ab-option-held .ab-agg-tile:hover .ab-agg-strip { opacity: 0; } /* guest favorite pill */ .ab-agg-gf { position: absolute; top: 10px; left: 10px; z-index: 3; background: #fff; color: #222; border-radius: 20px; padding: 5px 10px; font-size: 11px; font-weight: 600; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15); } .ab-agg-free-cancel { position: absolute; bottom: 10px; left: 10px; z-index: 3; background: #fff; color: #222; border-radius: 20px; padding: 4px 9px; font-size: 11px; font-weight: 500; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15); } /* info section */ .ab-agg-info { padding: 8px 0 0; transition: opacity 120ms ease; } .ab-agg-tile:hover .ab-agg-info { opacity: 1 !important; } .ab-agg-top-row { display: flex; justify-content: space-between; align-items: baseline; gap: 6px; margin-bottom: 1px; } .ab-agg-name { font-size: 14px; font-weight: 400; color: #222; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; line-height: 1.4; } .ab-agg-rating { font-size: 13px; color: #222; font-weight: 400; white-space: nowrap; flex-shrink: 0; line-height: 1.4; } .ab-agg-subtitle { font-size: 13px; color: #717171; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; line-height: 1.4; margin-bottom: 2px; } .ab-agg-price-row { display: flex; align-items: baseline; gap: 3px; margin-top: 2px; } .ab-agg-price { font-size: 14px; font-weight: 600; color: #222; line-height: 1.4; } .ab-agg-nightly { font-size: 14px; color: #222; font-weight: 400; line-height: 1.4; } .ab-nightly-rate { margin-left: 4px; } .ab-nightly-rate-value { font-weight: 700; } /* loading spinner shown while an unloaded image is pending */ .ab-img-spinner { position: absolute; top: 50%; left: 50%; margin: -12px 0 0 -12px; width: 24px; height: 24px; border: 3px solid rgba(255, 255, 255, 0.45); border-top-color: #fff; border-radius: 50%; animation: ab-spin 0.65s linear infinite; z-index: 20; display: none; pointer-events: none; box-shadow: 0 0 6px rgba(0, 0, 0, 0.25); } @keyframes ab-spin { to { transform: rotate(360deg); } } `; // src/lib/scoring.ts var A = 20; var B = 5; var Z = 1.28; var clip = (x, lo, hi) => Math.max(lo, Math.min(hi, x)); var starsToP = (stars) => clip((stars - 3) / 2, 0, 1); var wilsonLB = (stars, reviewCount) => { if (!stars || !reviewCount) return 0; const p = starsToP(stars); const v = reviewCount; const n = v + A + B; const pHat = (p * v + A) / n; const z2 = Z * Z; const numerator = pHat + z2 / (2 * n) - Z * Math.sqrt((pHat * (1 - pHat) + z2 / (4 * n)) / n); const denominator = 1 + z2 / n; return numerator / denominator; }; var avgRanks = (values) => { const N = values.length; if (N <= 1) return [0]; const indexed = values.map((v, i) => ({ v, i })); indexed.sort((a, b) => a.v - b.v); const ranks = new Array(N); let pos = 0; while (pos < N) { let end = pos + 1; while (end < N && indexed[end].v === indexed[pos].v) end++; const avg = (pos + end - 1) / 2; for (let k = pos;k < end; k++) ranks[indexed[k].i] = avg; pos = end; } return ranks; }; var scoreToStars = (score) => 1 + 4 * score; var computeReviewFinalScore = (lb, rel) => 0.75 * lb + 0.25 * rel; var scoreLowerBoundEntries = (entries) => { if (!entries.length) return []; const ranks = avgRanks(entries.map((entry) => entry.lb)); const total = entries.length; return entries.map((entry, index) => { const rel = total <= 1 ? 0.5 : ranks[index] / (total - 1); const final = computeReviewFinalScore(entry.lb, rel); return { ...entry, rel, final, finalStars: scoreToStars(final) }; }); }; var getColorForScore = (score, maxChannel = 255) => { const max = Math.max(0, maxChannel); let r, g; if (score < 0.5) { r = max; g = Math.round(score * 2 * max); } else { r = Math.round((1 - (score - 0.5) * 2) * max); g = max; } return `rgb(${r}, ${g}, 0)`; }; var parseReviewCount = (str) => { if (!str) return 0; const trimmed = str.trim(); const multiplier = /m/i.test(trimmed) ? 1e6 : /k/i.test(trimmed) ? 1000 : 1; return Math.round(parseFloat(trimmed.replace(/[,kKmM]/g, "")) * multiplier); }; // src/lib/store.ts var LOCAL_STORAGE_ADAPTER = { name: "localStorage", readText: (key) => { return localStorage.getItem(key); }, writeText: (key, value) => { localStorage.setItem(key, value); } }; var getAdapters = (adapters) => { return adapters?.length ? adapters : [LOCAL_STORAGE_ADAPTER]; }; var reportError = (opts, error, ctx) => { opts?.onError?.(error, ctx); }; var readRawStore = (key, opts) => { for (const adapter of getAdapters(opts?.adapters)) { try { return adapter.readText(key); } catch (error) { reportError(opts, error, { action: "read", adapterName: adapter.name, key }); } } return null; }; var readTextStore = (key, fallback, opts) => { const value = readRawStore(key, opts); return value === null ? fallback : value; }; var writeTextStore = (key, value, opts) => { for (const adapter of getAdapters(opts?.adapters)) { try { adapter.writeText(key, value); return true; } catch (error) { reportError(opts, error, { action: "write", adapterName: adapter.name, key }); } } return false; }; var readJsonStore = (key, fallback, opts) => { const raw = readRawStore(key, opts); if (raw === null) return fallback; try { const parsed = JSON.parse(raw); return opts?.parse ? opts.parse(parsed) : parsed; } catch (error) { reportError(opts, error, { action: "parse", key }); return fallback; } }; var writeJsonStore = (key, value, opts) => { try { return writeTextStore(key, JSON.stringify(value), opts); } catch (error) { reportError(opts, error, { action: "stringify", key }); return false; } }; // src/lib/text.ts var normalizeWhitespace = (value) => (value ?? "").replace(/\s+/g, " ").trim(); // src/airbnb.com/airbnb-score.helpers.ts var SETTINGS_KEY = "ab-score-settings-v1"; var SORT_FIELDS = [ { id: "score", label: "Score", field: "abScore" }, { id: "value", label: "Value", field: "abValue" }, { id: "rating", label: "Rating", field: "abStars" }, { id: "count", label: "Reviews", field: "abCount" } ]; var VALUE_MODELS = [ { id: "nightly", label: "Nightly blend", shortLabel: "Nightly", description: "Blend score with the cheapest nightly rates in the result set" }, { id: "deal", label: "Score-relative price", shortLabel: "Deal", description: "Rank price after adjusting for quality, so strong listings can justify higher rates" } ]; var DEFAULT_VALUE_MODEL = "nightly"; var VALUE_FLOOR = 0.05; function parseValueModel(valueModel) { return VALUE_MODELS.some((model) => model.id === valueModel) ? valueModel : DEFAULT_VALUE_MODEL; } function loadSettings() { return readJsonStore(SETTINGS_KEY, { sortMode: "default", valueModel: DEFAULT_VALUE_MODEL }, { parse: (value) => { if (!value || typeof value !== "object") { return { sortMode: "default", valueModel: DEFAULT_VALUE_MODEL }; } const parsed = value; const sortMode = typeof parsed.sortMode === "string" ? parsed.sortMode : "default"; const valueModel = parseValueModel(parsed.valueModel); const valid = sortMode === "default" || Boolean(parseSortMode(sortMode)); return { sortMode: valid ? sortMode : "default", valueModel }; } }); } var settings = loadSettings(); function saveSettings() { writeJsonStore(SETTINGS_KEY, settings); } function parseSortMode(sortMode) { if (!sortMode || sortMode === "default") return null; const m = sortMode.match(/^([a-z]+)-(asc|desc)$/); if (!m) return null; const fieldDef = SORT_FIELDS.find((f) => f.id === m[1]); if (!fieldDef) return null; return { fieldId: fieldDef.id, field: fieldDef.field, dir: m[2] === "desc" ? 1 : -1, dirLabel: m[2] === "desc" ? "↓" : "↑" }; } function nextSortMode(currentSortMode, fieldId) { const parsed = parseSortMode(currentSortMode); if (!parsed || parsed.fieldId !== fieldId) return `${fieldId}-desc`; if (parsed.dir === 1) return `${fieldId}-asc`; return "default"; } function getValueModelDef(valueModel = settings.valueModel) { return VALUE_MODELS.find((model) => model.id === valueModel) ?? VALUE_MODELS[0]; } function getValueMetricBasis(score, nightly, valueModel) { if (!Number.isFinite(nightly) || nightly <= 0) return null; if (valueModel === "deal") return nightly / Math.max(score, VALUE_FLOOR); return nightly; } function computeValueMetric(score, nightly, valueModel) { return getValueMetricBasis(score, nightly, valueModel); } function computeValueScore(score, valueRel) { return 0.7 * score + 0.3 * valueRel; } function createValueModelControl(className, onChange) { const label = document.createElement("label"); label.className = className; label.title = getValueModelDef().description; const text = document.createElement("span"); text.className = "ab-sort-label"; text.textContent = "Value:"; label.appendChild(text); const select = document.createElement("select"); select.className = "ab-value-model-select"; VALUE_MODELS.forEach((model) => { const option = document.createElement("option"); option.value = model.id; option.textContent = model.shortLabel; option.title = model.description; select.appendChild(option); }); select.value = settings.valueModel; select.addEventListener("change", () => { settings.valueModel = parseValueModel(select.value); label.title = getValueModelDef().description; saveSettings(); onChange(settings.valueModel); }); label.appendChild(select); return label; } function getTextColorForScore(score) { return getColorForScore(score, 185); } function parsePriceNights(text) { if (!text) return null; const normalized = normalizeWhitespace(text); const m = normalized.match(/(.+?)\s+for\s+(\d+)\s+nights?/i); if (!m) return null; const nights = parseInt(m[2], 10); if (!Number.isFinite(nights) || nights <= 0) return null; const amounts = Array.from(m[1].matchAll(/\$\s*([\d,]+(?:\.\d{1,2})?)/g), (x) => parseFloat(x[1].replace(/,/g, ""))).filter((n) => Number.isFinite(n)); if (!amounts.length) return null; return { total: amounts[amounts.length - 1], nights }; } function formatUsd(value) { return Math.round(value).toLocaleString("en-US"); } function getInfoSection(card) { const inner = card.children[1]; if (!inner) return null; return inner.children[1] || null; } function getPhotoSection(card) { const inner = card.children[1]; if (!inner) return null; return inner.children[0] || null; } function extractRatingData(card) { const text = card.textContent || ""; const m = text.match(/([0-9]+(?:\.[0-9]+)?)\s+out of 5 average rating[,\s]+([0-9,]+)\s+review/i); if (m) return { stars: parseFloat(m[1]), reviewCount: parseReviewCount(m[2]) }; return { stars: 0, reviewCount: 0 }; } function getCardRow(card) { let el = card; for (let i = 0;i < 5; i++) el = el?.parentElement ?? null; return el || null; } function getNearestSectionHeading(card) { let headingText = ""; for (const heading of document.querySelectorAll("h1, h2, h3")) { const pos = heading.compareDocumentPosition(card); if (pos & Node.DOCUMENT_POSITION_FOLLOWING) { headingText = (heading.textContent || "").replace(/\s+/g, " ").trim().toLowerCase(); continue; } if (pos & Node.DOCUMENT_POSITION_PRECEDING) break; } return headingText; } function getCardSectionRank(card) { const headingText = getNearestSectionHeading(card); if (headingText.includes("available for similar dates")) return 2; if (headingText.includes("more stays available for your dates")) return 1; return 0; } function getHeadingTexts(root) { return Array.from(root.querySelectorAll("h1, h2, h3")).map((el) => (el.textContent || "").replace(/\s+/g, " ").trim().toLowerCase()).filter(Boolean); } var hasSimilarDatesHeading = (headingTexts) => { return headingTexts.some((text) => text.includes("available for similar dates")); }; var hasMoreStaysHeading = (headingTexts) => { return headingTexts.some((text) => text.includes("more stays available for your dates")); }; var hasUnrelatedHeading = (headingTexts) => { return headingTexts.some((text) => !text.includes("available for similar dates")); }; var chooseSimilarDatesSectionIndex = (ancestors) => { let candidate = null; for (let i = 0;i < ancestors.length; i++) { const ancestor = ancestors[i]; if (ancestor.isProtectedContainer || hasMoreStaysHeading(ancestor.headingTexts)) break; if (ancestor.cardCount > 0 && hasSimilarDatesHeading(ancestor.headingTexts)) { candidate = i; } const parent = ancestors[i + 1]; if (candidate !== null && (!parent || parent.isProtectedContainer || hasUnrelatedHeading(parent.headingTexts))) { break; } } return candidate; }; var isProtectedSearchContainer = (el) => { if (el === document.body) return true; if (el.tagName === "MAIN") return true; return ["react-application", "site-content", "layout_container"].includes(el.id); }; function findAvailableSimilarDatesSection() { const heading = Array.from(document.querySelectorAll("h1, h2, h3")).find((el) => /available for similar dates/i.test(el.textContent || "")); if (!heading) return null; const ancestors = []; let el = heading.parentElement; while (el && el !== document.body) { ancestors.push(el); el = el.parentElement; } const sectionIdx = chooseSimilarDatesSectionIndex(ancestors.map((ancestor) => ({ headingTexts: getHeadingTexts(ancestor), cardCount: ancestor.querySelectorAll('[data-testid="card-container"]').length, isProtectedContainer: isProtectedSearchContainer(ancestor) }))); return sectionIdx === null ? null : ancestors[sectionIdx]; } function removeAvailableSimilarDatesSection() { const section = findAvailableSimilarDatesSection(); if (section) section.remove(); } function colorRatingDisplay(card, starsRel, reviewRel) { if (!isFinite(starsRel) || !isFinite(reviewRel)) return; const existing = card.querySelector(".ab-rating-val"); if (existing) { existing.style.color = getTextColorForScore(starsRel); const cEl = card.querySelector(".ab-review-val"); if (cEl) cEl.style.color = getTextColorForScore(reviewRel); return; } const span = Array.from(card.querySelectorAll('span[aria-hidden="true"]')).find((el) => /^\d+\.\d+\s*\(\d[\d,]*\)$/.test(el.textContent?.trim() ?? "")); if (!span) return; const m = (span.textContent ?? "").trim().match(/^(\d+\.\d+)\s*(\(\d[\d,]*\))$/); if (!m) return; span.innerHTML = `${m[1]}` + ` ${m[2]}`; } var HOVER_PRELOAD_COUNT = 2; var THRESHOLD_PRELOAD_COUNT = 4; var HOVER_FETCH_DEBOUNCE_MS = 120; function pushImageDebug(entry) { const w = window; const next = [...w.__abImageLogs ?? [], entry]; w.__abImageLogs = next.slice(-200); } function getHoverDebugLabel(photoSection) { const link = photoSection.closest('a[href*="/rooms/"]'); if (link?.href) return link.href; const img = photoSection.querySelector("img"); return img?.getAttribute("src") || "unknown-photo"; } function recordImageDebug(event, details = {}) { pushImageDebug({ ts: new Date().toISOString(), event, details }); console.log("[Airbnb Score][images]", event, details); } function logHoverImageEvent(photoSection, event, details = {}) { recordImageDebug(event, { listing: getHoverDebugLabel(photoSection), ...details }); } function warmImage(img) { if (!img) return; img.loading = "eager"; img.decode().catch(() => {}); } function warmImageRange(imgs, startIdx, count) { for (let i = 0;i < count; i++) warmImage(imgs[startIdx + i]); } function attachHoverCycle(photoSection, onThreshold) { if (photoSection.dataset.abHoverAttached) return; photoSection.dataset.abHoverAttached = "1"; let strip = null; function getStrip() { if (strip && strip.scrollWidth > strip.clientWidth + 20) return strip; for (const el of photoSection.querySelectorAll("*")) { if (el.scrollWidth > el.clientWidth + 20 && el.children.length >= 2) { strip = el; return strip; } } return null; } const spinner = document.createElement("div"); spinner.className = "ab-img-spinner"; photoSection.appendChild(spinner); let halfwayFired = false; let hasLoggedHover = false; let hoverFetchStarted = false; let hoverFetchTimer = 0; let hoverRaf = 0; let lastLoadedIdx = 0; let pendingIdx = 0; let pendingClientX = 0; const ensureExtraPhotos = (targetStrip, eagerCount, reason) => { if (hoverFetchStarted || !onThreshold) return; hoverFetchStarted = true; logHoverImageEvent(photoSection, "extra-photos-requested", { reason, eagerCount, currentImages: targetStrip.querySelectorAll("img").length }); onThreshold().then((newUrls) => { const existingSrcs = new Set(Array.from(targetStrip.querySelectorAll("img")).map((i) => i.src)); const imgClass = targetStrip.querySelector("img")?.className ?? ""; const appended = []; newUrls.filter((u) => !existingSrcs.has(u)).forEach((u, idx) => { const img = document.createElement("img"); img.className = imgClass; img.src = u; img.loading = idx < eagerCount ? "eager" : "lazy"; img.alt = ""; targetStrip.appendChild(img); appended.push(img); }); warmImageRange(appended, 0, eagerCount); logHoverImageEvent(photoSection, "extra-photos-appended", { reason, added: appended.length, totalImages: targetStrip.querySelectorAll("img").length }); }); }; const scheduleHoverFetch = () => { if (!onThreshold || hoverFetchStarted || hoverFetchTimer) return; hoverFetchTimer = window.setTimeout(() => { hoverFetchTimer = 0; const s = getStrip(); if (!s) return; ensureExtraPhotos(s, HOVER_PRELOAD_COUNT, "debounce"); }, HOVER_FETCH_DEBOUNCE_MS); }; const processHover = () => { hoverRaf = 0; const s = getStrip(); if (!s) return; const rect = photoSection.getBoundingClientRect(); const xFrac = Math.max(0, Math.min(1, (pendingClientX - rect.left) / rect.width)); const totalImgs = Math.round(s.scrollWidth / s.clientWidth) || 1; const imgIdx = Math.min(Math.floor(xFrac * totalImgs), totalImgs - 1); pendingIdx = imgIdx; const imgs = Array.from(s.querySelectorAll("img")); const eagerWarmCount = halfwayFired ? THRESHOLD_PRELOAD_COUNT : HOVER_PRELOAD_COUNT; warmImageRange(imgs, imgIdx + 1, eagerWarmCount); const targetImg = imgs[imgIdx]; const isLoaded = !targetImg || targetImg.complete && targetImg.naturalWidth > 0; if (isLoaded) { s.scrollLeft = imgIdx * s.clientWidth; lastLoadedIdx = imgIdx; spinner.style.display = "none"; } else { s.scrollLeft = lastLoadedIdx * s.clientWidth; spinner.style.display = "block"; if (!targetImg.dataset.abLoadWatched) { targetImg.dataset.abLoadWatched = "1"; const capturedIdx = imgIdx; logHoverImageEvent(photoSection, "target-image-cold", { idx: capturedIdx, totalImgs }); targetImg.addEventListener("load", () => { if (pendingIdx === capturedIdx) { s.scrollLeft = capturedIdx * s.clientWidth; lastLoadedIdx = capturedIdx; spinner.style.display = "none"; } }, { once: true }); } } const idxEl = photoSection.querySelector(".ab-photo-idx"); if (idxEl) { idxEl.textContent = `${imgIdx + 1}/${totalImgs}`; idxEl.style.display = "block"; } if (!halfwayFired && onThreshold && imgIdx >= Math.ceil(totalImgs * 0.3)) { halfwayFired = true; ensureExtraPhotos(s, THRESHOLD_PRELOAD_COUNT, "threshold"); warmImageRange(imgs, imgIdx + 1, THRESHOLD_PRELOAD_COUNT); } }; photoSection.addEventListener("mouseenter", () => { const s = getStrip(); if (!s) return; const imgs = Array.from(s.querySelectorAll("img")); warmImageRange(imgs, 1, HOVER_PRELOAD_COUNT); if (!hasLoggedHover) { hasLoggedHover = true; logHoverImageEvent(photoSection, "hover-enter", { initialImages: imgs.length, preloaded: Math.min(HOVER_PRELOAD_COUNT, Math.max(imgs.length - 1, 0)) }); } scheduleHoverFetch(); }); photoSection.addEventListener("mousemove", (e) => { pendingClientX = e.clientX; scheduleHoverFetch(); if (hoverRaf) return; hoverRaf = window.requestAnimationFrame(processHover); }); photoSection.addEventListener("mouseleave", () => { if (hoverFetchTimer) { window.clearTimeout(hoverFetchTimer); hoverFetchTimer = 0; } if (hoverRaf) { window.cancelAnimationFrame(hoverRaf); hoverRaf = 0; } if (strip) strip.scrollLeft = 0; lastLoadedIdx = 0; spinner.style.display = "none"; const idxEl = photoSection.querySelector(".ab-photo-idx"); if (idxEl) idxEl.style.display = "none"; }); } function buildPillRow(rel, finalStars) { const pillRow = document.createElement("div"); pillRow.className = "ab-pill-row"; const scorePill = document.createElement("div"); scorePill.className = "ab-score-pill"; scorePill.style.background = getColorForScore(rel); scorePill.textContent = `${finalStars.toFixed(1)}★`; pillRow.appendChild(scorePill); const idxEl = document.createElement("div"); idxEl.className = "ab-photo-idx"; pillRow.appendChild(idxEl); return { pillRow, scorePill, idxEl }; } function upsertValuePill(pillRow, valueRel, valueScore) { let valuePill = pillRow.querySelector(".ab-value-pill"); if (!valuePill) { valuePill = document.createElement("div"); valuePill.className = "ab-score-pill ab-value-pill"; const idxEl = pillRow.querySelector(".ab-photo-idx"); if (idxEl) pillRow.insertBefore(valuePill, idxEl); else pillRow.appendChild(valuePill); } const model = getValueModelDef(); valuePill.style.background = getColorForScore(valueRel); valuePill.textContent = `${(1 + 4 * valueScore).toFixed(1)}$`; valuePill.title = `${model.label} value`; } function upsertNightlyRate(card) { delete card.dataset.abNightly; const existing = card.querySelectorAll(".ab-nightly-rate"); existing.forEach((el) => el.remove()); const candidates = Array.from(card.querySelectorAll("span, div")); const isVisible = (el) => { const cs = window.getComputedStyle(el); if (cs.display === "none" || cs.visibility === "hidden") return false; const rect = el.getBoundingClientRect(); return rect.width > 0 && rect.height > 0; }; const parseDollars = (txt) => Array.from(txt.matchAll(/\$\s*([\d,]+(?:\.\d{1,2})?)/g), (m) => parseFloat(m[1].replace(/,/g, ""))).filter((n) => Number.isFinite(n)); let target = null; let parsed = null; const nightOnlyEls = candidates.filter((el) => { if (!isVisible(el)) return false; const txt = (el.textContent || "").replace(/\s+/g, " ").trim(); return /^for\s+(\d+)\s+nights?$/i.test(txt); }); const nightAnyEls = candidates.filter((el) => { if (!isVisible(el)) return false; const txt = (el.textContent || "").replace(/\s+/g, " ").trim(); return /for\s+\d+\s+nights?/i.test(txt); }); for (const nightsEl of nightOnlyEls) { const m = (nightsEl.textContent || "").replace(/\s+/g, " ").trim().match(/^for\s+(\d+)\s+nights?$/i); if (!m) continue; const nights = parseInt(m[1], 10); if (!Number.isFinite(nights) || nights <= 0) continue; let scan = nightsEl; while (scan && scan !== card) { const txt = (scan.textContent || "").replace(/\s+/g, " ").trim(); const dollars = parseDollars(txt); if (dollars.length) { target = nightsEl; parsed = { total: dollars[dollars.length - 1], nights }; break; } scan = scan.parentElement; } if (target && parsed) break; } if (!target || !parsed) { const fallback = parsePriceNights((card.textContent || "").replace(/\s+/g, " ")); if (!fallback) return; target = nightOnlyEls[0] || nightAnyEls.slice().sort((a, b) => (a.textContent || "").replace(/\s+/g, " ").trim().length - (b.textContent || "").replace(/\s+/g, " ").trim().length)[0] || null; parsed = fallback; } if (!target) return; const priceContainer = target.parentElement; if (priceContainer) { Array.from(priceContainer.querySelectorAll("span, div")).forEach((el) => { if (el.classList?.contains("ab-nightly-rate")) return; const txt = (el.textContent || "").replace(/\s+/g, " ").trim(); if (!/\$\s*\d/.test(txt)) return; el.style.fontWeight = "400"; }); } const nightly = parsed.total / parsed.nights; card.dataset.abNightly = nightly.toFixed(6); const rateEl = document.createElement("span"); rateEl.className = "ab-nightly-rate"; rateEl.innerHTML = ` ($${formatUsd(nightly)}/n)`; target.insertAdjacentElement("afterend", rateEl); } // src/airbnb.com/airbnb-score-fetch.ts var getAggregationKey = () => { const url = new URL(location.href); url.searchParams.delete("cursor"); url.searchParams.delete("pagination_search"); return `${url.pathname}?${url.searchParams.toString()}`; }; var parseRatingLabel = (label) => { const m = (label || "").match(/([0-9.]+)\s+out of 5[^,]+,\s*([0-9,]+)/i); if (!m) return null; return { stars: parseFloat(m[1]), reviewCount: parseReviewCount(m[2]) }; }; var parseUsdAmount = (text) => { if (!text) return null; const m = text.match(/\$\s*([\d,]+(?:\.\d{1,2})?)/); if (!m) return null; const n = parseFloat(m[1].replace(/,/g, "")); return Number.isFinite(n) ? n : null; }; var listingPhotosCache = new Map; var listingRatingCache = new Map; function logPhotoFetch(event, details) { recordImageDebug(event, details); } var decodeDemandId = (b64) => { if (!b64) return ""; try { const decoded = atob(b64); return decoded.replace(/^[^:]+:/, ""); } catch (_e) { return ""; } }; var parseListingRatingDetails = (html) => { const overallMatch = html.match(/"overallCount":(\d+),"overallRating":(\d+(?:\.\d+)?|null)/); if (overallMatch) { const reviewCount = parseReviewCount(overallMatch[1]); const stars = overallMatch[2] === "null" ? NaN : Number.parseFloat(overallMatch[2]); if (reviewCount > 0 && Number.isFinite(stars) && stars > 0) return { stars, reviewCount }; } const hiddenMatch = html.match(/"guestSatisfactionOverall":(\d+(?:\.\d+)?).*?"visibleReviewCount":"?([\d,]+)"?/); if (hiddenMatch) { const stars = Number.parseFloat(hiddenMatch[1]); const reviewCount = parseReviewCount(hiddenMatch[2]); if (reviewCount > 0 && Number.isFinite(stars) && stars > 0) return { stars, reviewCount }; } const embedMatch = html.match(/"reviewCount":(\d+),"starRating":(\d+(?:\.\d+)?)/); if (embedMatch) { const reviewCount = parseReviewCount(embedMatch[1]); const stars = Number.parseFloat(embedMatch[2]); if (reviewCount > 0 && Number.isFinite(stars) && stars > 0) return { stars, reviewCount }; } return null; }; var fetchListingRatingDetails = async (url) => { const cached = listingRatingCache.get(url); if (cached) return cached; const pending = (async () => { try { const html = await fetch(url, { credentials: "include" }).then((r) => r.text()); return parseListingRatingDetails(html); } catch (_e) { return null; } })(); listingRatingCache.set(url, pending); return pending; }; var hydrateMissingRatings = async (listings) => { const missing = listings.filter((item) => !item.hasRating && item.url); const concurrency = 6; let next = 0; const hydrateOne = async (item) => { const rating = await fetchListingRatingDetails(item.url); if (!rating) return; item.hasRating = true; item.stars = rating.stars; item.reviewCount = rating.reviewCount; item.ratingLabel = `${rating.stars.toFixed(2)} (${rating.reviewCount})`; item.lb = wilsonLB(rating.stars, rating.reviewCount); }; await Promise.all(Array.from({ length: Math.min(concurrency, missing.length) }, async () => { while (next < missing.length) { const item = missing[next]; next += 1; await hydrateOne(item); } })); }; var collectExcludedListingIds = (doc) => { const excluded = new Set; const heading = Array.from(doc.querySelectorAll("h1, h2, h3")).find((el) => /available for similar dates/i.test(el.textContent || "")); if (!heading) return excluded; let section = heading.parentElement; let best = null; while (section && section !== doc.body) { const headingTexts = Array.from(section.querySelectorAll("h1, h2, h3")).map((el) => (el.textContent || "").replace(/\s+/g, " ").trim().toLowerCase()).filter(Boolean); const hasSimilar = headingTexts.some((text) => text.includes("available for similar dates")); const hasMoreStays = headingTexts.some((text) => text.includes("more stays available for your dates")); const cardCount = section.querySelectorAll('[data-testid="card-container"]').length; if (cardCount > 0 && hasSimilar && !hasMoreStays) best = section; if (hasMoreStays) break; section = section.parentElement; } if (!best) return excluded; Array.from(best.querySelectorAll('a[href*="/rooms/"]')).forEach((anchor) => { const match = anchor.href.match(/\/rooms\/(\d+)/); if (match) excluded.add(match[1]); }); return excluded; }; var mapAggregatedListing = (result, fallbackIdx) => { const rating = parseRatingLabel(result.avgRatingA11yLabel); const hasRating = rating !== null; const listingId = decodeDemandId(result.demandStayListing?.id); const url = listingId ? `https://www.airbnb.com/rooms/${listingId}` : ""; const key = listingId || `fallback-${fallbackIdx}`; const latRaw = result.demandStayListing?.location?.coordinate?.latitude; const lngRaw = result.demandStayListing?.location?.coordinate?.longitude; const lat = typeof latRaw === "number" && Number.isFinite(latRaw) ? latRaw : null; const lng = typeof lngRaw === "number" && Number.isFinite(lngRaw) ? lngRaw : null; const name = result.nameLocalized?.localizedStringWithTranslationPreference || result.subtitle || result.title || "Listing"; const typeLabel = result.title || ""; const allContentLines = [ ...result.structuredContent?.primaryLine || [], ...result.structuredContent?.secondaryLine || [] ]; const bedsLabel = allContentLines.filter((l) => l.type === "BEDINFO" || l.body?.toLowerCase().includes("bed")).map((l) => l.body).filter(Boolean).join(" · "); const guestFavorite = result.badges?.some((b) => b.loggingContext?.badgeType?.includes("GUEST_FAVORITE")) ?? false; const freeCancellation = result.badges?.some((b) => b.text?.toLowerCase().includes("free cancellation")) ?? false; const pictures = (result.contextualPictures || []).map((p) => p.picture).filter((p) => Boolean(p)); const priceAccLabel = result.structuredDisplayPrice?.primaryLine?.accessibilityLabel || ""; const priceLabel = result.structuredDisplayPrice?.primaryLine?.price || ""; const qualifier = result.structuredDisplayPrice?.primaryLine?.qualifier || ""; let nightly = null; const accMatch = priceAccLabel.match(/\$([\d,]+(?:\.\d{1,2})?)\s+for\s+(\d+)\s+nights?/i); if (accMatch) { const total = parseFloat(accMatch[1].replace(/,/g, "")); const nights = parseInt(accMatch[2], 10); if (Number.isFinite(total) && nights > 0) nightly = total / nights; } if (!nightly) { const totalUsd = parseUsdAmount(priceLabel); const nightsMatch = qualifier.match(/(\d+)\s+nights?/i); const nights = nightsMatch ? parseInt(nightsMatch[1], 10) : 0; if (totalUsd && nights > 0) nightly = totalUsd / nights; else if (totalUsd && qualifier.includes("night")) nightly = totalUsd; } if (!nightly) { console.warn("[Airbnb Score] could not compute nightly rate:", { priceAccLabel, priceLabel, qualifier }); } const nightlyLabel = nightly ? `$${formatUsd(nightly)}/n` : ""; const stars = rating?.stars ?? 0; const reviewCount = rating?.reviewCount ?? 0; const lb = hasRating ? wilsonLB(stars, reviewCount) : 0; const priceDisplayLabel = priceAccLabel || (priceLabel && qualifier ? `${priceLabel} ${qualifier}` : priceLabel); return { key, listingId, url, name, typeLabel, bedsLabel, guestFavorite, freeCancellation, pictures, priceLabel: priceDisplayLabel, nightlyLabel, nightly, lat, lng, hasRating, stars, reviewCount, ratingLabel: result.avgRatingLocalized || (hasRating ? `${stars} (${reviewCount})` : "No reviews"), lb, rel: 0, final: 0, finalStars: 0, starsRel: 0, reviewRel: 0, priceRel: 0.5, dealRel: 0.5, valueRel: 0.5, valueScore: 0 }; }; async function fetchListingPhotos(url) { const cached = listingPhotosCache.get(url); if (cached) { logPhotoFetch("listing-photos-cache-hit", { url }); return cached; } logPhotoFetch("listing-photos-fetch-start", { url }); const pending = (async () => { const startedAt = performance.now(); try { const html = await fetch(url, { credentials: "include" }).then((r) => r.text()); const doc = new DOMParser().parseFromString(html, "text/html"); const s = Array.from(doc.querySelectorAll("script:not([src])")).find((sc) => sc.textContent?.includes("niobeClientData")); if (!s) { logPhotoFetch("listing-photos-fetch-missing-data", { url, durationMs: Math.round(performance.now() - startedAt) }); return []; } const data = JSON.parse(s.textContent ?? ""); const secs = data?.niobeClientData?.[0]?.[1]?.data?.presentation?.stayProductDetailPage?.sections?.sections ?? []; const photoSec = secs.find((x) => x.sectionId === "PHOTO_TOUR_SCROLLABLE_MODAL"); const urls = (photoSec?.section?.mediaItems ?? []).map((i) => i.baseUrl).filter((u) => Boolean(u)); logPhotoFetch("listing-photos-fetch-done", { url, count: urls.length, durationMs: Math.round(performance.now() - startedAt) }); return urls; } catch (_e) { logPhotoFetch("listing-photos-fetch-error", { url, durationMs: Math.round(performance.now() - startedAt) }); return []; } })(); listingPhotosCache.set(url, pending); return pending; } async function fetchSearchPage(cursor) { const url = new URL(location.href); url.searchParams.delete("cursor"); url.searchParams.delete("pagination_search"); if (cursor) { url.searchParams.set("cursor", cursor); url.searchParams.set("pagination_search", "true"); } try { const html = await fetch(url.toString(), { credentials: "include" }).then((r) => r.text()); const doc = new DOMParser().parseFromString(html, "text/html"); const s = Array.from(doc.querySelectorAll("script:not([src])")).find((sc) => sc.textContent?.includes("niobeClientData")); if (!s) return { searchResults: [], pageCursors: null, nextCursor: null }; const data = JSON.parse(s.textContent ?? ""); const staysResults = data?.niobeClientData?.[0]?.[1]?.data?.presentation?.staysSearch?.results; const excludedListingIds = collectExcludedListingIds(doc); const searchResults = (staysResults?.searchResults || []).filter((result) => { const listingId = decodeDemandId(result.demandStayListing?.id); return !listingId || !excludedListingIds.has(listingId); }); const pageCursors = staysResults?.paginationInfo?.pageCursors || null; const nextLink = doc.querySelector('a[aria-label="Next"]'); let nextCursor = null; if (nextLink) { const href = nextLink.getAttribute("href"); if (href) { try { nextCursor = new URL(href, "https://www.airbnb.com").searchParams.get("cursor"); } catch (_e) {} } } return { searchResults, pageCursors, nextCursor }; } catch (e) { console.error("[Airbnb Score] fetchSearchPage error:", e); return { searchResults: [], pageCursors: null, nextCursor: null }; } } async function fetchPageRatings(cursor) { const { searchResults, pageCursors, nextCursor } = await fetchSearchPage(cursor); const ratings = searchResults.map((r) => parseRatingLabel(r.avgRatingA11yLabel)).filter((r) => Boolean(r)); return { ratings, pageCursors, nextCursor }; } async function fetchAggregatedListings(limit, onProgress) { const byKey = new Map; onProgress("p1"); const firstPage = await fetchSearchPage(null); firstPage.searchResults.forEach((result, i) => { if (byKey.size >= limit) return; const mapped = mapAggregatedListing(result, i); if (!mapped) return; if (!byKey.has(mapped.key)) byKey.set(mapped.key, mapped); }); let pagesFetched = 1; const cursors = firstPage.pageCursors?.slice(1) ?? []; const maxPages = 30; if (!cursors.length && firstPage.nextCursor) cursors.push(firstPage.nextCursor); for (let i = 0;i < cursors.length && byKey.size < limit && pagesFetched < maxPages; i++) { const pageNum = i + 2; onProgress(`p${pageNum}`); const page = await fetchSearchPage(cursors[i]); pagesFetched = pageNum; page.searchResults.forEach((result, idx) => { if (byKey.size >= limit) return; const mapped = mapAggregatedListing(result, pageNum * 1000 + idx); if (!mapped) return; if (!byKey.has(mapped.key)) byKey.set(mapped.key, mapped); }); } const listings = Array.from(byKey.values()); if (listings.some((item) => !item.hasRating && item.url)) { onProgress("details"); await hydrateMissingRatings(listings); } const ratedListings = listings.filter((d) => d.hasRating); const lbs = ratedListings.map((d) => d.lb); const ranks = avgRanks(lbs); const N = ratedListings.length; ratedListings.forEach((d, i) => { const rel = N <= 1 ? 0.5 : ranks[i] / (N - 1); const final = 0.75 * d.lb + 0.25 * rel; d.rel = rel; d.final = final; d.finalStars = 1 + 4 * final; }); const starsRanks = avgRanks(ratedListings.map((d) => d.stars)); const reviewRanks = avgRanks(ratedListings.map((d) => d.reviewCount)); ratedListings.forEach((d, i) => { d.starsRel = N <= 1 ? 0.5 : starsRanks[i] / (N - 1); d.reviewRel = N <= 1 ? 0.5 : reviewRanks[i] / (N - 1); }); const priced = listings.filter((d) => d.nightly !== null && d.nightly > 0); if (priced.length > 1) { const priceRanks = avgRanks(priced.map((d) => d.nightly)); priced.forEach((d, i) => { d.priceRel = 1 - priceRanks[i] / (priced.length - 1); }); } else { priced.forEach((d) => { d.priceRel = 0.5; }); } const dealPriced = listings.map((d) => ({ item: d, metric: !d.hasRating || d.nightly === null ? null : computeValueMetric(d.final, d.nightly, "deal") })).filter((d) => d.metric !== null); if (dealPriced.length > 1) { const dealRanks = avgRanks(dealPriced.map((d) => d.metric)); dealPriced.forEach((d, i) => { d.item.dealRel = 1 - dealRanks[i] / (dealPriced.length - 1); }); } else { dealPriced.forEach((d) => { d.item.dealRel = 0.5; }); } applyAggregatedValueModel(listings); listings.sort((a, b) => { if (b.final !== a.final) return b.final - a.final; if (b.reviewCount !== a.reviewCount) return b.reviewCount - a.reviewCount; return b.stars - a.stars; }); return { listings, pagesFetched }; } function applyAggregatedValueModel(listings) { listings.forEach((d) => { d.valueRel = settings.valueModel === "deal" ? d.dealRel : d.priceRel; d.valueScore = d.hasRating ? computeValueScore(d.final, d.valueRel) : 0; }); } // src/airbnb.com/airbnb-score-overlay.ts var AGG_LIMIT = 200; var AGG_OVERLAY_ID = "ab-agg-overlay"; var OPTION_HELD_CLASS = "ab-option-held"; var MAP_ZOOM_KEY = "ab-agg-map-zoom-v1"; var MAP_ZOOM_DEFAULT = 15; var MAP_ZOOM_MIN = 3; var MAP_ZOOM_MAX = 20; var MAP_TILE_SIZE = 256; var aggregatedCache = null; var aggregatedCacheKey = ""; var optionPreviewListenersInstalled = false; var activeAggMapRender = null; function invalidateAggCache() { if (aggregatedCache === null) return; const newKey = getAggregationKey(); if (newKey !== aggregatedCacheKey) { aggregatedCache = null; aggregatedCacheKey = ""; } } function closeAggregatedOverlay() { const overlay = document.getElementById(AGG_OVERLAY_ID); if (overlay) overlay.remove(); activeAggMapRender = null; document.body.classList.remove("ab-agg-open", OPTION_HELD_CLASS); } var setOptionPreviewHeld = (held) => { document.body.classList.toggle(OPTION_HELD_CLASS, held); if (held) { document.querySelectorAll(".ab-agg-tile:hover").forEach(loadAggTileMap); } }; var installOptionPreviewListeners = () => { if (optionPreviewListenersInstalled) return; optionPreviewListenersInstalled = true; window.addEventListener("keydown", (event) => { if (event.altKey) setOptionPreviewHeld(true); }); window.addEventListener("keyup", (event) => { if (event.key === "Alt") setOptionPreviewHeld(false); }); window.addEventListener("blur", () => setOptionPreviewHeld(false)); }; var clampMapZoom = (zoom) => { if (!Number.isFinite(zoom)) return MAP_ZOOM_DEFAULT; return Math.min(Math.max(Math.round(zoom), MAP_ZOOM_MIN), MAP_ZOOM_MAX); }; var parseMapZoom = (value) => { return clampMapZoom(Number.parseInt(value, 10)); }; var aggMapZoom = parseMapZoom(readTextStore(MAP_ZOOM_KEY, String(MAP_ZOOM_DEFAULT))); var updateAggMapZoomInputs = () => { document.querySelectorAll(".ab-agg-map-zoom-input").forEach((input) => { input.value = String(aggMapZoom); }); }; var getAggMapEmbedUrl = (lat, lng, zoom = aggMapZoom) => { return `https://www.google.com/maps?q=${encodeURIComponent(`${lat},${lng}`)}&z=${zoom}&output=embed`; }; var getAggListingMapEmbedUrl = (item) => { if (item.lat === null || item.lng === null) return null; return getAggMapEmbedUrl(item.lat, item.lng); }; var saveAggMapZoom = (zoom) => { aggMapZoom = clampMapZoom(zoom); writeTextStore(MAP_ZOOM_KEY, String(aggMapZoom)); updateAggMapZoomInputs(); document.querySelectorAll(".ab-agg-map iframe").forEach((iframe) => { const lat = Number.parseFloat(iframe.dataset.lat ?? ""); const lng = Number.parseFloat(iframe.dataset.lng ?? ""); if (!Number.isFinite(lat) || !Number.isFinite(lng)) return; const src = getAggMapEmbedUrl(lat, lng); iframe.dataset.src = src; if (iframe.getAttribute("src")) iframe.src = src; }); activeAggMapRender?.(); }; var loadAggTileMap = (tile) => { const iframe = tile.querySelector(".ab-agg-map iframe"); const src = iframe?.dataset.src; if (!iframe || !src || iframe.src === src) return; iframe.src = src; }; function getAggSortValue(item, fieldId) { if (!item.hasRating && fieldId !== "price") return 0; switch (fieldId) { case "score": return item.final; case "value": return item.valueScore; case "rating": return item.stars; case "count": return item.reviewCount; default: return 0; } } function buildOverlaySortBar(listings, grid) { const bar = document.createElement("div"); bar.className = "ab-agg-sort-bar"; bar.dataset.sortMode = "score-desc"; const label = document.createElement("span"); label.className = "ab-sort-label"; label.textContent = "Sort:"; bar.appendChild(label); const getSortMode = () => bar.dataset.sortMode || "score-desc"; const renderGrid = () => { applyAggregatedValueModel(listings); const parsed = parseSortMode(getSortMode()); grid.innerHTML = ""; const sorted = listings.slice().sort((a, b) => { if (!parsed) return b.final - a.final; const av = getAggSortValue(a, parsed.fieldId); const bv = getAggSortValue(b, parsed.fieldId); return parsed.dir * (bv - av); }); sorted.forEach((item) => grid.appendChild(buildAggTile(item))); }; const updateButtons = () => { const parsed = parseSortMode(getSortMode()); bar.querySelectorAll(".ab-sort-btn").forEach((btn) => { const fld = SORT_FIELDS.find((f) => f.id === btn.dataset.sortField); if (!fld) return; const isActive = parsed !== null && parsed.fieldId === btn.dataset.sortField; btn.classList.toggle("ab-active", isActive); btn.textContent = isActive ? `${fld.label} ${parsed.dirLabel}` : fld.label; }); }; SORT_FIELDS.forEach((mode) => { const btn = document.createElement("button"); btn.className = "ab-sort-btn"; btn.dataset.sortField = mode.id; btn.addEventListener("click", () => { bar.dataset.sortMode = nextSortMode(getSortMode(), mode.id); updateButtons(); renderGrid(); }); bar.appendChild(btn); }); updateButtons(); const valueControl = createValueModelControl("ab-value-model-ctrl", () => { renderGrid(); }); bar.appendChild(valueControl); const mapHint = document.createElement("span"); mapHint.className = "ab-agg-map-hint"; mapHint.textContent = "Option-hover photos for map"; bar.appendChild(mapHint); const zoomGroup = document.createElement("label"); zoomGroup.className = "ab-agg-map-zoom-ctrl"; zoomGroup.textContent = "Map zoom:"; const zoomInput = document.createElement("input"); zoomInput.className = "ab-agg-map-zoom-input"; zoomInput.type = "number"; zoomInput.min = String(MAP_ZOOM_MIN); zoomInput.max = String(MAP_ZOOM_MAX); zoomInput.step = "1"; zoomInput.value = String(aggMapZoom); zoomInput.title = "Google Maps zoom level for Option-hover previews"; zoomInput.addEventListener("change", () => { const nextZoom = parseMapZoom(zoomInput.value); zoomInput.value = String(nextZoom); saveAggMapZoom(nextZoom); }); zoomGroup.appendChild(zoomInput); bar.appendChild(zoomGroup); const COL_OPTIONS = [0, 1, 2, 3, 4, 5, 6]; let activeCols = 0; const colsGroup = document.createElement("div"); colsGroup.className = "ab-agg-cols-ctrl"; const colsLabel = document.createElement("span"); colsLabel.className = "ab-sort-label"; colsLabel.textContent = "Cols:"; colsGroup.appendChild(colsLabel); const applyColLayout = () => { grid.style.gridTemplateColumns = activeCols > 0 ? `repeat(${activeCols}, minmax(0, 1fr))` : ""; }; const updateColButtons = () => { colsGroup.querySelectorAll("[data-cols]").forEach((btn) => { btn.classList.toggle("ab-active", parseInt(btn.dataset.cols ?? "0", 10) === activeCols); }); }; COL_OPTIONS.forEach((n) => { const btn = document.createElement("button"); btn.className = "ab-sort-btn"; btn.dataset.cols = String(n); btn.textContent = n === 0 ? "auto" : String(n); btn.addEventListener("click", () => { activeCols = n; updateColButtons(); applyColLayout(); }); colsGroup.appendChild(btn); }); updateColButtons(); const onResize = () => { if (!document.getElementById(AGG_OVERLAY_ID)) { window.removeEventListener("resize", onResize); return; } activeCols = 0; updateColButtons(); applyColLayout(); }; window.addEventListener("resize", onResize); bar.appendChild(colsGroup); renderGrid(); return bar; } var getMappableListings = (listings) => { return listings.filter((item) => item.lat !== null && item.lng !== null); }; var projectMapPoint = (lat, lng, zoom) => { const scale = MAP_TILE_SIZE * 2 ** zoom; const latRad = lat * Math.PI / 180; return { x: (lng + 180) / 360 * scale, y: (1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * scale }; }; var unprojectMapPoint = (x, y, zoom) => { const scale = MAP_TILE_SIZE * 2 ** zoom; const lng = x / scale * 360 - 180; const n = Math.PI - 2 * Math.PI * y / scale; const lat = 180 / Math.PI * Math.atan(0.5 * (Math.exp(n) - Math.exp(-n))); return { lat, lng }; }; var getMapCenter = (items) => { if (!items.length) return { lat: 0, lng: 0 }; const sum = items.reduce((acc, item) => ({ lat: acc.lat + item.lat, lng: acc.lng + item.lng }), { lat: 0, lng: 0 }); return { lat: sum.lat / items.length, lng: sum.lng / items.length }; }; var getMapFitState = (items, width, height) => { const center = getMapCenter(items); if (!items.length || width <= 0 || height <= 0) return { ...center, zoom: aggMapZoom }; const padding = 96; let fitZoom = MAP_ZOOM_MIN; for (let zoom = MAP_ZOOM_MAX;zoom >= MAP_ZOOM_MIN; zoom -= 1) { const points = items.map((item) => projectMapPoint(item.lat, item.lng, zoom)); const xs = points.map((point) => point.x); const ys = points.map((point) => point.y); const fits = Math.max(...xs) - Math.min(...xs) <= Math.max(80, width - padding) && Math.max(...ys) - Math.min(...ys) <= Math.max(80, height - padding); if (fits) { fitZoom = zoom; break; } } const fitPoints = items.map((item) => projectMapPoint(item.lat, item.lng, fitZoom)); const fitXs = fitPoints.map((point) => point.x); const fitYs = fitPoints.map((point) => point.y); const fitCenter = unprojectMapPoint((Math.min(...fitXs) + Math.max(...fitXs)) / 2, (Math.min(...fitYs) + Math.max(...fitYs)) / 2, fitZoom); return { ...fitCenter, zoom: fitZoom }; }; var getMapPinLabel = (item, labelMode) => { if (!item.hasRating && labelMode !== "price") return "0"; if (labelMode === "value") return (1 + 4 * item.valueScore).toFixed(1); if (labelMode === "price") return item.nightly ? `$${formatUsd(item.nightly)}` : "$-"; return item.finalStars.toFixed(1); }; var buildAggMapView = (listings) => { const items = getMappableListings(listings); const view = document.createElement("div"); view.className = "ab-agg-map-view"; const toolbar = document.createElement("div"); toolbar.className = "ab-agg-map-toolbar"; const summary = document.createElement("span"); summary.className = "ab-agg-map-summary"; summary.textContent = `${items.length} mapped listings`; toolbar.appendChild(summary); const fitBtn = document.createElement("button"); fitBtn.type = "button"; fitBtn.textContent = "Fit"; toolbar.appendChild(fitBtn); const zoomOutBtn = document.createElement("button"); zoomOutBtn.type = "button"; zoomOutBtn.textContent = "-"; toolbar.appendChild(zoomOutBtn); const zoomInput = document.createElement("input"); zoomInput.className = "ab-agg-map-zoom-input"; zoomInput.type = "number"; zoomInput.min = String(MAP_ZOOM_MIN); zoomInput.max = String(MAP_ZOOM_MAX); zoomInput.step = "1"; zoomInput.value = String(aggMapZoom); toolbar.appendChild(zoomInput); const zoomInBtn = document.createElement("button"); zoomInBtn.type = "button"; zoomInBtn.textContent = "+"; toolbar.appendChild(zoomInBtn); const labelCtrl = document.createElement("label"); labelCtrl.className = "ab-agg-map-label-ctrl"; labelCtrl.textContent = "Pins:"; const labelSelect = document.createElement("select"); const labelOptions = [ { id: "score", label: "score" }, { id: "value", label: "value" }, { id: "price", label: "price" } ]; labelOptions.forEach((option) => { const opt = document.createElement("option"); opt.value = option.id; opt.textContent = option.label; labelSelect.appendChild(opt); }); labelCtrl.appendChild(labelSelect); toolbar.appendChild(labelCtrl); const viewport = document.createElement("div"); viewport.className = "ab-agg-map-viewport"; const tilePane = document.createElement("div"); tilePane.className = "ab-agg-map-tiles"; viewport.appendChild(tilePane); const pinPane = document.createElement("div"); pinPane.className = "ab-agg-map-pins"; viewport.appendChild(pinPane); const preview = document.createElement("div"); preview.className = "ab-agg-map-preview"; viewport.appendChild(preview); view.appendChild(toolbar); view.appendChild(viewport); let labelMode = "score"; let state = { ...getMapCenter(items), zoom: aggMapZoom }; let fitted = false; const showPreview = (item) => { preview.innerHTML = ""; const tile = buildAggTile(item); tile.classList.add("ab-agg-map-card-tile"); preview.appendChild(tile); preview.classList.add("is-open"); }; const renderMap = () => { state.zoom = aggMapZoom; zoomInput.value = String(state.zoom); tilePane.innerHTML = ""; pinPane.innerHTML = ""; const width = viewport.clientWidth; const height = viewport.clientHeight; if (!width || !height || !items.length) return; if (!fitted) { state = getMapFitState(items, width, height); fitted = true; saveAggMapZoom(state.zoom); return; } const center = projectMapPoint(state.lat, state.lng, state.zoom); const topLeft = { x: center.x - width / 2, y: center.y - height / 2 }; const scaleTiles = 2 ** state.zoom; const minTileX = Math.floor(topLeft.x / MAP_TILE_SIZE); const maxTileX = Math.floor((topLeft.x + width) / MAP_TILE_SIZE); const minTileY = Math.floor(topLeft.y / MAP_TILE_SIZE); const maxTileY = Math.floor((topLeft.y + height) / MAP_TILE_SIZE); for (let tileX = minTileX;tileX <= maxTileX; tileX += 1) { for (let tileY = minTileY;tileY <= maxTileY; tileY += 1) { if (tileY < 0 || tileY >= scaleTiles) continue; const wrappedX = (tileX % scaleTiles + scaleTiles) % scaleTiles; const img = document.createElement("img"); img.alt = ""; img.draggable = false; img.src = `https://tile.openstreetmap.org/${state.zoom}/${wrappedX}/${tileY}.png`; img.style.left = `${tileX * MAP_TILE_SIZE - topLeft.x}px`; img.style.top = `${tileY * MAP_TILE_SIZE - topLeft.y}px`; tilePane.appendChild(img); } } items.forEach((item) => { const point = projectMapPoint(item.lat, item.lng, state.zoom); const x = point.x - topLeft.x; const y = point.y - topLeft.y; if (x < -40 || y < -40 || x > width + 40 || y > height + 40) return; const pin = document.createElement("button"); pin.type = "button"; pin.className = "ab-agg-map-pin"; pin.textContent = getMapPinLabel(item, labelMode); pin.title = item.name; pin.style.left = `${x}px`; pin.style.top = `${y}px`; pin.style.background = item.hasRating ? `hsl(${Math.round(120 * item.rel)}, 82%, 38%)` : "#717171"; pin.addEventListener("mouseenter", () => showPreview(item)); pin.addEventListener("focus", () => showPreview(item)); pin.addEventListener("click", () => { if (item.url) window.open(item.url, "_blank", "noopener,noreferrer"); }); pinPane.appendChild(pin); }); }; const zoomAt = (clientX, clientY, nextZoom) => { const rect = viewport.getBoundingClientRect(); const width = viewport.clientWidth; const height = viewport.clientHeight; const offsetX = clientX - rect.left; const offsetY = clientY - rect.top; const centerBefore = projectMapPoint(state.lat, state.lng, state.zoom); const topLeftBefore = { x: centerBefore.x - width / 2, y: centerBefore.y - height / 2 }; const focusBefore = unprojectMapPoint(topLeftBefore.x + offsetX, topLeftBefore.y + offsetY, state.zoom); const clampedZoom = clampMapZoom(nextZoom); const focusAfter = projectMapPoint(focusBefore.lat, focusBefore.lng, clampedZoom); const centerAfter = unprojectMapPoint(focusAfter.x - offsetX + width / 2, focusAfter.y - offsetY + height / 2, clampedZoom); state = { ...centerAfter, zoom: clampedZoom }; saveAggMapZoom(clampedZoom); }; const fitMap = () => { state = getMapFitState(items, viewport.clientWidth, viewport.clientHeight); saveAggMapZoom(state.zoom); }; fitBtn.addEventListener("click", fitMap); zoomOutBtn.addEventListener("click", () => saveAggMapZoom(aggMapZoom - 1)); zoomInBtn.addEventListener("click", () => saveAggMapZoom(aggMapZoom + 1)); zoomInput.addEventListener("change", () => saveAggMapZoom(parseMapZoom(zoomInput.value))); labelSelect.addEventListener("change", () => { labelMode = labelSelect.value; renderMap(); }); viewport.addEventListener("mouseleave", () => preview.classList.remove("is-open")); viewport.addEventListener("dblclick", (event) => { if (event.target.closest("button, a, input, select")) return; event.preventDefault(); zoomAt(event.clientX, event.clientY, aggMapZoom + (event.shiftKey ? -1 : 1)); }); let wheelZoomRemainder = 0; let wheelZoomReset = null; viewport.addEventListener("wheel", (event) => { event.preventDefault(); if (wheelZoomReset !== null) window.clearTimeout(wheelZoomReset); wheelZoomRemainder += event.deltaY; const stepSize = 700; if (Math.abs(wheelZoomRemainder) >= stepSize) { saveAggMapZoom(aggMapZoom + (wheelZoomRemainder < 0 ? 1 : -1)); wheelZoomRemainder = 0; } wheelZoomReset = window.setTimeout(() => { wheelZoomRemainder = 0; wheelZoomReset = null; }, 180); }, { passive: false }); viewport.addEventListener("pointerdown", (event) => { if (event.target.closest("button, a, input, select")) return; event.preventDefault(); viewport.setPointerCapture(event.pointerId); viewport.classList.add("is-panning"); const start = { x: event.clientX, y: event.clientY }; const startCenter = projectMapPoint(state.lat, state.lng, state.zoom); const onMove = (moveEvent) => { const next = unprojectMapPoint(startCenter.x - (moveEvent.clientX - start.x), startCenter.y - (moveEvent.clientY - start.y), state.zoom); state = { ...next, zoom: state.zoom }; renderMap(); }; const onDone = () => { viewport.classList.remove("is-panning"); viewport.removeEventListener("pointermove", onMove); viewport.removeEventListener("pointerup", onDone); viewport.removeEventListener("pointercancel", onDone); }; viewport.addEventListener("pointermove", onMove); viewport.addEventListener("pointerup", onDone); viewport.addEventListener("pointercancel", onDone); }); activeAggMapRender = renderMap; window.setTimeout(renderMap, 0); return view; }; function buildAggTile(item) { const tile = document.createElement("a"); tile.className = "ab-agg-tile"; if (item.url) { tile.href = item.url; tile.target = "_blank"; tile.rel = "noopener noreferrer"; } const photo = document.createElement("div"); photo.className = "ab-agg-photo"; const strip = document.createElement("div"); strip.className = "ab-agg-strip"; item.pictures.forEach((src, i) => { const img = document.createElement("img"); img.className = "ab-agg-img"; img.src = src; img.loading = i === 0 ? "eager" : "lazy"; img.alt = ""; strip.appendChild(img); }); photo.appendChild(strip); const mapEmbedUrl = getAggListingMapEmbedUrl(item); if (mapEmbedUrl) { const map = document.createElement("div"); map.className = "ab-agg-map"; const iframe = document.createElement("iframe"); iframe.dataset.src = mapEmbedUrl; iframe.dataset.lat = String(item.lat); iframe.dataset.lng = String(item.lng); iframe.loading = "lazy"; iframe.referrerPolicy = "no-referrer-when-downgrade"; iframe.title = `Map for ${item.name}`; map.appendChild(iframe); photo.appendChild(map); tile.addEventListener("mouseenter", (event) => { if (event.altKey) { setOptionPreviewHeld(true); loadAggTileMap(tile); } }); tile.addEventListener("mousemove", (event) => { if (event.altKey) loadAggTileMap(tile); }); } if (item.guestFavorite) { const gf = document.createElement("div"); gf.className = "ab-agg-gf"; gf.textContent = "Guest favorite"; photo.appendChild(gf); } if (item.freeCancellation) { const fc = document.createElement("div"); fc.className = "ab-agg-free-cancel"; fc.textContent = "Free cancellation"; photo.appendChild(fc); } const { pillRow, scorePill } = buildPillRow(item.rel, item.finalStars); if (item.hasRating) { upsertValuePill(pillRow, item.valueRel, item.valueScore); } else { scorePill.style.background = "#717171"; scorePill.style.color = "#fff"; scorePill.textContent = "0 rev"; scorePill.title = "No reviews yet"; } photo.appendChild(pillRow); attachHoverCycle(photo, item.url ? () => fetchListingPhotos(item.url) : undefined); tile.appendChild(photo); const info = document.createElement("div"); info.className = "ab-agg-info"; info.style.opacity = (0.35 + 0.65 * item.rel).toFixed(2); const topRow = document.createElement("div"); topRow.className = "ab-agg-top-row"; const nameEl = document.createElement("span"); nameEl.className = "ab-agg-name"; nameEl.textContent = item.name; topRow.appendChild(nameEl); if (item.stars > 0) { const ratingEl = document.createElement("span"); ratingEl.className = "ab-agg-rating"; const starsSpan = document.createElement("span"); starsSpan.style.color = getTextColorForScore(item.starsRel); starsSpan.style.fontWeight = "700"; starsSpan.textContent = `★ ${item.stars.toFixed(2)}`; ratingEl.appendChild(starsSpan); if (item.reviewCount > 0) { const countSpan = document.createElement("span"); countSpan.style.color = getTextColorForScore(item.reviewRel); countSpan.textContent = ` (${item.reviewCount.toLocaleString()})`; ratingEl.appendChild(countSpan); } topRow.appendChild(ratingEl); } info.appendChild(topRow); const subtitle = [item.typeLabel, item.bedsLabel].filter(Boolean).join(" · "); if (subtitle) { const subtitleEl = document.createElement("div"); subtitleEl.className = "ab-agg-subtitle"; subtitleEl.textContent = subtitle; info.appendChild(subtitleEl); } const priceRow = document.createElement("div"); priceRow.className = "ab-agg-price-row"; if (item.nightly) { const priceEl = document.createElement("span"); priceEl.className = "ab-agg-price"; priceEl.textContent = `$${formatUsd(item.nightly)}`; priceRow.appendChild(priceEl); const nightEl = document.createElement("span"); nightEl.className = "ab-agg-nightly"; nightEl.textContent = " night"; priceRow.appendChild(nightEl); } info.appendChild(priceRow); tile.appendChild(info); return tile; } function renderAggregatedOverlay(result, onRefresh) { closeAggregatedOverlay(); installOptionPreviewListeners(); const overlay = document.createElement("div"); overlay.id = AGG_OVERLAY_ID; overlay.className = "ab-agg-overlay"; const panel = document.createElement("div"); panel.className = "ab-agg-panel"; const header = document.createElement("div"); header.className = "ab-agg-header"; const heading = document.createElement("div"); heading.className = "ab-agg-heading"; heading.textContent = `Top ${result.listings.length} listings`; header.appendChild(heading); const sub = document.createElement("div"); sub.className = "ab-agg-subheading"; sub.textContent = `${result.pagesFetched} pages · ranked by score`; header.appendChild(sub); const actions = document.createElement("div"); actions.className = "ab-agg-actions"; const tabs = document.createElement("div"); tabs.className = "ab-agg-tabs"; const cardsTab = document.createElement("button"); cardsTab.type = "button"; cardsTab.className = "ab-agg-tab ab-active"; cardsTab.textContent = "Cards"; tabs.appendChild(cardsTab); const mappedCount = getMappableListings(result.listings).length; const mapTab = document.createElement("button"); mapTab.type = "button"; mapTab.className = "ab-agg-tab"; mapTab.textContent = `Map (${mappedCount})`; mapTab.disabled = mappedCount === 0; tabs.appendChild(mapTab); actions.appendChild(tabs); const refreshBtn = document.createElement("button"); refreshBtn.className = "ab-agg-refresh"; refreshBtn.textContent = "Refresh"; refreshBtn.title = "Clear cache and reload all pages"; refreshBtn.addEventListener("click", () => { aggregatedCache = null; aggregatedCacheKey = ""; closeAggregatedOverlay(); onRefresh(); }); actions.appendChild(refreshBtn); const closeBtn = document.createElement("button"); closeBtn.className = "ab-agg-close"; closeBtn.textContent = "×"; closeBtn.title = "Close"; closeBtn.addEventListener("click", closeAggregatedOverlay); actions.appendChild(closeBtn); header.appendChild(actions); panel.appendChild(header); const grid = document.createElement("div"); grid.className = "ab-agg-grid"; const mapView = buildAggMapView(result.listings); mapView.hidden = true; const sortBar = buildOverlaySortBar(result.listings, grid); panel.appendChild(sortBar); panel.appendChild(grid); panel.appendChild(mapView); const setActiveTab = (tab) => { const isMap = tab === "map"; cardsTab.classList.toggle("ab-active", !isMap); mapTab.classList.toggle("ab-active", isMap); sortBar.hidden = isMap; grid.hidden = isMap; mapView.hidden = !isMap; panel.classList.toggle("ab-agg-map-active", isMap); if (isMap) window.setTimeout(() => activeAggMapRender?.(), 0); }; cardsTab.addEventListener("click", () => setActiveTab("cards")); mapTab.addEventListener("click", () => setActiveTab("map")); overlay.appendChild(panel); overlay.addEventListener("click", (e) => { if (e.target === overlay) closeAggregatedOverlay(); }); document.body.appendChild(overlay); document.body.classList.add("ab-agg-open"); } async function openAggregatedView(btn) { const currentKey = getAggregationKey(); if (aggregatedCache && aggregatedCacheKey === currentKey) { renderAggregatedOverlay(aggregatedCache, () => openAggregatedView(btn)); return; } btn.disabled = true; btn.textContent = "Loading view…"; try { const result = await fetchAggregatedListings(AGG_LIMIT, (label) => { btn.textContent = `Loading view ${label}…`; }); aggregatedCache = result; aggregatedCacheKey = currentKey; renderAggregatedOverlay(result, () => openAggregatedView(btn)); } catch (e) { console.error("[Airbnb Score] openAggregatedView error:", e); btn.textContent = "View failed"; return; } finally { btn.disabled = false; btn.textContent = `Combined Top ${AGG_LIMIT}`; } } // src/airbnb.com/airbnb-score-grid.ts var CARD_SELECTOR = '[data-testid="card-container"]'; var ITEM_CLASS = "ab-score-item"; var SORT_BAR_ID = "ab-sort-bar"; var LOAD_BTN_ID = "ab-load-all-btn"; var AGG_VIEW_BTN_ID = "ab-agg-view-btn"; var _observer = null; var _container = document.body; function initGrid(observer, container) { _observer = observer; _container = container; } function applyValueScoring(cards) { const scored = cards.map((card) => ({ card, score: parseFloat(card.dataset.abScore ?? ""), nightly: parseFloat(card.dataset.abNightly ?? "") })).filter((d) => Number.isFinite(d.score)); if (!scored.length) return; const modeled = scored.map((d) => ({ card: d.card, valueMetric: computeValueMetric(d.score, d.nightly, settings.valueModel) })).filter((d) => d.valueMetric !== null); const valueRanks = avgRanks(modeled.map((d) => d.valueMetric)); const valueRelByCard = new Map; modeled.forEach((d, i) => { const valueRel = modeled.length <= 1 ? 0.5 : 1 - valueRanks[i] / (modeled.length - 1); valueRelByCard.set(d.card, valueRel); }); scored.forEach((d) => { const valueRel = valueRelByCard.has(d.card) ? valueRelByCard.get(d.card) : 0.5; const valueScore = computeValueScore(d.score, valueRel); d.card.dataset.abValue = valueScore.toFixed(6); const photoSection = getPhotoSection(d.card); const pillRow = photoSection?.querySelector(".ab-pill-row"); if (pillRow) upsertValuePill(pillRow, valueRel, valueScore); }); } function getAllCardRows() { return Array.from(document.querySelectorAll(CARD_SELECTOR)).map((c) => ({ card: c, row: getCardRow(c), sectionRank: getCardSectionRank(c) })).filter((d) => d.row !== null); } function getSortValue(card, field) { const v = parseFloat(card.dataset[field] ?? ""); return Number.isFinite(v) ? v : -Infinity; } function applySort() { removeAvailableSimilarDatesSection(); const mode = parseSortMode(settings.sortMode); const rows = getAllCardRows(); if (!rows.length) return; if (!mode) { rows.forEach(({ row }) => row.style.order = ""); return; } const { field, dir } = mode; const sorted = rows.slice().sort((a, b) => { if (a.sectionRank !== b.sectionRank) return a.sectionRank - b.sectionRank; return dir * (getSortValue(b.card, field) - getSortValue(a.card, field)); }); sorted.forEach(({ row }, i) => { row.style.order = String(i); }); } function getGridAnchor() { const firstCard = document.querySelector(CARD_SELECTOR); if (!firstCard) return null; let el = firstCard; for (let i = 0;i < 15; i++) { el = el?.parentElement ?? null; if (!el) break; if (el.className && el.className.includes("gsgwcjk")) return el; } return null; } function updateSortBarActive() { const bar = document.getElementById(SORT_BAR_ID); if (!bar) return; const active = parseSortMode(settings.sortMode); bar.querySelectorAll(".ab-sort-btn").forEach((btn) => { const label = SORT_FIELDS.find((f) => f.id === btn.dataset.sortField)?.label || "Sort"; const isActive = active && active.fieldId === btn.dataset.sortField; btn.classList.toggle("ab-active", Boolean(isActive)); btn.textContent = isActive ? `${label} ${active.dirLabel}` : label; }); } function ensureSortBar() { if (document.getElementById(SORT_BAR_ID)) return; const grid = getGridAnchor(); if (!grid || !grid.parentElement) return; const bar = document.createElement("div"); bar.id = SORT_BAR_ID; const label = document.createElement("span"); label.className = "ab-sort-label"; label.textContent = "Sort:"; bar.appendChild(label); SORT_FIELDS.forEach((mode) => { const btn = document.createElement("button"); btn.className = "ab-sort-btn"; btn.dataset.sortField = mode.id; btn.textContent = mode.label; btn.addEventListener("click", () => { settings.sortMode = nextSortMode(settings.sortMode, mode.id); saveSettings(); updateSortBarActive(); applySort(); }); bar.appendChild(btn); }); updateSortBarActive(); const valueControl = createValueModelControl("ab-value-model-ctrl", () => { const cards = Array.from(document.querySelectorAll(CARD_SELECTOR)); applyValueScoring(cards); applySort(); }); bar.appendChild(valueControl); const divider = document.createElement("div"); divider.className = "ab-divider"; bar.appendChild(divider); const loadBtn = document.createElement("button"); loadBtn.id = LOAD_BTN_ID; loadBtn.textContent = "Load All Pages"; loadBtn.title = "Fetch all search result pages and recompute scores globally"; loadBtn.addEventListener("click", () => loadAllPages(loadBtn)); bar.appendChild(loadBtn); const viewBtn = document.createElement("button"); viewBtn.id = AGG_VIEW_BTN_ID; viewBtn.textContent = `Combined Top ${AGG_LIMIT}`; viewBtn.title = `Open combined ranked view from up to ${AGG_LIMIT} listings`; viewBtn.addEventListener("click", () => openAggregatedView(viewBtn)); bar.appendChild(viewBtn); grid.parentElement.insertBefore(bar, grid); } async function loadAllPages(btn) { btn.disabled = true; btn.classList.remove("ab-done"); btn.textContent = "Loading…"; _observer?.disconnect(); const allRatings = []; let pageNum = 1; btn.textContent = "Loading p1…"; const { ratings: p1Ratings, pageCursors } = await fetchPageRatings(null); p1Ratings.forEach((r) => allRatings.push({ stars: r.stars, reviewCount: r.reviewCount, lb: wilsonLB(r.stars, r.reviewCount) })); const remainingCursors = pageCursors ? pageCursors.slice(1) : []; for (let i = 0;i < remainingCursors.length; i++) { pageNum = i + 2; btn.textContent = `Loading p${pageNum}…`; const { ratings } = await fetchPageRatings(remainingCursors[i]); ratings.forEach((r) => allRatings.push({ stars: r.stars, reviewCount: r.reviewCount, lb: wilsonLB(r.stars, r.reviewCount) })); } const globalRatings = scoreLowerBoundEntries(allRatings); const N = globalRatings.length; if (!N) { btn.textContent = "No data"; btn.disabled = false; _observer?.observe(_container, { childList: true, subtree: true }); return; } const domCards = Array.from(document.querySelectorAll(CARD_SELECTOR)).filter((c) => c.dataset.abProcessed === "true"); domCards.forEach((card) => { const cardLb = parseFloat(card.dataset.abLb ?? ""); if (!isFinite(cardLb) || cardLb === 0) return; let countBelow = 0; let countSame = 0; globalRatings.forEach((d) => { if (d.lb < cardLb - 0.000000001) countBelow++; else if (Math.abs(d.lb - cardLb) < 0.000000001) countSame++; }); const rank = countSame > 0 ? countBelow + (countSame - 1) / 2 : countBelow; const rel = N <= 1 ? 0.5 : rank / (N - 1); const finalScore = computeReviewFinalScore(cardLb, rel); const finalStars = scoreToStars(finalScore); const infoSection = getInfoSection(card); if (infoSection) infoSection.style.opacity = (0.35 + 0.65 * rel).toFixed(2); const pill = card.querySelector(".ab-score-pill:not(.ab-value-pill)"); if (pill) { pill.style.background = getColorForScore(rel); pill.textContent = finalStars.toFixed(1) + "★"; } card.dataset.abScore = finalScore.toFixed(6); upsertNightlyRate(card); card.title = `${card.dataset.abStars}★ | ${card.dataset.abCount} reviews | ` + `LB: ${cardLb.toFixed(3)} | Rel(global): ${rel.toFixed(3)} | ` + `Final: ${finalScore.toFixed(3)} (${finalStars.toFixed(1)}★)`; }); applyValueScoring(domCards); applySort(); btn.textContent = `Global (${N})`; btn.classList.add("ab-done"); btn.disabled = false; _observer?.observe(_container, { childList: true, subtree: true }); console.log(`[Airbnb Score] Global scoring: ${N} rated listings across ${pageNum} pages`); } function processCards() { ensureSortBar(); removeAvailableSimilarDatesSection(); const cards = Array.from(document.querySelectorAll(CARD_SELECTOR)); if (!cards.length) return; const unprocessed = cards.filter((c) => c.dataset.abProcessed !== "true"); if (!unprocessed.length) { cards.forEach((card) => upsertNightlyRate(card)); applyValueScoring(cards); applySort(); return; } const itemData = []; unprocessed.forEach((card) => { if (card.offsetParent === null || window.getComputedStyle(card).display === "none") return; const { stars, reviewCount } = extractRatingData(card); const lb = wilsonLB(stars, reviewCount); itemData.push({ card, stars, reviewCount, lb }); }); if (!itemData.length) return; const allProcessed = cards.filter((c) => c.dataset.abProcessed === "true" && c.dataset.abLb).map((c) => ({ card: c, lb: parseFloat(c.dataset.abLb), stars: parseFloat(c.dataset.abStars ?? ""), reviewCount: parseInt(c.dataset.abCount ?? "", 10), processed: true })); const combined = [...allProcessed, ...itemData]; const N = combined.length; const starsRanks = avgRanks(combined.map((d) => d.stars)); const reviewRanks = avgRanks(combined.map((d) => d.reviewCount)); const scoredCombined = scoreLowerBoundEntries(combined).map((d, i) => ({ ...d, starsRel: N === 1 ? 0.5 : starsRanks[i] / (N - 1), reviewRel: N === 1 ? 0.5 : reviewRanks[i] / (N - 1) })); scoredCombined.forEach((d) => { if (!d.processed) return; const infoSection = getInfoSection(d.card); if (infoSection) infoSection.style.opacity = (0.35 + 0.65 * d.rel).toFixed(2); const pill = d.card.querySelector(".ab-score-pill:not(.ab-value-pill)"); if (pill) { pill.style.background = getColorForScore(d.rel); pill.textContent = d.finalStars.toFixed(1) + "★"; } d.card.dataset.abScore = d.final.toFixed(6); colorRatingDisplay(d.card, d.starsRel, d.reviewRel); upsertNightlyRate(d.card); }); scoredCombined.forEach((d) => { if (d.processed) return; const infoSection = getInfoSection(d.card); if (!infoSection) return; infoSection.classList.add(ITEM_CLASS); infoSection.style.opacity = (0.35 + 0.65 * d.rel).toFixed(2); const photoSection = getPhotoSection(d.card); if (photoSection) { photoSection.style.position = "relative"; const { pillRow } = buildPillRow(d.rel, d.finalStars); photoSection.appendChild(pillRow); attachHoverCycle(photoSection); } colorRatingDisplay(d.card, d.starsRel, d.reviewRel); upsertNightlyRate(d.card); d.card.title = `${d.stars}★ | ${d.reviewCount} reviews | ` + `LB: ${d.lb.toFixed(3)} | Rel: ${d.rel.toFixed(3)} | ` + `Final: ${d.final.toFixed(3)} (${d.finalStars.toFixed(1)}★)`; d.card.dataset.abScore = d.final.toFixed(6); d.card.dataset.abStars = d.stars.toString(); d.card.dataset.abCount = d.reviewCount.toString(); d.card.dataset.abLb = d.lb.toFixed(6); d.card.dataset.abProcessed = "true"; }); applyValueScoring(scoredCombined.map((d) => d.card)); applySort(); console.log(`[Airbnb Score] Processed ${itemData.length} new cards, ${N} total`); } // src/airbnb.com/airbnb-score.user.ts var STYLE_ID = "ab-score-style"; var SORT_BAR_ID2 = "ab-sort-bar"; var BOOT_DELAY_MS = 1200; var SEARCH_OBSERVER_INIT = { childList: true, subtree: true }; injectCSS(STYLE_ID, airbnb_score_default); var observedRoot = null; var isSearchRoute = () => { return /^\/s\/.+/.test(window.location.pathname); }; var getSearchContainer = () => { return document.querySelector('[data-testid="browse-list-and-map-container"]') || document.querySelector('[data-testid="homes-search-result"]'); }; var resetSearchUi = () => { const bar = document.getElementById(SORT_BAR_ID2); if (bar) bar.remove(); closeAggregatedOverlay(); invalidateAggCache(); }; var observeSearchDom = (target) => { if (observedRoot === target) return; observedRoot = target; observer.observe(target, SEARCH_OBSERVER_INIT); initGrid(observer, target); }; var refreshObservation = () => { if (!isSearchRoute()) { observedRoot = null; observer.disconnect(); initGrid(observer, document.body); return; } observeSearchDom(getSearchContainer() ?? document.body); }; var runScorePass = () => { if (!isSearchRoute()) { resetSearchUi(); return; } observeSearchDom(getSearchContainer() ?? document.body); processCards(); }; var scheduleDeferredScorePass = () => { window.setTimeout(runScorePass, 1500); }; var observer = observeDomChanges(runScorePass, { root: document.body, debounceMs: 600 }); var runInitialScorePass = () => { refreshObservation(); if (!isSearchRoute()) return; runScorePass(); scheduleDeferredScorePass(); maybeResolveDirectSearchLocation(); }; var bootstrap = () => { runInitialScorePass(); watchLocationChange(() => { resetSearchUi(); refreshObservation(); if (!isSearchRoute()) return; runScorePass(); scheduleDeferredScorePass(); maybeResolveDirectSearchLocation(); }, { debounceMs: 300 }); }; var scheduleBootstrap = () => { window.setTimeout(bootstrap, BOOT_DELAY_MS); }; if (document.readyState === "complete") { scheduleBootstrap(); } else { window.addEventListener("load", scheduleBootstrap, { once: true }); } })();