您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Track kudos & comments with score, icons, reset & stats
// ==UserScript== // @name AO3 Karma // @namespace http://lithiumdoll // @version 0.1 // @description Track kudos & comments with score, icons, reset & stats // @match https://archiveofourown.org/* // @run-at document-idle // @grant GM.getValue // @grant GM.setValue // ==/UserScript== /* jshint esversion: 11 */ (async function () { 'use strict'; const CONFIG = { BOX_ID: 'ao3-karma-box', KUDOS_POINTS: 1, COMMENT_POINTS: 10, STORAGE_KEYS: { SCORE: 'score', KUDOS: 'kudos', COMMENT: 'comment', POS: 'pos' }, TIME_RANGES: { Day: 86400000, Week: 604800000, Month: 2592000000, Year: 31536000000 } }; // One time only if (window.__ao3_karma_initialized) { window.__ao3_karma_refresh?.(); return; } const store = { get: (key, def) => GM.getValue(CONFIG.STORAGE_KEYS[key], def), set: (key, val) => GM.setValue(CONFIG.STORAGE_KEYS[key], val) }; function injectStyles() { const css = ` #${CONFIG.BOX_ID} { position: fixed; top: 10px; right: 10px; width: 160px; height: 160px; background: #e0e0e0; color: #000; font: bold 48px sans-serif; display: flex; align-items: center; justify-content: center; border-radius: 12px; z-index: 999999; box-shadow: 0 2px 6px rgba(0,0,0,.2); cursor: grab; user-select: none; transition: transform .2s; } #${CONFIG.BOX_ID} .karma-action { position: absolute; cursor: pointer; color: gray; transition: color .2s, transform .1s; } #${CONFIG.BOX_ID} .karma-action:hover { transform: scale(1.1); } `; document.head.appendChild(Object.assign(document.createElement('style'), { textContent: css })); } class AO3Karma { constructor() { this.score = 0; this.kudosLog = {}; this.commentLog = {}; } async init() { injectStyles(); await this.load(); this.createUI(); this.bindEvents(); this.updateUI(); window.__ao3_karma_refresh = () => this.refresh(); window.__ao3_karma_initialized = true; } async load() { this.score = await store.get('SCORE', 0); this.kudosLog = await store.get('KUDOS', {}) || {}; this.commentLog = await store.get('COMMENT', {}) || {}; } async save() { await Promise.all([ store.set('SCORE', this.score), store.set('KUDOS', this.kudosLog), store.set('COMMENT', this.commentLog) ]); } createUI() { document.getElementById(CONFIG.BOX_ID)?.remove(); this.box = Object.assign(document.createElement('div'), { id: CONFIG.BOX_ID }); this.scoreLabel = Object.assign(document.createElement('div'), { style: 'pointer-events:none' }); this.box.appendChild(this.scoreLabel); const btn = (txt, pos, handler) => { const b = Object.assign(document.createElement('div'), { className: 'karma-action', textContent: txt }); b.style.cssText = pos; b.onclick = e => { e.stopPropagation(); handler(); }; return b; }; this.heart = btn('♡', 'top:6px;left:8px;font-size:24px', () => document.querySelector('#kudo_submit')?.scrollIntoView({ behavior: 'smooth', block: 'center' }) ); this.comment = btn('💬', 'top:6px;right:8px;font-size:22px', () => document.querySelector('input[id^="comment_submit_for_"]')?.scrollIntoView({ behavior: 'smooth', block: 'center' }) ); const reset = btn('🔄', 'bottom:6px;left:8px;font-size:18px', () => this.reset()); const stats = btn('📊', 'bottom:6px;right:8px;font-size:18px', () => this.stats()); [this.heart, this.comment, reset, stats].forEach(b => this.box.appendChild(b)); document.body.appendChild(this.box); this.makeDraggable(); this.restorePos(); } makeDraggable() { let dragging = false, offsetX = 0, offsetY = 0; this.box.addEventListener('mousedown', e => { if (e.target.classList.contains('karma-action')) return; dragging = true; this.box.style.cursor = 'grabbing'; const r = this.box.getBoundingClientRect(); offsetX = e.clientX - r.left; offsetY = e.clientY - r.top; }); document.addEventListener('mousemove', e => { if (!dragging) return; const x = Math.min(window.innerWidth - this.box.offsetWidth, Math.max(0, e.clientX - offsetX)); const y = Math.min(window.innerHeight - this.box.offsetHeight, Math.max(0, e.clientY - offsetY)); Object.assign(this.box.style, { left: x + 'px', top: y + 'px', right: 'auto' }); }); document.addEventListener('mouseup', async () => { if (!dragging) return; dragging = false; this.box.style.cursor = 'grab'; await store.set('POS', { top: this.box.style.top, left: this.box.style.left }); }); } async restorePos() { const pos = await store.get('POS', null); if (pos) Object.assign(this.box.style, pos, { right: 'auto' }); } bindEvents() { document.addEventListener('click', e => { if (e.target?.id === 'kudo_submit') this.add('kudos', CONFIG.KUDOS_POINTS); if (e.target?.id?.startsWith('comment_submit_for_')) this.add('comment', CONFIG.COMMENT_POINTS, true); }, true); document.addEventListener('submit', e => { if (e.target?.querySelector('#kudo_commentable_id')) this.add('kudos', CONFIG.KUDOS_POINTS); if (e.target?.querySelector('input[id^="comment_submit_for_"]')) this.add('comment', CONFIG.COMMENT_POINTS, true); }, true); } getWorkId() { return document.querySelector('#kudo_commentable_id')?.value || null; } async add(type, points, requireText = false) { const id = this.getWorkId(); if (!id) return; if (requireText) { const txt = document.querySelector('textarea[id^="comment_content_for_"]')?.value.trim(); if (!txt) return; } const log = this[`${type}Log`]; if (log[id]) return; log[id] = new Date().toISOString(); this.score += points; await this.save(); this.updateUI(); } updateUI() { this.scoreLabel.textContent = this.score; const id = this.getWorkId(); this.heart.textContent = (id && this.kudosLog[id]) ? '❤️' : '♡'; this.heart.style.color = (id && this.kudosLog[id]) ? 'red' : 'gray'; this.comment.style.color = (id && this.commentLog[id]) ? 'green' : 'gray'; } async reset() { if (!confirm('Reset karma?')) return; this.score = 0; this.kudosLog = {}; this.commentLog = {}; await this.save(); this.updateUI(); } stats() { const now = Date.now(); const count = (log, ms) => Object.values(log).filter(ts => now - Date.parse(ts) <= ms).length; let msg = '⭐ Stats ⭐\n\n' + Object.entries(CONFIG.TIME_RANGES).map(([p, ms]) => { const k = count(this.kudosLog, ms), c = count(this.commentLog, ms); return `${p}: ${(k + c * CONFIG.COMMENT_POINTS)} points (❤️ ${k}, 💬 ${c})`; }).join('\n'); msg += `\n\nAll-time: ${this.score} points (❤️ ${Object.keys(this.kudosLog).length}, 💬 ${Object.keys(this.commentLog).length})`; alert(msg); } async refresh() { await this.load(); this.updateUI(); } } new AO3Karma().init(); })();