// ==UserScript==
// @name 帖子观点阵营AI分析(开箱即用)
// @namespace https://github.com/sedoruee
// @version 2.0.6
// @description null
// @author Sedoruee
// @grant GM.xmlHttpRequest
// @grant GM_xmlhttpRequest
// @grant GM_getValue
// @grant GM_setValue
// @grant GM.setValue
// @grant GM_addStyle
// @grant GM_deleteValue
// @grant GM.info
// @run-at document-start
// @match *://bgm.tv/*
// @match *://chii.in/*
// @match *://bangumi.tv/*
// @connect *
// @license MIT
// ==/UserScript==
(async function() {
'use strict';
if (!/^\/group\/topic\//.test(window.location.pathname)) {
return;
}
const SELECTORS = {
title: 'h1',
mainPost: 'div.topic_content',
replies: '#comment_list [id^="post_"]',
replyInner: '.inner',
userName: 'a.l',
settingsMenu: '#robot_speech_js > ul',
copyright: 'div.copyright'
};
const CONFIG_KEY = 'BGM_FACTION_ANALYZER_CONFIG_V2';
const CACHE_PREFIX = 'BGM_FACTION_ANALYZER_CACHE_V2_';
const STATS_KEY = 'BGM_FACTION_ANALYZER_STATS_V2';
const MAX_STATS_ENTRIES = 10;
const MAX_LOG_ENTRIES = 100;
const logEntries = [];
const DEFAULT_CONFIG = {
apiKey: 'sk-qO8OX6SysmGgakFMOO4ZmiqQ0VciFyUTs0KSQyd3xflSfBKJ',
apiBaseUrl: 'https://miaodi.zeabur.app/v1/chat/completions',
modelName: 'deepseek-ai/DeepSeek-V3-0324',
showAnalysisButton: true
};
function logEvent(message, details = null) {
const entry = { timestamp: new Date(), message, details };
logEntries.push(entry);
if (logEntries.length > MAX_LOG_ENTRIES) logEntries.shift();
if (details && (details.error || /失败/.test(message))) {
console.error(`[BFA Log] ${message}`, details);
} else {
console.log(`[BFA Log] ${message}`, details || '');
}
}
GM_addStyle(`
.bfa-controls-container { display: flex; gap: 10px; align-items: center; margin: 10px 0; }
.bfa-btn { background-color: #f09199; color: white; border: none; padding: 6px 12px; border-radius: 5px; cursor: pointer; font-size: 13px; transition: background-color 0.3s; min-width: 120px; text-align: center; }
.bfa-btn:hover { background-color: #e07179; }
.bfa-btn:disabled { background-color: #ccc; cursor: not-allowed; }
.bfa-analysis-settings-btn { background-color: #f09199; color: white; border: none; padding: 0; width: 24px; height: 24px; border-radius: 50%; font-size: 14px; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: background-color 0.3s; margin-left: 5px; flex-shrink: 0; min-width: 24px; }
.bfa-analysis-settings-btn:hover { background-color: #e07179; }
.bfa-footer-settings-container { text-align: right; margin-right: 20px; margin-bottom: 10px; }
.bfa-footer-settings-container .bfa-analysis-settings-btn { margin-left: 0; display: inline-flex; }
.bfa-camp-marker { display: inline-block; vertical-align: middle; padding: 1px 5px; border-radius: 10px; font-size: 10px; color: white; margin-right: 5px; margin-bottom: 4px; border: 1px solid rgba(0,0,0,0.1); max-width: calc(100% - 10px); overflow: hidden; text-overflow: ellipsis; }
.bfa-viz-container { border: 1px solid #ddd; border-radius: 8px; padding: 15px; margin: 20px 0; position: relative; }
.bfa-viz-controls { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
.bfa-viz-btn { font-size: 12px; padding: 4px 8px; cursor: pointer; background: #eee; border: 1px solid #ccc; border-radius: 4px; }
.bfa-viz-right-controls { display: flex; gap: 8px; }
.bfa-tug-of-war { display: flex; width: 100%; height: 40px; border-radius: 5px; overflow: hidden; }
.bfa-tug-of-war > div { display: flex; align-items: center; justify-content: center; color: white; font-weight: bold; text-shadow: 1px 1px 2px rgba(0,0,0,0.5); transition: all 0.5s ease-in-out; padding: 2px 4px; overflow: hidden; cursor: pointer; text-align: center; line-height: 1.1; word-break: break-word; }
.bfa-camp-info { text-align: center; margin-top: 10px; font-size: 13px; color: #555; }
.bfa-bar-chart { display: none; }
.bfa-bar-item { display: flex; align-items: center; margin-bottom: 8px; cursor: pointer; }
.bfa-bar-label { width: 150px; text-align: right; padding-right: 10px; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex-shrink: 0; }
.bfa-bar-wrapper { flex-grow: 1; background: #f0f0f0; border-radius: 3px; }
.bfa-bar { height: 20px; border-radius: 3px; transition: width 0.5s ease-in-out; }
.bfa-bar-count { margin-left: 10px; font-size: 13px; width: 60px; flex-shrink: 0; text-align: left; }
.bfa-settings-panel, .bfa-log-panel { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); z-index: 10002; display: none; width: 90%; max-width: 500px; max-height: 90vh; overflow-y: auto; color: #333; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; font-size: 14px; }
.bfa-settings-panel h3, .bfa-log-panel h3 { margin-top: 0; margin-bottom: 20px; color: #444; font-size: 18px; text-align: center; }
.bfa-settings-panel .form-group { margin-bottom: 15px; }
.bfa-settings-panel label { display: block; margin-bottom: 5px; font-weight: bold; }
.bfa-settings-panel input[type="text"], .bfa-settings-panel input[type="password"], .bfa-settings-panel select { width: 100%; padding: 8px 10px; border: 1px solid #ccc; border-radius: 4px; box-sizing: border-box; }
.bfa-settings-panel .actions, .bfa-log-panel .actions { text-align: right; margin-top: 20px; }
.bfa-settings-panel button, .bfa-log-panel button { margin-left: 10px; padding: 8px 15px; border: none; border-radius: 4px; cursor: pointer; }
.bfa-settings-panel .save-btn, .bfa-log-panel .save-btn { background: #2b88ff; color: white; }
.bfa-settings-panel .close-btn, .bfa-log-panel .close-btn { background: #eee; color: #666; }
.bfa-log-panel pre { background-color: #f4f4f4; border: 1px solid #ddd; padding: 10px; border-radius: 4px; white-space: pre-wrap; word-wrap: break-word; max-height: 60vh; overflow-y: auto; font-size: 12px; }
#bfa-user-tooltip { position: fixed; display: none; background: #fff; border: 1px solid #ccc; box-shadow: 0 2px 5px rgba(0,0,0,0.2); border-radius: 5px; padding: 10px; z-index: 10003; max-width: 300px; font-size: 12px; pointer-events: none; }
#bfa-user-tooltip h4 { margin: 0 0 5px 0; font-size: 13px; color: #333; }
#bfa-user-tooltip p { margin: 0; line-height: 1.5; color: #555; }
`);
const $ = (selector, parent = document) => parent.querySelector(selector);
const $$ = (selector, parent = document) => Array.from(parent.querySelectorAll(selector));
async function loadConfig() {
const storedConfig = await GM_getValue(CONFIG_KEY, {});
return { ...DEFAULT_CONFIG, ...storedConfig };
}
async function saveConfig(config) {
await GM_setValue(CONFIG_KEY, config);
}
async function getTopicId() {
const match = window.location.pathname.match(/\/group\/topic\/(\d+)/);
return match ? `topic_${match[1]}` : null;
}
async function getCachedAnalysis(topicId) {
if (!topicId) return null;
return await GM_getValue(CACHE_PREFIX + topicId, null);
}
async function saveAnalysisToCache(topicId, data) {
if (!topicId) return;
await GM_setValue(CACHE_PREFIX + topicId, data);
}
function calculateEstimate(stats, currentReplyCount) {
const MIN_DATA_POINTS_FOR_REGRESSION = 3;
const MIN_ESTIMATE_MS = 15000;
const MAX_ESTIMATE_MS = 300000;
const DEFAULT_MS_PER_REPLY = 600;
const DEFAULT_OVERHEAD_MS = 5000;
let rawEstimate;
if (stats.length < MIN_DATA_POINTS_FOR_REGRESSION) {
logEvent(`预测模型: 使用默认值 (历史数据不足 ${MIN_DATA_POINTS_FOR_REGRESSION} 条)`);
rawEstimate = currentReplyCount * DEFAULT_MS_PER_REPLY + DEFAULT_OVERHEAD_MS;
} else {
let n = stats.length;
let sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0;
for (const stat of stats) {
const x = stat.replyCount;
const y = stat.durationMs;
sumX += x;
sumY += y;
sumXY += x * y;
sumX2 += x * x;
}
const denominator = n * sumX2 - sumX * sumX;
if (denominator === 0) {
logEvent(`预测模型: 使用简单平均值 (无法进行回归分析)`);
rawEstimate = (sumY / n) / (sumX / n) * currentReplyCount;
} else {
const slope = (n * sumXY - sumX * sumY) / denominator;
const intercept = (sumY - slope * sumX) / n;
const finalSlope = Math.max(50, slope);
const finalIntercept = Math.max(1000, intercept);
logEvent(`预测模型: 使用线性回归`, { slope: finalSlope.toFixed(2), intercept: finalIntercept.toFixed(2) });
rawEstimate = finalSlope * currentReplyCount + finalIntercept;
}
}
const finalEstimate = Math.max(MIN_ESTIMATE_MS, Math.min(MAX_ESTIMATE_MS, rawEstimate));
logEvent(`预测结果`, { rawEstimate: rawEstimate.toFixed(0), finalEstimate: finalEstimate.toFixed(0) });
return finalEstimate;
}
async function analyzeFactions() {
const analysisBtn = $('#bfa-start-analysis');
analysisBtn.disabled = true;
analysisBtn.textContent = '准备分析...';
logEvent('分析开始');
let countdownTimerId = null;
const clearCountdown = () => {
if (countdownTimerId) {
clearInterval(countdownTimerId);
countdownTimerId = null;
}
};
try {
const topicId = await getTopicId();
if (!topicId) throw new Error('无法获取当前帖子的ID。');
const loadedConfig = await loadConfig();
const runtimeConfig = { ...loadedConfig };
logEvent('配置加载完毕', { ...runtimeConfig, apiKey: '***' });
if (!runtimeConfig.apiKey || !runtimeConfig.apiBaseUrl || !runtimeConfig.modelName) {
alert('AI配置不完整。请点击“⚙️”按钮或右下角菜单中的“AI阵营分析设置”来配置您的API Key, Base URL 和模型名称。');
throw new Error('AI配置不完整。');
}
const title = $(SELECTORS.title)?.innerText.trim() || '无标题';
const mainPostContent = $(SELECTORS.mainPost)?.innerText.trim() || '楼主没有填写正文。';
const repliesData = $$(SELECTORS.replies).map(replyEl => ({ id: replyEl.id, text: $(SELECTORS.replyInner, replyEl)?.innerText.trim(), user: $(SELECTORS.userName, replyEl)?.innerText.trim() || '匿名用户' })).filter(r => r.id && r.text);
if (repliesData.length === 0) throw new Error('此贴没有找到可以分析的回复。');
logEvent(`已收集 ${repliesData.length} 条回复`);
const startTime = Date.now();
const maxRetries = 5;
let lastError = null;
let firstCallResult = null;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
if (attempt === 1) {
const historyStats = await GM_getValue(STATS_KEY, []);
const estimatedMs = calculateEstimate(historyStats, repliesData.length);
const endTime = Date.now() + estimatedMs;
countdownTimerId = setInterval(() => {
const remaining = Math.max(0, Math.round((endTime - Date.now()) / 1000));
analysisBtn.textContent = `分析中... (约${remaining}s)`;
}, 1000);
} else {
analysisBtn.textContent = `分析中... (重试 ${attempt}/${maxRetries})`;
}
logEvent(`第 ${attempt} 次尝试调用 AI...`);
const prompt = buildPrompt(title, mainPostContent, repliesData);
firstCallResult = await callOpenAI(runtimeConfig.apiKey, runtimeConfig.apiBaseUrl, runtimeConfig.modelName, prompt);
clearCountdown();
if (!firstCallResult || !firstCallResult.grandCamps || !firstCallResult.replies || !Array.isArray(firstCallResult.grandCamps) || !Array.isArray(firstCallResult.replies)) {
throw new Error('AI返回的数据格式不正确。');
}
lastError = null;
break;
} catch (error) {
lastError = error;
logEvent(`尝试 ${attempt} 失败`, { error: error.message });
clearCountdown();
if (attempt < maxRetries) {
await new Promise(resolve => setTimeout(resolve, 2000 * attempt));
}
}
}
if (lastError) throw lastError;
let finalResult = firstCallResult;
let analyzedPostIds = new Set(finalResult.replies.map(r => r.postId));
let missingReplies = repliesData.filter(r => !analyzedPostIds.has(r.id));
const maxSupplementRetries = 5;
while (missingReplies.length > 0) {
logEvent(`补充分析开始,剩余 ${missingReplies.length} 个楼层。`);
let supplementAttempt = 1;
let supplementSuccess = false;
while (supplementAttempt <= maxSupplementRetries) {
analysisBtn.textContent = `补充分析...(${missingReplies.length}楼) (尝试 ${supplementAttempt}/${maxSupplementRetries})`;
try {
const secondPrompt = buildSecondPrompt(finalResult.grandCamps, missingReplies);
const supplementResult = await callOpenAI(runtimeConfig.apiKey, runtimeConfig.apiBaseUrl, runtimeConfig.modelName, secondPrompt);
if (supplementResult && supplementResult.replies && Array.isArray(supplementResult.replies)) {
logEvent(`补充分析成功,获得 ${supplementResult.replies.length} 个新分类。`);
const validSupplementReplies = supplementResult.replies.filter(r => r.postId && r.subCampId);
finalResult = {
grandCamps: finalResult.grandCamps,
replies: [...finalResult.replies, ...validSupplementReplies]
};
analyzedPostIds = new Set(finalResult.replies.map(r => r.postId));
missingReplies = repliesData.filter(r => !analyzedPostIds.has(r.id));
supplementSuccess = true;
break;
} else {
throw new Error('补充分析返回数据格式不正确');
}
} catch (error) {
logEvent(`补充分析尝试 ${supplementAttempt} 失败`, { error: error.message });
supplementAttempt++;
if (supplementAttempt <= maxSupplementRetries) {
await new Promise(resolve => setTimeout(resolve, 2000));
}
}
}
if (!supplementSuccess) {
logEvent(`补充分析连续失败 ${maxSupplementRetries} 次,中止补充。`);
break;
}
}
const endTime = Date.now();
const durationMs = endTime - startTime;
logEvent('分析流程完成', { durationMs, finalReplyCount: finalResult.replies.length });
displayAnalysisResults(finalResult, repliesData);
await saveAnalysisToCache(topicId, finalResult);
const historyStats = await GM_getValue(STATS_KEY, []);
const newStats = [...historyStats, { replyCount: repliesData.length, durationMs }];
if (newStats.length > MAX_STATS_ENTRIES) newStats.shift();
await GM_setValue(STATS_KEY, newStats);
analysisBtn.textContent = '分析完成!可再次分析';
} catch (error) {
clearCountdown();
logEvent('分析最终失败', { error: error.message });
if (error.message !== '此贴没有找到可以分析的回复。' && error.message !== 'AI配置不完整.') {
alert(`分析失败: ${error.message}`);
}
analysisBtn.textContent = '分析失败,点击重试';
} finally {
analysisBtn.disabled = false;
}
}
function buildPrompt(title, mainPost, replies) {
const repliesText = replies.map(r => `{"postId": "${r.id}", "content": "${r.text.substring(0, 300).replace(/"/g, "'").replace(/\n/g, " ")}"}`).join('\n');
return `你是社群观察员,任务是分析论坛帖子,划分回复阵营。请严格按以下要求操作:
1. 识别2-3个主要“大阵营”,再细分出若干“小观点”。大阵营小观点皆应基于核心观点对立,必须是相互排斥的,不可交叉重叠。 阵营规模无需平衡,可存在一方观点占压倒性优势的情况。小观点数量越少越好,能合并的合并。
2. 对每个\`postId\`,**必须且只能**输出一个分类结果。请综合整个\`content\`字段的内容来判断其所属的唯一观点,即使内容包含引述或多段对话。
3. 严格按以下JSON格式输出,不含任何额外文本或标记。
{
"grandCamps": [ { "id": "...", "name": "...", "subCamps": [ { "id": "...", "name": "..." } ] } ],
"replies": [ { "postId": "...", "subCampId": "..." } ]
}
---
[帖子标题]: ${title}
[主楼内容]: ${mainPost}
[回帖列表]:
${repliesText}`;
}
function buildSecondPrompt(grandCamps, missingReplies) {
const repliesText = missingReplies.map(r => `{"postId": "${r.id}", "content": "${r.text.substring(0, 300).replace(/"/g, "'").replace(/\n/g, " ")}"}`).join('\n');
const campsStructureText = JSON.stringify(grandCamps, null, 2);
return `你是一位社群观察员,正在进行补充分析。
你已经完成了第一轮分析,并确定了以下阵营结构:
${campsStructureText}
现在,请将以下剩余的回帖划分到已有的【小观点 (subCamps)】中。
1. 【不要】创建新的大阵营或小观点。
2. 为每一个回帖指定一个最合适的【subCampId】。
3. 严格按照以下JSON格式输出,只包含 "replies" 字段,不含任何额外文本或标记。
{
"replies": [ { "postId": "...", "subCampId": "..." } ]
}
---
[待分类的回帖列表]:
${repliesText}`;
}
function callOpenAI(apiKey, apiBaseUrl, modelName, prompt) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'POST',
url: apiBaseUrl,
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` },
data: JSON.stringify({ model: modelName, messages: [{ role: 'user', content: prompt }], response_format: { type: "json_object" }, temperature: 0.1 }),
timeout: 300000,
onload: function(response) {
if (response.status >= 200 && response.status < 300) {
logEvent('收到API响应', { status: response.status, text: response.responseText.substring(0, 200) });
const responseText = response.responseText.trim();
let accumulatedContent = '';
if (responseText.includes("data:")) {
const lines = responseText.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
const jsonStr = line.substring(5).trim();
if (jsonStr === '[DONE]') continue;
try {
const chunk = JSON.parse(jsonStr);
if (chunk.choices && chunk.choices[0]?.delta?.content) {
accumulatedContent += chunk.choices[0].delta.content;
}
} catch (e) {
}
}
}
}
if (accumulatedContent) {
try {
return resolve(JSON.parse(accumulatedContent));
} catch (e) {
logEvent('累积的流式内容解析失败', { error: e.message });
}
}
try {
const data = JSON.parse(responseText);
if (data.choices && data.choices[0]?.message?.content) {
return resolve(JSON.parse(data.choices[0].message.content));
}
if (data.replies) {
return resolve(data);
}
} catch (e) {
}
logEvent('标准解析失败,尝试从文本中提取JSON。');
const jsonMatch = responseText.match(/\{[\s\S]*\}/);
if (jsonMatch && jsonMatch[0]) {
try {
const extractedJson = JSON.parse(jsonMatch[0]);
logEvent('成功从响应文本中提取并解析JSON。');
return resolve(extractedJson);
} catch (e2) {
return reject(new Error('无法解析API响应:提取的JSON内容也无效。'));
}
}
reject(new Error('无法解析API响应:所有解析方法都已失败。'));
} else { reject(new Error(`API请求失败,状态码: ${response.status} - ${response.statusText}`)); }
},
onerror: (error) => reject(new Error('网络请求错误: ' + JSON.stringify(error))),
ontimeout: () => reject(new Error('请求超时。'))
});
});
}
async function displayAnalysisResults(analysisResult, originalRepliesData) {
$$('.bfa-camp-marker, .bfa-viz-container').forEach(el => el.remove());
const { grandCamps, replies } = analysisResult;
const subCampData = new Map();
const grandCampData = new Map();
grandCamps.forEach(gc => {
grandCampData.set(gc.id, { ...gc, postCount: 0, users: new Set() });
gc.subCamps.forEach(sc => {
subCampData.set(sc.id, { ...sc, grandCampId: gc.id, grandCampName: gc.name, postCount: 0, users: new Set() });
});
});
const originalRepliesMap = new Map(originalRepliesData.map(r => [r.id, r]));
replies.forEach(({ postId, subCampId }) => {
const originalReply = originalRepliesMap.get(postId);
const subCamp = subCampData.get(subCampId);
if (!originalReply || !subCamp) return;
const postEl = $(`#${postId} > .inner`);
if (postEl) {
const marker = document.createElement('div');
marker.className = 'bfa-camp-marker';
const subCampInfo = subCampData.get(subCampId);
marker.textContent = `观点: ${subCampInfo.name}`;
const colors = ['#e57373', '#64b5f6', '#81c784', '#fff176', '#ffb74d', '#ba68c8', '#90a4ae'];
const subCampIds = Array.from(subCampData.keys());
marker.style.backgroundColor = colors[subCampIds.indexOf(subCampId) % colors.length];
postEl.prepend(marker);
}
subCamp.postCount++;
subCamp.users.add(originalReply.user);
const grandCamp = grandCampData.get(subCamp.grandCampId);
if (grandCamp) {
grandCamp.postCount++;
grandCamp.users.add(originalReply.user);
}
});
createVisualization(grandCampData, subCampData, grandCamps);
}
function createVisualization(grandCampData, subCampData, orderedGrandCampsFromAI) {
let currentDisplayMode = 'people';
let hideOthers = false;
const container = document.createElement('div');
container.className = 'bfa-viz-container';
const controls = document.createElement('div');
controls.className = 'bfa-viz-controls';
const modeSwitch = document.createElement('button');
modeSwitch.className = 'bfa-viz-btn';
const rightControls = document.createElement('div');
rightControls.className = 'bfa-viz-right-controls';
const hideOthersBtn = document.createElement('button');
hideOthersBtn.className = 'bfa-viz-btn';
const toggleBtn = document.createElement('button');
toggleBtn.className = 'bfa-viz-btn';
rightControls.appendChild(hideOthersBtn);
rightControls.appendChild(toggleBtn);
controls.appendChild(modeSwitch);
controls.appendChild(rightControls);
container.appendChild(controls);
const tugOfWar = document.createElement('div');
tugOfWar.className = 'bfa-tug-of-war';
container.appendChild(tugOfWar);
const campInfo = document.createElement('div');
campInfo.className = 'bfa-camp-info';
container.appendChild(campInfo);
const barChart = document.createElement('div');
barChart.className = 'bfa-bar-chart';
container.appendChild(barChart);
const userTooltip = $('#bfa-user-tooltip');
const moveTooltip = (event) => {
if (userTooltip && userTooltip.style.display === 'block') {
userTooltip.style.left = `${event.clientX + 15}px`;
userTooltip.style.top = `${event.clientY + 15}px`;
}
};
const showTooltip = (event) => {
const target = event.currentTarget;
if (!target.dataset.users || !userTooltip) return;
userTooltip.querySelector('h4').textContent = target.dataset.title;
userTooltip.querySelector('p').textContent = target.dataset.users.split(',').join('、');
userTooltip.style.display = 'block';
moveTooltip(event);
};
const hideTooltip = () => { if (userTooltip) userTooltip.style.display = 'none'; };
const adjustTugFontSize = (element) => {
const minFontSize = 7;
const maxFontSize = 14;
const width = element.clientWidth;
let fontSize = Math.max(minFontSize, Math.min(width / 7, maxFontSize));
element.style.fontSize = `${fontSize}px`;
};
function renderVisualization() {
modeSwitch.textContent = `当前: ${currentDisplayMode === 'people' ? '按人数' : '按楼层'}`;
hideOthersBtn.textContent = `当前: ${hideOthers ? '显示其他' : '隐藏其他'}`;
toggleBtn.textContent = `当前: ${barChart.style.display === 'block' ? '柱状图' : '对立条'}`;
const countKey = currentDisplayMode === 'people' ? 'peopleCount' : 'postCount';
const unit = currentDisplayMode === 'people' ? '人' : '楼';
grandCampData.forEach(gc => { gc.peopleCount = gc.users.size; });
subCampData.forEach(sc => { sc.peopleCount = sc.users.size; });
let allGrandCampsSorted = Array.from(grandCampData.values()).filter(gc => gc.postCount > 0).sort((a, b) => b[countKey] - a[countKey]);
tugOfWar.innerHTML = '';
campInfo.textContent = '';
const topCamp1 = allGrandCampsSorted[0] || null;
const topCamp2 = allGrandCampsSorted[1] || null;
const otherCamps = allGrandCampsSorted.slice(2);
const neutralCamp = { name: '其他观点', postCount: 0, peopleCount: 0, users: new Set() };
if (otherCamps.length > 0) {
neutralCamp.postCount = otherCamps.reduce((sum, camp) => sum + camp.postCount, 0);
otherCamps.forEach(camp => camp.users.forEach(user => neutralCamp.users.add(user)));
neutralCamp.peopleCount = neutralCamp.users.size;
}
let visibleCamps = [topCamp1, topCamp2].filter(Boolean);
if (!hideOthers && neutralCamp.postCount > 0) {
visibleCamps.push(neutralCamp);
}
if (visibleCamps.length === 0) {
campInfo.textContent = "未能识别出足够数据进行可视化。";
} else {
const totalCount = visibleCamps.reduce((sum, camp) => sum + camp[countKey], 0);
let infoParts = [];
const processCamp = (camp, color) => {
const percent = totalCount > 0 ? (camp[countKey] / totalCount) * 100 : 0;
const usersArray = Array.from(camp.users);
const div = document.createElement('div');
div.style.width = `${percent}%`;
div.style.backgroundColor = color;
div.textContent = camp.name;
const titleText = `${camp.name} (${camp[countKey]}${unit})`;
div.dataset.title = titleText;
div.dataset.users = usersArray.join(',');
div.addEventListener('mouseover', showTooltip);
div.addEventListener('mousemove', moveTooltip);
div.addEventListener('mouseout', hideTooltip);
tugOfWar.appendChild(div);
adjustTugFontSize(div);
infoParts.push(`${camp.name} (${camp[countKey]}${unit})`);
};
if (topCamp1) processCamp(topCamp1, '#ff6961');
if (!hideOthers && neutralCamp.postCount > 0) processCamp(neutralCamp, '#cccccc');
if (topCamp2) processCamp(topCamp2, '#77ddff');
campInfo.textContent = infoParts.join(' vs ');
}
const colors = ['#e57373', '#64b5f6', '#81c784', '#fff176', '#ffb74d', '#ba68c8', '#90a4ae'];
const subCampIds = Array.from(subCampData.keys());
barChart.innerHTML = '';
let barData = orderedGrandCampsFromAI.map(gc_template => grandCampData.get(gc_template.id)).filter(Boolean);
if (hideOthers) {
const top2Ids = new Set([topCamp1?.id, topCamp2?.id].filter(Boolean));
barData = barData.filter(gc => top2Ids.has(gc.id));
}
const flatBarData = barData.flatMap(gc => gc.subCamps.map(sc_template => subCampData.get(sc_template.id))).filter(d => d && d.postCount > 0);
const maxCount = Math.max(1, ...flatBarData.map(d => d[countKey]));
flatBarData.forEach(d => {
const item = document.createElement('div');
item.className = 'bfa-bar-item';
const barColor = colors[subCampIds.indexOf(d.id) % colors.length];
item.innerHTML = `<div class="bfa-bar-label">${d.name}</div><div class="bfa-bar-wrapper"><div class="bfa-bar" style="width:${d[countKey]/maxCount*100}%; background-color:${barColor};"></div></div><div class="bfa-bar-count">${d[countKey]}${unit}</div>`;
const titleText = `观点: ${d.name} (${d[countKey]}${unit})`;
item.dataset.title = titleText;
item.dataset.users = Array.from(d.users).join(',');
item.addEventListener('mouseover', showTooltip);
item.addEventListener('mousemove', moveTooltip);
item.addEventListener('mouseout', hideTooltip);
barChart.appendChild(item);
});
}
modeSwitch.onclick = () => {
currentDisplayMode = currentDisplayMode === 'people' ? 'posts' : 'people';
renderVisualization();
};
hideOthersBtn.onclick = () => {
hideOthers = !hideOthers;
renderVisualization();
};
toggleBtn.onclick = () => {
const isBarVisible = barChart.style.display === 'block';
barChart.style.display = isBarVisible ? 'none' : 'block';
tugOfWar.style.display = isBarVisible ? 'flex' : 'none';
campInfo.style.display = isBarVisible ? 'block' : 'none';
renderVisualization();
};
$(SELECTORS.mainPost).insertAdjacentElement('afterend', container);
renderVisualization();
}
function createUserTooltip() {
if ($('#bfa-user-tooltip')) return;
const tooltip = document.createElement('div');
tooltip.id = 'bfa-user-tooltip';
tooltip.innerHTML = '<h4></h4><p></p>';
document.body.appendChild(tooltip);
}
async function initUI() {
logEvent('脚本初始化');
createUserTooltip();
const config = await loadConfig();
const titleEl = $(SELECTORS.title);
if (!titleEl) return;
if ($('.bfa-controls-container, .bfa-footer-settings-container')) return;
const settingsBtn = document.createElement('button');
settingsBtn.className = 'bfa-btn bfa-analysis-settings-btn';
settingsBtn.title = '自定义AI模型设置';
settingsBtn.textContent = '⚙️';
settingsBtn.onclick = showSettingsPanel;
if (config.showAnalysisButton) {
const controlsContainer = document.createElement('div');
controlsContainer.className = 'bfa-controls-container';
const startBtn = document.createElement('button');
startBtn.id = 'bfa-start-analysis';
startBtn.className = 'bfa-btn';
startBtn.textContent = '启动AI阵营分析';
startBtn.onclick = analyzeFactions;
controlsContainer.appendChild(startBtn);
controlsContainer.appendChild(settingsBtn);
titleEl.insertAdjacentElement('afterend', controlsContainer);
const topicId = await getTopicId();
const cachedResult = topicId ? await getCachedAnalysis(topicId) : null;
if (cachedResult && cachedResult.grandCamps && cachedResult.replies) {
logEvent('从缓存加载结果');
const repliesData = $$(SELECTORS.replies).map(el => ({id: el.id, text: $(SELECTORS.replyInner, el)?.innerText.trim(), user: $(SELECTORS.userName, el)?.innerText.trim() || '匿名用户'})).filter(r => r.id && r.text);
displayAnalysisResults(cachedResult, repliesData);
$('#bfa-start-analysis').textContent = '分析完成!可再次分析';
}
} else {
const copyrightEl = $(SELECTORS.copyright);
if (copyrightEl) {
const footerContainer = document.createElement('div');
footerContainer.className = 'bfa-footer-settings-container';
footerContainer.appendChild(settingsBtn);
copyrightEl.parentNode.insertBefore(footerContainer, copyrightEl);
}
}
const targetList = $(SELECTORS.settingsMenu);
if (targetList && !$('.bfa-settings-button', targetList)) {
const settingsButton = document.createElement('li');
settingsButton.className = 'bfa-settings-button';
settingsButton.innerHTML = '◇ <a href="javascript:void(0);" class="nav">AI阵营分析设置</a>';
settingsButton.querySelector('a').onclick = showSettingsPanel;
targetList.appendChild(settingsButton);
}
}
function showSettingsPanel() {
let panel = $('#bfa-settings-panel');
if (!panel) panel = document.body.appendChild(createSettingsPanel());
loadConfig().then(config => {
$('#bfa-api-key', panel).value = config.apiKey;
$('#bfa-api-base-url', panel).value = config.apiBaseUrl;
$('#bfa-model-name', panel).value = config.modelName;
$('#bfa-show-button', panel).checked = config.showAnalysisButton;
});
panel.style.display = 'block';
}
function createSettingsPanel() {
const panel = document.createElement('div');
panel.id = 'bfa-settings-panel';
panel.className = 'bfa-settings-panel';
panel.innerHTML = `
<h3>AI阵营分析设置</h3>
<div class="form-group"><label for="bfa-api-key">API Key:</label><input type="password" id="bfa-api-key" placeholder="必填"></div>
<div class="form-group"><label for="bfa-api-base-url">API Base URL:</label><input type="text" id="bfa-api-base-url" placeholder="必填"></div>
<div class="form-group"><label for="bfa-model-name">模型名称:</label><input type="text" id="bfa-model-name" placeholder="必填"></div>
<p style="font-size:0.9em;color:#666;">API Key会存储在浏览器本地。</p>
<div class="form-group"><label style="display:flex;align-items:center;font-weight:normal;"><input type="checkbox" id="bfa-show-button" style="width:auto;height:auto;margin-right:8px;">在帖子页面显示AI分析按钮</label></div>
<div class="actions"><button class="view-log-btn close-btn">查看日志</button><button class="save-btn">保存并刷新</button><button class="close-btn">关闭</button></div>
`;
panel.querySelector('.save-btn').onclick = () => {
const config = {
apiKey: $('#bfa-api-key', panel).value.trim(),
apiBaseUrl: $('#bfa-api-base-url', panel).value.trim(),
modelName: $('#bfa-model-name', panel).value.trim(),
showAnalysisButton: $('#bfa-show-button', panel).checked
};
if (!config.apiKey || !config.apiBaseUrl || !config.modelName) return alert('API Key, Base URL 和模型名称均为必填项。');
saveConfig(config).then(() => {
alert('设置已保存!页面将刷新以应用更改。');
panel.style.display = 'none';
window.location.reload();
});
};
panel.querySelector('.view-log-btn').onclick = () => showLogPanel();
panel.querySelector('.close-btn:last-child').onclick = () => panel.style.display = 'none';
return panel;
}
function showLogPanel() {
let panel = $('#bfa-log-panel');
if (!panel) panel = document.body.appendChild(createLogPanel());
const logContentEl = panel.querySelector('pre');
logContentEl.textContent = logEntries.map(entry => {
const time = entry.timestamp.toLocaleTimeString();
let detailsStr = entry.details ? `\n ` + JSON.stringify(entry.details, null, 2).replace(/\n/g, '\n ') : '';
return `[${time}] ${entry.message}${detailsStr}`;
}).join('\n\n');
logContentEl.scrollTop = logContentEl.scrollHeight;
panel.style.display = 'block';
}
function createLogPanel() {
const panel = document.createElement('div');
panel.id = 'bfa-log-panel';
panel.className = 'bfa-log-panel';
panel.innerHTML = `<h3>运行日志</h3><pre></pre><div class="actions"><button class="close-btn">关闭</button></div>`;
panel.querySelector('.close-btn').onclick = () => panel.style.display = 'none';
return panel;
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initUI);
} else {
initUI();
}
})();