Workshop Mod Marker

Steam创意工坊MOD浏览标记

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==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);
})();