// ==UserScript== // @name Booklists "Free" Link Extractor // @namespace http://tampermonkey.net/ // @version 1.0 // @description Extract only FREE Kindle/Kobo/Amazon links from BookBub, BookSends, eReaderIQ, Freebooksy, and The Fussy Librarian emails - Export as HTML bookmarks or plain text // @author Jadehawk // @license MIT // @match https://outlook.live.com/* // @match https://outlook.office.com/* // @match https://outlook.office365.com/* // @match https://mail.google.com/* // @match https://mail.proton.me/* // @match https://mail.protonmail.com/* // @grant none // ==/UserScript== /* * MIT License * * Copyright (c) 2026 Jadehawk (Techy-Notes.com) * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in all * copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ (function () { "use strict"; const LINKS_KEY = "tempFreeBooksLinks"; function getDateString() { const d = new Date(); const month = String(d.getMonth() + 1).padStart(2, "0"); const day = String(d.getDate()).padStart(2, "0"); const year = d.getFullYear(); return `${month}-${day}-${year}`; } function decodeSafeLink(url) { try { const u = new URL(url); if (u.hostname.includes("safelinks.protection.outlook.com")) { const real = u.searchParams.get("url"); if (real) return decodeURIComponent(real); } } catch (e) {} return url; } function normalizeText(elOrString) { const s = typeof elOrString === "string" ? elOrString : elOrString?.textContent || ""; return s.trim().replace(/\s+/g, " "); } function storeItems(items) { const existing = JSON.parse(localStorage.getItem(LINKS_KEY) || "[]"); const uniqueItems = items.filter( (item) => !existing.some((e) => e.url === item.url) ); const updated = existing.concat(uniqueItems); localStorage.setItem(LINKS_KEY, JSON.stringify(updated)); return updated.length; } function getEmailRoot() { // Check if we're in ProtonMail - email content is in iframe[0] const isProtonMail = window.location.hostname.includes('mail.proton') || window.location.hostname.includes('protonmail.com'); if (isProtonMail) { const iframes = document.querySelectorAll('iframe'); if (iframes.length > 0) { try { const iframeDoc = iframes[0].contentDocument || iframes[0].contentWindow.document; if (iframeDoc && iframeDoc.body) { return iframeDoc.body; } } catch(e) { console.error('Cannot access ProtonMail iframe:', e); } } } // Fallback for Outlook/Gmail (email content in main page) return document.querySelector("div[role='document']") || document.body; } // --- UI Styling --- const style = document.createElement("style"); style.textContent = ` .book-export-btn { position: fixed; right: 60px; padding: 8px 14px; font-size: 14px; font-weight: 600; background-color: #4a90e2; color: #fff; border: none; border-radius: 6px; box-shadow: 0 2px 6px rgba(0,0,0,0.2); cursor: pointer; z-index: 9999; transition: background-color 0.2s ease; } .book-export-btn:hover { background-color: #357ABD; } .book-export-separator { position: fixed; right: 60px; width: 160px; height: 2px; background-color: #999; z-index: 9999; } /* Modal Styles - Dark Theme */ .book-export-modal { display: none; position: fixed; z-index: 10000; background-color: #1e1e1e; border: 2px solid #4a90e2; border-radius: 8px; padding: 15px; box-shadow: 0 4px 20px rgba(0,0,0,0.7); min-width: 180px; max-height: 80vh; overflow-y: auto; } .book-export-modal.show { display: block; } .book-export-modal-btn { display: block; width: 100%; padding: 10px 14px; margin: 5px 0; font-size: 14px; font-weight: 600; background-color: #4a90e2; color: #fff; border: none; border-radius: 6px; cursor: pointer; transition: background-color 0.2s ease; } .book-export-modal-btn:hover { background-color: #5ba3ff; } .book-export-modal-btn-help { display: block; width: 100%; padding: 10px 14px; margin: 5px 0; font-size: 14px; font-weight: 600; background-color: #28a745; color: #fff; border: none; border-radius: 6px; cursor: pointer; transition: background-color 0.2s ease; } .book-export-modal-btn-help:hover { background-color: #34d058; } .book-export-modal-title { font-size: 13px; font-weight: 600; color: #e0e0e0; margin-bottom: 10px; text-align: center; border-bottom: 1px solid #404040; padding-bottom: 8px; } .book-export-modal-separator { height: 1px; background-color: #404040; margin: 10px 0; } `; document.head.appendChild(style); // --- Auto Extract button --- const btnAuto = document.createElement("button"); btnAuto.innerText = "⚔ Auto Extract"; btnAuto.className = "book-export-btn"; btnAuto.style.top = "130px"; btnAuto.title = "Left-click to extract | Right-click for more options"; document.body.appendChild(btnAuto); // --- Modal for additional options --- const modal = document.createElement("div"); modal.className = "book-export-modal"; document.body.appendChild(modal); const modalTitle = document.createElement("div"); modalTitle.className = "book-export-modal-title"; modalTitle.innerText = "Quick Actions"; modal.appendChild(modalTitle); // --- Buttons in modal --- const btnTxt = document.createElement("button"); btnTxt.innerText = "šŸ’¾ Save as TEXT"; btnTxt.className = "book-export-modal-btn"; modal.appendChild(btnTxt); const btnClear = document.createElement("button"); btnClear.innerText = "šŸ—‘ļø Clear Extracted Links"; btnClear.className = "book-export-modal-btn"; modal.appendChild(btnClear); // Add separator before help button const separator = document.createElement("div"); separator.className = "book-export-modal-separator"; modal.appendChild(separator); const btnHelp = document.createElement("button"); btnHelp.innerText = "šŸ“– Help / Tips"; btnHelp.className = "book-export-modal-btn-help"; modal.appendChild(btnHelp); // --- Modal Control Functions --- function showModal(x, y) { // First, make modal visible to calculate its dimensions modal.style.left = `${x}px`; modal.style.top = `${y}px`; modal.classList.add("show"); // Get modal dimensions const modalRect = modal.getBoundingClientRect(); const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; // Adjust horizontal position if modal goes off right edge let adjustedX = x; if (modalRect.right > viewportWidth) { adjustedX = viewportWidth - modalRect.width - 10; // 10px padding from edge } // Adjust vertical position if modal goes off bottom edge let adjustedY = y; if (modalRect.bottom > viewportHeight) { adjustedY = viewportHeight - modalRect.height - 10; // 10px padding from edge } // Ensure modal doesn't go off left edge if (adjustedX < 10) { adjustedX = 10; } // Ensure modal doesn't go off top edge if (adjustedY < 10) { adjustedY = 10; } // Apply adjusted position modal.style.left = `${adjustedX}px`; modal.style.top = `${adjustedY}px`; } function hideModal() { modal.classList.remove("show"); } // --- Auto Extract All --- btnAuto.addEventListener("click", (e) => { e.stopPropagation(); // Prevent event from bubbling const root = getEmailRoot(); const emailContent = root.textContent || ""; // Check for emails that never have free books const isReadworthy = /Readworthy/i.test(emailContent); const isNetGalley = /NetGalley/i.test(emailContent); if (isReadworthy) { alert("Auto Extract: This is a 'Readworthy by BookBub' email which does not contain FREE books."); return; } if (isNetGalley) { alert("Auto Extract: This is a 'NetGalley' email which does not contain FREE books."); return; } const results = { BookBub: extractBookBub(), BookSends: extractBookSends(), eReaderIQ: extractEReaderIQ(), Freebooksy: extractFreebooksy(), "Fussy Librarian": extractFussyLibrarian(), }; const allCollected = []; const summary = []; for (const [source, items] of Object.entries(results)) { if (items.length > 0) { allCollected.push(...items); summary.push(`${source}: ${items.length}`); } } if (!allCollected.length) { alert("Auto Extract: No FREE books found from any source."); return; } const total = storeItems(allCollected); const detectedSources = summary.join("\n"); alert( `Auto Extract Complete!\n\nDetected sources:\n${detectedSources}\n\nTotal extracted: ${allCollected.length} FREE links\nTotal stored: ${total}` ); }); // Right-click on Auto button to show modal btnAuto.addEventListener("contextmenu", (e) => { e.preventDefault(); e.stopPropagation(); const rect = btnAuto.getBoundingClientRect(); // Position modal to the left of the button instead of aligned with it const modalX = rect.left - 200; // Offset to the left (modal width + some padding) const modalY = rect.bottom + 5; showModal(modalX, modalY); }); // Click outside modal to close it (but don't interfere with button clicks) document.addEventListener("click", (e) => { // Only close modal if it's open and click is outside both modal and button if (modal.classList.contains("show") && !modal.contains(e.target) && e.target !== btnAuto) { hideModal(); } }); // Close modal on ESC key document.addEventListener("keydown", (e) => { if (e.key === "Escape") { hideModal(); } }); // --- Extraction Functions --- function extractBookBub() { const root = getEmailRoot(); const bookTables = Array.from(root.querySelectorAll("table.x_bk-book")); const collected = []; const urls = new Set(); for (const tbl of bookTables) { const priceEl = tbl.querySelector(".x_bk-deal-price"); if (!priceEl) continue; if (!/free!/i.test(priceEl.textContent)) continue; const titleEl = tbl.querySelector(".x_bk-title-link"); const titleText = titleEl ? normalizeText(titleEl) : "Untitled"; const storeAnchors = Array.from(tbl.querySelectorAll("a")).filter((a) => { const txt = normalizeText(a); const alt = a.querySelector("img")?.alt || ""; return ( /(Amazon|Kindle|Kobo)/i.test(txt) || /(Amazon|Kindle|Kobo)/i.test(alt) ); }); for (const a of storeAnchors) { const url = decodeSafeLink(a.href); if (urls.has(url)) continue; urls.add(url); collected.push({ url: url, label: `${normalizeText(a)} – ${titleText} (Free)`, }); } } return collected; } function extractEReaderIQ() { const root = getEmailRoot(); const collected = []; const urls = new Set(); const bookCells = Array.from( root.querySelectorAll("td.x_col-break-520") ).filter((td) => { // Look for FREE price - try both with and without space in style const priceEl = td.querySelector('b[style*="font-size: 24px"], b[style*="font-size:24px"]'); return priceEl && normalizeText(priceEl) === "FREE"; }); for (const td of bookCells) { const titleLink = td.querySelector("a.x_heading"); const titleEl = titleLink?.querySelector("b"); const titleText = titleEl ? normalizeText(titleEl) : "Untitled"; const authorDiv = td.querySelector('div[style*="margin-top: 5px"]'); const authorText = authorDiv ? normalizeText(authorDiv) : ""; const getItNowLink = Array.from(td.querySelectorAll("a")).find((a) => normalizeText(a).includes("Get It Now") ); if (!getItNowLink) continue; const url = decodeSafeLink(getItNowLink.href); if (urls.has(url)) continue; urls.add(url); const label = authorText ? `Amazon – ${titleText} – ${authorText} (Free)` : `Amazon – ${titleText} (Free)`; collected.push({ url: url, label: label, }); } return collected; } function extractFreebooksy() { const root = getEmailRoot(); const collected = []; const urls = new Set(); const storeButtons = Array.from( root.querySelectorAll("a.x_button-a") ).filter((a) => { const span = a.querySelector("span.x_button-link"); if (!span) return false; return /(Amazon|Kindle|Kobo)/i.test(span.textContent); }); for (const btn of storeButtons) { const bookBlock = btn.closest("table")?.parentNode?.closest("table") || btn.closest("table") || btn.closest("div"); const titleEl = bookBlock?.querySelector("h1, h2, h3, a"); const hasFree = bookBlock && /free/i.test(bookBlock.textContent); if (!hasFree) continue; const titleText = titleEl ? normalizeText(titleEl) : "Untitled"; const span = btn.querySelector("span.x_button-link"); const storeText = span ? normalizeText(span) : "Store"; const url = decodeSafeLink(btn.href); if (urls.has(url)) continue; urls.add(url); collected.push({ url: url, label: `${storeText} – ${titleText} (Free)`, }); } return collected; } function extractFussyLibrarian() { const root = getEmailRoot(); const collected = []; const urls = new Set(); // Exclude elements from other email formats to avoid false positives const storeAnchors = Array.from(root.querySelectorAll("a")).filter((a) => { // Skip Freebooksy-specific buttons if (a.classList.contains("x_button-a")) return false; if (a.querySelector("span.x_button-link")) return false; // Skip BookBub-specific elements if (a.closest("table.x_bk-book")) return false; if (a.classList.contains("x_bk-title-link")) return false; // Skip eReaderIQ-specific elements if (a.classList.contains("x_heading")) return false; if (a.closest("td.x_col-break-520")) return false; const txt = normalizeText(a); const alt = a.querySelector("img")?.alt || ""; return ( /(Amazon|Kindle|Kobo)/i.test(txt) || /(Amazon|Kindle|Kobo)/i.test(alt) ); }); for (const a of storeAnchors) { const bookBlock = a.closest("table") || a.closest("div"); const titleEl = bookBlock?.querySelector("h1, h2, h3, strong, a"); const titleText = titleEl ? normalizeText(titleEl) : "Untitled"; const url = decodeSafeLink(a.href); if (urls.has(url)) continue; urls.add(url); collected.push({ url: url, label: `${normalizeText(a)} – ${titleText} (Free)`, }); } return collected; } function extractBookSends() { const root = getEmailRoot(); const collected = []; const urls = new Set(); // Exclude BookBub and other email format tables to avoid false positives const bookTables = Array.from(root.querySelectorAll("table")).filter( (tbl) => { // Skip BookBub-specific tables if (tbl.classList.contains("x_bk-book")) return false; if (tbl.querySelector(".x_bk-deal-price")) return false; if (tbl.querySelector(".x_bk-title-link")) return false; // Skip Freebooksy-specific tables if (tbl.querySelector("a.x_button-a")) return false; // Skip eReaderIQ-specific tables if (tbl.querySelector("td.x_col-break-520")) return false; // Only include tables with "free!" text return /free!/i.test(tbl.textContent); } ); for (const tbl of bookTables) { const titleEl = tbl.querySelector("h3"); const titleText = titleEl ? normalizeText(titleEl) : "Untitled"; const linkEl = Array.from(tbl.querySelectorAll("a")).find((a) => normalizeText(a).includes("FIND OUT MORE") ); if (!linkEl) continue; const url = decodeSafeLink(linkEl.href); if (urls.has(url)) continue; urls.add(url); collected.push({ url: url, label: `Amazon – ${titleText} (Free)`, }); } return collected; } // --- Save as TEXT file --- btnTxt.addEventListener("click", () => { const stored = JSON.parse(localStorage.getItem(LINKS_KEY) || "[]"); if (!stored.length) { alert("No links stored. Extract first."); return; } const chunkSize = 50; const totalChunks = Math.ceil(stored.length / chunkSize); const dateStr = getDateString(); for (let i = 0; i < totalChunks; i++) { const start = i * chunkSize; const end = Math.min(start + chunkSize, stored.length); const chunk = stored.slice(start, end); const text = chunk.map((l) => l.url).join("\n"); const blob = new Blob([text], { type: "text/plain" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `freebooks - ${dateStr} - part${i + 1}.txt`; a.click(); setTimeout(() => URL.revokeObjectURL(url), 2000); } alert( `Generated ${totalChunks} text file(s) with ${stored.length} FREE links total (${chunkSize} links max per file). If downloads didn't start, save as .txt.` ); if (confirm("Do you want to clear the stored links now?")) { localStorage.removeItem(LINKS_KEY); alert("Stored links cleared."); } }); // --- Clear stored links --- btnClear.addEventListener("click", () => { localStorage.removeItem(LINKS_KEY); alert("All extracted links cleared. Start fresh."); }); // --- Help button --- btnHelp.addEventListener("click", () => { hideModal(); alert("šŸ“– Booklists \"Free\" Link Extractor\n\nšŸ“š SUPPORTED LISTS:\n• BookBub\n• BookSends\n• eReaderIQ\n• Freebooksy\n• Fussy Librarian\n\n✨ RECOMMENDED TOOL:\n\"Bulk URL Opener - Open Multiple URLs or Search Queries\"\nBy: Aman Panchal\n\nSearch for it in your browser's extension store to open all extracted links at once!"); }); })();