// ==UserScript==
// @name Skeb 悬停展示接稿信息
// @name:zh-cn Skeb 悬停展示接稿信息
// @name:en Skeb Creator Hover Info
// @name:ja Skeb クリエイター情報ホバー表示
// @namespace https://gf.qytechs.cn/zh-CN/users/1497660-rde9
// @version 2025-09-01-fix
// @author rde9
// @description 光标悬停显示 Skeb 创作者接稿信息,包括作品数、各类稿件价格、完成率和接稿状态,支持缓存与自动清理。
// @description:zh-cn 光标悬停显示 Skeb 创作者接稿信息,包括作品数、各类稿件价格、完成率和接稿状态,支持缓存与自动清理。
// @description:en Display Skeb creator info on hover: works count, prices by genre, completion rate, and commission status. Supports caching and auto cleanup.
// @description:ja カーソルをホバーすると Skeb クリエイターの情報が表示され、作品数、各ジャンルの依頼価格、締切厳守率、受付状況を確認できます。キャッシュと自動クリア機能あり。
// @license MIT License
// @match https://skeb.jp/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=skeb.jp
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_deleteValue
// @grant GM_listValues
// @grant GM_addStyle
// ==/UserScript==
;(function() {
'use strict';
const HOVER_DELAY = 1000; // 悬停延迟:1000ms
const CACHE_EXPIRE = 12 * 60 * 60 * 1000; // 缓存有效期:12h
const CACHE_PREFIX = 'creator_';
let hoverTimer = null;
let hoveredElement = null;
let hoverBox = null;
GM_addStyle(`
#skeb-creator-hover-box {
position: fixed;
z-index: 9999;
background: #fff;
border: 1px solid #ccc;
padding: 8px;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
max-width: 216px;
font-size: 14px;
line-height: 1.4;
display: none;
}
#skeb-creator-hover-box .skeb-box-header {
display: flex;
align-items: center;
margin-bottom: 8px;
word-break: break-all;
}
#skeb-creator-hover-box .skeb-box-content {
display: flex;
flex-direction: column;
gap: 4px;
}
#skeb-creator-hover-box .skeb-row {
display: flex;
justify-content: space-between;
}
#skeb-creator-hover-box .skeb-value {
font-weight: bold;
}
#skeb-creator-hover-box .skeb-label-acc {
color: green;
}
#skeb-creator-hover-box .skeb-label-not-acc {
color: red;
}
a.skeb-hover-transition {
transition: box-shadow 0.8s ease-in-out;
}
a.skeb-hover-target {
box-shadow: 0 0 0 2px red !important;
}
`);
// 创建悬浮框节点并清理过期缓存
async function init() {
const keys = await GM_listValues();
const now = Date.now();
for (const key of keys) {
if (!key.startsWith(CACHE_PREFIX)) continue;
const raw = await GM_getValue(key);
if (!raw) { await GM_deleteValue(key); continue; }
try {
const obj = JSON.parse(raw);
if (now - obj.ts > CACHE_EXPIRE) await GM_deleteValue(key);
} catch {
await GM_deleteValue(key);
}
}
hoverBox = document.createElement('div');
hoverBox.id = 'skeb-creator-hover-box';
document.body.appendChild(hoverBox);
}
init();
const container = document.querySelector('main') || document.body;
container.addEventListener('mouseover', (e) => {
const a = e.target.closest('a[href^="/@"]');
if (a) onMouseOver(e);
}, false);
container.addEventListener('mouseout', (e) => {
const a = e.target.closest('a[href^="/@"]');
if (a) onMouseOut(e);
}, false);
function onMouseOver(e) {
const a = e.target.closest('a[href]');
if (!a) return;
// 提取 @用户名
// 示例:"/@username/works/4", "/@username"
const match = a.getAttribute('href').match(/^\/@([^\/]+)/);
if (!match) return;
const username = match[1];
// 添加高亮动画类
a.classList.add('skeb-hover-transition', 'skeb-hover-target');
clearTimeout(hoverTimer);
hoveredElement = a;
hoverTimer = setTimeout(() => {
fetchAndShowCreatorInfo(username, e);
}, HOVER_DELAY);
}
function onMouseOut(e) {
if (!hoveredElement) return;
const related = e.relatedTarget;
if (related && (hoveredElement.contains(related))) return;
// 移除高亮动画类
hoveredElement.classList.remove('skeb-hover-transition', 'skeb-hover-target');
clearTimeout(hoverTimer);
hoveredElement = null;
setTimeout(hideFloatingBox, 200);
}
async function fetchAndShowCreatorInfo(username, event) {
try {
let data = await getCreatorCache(username);
if (!data) {
const json = await fetchFromAPI(username);
if (!json.creator) return; // 仅展示 Creator
data = formatCreatorData(json);
await setCreatorCache(username, data);
}
showFloatingBox(data, event);
} catch (err) {
console.error('fetchAndShowCreatorInfo error:', err);
hoverBox.innerHTML = '<div class="skeb-box-content">fetchAndShowCreatorInfoError: ' + err.message + '</div>';
hoverBox.style.display = 'block';
}
}
async function fetchFromAPI(username) {
const url = `https://skeb.jp/api/users/${username}`;
const headers = {
'accept': 'application/json',
'authorization': `Bearer null`,
'user-agent': navigator.userAgent
};
const res = await fetch(url, { headers });
if (!res.ok) throw new Error(`fetchFromAPI error: ${res.status}`);
return res.json();
}
function formatCreatorData(json) {
const genreMap = {
art: 'art/イラスト',
comic: 'comic/コミック',
voice: 'voice/ボイス',
novel: 'novel/テキスト',
video: 'movie/ムービー',
music: 'music/ミュージック',
correction: 'advice/アドバイス',
};
const skills = {};
(json.skills || []).forEach(s => {
const label = genreMap[s.genre] || s.genre;
skills[label] = s.default_amount;
});
return {
screen_name: json.screen_name,
name: json.name,
avatar_url: json.avatar_url,
received_works_count: json.received_works_count,
complete_rate: json.complete_rate,
acceptable: json.acceptable,
skills
};
}
async function getCreatorCache(username) {
const key = `${CACHE_PREFIX}${username}`;
const raw = await GM_getValue(key);
if (!raw) return null;
let obj;
try { obj = JSON.parse(raw); } catch { return null; }
if (Date.now() - obj.ts > CACHE_EXPIRE) {
await GM_deleteValue(key);
return null;
}
return obj.data;
}
async function setCreatorCache(username, data) {
const key = `creator_${username}`;
const value = { ts: Date.now(), data };
await GM_setValue(key, JSON.stringify(value));
}
function showFloatingBox(data, event) {
const rate = data.complete_rate != null ? (data.complete_rate * 100).toFixed(0) + '%' : '--';
let html = `<div class="skeb-box-header"><img src="${data.avatar_url}" style="width:32px;height:32px;border-radius:50%;margin-right:8px;"><strong>${data.name} (@${data.screen_name})</strong></div>`;
html += `<div class="skeb-box-content">`;
html += `<div class="skeb-row"><span class="skeb-label">Total/作品数:</span><span class="skeb-value">${data.received_works_count}</span></div>`;
html += `<div class="skeb-row"><span class="skeb-label">Comp. rate/締切厳守率:</span><span class="skeb-value">${rate}</span></div>`;
Object.entries(data.skills).forEach(([label, amt]) => {
html += `<div class="skeb-row"><span class="skeb-label-${data.acceptable ? 'acc' : 'not-acc'}">${label}:</span><span class="skeb-value">¥${amt}</span></div>`;
});
html += `</div>`;
hoverBox.innerHTML = html;
hoverBox.style.display = 'block';
// 位置调整
const x = event.clientX + 10;
const y = event.clientY + 10;
const { innerWidth, innerHeight, scrollX, scrollY } = window;
const bw = hoverBox.offsetWidth;
const bh = hoverBox.offsetHeight;
hoverBox.style.left = (x + bw > innerWidth ? innerWidth - bw - 10 : x) + 'px';
hoverBox.style.top = (y + bh > innerHeight ? innerHeight - bh - 10 : y) + 'px';
}
function hideFloatingBox() {
if (hoverBox) hoverBox.style.display = 'none';
}
})();