/packages/sdk/src/helpers/user-id-resolver.ts
var CACHE_TTL2 = Duration_exports.hours(24);
// src/core/sdk.ts
var initializedBduss = null;
var BdussMissingError = class extends Error {
constructor() {
super("尚未设置 BDUSS,请先在脚本菜单里填写");
this.name = "BdussMissingError";
}
};
function ensureClient() {
if (!hasBduss()) throw new BdussMissingError();
const trimmed = getSettings().bduss.trim();
if (trimmed === initializedBduss) return;
initClient(new TiebaClient({ bduss: trimmed }));
initializedBduss = trimmed;
}
function invalidateClient() {
initializedBduss = null;
}
// src/core/identity.ts
function hasEffect(value) {
return typeof value === "object" && value !== null && typeof value.pipe === "function";
}
function callSdkLoose(factory) {
ensureClient();
return requestQueue.run(() => {
const produced = factory();
return hasEffect(produced) ? Effect_exports.runPromise(produced) : Promise.resolve(produced);
});
}
function snapshotProfile(user, ref) {
return {
id: Number(user?.id ?? 0) || void 0,
uid: user?.tiebaUid ? String(user.tiebaUid) : void 0,
un: user?.name || void 0,
nickname: user?.nameShow || ref.nickname || void 0,
portrait: user?.portrait ? stripPortraitQuery(user.portrait) : ref.portrait ? stripPortraitQuery(ref.portrait) : void 0,
level: toNumber(user?.levelId) || void 0,
tbAge: user?.tbAge || void 0,
postNum: toNumber(user?.postNum) || void 0,
fansNum: toNumber(user?.fansNum) || void 0,
concernNum: toNumber(user?.concernNum) || void 0,
likeNum: toNumber(user?.myLikeNum) || void 0,
intro: user?.intro || void 0,
ip: user?.ipAddress || void 0,
// profile 本身就带着关注贴吧名单:隐藏关注贴吧的用户只能从这里和 panel 恢复,
// 顺手记进缓存,成分检测就不用再多发一次请求
likeForum: (user?.likeForum ?? []).map((forum) => forum?.forumName).filter(Boolean),
gender: toNumber(user?.sex) || toNumber(user?.gender) || void 0,
isBawu: toNumber(user?.isBawu) === 1,
bawuType: user?.bawuType || void 0,
vipLevel: toNumber(user?.vipInfo?.vLevel) || void 0
};
}
async function resolveIdentity(ref, force = false) {
const key = profileCacheKey(ref);
const cached4 = force ? null : readProfileCache(key);
if (cached4?.id) {
return {
id: cached4.id,
uid: cached4.uid,
un: cached4.un,
nickname: cached4.nickname,
portrait: cached4.portrait,
profile: cached4
};
}
let raw = null;
if (ref.userId) {
raw = await callSdkLoose(() => getProfile(Number(ref.userId)));
} else if (ref.portrait) {
raw = await callSdkLoose(() => getProfile(stripPortraitQuery(ref.portrait)));
} else if (ref.un) {
const info = await callSdkLoose(() => getUserInfo(ref.un));
if (info?.id) {
raw = await callSdkLoose(() => getProfile(Number(info.id)));
}
}
const user = raw?.user;
if (!user?.id) {
throw new Error("无法识别该用户(缺少贴吧号 / 用户 ID / 用户名)");
}
const snapshot = snapshotProfile(user, ref);
writeProfileCache(key, snapshot);
return {
id: Number(user.id),
uid: snapshot.uid,
un: snapshot.un,
nickname: snapshot.nickname,
portrait: snapshot.portrait,
profile: snapshot
};
}
// src/core/userForums.ts
var HIDDEN_FORUMS_NOTE = "该用户的关注贴吧未公开,以下数据从用户数据中还原,可能不完整。";
var NO_USERNAME_NOTE = "注意:该用户没有设置用户名,「吧内等级」无法查询。";
var NO_LEVEL_NOTE = "部分吧缺少等级信息。";
async function loadUserForums(id, profileForums = []) {
let items = [];
let primaryFailed = false;
try {
const forums = await callSdkLoose(() => getLikeForum(id));
items = (Array.isArray(forums) ? forums : []).map((forum) => ({
name: forum.name,
display: forum.name_show || forum.name,
level: toNumber(forum.level_id) || void 0,
levelName: forum.level_name || void 0,
slogan: forum.slogan || void 0
}));
} catch {
primaryFailed = true;
}
if (!items.length) {
const hidden = await callSdkLoose(() => getHiddenLikeForum(id));
const graded = Object.entries(
hidden?.grade ?? {}
).flatMap(
([level, group]) => (group?.forum_list ?? []).map((name) => ({
name,
display: name,
level: toNumber(level) || void 0
}))
);
const recovered = mergeForums(graded, hidden?.plain ?? [], profileForums);
return {
forums: recovered,
hidden: true,
recovered: recovered.length
};
}
const merged = mergeForums(items, [], profileForums);
return {
forums: merged,
hidden: merged.length > items.length,
recovered: merged.length - items.length
};
}
function mergeForums(base, extraNames, profileNames) {
const seen = new Set(base.map((item) => item.name));
const out = [...base];
for (const name of [...extraNames, ...profileNames]) {
const clean = String(name ?? "").trim();
if (!clean || seen.has(clean)) continue;
seen.add(clean);
out.push({ name: clean, display: clean });
}
return out;
}
// src/core/userPost.ts
var ENDPOINT = "/c/u/feed/userpost?cmd=303002";
var CLIENT_VERSION_OLD2 = "8.9.8.5";
function buildRequest(uid, isThread, pn) {
const message = UserPostReqIdl.fromPartial({
data: {
userId: String(uid),
isThread,
needContent: 1,
pn,
common: { ClientType: 2, ClientVersion: CLIENT_VERSION_OLD2 }
}
});
return UserPostReqIdl.encode(message).finish();
}
async function fetchRaw(uid, isThread, pn) {
ensureClient();
return requestQueue.run(async () => {
const client = getClient();
const buffer = await Effect_exports.runPromise(
client.postProtobuf(ENDPOINT, buildRequest(uid, isThread, pn))
);
const decoded = UserPostResIdl.decode(buffer);
const errorno = decoded?.error?.errorno;
if (errorno) {
throw new TiebaServerError(errorno, decoded?.error?.errmsg ?? "");
}
return {
postList: rememberForumNames(decoded?.data?.postList ?? []),
hidden: toNumber(decoded?.data?.hidePost) > 0
};
});
}
function rememberForumNames(items) {
for (const item of items) {
const id = String(item?.forumId ?? "");
const name = String(item?.forumName ?? "").trim();
if (!id || id === "0" || !name) continue;
const entry = forumNames.get(id);
if (!entry || !entry.name) forumNames.set(id, { name, ts: Date.now() });
}
return items;
}
function textOf(contents) {
if (!Array.isArray(contents)) return "";
return contents.map((item) => item?.text ?? "").join("").replace(/\s+/g, " ").trim();
}
async function loadTopicPage(uid, page) {
const raw = await fetchRaw(uid, 1, page);
return {
hidden: raw.hidden,
rows: raw.postList.map((item) => ({
kind: "topic",
threadId: String(item.threadId ?? ""),
postId: String(item.postId ?? ""),
title: item.title || "",
preview: textOf(item.firstPostContent),
forumName: item.forumName || "",
createTime: toNumber(item.createTime)
}))
};
}
async function loadReplyPage(uid, page) {
const raw = await fetchRaw(uid, 0, page);
const result = processUserPosts(raw.postList, false);
const posts = await Effect_exports.runPromise(result);
const names = await resolveForumNames(
raw.postList.map((item) => String(item?.forumId ?? ""))
);
return {
hidden: raw.hidden,
rows: (posts ?? []).map((post) => ({
kind: post.affiliated ? "sub" : "reply",
threadId: String(post.threadId ?? ""),
// 必须用**正文级**的 pid(cid),不能用 PostInfoList.postId:
// 一条 feed 记录可以带多条正文(他在同一个帖子里连发几楼),
// 记录级 pid 是这几行共用的,用它去查楼层会让同一帖的每一行都查回同一个楼层。
// 实测(2026-09-27,uid 874540992):一条记录含 3 条正文,
// 记录级 pid 对应 112 楼,而第二、三条正文其实在 111 / 其它楼。
postId: String(post.cid || post.postId || ""),
title: post.title || post.content || "",
preview: post.content || "",
forumName: names.get(String(post.forumId ?? "")) || post.forumName || "",
createTime: toNumber(post.createTime),
replyTo: post.replyTo || void 0
}))
};
}
var forumNames = /* @__PURE__ */ new Map();
var FORUM_NAME_CACHE_MAX = 500;
var FORUM_NAME_EMPTY_TTL_MS = 5 * 60 * 1e3;
function isFresh2(entry) {
if (!entry) return false;
if (entry.name) return true;
return Date.now() - entry.ts < FORUM_NAME_EMPTY_TTL_MS;
}
var FORUM_NAME_CONCURRENCY = 3;
var FORUM_NAME_MIN_GAP_MS = 150;
var lastForumNameLookupAt = 0;
async function waitForumNameSlot() {
const configured = Number(getSettings().minIntervalMs);
const gap = Math.max(
FORUM_NAME_MIN_GAP_MS,
Number.isFinite(configured) && configured > 0 ? Math.floor(configured / FORUM_NAME_CONCURRENCY) : FORUM_NAME_MIN_GAP_MS
);
const now = Date.now();
const wait = Math.max(0, lastForumNameLookupAt + gap - now);
lastForumNameLookupAt = Math.max(
now,
lastForumNameLookupAt + gap
);
if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait));
}
var forumNameInFlight = /* @__PURE__ */ new Map();
async function lookupForumName(id) {
const inFlight2 = forumNameInFlight.get(id);
if (inFlight2) return inFlight2;
ensureClient();
const task = (async () => {
try {
return String(
await Effect_exports.runPromise(getForumName(Number(id))) ?? ""
).trim();
} catch {
return "";
}
})();
forumNameInFlight.set(id, task);
try {
return await task;
} finally {
forumNameInFlight.delete(id);
}
}
async function resolveForumNames(ids3) {
const wanted = Array.from(
new Set(ids3.filter((id) => id && id !== "0"))
).filter((id) => !isFresh2(forumNames.get(id)));
let cursor = 0;
const worker = async () => {
while (cursor < wanted.length) {
const id = wanted[cursor];
cursor += 1;
if (isFresh2(forumNames.get(id))) continue;
await waitForumNameSlot();
if (isFresh2(forumNames.get(id))) continue;
let name = "";
for (let attempt = 0; attempt < 2 && !name; attempt += 1) {
name = await lookupForumName(id);
}
forumNames.set(id, { name, ts: Date.now() });
}
};
await Promise.all(
Array.from(
{ length: Math.min(FORUM_NAME_CONCURRENCY, wanted.length) },
worker
)
);
if (forumNames.size > FORUM_NAME_CACHE_MAX) {
const stale = Array.from(forumNames.entries()).sort((a, b) => a[1].ts - b[1].ts).slice(0, forumNames.size - FORUM_NAME_CACHE_MAX);
for (const [key] of stale) forumNames.delete(key);
}
const out = /* @__PURE__ */ new Map();
for (const id of ids3) {
const entry = forumNames.get(id);
if (entry) out.set(id, entry.name);
}
return out;
}
async function loadTopicRows(uid, page) {
return (await loadTopicPage(uid, page)).rows;
}
async function loadReplyRows(uid, page) {
return (await loadReplyPage(uid, page)).rows;
}
// src/core/errno.ts
var KNOWN_ERRNO = {
3e5: {
hint: "贴吧这次没有把数据交出来;同一个请求重试有时能成功",
observed: "HANDOFF §5 #18 / #26:实测出现在取某个用户的发帖记录时(同一 uid 有时正常、有时回 300000)"
}
};
var UNKNOWN_MAX = 20;
var unknownErrno = [];
function recordUnknownErrno(code, msg, where) {
const line = `${(/* @__PURE__ */ new Date()).toISOString()} ${where} → errno=${code}${msg ? ` errmsg=${JSON.stringify(msg)}` : ""}`;
unknownErrno.push(line);
if (unknownErrno.length > UNKNOWN_MAX) {
unknownErrno.splice(0, unknownErrno.length - UNKNOWN_MAX);
}
}
function recentUnknownErrno() {
return unknownErrno.slice();
}
function formatServerError(code, msg) {
const numeric = Number(code);
const note = Number.isFinite(numeric) ? KNOWN_ERRNO[numeric] : void 0;
const tail = msg && String(msg).trim() ? `:${String(msg).trim()}` : "(贴吧没有给出说明文字)";
if (note) return `贴吧接口返回错误 ${code}${tail} —— ${note.hint}`;
return `贴吧接口返回错误 ${code}${tail}(未收录的错误码,已记进诊断日志)`;
}
function asServerError(error) {
if (!error || typeof error !== "object") return null;
const code = error.code;
if (typeof code !== "number" || !Number.isFinite(code)) return null;
const msg = error.msg;
return { code, msg: typeof msg === "string" ? msg : "" };
}
function describeRequestError(error, where = "请求") {
const serverError = asServerError(error);
if (serverError) {
if (!(serverError.code in KNOWN_ERRNO)) {
recordUnknownErrno(serverError.code, serverError.msg, where);
}
return formatServerError(serverError.code, serverError.msg);
}
const kind = error?.kind;
if (kind === "timeout") {
return "请求超时:贴吧接口 30 秒内没有回应(网络慢或被限流,稍后再试)";
}
if (kind === "network") {
return "网络错误:连不上贴吧接口(检查网络或代理,必要时重新登录贴吧)";
}
if (kind === "abort") return "请求已取消";
if (kind === "unavailable") {
return error instanceof Error ? error.message : "当前环境不支持 GM_xmlhttpRequest,请检查脚本管理器";
}
if (error instanceof Error) return error.message;
if (error === void 0 || error === null) return "未知错误";
return String(error);
}
// src/core/compositionDetect.ts
function normalizePages(value) {
const num = Number(value);
if (!Number.isFinite(num) || num < 1) return 1;
return Math.min(Math.floor(num), 10);
}
async function detectComposition(target, rules, options = {}) {
const pages = normalizePages(options.pages ?? 1);
const stat = {
forums: 0,
forumsRecovered: 0,
topics: 0,
replies: 0,
topicPages: 0,
replyPages: 0,
hidden: false,
failed: [],
verdict: { insufficient: false, note: "" }
};
const posts = [];
let forums = [];
const needForums = rules.some((rule) => rule.forumKeywords.length);
const needPosts = rules.some(
(rule) => rule.postKeywords.length || rule.postForumKeywords.length
);
if (needForums) {
try {
const loaded = await loadUserForums(
target.id,
target.profileForums ?? []
);
forums = loaded.forums.map((forum) => forum.name);
stat.forums = forums.length;
stat.forumsRecovered = loaded.recovered;
} catch (error) {
stat.failed.push(`关注的吧:${describeRequestError(error, "成分·关注的吧")}`);
}
}
if (needPosts) {
for (let page = 1; page <= pages; page += 1) {
try {
const result = await loadTopicPage(target.id, page);
stat.topicPages = page;
stat.topics += result.rows.length;
stat.hidden = stat.hidden || result.hidden;
for (const row of result.rows) {
posts.push({
title: row.title,
preview: row.preview,
kind: "topic",
forumName: row.forumName,
// 时间 / 帖子 id 一并带上:面板要按时间倒序,并跳到那条帖子
createTime: row.createTime,
threadId: row.threadId,
postId: row.postId
});
}
if (!result.rows.length) break;
} catch (error) {
stat.failed.push(`主题帖:${describeRequestError(error, "成分·主题帖")}`);
break;
}
}
for (let page = 1; page <= pages; page += 1) {
try {
const result = await loadReplyPage(target.id, page);
stat.replyPages = page;
stat.replies += result.rows.length;
stat.hidden = stat.hidden || result.hidden;
for (const row of result.rows) {
posts.push({
title: row.title,
preview: row.preview,
kind: row.kind === "sub" ? "sub" : "reply",
forumName: row.forumName,
createTime: row.createTime,
threadId: row.threadId,
postId: row.postId,
replyTo: row.replyTo
});
}
if (!result.rows.length) break;
} catch (error) {
stat.failed.push(`回复:${describeRequestError(error, "成分·回复")}`);
break;
}
}
}
const hits = matchComposition(
{ uid: target.uid, userId: target.id, forums, posts },
rules,
// 匹配方式来自设置:不带 `=` 前缀的关键词按它走(单个词写成 `=xxx` 一定精确)
{ mode: getSettings().compositionKeywordMatch }
);
stat.verdict = compositionVerdict({
hits: hits.length,
failed: stat.failed,
hidden: stat.hidden,
needForums,
needPosts,
forums: stat.forums,
posts: stat.topics + stat.replies
});
return { hits, stat };
}
// src/page/badges.ts
var BADGES_CLASS = "tb-eztb-badges";
var BADGE_CLASS = "tb-eztb-badge";
var MAX_VISIBLE = 3;
function availableSlack(button) {
const row = button.closest(".head-line");
if (!row) return Number.POSITIVE_INFINITY;
const spacer = row.querySelector(".head-spacer");
if (!spacer) return Number.POSITIVE_INFINITY;
return spacer.getBoundingClientRect().width - 6;
}
var badgeRefs = /* @__PURE__ */ new WeakMap();
function getBadgeRef(element) {
return badgeRefs.get(element);
}
function makeBadge(hit, ref) {
const badge = document.createElement("span");
badge.className = `${BADGE_CLASS}${hit.sure ? "" : " tb-eztb-badge-unsure"}`;
badge.textContent = hit.rule.name;
badge.style.setProperty("--tb-eztb-badge-hue", String(badgeHue(hit.rule.name)));
badge.title = hitsTooltip(hit);
badge.tabIndex = 0;
badge.setAttribute("role", "button");
badgeRefs.set(badge, ref);
return badge;
}
function hitsTooltip(hit) {
return [
`成分:${hit.rule.name}`,
`标记原因:${hit.summary}`,
hit.sure ? "" : "(证据较弱,可能是误判)",
"点击查看详情"
].filter(Boolean).join("\n");
}
function clearBadges(button) {
const next4 = button.nextElementSibling;
if (next4?.classList?.contains(BADGES_CLASS)) next4.remove();
}
function renderBadges(button, hits, ref) {
clearBadges(button);
if (!hits.length || !button.isConnected) return;
const container = document.createElement("span");
container.className = BADGES_CLASS;
container.dataset.count = String(hits.length);
button.insertAdjacentElement("afterend", container);
const budget = availableSlack(button);
const fits = () => container.getBoundingClientRect().width <= budget;
const tryAppend = (element) => {
container.appendChild(element);
if (fits()) return true;
container.removeChild(element);
return false;
};
const shown = [];
for (const hit of hits.slice(0, MAX_VISIBLE)) {
if (!tryAppend(makeBadge(hit, ref))) break;
shown.push(hit);
}
while (shown.length < hits.length) {
const more = document.createElement("span");
more.className = `${BADGE_CLASS} tb-eztb-badge-more`;
more.textContent = `+${hits.length - shown.length}`;
more.title = hits.slice(shown.length).map((hit) => `${hit.rule.name}:${hit.summary}`).join("\n");
more.tabIndex = 0;
more.setAttribute("role", "button");
badgeRefs.set(more, ref);
if (tryAppend(more)) break;
const last3 = shown.length ? container.lastElementChild : null;
if (!last3) break;
container.removeChild(last3);
shown.pop();
}
if (!container.childElementCount) {
const dot = document.createElement("span");
dot.className = `${BADGE_CLASS} tb-eztb-badge-dot`;
dot.textContent = "●";
dot.style.setProperty(
"--tb-eztb-badge-hue",
String(badgeHue(hits[0].rule.name))
);
dot.title = hits.map(hitsTooltip).join("\n\n");
dot.tabIndex = 0;
dot.setAttribute("role", "button");
badgeRefs.set(dot, ref);
container.appendChild(dot);
}
}
var INSUFFICIENT_TEXT = "证据不足";
function renderInsufficientBadge(button, ref, note) {
clearBadges(button);
if (!button.isConnected) return;
const container = document.createElement("span");
container.className = BADGES_CLASS;
container.dataset.count = "0";
container.dataset.insufficient = "1";
button.insertAdjacentElement("afterend", container);
const tooltip = `${note}
(没有命中任何规则,但本次的数据不足以判定)
点击查看详情`;
const badge = document.createElement("span");
badge.className = `${BADGE_CLASS} tb-eztb-badge-insufficient`;
badge.textContent = INSUFFICIENT_TEXT;
badge.title = tooltip;
badge.tabIndex = 0;
badge.setAttribute("role", "button");
badgeRefs.set(badge, ref);
container.appendChild(badge);
if (container.getBoundingClientRect().width > availableSlack(button)) {
container.removeChild(badge);
const dot = document.createElement("span");
dot.className = `${BADGE_CLASS} tb-eztb-badge-dot tb-eztb-badge-insufficient`;
dot.textContent = "●";
dot.title = tooltip;
dot.tabIndex = 0;
dot.setAttribute("role", "button");
badgeRefs.set(dot, ref);
container.appendChild(dot);
}
}
// src/features/compositionScan.ts
var emptyStat = () => ({
forums: 0,
forumsRecovered: 0,
topics: 0,
replies: 0,
topicPages: 0,
replyPages: 0,
hidden: false,
failed: [],
verdict: { insufficient: false, note: "" }
});
var buttonsByKey = /* @__PURE__ */ new Map();
var refsByKey = /* @__PURE__ */ new Map();
var checkedKeys = /* @__PURE__ */ new Set();
var pending3 = /* @__PURE__ */ new Map();
var draining = false;
var noBdussWarned = false;
function rulesText() {
return getSettings().compositionRules;
}
function rulesHash() {
return hashRules(rulesText());
}
function hasRules() {
return parseRules(rulesText()).length > 0;
}
function maxPerPage() {
const value = Number(getSettings().compositionMaxPerPage);
return Number.isFinite(value) && value > 0 ? Math.floor(value) : 20;
}
function applyToButtons(key, hits, stat) {
const ref = refsByKey.get(key);
const buttons = buttonsByKey.get(key);
if (!ref || !buttons) return;
for (const button of buttons) {
if (!button.isConnected) continue;
if (hits.length) {
renderBadges(button, hits, ref);
} else if (stat?.verdict?.insufficient) {
renderInsufficientBadge(button, ref, stat.verdict.note);
} else {
clearBadges(button);
}
}
}
async function checkUser(ref, options = {}) {
const key = compositionCacheKey(ref);
if (!hasRules()) {
return { hits: [], stat: emptyStat(), fromCache: false, noRules: true };
}
if (!hasBduss()) {
return { hits: [], stat: emptyStat(), fromCache: false, noBduss: true };
}
if (!options.force && key) {
const cached4 = readCompositionCache(key, rulesHash());
if (cached4) {
applyToButtons(key, cached4.hits, cached4.stat);
return { hits: cached4.hits, stat: cached4.stat, fromCache: true };
}
}
const rules = parseRules(rulesText());
const identity4 = await resolveIdentity(ref, options.force === true);
const detection = await detectComposition(
{
id: identity4.id,
uid: identity4.uid,
// profile 里带着关注贴吧名单,交给检测去补齐 / 恢复隐藏的部分
profileForums: identity4.profile?.likeForum ?? []
},
rules,
{ pages: getSettings().compositionPages }
);
if (key) {
writeCompositionCache(key, {
rulesHash: rulesHash(),
hits: detection.hits,
stat: detection.stat
});
checkedKeys.add(key);
applyToButtons(key, detection.hits, detection.stat);
}
return { hits: detection.hits, stat: detection.stat, fromCache: false };
}
function enqueue(key, ref, force = false) {
if (pending3.has(key) || checkedKeys.has(key)) return;
if (checkedKeys.size + pending3.size >= maxPerPage()) return;
refsByKey.set(key, ref);
if (force) pending3.set(key, true);
else if (!pending3.has(key)) pending3.set(key, false);
void drain();
}
async function drain() {
if (draining) return;
draining = true;
try {
while (pending3.size) {
const key = pending3.keys().next().value;
const force = pending3.get(key) === true;
pending3.delete(key);
const ref = refsByKey.get(key);
if (!ref) continue;
if (checkedKeys.has(key)) continue;
checkedKeys.add(key);
try {
await checkUser(ref, { force });
} catch (error) {
log.warn("成分检测失败:", error);
}
}
} finally {
draining = false;
}
}
function registerUserForComposition(ref, button) {
const key = compositionCacheKey(ref);
if (!key) return;
let buttons = buttonsByKey.get(key);
if (!buttons) {
buttons = /* @__PURE__ */ new Set();
buttonsByKey.set(key, buttons);
}
buttons.add(button);
if (!refsByKey.has(key)) refsByKey.set(key, ref);
if (!hasRules()) return;
if (!hasBduss()) {
if (!noBdussWarned) {
noBdussWarned = true;
log.warn("还没设置 BDUSS,成分检测不会工作");
}
return;
}
const cached4 = readCompositionCache(key, rulesHash());
if (cached4) {
applyToButtons(key, cached4.hits);
return;
}
enqueue(key, ref);
}
function rescanPage() {
dropCompositionMemory();
checkedKeys.clear();
pending3.clear();
for (const [key, ref] of refsByKey) {
for (const button of buttonsByKey.get(key) ?? []) clearBadges(button);
enqueue(key, ref, true);
}
}
// src/core/bdussCheck.ts
var BDUSS_PROBE_URL = "https://tieba.baidu.com/f/user/json_userinfo";
var INVALID_PROBE = "eztb-invalid-probe";
function pickAccountName(payload) {
if (!payload || typeof payload !== "object") return void 0;
const record = payload;
const containers = [
record,
record.data,
record.user,
record.userinfo
];
for (const container of containers) {
if (!container || typeof container !== "object") continue;
for (const key of ["user_name", "username", "name_show", "name"]) {
const value = container[key];
if (typeof value === "string" && value.trim()) return value.trim();
}
}
return void 0;
}
async function whoAmI(bduss) {
const response = await requestQueue.run(
() => gmRequest({
method: "GET",
url: BDUSS_PROBE_URL,
headers: {
...bduss ? { Cookie: `BDUSS=${bduss}` } : {},
Referer: "https://tieba.baidu.com/"
},
responseType: "text",
timeout: 15e3
})
);
const raw = response.response;
const text = (typeof raw === "string" ? raw : raw === void 0 || raw === null ? "" : String(raw)).trim();
if (!text || text === "null") return { loggedIn: false };
let parsed;
try {
parsed = JSON.parse(text);
} catch {
return { loggedIn: false };
}
if (!parsed || typeof parsed !== "object") return { loggedIn: false };
return { loggedIn: true, account: pickAccountName(parsed) };
}
async function checkBduss(bduss) {
const value = bduss.trim();
const checkedAt = Date.now();
if (!value) {
return {
status: "empty",
message: "还没有填 BDUSS,先粘贴凭据再校验。",
checkedAt
};
}
let browser;
let probe;
try {
browser = await whoAmI(null);
probe = await whoAmI(INVALID_PROBE);
} catch (error) {
return {
status: "error",
message: `校验没能完成:${error instanceof Error ? error.message : String(error)}`,
checkedAt
};
}
const headerIgnored = probe.loggedIn;
let real;
try {
real = await whoAmI(value);
} catch (error) {
return {
status: "error",
message: `校验没能完成:${error instanceof Error ? error.message : String(error)}`,
checkedAt
};
}
if (real.loggedIn && !headerIgnored) {
return {
status: "ok",
account: real.account,
message: `校验通过:这份 BDUSS 当前有效${real.account ? `(账号「${real.account}」)` : ""}。`,
checkedAt
};
}
if (headerIgnored) {
const browserName = browser.account ? `「${browser.account}」` : "(没认出来是谁)";
return {
status: "unknown",
account: browser.account,
message: `无法确认你粘贴的这份 BDUSS:这个脚本管理器不接受脚本设置的 Cookie 头,请求用的是浏览器自己的登录状态(当前登录${browserName})。请改用能直接查询的方式确认(比如随便点一个用户的「查询」,能出数据就说明凭据没问题)。`,
checkedAt
};
}
if (browser.loggedIn) {
return {
status: "invalid",
message: "校验没通过:贴吧没有认下这份 BDUSS(无效或已过期)。请重新复制一次;注意别把前后的空格或不相关的 cookie 一起粘进来。",
checkedAt
};
}
return {
status: "invalid",
message: "校验没通过:这个端点没有把凭据认下来。可能是这份 BDUSS 无效/已过期,也可能是脚本管理器没有发送脚本设置的 Cookie 头、且浏览器当前未登录贴吧。两种情况下查询都会失败,建议先按 F12 → Application → Cookies 重新复制一份。",
checkedAt
};
}
// src/core/forumLevel.ts
var CACHE_KEY3 = "tbEztbToolboxForumLevelV1";
var CACHE_MAX3 = 500;
var MAX_THREADS = 3;
var cache4 = createKvCache({
storageKey: CACHE_KEY3,
max: CACHE_MAX3,
validate: (entry) => entry.level > 0
});
var cacheKey = (targetId, forumName) => `${targetId}:${forumName}`;
function readForumLevelCache(targetId, forumName) {
const hit = cache4.read(cacheKey(targetId, forumName));
return hit ? hit.level : null;
}
function writeForumLevelCache(targetId, forumName, level) {
cache4.write(cacheKey(targetId, forumName), { level });
}
function clearForumLevelCache() {
cache4.clear();
}
async function findThreads(targetId, forumName) {
const candidates = [];
const collect = (rows, via) => {
for (const row of rows) {
if (row.forumName !== forumName || !row.threadId) continue;
if (candidates.some((item) => item.tid === row.threadId)) continue;
candidates.push({ tid: row.threadId, via });
}
};
try {
collect(await loadTopicRows(targetId, 1), "topic");
} catch {
}
if (!candidates.length) {
try {
collect(await loadReplyRows(targetId, 1), "reply");
} catch {
}
}
return candidates;
}
async function fetchUserForumLevel(targetId, forumName) {
const cached4 = readForumLevelCache(targetId, forumName);
if (cached4) return { level: cached4, via: "cache" };
const candidates = await findThreads(targetId, forumName);
if (!candidates.length) {
return {
reason: `最近一页帖子里没有他在「${forumName}」的帖子,读不到他在这个吧的等级`
};
}
let lastError = "";
for (const candidate of candidates.slice(0, MAX_THREADS)) {
try {
const page = await callSdkLoose(
() => getPosts(Number(candidate.tid), 1, { rn: 30 })
);
const users = page?.userList ?? [];
const me = users.find((user) => String(user?.id) === String(targetId));
const level = toNumber(me?.levelId);
if (level > 0) {
writeForumLevelCache(targetId, forumName, level);
return { level, via: candidate.via };
}
} catch (error) {
lastError = describeRequestError(error, `查等级·${forumName}`);
}
}
return {
reason: lastError || `在他这几个帖子的第 1 页里没找到他的楼层(回复可能在后几页),读不到等级`
};
}
// src/core/replyFloor.ts
var CACHE_KEY4 = "tbEztbToolboxReplyFloorV1";
var CACHE_MAX4 = 800;
var cache5 = createKvCache({
storageKey: CACHE_KEY4,
max: CACHE_MAX4,
validate: (entry) => entry.floor > 0
});
var cacheKey2 = (threadId, postId) => `${threadId}:${postId}`;
function readReplyFloorCache(threadId, postId) {
return cache5.read(cacheKey2(threadId, postId));
}
function writeReplyFloorCache(threadId, postId, floor, excerpt) {
return cache5.write(cacheKey2(threadId, postId), { floor, excerpt });
}
function clearReplyFloorCache() {
cache5.clear();
}
function contentText(contents) {
if (!Array.isArray(contents)) return "";
return contents.map((item) => item?.text ?? "").join("").replace(/\s+/g, " ").trim();
}
async function fetchReplyFloor(threadId, postId) {
const tid = toNumber(threadId);
const pid = toNumber(postId);
if (!tid || !pid) {
return { reason: "这条记录没有楼层可查(缺 threadId / postId)" };
}
const cached4 = readReplyFloorCache(threadId, postId);
if (cached4) {
return {
floor: cached4.floor,
excerpt: cached4.excerpt,
via: "cache"
};
}
try {
const data = await callSdkLoose(
() => getComments({ tid, pid, pn: 1 })
);
const floor = toNumber(data?.post?.floor);
if (!(floor > 0)) {
return {
reason: "贴吧没有返回楼层号(这一楼可能已被删除)"
};
}
const entry = writeReplyFloorCache(
threadId,
postId,
floor,
contentText(data?.post?.content)
);
return { floor: entry.floor, excerpt: entry.excerpt, via: "request" };
} catch (error) {
return { reason: describeRequestError(error, "查楼层") };
}
}
// src/core/activityRule.ts
var UNKNOWN_FORUM = "未知贴吧";
function countPostsByForum(rows) {
const out = {};
for (const row of rows) {
const name = String(row.forumName ?? "").trim() || UNKNOWN_FORUM;
out[name] = (out[name] ?? 0) + 1;
}
return out;
}
function findSignInForums(forums, byForum, levelThreshold) {
const threshold2 = Number.isFinite(levelThreshold) ? levelThreshold : 6;
const out = [];
for (const forum of forums) {
const level = Number(forum.level ?? 0);
if (!(level >= threshold2)) continue;
const posts = Number(byForum[forum.name] ?? 0);
if (posts > 0) continue;
out.push({ forumName: forum.name, level, posts });
}
return out.sort((a, b) => b.level - a.level);
}
function signInSummary(candidates, levelThreshold, sample) {
const sampleText = `样本:最近一页发帖(主题帖 ${sample.topics} 条 + 回复 ${sample.replies} 条)`;
if (!candidates.length) {
return `没有发现「等级 ≥ ${levelThreshold} 但最近没在该吧发言」的吧。判定条件:吧内等级 ≥ ${levelThreshold},且最近这批帖子里在该吧 0 条发言。${sampleText}。`;
}
const names = candidates.slice(0, 5).map((item) => `${item.forumName}(Lv.${item.level})`).join("、");
return `疑似只签到 ${candidates.length} 个吧:${names}${candidates.length > 5 ? " 等" : ""}。判定条件:吧内等级 ≥ ${levelThreshold},且最近这批帖子里在该吧 0 条发言。${sampleText}——所以也可能是"以前发言多、最近没来",请结合其它信息判断。`;
}
// src/core/forumActivity.ts
var CACHE_KEY5 = "tbEztbToolboxForumActivityV1";
var CACHE_MAX5 = 300;
var TTL_MS = 24 * 60 * 60 * 1e3;
var cache6 = createKvCache({
storageKey: CACHE_KEY5,
max: CACHE_MAX5,
ttlMs: TTL_MS,
// 对方的发帖记录被隐藏时活跃度无从判断,这种结果不算数
validate: (entry) => !entry.hidden
});
function readForumActivityCache(uid) {
return cache6.read(String(uid));
}
function writeForumActivityCache(uid, value) {
cache6.write(String(uid), value);
}
function clearForumActivityCache() {
cache6.clear();
}
async function loadForumActivity(uid, force = false) {
if (!force) {
const cached4 = readForumActivityCache(uid);
if (cached4) return cached4;
}
const failed = [];
const rows = [];
let topics = 0;
let replies = 0;
let hidden = false;
try {
const page = await loadTopicPage(uid, 1);
topics = page.rows.length;
hidden = hidden || page.hidden;
rows.push(...page.rows);
} catch (error) {
failed.push(`主题帖:${describeRequestError(error, "签到检测·主题帖")}`);
}
try {
const page = await loadReplyPage(uid, 1);
replies = page.rows.length;
hidden = hidden || page.hidden;
rows.push(...page.rows);
} catch (error) {
failed.push(`回复:${describeRequestError(error, "签到检测·回复")}`);
}
const value = {
byForum: countPostsByForum(rows),
topics,
replies,
hidden,
failed,
ts: Date.now()
};
if (!failed.length && !hidden) writeForumActivityCache(uid, value);
return value;
}
// src/ui/modal.ts
var FOCUSABLE_SELECTOR = 'button,[href],input,select,textarea,[tabindex]:not([tabindex="-1"])';
var activeClose = null;
function openDialog(options) {
closeOpenDialog();
const root = document.createElement("div");
root.className = "tb-eztb-mask";
const avatar = options.avatarUrl ? `
` : "";
const tabs = options.tabs?.length ? `${options.tabs.map(
(tab) => ``
).join("")}
` : "";
root.innerHTML = `` + avatar + `
${escapeHtml(options.title)}
${options.subtitleHtml ?? ""}
` + tabs + `
`;
document.body.appendChild(root);
const dialogEl = root.querySelector(".tb-eztb-dialog");
const body = root.querySelector(".tb-eztb-body");
const footer = root.querySelector(".tb-eztb-foot");
const titleEl = root.querySelector(".tb-eztb-title");
const subEl = root.querySelector(".tb-eztb-sub");
const previouslyFocused = document.activeElement instanceof HTMLElement ? document.activeElement : null;
let disposed = false;
const close2 = () => {
if (disposed) return;
disposed = true;
if (activeClose === close2) activeClose = null;
root.remove();
document.removeEventListener("keydown", onKeydown, true);
if (previouslyFocused?.isConnected) previouslyFocused.focus();
try {
options.onClose?.();
} catch (error) {
log.warn("弹窗关闭回调抛错:", error);
}
};
const onKeydown = (event) => {
if (event.key === "Escape") {
event.stopPropagation();
close2();
return;
}
if (event.key !== "Tab") return;
const items = Array.from(
dialogEl.querySelectorAll(FOCUSABLE_SELECTOR)
).filter((el) => !el.hasAttribute("disabled") && el.offsetParent !== null);
if (!items.length) return;
const first2 = items[0];
const last3 = items[items.length - 1];
const current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
const inside = current !== null && items.includes(current);
if (event.shiftKey) {
if (!inside || current === first2) {
event.preventDefault();
last3.focus();
}
return;
}
if (!inside || current === last3) {
event.preventDefault();
first2.focus();
}
};
document.addEventListener("keydown", onKeydown, true);
activeClose = close2;
dialogEl.tabIndex = -1;
dialogEl.focus({ preventScroll: true });
root.querySelector(".tb-eztb-close")?.addEventListener("click", () => close2());
root.addEventListener("click", (event) => {
if (event.target === root) close2();
});
const tabButtons = Array.from(
root.querySelectorAll(".tb-eztb-tab")
);
const activateTab = (id) => {
for (const button of tabButtons) {
button.classList.toggle("active", button.dataset.tab === id);
}
};
for (const button of tabButtons) {
button.addEventListener("click", () => {
const id = button.dataset.tab;
if (!id) return;
activateTab(id);
options.onTab?.(id);
});
}
if (options.activeTab) activateTab(options.activeTab);
return {
root,
body,
footer,
close: close2,
setTitle: (text) => {
titleEl.textContent = text;
dialogEl.setAttribute("aria-label", text);
},
setSubtitle: (html) => {
subEl.innerHTML = html;
},
setAvatar: (url) => {
const img = root.querySelector(".tb-eztb-avatar");
if (img && url) img.src = url;
},
activateTab
};
}
function closeOpenDialog() {
activeClose?.();
document.querySelector(".tb-eztb-mask")?.remove();
}
// src/features/settingsDialog.ts
function openSettingsDialog(options = {}) {
const current = getSettings();
const dialog = openDialog({
title: "eztb 工具箱设置",
subtitleHtml: "数据直连贴吧接口,不经过任何第三方服务"
});
const parts2 = [];
if (options.requireBduss) {
parts2.push(
`还没有填写 BDUSS,所有查询都会失败。请先按下面的说明获取并粘贴。
`
);
}
parts2.push(
`安全提示:BDUSS 等同于你账号的登录凭据,任何拿到它的人都能以你的身份操作。它只保存在本机浏览器里,不要发给别人。
`
);
parts2.push(``);
dialog.body.innerHTML = parts2.join("");
const readForm = () => {
const value = (id) => (dialog.body.querySelector(`#${id}`)?.value ?? "").trim();
const rawValue = (id) => dialog.body.querySelector(`#${id}`)?.value ?? "";
let bduss = value("tb-eztb-bduss");
const matched = bduss.match(/BDUSS=([^;\s]+)/i);
if (matched) bduss = matched[1];
return {
bduss,
bdussHelpUrl: value("tb-eztb-help"),
minIntervalMs: Number(value("tb-eztb-interval")),
maxPagesPerList: Number(value("tb-eztb-maxpages")),
compositionRules: rawValue("tb-eztb-rules").trim(),
compositionAuto: value("tb-eztb-composition-auto") !== "0",
compositionMaxPerPage: Number(value("tb-eztb-maxcheck")),
compositionPages: Number(value("tb-eztb-composition-pages")),
compositionKeywordMatch: value("tb-eztb-match") === "exact" ? "exact" : "contains",
compositionCacheDays: Number(value("tb-eztb-cachedays")),
signInLevelThreshold: Number(value("tb-eztb-signin-level")),
defaultTab: normalizePanelTabId(value("tb-eztb-default-tab")),
rememberLastTab: value("tb-eztb-remember-tab") === "1",
selfIdentity: value("tb-eztb-self")
};
};
const textarea = () => dialog.body.querySelector("#tb-eztb-rules");
const issuesEl = () => dialog.body.querySelector("#tb-eztb-rules-issues");
const refreshRuleIssues = () => {
const el = issuesEl();
if (!el) return;
const box = textarea();
const { rules, issues: issues2 } = parseRulesDetailed(box?.value ?? "");
if (!issues2.length) {
el.textContent = rules.length ? `已解析出 ${rules.length} 条规则,没有发现问题。` : "还没有可用的规则(规则表为空时不会发起任何检测请求)。";
el.classList.remove("tb-eztb-warn");
return;
}
const lines = issues2.map(
(issue) => `第 ${issue.line} 行${issue.level === "error" ? "(错误)" : ""}:${issue.message}`
);
el.textContent = `已解析出 ${rules.length} 条规则,有 ${issues2.length} 处要留意:` + lines.join(" ");
el.classList.add("tb-eztb-warn");
};
const ioBox = () => dialog.body.querySelector("#tb-eztb-io");
const ioStatus = (text) => {
const el = dialog.body.querySelector("#tb-eztb-io-status");
if (el) el.textContent = text;
};
const fillForm = (settings) => {
const set6 = (id, value) => {
const el = dialog.body.querySelector(`#${id}`);
if (el) el.value = value;
};
set6("tb-eztb-bduss", settings.bduss);
set6("tb-eztb-help", settings.bdussHelpUrl);
set6("tb-eztb-interval", String(settings.minIntervalMs));
set6("tb-eztb-maxpages", String(settings.maxPagesPerList));
set6("tb-eztb-rules", settings.compositionRules);
set6("tb-eztb-composition-auto", settings.compositionAuto ? "1" : "0");
set6("tb-eztb-maxcheck", String(settings.compositionMaxPerPage));
set6("tb-eztb-composition-pages", String(settings.compositionPages));
set6("tb-eztb-match", settings.compositionKeywordMatch);
set6("tb-eztb-cachedays", String(settings.compositionCacheDays));
set6("tb-eztb-signin-level", String(settings.signInLevelThreshold));
set6("tb-eztb-default-tab", settings.defaultTab);
set6("tb-eztb-remember-tab", settings.rememberLastTab ? "1" : "0");
set6("tb-eztb-self", settings.selfIdentity);
refreshRuleIssues();
};
dialog.body.querySelector('[data-act="export"]')?.addEventListener("click", () => {
const box = ioBox();
if (!box) return;
box.value = exportSettingsJson();
box.focus();
box.select();
ioStatus("已填入上面的文本框(不含 BDUSS),可以直接全选复制。");
});
dialog.body.querySelector('[data-act="io-copy"]')?.addEventListener("click", () => {
const box = ioBox();
if (!box) return;
if (!box.value.trim()) {
ioStatus("文本框是空的,先点「导出到文本框」。");
return;
}
box.focus();
box.select();
void (async () => {
try {
await navigator.clipboard.writeText(box.value);
ioStatus("已复制到剪贴板。");
} catch {
ioStatus("浏览器不允许直接写剪贴板,内容已选中,按 Ctrl+C 复制。");
}
})();
});
dialog.body.querySelector('[data-act="import"]')?.addEventListener("click", () => {
const box = ioBox();
if (!box) return;
const result = importSettingsJson(box.value);
if (!result.ok) {
ioStatus(`导入失败:${result.reason}`);
return;
}
fillForm(result.settings);
requestQueue.setMinInterval(result.settings.minIntervalMs);
invalidateClient();
rescanPage();
ioStatus("已导入并保存(BDUSS 未改动)。");
});
dialog.body.querySelector('[data-act="check-bduss"]')?.addEventListener("click", (event) => {
const button = event.currentTarget;
const status = dialog.body.querySelector(
"#tb-eztb-bduss-status"
);
const value = readForm().bduss;
button.disabled = true;
button.textContent = "校验中…";
if (status) status.textContent = "正在问贴吧「当前是谁」……";
void checkBduss(value).then((result) => {
if (status) {
status.textContent = `${result.message}(校验时间:${new Date(result.checkedAt).toLocaleString()})`;
}
if (result.status === "ok") invalidateClient();
}).catch((error) => {
if (status) {
status.textContent = `校验没能完成:${error instanceof Error ? error.message : String(error)}`;
}
}).finally(() => {
button.disabled = false;
button.textContent = "校验 BDUSS";
});
});
dialog.body.querySelector('[data-act="rules-example"]')?.addEventListener("click", () => {
const el = textarea();
if (el) el.value = EXAMPLE_RULES;
refreshRuleIssues();
});
dialog.body.querySelector('[data-act="rules-clear"]')?.addEventListener("click", () => {
const el = textarea();
if (el) el.value = "";
refreshRuleIssues();
});
textarea()?.addEventListener("input", refreshRuleIssues);
refreshRuleIssues();
dialog.body.querySelector('[data-act="help"]')?.addEventListener("click", () => {
const url = readForm().bdussHelpUrl;
if (url) window.open(url, "_blank", "noopener,noreferrer");
});
dialog.body.querySelector('[data-act="clear"]')?.addEventListener("click", () => {
clearProfileCache();
const button = dialog.body.querySelector(
'[data-act="clear"]'
);
if (button) button.textContent = "已清空";
});
dialog.body.querySelector('[data-act="clear-composition"]')?.addEventListener("click", () => {
clearCompositionCache();
const button = dialog.body.querySelector(
'[data-act="clear-composition"]'
);
if (button) button.textContent = "已清空";
});
dialog.body.querySelector('[data-act="clear-forum-level"]')?.addEventListener("click", () => {
clearForumLevelCache();
const button = dialog.body.querySelector(
'[data-act="clear-forum-level"]'
);
if (button) button.textContent = "已清空";
});
dialog.body.querySelector('[data-act="clear-reply-floor"]')?.addEventListener("click", () => {
clearReplyFloorCache();
const button = dialog.body.querySelector(
'[data-act="clear-reply-floor"]'
);
if (button) button.textContent = "已清空";
});
dialog.body.querySelector('[data-act="clear-activity"]')?.addEventListener("click", () => {
clearForumActivityCache();
const button = dialog.body.querySelector(
'[data-act="clear-activity"]'
);
if (button) button.textContent = "已清空";
});
dialog.body.querySelector('[data-act="save"]')?.addEventListener("click", () => {
invalidateClient();
const next4 = updateSettings(readForm());
requestQueue.setMinInterval(next4.minIntervalMs);
dialog.close();
rescanPage();
options.onSaved?.();
});
}
// src/core/version.ts
var SCRIPT_VERSION = "1.11.0" ? "1.11.0" : "dev";
// src/page/adapters.ts
function parseDataField(el) {
const raw = el?.getAttribute?.("data-field");
if (!raw) return null;
try {
return JSON.parse(raw.replace(/'/g, '"'));
} catch {
return null;
}
}
function portraitFromHref(href) {
if (!href) return null;
try {
const url = new URL(href, location.href);
const id = url.searchParams.get("id");
return id ? stripPortraitQuery(id) : null;
} catch {
const matched = String(href).match(/[?&]id=([^&]+)/);
return matched ? stripPortraitQuery(decodeURIComponent(matched[1])) : null;
}
}
function processOldPostAuthor(nameEl, mount2) {
const post = nameEl.closest(".l_post");
const dName = nameEl.closest("li.d_name");
const card = post?.querySelector(".j_user_card") ?? null;
const postData = parseDataField(post);
const nameData = parseDataField(nameEl);
const dNameData = parseDataField(dName);
const cardData = parseDataField(card);
const img = nameEl.closest(".d_author")?.querySelector("img[username]") ?? null;
const author = postData?.author ?? {};
mount2(
{
userId: author.user_id ?? dNameData?.user_id ?? null,
un: decodeUserName(author.user_name) || decodeUserName(nameData?.un) || decodeUserName(cardData?.un) || decodeUserName(img?.getAttribute("username")),
portrait: stripPortraitQuery(author.portrait) || stripPortraitQuery(nameData?.id) || stripPortraitQuery(cardData?.id) || portraitFromHref(nameEl.getAttribute("href")) || "",
nickname: cleanText(nameEl)
},
nameEl
);
}
function processOldFrsAuthor(nameEl, mount2) {
const holder = nameEl.closest("[data-field]");
const data = parseDataField(holder);
const author = data?.author ?? data?.author_user ?? {};
mount2(
{
userId: author.user_id ?? data?.user_id ?? null,
un: decodeUserName(author.user_name ?? author.name) || decodeUserName(data?.user_name ?? data?.author_name) || decodeUserName(nameEl.getAttribute("username")),
portrait: stripPortraitQuery(author.portrait) || portraitFromHref(nameEl.getAttribute("href")) || "",
nickname: cleanText(nameEl)
},
nameEl
);
}
function processOldLzlAuthor(atEl, mount2) {
const item = atEl.closest(".lzl_item, li[data-field]");
const itemData = parseDataField(item);
const author = itemData?.author ?? itemData?.user ?? {};
mount2(
{
userId: author.user_id ?? null,
un: decodeUserName(author.user_name) || decodeUserName(atEl.getAttribute("username")) || decodeUserName(parseDataField(atEl)?.un),
portrait: stripPortraitQuery(author.portrait) || portraitFromHref(atEl.getAttribute("href")) || "",
nickname: cleanText(atEl)
},
atEl
);
}
function processNewHeadline(headline, mount2) {
const link = headline.querySelector('a.name-info-link[href*="home/main?id="]') ?? headline.querySelector('a[href*="home/main?id="]');
if (!link) return;
const nameEl = headline.querySelector(".head-name") ?? link;
const wrapper = headline.querySelector(".btn-wrapper") ?? headline;
mount2(
{
userId: null,
un: "",
portrait: portraitFromHref(link.getAttribute("href")) ?? "",
nickname: cleanText(nameEl)
},
nameEl,
wrapper
);
}
// src/page/scanner.ts
var DONE_ATTR = "data-tb-eztb-toolbox-done";
var LEGACY_BUTTON_CLASS = "tb-eztb-follow-btn";
var legacyWarned = false;
var BUTTON_CLASS = "tb-eztb-btn";
var INJECTED_SELECTOR = `.${BUTTON_CLASS}, .tb-eztb-badges`;
var TARGETS = [
{
selector: ".p_author_name",
handler: processOldPostAuthor,
label: "旧版楼层"
},
{
selector: "a.frs-author-name",
handler: processOldFrsAuthor,
label: "旧版列表"
},
{
selector: ".lzl_cnt > .at, .lzl_content_main > .at",
handler: processOldLzlAuthor,
label: "旧版楼中楼"
},
{ selector: ".head-line", handler: processNewHeadline, label: "新版用户行" }
];
function scan(root, mount2) {
if (!root?.querySelectorAll) return;
for (const target of TARGETS) {
for (const el of Array.from(root.querySelectorAll(target.selector))) {
if (el.getAttribute(DONE_ATTR)) continue;
el.setAttribute(DONE_ATTR, "1");
try {
target.handler(el, mount2);
} catch (error) {
log.warn(`处理${target.label}失败:`, error);
}
}
}
}
function healHeadline(node, mount2) {
const headline = node.classList?.contains("head-line") ? node : node.closest?.(".head-line");
if (!headline) return;
if (!headline.getAttribute(DONE_ATTR)) return;
if (headline.querySelector(`.${BUTTON_CLASS}`)) return;
try {
processNewHeadline(headline, mount2);
} catch (error) {
log.warn("补回按钮失败:", error);
}
}
var pendingScan = false;
var pendingNodes = /* @__PURE__ */ new Set();
var pendingHeal = /* @__PURE__ */ new Set();
var currentMount = null;
var BATCH_MAX_ROOTS = 60;
function scheduleFrame(task) {
if (typeof requestAnimationFrame === "function") {
requestAnimationFrame(() => task());
return;
}
setTimeout(task, 16);
}
function enqueue2(node) {
if (pendingNodes.has(node)) return;
if (pendingNodes.size > BATCH_MAX_ROOTS) {
pendingNodes.add(node);
return;
}
for (const queued of pendingNodes) {
if (queued.contains(node)) return;
if (node.contains(queued)) pendingNodes.delete(queued);
}
pendingNodes.add(node);
}
function scanSelf(node, mount2) {
if (node.hasAttribute?.(DONE_ATTR)) return;
for (const target of TARGETS) {
if (!node.matches?.(target.selector)) continue;
node.setAttribute(DONE_ATTR, "1");
try {
target.handler(node, mount2);
} catch (error) {
log.warn(`处理${target.label}失败:`, error);
}
return;
}
}
function flushPending() {
pendingScan = false;
const mount2 = currentMount;
const nodes = Array.from(pendingNodes).slice(0, BATCH_MAX_ROOTS);
const rest = Array.from(pendingNodes).slice(BATCH_MAX_ROOTS);
pendingNodes.clear();
const heads = Array.from(pendingHeal);
pendingHeal.clear();
if (!mount2) return;
for (const node of rest) pendingNodes.add(node);
for (const node of nodes) {
if (!node.isConnected) continue;
scanSelf(node, mount2);
scan(node, mount2);
}
for (const node of heads) {
if (node.isConnected) healHeadline(node, mount2);
}
if (pendingNodes.size) scheduleFlush();
}
function scheduleFlush() {
if (pendingScan) return;
pendingScan = true;
scheduleFrame(flushPending);
}
function resetPageMarks(mount2) {
for (const el of Array.from(document.querySelectorAll(INJECTED_SELECTOR))) {
el.remove();
}
for (const el of Array.from(
document.querySelectorAll(`[${DONE_ATTR}]`)
)) {
el.removeAttribute(DONE_ATTR);
}
pendingNodes.clear();
pendingHeal.clear();
scan(document, mount2);
}
var lastHref = "";
function onSoftNavigation(mount2) {
const href = location.href;
if (href === lastHref) return;
lastHref = href;
try {
resetPageMarks(mount2);
} catch (error) {
log.warn("软导航后重置页面标记失败:", error);
}
}
function watchSoftNavigation(mount2) {
lastHref = location.href;
window.addEventListener("popstate", () => onSoftNavigation(mount2));
window.addEventListener("hashchange", () => onSoftNavigation(mount2));
for (const name of ["pushState", "replaceState"]) {
const original = history[name];
if (typeof original !== "function") continue;
const wrapped = function(...args2) {
const result = original.apply(this, args2);
if (location.href !== lastHref) onSoftNavigation(mount2);
return result;
};
try {
history[name] = wrapped;
} catch (error) {
log.warn(`包装 history.${name} 失败(软导航后不会自动重置标记):`, error);
}
}
}
function startScanner(mount2) {
currentMount = mount2;
scan(document, mount2);
if (!legacyWarned && document.querySelector(`.${LEGACY_BUTTON_CLASS}`)) {
legacyWarned = true;
log.warn(
"检测到旧版脚本(tieba-eztb-follow.user.js)的按钮仍在页面上。它会在每个用户名旁再插一个「查关注」按钮,且点击已失效;请在油猴里卸载它,避免重复与干扰。"
);
}
watchSoftNavigation(mount2);
if (!window.MutationObserver) return;
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of Array.from(mutation.addedNodes)) {
if (node.nodeType === Node.ELEMENT_NODE) {
enqueue2(node);
}
}
const target = mutation.target;
if (target && target.nodeType === Node.ELEMENT_NODE) {
pendingHeal.add(target);
}
}
if (pendingNodes.size || pendingHeal.size) scheduleFlush();
});
observer.observe(document.body ?? document.documentElement, {
childList: true,
subtree: true
});
}
// src/features/diagnose.ts
var SELECTORS = [
".l_post",
".p_author_name",
".d_author",
".lzl_cnt > .at",
"a.frs-author-name",
".head-line",
".head-name",
"a.name-info-link"
];
function countOf(selector) {
try {
return document.querySelectorAll(selector).length;
} catch {
return -1;
}
}
function userLinkClasses() {
const tally = /* @__PURE__ */ new Map();
for (const link of Array.from(
document.querySelectorAll('a[href*="home/main?id="]')
)) {
const className = link.getAttribute("class") ?? "(无 class)";
tally.set(className, (tally.get(className) ?? 0) + 1);
}
return Array.from(tally, ([className, count3]) => ({ className, count: count3 })).sort((a, b) => b.count - a.count).slice(0, 12);
}
function selfCheckLines() {
const lines = [];
const buttons = Array.from(
document.querySelectorAll(`.${BUTTON_CLASS}`)
).slice(0, 3);
if (!buttons.length) {
lines.push(" (本页还没有注入按钮,跳过)");
return lines;
}
const pointerOk = buttons.filter(
(button) => getComputedStyle(button).pointerEvents === "auto"
);
lines.push(
` ${pointerOk.length === buttons.length ? "PASS" : "FAIL"} 按钮 pointer-events = auto —— ${pointerOk.length}/${buttons.length}` + (pointerOk.length === buttons.length ? "" : "(不成立就会「看得见、点不动」,坑 #1)")
);
const zOk = buttons.filter(
(button) => getComputedStyle(button).zIndex === "5"
);
lines.push(
` ${zOk.length === buttons.length ? "PASS" : "FAIL"} 按钮 z-index = 5 —— ${zOk.length}/${buttons.length}` + (zOk.length === buttons.length ? "" : "(大于 5 会盖住本该在上面的页面弹层,坑 #3)")
);
const containers = Array.from(
document.querySelectorAll(`.${BADGES_CLASS}`)
).slice(0, 3);
if (!containers.length) {
lines.push(" (本页没有成分徽章,跳过徽章相关的两条)");
return lines;
}
const nowrapOk = containers.filter(
(container) => getComputedStyle(container).whiteSpace.includes("nowrap")
);
lines.push(
` ${nowrapOk.length === containers.length ? "PASS" : "FAIL"} 徽章容器不折行(white-space: nowrap) —— ${nowrapOk.length}/${containers.length}` + (nowrapOk.length === containers.length ? "" : "(折行会压住下面的正文,坑 #14)")
);
const fitOk = containers.filter((container) => {
const row = container.closest(".head-line");
if (!row) return true;
const rowBox = row.getBoundingClientRect();
const box = container.getBoundingClientRect();
return box.height <= rowBox.height + 1;
});
lines.push(
` ${fitOk.length === containers.length ? "PASS" : "FAIL"} 徽章待在头部行的高度内 —— ${fitOk.length}/${containers.length}`
);
return lines;
}
function breakerLines() {
const snapshot = breakerSnapshot();
if (!snapshot.open) {
return [
` 正常(连续失败 ${snapshot.consecutiveFailures} 次;连续 ${snapshot.threshold} 次才熔断)`
];
}
return [
` 已暂停:连续失败 ${snapshot.consecutiveFailures} 次,约 ${Math.ceil(snapshot.remainingMs / 1e3)} 秒后自动恢复`,
` 期间被挡回去的请求:${snapshot.pausedRequests} 个`
];
}
function buildDiagnoseReport() {
const lines = [
`URL: ${location.href}`,
`标题: ${document.title}`,
// 用户报障时第一件要知道的事:他装的是哪一版
`脚本版本: ${SCRIPT_VERSION}`,
`脚本已运行: 是`,
"",
"扫描器选择器命中数:",
...SELECTORS.map((selector) => ` ${selector} = ${countOf(selector)}`),
` [本脚本已处理标记] = ${countOf("[data-tb-eztb-toolbox-done]")}`,
` [旧脚本遗留按钮] = ${countOf(".tb-eztb-follow-btn")}`,
` [已注入按钮] = ${countOf(`.${BUTTON_CLASS}`)}`,
"",
"关键约束自检(这几条不成立,界面上就会出现「点不动 / 压住正文」):",
...selfCheckLines(),
"",
"页面上的用户主页链接(按 class 统计):",
...userLinkClasses().map((item) => ` ${item.count} × ${item.className}`),
"",
`在飞请求: ${inFlightCount()}`,
"请求熔断状态:",
...breakerLines(),
"",
"存储写入失败记录(空 = 一切正常):",
...storageIssueLines(),
"",
"贴吧返回过但本脚本还没收录的错误码(报给开发者就能补进对照表):",
...unknownErrnoLines(),
"",
"最近的日志(最多 20 条):",
...recentLogs().slice(-20).map((line) => ` ${line}`),
"",
`UA: ${navigator.userAgent}`
];
return lines.join("\n");
}
function unknownErrnoLines() {
const unknown = recentUnknownErrno();
if (!unknown.length) return [" (无)"];
return unknown.map((line) => ` ${line}`);
}
function storageIssueLines() {
const issues2 = getStorageIssues();
if (!issues2.length) return [" (无)"];
return issues2.map(
(issue) => ` ${new Date(issue.at).toISOString()} ${issue.storageKey}: ${issue.message}`
);
}
function openDiagnoseDialog() {
const report = buildDiagnoseReport();
const dialog = openDialog({
title: "eztb 页面诊断",
subtitleHtml: "把下面的内容整段复制发给开发者"
});
dialog.body.innerHTML = `${escapeHtml(report)}`;
dialog.body.querySelector('[data-act="reset-breaker"]')?.addEventListener("click", (event) => {
const button = event.currentTarget;
const before2 = breakerSnapshot();
resetBreaker();
button.textContent = before2.open ? "已重置,可以继续查询" : "本来就没熔断";
});
dialog.body.querySelector('[data-act="abort"]')?.addEventListener("click", (event) => {
const button = event.currentTarget;
const count3 = abortAllInFlight();
button.textContent = count3 > 0 ? `已中断 ${count3} 个` : "当前没有在飞请求";
});
dialog.body.querySelector('[data-act="copy"]')?.addEventListener("click", () => {
const button = dialog.body.querySelector(
'[data-act="copy"]'
);
navigator.clipboard?.writeText(report).then(() => {
if (button) button.textContent = "已复制";
}).catch(() => {
if (button) button.textContent = "复制失败,请手动选中";
});
});
console.log("[eztb] 页面诊断\n" + report);
}
// src/core/lastTab.ts
var LAST_TAB_KEY = "tbEztbToolboxLastTabV1";
function readLastTab() {
try {
const stored = GM_getValue(LAST_TAB_KEY, "");
if (typeof stored !== "string" || !stored) return null;
return normalizePanelTabId(stored);
} catch {
return null;
}
}
function writeLastTab(id) {
try {
GM_setValue(LAST_TAB_KEY, id);
} catch {
}
}
// src/core/evidenceHtml.ts
var KIND_LABEL = {
topic: "主题",
reply: "回复",
sub: "楼中楼"
};
function evidenceLink(evidence) {
const post = evidence.post;
if (!post?.threadId) return null;
const base = threadUrl(post.threadId);
if (post.kind === "topic" || !post.postId) {
return { href: base, label: "打开主题帖" };
}
return {
href: `${base}?pid=${encodeURIComponent(post.postId)}`,
label: "打开这一楼"
};
}
function buildEvidenceHtml(evidence, options = {}) {
const post = evidence.post;
const parts2 = [];
const kindTag = post ? `${KIND_LABEL[post.kind]}` : "";
const time = post?.createTime ? `${escapeHtml(formatTimestamp(post.createTime))}` : "";
const replyTo = post?.kind === "sub" && post.replyTo ? `回复 ${escapeHtml(post.replyTo)}` : "";
const forum = post?.forumName && post.kind !== "topic" ? `${escapeHtml(post.forumName)}` : "";
parts2.push(``);
parts2.push(
`
` + kindTag + `${escapeHtml(evidence.reason)}${escapeHtml(evidence.keyword)}` + time + forum + replyTo + `
`
);
if (evidence.excerpt) {
parts2.push(
`
${highlightKeywords(evidence.excerpt, [evidence.keyword])}
`
);
}
const link = evidenceLink(evidence);
if (link) {
const floorSlot = renderFloorSlot(post, options);
parts2.push(
`
`
);
}
parts2.push(`
`);
return parts2.join("");
}
function renderFloorSlot(post, options) {
if (!post || post.kind === "topic" || !post.postId || !post.threadId) return "";
const known = options.floorFor?.({
threadId: post.threadId,
postId: post.postId
});
if (known && known > 0) {
return `${known}楼`;
}
return ``;
}
// src/features/panel/composition.ts
function bindFloorButtons(root) {
for (const button of Array.from(
root.querySelectorAll(".tb-eztb-floorbtn")
)) {
if (button.dataset.bound === "1") continue;
button.dataset.bound = "1";
button.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
if (button.disabled) return;
const threadId = button.dataset.thread ?? "";
const postId = button.dataset.post ?? "";
if (!threadId || !postId) return;
button.disabled = true;
button.textContent = "查询中…";
void (async () => {
const result = await fetchReplyFloor(threadId, postId);
if (result.floor) {
const span2 = document.createElement("span");
span2.className = "tb-eztb-floor";
span2.textContent = `${result.floor}楼`;
span2.title = result.excerpt ? `${result.floor} 楼的内容:${result.excerpt}` : `${result.floor} 楼`;
button.replaceWith(span2);
return;
}
button.disabled = false;
button.textContent = "查不到";
button.title = result.reason ?? "没查到";
})();
});
}
}
function renderCompositionTab(body, ref, force = false) {
body.innerHTML = ``;
const actions = (html) => `${html}
`;
void (async () => {
let result;
try {
result = await checkUser(ref, { force });
} catch (error) {
body.innerHTML = `${escapeHtml(describeRequestError(error))}
` + actions(
``
);
bindRecheck();
return;
}
if (result.noRules) {
body.innerHTML = `还没有配置成分关键词规则,因此没有做任何检测(也没有发出请求)。
` + actions(
``
);
body.querySelector('[data-act="settings"]')?.addEventListener("click", () => openSettingsDialog());
return;
}
if (result.noBduss) {
body.innerHTML = `成分检测需要读取关注吧与发帖,请先设置 BDUSS。
` + actions(
``
);
body.querySelector('[data-act="settings"]')?.addEventListener(
"click",
() => openSettingsDialog({ requireBduss: true })
);
return;
}
const { hits, stat } = result;
const statLine = `已检查:关注的吧 ${stat.forums} 个` + (stat.forumsRecovered ? `(其中 ${stat.forumsRecovered} 个来自隐藏关注贴吧的恢复)` : "") + ` · 主题帖 ${stat.topics} 条 · 回复 ${stat.replies} 条` + (result.fromCache ? "(来自缓存)" : "");
const rules = parseRules(getSettings().compositionRules);
const hitBlocks = hits.map((hit) => {
const evidences = hit.evidences.map(
(evidence) => buildEvidenceHtml(evidence, {
floorFor: (target) => readReplyFloorCache(target.threadId, target.postId)?.floor ?? null
})
).join("");
const newest = newestEvidenceAt(hit);
return `${escapeHtml(hit.rule.name)}` + (hit.sure ? "" : `证据较弱,可能是误判`) + (newest ? `最近依据 ${escapeHtml(formatTimestamp(newest))}` : "") + `
` + evidences + `
`;
}).join("");
const body_ = (hits.length ? `命中 ${hits.length} 条规则(共配置 ${rules.length} 条)
${hitBlocks}
` : `没有命中任何关键词:这个用户关注的吧与发帖里都没出现规则表中的词。
`) + `${escapeHtml(statLine)}
` + (stat.failed.length ? `部分数据没取到:${escapeHtml(stat.failed.join(";"))}
` : "") + actions(
``
);
body.innerHTML = body_;
body.querySelector('[data-act="settings"]')?.addEventListener("click", () => openSettingsDialog());
bindFloorButtons(body);
bindRecheck();
function bindRecheck() {
body.querySelector('[data-act="recheck"]')?.addEventListener("click", () => renderCompositionTab(body, ref, true));
}
})();
}
// src/features/panel/pagedList.ts
function mountPagedList(options) {
const settings = getSettings();
const maxPages = Math.max(1, settings.maxPagesPerList);
options.body.innerHTML = ``;
const listEl = options.body.querySelector(".tb-eztb-list");
const moreBtn = options.body.querySelector(".tb-eztb-more");
const hintEl = options.body.querySelector(".tb-eztb-hint");
const noticeEl = options.body.querySelector(".tb-eztb-notice");
let page = 0;
let loaded = 0;
let totalPages = Number.POSITIVE_INFINITY;
let loading = false;
let exhaustedFlag = false;
let cappedFlag = false;
let hiddenFlag = false;
let failedPageNum = null;
const items = [];
const refreshFooter = () => {
const summary5 = options.summaryText?.(loaded, totalPages);
const parts2 = [];
if (summary5) parts2.push(summary5);
if (loading) parts2.push("正在加载…");
else if (moreBtn.disabled) parts2.push("已全部加载");
hintEl.textContent = parts2.join(" · ");
};
const loadNext = async () => {
if (loading) return;
loading = true;
moreBtn.disabled = true;
moreBtn.textContent = "加载中…";
refreshFooter();
try {
const result = await options.loadPage(page + 1);
page += 1;
failedPageNum = null;
loaded += result.items.length;
items.push(...result.items);
if (result.totalPages && result.totalPages > 0) {
totalPages = result.totalPages;
}
if (result.items.length) {
listEl.insertAdjacentHTML(
"beforeend",
result.items.map(options.renderRow).join("")
);
}
if (result.hidden) {
hiddenFlag = true;
noticeEl.innerHTML = `${escapeHtml(
options.hiddenText ?? options.emptyText
)}
`;
}
options.onPage?.(result);
const noMoreData = result.items.length === 0 || page >= totalPages && Number.isFinite(totalPages);
const hitCap = page >= maxPages;
exhaustedFlag = noMoreData || hitCap;
cappedFlag = hitCap && !noMoreData;
if (!loaded && exhaustedFlag) {
if (!result.hidden) {
listEl.innerHTML = `${escapeHtml(options.emptyText)}
`;
}
}
moreBtn.disabled = exhaustedFlag;
moreBtn.textContent = exhaustedFlag ? "没有更多了" : "加载更多";
} catch (error) {
const message = describeRequestError(error);
failedPageNum = page + 1;
options.onError?.(error);
listEl.insertAdjacentHTML(
"beforeend",
`${escapeHtml(message)}
`
);
moreBtn.disabled = false;
moreBtn.textContent = "重试";
} finally {
loading = false;
refreshFooter();
}
};
moreBtn.addEventListener("click", () => {
void loadNext();
});
void loadNext();
return {
loadNext,
rows: () => items.slice(),
pages: () => page,
exhausted: () => exhaustedFlag,
capped: () => cappedFlag,
hidden: () => hiddenFlag,
failedPage: () => failedPageNum
};
}
// src/features/panel/rows.ts
function renderUserRow(user) {
const display = user.name_show || user.name || "贴吧用户";
const portrait = stripPortraitQuery(user.portrait);
const href = portrait ? `https://tieba.baidu.com/home/main?id=${encodeURIComponent(portrait)}` : "";
return `` + (portrait ? `
` : ``) + `${escapeHtml(display)}` + (user.name ? `${escapeHtml(user.name)}` : "") + ``;
}
// src/features/panel/fans.ts
function renderFansTab(body, identity4) {
mountPagedList({
body,
emptyText: "该用户没有公开的粉丝",
summaryText: (loaded, totalPages) => Number.isFinite(totalPages) ? `已加载 ${loaded} 位 / 共 ${totalPages} 页` : `已加载 ${loaded} 位`,
loadPage: async (page) => {
const res = await callSdkLoose(() => getFans(identity4.id, page));
const items = Array.isArray(res?.user_list) ? res.user_list : [];
const totalPages = toNumber(res?.page?.total_page);
return { items, totalPages: totalPages || void 0 };
},
renderRow: renderUserRow
});
}
// src/features/panel/forums.ts
function applyForumActivity(body, items, activity, levelThreshold) {
const activityEl = body.querySelector(".tb-eztb-activity");
const parts2 = [];
if (activity.hidden) {
parts2.push(
`该用户隐藏了发帖记录,读不到发帖样本,因此没法判断「等级与活跃度是否相符」。
`
);
} else {
const candidates = findSignInForums(items, activity.byForum, levelThreshold);
const candidateNames = new Set(candidates.map((item) => item.forumName));
parts2.push(
`${escapeHtml(
signInSummary(candidates, levelThreshold, {
topics: activity.topics,
replies: activity.replies
})
)}
`
);
for (const row of Array.from(
body.querySelectorAll(".tb-eztb-row[data-forum]")
)) {
const name = row.dataset.forum ?? "";
const count3 = activity.byForum[name] ?? 0;
const main = row.querySelector(".tb-eztb-row-main");
if (main) {
let sub = main.querySelector(".tb-eztb-row-sub");
if (!sub) {
sub = document.createElement("span");
sub.className = "tb-eztb-row-sub";
main.appendChild(sub);
}
if (sub.dataset.baseHtml === void 0) {
sub.dataset.baseHtml = sub.innerHTML;
}
sub.innerHTML = sub.dataset.baseHtml + ` `;
}
if (!candidateNames.has(name)) continue;
const meta = row.querySelector(".tb-eztb-row-meta");
if (!meta || meta.querySelector(".tb-eztb-signin")) continue;
const level = items.find((item) => item.name === name)?.level;
const mark = document.createElement("span");
mark.className = "tb-eztb-signin";
mark.textContent = "疑似只签到";
mark.title = `吧内等级 Lv.${level}(≥ ${levelThreshold}),但最近一页发帖里在这个吧 0 条发言。可能是只签到不发言,也可能是最近没来。`;
meta.prepend(mark);
}
}
if (activity.failed.length) {
parts2.push(
`部分数据没取到:${escapeHtml(activity.failed.join(";"))}
`
);
}
if (activityEl) activityEl.innerHTML = parts2.join("");
}
function renderFollowForumsTab(body, identity4) {
body.innerHTML = ``;
void (async () => {
try {
const { forums: items, hidden } = await loadUserForums(
identity4.id,
identity4.profile?.likeForum ?? []
);
if (!items.length) {
body.innerHTML = `该用户没有公开的关注贴吧
`;
return;
}
for (const item of items) {
if (item.level) continue;
const cached4 = readForumLevelCache(identity4.id, item.name);
if (cached4) {
item.level = cached4;
item.levelFromPost = true;
}
}
const withLevel = items.filter((item) => item.level).length;
const missingLevel = items.length - withLevel;
const notes = [
hidden ? HIDDEN_FORUMS_NOTE : "",
// 缺等级就说明一次:隐藏关注贴吧的用户同样会看到(F1 精简后不再重复这件事)
missingLevel ? NO_LEVEL_NOTE : "",
hidden && !identity4.profile?.un ? NO_USERNAME_NOTE : ""
].filter(Boolean);
body.innerHTML = notes.map((note) => `${escapeHtml(note)}
`).join("") + `共 ${items.length} 个${withLevel ? ` · 其中 ${withLevel} 个有等级信息` : " · 都没有等级信息"}
等级高、最近又不在该吧发言的吧
`;
const activityButton = body.querySelector(
'[data-act="activity"]'
);
const activityEl = body.querySelector(".tb-eztb-activity");
activityButton?.addEventListener("click", () => {
if (activityButton.disabled) return;
activityButton.disabled = true;
const originalLabel = activityButton.textContent ?? "检测签到号";
activityButton.textContent = "检测中…";
if (activityEl) {
activityEl.innerHTML = `正在读取他最近一页的发帖…
`;
}
void (async () => {
try {
const activity = await loadForumActivity(identity4.id);
applyForumActivity(
body,
items,
activity,
getSettings().signInLevelThreshold
);
activityButton.textContent = "重新检测";
} catch (error) {
if (activityEl) {
activityEl.innerHTML = `${escapeHtml(describeRequestError(error))}
`;
}
activityButton.textContent = originalLabel;
} finally {
activityButton.disabled = false;
}
})();
});
for (const button of Array.from(
body.querySelectorAll(".tb-eztb-levelbtn")
)) {
const forumName = button.dataset.forum ?? "";
button.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
if (button.disabled || !forumName) return;
button.disabled = true;
button.textContent = "查询中…";
void (async () => {
try {
const result = await fetchUserForumLevel(
identity4.id,
forumName
);
if (result.level) {
const meta = document.createElement("span");
meta.className = "tb-eztb-row-meta";
meta.textContent = `Lv.${result.level}`;
meta.title = `这个等级是从他${result.via === "reply" ? "回复过的帖子" : "在本吧的帖子"}里读到的`;
button.replaceWith(meta);
return;
}
button.textContent = "查不到";
button.title = result.reason ?? "没查到";
button.disabled = false;
} catch (error) {
button.textContent = "查询失败";
button.title = describeRequestError(error);
button.disabled = false;
}
})();
});
}
} catch (error) {
body.innerHTML = `${escapeHtml(describeRequestError(error))}
`;
}
})();
}
// src/features/panel/follows.ts
var FOLLOW_PAGE_SIZE = 20;
function renderFollowUsersTab(body, identity4) {
mountPagedList({
body,
emptyText: "该用户没有公开的关注的人",
summaryText: (loaded, totalPages) => Number.isFinite(totalPages) ? `已加载 ${loaded} 人 / 共约 ${totalPages * FOLLOW_PAGE_SIZE} 人` : `已加载 ${loaded} 人`,
loadPage: async (page) => {
const res = await callSdkLoose(() => getFollow(identity4.id, page));
const items = Array.isArray(res?.follow_list) ? res.follow_list : [];
const total = toNumber(res?.total_follow_num);
return {
items,
totalPages: total > 0 ? Math.ceil(total / FOLLOW_PAGE_SIZE) : void 0
};
},
renderRow: renderUserRow
});
}
// src/core/mutualFollows.ts
function followKey(user) {
if (!user) return "";
if (user.id !== void 0 && user.id !== null && String(user.id)) {
return `id:${String(user.id)}`;
}
const portrait = String(user.portrait ?? "").split("?")[0];
if (portrait) return `portrait:${portrait}`;
const name = String(user.name ?? user.name_show ?? "").trim();
return name ? `name:${name}` : "";
}
function intersectFollows(mine, theirs) {
const myKeys = /* @__PURE__ */ new Set();
for (const user of mine) {
const key = followKey(user);
if (key) myKeys.add(key);
}
const common = [];
const seen = /* @__PURE__ */ new Set();
let byName = 0;
for (const user of theirs) {
const key = followKey(user);
if (!key || !myKeys.has(key) || seen.has(key)) continue;
seen.add(key);
if (key.startsWith("name:")) byName += 1;
common.push(user);
}
return { common, byName };
}
function buildMutualSummary(input) {
const mineLabel = input.mineLabel ?? "我";
const parts2 = [
`共同关注 ${input.common} 人`,
`${mineLabel}这边读了 ${input.mineRead} 人(${input.minePages} 页)`,
`对方读了 ${input.theirsRead} 人(${input.theirsPages} 页)`
];
if (input.byName) {
parts2.push(`其中 ${input.byName} 个是按用户名比上的(可能有同名)`);
}
if (input.capped) {
parts2.push("已到这边的页数上限,可能还有没比对到的");
}
return parts2.join(" · ");
}
// src/features/panel/mutual.ts
var INITIAL_PAGES = 3;
function parseSelfRef(raw) {
const value = String(raw ?? "").trim();
if (!value) return null;
const fromUrl = value.match(/[?&]id=([^&\s]+)/);
const body = fromUrl ? decodeURIComponent(fromUrl[1]) : value;
if (/^\d+$/.test(body)) return { userId: Number(body) };
if (body.startsWith("tb.") || body.includes(".")) return { portrait: body };
return { un: body };
}
async function resolveSelfIdentity(ref) {
if (typeof ref.userId === "number" && Number.isFinite(ref.userId)) {
try {
const byUid = await callSdkLoose(() => getUserByUid(ref.userId));
const internalId = Number(byUid?.id ?? 0);
if (internalId) {
return {
id: internalId,
label: byUid?.nameShow || byUid?.name || String(ref.userId)
};
}
} catch {
}
}
const identity4 = await resolveIdentity(ref);
return {
id: identity4.id,
label: identity4.nickname || identity4.un || "我"
};
}
async function loadFollowPage(uid, page) {
const res = await callSdkLoose(() => getFollow(uid, page));
return Array.isArray(res?.follow_list) ? res.follow_list : [];
}
function renderMutualTab(body, identity4) {
const settings = getSettings();
const selfRef = parseSelfRef(settings.selfIdentity);
if (!selfRef) {
body.innerHTML = `要看「共同关注」,得先告诉脚本你自己是谁:在设置里填上你自己的贴吧号(或用户名 / 主页链接)。
脚本会用这个身份读一次你自己的「关注的人」,再和对方的关注列表求交集。全部只读,不会关注或取关任何人。
`;
return;
}
const maxPages = Math.max(
INITIAL_PAGES,
Math.min(Number(settings.maxPagesPerList) || INITIAL_PAGES, 50)
);
body.innerHTML = `正在读双方的关注列表…
`;
const noticeEl = body.querySelector(".tb-eztb-notice");
const summaryEl = body.querySelector('[data-role="mutual-summary"]');
const listEl = body.querySelector(".tb-eztb-list");
const moreBtn = body.querySelector('[data-role="mutual-more"]');
const footEl = body.querySelector('[data-role="mutual-foot"]');
let minePages = 0;
let theirsPages = 0;
let mine = [];
let theirs = [];
let capped = false;
let loading = false;
const loadOneMorePage = async () => {
const wantMine = minePages < maxPages;
const wantTheirs = theirsPages < maxPages;
if (!wantMine && !wantTheirs) {
capped = true;
return false;
}
if (wantMine) {
const rows = await loadFollowPage(selfUid, minePages + 1);
minePages += 1;
mine = mine.concat(rows);
if (!rows.length) minePages = maxPages;
}
if (wantTheirs) {
const rows = await loadFollowPage(identity4.id, theirsPages + 1);
theirsPages += 1;
theirs = theirs.concat(rows);
if (!rows.length) theirsPages = maxPages;
}
return true;
};
const render2 = () => {
const { common, byName } = intersectFollows(mine, theirs);
summaryEl.textContent = buildMutualSummary({
common: common.length,
byName,
mineRead: mine.length,
minePages,
theirsRead: theirs.length,
theirsPages,
capped,
mineLabel: selfLabel
});
listEl.innerHTML = common.length ? common.map(renderUserRow).join("") : `在已经读到的这些页里没有共同关注
`;
moreBtn.disabled = loading || minePages >= maxPages && theirsPages >= maxPages;
moreBtn.textContent = loading ? "读取中…" : moreBtn.disabled ? "到页数上限了" : "再比一页";
};
let selfUid = null;
let selfLabel = "我";
const run3 = async () => {
if (loading) return;
loading = true;
render2();
try {
await loadOneMorePage();
noticeEl.innerHTML = "";
} catch (error) {
noticeEl.innerHTML = `${escapeHtml(
describeRequestError(error, "共同关注")
)}
`;
} finally {
loading = false;
render2();
}
};
moreBtn.addEventListener("click", () => {
void run3();
});
void (async () => {
try {
const self = await resolveSelfIdentity(selfRef);
selfUid = self.id;
selfLabel = self.label;
footEl.textContent = `以「${selfLabel}」的身份比对(每页 20 人,最多 ${maxPages} 页)`;
await run3();
} catch (error) {
noticeEl.innerHTML = `认不出设置里的「我自己」:${escapeHtml(
describeRequestError(error, "共同关注·我")
)}
`;
summaryEl.textContent = "";
moreBtn.disabled = true;
}
})();
}
// src/core/postStats.ts
function mergeForumCounts(a, b) {
const out = { ...a };
for (const [forum, count3] of Object.entries(b)) {
out[forum] = (out[forum] ?? 0) + count3;
}
return out;
}
function totalForumCount(counts) {
return Object.values(counts).reduce((sum2, value) => sum2 + value, 0);
}
function postRowSubParts(post) {
const parts2 = [];
if (post.forumName) {
parts2.push(
`${escapeHtml(post.forumName)}`
);
}
if (post.kind === "sub" && post.replyTo) {
parts2.push(
`↩ ${escapeHtml(post.replyTo)}`
);
}
if (post.kind !== "topic" && post.preview) {
parts2.push(escapeHtml(post.preview));
}
return parts2;
}
var PIE_COLORS = [
"#1677ff",
"#e8a33d",
"#3fb950",
"#a371f7",
"#e5534b",
"#1f9ea8"
];
var PIE_OTHER_COLOR = "#b6bcc6";
function buildForumStats(counts) {
const total = totalForumCount(counts);
if (!total) return [];
return Object.entries(counts).filter(([, count3]) => count3 > 0).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([forum, count3]) => ({
forum,
count: count3,
fraction: count3 / total,
percentText: `${(count3 / total * 100).toFixed(1)}%`
}));
}
function buildForumFilterOptionsHtml(counts, selected) {
const options = buildForumStats(counts).map((stat) => {
const value = escapeHtml(stat.forum);
const isSelected = stat.forum === selected ? " selected" : "";
return ``;
});
return `` + options.join("");
}
function postMatchesQuery(post, query) {
const needle = query.trim().toLowerCase();
if (!needle) return true;
return `${post.title ?? ""} ${post.preview ?? ""}`.toLowerCase().includes(needle);
}
function mergePostRows(topicRows, replyRows) {
return [...topicRows, ...replyRows].sort((a, b) => b.createTime - a.createTime);
}
function buildPostFilterHint(input) {
const forum = input.forum.trim();
const query = input.query.trim();
if (!forum && !query) return "";
const label = forum && query ? `筛选「${forum}」+ 搜索「${query}」` : forum ? `筛选「${forum}」` : `搜索「${query}」`;
const { topic, reply } = input.matched;
if (topic + reply === 0) {
return query ? `${label}:已加载的 ${input.loadedTotal} 条里没有命中` : `${label}:该用户在这个吧没有发帖或回复`;
}
return `${label}:主题帖 ${topic} 个 · 回复 ${reply} 条`;
}
function buildSearchAllSummary(input) {
const query = input.query.trim();
const loadedTotal = input.loaded.topic + input.loaded.reply;
const matchedTotal = input.matched.topic + input.matched.reply;
const scope3 = input.complete ? `已翻完主题帖 ${input.pages.topic} 页、回复 ${input.pages.reply} 页` : `翻到上限(每路最多 ${input.pageLimit} 页)时仍有更早的没加载,已翻主题帖 ${input.pages.topic} 页、回复 ${input.pages.reply} 页`;
const hit = matchedTotal === 0 ? `没有命中` : `命中 ${matchedTotal} 条(主题帖 ${input.matched.topic} · 回复 ${input.matched.reply})`;
return `搜索「${query}」:${scope3},共 ${loadedTotal} 条,${hit}。`;
}
function buildForumListHtml(counts, open) {
const stats = buildForumStats(counts);
if (stats.length < 2) return "";
const total = totalForumCount(counts);
const button = ``;
if (!open) return `${button}
`;
const rows = stats.map(
(stat) => `${escapeHtml(stat.forum)}${stat.count}${stat.percentText}
`
).join("");
return `${button}
共 ${stats.length} 个吧 · ${total} 条发言
` + rows + `
`;
}
function buildPieNotes(states) {
const loading = states.filter((state) => state.loading);
const failed = states.filter((state) => !state.loading && state.error);
const parts2 = [];
if (loading.length) {
const labels = loading.map((state) => `「${state.label}」`).join("、");
parts2.push(
`${labels}的数据还在加载,下面的占比还不完整——到齐后会自动补上。
`
);
}
for (const state of failed) {
const reason = escapeHtml(state.error ?? "");
if (!state.hasRows) {
const page = state.failedPage ?? 1;
parts2.push(
`「${state.label}」的第 ${page} 页就没取到(饼图里缺这一路的条数):${reason}
`
);
continue;
}
const detail = state.failedPage === void 0 ? "后续页没取到" : state.loadedPages ? `第 ${state.failedPage} 页没取到(前 ${state.loadedPages} 页已经计入饼图)` : `第 ${state.failedPage} 页没取到`;
parts2.push(
`「${state.label}」的${detail}(饼图只统计到已经加载出来的那部分):${reason}
`
);
}
return parts2.join("");
}
function buildForumSlices(counts, maxSlices = 5) {
const total = totalForumCount(counts);
if (!total) return [];
const ranked = Object.entries(counts).filter(([, count3]) => count3 > 0).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
const head5 = ranked.slice(0, maxSlices);
const rest = ranked.slice(maxSlices);
const slices = head5.map(([forum, count3], index) => ({
forum,
label: forum,
count: count3,
color: PIE_COLORS[index % PIE_COLORS.length],
fraction: count3 / total,
percentText: `${(count3 / total * 100).toFixed(1)}%`
}));
if (rest.length) {
const count3 = rest.reduce((sum2, [, value]) => sum2 + value, 0);
slices.push({
forum: null,
label: `其它 ${rest.length} 个吧`,
count: count3,
color: PIE_OTHER_COLOR,
fraction: count3 / total,
percentText: `${(count3 / total * 100).toFixed(1)}%`
});
}
return slices;
}
var RADIUS = 46;
var STROKE = 18;
var CIRCUMFERENCE = 2 * Math.PI * RADIUS;
function buildForumPieSvg(counts, maxSlices = 5) {
const slices = buildForumSlices(counts, maxSlices);
const total = totalForumCount(counts);
if (!total) {
return `还没有加载到发帖记录
`;
}
let acc = 0;
const arcs = slices.filter((slice) => slice.count > 0).map((slice) => {
const length2 = slice.fraction * CIRCUMFERENCE;
const gap = CIRCUMFERENCE - length2;
const offset = -acc;
acc += length2;
return `${escapeHtml(slice.label)} ${slice.count} 条(${slice.percentText})`;
}).join("");
const legend = slices.map(
(slice) => `${escapeHtml(slice.label)}${slice.count}${slice.percentText}`
).join("");
return `${legend}已加载 ${total} 条 · ${Object.keys(counts).length} 个吧
`;
}
// src/features/panel/posts.ts
var POST_KIND_LABEL = {
topic: "主题",
reply: "回复",
sub: "楼中楼"
};
var HIDDEN_POSTS_NOTE = "发帖信息设为私密。";
function renderFloorSlot2(post) {
if (!post.postId) return "";
const cached4 = readReplyFloorCache(post.threadId, post.postId);
if (cached4) {
return `${cached4.floor}楼`;
}
return ``;
}
function renderPostRow(post) {
const isReply = post.kind !== "topic";
const forum = String(post.forumName ?? "").trim() || UNKNOWN_FORUM;
const searchText = `${post.title ?? ""} ${post.preview ?? ""}`.toLowerCase().replace(/\s+/g, " ").trim();
const subParts = postRowSubParts(post);
return (
// data-time 是原始时间戳(合并视图按它倒序,测试也按它断言顺序)
`${POST_KIND_LABEL[post.kind]}${escapeHtml(post.title || post.preview || "(无标题)")}` + (subParts.length ? `${subParts.join(" ")}` : `${escapeHtml(UNKNOWN_FORUM)}`) + `` + (isReply ? renderFloorSlot2(post) : "") + `${escapeHtml(formatTimestamp(post.createTime))}`
);
}
function bindFloorButtons2(root) {
for (const button of Array.from(
root.querySelectorAll(".tb-eztb-floorbtn")
)) {
if (button.dataset.bound === "1") continue;
button.dataset.bound = "1";
button.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
if (button.disabled) return;
const threadId = button.dataset.thread ?? "";
const postId = button.dataset.post ?? "";
if (!threadId || !postId) return;
button.disabled = true;
button.textContent = "查询中…";
void (async () => {
const result = await fetchReplyFloor(threadId, postId);
if (result.floor) {
const span2 = document.createElement("span");
span2.className = "tb-eztb-floor";
span2.textContent = `${result.floor}楼`;
span2.title = result.excerpt ? `${result.floor} 楼的内容:${result.excerpt}` : `${result.floor} 楼`;
button.replaceWith(span2);
return;
}
button.textContent = "查不到";
button.title = result.reason ?? "没查到";
button.disabled = false;
})();
});
}
}
var POST_MODES = [
{
id: "split",
label: "分开",
title: "主题帖与回复各占一个列表,各自翻页"
},
{
id: "merged",
label: "合并",
title: "主题帖与回复合成一个列表(按时间倒序,跨 feed 的顺序是近似的)"
}
];
var POST_SUBTABS = [
{
id: "topic",
label: "主题帖",
emptyText: "该用户没有公开的主题帖",
summaryText: (loaded) => `已加载 ${loaded} 个主题帖`
},
{
id: "reply",
label: "回复",
emptyText: "该用户没有公开的回复",
summaryText: (loaded) => `已加载 ${loaded} 条回复`
}
];
function mountPostsSubList(pane, identity4, subTab, onRows, onError3) {
const spec = POST_SUBTABS.find((item) => item.id === subTab);
return mountPagedList({
body: pane,
emptyText: spec.emptyText,
hiddenText: HIDDEN_POSTS_NOTE,
summaryText: spec.summaryText,
// 主题帖与回复是两个独立 feed,各自翻页,不再合并成一个列表:
// 合并后两条 feed 的页码对不上,跨页的时间倒序只能近似。
loadPage: async (page) => {
const feed = subTab === "topic" ? await loadTopicPage(identity4.id, page) : await loadReplyPage(identity4.id, page);
return { items: feed.rows, hidden: feed.hidden };
},
renderRow: renderPostRow,
onError: (error) => onError3(describeRequestError(error)),
onPage: (result) => {
bindFloorButtons2(pane);
onRows(result.items);
}
});
}
function renderPostsTab(body, identity4) {
const active2 = body.dataset.subtab === "reply" ? "reply" : "topic";
let mode = body.dataset.postmode === "merged" ? "merged" : "split";
body.innerHTML = // 占比饼图:按"发帖都发在哪些吧"统计,随已加载的行更新
`` + POST_SUBTABS.map(
(item) => ``
).join("") + `
` + POST_SUBTABS.map(
(item) => `
`
).join("") + `
`;
let counts = {};
let listOpen = false;
let forumFilter = "";
const pending4 = new Set(POST_SUBTABS.map((item) => item.id));
const loadedAny = /* @__PURE__ */ new Set();
const failures2 = /* @__PURE__ */ new Map();
const pieEl = body.querySelector(".tb-eztb-piestat");
const filterSelect = body.querySelector(
'[data-act="forum-filter"]'
);
const searchInput = body.querySelector(
'[data-act="post-search"]'
);
const filterHintEl = body.querySelector(
'[data-role="filter-hint"]'
);
const viewEl = body.querySelector(".tb-eztb-postview");
const mergedListEl = body.querySelector(
'[data-role="merged-list"]'
);
const mergedMoreBtn = body.querySelector(
'[data-role="merged-more"]'
);
const mergedHintEl = body.querySelector(
'[data-role="merged-hint"]'
);
const mergedNoticeEl = body.querySelector(
'[data-role="merged-notice"]'
);
const searchAllBtn = body.querySelector(
'[data-act="search-all"]'
);
const searchStopBtn = body.querySelector(
'[data-role="search-stop"]'
);
const handles = /* @__PURE__ */ new Map();
let searchQuery = body.dataset.query ?? "";
let loadingAll = false;
let stopLoadingAll = false;
let searchAllResult = null;
const pagesLoaded = (id) => handles.get(id)?.pages() ?? 0;
const loadedRows = (id) => handles.get(id)?.rows() ?? [];
const matchesForum = (forumName) => !forumFilter || (forumName.trim() || UNKNOWN_FORUM) === forumFilter;
const matchesFilters = (post) => matchesForum(post.forumName) && postMatchesQuery(post, searchQuery);
const rowMatchesFilters = (row) => {
if (forumFilter && (row.dataset.forum ?? UNKNOWN_FORUM) !== forumFilter) {
return false;
}
const needle = searchQuery.trim().toLowerCase();
return !needle || (row.dataset.search ?? "").includes(needle);
};
const updateHint = () => {
if (!filterHintEl) return;
if (loadingAll) {
filterHintEl.textContent = `正在翻页:主题帖 ${pagesLoaded("topic")} 页 / 回复 ${pagesLoaded("reply")} 页(已加载 ${loadedRows("topic").length + loadedRows("reply").length} 条)…点「停止」可以只保留已经取到的。`;
return;
}
if (searchAllResult) {
filterHintEl.textContent = buildSearchAllSummary(searchAllResult);
return;
}
const matched = {
topic: loadedRows("topic").filter(matchesFilters).length,
reply: loadedRows("reply").filter(matchesFilters).length
};
filterHintEl.textContent = buildPostFilterHint({
forum: forumFilter,
query: searchQuery,
matched,
loadedTotal: loadedRows("topic").length + loadedRows("reply").length
});
};
const summarizeSearchAll = () => {
searchAllResult = {
query: searchQuery,
pages: { topic: pagesLoaded("topic"), reply: pagesLoaded("reply") },
loaded: {
topic: loadedRows("topic").length,
reply: loadedRows("reply").length
},
matched: {
topic: loadedRows("topic").filter(matchesFilters).length,
reply: loadedRows("reply").filter(matchesFilters).length
},
// 只有"真的没有更多数据"才算翻完;到页数上限停的要说清可能还有更早的
complete: POST_SUBTABS.every((item) => {
const handle = handles.get(item.id);
return !handle || handle.exhausted() && !handle.capped();
}),
pageLimit: getSettings().maxPagesPerList
};
};
const loadAllPages = async () => {
if (loadingAll) return;
loadingAll = true;
stopLoadingAll = false;
searchAllResult = null;
if (searchStopBtn) searchStopBtn.disabled = false;
if (searchAllBtn) searchAllBtn.disabled = true;
updateHint();
try {
await Promise.all(
POST_SUBTABS.map(async (item) => {
const handle = handles.get(item.id);
if (!handle) return;
while (!stopLoadingAll && !handle.exhausted()) {
const before2 = handle.rows().length;
await handle.loadNext();
updateHint();
if (handle.rows().length === before2 && !handle.exhausted()) break;
}
})
);
} finally {
loadingAll = false;
if (searchStopBtn) searchStopBtn.disabled = true;
if (searchAllBtn) searchAllBtn.disabled = false;
summarizeSearchAll();
applyFilters();
}
};
const renderMerged = () => {
if (!mergedListEl) return;
const topicLoaded = loadedRows("topic");
const replyLoaded = loadedRows("reply");
const loadedTotal = topicLoaded.length + replyLoaded.length;
const rows = mergePostRows(topicLoaded, replyLoaded).filter(matchesFilters);
mergedListEl.innerHTML = rows.length ? rows.map(renderPostRow).join("") : `${escapeHtml(
loadedTotal ? "没有符合筛选条件的发帖或回复" : "还没有加载到发帖或回复"
)}
`;
if (mergedNoticeEl) {
const hiddenAll = POST_SUBTABS.every(
(item) => handles.get(item.id)?.hidden()
);
mergedNoticeEl.innerHTML = hiddenAll && !loadedTotal ? `${escapeHtml(HIDDEN_POSTS_NOTE)}
` : "";
}
bindFloorButtons2(mergedListEl);
const loading = POST_SUBTABS.some((item) => pending4.has(item.id));
const done7 = POST_SUBTABS.every((item) => handles.get(item.id)?.exhausted());
const failed = POST_SUBTABS.map((item) => failures2.get(item.id)).filter(
(message) => !!message
);
if (mergedHintEl) {
mergedHintEl.textContent = [
`已加载 ${topicLoaded.length} 个主题帖 + ${replyLoaded.length} 条回复`,
rows.length !== loadedTotal ? `当前显示 ${rows.length} 条` : "",
loading ? "还有数据在加载…" : "",
done7 && failed.length ? `部分数据没取到:${failed.join(";")}` : ""
].filter(Boolean).join(" · ");
}
if (mergedMoreBtn) {
mergedMoreBtn.disabled = loading || done7;
mergedMoreBtn.textContent = loading ? "加载中…" : done7 ? "没有更多了" : "加载更多";
}
};
const applyFilters = () => {
for (const item of POST_SUBTABS) {
for (const row of body.querySelectorAll(
`.tb-eztb-subpane[data-subpane="${item.id}"] .tb-eztb-row`
)) {
row.classList.toggle("tb-eztb-filtered-out", !rowMatchesFilters(row));
}
}
renderMerged();
updateHint();
};
const refreshForumFilter = () => {
if (!filterSelect) return;
const html = buildForumFilterOptionsHtml(counts, forumFilter);
if (filterSelect.dataset.options === html) return;
filterSelect.dataset.options = html;
filterSelect.innerHTML = html;
if (!Array.from(filterSelect.options).some(
(option3) => option3.value === forumFilter
)) {
forumFilter = "";
}
filterSelect.value = forumFilter;
};
filterSelect?.addEventListener("change", () => {
forumFilter = filterSelect.value;
searchAllResult = null;
applyFilters();
});
searchInput?.addEventListener("input", () => {
searchQuery = searchInput.value;
body.dataset.query = searchQuery;
searchAllResult = null;
applyFilters();
});
searchInput?.addEventListener("keydown", (event) => {
if (event.key !== "Enter") return;
event.preventDefault();
void loadAllPages();
});
searchAllBtn?.addEventListener("click", () => {
void loadAllPages();
});
searchStopBtn?.addEventListener("click", () => {
stopLoadingAll = true;
});
mergedMoreBtn?.addEventListener("click", () => {
void (async () => {
await Promise.all(
POST_SUBTABS.map(
(item) => handles.get(item.id)?.loadNext() ?? Promise.resolve()
)
);
renderMerged();
updateHint();
})();
});
for (const button of body.querySelectorAll("[data-postmode]")) {
button.addEventListener("click", () => {
const next4 = button.dataset.postmode === "merged" ? "merged" : "split";
if (next4 === mode) return;
mode = next4;
body.dataset.postmode = mode;
if (viewEl) viewEl.dataset.mode = mode;
for (const item of body.querySelectorAll("[data-postmode]")) {
item.classList.toggle("active", item.dataset.postmode === mode);
}
applyFilters();
});
}
const updatePie = () => {
if (!pieEl) return;
const notes = buildPieNotes(
POST_SUBTABS.map((item) => {
const handle = handles.get(item.id);
return {
label: item.label,
loading: pending4.has(item.id),
error: failures2.get(item.id),
hasRows: loadedAny.has(item.id),
// 失败提示要写到页上:只写"后续页没取到"没法判断是第 2 页还是第 9 页
failedPage: handle?.failedPage() ?? void 0,
loadedPages: handle?.pages() ?? 0
};
})
);
pieEl.innerHTML = buildForumPieSvg(counts) + notes + buildForumListHtml(counts, listOpen);
pieEl.querySelector('[data-act="pie-all"]')?.addEventListener("click", () => {
listOpen = !listOpen;
updatePie();
});
refreshForumFilter();
applyFilters();
};
updatePie();
const mounted = /* @__PURE__ */ new Set();
const mount2 = (id) => {
if (mounted.has(id)) return;
mounted.add(id);
const pane = body.querySelector(
`.tb-eztb-subpane[data-subpane="${id}"]`
);
if (pane) {
const handle = mountPostsSubList(
pane,
identity4,
id,
(rows) => {
pending4.delete(id);
failures2.delete(id);
loadedAny.add(id);
if (!loadingAll) searchAllResult = null;
counts = mergeForumCounts(counts, countPostsByForum(rows));
updatePie();
},
(message) => {
pending4.delete(id);
failures2.set(id, message);
updatePie();
}
);
handles.set(id, handle);
}
};
const activate = (id) => {
body.dataset.subtab = id;
for (const button of body.querySelectorAll(".tb-eztb-subtab")) {
button.classList.toggle("active", button.dataset.subtab === id);
}
for (const pane of body.querySelectorAll(".tb-eztb-subpane")) {
pane.classList.toggle("active", pane.dataset.subpane === id);
}
mount2(id);
};
for (const item of POST_SUBTABS) mount2(item.id);
activate(active2);
for (const button of body.querySelectorAll(".tb-eztb-subtab")) {
button.addEventListener("click", () => {
const id = button.dataset.subtab;
if (id === "topic" || id === "reply") activate(id);
});
}
}
// src/features/panel/profile.ts
function renderProfile(body, identity4) {
const profile = identity4.profile ?? {};
const genderText = profile.gender === 1 ? "男" : profile.gender === 2 ? "女" : "未知";
const rows = [
["贴吧号", profile.uid ? String(profile.uid) : "—"],
["用户名", profile.un || "—"],
["昵称", profile.nickname || "—"],
["等级", profile.level ? String(profile.level) : "—"],
["吧龄", profile.tbAge || "—"],
["发帖数", profile.postNum !== void 0 ? String(profile.postNum) : "—"],
["粉丝数", profile.fansNum !== void 0 ? String(profile.fansNum) : "—"],
["关注数", profile.concernNum !== void 0 ? String(profile.concernNum) : "—"],
["关注吧数", profile.likeNum !== void 0 ? String(profile.likeNum) : "—"],
["性别", genderText],
["IP 属地", profile.ip || "—"],
["会员", profile.vipLevel ? `VIP ${profile.vipLevel}` : "—"],
["吧务", profile.isBawu ? profile.bawuType || "是" : "—"],
["简介", profile.intro || "—"]
];
body.innerHTML = `${rows.map(
([key, value]) => `- ${escapeHtml(key)}
- ${escapeHtml(value)}
`
).join("")}
` + (identity4.portrait ? `` : "") + `
`;
body.querySelector('[data-act="home"]')?.addEventListener("click", () => {
if (!identity4.portrait) return;
window.open(
`https://tieba.baidu.com/home/main?id=${encodeURIComponent(identity4.portrait)}`,
"_blank",
"noopener,noreferrer"
);
});
}
// src/features/userPanel.ts
var TABS = PANEL_TABS;
function openUserPanel(ref, options = {}) {
const settings = getSettings();
const initialTab = options.tab ?? (settings.rememberLastTab ? readLastTab() ?? normalizePanelTabId(settings.defaultTab) : normalizePanelTabId(settings.defaultTab));
const dialog = openDialog({
title: ref.nickname || ref.un || "贴吧用户",
subtitleHtml: "正在解析用户信息…",
avatarUrl: ref.portrait ? portraitUrl(ref.portrait) : void 0,
tabs: TABS.map((tab) => ({ id: tab.id, label: tab.label })),
activeTab: initialTab,
footerHtml: `数据由脚本直连贴吧接口获取`
});
dialog.footer.querySelector('[data-act="settings"]')?.addEventListener("click", () => openSettingsDialog());
const panes = /* @__PURE__ */ new Map();
const initialized = /* @__PURE__ */ new Set();
let activeTabId = initialTab;
let currentIdentity = null;
let requestedTab = initialTab;
for (const tab of TABS) {
dialog.root.querySelector(`.tb-eztb-tab[data-tab="${tab.id}"]`)?.addEventListener("click", () => {
requestedTab = tab.id;
if (currentIdentity) switchTab(tab.id, currentIdentity);
});
}
const paneFor = (id) => {
const existing = panes.get(id);
if (existing) return existing;
const pane = document.createElement("div");
pane.className = "tb-eztb-pane";
pane.dataset.pane = id;
dialog.body.appendChild(pane);
panes.set(id, pane);
return pane;
};
const switchTab = (id, identity4) => {
if (getSettings().rememberLastTab) writeLastTab(id);
activeTabId = id;
for (const [key, pane2] of panes) {
pane2.classList.toggle("active", key === id);
}
const pane = paneFor(id);
pane.classList.add("active");
dialog.body.scrollTop = 0;
if (initialized.has(id)) return;
initialized.add(id);
switch (id) {
case "profile":
renderProfile(pane, identity4);
break;
case "composition":
renderCompositionTab(pane, ref);
break;
case "follow":
renderFollowUsersTab(pane, identity4);
break;
case "mutual":
renderMutualTab(pane, identity4);
break;
case "forums":
renderFollowForumsTab(pane, identity4);
break;
case "fans":
renderFansTab(pane, identity4);
break;
case "posts":
renderPostsTab(pane, identity4);
break;
}
};
const applyIdentity = (identity4) => {
currentIdentity = identity4;
dialog.setTitle(identity4.nickname || identity4.un || "贴吧用户");
dialog.setSubtitle(
[
identity4.un ? `用户名 ${escapeHtml(identity4.un)}` : "",
identity4.uid ? `贴吧号 ${escapeHtml(identity4.uid)}` : ""
].filter(Boolean).join(" · ") || "已解析"
);
if (identity4.portrait) {
dialog.setAvatar(portraitUrl(identity4.portrait));
}
};
const refreshButton = dialog.footer.querySelector(
'[data-act="refresh"]'
);
refreshButton?.addEventListener("click", () => {
if (!currentIdentity || refreshButton.disabled) return;
refreshButton.disabled = true;
const originalLabel = refreshButton.textContent ?? "刷新当前页签";
refreshButton.textContent = "刷新中…";
void (async () => {
try {
const identity4 = await resolveIdentity(ref, true);
applyIdentity(identity4);
const pane = panes.get(activeTabId);
if (pane) pane.innerHTML = "";
initialized.delete(activeTabId);
switchTab(activeTabId, identity4);
refreshButton.textContent = "已刷新";
} catch (error) {
refreshButton.textContent = "刷新失败";
log.warn("刷新当前页签失败:", error);
} finally {
setTimeout(() => {
refreshButton.disabled = false;
refreshButton.textContent = originalLabel;
}, 1e3);
}
})();
});
dialog.body.innerHTML = ``;
void (async () => {
try {
const identity4 = await resolveIdentity(ref);
applyIdentity(identity4);
dialog.body.innerHTML = "";
switchTab(requestedTab, identity4);
} catch (error) {
const message = describeRequestError(error);
const needBduss = /BDUSS/i.test(message);
dialog.body.innerHTML = `${escapeHtml(message)}
` + (needBduss ? `` : "");
dialog.body.querySelector('[data-act="config"]')?.addEventListener(
"click",
() => openSettingsDialog({ requireBduss: true })
);
}
})();
}
// src/ui/styles.ts
var THEME_VARS = `
:root{
--tb-eztb-accent:#1677ff;
--tb-eztb-accent-border:#bcd8ff;
--tb-eztb-accent-bg:#e8f3ff;
--tb-eztb-spinner-track:#d6e4ff;
--tb-eztb-info-bg:#f2f7ff;
--tb-eztb-info-border:#cfe0ff;
--tb-eztb-info-text:#1a4d99;
--tb-eztb-danger-bg:#fff5f5;
--tb-eztb-danger-border:#ffd8d8;
--tb-eztb-danger-text:#c0392b;
--tb-eztb-warn-bg:#fffaf0;
--tb-eztb-warn-bg-soft:#fff8e6;
--tb-eztb-warn-border:#ffe2b8;
--tb-eztb-warn-text:#8a5a00;
--tb-eztb-warn-text-strong:#9a6700;
--tb-eztb-mark-bg:#fff3bf;
--tb-eztb-text:#222;
--tb-eztb-text-strong:#24292f;
--tb-eztb-text-muted:#57606a;
--tb-eztb-text-dim:#666;
--tb-eztb-text-faint:#8a8f99;
--tb-eztb-text-on-soft:#555;
--tb-eztb-text-busy:#999;
--tb-eztb-surface:#fff;
--tb-eztb-surface-alt:#fafbfc;
--tb-eztb-surface-soft:#f6f8fa;
--tb-eztb-surface-busy:#fafafa;
--tb-eztb-surface-hover:#e2e5ea;
--tb-eztb-chip:#f2f3f5;
--tb-eztb-chip-alt:#eef0f3;
--tb-eztb-chip-strong:#eef1f4;
--tb-eztb-border:#e8e8e8;
--tb-eztb-border-soft:#e4e8ec;
--tb-eztb-border-muted:#e0e3e7;
--tb-eztb-border-input:#d0d7de;
--tb-eztb-border-busy:#ddd;
--tb-eztb-pie-track:#eef0f3;
--tb-eztb-overlay:rgba(0,0,0,.45);
--tb-eztb-shadow:rgba(0,0,0,.28);
/* 成分徽章的色相由 JS 按下标给,这里只切明度 */
--tb-eztb-badge-fg:28%;
--tb-eztb-badge-bg:94%;
--tb-eztb-badge-bd:76%;
}
@media (prefers-color-scheme: dark){
:root{
--tb-eztb-accent:#4c9aff;
--tb-eztb-accent-border:#33507a;
--tb-eztb-accent-bg:#1b2a41;
--tb-eztb-spinner-track:#33507a;
--tb-eztb-info-bg:#1c2a3d;
--tb-eztb-info-border:#33507a;
--tb-eztb-info-text:#8fbaff;
--tb-eztb-danger-bg:#3a2326;
--tb-eztb-danger-border:#5c3236;
--tb-eztb-danger-text:#ff8b7d;
--tb-eztb-warn-bg:#322a1c;
--tb-eztb-warn-bg-soft:#322a1c;
--tb-eztb-warn-border:#5a4629;
--tb-eztb-warn-text:#e0b060;
--tb-eztb-warn-text-strong:#e0b060;
--tb-eztb-mark-bg:#4a3f1a;
--tb-eztb-text:#e6e8eb;
--tb-eztb-text-strong:#e6e8eb;
--tb-eztb-text-muted:#a8b0ba;
--tb-eztb-text-dim:#a8b0ba;
--tb-eztb-text-faint:#8b939d;
--tb-eztb-text-on-soft:#c2c8d0;
--tb-eztb-text-busy:#7d858f;
--tb-eztb-surface:#1c1f24;
--tb-eztb-surface-alt:#202429;
--tb-eztb-surface-soft:#262b31;
--tb-eztb-surface-busy:#262b31;
--tb-eztb-surface-hover:#333941;
--tb-eztb-chip:#2b3036;
--tb-eztb-chip-alt:#2b3036;
--tb-eztb-chip-strong:#2b3036;
--tb-eztb-border:#343a41;
--tb-eztb-border-soft:#343a41;
--tb-eztb-border-muted:#343a41;
--tb-eztb-border-input:#3c434b;
--tb-eztb-border-busy:#3c434b;
--tb-eztb-pie-track:#343a41;
--tb-eztb-overlay:rgba(0,0,0,.6);
--tb-eztb-shadow:rgba(0,0,0,.55);
--tb-eztb-badge-fg:78%;
--tb-eztb-badge-bg:20%;
--tb-eztb-badge-bd:34%;
}
}
`;
var STYLE_TEXT = `${THEME_VARS}
.tb-eztb-btn{
display:inline-block !important;margin-left:6px;padding:2px 10px;
font:normal 12px/18px -apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei",sans-serif !important;
color:var(--tb-eztb-accent) !important;background:var(--tb-eztb-surface) !important;border:1px solid var(--tb-eztb-accent-border) !important;
border-radius:6px;cursor:pointer;text-decoration:none !important;vertical-align:middle;
white-space:nowrap;user-select:none;position:relative;
/* 行被挤时按钮自己不许被压缩(点了才知道还能不能查) */
flex:0 0 auto;
/* 只抬到能盖住同层内容即可。曾经设成 2e9,导致页面弹层/遮罩该盖住按钮时盖不住,
表现为「本该被遮挡却浮在最上层」。5 与基线脚本一致。 */
z-index:5;
opacity:1 !important;visibility:visible !important;box-shadow:none;
/* 贴吧新版页面的操作条容器常为 pointer-events:none(悬停才展开),
子元素必须显式恢复,否则按钮看得见、点不动。基线脚本同样用这条覆盖。 */
pointer-events:auto !important;
}
.tb-eztb-btn:hover{background:var(--tb-eztb-accent-bg) !important;border-color:var(--tb-eztb-accent) !important;}
.tb-eztb-btn.busy{color:var(--tb-eztb-text-busy) !important;border-color:var(--tb-eztb-border-busy) !important;background:var(--tb-eztb-surface-busy) !important;cursor:wait;}
.tb-eztb-mask{
position:fixed;inset:0;z-index:2147483647;display:flex;align-items:center;justify-content:center;
background:var(--tb-eztb-overlay);
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei",sans-serif;
}
.tb-eztb-dialog{
display:flex;flex-direction:column;width:min(820px,calc(100vw - 32px));
height:min(84vh,760px);background:var(--tb-eztb-surface) !important;border-radius:12px;overflow:hidden;
box-shadow:0 12px 48px var(--tb-eztb-shadow);color:var(--tb-eztb-text) !important;font-size:14px;line-height:1.6;
/* 原生滚动条与表单控件跟着系统偏好走,否则深色弹窗里会拖一条亮色滚动条 */
color-scheme:light dark;
/* 弹窗会被注入到贴吧页面里,页面样式可能通过继承污染排版,这里逐项复位 */
text-align:left !important;text-indent:0 !important;letter-spacing:normal !important;
word-spacing:normal !important;white-space:normal !important;
font-weight:400;font-style:normal;
}
.tb-eztb-dialog *{box-sizing:border-box;}
.tb-eztb-head{
display:flex;align-items:center;gap:10px;padding:12px 16px;
border-bottom:1px solid var(--tb-eztb-border);background:var(--tb-eztb-surface-alt) !important;flex:0 0 auto;
}
.tb-eztb-avatar{width:34px;height:34px;border-radius:50%;flex:0 0 auto;background:var(--tb-eztb-chip-alt);}
.tb-eztb-head-main{flex:1 1 auto;min-width:0;}
.tb-eztb-title{
font-weight:600;font-size:15px;color:var(--tb-eztb-text) !important;
overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
}
.tb-eztb-sub{color:var(--tb-eztb-text-faint) !important;font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.tb-eztb-close{
flex:0 0 auto;width:28px;height:28px;line-height:26px;text-align:center;border:none;
background:var(--tb-eztb-chip-alt) !important;border-radius:50%;cursor:pointer;font-size:16px;color:var(--tb-eztb-text-on-soft) !important;padding:0;
}
.tb-eztb-close:hover{background:var(--tb-eztb-surface-hover) !important;}
.tb-eztb-tabs{
display:flex;gap:4px;padding:8px 12px 0;border-bottom:1px solid var(--tb-eztb-border);
background:var(--tb-eztb-surface) !important;flex:0 0 auto;overflow-x:auto;
}
.tb-eztb-tab{
border:none;background:transparent !important;cursor:pointer;padding:7px 12px;
font-size:13px;color:var(--tb-eztb-text-muted) !important;border-bottom:2px solid transparent;white-space:nowrap;
}
.tb-eztb-tab:hover{color:var(--tb-eztb-accent) !important;}
.tb-eztb-tab.active{color:var(--tb-eztb-accent) !important;border-bottom-color:var(--tb-eztb-accent);font-weight:600;}
.tb-eztb-body{position:relative;flex:1 1 auto;min-height:0;overflow:auto;padding:14px 16px;background:var(--tb-eztb-surface) !important;}
/* 每个页签一个独立容器:内容互不覆盖,切换只切显隐,异步回调也不会串台 */
.tb-eztb-pane{display:none !important;}
.tb-eztb-pane.active{display:block !important;}
.tb-eztb-foot{
display:flex;align-items:center;gap:10px;padding:8px 16px;border-top:1px solid var(--tb-eztb-border);
background:var(--tb-eztb-surface-alt) !important;flex:0 0 auto;font-size:12px;color:var(--tb-eztb-text-faint) !important;
}
.tb-eztb-foot .tb-eztb-spacer{flex:1 1 auto;}
.tb-eztb-foot a{color:var(--tb-eztb-accent) !important;text-decoration:none;}
.tb-eztb-linkbtn{
background:none !important;border:none;padding:0;margin:0;cursor:pointer;
color:var(--tb-eztb-accent) !important;font-size:12px;font-family:inherit;line-height:1.6;
}
.tb-eztb-linkbtn:hover{text-decoration:underline;}
.tb-eztb-linkbtn[disabled]{color:var(--tb-eztb-text-faint) !important;cursor:default;text-decoration:none;}
.tb-eztb-loading{display:flex;align-items:center;justify-content:center;gap:10px;padding:40px 0;color:var(--tb-eztb-text-dim) !important;font-size:13px;}
.tb-eztb-spinner{
width:22px;height:22px;border:3px solid var(--tb-eztb-spinner-track);border-top-color:var(--tb-eztb-accent);
border-radius:50%;animation:tb-eztb-spin .8s linear infinite;
}
@keyframes tb-eztb-spin{to{transform:rotate(360deg);}}
.tb-eztb-empty{padding:32px 0;text-align:center;color:var(--tb-eztb-text-faint) !important;font-size:13px;}
.tb-eztb-error{
margin:0 0 12px;padding:10px 12px;border-radius:8px;background:var(--tb-eztb-danger-bg) !important;
border:1px solid var(--tb-eztb-danger-border);color:var(--tb-eztb-danger-text) !important;font-size:13px;word-break:break-all;
}
.tb-eztb-warn{
margin:0 0 12px;padding:10px 12px;border-radius:8px;background:var(--tb-eztb-warn-bg) !important;
border:1px solid var(--tb-eztb-warn-border);color:var(--tb-eztb-warn-text) !important;font-size:12px;
}
.tb-eztb-kv{display:grid;grid-template-columns:96px 1fr;gap:8px 12px;font-size:13px;margin:0;padding:0;}
.tb-eztb-kv dt{color:var(--tb-eztb-text-faint) !important;}
.tb-eztb-kv dd{margin:0;color:var(--tb-eztb-text) !important;word-break:break-word;}
.tb-eztb-list{display:flex;flex-direction:column;gap:2px;}
.tb-eztb-row{
display:flex;align-items:center;gap:10px;padding:8px 6px;border-radius:8px;
text-decoration:none !important;color:inherit !important;
text-align:left !important;white-space:normal !important;
}
.tb-eztb-row:hover{background:var(--tb-eztb-surface-soft) !important;}
.tb-eztb-row-avatar{width:30px;height:30px;border-radius:50%;flex:0 0 auto;background:var(--tb-eztb-chip-alt);}
/* 标题与副标题必须是块级,否则 overflow/ellipsis 对行内元素无效,
两行文字会挤在同一行并撑乱行高。 */
.tb-eztb-row-main{
flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:2px;
}
.tb-eztb-row-title{
display:block;max-width:100%;font-size:13px;line-height:1.45;
color:var(--tb-eztb-text) !important;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
}
.tb-eztb-row-sub{
display:block;max-width:100%;font-size:12px;line-height:1.4;
color:var(--tb-eztb-text-faint) !important;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
}
.tb-eztb-row-meta{
flex:0 0 auto;max-width:40%;font-size:12px;line-height:1.4;
color:var(--tb-eztb-text-faint) !important;text-align:right;white-space:nowrap;
}
/* 右侧要同时放「查楼层」和时间:竖着排,别把行撑宽 */
.tb-eztb-row-meta-stack{
display:flex;flex-direction:column;align-items:flex-end;gap:3px;
}
.tb-eztb-row-time{font-size:12px;color:var(--tb-eztb-text-faint) !important;}
/* 副标题里的小吧名:和正文区分开 */
.tb-eztb-row-forum{
display:inline-block;margin-right:6px;padding:0 5px;border-radius:4px;
background:var(--tb-eztb-chip) !important;color:var(--tb-eztb-text-muted) !important;font-size:11px;line-height:16px;
}
/* 「检测签到号」之后补上的"近期发言 N 条" */
.tb-eztb-row-extra{color:var(--tb-eztb-text-faint) !important;}
/* 楼中楼回复的对象:淡一点,别抢正文 */
.tb-eztb-row-replyto{color:var(--tb-eztb-text-faint) !important;margin-right:4px;}
/* 楼层号(查到了就换成它) */
.tb-eztb-floor{
padding:0 6px;border-radius:4px;background:var(--tb-eztb-chip-strong) !important;color:var(--tb-eztb-text-strong) !important;
font-size:11px;line-height:17px;white-space:nowrap;
}
/* 「疑似只签到」标记:只提示、不结论,所以用弱一点的样式 */
.tb-eztb-signin{
display:inline-block;margin-right:4px;padding:0 6px;border-radius:999px;
background:var(--tb-eztb-warn-bg-soft) !important;color:var(--tb-eztb-warn-text-strong) !important;border:1px dashed var(--tb-eztb-warn-border);
font-size:11px;line-height:16px;white-space:nowrap;
}
/* 「关注的吧」里的签到检测结论区 */
.tb-eztb-activity{margin:0 0 10px;}
.tb-eztb-activity .tb-eztb-hint{margin:0 0 6px;}
/* 「发帖」页签顶部的占比饼图 */
.tb-eztb-piestat{margin:0 0 12px;}
.tb-eztb-pie{
display:flex;align-items:center;gap:16px;margin:0;padding:10px 12px;
border:1px solid var(--tb-eztb-border);border-radius:8px;background:var(--tb-eztb-surface-alt) !important;
}
/* 空数据时的那圈底环:颜色必须走变量,写死会在深色模式下变成一圈亮灰 */
.tb-eztb-pie-svg{width:96px;height:96px;flex:0 0 auto;}
.tb-eztb-pie-track{stroke:var(--tb-eztb-pie-track);}
.tb-eztb-pie-legend{display:flex;flex-direction:column;gap:4px;font-size:12px;min-width:0;}
.tb-eztb-pie-item{display:flex;align-items:center;gap:6px;color:var(--tb-eztb-text-muted) !important;}
.tb-eztb-pie-dot{
width:8px;height:8px;border-radius:50%;flex:0 0 auto;display:inline-block;
}
/* 吧名可以很长:给个上限并省略,别把图例撑破 */
.tb-eztb-pie-label{
display:inline-block;max-width:150px;overflow:hidden;text-overflow:ellipsis;
white-space:nowrap;vertical-align:bottom;
}
.tb-eztb-pie-count{color:var(--tb-eztb-text-strong) !important;font-weight:600;}
.tb-eztb-pie-percent{color:var(--tb-eztb-text-faint) !important;}
.tb-eztb-pie-total{color:var(--tb-eztb-text-faint) !important;margin-top:2px;}
.tb-eztb-pie-empty{color:var(--tb-eztb-text-faint) !important;}
/* 「查看全部 N 个吧」按钮与展开后的完整列表 */
.tb-eztb-pielistwrap{margin-top:8px;}
/* 两路 feed 没到齐时的提示:不能让"只有主题帖"的饼图看起来像完整的 */
.tb-eztb-pie-pending{
margin:6px 0 0;padding:6px 8px;border-radius:6px;font-size:12px;
background:var(--tb-eztb-info-bg) !important;border:1px solid var(--tb-eztb-info-border);color:var(--tb-eztb-info-text) !important;
}
.tb-eztb-pielistbtn{
padding:2px 10px;border:1px solid var(--tb-eztb-border-input);border-radius:6px;cursor:pointer;
background:var(--tb-eztb-surface) !important;color:var(--tb-eztb-accent) !important;font:inherit;font-size:12px;
}
.tb-eztb-pielistbtn:hover{background:var(--tb-eztb-accent-bg) !important;border-color:var(--tb-eztb-accent-border);}
.tb-eztb-pielist{
margin-top:8px;max-height:240px;overflow:auto;
border:1px solid var(--tb-eztb-border);border-radius:8px;background:var(--tb-eztb-surface) !important;padding:6px 8px;
}
.tb-eztb-pielist-head{font-size:12px;color:var(--tb-eztb-text-faint) !important;margin:2px 0 6px;}
.tb-eztb-pieitem{display:flex;align-items:center;gap:8px;padding:3px 0;font-size:12px;}
.tb-eztb-pieitem-name{
flex:0 0 auto;width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
color:var(--tb-eztb-text-strong) !important;
}
.tb-eztb-pieitem-bar{
flex:1 1 auto;min-width:40px;height:8px;border-radius:4px;background:var(--tb-eztb-chip-strong) !important;overflow:hidden;
}
.tb-eztb-pieitem-bar > i{display:block;height:100%;background:var(--tb-eztb-accent) !important;border-radius:4px;}
.tb-eztb-pieitem-count{flex:0 0 auto;min-width:34px;text-align:right;color:var(--tb-eztb-text-strong) !important;}
.tb-eztb-pieitem-percent{flex:0 0 auto;min-width:48px;text-align:right;color:var(--tb-eztb-text-faint) !important;}
/* 三种行内小按钮共用一套样式:「查等级」「查楼层」「检测签到号」。
注意别互相复用类名——测试和排查都按类名找按钮,混用会点错目标。 */
.tb-eztb-levelbtn,.tb-eztb-floorbtn,.tb-eztb-minibtn{
padding:1px 8px;border:1px solid var(--tb-eztb-accent-border);border-radius:6px;cursor:pointer;
background:var(--tb-eztb-surface) !important;color:var(--tb-eztb-accent) !important;font:inherit;font-size:12px;
}
.tb-eztb-levelbtn:hover,.tb-eztb-floorbtn:hover,.tb-eztb-minibtn:hover{background:var(--tb-eztb-accent-bg) !important;}
.tb-eztb-levelbtn[disabled],.tb-eztb-floorbtn[disabled],.tb-eztb-minibtn[disabled]{
opacity:.6;cursor:default;color:var(--tb-eztb-text-faint) !important;border-color:var(--tb-eztb-border-muted);
}
/* 发帖页签的类型标签:主题 / 回复 / 楼中楼 */
.tb-eztb-tag{
display:inline-block;margin-right:6px;padding:0 6px;border-radius:4px;
font-size:11px;line-height:17px;vertical-align:1px;white-space:nowrap;
}
.tb-eztb-tag-topic{background:var(--tb-eztb-accent-bg) !important;color:var(--tb-eztb-accent) !important;border:1px solid var(--tb-eztb-accent-border);}
.tb-eztb-tag-reply{background:var(--tb-eztb-chip) !important;color:var(--tb-eztb-text-muted) !important;border:1px solid var(--tb-eztb-border-muted);}
.tb-eztb-tag-sub{background:var(--tb-eztb-warn-bg-soft) !important;color:var(--tb-eztb-warn-text-strong) !important;border:1px solid var(--tb-eztb-warn-border);}
/* 「发帖」页签里的两个子页签(主题帖 / 回复):两个 feed 各自分页,互不影响 */
/* 「发帖」页签的工具条:显示方式(分开 / 合并)+ 只看某个吧 + 内容搜索。
只筛下面的列表,饼图仍然统计全部(把饼图筛成一段没有信息量)。
选择器带 .tb-eztb-postbar 前缀是为了盖过 .tb-eztb-input 的 width:100%(同优先级时后者会赢)。 */
.tb-eztb-postbar{margin:0 0 10px;}
.tb-eztb-postbar-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;}
.tb-eztb-postbar-label{font-size:12px;color:var(--tb-eztb-text-muted) !important;white-space:nowrap;}
.tb-eztb-modetabs{display:inline-flex;gap:4px;}
.tb-eztb-modetab{
padding:3px 12px;border:1px solid var(--tb-eztb-border-input);border-radius:999px;cursor:pointer;
background:var(--tb-eztb-surface-soft) !important;color:var(--tb-eztb-text-muted) !important;
font:inherit;font-size:12px;line-height:20px;white-space:nowrap;
}
.tb-eztb-modetab:hover{color:var(--tb-eztb-accent) !important;border-color:var(--tb-eztb-accent-border);}
.tb-eztb-modetab.active{
background:var(--tb-eztb-accent-bg) !important;border-color:var(--tb-eztb-accent);
color:var(--tb-eztb-accent) !important;font-weight:600;
}
.tb-eztb-postbar .tb-eztb-forumfilter{width:auto;max-width:220px;padding:4px 8px;font-size:12px;}
.tb-eztb-postbar .tb-eztb-postsearch{width:auto;flex:1 1 160px;max-width:260px;padding:4px 8px;font-size:12px;}
.tb-eztb-postbar .tb-eztb-hint{margin-top:6px;}
/* 分开 / 合并:同一时刻只显示一种视图 */
.tb-eztb-postview[data-mode="split"] .tb-eztb-mergedview{display:none !important;}
.tb-eztb-postview[data-mode="merged"] .tb-eztb-splitview{display:none !important;}
/* 被筛掉的行只藏起来(DOM 里留着),翻页新加载的行走同一套筛选规则 */
.tb-eztb-row.tb-eztb-filtered-out{display:none !important;}
.tb-eztb-subtabs{display:flex;gap:6px;margin:0 0 10px;}
.tb-eztb-subtab{
padding:3px 12px;border:1px solid var(--tb-eztb-border-input);border-radius:999px;cursor:pointer;
background:var(--tb-eztb-surface-soft) !important;color:var(--tb-eztb-text-muted) !important;
font:inherit;font-size:12px;line-height:20px;white-space:nowrap;
}
.tb-eztb-subtab:hover{color:var(--tb-eztb-accent) !important;border-color:var(--tb-eztb-accent-border);}
.tb-eztb-subtab.active{
background:var(--tb-eztb-accent-bg) !important;border-color:var(--tb-eztb-accent);
color:var(--tb-eztb-accent) !important;font-weight:600;
}
/* 同 .tb-eztb-pane:只切显隐,切回来时已加载的内容还在 */
.tb-eztb-subpane{display:none !important;}
.tb-eztb-subpane.active{display:block !important;}
/* 「成分」标记:命中关键词时挂在用户名旁的徽章。
和按钮一样必须显式声明 pointer-events:auto —— 新版页面的 .btn-wrapper
常态是 pointer-events:none,否则徽章看得见、点不动(同一个坑)。
还有一条:新版页面的头部行是**固定高度**(.image-text .user-info{height:40px}),
标记一旦折行就会顶到下面的标题/正文上(实测量到压住 13px)。
所以这里强制单行、允许被压缩裁剪——少显示几个标记,也不许压内容。 */
.tb-eztb-badges{
display:inline-flex;flex-wrap:nowrap;align-items:center;gap:4px;
margin-left:6px;vertical-align:middle;min-width:0;overflow:hidden;
}
/* 行实在挤不下时,先让我们这块被压缩裁剪,而不是把整行顶出去。
:has 保证只在"这个槽里真的插了我们的标记"时才生效,不干扰页面自己的按钮。 */
.head-line > .btn-wrapper:has(> .tb-eztb-badges){min-width:0;flex-shrink:1;}
/* 回复行(头部行后面紧跟正文块 .comment-content):
正文块会**往上顶**到头部行的下半部分(实测:40px 的行,正文从 y=271 开始,
而按钮默认在这行里垂直居中、占 255~279,正好压住正文第一行的尾巴)。
这类行里把按钮/标记贴到行顶——行顶那 24px 是空的,正文碰不到。
用户报的"查询按钮有时部分遮挡发言"就是这个(快照里 20/23 行如此)。 */
.head-line:has(+ .comment-content) > .btn-wrapper{align-self:flex-start;padding-top:0;}
.tb-eztb-badge{
display:inline-block;padding:0 6px;border-radius:999px;font-size:11px;line-height:17px;
font-weight:600;white-space:nowrap;cursor:pointer;pointer-events:auto !important;flex:0 0 auto;
color:hsl(var(--tb-eztb-badge-hue,210) 62% var(--tb-eztb-badge-fg)) !important;
background:hsl(var(--tb-eztb-badge-hue,210) 92% var(--tb-eztb-badge-bg)) !important;
border:1px solid hsl(var(--tb-eztb-badge-hue,210) 72% var(--tb-eztb-badge-bd));
}
.tb-eztb-badge:hover{filter:brightness(.97);}
/* 证据较弱(只在回复/楼中楼里出现):虚线边框 + 降透明度 */
.tb-eztb-badge-unsure{opacity:.72;border-style:dashed;}
.tb-eztb-badge-more{
background:var(--tb-eztb-chip) !important;color:var(--tb-eztb-text-muted) !important;border-color:var(--tb-eztb-border-input);
}
/* 行里连一个标记都放不下时的兜底:一个小圆点,颜色仍然区分规则 */
.tb-eztb-badge-dot{padding:0 5px;font-size:10px;line-height:17px;}
/* 「证据不足」:没有命中、但这次的数据不足以判定。用中性灰,不参与规则配色,
免得看起来像"命中了某条规则"(HANDOFF §9.2)。 */
.tb-eztb-badge-insufficient{
color:var(--tb-eztb-text-muted) !important;
background:var(--tb-eztb-chip) !important;
border-color:var(--tb-eztb-border-input);
border-style:solid;
font-weight:500;
}
/* 键盘可达:徽章自己是 span(用 button 会被 .btn-wrapper 的样式带跑),
所以显式给一圈焦点环,键盘用户才看得出焦点在哪。 */
.tb-eztb-badge:focus-visible,.tb-eztb-btn:focus-visible{
outline:2px solid var(--tb-eztb-accent);
outline-offset:1px;
}
/* 面板「成分」页签 */
.tb-eztb-hits{display:flex;flex-direction:column;gap:10px;}
.tb-eztb-hit{
padding:10px 12px;border:1px solid var(--tb-eztb-border);border-radius:8px;background:var(--tb-eztb-surface-alt) !important;
}
.tb-eztb-hit-head{display:flex;align-items:center;gap:8px;margin-bottom:6px;flex-wrap:wrap;}
.tb-eztb-hit-head .tb-eztb-badge{cursor:default;}
.tb-eztb-hit-unsure{font-size:12px;color:var(--tb-eztb-warn-text) !important;}
/* 命中规则右上角写"最近依据"的时间:证据按时间倒序,得让用户看出为什么这么排 */
.tb-eztb-hit-newest{font-size:11px;color:var(--tb-eztb-text-muted) !important;margin-left:auto;}
.tb-eztb-evidence{
display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin-top:4px;
font-size:12px;color:var(--tb-eztb-text-muted) !important;
}
/* 一行证据的头部:类型标签 + 原因 + 关键词 + 时间 + 吧名 + 回复对象 */
.tb-eztb-evidence-head{display:flex;flex-wrap:wrap;align-items:center;gap:6px;flex:1 1 100%;}
.tb-eztb-evidence-time,.tb-eztb-evidence-forum,.tb-eztb-evidence-replyto{
font-size:11px;color:var(--tb-eztb-text-muted) !important;
}
.tb-eztb-evidence-forum::before{content:"·";margin-right:4px;}
.tb-eztb-evidence-keyword{
padding:0 6px;border-radius:4px;background:var(--tb-eztb-chip-strong) !important;color:var(--tb-eztb-text-strong) !important;
font-size:11px;line-height:17px;
}
.tb-eztb-evidence-text{
flex:1 1 100%;font-size:12px;color:var(--tb-eztb-text-muted) !important;word-break:break-word;
}
/* 跳转 + 楼层那一行 */
.tb-eztb-evidence-actions{display:flex;align-items:center;gap:8px;flex:1 1 100%;margin-top:2px;}
.tb-eztb-evidence-link{
font-size:12px;color:var(--tb-eztb-accent) !important;text-decoration:none;
border-bottom:1px dashed var(--tb-eztb-accent-border);
}
.tb-eztb-evidence-link:hover{border-bottom-style:solid;}
.tb-eztb-mark{background:var(--tb-eztb-mark-bg) !important;color:inherit !important;padding:0 2px;border-radius:2px;}
.tb-eztb-textarea-tall{min-height:150px;}
.tb-eztb-more{
display:block;width:100%;margin-top:12px;padding:8px;border:1px solid var(--tb-eztb-border-input);
background:var(--tb-eztb-surface-soft) !important;border-radius:8px;cursor:pointer;font-size:13px;color:var(--tb-eztb-text-strong) !important;
}
.tb-eztb-more:hover{background:var(--tb-eztb-chip-strong) !important;}
.tb-eztb-more[disabled]{opacity:.6;cursor:default;}
.tb-eztb-form{display:flex;flex-direction:column;gap:12px;}
.tb-eztb-report{
margin:0 0 12px;padding:10px 12px;border-radius:8px;background:var(--tb-eztb-surface-soft) !important;
border:1px solid var(--tb-eztb-border-soft);font:12px/1.6 Consolas,Menlo,monospace !important;
color:var(--tb-eztb-text-strong) !important;white-space:pre-wrap;word-break:break-all;max-height:52vh;overflow:auto;
}
.tb-eztb-field{display:flex;flex-direction:column;gap:6px;}
.tb-eztb-field label{font-size:13px;font-weight:600;color:var(--tb-eztb-text-strong) !important;}
.tb-eztb-textarea,.tb-eztb-input{
width:100%;padding:8px 10px;border:1px solid var(--tb-eztb-border-input);border-radius:8px;
font:13px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei",sans-serif;
color:var(--tb-eztb-text-strong) !important;background:var(--tb-eztb-surface) !important;
}
.tb-eztb-textarea{min-height:76px;resize:vertical;word-break:break-all;}
.tb-eztb-hint{font-size:12px;color:var(--tb-eztb-text-faint) !important;}
.tb-eztb-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:4px;flex-wrap:wrap;}
.tb-eztb-actions button{
border:1px solid var(--tb-eztb-border-input);background:var(--tb-eztb-surface-soft) !important;color:var(--tb-eztb-text-strong) !important;
border-radius:6px;padding:6px 14px;font-size:13px;cursor:pointer;
}
.tb-eztb-actions button.primary{background:var(--tb-eztb-accent) !important;border-color:var(--tb-eztb-accent);color:var(--tb-eztb-surface) !important;}
.tb-eztb-actions button:hover{opacity:.92;}
`;
// src/main.ts
function createButton(ref) {
const button = document.createElement("button");
button.type = "button";
button.className = BUTTON_CLASS;
button.textContent = "查询";
button.title = "查看该用户的资料 / 关注的人 / 关注的吧 / 粉丝 / 发帖";
buttonRefs.set(button, ref);
button.addEventListener("mousedown", (event) => {
event.stopPropagation();
});
return button;
}
var buttonRefs = /* @__PURE__ */ new WeakMap();
function installClickDelegate() {
document.addEventListener(
"click",
(event) => {
const target = event.target;
const badge = target?.closest?.(`.${BADGE_CLASS}`);
if (badge) {
const badgeRef = getBadgeRef(badge);
if (badgeRef) {
event.preventDefault();
event.stopPropagation();
openUserPanel(badgeRef, { tab: "composition" });
return;
}
}
const button = target?.closest?.(`.${BUTTON_CLASS}`);
if (!button) return;
const ref = buttonRefs.get(button);
if (!ref) return;
event.preventDefault();
event.stopPropagation();
log.debug("按钮被点击,正在打开面板:", describeUser(ref));
openUserPanel(ref);
},
true
);
}
function installKeyboardDelegate() {
document.addEventListener(
"keydown",
(event) => {
if (event.key !== "Enter" && event.key !== " " && event.key !== "Spacebar") {
return;
}
const target = event.target;
const badge = target?.closest?.(`.${BADGE_CLASS}`);
if (!badge) return;
const badgeRef = getBadgeRef(badge);
if (!badgeRef) return;
event.preventDefault();
event.stopPropagation();
openUserPanel(badgeRef, { tab: "composition" });
},
true
);
}
var mount = (ref, nameEl, wrapper) => {
if (!ref.userId && !ref.un && !ref.portrait) return;
const button = createButton(ref);
if (wrapper) {
wrapper.appendChild(button);
} else if (nameEl?.parentNode) {
nameEl.parentNode.insertBefore(button, nameEl.nextSibling);
}
if (getSettings().compositionAuto) {
registerUserForComposition(ref, button);
}
};
function registerMenuCommands() {
if (typeof GM_registerMenuCommand !== "function") return;
try {
GM_registerMenuCommand("eztb:设置 BDUSS / 运行参数", () => {
openSettingsDialog();
});
GM_registerMenuCommand("eztb:清空用户资料缓存", () => {
clearProfileCache();
alert("已清空 eztb 用户资料缓存");
});
GM_registerMenuCommand("eztb:清空成分缓存", () => {
clearCompositionCache();
alert("已清空 eztb 成分检测缓存");
});
GM_registerMenuCommand("eztb:重新检测本页用户", () => {
rescanPage();
alert(
`已重新排队检测本页用户(每页最多 ${getSettings().compositionMaxPerPage} 人,按设置里的间隔逐个进行)`
);
});
GM_registerMenuCommand("eztb:诊断当前页面", () => {
openDiagnoseDialog();
});
GM_registerMenuCommand("eztb:中断当前所有在飞请求", () => {
const count3 = abortAllInFlight();
alert(
count3 > 0 ? `已中断 ${count3} 个在飞请求` : "当前没有在飞请求"
);
});
} catch (error) {
log.warn("注册菜单命令失败:", error);
}
}
function boot() {
const style = document.createElement("style");
style.textContent = STYLE_TEXT;
(document.head ?? document.documentElement).appendChild(style);
requestQueue.setMinInterval(getSettings().minIntervalMs);
registerMenuCommands();
installClickDelegate();
installKeyboardDelegate();
startScanner(mount);
log.info("已加载:数据直连贴吧接口,不经过第三方服务");
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot, { once: true });
} else {
boot();
}
})();
/* ===========================================================================
* NOTICE · 本文件打包进来的第三方代码
*
* 这不是 @require 进来的外部脚本,而是构建时打包进来的库(见 src/ 与 build.mjs)。
* 按 Greasy Fork 的规定,内嵌的库要写明来源、名称与版本。
* 下面这份清单由 scripts/deps-info.mjs 在构建时从磁盘上的 package.json 与 git 读出,
* 不是手写的,所以不会随着上游更新而失真。
*
* tieba.js 3.1.3 · 许可 ISC
* 来源 https://github.com/Dilettante258/tieba.js
* 锁定提交 db48716f754c4cfd8ebbeb87d447c378dafeb0eb
* 依上游 package.json 的 license 字段声明为 ISC。该仓库暂未附带 LICENSE 文件,
* 这里按 ISC 模板补一份(版权人取自 package.json 的 author:Dilettante258):
*
* Copyright (c) Dilettante258
* Permission to use, copy, modify, and/or distribute this software for any
* purpose with or without fee is hereby granted, provided that the above
* copyright notice and this permission notice appear in all copies.
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
* WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
* ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
* WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
* ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*
* 建议仍向上游要一份正式 LICENSE 文件。
* 上游 eztb v3 · 构建时用的那一份检出(见 sdk.lock.json)
* 来源 https://github.com/Dilettante258/eazy-tieba
* 锁定提交 8ca0637c9c4082979f61626718b072a076ba7508
* effect 3.19.18 · 许可 MIT
* 来源 https://github.com/Effect-TS/effect
* @bufbuild/protobuf 2.11.0 · 许可 (Apache-2.0 AND BSD-3-Clause)
* 来源 https://github.com/bufbuild/protobuf-es
* long 5.3.2 · 许可 Apache-2.0
* 来源 https://github.com/dcodeIO/long.js
*
* 本工程自己的代码按上面的 @license 发布。
* =========================================================================== */