Visibility internal Owner _ Approver _ Created _ Updated _
Goodreads_List_to_CSV.html
| Field | Value |
|---|---|
| Type | HTML |
| Source | Product/Projects/Goodreads/Goodreads_List_to_CSV.html |
| Parent | Product |
| GitHub | Product/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>