发一个boost 的小玩具
- 内容介绍
- 文章标签
- 相关推荐
回复话题帖子的时候自动判断是否发送boost
PixPin_2026-04-08_12-50-45746×222 16.5 KB
油猴脚本 linux.do 自动切换 boost/回复方式
油猴脚本
// ==UserScript==
// @name linux.do 自动切换 boost/回复方式
// @namespace https://linux.do/
// @version 1.3
// @description ≤16字自动走 Boost;17~19字保留普通回复并提示;20字及以上正常回复。支持同帖单用户仅1次 Boost、每帖 50 Boost 上限检测,并在不可 Boost 时自动切换普通回复提示。
// @match https://linux.do/t/*
// @run-at document-end
// @grant none
// ==/UserScript==
(function () {
"use strict";
const CONFIG = {
BOOST_MAX_LEN: 16,
REPLY_MIN_LEN: 20,
MAX_BOOSTS_PER_TARGET: 50,
AUTO_CLOSE_COMPOSER_AFTER_SUCCESS: true,
BOOST_TOPIC_REPLY_TO_FIRST_POST: true,
TOAST_MS: 3200,
DEBUG: false,
MAX_TOPIC_CACHE: 8,
CURRENT_USER_RETRY_MS: 10 * 60 * 1000,
};
const COLORS = {
BOOST: "#e67e22",
ERROR: "#c0392b",
PENDING: "#7f8c8d",
UNCERTAIN: "#2d7ff9",
WARNING: "#f39c12",
SUCCESS: "#27ae60",
};
const state = {
sending: false,
rafId: 0,
moTimer: 0,
domVersion: 0,
composerSyncTimers: [],
currentTopicId: null,
lastClickedTarget: null,
composerTarget: null,
currentUser: null,
currentUserPromise: null,
// 避免反复请求 session/current.json 导致 429。
currentUserFetchTried: false,
currentUserRetryAt: 0,
topicMetaData: new Map(),
topicMetaPromises: new Map(),
topicBoostScanCache: {
key: "",
data: null,
},
// topicId => { boostedTargets: Set<postId>, fullTargets: Set<postId> }
localBoostGuards: new Map(),
};
const qs = (sel, root = document) => root.querySelector(sel);
const qsa = (sel, root = document) => Array.from(root.querySelectorAll(sel));
const log = (...args) => CONFIG.DEBUG && console.log("[ld-boost]", ...args);
function asElement(node) {
return node instanceof Element ? node : null;
}
function stopEvent(e) {
e?.preventDefault?.();
e?.stopPropagation?.();
e?.stopImmediatePropagation?.();
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function countChars(text) {
return [...String(text || "").trim()].length;
}
function normalizeUsername(value) {
return String(value || "").trim().replace(/^@/, "").toLowerCase();
}
function normalizeErrorText(value) {
return String(value || "").trim().toLowerCase();
}
function pickDefined(...values) {
for (const v of values) {
if (v !== undefined && v !== null && v !== "") return v;
}
return null;
}
function pruneMapCache(map, max = CONFIG.MAX_TOPIC_CACHE) {
while (map.size > max) {
const oldestKey = map.keys().next().value;
map.delete(oldestKey);
}
}
function getTopicId() {
return (
Number(
qs('section#topic[data-topic-id]')?.dataset.topicId ||
qs('#topic-title h1[data-topic-id]')?.dataset.topicId ||
location.pathname.match(/\/t\/[^/]+\/(\d+)/)?.[1] ||
0
) || null
);
}
function resetBoostScanCache() {
state.topicBoostScanCache.key = "";
state.topicBoostScanCache.data = null;
}
function syncTopicChange() {
const topicId = getTopicId();
if (state.currentTopicId && topicId && state.currentTopicId !== topicId) {
state.lastClickedTarget = null;
state.composerTarget = null;
resetBoostScanCache();
state.domVersion++;
}
state.currentTopicId = topicId || state.currentTopicId;
}
function getCanonicalTopicPath() {
const href =
qs('#topic-title a.fancy-title[href*="/t/"]')?.getAttribute("href") ||
qs('#reply-control .reply-details .topic-link[href*="/t/"]')?.getAttribute("href") ||
location.pathname;
const clean = String(href || "").split(/[?#]/)[0];
const match = clean.match(/\/t\/[^/]+\/\d+/);
return match ? match[0] : null;
}
function getCsrfToken() {
return qs('meta[name="csrf-token"]')?.content || "";
}
function getComposer() {
return qs("#reply-control .reply-area");
}
function getComposerTextarea() {
return qs("#reply-control textarea.d-editor-input");
}
function getComposerSubmitBtn() {
return qs("#reply-control .submit-panel button.create");
}
function getComposerDiscardBtn() {
return qs("#reply-control .discard-button");
}
function getFirstPostArticle() {
return (
qs('section#topic article#post_1[data-post-id]') ||
qs('section#topic .topic-post[data-post-number="1"] article[data-post-id]')
);
}
function getArticleByPostNumber(postNumber) {
if (!postNumber) return null;
return (
qs(`section#topic article#post_${postNumber}[data-post-id]`) ||
qs(`section#topic .topic-post[data-post-number="${postNumber}"] article[data-post-id]`)
);
}
function getArticleByPostId(postId) {
if (!postId) return null;
return qs(`section#topic article[data-post-id="${postId}"]`);
}
function getArticleForTarget(target) {
if (!target) return null;
if (target.mode === "topic") return getFirstPostArticle();
return (
(target.postNumber ? getArticleByPostNumber(target.postNumber) : null) ||
(target.postId ? getArticleByPostId(target.postId) : null) ||
null
);
}
function getPostNumberFromTopicHref(href) {
try {
const url = new URL(href, location.origin);
const parts = url.pathname.split("/").filter(Boolean);
if (parts[0] !== "t") return null;
return Number(parts[3] || 0) || null;
} catch {
return null;
}
}
function extractUsernameFromArticle(article) {
return normalizeUsername(
qs('.topic-meta-data .names a[href^="/u/"]', article)?.getAttribute("href")?.split("/u/")[1]?.split(/[/?#]/)[0] || ""
);
}
function getBoostButtonInArticle(article) {
if (!article) return null;
const menuArea = qs(".post__menu-area", article);
if (!menuArea) return null;
const candidates = qsa(
[
"button.post-action-menu__boost",
"button.discourse-boosts__add-btn",
"button.discourse-boosts-trigger",
].join(","),
menuArea
);
return (
candidates.find((el) => {
if (!(el instanceof HTMLElement)) return false;
if (el.disabled) return false;
if (el.getAttribute("aria-disabled") === "true") return false;
return true;
}) || null
);
}
function detectBoostSupportFromArticle(article) {
if (!article) return "unknown";
if (getBoostButtonInArticle(article)) return "yes";
// 已渲染出 boosts 区域但没有可点击按钮时,视为当前目标不可 Boost。
const hasBoostHints = !!qs(
".discourse-boosts__post-menu, .discourse-boosts, [class*='discourse-boosts']",
article
);
return hasBoostHints ? "no" : "unknown";
}
function getBoostBubbles(article) {
if (!article) return [];
return qsa(".discourse-boosts__bubble", article);
}
function extractUsernameFromBoostBubble(bubble) {
if (!bubble) return "";
const fromCard = normalizeUsername(
qs("a[data-user-card]", bubble)?.getAttribute("data-user-card") || ""
);
if (fromCard) return fromCard;
const fromHref = normalizeUsername(
qs('a[href^="/u/"]', bubble)?.getAttribute("href")?.split("/u/")[1]?.split(/[/?#]/)[0] || ""
);
return fromHref;
}
function scanTopicBoostsSync() {
syncTopicChange();
const topicId = getTopicId();
const cacheKey = `${topicId || 0}:${state.domVersion}`;
if (state.topicBoostScanCache.key === cacheKey && state.topicBoostScanCache.data) {
return state.topicBoostScanCache.data;
}
const byPostId = new Map();
const allUsernames = new Set();
qsa("section#topic article[data-post-id]").forEach((article) => {
const postId = Number(article.dataset.postId || 0) || null;
if (!postId) return;
const bubbles = getBoostBubbles(article);
const users = new Set();
for (const bubble of bubbles) {
const username = extractUsernameFromBoostBubble(bubble);
if (username) {
users.add(username);
allUsernames.add(username);
}
}
byPostId.set(postId, {
count: bubbles.length,
users,
});
});
const data = { byPostId, allUsernames };
state.topicBoostScanCache.key = cacheKey;
state.topicBoostScanCache.data = data;
return data;
}
function ensureLocalBoostGuard(topicId = getTopicId()) {
if (!topicId) return null;
const key = String(topicId);
let guard = state.localBoostGuards.get(key);
if (!guard) {
guard = {
boostedTargets: new Set(),
fullTargets: new Set(),
};
state.localBoostGuards.set(key, guard);
pruneMapCache(state.localBoostGuards);
}
return guard;
}
function getLocalBoostGuard(topicId = getTopicId()) {
if (!topicId) return null;
return state.localBoostGuards.get(String(topicId)) || null;
}
function markLocalBoostedTarget(topicId = getTopicId(), postId) {
const guard = ensureLocalBoostGuard(topicId);
if (guard && postId) {
guard.boostedTargets.add(Number(postId));
}
}
function markLocalTargetFull(topicId = getTopicId(), postId) {
const guard = ensureLocalBoostGuard(topicId);
if (guard && postId) {
guard.fullTargets.add(Number(postId));
}
}
function getTargetBoostSnapshot(target, scan) {
const article = getArticleForTarget(target);
const hasBoostButton = !!getBoostButtonInArticle(article);
if (target.postId && scan?.byPostId?.has(target.postId)) {
const data = scan.byPostId.get(target.postId);
return { hasBoostButton, targetBoostCount: data.count, targetUsers: data.users };
}
if (!article) {
return { hasBoostButton, targetBoostCount: null, targetUsers: null };
}
// 直接基于当前帖子 DOM 统计,避免局部刷新时拿到旧状态。
const targetUsers = new Set();
const bubbles = getBoostBubbles(article);
bubbles.forEach((bubble) => {
const username = extractUsernameFromBoostBubble(bubble);
if (username) targetUsers.add(username);
});
return { hasBoostButton, targetBoostCount: bubbles.length, targetUsers };
}
function isTargetBoostLimitReached(snapshot, target, localGuard) {
// 页面上仍有可点击的 Boost 按钮时,优先认为目标未满。
if (snapshot.hasBoostButton) return false;
if (localGuard?.fullTargets?.has?.(Number(target.postId))) return true;
return Number.isFinite(snapshot.targetBoostCount)
&& snapshot.targetBoostCount >= CONFIG.MAX_BOOSTS_PER_TARGET;
}
function getBoostConstraintStateSync(target) {
if (!target) {
return { targetBoostCount: null, targetBoostLimitReached: false, myBoostInTarget: false };
}
const me = state.currentUser || readCurrentUserFromMeta() || readCurrentUserFromGlobals();
const myUsername = normalizeUsername(me?.username);
const scan = scanTopicBoostsSync();
const localGuard = getLocalBoostGuard(target.topicId || getTopicId());
const snapshot = getTargetBoostSnapshot(target, scan);
const myBoostInTarget = !!(myUsername && snapshot.targetUsers instanceof Set && snapshot.targetUsers.has(myUsername));
const hasLocalBoostInTarget = !!localGuard?.boostedTargets?.has?.(Number(target.postId));
return {
targetBoostCount: snapshot.targetBoostCount,
targetBoostLimitReached: isTargetBoostLimitReached(snapshot, target, localGuard),
myBoostInTarget: myBoostInTarget || hasLocalBoostInTarget,
};
}
function makeNormalReplyFallback(code, text, title) {
return {
blocked: true,
code,
text,
title,
fallbackToNormalReply: true,
normalReplyText: `普通回复(需≥${CONFIG.REPLY_MIN_LEN}字)`,
normalReplyTitle: `${title};已自动切换为普通回复,普通回复通常至少需要 ${CONFIG.REPLY_MIN_LEN} 字`,
};
}
function getBoostBlockReasonSync(target) {
if (!target?.postId) {
return {
blocked: true,
code: "no-target",
text: "Boost 目标识别中",
title: "正在识别目标帖子",
fallbackToNormalReply: false,
};
}
if (isOwnTargetSync(target)) {
return makeNormalReplyFallback(
"self",
"自己的帖子/话题不可 Boost",
"这是你自己的帖子或话题,不支持 Boost"
);
}
const constraints = getBoostConstraintStateSync(target);
if (constraints.myBoostInTarget) {
return makeNormalReplyFallback(
"duplicate-post",
"你已在本帖 Boost 过",
"同一用户在同一帖子中只能发送 1 个 Boost"
);
}
if (constraints.targetBoostLimitReached) {
return makeNormalReplyFallback(
"limit",
`本帖 Boost 已满 ${CONFIG.MAX_BOOSTS_PER_TARGET} 个`,
`当前帖子已达到 ${CONFIG.MAX_BOOSTS_PER_TARGET} 个 Boost 上限`
);
}
if (target.boostSupport === "no") {
return makeNormalReplyFallback(
"unsupported",
"此帖不可 Boost",
"当前目标帖子不支持 Boost"
);
}
return {
blocked: false,
code: "ok",
constraints,
fallbackToNormalReply: false,
};
}
function articleToTarget(article, mode = "post", source = "") {
if (!article) return null;
const postId = Number(article.dataset.postId || 0) || null;
if (!postId) return null;
const postNumber =
Number(
article.id?.match(/^post_(\d+)$/)?.[1] ||
article.closest(".topic-post,[data-post-number]")?.dataset.postNumber ||
0
) || null;
const ownerUserId = Number(article.dataset.userId || 0) || null;
const ownerUsername = extractUsernameFromArticle(article) || null;
return {
topicId: getTopicId(),
mode,
postId,
postNumber,
ownerUserId,
ownerUsername,
boostSupport: detectBoostSupportFromArticle(article),
source,
};
}
function mergeTopicMeta(oldMeta = {}, newMeta = {}) {
return {
topicId: pickDefined(newMeta.topicId, oldMeta.topicId),
firstPostId: pickDefined(newMeta.firstPostId, oldMeta.firstPostId),
firstPostOwnerId: pickDefined(newMeta.firstPostOwnerId, oldMeta.firstPostOwnerId),
firstPostOwnerUsername: pickDefined(newMeta.firstPostOwnerUsername, oldMeta.firstPostOwnerUsername),
firstPostBoostSupport:
newMeta.firstPostBoostSupport && newMeta.firstPostBoostSupport !== "unknown"
? newMeta.firstPostBoostSupport
: oldMeta.firstPostBoostSupport || "unknown",
};
}
function setTopicMeta(topicId, meta) {
if (!topicId || !meta) return null;
const key = String(topicId);
const oldMeta = state.topicMetaData.get(key) || {};
const merged = mergeTopicMeta(oldMeta, meta);
state.topicMetaData.delete(key);
state.topicMetaData.set(key, merged);
pruneMapCache(state.topicMetaData);
return merged;
}
function getTopicMetaSync(topicId = getTopicId()) {
if (!topicId) return null;
const domArticle = getFirstPostArticle();
if (domArticle) {
setTopicMeta(topicId, {
topicId,
firstPostId: Number(domArticle.dataset.postId || 0) || null,
firstPostOwnerId: Number(domArticle.dataset.userId || 0) || null,
firstPostOwnerUsername: extractUsernameFromArticle(domArticle) || null,
firstPostBoostSupport: detectBoostSupportFromArticle(domArticle),
});
}
return state.topicMetaData.get(String(topicId)) || null;
}
async function ensureTopicMeta(topicId = getTopicId()) {
if (!topicId) return null;
const key = String(topicId);
const syncMeta = getTopicMetaSync(topicId);
if (syncMeta?.firstPostId && (syncMeta?.firstPostOwnerId || syncMeta?.firstPostOwnerUsername)) {
return syncMeta;
}
if (state.topicMetaPromises.has(key)) {
return state.topicMetaPromises.get(key);
}
const promise = (async () => {
try {
const canonicalPath = getCanonicalTopicPath() || `/t/topic/${topicId}`;
const res = await fetch(`${canonicalPath}.json`, {
credentials: "same-origin",
});
if (!res.ok) {
throw new Error(`获取 topic json 失败:HTTP ${res.status}`);
}
const data = await res.json();
const firstPost =
data?.post_stream?.posts?.find((p) => Number(p.post_number) === 1) ||
data?.post_stream?.posts?.[0] ||
null;
const domFirstPost = getFirstPostArticle();
const fetchedMeta = {
topicId,
firstPostId: Number(firstPost?.id || data?.post_stream?.stream?.[0] || 0) || null,
firstPostOwnerId:
Number(firstPost?.user_id || data?.details?.created_by?.id || 0) || null,
firstPostOwnerUsername:
normalizeUsername(firstPost?.username || data?.details?.created_by?.username || "") || null,
firstPostBoostSupport: domFirstPost
? detectBoostSupportFromArticle(domFirstPost)
: "unknown",
};
return setTopicMeta(topicId, fetchedMeta);
} finally {
state.topicMetaPromises.delete(key);
}
})();
state.topicMetaPromises.set(key, promise);
return promise;
}
function targetFromTopicMeta(meta, source = "") {
if (!meta) return null;
return {
topicId: meta.topicId || getTopicId(),
mode: "topic",
postId: meta.firstPostId || null,
postNumber: 1,
ownerUserId: meta.firstPostOwnerId || null,
ownerUsername: meta.firstPostOwnerUsername || null,
boostSupport: meta.firstPostBoostSupport || "unknown",
source,
};
}
function mergeTarget(base = {}, overlay = {}) {
return {
topicId: pickDefined(overlay.topicId, base.topicId),
mode: pickDefined(overlay.mode, base.mode),
postId: pickDefined(overlay.postId, base.postId),
postNumber: pickDefined(overlay.postNumber, base.postNumber),
ownerUserId: pickDefined(overlay.ownerUserId, base.ownerUserId),
ownerUsername: pickDefined(overlay.ownerUsername, base.ownerUsername),
boostSupport:
overlay.boostSupport && overlay.boostSupport !== "unknown"
? overlay.boostSupport
: base.boostSupport || "unknown",
source: pickDefined(overlay.source, base.source),
};
}
function getTopicReplyTargetSync() {
if (!CONFIG.BOOST_TOPIC_REPLY_TO_FIRST_POST) return null;
const domArticle = getFirstPostArticle();
if (domArticle) {
return articleToTarget(domArticle, "topic", "topic-first-post-dom");
}
const meta = getTopicMetaSync(getTopicId());
if (meta) {
return targetFromTopicMeta(meta, "topic-meta-sync");
}
return {
topicId: getTopicId(),
mode: "topic",
postId: null,
postNumber: 1,
ownerUserId: null,
ownerUsername: null,
boostSupport: "unknown",
source: "topic-pending",
};
}
function refreshTargetSync(target) {
if (!target) return null;
if (target.mode === "topic") {
const domArticle = getFirstPostArticle();
if (domArticle) {
return mergeTarget(target, articleToTarget(domArticle, "topic", "topic-refresh-dom"));
}
const meta = getTopicMetaSync(target.topicId || getTopicId());
if (meta) {
return mergeTarget(target, targetFromTopicMeta(meta, "topic-refresh-meta"));
}
return target;
}
let article = null;
if (target.postNumber) {
article = getArticleByPostNumber(target.postNumber);
}
if (!article && target.postId) {
article = getArticleByPostId(target.postId);
}
if (article) {
return mergeTarget(target, articleToTarget(article, "post", "post-refresh-dom"));
}
return target;
}
function packUser(raw) {
if (!raw) return null;
const id = Number(raw.id || raw.user_id || raw.userId || 0) || null;
const username = normalizeUsername(raw.username || raw.user || raw.login || "");
if (!id && !username) return null;
return { id, username };
}
function readCurrentUserFromMeta() {
const username = normalizeUsername(
qs('meta[name="current-user"]')?.content ||
qs('meta[name="discourse-current-username"]')?.content ||
""
);
const id =
Number(
qs('meta[name="current-user-id"]')?.content ||
qs('meta[name="discourse-current-user-id"]')?.content ||
0
) || null;
return id || username ? { id, username } : null;
}
function readCurrentUserFromGlobals() {
try {
if (window.currentUser) {
const u = packUser(window.currentUser);
if (u) return u;
}
} catch (err) {
log("readCurrentUserFromGlobals currentUser 失败", err);
}
try {
if (window.Discourse?.currentUser) {
const u = packUser(window.Discourse.currentUser);
if (u) return u;
}
} catch (err) {
log("readCurrentUserFromGlobals Discourse.currentUser 失败", err);
}
try {
const discourseUser = window.require?.("discourse/lib/user")?.default?.current?.();
if (discourseUser) {
const u = packUser(discourseUser);
if (u) return u;
}
} catch (err) {
log("readCurrentUserFromGlobals discourse/lib/user 失败", err);
}
return null;
}
async function ensureCurrentUser() {
if (state.currentUser) return state.currentUser;
if (state.currentUserPromise) return state.currentUserPromise;
state.currentUserPromise = (async () => {
let user = readCurrentUserFromMeta() || readCurrentUserFromGlobals();
if (!user) {
const now = Date.now();
const canRetry = !state.currentUserFetchTried || now >= state.currentUserRetryAt;
// 仅在首次或退避期结束后才请求,避免频繁打到 session/current.json。
if (canRetry) {
state.currentUserFetchTried = true;
state.currentUserRetryAt = now + CONFIG.CURRENT_USER_RETRY_MS;
try {
const res = await fetch("/session/current.json", {
credentials: "same-origin",
});
if (res.ok) {
const data = await res.json();
user = packUser(data?.current_user || data?.user || data);
}
} catch (err) {
log("读取当前用户失败", err);
}
}
}
state.currentUser = user || null;
state.currentUserPromise = null;
scheduleUIUpdate();
return state.currentUser;
})();
return state.currentUserPromise;
}
function isOwnTargetSync(target) {
const me = state.currentUser || readCurrentUserFromMeta() || readCurrentUserFromGlobals();
if (!me || !target) return false;
if (me.id && target.ownerUserId) {
return Number(me.id) === Number(target.ownerUserId);
}
if (me.username && target.ownerUsername) {
return normalizeUsername(me.username) === normalizeUsername(target.ownerUsername);
}
return false;
}
function resolveTargetFromReplyLauncher(btn) {
if (!btn) return null;
if (
btn.matches(".post-action-menu__reply.reply.create") ||
btn.closest(".post-action-menu__reply.reply.create")
) {
const article =
btn.closest("article[data-post-id]") ||
btn.closest(".topic-post")?.querySelector("article[data-post-id]");
return articleToTarget(article, "post", "clicked-post-reply");
}
if (
btn.matches(".reply-to-post, #topic-footer-buttons .topic-footer-button.create") ||
btn.closest(".reply-to-post, #topic-footer-buttons .topic-footer-button.create")
) {
return getTopicReplyTargetSync();
}
return null;
}
function parseComposerTarget() {
const composer = getComposer();
if (!composer) {
state.composerTarget = null;
return null;
}
const userLink = qs('.reply-details .action-title .user-link[href*="/t/"]', composer);
if (userLink) {
const postNumber = getPostNumberFromTopicHref(userLink.getAttribute("href") || "");
if (postNumber) {
const article = getArticleByPostNumber(postNumber);
if (article) {
const target = articleToTarget(article, "post", "composer-user-link");
state.composerTarget = target;
return target;
}
if (
state.lastClickedTarget?.mode === "post" &&
Number(state.lastClickedTarget.postNumber) === Number(postNumber)
) {
const target = refreshTargetSync(state.lastClickedTarget);
state.composerTarget = target;
return target;
}
}
}
const topicLink = qs(".reply-details .action-title .topic-link", composer);
if (topicLink) {
const target = getTopicReplyTargetSync();
state.composerTarget = target;
return target;
}
if (state.composerTarget) {
state.composerTarget = refreshTargetSync(state.composerTarget);
return state.composerTarget;
}
return null;
}
function getEffectiveTargetSync() {
syncTopicChange();
const composerTarget = parseComposerTarget();
if (composerTarget) return refreshTargetSync(composerTarget);
if (state.lastClickedTarget) {
return refreshTargetSync(state.lastClickedTarget);
}
return null;
}
async function getEffectiveTargetForSubmit() {
let target = getEffectiveTargetSync();
if (target?.mode === "topic" && (!target.postId || (!target.ownerUserId && !target.ownerUsername))) {
await ensureTopicMeta(target.topicId || getTopicId());
target = getEffectiveTargetSync();
}
if (!target?.postId) {
for (const ms of [60, 150, 300, 600, 1200]) {
await sleep(ms);
target = getEffectiveTargetSync();
if (target?.mode === "topic" && (!target.postId || (!target.ownerUserId && !target.ownerUsername))) {
try {
await ensureTopicMeta(target.topicId || getTopicId());
} catch (err) {
log("重试补全 topic meta 失败", err);
}
target = getEffectiveTargetSync();
}
if (target?.postId) return target;
}
}
return target;
}
function showToast(message, type = "info") {
let container = qs("#ld-boost-toast");
if (!container) {
container = document.createElement("div");
container.id = "ld-boost-toast";
container.style.cssText = [
"position:fixed",
"top:20px",
"right:20px",
"z-index:999999",
"padding:10px 14px",
"border-radius:10px",
"font-size:14px",
"color:#fff",
"box-shadow:0 8px 24px rgba(0,0,0,.18)",
"opacity:0",
"transform:translateY(-8px)",
"transition:all .2s ease",
"pointer-events:none",
"max-width:520px",
"word-break:break-word",
].join(";");
document.body.appendChild(container);
}
const bg =
type === "success" ? COLORS.SUCCESS :
type === "error" ? COLORS.ERROR :
COLORS.UNCERTAIN;
container.style.background = bg;
container.textContent = message;
container.style.opacity = "1";
container.style.transform = "translateY(0)";
clearTimeout(container.__ldTimer);
container.__ldTimer = setTimeout(() => {
container.style.opacity = "0";
container.style.transform = "translateY(-8px)";
}, CONFIG.TOAST_MS);
}
function scheduleUIUpdate() {
cancelAnimationFrame(state.rafId);
state.rafId = requestAnimationFrame(updateComposerUI);
}
function clearComposerSyncTimers() {
state.composerSyncTimers.forEach(clearTimeout);
state.composerSyncTimers = [];
}
function scheduleComposerSync() {
clearComposerSyncTimers();
[0, 60, 150, 300, 600, 1200].forEach((ms) => {
const timer = setTimeout(() => {
parseComposerTarget();
scheduleUIUpdate();
}, ms);
state.composerSyncTimers.push(timer);
});
}
function isInternalUIButtonLabel(text) {
const t = String(text || "").trim();
return (
t === "Boost 回复" ||
t === "Boost 目标识别中" ||
t === "自己的帖子/话题不可 Boost" ||
t === "此帖不可 Boost" ||
t === "你已在本帖 Boost 过" ||
t === "Boost 发送中..." ||
t.startsWith("普通回复(") ||
t.startsWith("本帖 Boost 已满 ")
);
}
function resetSubmitButton(btn) {
const label = qs(".d-button-label", btn);
if (label) {
const currentText = (label.textContent || "").trim();
if (!label.dataset.origLabel) {
label.dataset.origLabel = currentText || "回复";
} else if (currentText && !isInternalUIButtonLabel(currentText)) {
label.dataset.origLabel = currentText;
}
label.textContent = label.dataset.origLabel || "回复";
}
btn.style.background = "";
btn.style.borderColor = "";
btn.dataset.useBoost = "0";
btn.dataset.boostBlocked = "0";
btn.title = "或按 Ctrl Enter";
}
function getNormalReplyHintTitle(block) {
return (
block?.normalReplyTitle ||
`${block?.title || "当前条件无法使用 Boost"};已自动切换为普通回复,普通回复通常至少需要 ${CONFIG.REPLY_MIN_LEN} 字`
);
}
function updateComposerUI() {
if (state.sending) return;
syncTopicChange();
const textarea = getComposerTextarea();
const primaryBtn = getComposerSubmitBtn();
if (!textarea || !primaryBtn) return;
resetSubmitButton(primaryBtn);
const raw = String(textarea.value || "").trim();
const len = countChars(raw);
if (!raw) return;
const target = getEffectiveTargetSync();
const label = qs(".d-button-label", primaryBtn);
if (len > CONFIG.BOOST_MAX_LEN && len < CONFIG.REPLY_MIN_LEN) {
if (label) {
label.textContent = `普通回复(${len}字,需≥${CONFIG.REPLY_MIN_LEN}字)`;
}
primaryBtn.style.background = COLORS.WARNING;
primaryBtn.style.borderColor = COLORS.WARNING;
primaryBtn.title = `已超过 Boost 上限 ${CONFIG.BOOST_MAX_LEN} 字;普通回复通常至少需要 ${CONFIG.REPLY_MIN_LEN} 字`;
return;
}
if (len >= CONFIG.REPLY_MIN_LEN) {
primaryBtn.title = "或按 Ctrl Enter";
return;
}
const block = getBoostBlockReasonSync(target);
if (block.blocked) {
if (block.fallbackToNormalReply) {
if (label) label.textContent = block.normalReplyText || `普通回复(需≥${CONFIG.REPLY_MIN_LEN}字)`;
primaryBtn.style.background = COLORS.WARNING;
primaryBtn.style.borderColor = COLORS.WARNING;
primaryBtn.title = getNormalReplyHintTitle(block);
return;
}
if (label) label.textContent = block.text || "Boost 目标识别中";
primaryBtn.style.background = block.code === "no-target" ? COLORS.PENDING : COLORS.ERROR;
primaryBtn.style.borderColor = block.code === "no-target" ? COLORS.PENDING : COLORS.ERROR;
primaryBtn.dataset.boostBlocked = "1";
primaryBtn.title = block.title || "当前无法使用 Boost";
return;
}
const stats = block.constraints || {};
const countDesc = Number.isFinite(stats.targetBoostCount)
? `,当前 ${stats.targetBoostCount}/${CONFIG.MAX_BOOSTS_PER_TARGET}`
: "";
if (label) label.textContent = "Boost 回复";
primaryBtn.style.background =
target.boostSupport === "unknown" ? COLORS.UNCERTAIN : COLORS.BOOST;
primaryBtn.style.borderColor =
target.boostSupport === "unknown" ? COLORS.UNCERTAIN : COLORS.BOOST;
primaryBtn.dataset.useBoost = "1";
primaryBtn.title =
target.boostSupport === "unknown"
? `当前 ${len}/${CONFIG.BOOST_MAX_LEN} 字,目标 #${target.postNumber || "?"},支持状态未确认,将尝试 Boost${countDesc}`
: `当前 ${len}/${CONFIG.BOOST_MAX_LEN} 字,将走 Boost(目标 #${target.postNumber || "?"})${countDesc}`;
}
function tryParseJSON(text) {
try {
return text ? JSON.parse(text) : null;
} catch {
return null;
}
}
function extractErrorMessage(payload, fallbackText = "") {
if (!payload) return String(fallbackText || "").trim();
if (typeof payload === "string") return payload.trim();
if (Array.isArray(payload)) return payload.map((x) => String(x)).join(";");
if (Array.isArray(payload.errors) && payload.errors.length) {
return payload.errors.map((x) => String(x)).join(";");
}
if (typeof payload.error === "string" && payload.error) return payload.error.trim();
if (typeof payload.message === "string" && payload.message) return payload.message.trim();
if (typeof payload.detail === "string" && payload.detail) return payload.detail.trim();
return String(fallbackText || "").trim();
}
function classifyBoostError(message) {
const s = normalizeErrorText(message);
if (!s) return "other";
if (
/already|duplicate|same user|one boost|only one|只能.*一次|只能.*1.*次|已经.*boost|已.*boost|重复/.test(s)
) {
return "duplicate";
}
if (
/limit|max|maximum|full|too many|reached|over|上限|已满|最多|50/.test(s)
) {
return "limit";
}
return "other";
}
async function postBoost(raw, target) {
const csrf = getCsrfToken();
if (!csrf) throw new Error("未找到 csrf-token");
const res = await fetch(`/discourse-boosts/posts/${target.postId}/boosts`, {
method: "POST",
credentials: "same-origin",
headers: {
"content-type": "application/x-www-form-urlencoded; charset=UTF-8",
"x-csrf-token": csrf,
"x-requested-with": "XMLHttpRequest",
"discourse-logged-in": "true",
"discourse-present": "true",
},
body: new URLSearchParams({ raw }).toString(),
});
const text = await res.text();
const payload = tryParseJSON(text);
if (!res.ok) {
const message = extractErrorMessage(payload, text || `HTTP ${res.status}`);
const err = new Error(message || `HTTP ${res.status}`);
err.code = classifyBoostError(message);
err.httpStatus = res.status;
err.payload = payload;
throw err;
}
return payload || text || { ok: true };
}
function closeComposer() {
const discard = getComposerDiscardBtn();
if (discard) {
discard.click();
return true;
}
const closeBtn =
qs("#reply-control .toggle-save-and-close") ||
qs("#reply-control .toggle-minimize");
if (closeBtn) {
closeBtn.click();
return true;
}
return false;
}
function shouldInterceptSubmitAsBoostSync() {
const textarea = getComposerTextarea();
const len = countChars(textarea?.value || "");
if (!len || len > CONFIG.BOOST_MAX_LEN) {
return false;
}
const target = getEffectiveTargetSync();
const block = getBoostBlockReasonSync(target);
if (block.blocked && block.fallbackToNormalReply) {
return false;
}
return true;
}
async function sendBoostFromComposer(e, { force = false } = {}) {
if (state.sending) {
stopEvent(e);
return false;
}
const textarea = getComposerTextarea();
const primaryBtn = getComposerSubmitBtn();
if (!textarea || !primaryBtn) return true;
const raw = String(textarea.value || "").trim();
const len = countChars(raw);
if (!raw) {
stopEvent(e);
showToast("内容不能为空", "error");
return false;
}
if (!force && len > CONFIG.BOOST_MAX_LEN) {
return true;
}
if (len > CONFIG.BOOST_MAX_LEN) {
stopEvent(e);
showToast(`Boost 最多支持 ${CONFIG.BOOST_MAX_LEN} 个字符,当前 ${len} 个`, "error");
return false;
}
stopEvent(e);
let target = await getEffectiveTargetForSubmit();
if (target?.mode === "topic" && (!target.postId || (!target.ownerUserId && !target.ownerUsername))) {
try {
const meta = await ensureTopicMeta(target.topicId || getTopicId());
target = mergeTarget(target, targetFromTopicMeta(meta, "submit-topic-meta"));
} catch (err) {
log("补全 topic meta 失败", err);
}
}
target = refreshTargetSync(target);
if (!target?.postId) {
showToast("未找到目标 post_id,无法使用 Boost", "error");
scheduleUIUpdate();
return false;
}
await ensureCurrentUser();
const block = getBoostBlockReasonSync(target);
if (block.blocked) {
if (block.fallbackToNormalReply) {
scheduleUIUpdate();
return true;
}
showToast(block.title || block.text || "当前无法使用 Boost", "error");
scheduleUIUpdate();
return false;
}
const label = qs(".d-button-label", primaryBtn);
const oldLabel =
label?.dataset.origLabel ||
label?.textContent ||
"回复";
state.sending = true;
primaryBtn.disabled = true;
if (label) label.textContent = "Boost 发送中...";
try {
await postBoost(raw, target);
markLocalBoostedTarget(target.topicId || getTopicId(), target.postId);
const constraints = getBoostConstraintStateSync(target);
if (
Number.isFinite(constraints.targetBoostCount) &&
constraints.targetBoostCount + 1 >= CONFIG.MAX_BOOSTS_PER_TARGET
) {
markLocalTargetFull(target.topicId || getTopicId(), target.postId);
}
textarea.value = "";
textarea.dispatchEvent(new Event("input", { bubbles: true }));
showToast(
`已通过 Boost 发送到 #${target.postNumber}(post_id: ${target.postId})`,
"success"
);
state.lastClickedTarget = null;
state.composerTarget = null;
if (CONFIG.AUTO_CLOSE_COMPOSER_AFTER_SUCCESS) {
await sleep(120);
closeComposer();
}
return false;
} catch (err) {
console.error(err);
if (err?.code === "duplicate") {
markLocalBoostedTarget(target.topicId || getTopicId(), target.postId);
scheduleUIUpdate();
return false;
}
if (err?.code === "limit") {
markLocalTargetFull(target.topicId || getTopicId(), target.postId);
scheduleUIUpdate();
return false;
}
showToast(`Boost 发送失败:${err.message}`, "error");
return false;
} finally {
state.sending = false;
primaryBtn.disabled = false;
if (label) label.textContent = oldLabel;
scheduleUIUpdate();
}
}
function primeReplyContext(target) {
if (!target) return;
if (target.mode === "topic") {
void ensureTopicMeta(target.topicId || getTopicId())
.then(() => {
if (state.lastClickedTarget?.mode === "topic") {
state.lastClickedTarget = refreshTargetSync(state.lastClickedTarget);
}
scheduleUIUpdate();
})
.catch((err) => {
log("primeReplyContext ensureTopicMeta 失败", err);
});
}
void ensureCurrentUser().then(() => scheduleUIUpdate());
}
document.addEventListener(
"click",
(e) => {
const el = asElement(e.target);
if (!el) return;
const submitBtn = el.closest("#reply-control .submit-panel button.create");
if (submitBtn) {
if (shouldInterceptSubmitAsBoostSync()) {
void sendBoostFromComposer(e, { force: false });
}
return;
}
const replyLauncher = el.closest(
[
".post-action-menu__reply.reply.create",
".timeline-footer-controls .reply-to-post",
"#topic-footer-buttons .topic-footer-button.create"
].join(",")
);
if (replyLauncher && !replyLauncher.closest("#reply-control")) {
const target = resolveTargetFromReplyLauncher(replyLauncher);
if (target) {
state.lastClickedTarget = target;
state.composerTarget = target;
primeReplyContext(target);
}
scheduleComposerSync();
}
},
true
);
document.addEventListener(
"keydown",
(e) => {
const el = asElement(e.target);
if (!el) return;
const inComposer = el.closest("#reply-control textarea.d-editor-input");
if (!inComposer) return;
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
if (shouldInterceptSubmitAsBoostSync()) {
void sendBoostFromComposer(e, { force: false });
}
}
},
true
);
document.addEventListener(
"input",
(e) => {
const el = asElement(e.target);
if (el?.matches?.("#reply-control textarea.d-editor-input")) {
scheduleUIUpdate();
}
},
true
);
document.addEventListener(
"focusin",
(e) => {
const el = asElement(e.target);
if (el?.matches?.("#reply-control textarea.d-editor-input")) {
scheduleUIUpdate();
}
},
true
);
new MutationObserver(() => {
clearTimeout(state.moTimer);
state.moTimer = setTimeout(() => {
syncTopicChange();
state.domVersion++;
resetBoostScanCache();
if (getComposer()) {
parseComposerTarget();
const target = getEffectiveTargetSync();
if (target?.mode === "topic") {
primeReplyContext(target);
}
} else {
state.composerTarget = null;
}
scheduleUIUpdate();
}, 80);
}).observe(document.body, {
childList: true,
subtree: true,
});
window.__ldBoostShortReply = {
config: CONFIG,
state,
getEffectiveTargetSync,
ensureTopicMeta,
ensureCurrentUser,
parseComposerTarget,
refreshTargetSync,
getBoostConstraintStateSync,
getBoostBlockReasonSync,
scanTopicBoostsSync,
shouldInterceptSubmitAsBoostSync,
};
void ensureCurrentUser();
scheduleUIUpdate();
})();
--【壹】--:
是一个话题的所有帖子 还是一个帖子单独的限制啊
--【贰】--:
是的 就是问佬的脚本有没有考虑这个问题特殊处理来着
--【叁】--:
每一条回复 我给你找找始皇发那个 等下哈
image910×272 35.1 KB
--【肆】--:
有点意思,佬可以上传一个到 greasyfork 或 GitHub 地址吗,方便更新
--【伍】--:
image300×168 52.7 KB
--【陆】--:
是的
PixPin_2026-04-08_15-59-09513×273 21.2 KB
--【柒】--:
不是字符,是一个主题只允许50个boost回应,50个人的小火箭
--【捌】--:
如果判定不够字数,回复会自动发boost吗??
--【玖】--:
我还不知道这回事
等我去改一改 看一下情况
--【拾】--:
我看了下 本身 超过50个 就没有小火箭的按钮了 所以就会是普通回复的样子
--【拾壹】--:
linux.do 自动切换 boost/回复方式
≤16字自动走 Boost;17~19字保留普通回复并提示;20字及以上正常回复。支持同帖单用户仅1次 Boost、每帖 50 Boost 上限检测,并在不可 Boost 时自动切换普通回复提示。
--【拾贰】--:
佬请问下,bosst限制50个,超过50个时候这个脚本回复的boost会怎么处理
--【拾叁】--:
大召唤术,召唤哈雷表情包,佬友们快来助我
--【拾肆】--:
好东西,mm再也不担心boost吞我评论了
--【拾伍】--:
来了来了
--【拾陆】--:
既有存在50个的话 应该是不能再发送了的(不会显示小火箭)
所以如果直接打在接口上的话 那肯定是报错的
回复话题帖子的时候自动判断是否发送boost
PixPin_2026-04-08_12-50-45746×222 16.5 KB
油猴脚本 linux.do 自动切换 boost/回复方式
油猴脚本
// ==UserScript==
// @name linux.do 自动切换 boost/回复方式
// @namespace https://linux.do/
// @version 1.3
// @description ≤16字自动走 Boost;17~19字保留普通回复并提示;20字及以上正常回复。支持同帖单用户仅1次 Boost、每帖 50 Boost 上限检测,并在不可 Boost 时自动切换普通回复提示。
// @match https://linux.do/t/*
// @run-at document-end
// @grant none
// ==/UserScript==
(function () {
"use strict";
const CONFIG = {
BOOST_MAX_LEN: 16,
REPLY_MIN_LEN: 20,
MAX_BOOSTS_PER_TARGET: 50,
AUTO_CLOSE_COMPOSER_AFTER_SUCCESS: true,
BOOST_TOPIC_REPLY_TO_FIRST_POST: true,
TOAST_MS: 3200,
DEBUG: false,
MAX_TOPIC_CACHE: 8,
CURRENT_USER_RETRY_MS: 10 * 60 * 1000,
};
const COLORS = {
BOOST: "#e67e22",
ERROR: "#c0392b",
PENDING: "#7f8c8d",
UNCERTAIN: "#2d7ff9",
WARNING: "#f39c12",
SUCCESS: "#27ae60",
};
const state = {
sending: false,
rafId: 0,
moTimer: 0,
domVersion: 0,
composerSyncTimers: [],
currentTopicId: null,
lastClickedTarget: null,
composerTarget: null,
currentUser: null,
currentUserPromise: null,
// 避免反复请求 session/current.json 导致 429。
currentUserFetchTried: false,
currentUserRetryAt: 0,
topicMetaData: new Map(),
topicMetaPromises: new Map(),
topicBoostScanCache: {
key: "",
data: null,
},
// topicId => { boostedTargets: Set<postId>, fullTargets: Set<postId> }
localBoostGuards: new Map(),
};
const qs = (sel, root = document) => root.querySelector(sel);
const qsa = (sel, root = document) => Array.from(root.querySelectorAll(sel));
const log = (...args) => CONFIG.DEBUG && console.log("[ld-boost]", ...args);
function asElement(node) {
return node instanceof Element ? node : null;
}
function stopEvent(e) {
e?.preventDefault?.();
e?.stopPropagation?.();
e?.stopImmediatePropagation?.();
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function countChars(text) {
return [...String(text || "").trim()].length;
}
function normalizeUsername(value) {
return String(value || "").trim().replace(/^@/, "").toLowerCase();
}
function normalizeErrorText(value) {
return String(value || "").trim().toLowerCase();
}
function pickDefined(...values) {
for (const v of values) {
if (v !== undefined && v !== null && v !== "") return v;
}
return null;
}
function pruneMapCache(map, max = CONFIG.MAX_TOPIC_CACHE) {
while (map.size > max) {
const oldestKey = map.keys().next().value;
map.delete(oldestKey);
}
}
function getTopicId() {
return (
Number(
qs('section#topic[data-topic-id]')?.dataset.topicId ||
qs('#topic-title h1[data-topic-id]')?.dataset.topicId ||
location.pathname.match(/\/t\/[^/]+\/(\d+)/)?.[1] ||
0
) || null
);
}
function resetBoostScanCache() {
state.topicBoostScanCache.key = "";
state.topicBoostScanCache.data = null;
}
function syncTopicChange() {
const topicId = getTopicId();
if (state.currentTopicId && topicId && state.currentTopicId !== topicId) {
state.lastClickedTarget = null;
state.composerTarget = null;
resetBoostScanCache();
state.domVersion++;
}
state.currentTopicId = topicId || state.currentTopicId;
}
function getCanonicalTopicPath() {
const href =
qs('#topic-title a.fancy-title[href*="/t/"]')?.getAttribute("href") ||
qs('#reply-control .reply-details .topic-link[href*="/t/"]')?.getAttribute("href") ||
location.pathname;
const clean = String(href || "").split(/[?#]/)[0];
const match = clean.match(/\/t\/[^/]+\/\d+/);
return match ? match[0] : null;
}
function getCsrfToken() {
return qs('meta[name="csrf-token"]')?.content || "";
}
function getComposer() {
return qs("#reply-control .reply-area");
}
function getComposerTextarea() {
return qs("#reply-control textarea.d-editor-input");
}
function getComposerSubmitBtn() {
return qs("#reply-control .submit-panel button.create");
}
function getComposerDiscardBtn() {
return qs("#reply-control .discard-button");
}
function getFirstPostArticle() {
return (
qs('section#topic article#post_1[data-post-id]') ||
qs('section#topic .topic-post[data-post-number="1"] article[data-post-id]')
);
}
function getArticleByPostNumber(postNumber) {
if (!postNumber) return null;
return (
qs(`section#topic article#post_${postNumber}[data-post-id]`) ||
qs(`section#topic .topic-post[data-post-number="${postNumber}"] article[data-post-id]`)
);
}
function getArticleByPostId(postId) {
if (!postId) return null;
return qs(`section#topic article[data-post-id="${postId}"]`);
}
function getArticleForTarget(target) {
if (!target) return null;
if (target.mode === "topic") return getFirstPostArticle();
return (
(target.postNumber ? getArticleByPostNumber(target.postNumber) : null) ||
(target.postId ? getArticleByPostId(target.postId) : null) ||
null
);
}
function getPostNumberFromTopicHref(href) {
try {
const url = new URL(href, location.origin);
const parts = url.pathname.split("/").filter(Boolean);
if (parts[0] !== "t") return null;
return Number(parts[3] || 0) || null;
} catch {
return null;
}
}
function extractUsernameFromArticle(article) {
return normalizeUsername(
qs('.topic-meta-data .names a[href^="/u/"]', article)?.getAttribute("href")?.split("/u/")[1]?.split(/[/?#]/)[0] || ""
);
}
function getBoostButtonInArticle(article) {
if (!article) return null;
const menuArea = qs(".post__menu-area", article);
if (!menuArea) return null;
const candidates = qsa(
[
"button.post-action-menu__boost",
"button.discourse-boosts__add-btn",
"button.discourse-boosts-trigger",
].join(","),
menuArea
);
return (
candidates.find((el) => {
if (!(el instanceof HTMLElement)) return false;
if (el.disabled) return false;
if (el.getAttribute("aria-disabled") === "true") return false;
return true;
}) || null
);
}
function detectBoostSupportFromArticle(article) {
if (!article) return "unknown";
if (getBoostButtonInArticle(article)) return "yes";
// 已渲染出 boosts 区域但没有可点击按钮时,视为当前目标不可 Boost。
const hasBoostHints = !!qs(
".discourse-boosts__post-menu, .discourse-boosts, [class*='discourse-boosts']",
article
);
return hasBoostHints ? "no" : "unknown";
}
function getBoostBubbles(article) {
if (!article) return [];
return qsa(".discourse-boosts__bubble", article);
}
function extractUsernameFromBoostBubble(bubble) {
if (!bubble) return "";
const fromCard = normalizeUsername(
qs("a[data-user-card]", bubble)?.getAttribute("data-user-card") || ""
);
if (fromCard) return fromCard;
const fromHref = normalizeUsername(
qs('a[href^="/u/"]', bubble)?.getAttribute("href")?.split("/u/")[1]?.split(/[/?#]/)[0] || ""
);
return fromHref;
}
function scanTopicBoostsSync() {
syncTopicChange();
const topicId = getTopicId();
const cacheKey = `${topicId || 0}:${state.domVersion}`;
if (state.topicBoostScanCache.key === cacheKey && state.topicBoostScanCache.data) {
return state.topicBoostScanCache.data;
}
const byPostId = new Map();
const allUsernames = new Set();
qsa("section#topic article[data-post-id]").forEach((article) => {
const postId = Number(article.dataset.postId || 0) || null;
if (!postId) return;
const bubbles = getBoostBubbles(article);
const users = new Set();
for (const bubble of bubbles) {
const username = extractUsernameFromBoostBubble(bubble);
if (username) {
users.add(username);
allUsernames.add(username);
}
}
byPostId.set(postId, {
count: bubbles.length,
users,
});
});
const data = { byPostId, allUsernames };
state.topicBoostScanCache.key = cacheKey;
state.topicBoostScanCache.data = data;
return data;
}
function ensureLocalBoostGuard(topicId = getTopicId()) {
if (!topicId) return null;
const key = String(topicId);
let guard = state.localBoostGuards.get(key);
if (!guard) {
guard = {
boostedTargets: new Set(),
fullTargets: new Set(),
};
state.localBoostGuards.set(key, guard);
pruneMapCache(state.localBoostGuards);
}
return guard;
}
function getLocalBoostGuard(topicId = getTopicId()) {
if (!topicId) return null;
return state.localBoostGuards.get(String(topicId)) || null;
}
function markLocalBoostedTarget(topicId = getTopicId(), postId) {
const guard = ensureLocalBoostGuard(topicId);
if (guard && postId) {
guard.boostedTargets.add(Number(postId));
}
}
function markLocalTargetFull(topicId = getTopicId(), postId) {
const guard = ensureLocalBoostGuard(topicId);
if (guard && postId) {
guard.fullTargets.add(Number(postId));
}
}
function getTargetBoostSnapshot(target, scan) {
const article = getArticleForTarget(target);
const hasBoostButton = !!getBoostButtonInArticle(article);
if (target.postId && scan?.byPostId?.has(target.postId)) {
const data = scan.byPostId.get(target.postId);
return { hasBoostButton, targetBoostCount: data.count, targetUsers: data.users };
}
if (!article) {
return { hasBoostButton, targetBoostCount: null, targetUsers: null };
}
// 直接基于当前帖子 DOM 统计,避免局部刷新时拿到旧状态。
const targetUsers = new Set();
const bubbles = getBoostBubbles(article);
bubbles.forEach((bubble) => {
const username = extractUsernameFromBoostBubble(bubble);
if (username) targetUsers.add(username);
});
return { hasBoostButton, targetBoostCount: bubbles.length, targetUsers };
}
function isTargetBoostLimitReached(snapshot, target, localGuard) {
// 页面上仍有可点击的 Boost 按钮时,优先认为目标未满。
if (snapshot.hasBoostButton) return false;
if (localGuard?.fullTargets?.has?.(Number(target.postId))) return true;
return Number.isFinite(snapshot.targetBoostCount)
&& snapshot.targetBoostCount >= CONFIG.MAX_BOOSTS_PER_TARGET;
}
function getBoostConstraintStateSync(target) {
if (!target) {
return { targetBoostCount: null, targetBoostLimitReached: false, myBoostInTarget: false };
}
const me = state.currentUser || readCurrentUserFromMeta() || readCurrentUserFromGlobals();
const myUsername = normalizeUsername(me?.username);
const scan = scanTopicBoostsSync();
const localGuard = getLocalBoostGuard(target.topicId || getTopicId());
const snapshot = getTargetBoostSnapshot(target, scan);
const myBoostInTarget = !!(myUsername && snapshot.targetUsers instanceof Set && snapshot.targetUsers.has(myUsername));
const hasLocalBoostInTarget = !!localGuard?.boostedTargets?.has?.(Number(target.postId));
return {
targetBoostCount: snapshot.targetBoostCount,
targetBoostLimitReached: isTargetBoostLimitReached(snapshot, target, localGuard),
myBoostInTarget: myBoostInTarget || hasLocalBoostInTarget,
};
}
function makeNormalReplyFallback(code, text, title) {
return {
blocked: true,
code,
text,
title,
fallbackToNormalReply: true,
normalReplyText: `普通回复(需≥${CONFIG.REPLY_MIN_LEN}字)`,
normalReplyTitle: `${title};已自动切换为普通回复,普通回复通常至少需要 ${CONFIG.REPLY_MIN_LEN} 字`,
};
}
function getBoostBlockReasonSync(target) {
if (!target?.postId) {
return {
blocked: true,
code: "no-target",
text: "Boost 目标识别中",
title: "正在识别目标帖子",
fallbackToNormalReply: false,
};
}
if (isOwnTargetSync(target)) {
return makeNormalReplyFallback(
"self",
"自己的帖子/话题不可 Boost",
"这是你自己的帖子或话题,不支持 Boost"
);
}
const constraints = getBoostConstraintStateSync(target);
if (constraints.myBoostInTarget) {
return makeNormalReplyFallback(
"duplicate-post",
"你已在本帖 Boost 过",
"同一用户在同一帖子中只能发送 1 个 Boost"
);
}
if (constraints.targetBoostLimitReached) {
return makeNormalReplyFallback(
"limit",
`本帖 Boost 已满 ${CONFIG.MAX_BOOSTS_PER_TARGET} 个`,
`当前帖子已达到 ${CONFIG.MAX_BOOSTS_PER_TARGET} 个 Boost 上限`
);
}
if (target.boostSupport === "no") {
return makeNormalReplyFallback(
"unsupported",
"此帖不可 Boost",
"当前目标帖子不支持 Boost"
);
}
return {
blocked: false,
code: "ok",
constraints,
fallbackToNormalReply: false,
};
}
function articleToTarget(article, mode = "post", source = "") {
if (!article) return null;
const postId = Number(article.dataset.postId || 0) || null;
if (!postId) return null;
const postNumber =
Number(
article.id?.match(/^post_(\d+)$/)?.[1] ||
article.closest(".topic-post,[data-post-number]")?.dataset.postNumber ||
0
) || null;
const ownerUserId = Number(article.dataset.userId || 0) || null;
const ownerUsername = extractUsernameFromArticle(article) || null;
return {
topicId: getTopicId(),
mode,
postId,
postNumber,
ownerUserId,
ownerUsername,
boostSupport: detectBoostSupportFromArticle(article),
source,
};
}
function mergeTopicMeta(oldMeta = {}, newMeta = {}) {
return {
topicId: pickDefined(newMeta.topicId, oldMeta.topicId),
firstPostId: pickDefined(newMeta.firstPostId, oldMeta.firstPostId),
firstPostOwnerId: pickDefined(newMeta.firstPostOwnerId, oldMeta.firstPostOwnerId),
firstPostOwnerUsername: pickDefined(newMeta.firstPostOwnerUsername, oldMeta.firstPostOwnerUsername),
firstPostBoostSupport:
newMeta.firstPostBoostSupport && newMeta.firstPostBoostSupport !== "unknown"
? newMeta.firstPostBoostSupport
: oldMeta.firstPostBoostSupport || "unknown",
};
}
function setTopicMeta(topicId, meta) {
if (!topicId || !meta) return null;
const key = String(topicId);
const oldMeta = state.topicMetaData.get(key) || {};
const merged = mergeTopicMeta(oldMeta, meta);
state.topicMetaData.delete(key);
state.topicMetaData.set(key, merged);
pruneMapCache(state.topicMetaData);
return merged;
}
function getTopicMetaSync(topicId = getTopicId()) {
if (!topicId) return null;
const domArticle = getFirstPostArticle();
if (domArticle) {
setTopicMeta(topicId, {
topicId,
firstPostId: Number(domArticle.dataset.postId || 0) || null,
firstPostOwnerId: Number(domArticle.dataset.userId || 0) || null,
firstPostOwnerUsername: extractUsernameFromArticle(domArticle) || null,
firstPostBoostSupport: detectBoostSupportFromArticle(domArticle),
});
}
return state.topicMetaData.get(String(topicId)) || null;
}
async function ensureTopicMeta(topicId = getTopicId()) {
if (!topicId) return null;
const key = String(topicId);
const syncMeta = getTopicMetaSync(topicId);
if (syncMeta?.firstPostId && (syncMeta?.firstPostOwnerId || syncMeta?.firstPostOwnerUsername)) {
return syncMeta;
}
if (state.topicMetaPromises.has(key)) {
return state.topicMetaPromises.get(key);
}
const promise = (async () => {
try {
const canonicalPath = getCanonicalTopicPath() || `/t/topic/${topicId}`;
const res = await fetch(`${canonicalPath}.json`, {
credentials: "same-origin",
});
if (!res.ok) {
throw new Error(`获取 topic json 失败:HTTP ${res.status}`);
}
const data = await res.json();
const firstPost =
data?.post_stream?.posts?.find((p) => Number(p.post_number) === 1) ||
data?.post_stream?.posts?.[0] ||
null;
const domFirstPost = getFirstPostArticle();
const fetchedMeta = {
topicId,
firstPostId: Number(firstPost?.id || data?.post_stream?.stream?.[0] || 0) || null,
firstPostOwnerId:
Number(firstPost?.user_id || data?.details?.created_by?.id || 0) || null,
firstPostOwnerUsername:
normalizeUsername(firstPost?.username || data?.details?.created_by?.username || "") || null,
firstPostBoostSupport: domFirstPost
? detectBoostSupportFromArticle(domFirstPost)
: "unknown",
};
return setTopicMeta(topicId, fetchedMeta);
} finally {
state.topicMetaPromises.delete(key);
}
})();
state.topicMetaPromises.set(key, promise);
return promise;
}
function targetFromTopicMeta(meta, source = "") {
if (!meta) return null;
return {
topicId: meta.topicId || getTopicId(),
mode: "topic",
postId: meta.firstPostId || null,
postNumber: 1,
ownerUserId: meta.firstPostOwnerId || null,
ownerUsername: meta.firstPostOwnerUsername || null,
boostSupport: meta.firstPostBoostSupport || "unknown",
source,
};
}
function mergeTarget(base = {}, overlay = {}) {
return {
topicId: pickDefined(overlay.topicId, base.topicId),
mode: pickDefined(overlay.mode, base.mode),
postId: pickDefined(overlay.postId, base.postId),
postNumber: pickDefined(overlay.postNumber, base.postNumber),
ownerUserId: pickDefined(overlay.ownerUserId, base.ownerUserId),
ownerUsername: pickDefined(overlay.ownerUsername, base.ownerUsername),
boostSupport:
overlay.boostSupport && overlay.boostSupport !== "unknown"
? overlay.boostSupport
: base.boostSupport || "unknown",
source: pickDefined(overlay.source, base.source),
};
}
function getTopicReplyTargetSync() {
if (!CONFIG.BOOST_TOPIC_REPLY_TO_FIRST_POST) return null;
const domArticle = getFirstPostArticle();
if (domArticle) {
return articleToTarget(domArticle, "topic", "topic-first-post-dom");
}
const meta = getTopicMetaSync(getTopicId());
if (meta) {
return targetFromTopicMeta(meta, "topic-meta-sync");
}
return {
topicId: getTopicId(),
mode: "topic",
postId: null,
postNumber: 1,
ownerUserId: null,
ownerUsername: null,
boostSupport: "unknown",
source: "topic-pending",
};
}
function refreshTargetSync(target) {
if (!target) return null;
if (target.mode === "topic") {
const domArticle = getFirstPostArticle();
if (domArticle) {
return mergeTarget(target, articleToTarget(domArticle, "topic", "topic-refresh-dom"));
}
const meta = getTopicMetaSync(target.topicId || getTopicId());
if (meta) {
return mergeTarget(target, targetFromTopicMeta(meta, "topic-refresh-meta"));
}
return target;
}
let article = null;
if (target.postNumber) {
article = getArticleByPostNumber(target.postNumber);
}
if (!article && target.postId) {
article = getArticleByPostId(target.postId);
}
if (article) {
return mergeTarget(target, articleToTarget(article, "post", "post-refresh-dom"));
}
return target;
}
function packUser(raw) {
if (!raw) return null;
const id = Number(raw.id || raw.user_id || raw.userId || 0) || null;
const username = normalizeUsername(raw.username || raw.user || raw.login || "");
if (!id && !username) return null;
return { id, username };
}
function readCurrentUserFromMeta() {
const username = normalizeUsername(
qs('meta[name="current-user"]')?.content ||
qs('meta[name="discourse-current-username"]')?.content ||
""
);
const id =
Number(
qs('meta[name="current-user-id"]')?.content ||
qs('meta[name="discourse-current-user-id"]')?.content ||
0
) || null;
return id || username ? { id, username } : null;
}
function readCurrentUserFromGlobals() {
try {
if (window.currentUser) {
const u = packUser(window.currentUser);
if (u) return u;
}
} catch (err) {
log("readCurrentUserFromGlobals currentUser 失败", err);
}
try {
if (window.Discourse?.currentUser) {
const u = packUser(window.Discourse.currentUser);
if (u) return u;
}
} catch (err) {
log("readCurrentUserFromGlobals Discourse.currentUser 失败", err);
}
try {
const discourseUser = window.require?.("discourse/lib/user")?.default?.current?.();
if (discourseUser) {
const u = packUser(discourseUser);
if (u) return u;
}
} catch (err) {
log("readCurrentUserFromGlobals discourse/lib/user 失败", err);
}
return null;
}
async function ensureCurrentUser() {
if (state.currentUser) return state.currentUser;
if (state.currentUserPromise) return state.currentUserPromise;
state.currentUserPromise = (async () => {
let user = readCurrentUserFromMeta() || readCurrentUserFromGlobals();
if (!user) {
const now = Date.now();
const canRetry = !state.currentUserFetchTried || now >= state.currentUserRetryAt;
// 仅在首次或退避期结束后才请求,避免频繁打到 session/current.json。
if (canRetry) {
state.currentUserFetchTried = true;
state.currentUserRetryAt = now + CONFIG.CURRENT_USER_RETRY_MS;
try {
const res = await fetch("/session/current.json", {
credentials: "same-origin",
});
if (res.ok) {
const data = await res.json();
user = packUser(data?.current_user || data?.user || data);
}
} catch (err) {
log("读取当前用户失败", err);
}
}
}
state.currentUser = user || null;
state.currentUserPromise = null;
scheduleUIUpdate();
return state.currentUser;
})();
return state.currentUserPromise;
}
function isOwnTargetSync(target) {
const me = state.currentUser || readCurrentUserFromMeta() || readCurrentUserFromGlobals();
if (!me || !target) return false;
if (me.id && target.ownerUserId) {
return Number(me.id) === Number(target.ownerUserId);
}
if (me.username && target.ownerUsername) {
return normalizeUsername(me.username) === normalizeUsername(target.ownerUsername);
}
return false;
}
function resolveTargetFromReplyLauncher(btn) {
if (!btn) return null;
if (
btn.matches(".post-action-menu__reply.reply.create") ||
btn.closest(".post-action-menu__reply.reply.create")
) {
const article =
btn.closest("article[data-post-id]") ||
btn.closest(".topic-post")?.querySelector("article[data-post-id]");
return articleToTarget(article, "post", "clicked-post-reply");
}
if (
btn.matches(".reply-to-post, #topic-footer-buttons .topic-footer-button.create") ||
btn.closest(".reply-to-post, #topic-footer-buttons .topic-footer-button.create")
) {
return getTopicReplyTargetSync();
}
return null;
}
function parseComposerTarget() {
const composer = getComposer();
if (!composer) {
state.composerTarget = null;
return null;
}
const userLink = qs('.reply-details .action-title .user-link[href*="/t/"]', composer);
if (userLink) {
const postNumber = getPostNumberFromTopicHref(userLink.getAttribute("href") || "");
if (postNumber) {
const article = getArticleByPostNumber(postNumber);
if (article) {
const target = articleToTarget(article, "post", "composer-user-link");
state.composerTarget = target;
return target;
}
if (
state.lastClickedTarget?.mode === "post" &&
Number(state.lastClickedTarget.postNumber) === Number(postNumber)
) {
const target = refreshTargetSync(state.lastClickedTarget);
state.composerTarget = target;
return target;
}
}
}
const topicLink = qs(".reply-details .action-title .topic-link", composer);
if (topicLink) {
const target = getTopicReplyTargetSync();
state.composerTarget = target;
return target;
}
if (state.composerTarget) {
state.composerTarget = refreshTargetSync(state.composerTarget);
return state.composerTarget;
}
return null;
}
function getEffectiveTargetSync() {
syncTopicChange();
const composerTarget = parseComposerTarget();
if (composerTarget) return refreshTargetSync(composerTarget);
if (state.lastClickedTarget) {
return refreshTargetSync(state.lastClickedTarget);
}
return null;
}
async function getEffectiveTargetForSubmit() {
let target = getEffectiveTargetSync();
if (target?.mode === "topic" && (!target.postId || (!target.ownerUserId && !target.ownerUsername))) {
await ensureTopicMeta(target.topicId || getTopicId());
target = getEffectiveTargetSync();
}
if (!target?.postId) {
for (const ms of [60, 150, 300, 600, 1200]) {
await sleep(ms);
target = getEffectiveTargetSync();
if (target?.mode === "topic" && (!target.postId || (!target.ownerUserId && !target.ownerUsername))) {
try {
await ensureTopicMeta(target.topicId || getTopicId());
} catch (err) {
log("重试补全 topic meta 失败", err);
}
target = getEffectiveTargetSync();
}
if (target?.postId) return target;
}
}
return target;
}
function showToast(message, type = "info") {
let container = qs("#ld-boost-toast");
if (!container) {
container = document.createElement("div");
container.id = "ld-boost-toast";
container.style.cssText = [
"position:fixed",
"top:20px",
"right:20px",
"z-index:999999",
"padding:10px 14px",
"border-radius:10px",
"font-size:14px",
"color:#fff",
"box-shadow:0 8px 24px rgba(0,0,0,.18)",
"opacity:0",
"transform:translateY(-8px)",
"transition:all .2s ease",
"pointer-events:none",
"max-width:520px",
"word-break:break-word",
].join(";");
document.body.appendChild(container);
}
const bg =
type === "success" ? COLORS.SUCCESS :
type === "error" ? COLORS.ERROR :
COLORS.UNCERTAIN;
container.style.background = bg;
container.textContent = message;
container.style.opacity = "1";
container.style.transform = "translateY(0)";
clearTimeout(container.__ldTimer);
container.__ldTimer = setTimeout(() => {
container.style.opacity = "0";
container.style.transform = "translateY(-8px)";
}, CONFIG.TOAST_MS);
}
function scheduleUIUpdate() {
cancelAnimationFrame(state.rafId);
state.rafId = requestAnimationFrame(updateComposerUI);
}
function clearComposerSyncTimers() {
state.composerSyncTimers.forEach(clearTimeout);
state.composerSyncTimers = [];
}
function scheduleComposerSync() {
clearComposerSyncTimers();
[0, 60, 150, 300, 600, 1200].forEach((ms) => {
const timer = setTimeout(() => {
parseComposerTarget();
scheduleUIUpdate();
}, ms);
state.composerSyncTimers.push(timer);
});
}
function isInternalUIButtonLabel(text) {
const t = String(text || "").trim();
return (
t === "Boost 回复" ||
t === "Boost 目标识别中" ||
t === "自己的帖子/话题不可 Boost" ||
t === "此帖不可 Boost" ||
t === "你已在本帖 Boost 过" ||
t === "Boost 发送中..." ||
t.startsWith("普通回复(") ||
t.startsWith("本帖 Boost 已满 ")
);
}
function resetSubmitButton(btn) {
const label = qs(".d-button-label", btn);
if (label) {
const currentText = (label.textContent || "").trim();
if (!label.dataset.origLabel) {
label.dataset.origLabel = currentText || "回复";
} else if (currentText && !isInternalUIButtonLabel(currentText)) {
label.dataset.origLabel = currentText;
}
label.textContent = label.dataset.origLabel || "回复";
}
btn.style.background = "";
btn.style.borderColor = "";
btn.dataset.useBoost = "0";
btn.dataset.boostBlocked = "0";
btn.title = "或按 Ctrl Enter";
}
function getNormalReplyHintTitle(block) {
return (
block?.normalReplyTitle ||
`${block?.title || "当前条件无法使用 Boost"};已自动切换为普通回复,普通回复通常至少需要 ${CONFIG.REPLY_MIN_LEN} 字`
);
}
function updateComposerUI() {
if (state.sending) return;
syncTopicChange();
const textarea = getComposerTextarea();
const primaryBtn = getComposerSubmitBtn();
if (!textarea || !primaryBtn) return;
resetSubmitButton(primaryBtn);
const raw = String(textarea.value || "").trim();
const len = countChars(raw);
if (!raw) return;
const target = getEffectiveTargetSync();
const label = qs(".d-button-label", primaryBtn);
if (len > CONFIG.BOOST_MAX_LEN && len < CONFIG.REPLY_MIN_LEN) {
if (label) {
label.textContent = `普通回复(${len}字,需≥${CONFIG.REPLY_MIN_LEN}字)`;
}
primaryBtn.style.background = COLORS.WARNING;
primaryBtn.style.borderColor = COLORS.WARNING;
primaryBtn.title = `已超过 Boost 上限 ${CONFIG.BOOST_MAX_LEN} 字;普通回复通常至少需要 ${CONFIG.REPLY_MIN_LEN} 字`;
return;
}
if (len >= CONFIG.REPLY_MIN_LEN) {
primaryBtn.title = "或按 Ctrl Enter";
return;
}
const block = getBoostBlockReasonSync(target);
if (block.blocked) {
if (block.fallbackToNormalReply) {
if (label) label.textContent = block.normalReplyText || `普通回复(需≥${CONFIG.REPLY_MIN_LEN}字)`;
primaryBtn.style.background = COLORS.WARNING;
primaryBtn.style.borderColor = COLORS.WARNING;
primaryBtn.title = getNormalReplyHintTitle(block);
return;
}
if (label) label.textContent = block.text || "Boost 目标识别中";
primaryBtn.style.background = block.code === "no-target" ? COLORS.PENDING : COLORS.ERROR;
primaryBtn.style.borderColor = block.code === "no-target" ? COLORS.PENDING : COLORS.ERROR;
primaryBtn.dataset.boostBlocked = "1";
primaryBtn.title = block.title || "当前无法使用 Boost";
return;
}
const stats = block.constraints || {};
const countDesc = Number.isFinite(stats.targetBoostCount)
? `,当前 ${stats.targetBoostCount}/${CONFIG.MAX_BOOSTS_PER_TARGET}`
: "";
if (label) label.textContent = "Boost 回复";
primaryBtn.style.background =
target.boostSupport === "unknown" ? COLORS.UNCERTAIN : COLORS.BOOST;
primaryBtn.style.borderColor =
target.boostSupport === "unknown" ? COLORS.UNCERTAIN : COLORS.BOOST;
primaryBtn.dataset.useBoost = "1";
primaryBtn.title =
target.boostSupport === "unknown"
? `当前 ${len}/${CONFIG.BOOST_MAX_LEN} 字,目标 #${target.postNumber || "?"},支持状态未确认,将尝试 Boost${countDesc}`
: `当前 ${len}/${CONFIG.BOOST_MAX_LEN} 字,将走 Boost(目标 #${target.postNumber || "?"})${countDesc}`;
}
function tryParseJSON(text) {
try {
return text ? JSON.parse(text) : null;
} catch {
return null;
}
}
function extractErrorMessage(payload, fallbackText = "") {
if (!payload) return String(fallbackText || "").trim();
if (typeof payload === "string") return payload.trim();
if (Array.isArray(payload)) return payload.map((x) => String(x)).join(";");
if (Array.isArray(payload.errors) && payload.errors.length) {
return payload.errors.map((x) => String(x)).join(";");
}
if (typeof payload.error === "string" && payload.error) return payload.error.trim();
if (typeof payload.message === "string" && payload.message) return payload.message.trim();
if (typeof payload.detail === "string" && payload.detail) return payload.detail.trim();
return String(fallbackText || "").trim();
}
function classifyBoostError(message) {
const s = normalizeErrorText(message);
if (!s) return "other";
if (
/already|duplicate|same user|one boost|only one|只能.*一次|只能.*1.*次|已经.*boost|已.*boost|重复/.test(s)
) {
return "duplicate";
}
if (
/limit|max|maximum|full|too many|reached|over|上限|已满|最多|50/.test(s)
) {
return "limit";
}
return "other";
}
async function postBoost(raw, target) {
const csrf = getCsrfToken();
if (!csrf) throw new Error("未找到 csrf-token");
const res = await fetch(`/discourse-boosts/posts/${target.postId}/boosts`, {
method: "POST",
credentials: "same-origin",
headers: {
"content-type": "application/x-www-form-urlencoded; charset=UTF-8",
"x-csrf-token": csrf,
"x-requested-with": "XMLHttpRequest",
"discourse-logged-in": "true",
"discourse-present": "true",
},
body: new URLSearchParams({ raw }).toString(),
});
const text = await res.text();
const payload = tryParseJSON(text);
if (!res.ok) {
const message = extractErrorMessage(payload, text || `HTTP ${res.status}`);
const err = new Error(message || `HTTP ${res.status}`);
err.code = classifyBoostError(message);
err.httpStatus = res.status;
err.payload = payload;
throw err;
}
return payload || text || { ok: true };
}
function closeComposer() {
const discard = getComposerDiscardBtn();
if (discard) {
discard.click();
return true;
}
const closeBtn =
qs("#reply-control .toggle-save-and-close") ||
qs("#reply-control .toggle-minimize");
if (closeBtn) {
closeBtn.click();
return true;
}
return false;
}
function shouldInterceptSubmitAsBoostSync() {
const textarea = getComposerTextarea();
const len = countChars(textarea?.value || "");
if (!len || len > CONFIG.BOOST_MAX_LEN) {
return false;
}
const target = getEffectiveTargetSync();
const block = getBoostBlockReasonSync(target);
if (block.blocked && block.fallbackToNormalReply) {
return false;
}
return true;
}
async function sendBoostFromComposer(e, { force = false } = {}) {
if (state.sending) {
stopEvent(e);
return false;
}
const textarea = getComposerTextarea();
const primaryBtn = getComposerSubmitBtn();
if (!textarea || !primaryBtn) return true;
const raw = String(textarea.value || "").trim();
const len = countChars(raw);
if (!raw) {
stopEvent(e);
showToast("内容不能为空", "error");
return false;
}
if (!force && len > CONFIG.BOOST_MAX_LEN) {
return true;
}
if (len > CONFIG.BOOST_MAX_LEN) {
stopEvent(e);
showToast(`Boost 最多支持 ${CONFIG.BOOST_MAX_LEN} 个字符,当前 ${len} 个`, "error");
return false;
}
stopEvent(e);
let target = await getEffectiveTargetForSubmit();
if (target?.mode === "topic" && (!target.postId || (!target.ownerUserId && !target.ownerUsername))) {
try {
const meta = await ensureTopicMeta(target.topicId || getTopicId());
target = mergeTarget(target, targetFromTopicMeta(meta, "submit-topic-meta"));
} catch (err) {
log("补全 topic meta 失败", err);
}
}
target = refreshTargetSync(target);
if (!target?.postId) {
showToast("未找到目标 post_id,无法使用 Boost", "error");
scheduleUIUpdate();
return false;
}
await ensureCurrentUser();
const block = getBoostBlockReasonSync(target);
if (block.blocked) {
if (block.fallbackToNormalReply) {
scheduleUIUpdate();
return true;
}
showToast(block.title || block.text || "当前无法使用 Boost", "error");
scheduleUIUpdate();
return false;
}
const label = qs(".d-button-label", primaryBtn);
const oldLabel =
label?.dataset.origLabel ||
label?.textContent ||
"回复";
state.sending = true;
primaryBtn.disabled = true;
if (label) label.textContent = "Boost 发送中...";
try {
await postBoost(raw, target);
markLocalBoostedTarget(target.topicId || getTopicId(), target.postId);
const constraints = getBoostConstraintStateSync(target);
if (
Number.isFinite(constraints.targetBoostCount) &&
constraints.targetBoostCount + 1 >= CONFIG.MAX_BOOSTS_PER_TARGET
) {
markLocalTargetFull(target.topicId || getTopicId(), target.postId);
}
textarea.value = "";
textarea.dispatchEvent(new Event("input", { bubbles: true }));
showToast(
`已通过 Boost 发送到 #${target.postNumber}(post_id: ${target.postId})`,
"success"
);
state.lastClickedTarget = null;
state.composerTarget = null;
if (CONFIG.AUTO_CLOSE_COMPOSER_AFTER_SUCCESS) {
await sleep(120);
closeComposer();
}
return false;
} catch (err) {
console.error(err);
if (err?.code === "duplicate") {
markLocalBoostedTarget(target.topicId || getTopicId(), target.postId);
scheduleUIUpdate();
return false;
}
if (err?.code === "limit") {
markLocalTargetFull(target.topicId || getTopicId(), target.postId);
scheduleUIUpdate();
return false;
}
showToast(`Boost 发送失败:${err.message}`, "error");
return false;
} finally {
state.sending = false;
primaryBtn.disabled = false;
if (label) label.textContent = oldLabel;
scheduleUIUpdate();
}
}
function primeReplyContext(target) {
if (!target) return;
if (target.mode === "topic") {
void ensureTopicMeta(target.topicId || getTopicId())
.then(() => {
if (state.lastClickedTarget?.mode === "topic") {
state.lastClickedTarget = refreshTargetSync(state.lastClickedTarget);
}
scheduleUIUpdate();
})
.catch((err) => {
log("primeReplyContext ensureTopicMeta 失败", err);
});
}
void ensureCurrentUser().then(() => scheduleUIUpdate());
}
document.addEventListener(
"click",
(e) => {
const el = asElement(e.target);
if (!el) return;
const submitBtn = el.closest("#reply-control .submit-panel button.create");
if (submitBtn) {
if (shouldInterceptSubmitAsBoostSync()) {
void sendBoostFromComposer(e, { force: false });
}
return;
}
const replyLauncher = el.closest(
[
".post-action-menu__reply.reply.create",
".timeline-footer-controls .reply-to-post",
"#topic-footer-buttons .topic-footer-button.create"
].join(",")
);
if (replyLauncher && !replyLauncher.closest("#reply-control")) {
const target = resolveTargetFromReplyLauncher(replyLauncher);
if (target) {
state.lastClickedTarget = target;
state.composerTarget = target;
primeReplyContext(target);
}
scheduleComposerSync();
}
},
true
);
document.addEventListener(
"keydown",
(e) => {
const el = asElement(e.target);
if (!el) return;
const inComposer = el.closest("#reply-control textarea.d-editor-input");
if (!inComposer) return;
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
if (shouldInterceptSubmitAsBoostSync()) {
void sendBoostFromComposer(e, { force: false });
}
}
},
true
);
document.addEventListener(
"input",
(e) => {
const el = asElement(e.target);
if (el?.matches?.("#reply-control textarea.d-editor-input")) {
scheduleUIUpdate();
}
},
true
);
document.addEventListener(
"focusin",
(e) => {
const el = asElement(e.target);
if (el?.matches?.("#reply-control textarea.d-editor-input")) {
scheduleUIUpdate();
}
},
true
);
new MutationObserver(() => {
clearTimeout(state.moTimer);
state.moTimer = setTimeout(() => {
syncTopicChange();
state.domVersion++;
resetBoostScanCache();
if (getComposer()) {
parseComposerTarget();
const target = getEffectiveTargetSync();
if (target?.mode === "topic") {
primeReplyContext(target);
}
} else {
state.composerTarget = null;
}
scheduleUIUpdate();
}, 80);
}).observe(document.body, {
childList: true,
subtree: true,
});
window.__ldBoostShortReply = {
config: CONFIG,
state,
getEffectiveTargetSync,
ensureTopicMeta,
ensureCurrentUser,
parseComposerTarget,
refreshTargetSync,
getBoostConstraintStateSync,
getBoostBlockReasonSync,
scanTopicBoostsSync,
shouldInterceptSubmitAsBoostSync,
};
void ensureCurrentUser();
scheduleUIUpdate();
})();
--【壹】--:
是一个话题的所有帖子 还是一个帖子单独的限制啊
--【贰】--:
是的 就是问佬的脚本有没有考虑这个问题特殊处理来着
--【叁】--:
每一条回复 我给你找找始皇发那个 等下哈
image910×272 35.1 KB
--【肆】--:
有点意思,佬可以上传一个到 greasyfork 或 GitHub 地址吗,方便更新
--【伍】--:
image300×168 52.7 KB
--【陆】--:
是的
PixPin_2026-04-08_15-59-09513×273 21.2 KB
--【柒】--:
不是字符,是一个主题只允许50个boost回应,50个人的小火箭
--【捌】--:
如果判定不够字数,回复会自动发boost吗??
--【玖】--:
我还不知道这回事
等我去改一改 看一下情况
--【拾】--:
我看了下 本身 超过50个 就没有小火箭的按钮了 所以就会是普通回复的样子
--【拾壹】--:
linux.do 自动切换 boost/回复方式
≤16字自动走 Boost;17~19字保留普通回复并提示;20字及以上正常回复。支持同帖单用户仅1次 Boost、每帖 50 Boost 上限检测,并在不可 Boost 时自动切换普通回复提示。
--【拾贰】--:
佬请问下,bosst限制50个,超过50个时候这个脚本回复的boost会怎么处理
--【拾叁】--:
大召唤术,召唤哈雷表情包,佬友们快来助我
--【拾肆】--:
好东西,mm再也不担心boost吞我评论了
--【拾伍】--:
来了来了
--【拾陆】--:
既有存在50个的话 应该是不能再发送了的(不会显示小火箭)
所以如果直接打在接口上的话 那肯定是报错的

