您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Uses Torn API to list missing OC item requirements for your faction and adds quick-send helpers on /item.php
// ==UserScript== // @name Torn Faction OC Item Handling // @namespace https://torn.com/ // @version 1.1.1 // @description Uses Torn API to list missing OC item requirements for your faction and adds quick-send helpers on /item.php // @author Canixe [3753120] // @match https://www.torn.com/item.php* // @run-at document-idle // @grant GM_xmlhttpRequest // @grant GM.xmlHttpRequest // @grant GM_getValue // @grant GM_setValue // @grant GM.registerMenuCommand // @grant GM_registerMenuCommand // @connect api.torn.com // ==/UserScript== (() => { "use strict"; ////////////////////////////////////////////////////////////////////////////// // CONSTANTS ////////////////////////////////////////////////////////////////////////////// const SECTION_ID = "tf-oc-item-handling"; const TITLE_TEXT = "Faction OC Item Handling"; const API_COMMENT = "TF-OC-Item-Handling"; const API_URL = "https://api.torn.com/v2/faction/crimes"; const ITEMS_URL = "https://api.torn.com/v2/torn/items"; const ITEMS_CACHE_KEY = "itemsCatalogV1"; const ITEMS_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours cache const FILTER_STATUS_KEY = "filterOcStatus"; const FILTER_TYPE_KEY = "filterItemType"; const DEFAULT_STATUS = "Recruiting,Planning"; const DEFAULT_TYPES = "Consumables,Reusables"; const ALLOWED_STATUS = ["Recruiting", "Planning"]; const ALLOWED_TYPES = ["Consumables", "Reusables"]; const DEFAULT_MSG_REUSABLE = "For upcoming OC, return when OC completed"; const DEFAULT_MSG_CONSUMABLE = "For upcoming OC"; const KEY_MSG_REUSABLE = `${SECTION_ID}:msg:reusable`; const KEY_MSG_CONSUMABLE = `${SECTION_ID}:msg:consumable`; const SHOW_THANKS = false; const TEST_MODE = false; const TEST_FIXTURE = { crimes: [{ id: 999999, name: "Dummy Organized Crime", status: "Planning", ready_at: 1757942576, slots: [{ user: { id: 3753120, name: null }, item_requirement: { id: 201, is_reusable: false, is_available: false } }] }, { id: 999999, name: "Dummy Organized Crime", status: "Planning", ready_at: 1757942576, slots: [{ user: { id: 3753120, name: null }, item_requirement: { id: 1431, is_reusable: false, is_available: false } }] }, { id: 999999, name: "Dummy Organized Crime", status: "Planning", ready_at: 1757942576, slots: [{ user: { id: 3753120, name: null }, item_requirement: { id: 1429, is_reusable: false, is_available: false } }] }, { id: 999999, name: "Dummy Organized Crime", status: "Planning", ready_at: 1757942576, slots: [{ user: { id: 3753120, name: null }, item_requirement: { id: 1203, is_reusable: true, is_available: false } }] }] }; ////////////////////////////////////////////////////////////////////////////// // STYLES ////////////////////////////////////////////////////////////////////////////// const ICONS = { refresh: '<svg class="tf-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 2v6h-6"/><path d="M21 13a8 8 0 1 1-3-6.74L21 8"/></svg>', search: '<svg class="tf-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>', send: '<svg class="tf-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 2 11 13"/><path d="M22 2 15 22 11 13 2 9 22 2"/></svg>', user: '<svg class="tf-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21a8 8 0 0 0-16 0"/><circle cx="12" cy="7" r="4"/></svg>', comment: '<svg class="tf-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>', pen: '<svg class="tf-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 1 1 3 3L7 19l-4 1 1-4Z"/></svg>', arrowRight: '<svg class="tf-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>', check: '<svg class="tf-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>', }; const style = ` #${SECTION_ID} { margin-bottom:14px; } #${SECTION_ID} .tf-refresh{ display:inline-flex; align-items:center; gap:6px; color:#fff; font-weight:600; cursor:pointer; user-select:none; padding:0 4px; text-decoration:none; } #${SECTION_ID} .tf-refresh:hover{ text-decoration:underline; } #${SECTION_ID}-content{ padding:5px; background: var(--default-bg-panel-color); border-radius: 0 0 6px 6px; } #${SECTION_ID} .tf-muted{ opacity:.75; font-style:italic; padding:4px 2px; } /* grid: main content dominates, thanks card slim on the right (wide screens) */ #${SECTION_ID} .tf-grid{ display:grid; gap:8px; align-items:start; grid-template-columns: 1fr; } @media (min-width: 980px){ #${SECTION_ID} .tf-grid{ grid-template-columns: minmax(340px, 1fr) 220px; } } @media (min-width: 980px){ #${SECTION_ID} .tf-grid.no-thanks{ grid-template-columns: minmax(340px, 1fr); } } /* card */ #${SECTION_ID} .card{ border:1px solid var(--default-panel-divider-outer-side-color); border-radius:6px; background: var(--default-bg-panel-color); padding:8px; display:flex; flex-direction:column; } #${SECTION_ID} .card h4{ margin:0 0 6px; font-weight:700; font-size:13px; display:flex; gap:8px; align-items:center; justify-content:space-between; } #${SECTION_ID} .card a.small{ font-size:11px; text-decoration:underline; color:var(--default-blue-color); } /* list + row buttons */ #${SECTION_ID} .card ul{ list-style:none; margin:0; padding-left:0; } #${SECTION_ID} .card li{ display:flex; align-items:flex-start; gap:6px; line-height:1.35; padding:2px 0; } #${SECTION_ID} .tf-rowbtn{ display:inline-flex; align-items:center; justify-content:center; width:16px; height:16px; margin-top:1px; flex:0 0 auto; padding:0; border:1px solid var(--default-panel-divider-outer-side-color); border-radius:3px; cursor:pointer; font-size:11px; line-height:1; background: var(--default-bg-panel-active-color); } #${SECTION_ID} .tf-rowbtn:hover{ filter:brightness(1.06); } #${SECTION_ID} .tf-rowbtn.tf-done{ opacity:.65; cursor:default; } /* misc */ #${SECTION_ID} .spinner{ font-size:12px; opacity:.7; } #${SECTION_ID} .error{ color:#b00020; } #${SECTION_ID} .hint{ font-size:12px; opacity:.8; } /* log */ #${SECTION_ID} .tf-log h4{ margin:0 0 6px; font-weight:700; font-size:13px; } #${SECTION_ID} .tf-log ul{ margin:0; padding-left:18px; } #${SECTION_ID} .tf-log-head{ display:flex; align-items:center; justify-content:space-between; margin:0 0 6px; font-weight:700; font-size:13px; } #${SECTION_ID} .tf-copy{ font-size:12px; padding:2px 8px; cursor:pointer; border-radius:4px; border:1px solid var(--default-panel-divider-outer-side-color); background: var(--default-bg-panel-active-color); } #${SECTION_ID} .tf-copy:hover{ filter:brightness(1.06); } /* settings area */ #${SECTION_ID} .pill { display:inline-block; border:1px solid var(--default-panel-divider-outer-side-color); border-radius:999px; padding:2px 8px; font-size:11px; margin-right:6px; background:var(--default-bg-panel-active-color); } #${SECTION_ID} .pill a { color:var(--default-blue-color); text-decoration:underline; } #${SECTION_ID} .tf-tip { font-size:11px; opacity:.75; } /* thanks card — extra small and subtle */ #${SECTION_ID} .card--thanks{ font-size:11px; opacity:.7; padding:6px; } #${SECTION_ID} .card--thanks h4{ font-size:12px; margin-bottom:4px; } #${SECTION_ID} .card--thanks p{ margin:0; line-height:1.3; } #${SECTION_ID} .card--thanks a{ color:var(--default-blue-color); text-decoration:underline; } #${SECTION_ID} .tf-deadline{ margin-left:6px; font-size:11px; opacity:.8; } #${SECTION_ID} .tf-deadline.overdue{ color:#b00020; opacity:1; font-weight:600; } #${SECTION_ID} .tf-icon { width:14px; height:14px; display:inline-block; vertical-align:-2px; } #${SECTION_ID} .tf-rowbtn svg.tf-icon { width:12px; height:12px; } @media (pointer:coarse){ #${SECTION_ID} .tf-rowbtn { width:22px; height:22px; } /* bigger tap target on mobile */ #${SECTION_ID} .tf-rowbtn svg.tf-icon { width:16px; height:16px; } } #${SECTION_ID} .tf-buy { margin-left: 8px; font-size: 11px; padding: 2px 6px; border: 1px solid var(--default-panel-divider-outer-side-color); border-radius: 4px; background: var(--default-bg-panel-active-color); color: var(--default-blue-color); text-decoration: underline; } .tf-popover{ position: fixed; z-index: 2147483647; min-width:220px; max-width:280px; padding:8px; border:1px solid var(--default-panel-divider-outer-side-color); border-radius:6px; background:var(--default-bg-panel-color); box-shadow:0 8px 20px rgba(0,0,0,.25); max-height: calc(100vh - 24px); overflow: auto; } .tf-popover-backdrop{ position: fixed; inset: 0; z-index: 2147483646; background: transparent; /* keep it invisible */ } .tf-popover .hd{ font-weight:700; margin:0 0 6px; display:flex; justify-content:space-between; align-items:center; } .tf-popover .bd label{ display:flex; align-items:center; gap:6px; margin:3px 0; } .tf-popover .ft{ margin-top:8px; display:flex; gap:6px; justify-content:flex-end; } .tf-btn{ font-size:12px; padding:3px 8px; border-radius:4px; cursor:pointer; border:1px solid var(--default-panel-divider-outer-side-color); background:var(--default-bg-panel-active-color); } .tf-btn:hover{ filter:brightness(1.06); } .tf-popover .bd textarea{ width:100%; min-height:48px; resize:vertical; background:var(--default-bg-panel-active-color); border:1px solid var(--default-panel-divider-outer-side-color); border-radius:6px; padding:6px; font:inherit; } .tf-popover .hd .tf-close{ text-decoration:none; line-height:1; padding:0 6px; } `; ////////////////////////////////////////////////////////////////////////////// // UTILITIES ////////////////////////////////////////////////////////////////////////////// let ID_TO_NAME = new Map(); let ID_TO_TYPE = new Map(); let NAME_TO_ID = new Map(); let MSG_REUSABLE_CUR = DEFAULT_MSG_REUSABLE; let MSG_CONSUMABLE_CUR = DEFAULT_MSG_CONSUMABLE; const delay = ms => new Promise(r => setTimeout(r, ms)); const normalize = s => (s||"").replace(/\s+/g," ").trim().toLowerCase(); async function loadMessageTemplates(){ MSG_REUSABLE_CUR = await getSetting(KEY_MSG_REUSABLE, DEFAULT_MSG_REUSABLE); MSG_CONSUMABLE_CUR = await getSetting(KEY_MSG_CONSUMABLE, DEFAULT_MSG_CONSUMABLE); } function getMessageByType(isReusable){ return isReusable ? MSG_REUSABLE_CUR : MSG_CONSUMABLE_CUR; } function setInputValue(el, value){ if(!el) return; const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; setter ? setter.call(el, value) : (el.value = value); el.dispatchEvent(new Event("input", { bubbles:true })); el.dispatchEvent(new Event("change", { bubbles:true })); el.dispatchEvent(new KeyboardEvent("keyup", { bubbles:true, key:"Enter" })); } async function waitFor(fn, tries=30, ms=100){ for(let i=0;i<tries;i++){ const v = fn(); if(v) return v; await delay(ms); } return null; } function clickEl(el){ if(!el) return false; el.dispatchEvent(new MouseEvent("mousedown",{bubbles:true})); el.dispatchEvent(new MouseEvent("mouseup",{bubbles:true})); el.click(); return true; } function isVisible(el){ if(!el || !el.isConnected) return false; const r = el.getBoundingClientRect(); const cs = getComputedStyle(el); return cs.display!=="none" && cs.visibility!=="hidden" && r.width>0 && r.height>0; } function getServerEpochSec(){ const el = document.querySelector(".tc-clock .server-date-time"); if (el) { const m = el.textContent.trim().match(/^[A-Za-z]{3}\s+(\d{2}):(\d{2}):(\d{2})\s*-\s*(\d{2})\/(\d{2})\/(\d{2})$/); if (m) { const hh = +m[1], mm = +m[2], ss = +m[3]; const DD = +m[4], MM = +m[5], YY = +m[6]; return Math.floor(Date.UTC(2000 + YY, MM - 1, DD, hh, mm, ss) / 1000); } } return Math.floor(Date.now() / 1000); } function getServerTimeFormatted(){ const epoch = getServerEpochSec(); const d = new Date(epoch * 1000); const p = n => String(n).padStart(2, "0"); return `${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())} - ${p(d.getUTCDate())}/${p(d.getUTCMonth() + 1)}/${String(d.getUTCFullYear()).slice(-2)}`; } function buildMarketUrlByName(itemName) { const base = "https://www.torn.com/page.php?sid=ItemMarket#/market/view=search"; const id = NAME_TO_ID.get(normalize(itemName)); if (!id) return `${base}&itemName=${encodeURIComponent(itemName)}`; const type = ID_TO_TYPE.get(id); const typeParam = type ? `&itemType=${encodeURIComponent(type)}` : ""; return `${base}&itemID=${id}&itemName=${encodeURIComponent(itemName)}${typeParam}`; } function parseMulti(str, allowed) { const set = new Set( String(str || "") .split(",") .map(s => s.trim()) .filter(v => allowed.includes(v)) ); return set.size ? set : new Set(allowed); } function prettyList(setOrArr) { return Array.from(setOrArr).join(", "); } function showCheckboxPopover(anchorEl, { title, options, selected, onSave }) { const sel = new Set(selected || []); const pop = document.createElement("div"); pop.className = "tf-popover"; pop.innerHTML = ` <div class="hd"> <span>${title}</span> <button type="button" class="tf-btn tf-close">×</button> </div> <div class="bd"></div> <div class="ft"> <button type="button" class="tf-btn tf-cancel">Cancel</button> <button type="button" class="tf-btn tf-save">Save</button> </div> `; const bd = pop.querySelector(".bd"); options.forEach(opt => { const id = `tfopt-${title.replace(/\s+/g,'-')}-${opt.value}`; const row = document.createElement("label"); row.innerHTML = ` <input type="checkbox" id="${id}" value="${opt.value}"> <span>${opt.label}</span> `; const cb = row.querySelector("input"); cb.checked = sel.has(opt.value); cb.addEventListener("change", () => { if (cb.checked) sel.add(opt.value); else sel.delete(opt.value); }); bd.appendChild(row); }); // Backdrop + attach const backdrop = document.createElement('div'); backdrop.className = 'tf-popover-backdrop'; document.body.appendChild(backdrop); document.body.appendChild(pop); // Initial placement + keep in view on resize/scroll const place = () => clampPopoverToViewport(pop, anchorEl, 8); place(); const onReflow = () => place(); window.addEventListener('resize', onReflow, { passive: true }); window.addEventListener('scroll', onReflow, { passive: true }); // Close helpers const close = () => { window.removeEventListener('resize', onReflow); window.removeEventListener('scroll', onReflow); backdrop.remove(); pop.remove(); document.removeEventListener('keydown', onKey); }; const onKey = (e) => { if (e.key === 'Escape') close(); }; // Wire buttons + outside click pop.querySelector(".tf-close") .addEventListener("click", close); pop.querySelector(".tf-cancel") .addEventListener("click", close); pop.querySelector(".tf-save") .addEventListener("click", () => { if (sel.size === 0) return; // require at least one onSave(Array.from(sel)); close(); }); backdrop.addEventListener('mousedown', close, { once: true }); document.addEventListener('keydown', onKey); } function openMessagesPopover(anchorEl){ document.querySelectorAll('.tf-popover, .tf-popover-backdrop').forEach(n => n.remove()); const backdrop = document.createElement('div'); backdrop.className = 'tf-popover-backdrop'; const pop = document.createElement('div'); pop.className = 'tf-popover'; pop.innerHTML = ` <div class="hd"> <span>OC Send Messages</span> <a href="#" class="tf-btn tf-close" data-act="cancel" aria-label="Close">×</a> </div> <div class="bd"> <label> <strong style="min-width:84px; display:inline-block;">Reusable</strong> <textarea id="${SECTION_ID}-msg-reusable" placeholder="${DEFAULT_MSG_REUSABLE}">${MSG_REUSABLE_CUR}</textarea> </label> <label> <strong style="min-width:84px; display:inline-block;">Consumable</strong> <textarea id="${SECTION_ID}-msg-consumable" placeholder="${DEFAULT_MSG_CONSUMABLE}">${MSG_CONSUMABLE_CUR}</textarea> </label> </div> <div class="ft"> <button class="tf-btn" data-act="cancel">Cancel</button> <button class="tf-btn" data-act="save">Save</button> </div> `; document.body.appendChild(backdrop); document.body.appendChild(pop); const place = () => { const pad = 8; const r = anchorEl.getBoundingClientRect(); const w = pop.offsetWidth || 280; const h = pop.offsetHeight || 120; let left = Math.max(pad, Math.min(r.left, window.innerWidth - w - pad)); let top = Math.max(pad, Math.min(r.bottom + 6, window.innerHeight - h - pad)); pop.style.left = left + 'px'; pop.style.top = top + 'px'; }; place(); const ro = () => place(); window.addEventListener('resize', ro, { passive:true }); window.addEventListener('scroll', ro, { passive:true }); const cleanup = () => { window.removeEventListener('resize', ro); window.removeEventListener('scroll', ro); backdrop.remove(); pop.remove(); }; backdrop.addEventListener('mousedown', cleanup, { once:true }); pop.addEventListener('click', async (e) => { const act = e.target?.dataset?.act; if(!act) return; e.preventDefault(); if(act === 'cancel'){ cleanup(); return; } if(act === 'save'){ const r = pop.querySelector(`#${SECTION_ID}-msg-reusable`)?.value ?? MSG_REUSABLE_CUR; const c = pop.querySelector(`#${SECTION_ID}-msg-consumable`)?.value ?? MSG_CONSUMABLE_CUR; await setSetting(KEY_MSG_REUSABLE, r.trim()); await setSetting(KEY_MSG_CONSUMABLE, c.trim()); await loadMessageTemplates(); cleanup(); } }); } function clampPopoverToViewport(panelEl, triggerEl, offset = 8){ if (!panelEl || !triggerEl) return; panelEl.style.position = 'fixed'; panelEl.style.visibility = 'hidden'; panelEl.style.display = 'block'; const r = triggerEl.getBoundingClientRect(); const w = panelEl.offsetWidth; const h = panelEl.offsetHeight; let top = r.bottom + offset; let left = r.left; if (top + h > window.innerHeight - offset) { const above = r.top - h - offset; top = (above > offset) ? above : Math.max(offset, window.innerHeight - h - offset); } if (left + w > window.innerWidth - offset) left = Math.max(offset, window.innerWidth - w - offset); if (left < offset) left = offset; panelEl.style.top = `${top}px`; panelEl.style.left = `${left}px`; panelEl.style.visibility = ''; } ////////////////////////////////////////////////////////////////////////////// // STORAGE / MENU ////////////////////////////////////////////////////////////////////////////// async function getSetting(key, def=""){ try{ if(typeof GM !== "undefined" && GM.getValue) return await GM.getValue(key, def); if(typeof GM_getValue !== "undefined"){ const v = GM_getValue(key); return v == null ? def : v; } }catch{} return def; } async function setSetting(key, val){ try{ if(typeof GM !== "undefined" && GM.setValue) return await GM.setValue(key, val); if(typeof GM_setValue !== "undefined") return GM_setValue(key, val); }catch{} } function registerMenus(){ const reg = (label, fn) => { if(typeof GM !== "undefined" && GM.registerMenuCommand) GM.registerMenuCommand(label, fn); else if(typeof GM_registerMenuCommand !== "undefined") GM_registerMenuCommand(label, fn); }; reg("Set Torn API key", async () => { const cur = await getSetting("apiKey", ""); const v = prompt("Enter your Torn API key (requires Minimal Access):", cur || ""); if(v !== null) await setSetting("apiKey", v.trim()); showSettingsHint(); }); } ////////////////////////////////////////////////////////////////////////////// // DOM ////////////////////////////////////////////////////////////////////////////// function injectStyle(){ if(document.getElementById(`${SECTION_ID}-style`)) return; const s=document.createElement("style"); s.id=`${SECTION_ID}-style`; s.textContent=style; document.head.appendChild(s); } function makeSection(){ if(document.getElementById(SECTION_ID)) return document.getElementById(SECTION_ID); const CLS = { wrap:"equipped-items-wrap", main:"main___QuzF7", header:"header___f_BFs", title:"title___nIMRx", icons:"icons___VmEI4", btn:"button___MO5cW", caretBtn:"iconParentButton___POutJ", caretFill:"grayFill___tkuer", content:"content___Gb8DR" }; const OPEN_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="11" height="16" viewBox="0 0 11 16" class="${CLS.caretFill}"><path d="M1302,21l-5,5V16Z" transform="translate(-1294 -13)"></path></svg>`; const COLLAPSE_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="11" viewBox="0 0 16 11" class="${CLS.caretFill}"><path d="M1302,21l-5,5V16Z" transform="translate(29 -1294) rotate(90)"></path></svg>`; const wrap = document.createElement("div"); wrap.id = SECTION_ID; wrap.className = CLS.wrap; wrap.innerHTML = ` <div class="${CLS.main}"> <header class="${CLS.header}"> <p class="${CLS.title}" role="heading" aria-level="2">${TITLE_TEXT}</p> <nav class="${CLS.icons}"> <div id="${SECTION_ID}-refresh" class="tf-refresh">${ICONS.refresh} Refresh</div> <button type="button" class="${CLS.btn} ${CLS.caretBtn}" id="${SECTION_ID}-toggle" aria-label="Open" aria-expanded="false">${OPEN_SVG}</button> </nav> </header> <div class="${CLS.content}" id="${SECTION_ID}-content" hidden> <div id="${SECTION_ID}-placeholder" class="tf-muted">Click Refresh to display information.</div> <div id="${SECTION_ID}-settings" class="hint" style="padding:4px 0 6px;"></div> <div class="tf-grid" id="${SECTION_ID}-grid" style="display:none"></div> <div id="${SECTION_ID}-logwrap" class="card tf-log" style="display:none"> <h4 class="tf-log-head"><span>LOG</span><button type="button" id="${SECTION_ID}-copylog" class="tf-copy">Copy</button></h4> <ul id="${SECTION_ID}-loglist"></ul> </div> </div> </div> `; const contentEl = wrap.querySelector(`#${SECTION_ID}-content`); const toggleBtn = wrap.querySelector(`#${SECTION_ID}-toggle`); const setExpanded = (on) => { contentEl.hidden = !on; toggleBtn.setAttribute("aria-expanded", String(on)); toggleBtn.setAttribute("aria-label", on ? "Collapse" : "Open"); toggleBtn.innerHTML = on ? COLLAPSE_SVG : OPEN_SVG; }; setExpanded(false); toggleBtn.addEventListener("click", e => { e.preventDefault(); setExpanded(toggleBtn.getAttribute("aria-expanded")==="false"); }); return wrap; } function insertSection(){ const section = makeSection(); if(!section) return; const quickItems = document.querySelector("#quickItems"); const loadoutsRoot = document.querySelector("#loadoutsRoot"); if(quickItems && loadoutsRoot) loadoutsRoot.parentElement.insertBefore(section, loadoutsRoot); else if(quickItems) quickItems.insertAdjacentElement("afterend", section); else if(loadoutsRoot) loadoutsRoot.parentElement.insertBefore(section, loadoutsRoot); else { (document.querySelector(".main-items-cont-wrap") || document.body).prepend(section); } } async function showSettingsHint(){ const el = document.getElementById(`${SECTION_ID}-settings`); if(!el) return; const testTag = TEST_MODE ? `<span class="pill" title="Using dummy data instead of the live API">TEST MODE</span>` : ""; const key = await getSetting("apiKey",""); const keyBadge = key ? `<span class="pill">API key: <em>set</em> · <a href="#" id="${SECTION_ID}-setkey">edit</a></span>` : `<span class="pill">API key: <strong>not set</strong> · <a href="#" id="${SECTION_ID}-setkey">set</a></span>`; const tip = key ? "" : `<span class="tf-tip">Requires <em>Minimal Access</em>.</span>`; const msgsPill = `<span class="pill"><a href="#" id="${SECTION_ID}-editmsgs">Messages</a></span>`; const statusSel = parseMulti(await getSetting(FILTER_STATUS_KEY, DEFAULT_STATUS), ALLOWED_STATUS); const typesSel = parseMulti(await getSetting(FILTER_TYPE_KEY, DEFAULT_TYPES), ALLOWED_TYPES); const statusBadge = `<span class="pill">OC status: <em>${prettyList(statusSel)}</em> · <a href="#" id="${SECTION_ID}-setstatus">edit</a></span>`; const typeBadge = `<span class="pill">Item type: <em>${prettyList(typesSel)}</em> · <a href="#" id="${SECTION_ID}-settype">edit</a></span>`; el.innerHTML = `${testTag} ${keyBadge}${tip ? " " + tip : ""} ${msgsPill} ${statusBadge} ${typeBadge}`; el.querySelector(`#${SECTION_ID}-setkey`)?.addEventListener("click", async (e)=>{ e.preventDefault(); const cur=await getSetting("apiKey",""); const v=prompt("Enter your Torn API key (requires Minimal Access):", cur||""); if(v!==null){ await setSetting("apiKey", v.trim()); showSettingsHint(); } }); el.querySelector(`#${SECTION_ID}-setstatus`)?.addEventListener("click", async (e)=>{ e.preventDefault(); const current = parseMulti(await getSetting(FILTER_STATUS_KEY, DEFAULT_STATUS), ALLOWED_STATUS); showCheckboxPopover(e.currentTarget, { title: "OC status", options: [ { value: "Recruiting", label: "Recruiting" }, { value: "Planning", label: "Planning" }, ], selected: current, onSave: async (vals) => { await setSetting(FILTER_STATUS_KEY, vals.join(",")); await showSettingsHint(); // refresh pills text await refreshAll(); // reload data with new filter } }); }); el.querySelector(`#${SECTION_ID}-settype`)?.addEventListener("click", async (e)=>{ e.preventDefault(); const current = parseMulti(await getSetting(FILTER_TYPE_KEY, DEFAULT_TYPES), ALLOWED_TYPES); showCheckboxPopover(e.currentTarget, { title: "Item type", options: [ { value: "Consumables", label: "Consumables" }, { value: "Reusables", label: "Reusables" }, ], selected: current, onSave: async (vals) => { await setSetting(FILTER_TYPE_KEY, vals.join(",")); await showSettingsHint(); await refreshAll(); } }); }); el.querySelector(`#${SECTION_ID}-editmsgs`)?.addEventListener('click', (e) => { e.preventDefault(); openMessagesPopover(e.currentTarget); }); await loadMessageTemplates(); } ////////////////////////////////////////////////////////////////////////////// // NETWORKING ////////////////////////////////////////////////////////////////////////////// const httpGetJSON = (url) => { const fn = (typeof GM !== "undefined" && GM.xmlHttpRequest) ? GM.xmlHttpRequest : GM_xmlhttpRequest; return new Promise((resolve,reject) => { fn({ method:"GET", url, headers:{Accept:"application/json"}, onload:res => { if(!(res.status>=200 && res.status<300)) return reject(new Error(`HTTP ${res.status}`)); try{ const data = JSON.parse(res.responseText); if(data && (data.error || data.code)){ const code = data.error?.code ?? data.code ?? "unknown"; const msg = data.error?.error ?? data.error?.message ?? "API error"; const nice = (code === 5 || code === 17) ? "Rate limited: please try again in ~30s" : (code === 7) ? "Incorrect ID-entity relation — this key lacks Faction API Access (ask your faction to grant it)." : msg; reject(new Error(`Torn API error ${code}: ${nice}`)); }else{ resolve(data); } }catch(e){ reject(new Error("Invalid JSON response")); } }, onerror:() => reject(new Error("Network error")), ontimeout:() => reject(new Error("Request timed out")), timeout:25000 }); }); }; async function fetchCrimesForCat(cat) { const key = await getSetting("apiKey",""); if(!key) throw new Error("No Torn API key set. Use the menu or the 'set' link above."); const u = new URL(API_URL); u.searchParams.set("comment", API_COMMENT); u.searchParams.set("key", key.trim()); u.searchParams.set("cat", String(cat).toLowerCase()); return await httpGetJSON(u.toString()); } async function fetchCrimesFromTorn(cats) { if (TEST_MODE) return TEST_FIXTURE; const list = (Array.isArray(cats) && cats.length) ? cats : ALLOWED_STATUS; const results = await Promise.allSettled(list.map(c => fetchCrimesForCat(c))); let crimes = []; let firstErr = null; for (const r of results) { if (r.status === "fulfilled" && r.value && r.value.crimes) { const arr = Array.isArray(r.value.crimes) ? r.value.crimes : Object.values(r.value.crimes); crimes = crimes.concat(arr); } else if (!firstErr && r.status === "rejected") { firstErr = r.reason; } } if (!crimes.length && firstErr) throw firstErr; return { crimes }; } function parseItemsPayload(json){ const raw = json?.items || json?.result?.items || json || {}; let entries = Array.isArray(raw) ? raw : raw && typeof raw === "object" ? Object.entries(raw).map(([id, v]) => ({ id: Number(id), ...v })) : []; const byIdName = new Map(); const byIdType = new Map(); for (const it of entries) { const id = Number(it?.id); const nm = String(it?.name || "").trim(); if (!Number.isFinite(id) || !nm) continue; byIdName.set(id, nm); if (it?.type) byIdType.set(id, String(it.type)); } return { byIdName, byIdType }; } function mergeCatalogMaps({ byIdName, byIdType }){ ID_TO_NAME = byIdName || new Map(); ID_TO_TYPE = byIdType || new Map(); NAME_TO_ID = new Map([...ID_TO_NAME].map(([id, nm]) => [normalize(nm), id])); } async function ensureItemsCatalog(){ const cached = await getSetting(ITEMS_CACHE_KEY, ""); if (cached) { try { const { ts, items } = JSON.parse(cached); if (ts && (Date.now() - ts) < ITEMS_TTL_MS && items) { mergeCatalogMaps(parseItemsPayload(items)); return; } } catch {} } const key = await getSetting("apiKey", ""); if (!key) return; const u = new URL(ITEMS_URL); u.searchParams.set("comment", API_COMMENT); u.searchParams.set("key", key.trim()); const data = await httpGetJSON(u.toString()); await setSetting(ITEMS_CACHE_KEY, JSON.stringify({ ts: Date.now(), items: data })); mergeCatalogMaps(parseItemsPayload(data)); } ////////////////////////////////////////////////////////////////////////////// // DATA BUILDERS ////////////////////////////////////////////////////////////////////////////// function buildMissingEntriesFromAPI(json){ const entries = []; const missingIds = new Set(); const crimes = Array.isArray(json?.crimes) ? json.crimes : (json?.crimes ? Object.values(json.crimes) : []); for (const crime of crimes){ const readyAt = crime?.ready_at ?? null; const slots = Array.isArray(crime?.slots) ? crime.slots : []; for (const slot of slots){ const user = slot?.user; const req = slot?.item_requirement; if (user && req && req.is_available === false){ const uname = user.name || ""; const uid = user.id ?? user.user_id ?? ""; const itemId = req.id ?? req.item_id ?? null; const nameFromAPI = req.name || null; const nameFromItems = (itemId != null) ? ID_TO_NAME.get(Number(itemId)) : null; const friendly = nameFromAPI || nameFromItems; if (!friendly){ if (itemId != null) missingIds.add(Number(itemId)); continue; } entries.push({ playerName: uname, playerId: String(uid), itemName: friendly, itemId: Number(itemId) || null, isReusable: !!req.is_reusable, readyAt: (typeof readyAt === "number" ? readyAt : null), line: `${uname} [${uid}] (${friendly})` }); } } } return { entries, missingIds: Array.from(missingIds) }; } function scrapeSendConfirmation(){ const wrap = document.querySelector('.action-wrap.send-act.msg-active'); if(!wrap) return null; const p = wrap.querySelector('p'); if(!p) return null; const itemEl = p.querySelector('b'); const userEl = p.querySelector('a[href*="/profiles.php"]'); const itemName = itemEl?.textContent?.trim(); const playerName = userEl?.textContent?.trim(); const txt = p.textContent || ""; const m = txt.match(/with the message:\s*(.+)$/i); const message = m ? m[1].trim() : ""; if(!itemName || !playerName) return null; return { itemName, playerName, message }; } function scrapePendingConfirm(){ const form = document.querySelector('.action-wrap.send-act.msg-active form[data-confirm="1"]'); if(!form) return null; const p = form.querySelector('p'); const bTags = p ? p.querySelectorAll('b') : []; const itemName = bTags[0]?.textContent?.trim() || ""; const playerName = bTags[1]?.textContent?.trim() || ""; let message = ""; const tagInput = form.querySelector('input[name="tag"]'); if(tagInput) { message = String(tagInput.value || "").trim(); } else { const txt = p?.textContent || ""; const m = txt.match(/message:\s*(.+?)\?$/i); message = m ? m[1].trim() : ""; } if(!itemName || !playerName) return null; return { itemName, playerName, message }; } ////////////////////////////////////////////////////////////////////////////// // ACTIONS ////////////////////////////////////////////////////////////////////////////// function findItemLiByName(itemName){ const target = normalize(itemName); if(!target) return null; const rows = document.querySelectorAll('li[data-item][data-category]'); for(const row of rows){ const n = normalize((row.querySelector('.title-wrap .name-wrap .name') || row.querySelector('.name'))?.textContent); if(n === target) return row; } return null; } function clickMessageToggle(){ const a = document.querySelector("a.action-message.left"); return a ? (clickEl(a), true) : false; } function findVisibleSendButtonByName(itemName){ const sel = `button.option-send[aria-label="Send ${CSS.escape(itemName)}"]`; const list = document.querySelectorAll(sel); for(const n of list){ if(isVisible(n)) return n; } return null; } async function actionSendItemByName(itemName){ await delay(120); const row = findItemLiByName(itemName); if (!row) return false; const cont = row.querySelector('.cont-wrap[role="tabpanel"]'); const header = row.querySelector('.title-wrap.ui-accordion-header'); const isAccordion = !!header || document.querySelector('#items-search-tab.ui-accordion'); if (isAccordion && cont && (cont.style.display === 'none' || cont.getAttribute('aria-expanded') === 'false')) { header?.click(); await waitFor(() => cont.getAttribute('aria-expanded') === 'true', 15, 50); } let btn = await waitFor(() => findVisibleSendButtonByName(itemName), 30, 80); if(btn){ clickEl(btn); return true; } const thumb = row.querySelector('.thumbnail-wrap, .thumbnail') || row; ["mouseenter","mouseover","mousemove"].forEach(t => thumb.dispatchEvent(new MouseEvent(t,{bubbles:true}))); await delay(80); btn = findVisibleSendButtonByName(itemName); if(btn){ clickEl(btn); return true; } } function attachMultiActionButton(li, actions, lineText){ let state = 0; const btn = document.createElement("button"); btn.type = "button"; btn.className = "tf-rowbtn"; const setBtnUI = () => { const next = actions[state]; btn.title = next?.title || "Next action"; btn.innerHTML = next?.label ?? "•"; }; setBtnUI(); const textNode = document.createTextNode(lineText); btn.addEventListener("click", async () => { const act = actions[state]; if(act?.run){ try { await act.run(btn, state, lineText); } catch(e){ console.error("Row action error:", e); } } state = (state + 1) % actions.length; setBtnUI(); }); li.append(btn, textNode); } function addLogEntry({ itemName, playerName, message }){ const wrap = document.getElementById(`${SECTION_ID}-logwrap`); const ul = document.getElementById(`${SECTION_ID}-loglist`); if(!wrap || !ul) return; const li = document.createElement("li"); li.textContent = `${getServerTimeFormatted()} You sent a ${itemName} to ${playerName} with the message: ${message}`; ul.appendChild(li); wrap.style.display = ""; } async function copyLogsToClipboard(){ const ul = document.getElementById(`${SECTION_ID}-loglist`); if(!ul) return false; const text = Array.from(ul.querySelectorAll("li")) .map(li => li.textContent.trim()) .filter(Boolean) .join("\n"); if(!text) return false; try { await navigator.clipboard.writeText(text); return true; } catch { const ta = document.createElement("textarea"); ta.value = text; ta.style.position="fixed"; ta.style.opacity="0"; document.body.appendChild(ta); ta.select(); const ok = document.execCommand("copy"); document.body.removeChild(ta); return ok; } } ////////////////////////////////////////////////////////////////////////////// // PANELS ////////////////////////////////////////////////////////////////////////////// function makePanelCard(){ const card = document.createElement("div"); card.className = "card"; card.innerHTML = ` <h4> <span>Missing Items</span> <span class="spinner" aria-live="polite">loading…</span> </h4> <div class="content muted">Fetching…</div> `; return card; } function makeThanksCard(){ const card = document.createElement("div"); card.className = "card card--thanks"; card.innerHTML = ` <h4><span>Thanks</span></h4> <div class="content"> <p>Free by <strong>Canixe</strong> · <a href="https://www.torn.com/bazaar.php?userId=3753120#/" target="_blank" rel="noopener">bazaar</a></p> </div> `; return card; } async function loadCardFromAPI(card){ const spinner = card.querySelector(".spinner"); const content = card.querySelector(".content"); try{ spinner.textContent = "loading…"; const statusSel = parseMulti(await getSetting(FILTER_STATUS_KEY, DEFAULT_STATUS), ALLOWED_STATUS); const data = await fetchCrimesFromTorn(Array.from(statusSel)); const { entries, missingIds } = buildMissingEntriesFromAPI(data); const typeSel = parseMulti(await getSetting(FILTER_TYPE_KEY, DEFAULT_TYPES), ALLOWED_TYPES); const allowReus = typeSel.has("Reusables"); const allowCons = typeSel.has("Consumables"); let filtered = entries.filter(e => (e.isReusable && allowReus) || (!e.isReusable && allowCons)); filtered.sort((a, b) => { const A = (typeof a.readyAt === "number") ? a.readyAt : Infinity; const B = (typeof b.readyAt === "number") ? b.readyAt : Infinity; return A - B; }); const serverNowSec = getServerEpochSec(); const fmtDue = (readyAt) => { if (typeof readyAt !== "number" || !isFinite(readyAt)) return ""; let dsec = readyAt - serverNowSec; const past = dsec < 0; dsec = Math.abs(dsec); const d = Math.floor(dsec / 86400); dsec %= 86400; const h = Math.floor(dsec / 3600); dsec %= 3600; const m = Math.floor(dsec / 60); let txt = d ? `${d}d ${h}h` : h ? `${h}h ${m}m` : `${m}m`; return past ? `${txt} ago` : `in ${txt}`; }; let warningHtml = ""; if (missingIds.length){ warningHtml = `<div class="error" style="margin-bottom:6px;"> Unknown item IDs (not found in Items catalog): ${missingIds.join(", ")}. Try Refresh (the Items list is cached ~24h). </div>`; } if(!filtered.length){ content.innerHTML = `${warningHtml}<div class="muted">No missing item requirements found.</div>`; }else{ const ul = document.createElement("ul"); const frag = document.createDocumentFragment(); filtered.forEach(info => { const li = document.createElement("li"); attachMultiActionButton(li, [ { label: ICONS.search, title: "Search item", run: async (btn) => { const s = await waitFor(() => document.getElementById("items_search"), 40, 120); setInputValue(s, info.itemName); if (s) s.focus(); await waitFor(() => document.querySelector('#items-search-tab'), 60, 150); const status = await waitFor(() => { const tab = document.querySelector('#items-search-tab'); if (!tab) return null; const qAttr = tab.getAttribute('data-query') || ""; if (qAttr && normalize(qAttr) !== normalize(info.itemName)) { return null; } if (tab.querySelector('li[data-item]')) return { kind: 'found' }; const msg = tab.querySelector('.item-cont .info-msg, .info-msg'); if (msg && /no items matching/i.test(msg.textContent)) { return { kind: 'empty', el: msg }; } return null; }, 80, 150); if (status && status.kind === 'empty') { btn.disabled = true; btn.classList.add('tf-done'); btn.title = "Item not in your inventory"; const li = btn.closest('li'); if (li && !li.querySelector('.tf-buy')) { const url = buildMarketUrlByName(info.itemName); if (url) { const a = document.createElement('a'); a.className = 'tf-buy'; a.href = url; a.target = '_blank'; a.rel = 'noopener'; a.textContent = 'Buy on Market'; li.appendChild(a); } } } }}, { label: ICONS.send, title: "Open Send", run: async () => { const ok = await actionSendItemByName(info.itemName); if(!ok) console.warn("Send button not found/visible for:", info.itemName); }}, { label: ICONS.user, title: "Set recipient", run: async () => { const u = await waitFor(() => document.querySelector('input.ac-search[name="userID"]'), 40, 100); setInputValue(u, info.playerName || info.playerId || ""); if(u) u.focus(); }}, { label: ICONS.comment, title: "Toggle message", run: async () => { let ok = clickMessageToggle(); if(!ok){ await delay(120); clickMessageToggle(); } }}, { label: ICONS.pen, title: "Write message", run: async () => { const m = await waitFor(() => document.querySelector('input.message[name="tag"]'), 40, 100); setInputValue(m, getMessageByType(info.isReusable)); if(m) m.focus(); }}, { label: ICONS.arrowRight, title: "Submit", run: async () => { const send = await waitFor(() => document.querySelector('input.torn-btn[type="submit"][value="SEND"]'), 50, 100); if(!send) return console.warn("SEND submit not found"); clickEl(send); }}, { label: ICONS.check, title: "Confirm & log", run: async (btn) => { const form = await waitFor( () => document.querySelector('.action-wrap.send-act.msg-active form[data-confirm="1"]'), 50, 120 ); const pending = form ? scrapePendingConfirm() : null; const yes = form ? form.querySelector('a.next-act.t-blue') : await waitFor(() => { const a = document.querySelector("a.next-act.t-blue"); return a && /yes/i.test(a.textContent) ? a : null; }, 50, 120); if (yes) clickEl(yes); else console.warn("Yes confirm not found"); if (pending && pending.itemName && pending.playerName) { addLogEntry(pending); } else { const conf = await waitFor(() => scrapeSendConfirmation(), 40, 120); if (conf) addLogEntry(conf); else addLogEntry({ itemName: info.itemName, playerName: info.playerName, message: getMessageByType(info.isReusable) }); } btn.innerHTML = ICONS.check; btn.classList.add("tf-done"); btn.disabled = true; }}, ], info.line); // append a small "due …" note if we have a deadline if (typeof info.readyAt === "number" && isFinite(info.readyAt)) { const due = document.createElement("span"); due.className = "tf-deadline"; due.textContent = `· due ${fmtDue(info.readyAt)}`; { const pad = n => String(n).padStart(2, "0"); const dt = new Date(info.readyAt * 1000); const tct = `${pad(dt.getUTCHours())}:${pad(dt.getUTCMinutes())}:${pad(dt.getUTCSeconds())} - ${pad(dt.getUTCDate())}/${pad(dt.getUTCMonth()+1)}/${String(dt.getUTCFullYear()).slice(2)} TCT`; const parts = Intl.DateTimeFormat(undefined, { timeZoneName: "short" }).formatToParts(dt); const shortName = parts.find(p => p.type === "timeZoneName")?.value; const offsetMin = -dt.getTimezoneOffset(); // minutes east of UTC const sign = offsetMin >= 0 ? "+" : "-"; const hhOff = pad(Math.floor(Math.abs(offsetMin) / 60)); const mmOff = pad(Math.abs(offsetMin) % 60); const tzLabel = shortName || `UTC${sign}${hhOff}:${mmOff}`; const loc = `${pad(dt.getHours())}:${pad(dt.getMinutes())}:${pad(dt.getSeconds())} - ${pad(dt.getDate())}/${pad(dt.getMonth()+1)}/${String(dt.getFullYear()).slice(2)} ${tzLabel}`; due.title = `Deadline:\u00A0${tct}<br/>Local:\u00A0${loc}`; } li.appendChild(due); } frag.appendChild(li); }); content.innerHTML = warningHtml; ul.appendChild(frag); content.appendChild(ul); } }catch(err){ content.innerHTML = `<div class="error">Failed to load: ${err?.message || err}</div> <div class="hint">Tip: set your Torn API key (Minimal Access) via the button above.</div>`; }finally{ spinner.textContent = ""; content.classList.remove("muted"); } } ////////////////////////////////////////////////////////////////////////////// // BOOT ////////////////////////////////////////////////////////////////////////////// async function refreshAll(){ await showSettingsHint(); const grid = document.getElementById(`${SECTION_ID}-grid`); const ph = document.getElementById(`${SECTION_ID}-placeholder`); if(ph) ph.style.display = "none"; grid.style.display = ""; grid.innerHTML = ""; const mainCard = makePanelCard(); grid.appendChild(mainCard); if (SHOW_THANKS) { grid.classList.remove('no-thanks'); grid.appendChild(makeThanksCard()); } else { grid.classList.add('no-thanks'); } await ensureItemsCatalog(); await loadCardFromAPI(mainCard); } function wireUp(){ const refreshBtn = document.getElementById(`${SECTION_ID}-refresh`); if(refreshBtn && !refreshBtn.dataset.bound){ refreshBtn.dataset.bound = "1"; refreshBtn.addEventListener("click", async e => { e.preventDefault(); if (refreshBtn.dataset.loading === "1") return; refreshBtn.dataset.loading = "1"; const toggleBtn = document.getElementById(`${SECTION_ID}-toggle`); if(toggleBtn?.getAttribute("aria-expanded")==="false") toggleBtn.click(); try { await refreshAll(); } finally { delete refreshBtn.dataset.loading; } }); } const copyBtn = document.getElementById(`${SECTION_ID}-copylog`); if(copyBtn && !copyBtn.dataset.bound){ copyBtn.dataset.bound = "1"; copyBtn.addEventListener("click", async (e) => { e.preventDefault(); e.stopPropagation(); const ok = await copyLogsToClipboard(); const prev = copyBtn.textContent; copyBtn.textContent = ok ? "Copied!" : "No logs"; setTimeout(() => copyBtn.textContent = prev, 1200); }); } } function observe(){ const obs = new MutationObserver(() => { if(!location.pathname.includes("/item.php")) return; const el = document.getElementById(SECTION_ID); if(!el || !el.isConnected){ insertSection(); wireUp(); } }); obs.observe(document.documentElement, { childList:true, subtree:true }); } // init injectStyle(); insertSection(); wireUp(); observe(); registerMenus(); showSettingsHint(); })();
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址