您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Shows Last Swiss / Last rehab from a PublicView CSV on the Employees page.
// ==UserScript== // @name Torn – Last Rehab & Last Swiss (PublicView CSV • row under header) // @namespace http://tampermonkey.net/ // @version 1.6 // @description Shows Last Swiss / Last rehab from a PublicView CSV on the Employees page. // @match https://www.torn.com/companies.php* // @run-at document-idle // @license MIT // @grant none // ==/UserScript== (function () { 'use strict'; const LS_URL = 'tm_torn_public_csv_url_v1'; const LS_CACHE = 'tm_torn_public_cache_v1'; const CACHE_TTL_MS = 30 * 60 * 1000; let cache = loadCache(); let fetchInFlight = null; const EMP_LIST_SEL = '#employees form .employee-list-wrap ul.employee-list.t-blue-cont.h'; const isEmployeesRoute = () => location.pathname === '/companies.php' && /(^|#|&|\?)option=employees\b/i.test(location.href); mountCsvAndRefreshButtons(); startOnce(); window.addEventListener('load', () => { if (isEmployeesRoute()) renderAllEmployees(true); }); window.addEventListener('hashchange', () => { if (isEmployeesRoute()) startOnce(); }); async function startOnce() { if (!isEmployeesRoute()) return; await waitFor(() => document.querySelector(EMP_LIST_SEL), 10000, 250); renderAllEmployees(true).catch(() => {}); } async function renderAllEmployees(force = false) { const url = getCsvUrl(); if (!url) return; await ensureData(url, force); document .querySelectorAll(`${EMP_LIST_SEL} > li[data-user]`) .forEach(injectLeftMeta); } function injectLeftMeta(liEl) { const leftCell = liEl.querySelector(':scope > .acc-header .employee'); if (!leftCell) return; leftCell.style.overflow = 'visible'; leftCell.style.height = 'auto'; leftCell.style.maxHeight = 'none'; leftCell.style.minHeight = '40px'; const header = liEl.querySelector(':scope > .acc-header'); if (header) { header.style.height = 'auto'; header.style.minHeight = '40px'; } const id = liEl.getAttribute('data-user'); const name = leftCell.querySelector('.honor-text')?.textContent?.trim() || ''; const rec = lookup(id ? { id } : { id: null, name }); let meta = leftCell.querySelector(':scope > .tm-left-meta'); if (!meta) { meta = document.createElement('div'); meta.className = 'tm-left-meta'; const anchor = leftCell.querySelector('a.user.name') || leftCell.lastElementChild; anchor?.insertAdjacentElement('afterend', meta); Object.assign(meta.style, { marginTop: '3px', fontSize: '12px', fontFamily: 'Arial, sans-serif', fontWeight: '400', lineHeight: '16px', color: 'rgb(221, 221, 221)', whiteSpace: 'normal', wordBreak: 'break-word', pointerEvents: 'none', }); } const isUnknown = v => !v || /unknown/i.test(v); const swissTxt = (rec && !isUnknown(rec.last_swiss)) ? tornRelative(rec.last_swiss) : 'Unknown'; const rehabTxt = (rec && !isUnknown(rec.last_rehab)) ? tornRelative(rec.last_rehab) : 'Unknown'; meta.innerHTML = `Swiss: ${swissTxt}<br>Rehab: ${rehabTxt}`; } async function ensureData(url, force = false) { const now = Date.now(); if (!force && (now - cache.lastFetch < CACHE_TTL_MS)) return; if (fetchInFlight) return fetchInFlight; fetchInFlight = (async () => { let reqUrl = url; if (force) { try { const u = new URL(url); u.searchParams.set('tmcb', String(Date.now())); reqUrl = u.toString(); } catch { reqUrl = url + (url.includes('?') ? '&' : '?') + 'tmcb=' + Date.now(); } } const res = await fetch(reqUrl, { method: 'GET', cache: 'no-store', credentials: 'omit', redirect: 'follow' }); if (!res.ok) throw new Error('CSV fetch failed ' + res.status); const csv = await res.text(); const rows = parseCsv(csv); if (!rows.length) return; const header = rows.shift().map(s => (s || '').trim().toLowerCase()); const ci = { id: header.indexOf('id'), name: header.indexOf('name'), swiss: header.indexOf('last_swiss'), rehab: header.indexOf('last_rehab') }; ['id','name','swiss','rehab'].forEach(k => { if (ci[k] < 0) throw new Error('Missing column: ' + k); }); const byId = Object.create(null), byName = Object.create(null), byNameNorm = Object.create(null); for (const r of rows) { const id = ((r[ci.id] ?? '') + '').trim(); const name = ((r[ci.name] ?? '') + '').trim(); const last_swiss = ((r[ci.swiss] ?? '') + '').trim(); const last_rehab = ((r[ci.rehab] ?? '') + '').trim(); const obj = { id, name, last_swiss, last_rehab }; if (id) byId[id] = obj; if (name) { byName[name.toLowerCase()] = obj; byNameNorm[normalizeName(name)] = obj; } } cache = { byId, byName, byNameNorm, lastFetch: now }; saveCache(); })().finally(() => { fetchInFlight = null; }); return fetchInFlight; } function lookup(ref) { if (ref.id && cache.byId?.[ref.id]) return cache.byId[ref.id]; if (ref.name) { const n = ref.name.toLowerCase(); if (cache.byName?.[n]) return cache.byName[n]; const nn = normalizeName(ref.name); if (cache.byNameNorm?.[nn]) return cache.byNameNorm[nn]; } return null; } function mountCsvAndRefreshButtons() { const attach = () => { const heading = document.querySelector('.title-black.top-round.m-top10[role="heading"][aria-level="5"]'); if (!heading) return; let chip = heading.querySelector('.tm-csv-chip'); if (!chip) { chip = document.createElement('button'); chip.className = 'tm-csv-chip'; chip.title = 'Paste your PublicView → CSV link'; Object.assign(chip.style, { marginLeft: '8px', padding: '2px 8px', font: '12px/1 system-ui, Arial, sans-serif', border: '0', borderRadius: '999px', cursor: 'pointer', verticalAlign: 'middle', color: '#fff', }); heading.appendChild(chip); chip.addEventListener('click', async () => { await promptForUrl(); const ok = !!getCsvUrl(); chip.textContent = ok ? 'CSV: Set' : 'CSV: Missing'; chip.style.background = ok ? '#2e7d32' : '#c62828'; if (isEmployeesRoute()) renderAllEmployees(true); document.dispatchEvent(new CustomEvent('tm:csv-updated')); }); } const ok = !!getCsvUrl(); chip.textContent = ok ? 'CSV: Set' : 'CSV: Missing'; chip.style.background = ok ? '#2e7d32' : '#c62828'; let refresh = heading.querySelector('.tm-csv-refresh'); if (!refresh) { refresh = document.createElement('button'); refresh.className = 'tm-csv-refresh'; refresh.textContent = '↻ Refresh'; Object.assign(refresh.style, { marginLeft: '6px', padding: '2px 8px', font: '12px/1 system-ui, Arial, sans-serif', border: '0', borderRadius: '999px', cursor: 'pointer', verticalAlign: 'middle', background: '#424a57', color: '#fff', }); refresh.addEventListener('click', async () => { const old = refresh.textContent; refresh.textContent = 'Refreshing…'; refresh.disabled = true; try { await renderAllEmployees(true); } finally { refresh.textContent = old; refresh.disabled = false; } }); heading.appendChild(refresh); } }; attach(); waitFor(() => document.querySelector('.title-black.top-round.m-top10[role="heading"][aria-level="5"]'), 8000, 250) .then(attach).catch(() => {}); } async function promptForUrl() { const wrap = document.createElement('div'); wrap.style.cssText = 'position:fixed;inset:0;z-index:100000;background:rgba(0,0,0,.55);display:flex;align-items:center;justify-content:center;padding:16px;'; wrap.innerHTML = ` <div style="background:#fff;border-radius:10px;max-width:520px;width:100%;padding:14px;font:14px/1.4 system-ui, Arial"> <div style="font-weight:700;margin-bottom:8px">Paste your <i>PublicView → CSV</i> link</div> <div style="color:#555;margin-bottom:8px">From your sheet: <b>File → Share → Publish to web → Sheet: PublicView → Format: CSV</b></div> <input id="tm_csv_inp" placeholder="https://docs.google.com/.../pub?gid=...&single=true&output=csv" inputmode="url" style="width:100%;padding:10px 12px;border:1px solid #ccc;border-radius:8px;font:14px/1.4 system-ui, Arial;" /> <div style="margin-top:10px;display:flex;gap:8px;justify-content:flex-end"> <button id="tm_csv_cancel" style="padding:8px 10px;border:0;border-radius:8px;background:#eee;cursor:pointer">Cancel</button> <button id="tm_csv_save" style="padding:8px 12px;border:0;border-radius:8px;background:#1976d2;color:#fff;cursor:pointer">Save</button> </div> </div>`; document.body.appendChild(wrap); const inp = wrap.querySelector('#tm_csv_inp'); inp.value = getCsvUrl() || ''; const close = () => wrap.remove(); wrap.querySelector('#tm_csv_cancel').addEventListener('click', close); wrap.querySelector('#tm_csv_save').addEventListener('click', async () => { const raw = (inp.value || '').trim(); if (!raw) return; const url = canonCsvUrl(raw); if (!url) return; try { const head = await fetch(url, { method:'GET', cache:'no-store', credentials:'omit' }); if (!head.ok) throw 0; } catch { return; } localStorage.setItem(LS_URL, url); close(); }); setTimeout(() => inp.focus(), 50); } // Helpers function waitFor(pred, timeoutMs=5000, intervalMs=200){ return new Promise((resolve, reject)=>{ const t0 = Date.now(); (function tick(){ try { const v = pred(); if (v) return resolve(v); } catch {} if (Date.now() - t0 >= timeoutMs) return reject(new Error('timeout')); setTimeout(tick, intervalMs); })(); }); } function getCsvUrl(){ return localStorage.getItem(LS_URL) || ''; } function canonCsvUrl(u){ try{ const url = new URL(u); if (/docs\.google\.com\/spreadsheets\/d\/.+\/pub/.test(url.href) && url.searchParams.get('output')==='csv') return url.href; if (/docs\.google\.com\/spreadsheets\/d\/.+\/gviz\/tq/.test(url.href)) { const tqx = url.searchParams.get('tqx') || ''; if (/out:csv/i.test(tqx)) return url.href; } if (url.protocol==='https:' && /csv\b/i.test(url.search)) return url.href; return ''; } catch { return ''; } } function loadCache(){ try{ return JSON.parse(localStorage.getItem(LS_CACHE)) || {byId:{},byName:{},byNameNorm:{},lastFetch:0}; } catch { return {byId:{},byName:{},byNameNorm:{},lastFetch:0}; } } function saveCache(){ try{ localStorage.setItem(LS_CACHE, JSON.stringify(cache)); } catch {} } function normalizeName(s){ if(!s)return''; let t=s.normalize('NFKC'); t=t.replace(/[\u{1F3FB}-\u{1F3FF}]/gu,''); t=t.replace(/\[[^\]]*\]|\([^\)]*\)/g,''); t=t.replace(/\s+/g,' ').trim(); return t.toLowerCase(); } function parseCsv(text){ const rows=[]; let row=[],cur='',inQ=false; for(let i=0;i<text.length;i++){ const c=text[i]; if(inQ){ if(c==='"'){ if(text[i+1]==='"'){cur+='"'; i++;} else inQ=false; } else cur+=c; } else { if(c==='"') inQ=true; else if(c===','){ row.push(cur); cur=''; } else if(c==='\n'){ row.push(cur); rows.push(row); row=[]; cur=''; } else if(c!=='\r') cur+=c; } } if(cur.length||row.length){ row.push(cur); rows.push(row); } return rows; } // Torn date helpers function parseTornDate(str){ const m = String(str).match(/(\d{2}):(\d{2}):(\d{2})\s*-\s*(\d{2})\/(\d{2})\/(\d{2})/); if (!m) return null; const [, hh, mm, ss, dd, MM, yy] = m; const year = 2000 + (+yy); return new Date(year, (+MM)-1, (+dd), (+hh), (+mm), (+ss)); } function tornRelative(str){ const d = parseTornDate(str); if (!d) return String(str); const diff = Date.now() - d.getTime(); if (diff < 0) return String(str); const mins = Math.floor(diff/60000); if (mins < 60) return `${str} (${mins}m ago)`; const hours = Math.floor(mins/60); if (hours < 48) return `${str} (${hours}h ago)`; const days = Math.floor(hours/24); return `${str} (${days}d ago)`; } })();