Skip to content

Commit 3f104ec

Browse files
ion05cursoragent
andcommitted
Add a View on Goodreads button on Amazon book pages.
An Amazon listing already has the ISBN; jumping to the matching Goodreads page should not require leaving the product page to search. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent d3191ce commit 3f104ec

11 files changed

Lines changed: 377 additions & 13 deletions

File tree

‎AGENTS.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,10 @@ src/lib/dnr/fit.ts Chrome's RE2 check, resplitting a refused shard, coverag
5757
src/lib/draft.ts What the edit form edits, and the pure parsing around it
5858
src/lib/text.ts String helpers every surface shares
5959
src/lib/url.ts Small URL helpers
60+
src/lib/amazon-book.ts Amazon product HTML → ISBN → Goodreads URL. Pure.
6061
src/lib/install.ts The onInstalled branch: starter pick, rule sync, welcome tab
6162
src/background.ts MV3 service worker: listener registration, rule sync, omnibox
63+
src/content/ Isolated-world content scripts. `amazon-goodreads.ts` is IIFE-bundled.
6264
src/go/go.ts Dispatch page: resolves and navigates
6365
src/ui/dom.ts `el` / `mark` / `nextId`: the element builders every surface shares
6466
src/options/ Shortcut manager UI (below)

‎CHANGELOG.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- On Amazon.com book pages, a **View on Goodreads** button next to the title.
13+
BunnyLol reads the ISBN off the product details (locally) and opens
14+
`goodreads.com/book/isbn/…` for that same book. Pages without an ISBN are
15+
left alone.
16+
1017
## [1.1.0] - 2026-09-02
1118

1219
### Added

‎PRIVACY.md‎

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Privacy Policy
22

3-
Last updated: 2026-09-03
3+
Last updated: 2026-09-07
44

55
## Summary
66

@@ -37,15 +37,21 @@ one of your keywords, Chrome rewrites the URL to the extension's own
3737
then reads the query string, and it never goes anywhere else. Searches that
3838
do not match are left untouched and go to the search engine as normal.
3939

40-
## What the extension cannot see
41-
42-
BunnyLol has no content scripts, reads no page content, and has no access to
43-
your browsing history. It does not request the `tabs` permission. Three places
44-
open a tab, and all of them use only `chrome.tabs.create` and
45-
`chrome.tabs.update`, which do not require that permission: the toolbar popup
46-
(`src/popup/popup.ts`), the omnibox keyword (`src/background.ts`), and the
47-
welcome tab shown once on install (`src/lib/install.ts`). Neither call can
48-
read a tab, only point one at a URL.
40+
## What the extension can see
41+
42+
BunnyLol does not request the `tabs` permission and has no access to your
43+
browsing history. Three places open a tab, and all of them use only
44+
`chrome.tabs.create` and `chrome.tabs.update`, which do not require that
45+
permission: the toolbar popup (`src/popup/popup.ts`), the omnibox keyword
46+
(`src/background.ts`), and the welcome tab shown once on install
47+
(`src/lib/install.ts`). Neither call can read a tab, only point one at a URL.
48+
49+
The one content script is `src/content/amazon-goodreads.ts`, injected only on
50+
`amazon.com` product pages. It reads the page locally to find a labelled ISBN
51+
(or a JSON-LD `isbn`) and, when it finds one, draws a button. Clicking that
52+
button navigates your tab to Goodreads. The ISBN never leaves the browser
53+
except as the path of that navigation you started. Pages without an ISBN are
54+
untouched. No other site is injected into.
4955

5056
## Third parties
5157

‎README.md‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ It is an independent, unofficial project, inspired by an internal tool at Meta c
2121

2222
Manifest V3. 93 shortcuts ship across 180 keywords, and every one of them can be renamed, re-keyed,
2323
moved, switched off or deleted. No runtime dependencies, no network requests of its own, and nothing
24-
leaves your machine. See [PRIVACY.md](PRIVACY.md).
24+
leaves your machine. On Amazon.com book pages a content script reads the ISBN locally to offer a
25+
Goodreads button. See [PRIVACY.md](PRIVACY.md).
2526

2627
## A look inside
2728

@@ -38,6 +39,11 @@ before you save:
3839

3940
![BunnyLol shortcut editor with a live MDN preview](docs/images/editor.png)
4041

42+
On an Amazon.com book page, **View on Goodreads** sits under the title and opens
43+
the same book on Goodreads from its ISBN. No ISBN, no button.
44+
45+
![View on Goodreads button on an Amazon book page](docs/images/amazon-goodreads-button.png)
46+
4147
The toolbar popup gives you autocomplete when you do not want to leave the current page:
4248

4349
<p align="center">
379 KB
Loading
207 KB
Loading

