Last active
January 25, 2026 16:48
-
-
Save abear1538/676780fd5b5d4c46983f557be3c99ce3 to your computer and use it in GitHub Desktop.
Wolfery Look Notify Utility Script (Userscript)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name Wolfery Look Notifier | |
| // @namespace wf-tools | |
| // @version 2.37.0 | |
| // @description Notifies you when someone looks at your character in the chat, including a count of how many times they've looked. | |
| // @match https://wolfery.com/* | |
| // @run-at document-start | |
| // @grant none | |
| // ==/UserScript== | |
| /** | |
| * DEBUGGING NOTE: | |
| * To ensure this script runs before Wolfery's initial WebSocket sync: | |
| * 1. Open Tampermonkey Dashboard -> Settings. | |
| * 2. Set 'Config Mode' to 'Advanced'. | |
| * 3. Change 'Content Script API' to 'UserScripts API Dynamic'. | |
| A note on privacy and platform rules compliance: | |
| * The script does not store any personally identifiable information (PII) in local storage. It hashes the information and stores the hashes. | |
| * The script does not interact with the server in any way. It only uses information sent by thee server. | |
| */ | |
| (() => { | |
| 'use strict'; | |
| const CFG = { | |
| tag: 'WF', | |
| version: '2.37.0', | |
| look: { startEmoji: '👁️', stopEmoji: '🙈' } | |
| }; | |
| console.log(`[${CFG.tag}] Script loading version ${CFG.version}...`); | |
| // DISABLE LEAVE CONFIRMATION | |
| window.addEventListener('beforeunload', (e) => { | |
| e.stopImmediatePropagation(); | |
| }, true); | |
| // Simple FNV-1a hash function for privacy | |
| const hashId = (str) => { | |
| let hash = 0x811c9dc5; | |
| for (let i = 0; i < str.length; i++) { | |
| hash ^= str.charCodeAt(i); | |
| hash = (hash * 0x01000193) >>> 0; | |
| } | |
| return hash.toString(16); | |
| }; | |
| const LS = { | |
| p: 'wfTools:', | |
| k(s){ return this.p + s; }, | |
| jget(s, d){ try { const v = localStorage.getItem(this.k(s)); return v ? JSON.parse(v) : d; } catch { return d; } }, | |
| jset(s, v){ localStorage.setItem(this.k(s), JSON.stringify(v)); }, | |
| }; | |
| const Time = { | |
| p2(n){ return String(n).padStart(2, '0'); }, | |
| hms(ms){ | |
| const d = new Date(ms); | |
| return `${this.p2(d.getHours())}:${this.p2(d.getMinutes())}:${this.p2(d.getSeconds())}`; | |
| } | |
| }; | |
| class Store { | |
| constructor(){ | |
| // Structure: { [hashedMyCharId]: { [hashedViewerId]: count } } | |
| this.lookStats = LS.jget('lookStats', {}); | |
| } | |
| save(){ | |
| LS.jset('lookStats', this.lookStats); | |
| } | |
| incLook(myCharId, viewerId){ | |
| if (!myCharId || !viewerId || viewerId === 'Someone') return 0; | |
| const myHash = hashId(myCharId); | |
| const viewerHash = hashId(viewerId); | |
| if (!this.lookStats[myHash]) this.lookStats[myHash] = {}; | |
| this.lookStats[myHash][viewerHash] = (this.lookStats[myHash][viewerHash] || 0) + 1; | |
| this.save(); | |
| return this.lookStats[myHash][viewerHash]; | |
| } | |
| } | |
| class Registry { | |
| constructor(){ | |
| this.byId = new Map(); | |
| this.controlledIds = new Set(); | |
| this.setupObserver(); | |
| } | |
| upsert(id, name, source) { | |
| if (!id || !name || name === 'Someone') return; | |
| const cleanName = name.trim(); | |
| if (!this.byId.has(id) || this.byId.get(id) === 'Someone') { | |
| this.byId.set(id, cleanName); | |
| console.log(`[${CFG.tag}] Mapped ${id} -> "${cleanName}" (${source})`); | |
| } | |
| } | |
| setupObserver() { | |
| const observer = new MutationObserver((mutations) => { | |
| let shouldScrape = false; | |
| for (const mutation of mutations) { | |
| if (mutation.addedNodes.length) { | |
| shouldScrape = true; | |
| break; | |
| } | |
| } | |
| if (shouldScrape) this.scrapeRoomList(); | |
| }); | |
| observer.observe(document.documentElement, { | |
| childList: true, | |
| subtree: true | |
| }); | |
| } | |
| findControlledCharId() { | |
| const tabs = document.querySelectorAll('.console--controlledlist .console-controlledchar'); | |
| let activeId = null; | |
| tabs.forEach(tab => { | |
| const id = tab.getAttribute('data-charid'); | |
| if (id) { | |
| this.controlledIds.add(id); | |
| if (tab.classList.contains('active')) activeId = id; | |
| const nameNode = tab.querySelector('.console-controlledchar--name'); | |
| if (nameNode) this.upsert(id, nameNode.innerText.trim(), "Self Tab Scrape"); | |
| } | |
| }); | |
| return activeId; | |
| } | |
| isUserChar(id) { | |
| if (!id) return false; | |
| if (this.controlledIds.has(id)) return true; | |
| this.findControlledCharId(); | |
| return this.controlledIds.has(id); | |
| } | |
| resolveName(id, models){ | |
| if (models) { | |
| for (const k of Object.keys(models)) { | |
| const m = models[k]; | |
| if (m && m.id === id && m.name) { | |
| const fullName = (m.name + (m.surname ? ' ' + m.surname : '')).trim(); | |
| this.upsert(id, fullName, "Server Model"); | |
| return fullName; | |
| } | |
| } | |
| } | |
| if (this.byId.has(id) && this.byId.get(id) !== 'Someone') return this.byId.get(id); | |
| const result = this.findNameBySpecificId(id); | |
| if (result) return result; | |
| this.scrapeRoomList(); | |
| if (this.byId.has(id)) return this.byId.get(id); | |
| return 'Someone'; | |
| } | |
| findNameBySpecificId(id) { | |
| if (!id) return null; | |
| const el = document.querySelector(`[data-charid="${id}"]`); | |
| if (el) { | |
| const name = this.extractNameFromElement(el); | |
| if (name) { | |
| this.upsert(id, name, "Instant ID Lookup"); | |
| return name; | |
| } | |
| } | |
| const img = document.querySelector(`img[src*="${id}"]`); | |
| if (img) { | |
| const parent = img.closest('.pageroom-char--cont, .badge, .console-controlledchar'); | |
| if (parent) { | |
| const name = this.extractNameFromElement(parent); | |
| if (name) { | |
| this.upsert(id, name, "Instant Image Lookup"); | |
| return name; | |
| } | |
| } | |
| } | |
| return null; | |
| } | |
| extractNameFromElement(el) { | |
| let name = ""; | |
| const titleAttr = el.getAttribute('title') || ""; | |
| if (titleAttr) { | |
| name = titleAttr.split('\n')[0].trim(); | |
| } | |
| if (!name || name === "Someone") { | |
| const nameContainer = el.querySelector('.pageroom-char--name, .console-controlledchar--name, .badge--title'); | |
| if (nameContainer) { | |
| name = Array.from(nameContainer.querySelectorAll('span')) | |
| .map(s => s.innerText.trim()) | |
| .filter(t => t) | |
| .join(' '); | |
| if (!name) name = nameContainer.innerText.trim(); | |
| } | |
| } | |
| return (name && name !== "Someone") ? name : null; | |
| } | |
| scrapeRoomList() { | |
| const elements = document.querySelectorAll('.pageroom-char--badge, .console-controlledchar, .badge, [data-charid], .pageroom-char--cont'); | |
| elements.forEach(el => { | |
| let id = el.getAttribute('data-charid'); | |
| if (!id) { | |
| const img = el.querySelector('img[src*="/chars/"]'); | |
| if (img) { | |
| const src = img.getAttribute('src') || ''; | |
| const idMatch = src.match(/\/([a-z0-9]+)-s\.png/i) || src.match(/chars\/([a-z0-9]+)/i); | |
| if (idMatch) id = idMatch[1]; | |
| } | |
| } | |
| if (!id) return; | |
| const name = this.extractNameFromElement(el); | |
| if (name) this.upsert(id, name, "Proactive Scrape"); | |
| }); | |
| } | |
| } | |
| class UI { | |
| constructor(reg){ | |
| this.reg = reg; | |
| this.injectCSS(); | |
| this.setupClearCheck(); | |
| } | |
| injectCSS(){ | |
| const css = ` | |
| .wf-sysline{padding:4px 0; font-size: 0.9em; border-left: 3px solid #666; padding-left: 10px; margin: 6px 0; animation: wf-fadein 0.4s ease-out; font-family: sans-serif;} | |
| .wf-tag{font-weight:700; opacity:0.6; margin-right:8px; color: #888; font-size: 0.85em;} | |
| .wf-ts{opacity:0.5; margin-right:8px; font-variant-numeric: tabular-nums; font-size: 0.85em;} | |
| .wf-emoji{margin-right:8px;} | |
| .wf-text{opacity: 0.95; color: #eee;} | |
| .wf-version{opacity: 0.2; font-size: 0.7em; margin-left: 10px; font-style: italic;} | |
| .wf-text.wf-pending{color: #888; font-style: italic;} | |
| .wf-clear-btn { | |
| position: fixed; | |
| top: 10px; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| z-index: 10000; | |
| background: #111; | |
| color: #777; | |
| border: 1px solid #333; | |
| border-radius: 4px; | |
| padding: 4px 10px; | |
| font-size: 10px; | |
| font-weight: 600; | |
| text-transform: uppercase; | |
| cursor: pointer; | |
| box-shadow: 0 2px 5px rgba(0,0,0,0.5); | |
| transition: all 0.2s; | |
| opacity: 0.7; | |
| } | |
| .wf-clear-btn:hover { background: #222; color: #eee; border-color: #555; opacity: 1; } | |
| @keyframes wf-fadein { from { opacity: 0; transform: translateX(-10px); } to { opacity: 1; transform: translateX(0); } } | |
| `; | |
| const s = document.createElement('style'); | |
| s.textContent = css; | |
| document.documentElement.appendChild(s); | |
| } | |
| setupClearCheck() { | |
| setInterval(() => { | |
| if (!document.querySelector('.wf-clear-btn')) { | |
| const btn = document.createElement('button'); | |
| btn.className = 'wf-clear-btn'; | |
| btn.textContent = 'Clear Look Notices'; | |
| btn.title = 'Remove WF notifications from chat'; | |
| btn.onclick = (e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| const lines = document.querySelectorAll('.wf-sysline'); | |
| console.log(`[${CFG.tag}] Clearing ${lines.length} lines.`); | |
| lines.forEach(el => el.remove()); | |
| }; | |
| document.body.appendChild(btn); | |
| } | |
| }, 2000); | |
| } | |
| chatList(){ | |
| return document.querySelector('.charlog--list'); | |
| } | |
| notify(emoji, text, viewerId, targetId, count) { | |
| const list = this.chatList(); | |
| if (!list) return; | |
| const wrap = document.createElement('div'); | |
| wrap.className = 'wf-sysline'; | |
| const tsEl = document.createElement('span'); | |
| tsEl.className = 'wf-ts'; | |
| tsEl.textContent = Time.hms(Date.now()); | |
| const emo = document.createElement('span'); | |
| emo.className = 'wf-emoji'; | |
| emo.textContent = emoji; | |
| const msg = document.createElement('span'); | |
| msg.className = 'wf-text'; | |
| if (text.includes('Someone')) msg.classList.add('wf-pending'); | |
| msg.textContent = text; | |
| const ver = document.createElement('span'); | |
| ver.className = 'wf-version'; | |
| ver.textContent = `v${CFG.version}`; | |
| wrap.append(tsEl, emo, msg, ver); | |
| const last = list.lastElementChild; | |
| if (last) last.after(wrap); | |
| else list.appendChild(wrap); | |
| const cont = list.closest('.charlog--list-cont'); | |
| if (cont) { | |
| const isNearBottom = cont.scrollHeight - cont.clientHeight - cont.scrollTop < 250; | |
| if (isNearBottom) cont.scrollTop = cont.scrollHeight; | |
| } | |
| if (text.includes('Someone')) { | |
| let attempts = 0; | |
| const maxAttempts = 15; | |
| const interval = setInterval(() => { | |
| attempts++; | |
| this.reg.scrapeRoomList(); | |
| const vName = this.reg.resolveName(viewerId); | |
| const tName = this.reg.resolveName(targetId); | |
| if (vName !== 'Someone' && tName !== 'Someone') { | |
| const verb = emoji === CFG.look.startEmoji ? 'is looking at' : 'stopped looking at'; | |
| let newText = `${vName} ${verb} ${tName}`; | |
| if (emoji === CFG.look.startEmoji && count > 1) newText += ` (${count}x)`; | |
| msg.textContent = newText; | |
| msg.classList.remove('wf-pending'); | |
| clearInterval(interval); | |
| } | |
| if (attempts >= maxAttempts) clearInterval(interval); | |
| }, 500); | |
| } | |
| } | |
| } | |
| class Router { | |
| constructor(store, reg, ui){ | |
| this.store = store; | |
| this.reg = reg; | |
| this.ui = ui; | |
| this.patchWebSocket(); | |
| } | |
| patchWebSocket(){ | |
| const self = this; | |
| const OriginalWS = window.WebSocket; | |
| window.WebSocket = function(...args) { | |
| const socket = new OriginalWS(...args); | |
| socket.addEventListener('message', (ev) => { | |
| try { self.handle(ev.data); } catch (e) { console.error(`[${CFG.tag}] Router Error:`, e); } | |
| }); | |
| return socket; | |
| }; | |
| window.WebSocket.prototype = OriginalWS.prototype; | |
| for (const prop in OriginalWS) { | |
| if (OriginalWS.hasOwnProperty(prop)) window.WebSocket[prop] = OriginalWS[prop]; | |
| } | |
| console.log(`[${CFG.tag}] WebSocket Interceptor active (v${CFG.version}).`); | |
| } | |
| handle(raw){ | |
| if (typeof raw !== 'string' || raw[0] !== '{') return; | |
| const msg = JSON.parse(raw); | |
| if (!msg) return; | |
| const models = msg.data?.models; | |
| if (models) { | |
| for (const k of Object.keys(models)) { | |
| const m = models[k]; | |
| if (m && m.id && m.name) { | |
| const fullName = (m.name + (m.surname ? ' ' + m.surname : '')).trim(); | |
| this.reg.upsert(m.id, fullName, "WebSocket Model"); | |
| } | |
| } | |
| } | |
| if (typeof msg.event === 'string' && msg.event.includes('core.lookedat.char.')) { | |
| const match = msg.event.match(/core\.lookedat\.char\.([a-z0-9]+)/i); | |
| const targetId = match ? match[1] : null; | |
| const values = msg.data?.values || {}; | |
| setTimeout(() => { | |
| this.reg.findControlledCharId(); | |
| const targetName = this.reg.resolveName(targetId, models); | |
| const targetDisplay = targetName; | |
| for (const [viewerId, v] of Object.entries(values)) { | |
| const started = (v === true); | |
| const stopped = (!!v && typeof v === 'object' && v.action === 'delete'); | |
| if (!started && !stopped) continue; | |
| const viewerName = this.reg.resolveName(viewerId, models); | |
| const emoji = started ? CFG.look.startEmoji : CFG.look.stopEmoji; | |
| const verb = started ? 'is looking at' : 'stopped looking at'; | |
| let text = `${viewerName} ${verb} ${targetDisplay}`; | |
| let count = 0; | |
| if (started) { | |
| count = this.store.incLook(targetId, viewerId); // Increment count for specific target | |
| if (count > 1) text += ` (${count}x)`; | |
| } | |
| this.ui.notify(emoji, text, viewerId, targetId, count); | |
| } | |
| }, 150); | |
| } | |
| } | |
| } | |
| const store = new Store(); | |
| const reg = new Registry(); | |
| const ui = new UI(reg); | |
| new Router(store, reg, ui); | |
| console.log(`[${CFG.tag}] System ready (v${CFG.version}).`); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment