// ==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; /** 把简介内容节点读成带换行的纯文本。 * 有

/

  • 时按块读(li 加「· 」,见 McCartney Legacy/0063000709);否则是 * span+
    结构(见 Math for Web Design/1633434826),把
    当换行——否则 * textContent 会把所有段落挤成一行。始终用 textContent(DOM 顺序、单份), * 不碰 partial-collapse 的 innerText 重排坑(见 Messy Jobs/B0H4495X34)。 */ function descriptionBlockText(content) { const blocks = [...content.querySelectorAll('p, li')]; if (blocks.length) { return blocks .map((el) => (el.tagName === 'LI' ? '· ' : '') + el.textContent.replace(/\s+/g, ' ').trim()) .filter((t) => t && t !== '· ') .join('\n\n'); } const clone = content.cloneNode(true); clone.querySelectorAll('br').forEach((br) => br.replaceWith('\n')); return clone.textContent .replace(/[^\S\n]+/g, ' ') .split('\n').map((l) => l.trim()).join('\n') .replace(/\n{3,}/g, '\n\n') .trim(); } /** 内容简介:读全文后,若内嵌「About the author」小节则截断到它之前(作者简介另取)。 */ function extractDescription() { const content = descriptionContentNode(); if (!content) return ''; const full = descriptionBlockText(content); const m = full.match(ABOUT_AUTHOR_RE); return cleanDescription(m ? full.slice(0, m.index).trim() : full); } /** 作者简介:先取 #editorialReviews 的「About the Author」(Sapiens 式);否则取简介 * 内嵌「About the author」小节之后的文本(Manning 式);再退旧选择器。 */ function extractAuthorBio() { const er = document.querySelector('#editorialReviews_feature_div'); if (er) { for (const h of er.querySelectorAll('h2, h3')) { if (h.textContent.toLowerCase().replace(/[^a-z]/g, '') === 'abouttheauthor') { const sib = h.nextElementSibling; const t = sib ? (sib.innerText || sib.textContent || '').trim() : ''; if (t) return t; } } } const content = descriptionContentNode(); if (content) { const full = descriptionBlockText(content); const m = full.match(ABOUT_AUTHOR_RE); if (m) { const bio = full.slice(m.index + m[0].length).trim(); if (bio) return bio; } } return firstText(['#authorBio_feature_div', '#bookAbout_feature_div .a-expander-content']); } /** 从 Amazon 图书页提取 canonical payload;ISBN 无效则 isbn13=null(调用方据此阻断)。 */ function extractAmazonPayload() { const rows = [...amazonDetailRows(), ...rpiRows()]; const productTitle = (document.querySelector('#productTitle')?.textContent || '').trim(); const { title, subtitle } = splitTitle(productTitle); let isbn13 = ''; const raw13 = detailValue(rows, /isbn-13/i).replace(/[^0-9]/g, ''); if (validateIsbn13(raw13)) { isbn13 = raw13; } else { const conv = isbn10to13(detailValue(rows, /isbn-10/i)); if (conv && validateIsbn13(conv)) isbn13 = conv; } const pub = splitPublisherDate(detailValue(rows, /^publisher/i)); const pubDate = pub.date || parseDate(detailValue(rows, /publication date/i)); const pageRow = rows.find((r) => /\d+\s*pages/i.test(r.value) || /print length/i.test(r.label)); const bindingRaw = extractBindingRaw(); const binding = mapBinding(bindingRaw); // 仅取 buybox 当前版本价;不用通用 .a-offscreen(会抓到其他版本/Kindle 的最低价)。 const price = normalizePrice( firstText([ '#corePriceDisplay_desktop_feature_div span.a-price span.a-offscreen', '#corePrice_feature_div span.a-price span.a-offscreen', '#price_inside_buybox', '#tmmSwatches .a-button-selected .a-color-price', ]) ); const description = extractDescription(); const authorBio = extractAuthorBio(); const coverEl = document.querySelector('#landingImage, #imgBlkFront'); const coverUrl = coverEl ? coverEl.getAttribute('data-old-hires') || coverEl.getAttribute('src') || '' : ''; return { title, subtitle, authors: extractAuthors(), isbn13: isbn13 || null, publisher: pub.publisher || '', pubDate: pubDate || { y: null, m: null, d: null }, pageCount: parsePageCount(pageRow ? pageRow.value : ''), binding, bindingRaw: cleanLabel(bindingRaw), price: price || null, description: description || '', authorBio: authorBio || '', coverUrl, source: { name: 'amazon', url: location.href.split('?')[0], capturedAt: Date.now() }, }; } // ============================================================ // 豆瓣查重服务(与来源无关) // ============================================================ function parseDoubanRating(html) { try { const doc = new DOMParser().parseFromString(html, 'text/html'); const num = doc.querySelector('strong.rating_num'); const val = num ? parseFloat(num.textContent.trim()) : NaN; return isNaN(val) || val === 0 ? null : val.toFixed(1); } catch { return null; } } async function titleSearch(title) { try { const resp = await deps.request( `https://book.douban.com/j/subject_suggest?q=${encodeURIComponent(title)}`, { anonymous: false } ); const list = JSON.parse(resp.responseText); if (!Array.isArray(list)) return []; const want = normalizeTitle(title); return list .filter((it) => it && it.type === 'b' && normalizeTitle(it.title) === want) .map((it) => ({ title: it.title, url: it.url, year: it.year, author: it.author_name })); } catch { return []; } } /** /isbn/{isbn}/ 三态查重;miss 时串行回落书名查重。返回 {kind, ...}。 */ async function dedup(isbn13, title) { let resp; try { resp = await deps.request(`https://book.douban.com/isbn/${isbn13}/`, { anonymous: false }); } catch { return { kind: 'error' }; } const finalUrl = resp.finalUrl || ''; if (resp.status === 200 && /\/subject\/\d+/.test(finalUrl)) { return { kind: 'hit', url: finalUrl, rating: parseDoubanRating(resp.responseText) }; } if (resp.status === 404) { const others = await titleSearch(title); return others.length ? { kind: 'other', items: others } : { kind: 'absent' }; } return { kind: 'error' }; } // ============================================================ // 跨域交接 // ============================================================ function stashAndOpen(payload) { GM_setValue(STORAGE_KEY, JSON.stringify(payload)); const url = `${NEW_SUBJECT_BASE}?cat=1001&search_text=${encodeURIComponent(payload.title)}`; GM_openInTab(url, { active: true, insert: true }); } // ============================================================ // Amazon 侧角标 UI // ============================================================ const BADGE_ID = 'dbb-badge'; function fieldSummaryHtml(p) { const rows = [ ['正标题', p.title], ['副标题', p.subtitle || '—'], ['作者', p.authors.length ? p.authors.join(' / ') : '—'], ['ISBN-13', p.isbn13 || '(缺失)'], ['出版社', p.publisher || '—'], ['出版日期', p.pubDate.y ? [p.pubDate.y, p.pubDate.m, p.pubDate.d].filter(Boolean).join('-') : '—'], ['页数', p.pageCount || '—'], ['装帧', p.binding === 'other' ? `其他(${p.bindingRaw || '?'})` : p.binding], ['定价', p.price ? `${p.price.currency} ${p.price.amount}` : '—'], ['内容简介', p.description ? `${p.description.length} 字` : '(缺失,必填)'], ]; return rows .map(([k, v]) => `
    ${k}${escapeHtml(String(v))}
    `) .join(''); } function ensureBadge() { let box = document.getElementById(BADGE_ID); if (box) return box; box = document.createElement('div'); box.id = BADGE_ID; box.style.cssText = 'margin:12px 0;padding:12px 14px;border:1px solid #d6c79b;border-radius:8px;' + 'background:#fcf9ef;font-size:13px;line-height:1.6;color:#333;max-width:640px'; const title = document.querySelector('#productTitle'); if (title && title.parentElement) { title.parentElement.insertBefore(box, title.nextSibling); } else { box.style.cssText += ';position:fixed;top:12px;right:12px;z-index:99999;background:#fff;box-shadow:0 2px 12px rgba(0,0,0,.2)'; document.body.appendChild(box); } return box; } function renderBadge(state, payload, result) { const box = ensureBadge(); let head = ''; if (state === 'loading') head = '豆瓣查重中…'; else if (state === 'no-isbn') head = '未找到 ISBN — 请切换到 print edition 页面。'; else if (state === 'hit') { const r = result.rating ? `豆瓣 ${result.rating} 分` : '豆瓣已收录'; head = `✓ ${r} · 直达条目 →`; } else if (state === 'error') { const q = encodeURIComponent(payload ? payload.title : ''); head = `查重失败(风控/网络)· 手动搜索 →`; } else if (state === 'other') { const links = result.items .map((it) => `${escapeHtml(it.title)}${it.year ? `(${escapeHtml(it.year)})` : ''}`) .join('、'); head = `豆瓣有其他版本:${links}`; } else if (state === 'absent') { head = '豆瓣未收录'; } let action = ''; if (state === 'other' || state === 'absent') { if (!payload.description) { action = '
    内容简介缺失(豆瓣必填)— 无法自动添加,请确认这是图书详情页。
    '; } else { const label = state === 'other' ? '仍要添加此版本' : '+ 添加到豆瓣'; action = `
    `; } } const summary = payload ? `
    ${fieldSummaryHtml(payload)}
    ` : ''; box.innerHTML = `
    ${head}
    ${action}${summary}`; const btn = box.querySelector('#dbb-add'); if (btn) btn.addEventListener('click', () => stashAndOpen(payload)); } async function runAmazon() { if (!document.querySelector('#productTitle')) return; // 非图书/详情页,静默早退 const payload = extractAmazonPayload(); if (!payload.isbn13) { renderBadge('no-isbn', payload, null); return; } renderBadge('loading', payload, null); const result = await dedup(payload.isbn13, payload.title); renderBadge(result.kind, payload, result); } // ============================================================ // 豆瓣侧回填器(与来源无关)— 标签文本定位,不依赖 name/id // ============================================================ function normLabel(s) { return str(s).replace(/\*/g, '').replace(/\s+/g, '').trim(); } /** 在指定表单内按 label 文本找到所属 .item 容器。 */ function fieldByLabel(root, labelText) { const want = normLabel(labelText); for (const label of root.querySelectorAll('label')) { if (normLabel(label.textContent) === want) return label.closest('.item'); } return null; } function fireValue(el, value) { el.value = value; el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); } function setTextByLabel(root, labelText, value) { const item = fieldByLabel(root, labelText); const el = item && item.querySelector('input.input_basic'); if (!el) return false; fireValue(el, value); return true; } function setTextareaByLabel(root, labelText, value) { const item = fieldByLabel(root, labelText); const el = item && item.querySelector('textarea.textarea_basic'); if (!el) return false; fireValue(el, value); return true; } function setSelect(sel, val) { sel.value = String(val); sel.dispatchEvent(new Event('change', { bubbles: true })); } /** 「日」下拉由豆瓣脚本在「月」change 后异步填充,轮询直到目标 option 出现。 */ function pollDay(daySel, day, attempts) { if (day == null) return; const tick = (n) => { if ([...daySel.options].some((o) => o.value === String(day))) { setSelect(daySel, day); return; } if (n > 0) setTimeout(() => tick(n - 1), 60); }; tick(attempts); } function setPubDate(root, date) { if (!date.y) return; const item = fieldByLabel(root, '出版日期'); const sels = item ? item.querySelectorAll('select') : []; if (sels.length < 3) return; const [yearSel, monthSel, daySel] = sels; setSelect(yearSel, date.y); if (date.m) { setSelect(monthSel, date.m); pollDay(daySel, date.d, 20); } } /** 多作者:第一位填进现有左栏,其余克隆
  • 追加。人物实体关联栏(右栏)一律留空。 */ function setAuthors(root, authors) { if (!authors.length) return; const item = fieldByLabel(root, '作者'); const ul = item && item.querySelector('ul'); const firstLi = ul && ul.querySelector('li'); if (!firstLi) return; const leftInput = (li) => li.querySelectorAll('input')[0]; fireValue(leftInput(firstLi), authors[0]); for (let i = 1; i < authors.length; i++) { const li = firstLi.cloneNode(true); li.querySelectorAll('input').forEach((inp) => { inp.value = ''; }); li.querySelectorAll('a.add, .author-tip').forEach((e) => e.remove()); fireValue(leftInput(li), authors[i]); ul.appendChild(li); } } function setBinding(root, binding) { const item = fieldByLabel(root, '装帧'); if (!item) return; const radio = item.querySelector(`input[type="radio"][value="${binding.radioValue}"]`); if (radio) { radio.checked = true; radio.dispatchEvent(new Event('change', { bubbles: true })); } if (binding.radioValue === 'other') { const other = item.querySelector('input.other'); if (other) fireValue(other, binding.otherText); } } const SUMMARY_ID = 'dbb-summary'; function injectBanner(root, innerHtml) { let bar = document.getElementById(SUMMARY_ID); if (bar) bar.remove(); bar = document.createElement('div'); bar.id = SUMMARY_ID; bar.style.cssText = 'margin:0 0 14px;padding:12px 14px;border:1px solid #d6c79b;border-radius:8px;' + 'background:#fcf9ef;font-size:13px;line-height:1.7;color:#333'; bar.innerHTML = innerHtml; root.parentElement.insertBefore(bar, root); } function chips(items, color) { return items .map((t) => `${escapeHtml(t)}`) .join(''); } function injectSummary(root, plan) { const warn = plan.warnings.length ? `
    ${plan.warnings.map((w) => `⚠ ${escapeHtml(w)}`).join('
    ')}
    ` : ''; injectBanner( root, `豆瓣一键添书 · 已回填 请核对后人工点「下一步」提交` + `
    已填:${chips(plan.filled, '#dbefda')}
    ` + (plan.skipped.length ? `
    跳过:${chips(plan.skipped, '#eee')}
    ` : '') + warn ); } function highlightSubmit(root) { const btn = root.querySelector('input[type="submit"]'); if (btn) btn.style.boxShadow = HIGHLIGHT_SHADOW; } function fillStep1(root, payload) { const item = fieldByLabel(root, 'ISBN'); const el = item && item.querySelector('input.input_basic'); if (el && payload.isbn13) fireValue(el, payload.isbn13); highlightSubmit(root); injectBanner(root, `豆瓣一键添书 已填 ISBN ${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 ? `
    封面预览
    ` : ''; injectBanner( form, ok ? `封面已就绪(来自 Amazon)。核对下方预览后点「上传图片」。${preview}` : `封面自动获取失败。可打开原图手动选择,或点「跳过」。` ); } function runDouban() { const fileInput = document.querySelector('form.fileupload input[name="picfile"]'); if (fileInput) { runCoverUpload(fileInput); return; } const form = document.querySelector('form.detail_form'); if (!form) return; let payload = null; const raw = GM_getValue(STORAGE_KEY); if (raw) { try { const p = JSON.parse(raw); if (isPayloadFresh(p, Date.now(), TTL_MS)) payload = p; else GM_deleteValue(STORAGE_KEY); } catch { /* 损坏即忽略 */ } } if (!payload) return; // 无有效 payload:用户可能在正常手动添加,什么都不做 if (fieldByLabel(form, '副标题')) fillStep2(form, payload); else fillStep1(form, payload); } // ============================================================ // 分派 // ============================================================ const host = location.hostname; if (host === 'www.amazon.com') { runAmazon(); } else if (host === 'book.douban.com') { runDouban(); } })();