每日自动缓存商户数据,并在用户卡片弹出时,为富可敌国显示其评分信息,并适配网站暗色模式。优先通过头像URL获取用户名,失败时再通过API获取。
// ==UserScript==
// @name Linux.do 富可敌国评分展示
// @namespace http://tampermonkey.net/
// @version 1.1.0
// @license GNU GPLv3
// @description 每日自动缓存商户数据,并在用户卡片弹出时,为富可敌国显示其评分信息,并适配网站暗色模式。优先通过头像URL获取用户名,失败时再通过API获取。
// @author haorwen
// @match *://linux.do/*
// @connect rate.linux.do
// @connect linux.do
// @grant GM_xmlhttpRequest
// @grant GM_log
// ==/UserScript==
(function() {
'use strict';
// --- 全局变量与工具函数 ---
const LAST_FETCH_DATE_KEY = 'ld_merchant_last_fetch_date';
const MERCHANT_DATA_KEY = 'ld_merchant_ratings_data';
let premiumTopicAuthor = null;
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
return null;
}
function isDarkModeDetected() {
const systemPrefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
const cookieForceDark = getCookie('forced_color_mode') === 'dark';
return systemPrefersDark || cookieForceDark;
}
// --- Part 1: 数据获取与缓存 ---
function getTodayDateString() {
return new Date().toISOString().split('T')[0];
}
/**
* [方法一:快速] 尝试从头像URL中提取用户名
* e.g., /user_avatar/linux.do/username/45/1_2.png -> username
* @param {string} avatarUrl
* @returns {string|null}
*/
function extractUsernameFromAvatar(avatarUrl) {
if (!avatarUrl || typeof avatarUrl !== 'string') return null;
try {
// 匹配 /user_avatar/域名/用户名/ 的格式
const match = avatarUrl.match(/\/user_avatar\/[^/]+\/([^/]+)/);
return match ? match[1] : null;
} catch (error) {
console.error('从头像URL提取用户名失败:', avatarUrl, error);
return null;
}
}
/**
* [方法二:后备] 从linux.do帖子URL中提取主题ID
* @param {string} url - 帖子的URL
* @returns {string|null} - 帖子ID或null
*/
function extractTopicIdFromUrl(url) {
if (!url) return null;
const match = url.match(/\/t\/(?:[^\/]+\/)?(\d+)/);
return match ? match[1] : null;
}
/**
* [核心修改] 获取并存储商户数据,采用混合模式获取用户名
*/
function fetchAndStoreMerchantData() {
GM_log('开始获取商户评价数据...');
const apiUrl = 'https://rate.linux.do/api/merchant?page=1&size=100&order_by=average_rating&order_direction=desc';
GM_xmlhttpRequest({
method: 'GET',
url: apiUrl,
headers: { "Accept": "application/json, text/plain, */*" },
onload: function(response) {
if (response.status !== 200) {
GM_log(`获取商户列表失败,HTTP状态码: ${response.status}`);
return;
}
try {
const result = JSON.parse(response.responseText);
if (!result.success || !Array.isArray(result.data?.data)) {
GM_log('商户列表API响应格式不正确:', result.message);
return;
}
const merchants = result.data.data;
const merchantDataToStore = {};
GM_log(`获取到 ${merchants.length} 条商户原始数据,开始混合模式处理...`);
const promises = merchants.map(merchant => {
// ** 步骤1: 尝试快速方法 **
const usernameFromAvatar = extractUsernameFromAvatar(merchant.avatar_url);
if (usernameFromAvatar) {
// ** 快速通道:成功从头像URL提取 **
GM_log(`[快速通道] 商户'${merchant.name}' -> 用户名'${usernameFromAvatar}' (来自头像)`);
merchantDataToStore[usernameFromAvatar.toLowerCase()] = {
id: merchant.id, name: merchant.name, like_count: merchant.like_count,
dislike_count: merchant.dislike_count, average_rating: merchant.average_rating, rating_count: merchant.rating_count,
};
return Promise.resolve(); // 返回一个已解决的Promise
}
// ** 步骤2: 降级到后备方法 **
GM_log(`[后备通道] 商户'${merchant.name}' 头像URL无法提取,尝试API获取...`);
return new Promise((resolve) => {
const topicId = extractTopicIdFromUrl(merchant.linux_do_url);
if (!topicId) {
GM_log(`[跳过] 商户'${merchant.name}'的URL格式不正确: ${merchant.linux_do_url}`);
resolve();
return;
}
const topicJsonUrl = `https://linux.do/t/${topicId}.json`;
GM_xmlhttpRequest({
method: 'GET',
url: topicJsonUrl,
onload: function(topicResponse) {
if (topicResponse.status === 200) {
try {
const topicData = JSON.parse(topicResponse.responseText);
const username = topicData?.post_stream?.posts[0]?.username;
if (username) {
GM_log(`[成功] 商户'${merchant.name}' -> 用户名'${username}' (来自API)`);
merchantDataToStore[username.toLowerCase()] = {
id: merchant.id, name: merchant.name, like_count: merchant.like_count,
dislike_count: merchant.dislike_count, average_rating: merchant.average_rating, rating_count: merchant.rating_count,
};
} else {
GM_log(`[API失败] 无法从 ${topicJsonUrl} 中解析出用户名。`);
}
} catch (e) {
GM_log(`[API失败] 解析 ${topicJsonUrl} 的JSON时出错:`, e);
}
} else {
GM_log(`[API失败] 请求 ${topicJsonUrl} 失败,状态码: ${topicResponse.status}`);
}
resolve();
},
onerror: function(error) {
GM_log(`[API失败] 网络请求 ${topicJsonUrl} 失败:`, error);
resolve();
}
});
});
});
// 等待所有处理(包括快速通道和后备通道)完成后,再统一存储
Promise.all(promises).then(() => {
localStorage.setItem(MERCHANT_DATA_KEY, JSON.stringify(merchantDataToStore));
localStorage.setItem(LAST_FETCH_DATE_KEY, getTodayDateString());
GM_log(`--------- 数据更新完成 ---------`);
GM_log(`成功缓存了 ${Object.keys(merchantDataToStore).length} 条商户数据。`);
GM_log(`---------------------------------`);
});
} catch (error) {
GM_log('解析商户列表API响应时出错:', error);
}
},
onerror: function(error) {
GM_log('网络请求失败:', error);
}
});
}
function dailyCheckAndFetch() {
if (localStorage.getItem(LAST_FETCH_DATE_KEY) !== getTodayDateString()) {
GM_log(`日期已更新或首次加载,准备更新商户数据。`);
fetchAndStoreMerchantData();
} else {
GM_log(`今日已缓存商户数据。`);
}
}
// --- Part 2: 页面监控与信息注入 --- (无变化)
function checkForPremiumTag() {
const premiumTag = document.querySelector('a[data-tag-name="高级推广"]');
if (premiumTag) {
const authorElement = document.querySelector('.topic-post.regular:first-of-type a[data-user-card]');
if (authorElement) {
const authorUsername = authorElement.getAttribute('data-user-card');
if (authorUsername) {
const lowerCaseAuthor = authorUsername.toLowerCase();
if (premiumTopicAuthor !== lowerCaseAuthor) {
premiumTopicAuthor = lowerCaseAuthor;
GM_log(`[高级推广] 已记录作者 (小写): ${premiumTopicAuthor}`);
}
}
return;
}
}
if (premiumTopicAuthor && !premiumTag) { premiumTopicAuthor = null; }
}
function handleUserCard(cardElement) {
if (!cardElement) return;
const oldInfo = cardElement.querySelector('.merchant-rating-info');
if (oldInfo) oldInfo.remove();
const usernameElement = cardElement.querySelector('.names__secondary.username');
if (!usernameElement) return;
const username = usernameElement.textContent.trim().toLowerCase();
const isMerchant = cardElement.classList.contains('group-g-merchant');
const isRichTitle = Array.from(cardElement.querySelectorAll('.names__secondary')).some(el => el.textContent.trim() === '富可敌国');
const isPremiumAuthor = !!(username && premiumTopicAuthor && username === premiumTopicAuthor);
if (!isMerchant && !isRichTitle && !isPremiumAuthor) return;
GM_log(`检测到目标用户 [${username}] 的卡片。`);
const allMerchantData = JSON.parse(localStorage.getItem(MERCHANT_DATA_KEY) || '{}');
const merchantInfo = allMerchantData[username];
if (!merchantInfo) {
GM_log(`本地缓存中未找到 [${username}] 的评分数据。`);
return;
}
const isDark = isDarkModeDetected();
GM_log(`暗色模式检测: ${isDark}`);
const bgColor = isDark ? '#3a3a3a' : '#f9f9f9';
const borderColor = isDark ? '#555555' : '#e9e9e9';
const textColor = isDark ? '#e0e0e0' : '#222';
const linkStyle = `color: ${textColor}; text-decoration: none; display:contents;`;
const ratingDiv = document.createElement('div');
ratingDiv.className = 'card-row merchant-rating-info';
ratingDiv.style.cssText = `
padding: 8px 12px; margin: 10px 18px 0; border: 1px solid ${borderColor}; border-radius: 5px;
background-color: ${bgColor}; font-size: 0.9em; display: flex; justify-content: space-around;
flex-wrap: wrap; gap: 10px; text-align: center;
`;
ratingDiv.innerHTML = `
<a href="https://rate.linux.do/merchant/${merchantInfo.id}" target="_blank" title="点击查看详情" style="${linkStyle}">
<span>⭐ <strong>${merchantInfo.average_rating.toFixed(1)}</strong> (${merchantInfo.rating_count}人)</span>
<span>👍 <strong>${merchantInfo.like_count}</strong></span>
<span>👎 <strong>${merchantInfo.dislike_count}</strong></span>
</a>
`;
const targetRow = cardElement.querySelector('.card-row.metadata-row');
if (targetRow) {
targetRow.insertAdjacentElement('beforebegin', ratingDiv);
GM_log(`已为 [${username}] 成功注入评分信息。`);
}
}
// --- Part 3: 主逻辑与启动 --- (无变化)
function main() {
dailyCheckAndFetch();
const waitForElement = (selector, callback) => {
const el = document.querySelector(selector);
if (el) { callback(el); return; }
const obs = new MutationObserver((mutations, observer) => {
const targetEl = document.querySelector(selector);
if (targetEl) { observer.disconnect(); callback(targetEl); }
});
obs.observe(document.body, { childList: true, subtree: true });
};
waitForElement('#d-menu-portals', (portalElement) => {
GM_log("商户信息增强脚本已启动 (v1.2.0 by haorwen)");
checkForPremiumTag();
const observer = new MutationObserver((mutationsList) => {
checkForPremiumTag();
for (const mutation of mutationsList) {
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
const target = mutation.target.closest('#user-card');
if (target && target.classList.contains('show')) {
queueMicrotask(() => handleUserCard(target));
}
} else if (mutation.type === 'childList') {
for (const node of mutation.addedNodes) {
if (node.nodeType === 1 && node.querySelector) {
const card = node.querySelector('#user-card.show');
if (card) { queueMicrotask(() => handleUserCard(card)); }
}
}
}
}
});
observer.observe(portalElement, {
childList: true, subtree: true, attributes: true, attributeFilter: ['class']
});
});
}
main();
})();