Workshop Mod Marker

Steam创意工坊MOD浏览标记

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey, Greasemonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

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

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Workshop Mod Marker
// @namespace    mod-workshop-marker
// @version      1.1
// @description  Steam创意工坊MOD浏览标记
// @author       Teshuwenzi
// @license      MIT
// @include      https://steamcommunity.com/workshop/*
// @run-at       document-end
// @grant        none
// ==/UserScript==

(function () {
    'use strict';

    const KEY = 'steam_mod_marker_data';

    /* ================= 数据读写 ================= */

    function load() {
        return JSON.parse(localStorage.getItem(KEY) || '{}');
    }

    function save(data) {
        localStorage.setItem(KEY, JSON.stringify(data));
    }

    /* ================= 工坊卡片识别 ================= */

    function getModId(card) {
        const a = card.querySelector('a[href*="filedetails/?id="]');
        if (!a) return null;
        const m = a.href.match(/id=(\d+)/);
        return m ? m[1] : null;
    }

    function findCards() {
        return Array.from(document.querySelectorAll('a[href*="filedetails/?id="]'))
            .map(a => a.closest('div'))
            .filter(Boolean);
    }

    function applyStyle(card, state) {
        card.style.opacity = '';
        card.style.outline = '';

        if (state === 'seen') {
            card.style.opacity = '0.7';
            card.style.outline = '2px solid white';
        }
        if (state === 'unlike') {
            card.style.opacity = '0.4';
            card.style.outline = '2px solid red';
        }
        if (state === 'like') {
            card.style.opacity = '0.9';
            card.style.outline = '2px solid gold';
        }
    }

    /* ================= MOD 按钮 ================= */

    function addButtons(card, id, data) {
        if (card.querySelector('.tm-marker')) return;

        const box = document.createElement('div');
        box.className = 'tm-marker';
        box.style.position = 'absolute';
        box.style.top = '4px';
        box.style.right = '4px';
        box.style.zIndex = '999';
        box.style.display = 'flex';
        box.style.flexDirection = 'column';
        box.style.gap = '2px';

        const row1 = document.createElement('div');
        row1.style.display = 'flex';
        row1.style.gap = '3px';

        const row2 = document.createElement('div');
        row2.style.display = 'flex';
        row2.style.justifyContent = 'flex-end';

        const makeBtn = (text, title, onClick) => {
            const b = document.createElement('button');
            b.textContent = text;
            b.title = title;
            b.style.cursor = 'pointer';
            b.style.fontSize = '9px';
            b.style.width = '18px';
            b.style.height = '18px';
            b.style.padding = '0';
            b.onclick = e => {
                e.stopPropagation();
                onClick();
            };
            return b;
        };

        row1.appendChild(makeBtn('👁', '已浏览', () => {
            data[id] = data[id] || {};
            data[id].state = 'seen';
            save(data);
            applyStyle(card, 'seen');
        }));

        row1.appendChild(makeBtn('❌', '不喜欢', () => {
            data[id] = data[id] || {};
            data[id].state = 'unlike';
            save(data);
            applyStyle(card, 'unlike');
        }));

        row1.appendChild(makeBtn('❤️', '收藏', () => {
            data[id] = data[id] || {};
            data[id].state = 'like';
            save(data);
            applyStyle(card, 'like');
        }));

        row2.appendChild(makeBtn('🔄', '重置标注', () => {
            if (data[id]) {
                delete data[id].state;
                if (!data[id].note) delete data[id];
            }
            save(data);
            card.style.opacity = '';
            card.style.outline = '';
        }));

        box.appendChild(row1);
        box.appendChild(row2);

        card.style.position = 'relative';
        card.appendChild(box);
    }

    /* ================= 备注 ================= */

    function addNote(card, id, data) {
        if (card.querySelector('.tm-note')) return;

        const noteBox = document.createElement('div');
        noteBox.className = 'tm-note';
        noteBox.style.marginTop = '4px';
        noteBox.style.fontSize = '12px';

        const input = document.createElement('input');
        input.type = 'text';
        input.placeholder = '添加你的备注…';
        input.style.width = '80%';

        const ok = document.createElement('button');
        ok.textContent = '✔';
        ok.style.marginLeft = '4px';
        ok.style.width = '20px';
        ok.style.height = '20px';
        ok.style.lineHeight = '18px';
        ok.style.fontSize = '14px';
        ok.style.fontWeight = 'bold';
        ok.style.background = 'transparent';
        ok.style.border = 'none';
        ok.style.borderRadius = '4px';
        ok.style.cursor = 'pointer';
        ok.style.padding = '0';

        const setSaved = (saved) => {
            ok.style.color = saved ? '#6fdc6f' : '#ddd';
        };

        ok.onmouseenter = () => ok.style.background = 'rgba(255,255,255,0.15)';
        ok.onmouseleave = () => ok.style.background = 'transparent';

        if (data[id] && data[id].note) {
            input.value = data[id].note;
            setSaved(true);
        } else {
            setSaved(false);
        }

        input.addEventListener('focus', () => {
            setSaved(false);
        });

        ok.onclick = e => {
            e.stopPropagation();
            data[id] = data[id] || {};
            data[id].note = input.value;

            if (!data[id].note && !data[id].state) {
                delete data[id];
            }

            save(data);
            setSaved(!!input.value);
        };

        noteBox.appendChild(input);
        noteBox.appendChild(ok);
        card.appendChild(noteBox);
    }

    /* ================= 导入 / 导出 ================= */

    function getDateStr() {
        return new Date().toISOString().slice(0, 10);
    }

    function exportTxt() {
        const data = load();
        const lines = [];

        lines.push(
`
作者:Teshuwenzi
功能:Steam创意工坊MOD浏览标记
安装说明:需要呦齁,chrome或者edge,浏览器需开启开发人员模式
功能介绍:
1.创意工坊的每个MOD小页面的增强按钮:看过啦,不喜欢的,收藏的,重置标注。
2.每个MOD下方支持输入文字备注(每条MOD备注后,需要按 √ 才可以保存,√ 绿色:已保存。√ 白色:未保存/编辑中)。
3.支持将所有已记录的MOD备注数据 导入 / 导出成为TXT存档文件。
4.数据格式:每一行为一个MOD 【标注状态	工坊id	文字备注】,TXT里,收藏的排序最前。
5.导入tXt会合并浏览器内保存的进度,比如保存TxT 时某条MOD有备注,过后这条备注在浏览器被手动重置归零(状态,文字),那么再次导入tXt时则会合并为有备注的版本
6.导入txT会合并浏览器内保存的进度,比如保存tXT时某条MOD无备注,过后这条备注在浏览器被手动添加状态(状态,文字),那么再次导入txT时则会合并为有备注的版本
7.导入Txt会覆盖浏览器内保存的进度,比如保存TXt时某条MOD有备注,过后这条备注在浏览器被手动修改状态(状态,文字),那么再次导入Txt时则会覆盖为导入的历史版本
8.TxT 被视为“存档快照”,导入 tXt 会覆盖 浏览器 中已有的记录,但TXT里没有的信息:状态,文字,不会被覆盖,而是共存。
⑨脑子不好使,逻辑有点绕,但按上面规则来就对了
10.TXT 导入 / 导出功能主要用于:更换电脑,更换浏览器,重装电脑系统前备份,日常使用时,所有数据都会直接保存在本地浏览器中,不依赖 txt 文件。
11.所有功能与数据仅对浏览器页面进行增强显示,不会修改 Steam 服务器数据,也不会绑定或关联 Steam 账号,所有数据仅保存在用户本地浏览器中。
12.目的:搜集MOD时,避免反复观看排名变化的MOD详情而设计,收藏按钮只为统一视觉效果,不与steam互通,如果真收藏/订阅请在MOD页面内收藏/订阅。
13.如果收藏按钮使用出现理解分歧以至于不知道收藏到哪了,请参考第4条:在txt内寻找收藏的ID与备注,返工愉快!

`
        );

        const entries = Object.keys(data).map(id => {
            const state = data[id].state || (data[id].note ? 'none' : '');
            const note = data[id].note || '';
            return { id, state, note };
        }).filter(e => e.state || e.note);

        // 按状态首字母排序,其次按 ID
        entries.sort((a, b) => {
            const sa = (a.state || 'none')[0];
            const sb = (b.state || 'none')[0];
            if (sa !== sb) return sa.localeCompare(sb);
            return a.id.localeCompare(b.id);
        });

        entries.forEach(e => {
            lines.push(`${e.state || 'none'}\t${e.id}\t${e.note}`);
        });

        const blob = new Blob([lines.join('\n')], { type: 'text/plain;charset=utf-8' });
        const a = document.createElement('a');
        a.href = URL.createObjectURL(blob);
        a.download = `WorkShopModMarker_${getDateStr()}.txt`;
        a.click();
        URL.revokeObjectURL(a.href);
    }

    function importTxt(file) {
        const reader = new FileReader();
        reader.onload = () => {
            const data = load();
            const lines = reader.result.split('\n');

            lines.forEach(line => {
                if (!/^\w+\t\d+/.test(line)) return;

                const parts = line.split('\t');
                const state = (parts[0] || '').trim();
                const id = (parts[1] || '').trim();
                const note = (parts[2] || '').trim();

                if (!id) return;

                if (!state && !note) {
                    delete data[id];
                    return;
                }

                data[id] = {};
                if (state && state !== 'none') data[id].state = state;
                if (note) data[id].note = note;
                if (!data[id].state && !data[id].note) delete data[id];
            });

            save(data);
            alert('导入完成,请刷新页面');
        };
        reader.readAsText(file);
    }

    function addTopButtons() {
        if (document.querySelector('.tm-top-btns')) return;

        const box = document.createElement('div');
        box.className = 'tm-top-btns';
        box.style.position = 'fixed';
        box.style.top = '10px';
        box.style.right = '20px';
        box.style.zIndex = '10000';
        box.style.display = 'flex';
        box.style.gap = '6px';

        const btn = (text, title, onClick) => {
            const b = document.createElement('button');
            b.textContent = text;
            b.title = title;
            b.style.cursor = 'pointer';
            b.onclick = onClick;
            return b;
        };

        box.appendChild(btn('📤 导出', '导出备注 TXT', exportTxt));

        const fileInput = document.createElement('input');
        fileInput.type = 'file';
        fileInput.accept = '.txt';
        fileInput.style.display = 'none';
        fileInput.onchange = () => {
            if (fileInput.files[0]) importTxt(fileInput.files[0]);
        };

        box.appendChild(btn('📥 导入', '导入备注 TXT', () => fileInput.click()));
        box.appendChild(fileInput);

        document.body.appendChild(box);
    }

    /* ================= 主循环 ================= */

    function run() {
        const data = load();
        findCards().forEach(card => {
            const id = getModId(card);
            if (!id) return;
            addButtons(card, id, data);
            addNote(card, id, data);
            if (data[id] && data[id].state) {
                applyStyle(card, data[id].state);
            }
        });
        addTopButtons();
    }

    setInterval(run, 1200);
})();