Skip to content
Visibility internal Owner _ Approver _ Created _ Updated _

Goodreads_List_to_CSV.html



FieldValue
TypeHTML
SourceProduct/Projects/Goodreads/Goodreads_List_to_CSV.html
ParentProduct
GitHubProduct/Projects/Goodreads/Goodreads_List_to_CSV.html

This page is auto-generated. Edit the source to change the content.



<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Goodreads List → CSV</title>
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <style>
      :root {
        color-scheme: light dark;
      }
      body {
        font-family:
          system-ui,
          -apple-system,
          Segoe UI,
          Roboto,
          Helvetica,
          Arial,
          sans-serif;
        margin: 2rem;
      }
      .card {
        max-width: 720px;
        padding: 1.25rem 1.5rem;
        border: 1px solid #ccc;
        border-radius: 12px;
      }
      h1 {
        font-size: 1.25rem;
        margin: 0 0 0.5rem;
      }
      p {
        margin: 0.25rem 0 0.75rem;
        opacity: 0.85;
      }
      input[type='file'] {
        display: block;
        margin: 0.75rem 0 0;
      }
      .muted {
        font-size: 0.9rem;
        opacity: 0.75;
      }
      .ok {
        color: #0a7;
      }
      .err {
        color: #c22;
      }
      pre {
        background: transparent;
        white-space: pre-wrap;
        word-break: break-word;
        background: #f6f6f6;
        padding: 0.75rem;
        border-radius: 8px;
        display: none;
      }
      .foot {
        margin-top: 0.75rem;
        font-size: 0.85rem;
        opacity: 0.7;
      }
    </style>
  </head>
  <body>
    <div class="card">
      <h1>Goodreads List → CSV (offline)</h1>
      <p>
        Select your saved Goodreads HTML (e.g., <em>Goodreads Table 01.html</em>). The CSV will
        download automatically.
      </p>
      <input id="file" type="file" accept=".html,.htm,text/html" />
      <div id="status" class="muted">Waiting for file…</div>
      <pre id="log"></pre>
      <div class="foot">Output file name: <code>goodreads_list.csv</code></div>
    </div>

    <script>
      (() => {
        const ABS_PREFIX = 'https://www.goodreads.com';
        const fileInput = document.getElementById('file');
        const statusEl = document.getElementById('status');
        const logEl = document.getElementById('log');

        const clean = (s) => (s == null ? '' : s.replace(/\s+/g, ' ').trim());
        const toAbs = (u) => {
          if (!u) return '';
          try {
            if (/^https?:\/\//i.test(u)) return u;
            if (u.startsWith('/')) return ABS_PREFIX + u;
            return u;
          } catch {
            return u;
          }
        };
        const csvEscape = (val) => {
          const s = String(val ?? '');
          return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
        };
        const parseIntSafe = (s) => {
          if (!s) return '';
          const m = String(s).match(/-?\d[\d,]*/);
          return m ? m[0].replace(/,/g, '') : '';
        };
        const parseFloatSafe = (s) => {
          if (!s) return '';
          const m = String(s).match(/-?\d+(?:\.\d+)?/);
          return m ? m[0] : '';
        };

        const getBookId = (tr, bookUrl) => {
          const hidden = tr.querySelector('input[name="book_id"][value]');
          if (hidden && hidden.getAttribute('value')) {
            const v = hidden.getAttribute('value').match(/\d+/);
            if (v) return v[0];
          }
          const anchorTarget = tr.querySelector('div.u-anchorTarget[id]');
          if (anchorTarget && anchorTarget.id) {
            const v = String(anchorTarget.id).match(/\d+/);
            if (v) return v[0];
          }
          if (bookUrl) {
            const m = bookUrl.match(/\/book\/show\/(\d+)/);
            if (m) return m[1];
          }
          return '';
        };

        function htmlToCsv(doc) {
          const rows = Array.from(
            doc.querySelectorAll('tr[itemscope][itemtype="http://schema.org/Book"]'),
          );
          const header = [
            'rank',
            'book_id',
            'title',
            'subtitle',
            'authors',
            'author_urls',
            'book_url',
            'image_url',
            'avg_rating',
            'ratings_count',
            'score',
            'num_votes',
          ];
          const csv = [header.join(',')];

          for (const tr of rows) {
            const rank = parseIntSafe(clean(tr.querySelector('td.number')?.textContent || ''));

            const bookLink = tr.querySelector('a.bookTitle[itemprop="url"]');
            const book_url = toAbs(bookLink?.getAttribute('href') || '');

            const titleRaw = clean(
              tr.querySelector('a.bookTitle[itemprop="url"] span[itemprop="name"]')?.textContent ||
                '',
            );
            let title = titleRaw,
              subtitle = '';
            const colonIdx = titleRaw.indexOf(':');
            if (colonIdx !== -1) {
              title = clean(titleRaw.slice(0, colonIdx));
              subtitle = clean(titleRaw.slice(colonIdx + 1));
            }

            const image_url = toAbs(
              tr.querySelector('img[itemprop="image"]')?.getAttribute('src') || '',
            );

            const authorBlocks = Array.from(tr.querySelectorAll('[itemprop="author"]'));
            const authors = [];
            const author_urls = [];
            for (const ab of authorBlocks) {
              const nameEl =
                ab.querySelector('[itemprop="name"]') ||
                ab.querySelector('.authorName') ||
                ab.querySelector('a, span');
              const name = clean(nameEl?.textContent || '');
              if (name) authors.push(name);

              const linkEl =
                ab.querySelector('a[itemprop="url"]') ||
                ab.querySelector('.authorName') ||
                ab.querySelector('a');
              const href = toAbs(linkEl?.getAttribute('href') || '');
              if (href) author_urls.push(href);
            }

            const mini = clean(tr.querySelector('.minirating')?.textContent || '');
            const avg_rating = parseFloatSafe(mini);
            let ratings_count = '';
            {
              const m = mini.match(/avg rating[^0-9]*([\d,]+)/i);
              ratings_count = m ? m[1].replace(/,/g, '') : '';
            }

            const aTags = Array.from(tr.querySelectorAll('a'));
            const scoreAnchor = aTags.find((a) => /^score:\s*/i.test(clean(a.textContent)));
            const score = scoreAnchor ? parseIntSafe(scoreAnchor.textContent) : '';

            const votesAnchor = aTags.find((a) => /people voted\s*$/i.test(clean(a.textContent)));
            let num_votes = '';
            if (votesAnchor) {
              const m = clean(votesAnchor.textContent).match(/(\d[\d,]*)\s+people voted/i);
              num_votes = m ? m[1].replace(/,/g, '') : '';
            }

            const book_id = getBookId(tr, book_url);

            const row = [
              rank,
              book_id,
              title,
              subtitle,
              authors.join('; '),
              author_urls.join('; '),
              book_url,
              image_url,
              avg_rating,
              ratings_count,
              score,
              num_votes,
            ]
              .map(csvEscape)
              .join(',');

            csv.push(row);
          }
          return csv.join('\n');
        }

        function downloadCsv(csvText, filename = 'goodreads_list.csv') {
          const blob = new Blob([csvText], { type: 'text/csv;charset=utf-8' });
          const a = document.createElement('a');
          a.href = URL.createObjectURL(blob);
          a.download = filename;
          document.body.appendChild(a);
          a.click();
          setTimeout(() => {
            URL.revokeObjectURL(a.href);
            a.remove();
          }, 800);
        }

        fileInput.addEventListener('change', async (e) => {
          const file = e.target.files?.[0];
          if (!file) return;
          statusEl.textContent = 'Reading file…';
          try {
            const text = await file.text();
            const parser = new DOMParser();
            const doc = parser.parseFromString(text, 'text/html');
            const csv = htmlToCsv(doc);
            const rows = csv.split('\n').length - 1; // minus header
            downloadCsv(csv);
            statusEl.innerHTML = `<span class="ok">Done.</span> Exported ${rows} rows to <code>goodreads_list.csv</code>.`;
            logEl.style.display = 'block';
            logEl.textContent = csv.split('\n').slice(0, 6).join('\n') + '\n…';
          } catch (err) {
            console.error(err);
            statusEl.innerHTML = `<span class="err">Failed:</span> ${err?.message || err}`;
          }
        });
      })();
    </script>
  </body>
</html>