From 8501c5683ee9ab4a97394cc0d46589a24c4b7247 Mon Sep 17 00:00:00 2001 From: Stardream Date: Thu, 23 Apr 2026 00:06:03 +1000 Subject: [PATCH] feat(Bangumi_Topic_Share): add OAuth-backed share cards for collections, subject comments, and lists, bump version to 6.0 Core new features: - Add Bangumi OAuth flow with token storage, refresh, menu commands, and settings-panel auth controls - Move optional AI tag configuration into GM storage and expose it in the customize-panel settings tab - Add full /subject/* matching plus subject metadata extraction and API fallback for NSFW-limited data - Preserve richer HTML when truncating card content, including quote and inline formatting - Limit ordinary content images in cards to avoid overly tall share images while keeping emoji/stickers inline Collection and comment sharing: - Add share cards for subject comment rows with API-enriched user rating, tags, and collection timestamps - Add share cards for collection member pages and user browser list comments across anime/music/game/real/book lists - Add share cards for the current user's collection comment from the subject collection panel - Reuse subject covers, subject types, collection statuses, user tags, and infobox-derived fallback tags Card rendering refinements: - Add subject/blog topic title handling so single-subject blog cards promote the subject as the main title - Improve nested reply layout, rating display, cover-image spacing, and API fallback notices - Carry quote/body/link styling into the isolated iframe capture and improve spoiler mask overlay alignment - Expand injected share buttons and mutation observers for subject comments, collection pages, browser lists, and Rakuen frames --- js/Bangumi_Topic_Share.js | 977 +++++++++++++++++++++++++++++++++++--- 1 file changed, 923 insertions(+), 54 deletions(-) diff --git a/js/Bangumi_Topic_Share.js b/js/Bangumi_Topic_Share.js index b70482c..205733c 100644 --- a/js/Bangumi_Topic_Share.js +++ b/js/Bangumi_Topic_Share.js @@ -1,19 +1,13 @@ // ==UserScript== // @name Bangumi Topic Share // @namespace http://tampermonkey.net/ -// @version 5.4 +// @version 6.0 // @description Bangumi 话题/日志分享工具:生成分享卡片,支持图片复制/下载、一键复制分享文案、可选 AI 标签 // @author Stardream // @contributor Chang ji, Mewtw0 // @match *://bgm.tv/group/topic/* // @match *://bangumi.tv/group/topic/* // @match *://chii.in/group/topic/* -// @match *://bgm.tv/subject/*/topic/* -// @match *://bangumi.tv/subject/*/topic/* -// @match *://chii.in/subject/*/topic/* -// @match *://bgm.tv/subject/topic/* -// @match *://bangumi.tv/subject/topic/* -// @match *://chii.in/subject/topic/* // @match *://bgm.tv/blog/* // @match *://bangumi.tv/blog/* // @match *://chii.in/blog/* @@ -26,10 +20,34 @@ // @match *://bgm.tv/person/* // @match *://bangumi.tv/person/* // @match *://chii.in/person/* +// @match *://bgm.tv/subject/* +// @match *://bangumi.tv/subject/* +// @match *://chii.in/subject/* // @match *://bgm.tv/rakuen* // @match *://bangumi.tv/rakuen* // @match *://chii.in/rakuen* +// @match *://bgm.tv/anime/list/* +// @match *://bangumi.tv/anime/list/* +// @match *://chii.in/anime/list/* +// @match *://bgm.tv/music/list/* +// @match *://bangumi.tv/music/list/* +// @match *://chii.in/music/list/* +// @match *://bgm.tv/game/list/* +// @match *://bangumi.tv/game/list/* +// @match *://chii.in/game/list/* +// @match *://bgm.tv/real/list/* +// @match *://bangumi.tv/real/list/* +// @match *://chii.in/real/list/* +// @match *://bgm.tv/book/list/* +// @match *://bangumi.tv/book/list/* +// @match *://chii.in/book/list/* +// @match *://bgm.tv/ +// @match *://bangumi.tv/ +// @match *://chii.in/ // @grant GM_xmlhttpRequest +// @grant GM_getValue +// @grant GM_setValue +// @grant GM_registerMenuCommand // @connect * // @require https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js // @license MIT @@ -38,13 +56,109 @@ (function() { 'use strict'; - // ================= 配置区 ================= - const AI_CONFIG = { - apiUrl: "在此处填入你的_API_URL", - apiKey: "在此处填入你的_API_KEY", - model: "gpt-3.5-turbo", + const CARD_CONTENT_IMAGE_LIMIT = 3; + + + // ================= Bangumi OAuth ================= + const BGM_CLIENT_ID = 'bgm600069e7caaa1d2ba'; + const BGM_REDIRECT_URI = 'https://bgm.tv/rakuen'; + const BGM_TOKEN_PROXY = 'https://bgm-topic-share.stdm.workers.dev'; + const getBgmToken = () => GM_getValue('bgm_share_token', ''); + const setBgmToken = t => GM_setValue('bgm_share_token', t); + const clearBgmToken = () => { + GM_setValue('bgm_share_token', ''); + GM_setValue('bgm_share_refresh_token', ''); + GM_setValue('bgm_share_token_expiry', 0); }; - // ========================================= + function _refreshAuthStatusUI() { + const statusEl = document.getElementById('bgm-share-auth-status'); + const authBtn = document.getElementById('bgm-share-auth-btn'); + const deauthBtn = document.getElementById('bgm-share-deauth-btn'); + if (!statusEl) return; + const hasToken = !!getBgmToken(); + statusEl.textContent = hasToken ? '✓ 已授权,API 请求将携带 Token' : '未授权 - NSFW 条目数据将降级为 DOM 抓取'; + statusEl.style.color = hasToken ? '#4caf50' : '#aaa'; + if (authBtn) authBtn.style.display = hasToken ? 'none' : ''; + if (deauthBtn) deauthBtn.style.display = hasToken ? '' : 'none'; + } + + const startBgmOAuth = () => { + window.open( + `https://bgm.tv/oauth/authorize?client_id=${BGM_CLIENT_ID}&response_type=code&redirect_uri=${encodeURIComponent(BGM_REDIRECT_URI)}`, + '_blank' + ); + const onFocus = () => { + window.removeEventListener('focus', onFocus); + _refreshAuthStatusUI(); + }; + window.addEventListener('focus', onFocus); + }; + GM_registerMenuCommand('Bangumi Topic Share 授权', startBgmOAuth); + GM_registerMenuCommand('Bangumi Topic Share 清除授权', () => { clearBgmToken(); alert('已清除授权'); }); + + function _saveBgmTokenResponse(data) { + if (!data?.access_token) return false; + setBgmToken(data.access_token); + if (data.refresh_token) GM_setValue('bgm_share_refresh_token', data.refresh_token); + if (data.expires_in) GM_setValue('bgm_share_token_expiry', Date.now() + data.expires_in * 1000); + return true; + } + + function _showOAuthToast(msg, ok = true) { + const toast = document.createElement('div'); + toast.textContent = msg; + toast.style.cssText = `position:fixed;top:20px;right:20px;background:${ok ? '#F09199' : '#888'};color:#fff;padding:10px 20px;border-radius:8px;font-size:14px;z-index:100002;box-shadow:0 4px 12px rgba(0,0,0,0.3);`; + document.body.appendChild(toast); + setTimeout(() => toast.remove(), 3000); + } + + function checkOAuthCallback() { + if (window.location.pathname !== '/rakuen') return; + const params = new URLSearchParams(window.location.search); + const code = params.get('code'); + if (!code) return; + history.replaceState(null, '', '/rakuen'); + GM_xmlhttpRequest({ + method: 'POST', + url: BGM_TOKEN_PROXY, + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + data: new URLSearchParams({ + grant_type: 'authorization_code', + code, + }).toString(), + onload: res => { + try { + const data = JSON.parse(res.responseText); + if (_saveBgmTokenResponse(data)) { + window.close(); + } else { + _showOAuthToast('✗ 授权失败:' + (data.error_description || data.error || '未知错误'), false); + } + } catch { _showOAuthToast('✗ 授权响应解析失败', false); } + }, + onerror: () => _showOAuthToast('✗ 授权请求失败', false) + }); + } + + function refreshBgmTokenIfNeeded() { + const expiry = GM_getValue('bgm_share_token_expiry', 0); + const refreshToken = GM_getValue('bgm_share_refresh_token', ''); + if (!refreshToken || !expiry) return; + if (Date.now() < expiry - 86400000) return; // 超过剩余 1 天才刷新 + GM_xmlhttpRequest({ + method: 'POST', + url: BGM_TOKEN_PROXY, + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + data: new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: refreshToken, + }).toString(), + onload: res => { + try { _saveBgmTokenResponse(JSON.parse(res.responseText)); } catch {} + } + }); + } + // ================================================= const style = document.createElement('style'); style.innerHTML = ` @@ -67,6 +181,7 @@ .user-meta .time { font-size: 12px; color: #aaa; margin-top: 4px; display: block; } .card-body { padding: 15px 25px 25px; text-align: left; } .main-title { font-size: 20px; color: #111; margin: 0 0 15px 0; line-height: 1.5; font-weight: 800; } + .topic-sub-title { font-size: 16px; color: #333; margin: 0 0 14px; font-weight: 700; line-height: 1.4; } .content-box { background: #fdfafb; padding: 18px; border-radius: 12px; border-left: 5px solid #F09199; } .content-text { font-size: 14px; color: #333; line-height: 1.8; margin: 0; word-break: break-all; } .tags-container { display: flex; flex-wrap: wrap; gap: 8px; } @@ -113,6 +228,7 @@ .share-card.dark .card-header { border-bottom: 1px solid rgba(255,255,255,0.1); } .share-card.dark .card-body { background: #1e1e1e; padding-top: 15px; } .share-card.dark .main-title { color: #f0f0f0; } + .share-card.dark .topic-sub-title { color: #e0e0e0; } .share-card.dark .content-box { background: #2a2a2a; border-left: 5px solid #F09199; } .share-card.dark .content-text { color: #ddd; } .share-card.dark .tags-container { margin-top: 15px; } @@ -123,7 +239,7 @@ .content-text img.bmoji-image { display: inline; vertical-align: baseline; } .content-text img.smile-dynamic { display: inline !important; height: 3em !important; width: auto !important; vertical-align: baseline; } .content-text img:not([data-bgm-emoji]):not(.bmoji-image):not(.smile-dynamic) { max-width: 100%; height: auto; border-radius: 4px; margin: 4px 0; display: block; } - [data-bgm-mask] { display: inline; background-color: #555; color: #555; border-radius: 2px; background-clip: padding-box; padding: 0 5px; position: relative; transition: color 0.5s linear; } + [data-bgm-mask] { display: inline; background-color: #555; color: #555; border-radius: 2px; padding: 0 5px; position: relative; transition: color 0.5s linear; } `; document.head.appendChild(style); @@ -147,14 +263,35 @@ if (!html) return html; const div = document.createElement('div'); div.innerHTML = html; - div.querySelectorAll('.embed-play-btn, .embed-player-wrapper, iframe').forEach(el => el.remove()); + div.querySelectorAll('.embed-play-btn, .embed-player-wrapper, iframe, #catfish_likes_grid').forEach(el => el.remove()); + div.querySelectorAll('p').forEach(el => { if (!el.textContent.trim() && !el.querySelector('img')) el.remove(); }); + // Remove
immediately after non-emoji images (avoids blank line below image) + div.querySelectorAll('img:not([smileid]):not([data-bgm-emoji])').forEach(img => { + let next = img.nextSibling; + while (next && next.nodeType === 3 && !next.textContent.trim()) next = next.nextSibling; + if (next && next.nodeName === 'BR') next.remove(); + }); div.querySelectorAll('.text_mask').forEach(el => { el.removeAttribute('style'); el.classList.remove('text_mask'); el.dataset.bgmMask = '1'; }); - const imgs = [...div.querySelectorAll('img')]; + let imgs = [...div.querySelectorAll('img')]; imgs.forEach(img => { if (img.hasAttribute('smileid') || /\/smiles\//.test(img.src)) { img.setAttribute('data-bgm-emoji', '1'); } }); + const contentImgs = imgs.filter(img => + !img.hasAttribute('data-bgm-emoji') && + !img.classList.contains('bmoji-image') && + !img.classList.contains('smile-dynamic') + ); + if (CARD_CONTENT_IMAGE_LIMIT >= 0 && contentImgs.length > CARD_CONTENT_IMAGE_LIMIT) { + const omitted = contentImgs.length - CARD_CONTENT_IMAGE_LIMIT; + contentImgs.slice(CARD_CONTENT_IMAGE_LIMIT).forEach(img => img.remove()); + const omittedTip = document.createElement('div'); + omittedTip.style.cssText = 'font-size:12px;color:#999;margin-top:6px;'; + omittedTip.textContent = `还有 ${omitted} 张图片已省略`; + div.appendChild(omittedTip); + imgs = [...div.querySelectorAll('img')]; + } if (imgs.length > 0) { const base64s = await Promise.all(imgs.map(img => fetchAsBase64(img.src))); imgs.forEach((img, i) => { if (base64s[i]) img.src = base64s[i]; }); @@ -169,6 +306,15 @@ const isEpisode = /\/ep\/\d+/.test(pathname); const isCharacter = /\/character\/\d+|\/rakuen\/topic\/crt\/\d+/.test(pathname); const isPerson = /\/person\/\d+|\/rakuen\/topic\/prsn\/\d+/.test(pathname); + const isSubject = /^\/subject\/\d+$/.test(pathname); + if (isSubject) { + const subjectIdMatch = pathname.match(/\/subject\/(\d+)/); + if (subjectIdMatch) { + const subjectData = await fetchSubjectDataById(subjectIdMatch[1]); + if (subjectData?.type) return [subjectData.type]; + } + return ['作品']; + } if (isEpisode) { const epIdMatch = pathname.match(/\/ep\/(\d+)/); const replyCount = contentDoc.querySelectorAll('[id^="post_"]').length; @@ -185,7 +331,9 @@ .map(a => a.textContent.trim()) )]; const replyCount = contentDoc.querySelectorAll('[id^="post_"]').length; - return [...subjectNames, `${replyCount} 回复`, '日志']; + // Single subject: name moves to main title, skip it from tags + const tagSubjects = subjectNames.length === 1 ? [] : subjectNames; + return [...tagSubjects, `${replyCount} 回复`, '日志']; } if (isCharacter) { const replyCount = contentDoc.querySelectorAll('[id^="post_"]').length; @@ -255,13 +403,16 @@ } async function getAITags(title, content, contentDoc) { - if (!AI_CONFIG.apiKey || AI_CONFIG.apiKey.includes("填入")) return getPageTags(contentDoc); + const apiKey = GM_getValue('bgm_share_ai_key', ''); + const apiUrl = GM_getValue('bgm_share_ai_url', ''); + const model = GM_getValue('bgm_share_ai_model', '') || 'gpt-3.5-turbo'; + if (!apiKey || !apiUrl) return getPageTags(contentDoc); return new Promise((resolve) => { const prompt = `根据标题和内容生成3个短标签,只要标签名,空格隔开。内容:${title} ${content.substring(0, 150)}`; GM_xmlhttpRequest({ - method: "POST", url: AI_CONFIG.apiUrl, - headers: { "Content-Type": "application/json", "Authorization": `Bearer ${AI_CONFIG.apiKey}` }, - data: JSON.stringify({ model: AI_CONFIG.model, messages: [{ role: "user", content: prompt }], temperature: 0.5 }), + method: "POST", url: apiUrl, + headers: { "Content-Type": "application/json", "Authorization": `Bearer ${apiKey}` }, + data: JSON.stringify({ model, messages: [{ role: "user", content: prompt }], temperature: 0.5 }), onload: (res) => { try { const tags = JSON.parse(res.responseText).choices[0].message.content.trim().split(/\s+/).slice(0, 3); @@ -276,7 +427,8 @@ // contentDoc: the document containing the topic (may be an iframe's doc on Rakuen) // Overlay is always rendered in the outer document (where GM functions are available) async function _doShareCard({ username, postTime, avatarUrl, contentEl, pureTitle, contentDoc, contentWin, dark, - replies = [], replyId = '', charImageUrl = '', badgeLabel = '' }) { + replies = [], replyId = '', charImageUrl = '', badgeLabel = '', overrideTags = null, topicTitle = '' }) { + if (document.getElementById('bgm-share-overlay')) return; if (typeof html2canvas === 'undefined') { alert("截图库加载失败,请刷新页面或检查网络。"); return; @@ -301,17 +453,14 @@ computedHidden.forEach(el => { delete el.dataset.hiddenSnapshot; el.style.display = ''; }); toHide.forEach(el => el.style.display = ''); const lim = replies.length > 0 ? 200 : 300; - displayContentHtml = fullContent.length > lim ? (fullContent.substring(0, lim) + "...") : fullHtml; + displayContentHtml = fullContent.length > lim ? truncateHtml(fullHtml, lim) : fullHtml; } - const mainLimit = replies.length > 0 ? 200 : 300; - let displayContent = fullContent.length > mainLimit ? fullContent.substring(0, mainLimit) + "..." : fullContent; - const currentFullUrl = contentWin.location.origin + contentWin.location.pathname; const shareUrl = replyId ? currentFullUrl + '#' + replyId : currentFullUrl; const displayUrl = currentFullUrl.replace(/^https?:\/\//, ''); const [tags, base64Avatar, base64CharImage, base64QR, ...base64ReplyAvatars] = await Promise.all([ - getAITags(pureTitle, fullContent, contentDoc), + overrideTags ? Promise.resolve(overrideTags) : getAITags(pureTitle, fullContent, contentDoc), fetchAsBase64(avatarUrl), fetchAsBase64(charImageUrl), fetchAsBase64(`https://api.qrserver.com/v1/create-qr-code/?size=150x150&data=${encodeURIComponent(shareUrl)}${dark ? '&color=F09199&bgcolor=2a2a2a' : ''}`), @@ -330,18 +479,20 @@ if (idx >= replies.length) return ''; const r = replies[idx]; const b64 = base64ReplyAvatars[idx]; - const avatarSize = Math.max(22, 28 - idx * 4); + const avatarSize = Math.max(24, 36 - idx * 4); + const suppressGap = base64CharImage && !topicTitle && !inlinedMainContent && !username; const topStyle = idx === 0 - ? `margin-top:${base64CharImage ? '6' : '14'}px;padding-top:${base64CharImage ? '6' : '14'}px;${hasMainContent ? `border-top:1px solid ${divider};` : ''}` + ? `margin-top:${suppressGap ? '0' : '14'}px;padding-top:${suppressGap ? '0' : '14'}px;${hasMainContent ? `border-top:1px solid ${divider};` : ''}` : `margin-top:10px;padding-left:14px;border-left:3px solid ${dark ? '#F0919955' : '#F0919933'};`; const inner = idx + 1 < replies.length ? `
${renderLevel(idx + 1)}
` : ''; return `
- - ${r.username} - ${r.time} + + ${r.username} + ${r.rating ? `★ ${r.rating}` : ''} + ${r.time}
-

${inlinedReplyContents[idx]}

+
${inlinedReplyContents[idx]}
${inner}
`; }; @@ -351,12 +502,16 @@ const overlay = document.createElement('div'); overlay.id = 'bgm-share-overlay'; overlay.style.display = 'flex'; + const notice = _shareNotice; + _shareNotice = ''; overlay.innerHTML = `
+
+ ${notice ? `
⚠ ${notice}
` : ''}
+
`; document.body.appendChild(overlay); + const noticeEl = document.getElementById('bgm-share-notice'); + if (noticeEl) { + requestAnimationFrame(() => requestAnimationFrame(() => { noticeEl.style.opacity = '1'; })); + setTimeout(() => { noticeEl.style.opacity = '0'; setTimeout(() => noticeEl.remove(), 400); }, 4000); + } let cancelled = false; let maskRevealed = false; @@ -497,13 +659,65 @@ const linkColor = rawLinkColor && rawLinkColor !== 'rgba(0, 0, 0, 0)' && rawLinkColor !== 'transparent' ? rawLinkColor : (dark ? '#8ec8e8' : '#0066cc'); const sampleLinkDecoration = sampleLink ? getComputedStyle(sampleLink).textDecorationLine : 'none'; - iStyle.textContent = style.innerHTML + maskCss + ` a { color: ${linkColor}; text-decoration: ${sampleLinkDecoration}; }`; + let quoteCss = ''; + try { + for (const sheet of document.styleSheets) { + try { + for (const rule of sheet.cssRules) { + if (rule.selectorText && /\.quote/.test(rule.selectorText)) { + quoteCss += rule.cssText + '\n'; + // Also add ancestor-stripped version to ensure match inside card + const sel = rule.selectorText.split(',').map(s => { + const i = s.indexOf('.quote'); + return i >= 0 ? s.slice(i).trim() : s.trim(); + }).join(','); + if (sel !== rule.selectorText.trim()) { + quoteCss += `${sel}{${rule.style.cssText}}\n`; + } + } + } + } catch (e) { /* cross-origin sheet */ } + } + } catch (e) {} + // Sync quote and body text colors from actual page computed styles + try { + const cw = contentDoc.defaultView || window; + const sampleQuoteQ = contentDoc.querySelector('.quote q'); + const sampleBody = contentDoc.querySelector('.cmt_sub_content') || contentDoc.querySelector('.reply_content .message') || contentDoc.querySelector('.topic_content'); + if (sampleQuoteQ) { + const quoteColor = cw.getComputedStyle(sampleQuoteQ).color; + if (quoteColor) quoteCss += `.quote,.quote q{color:${quoteColor} !important;}`; + } + if (sampleBody) { + const bodyColor = cw.getComputedStyle(sampleBody).color; + if (bodyColor) quoteCss += `.content-text{color:${bodyColor} !important;}`; + } + } catch (e) {} + iStyle.textContent = style.innerHTML + maskCss + quoteCss + ` a { color: ${linkColor}; text-decoration: ${sampleLinkDecoration}; } span[style*="line-through"], span[style*="line-through"] * { text-decoration: line-through !important; text-decoration-color: white !important; }`; iDoc.head.appendChild(iStyle); iDoc.body.style.cssText = 'margin:0;padding:0;background:transparent;display:inline-block;'; iDoc.body.innerHTML = captureEl.innerHTML; await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))); + // html2canvas renders inline backgrounds as full-width blocks; replace each + // [data-bgm-mask] background with precisely-positioned absolute overlays + // matching the actual per-line text rects from getClientRects(). + const captureRoot = iDoc.body.firstElementChild; + captureRoot.style.position = 'relative'; + const rootRect = captureRoot.getBoundingClientRect(); + iDoc.querySelectorAll('[data-bgm-mask]').forEach(el => { + Array.from(el.getClientRects()).forEach(rect => { + const ov = iDoc.createElement('div'); + ov.style.cssText = `position:absolute;left:${rect.left - rootRect.left}px;top:${rect.top - rootRect.top}px;width:${rect.width}px;height:${rect.height}px;background-color:#555;border-radius:2px;pointer-events:none;`; + // Revealed: overlay behind text (prepend); Hidden: overlay on top (append) + if (maskRevealed) captureRoot.insertBefore(ov, captureRoot.firstChild); + else captureRoot.appendChild(ov); + }); + el.style.backgroundColor = 'transparent'; + el.style.backgroundImage = 'none'; + }); + canvas = await Promise.race([ html2canvas(iDoc.body.firstElementChild, { scale: 2, backgroundColor: null }), timeout @@ -612,12 +826,17 @@ } function fetchBangumiAPI(path) { + const token = getBgmToken(); return new Promise(resolve => { GM_xmlhttpRequest({ method: 'GET', url: 'https://api.bgm.tv/v0/' + path, - headers: { 'Accept': 'application/json' }, - onload: res => { try { resolve(JSON.parse(res.responseText)); } catch { resolve(null); } }, + headers: { 'Accept': 'application/json', ...(token ? { 'Authorization': `Bearer ${token}` } : {}) }, + onload: res => { + if (res.status === 401) clearBgmToken(); + if (res.status !== 200) { resolve(null); return; } + try { resolve(JSON.parse(res.responseText)); } catch { resolve(null); } + }, onerror: () => resolve(null) }); }); @@ -640,20 +859,273 @@ const _episodeDataCache = {}; async function fetchEpisodeData(episodeId) { - if (_episodeDataCache[episodeId]) return _episodeDataCache[episodeId]; + if (_episodeDataCache[episodeId]) { + if (_episodeDataCache[episodeId]._domFallback) _shareNotice = 'NSFW 条目 API 无权访问,已降级为页面数据,部分信息可能不完整'; + return _episodeDataCache[episodeId]; + } const ep = await fetchBangumiAPI(`episodes/${episodeId}`); - if (!ep) return null; + if (!ep) { + _shareNotice = 'NSFW 条目 API 无权访问,已降级为页面数据,部分信息可能不完整'; + return null; + } const subject = await fetchBangumiAPI(`subjects/${ep.subject_id}`); + let subjectName = subject?.name_cn || subject?.name || ''; + let subjectImageUrl = subject?.images?.medium || subject?.images?.common || ''; + if (!subject) { + _shareNotice = 'NSFW 条目 API 无权访问,已降级为页面数据,部分信息可能不完整'; + const subjectLink = document.querySelector('#headerSubject a[href*="/subject/"]') || document.querySelector('a.cover[href*="/subject/"]'); + if (subjectLink) subjectName = subjectLink.textContent?.trim() || ''; + const subjectImg = document.querySelector('img[src*="/pic/cover/l/"]') || document.querySelector('img[src*="/pic/cover/m/"]'); + if (subjectImg) subjectImageUrl = subjectImg.src; + } const result = { episodeName: ep.name_cn || ep.name || '', - subjectName: subject?.name_cn || subject?.name || '', - subjectImageUrl: subject?.images?.common || subject?.images?.medium || '', - epNumber: ep.ep + subjectName, + subjectImageUrl, + epNumber: ep.ep, + _domFallback: !subject }; _episodeDataCache[episodeId] = result; return result; } + function extractSubjectDataFromDOM(subjectId, doc) { + const h1 = doc.querySelector('#pageHeader h1'); + let name = ''; + if (h1) h1.childNodes.forEach(n => { if (n.nodeType === 3) name += n.textContent; }); + name = name.trim() || doc.title?.split(/\s*[|//]\s*/)[0].trim() || ''; + const coverA = doc.querySelector('a.thickbox[href*="/pic/cover/"]'); + const coverImg = doc.querySelector('img[src*="/pic/cover/l/"]') || doc.querySelector('img[src*="/pic/cover/m/"]') || doc.querySelector('#bangumiInfo img'); + const imageUrl = coverA?.href || coverImg?.src || ''; + const navTypeMap = { '/anime': ['动画', 2], '/book': ['书籍', 1], '/music': ['音乐', 3], '/game': ['游戏', 4], '/real': ['三次元', 6] }; + let type = '作品', typeNum = 0; + for (const [href, [t, n]] of Object.entries(navTypeMap)) { + if (doc.querySelector(`#navMenuNeue a[href="${href}"].focus, #navMenuNeue a[href="${href}"][class*="selected"]`)) { + type = t; typeNum = n; break; + } + } + return { name, imageUrl, type, typeNum, infobox: [] }; + } + + function getSubjectIdFromTopicPage(pathname, contentDoc) { + const direct = pathname.match(/\/subject\/(\d+)\/topic\/\d+/); + if (direct) return direct[1]; + if (/\/subject\/topic\/\d+/.test(pathname) || /\/rakuen\/topic\/subject\/\d+/.test(pathname)) { + const link = contentDoc.querySelector('#pageHeader a[href^="/subject/"]') + || contentDoc.querySelector('a.cover[href^="/subject/"]') + || contentDoc.querySelector('a[href^="/subject/"]'); + const m = link?.getAttribute('href')?.match(/\/subject\/(\d+)/); + if (m) return m[1]; + } + return null; + } + + let _shareNotice = ''; + const _subjectDataCache = {}; + async function fetchSubjectDataById(subjectId) { + if (_subjectDataCache[subjectId]) { + if (_subjectDataCache[subjectId]._domFallback) _shareNotice = 'NSFW 条目 API 无权访问,已降级为页面数据,部分信息可能不完整'; + return _subjectDataCache[subjectId]; + } + const data = await fetchBangumiAPI(`subjects/${subjectId}`); + if (!data) { + if (new RegExp(`/subject/${subjectId}(/|$)`).test(window.location.pathname)) { + _shareNotice = 'NSFW 条目 API 无权访问,已降级为页面数据,部分信息可能不完整'; + const result = extractSubjectDataFromDOM(subjectId, document); + result._domFallback = true; + _subjectDataCache[subjectId] = result; + return result; + } + return null; + } + const typeMap = { 1: '书籍', 2: '动画', 3: '音乐', 4: '游戏', 6: '三次元' }; + const result = { + name: data.name_cn || data.name || '', + imageUrl: data.images?.medium || data.images?.common || '', + type: typeMap[data.type] || '作品', + typeNum: data.type, + infobox: data.infobox || [] + }; + _subjectDataCache[subjectId] = result; + return result; + } + + function getInfoboxValue(infobox, key) { + const entry = (infobox || []).find(e => e.key === key); + if (!entry) return null; + if (typeof entry.value === 'string') { + const parts = entry.value.split(/[、\/]/).map(s => s.trim()).filter(Boolean); + if (parts.length > 3) return parts.slice(0, 3).join(' / ') + ' 等'; + return entry.value; + } + if (Array.isArray(entry.value)) { + const vals = entry.value.map(v => v.v || v).filter(Boolean); + const limited = vals.slice(0, 3); + return limited.join(' / ') + (vals.length > 3 ? ' 等' : ''); + } + return null; + } + + const SUBJECT_INFOBOX_KEYS = { + 1: ['作者', '出版社'], + 2: ['导演', '原作'], + 3: ['艺术家', '厂牌'], + 4: ['游戏类型', '开发'], + 6: ['导演', '主演'] + }; + + function extractCommentItemInfo(itemEl, contentWin) { + const userLink = itemEl.querySelector('a[href*="/user/"]'); + const username = userLink ? userLink.textContent.trim() : '未知用户'; + const userSlug = userLink ? (userLink.getAttribute('href') || '').replace('/user/', '').replace(/^\//, '') : ''; + const avatarEl = itemEl.querySelector('.avatarNeue') || itemEl.querySelector('[class*="avatar"]'); + let avatarUrl = ''; + if (avatarEl) { + const bg = contentWin.getComputedStyle(avatarEl).backgroundImage; + if (bg && bg !== 'none') avatarUrl = bg.replace(/url\(["']?([^"']+)["']?\)/, '$1'); + if (!avatarUrl) { + const m = (avatarEl.getAttribute('style') || '').match(/background-image:\s*url\(["']?([^"']+)["']?\)/); + if (m) avatarUrl = m[1]; + } + } + const commentEl = itemEl.querySelector('p.comment'); + const rawText = commentEl?.innerText?.trim() || ''; + const truncated = rawText.length > 150; + const contentHtml = truncated ? rawText.substring(0, 150) + '...' : (commentEl?.innerHTML?.trim() || ''); + const greyEls = [...itemEl.querySelectorAll('small.grey')]; + const timeEl = greyEls.find(el => el.textContent.trim().startsWith('@')); + const domTime = timeEl ? timeEl.textContent.trim().replace(/^@\s*/, '') : ''; + const statusEl = greyEls.find(el => !el.textContent.trim().startsWith('@')); + const collectionStatus = statusEl ? statusEl.textContent.trim() : ''; + return { + username, userSlug, avatarUrl, + time: domTime, rating: 0, collectionStatus, + content: truncated ? rawText.substring(0, 150) + '...' : rawText, + contentHtml + }; + } + + async function createSubjectCommentShareImage(itemEl, contentDoc = document) { + const dark = contentDoc.documentElement.getAttribute('data-theme') === 'dark'; + const contentWin = contentDoc.defaultView || window; + const subjectIdMatch = contentWin.location.pathname.match(/\/subject\/(\d+)/); + if (!subjectIdMatch) return; + const subjectId = subjectIdMatch[1]; + const subjectData = await fetchSubjectDataById(subjectId); + const pureTitle = subjectData?.name || contentDoc.title?.split(/\s*[|//]\s*/)[0].trim() || '作品'; + const charImageUrl = subjectData?.imageUrl || ''; + const badgeLabel = subjectData?.type || '作品'; + const commentInfo = extractCommentItemInfo(itemEl, contentWin); + let userTags = null; + if (commentInfo.userSlug) { + const collection = await fetchBangumiAPI(`users/${commentInfo.userSlug}/collections/${subjectId}`); + if (collection) { + commentInfo.rating = collection.rate || 0; + if (collection.updated_at) { + const d = new Date(collection.updated_at); + const pad = n => String(n).padStart(2, '0'); + commentInfo.time = `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; + } + if (collection.tags?.length > 0) userTags = collection.tags; + } + } + if (!commentInfo.time) commentInfo.time = '未知时间'; + const infoboxTags = (SUBJECT_INFOBOX_KEYS[subjectData?.typeNum] || []) + .map(key => getInfoboxValue(subjectData?.infobox, key)) + .filter(Boolean); + const fallbackTags = [commentInfo.collectionStatus, ...infoboxTags].filter(Boolean); + const overrideTags = userTags + ? [commentInfo.collectionStatus, ...userTags].filter(Boolean) + : fallbackTags; + await _doShareCard({ + username: '', postTime: '', avatarUrl: '', contentEl: null, + pureTitle, contentDoc, contentWin, dark, + replies: [commentInfo], replyId: itemEl.id || '', + charImageUrl, badgeLabel, overrideTags + }); + } + + function extractCollectionPageItemInfo(itemEl, contentWin) { + const userLink = itemEl.querySelector('a.avatar[href*="/user/"]'); + const userSlug = userLink?.getAttribute('href')?.match(/\/user\/([^\/\?]+)/)?.[1] || ''; + let username = ''; + if (userLink) { + userLink.childNodes.forEach(n => { if (n.nodeType === 3) username += n.textContent; }); + username = username.trim(); + } + if (!username) username = '未知用户'; + const avatarEl = itemEl.querySelector('.avatarNeue'); + let avatarUrl = ''; + if (avatarEl) { + const bg = contentWin.getComputedStyle(avatarEl).backgroundImage; + if (bg && bg !== 'none') avatarUrl = bg.replace(/url\(["']?([^"']+)["']?\)/, '$1'); + if (!avatarUrl) { + const m = (avatarEl.getAttribute('style') || '').match(/background-image:\s*url\(["']?([^"']+)["']?\)/); + if (m) avatarUrl = m[1]; + } + } + const ratingMatch = itemEl.querySelector('.starlight')?.className.match(/stars(\d+)/); + const rating = ratingMatch ? parseInt(ratingMatch[1]) : 0; + const timeEl = itemEl.querySelector('p.info'); + const time = timeEl ? (timeEl.textContent.trim().match(/\d{4}-\d+-\d+\s+\d+:\d+/)?.[0] || timeEl.textContent.trim()) : ''; + const container = itemEl.querySelector('.userContainer'); + let rawText = ''; + if (container) { + let afterInfo = false; + container.childNodes.forEach(n => { + if (n.nodeType === 1 && n.tagName === 'P' && n.classList.contains('info')) { afterInfo = true; return; } + if (afterInfo) rawText += n.nodeType === 3 ? n.textContent : (n.innerText || n.textContent || ''); + }); + rawText = rawText.trim(); + } + const truncated = rawText.length > 150; + const contentHtml = truncated ? rawText.substring(0, 150) + '...' : rawText; + return { username, userSlug, avatarUrl, rating, time, content: contentHtml, contentHtml }; + } + + const COLLECTION_PAGE_STATUS = { collections: '看过', wishes: '想看', doings: '在看', on_hold: '搁置', dropped: '抛弃' }; + + async function createCollectionPageShareImage(itemEl, contentDoc = document) { + const dark = contentDoc.documentElement.getAttribute('data-theme') === 'dark'; + const contentWin = contentDoc.defaultView || window; + const subjectIdMatch = contentWin.location.pathname.match(/\/subject\/(\d+)/); + if (!subjectIdMatch) return; + const subjectId = subjectIdMatch[1]; + const subjectData = await fetchSubjectDataById(subjectId); + const pureTitle = subjectData?.name || contentDoc.title?.split(/\s*[|//]\s*/)[0].trim() || '作品'; + const charImageUrl = subjectData?.imageUrl || ''; + const badgeLabel = subjectData?.type || '作品'; + const commentInfo = extractCollectionPageItemInfo(itemEl, contentWin); + let userTags = null; + if (commentInfo.userSlug) { + const collection = await fetchBangumiAPI(`users/${commentInfo.userSlug}/collections/${subjectId}`); + if (collection) { + if (!commentInfo.rating) commentInfo.rating = collection.rate || 0; + if (!commentInfo.time && collection.updated_at) { + const d = new Date(collection.updated_at); + const pad = n => String(n).padStart(2, '0'); + commentInfo.time = `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; + } + if (collection.tags?.length > 0) userTags = collection.tags; + } + } + if (!commentInfo.time) commentInfo.time = '未知时间'; + const pageType = contentWin.location.pathname.match(/\/subject\/\d+\/(\w+)/)?.[1] || ''; + const collectionStatus = COLLECTION_PAGE_STATUS[pageType] || ''; + const infoboxTags = (SUBJECT_INFOBOX_KEYS[subjectData?.typeNum] || []) + .map(key => getInfoboxValue(subjectData?.infobox, key)) + .filter(Boolean); + const overrideTags = userTags + ? [collectionStatus, ...userTags].filter(Boolean) + : [collectionStatus, ...infoboxTags].filter(Boolean); + await _doShareCard({ + username: '', postTime: '', avatarUrl: '', contentEl: null, + pureTitle, contentDoc, contentWin, dark, + replies: [commentInfo], replyId: '', + charImageUrl, badgeLabel, overrideTags + }); + } + async function createShareImage(contentDoc = document) { const dark = contentDoc.documentElement.getAttribute('data-theme') === 'dark'; const contentWin = contentDoc.defaultView || window; @@ -662,6 +1134,8 @@ const isEpisode = /\/ep\/\d+/.test(contentWin.location.pathname); const isCharacter = /\/character\/\d+|\/rakuen\/topic\/crt\/\d+/.test(contentWin.location.pathname); const isPerson = /\/person\/\d+|\/rakuen\/topic\/prsn\/\d+/.test(contentWin.location.pathname); + const subjectTopicId = getSubjectIdFromTopicPage(contentWin.location.pathname, contentDoc); + const isSubjectTopic = !!subjectTopicId; let username, postTime, avatarUrl, contentEl; if (isEpisode || isCharacter || isPerson) { @@ -681,10 +1155,9 @@ username = idNode ? idNode.innerText.trim() : "未知用户"; const timeNode = firstPost?.querySelector('small'); postTime = timeNode ? (timeNode.innerText.match(/\d{4}-\d{1,2}-\d{1,2}\s\d{1,2}:\d{1,2}/)?.[0] || "未知时间") : "未知时间"; - const masterPost = contentDoc.querySelector('.postTopic') || contentDoc.querySelector('[id^="post_"]'); - const avatarBox = masterPost?.querySelector('.avatarSize48'); + const avatarBox = firstPost?.querySelector('.avatarSize48'); avatarUrl = avatarBox ? contentWin.getComputedStyle(avatarBox).backgroundImage.replace(/url\(["']?([^"']+)["']?\)/, '$1') : ""; - contentEl = masterPost?.querySelector('.topic_content') || masterPost?.querySelector('.inner'); + contentEl = firstPost?.querySelector('.topic_content') || firstPost?.querySelector('.inner'); } const h1Node = contentDoc.querySelector('#pageHeader h1') || contentDoc.querySelector('h1.title') || contentDoc.querySelector('h1.nameSingle a') || contentDoc.querySelector('h1'); @@ -698,6 +1171,7 @@ let charImageUrl = (isCharacter || isPerson) ? extractCharImageUrl(contentDoc) : ''; let badgeLabel = ''; + let topicTitle = ''; if (isCharacter) badgeLabel = '角色'; if (isPerson) { const subtitleEl = contentDoc.querySelector('h2.subtitle'); @@ -722,7 +1196,53 @@ } } } - await _doShareCard({ username, postTime, avatarUrl, contentEl, pureTitle, contentDoc, contentWin, dark, charImageUrl, badgeLabel }); + if (isSubjectTopic) { + const subjectData = await fetchSubjectDataById(subjectTopicId); + if (subjectData) { + topicTitle = pureTitle; + pureTitle = subjectData.name || pureTitle; + charImageUrl = subjectData.imageUrl || ''; + badgeLabel = subjectData.type || ''; + } + } + if (isBlog) { + const blogSubjectIds = [...new Set( + [...contentDoc.querySelectorAll('a[href]')] + .map(a => (a.getAttribute('href') || '').match(/\/subject\/(\d+)$/)?.[1]) + .filter(Boolean) + )]; + if (blogSubjectIds.length === 1) { + const subjectData = await fetchSubjectDataById(blogSubjectIds[0]); + if (subjectData) { + topicTitle = pureTitle; + pureTitle = subjectData.name || pureTitle; + charImageUrl = subjectData.imageUrl || ''; + badgeLabel = subjectData.type || ''; + } + } + } + await _doShareCard({ username, postTime, avatarUrl, contentEl, pureTitle, contentDoc, contentWin, dark, charImageUrl, badgeLabel, topicTitle }); + } + + function truncateHtml(html, limit) { + const tmp = document.createElement('div'); + tmp.innerHTML = html; + let count = 0, done = false; + const walk = (node) => { + if (done) { node.remove(); return; } + if (node.nodeType === 3) { + const rem = limit - count; + if (node.textContent.length > rem) { + node.textContent = node.textContent.substring(0, rem) + '...'; + done = true; + } else { count += node.textContent.length; } + return; + } + if (node.nodeType !== 1) return; + [...node.childNodes].forEach(c => walk(c)); + }; + [...tmp.childNodes].forEach(c => walk(c)); + return tmp.innerHTML; } function extractPostInfo(postEl, contentWin) { @@ -754,9 +1274,16 @@ const contentEl = postEl.querySelector('.reply_content .message') || postEl.querySelector('.cmt_sub_content') || postEl.querySelector('.topic_content'); const contentClone = contentEl ? contentEl.cloneNode(true) : null; contentClone?.querySelectorAll('.embed-play-btn, .embed-player-wrapper, iframe').forEach(el => el.remove()); + const quoteEl = contentClone?.querySelector('.quote'); + const savedQuoteHtml = quoteEl ? quoteEl.outerHTML : ''; + const bodyClone = contentClone?.cloneNode(true); + bodyClone?.querySelector('.quote')?.remove(); + const bodyText = bodyClone?.innerText?.trim() || ""; const rawText = contentClone?.innerText?.trim() || ""; - const truncated = rawText.length > 150; - const contentHtml = truncated ? (rawText.substring(0, 150) + "...") : (contentEl?.innerHTML?.trim() || ""); + const truncated = bodyText.length > 150; + const contentHtml = truncated + ? savedQuoteHtml + truncateHtml(bodyClone?.innerHTML?.trim() || "", 150) + : (contentEl?.innerHTML?.trim() || ""); return { username, time, avatarUrl, content: truncated ? rawText.substring(0, 150) + "..." : rawText, contentHtml }; } @@ -769,6 +1296,8 @@ const isEpisode = /\/ep\/\d+/.test(contentWin.location.pathname); const isCharacter = /\/character\/\d+|\/rakuen\/topic\/crt\/\d+/.test(contentWin.location.pathname); const isPerson = /\/person\/\d+|\/rakuen\/topic\/prsn\/\d+/.test(contentWin.location.pathname); + const subjectTopicId = getSubjectIdFromTopicPage(contentWin.location.pathname, contentDoc); + const isSubjectTopic = !!subjectTopicId; let username, postTime, avatarUrl, contentEl; if (isEpisode || isCharacter || isPerson) { username = ""; postTime = ""; avatarUrl = ""; contentEl = null; @@ -810,6 +1339,7 @@ let charImageUrl = (isCharacter || isPerson) ? extractCharImageUrl(contentDoc) : ''; let badgeLabel = ''; + let topicTitle = ''; if (isCharacter) badgeLabel = '角色'; if (isPerson) { const subtitleEl = contentDoc.querySelector('h2.subtitle'); @@ -834,8 +1364,33 @@ } } } + if (isSubjectTopic) { + const subjectData = await fetchSubjectDataById(subjectTopicId); + if (subjectData) { + topicTitle = pureTitle; + pureTitle = subjectData.name || pureTitle; + charImageUrl = subjectData.imageUrl || ''; + badgeLabel = subjectData.type || ''; + } + } + if (isBlog) { + const blogSubjectIds = [...new Set( + [...contentDoc.querySelectorAll('a[href]')] + .map(a => (a.getAttribute('href') || '').match(/\/subject\/(\d+)$/)?.[1]) + .filter(Boolean) + )]; + if (blogSubjectIds.length === 1) { + const subjectData = await fetchSubjectDataById(blogSubjectIds[0]); + if (subjectData) { + topicTitle = pureTitle; + pureTitle = subjectData.name || pureTitle; + charImageUrl = subjectData.imageUrl || ''; + badgeLabel = subjectData.type || ''; + } + } + } await _doShareCard({ username, postTime, avatarUrl, contentEl, pureTitle, contentDoc, contentWin, dark, - replies, replyId: replyEl.id, charImageUrl, badgeLabel }); + replies, replyId: replyEl.id, charImageUrl, badgeLabel, topicTitle }); } const REPLY_SHARE_BTN_CLASS = 'bgm-reply-share-btn'; @@ -843,6 +1398,7 @@ const insertReplyButtons = (targetDoc = document) => { targetDoc.querySelectorAll('[id^="post_"]').forEach(post => { + if (post.classList.contains('postTopic')) return; const reInfo = post.querySelector('.post_actions.re_info'); if (!reInfo || post.querySelector('.' + REPLY_SHARE_BTN_CLASS)) return; const wrap = targetDoc.createElement('span'); @@ -861,7 +1417,7 @@ const postActions = targetDoc.querySelector('.entry-actions .post_actions') || targetDoc.querySelector('.postTopic .post_actions:not(.re_info)') || targetDoc.querySelector('[id^="post_"] .post_actions:not(.re_info)') - || [...targetDoc.querySelectorAll('.post_actions:not(.re_info)')].find(el => !el.closest('#sliderContainer')); + || [...targetDoc.querySelectorAll('.post_actions:not(.re_info)')].find(el => !el.closest('#sliderContainer') && !el.closest('#comment_box')); if (postActions) { const wrap = targetDoc.createElement('span'); wrap.className = 'action'; @@ -889,9 +1445,307 @@ } }; + async function createMyCollectionShareImage(targetDoc) { + const dark = targetDoc.documentElement.getAttribute('data-theme') === 'dark'; + const contentWin = targetDoc.defaultView || window; + const subjectIdMatch = contentWin.location.pathname.match(/\/subject\/(\d+)/); + if (!subjectIdMatch) return; + const subjectId = subjectIdMatch[1]; + + const commentText = (targetDoc.querySelector('textarea#comment')?.value || '').trim(); + if (!commentText) return; + + const collectLink = targetDoc.querySelector('.shareBtn a[href*="/user/"]'); + const userSlug = collectLink?.getAttribute('href').match(/\/user\/([^\/\?]+)/)?.[1] || ''; + + let avatarUrl = ''; + let username = userSlug; + if (userSlug) { + const userInfo = await fetchBangumiAPI(`users/${userSlug}`); + if (userInfo) { + avatarUrl = userInfo.avatar?.medium || userInfo.avatar?.large || userInfo.avatar?.small || ''; + username = userInfo.nickname || userInfo.username || userSlug; + } + } + + const ratedInput = targetDoc.querySelector('#panelInterestWrapper input[name="rate"]:checked'); + const rating = ratedInput ? parseInt(ratedInput.value) : 0; + + const timeEl = targetDoc.querySelector('#panelInterestWrapper p.tip'); + const timeText = timeEl ? (timeEl.textContent.match(/\d{4}-\d+-\d+\s+\d+:\d+/)?.[0] || '') : ''; + + const interestNow = targetDoc.querySelector('.interest_now')?.textContent.trim() || ''; + const statusKeys = ['看过', '在看', '想看', '玩过', '在玩', '想玩', '读过', '在读', '想读', '听过', '在听', '想听', '搁置', '抛弃']; + const collectionStatus = statusKeys.find(k => interestNow.includes(k)) || ''; + + const subjectData = await fetchSubjectDataById(subjectId); + const pureTitle = subjectData?.name || targetDoc.title?.split(/\s*[|//]\s*/)[0].trim() || '作品'; + const charImageUrl = subjectData?.imageUrl || ''; + const badgeLabel = subjectData?.type || '作品'; + + const domTagsRaw = (targetDoc.querySelector('#collectBoxForm input[name="tags"]')?.value || '').trim(); + const domTags = domTagsRaw ? domTagsRaw.split(/[\s,,]+/).map(t => t.trim()).filter(Boolean) : []; + const infoboxTags = (SUBJECT_INFOBOX_KEYS[subjectData?.typeNum] || []) + .map(key => getInfoboxValue(subjectData?.infobox, key)) + .filter(Boolean); + const overrideTags = domTags.length > 0 + ? [collectionStatus, ...domTags].filter(Boolean) + : [collectionStatus, ...infoboxTags].filter(Boolean); + + const truncated = commentText.length > 150; + const contentHtml = truncated ? commentText.substring(0, 150) + '...' : commentText; + + await _doShareCard({ + username: '', postTime: '', avatarUrl: '', contentEl: null, + pureTitle, contentDoc: targetDoc, contentWin, dark, + replies: [{ username, time: timeText, avatarUrl, rating, content: contentHtml, contentHtml }], + charImageUrl, badgeLabel, overrideTags + }); + } + + const insertMyCollectionShareButton = (targetDoc = document) => { + if (targetDoc.getElementById('bgm-my-collection-share-btn')) return; + const shareBtn = targetDoc.querySelector('.shareBtn'); + if (!shareBtn) return; + const commentText = (targetDoc.querySelector('textarea#comment')?.value || '').trim(); + if (!commentText) return; + shareBtn.querySelector('.share_pasteboard')?.closest('.action')?.remove(); + shareBtn.querySelector('.shareText')?.remove(); + shareBtn.querySelectorAll('a.share').forEach(el => el.remove()); + const wrap = targetDoc.createElement('span'); + wrap.className = 'action'; + wrap.innerHTML = `${SHARE_SVG}分享`; + shareBtn.appendChild(wrap); + wrap.querySelector('a').addEventListener('click', () => createMyCollectionShareImage(targetDoc)); + }; + + const SUBJECT_COMMENT_SHARE_BTN_CLASS = 'bgm-subject-comment-share-btn'; + + const insertSubjectCommentButtons = (targetDoc = document) => { + targetDoc.querySelectorAll('#comment_box .item.clearit').forEach(item => { + if (item.querySelector('.' + SUBJECT_COMMENT_SHARE_BTN_CLASS)) return; + const postActions = item.querySelector('.post_actions'); + if (!postActions) return; + const wrap = targetDoc.createElement('span'); + wrap.className = 'action'; + wrap.innerHTML = `${SHARE_SVG}分享`; + postActions.appendChild(wrap); + wrap.querySelector('a').addEventListener('click', () => createSubjectCommentShareImage(item, targetDoc)); + }); + }; + + const COLLECTION_PAGE_SHARE_BTN_CLASS = 'bgm-collection-page-share-btn'; + + const insertCollectionPageButtons = (targetDoc = document) => { + if (!Object.keys(COLLECTION_PAGE_STATUS).some(k => + new RegExp(`/subject/\\d+/${k}`).test((targetDoc.defaultView || window).location.pathname) + )) return; + targetDoc.querySelectorAll('#memberUserList li.user').forEach(item => { + if (item.querySelector('.' + COLLECTION_PAGE_SHARE_BTN_CLASS)) return; + const container = item.querySelector('.userContainer'); + if (!container) return; + let hasComment = false; + let afterInfo = false; + container.childNodes.forEach(n => { + if (n.nodeType === 1 && n.tagName === 'P' && n.classList.contains('info')) { afterInfo = true; return; } + if (afterInfo && n.nodeType === 3 && n.textContent.trim()) hasComment = true; + }); + if (!hasComment) return; + const infoEl = item.querySelector('p.info'); + if (!infoEl) return; + const btn = targetDoc.createElement('a'); + btn.href = 'javascript:void(0);'; + btn.className = COLLECTION_PAGE_SHARE_BTN_CLASS + ' icon'; + btn.title = '分享吐槽'; + btn.style.cssText = 'display:inline-flex;align-items:center;gap:4px;margin-left:8px;font-size:12px;'; + btn.innerHTML = SHARE_SVG + '分享'; + infoEl.appendChild(btn); + btn.addEventListener('click', () => createCollectionPageShareImage(item, targetDoc)); + }); + }; + + const BROWSER_LIST_CATEGORY_STATUS = { + anime: { do: '在看', collect: '看过', wish: '想看', on_hold: '搁置', dropped: '抛弃' }, + music: { do: '在听', collect: '听过', wish: '想听', on_hold: '搁置', dropped: '抛弃' }, + game: { do: '在玩', collect: '玩过', wish: '想玩', on_hold: '搁置', dropped: '抛弃' }, + real: { do: '在看', collect: '看过', wish: '想看', on_hold: '搁置', dropped: '抛弃' }, + book: { do: '在读', collect: '读过', wish: '想读', on_hold: '搁置', dropped: '抛弃' } + }; + + function extractBrowserListItemInfo(itemEl, contentWin) { + const subjectId = itemEl.id?.replace('item_', '') || ''; + const ratingMatch = itemEl.querySelector('.starlight')?.className.match(/stars(\d+)/); + const rating = ratingMatch ? parseInt(ratingMatch[1]) : 0; + const time = itemEl.querySelector('.tip_j')?.textContent.trim() || ''; + const tagsRaw = itemEl.querySelector('p.collectInfo .tip')?.textContent.replace(/^[^::]*[::]\s*/, '').trim() || ''; + const domTags = tagsRaw ? tagsRaw.split(/\s+/).filter(Boolean) : []; + const commentEl = itemEl.querySelector('#comment_box .text'); + const rawText = commentEl?.textContent.trim() || ''; + const truncated = rawText.length > 150; + const contentHtml = truncated ? rawText.substring(0, 150) + '...' : rawText; + return { subjectId, rating, time, domTags, content: contentHtml, contentHtml }; + } + + async function createBrowserListShareImage(itemEl, contentDoc = document) { + const dark = contentDoc.documentElement.getAttribute('data-theme') === 'dark'; + const contentWin = contentDoc.defaultView || window; + const m = contentWin.location.pathname.match(/\/(anime|music|game|real|book)\/list\/([^\/]+)\/(do|collect|wish|on_hold|dropped)/); + if (!m) return; + const [, category, userSlug, statusKey] = m; + const collectionStatus = BROWSER_LIST_CATEGORY_STATUS[category]?.[statusKey] || ''; + const itemInfo = extractBrowserListItemInfo(itemEl, contentWin); + if (!itemInfo.subjectId) return; + const [subjectData, userInfo] = await Promise.all([ + fetchSubjectDataById(itemInfo.subjectId), + fetchBangumiAPI(`users/${userSlug}`) + ]); + const categoryTypeMap = { anime: '动画', music: '音乐', game: '游戏', real: '三次元', book: '书籍' }; + let pureTitle = subjectData?.name || ''; + let charImageUrl = subjectData?.imageUrl || ''; + let badgeLabel = subjectData?.type || ''; + if (!subjectData) { + _shareNotice = 'NSFW 条目 API 无权访问,已降级为页面数据,部分信息可能不完整'; + pureTitle = itemEl.querySelector('h3 a.l')?.textContent.trim() || ''; + const coverImg = itemEl.querySelector('.subjectCover img'); + charImageUrl = coverImg?.src || coverImg?.dataset.src || ''; + badgeLabel = categoryTypeMap[category] || '作品'; + } + if (!badgeLabel) badgeLabel = categoryTypeMap[category] || '作品'; + let username = userInfo?.nickname || userInfo?.username || userSlug; + let avatarUrl = userInfo?.avatar?.medium || userInfo?.avatar?.large || ''; + let userTags = null; + if (userSlug) { + const collection = await fetchBangumiAPI(`users/${userSlug}/collections/${itemInfo.subjectId}`); + if (collection) { + if (!itemInfo.rating) itemInfo.rating = collection.rate || 0; + if (!itemInfo.time && collection.updated_at) { + const d = new Date(collection.updated_at); + const pad = n => String(n).padStart(2, '0'); + itemInfo.time = `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; + } + if (collection.tags?.length > 0) userTags = collection.tags; + } + } + if (!itemInfo.time) itemInfo.time = '未知时间'; + const infoboxTags = (SUBJECT_INFOBOX_KEYS[subjectData?.typeNum] || []) + .map(key => getInfoboxValue(subjectData?.infobox, key)) + .filter(Boolean); + const effectiveTags = userTags || (itemInfo.domTags.length > 0 ? itemInfo.domTags : null); + const overrideTags = effectiveTags + ? [collectionStatus, ...effectiveTags].filter(Boolean) + : [collectionStatus, ...infoboxTags].filter(Boolean); + await _doShareCard({ + username: '', postTime: '', avatarUrl: '', contentEl: null, + pureTitle, contentDoc, contentWin, dark, + replies: [{ username, time: itemInfo.time, avatarUrl, rating: itemInfo.rating, content: itemInfo.content, contentHtml: itemInfo.contentHtml }], + charImageUrl, badgeLabel, overrideTags + }); + } + + const BROWSER_LIST_SHARE_BTN_CLASS = 'bgm-browser-list-share-btn'; + + const insertBrowserListButtons = (targetDoc = document) => { + const pathname = (targetDoc.defaultView || window).location.pathname; + if (!/\/(anime|music|game|real|book)\/list\/[^\/]+\/(do|collect|wish|on_hold|dropped)/.test(pathname)) return; + targetDoc.querySelectorAll('#browserItemList li.item').forEach(item => { + if (item.querySelector('.' + BROWSER_LIST_SHARE_BTN_CLASS)) return; + const commentEl = item.querySelector('#comment_box .text'); + if (!commentEl?.textContent.trim()) return; + const modifyP = item.querySelector('p.collectModify'); + if (!modifyP) return; + modifyP.appendChild(targetDoc.createTextNode(' | ')); + const btn = targetDoc.createElement('a'); + btn.href = 'javascript:void(0);'; + btn.className = BROWSER_LIST_SHARE_BTN_CLASS + ' l'; + btn.style.cssText = 'display:inline-flex;align-items:center;gap:3px;vertical-align:middle;'; + btn.innerHTML = SHARE_SVG + '分享'; + modifyP.appendChild(btn); + btn.addEventListener('click', () => createBrowserListShareImage(item, targetDoc)); + }); + }; + + function injectShareSettingsTab() { + const panel = document.getElementById('customize-panel'); + if (!panel || panel.querySelector('[data-tab="bgm-topic-share"]')) return; + const tabList = panel.querySelector('.panel-tabs .scrollable'); + const contentArea = panel.querySelector('.content'); + if (!tabList || !contentArea) return; + + tabList.style.overflowX = 'auto'; + tabList.style.scrollbarWidth = 'thin'; + tabList.style.flexWrap = 'nowrap'; + + const tabLi = document.createElement('li'); + tabLi.innerHTML = '番组分享'; + tabList.appendChild(tabLi); + + const token = getBgmToken(); + const aiUrl = GM_getValue('bgm_share_ai_url', ''); + const aiKey = GM_getValue('bgm_share_ai_key', ''); + const aiModel = GM_getValue('bgm_share_ai_model', '') || 'gpt-3.5-turbo'; + + const tabDiv = document.createElement('div'); + tabDiv.className = 'tab-content'; + tabDiv.id = 'bgm-topic-share-tab'; + tabDiv.innerHTML = ` +
+
Bangumi 授权(解锁 NSFW 数据)
+
+
+ ${token ? '✓ 已授权,API 请求将携带 Token' : '未授权 - NSFW 条目数据将降级为 DOM 抓取'} +
+
+ 立即授权 + 撤销授权 +
+
+
+
+
AI 标签配置(可选)
+
+ + + +
+ 保存 + +
+
+
`; + contentArea.appendChild(tabDiv); + + const tabLink = tabLi.querySelector('a'); + tabLink.addEventListener('click', () => { + panel.querySelectorAll('.tab-item').forEach(t => t.classList.remove('focus')); + tabLink.classList.add('focus'); + panel.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); + tabDiv.classList.add('active'); + }); + tabDiv.querySelector('#bgm-share-auth-btn').addEventListener('click', startBgmOAuth); + tabDiv.querySelector('#bgm-share-deauth-btn').addEventListener('click', () => { + clearBgmToken(); + tabDiv.querySelector('#bgm-share-auth-status').textContent = '未授权 - NSFW 条目数据将降级为 DOM 抓取'; + tabDiv.querySelector('#bgm-share-auth-status').style.color = '#aaa'; + tabDiv.querySelector('#bgm-share-auth-btn').style.display = ''; + tabDiv.querySelector('#bgm-share-deauth-btn').style.display = 'none'; + }); + tabDiv.querySelector('#bgm-share-ai-save').addEventListener('click', () => { + GM_setValue('bgm_share_ai_url', tabDiv.querySelector('#bgm-share-ai-url').value); + GM_setValue('bgm_share_ai_key', tabDiv.querySelector('#bgm-share-ai-key').value); + GM_setValue('bgm_share_ai_model', tabDiv.querySelector('#bgm-share-ai-model').value); + const s = tabDiv.querySelector('#bgm-share-ai-saved'); + s.style.display = ''; + setTimeout(() => s.style.display = 'none', 2000); + }); + } + const observeReplies = (targetDoc) => { - const observer = new MutationObserver(() => insertReplyButtons(targetDoc)); - const root = targetDoc.getElementById('comment_list') || targetDoc.getElementById('reply_list') || targetDoc.body; + const observer = new MutationObserver(() => { + insertReplyButtons(targetDoc); + insertSubjectCommentButtons(targetDoc); + }); + const root = targetDoc.getElementById('comment_list') || targetDoc.getElementById('reply_list') + || targetDoc.getElementById('comment_box') || targetDoc.body; if (root) observer.observe(root, { childList: true, subtree: true }); }; @@ -913,6 +1767,21 @@ }; rightFrame.addEventListener('load', onRightFrameLoad); } else { - setTimeout(() => { insertButton(); insertReplyButtons(); observeReplies(document); }, 500); + setTimeout(() => { insertButton(); insertReplyButtons(); insertSubjectCommentButtons(); insertCollectionPageButtons(); insertBrowserListButtons(); insertMyCollectionShareButton(); observeReplies(document); }, 500); + } + + checkOAuthCallback(); + refreshBgmTokenIfNeeded(); + + if (document.getElementById('customize-panel')) { + injectShareSettingsTab(); + } else { + const _panelObs = new MutationObserver(() => { + if (document.getElementById('customize-panel')) { + _panelObs.disconnect(); + injectShareSettingsTab(); + } + }); + _panelObs.observe(document.body, { childList: true, subtree: true }); } })();