// ==UserScript==
// @name Bangumi Topic Share
// @namespace http://tampermonkey.net/
// @version 5.2
// @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/*
// @match *://bgm.tv/ep/*
// @match *://bangumi.tv/ep/*
// @match *://chii.in/ep/*
// @match *://bgm.tv/character/*
// @match *://bangumi.tv/character/*
// @match *://chii.in/character/*
// @match *://bgm.tv/person/*
// @match *://bangumi.tv/person/*
// @match *://chii.in/person/*
// @match *://bgm.tv/rakuen*
// @match *://bangumi.tv/rakuen*
// @match *://chii.in/rakuen*
// @grant GM_xmlhttpRequest
// @connect *
// @require https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js
// @license MIT
// ==/UserScript==
(function() {
'use strict';
// ================= 配置区 =================
const AI_CONFIG = {
apiUrl: "在此处填入你的_API_URL",
apiKey: "在此处填入你的_API_KEY",
model: "gpt-3.5-turbo",
};
// =========================================
const style = document.createElement('style');
style.innerHTML = `
#bgm-share-overlay {
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.85); display: none; justify-content: center;
align-items: flex-start; overflow-y: auto; z-index: 100000;
box-sizing: border-box;
}
.share-card {
width: 420px; background: #fff; border-radius: 20px; overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
box-shadow: 0 25px 60px rgba(0,0,0,0.5);
}
.card-top-bar { height: 6px; background: #F09199; }
.card-header { padding: 25px 25px 15px; display: flex; align-items: center; gap: 15px; text-align: left; border-bottom: 1px solid #eee; }
.avatar-img { width: 54px; height: 54px; border-radius: 12px; background: #eee; background-size: cover; background-position: center; border: 1px solid #f0f0f0; flex-shrink: 0; }
.user-meta { text-align: left; }
.user-meta .name { display: block; font-weight: bold; color: #F09199; font-size: 17px; line-height: 1.2; }
.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; }
.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; }
.share-card .tags-container { margin-top: 15px; }
.tag-item { background: #FEEFF0; color: #F09199; font-size: 11px; padding: 4px 12px; border-radius: 20px; font-weight: bold; border: 1px solid #F0919944; }
.card-footer { background: #f9f9f9; padding: 20px 25px; display: flex; justify-content: space-between; align-items: center; border-top: 1px solid #eee; }
.qr-img { width: 55px; height: 55px; background: #fff; }
#loading-info { position: fixed; top: 55%; left: 50%; transform: translateX(-50%); color: #fff; font-size: 14px; z-index: 100001; }
.bgm-btn-row { display: flex; gap: 16px; margin: 20px auto 0; justify-content: center; }
.bgm-action-btn {
width: 48px; height: 48px; padding: 0;
background: rgba(255,255,255,0.15); color: #fff;
border: 1.5px solid rgba(255,255,255,0.35);
border-radius: 50%; cursor: pointer; display: flex; align-items: center; justify-content: center;
transition: background 0.2s, transform 0.15s, opacity 0.2s;
position: relative;
}
.bgm-action-btn:hover:not(:disabled) { background: rgba(255,255,255,0.3); transform: scale(1.1); }
.bgm-action-btn:disabled { opacity: 0.3; cursor: default; }
.bgm-action-btn svg { display: block; }
.bgm-action-btn::after {
content: attr(data-tip);
position: absolute;
top: calc(100% + 10px);
left: 50%;
transform: translateX(-50%) translateY(-6px);
background: rgba(20,20,20,0.82);
color: #fff;
font-size: 12px;
padding: 5px 12px;
border-radius: 8px;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transition: opacity 0.18s ease, transform 0.18s ease;
}
.bgm-action-btn:hover:not(:disabled)::after {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
/* ===== 暗色主题 ===== */
.share-card.dark { background: #1e1e1e; }
.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 .content-box { background: #2a2a2a; border-left: 5px solid #F09199; }
.share-card.dark .content-text { color: #ddd; }
.share-card.dark .tags-container { margin-top: 15px; }
.share-card.dark .tag-item { background: #2a2a2a; border: 1px solid #F0919966; }
.share-card.dark .card-footer { background: #181818; border-top: 1px solid rgba(255,255,255,0.1); }
.share-card.dark .qr-img { background: #2a2a2a; }
.content-text img[data-bgm-emoji]:not(.smile-dynamic) { height: 1.5em; width: auto; vertical-align: baseline; }
.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; }
`;
document.head.appendChild(style);
function fetchAsBase64(url) {
return new Promise((resolve) => {
if (!url) { resolve(""); return; }
const finalUrl = url.startsWith('//') ? 'https:' + url : url;
GM_xmlhttpRequest({
method: "GET", url: finalUrl, responseType: "blob",
onload: (res) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result);
reader.readAsDataURL(res.response);
},
onerror: () => resolve("")
});
});
}
async function inlineImages(html) {
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('.text_mask').forEach(el => { el.removeAttribute('style'); el.classList.remove('text_mask'); el.dataset.bgmMask = '1'; });
const imgs = [...div.querySelectorAll('img')];
imgs.forEach(img => {
if (img.hasAttribute('smileid') || /\/smiles\//.test(img.src)) {
img.setAttribute('data-bgm-emoji', '1');
}
});
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]; });
}
return div.innerHTML;
}
async function getPageTags(contentDoc) {
const contentWin = contentDoc.defaultView || window;
const pathname = contentWin.location.pathname;
const isBlog = /\/blog\/\d+/.test(pathname);
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);
if (isEpisode) {
const epIdMatch = pathname.match(/\/ep\/(\d+)/);
const replyCount = contentDoc.querySelectorAll('[id^="post_"]').length;
if (epIdMatch) {
const epData = await fetchEpisodeData(epIdMatch[1]);
if (epData?.subjectName) return [epData.subjectName, `${replyCount} 回复`];
}
return [`${replyCount} 回复`];
}
if (isBlog) {
const subjectNames = [...new Set(
[...contentDoc.querySelectorAll('a')]
.filter(a => /\/subject\/\d+$/.test(a.href) && a.textContent.trim())
.map(a => a.textContent.trim())
)];
const replyCount = contentDoc.querySelectorAll('[id^="post_"]').length;
return [...subjectNames, `${replyCount} 回复`, '日志'];
}
if (isCharacter) {
const replyCount = contentDoc.querySelectorAll('[id^="post_"]').length;
const crtMatch = pathname.match(/\/rakuen\/topic\/crt\/(\d+)/);
if (crtMatch) {
const [subjects, persons] = await Promise.all([
fetchBangumiAPI(`characters/${crtMatch[1]}/subjects`),
fetchBangumiAPI(`characters/${crtMatch[1]}/persons`)
]);
const staffPriority = { '主角': 0, '配角': 1, '客串': 2 };
const typePriorityApi = { 2: 0, 4: 1 };
const scored = (subjects || []).map(s => ({
name: s.name_cn || s.name,
rp: staffPriority[s.staff] ?? 99,
tp: typePriorityApi[s.type] ?? 99
})).sort((a, b) => a.rp !== b.rp ? a.rp - b.rp : a.tp - b.tp);
const subjectNames = [...new Set(scored.map(s => s.name))].slice(0, 2);
const cvNames = [...new Set((persons || []).filter(p => p.type === 1).map(p => p.name))];
const tags = [];
if (cvNames.length) tags.push('!CV: ' + cvNames.join(' / '));
tags.push(...subjectNames);
tags.push(`${replyCount} 回复`);
return tags;
}
const cvNames = [...new Set(
[...contentDoc.querySelectorAll('.browserList .badge_actor h3 a')]
.map(a => a.textContent.trim()).filter(Boolean)
)];
const rolePriority = { '1': 0, '2': 1, '3': 2 };
const typePriority = { '2': 0, '3': 1 };
const scoredItems = [...contentDoc.querySelectorAll('.browserList .item')].map(item => {
const nameEl = item.querySelector('.innerLeftItem h3 a.l');
if (!nameEl) return null;
const roleAttr = item.querySelector('.badge_job[attr-crt-type]')?.getAttribute('attr-crt-type') || '99';
const typeMatch = item.querySelector('.ico_subject_type')?.className.match(/subject_type_(\d+)/);
const typeNum = typeMatch ? typeMatch[1] : '99';
return { name: nameEl.textContent.trim(), rp: rolePriority[roleAttr] ?? 99, tp: typePriority[typeNum] ?? 99 };
}).filter(Boolean);
scoredItems.sort((a, b) => a.rp !== b.rp ? a.rp - b.rp : a.tp - b.tp);
const subjectNames = [...new Set(scoredItems.map(s => s.name))].slice(0, 2);
const tags = [];
if (cvNames.length) tags.push('!CV: ' + cvNames.join(' / '));
tags.push(...subjectNames);
tags.push(`${replyCount} 回复`);
return tags;
}
if (isPerson) {
const replyCount = contentDoc.querySelectorAll('[id^="post_"]').length;
const personIdMatch = pathname.match(/\/rakuen\/topic\/prsn\/(\d+)/) || pathname.match(/\/person\/(\d+)/);
if (personIdMatch) {
const workTags = await fetchPersonWorkTags(personIdMatch[1]);
return [...workTags, `${replyCount} 回复`];
}
}
const groupLink = contentDoc.querySelector('a.avatar[href^="/group/"]');
let groupName = '';
if (groupLink) {
groupLink.childNodes.forEach(n => { if (n.nodeType === 3) groupName += n.textContent.trim(); });
}
if (!groupName) {
const subjectLink = contentDoc.querySelector('#pageHeader a[href^="/subject/"]')
|| contentDoc.querySelector('a[href^="/subject/"]');
if (subjectLink) groupName = subjectLink.textContent.trim();
}
const replyCount = Math.max(0, contentDoc.querySelectorAll('[id^="post_"]').length - 1);
return [groupName || 'Bangumi', `${replyCount} 回复`];
}
async function getAITags(title, content, contentDoc) {
if (!AI_CONFIG.apiKey || AI_CONFIG.apiKey.includes("填入")) 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 }),
onload: (res) => {
try {
const tags = JSON.parse(res.responseText).choices[0].message.content.trim().split(/\s+/).slice(0, 3);
resolve(tags);
} catch (e) { resolve(getPageTags(contentDoc)); }
},
onerror: () => resolve(getPageTags(contentDoc))
});
});
}
// 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 = '' }) {
if (typeof html2canvas === 'undefined') {
alert("截图库加载失败,请刷新页面或检查网络。");
return;
}
const loading = document.createElement('div');
loading.innerHTML = '
';
document.body.appendChild(loading);
let fullContent = "";
let displayContentHtml = "";
if (contentEl) {
const toHide = contentEl.querySelectorAll('.forum_category, #catfish_likes_grid, .embed-play-btn');
toHide.forEach(el => el.style.display = 'none');
const computedHidden = [...contentEl.querySelectorAll('*')].filter(el => {
const cs = getComputedStyle(el);
return cs.display === 'none' || cs.visibility === 'hidden';
});
computedHidden.forEach(el => { el.dataset.hiddenSnapshot = '1'; el.style.display = 'none'; });
fullContent = contentEl.innerText?.trim() || "";
const fullHtml = contentEl.innerHTML?.trim() || "";
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;
}
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),
fetchAsBase64(avatarUrl),
fetchAsBase64(charImageUrl),
fetchAsBase64(`https://api.qrserver.com/v1/create-qr-code/?size=150x150&data=${encodeURIComponent(shareUrl)}${dark ? '&color=F09199&bgcolor=2a2a2a' : ''}`),
...replies.map(r => fetchAsBase64(r.avatarUrl))
]);
const [inlinedMainContent, ...inlinedReplyContents] = await Promise.all([
inlineImages(displayContentHtml),
...replies.map(r => inlineImages(r.contentHtml || r.content))
]);
const tagsHtml = tags.map(tag => tag.startsWith('!') ? `${tag.slice(1)}` : `# ${tag}`).join('');
const divider = dark ? 'rgba(255,255,255,0.1)' : '#eee';
const hasMainContent = !!inlinedMainContent || !!username;
const renderLevel = (idx) => {
if (idx >= replies.length) return '';
const r = replies[idx];
const b64 = base64ReplyAvatars[idx];
const avatarSize = Math.max(22, 28 - idx * 4);
const topStyle = idx === 0
? `margin-top:${base64CharImage ? '6' : '14'}px;padding-top:${base64CharImage ? '6' : '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}
${inlinedReplyContents[idx]}
${inner}
`;
};
const replySection = replies.length > 0 ? renderLevel(0) : '';
loading.remove();
const overlay = document.createElement('div');
overlay.id = 'bgm-share-overlay';
overlay.style.display = 'flex';
overlay.innerHTML = `
${base64CharImage ? `
${pureTitle}
${badgeLabel}
` : badgeLabel ? `
${pureTitle}
${badgeLabel}
` : ''}
${username ? `` : ''}
${base64CharImage ? '' : `
${pureTitle}
`}
${inlinedMainContent ? `
` : ''}
${replySection}
${tagsHtml}
`;
document.body.appendChild(overlay);
let cancelled = false;
let maskRevealed = false;
let currentCanvas = null;
const maskPreviewStyle = document.createElement('style');
document.head.appendChild(maskPreviewStyle);
const copyBtn = document.getElementById('bgm-copy-btn');
const downloadBtn = document.getElementById('bgm-download-btn');
const hasMask = !!document.querySelector('#capture-area [data-bgm-mask]');
if (!hasMask) document.getElementById('bgm-mask-btn').style.display = 'none';
const updateMaskPreview = () => {
maskPreviewStyle.textContent = maskRevealed
? '#capture-area [data-bgm-mask] { color: #fff !important; }'
: '';
const btn = document.getElementById('bgm-mask-btn');
if (btn) btn.setAttribute('data-tip', maskRevealed ? '遮住剧透' : '显示剧透');
};
updateMaskPreview();
document.getElementById('bgm-close-btn').addEventListener('click', () => {
cancelled = true;
maskPreviewStyle.remove();
overlay.remove();
});
const showToast = (msg) => {
const toast = document.createElement('div');
toast.textContent = msg;
toast.style.cssText = 'position:fixed;bottom:40px;left:50%;transform:translateX(-50%);background:#333;color:#fff;padding:8px 20px;border-radius:20px;font-size:14px;z-index:100002;opacity:1;transition:opacity 0.5s';
document.body.appendChild(toast);
setTimeout(() => { toast.style.opacity = '0'; setTimeout(() => toast.remove(), 500); }, 1800);
};
document.getElementById('bgm-text-btn').addEventListener('click', async () => {
const shareText = `【链接】${pureTitle} | Bangumi番组计划\n${shareUrl}`;
try {
await navigator.clipboard.writeText(shareText);
showToast('✓ 文案已复制');
} catch (e) {
showToast('✗ 复制失败');
}
});
document.getElementById('bgm-mask-btn').addEventListener('click', () => {
maskRevealed = !maskRevealed;
updateMaskPreview();
doCapture();
});
copyBtn.addEventListener('click', async () => {
if (!currentCanvas) return;
try {
const blob = await new Promise(resolve => currentCanvas.toBlob(resolve, 'image/png'));
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
showToast('✓ 已复制到剪贴板');
} catch (e) {
showToast('✗ 复制失败,请改用下载');
}
});
downloadBtn.addEventListener('click', () => {
if (!currentCanvas) return;
const link = document.createElement('a');
link.download = `BGM_Share_${username}.png`;
link.href = currentCanvas.toDataURL('image/png');
link.click();
});
const doCapture = async () => {
if (cancelled) return;
copyBtn.disabled = true;
downloadBtn.disabled = true;
let canvas;
let iframe = null;
try {
const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 10000));
const captureEl = document.querySelector('#capture-area');
iframe = document.createElement('iframe');
iframe.style.cssText = 'position:fixed;top:0;left:0;border:0;opacity:0;pointer-events:none;z-index:99999;';
iframe.style.width = captureEl.offsetWidth + 'px';
iframe.style.height = captureEl.offsetHeight + 'px';
document.body.appendChild(iframe);
const iDoc = iframe.contentDocument;
const iStyle = iDoc.createElement('style');
const maskCss = maskRevealed ? '[data-bgm-mask] { color: #fff !important; }' : '';
const sampleLink = contentDoc.querySelector('a') || document.querySelector('a');
const linkColor = sampleLink ? getComputedStyle(sampleLink).color : (dark ? '#8ec8e8' : '#0066cc');
const sampleLinkDecoration = sampleLink ? getComputedStyle(sampleLink).textDecorationLine : 'none';
iStyle.textContent = style.innerHTML + maskCss + ` a { color: ${linkColor}; text-decoration: ${sampleLinkDecoration}; }`;
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)));
canvas = await Promise.race([
html2canvas(iDoc.body.firstElementChild, { scale: 2, backgroundColor: null }),
timeout
]);
// 裁掉底部全透明行(inline-block baseline gap 产生的透明条)
const imgData = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height).data;
let trimH = canvas.height;
for (let y = canvas.height - 1; y >= 0; y--) {
let opaque = false;
for (let x = 0; x < canvas.width; x++) {
if (imgData[(y * canvas.width + x) * 4 + 3] > 0) { opaque = true; break; }
}
if (opaque) { trimH = y + 1; break; }
}
if (trimH < canvas.height) {
const trimmed = document.createElement('canvas');
trimmed.width = canvas.width;
trimmed.height = trimH;
trimmed.getContext('2d').drawImage(canvas, 0, 0);
canvas = trimmed;
}
} catch (e) {
iframe?.remove();
showToast('✗ 截图失败,请刷新后重试');
return;
}
iframe?.remove();
if (cancelled) return;
currentCanvas = canvas;
copyBtn.disabled = false;
downloadBtn.disabled = false;
};
setTimeout(() => doCapture(), 800);
}
function extractCharImageUrl(contentDoc) {
const link = contentDoc.querySelector('#columnCrtA a.thickbox')
|| contentDoc.querySelector('a.thickbox[href*="/pic/crt/"]')
|| contentDoc.querySelector('a.thickbox[href*="/pic/user/"]');
if (link?.href) return link.href;
const img = contentDoc.querySelector('#columnCrtA img')
|| contentDoc.querySelector('#crt_cover img')
|| contentDoc.querySelector('img[src*="/pic/crt/l/"]')
|| contentDoc.querySelector('img[src*="/pic/user/l/"]')
|| contentDoc.querySelector('img[src*="/pic/crt/m/"]');
return img?.src || '';
}
async function fetchPersonWorkTags(personId) {
const staffPriority = { '主角': 0, '配角': 1, '客串': 2 };
const typePriority = { 2: 0, 4: 1 };
const chars = await fetchBangumiAPI(`persons/${personId}/characters`);
if (chars && chars.length) {
const charMap = new Map();
for (const c of chars) {
const rp = staffPriority[c.staff] ?? 99;
const tp = typePriority[c.subject_type] ?? 99;
const existing = charMap.get(c.id);
if (!existing || rp < existing.rp || (rp === existing.rp && tp < existing.tp)) {
charMap.set(c.id, { charName: c.name, workName: c.subject_name_cn || c.subject_name, rp, tp });
}
}
return [...charMap.values()]
.sort((a, b) => a.rp !== b.rp ? a.rp - b.rp : a.tp - b.tp)
.slice(0, 2)
.map(c => `${c.charName} - ${c.workName}`);
}
const subjects = await fetchBangumiAPI(`persons/${personId}/subjects`);
if (subjects && subjects.length) {
const staffOrder = [
// 核心创作
'监督', '原案', '原作', '系列构成', '脚本', '剧本', '编剧', '企画',
// 人设/美术
'人物设计', '总作画监督', '作画监督', '作画监督助理', '监修', '设定协力',
// 动画制作
'演出', '分镜', '原画', '主动画师', '作画', '第二原画', '补间动画', '动画',
// 音乐
'音乐', '作曲', '编曲', '作词', '主题歌演出', '主题歌作曲', '主题歌编曲', '母带制作', '艺术家',
// 技术/制作
'音响监督', '摄影监督', '色彩设计', '美术监督', '制作人', '监制', '执行制片人', '协力',
// 出演
'主演', '配角', '出演', '客串'
];
const staffRank = (staff) => { const i = staffOrder.indexOf(staff); return i === -1 ? staffOrder.length : i; };
const seen = new Set();
const seenStaff = new Set();
const result = [];
for (const s of [...subjects].sort((a, b) => {
const sr = staffRank(a.staff) - staffRank(b.staff);
if (sr !== 0) return sr;
return (typePriority[a.type] ?? 99) - (typePriority[b.type] ?? 99);
})) {
const name = s.name_cn || s.name;
if (name && !seen.has(s.id) && !seenStaff.has(s.staff)) {
seen.add(s.id);
seenStaff.add(s.staff);
result.push(s.staff ? `${s.staff} - ${name}` : name);
}
if (result.length >= 2) break;
}
return result;
}
return [];
}
function fetchBangumiAPI(path) {
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); } },
onerror: () => resolve(null)
});
});
}
async function getRakuenCharPersonData(pathname) {
const crtMatch = pathname.match(/\/rakuen\/topic\/crt\/(\d+)/);
const prsnMatch = pathname.match(/\/rakuen\/topic\/prsn\/(\d+)/);
if (!crtMatch && !prsnMatch) return null;
const id = (crtMatch || prsnMatch)[1];
const type = crtMatch ? 'characters' : 'persons';
const data = await fetchBangumiAPI(`${type}/${id}`);
if (!data) return null;
const imageUrl = data.images?.large || data.images?.medium || '';
const name = data.name || '';
const careerMap = { producer: '制作人', mangaka: '漫画家', artist: '画师', seiyu: '声优', writer: '作者', illustrator: '插画师', actor: '演员' };
const badgeLabel = crtMatch ? '角色' : ((data.career || []).map(c => careerMap[c]).filter(Boolean).join(' ') || '人物');
return { imageUrl, name, badgeLabel, id, type };
}
const _episodeDataCache = {};
async function fetchEpisodeData(episodeId) {
if (_episodeDataCache[episodeId]) return _episodeDataCache[episodeId];
const ep = await fetchBangumiAPI(`episodes/${episodeId}`);
if (!ep) return null;
const subject = await fetchBangumiAPI(`subjects/${ep.subject_id}`);
const result = {
episodeName: ep.name_cn || ep.name || '',
subjectName: subject?.name_cn || subject?.name || '',
subjectImageUrl: subject?.images?.common || subject?.images?.medium || '',
epNumber: ep.ep
};
_episodeDataCache[episodeId] = result;
return result;
}
async function createShareImage(contentDoc = document) {
const dark = contentDoc.documentElement.getAttribute('data-theme') === 'dark';
const contentWin = contentDoc.defaultView || window;
const isBlog = /\/blog\/\d+/.test(contentWin.location.pathname);
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);
let username, postTime, avatarUrl, contentEl;
if (isEpisode || isCharacter || isPerson) {
username = ""; postTime = ""; avatarUrl = ""; contentEl = null;
} else if (isBlog) {
const authorLink = contentDoc.querySelector('.author.user-card .title p a')
|| contentDoc.querySelector('.author.user-card a.avatar');
username = authorLink ? authorLink.textContent.trim() : "未知用户";
const timeEl = contentDoc.querySelector('.header .tools .time');
postTime = timeEl ? (timeEl.innerText.match(/\d{4}-\d{1,2}-\d{1,2}\s\d{1,2}:\d{1,2}/)?.[0] || "未知时间") : "未知时间";
const avatarImg = contentDoc.querySelector('.author.user-card a.avatar img');
avatarUrl = avatarImg ? avatarImg.src : "";
contentEl = contentDoc.querySelector('#entry_content');
} else {
const firstPost = contentDoc.querySelector('.postTopic') || contentDoc.querySelector('[id^="post_"]');
const idNode = firstPost?.querySelector('strong a') || firstPost?.querySelector('.author strong a');
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');
avatarUrl = avatarBox ? contentWin.getComputedStyle(avatarBox).backgroundImage.replace(/url\(["']?([^"']+)["']?\)/, '$1') : "";
contentEl = masterPost?.querySelector('.topic_content') || masterPost?.querySelector('.inner');
}
const h1Node = contentDoc.querySelector('#pageHeader h1') || contentDoc.querySelector('h1.title') || contentDoc.querySelector('h1.nameSingle a') || contentDoc.querySelector('h1');
let pureTitle = "";
if (h1Node) h1Node.childNodes.forEach(n => { if (n.nodeType === 3) pureTitle += n.textContent; });
pureTitle = pureTitle.replace(/[»\n]/g, '').trim();
if (!pureTitle) {
const rawTitle = (contentDoc.title || '').split(/\s*[|//]\s*/)[0].trim();
pureTitle = rawTitle || "分享话题";
}
let charImageUrl = (isCharacter || isPerson) ? extractCharImageUrl(contentDoc) : '';
let badgeLabel = '';
if (isCharacter) badgeLabel = '角色';
if (isPerson) {
const subtitleEl = contentDoc.querySelector('h2.subtitle');
const subtitleText = subtitleEl ? subtitleEl.textContent.trim() : '';
badgeLabel = subtitleText.replace(/^[^::]*[::]\s*/, '').trim() || '人物';
}
if ((isCharacter || isPerson) && (!charImageUrl || /\/rakuen\/topic\/(crt|prsn)\//.test(contentWin.location.pathname))) {
const apiData = await getRakuenCharPersonData(contentWin.location.pathname);
if (apiData) {
if (!charImageUrl) charImageUrl = apiData.imageUrl;
if (!badgeLabel || badgeLabel === '人物') badgeLabel = apiData.badgeLabel;
}
}
if (isEpisode) {
const epIdMatch = contentWin.location.pathname.match(/\/ep\/(\d+)/);
if (epIdMatch) {
const epData = await fetchEpisodeData(epIdMatch[1]);
if (epData) {
if (epData.episodeName) pureTitle = epData.episodeName;
if (epData.subjectImageUrl) charImageUrl = epData.subjectImageUrl;
badgeLabel = epData.epNumber ? `第${epData.epNumber}话` : '章节';
}
}
}
await _doShareCard({ username, postTime, avatarUrl, contentEl, pureTitle, contentDoc, contentWin, dark, charImageUrl, badgeLabel });
}
function extractPostInfo(postEl, contentWin) {
const idNode = postEl.querySelector('.userInfo strong a') || postEl.querySelector('.userName a') || postEl.querySelector('strong a');
const username = idNode ? idNode.innerText.trim() : "未知用户";
const timeNode = postEl.querySelector('.post_actions.re_info small');
const time = timeNode ? (timeNode.innerText.match(/\d{4}-\d{1,2}-\d{1,2}\s\d{1,2}:\d{1,2}/)?.[0] || "未知时间") : "未知时间";
const avatarBox = postEl.querySelector('.avatarReSize40') || postEl.querySelector('.avatarReSize32') || postEl.querySelector('.avatarSize48');
let avatarUrl = "";
if (avatarBox) {
const bg = contentWin.getComputedStyle(avatarBox).backgroundImage;
if (bg && bg !== 'none') avatarUrl = bg.replace(/url\(["']?([^"']+)["']?\)/, '$1');
if (!avatarUrl) {
const m = (avatarBox.getAttribute('style') || '').match(/background-image:\s*url\(["']?([^"']+)["']?\)/);
if (m) avatarUrl = m[1];
}
}
if (!avatarUrl) {
const anyBg = postEl.querySelector('[style*="background-image"]');
if (anyBg) {
const m = (anyBg.getAttribute('style') || '').match(/background-image:\s*url\(["']?([^"']+)["']?\)/);
if (m) avatarUrl = m[1];
}
}
if (!avatarUrl) {
const avatarImg = postEl.querySelector('a.avatar img') || postEl.querySelector('[href*="/user/"] img') || postEl.querySelector('img[src*="/pic/user"]');
if (avatarImg) avatarUrl = avatarImg.src;
}
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 rawText = contentClone?.innerText?.trim() || "";
const truncated = rawText.length > 150;
const contentHtml = truncated ? (rawText.substring(0, 150) + "...") : (contentEl?.innerHTML?.trim() || "");
return { username, time, avatarUrl, content: truncated ? rawText.substring(0, 150) + "..." : rawText, contentHtml };
}
async function createReplyShareImage(replyEl, contentDoc = document) {
const dark = contentDoc.documentElement.getAttribute('data-theme') === 'dark';
const contentWin = contentDoc.defaultView || window;
// Main post (楼主) data
const isBlog = /\/blog\/\d+/.test(contentWin.location.pathname);
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);
let username, postTime, avatarUrl, contentEl;
if (isEpisode || isCharacter || isPerson) {
username = ""; postTime = ""; avatarUrl = ""; contentEl = null;
} else if (isBlog) {
const authorLink = contentDoc.querySelector('.author.user-card .title p a') || contentDoc.querySelector('.author.user-card a.avatar');
username = authorLink ? authorLink.textContent.trim() : "未知用户";
const timeEl = contentDoc.querySelector('.header .tools .time');
postTime = timeEl ? (timeEl.innerText.match(/\d{4}-\d{1,2}-\d{1,2}\s\d{1,2}:\d{1,2}/)?.[0] || "未知时间") : "未知时间";
const avatarImg = contentDoc.querySelector('.author.user-card a.avatar img');
avatarUrl = avatarImg ? avatarImg.src : "";
contentEl = contentDoc.querySelector('#entry_content');
} else {
const firstPost = contentDoc.querySelector('.postTopic') || contentDoc.querySelector('[id^="post_"]');
const mainIdNode = firstPost?.querySelector('strong a') || firstPost?.querySelector('.author strong a');
username = mainIdNode ? mainIdNode.innerText.trim() : "未知用户";
const mainTimeNode = firstPost?.querySelector('small');
postTime = mainTimeNode ? (mainTimeNode.innerText.match(/\d{4}-\d{1,2}-\d{1,2}\s\d{1,2}:\d{1,2}/)?.[0] || "未知时间") : "未知时间";
const mainAvatarBox = firstPost?.querySelector('.avatarSize48');
avatarUrl = mainAvatarBox ? contentWin.getComputedStyle(mainAvatarBox).backgroundImage.replace(/url\(["']?([^"']+)["']?\)/, '$1') : "";
contentEl = firstPost?.querySelector('.topic_content') || firstPost?.querySelector('.inner');
}
// Build replies chain: if sub-reply, prepend the parent reply first
const replies = [];
const parentPost = replyEl.closest('.topic_sub_reply')?.closest('[id^="post_"]');
if (parentPost) {
replies.push(extractPostInfo(parentPost, contentWin));
}
replies.push(extractPostInfo(replyEl, contentWin));
const h1Node = contentDoc.querySelector('#pageHeader h1') || contentDoc.querySelector('h1.title') || contentDoc.querySelector('h1.nameSingle a') || contentDoc.querySelector('h1');
let pureTitle = "";
if (h1Node) h1Node.childNodes.forEach(n => { if (n.nodeType === 3) pureTitle += n.textContent; });
pureTitle = pureTitle.replace(/[»\n]/g, '').trim();
if (!pureTitle) {
const rawTitle = (contentDoc.title || '').split(/\s*[|//]\s*/)[0].trim();
pureTitle = rawTitle || "分享话题";
}
let charImageUrl = (isCharacter || isPerson) ? extractCharImageUrl(contentDoc) : '';
let badgeLabel = '';
if (isCharacter) badgeLabel = '角色';
if (isPerson) {
const subtitleEl = contentDoc.querySelector('h2.subtitle');
const subtitleText = subtitleEl ? subtitleEl.textContent.trim() : '';
badgeLabel = subtitleText.replace(/^[^::]*[::]\s*/, '').trim() || '人物';
}
if ((isCharacter || isPerson) && (!charImageUrl || /\/rakuen\/topic\/(crt|prsn)\//.test(contentWin.location.pathname))) {
const apiData = await getRakuenCharPersonData(contentWin.location.pathname);
if (apiData) {
if (!charImageUrl) charImageUrl = apiData.imageUrl;
if (!badgeLabel || badgeLabel === '人物') badgeLabel = apiData.badgeLabel;
}
}
if (isEpisode) {
const epIdMatch = contentWin.location.pathname.match(/\/ep\/(\d+)/);
if (epIdMatch) {
const epData = await fetchEpisodeData(epIdMatch[1]);
if (epData) {
if (epData.episodeName) pureTitle = epData.episodeName;
if (epData.subjectImageUrl) charImageUrl = epData.subjectImageUrl;
badgeLabel = epData.epNumber ? `第${epData.epNumber}话` : '章节';
}
}
}
await _doShareCard({ username, postTime, avatarUrl, contentEl, pureTitle, contentDoc, contentWin, dark,
replies, replyId: replyEl.id, charImageUrl, badgeLabel });
}
const REPLY_SHARE_BTN_CLASS = 'bgm-reply-share-btn';
const SHARE_SVG = '';
const insertReplyButtons = (targetDoc = document) => {
targetDoc.querySelectorAll('[id^="post_"]').forEach(post => {
const reInfo = post.querySelector('.post_actions.re_info');
if (!reInfo || post.querySelector('.' + REPLY_SHARE_BTN_CLASS)) return;
const wrap = targetDoc.createElement('span');
wrap.className = 'action';
wrap.innerHTML = `${SHARE_SVG}分享`;
const dropdowns = reInfo.querySelectorAll('.action.dropdown');
const moreMenu = dropdowns[dropdowns.length - 1];
moreMenu ? reInfo.insertBefore(wrap, moreMenu) : reInfo.appendChild(wrap);
wrap.querySelector('a').addEventListener('click', () => createReplyShareImage(post, targetDoc));
});
};
const insertButton = (targetDoc = document) => {
if (targetDoc.getElementById('gen-card-btn')) return;
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.querySelector('.post_actions:not(.re_info)');
if (postActions) {
const wrap = targetDoc.createElement('span');
wrap.className = 'action';
wrap.innerHTML = '分享';
postActions.appendChild(wrap);
targetDoc.getElementById('gen-card-btn').addEventListener('click', () => createShareImage(targetDoc));
return;
}
// 降级:插入普通页面侧栏(仅非 Rakuen 场景)
if (targetDoc === document) {
const menuInner = document.querySelector('#columnInSubjectB .menu_inner')
|| document.querySelector('#columnSubjectB .menu_inner');
if (menuInner) {
const br = document.createElement('br');
const btn = document.createElement('a');
btn.id = 'gen-card-btn';
btn.href = 'javascript:void(0);';
btn.className = 'l';
btn.textContent = '/ 分享';
menuInner.appendChild(br);
menuInner.appendChild(btn);
btn.addEventListener('click', () => createShareImage(document));
}
}
};
const observeReplies = (targetDoc) => {
const observer = new MutationObserver(() => insertReplyButtons(targetDoc));
const root = targetDoc.getElementById('comment_list') || targetDoc.getElementById('reply_list') || targetDoc.body;
if (root) observer.observe(root, { childList: true, subtree: true });
};
// 超展开:在外层页面监听 #right iframe 导航,注入按钮
const rightFrame = document.getElementById('right');
if (rightFrame && rightFrame.tagName === 'IFRAME') {
const onRightFrameLoad = () => {
setTimeout(() => {
try {
const iDoc = rightFrame.contentDocument;
const iUrl = rightFrame.contentWindow.location.href;
if (/\/(group\/topic|subject(?:\/\d+)?\/topic)\//.test(iUrl) || /\/blog\/\d+/.test(iUrl) || /\/ep\/\d+/.test(iUrl) || /\/character\/\d+/.test(iUrl) || /\/person\/\d+/.test(iUrl)) {
insertButton(iDoc);
insertReplyButtons(iDoc);
observeReplies(iDoc);
}
} catch (e) {}
}, 800);
};
rightFrame.addEventListener('load', onRightFrameLoad);
} else {
setTimeout(() => { insertButton(); insertReplyButtons(); observeReplies(document); }, 500);
}
})();