// ==UserScript== // @name 豆瓣一键添书 | One-Click Add to Douban // @namespace https://github.com/lzblack // @homepageURL https://github.com/lzblack/userscripts // @supportURL https://github.com/lzblack/userscripts/issues // @version 1.0.3 // @author lzblack // @description 在 Amazon 图书页查豆瓣是否收录;未收录则一键跳转「添加书籍」流程、自动回填全字段并注入封面。人工只审核和提交。 // @match https://www.amazon.com/* // @match https://book.douban.com/new_subject* // @connect book.douban.com // @connect media-amazon.com // @connect ssl-images-amazon.com // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @grant GM_deleteValue // @grant GM_openInTab // @icon https://img3.doubanio.com/favicon.ico // @icon64 https://img3.doubanio.com/favicon.ico // @license MIT // @updateURL https://raw.githubusercontent.com/lzblack/userscripts/main/douban-add-book/douban-add-book.user.js // @downloadURL https://raw.githubusercontent.com/lzblack/userscripts/main/douban-add-book/douban-add-book.user.js // ==/UserScript== (function () { 'use strict'; /** null/undefined 安全的 String()——全脚本统一用它收口空值。 */ const str = (v) => String(v == null ? '' : v); // ============================================================ // 纯函数解析层 — 无 DOM/网络副作用(见 douban-add-book.test.js) // ============================================================ const MONTHS = { jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6, jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12, }; /** 校验 ISBN-13:长度 13、前缀 978/979、mod-10 校验位。 */ function validateIsbn13(input) { const s = str(input).replace(/[^0-9]/g, ''); if (s.length !== 13) return false; if (!/^97[89]/.test(s)) return false; let sum = 0; for (let i = 0; i < 13; i++) { sum += Number(s[i]) * (i % 2 === 0 ? 1 : 3); } return sum % 10 === 0; } /** ISBN-10 → ISBN-13(加 978 前缀、重算校验位)。非 10 位输入返回 null。 */ function isbn10to13(input) { const s = str(input).replace(/[^0-9Xx]/g, ''); if (s.length !== 10) return null; const core = '978' + s.slice(0, 9); let sum = 0; for (let i = 0; i < 12; i++) { sum += Number(core[i]) * (i % 2 === 0 ? 1 : 3); } const check = (10 - (sum % 10)) % 10; return core + check; } /** 按第一个冒号拆正/副标题(半角或全角冒号)。 */ function splitTitle(input) { const s = str(input).trim(); const idx = s.search(/[::]/); if (idx === -1) return { title: s, subtitle: '' }; return { title: s.slice(0, idx).trim(), subtitle: s.slice(idx + 1).trim() }; } /** 解析 "March 5, 2024" / "Mar 2024" / "2024" → {y,m,d}(缺位为 null);无年份返回 null。 */ function parseDate(input) { const s = str(input).trim(); const ym = s.match(/([A-Za-z]{3,})[^0-9A-Za-z]+(?:(\d{1,2})[^0-9A-Za-z]+)?(\d{4})/); if (ym) { const mon = MONTHS[ym[1].slice(0, 3).toLowerCase()]; if (mon) { return { y: Number(ym[3]), m: mon, d: ym[2] ? Number(ym[2]) : null }; } } const yOnly = s.match(/\b(\d{4})\b/); if (yOnly) return { y: Number(yOnly[1]), m: null, d: null }; return null; } /** 拆 "Penguin Press (March 5, 2024)" → {publisher, date|null}。无括号日期则 date=null。 */ function splitPublisherDate(input) { const s = str(input).trim(); const m = s.match(/^(.*?)\s*\(([^)]*)\)\s*$/); if (m) { const date = parseDate(m[2]); if (date) return { publisher: m[1].trim(), date }; } return { publisher: s, date: null }; } /** 归一化定价 → {currency, amount:'30.00'};识别 $/£/€ 与三字母代码。无法解析返回 null。 */ function normalizePrice(input) { const s = str(input).trim(); if (!s) return null; const SYMBOL = { $: 'USD', '£': 'GBP', '€': 'EUR', '¥': 'CNY' }; let currency = null; const code = s.match(/\b([A-Z]{3})\b/); if (code) currency = code[1]; if (!currency) { const sym = s.match(/[$£€¥]/); if (sym) currency = SYMBOL[sym[0]]; } const num = s.replace(/,/g, '').match(/\d+(?:\.\d+)?/); if (!currency || !num) return null; return { currency, amount: Number(num[0]).toFixed(2) }; } /** 装帧归一化:含 hardcover→'hardcover',含 paperback→'paperback',否则 'other'。 */ function mapBinding(input) { const s = str(input).toLowerCase(); if (s.includes('hardcover') || s.includes('hardback')) return 'hardcover'; if (s.includes('paperback')) return 'paperback'; return 'other'; } /** 书名归一化(与 rating-hub 一致):&→and、小写、去非字母数字。用于 suggest 精确匹配。 */ function normalizeTitle(input) { return str(input).replace(/&/g, 'and').toLowerCase().replace(/[^a-z0-9]/g, ''); } /** "320 pages" / "1,024 pages" / "xii, 416 pages" → 整数;无数字返回 null。 */ function parsePageCount(input) { const s = str(input).replace(/,/g, ''); const m = s.match(/\d+/); return m ? Number(m[0]) : null; } /** 剥掉 Amazon 简介尾部的展开切换文案("Read more" / "Read less")。 */ function cleanDescription(input) { return str(input).replace(/\s*\bRead (?:more|less)\s*$/i, '').trim(); } /** payload 是否在 TTL 窗口内(now - capturedAt < ttl)。 */ function isPayloadFresh(payload, now, ttl) { const at = payload && payload.source && payload.source.capturedAt; if (typeof at !== 'number') return false; return now - at < ttl; } /** canonical binding → 豆瓣装帧 radio 的 value(英文对英文精确映射)。 */ function bindingRadioValue(binding) { if (binding === 'hardcover') return 'Hardcover'; if (binding === 'paperback') return 'Paperback'; return 'other'; } /** * 纯函数:payload → 豆瓣第二步回填计划。无 DOM 副作用,便于单测。 * 字段按「标签文本」标识;DOM 执行器据此定位控件。 * @returns {{texts,authors,textareas,date,binding,warnings,filled,skipped}} */ function buildFillPlan(payload) { const p = payload || {}; const texts = []; const filled = []; const skipped = []; const warnings = []; const pushText = (label, value) => { if (value) { texts.push({ label, value }); filled.push(label); } else skipped.push(label); }; pushText('书名', p.title); pushText('副标题', p.subtitle); pushText('定价', p.price ? `${p.price.currency} ${p.price.amount}` : ''); pushText('出版社', p.publisher); pushText('页数', p.pageCount != null ? String(p.pageCount) : ''); const authors = (Array.isArray(p.authors) ? p.authors : []).filter(Boolean); if (authors.length) filled.push(authors.length > 1 ? `作者 ×${authors.length}` : '作者'); else { skipped.push('作者'); warnings.push('缺作者(豆瓣必填)'); } const textareas = []; const pushArea = (label, value, required) => { if (value) { textareas.push({ label, value }); filled.push(label); } else { skipped.push(label); if (required) warnings.push(`${label}缺失(豆瓣必填)`); } }; pushArea('内容简介', p.description, true); pushArea('作者简介', p.authorBio, false); const d = p.pubDate || {}; const date = { y: d.y || null, m: d.m || null, d: d.d || null }; if (date.y) filled.push('出版日期'); else { skipped.push('出版日期'); warnings.push('缺出版日期'); } const radioValue = bindingRadioValue(p.binding); const binding = { radioValue, otherText: radioValue === 'other' ? (p.bindingRaw || '') : '' }; filled.push('装帧'); if (radioValue === 'other') warnings.push(`装帧落「其他」:${p.bindingRaw || '?'}`); return { texts, authors, textareas, date, binding, warnings, filled, skipped }; } // ── node 测试导出(在 DOM 启动代码之前 return) ────────────────────────── if (typeof module !== 'undefined' && module.exports) { module.exports = { validateIsbn13, isbn10to13, splitTitle, parseDate, splitPublisherDate, normalizePrice, mapBinding, normalizeTitle, parsePageCount, cleanDescription, isPayloadFresh, bindingRadioValue, buildFillPlan, }; return; } // ============================================================ // 运行期:配置 + GM 封装 // ============================================================ const STORAGE_KEY = 'dbb:pending'; const COVER_KEY = 'dbb:cover'; const TTL_MS = 10 * 60 * 1000; const NEW_SUBJECT_BASE = 'https://book.douban.com/new_subject'; const HIGHLIGHT_SHADOW = '0 0 0 3px rgba(46,125,50,.6)'; // 绿色「该点这个」描边 const deps = { // 默认带 cookie:唯一跨域目标是豆瓣,登录态既是 new_subject 的前提, // 也是对抗 /isbn/ 风控 403 的主要手段。匿名请求请显式传 anonymous:true。 request(url, opts = {}) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: opts.method || 'GET', url, headers: opts.headers || {}, timeout: opts.timeout || 15000, responseType: opts.responseType || undefined, anonymous: opts.anonymous === true, onload: resolve, onerror: () => reject(new Error('request failed: ' + url)), ontimeout: () => reject(new Error('request timeout: ' + url)), }); }); }, }; function escapeHtml(input) { return str(input) .replace(/&/g, '&').replace(//g, '>') .replace(/"/g, '"').replace(/'/g, '''); } function safeLinkUrl(input) { const url = str(input).trim(); return /^https?:\/\//i.test(url) ? url : '#'; } /** 清洗 Amazon 详情里的方向控制符与冒号噪声,折叠空白。 */ function cleanLabel(s) { return str(s) .replace(/[-]/g, '') .replace(/[::]\s*$/, '') .replace(/\s+/g, ' ') .trim(); } // ============================================================ // Amazon 适配器:DOM → canonical payload + 角标 // ============================================================ /** 收集 Amazon 详情区的 {label,value} 行(detail-bullets 列表 + product-details 表格两套布局)。 */ function amazonDetailRows() { const rows = []; document .querySelectorAll('#detailBullets_feature_div li, #detailBulletsWrapper_feature_div li') .forEach((li) => { const spans = li.querySelectorAll('span'); if (spans.length >= 2) { rows.push({ label: cleanLabel(spans[0].textContent), value: cleanLabel(spans[spans.length - 1].textContent) }); } }); document .querySelectorAll( '#productDetails_detailBullets_sections1 tr, #productDetails_techSpec_section_1 tr, table.prodDetTable tr' ) .forEach((tr) => { const th = tr.querySelector('th'); const td = tr.querySelector('td'); if (th && td) rows.push({ label: cleanLabel(th.textContent), value: cleanLabel(td.textContent) }); }); return rows; } /** 现代 "Rich Product Information" 卡片布局:值在 .rpi-attribute-value,必须用 textContent * (轮播里离屏卡片 innerText 为空)。归一成与旧布局一致的 {label,value} 行。 */ function rpiRows() { const map = [ ['ISBN-13', 'isbn13'], ['ISBN-10', 'isbn10'], ['Publisher', 'publisher'], ['Publication date', 'publication_date'], ['Print length', 'fiona_pages'], ]; const rows = []; for (const [label, key] of map) { const el = document.getElementById('rpi-attribute-book_details-' + key); const v = el && el.querySelector('.rpi-attribute-value'); if (v) { const value = cleanLabel(v.textContent); if (value) rows.push({ label, value }); } } return rows; } function detailValue(rows, labelRe) { const row = rows.find((r) => labelRe.test(r.label)); return row ? row.value : ''; } /** 按选择器顺序返回首个非空文本(innerText 优先,回退 textContent)。 */ function firstText(selectors) { for (const sel of selectors) { const el = document.querySelector(sel); if (el) { const t = (el.innerText || el.textContent || '').trim(); if (t) return t; } } return ''; } const BINDING_RE = /^(Hardcover|Paperback|Kindle Edition|Kindle|Board book|Mass Market Paperback|Audiobook|Spiral-bound|Library Binding)$/i; /** 当前版本的装帧:rpi 布局放在 #bylineInfo 的叶子节点;旧布局在 #productSubtitle。 */ function extractBindingRaw() { const byline = document.querySelector('#bylineInfo'); if (byline) { const n = [...byline.querySelectorAll('span, a')].find( (e) => e.children.length === 0 && BINDING_RE.test(e.textContent.trim()) ); if (n) return n.textContent.trim(); } return firstText(['#productSubtitle', '#tmmSwatches .selected', '.swatchElement.selected']); } function extractAuthors() { const out = []; document.querySelectorAll('#bylineInfo .author').forEach((el) => { if (/author/i.test(el.textContent)) { const a = el.querySelector('a'); const name = cleanLabel((a ? a.textContent : el.textContent).replace(/\(author\)/i, '')); if (name) out.push(name); } }); return out; } function descriptionContentNode() { const root = document.querySelector('#bookDescription_feature_div') || document.querySelector('#bookDescription_expander'); return root ? root.querySelector('.a-expander-content') || root : null; } const ABOUT_AUTHOR_RE = /(^|\n)[ \t]*about the author[ \t]*(\n|$)/i; /** 把简介内容节点读成带换行的纯文本。 * 有
/
${escapeHtml(payload.isbn13 || '')} — 请点「下一步」(豆瓣将做服务端查重)。`);
}
function formIsbnDigits(root) {
const uid = root.querySelector('input[name="p_uid"]');
const val = uid ? uid.value : (fieldByLabel(root, 'ISBN')?.querySelector('input')?.value || '');
return String(val).replace(/[^0-9]/g, '');
}
function fillStep2(root, payload) {
const formIsbn = formIsbnDigits(root);
if (formIsbn && payload.isbn13 && formIsbn !== payload.isbn13) {
injectBanner(root, `未自动回填:表单 ISBN(${escapeHtml(formIsbn)})与 Amazon 抓取的(${escapeHtml(payload.isbn13)})不一致,疑似不同书。请手动核对。`);
return; // 不动表单、不消费 payload
}
const plan = buildFillPlan(payload);
for (const t of plan.texts) setTextByLabel(root, t.label, t.value);
setAuthors(root, plan.authors);
for (const a of plan.textareas) setTextareaByLabel(root, a.label, a.value);
setBinding(root, plan.binding);
setPubDate(root, plan.date);
injectSummary(root, plan);
highlightSubmit(root);
// 把封面单独交棒给下一页(上传封面页)。主 payload 在此消费即删,
// 既保留封面、又不会在第二步重载时重复回填覆盖用户编辑。
if (payload.coverUrl) {
GM_setValue(COVER_KEY, JSON.stringify({ coverUrl: payload.coverUrl, source: { capturedAt: Date.now() } }));
}
GM_deleteValue(STORAGE_KEY);
}
/** 上传封面页:拉 Amazon 封面 blob,DataTransfer 注入 file input。不自动提交。 */
async function runCoverUpload(fileInput) {
let info = null;
const raw = GM_getValue(COVER_KEY);
if (raw) {
try {
const c = JSON.parse(raw);
if (isPayloadFresh(c, Date.now(), TTL_MS) && c.coverUrl) info = c;
else GM_deleteValue(COVER_KEY);
} catch { GM_deleteValue(COVER_KEY); }
}
if (!info) return; // 用户正常手动上传,不干预
GM_deleteValue(COVER_KEY); // 消费即删(无论成败,避免污染下一本)
try {
const resp = await deps.request(info.coverUrl, { anonymous: true, responseType: 'blob' });
const blob = resp.response;
if (!blob || !blob.size) throw new Error('empty blob');
const ext = (blob.type && blob.type.split('/')[1]) || 'jpg';
const file = new File([blob], `cover.${ext}`, { type: blob.type || 'image/jpeg' });
const dt = new DataTransfer();
dt.items.add(file);
fileInput.files = dt.files;
fileInput.dispatchEvent(new Event('change', { bubbles: true }));
// 用 blob 生成预览(显示真正会上传的字节,让用户能核对封面)
injectCoverBanner(fileInput, true, info.coverUrl, URL.createObjectURL(blob));
} catch {
injectCoverBanner(fileInput, false, info.coverUrl, null);
}
}
function injectCoverBanner(fileInput, ok, coverUrl, previewUrl) {
const form = fileInput.closest('form') || fileInput.parentElement;
const submit = form.querySelector('input[name="img_submit"], input[type="submit"]');
if (ok && submit) submit.style.boxShadow = HIGHLIGHT_SHADOW;
const preview = ok && previewUrl
? `