bookfinder-mcp 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ #!/usr/bin/env node
package/dist/index.js ADDED
@@ -0,0 +1,217 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
+ import { z } from "zod";
7
+
8
+ // src/scraper.ts
9
+ import { chromium } from "playwright-core";
10
+ var BASE_URL = "https://www.bookfinder.com";
11
+ var TIMEOUT = 3e4;
12
+ var CONDITION_MAP = { new: "NEW", used: "USED", all: "ANY" };
13
+ var CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || void 0;
14
+ function decodeHtmlEntities(s) {
15
+ return s.replace(/&amp;/g, "&").replace(/&#38;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">");
16
+ }
17
+ async function withBrowser(fn) {
18
+ const browser = await chromium.launch({
19
+ headless: true,
20
+ ...CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {},
21
+ args: ["--no-sandbox", "--disable-setuid-sandbox"]
22
+ });
23
+ try {
24
+ return await fn(browser);
25
+ } finally {
26
+ await browser.close();
27
+ }
28
+ }
29
+ async function newPage(browser) {
30
+ const context = await browser.newContext({
31
+ userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
32
+ viewport: { width: 1280, height: 800 },
33
+ locale: "en-US"
34
+ });
35
+ return { page: await context.newPage(), context };
36
+ }
37
+ async function searchBooks(query, condition = "all") {
38
+ const conditionCode = CONDITION_MAP[condition] ?? "ANY";
39
+ const encoded = encodeURIComponent(query);
40
+ const url = `${BASE_URL}/search/?keywords=${encoded}&condition=${conditionCode}&currency=CAD&destination=CA&language=en`;
41
+ return withBrowser(async (browser) => {
42
+ const { page, context } = await newPage(browser);
43
+ try {
44
+ await page.goto(url, { timeout: TIMEOUT, waitUntil: "networkidle" });
45
+ const html = await page.content();
46
+ const bunchLinks = [...html.matchAll(/href="(\/search\?[^"]*bunchKey=[^"]+)"/g)].map((m) => decodeHtmlEntities(m[1])).filter((href) => {
47
+ const val = href.match(/bunchKey=([^&]*)/)?.[1] ?? "";
48
+ return val.length > 0;
49
+ });
50
+ if (bunchLinks.length === 0) {
51
+ const pageTitle = await page.title().catch(() => "(unknown)");
52
+ throw new Error(`No results found (page: "${pageTitle}"). Is bookfinder.com reachable?`);
53
+ }
54
+ await page.goto(`${BASE_URL}${bunchLinks[0]}`, { timeout: TIMEOUT, waitUntil: "networkidle" });
55
+ const hasCards = await page.waitForSelector('[data-test-id^="search-offer-card-NON-RENTAL-"]', {
56
+ timeout: TIMEOUT,
57
+ state: "attached"
58
+ }).then(() => true).catch(() => false);
59
+ if (!hasCards) return [];
60
+ const cards = await page.$$('[data-test-id^="search-offer-card-NON-RENTAL-"]');
61
+ const isbnMap = /* @__PURE__ */ new Map();
62
+ for (const card of cards) {
63
+ try {
64
+ const isbn = (await card.getAttribute("data-csa-c-isbn") ?? "").trim();
65
+ const title = (await card.getAttribute("data-csa-c-title") ?? "").trim();
66
+ const author = (await card.getAttribute("data-csa-c-authors") ?? "").trim();
67
+ const priceStr = (await card.getAttribute("data-csa-c-usdprice") ?? "").trim();
68
+ const cond = (await card.getAttribute("data-csa-c-condition") ?? "").trim().toLowerCase();
69
+ if (!isbn || !priceStr) continue;
70
+ const price = parseFloat(priceStr);
71
+ if (isNaN(price)) continue;
72
+ const existing = isbnMap.get(isbn);
73
+ if (!existing || price < existing.price) {
74
+ isbnMap.set(isbn, { title, author, isbn, price, condition: cond || condition });
75
+ }
76
+ } catch {
77
+ continue;
78
+ }
79
+ }
80
+ return [...isbnMap.values()].slice(0, 20).map((d) => ({
81
+ title: d.title,
82
+ author: d.author,
83
+ isbn: d.isbn,
84
+ lowest_price: d.price,
85
+ condition: d.condition
86
+ }));
87
+ } finally {
88
+ await context.close();
89
+ }
90
+ });
91
+ }
92
+ async function getPrices(isbn) {
93
+ const clean = isbn.replace(/-/g, "").trim();
94
+ if (!/^\d+$/.test(clean)) throw new Error(`Invalid ISBN format: ${isbn}`);
95
+ const url = `${BASE_URL}/search/?isbn=${clean}&new_used=*&destination=ca&currency=CAD&mode=basic&st=sr&ac=qr`;
96
+ return withBrowser(async (browser) => {
97
+ const { page, context } = await newPage(browser);
98
+ try {
99
+ await page.goto(url, { timeout: TIMEOUT, waitUntil: "networkidle" });
100
+ const hasCards = await page.waitForSelector('[data-test-id^="search-offer-card-NON-RENTAL-"]', {
101
+ timeout: TIMEOUT,
102
+ state: "attached"
103
+ }).then(() => true).catch(() => false);
104
+ if (!hasCards) return [];
105
+ const cards = await page.$$('[data-test-id^="search-offer-card-NON-RENTAL-"]');
106
+ const listings = [];
107
+ for (const card of cards) {
108
+ try {
109
+ const seller = (await card.getAttribute("data-csa-c-seller") ?? "").trim();
110
+ const priceStr = (await card.getAttribute("data-csa-c-usdprice") ?? "").trim();
111
+ const shippingStr = (await card.getAttribute("data-csa-c-usdshipping") ?? "0").trim();
112
+ const cond = (await card.getAttribute("data-csa-c-condition") ?? "").trim();
113
+ const linkEl = await card.$('a[href^="http"]');
114
+ if (!seller || !priceStr || !linkEl) continue;
115
+ const buyUrl = (await linkEl.getAttribute("href") ?? "").trim();
116
+ if (!buyUrl) continue;
117
+ const price = parseFloat(priceStr);
118
+ const shipping = parseFloat(shippingStr) || 0;
119
+ if (isNaN(price)) continue;
120
+ listings.push({ seller, price, shipping, total: Math.round((price + shipping) * 100) / 100, condition: cond, buy_url: buyUrl });
121
+ } catch {
122
+ continue;
123
+ }
124
+ }
125
+ return listings.sort((a, b) => a.total - b.total);
126
+ } finally {
127
+ await context.close();
128
+ }
129
+ });
130
+ }
131
+ async function getBookDetails(isbn) {
132
+ const clean = isbn.replace(/-/g, "").trim();
133
+ if (!/^\d+$/.test(clean)) throw new Error(`Invalid ISBN format: ${isbn}`);
134
+ const url = `${BASE_URL}/isbn/${clean}/`;
135
+ return withBrowser(async (browser) => {
136
+ const { page, context } = await newPage(browser);
137
+ try {
138
+ await page.goto(url, { timeout: TIMEOUT, waitUntil: "networkidle" });
139
+ await page.waitForSelector('meta[itemprop="name"]', { timeout: TIMEOUT, state: "attached" });
140
+ const title = (await page.$eval('meta[itemprop="name"]', (el) => el.getAttribute("content") ?? "").catch(() => "")).trim();
141
+ const author = (await page.$eval('meta[itemprop="author"]', (el) => el.getAttribute("content") ?? "").catch(() => "")).trim();
142
+ if (!title) throw new Error("Could not extract book title from page");
143
+ const getText = async (selector) => {
144
+ try {
145
+ return (await page.$eval(selector, (el) => el.textContent ?? "")).trim() || void 0;
146
+ } catch {
147
+ return void 0;
148
+ }
149
+ };
150
+ let publisher;
151
+ let year;
152
+ try {
153
+ const pubRowEl = await page.$('span.font-medium:text("Publisher:")');
154
+ if (pubRowEl) {
155
+ const rowText = await pubRowEl.evaluate((el) => el.parentElement?.textContent ?? "");
156
+ const boldEl = await page.$('span.font-medium:text("Publisher:") + span');
157
+ if (boldEl) publisher = (await boldEl.textContent() ?? "").trim() || void 0;
158
+ const yearMatch = rowText.match(/\b(19|20)\d{2}\b/);
159
+ if (yearMatch) year = parseInt(yearMatch[0]);
160
+ }
161
+ } catch {
162
+ }
163
+ const edition = await getText('span.font-medium:text("Edition:") + span');
164
+ const description = await getText("aside .text-sm.leading-relaxed");
165
+ return { title, author, isbn13: clean, publisher, year, edition, description };
166
+ } finally {
167
+ await context.close();
168
+ }
169
+ });
170
+ }
171
+
172
+ // src/index.ts
173
+ var server = new McpServer({ name: "bookfinder", version: "1.0.0" });
174
+ server.tool(
175
+ "search_books",
176
+ "Search bookfinder.com by title, author, or ISBN. Returns results with prices in CAD.",
177
+ { query: z.string(), condition: z.enum(["new", "used", "all"]).optional().default("all") },
178
+ async ({ query, condition }) => {
179
+ try {
180
+ const results = await searchBooks(query, condition ?? "all");
181
+ return { content: [{ type: "text", text: JSON.stringify(results) }] };
182
+ } catch (err) {
183
+ const msg = err instanceof Error ? err.message : String(err);
184
+ return { content: [{ type: "text", text: `search_books error: ${msg}` }], isError: true };
185
+ }
186
+ }
187
+ );
188
+ server.tool(
189
+ "get_prices",
190
+ "Get full price listing across all sellers for a given ISBN. Prices in CAD, sorted cheapest first.",
191
+ { isbn: z.string() },
192
+ async ({ isbn }) => {
193
+ try {
194
+ const listings = await getPrices(isbn);
195
+ return { content: [{ type: "text", text: JSON.stringify(listings) }] };
196
+ } catch (err) {
197
+ const msg = err instanceof Error ? err.message : String(err);
198
+ return { content: [{ type: "text", text: `get_prices error: ${msg}` }], isError: true };
199
+ }
200
+ }
201
+ );
202
+ server.tool(
203
+ "get_book_details",
204
+ "Get metadata for a book by ISBN: title, author, publisher, year, edition, description.",
205
+ { isbn: z.string() },
206
+ async ({ isbn }) => {
207
+ try {
208
+ const details = await getBookDetails(isbn);
209
+ return { content: [{ type: "text", text: JSON.stringify(details) }] };
210
+ } catch (err) {
211
+ const msg = err instanceof Error ? err.message : String(err);
212
+ return { content: [{ type: "text", text: `get_book_details error: ${msg}` }], isError: true };
213
+ }
214
+ }
215
+ );
216
+ var transport = new StdioServerTransport();
217
+ await server.connect(transport);
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "bookfinder-mcp",
3
+ "version": "1.0.0",
4
+ "description": "MCP server for searching book prices on bookfinder.com. Runs locally via npx.",
5
+ "type": "module",
6
+ "bin": {
7
+ "bookfinder-mcp": "./dist/index.js"
8
+ },
9
+ "scripts": {
10
+ "build": "tsup && chmod +x dist/index.js",
11
+ "dev": "tsx src/index.ts",
12
+ "prepublishOnly": "npm run build"
13
+ },
14
+ "files": [
15
+ "dist"
16
+ ],
17
+ "keywords": ["mcp", "bookfinder", "books", "prices"],
18
+ "author": "jansdhillon",
19
+ "license": "MIT",
20
+ "dependencies": {
21
+ "@modelcontextprotocol/sdk": "^1.12.0",
22
+ "playwright-core": "^1.60.0",
23
+ "zod": "^3.23.8"
24
+ },
25
+ "devDependencies": {
26
+ "tsup": "^8.0.0",
27
+ "tsx": "^4.0.0",
28
+ "typescript": "^5.0.0"
29
+ },
30
+ "engines": {
31
+ "node": ">=20.0.0"
32
+ }
33
+ }