小圣杯一键退坑
// ==UserScript==
// @license MIT
// @name Farewell TinyGrail
// @namespace xd.cedar.farewellTinyGrail
// @version 1.4.0
// @description 小圣杯一键退坑
// @author Cedar
// @include /^https?://(bgm\.tv|bangumi\.tv)/user/.+$/
// ==/UserScript==
// throw "I'm not gonna leave!";
{
const _getUid = (str) => (str || '').split('user/')[1]?.replace(/[\/\?#].*/g, '') || '';
if (_getUid(location.pathname) !== _getUid(document.querySelector('#dock .first a')?.href)) return;
}
const testing = false;
function escapeHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}
function formatNumber(number, decimals, dec_point, thousands_sep) {
number = (number + '').replace(/[^0-9+-Ee.]/g, '');
var n = !isFinite(+number) ? 0 : +number,
prec = !isFinite(+decimals) ? 2 : Math.abs(decimals),
sep = (typeof thousands_sep === 'undefined') ? ',' : thousands_sep,
dec = (typeof dec_point === 'undefined') ? '.' : dec_point,
s = '',
toFixedFix = function (n, prec) {
var k = Math.pow(10, prec);
return '' + Math.round(n * k) / k;
};
s = (prec ? toFixedFix(n, prec) : '' + Math.round(n)).split('.');
var re = /(-?\d+)(\d{3})/;
while (re.test(s[0])) {
s[0] = s[0].replace(re, "$1" + sep + "$2");
}
if ((s[1] || '').length < prec) {
s[1] = s[1] || '';
s[1] += new Array(prec - s[1].length + 1).join('0');
}
return s.join(dec);
}
const injectCSS = () => {
if (document.getElementById('farewell-tinygrail-css')) return;
const style = document.createElement('style');
style.id = 'farewell-tinygrail-css';
style.innerHTML = `
.farewell-chara-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
gap: 20px 10px;
padding: 20px 0;
margin: 0;
list-style: none;
}
.farewell-item {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.farewell-item .avatar-wrapper {
position: relative;
width: 64px;
height: 64px;
margin-bottom: 8px;
}
.farewell-item .avatar-img {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
border: 2px solid #e0e5ea;
box-sizing: border-box;
}
.farewell-item .level-badge {
position: absolute;
top: -4px;
left: -8px;
color: #fff;
font-size: 11px;
font-weight: bold;
padding: 1px 6px;
border-radius: 10px;
background-color: #b0b0b0;
box-shadow: 0 1px 2px rgba(0,0,0,0.2);
}
.level-badge.level-1, .level-badge.level-2 { background-color: #52c41a; }
.level-badge.level-3, .level-badge.level-4 { background-color: #1890ff; }
.level-badge.level-5 { background-color: #b37feb; }
.level-badge.level-6 { background-color: #ff4d4f; }
.level-badge.level-7 { background-color: #d48806; }
.level-badge.level-8 { background-color: #36cfc9; }
.level-badge.level-9 { background-color: #fa8c16; }
.level-badge.level-10, .level-badge.level-11, .level-badge.level-12, .level-badge.level-13, .level-badge.level-14, .level-badge.level-15, .level-badge.level-16, .level-badge.level-17, .level-badge.level-18, .level-badge.level-19, .level-badge.level-20 { background-color: #faad14; }
.farewell-item .inner {
width: 100%;
max-width: 110px;
}
.farewell-item .chara-name {
display: block;
font-weight: bold;
font-size: 13px;
color: #333;
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-decoration: none;
}
.farewell-item .chara-name:hover {
color: #0084ff;
text-decoration: underline;
}
.farewell-item .chara-assets {
font-size: 11px;
color: #666;
line-height: 1.5;
}
/* ==================== 方案 B: 现代圆角 ==================== */
.farewell-ui-wrapper {
background-color: #fafafa;
border: 1px solid #ebebeb;
border-radius: 8px;
padding: 8px 16px;
display: inline-block;
margin-left: 10px;
vertical-align: middle;
}
.farewell-control-row {
display: flex;
align-items: center;
gap: 20px;
}
.farewell-btn-quit {
background-color: #f298a8;
color: white;
border: none;
padding: 5px 16px;
border-radius: 16px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
text-decoration: none;
box-shadow: 0 2px 4px rgba(242, 152, 168, 0.2);
}
.farewell-btn-quit:hover {
background-color: #f4a7b5;
color: white;
text-decoration: none;
}
.farewell-control-row label {
font-size: 13px;
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
color: #555;
margin: 0;
}
.farewell-control-row input[type="checkbox"] {
-webkit-appearance: none;
appearance: none;
width: 28px;
height: 16px;
background: #d9d9d9;
border-radius: 16px;
position: relative;
outline: none;
cursor: pointer;
transition: background 0.3s;
margin: 0;
box-sizing: border-box;
}
.farewell-control-row input[type="checkbox"]::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 12px;
height: 12px;
background: #fff;
border-radius: 50%;
transition: left 0.3s;
box-shadow: 0 1px 2px rgba(0,0,0,0.2);
}
.farewell-control-row input[type="checkbox"]:checked {
background: #1890ff;
}
.farewell-control-row input[type="checkbox"]:checked::after {
left: 14px;
}
.farewell-status-row {
font-size: 13px;
color: #666;
font-family: monospace;
background: #f0f0f0;
padding: 4px 8px;
border-radius: 4px;
display: none;
margin-top: 10px;
text-align: left;
}
.farewell-status-row .chara-name {
color: #f298a8;
font-weight: bold;
}
`;
document.head.appendChild(style);
};
function renderUserCharacter(chara) {
var fluctSign = chara.Fluctuation > 0 ? '+' : '';
var title = `₵${formatNumber(chara.Current, 2)} / ${fluctSign}${formatNumber(chara.Fluctuation * 100, 2)}%`;
var amount = formatNumber(chara.State, 0);
if (chara.State == 0)
amount = "--";
var sacrifices = formatNumber(chara.Sacrifices, 0);
if (chara.Sacrifices == 0)
sacrifices = "--";
var level = chara.Level || 1; // 默认给个1防止没有
var item = `
<li class="farewell-item" title="${title}">
<a href="/character/${chara.Id}" target="_blank" class="avatar-link">
<div class="avatar-wrapper">
<img class="avatar-img" src="${normalizeAvatar(chara.Icon)}">
<span class="level-badge level-${level}">Lv${level}</span>
</div>
</a>
<div class="inner">
<a href="/character/${chara.Id}" target="_blank" class="chara-name">${escapeHtml(chara.Name)}</a>
<div class="chara-assets">
<div>持股: ${amount}</div>
<div>固定资产: ${sacrifices}</div>
</div>
</div>
</li>`;
return item;
}
function normalizeAvatar(avatar) {
if (!avatar) return '//lain.bgm.tv/pic/user/l/icon.jpg';
if (avatar.startsWith('https://tinygrail.oss-cn-hangzhou.aliyuncs.com/'))
return avatar + "!w120";
var a = avatar.replace("http://", "//");
// var index = a.indexOf("?");
// if (index >= 0)
// a = a.substr(0, index);
return a;
}
// ============================== //
const api = 'https://tinygrail.com/api/';
async function fetchWithTimeout(url, options = {}, timeoutMs = 15000) {
const controller = new AbortController();
const id = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, { ...options, signal: controller.signal });
clearTimeout(id);
return response;
} catch (e) {
clearTimeout(id);
throw e;
}
}
async function fetchGet(url) {
if (!url.startsWith('http')) url = api + url;
const response = await fetchWithTimeout(url, {
method: 'GET',
credentials: 'include'
});
if (!response.ok) throw new Error(`[HTTP error ${response.status}] ${response.statusText} `);
return await response.json();
}
async function fetchPost(url, data) {
if (!url.startsWith('http')) url = api + url;
const response = await fetchWithTimeout(url, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
if (!response.ok) throw new Error(`[HTTP error ${response.status}] ${response.statusText} `);
return await response.json();
}
async function retryPromise(promiseFn, n = 10, sleeptime = 500) {
let error;
for (let i = 0; i < n; i++) {
try {
return await promiseFn();
} catch (e) {
console.log(`[Retry ${i+1}/${n}] Error:`, e);
error = e;
await new Promise(resolve => setTimeout(resolve, sleeptime)); // sleep
}
}
throw error;
}
function cancelAsk(id) {
return retryPromise(() => fetchPost(`chara/ask/cancel/${id}`, null));
}
function cancelBid(id) {
return retryPromise(() => fetchPost(`chara/bid/cancel/${id}`, null));
}
function cancelAuction(id) {
return retryPromise(() => fetchPost(`chara/auction/cancel/${id}`, null));
}
function sacrificeCharacter(id, count, captial) {
return retryPromise(() => fetchPost(`chara/sacrifice/${id}/${count}/${captial}`, null));
}
function resetTempleCover(charaId, userId) { // userId 是内部ID 不是bgmId
return retryPromise(() => fetchPost(`chara/temple/cover/reset/${charaId}/${userId}`, null));
}
function getTradeInfo(charaId) {
return retryPromise(() => fetchGet(`chara/user/${charaId}`)).then(d => d ? d.Value : null);
}
function getBidsList() {
return retryPromise(() => fetchGet(`chara/bids/0/1/10000`))
.then(d => d && d.State === 0 && d.Value && d.Value.Items ? d.Value.Items : null);
}
function getCharaTemples(charaId) {
return retryPromise(() => fetchGet(`chara/temple/${charaId}`)).then(d => d ? d.Value : null);
}
function getAuctionsList() {
return retryPromise(() => fetchGet('chara/user/auction/1/1000'))
.then(d => d && d.State === 0 && d.Value && d.Value.Items ? d.Value.Items : null);
}
class Farewell {
constructor(captial, hyperMode = false) {
this._bgmId = (location.pathname.split('user/')[1] || '').replace(/[\/\?#].*/g, '');
this._charaInfo = null;
this._templeInfo = null;
this._captial = captial;
this._hyperMode = hyperMode;
this._charaInfoEl = null;
this.$farewellInfoEl = $(document.createElement('div')).addClass('farewell-status-row');
}
async farewell(callback) {
try {
this._prepare();
this.$farewellInfoEl.html('准备中…');
await this._charaFetch();
this._renderCharaPage();
for (let i = 0; i < this._charaInfo.length; i++) {
await this._farewellChara(this._charaInfo[i], this._charaInfoEl[i]);
}
this.$farewellInfoEl.html('取消剩余买单…');
await this._cancelMyBids();
this.$farewellInfoEl.html('取消拍卖挂单…');
await this._cancelMyAuctions();
this.$farewellInfoEl.html('获取重复圣殿信息…');
await this._templeFetch();
for (let temple of this._templeInfo) {
this.$farewellInfoEl.html(`重复圣殿图重置中…正在检测:#${temple.CharacterId} ${temple.Name}`);
await this._resetTempleCover(temple);
}
this.$farewellInfoEl.html(`再见,各位!`);
if (callback) callback();
} catch (e) {
console.error(e);
this.$farewellInfoEl.html(`<span style="color:#ff4d4f;font-weight:bold;">退坑过程发生错误: ${e.message}</span>`);
alert(`退坑过程中发生错误:\n${e.message}\n\n请打开浏览器控制台查看详情,或者刷新页面后重试。`);
if (callback) callback(e);
}
}
_prepare() {
injectCSS();
// 新版 DOM: tab 使用 role="tablist" > a.tab, 角色网格使用 .grid
// 点击"人物" tab 切换到角色视图
let $tabs = $('#tinygrail [role="tablist"] a.tab');
$tabs.each(function () {
if ($(this).text().includes('人物')) $(this).click();
});
// 创建一个专属的退坑列表容器
this.$farewellList = $(document.createElement('ul'))
.addClass('farewell-chara-list');
// 找到 tab 内容区域并替换为退坑列表
let $grid = $('#tinygrail .grid');
let $tabContent = $grid.length ? $grid.parent() : null;
if ($tabContent && $tabContent.length) {
$tabContent.empty().append(this.$farewellList);
} else {
// fallback: 插入到 tinygrail 面板尾部
$('#tinygrail .tinygrail').append(this.$farewellList);
}
}
// === get chara list === //
async _charaFetch() {
let d = await retryPromise(() => fetchGet(`chara/user/chara/${this._bgmId}/1/4096`));
if (!d || d.State !== 0) {
throw new Error(d ? d.Message || '获取角色列表失败' : '获取角色列表失败:返回值为空');
}
this._charaInfo = (d.Value.Items || []).filter(x => x.State).reverse(); // 去除无活股的角色并倒序排列
console.log('got charaInfo');
}
_renderCharaPage() {
this._charaInfoEl = this._charaInfo.map(x => $(renderUserCharacter(x)));
this.$farewellList.empty().append(this._charaInfoEl);
}
// === get temple list === //
async _templeFetch() {
let d = await retryPromise(() => fetchGet(`chara/user/temple/${this._bgmId}/1/20000`));
if (!d || d.State !== 0) {
throw new Error(d ? d.Message || '获取圣殿列表失败' : '获取圣殿列表失败:返回值为空');
}
console.log('got templeInfo');
this._templeInfo = d.Value.Items || [];
}
// === remove character === //
async _farewellChara(chara, charaEl) {
this.$farewellInfoEl.html(`再见,<span class="chara-name">${escapeHtml(chara.Name)}</span>!`);
let tradeInfo = await getTradeInfo(chara.Id);
await this._cancelTrades(tradeInfo);
if (testing) console.log(`fake sacrifice, chara Id: ${chara.Id}`);
else await sacrificeCharacter(chara.Id, chara.State, this._captial);
// 高速模式直接移除, 以极快速度退坑
// 非高速模式则增加延迟, 慢慢等待角色消失, 增强仪式感
if (this._hyperMode) {
charaEl.remove();
} else {
const elapse = 300;
await new Promise(resolve => charaEl.fadeOut(elapse, function () {
$(this).remove(); resolve();
}));
}
}
async _cancelTrades(tradeInfo) {
if (!tradeInfo) return;
let askIds = (tradeInfo.Asks || []).map(x => x.Id);
for (let id of askIds) {
if (testing) console.log(`fake cancel, ask Id: ${id}`);
else await cancelAsk(id);
}
let bidIds = (tradeInfo.Bids || []).map(x => x.Id);
for (let id of bidIds) {
if (testing) console.log(`fake cancel, bid Id: ${id}`);
else await cancelBid(id);
}
}
// === reset temple cover if duplicated === //
async _resetTempleCover(myTemple) {
if (!myTemple || !myTemple.Cover || myTemple.Cover.includes('lain.bgm.tv')) return;
let charaTemples = await getCharaTemples(myTemple.CharacterId);
if (!charaTemples || charaTemples.length <= 1) return;
if (charaTemples.some(x => x.Cover == myTemple.Cover && x.Name != this._bgmId)) {
if (testing) console.log(`fake reset temple cover, chara id: ${myTemple.CharacterId}`);
else await resetTempleCover(myTemple.CharacterId, myTemple.UserId);
}
}
// === remove all bids === //
async _cancelMyBids() {
let bids = await getBidsList();
if (!bids || !bids.length) return;
for (let i = 0; i < bids.length; i++) {
let bid = bids[i];
let tradeInfo = await getTradeInfo(bid.Id);
await this._cancelTrades(tradeInfo);
this.$farewellInfoEl.html(`取消剩余买单…(${i + 1}/${bids.length})`);
}
}
// === cancel all auctions === //
async _cancelMyAuctions() {
let auctionItems = await getAuctionsList();
if (!auctionItems || !auctionItems.length) return;
auctionItems = auctionItems.filter(x => x.State == 0);
for (let i = 0; i < auctionItems.length; i++) {
let item = auctionItems[i];
if (testing) console.log(`fake cancel, auction Id: ${item.Id}`);
else await cancelAuction(item.Id);
this.$farewellInfoEl.html(`取消拍卖挂单…(${i + 1}/${auctionItems.length})`);
}
}
}
let observer = new MutationObserver(function () {
let $grailOptions = $('#tinygrail .horizontalOptions');
if (!$grailOptions.length) return;
observer.disconnect();
injectCSS();
// farewell button
let $captialEl = $(`<label><input type="checkbox" name="captial" id="captial">无塔献祭</label>`);
let $hyperModeEl = $(`<label><input type="checkbox" name="hypermode" id="hypermode" checked>高速模式</label>`);
let $farewellBtn = $(document.createElement('a'))
.attr('href', "javascript:void(0)")
.addClass("farewell-btn-quit").html('一键退坑')
.on('click', function () {
if (!confirm('确定退坑吗?本操作无法反悔!\n如果误操作了,请及时关闭页面、刷新页面或者断开网络,以拯救暂未献祭的股票。')) return;
let captial = document.querySelector('#captial').checked;
let hypermode = document.querySelector('#hypermode').checked;
let asiaTime = new Date(new Date().toLocaleString("en-US", { timeZone: "Asia/Shanghai" }));
if (!captial && asiaTime.getDay() == 6) {
alert('周六无法进行资产重组!');
return;
}
let farewell = new Farewell(captial, hypermode);
$farewellBtn.html('退坑中…').off('click');
$captialEl.children('input').prop('disabled', true);
$hyperModeEl.children('input').prop('disabled', true);
// show status info and append it to the wrapper
farewell.$farewellInfoEl.css('display', 'block');
$uiWrapper.append(farewell.$farewellInfoEl);
farewell.farewell((err) => {
if (err) {
$farewellBtn.html('退坑失败');
} else {
$farewellBtn.html('退坑完成');
alert("退坑已完成!请刷新检查是否有遗漏。");
}
});
});
let $controlRow = $(document.createElement('div'))
.addClass('farewell-control-row')
.append($farewellBtn, $captialEl, $hyperModeEl);
let $uiWrapper = $(document.createElement('div'))
.addClass('farewell-ui-wrapper')
.append($controlRow);
$grailOptions.append($uiWrapper);
});
observer.observe(document.getElementById('user_home'), { 'childList': true, 'subtree': true });