‎public/manifest.json‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,13 @@
3838
"page": "options.html",
3939
"open_in_tab": true
4040
},
41+
"content_scripts": [
42+
{
43+
"matches": ["https://www.amazon.com/*", "https://amazon.com/*", "https://smile.amazon.com/*"],
44+
"js": ["amazon-goodreads.js"],
45+
"run_at": "document_idle"
46+
}
47+
],
4148
"web_accessible_resources": [
4249
{
4350
"resources": ["go.html"],

‎src/content/amazon-goodreads.ts‎

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
/**
2+
* Amazon book pages: a button next to the title that opens the same book on
3+
* Goodreads. Isolated world, shadow DOM, textContent only. The page is never
4+
* parsed as HTML we did not write (invariant 11).
5+
*/
6+
7+
import { extractIsbn, goodreadsUrlForIsbn, isAmazonProductPath } from '../lib/amazon-book';
8+
9+
const HOST_ID = 'bunnylol-goodreads';
10+
11+
/**
12+
* Copied by hand from design/tokens.css, the same way go.html does. This sheet
13+
* lives in a shadow root on amazon.com, so the extension's tokens.css is not
14+
* on the page, and Inter is not loaded there either. The sand fill and dark
15+
* label are the brand pair that already meets contrast on a white Amazon page.
16+
*/
17+
const SHADOW_CSS = `
18+
:host { all: initial; }
19+
.wrap { display: block; margin: 8px 0 12px; }
20+
button {
21+
font: 600 13px/1.3 -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif;
22+
background: #e1ab76;
23+
color: #1a1a1a;
24+
border: 0;
25+
border-radius: 6px;
26+
padding: 6px 12px;
27+
cursor: pointer;
28+
display: inline-flex;
29+
align-items: center;
30+
gap: 6px;
31+
}
32+
button:hover { background: #d69b5d; }
33+
button:focus-visible { outline: 2px solid #895420; outline-offset: 2px; }
34+
svg { width: 14px; height: 14px; flex: none; }
35+
`;
36+
37+
let mountedFor: string | null = null;
38+
let debounce = 0;
39+
let lastHref = location.href;
40+
41+
function refresh(): void {
42+
if (location.href !== lastHref) {
43+
lastHref = location.href;
44+
unmount();
45+
mountedFor = null;
46+
}
47+
48+
if (!isAmazonProductPath(location.pathname)) {
49+
unmount();
50+
mountedFor = null;
51+
return;
52+
}
53+
54+
if (mountedFor && document.getElementById(HOST_ID)) return;
55+
56+
const isbn = extractIsbn(document.documentElement.innerHTML);
57+
if (!isbn) {
58+
unmount();
59+
mountedFor = null;
60+
return;
61+
}
62+
63+
const url = goodreadsUrlForIsbn(isbn);
64+
unmount();
65+
mount(url);
66+
mountedFor = url;
67+
}
68+
69+
function schedule(): void {
70+
window.clearTimeout(debounce);
71+
debounce = window.setTimeout(refresh, 120);
72+
}
73+
74+
function mount(url: string): void {
75+
const host = document.createElement('div');
76+
host.id = HOST_ID;
77+
const shadow = host.attachShadow({ mode: 'open' });
78+
79+
const style = document.createElement('style');
80+
style.textContent = SHADOW_CSS;
81+
82+
const wrap = document.createElement('div');
83+
wrap.className = 'wrap';
84+
85+
const button = document.createElement('button');
86+
button.type = 'button';
87+
button.setAttribute('aria-label', 'View this book on Goodreads (BunnyLol)');
88+
button.title = 'Open the Goodreads page for this book';
89+
button.append(bookIcon(), document.createTextNode('View on Goodreads'));
90+
button.addEventListener('click', (event) => {
91+
event.preventDefault();
92+
event.stopPropagation();
93+
location.assign(url);
94+
});
95+
96+
wrap.append(button);
97+
shadow.append(style, wrap);
98+
99+
const target = mountPoint();
100+
if (target) {
101+
target.insertAdjacentElement('afterend', host);
102+
return;
103+
}
104+
host.style.position = 'fixed';
105+
host.style.top = '72px';
106+
host.style.right = '16px';
107+
host.style.zIndex = '2147483646';
108+
document.documentElement.append(host);
109+
}
110+
111+
function unmount(): void {
112+
document.getElementById(HOST_ID)?.remove();
113+
}
114+
115+
function mountPoint(): Element | null {
116+
return (
117+
document.querySelector('#productTitle') ??
118+
document.querySelector('#title') ??
119+
document.querySelector('#titleSection')
120+
);
121+
}
122+
123+
function bookIcon(): SVGSVGElement {
124+
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
125+
svg.setAttribute('viewBox', '0 0 16 16');
126+
svg.setAttribute('aria-hidden', 'true');
127+
svg.setAttribute('focusable', 'false');
128+
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
129+
path.setAttribute('d', 'M3 2.5h6.5A2.5 2.5 0 0 1 12 5v8.5H5.5A2.5 2.5 0 0 0 3 11V2.5zm0 0V11');
130+
path.setAttribute('fill', 'none');
131+
path.setAttribute('stroke', 'currentColor');
132+
path.setAttribute('stroke-width', '1.5');
133+
path.setAttribute('stroke-linecap', 'round');
134+
path.setAttribute('stroke-linejoin', 'round');
135+
svg.append(path);
136+
return svg;
137+
}
138+
139+
function boot(): void {
140+
refresh();
141+
const observer = new MutationObserver(schedule);
142+
observer.observe(document.documentElement, { childList: true, subtree: true });
143+
}
144+
145+
if (document.readyState === 'loading') {
146+
document.addEventListener('DOMContentLoaded', boot, { once: true });
147+
} else {
148+
boot();
149+
}

‎src/lib/amazon-book.ts‎

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/**
2+
* Turn an Amazon product page into a Goodreads destination.
3+
*
4+
* Pure: HTML in, a URL or null out. The content script is the only runtime
5+
* caller; the tests are the other. Matching the labelled ISBN fields (and
6+
* JSON-LD `isbn`) is the whole signal. Scanning for any 13-digit run would
7+
* follow a carousel ASIN onto the wrong book.
8+
*/
9+
10+
const ISBN_13_RE = /97[89](?:\D*\d){10}/;
11+
const ISBN_10_RE = /\b\d(?:\D*\d){8}[\dXx]\b/;
12+
13+
/** Amazon inserts these between "ISBN-13" and the number. */
14+
const LABEL_WINDOW = 400;
15+
16+
export function goodreadsUrlForIsbn(isbn: string): string {
17+
return `https://www.goodreads.com/book/isbn/${isbn}`;
18+
}
19+
20+
/** `/dp/ASIN` and the older product paths. Search pages are not books. */
21+
export function isAmazonProductPath(pathname: string): boolean {
22+
return /\/(?:dp|gp\/product|gp\/aw\/d)\//i.test(pathname);
23+
}
24+
25+
/**
26+
* ISBN-13 wins when both are on the page, because that is the one Goodreads
27+
* indexes most reliably. Null when the page never names a book.
28+
*/
29+
export function extractIsbn(html: string): string | null {
30+
return isbnFromJsonLd(html) ?? isbnAfterLabel(html, 'ISBN-13') ?? isbnAfterLabel(html, 'ISBN-10');
31+
}
32+
33+
function isbnAfterLabel(html: string, label: 'ISBN-13' | 'ISBN-10'): string | null {
34+
const haystack = html.toUpperCase();
35+
const at = haystack.indexOf(label);
36+
if (at < 0) return null;
37+
const snippet = stripMarkup(html.slice(at + label.length, at + label.length + LABEL_WINDOW));
38+
return label === 'ISBN-13' ? findIsbn13(snippet) : findIsbn10(snippet);
39+
}
40+
41+
function isbnFromJsonLd(html: string): string | null {
42+
const re = /<script\b[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
43+
for (const match of html.matchAll(re)) {
44+
const body = match[1];
45+
if (!body) continue;
46+
try {
47+
const found = isbnFromUnknown(JSON.parse(body) as unknown);
48+
if (found) return found;
49+
} catch {
50+
// Amazon's JSON-LD is occasionally not JSON. The labelled fields still
51+
// sit in the product details, so a parse miss is not a miss of the book.
52+
}
53+
}
54+
return null;
55+
}
56+
57+
function isbnFromUnknown(value: unknown): string | null {
58+
if (typeof value === 'string') return findIsbn13(value) ?? findIsbn10(value);
59+
if (Array.isArray(value)) {
60+
for (const entry of value) {
61+
const found = isbnFromUnknown(entry);
62+
if (found) return found;
63+
}
64+
return null;
65+
}
66+
if (!value || typeof value !== 'object') return null;
67+
const record = value as Record<string, unknown>;
68+
if (Object.hasOwn(record, 'isbn')) {
69+
const found = isbnFromUnknown(record.isbn);
70+
if (found) return found;
71+
}
72+
if (Object.hasOwn(record, '@graph')) return isbnFromUnknown(record['@graph']);
73+
return null;
74+
}
75+
76+
function findIsbn13(text: string): string | null {
77+
const match = text.match(ISBN_13_RE);
78+
if (!match) return null;
79+
const digits = match[0].replace(/\D/g, '');
80+
return digits.length === 13 ? digits : null;
81+
}
82+
83+
function findIsbn10(text: string): string | null {
84+
const match = text.match(ISBN_10_RE);
85+
if (!match) return null;
86+
const compact = match[0].toUpperCase().replace(/[^0-9X]/g, '');
87+
return compact.length === 10 ? compact : null;
88+
}
89+
90+
function stripMarkup(html: string): string {
91+
return html.replace(/<[^>]+>/g, ' ').replace(/&nbsp;|&#160;|&zwnj;|&#8203;/gi, ' ');
92+
}

0 commit comments

Comments
 (0)