|
MisFoxie (OP)
|
Many times, users mention each other but their profile link is not added. However, sometimes we want to visit that profile and at that time we have to copy the username, go to another platform then search for it and then view it. This takes some time and I think it is a bit of a hassle. I suggested this feature before but since I didn’t get any response, I decided to try it myself to see if it was actually possible so I get the help of powerful AI model. I hope you like it. What this doesWhen a username is selected, a small box appears just below it showing three types of profiles: a Bitcoin profile, a BPIP profile, and another Bitlist profile. Demo: Install1. Install Tampermonkey or Violentmonkey (works on desktop Chrome/Firefox/Edge, and on Firefox for Android) 2. Create a new script, paste the code below, save. Or you can directly install from Greasy Fork https://greasyfork.org/en/scripts/593347-highlight-to-profile-bitcointalk-username-lookup3. Reload any Bitcointalk page and highlight a username Code// ==UserScript== // @name Highlight to Profile - Bitcointalk Username Lookup // @namespace https://bitcointalk.org/ // @version 1.1.0 // @description Highlight any username in a Bitcointalk post to instantly get links to that user's Bitcointalk, BPIP and BitList profiles. // @author MisFoxie // @match https://bitcointalk.org/* // @match http://bitcointalk.org/* // @run-at document-idle // @grant GM_xmlhttpRequest // @grant GM.xmlHttpRequest // @connect bpip.org // @connect api.ninjastic.space // @noframes // ==/UserScript==
/* * How the lookup works (endpoints verified live): * * 1) Ninjastic exact match (JSON, sends access-control-allow-origin: *): * GET https://api.ninjastic.space/users/<username> * -> {"result":"success","message":null,"data":{"author":"LoyceV","author_uid":459836,"posts_count":35579}} * -> {"result":"success","message":"User not found","data":null} * Case-insensitive. Response header x-ratelimit-limit: 100. * * 2) BPIP username search (HTML, no CORS headers -> needs GM_xmlhttpRequest): * GET https://bpip.org/search.aspx?q=<text> * Result rows contain: * <td data-label="Name"><a href="Profile?p=LoyceV">LoyceV</a> ...</td> * <td data-label="User ID">459836</td> * <td data-label="Position">Legendary</td> * This is a prefix search (max 50 rows), so it also returns near-matches. * * All three profile links are built from the numeric UID, which is unambiguous * and avoids the URL-encoding problems usernames cause: * https://bitcointalk.org/index.php?action=profile;u=<uid> * https://bpip.org/Profile?id=<uid> * https://bitlist.co/user/id/<uid> * (bpip.org/Profile?p=<name> works too, but breaks on names with spaces: it * renders the title as "Profile for Sceptical%20Spectacles".) * * Not used, and why: * - BPIP api2/ProfileInfo (the endpoint the official BPIP extension calls) * only accepts numeric user IDs, so it cannot resolve a name to a profile. * - BitList has no public API (/api/* and /rpc return 404, and both are * robots-disallowed), so BitList is linked to but never queried. Its route * table (_app/immutable/entry/app.*.js) defines the profile route as * /user/id/[id] -- a numeric Bitcointalk UID, not a username. The older * /user/<username> form does not exist, which is why those links 404'd. */
(function () { "use strict";
// ---------------------------------------------------------------- config
const CFG = { NINJASTIC_USER: "https://api.ninjastic.space/users/", BPIP_SEARCH: "https://bpip.org/search.aspx?q=", BPIP_PROFILE: "https://bpip.org/Profile?id=", BCT_PROFILE: "https://bitcointalk.org/index.php?action=profile;u=", BITLIST_PROFILE: "https://bitlist.co/user/id/",
// A selection must look like a username to be worth a request. MIN_LEN: 2, MAX_LEN: 25, // Wait this long after the selection settles before firing requests. DEBOUNCE_MS: 350, // Never fire more often than this (protects the Ninjastic rate limit). MIN_REQUEST_GAP_MS: 700, REQUEST_TIMEOUT_MS: 12000, MAX_CANDIDATES: 6, CACHE_MAX: 300, };
const ICONS = { bct: "https://bitcointalk.org/favicon.ico", bpip: "https://bpip.org/favicon.ico", bitlist: "https://bitlist.co/favicon.ico", };
// ------------------------------------------------------------ GM bridge
// Tampermonkey exposes GM_xmlhttpRequest, Violentmonkey also exposes // GM.xmlHttpRequest. Normalise both into one promise-based helper. const gmRequest = (function () { const legacy = typeof GM_xmlhttpRequest === "function" ? GM_xmlhttpRequest : null; const modern = typeof GM !== "undefined" && GM && typeof GM.xmlHttpRequest === "function" ? GM.xmlHttpRequest.bind(GM) : null; const impl = legacy || modern;
return function (url) { if (!impl) { return Promise.reject(new Error("GM_xmlhttpRequest unavailable")); } return new Promise(function (resolve, reject) { impl({ method: "GET", url: url, timeout: CFG.REQUEST_TIMEOUT_MS, headers: { Accept: "text/html,application/json;q=0.9,*/*;q=0.8" }, onload: function (res) { if (res.status >= 200 && res.status < 300) resolve(res.responseText); else reject(new Error("HTTP " + res.status)); }, onerror: function () { reject(new Error("Network error")); }, ontimeout: function () { reject(new Error("Timed out")); }, }); }); }; })();
// ---------------------------------------------------------------- helpers
function esc(text) { return String(text).replace(/[&<>"']/g, function (c) { return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]; }); }
// Bitcointalk usernames allow letters, digits, spaces and punctuation. // Reject anything that looks like a sentence rather than a name. const STOPWORDS = new Set([ "a", "an", "and", "are", "as", "at", "be", "but", "by", "for", "from", "has", "have", "he", "her", "his", "i", "if", "in", "is", "it", "its", "me", "my", "no", "not", "of", "on", "or", "our", "she", "so", "than", "that", "the", "their", "them", "then", "there", "they", "this", "to", "up", "was", "we", "were", "what", "when", "which", "who", "will", "with", "you", "your", ]);
function looksLikeUsername(text) { if (text.length < CFG.MIN_LEN || text.length > CFG.MAX_LEN) return false; if (/[\r\n\t]/.test(text)) return false; if (/[,.;:!?"\u201c\u201d]\s/.test(text)) return false; if (!/^[A-Za-z0-9][A-Za-z0-9 ._\-~!@#$%^&*()+='|\\/\[\]{}<>?]*$/.test(text)) return false;
const words = text.split(/\s+/); if (words.length > 4) return false; // Lowercase filler words mean it is prose, not a username. Capitalised // words are kept so names like "The Sceptical Chymist" still work. if (words.some(function (w) { return STOPWORDS.has(w); })) return false; return true; }
const cache = new Map();
function cacheGet(key) { return cache.has(key) ? cache.get(key) : null; }
function cacheSet(key, value) { if (cache.size >= CFG.CACHE_MAX) cache.delete(cache.keys().next().value); cache.set(key, value); return value; }
// ------------------------------------------------------------- lookups
function ninjasticExact(name) { return gmRequest(CFG.NINJASTIC_USER + encodeURIComponent(name)) .then(function (text) { let json; try { json = JSON.parse(text); } catch (e) { return null; } if (!json || !json.data || !json.data.author_uid) return null; return { name: json.data.author, uid: String(json.data.author_uid), position: "", posts: json.data.posts_count, source: "ninjastic", }; }) .catch(function () { return null; }); }
// Parses the BPIP search result table with DOMParser so markup tweaks are // less likely to break the script than with regex scraping. function parseBpipRows(html) { const doc = new DOMParser().parseFromString(html, "text/html"); const rows = [];
doc.querySelectorAll("tr").forEach(function (tr) { const link = tr.querySelector('td[data-label="Name"] a[href*="Profile?p="]'); const uidCell = tr.querySelector('td[data-label="User ID"]'); if (!link || !uidCell) return;
const uid = uidCell.textContent.trim(); if (!/^\d+$/.test(uid)) return;
const posCell = tr.querySelector('td[data-label="Position"]'); const postsCell = tr.querySelector('td[data-label="Posts"]'); rows.push({ name: link.textContent.trim(), uid: uid, position: posCell ? posCell.textContent.trim() : "", posts: postsCell ? postsCell.textContent.trim() : "", source: "bpip", }); });
return rows; }
function bpipSearchRaw(text) { return gmRequest(CFG.BPIP_SEARCH + encodeURIComponent(text)) .then(parseBpipRows) .catch(function () { return []; }); }
// BPIP's search box matches a single token only: q=Sceptical finds // "Sceptical Spectacles", but q=Sceptical%20Spectacles returns 0 rows. // Its matching is also prefix-based, so only the *first* word of a // multi-word name finds it. Try each word (longest first) until the full // selection matches a returned row. function bpipSearch(text) { const words = text.split(/\s+/).filter(Boolean); if (words.length === 1) return bpipSearchRaw(text);
const key = text.toLowerCase(); const tokens = words.slice().sort(function (a, b) { return b.length - a.length; });
function narrow(rows) { return rows.filter(function (r) { const name = r.name.toLowerCase(); return name === key || name.startsWith(key) || name.indexOf(key) !== -1; }); }
// Note: the loop variable is named "idx" rather than a bare letter so // the source contains no bracketed single letter, which some forums // treat as BBCode italics when the script is pasted into a post. function attempt(idx) { if (idx >= tokens.length) return Promise.resolve([]); return bpipSearchRaw(tokens[idx]).then(function (rows) { const hit = narrow(rows); return hit.length ? hit : attempt(idx + 1); }); }
return attempt(0); }
// Exact hit first, then BPIP near-matches, de-duplicated by user ID. function lookup(text) { const key = text.toLowerCase(); const hit = cacheGet(key); if (hit) return Promise.resolve(hit);
return Promise.all([ninjasticExact(text), bpipSearch(text)]) .then(function (results) { const exact = results[0]; const rows = results[1];
const seen = new Set(); const candidates = [];
function push(entry) { if (!entry || seen.has(entry.uid)) return; seen.add(entry.uid); candidates.push(entry); }
push(exact); rows.filter(function (r) { return r.name.toLowerCase() === key; }).forEach(push); rows.filter(function (r) { return r.name.toLowerCase().startsWith(key); }).forEach(push); rows.forEach(push);
return cacheSet(key, { query: text, exact: candidates.some(function (c) { return c.name.toLowerCase() === key; }), candidates: candidates.slice(0, CFG.MAX_CANDIDATES), truncated: candidates.length > CFG.MAX_CANDIDATES, }); }); }
// ------------------------------------------------------------------- UI
const CSS = [ ".h2p-box{position:absolute;z-index:2147483000;max-width:330px;font:12px Verdana,Arial,sans-serif;", " background:#fff;color:#000;border:1px solid #8fa1c0;border-radius:6px;", " box-shadow:0 3px 12px rgba(0,0,0,.28);padding:6px 8px;line-height:1.5}", ".h2p-box *{box-sizing:border-box}", ".h2p-head{display:flex;align-items:center;gap:6px;margin-bottom:4px;font-weight:bold}", ".h2p-head .h2p-q{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}", ".h2p-close{cursor:pointer;border:0;background:transparent;font-size:15px;line-height:1;padding:0 2px;color:#666}", ".h2p-close:hover{color:#000}", ".h2p-row{display:flex;align-items:center;gap:6px;padding:3px 0;border-top:1px solid #eee}", ".h2p-row:first-of-type{border-top:0}", ".h2p-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}", ".h2p-meta{color:#777;font-size:10px;font-weight:normal}", ".h2p-links{display:flex;gap:4px;flex:0 0 auto}", ".h2p-links a{display:inline-flex;align-items:center;justify-content:center;", " width:26px;height:22px;border:1px solid #cfd8e6;border-radius:4px;background:#f7f9fc;text-decoration:none}", ".h2p-links a:hover{background:#e8eff9;border-color:#8fa1c0}", ".h2p-links img{width:16px;height:16px;display:block}", ".h2p-msg{color:#666;font-style:italic}", ".h2p-more{color:#777;font-size:10px;padding-top:3px}", "@media (prefers-color-scheme:dark){.h2p-box{background:#20242b;color:#dfe3ea;border-color:#3c4450}", " .h2p-row{border-top-color:#333a44}.h2p-links a{background:#2a3038;border-color:#3c4450}", " .h2p-links a:hover{background:#343c46}.h2p-close{color:#9aa4b2}.h2p-close:hover{color:#fff}}", ].join("\n");
let styleInjected = false; function injectStyle() { if (styleInjected) return; styleInjected = true; const style = document.createElement("style"); style.textContent = CSS; (document.head || document.documentElement).appendChild(style); }
let box = null;
function hideBox() { if (box) { box.remove(); box = null; } }
function bindClose() { if (!box) return; const btn = box.querySelector(".h2p-close"); if (btn) btn.addEventListener("click", hideBox); }
function showBox(rect, html) { injectStyle(); hideBox();
box = document.createElement("div"); box.className = "h2p-box"; box.innerHTML = html; // Interacting with the popup must not clear the selection or re-trigger. ["mousedown", "mouseup"].forEach(function (evt) { box.addEventListener(evt, function (e) { e.stopPropagation(); }); }); box.addEventListener("touchstart", function (e) { e.stopPropagation(); }, { passive: true });
bindClose(); document.body.appendChild(box); position(rect); return box; }
function position(rect) { if (!box) return; const pad = 6; const w = box.offsetWidth; const h = box.offsetHeight;
let left = window.scrollX + rect.left; let top = window.scrollY + rect.bottom + pad;
const maxLeft = window.scrollX + document.documentElement.clientWidth - w - pad; if (left > maxLeft) left = maxLeft; if (left < window.scrollX + pad) left = window.scrollX + pad;
// Flip above the selection when there is no room below. if (rect.bottom + h + pad > document.documentElement.clientHeight) { const above = window.scrollY + rect.top - h - pad; if (above > window.scrollY) top = above; }
box.style.left = left + "px"; box.style.top = top + "px"; }
function headerHtml(query, note) { return '<div class="h2p-head"><span class="h2p-q" title="' + esc(query) + '">' + esc(query) + "</span>" + (note ? '<span class="h2p-meta">' + esc(note) + "</span>" : "") + '<button class="h2p-close" title="Close" type="button">×</button></div>'; }
function linkHtml(href, icon, alt, title) { return '<a href="' + esc(href) + '" target="_blank" rel="noopener noreferrer" title="' + esc(title) + '"><img src="' + icon + '" alt="' + alt + '"></a>'; }
function linksHtml(entry) { const name = entry.name; return '<span class="h2p-links">' + linkHtml(CFG.BCT_PROFILE + entry.uid, ICONS.bct, "Bitcointalk", "Bitcointalk profile of " + name + " (UID " + entry.uid + ")") + linkHtml(CFG.BPIP_PROFILE + encodeURIComponent(entry.uid), ICONS.bpip, "BPIP", "BPIP profile of " + name + " (UID " + entry.uid + ")") + linkHtml(CFG.BITLIST_PROFILE + encodeURIComponent(entry.uid), ICONS.bitlist, "BitList", "BitList profile of " + name + " (UID " + entry.uid + ")") + "</span>"; }
function resultHtml(result) { if (!result.candidates.length) { return headerHtml(result.query) + '<div class="h2p-msg">No Bitcointalk user found.</div>'; }
let html = headerHtml(result.query, result.exact ? "" : "similar names");
result.candidates.forEach(function (entry) { const meta = [entry.position, "UID " + entry.uid].filter(Boolean).join(" \u00b7 "); html += '<div class="h2p-row"><span class="h2p-name">' + esc(entry.name) + '<br><span class="h2p-meta">' + esc(meta) + "</span></span>" + linksHtml(entry) + "</div>"; });
if (result.truncated) { html += '<div class="h2p-more">More matches on <a href="' + esc(CFG.BPIP_SEARCH + encodeURIComponent(result.query)) + '" target="_blank" rel="noopener noreferrer">BPIP search</a></div>'; }
return html; }
// -------------------------------------------------------- event handling
let debounceTimer = null; let lastRequestAt = 0; let currentQuery = ""; let requestSeq = 0;
function selectionRect() { const sel = window.getSelection(); if (!sel || sel.isCollapsed || sel.rangeCount === 0) return null; const range = sel.getRangeAt(0); const rects = range.getClientRects(); if (rects.length) return rects[rects.length - 1]; const r = range.getBoundingClientRect(); return r && (r.width || r.height) ? r : null; }
function renderInto(html, rect) { if (!box) return; box.innerHTML = html; bindClose(); position(selectionRect() || rect); }
function handleSelection() { const sel = window.getSelection(); if (!sel || sel.isCollapsed) { hideBox(); currentQuery = ""; return; }
// Ignore selections inside our own popup or inside form fields. const anchor = sel.anchorNode; const anchorEl = anchor && anchor.nodeType === 1 ? anchor : anchor && anchor.parentElement; if (anchorEl && anchorEl.closest && anchorEl.closest(".h2p-box, input, textarea, select")) return;
const text = sel.toString().trim().replace(/\s+/g, " "); if (!looksLikeUsername(text)) { hideBox(); currentQuery = ""; return; } if (text === currentQuery && box) return;
const rect = selectionRect(); if (!rect) return;
currentQuery = text; const seq = ++requestSeq;
const cached = cacheGet(text.toLowerCase()); if (cached) { showBox(rect, resultHtml(cached)); return; }
showBox(rect, headerHtml(text) + '<div class="h2p-msg">Searching\u2026</div>');
const wait = Math.max(0, CFG.MIN_REQUEST_GAP_MS - (Date.now() - lastRequestAt)); setTimeout(function () { if (seq !== requestSeq) return; lastRequestAt = Date.now(); lookup(text) .then(function (result) { if (seq === requestSeq) renderInto(resultHtml(result), rect); }) .catch(function (err) { if (seq === requestSeq) { renderInto(headerHtml(text) + '<div class="h2p-msg">Lookup failed: ' + esc(err.message) + "</div>", rect); } }); }, wait); }
function scheduleSelectionCheck() { clearTimeout(debounceTimer); debounceTimer = setTimeout(handleSelection, CFG.DEBOUNCE_MS); }
// Desktop: mouseup ends a drag-selection. document.addEventListener("mouseup", scheduleSelectionCheck, true); // Mobile: touchend plus selectionchange cover tap-hold and handle dragging. document.addEventListener("touchend", scheduleSelectionCheck, true); document.addEventListener("selectionchange", scheduleSelectionCheck); // Keyboard selection (shift+arrows, ctrl+A, ...). document.addEventListener("keyup", function (e) { if (e.shiftKey || e.key === "Shift" || e.ctrlKey) scheduleSelectionCheck(); }, true);
document.addEventListener("mousedown", function (e) { if (box && !box.contains(e.target)) hideBox(); }, true);
document.addEventListener("keydown", function (e) { if (e.key === "Escape") hideBox(); }, true);
function reposition() { if (!box) return; const r = selectionRect(); if (r) position(r); else hideBox(); }
window.addEventListener("scroll", reposition, true); window.addEventListener("resize", reposition); })();
I did not want this to hammer anyone's site so - Lookups are debounced 350ms after the selection settles, and rate-limited to one per 700ms
- Results are cached (300 entries) - re-selecting the same name costs zero requests
- A highlight has to actually look like a username before anything fires. Highlighting ordinary prose like "for the data" sends no requests at all: length limits, a stopword filter and a character whitelist screen it out first
Privacy Only ninjastic Api and bpip.org and only the text you selected are sent nothing else. No analytics, no accounts, no keys, no external libraries, no data stored anywhere except an in-memory cache that dies when you close the tab. The whole thing is one file of plain readable JavaScript - please read it before you install it and don't take my word for any of the above.All Credit goes to BPIP and Ninjastic.space because this tool made entirely based on the data collected by BPIP and Ninjastic.space. I only glued their lookups to a text highlight. I know there are many bugs I will try to solve it if people get interested in this tool.
|