LaCentraleEnhancer

Enhancements for lacentrale.fr.

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

You will need to install an extension such as Tampermonkey to install this script.

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name        LaCentraleEnhancer
// @namespace   userscripts
// @version     0.0.1
// @description Enhancements for lacentrale.fr.
// @author      Hogwai
// @include     https://www.lacentrale.fr/listing*
// @grant       none
// @noframes
// @license     MIT
// ==/UserScript==
(function () {
  'use strict';

  const __VERSION__ = "0.0.1";

  const SCRIPT_NAME = 'LaCentraleEnhancer';
  const LISTING_PATH = '/listing';
  const PAGINATION_SELECTOR = '[aria-label="Pagination"][data-page-zone="pagination"]';
  const RESULT_LIST_SELECTOR = '.resultList.resultList__withAd';
  const LISTING_LINK_SELECTOR = 'a[href*="/auto-occasion-annonce-"]';
  const CLONE_ATTRIBUTE = 'data-lacentrale-enhancer-pagination-clone';
  const PREVIEW_BUTTON_ATTRIBUTE = 'data-lacentrale-enhancer-preview-button';
  const CONTAINER_ID = 'lacentrale-enhancer-sticky-pagination';
  const OVERLAY_ID = 'lacentrale-enhancer-preview-overlay';

  let initialized = false;
  let refreshScheduled = false;
  let activePreviewToken = 0;
  let pageScrollLockState = null;
  const listingDetailsCache = new Map();

  function log(message, ...details) {
    console.info(`[${SCRIPT_NAME}] ${message}`, ...details);
  }

  function onDomReady(callback) {
    if (document.readyState === 'loading') {
      document.addEventListener('DOMContentLoaded', callback, { once: true });
      return;
    }

    callback();
  }

  function isListingPage() {
    return window.location.hostname === 'www.lacentrale.fr' && window.location.pathname === LISTING_PATH;
  }

  function findResultList() {
    return document.querySelector(RESULT_LIST_SELECTOR);
  }

  function getStickyTopOffset() {
    const viewportWidth = document.documentElement.clientWidth;

    return Array.from(document.body.children).reduce((offset, element) => {
      if (element.id === CONTAINER_ID) return offset;

      const styles = window.getComputedStyle(element);
      if (!['fixed', 'sticky'].includes(styles.position)) return offset;

      const rect = element.getBoundingClientRect();
      const isVisibleTopBar = rect.top <= 1 && rect.bottom > 0 && rect.width >= viewportWidth * 0.5;
      return isVisibleTopBar ? Math.max(offset, rect.bottom) : offset;
    }, 0);
  }

  function ensureStickyContainer(resultList) {
    const existing = document.getElementById(CONTAINER_ID);
    if (existing) {
      if (existing.parentElement !== resultList) resultList.prepend(existing);
      return existing;
    }

    const container = document.createElement('div');
    container.id = CONTAINER_ID;
    container.style.position = 'sticky';
    container.style.zIndex = '10';
    container.style.display = 'flex';
    container.style.justifyContent = 'center';
    container.style.padding = '8px 12px';
    container.style.background = 'rgba(255, 255, 255, 0.96)';
    container.style.boxShadow = '0 2px 10px rgba(0, 0, 0, 0.12)';
    container.style.backdropFilter = 'blur(6px)';

    container.addEventListener('click', handleCloneClick);
    resultList.prepend(container);
    return container;
  }

  function handleCloneClick(event) {
    const link = event.target.closest?.('a[href]');
    if (!link || !event.currentTarget.contains(link)) return;
    if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;

    event.preventDefault();
    window.location.assign(link.href);
  }

  function findSourcePagination() {
    return Array.from(document.querySelectorAll(PAGINATION_SELECTOR))
      .find((pagination) => !pagination.hasAttribute(CLONE_ATTRIBUTE));
  }

  function getAbsoluteUrl(href) {
    try {
      return new URL(href, window.location.origin).href;
    } catch {
      return null;
    }
  }

  function getListingUrl(link) {
    const url = getAbsoluteUrl(link.getAttribute('href'));
    if (!url) return null;

    const parsed = new URL(url);
    if (parsed.hostname !== window.location.hostname) return null;
    if (!parsed.pathname.includes('/auto-occasion-annonce-')) return null;
    return parsed.href;
  }

  function findListingCard(link) {
    const semanticCard = link.closest('article, li');
    if (semanticCard && !semanticCard.matches(RESULT_LIST_SELECTOR)) return semanticCard;

    let card = link;
    for (let depth = 0; depth < 5 && card.parentElement; depth += 1) {
      card = card.parentElement;
      if (card.matches(RESULT_LIST_SELECTOR)) break;
    }

    return card.matches(RESULT_LIST_SELECTOR) ? link.parentElement : card;
  }

  function findFavoriteButton(card) {
    return card.querySelector([
      'button[data-testid="favorite"]',
      '[data-testid="favorite"] button',
      'button[class*="Favorite" i]',
      'button[aria-label*="favori" i]',
      'button[aria-label*="favorite" i]',
    ].join(','));
  }

  function isFavoriteActive(button) {
    if (!button) return false;
    return button.getAttribute('data-active') === 'true'
      || button.getAttribute('aria-pressed') === 'true'
      || button.className?.toString().toLowerCase().includes('active');
  }

  function lockPageScroll() {
    if (pageScrollLockState) return;

    const scrollY = window.scrollY;
    pageScrollLockState = {
      scrollY,
      bodyOverflow: document.body.style.overflow,
      bodyPosition: document.body.style.position,
      bodyTop: document.body.style.top,
      bodyWidth: document.body.style.width,
      documentOverflow: document.documentElement.style.overflow,
    };

    document.documentElement.style.overflow = 'hidden';
    document.body.style.overflow = 'hidden';
    document.body.style.position = 'fixed';
    document.body.style.top = `-${scrollY}px`;
    document.body.style.width = '100%';
  }

  function unlockPageScroll() {
    if (!pageScrollLockState) return;

    const { scrollY, bodyOverflow, bodyPosition, bodyTop, bodyWidth, documentOverflow } = pageScrollLockState;
    pageScrollLockState = null;

    document.documentElement.style.overflow = documentOverflow;
    document.body.style.overflow = bodyOverflow;
    document.body.style.position = bodyPosition;
    document.body.style.top = bodyTop;
    document.body.style.width = bodyWidth;
    window.scrollTo(0, scrollY);
  }

  function createPreviewButton(card, url) {
    const button = document.createElement('button');
    button.type = 'button';
    button.textContent = 'Aperçu';
    button.setAttribute(PREVIEW_BUTTON_ATTRIBUTE, 'true');
    button.style.position = 'absolute';
    button.style.top = '10px';
    button.style.right = '10px';
    button.style.zIndex = '20';
    button.style.padding = '7px 12px';
    button.style.border = '1px solid #1f2937';
    button.style.borderRadius = '999px';
    button.style.background = 'rgba(255, 255, 255, 0.94)';
    button.style.color = '#111827';
    button.style.font = '600 13px/1.2 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
    button.style.cursor = 'pointer';
    button.style.boxShadow = '0 4px 14px rgba(0, 0, 0, 0.18)';
    button.addEventListener('click', (event) => {
      event.preventDefault();
      event.stopPropagation();
      openPreview(card, url);
    });
    return button;
  }

  function decorateListingCards() {
    if (!isListingPage()) return;

    const seenCards = new Set();
    Array.from(document.querySelectorAll(LISTING_LINK_SELECTOR)).forEach((link) => {
      if (link.closest(`#${OVERLAY_ID}`)) return;

      const url = getListingUrl(link);
      if (!url) return;

      const card = findListingCard(link);
      if (!card || seenCards.has(card) || card.querySelector(`[${PREVIEW_BUTTON_ATTRIBUTE}]`)) return;

      seenCards.add(card);
      if (window.getComputedStyle(card).position === 'static') {
        card.style.position = 'relative';
      }
      card.appendChild(createPreviewButton(card, url));
    });
  }

  function getFirstText(element, selectors) {
    for (const selector of selectors) {
      const match = element.querySelector(selector);
      const text = match?.textContent?.trim();
      if (text) return text;
    }
    return '';
  }

  function compactText(text) {
    return text.replace(/\s+/g, ' ').trim();
  }

  function getImageUrl(image) {
    const source = image.currentSrc || image.src || image.getAttribute('data-src') || image.getAttribute('data-lazy-src');
    if (source) return getAbsoluteUrl(source);

    const srcset = image.getAttribute('srcset') || image.getAttribute('data-srcset');
    if (!srcset) return null;

    const firstCandidate = srcset.split(',')[0]?.trim()?.split(/\s+/)[0];
    return firstCandidate ? getAbsoluteUrl(firstCandidate) : null;
  }

  function unique(values) {
    return Array.from(new Set(values.filter(Boolean)));
  }

  function getImageIdentity(url) {
    try {
      const parsed = new URL(url);
      return parsed.pathname.replace(/^(?:\/classifieds)?\//, '').replace(/^(?:\d+x\d+|[^/]+)\//, '');
    } catch {
      return url;
    }
  }

  function uniqueImages(urls) {
    const seen = new Set();
    return urls.filter((url) => {
      if (!url || url.startsWith('data:')) return false;
      const identity = getImageIdentity(url);
      if (seen.has(identity)) return false;
      seen.add(identity);
      return true;
    });
  }

  function extractImages(root) {
    return uniqueImages(Array.from(root.querySelectorAll('img')).map(getImageUrl));
  }

  function extractCardSummary(card, url) {
    const title = getFirstText(card, ['h1', 'h2', 'h3', '[class*="title" i]', '[aria-label]']) || 'Annonce LaCentrale';
    const text = compactText(card.textContent || '');
    const price = text.match(/\d[\d\s]*(?:€|EUR)/i)?.[0]?.trim() || '';
    const details = unique(text.split(/(?=\d[\d\s]*(?:km|KM|ch|CV|€|EUR)|Essence|Diesel|Hybride|Electrique|Électrique|Automatique|Manuelle)/)
      .map(compactText)
      .filter((part) => part && part.length <= 80))
      .slice(0, 8);

    return {
      url,
      title: compactText(title),
      price,
      details,
      images: extractImages(card),
      favoriteButton: findFavoriteButton(card),
    };
  }

  function extractJsonLdDetails(documentNode) {
    const details = { description: '', images: [], specs: [] };

    Array.from(documentNode.querySelectorAll('script[type="application/ld+json"]')).some((script) => {
      try {
        const data = JSON.parse(script.textContent || '{}');
        const entries = Array.isArray(data) ? data : [data];
        const vehicle = entries.find((entry) => entry && typeof entry === 'object' && (entry.description || entry.image));
        if (!vehicle) return false;

        details.description = compactText(String(vehicle.description || ''));
        details.images = unique((Array.isArray(vehicle.image) ? vehicle.image : [vehicle.image]).map((image) => getAbsoluteUrl(String(image || ''))));
        details.specs = [
          ['Année', vehicle.dateVehicleFirstRegistered],
          ['Kilométrage', formatWithUnit(vehicle.mileageFromOdometer?.value, vehicle.mileageFromOdometer?.unitText)],
          ['Motorisation', vehicle.fuelType],
          ['Boîte', vehicle.vehicleTransmission],
          ['Localisation', vehicle.offers?.seller?.address?.postalCode],
        ];
        return Boolean(details.description || details.images.length);
      } catch {
        return false;
      }
    });

    return details;
  }

  function extractAssignedJson(documentNode, variableName) {
    const pattern = new RegExp(`(?:var|window\\.)\\s*${variableName}\\s*=\\s*({[\\s\\S]*?})(?:;|$)`);

    for (const script of Array.from(documentNode.querySelectorAll('script'))) {
      const match = script.textContent?.match(pattern);
      if (!match) continue;

      try {
        return JSON.parse(match[1]);
      } catch {
        return null;
      }
    }

    return null;
  }

  function formatPrice(value) {
    if (!Number.isFinite(Number(value))) return value || '';
    return `${Number(value).toLocaleString('fr-FR')} €`;
  }

  function formatMileage(value) {
    if (!Number.isFinite(Number(value))) return value || '';
    return `${Number(value).toLocaleString('fr-FR')} km`;
  }

  function formatWithUnit(value, unit) {
    if (!value) return '';
    if (!Number.isFinite(Number(value))) return `${value}${unit ? ` ${unit}` : ''}`;
    return `${Number(value).toLocaleString('fr-FR')}${unit ? ` ${unit}` : ''}`;
  }

  function formatGearbox(value) {
    const normalized = String(value || '').toUpperCase();
    if (normalized === 'MECANIQUE') return 'Manuelle';
    if (normalized === 'AUTOMATIQUE') return 'Automatique';
    return value || '';
  }

  function formatLocation(value, zipCode) {
    return [value, zipCode].filter(Boolean).join(' · ');
  }

  function extractLaCentraleData(documentNode) {
    const gallery = extractAssignedJson(documentNode, 'CLASSIFIED_GALLERY');
    const mainInfos = extractAssignedJson(documentNode, 'CLASSIFIED_MAIN_INFOS');
    const summary = extractAssignedJson(documentNode, 'SummaryInformationData');
    const seller = extractAssignedJson(documentNode, 'SellerInformationData');
    const data = gallery?.data || mainInfos?.data || {};
    const classified = data.classified || summary?.classified?.classified || {};
    const vehicle = data.vehicle || {};
    const combinedVehicle = summary?.classified?.vehicle?.combined || {};
    const version = combinedVehicle.version || {};
    const specs = combinedVehicle.specs || {};
    const pictures = gallery?.data?.images?.v1?.pictures || [];
    const images = uniqueImages(pictures.map((picture) => getAbsoluteUrl(picture.src1_5x || picture.srcMobile2x)));
    const titleParts = unique([
      classified.title,
      version.label,
    ].map(compactText));

    return {
      title: titleParts.join(' ') || version.detailedModel || '',
      price: formatPrice(classified.price || summary?.classified?.classified?.price),
      description: classified.description?.content || '',
      images,
      specs: [
        ['Année', classified.year || summary?.classified?.classified?.year],
        ['Kilométrage', formatMileage(classified.mileage || summary?.classified?.classified?.mileage)],
        ['Motorisation', version.motorization || specs.energy || vehicle.commercialModel],
        ['Boîte', formatGearbox(specs.gearbox)],
        ['Localisation', formatLocation(classified.visitPlace || seller?.classified?.classified?.visitPlace, seller?.classified?.classified?.zipCode || summary?.classified?.classified?.zipCode)],
      ],
    };
  }

  function extractDetailsFromDocument(documentNode) {
    const lacentraleDetails = extractLaCentraleData(documentNode);
    const jsonLdDetails = extractJsonLdDetails(documentNode);
    const description = lacentraleDetails.description || jsonLdDetails.description || getFirstText(documentNode, [
      '[data-testid*="description" i]',
      '[class*="description" i]',
      'section[aria-label*="description" i]',
    ]);

    const metaImage = documentNode.querySelector('meta[property="og:image"]')?.content;
    const fallbackImages = [
      ...jsonLdDetails.images,
      getAbsoluteUrl(metaImage || ''),
    ];
    const images = uniqueImages(lacentraleDetails.images.length
      ? lacentraleDetails.images
      : [...fallbackImages, ...extractImages(documentNode)]).slice(0, 24);

    return {
      title: lacentraleDetails.title,
      price: lacentraleDetails.price,
      description: compactText(description || ''),
      images,
      specs: uniqueSpecs([...lacentraleDetails.specs, ...jsonLdDetails.specs]),
    };
  }

  function uniqueSpecs(specs) {
    const seen = new Set();
    return specs
      .map(([label, value]) => [label, compactText(String(value || ''))])
      .filter(([label, value]) => {
        if (!value || seen.has(label)) return false;
        seen.add(label);
        return true;
      });
  }

  async function loadListingDetails(url) {
    if (listingDetailsCache.has(url)) return listingDetailsCache.get(url);

    const response = await fetch(url, { credentials: 'include' });
    if (!response.ok) throw new Error(`HTTP ${response.status}`);

    const html = await response.text();
    const documentNode = new DOMParser().parseFromString(html, 'text/html');
    const details = extractDetailsFromDocument(documentNode);
    listingDetailsCache.set(url, details);
    return details;
  }

  function ensurePreviewOverlay() {
    const existing = document.getElementById(OVERLAY_ID);
    if (existing) {
      lockPageScroll();
      return existing;
    }

    const overlay = document.createElement('div');
    overlay.id = OVERLAY_ID;
    overlay.tabIndex = -1;
    overlay.style.position = 'fixed';
    overlay.style.inset = '0';
    overlay.style.zIndex = '2147483646';
    overlay.style.overflow = 'auto';
    overlay.style.background = 'rgba(17, 24, 39, 0.64)';
    overlay.addEventListener('click', (event) => {
      if (event.target === overlay) closePreview();
    });

    document.body.appendChild(overlay);
    lockPageScroll();
    return overlay;
  }

  function renderPreviewContent(summary, details = {}, state = 'loading') {
    const overlay = ensurePreviewOverlay();
    const images = unique([...(details.images || []), ...summary.images]);
    const title = details.title || summary.title;
    const price = details.price || summary.price;
    const description = details.description || '';
    const specs = details.specs?.length ? details.specs : summary.details.map((detail) => detail.split(':')).filter((parts) => parts.length === 2);
    const statusMessage = state === 'loading'
      ? 'Chargement de la description…'
      : 'Description non disponible pour cette annonce.';

    overlay.innerHTML = `
    <div style="box-sizing:border-box;min-height:100%;padding:28px 18px;">
      <section role="dialog" aria-modal="true" aria-label="Aperçu annonce" style="box-sizing:border-box;max-width:1180px;max-height:calc(100vh - 56px);margin:0 auto;background:#fff;border-radius:18px;box-shadow:0 24px 80px rgba(0,0,0,.28);overflow:hidden;font-family:system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#111827;display:flex;flex-direction:column;">
        <header style="position:sticky;top:0;z-index:1;display:flex;gap:16px;align-items:flex-start;justify-content:space-between;padding:18px 22px;border-bottom:1px solid #e5e7eb;background:rgba(255,255,255,.96);backdrop-filter:blur(6px);">
          <div>
            <h2 style="margin:0 0 6px;font-size:22px;line-height:1.25;">${escapeHtml(title)}</h2>
            ${price ? `<strong style="font-size:20px;">${escapeHtml(price)}</strong>` : ''}
          </div>
          <div style="display:flex;align-items:center;gap:8px;flex:0 0 auto;">
            ${renderFavoriteButton(summary.favoriteButton)}
            <button type="button" data-preview-close style="border:1px solid #d1d5db;border-radius:999px;background:#fff;padding:9px 14px;cursor:pointer;font-weight:700;">Fermer</button>
          </div>
        </header>
        <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:22px;padding:22px;overflow:hidden;min-height:0;">
          <div style="overflow:auto;min-height:0;">
            ${renderImageGallery(images)}
          </div>
          <div style="overflow:auto;min-height:0;padding-right:4px;">
            ${renderSpecs(specs)}
            <h3 style="margin:22px 0 10px;font-size:17px;">Description</h3>
            <p style="white-space:pre-wrap;margin:0;color:#374151;line-height:1.55;">${escapeHtml(description || statusMessage)}</p>
            <a href="${escapeAttribute(summary.url)}" target="_blank" rel="noopener noreferrer" style="display:inline-block;margin-top:20px;color:#0f766e;font-weight:700;">Ouvrir l’annonce complète</a>
          </div>
        </div>
      </section>
    </div>
  `;

    overlay.querySelector('[data-preview-close]')?.addEventListener('click', closePreview);
    setupPreviewFavorite(overlay, summary.favoriteButton);
    setupPreviewCarousel(overlay);
    setupPreviewFocusTrap(overlay);
    overlay.focus({ preventScroll: true });
  }

  function setupPreviewFocusTrap(overlay) {
    if (overlay.dataset.focusTrapReady === 'true') return;
    overlay.dataset.focusTrapReady = 'true';

    overlay.addEventListener('keydown', (event) => {
      if (event.key !== 'Tab') return;

      const focusableElements = Array.from(overlay.querySelectorAll('a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])'));
      if (!focusableElements.length) {
        event.preventDefault();
        overlay.focus({ preventScroll: true });
        return;
      }

      const firstElement = focusableElements[0];
      const lastElement = focusableElements[focusableElements.length - 1];

      if (event.shiftKey && document.activeElement === firstElement) {
        event.preventDefault();
        lastElement.focus({ preventScroll: true });
        return;
      }

      if (!event.shiftKey && document.activeElement === lastElement) {
        event.preventDefault();
        firstElement.focus({ preventScroll: true });
      }
    });
  }

  function renderFavoriteButton(sourceButton) {
    if (!sourceButton) return '';

    const active = isFavoriteActive(sourceButton);
    return `
    <button type="button" data-preview-favorite style="border:1px solid ${active ? '#ff004f' : '#d1d5db'};border-radius:999px;background:${active ? '#fff0f5' : '#fff'};color:#111827;padding:9px 14px;cursor:pointer;font-weight:700;display:inline-flex;align-items:center;gap:6px;">
      <span aria-hidden="true" style="color:#ff004f;font-size:16px;line-height:1;">${active ? '♥' : '♡'}</span>
      <span>Favori</span>
    </button>
  `;
  }

  function setupPreviewFavorite(overlay, sourceButton) {
    const favoriteButton = overlay.querySelector('[data-preview-favorite]');
    if (!favoriteButton || !sourceButton) return;

    favoriteButton.addEventListener('click', (event) => {
      event.preventDefault();
      event.stopPropagation();
      sourceButton.click();

      window.setTimeout(() => {
        const active = isFavoriteActive(sourceButton);
        favoriteButton.style.borderColor = active ? '#ff004f' : '#d1d5db';
        favoriteButton.style.background = active ? '#fff0f5' : '#fff';
        const icon = favoriteButton.querySelector('span[aria-hidden="true"]');
        if (icon) icon.textContent = active ? '♥' : '♡';
      }, 100);
    });
  }

  function renderImageGallery(images) {
    if (!images.length) return '<div style="display:grid;place-items:center;min-height:260px;border:1px dashed #d1d5db;border-radius:14px;color:#6b7280;">Aucune photo récupérée</div>';

    const [mainImage, ...otherImages] = images;
    return `
    <div data-preview-carousel style="position:relative;">
      <img data-preview-main-image src="${escapeAttribute(mainImage)}" alt="Photo principale" style="display:block;width:100%;max-height:520px;object-fit:contain;background:#f3f4f6;border-radius:14px;">
      ${images.length > 1 ? `
        <button type="button" data-preview-carousel-previous aria-label="Photo précédente" style="position:absolute;left:10px;top:50%;transform:translateY(-50%);width:38px;height:38px;border:0;border-radius:999px;background:rgba(17,24,39,.78);color:#fff;cursor:pointer;font-size:24px;line-height:1;">‹</button>
        <button type="button" data-preview-carousel-next aria-label="Photo suivante" style="position:absolute;right:10px;top:50%;transform:translateY(-50%);width:38px;height:38px;border:0;border-radius:999px;background:rgba(17,24,39,.78);color:#fff;cursor:pointer;font-size:24px;line-height:1;">›</button>
        <div data-preview-carousel-count style="position:absolute;right:12px;bottom:12px;border-radius:999px;background:rgba(17,24,39,.78);color:#fff;padding:5px 9px;font-size:12px;font-weight:700;">1 / ${images.length}</div>
      ` : ''}
      <div data-preview-carousel-images hidden>${images.map((image) => `<span data-src="${escapeAttribute(image)}"></span>`).join('')}</div>
    </div>
    ${otherImages.length ? `<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(92px,1fr));gap:8px;margin-top:10px;">${images.map((image, index) => `<button type="button" data-preview-thumbnail="${index}" style="padding:0;border:${index === 0 ? '2px solid #0f766e' : '1px solid #e5e7eb'};border-radius:8px;background:#fff;cursor:pointer;overflow:hidden;"><img src="${escapeAttribute(image)}" alt="Photo annonce ${index + 1}" style="display:block;width:100%;height:74px;object-fit:cover;background:#f3f4f6;"></button>`).join('')}</div>` : ''}
  `;
  }

  function setupPreviewCarousel(overlay) {
    const carousel = overlay.querySelector('[data-preview-carousel]');
    if (!carousel) return;

    const images = Array.from(carousel.querySelectorAll('[data-preview-carousel-images] [data-src]'))
      .map((element) => element.getAttribute('data-src'));
    if (images.length <= 1) return;

    const mainImage = carousel.querySelector('[data-preview-main-image]');
    const counter = carousel.querySelector('[data-preview-carousel-count]');
    const thumbnails = Array.from(overlay.querySelectorAll('[data-preview-thumbnail]'));
    let currentIndex = 0;

    const showImage = (nextIndex) => {
      currentIndex = (nextIndex + images.length) % images.length;
      mainImage.src = images[currentIndex];
      if (counter) counter.textContent = `${currentIndex + 1} / ${images.length}`;
      thumbnails.forEach((thumbnail, index) => {
        thumbnail.style.border = index === currentIndex ? '2px solid #0f766e' : '1px solid #e5e7eb';
      });
    };

    carousel.querySelector('[data-preview-carousel-previous]')?.addEventListener('click', () => showImage(currentIndex - 1));
    carousel.querySelector('[data-preview-carousel-next]')?.addEventListener('click', () => showImage(currentIndex + 1));
    thumbnails.forEach((thumbnail) => {
      thumbnail.addEventListener('click', () => showImage(Number(thumbnail.getAttribute('data-preview-thumbnail'))));
    });
  }

  function renderSpecs(specs) {
    if (!specs.length) return '';

    return `
    <dl style="display:grid;grid-template-columns:repeat(auto-fit,minmax(135px,1fr));gap:10px;margin:0;">
      ${specs.map(([label, value]) => `
        <div style="border:1px solid #e5e7eb;border-radius:12px;padding:10px;background:#f9fafb;">
          <dt style="margin:0 0 3px;color:#6b7280;font-size:12px;">${escapeHtml(label)}</dt>
          <dd style="margin:0;font-weight:700;color:#111827;">${escapeHtml(value)}</dd>
        </div>
      `).join('')}
    </dl>
  `;
  }

  function escapeHtml(value) {
    return String(value).replace(/[&<>"]/g, (character) => ({
      '&': '&amp;',
      '<': '&lt;',
      '>': '&gt;',
      '"': '&quot;',
    }[character]));
  }

  function escapeAttribute(value) {
    return escapeHtml(value).replace(/'/g, '&#39;');
  }

  async function openPreview(card, url) {
    const token = activePreviewToken + 1;
    activePreviewToken = token;

    const summary = extractCardSummary(card, url);
    renderPreviewContent(summary, {}, 'loading');

    try {
      const details = await loadListingDetails(url);
      if (activePreviewToken !== token) return;
      renderPreviewContent(summary, details, 'loaded');
    } catch (error) {
      log('preview details unavailable', { url, error });
      if (activePreviewToken !== token) return;
      renderPreviewContent(summary, {}, 'error');
    }
  }

  function closePreview() {
    activePreviewToken += 1;
    document.getElementById(OVERLAY_ID)?.remove();
    unlockPageScroll();
  }

  function handlePreviewKeyboard(event) {
    if (event.key === 'Escape') closePreview();
  }

  function clonePagination(source) {
    const clone = source.cloneNode(true);
    clone.setAttribute(CLONE_ATTRIBUTE, 'true');
    clone.style.margin = '0';
    clone.style.maxWidth = '100%';
    clone.style.pointerEvents = 'auto';
    return clone;
  }

  function refreshStickyPagination() {
    if (!isListingPage()) return;

    const source = findSourcePagination();
    if (!source) return;

    const resultList = findResultList();
    if (!resultList) return;

    const container = ensureStickyContainer(resultList);
    container.style.top = `${getStickyTopOffset()}px`;
    container.replaceChildren(clonePagination(source));
  }

  function scheduleRefresh() {
    if (refreshScheduled) return;
    refreshScheduled = true;

    window.setTimeout(() => {
      refreshScheduled = false;
      refreshStickyPagination();
      decorateListingCards();
    }, 100);
  }

  function observePaginationChanges() {
    const observer = new MutationObserver((mutations) => {
      if (mutations.every((mutation) => mutation.target.closest?.(`#${CONTAINER_ID}`))) return;
      scheduleRefresh();
    });
    observer.observe(document.body, { childList: true, subtree: true });
  }

  function init() {
    if (initialized) return;
    initialized = true;

    log('initialized', { version: __VERSION__ });

    if (!isListingPage()) return;

    refreshStickyPagination();
    decorateListingCards();
    observePaginationChanges();
    document.addEventListener('keydown', handlePreviewKeyboard);
  }

  onDomReady(init);

})();