Skip to content

Instantly share code, notes, and snippets.

@abear1538
Last active January 25, 2026 16:48
Show Gist options
  • Select an option

  • Save abear1538/676780fd5b5d4c46983f557be3c99ce3 to your computer and use it in GitHub Desktop.

Select an option

Save abear1538/676780fd5b5d4c46983f557be3c99ce3 to your computer and use it in GitHub Desktop.
Wolfery Look Notify Utility Script (Userscript)
// ==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