Exportar las reseñas de Google Maps a Excel (CSV)

Descarga todas las reseñas de un sitio de Google Maps (tu negocio, la competencia, un hotel o un restaurante) en una hoja de cálculo: autor, estrellas, fecha, texto completo, respuesta del propietario y número de fotos. Gratis y sin clave de API.

Abre el sitio, pulsa su pestaña Reseñas (ordénalas antes por Más recientes o Puntuación más baja si quieres) y ejecuta el script.

export-google-maps-reviews.js · 151 líneaspégalo en la consola (F12) de www.google.com
// Responsive Muse · Google Maps Reviews Exporter · v1.0 · 2026-10-22
// Saves the reviews of any place on Google Maps (your business, a competitor, a hotel you're
// comparing) to CSV or Markdown: author, stars, date, text, the owner's reply and photos count.
// Open the place, click its Reviews tab (sort them first if you like) and run the script.
// It scrolls the list and expands long reviews. It makes no network requests of its own and
// never reads cookies, passwords or tokens.
(async () => {
  const TOOL = 'Exportar reseñas de Google Maps';
  const FORMAT = 'csv';        // 'csv' (Excel, Sheets) or 'md' (readable document)
  const LIMIT = 500;           // stop after this many reviews (0 = all; big places have thousands)

  if (!/^(www\.)?google\.[a-z.]+$/.test(location.hostname) || !location.pathname.startsWith('/maps')) {
    alert('Abre primero un sitio en Google Maps y pulsa su pestaña Reseñas:\nhttps://www.google.com/maps');
    return;
  }
  const reviewsOnPage = () => [...document.querySelectorAll('div[data-review-id][aria-label]')];
  if (!reviewsOnPage().length) {
    alert('Pulsa primero la pestaña Reseñas del sitio y vuelve a ejecutar el script.');
    return;
  }
  // A small box on the page shows progress and asks before changing anything,
  // so you never need to read or edit the console.
  const many = (n, word) => `${n} ${n === 1 ? word : ({ canal: 'canales', 'canción': 'canciones', 'publicación': 'publicaciones', 'elemento guardado': 'elementos guardados' })[word] || word + 's'}`;   // 1 vídeo, 2 vídeos
  const panel = (() => {
    const box = document.createElement('div');
    box.style.cssText = 'position:fixed;right:16px;bottom:16px;z-index:2147483647;width:340px;max-width:calc(100vw - 32px);' +
      'background:#111318;color:#e6e8ee;font:14px/1.5 system-ui,sans-serif;border-radius:12px;padding:16px;' +
      'box-shadow:0 8px 30px rgba(0,0,0,.4);text-align:left';
    const head = document.createElement('strong');
    head.textContent = TOOL;
    const msg = document.createElement('p');
    msg.style.cssText = 'margin:8px 0 12px;white-space:pre-line';
    const row = document.createElement('div');
    row.style.cssText = 'display:flex;flex-wrap:wrap;gap:8px;justify-content:flex-end';
    box.append(head, msg, row);
    document.body.append(box);
    const button = (label, primary, onClick) => {
      const b = document.createElement('button');
      b.textContent = label;
      b.style.cssText = 'font:600 14px system-ui,sans-serif;padding:8px 14px;border-radius:8px;cursor:pointer;' +
        (primary ? 'background:#0f766e;color:#fff;border:1px solid #0f766e' : 'background:none;color:#e6e8ee;border:1px solid #3a3f4b');
      b.onclick = onClick;
      row.append(b);
    };
    return {
      text(t) { msg.textContent = t; },
      ask(t, yes) {
        msg.textContent = t;
        row.replaceChildren();
        return new Promise(r => {
          button('Cancelar', false, () => { row.replaceChildren(); r(false); });
          button(yes, true, () => { row.replaceChildren(); r(true); });
        });
      },
      stoppable(onStop) { row.replaceChildren(); button('Parar', false, onStop); },
      actions(t, list) {
        msg.textContent = t;
        row.replaceChildren();
        list.forEach(([label, primary, run]) => button(label, primary, run));
      },
      finish(t) { this.actions(t, [['Cerrar', true, () => box.remove()]]); },
      close() { box.remove(); },
    };
  })();
  // Offer the result as a downloaded file or as clipboard text (a real click, so it always works)
  const deliver = (summary, body, file) => {
    const save = () => {
      const a = document.createElement('a');
      a.href = URL.createObjectURL(new Blob([/\.csv$/.test(file) ? '\ufeff' + body : body], { type: 'text/plain;charset=utf-8' }));
      a.download = file;
      a.click();
      setTimeout(() => URL.revokeObjectURL(a.href), 5000);
      panel.text(`${summary}\nGuardado como ${file} (mira tu carpeta de Descargas).`);
    };
    const copy = () => navigator.clipboard.writeText(body)
      .then(() => panel.text(`${summary}\nCopiado. Pégalo en una hoja de cálculo o en una nota.`))
      .catch(() => panel.text(`${summary}\nEl navegador ha bloqueado el portapapeles: usa Descargar.`));
    panel.actions(summary, [['Cerrar', false, () => panel.close()], ['Copiar', false, copy], ['Descargar', true, save]]);
  };
  const sleep = ms => new Promise(r => setTimeout(r, ms));
  const reviews = new Map();
  // The reviews live in a scrolling side panel: find it from the first review
  let scroller = reviewsOnPage()[0];
  while (scroller && !(scroller.scrollHeight > scroller.clientHeight + 50 && /auto|scroll/.test(getComputedStyle(scroller).overflowY))) {
    scroller = scroller.parentElement;
  }
  scroller = scroller || document.scrollingElement;

  // Read the reviews currently loaded (long ones are expanded first)
  const MORE = 'button[jsaction*="review.expandReview"][aria-expanded="false"], ' +
    'button[jsaction*="review.expandOwnerResponse"][aria-expanded="false"]';
  const waited = new Set();
  const collect = async () => {
    const more = [...document.querySelectorAll(MORE)];
    more.forEach(b => b.click());
    if (more.length) await sleep(300);
    for (const el of reviewsOnPage()) {
      const id = el.dataset.reviewId;
      if (reviews.has(id)) continue;
      // Loaded after the clicks above: read it on the next pass, once expanded
      if (el.querySelector(MORE) && !waited.has(id)) { waited.add(id); continue; }
      // Review text and the owner's reply (inside .CDe7pd) use the same text class
      const text = [...el.querySelectorAll('.wiI7pd')].filter(t => !t.closest('.CDe7pd'))
        .map(t => t.innerText.trim()).filter(Boolean).join('\n');
      const reply = (el.querySelector('.CDe7pd .wiI7pd')?.innerText || '').trim();
      // Stars: an image labelled "5 stars"; hotels also show scores from other sites ("10/10")
      const label = el.querySelector('[role="img"][aria-label]')?.getAttribute('aria-label');
      const stars = label ? (label.match(/\d+([.,]\d+)?/) || [''])[0] : (el.querySelector('.fzvQIb')?.textContent || '').trim();
      reviews.set(id, {
        author: el.getAttribute('aria-label') || '',
        stars,
        date: (el.querySelector('.rsqaWe, .xRkPPb')?.textContent || '').trim(),
        text,
        reply,
        photos: el.querySelectorAll('button[jsaction*="openPhoto"], button.Tya61d').length,
      });
      if (LIMIT && reviews.size >= LIMIT) return;
    }
  };

  // 1. Scroll the panel, collecting as we go, until nothing new appears five times in a row
  let stop = false;
  panel.stoppable(() => { stop = true; });
  let idle = 0;
  while (idle < 5 && !stop && !(LIMIT && reviews.size >= LIMIT)) {
    const before = reviews.size;
    await collect();
    scroller.scrollTo(0, scroller.scrollHeight);
    await sleep(1500);
    idle = reviews.size === before ? idle + 1 : 0;
    panel.text(`Recogiendo reseñas… ${reviews.size}${LIMIT ? ' de un máximo de ' + LIMIT : ''}`);
  }
  const list = [...reviews.values()];
  if (!list.length) {
    panel.finish('No se han encontrado reseñas. Puede que Google Maps haya cambiado su diseño: avísanos en responsive-muse.com.');
    return 0;
  }

  // 2. Build the file and offer it
  const place = document.title.replace(/ [-–] Google Maps$/, '').trim() || 'place';
  const cell = v => '"' + String(v).replace(/"/g, '""') + '"';
  const body = FORMAT === 'md'
    ? `# Reseñas de ${place}\n\n` + list.map(r => `**${r.author}** · ${r.stars}★ · ${r.date}\n\n${r.text}` +
        (r.reply ? `\n\n> Propietario: ${r.reply.replace(/\n/g, '\n> ')}` : '')).join('\n\n---\n\n')
    : ['Autor,Estrellas,Fecha,Reseña,Respuesta del propietario,Fotos', ...list.map(r =>
        [r.author, r.stars, r.date, r.text, r.reply, r.photos].map(cell).join(','))].join('\n');
  const name = ('resenas-' + place).replace(/[\\/:*?"<>|]+/g, '_');
  deliver(`${stop ? 'Parado antes de terminar. ' : ''}Listo: ${many(list.length, 'reseña')}.`, body, `${name}.${FORMAT}`);
  return list.length;
})();

Marcador: arrastra el botón del medio a tu barra de marcadores, abre www.google.com y pulsa el marcador. Ejecuta el mismo script con las opciones de arriba.

Por qué es seguro ejecutarloComprobación automática superada 23 oct 2026 · probado en una cuenta real el 22 oct 2026
  • No envía nada a ningún sitio. Sin fetch, XHR ni scripts externos. La única actividad de red es la de www.google.com cargando tu lista.
  • No toca tu sesión. No lee cookies, contraseñas, tokens ni el almacenamiento del navegador.
  • Solo funciona en www.google.com. En cualquier otra web se detiene antes de hacer nada.
  • Corto y legible. 151 líneas comentadas, sin minificar ni ofuscar.
  • Muestra su progreso en la página. Un pequeño recuadro te dice qué está haciendo y al final te ofrece Descargar o Copiar.
  • El mismo código para todos. SHA-256 de la versión por defecto: 38c2ddda858104e1…
Compruébalo tú mismo en 30 segundos
  1. Léelo: todas las líneas están arriba, con comentarios.
  2. Busca en el código fetch, XMLHttpRequest, cookie, localStorage o eval. No aparece ninguna.
  3. Abre la pestaña Red (Network) antes de ejecutarlo: solo verás peticiones a www.google.com.
¿Te ha funcionado?

Cómo usarlo

  1. Abre el sitio en Google Maps y pulsa su pestaña Reseñas. Usa Ordenar antes si quieres las más recientes o las peor valoradas arriba.
  2. Abre la consola: F12 o Ctrl+Mayús+J (Cmd+Opción+J en Mac). Si Chrome te lo pide, escribe permitir pegar (o allow pasting si lo tienes en inglés) y pulsa Enter.
  3. Elige las opciones, copia el script, pégalo en la consola y pulsa Enter. Al terminar aparece un recuadro: pulsa Descargar (o Copiar).

¿Por qué no la API de Google?

La API de Places solo devuelve las cinco reseñas «más relevantes» de cada sitio, y los scrapers de pago cobran por reseña. Este script lee la misma lista que ves en pantalla, así que las consigue todas, al ritmo al que Google Maps las carga.

Preguntas frecuentes

¿Puedo exportar las reseñas de cualquier negocio, no solo del mío?

Sí. Las reseñas de Google Maps son públicas, así que el script funciona en cualquier sitio que puedas abrir: tu negocio, la competencia o un hotel que estás comparando.

¿Cuántas reseñas puede exportar?

Todas las que cargue Google Maps, unas 10 cada uno o dos segundos. Por defecto para en 500 para que los sitios grandes no tarden una eternidad; pon el límite a 0 para sacarlas todas y deja la pestaña abierta.

¿Incluye las respuestas del propietario?

Sí. Antes se despliegan las reseñas y respuestas largas, así que el texto sale completo.

¿Por qué las fechas salen como «hace un mes»?

Es la única fecha que muestra Google Maps en la página. Ordena por Más recientes si necesitas las últimas primero.

¿Necesito iniciar sesión?

Normalmente no, pero si Google te muestra una «vista limitada» de Maps sin la pestaña Reseñas, inicia sesión en tu cuenta de Google y vuelve a probar.

Más herramientas gratis