// ==UserScript==
// @name Discourse 双站内联合流(linux.do ←→ idcflare)
// @namespace https://example.com/userscripts
// @version 4.5.0
// @description 在 L 站刷帖时把 IF 论坛的话题无缝融入(反之亦然);后台预加载,智能去重,完美融合
// @match *://linux.do/*
// @match *://www.linux.do/*
// @match *://idcflare.com/*
// @match *://www.idcflare.com/*
// @run-at document-idle
// @grant GM_xmlhttpRequest
// @connect linux.do
// @connect idcflare.com
// ==/UserScript==
(function () {
"use strict";
/******************************************************************
* 配置
******************************************************************/
const CFG = {
logLevel: "info",
maxMergeOnce: 40,
fetchTimeoutMs: 15000,
perTargetRetries: 3,
backoffBase: 700,
backoffFactor: 1.8,
jitter: true,
preloadDelayMs: 2000,
backgroundRefreshInterval: 60000, // 60秒后台刷新一次
sourcePillText: {
"linux.do": "L",
"idcflare.com": "IF",
},
};
/******************************************************************
* 轻量日志
******************************************************************/
const LEVELS = ["debug", "info", "warn", "error"];
const logBuf = [];
const pushLog = (level, msg, extra) => {
if (CFG.logLevel === "none") return;
const li = LEVELS.indexOf(level);
const min = LEVELS.indexOf(CFG.logLevel);
if (li < min) return;
const item = { t: Date.now(), level, msg, extra };
logBuf.push(item);
if (logBuf.length > 500) logBuf.shift();
const tag = "%c[InlineMerge]";
const style =
level === "debug"
? "color:#9aa"
: level === "info"
? "color:#39c"
: level === "warn"
? "color:#d85"
: "color:#e33";
console[level === "debug" ? "log" : level](tag, style, msg, extra || "");
window.__InlineMergeLogs = logBuf;
};
const log = {
debug: (m, e) => pushLog("debug", m, e),
info: (m, e) => pushLog("info", m, e),
warn: (m, e) => pushLog("warn", m, e),
error: (m, e) => pushLog("error", m, e),
};
/******************************************************************
* 站点判定与对端映射
******************************************************************/
const HOST = location.hostname.replace(/^www\./, "");
const IS_LINUXDO = HOST.includes("linux.do");
const IS_IDCFLARE = HOST.includes("idcflare.com");
const OTHER = IS_LINUXDO ? "idcflare.com" : IS_IDCFLARE ? "linux.do" : null;
if (!OTHER) {
log.warn("非目标站点,退出");
return;
}
/******************************************************************
* 路径检测
******************************************************************/
function isLatestPage() {
const path = location.pathname;
return path === '/' || path === '/latest' || path.startsWith('/latest/');
}
/******************************************************************
* 全局状态
******************************************************************/
let cachedData = null;
let cachedItems = { normal: [], pinned: [] };
let isInitialized = false;
let mergeInProgress = false;
let backgroundRefreshTimer = null;
let templateCache = null;
/******************************************************************
* 小工具
******************************************************************/
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const backoff = (n) => {
let d = CFG.backoffBase * Math.pow(CFG.backoffFactor, n);
if (CFG.jitter) d = d * (0.7 + Math.random() * 0.6);
return d;
};
function getListContainer() {
return document.querySelector(".latest-topic-list, .topic-list tbody, .topic-list-body");
}
function getItemTemplate() {
// 优先使用缓存的模板
if (templateCache) {
return templateCache.cloneNode(true);
}
const candidate = document.querySelector(
".topic-list-item:not([data-inline-merge]), tr.topic-list-item:not([data-inline-merge])"
);
if (!candidate) return null;
const tpl = candidate.cloneNode(true);
// 清理数据属性
delete tpl.dataset.topicId;
tpl.removeAttribute("id");
tpl.removeAttribute("data-topic-id");
// 缓存模板
templateCache = tpl.cloneNode(true);
return tpl;
}
/******************************************************************
* 抓取对端数据
******************************************************************/
async function fetchLatest(host, attempt = 0) {
const url = `https://${host}/latest.json?order=activity&ascending=false`;
log.info("抓取对端数据", url);
try {
const text = await gmGet(url, CFG.fetchTimeoutMs);
const json = JSON.parse(text);
cachedData = json;
return json;
} catch (e) {
if (attempt + 1 < CFG.perTargetRetries) {
const d = backoff(attempt);
log.warn(`抓取失败,${Math.round(d)}ms 后重试 #${attempt + 1}`, e);
await sleep(d);
return fetchLatest(host, attempt + 1);
}
log.error("抓取失败(已达最大重试)", e);
return null;
}
}
function gmGet(url, timeout) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url,
timeout,
onload: (res) => {
if (res.status >= 200 && res.status < 400) resolve(res.responseText);
else reject(new Error("HTTP_" + res.status));
},
ontimeout: () => reject(new Error("TIMEOUT")),
onerror: (e) => reject(new Error("NETWORK")),
});
});
}
/******************************************************************
* 构建 DOM 节点 - 完全重写,精确匹配原始结构
******************************************************************/
function buildItemsFromLatest(json, template, sourceHost) {
if (!json || !json.topic_list || !Array.isArray(json.topic_list.topics)) {
return { normal: [], pinned: [] };
}
const topics = json.topic_list.topics.slice(0, CFG.maxMergeOnce);
const usersById = new Map();
(json.users || []).forEach((u) => usersById.set(u.id, u));
const normalItems = [];
const pinnedItems = [];
for (const t of topics) {
// 每次都重新克隆模板
const node = template.cloneNode(true);
// 清理所有数据属性
node.removeAttribute('data-topic-id');
node.removeAttribute('id');
const isPinned = t.pinned === true || t.pinned_globally === true;
// === 1. 设置 topic-id 和标记 ===
node.dataset.topicId = t.id;
node.dataset.inlineMerge = "1";
node.dataset.inlineOrigin = sourceHost;
node.dataset.inlineTopicId = `${sourceHost}-${t.id}`;
const ts = t.last_posted_at || t.bumped_at || t.created_at;
node.dataset.inlineActivity = new Date(ts).getTime().toString();
if (isPinned) {
node.dataset.inlinePinned = "true";
node.classList.add("pinned");
} else {
node.classList.remove("pinned");
}
// === 2. 处理主标题区域 (main-link) ===
const mainLink = node.querySelector('td.main-link');
if (mainLink) {
// 2.1 处理顶部标题行
const linkTopLine = mainLink.querySelector('.link-top-line');
if (linkTopLine) {
// 清理话题状态图标
const statusesSpan = linkTopLine.querySelector('.topic-statuses');
if (statusesSpan) {
statusesSpan.innerHTML = '';
// 如果是置顶话题,添加置顶图标
if (isPinned) {
const pinLink = document.createElement('a');
pinLink.href = '';
pinLink.className = 'topic-status pinned pin-toggle-button';
pinLink.title = '此话题已置顶';
pinLink.innerHTML = '<svg class="fa d-icon d-icon-thumbtack svg-icon svg-string" aria-hidden="true"><use href="#thumbtack"></use></svg>';
statusesSpan.appendChild(pinLink);
}
}
// 替换标题链接
const titleLink = linkTopLine.querySelector('a.title, a.raw-topic-link');
if (titleLink) {
const href = `https://${sourceHost}/t/${t.slug}/${t.id}`;
titleLink.href = href;
titleLink.target = "_blank";
titleLink.dataset.topicId = t.id;
// 清空并重建标题内容
const titleSpan = titleLink.querySelector('span[dir="auto"]') || titleLink;
if (titleSpan.tagName === 'SPAN') {
titleSpan.innerHTML = '';
if (t.fancy_title) {
titleSpan.innerHTML = t.fancy_title;
} else {
titleSpan.textContent = t.title || "(无标题)";
}
} else {
titleLink.innerHTML = '';
const newSpan = document.createElement('span');
newSpan.dir = 'auto';
if (t.fancy_title) {
newSpan.innerHTML = t.fancy_title;
} else {
newSpan.textContent = t.title || "(无标题)";
}
titleLink.appendChild(newSpan);
}
}
// 清理徽章区域(未读帖子数等)
const badgeSpan = linkTopLine.querySelector('.topic-post-badges');
if (badgeSpan) {
badgeSpan.remove();
}
}
// 2.2 处理底部信息行 (分类、标签)
const linkBottomLine = mainLink.querySelector('.link-bottom-line');
if (linkBottomLine) {
// 移除原有分类徽章
const oldCategory = linkBottomLine.querySelector('.badge-category__wrapper');
if (oldCategory) {
oldCategory.remove();
}
// 处理标签区域
let tagsDiv = linkBottomLine.querySelector('.discourse-tags');
if (!tagsDiv) {
tagsDiv = document.createElement('div');
tagsDiv.className = 'discourse-tags';
tagsDiv.setAttribute('role', 'list');
tagsDiv.setAttribute('aria-label', '标签');
linkBottomLine.appendChild(tagsDiv);
}
// 完全清空标签
tagsDiv.innerHTML = '';
// 添加真实标签
if (t.tags && t.tags.length > 0) {
t.tags.slice(0, 3).forEach(tagName => {
const tagLink = document.createElement('a');
tagLink.href = `https://${sourceHost}/tag/${tagName}`;
tagLink.target = '_blank';
tagLink.dataset.tagName = tagName;
tagLink.className = 'discourse-tag box';
tagLink.textContent = tagName;
tagsDiv.appendChild(tagLink);
});
}
// 添加来源标记
const sourcePill = document.createElement('span');
sourcePill.className = 'discourse-tag simple inline-merge-source';
sourcePill.textContent = CFG.sourcePillText[sourceHost];
sourcePill.title = `来自 ${sourceHost}`;
sourcePill.style.cssText = `
opacity: 0.6;
font-size: 0.857em;
margin-left: 5px;
cursor: default;
background: var(--tertiary-low);
color: var(--tertiary);
`;
tagsDiv.appendChild(sourcePill);
}
}
// === 3. 重建活动者头像 (posters) ===
const postersTd = node.querySelector('td.posters');
if (postersTd) {
postersTd.innerHTML = '';
const posters = t.posters || [];
posters.slice(0, 5).forEach((poster, idx) => {
const user = usersById.get(poster.user_id);
if (!user) return;
const avatarUrl = user.avatar_template
? `https://${sourceHost}${user.avatar_template.replace("{size}", "72")}`
: null;
if (avatarUrl) {
const avatarLink = document.createElement('a');
avatarLink.href = `https://${sourceHost}/u/${user.username}`;
avatarLink.target = '_blank';
avatarLink.dataset.userCard = user.username;
avatarLink.setAttribute('aria-label', `${user.username} 的个人资料`);
// 最后一个头像添加 latest 类
if (idx === posters.length - 1) {
avatarLink.className = 'latest';
}
const avatarImg = document.createElement('img');
avatarImg.src = avatarUrl;
avatarImg.className = idx === posters.length - 1 ? 'avatar latest' : 'avatar';
avatarImg.alt = '';
avatarImg.width = 24;
avatarImg.height = 24;
avatarImg.title = user.username;
avatarLink.appendChild(avatarImg);
postersTd.appendChild(avatarLink);
}
});
}
// === 4. 设置回复数 ===
const replies = typeof t.posts_count === "number"
? Math.max(0, t.posts_count - 1)
: (t.reply_count ?? 0);
const postsCell = node.querySelector('td.posts-map, td.num.posts');
if (postsCell) {
const badgeLink = postsCell.querySelector('a.badge-posts') || postsCell.querySelector('a');
if (badgeLink) {
badgeLink.href = `https://${sourceHost}/t/${t.slug}/${t.id}/1`;
badgeLink.target = '_blank';
badgeLink.setAttribute('aria-label', `${replies} 条回复,跳转到第一个帖子`);
const numberSpan = badgeLink.querySelector('.number');
if (numberSpan) {
numberSpan.textContent = replies.toString();
}
}
}
// === 5. 设置浏览数 ===
const views = t.views || 0;
const viewsCell = node.querySelector('td.views');
if (viewsCell) {
const numberSpan = viewsCell.querySelector('.number');
if (numberSpan) {
numberSpan.textContent = formatNumber(views);
numberSpan.title = `此话题已被浏览 ${views.toLocaleString()} 次`;
}
}
// === 6. 设置活动时间 ===
const activityCell = node.querySelector('td.activity, td.post-activity');
if (activityCell) {
const activityLink = activityCell.querySelector('a.post-activity') || activityCell.querySelector('a');
if (activityLink) {
activityLink.href = `https://${sourceHost}/t/${t.slug}/${t.id}/last`;
activityLink.target = '_blank';
const dateSpan = activityLink.querySelector('.relative-date');
if (dateSpan) {
dateSpan.textContent = formatRelative(ts);
dateSpan.dataset.time = new Date(ts).getTime().toString();
dateSpan.dataset.format = 'tiny';
}
// 设置 title
const createdDate = new Date(t.created_at).toLocaleString('zh-CN');
const lastDate = new Date(ts).toLocaleString('zh-CN');
activityCell.title = `创建日期:${createdDate}\n最新:${lastDate}`;
}
}
// === 7. 添加到对应列表 ===
if (isPinned) {
pinnedItems.push(node);
} else {
normalItems.push(node);
}
}
log.debug(`构建完成: ${pinnedItems.length} 置顶 + ${normalItems.length} 普通`);
return { normal: normalItems, pinned: pinnedItems };
}
function formatNumber(num) {
if (num >= 1000) {
return (num / 1000).toFixed(1).replace(/\.0$/, '') + 'k';
}
return num.toString();
}
function formatRelative(iso) {
const t = new Date(iso).getTime();
if (!t || Number.isNaN(t)) return "";
const s = Math.floor((Date.now() - t) / 1000);
if (s < 60) return "刚刚";
const m = Math.floor(s / 60);
if (m < 60) return `${m}分钟`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}小时`;
const d = Math.floor(h / 24);
if (d < 30) return `${d}天`;
const M = Math.floor(d / 30);
if (M < 12) return `${M}个月`;
const y = Math.floor(M / 12);
return `${y}年`;
}
/******************************************************************
* 合并逻辑
******************************************************************/
function mergeItems(items) {
if (!isLatestPage()) {
log.debug("非 /latest 页面,跳过合并");
return;
}
if (mergeInProgress) {
log.debug("合并进行中,跳过");
return;
}
mergeInProgress = true;
try {
const container = getListContainer();
if (!container) {
log.warn("未找到列表容器");
return;
}
const nativeItems = Array.from(
container.querySelectorAll(".topic-list-item:not([data-inline-merge]), tr.topic-list-item:not([data-inline-merge])")
);
const existingTopicIds = new Set();
for (const el of nativeItems) {
const topicId = el.dataset.topicId || el.getAttribute('data-topic-id');
if (topicId) {
existingTopicIds.add(`${HOST}-${topicId}`);
}
}
const validPinned = items.pinned.filter(n => {
const key = n.dataset.inlineTopicId;
return key && !existingTopicIds.has(key);
});
const validNormal = items.normal.filter(n => {
const key = n.dataset.inlineTopicId;
return key && !existingTopicIds.has(key);
});
if (!validPinned.length && !validNormal.length) {
log.debug("没有新的外站条目需要插入");
return;
}
const nativePinned = nativeItems.filter(el =>
el.classList.contains("pinned") || el.querySelector(".topic-statuses .d-icon-thumbtack")
);
const nativeNormal = nativeItems.filter(el => !nativePinned.includes(el));
const allPinned = [...nativePinned, ...validPinned];
const allNormal = [...nativeNormal, ...validNormal];
const sortByActivity = (A, B) => {
const ta = parseInt(A.dataset.inlineActivity || "0", 10) || readActivityFromDOM(A) || 0;
const tb = parseInt(B.dataset.inlineActivity || "0", 10) || readActivityFromDOM(B) || 0;
return tb - ta;
};
allPinned.sort(sortByActivity);
allNormal.sort(sortByActivity);
const fragment = document.createDocumentFragment();
[...allPinned, ...allNormal].forEach(el => fragment.appendChild(el));
container.innerHTML = '';
container.appendChild(fragment);
log.info(`✓ 已合并 ${validPinned.length} 置顶 + ${validNormal.length} 普通话题(来自 ${OTHER})`);
} finally {
mergeInProgress = false;
}
}
function readActivityFromDOM(el) {
const dateSpan = el.querySelector(".relative-date");
if (dateSpan && dateSpan.dataset.time) {
return parseInt(dateSpan.dataset.time, 10);
}
const timeLink = el.querySelector(".post-activity a, .activity a");
if (!timeLink) return 0;
const title = timeLink.getAttribute("title") || el.getAttribute("title") || "";
// 尝试解析 "最新:2025 年 10月 1 日 13:33" 格式
const match = title.match(/最新[::]\s*(.+)/);
if (match) {
const ms = Date.parse(match[1]);
return Number.isNaN(ms) ? 0 : ms;
}
return 0;
}
/******************************************************************
* 后台刷新
******************************************************************/
async function backgroundRefresh() {
if (!isLatestPage()) {
log.debug("非 /latest 页面,跳过后台刷新");
return;
}
log.info("🔄 执行后台刷新...");
const tpl = getItemTemplate();
if (!tpl) {
log.warn("后台刷新:未能获取模板");
return;
}
const data = await fetchLatest(OTHER);
if (!data) {
log.warn("后台刷新:获取数据失败");
return;
}
cachedItems = buildItemsFromLatest(data, tpl, OTHER);
log.info(`✓ 后台刷新完成,更新了 ${cachedItems.pinned.length + cachedItems.normal.length} 条话题`);
// 如果在 latest 页面,立即合并
if (isLatestPage()) {
mergeItems(cachedItems);
}
}
function startBackgroundRefresh() {
if (backgroundRefreshTimer) {
clearInterval(backgroundRefreshTimer);
}
backgroundRefreshTimer = setInterval(() => {
backgroundRefresh();
}, CFG.backgroundRefreshInterval);
log.info(`✓ 后台刷新已启动,间隔 ${CFG.backgroundRefreshInterval / 1000}秒`);
}
/******************************************************************
* 初始化
******************************************************************/
async function initialize() {
if (isInitialized) return;
// 只在 /latest 页面初始化
if (!isLatestPage()) {
log.debug("非 /latest 页面,跳过初始化");
return;
}
isInitialized = true;
log.info("🚀 开始初始化,后台预加载对端数据...");
await waitForList();
await sleep(CFG.preloadDelayMs);
const tpl = getItemTemplate();
if (!tpl) {
log.warn("未能获取模板,稍后重试");
isInitialized = false;
setTimeout(initialize, 3000);
return;
}
const data = await fetchLatest(OTHER);
if (!data) {
log.error("预加载失败");
isInitialized = false;
return;
}
cachedItems = buildItemsFromLatest(data, tpl, OTHER);
log.info(`✓ 预加载完成,缓存了 ${cachedItems.pinned.length + cachedItems.normal.length} 条话题`);
mergeItems(cachedItems);
watchRouteChange();
startBackgroundRefresh();
}
function watchRouteChange() {
let lastUrl = location.href;
let lastWasLatest = isLatestPage();
const observer = new MutationObserver(() => {
if (location.href !== lastUrl) {
lastUrl = location.href;
const nowIsLatest = isLatestPage();
log.debug(`路由变化:${nowIsLatest ? '进入' : '离开'} /latest`);
// 从非 latest 页面进入 latest 页面
if (nowIsLatest && !lastWasLatest) {
if (!isInitialized) {
setTimeout(initialize, 500);
} else if (cachedItems.normal.length || cachedItems.pinned.length) {
setTimeout(() => mergeItems(cachedItems), 500);
}
}
lastWasLatest = nowIsLatest;
}
});
observer.observe(document.body, { childList: true, subtree: true });
}
async function waitForList() {
const t0 = Date.now();
while (!getListContainer()) {
await sleep(200);
if (Date.now() - t0 > 15000) {
log.warn("等待列表容器超时");
break;
}
}
log.info("列表容器就绪");
}
/******************************************************************
* 启动
******************************************************************/
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initialize);
} else {
initialize();
}
})();