feat(Bangumi_Topic_Share): add timeline blog/collection/reply share support and fix CJK rendering, bump version to 6.2

- Add share buttons and cards for timeline blog (日志) items via page fetch
- Add share buttons for timeline 吐槽 and 收藏 reply items
- Collection reply card matches main collection card style (subject cover + nested replies)
- Add via (web/mobile/app) display in timeline card postTime
- Fix blog single-subject card: title/text copy was using subject name instead of blog title
- Fix collection timeline text copy URL pointing to timeline instead of subject page
- Collection text copy: title now "{user}的收藏 - {subject}", link points to status entry page
- Status page (/timeline/status/*): detect 吐槽 vs 收藏 and render appropriate card/text copy
- Fix collection status tag trailing "了" (搁置了 → 搁置)
- Fix html2canvas CJK full-width punctuation overlap by wrapping brackets as inline-block spans
- Fix blog card content-box using <p> auto-closed by nested <div> (changed to <div>)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-24 20:43:54 +10:00
parent 7bd220e688
commit 5d3ada5b47
+666 -14
View File
@@ -1,7 +1,7 @@
// ==UserScript== // ==UserScript==
// @name Bangumi Topic Share // @name Bangumi Topic Share
// @namespace http://tampermonkey.net/ // @namespace http://tampermonkey.net/
// @version 6.1 // @version 6.2
// @description Bangumi 话题/日志分享工具:生成分享卡片,支持图片复制/下载、一键复制分享文案、可选 AI 标签 // @description Bangumi 话题/日志分享工具:生成分享卡片,支持图片复制/下载、一键复制分享文案、可选 AI 标签
// @author Stardream // @author Stardream
// @contributor Chang ji, Mewtw0 // @contributor Chang ji, Mewtw0
@@ -41,6 +41,15 @@
// @match *://bgm.tv/book/list/* // @match *://bgm.tv/book/list/*
// @match *://bangumi.tv/book/list/* // @match *://bangumi.tv/book/list/*
// @match *://chii.in/book/list/* // @match *://chii.in/book/list/*
// @match *://bgm.tv/user/*/timeline/status/*
// @match *://bangumi.tv/user/*/timeline/status/*
// @match *://chii.in/user/*/timeline/status/*
// @match *://bgm.tv/user/*/timeline
// @match *://bangumi.tv/user/*/timeline
// @match *://chii.in/user/*/timeline
// @match *://bgm.tv/timeline
// @match *://bangumi.tv/timeline
// @match *://chii.in/timeline
// @match *://bgm.tv/ // @match *://bgm.tv/
// @match *://bangumi.tv/ // @match *://bangumi.tv/
// @match *://chii.in/ // @match *://chii.in/
@@ -187,7 +196,7 @@
.main-title { font-size: 20px; color: #111; margin: 0 0 15px 0; line-height: 1.5; font-weight: 800; } .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; } .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-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; } .content-text { font-size: 14px; color: #333; line-height: 1.8; margin: 0; word-break: break-all; font-kerning: none; }
.tags-container { display: flex; flex-wrap: wrap; gap: 8px; } .tags-container { display: flex; flex-wrap: wrap; gap: 8px; }
.share-card .tags-container { margin-top: 15px; } .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; } .tag-item { background: #FEEFF0; color: #F09199; font-size: 11px; padding: 4px 12px; border-radius: 20px; font-weight: bold; border: 1px solid #F0919944; }
@@ -431,7 +440,7 @@
// contentDoc: the document containing the topic (may be an iframe's doc on Rakuen) // 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) // Overlay is always rendered in the outer document (where GM functions are available)
async function _doShareCard({ username, postTime, avatarUrl, contentEl, pureTitle, contentDoc, contentWin, dark, async function _doShareCard({ username, postTime, avatarUrl, contentEl, pureTitle, contentDoc, contentWin, dark,
replies = [], replyId = '', charImageUrl = '', badgeLabel = '', overrideTags = null, topicTitle = '' }) { replies = [], replyId = '', charImageUrl = '', badgeLabel = '', overrideTags = null, topicTitle = '', noCardTitle = false, shareTitle = '' }) {
if (document.getElementById('bgm-share-overlay')) return; if (document.getElementById('bgm-share-overlay')) return;
if (typeof html2canvas === 'undefined') { if (typeof html2canvas === 'undefined') {
alert("截图库加载失败,请刷新页面或检查网络。"); alert("截图库加载失败,请刷新页面或检查网络。");
@@ -524,9 +533,9 @@
</div> </div>
</div>` : ''} </div>` : ''}
<div class="card-body"> <div class="card-body">
${(base64CharImage || badgeLabel) ? '' : `<h1 class="main-title">${pureTitle}</h1>`} ${(base64CharImage || badgeLabel || noCardTitle) ? '' : `<h1 class="main-title">${pureTitle}</h1>`}
${topicTitle ? `<h2 class="topic-sub-title">${topicTitle}</h2>` : ''} ${topicTitle ? `<h2 class="topic-sub-title">${topicTitle}</h2>` : ''}
${inlinedMainContent ? `<div class="content-box"><p class="content-text">${inlinedMainContent}</p></div>` : ''} ${inlinedMainContent ? `<div class="content-box"><div class="content-text">${inlinedMainContent}</div></div>` : ''}
${replySection} ${replySection}
<div class="tags-container">${tagsHtml}</div> <div class="tags-container">${tagsHtml}</div>
</div> </div>
@@ -553,9 +562,6 @@
<button id="bgm-mask-btn" class="bgm-action-btn" data-tip="显示剧透"> <button id="bgm-mask-btn" class="bgm-action-btn" data-tip="显示剧透">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
</button> </button>
<button id="bgm-close-btn" class="bgm-action-btn" data-tip="关闭">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div> </div>
</div> </div>
`; `;
@@ -588,10 +594,12 @@
}; };
updateMaskPreview(); updateMaskPreview();
document.getElementById('bgm-close-btn').addEventListener('click', () => { overlay.addEventListener('click', (e) => {
if (e.target === overlay) {
cancelled = true; cancelled = true;
maskPreviewStyle.remove(); maskPreviewStyle.remove();
overlay.remove(); overlay.remove();
}
}); });
const showToast = (msg) => { const showToast = (msg) => {
@@ -603,7 +611,7 @@
}; };
document.getElementById('bgm-text-btn').addEventListener('click', async () => { document.getElementById('bgm-text-btn').addEventListener('click', async () => {
const shareText = `【链接】${pureTitle} | Bangumi番组计划\n${shareUrl}`; const shareText = `【链接】${shareTitle || topicTitle || pureTitle} | Bangumi番组计划\n${shareUrl}`;
try { try {
await navigator.clipboard.writeText(shareText); await navigator.clipboard.writeText(shareText);
showToast('✓ 文案已复制'); showToast('✓ 文案已复制');
@@ -656,7 +664,7 @@
const iDoc = iframe.contentDocument; const iDoc = iframe.contentDocument;
const iStyle = iDoc.createElement('style'); const iStyle = iDoc.createElement('style');
const maskCss = maskRevealed ? '[data-bgm-mask] { color: #fff !important; }' : ''; const maskCss = maskRevealed ? '[data-bgm-mask] { color: #fff !important; }' : '';
const sampleLink = contentDoc.querySelector('.topic_content a[href], #entry_content a[href], .message a[href]') const sampleLink = contentDoc.querySelector('.topic_content a[href], #entry_content a[href], .message a[href], .statusContent .text a[href], .subReply a.l[href]')
|| contentDoc.querySelector('a[href^="http"]') || contentDoc.querySelector('a[href^="http"]')
|| contentDoc.querySelector('a[href]'); || contentDoc.querySelector('a[href]');
const rawLinkColor = sampleLink ? getComputedStyle(sampleLink).color : ''; const rawLinkColor = sampleLink ? getComputedStyle(sampleLink).color : '';
@@ -697,7 +705,7 @@
if (bodyColor) quoteCss += `.content-text{color:${bodyColor} !important;}`; if (bodyColor) quoteCss += `.content-text{color:${bodyColor} !important;}`;
} }
} catch (e) {} } 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; }`; iStyle.textContent = style.innerHTML + maskCss + quoteCss + ` a { color: ${linkColor} !important; 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.head.appendChild(iStyle);
iDoc.body.style.cssText = 'margin:0;padding:0;background:transparent;display:inline-block;'; iDoc.body.style.cssText = 'margin:0;padding:0;background:transparent;display:inline-block;';
iDoc.body.innerHTML = captureEl.innerHTML; iDoc.body.innerHTML = captureEl.innerHTML;
@@ -722,6 +730,38 @@
el.style.backgroundImage = 'none'; el.style.backgroundImage = 'none';
}); });
// html2canvas undercounts advance widths for full-width CJK punctuation (《(【etc.)
// because canvas.measureText() returns only the glyph width, not the full advance.
// Wrapping each as display:inline-block with width:1em forces html2canvas to read
// the DOM layout box instead of measureText, giving the correct advance.
const CJK_PUNCT_RE = /[《》〈〉「」【】〔〕〖〗(){}]/;
const CJK_PUNCT_SPLIT_RE = /(《|》|〈|〉|「|」|【|】|||〖|〗||||)/u;
iDoc.querySelectorAll('.content-text').forEach(contentEl => {
const walker = document.createTreeWalker(contentEl, NodeFilter.SHOW_TEXT, null);
const textNodes = [];
let tn;
while (tn = walker.nextNode()) {
if (CJK_PUNCT_RE.test(tn.textContent)) textNodes.push(tn);
}
textNodes.forEach(tn => {
const parts = tn.textContent.split(CJK_PUNCT_SPLIT_RE);
if (parts.length <= 1) return;
const frag = iDoc.createDocumentFragment();
parts.forEach(part => {
if (!part) return;
if (CJK_PUNCT_RE.test(part) && part.length === 1) {
const sp = iDoc.createElement('span');
sp.style.cssText = 'display:inline-block;width:1em;text-align:center;';
sp.textContent = part;
frag.appendChild(sp);
} else {
frag.appendChild(iDoc.createTextNode(part));
}
});
tn.parentNode.replaceChild(frag, tn);
});
});
canvas = await Promise.race([ canvas = await Promise.race([
html2canvas(iDoc.body.firstElementChild, { scale: 2, backgroundColor: null }), html2canvas(iDoc.body.firstElementChild, { scale: 2, backgroundColor: null }),
timeout timeout
@@ -1444,7 +1484,7 @@
const postActions = targetDoc.querySelector('.entry-actions .post_actions') const postActions = targetDoc.querySelector('.entry-actions .post_actions')
|| targetDoc.querySelector('.postTopic .post_actions:not(.re_info)') || targetDoc.querySelector('.postTopic .post_actions:not(.re_info)')
|| targetDoc.querySelector('[id^="post_"] .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') && !el.closest('#comment_box') && !el.closest('#timeline')); || [...targetDoc.querySelectorAll('.post_actions:not(.re_info)')].find(el => !el.closest('#sliderContainer') && !el.closest('#comment_box') && !el.closest('#timeline') && !el.closest('.statusContent'));
if (postActions) { if (postActions) {
const wrap = targetDoc.createElement('span'); const wrap = targetDoc.createElement('span');
wrap.className = 'action'; wrap.className = 'action';
@@ -1766,6 +1806,618 @@
}); });
} }
function _getStatusAuthorInfo(doc) {
const usernameEl = doc.querySelector('.statusHeader h3 a');
const username = usernameEl?.textContent.trim() || '';
const avatarSpan = doc.querySelector('.statusHeader .avatarNeue');
let avatarUrl = '';
const bgImg = avatarSpan?.style.backgroundImage;
if (bgImg) {
const m = bgImg.match(/url\(['"]?([^'"]+)['"]?\)/);
if (m) avatarUrl = m[1].replace(/^\/\//, 'https://');
}
const timeEl = doc.querySelector('.statusContent .post_actions .tip_j');
const postTime = timeEl?.textContent.trim() || '';
return { username, avatarUrl, postTime };
}
async function _buildCollectionStatusInfo(doc, contentWin, username, avatarUrl, postTime) {
const statusContent = doc.querySelector('.statusContent');
const subjectLink = statusContent?.querySelector('a[data-subject-id]');
const subjectId = subjectLink?.getAttribute('data-subject-id');
const collectComment = statusContent?.querySelector('.comment');
if (!subjectId || !collectComment) return null;
const userSlug = contentWin.location.pathname.match(/\/user\/([^\/]+)\//)?.[1] || '';
let collectionStatus = '';
if (subjectLink) {
let node = subjectLink.previousSibling;
while (node) {
if (node.nodeType === 3 && node.textContent.trim()) { collectionStatus = node.textContent.trim().replace(/了$/, ''); break; }
node = node.previousSibling;
}
}
const ratingEl = collectComment.querySelector('.starstop-s .starlight');
const domRating = parseInt(ratingEl?.className.match(/stars(\d+)/)?.[1] || '0');
const cleanCommentHtml = (() => {
const tmp = document.createElement('div');
tmp.innerHTML = collectComment.innerHTML;
tmp.querySelector('.starstop-s')?.remove();
return tmp.innerHTML.trim();
})();
const subjectData = await fetchSubjectDataById(subjectId);
const subjectName = subjectData?.name || subjectLink?.textContent.trim() || '作品';
let rating = domRating, userTags = null, apiTime = postTime;
if (userSlug) {
const collection = await fetchBangumiAPI(`users/${userSlug}/collections/${subjectId}`);
if (collection) {
rating = collection.rate || rating;
if (collection.updated_at) {
const d = new Date(collection.updated_at);
const pad = n => String(n).padStart(2, '0');
apiTime = `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
if (collection.tags?.length > 0) userTags = collection.tags;
}
}
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);
return { subjectData, subjectName, collectComment, cleanCommentHtml, username, avatarUrl, apiTime, rating, overrideTags };
}
async function createStatusShareImage(doc = document) {
const dark = doc.documentElement.getAttribute('data-theme') === 'dark';
const contentWin = doc.defaultView || window;
const { username, avatarUrl, postTime } = _getStatusAuthorInfo(doc);
const replyCount = doc.querySelectorAll('.subReply .reply_item').length;
const collectInfo = await _buildCollectionStatusInfo(doc, contentWin, username, avatarUrl, postTime);
if (collectInfo) {
const { subjectData, subjectName, collectComment, cleanCommentHtml, apiTime, rating, overrideTags } = collectInfo;
await _doShareCard({
username: '', postTime: '', avatarUrl: '', contentEl: null,
pureTitle: subjectName,
shareTitle: `${username}的收藏 - ${subjectName}`,
contentDoc: doc, contentWin, dark,
replies: [{ username, avatarUrl, content: collectComment.innerText?.trim() || '', contentHtml: cleanCommentHtml, time: apiTime, rating }],
charImageUrl: subjectData?.imageUrl || '',
badgeLabel: subjectData?.type || '作品',
overrideTags,
});
} else {
const contentEl = doc.querySelector('.statusContent .text');
await _doShareCard({
username, postTime, avatarUrl, contentEl,
pureTitle: username + '的吐槽', contentDoc: doc, contentWin, dark,
noCardTitle: true,
overrideTags: replyCount ? [`${replyCount} 回复`, '吐槽'] : ['吐槽'],
});
}
}
async function createStatusReplyShareImage(replyLi, doc = document) {
const dark = doc.documentElement.getAttribute('data-theme') === 'dark';
const contentWin = doc.defaultView || window;
const { username, avatarUrl, postTime } = _getStatusAuthorInfo(doc);
const replyUserEl = replyLi.querySelector('a.l[href*="/user/"]');
const replyUsername = replyUserEl?.textContent.trim() || '';
const replySlug = replyUserEl?.getAttribute('href')?.match(/\/user\/([^\/]+)/)?.[1] || '';
let replyAvatarUrl = '';
if (replySlug) {
const userInfo = await fetchBangumiAPI(`users/${replySlug}`);
replyAvatarUrl = userInfo?.avatar?.medium || userInfo?.avatar?.large || '';
}
let replyHtml = '';
let afterDash = false;
replyLi.childNodes.forEach(n => {
if (!afterDash) {
if (n.nodeType === 1 && n.tagName === 'SPAN' && n.classList.contains('tip_j')) afterDash = true;
return;
}
if (n.nodeType === 1 && n.classList?.contains(STATUS_REPLY_SHARE_BTN_CLASS)) return;
replyHtml += n.nodeType === 3 ? n.textContent : n.outerHTML;
});
const replyCount = doc.querySelectorAll('.subReply .reply_item').length;
const collectInfo = await _buildCollectionStatusInfo(doc, contentWin, username, avatarUrl, postTime);
if (collectInfo) {
const { subjectData, subjectName, collectComment, cleanCommentHtml, apiTime, rating, overrideTags } = collectInfo;
await _doShareCard({
username: '', postTime: '', avatarUrl: '', contentEl: null,
pureTitle: subjectName,
shareTitle: `${username}的收藏 - ${subjectName}`,
contentDoc: doc, contentWin, dark,
replies: [
{ username, avatarUrl, content: collectComment.innerText?.trim() || '', contentHtml: cleanCommentHtml, time: apiTime, rating },
{ username: replyUsername, avatarUrl: replyAvatarUrl, content: replyHtml.trim(), contentHtml: replyHtml.trim(), time: '' },
],
charImageUrl: subjectData?.imageUrl || '',
badgeLabel: subjectData?.type || '作品',
overrideTags,
});
} else {
const contentEl = doc.querySelector('.statusContent .text');
await _doShareCard({
username, postTime, avatarUrl, contentEl,
pureTitle: username + '的吐槽', contentDoc: doc, contentWin, dark,
noCardTitle: true,
replies: [{ username: replyUsername, avatarUrl: replyAvatarUrl, content: replyHtml.trim(), contentHtml: replyHtml.trim(), time: '' }],
overrideTags: replyCount ? [`${replyCount} 回复`, '吐槽'] : ['吐槽'],
});
}
}
const STATUS_REPLY_SHARE_BTN_CLASS = 'bgm-status-reply-share-btn';
const insertStatusReplyButtons = (doc = document) => {
if (!/\/user\/[^\/]+\/timeline\/status\/\d+/.test((doc.defaultView || window).location.pathname)) return;
doc.querySelectorAll('.subReply .reply_item').forEach(li => {
if (li.querySelector('.' + STATUS_REPLY_SHARE_BTN_CLASS)) return;
const btn = doc.createElement('a');
btn.href = 'javascript:void(0);';
btn.className = STATUS_REPLY_SHARE_BTN_CLASS;
btn.title = '分享回复';
btn.style.cssText = 'display:inline-flex;align-items:center;gap:3px;margin-left:6px;vertical-align:middle;opacity:0.6;';
btn.innerHTML = SHARE_SVG;
li.appendChild(btn);
btn.addEventListener('click', () => createStatusReplyShareImage(li, doc));
});
};
const insertStatusShareButton = (doc = document) => {
if (!/\/user\/[^\/]+\/timeline\/status\/\d+/.test((doc.defaultView || window).location.pathname)) return;
if (doc.getElementById('bgm-status-share-btn')) return;
const postActions = doc.querySelector('.statusContent .post_actions');
if (!postActions) return;
const btn = doc.createElement('a');
btn.id = 'bgm-status-share-btn';
btn.href = 'javascript:void(0);';
btn.title = '分享吐槽';
btn.style.cssText = 'color:inherit;display:inline-flex;align-items:center;margin-right:6px;opacity:0.75;';
btn.innerHTML = SHARE_SVG;
const dropdown = postActions.querySelector('.action.dropdown');
dropdown ? dropdown.after(btn) : postActions.appendChild(btn);
btn.addEventListener('click', () => createStatusShareImage(doc));
};
const TML_SHARE_BTN_CLASS = 'bgm-tml-share-btn';
function fetchStatusReplyCount(statusUrl) {
return new Promise(resolve => {
GM_xmlhttpRequest({
method: 'GET',
url: statusUrl,
onload: res => {
if (res.status !== 200) { resolve(0); return; }
const statusDoc = new DOMParser().parseFromString(res.responseText, 'text/html');
resolve(statusDoc.querySelectorAll('.subReply .reply_item').length);
},
onerror: () => resolve(0),
});
});
}
function extractTimelineVia(postActionsEl) {
if (!postActionsEl) return '';
const smallEl = postActionsEl.querySelector('small.grey');
if (smallEl) return smallEl.textContent.trim();
const titleTip = postActionsEl.querySelector('.titleTip');
let node = titleTip ? titleTip.nextSibling : null;
while (node) {
if (node.nodeType === 3) {
const text = node.textContent.replace(/[\u00B7·\s]/g, '').trim();
if (text) return text;
}
node = node.nextSibling;
}
return '';
}
async function createTimelineShareImage(item, doc = document) {
const dark = doc.documentElement.getAttribute('data-theme') === 'dark';
const contentWin = doc.defaultView || window;
let userSlug = item.getAttribute('data-item-user') || '';
if (!userSlug) {
// Own-user timeline has data-item-user="" — extract from tml_comment href or page URL
userSlug = item.querySelector('.tml_comment')?.href?.match(/\/user\/([^\/]+)\/timeline/)?.[1]
|| (doc.defaultView || window).location.pathname.match(/\/user\/([^\/]+)\/timeline/)?.[1]
|| '';
}
const userLink = item.querySelector('.info a.l[href*="/user/"], .info_full a.l[href*="/user/"]');
let username = userLink?.textContent.trim() || '';
const _extractNeueAvatar = el => {
if (!el) return '';
const m = el.style.backgroundImage?.match(/url\(['"]?([^'"]+)['"]?\)/);
return m ? m[1].replace(/^\/\//, 'https://') : '';
};
let avatarUrl = _extractNeueAvatar(item.querySelector('.avatar .avatarNeue'));
if (!avatarUrl && userSlug) {
const siblingNeue = doc.querySelector(`.tml_item[data-item-user="${CSS.escape(userSlug)}"] .avatar .avatarNeue`);
avatarUrl = _extractNeueAvatar(siblingNeue);
}
// For .info_full items (timeline owner's entries), there is no username link or avatar span —
// always fetch from API so username is authoritative (not a stale DOM fallback).
const isInfoFull = !!item.querySelector('.info_full');
if ((isInfoFull || !avatarUrl || !username) && userSlug) {
const userInfo = await fetchBangumiAPI(`users/${userSlug}`);
if (!avatarUrl) avatarUrl = userInfo?.avatar?.medium || userInfo?.avatar?.large || '';
if (isInfoFull || !username) username = userInfo?.nickname || userInfo?.username || username;
}
const timeTip = item.querySelector('.post_actions .titleTip');
const postTimeRaw = timeTip?.getAttribute('data-original-title') || timeTip?.textContent.trim() || '';
const via = extractTimelineVia(item.querySelector('.post_actions'));
const postTime = postTimeRaw + (via ? ` via ${via}` : '');
const statusP = item.querySelector('.info p.status, .info_full p.status');
const collectComment = item.querySelector('.collectInfo .comment');
const blogLink = item.querySelector('.info a[href*="/blog/"], .info_full a[href*="/blog/"]');
if (statusP && statusP.textContent.trim()) {
const statusUrl = item.querySelector('.tml_comment')?.href;
const replyCount = statusUrl ? await fetchStatusReplyCount(statusUrl) : 0;
await _doShareCard({
username, postTime, avatarUrl,
contentEl: statusP,
pureTitle: username + '的吐槽',
contentDoc: doc, contentWin, dark,
noCardTitle: true,
overrideTags: replyCount ? [`${replyCount} 回复`, '吐槽'] : ['吐槽'],
});
} else if (collectComment && collectComment.textContent.trim()) {
// Subject link is in .info, carries data-subject-id attribute
const subjectLink = item.querySelector('a[data-subject-id]');
const subjectId = subjectLink?.getAttribute('data-subject-id');
// Collection status ("看过"/"在看" etc.) is in the text node just before the subject link
let collectionStatus = '';
if (subjectLink) {
let node = subjectLink.previousSibling;
while (node) {
if (node.nodeType === 3 && node.textContent.trim()) {
collectionStatus = node.textContent.trim().replace(/了$/, '');
break;
}
node = node.previousSibling;
}
}
// Extract user rating and clean comment HTML (remove star span)
const ratingEl = collectComment.querySelector('.starstop-s .starlight');
const domRating = parseInt(ratingEl?.className.match(/stars(\d+)/)?.[1] || '0');
const cleanCommentHtml = (() => {
const tmp = document.createElement('div');
tmp.innerHTML = collectComment.innerHTML;
tmp.querySelector('.starstop-s')?.remove();
return tmp.innerHTML.trim();
})();
const statusUrl = item.querySelector('.tml_comment')?.href;
const statusUrlParsed = statusUrl ? new URL(statusUrl) : null;
if (subjectId) {
const subjectData = await fetchSubjectDataById(subjectId);
const subjectName = subjectData?.name || subjectLink?.textContent.trim() || '作品';
let rating = domRating;
let userTags = null;
let apiTime = postTime;
if (userSlug) {
const collection = await fetchBangumiAPI(`users/${userSlug}/collections/${subjectId}`);
if (collection) {
rating = collection.rate || rating;
if (collection.updated_at) {
const d = new Date(collection.updated_at);
const pad = n => String(n).padStart(2, '0');
apiTime = `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}` + (via ? ` via ${via}` : '');
}
if (collection.tags?.length > 0) userTags = collection.tags;
}
}
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: subjectName,
shareTitle: `${username}的收藏 - ${subjectName}`,
contentDoc: doc,
contentWin: statusUrlParsed
? { location: { origin: statusUrlParsed.origin, pathname: statusUrlParsed.pathname } }
: { location: { origin: contentWin.location.origin, pathname: `/subject/${subjectId}` } },
dark,
replies: [{ username, avatarUrl, content: collectComment.innerText?.trim() || '', contentHtml: cleanCommentHtml, time: apiTime, rating }],
charImageUrl: subjectData?.imageUrl || '',
badgeLabel: subjectData?.type || '作品',
overrideTags,
});
} else {
await _doShareCard({
username, postTime, avatarUrl,
contentEl: collectComment,
pureTitle: username + '的收藏',
contentDoc: doc, contentWin, dark,
noCardTitle: true,
overrideTags: ['收藏'],
});
}
} else if (blogLink) {
const blogUrl = blogLink.href;
const blogHtml = await new Promise(resolve => {
GM_xmlhttpRequest({
method: 'GET', url: blogUrl,
onload: res => resolve(res.status === 200 ? res.responseText : null),
onerror: () => resolve(null),
});
});
const blogDoc = blogHtml ? new DOMParser().parseFromString(blogHtml, 'text/html') : null;
const entryContent = blogDoc?.querySelector('#entry_content');
let contentEl = null;
if (entryContent) {
contentEl = document.createElement('div');
contentEl.innerHTML = entryContent.innerHTML;
}
let pureTitle = blogLink.textContent.trim();
let topicTitle = '';
let charImageUrl = '';
let badgeLabel = '';
const blogSubjectIds = blogDoc ? [...new Set(
[...blogDoc.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 || '';
}
}
const blogReplyCount = blogDoc?.querySelectorAll('[id^="post_"]').length || 0;
const subjectNamesFromBlog = blogDoc ? [...new Set(
[...blogDoc.querySelectorAll('a')]
.filter(a => /\/subject\/\d+$/.test(a.getAttribute('href') || '') && a.textContent.trim())
.map(a => a.textContent.trim())
)] : [];
const tagSubjects = subjectNamesFromBlog.length === 1 ? [] : subjectNamesFromBlog;
const overrideTags = [...tagSubjects, `${blogReplyCount} 回复`, '日志'];
const blogUrlParsed = new URL(blogUrl);
await _doShareCard({
username, postTime, avatarUrl,
contentEl,
pureTitle,
contentDoc: doc,
contentWin: { location: { origin: blogUrlParsed.origin, pathname: blogUrlParsed.pathname } },
dark,
charImageUrl, badgeLabel, topicTitle,
overrideTags,
});
}
}
const TML_REPLY_SHARE_BTN_CLASS = 'bgm-tml-reply-share-btn';
async function createTimelineReplyShareImage(replyLi, tmlItem, doc = document) {
const dark = doc.documentElement.getAttribute('data-theme') === 'dark';
const contentWin = doc.defaultView || window;
let userSlug = tmlItem.getAttribute('data-item-user') || '';
if (!userSlug) {
userSlug = tmlItem.querySelector('.tml_comment')?.href?.match(/\/user\/([^\/]+)\/timeline/)?.[1]
|| (doc.defaultView || window).location.pathname.match(/\/user\/([^\/]+)\/timeline/)?.[1]
|| '';
}
const userLink = tmlItem.querySelector('.info a.l[href*="/user/"], .info_full a.l[href*="/user/"]');
let username = userLink?.textContent.trim() || '';
const _extractNeueAvatar = el => {
if (!el) return '';
const m = el.style.backgroundImage?.match(/url\(['"]?([^'"]+)['"]?\)/);
return m ? m[1].replace(/^\/\//, 'https://') : '';
};
let avatarUrl = _extractNeueAvatar(tmlItem.querySelector('.avatar .avatarNeue'));
if (!avatarUrl && userSlug) {
const siblingNeue = doc.querySelector(`.tml_item[data-item-user="${CSS.escape(userSlug)}"] .avatar .avatarNeue`);
avatarUrl = _extractNeueAvatar(siblingNeue);
}
const isInfoFull = !!tmlItem.querySelector('.info_full');
if ((isInfoFull || !avatarUrl || !username) && userSlug) {
const userInfo = await fetchBangumiAPI(`users/${userSlug}`);
if (!avatarUrl) avatarUrl = userInfo?.avatar?.medium || userInfo?.avatar?.large || '';
if (isInfoFull || !username) username = userInfo?.nickname || userInfo?.username || username;
}
const statusP = tmlItem.querySelector('.info p.status, .info_full p.status');
const collectComment = tmlItem.querySelector('.collectInfo .comment');
const contentEl = statusP || collectComment;
const isCollect = !statusP && !!collectComment;
const timeTip = tmlItem.querySelector('.post_actions .titleTip');
const postTimeRaw = timeTip?.getAttribute('data-original-title') || timeTip?.textContent.trim() || '';
const via = extractTimelineVia(tmlItem.querySelector('.post_actions'));
const postTime = postTimeRaw + (via ? ` via ${via}` : '');
const replyUserEl = replyLi.querySelector('a.l[href*="/user/"]');
const replyUsername = replyUserEl?.textContent.trim() || '';
const replySlug = replyUserEl?.getAttribute('href')?.match(/\/user\/([^\/]+)/)?.[1] || '';
let replyAvatarUrl = '';
if (replySlug) {
const userInfo = await fetchBangumiAPI(`users/${replySlug}`);
replyAvatarUrl = userInfo?.avatar?.medium || userInfo?.avatar?.large || '';
}
let replyHtml = '';
let afterDash = false;
replyLi.childNodes.forEach(n => {
if (!afterDash) {
if (n.nodeType === 1 && n.tagName === 'SPAN' && n.classList.contains('tip_j')) afterDash = true;
return;
}
if (n.nodeType === 1 && n.classList?.contains(TML_REPLY_SHARE_BTN_CLASS)) return;
replyHtml += n.nodeType === 3 ? n.textContent : n.outerHTML;
});
const statusUrl = tmlItem.querySelector('.tml_comment')?.href;
const statusUrlParsed = statusUrl ? new URL(statusUrl) : null;
const replyCount = tmlItem.querySelectorAll('.subReply .reply_item').length;
if (isCollect) {
const subjectLink = tmlItem.querySelector('a[data-subject-id]');
const subjectId = subjectLink?.getAttribute('data-subject-id');
let collectionStatus = '';
if (subjectLink) {
let node = subjectLink.previousSibling;
while (node) {
if (node.nodeType === 3 && node.textContent.trim()) { collectionStatus = node.textContent.trim().replace(/了$/, ''); break; }
node = node.previousSibling;
}
}
const ratingEl = collectComment.querySelector('.starstop-s .starlight');
const domRating = parseInt(ratingEl?.className.match(/stars(\d+)/)?.[1] || '0');
const cleanCommentHtml = (() => {
const tmp = document.createElement('div');
tmp.innerHTML = collectComment.innerHTML;
tmp.querySelector('.starstop-s')?.remove();
return tmp.innerHTML.trim();
})();
if (subjectId) {
const subjectData = await fetchSubjectDataById(subjectId);
const subjectName = subjectData?.name || subjectLink?.textContent.trim() || '作品';
let rating = domRating;
let userTags = null;
let apiTime = postTime;
if (userSlug) {
const collection = await fetchBangumiAPI(`users/${userSlug}/collections/${subjectId}`);
if (collection) {
rating = collection.rate || rating;
if (collection.updated_at) {
const d = new Date(collection.updated_at);
const pad = n => String(n).padStart(2, '0');
apiTime = `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}` + (via ? ` via ${via}` : '');
}
if (collection.tags?.length > 0) userTags = collection.tags;
}
}
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: subjectName,
shareTitle: `${username}的收藏 - ${subjectName}`,
contentDoc: doc,
contentWin: statusUrlParsed
? { location: { origin: statusUrlParsed.origin, pathname: statusUrlParsed.pathname } }
: { location: { origin: contentWin.location.origin, pathname: `/subject/${subjectId}` } },
dark,
replies: [
{ username, avatarUrl, content: collectComment.innerText?.trim() || '', contentHtml: cleanCommentHtml, time: apiTime, rating },
{ username: replyUsername, avatarUrl: replyAvatarUrl, content: replyHtml.trim(), contentHtml: replyHtml.trim(), time: '' },
],
charImageUrl: subjectData?.imageUrl || '',
badgeLabel: subjectData?.type || '作品',
overrideTags,
});
} else {
await _doShareCard({
username, postTime, avatarUrl,
contentEl: collectComment,
pureTitle: username + '的收藏',
contentDoc: doc,
contentWin: statusUrlParsed
? { location: { origin: statusUrlParsed.origin, pathname: statusUrlParsed.pathname } }
: contentWin,
dark, noCardTitle: true,
replies: [{ username: replyUsername, avatarUrl: replyAvatarUrl, content: replyHtml.trim(), contentHtml: replyHtml.trim(), time: '' }],
overrideTags: ['收藏'],
});
}
} else {
await _doShareCard({
username, postTime, avatarUrl,
contentEl,
pureTitle: username + '的吐槽',
contentDoc: doc,
contentWin: statusUrlParsed
? { location: { origin: statusUrlParsed.origin, pathname: statusUrlParsed.pathname } }
: contentWin,
dark, noCardTitle: true,
replies: [{ username: replyUsername, avatarUrl: replyAvatarUrl, content: replyHtml.trim(), contentHtml: replyHtml.trim(), time: '' }],
overrideTags: replyCount ? [`${replyCount} 回复`, '吐槽'] : ['吐槽'],
});
}
}
const insertTimelineShareButtons = (doc = document) => {
doc.querySelectorAll('.tml_item').forEach(item => {
if (item.querySelector('.' + TML_SHARE_BTN_CLASS)) return;
const statusP = item.querySelector('.info p.status, .info_full p.status');
const collectComment = item.querySelector('.collectInfo .comment');
const blogLink = item.querySelector('.info a[href*="/blog/"], .info_full a[href*="/blog/"]');
const hasContent = (statusP?.textContent.trim()) || (collectComment?.textContent.trim()) || blogLink;
if (!hasContent) return;
const postActions = item.querySelector('.post_actions');
if (!postActions) return;
const btn = doc.createElement('a');
btn.href = 'javascript:void(0);';
btn.className = TML_SHARE_BTN_CLASS;
btn.title = '分享';
btn.style.cssText = 'color:inherit;display:inline-flex;align-items:center;margin-right:6px;opacity:0.75;cursor:pointer;';
btn.innerHTML = SHARE_SVG;
const dropdown = postActions.querySelector('.action.dropdown');
dropdown ? dropdown.after(btn) : postActions.prepend(btn);
btn.addEventListener('click', () => createTimelineShareImage(item, doc));
});
doc.querySelectorAll('.tml_item').forEach(item => {
if (!item.querySelector('.info p.status, .info_full p.status') && !item.querySelector('.collectInfo .comment')) return;
item.querySelectorAll('.subReply .reply_item').forEach(li => {
if (li.querySelector('.' + TML_REPLY_SHARE_BTN_CLASS)) return;
const btn = doc.createElement('a');
btn.href = 'javascript:void(0);';
btn.className = TML_REPLY_SHARE_BTN_CLASS;
btn.title = '分享回复';
btn.style.cssText = 'display:inline-flex;align-items:center;gap:3px;margin-left:6px;vertical-align:middle;opacity:0.6;cursor:pointer;';
btn.innerHTML = SHARE_SVG;
li.appendChild(btn);
btn.addEventListener('click', () => createTimelineReplyShareImage(li, item, doc));
});
});
};
const observeTimeline = (doc = document) => {
const root = doc.querySelector('#columnTimelineA') || doc.querySelector('#main') || doc.body;
if (!root) return;
let debounce = null;
const obs = new MutationObserver(() => {
clearTimeout(debounce);
debounce = setTimeout(() => insertTimelineShareButtons(doc), 150);
});
obs.observe(root, { childList: true, subtree: true });
};
const observeReplies = (targetDoc) => { const observeReplies = (targetDoc) => {
const observer = new MutationObserver(() => { const observer = new MutationObserver(() => {
insertReplyButtons(targetDoc); insertReplyButtons(targetDoc);
@@ -1794,7 +2446,7 @@
}; };
rightFrame.addEventListener('load', onRightFrameLoad); rightFrame.addEventListener('load', onRightFrameLoad);
} else { } else {
setTimeout(() => { insertButton(); insertReplyButtons(); insertSubjectCommentButtons(); insertCollectionPageButtons(); insertBrowserListButtons(); insertMyCollectionShareButton(); observeReplies(document); }, 500); setTimeout(() => { insertButton(); insertReplyButtons(); insertSubjectCommentButtons(); insertCollectionPageButtons(); insertBrowserListButtons(); insertMyCollectionShareButton(); insertStatusShareButton(); insertStatusReplyButtons(); insertTimelineShareButtons(); observeTimeline(); observeReplies(document); }, 500);
} }
checkOAuthCallback(); checkOAuthCallback();