Farewell TinyGrail

小圣杯一键退坑

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

You will need to install an extension such as Tampermonkey to install this script.

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==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, '&lt;')
        .replace(/>/g, '&gt;')
        .replace(/"/g, '&quot;');
}

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 });