Greasy Fork

4chan Bizantine Numbers

See ticker price right where it's mentioned

目前为 2021-04-10 提交的版本。查看 最新版本

// ==UserScript==
// @name        4chan Bizantine Numbers
// @namespace   smg
// @match       *://boards.4chan.org/biz/*
// @match       *://boards.4channel.org/biz/*
// @connect     query1.finance.yahoo.com
// @grant       GM.info
// @grant       GM.getValue
// @grant       GM.setValue
// @grant       GM.deleteValue
// @grant       GM.listValues
// @grant       GM.xmlHttpRequest
// @grant       GM.addStyle
// @version     0.6
// @author      anon
// @description See ticker price right where it's mentioned
// @run-at document-start
// ==/UserScript==

// quote - lots of metadata
var YahooFinancev7 = 'https://query1.finance.yahoo.com/v7/finance/quote'; // + '?symbols='+symbols + 'fields=' (optional)
// chart - chart data; https://github.com/ranaroussi/yfinance/blob/main/yfinance/base.py
var YahooFinancev8 = 'https://query1.finance.yahoo.com/v8/finance/chart/'; // + symbol + '?range=1wk&interval=1d'
// Lock symbol while async query pulls the data.
// {"SYMBOL": …}
var lock = new Map();
// time to consider data up to date: 15 minutes * 60 seconds * 1000 milliseconds
var lifetime = 15 * 60 * 1000;
// Load existing data from storage
// {"symbol": Yahoo Finance results[{…}, …]}
var quotes = new Map();
var notasymbol = new Map();
// currencies & crypto
var forex = ['USD', 'EUR', 'JPY', 'GBP', 'CAD', 'AUD'];
var crypto = ['BTC', 'XMR', 'ETH', 'LTC', 'LINK'];
var cryptopair = ['BTC', 'USD', 'EUR'];
var changelog = `New fancy tooltips
Added support for futures, few currencies, and crypto`;

async function yahoo(symbol) {
  let quote = [];
  var symbols = null;
  // special case for forex and crypto
  if (crypto.includes(symbol)) { symbols = cryptopair.map(x => symbol + '-' + x).toString(); };
  if (forex.includes(symbol))  { symbols = forex.map(x => symbol + x + '=X').toString() + ',XMR-'+symbol + ',BTC-'+symbol; };
  // cache
  if (!quotes.has(symbol)) {
    // populate cache
    let rawquote = await GM.getValue('quote.'+symbol, '[]');
    quote = JSON.parse(rawquote);
    quotes.set(symbol, quote);
  } else {
    quote = quotes.get(symbol);
    if (quote.length > 0 && quote[0].regularMarketTime * 1000 < (Date.now() - lifetime)) {
      // refresh cache
      let rawquote = await GM.getValue('quote.'+symbol, '[]');
      quote = JSON.parse(rawquote);
      quotes.set(symbol, quote);
    }
  }
  // download fresh data if symbol is missing from cache or quote is too old
  if (typeof quote === 'undefined' ||
      typeof quote !== 'undefined' && quote.length === 0 ||
      typeof quote !== 'undefined' && quote.length > 0 && quote[0].regularMarketTime * 1000 < (Date.now() - lifetime)) {
    let quoteURL = YahooFinancev7 + '?symbols=' + (symbols || symbol);
    let quoteXHR = GM.xmlHttpRequest({
      method: "GET",
      url: quoteURL,
      onload: function(response) {
        let data = JSON.parse(response.responseText);
        if (data.quoteResponse.error === null) {
          if (data.quoteResponse.result.length > 0) {
            let quote = data.quoteResponse.result;
            quotes.set(symbol, quote);
            let rawquote = JSON.stringify(quote);
            GM.setValue('quote.'+symbol, rawquote);
            populate(symbol, quote);
          } else if (quotes.has(symbol)) {
            // potential temporary error, use cache
            let quote = quotes.get(symbol);
            populate(symbol, quote);
          } else {
            // not a valid symbol
            notasymbol.set(symbol, true);
          }
        }
      }
    });
  } else {
    populate(symbol, quote);
  }
  
  lock.delete(symbol);
}

/* // Old code for v8 API, v7 API is better for our needs
function yahoov8(symbol, range='1wk', interval='1d') {
  let chartURL = YahooFinancev8 + symbol +
    '?range=' + range +
    '&interval=' + interval;
  // fetch data from yahoo
  var chartXHR = GM.xmlHttpRequest({
    method: "GET",
    url: chartURL,
    onload: function(response) {
      let data = JSON.parse(response.responseText);
      if (data.chart.error === null) {
        populate(symbol, data); // populate data format changed
      }
    }
  });
} */

/******************
 * Thread parsing *
 ******************/

// Parse all posts once 4chan X's init finishes
function init(e) {
  async function scriptUpdated() {
    let v = await GM.getValue('version', '0.0');
    if (v === '0.0') {
      notify('Bizantine Numbers installed' + '\nIf you find it useful, feel free to send me some XMR\n' +
            '88CNheH1h7PQgwK1Ehm6JvcAirBhXPJfyCHUvMhbgHwVCDKLu2c9fd9biPMXrEM4LK3Tta6638B9SDGwcDZDFcjw7ta8MuJ');
      GM.setValue('version', GM.info.script.version);
    } else if (v < GM.info.script.version) {
      for (let i = 0; i < changelog.length; i++) {
        if (v < changelog[i].v) {
          notify(changelog[i].msg);
        }
      }
      notify('Bizantine Numbers updated\n' + changelog + '\nIf you find it useful, feel free to send me some XMR\n' +
            '88CNheH1h7PQgwK1Ehm6JvcAirBhXPJfyCHUvMhbgHwVCDKLu2c9fd9biPMXrEM4LK3Tta6638B9SDGwcDZDFcjw7ta8MuJ');
      GM.setValue('version', GM.info.script.version);
    }
  };
  scriptUpdated();
  
  var posts = document.getElementsByClassName('postMessage');
  tag(posts);
  parse(posts);
}

// Parse new posts on thread update
function update(e) {
  var posts = [];
  if (is4ChanX) {
    var newPosts = e.detail.newPosts;
    for (let i = 0; i < newPosts.length; i++) {
      posts.push(document.getElementById(newPosts[i].replace(/.+\./g, 'm')));
    }
  } else {
    let elements = document.getElementsByClassName('postMessage');
    for (let i = elements.length - e.detail.count; i < elements.length; i++) {
      posts.push(elements[i]);
    }
  }
  tag(posts);
  parse(posts);
}

// Get all text nodes
// @param node Root node to look for text nodes under
function textNodesUnder(node){
  var all = [];
  for (node=node.firstChild;node;node=node.nextSibling){
    if (node.nodeType==3) all.push(node);
    else all = all.concat(textNodesUnder(node));
  }
  return all;
}

// Parse posts looking for symbols, wrapping them in <data> element
// @param array Post IDs to parse
function tag(posts) {
  for (let post = 0; post < posts.length; post++) {
    var nodes = textNodesUnder(posts[post]);
    for (let node = 0; node < nodes.length; node++) {
      var n = nodes[node];
      var htmlNode = document.createElement('span');
      var html = n.textContent.replace(/\b[A-Z0-9]{1,5}([.=][A-Z]{1,2})?\b/g, '<data class="ticker" symbol="$&">$&</data>');
      n.parentNode.insertBefore(htmlNode, n);
      n.parentNode.removeChild(n);
      htmlNode.outerHTML = html;
    }
  }
}

// Parse the <data> and start fetch
function parse(posts) {
  // get all elements by tag <data>
  for (let post = 0; post < posts.length; post++) {
    var symbols = posts[post].querySelectorAll('data[symbol]');
    // extract symbols
    for (let i = 0; i < symbols.length; i++) {
      let symbol = symbols[i].getAttribute('symbol');
      if (!notasymbol.has(symbol)) {
        if (!lock.has(symbol) || lock.get(symbol) < (Date.now() - lifetime)) {
          lock.set(symbol, Date.now());
          yahoo(symbol);
        }
      }
    }
  }
}

function populate(symbol, quote) {
  if (typeof quote[0].regularMarketPrice !== 'undefined') {
    let price = quote[0].regularMarketPrice;
    let change = quote[0].regularMarketChangePercent;
    // get all elements by tag <data> and attribute symbol="symbol"
    var symbols = document.querySelectorAll('data[symbol="'+symbol+'"]');
    for (let i = 0; i < symbols.length; i++) {
      let symbol = symbols[i];
      //symbol.setAttribute('title', price+' ('+change.toFixed(2)+'%)');
      symbol.setAttribute('price', price);
      if (change < -0.2) {
        symbol.classList.remove('green', 'crab');
        symbol.classList.add('red');
      } else if (change > 0.2) {
        symbol.classList.remove('red', 'crab');
        symbol.classList.add('green');
      } else {
        symbol.classList.remove('red', 'green');
        symbol.classList.add('crab');
      }
      symbol.onmouseover = tooltip;
    }
  }
}

function tooltip(e) {
  let ticker = e.target;
  let symbol = ticker.getAttribute('symbol');
  let quote = quotes.get(symbol);
  let tooltip = document.createElement('div');
  tooltip.classList.add('dialog', 'tooltip');
  if (!is4ChanX) {
    tooltip.classList.add('reply');
  }
  ticker.append(tooltip);
  
  let h = document.createElement('header');
  h.innerHTML = symbol + ' ';
  let sm = document.createElement('small');
  h.append(sm);
  tooltip.append(h);
  for (let i = 0; i < quote.length; i++) {
    // if (i === 0) {  }
    let el = document.createElement('div');
    let price = document.createElement('span');
    price.classList.add('price');

    price.innerHTML = quote[i].currency + ' ' + quote[i].regularMarketPrice + ' ('+quote[i].regularMarketChangePercent.toFixed(2)+'%)';
    switch (quote[i].quoteType) {
      case 'EQUITY':
        sm.innerHTML = (quote[i].displayName || quote[i].shortName || quote[i].longName);
        break;
      case 'ETF':
        sm.innerHTML = (quote[i].displayName || quote[i].longName || quote[i].shortName);
        break;
      case 'FUTURE':
        sm.innerHTML = (quote[i].displayName || quote[i].shortName || quote[i].longName);
        break;
      case 'CURRENCY':
        if (i===0) { sm.innerHTML = quote[i].quoteType; }
        price.innerHTML = quote[i].shortName + ' ' + quote[i].regularMarketPrice + ' ('+quote[i].regularMarketChangePercent.toFixed(2)+'%)';
        break;
      case 'CRYPTOCURRENCY':
        if (i===0) { sm.innerHTML = quote[i].quoteType; }
        price.innerHTML = quote[i].symbol.replace('-', '/') + ' ' + quote[i].regularMarketPrice + ' ('+quote[i].regularMarketChangePercent.toFixed(2)+'%)';
        break;
    }
    el.append(price);
    tooltip.append(el);
  }
  
  tooltip.style.top = (e.clientY - 16 - tooltip.getBoundingClientRect().height) + 'px';
  tooltip.style.left = (e.clientX) + 'px';
  e.target.onmouseout = function(e) {
    let ticker = e.target;
    let tooltip = ticker.getElementsByClassName('tooltip')[0];
    e.target.removeChild(tooltip);
  }
}

// Notify helper class https://github.com/ccd0/4chan-x/wiki/4chan-X-API#createnotification
// @param type One of 'info', 'success', 'warning', or 'error'
// @param content Message to display
// @param lifetime Show notification for lifetime seconds; 0 = user needs to close it manually
function notify(content, type='info', lifetime=0) {
  var detail = {type: type, content: content, lifetime: lifetime};
  // dispatch event
  if (typeof cloneInto === 'function') {
    detail = cloneInto(detail, document.defaultView);
  }
  var event = new CustomEvent('CreateNotification', {bubbles: true, detail: detail});
  document.dispatchEvent(event);
  console.log(type, content);
}

var is4ChanX = true;
// Add event listeners
document.addEventListener('4chanXInitFinished', init, false);
document.addEventListener('ThreadUpdate', update, false);
// No 4chan X
document.addEventListener("DOMContentLoaded",
  function (event) {
    setTimeout(
      function () {
        if (!document.documentElement.classList.contains("fourchan-x")) {
          is4ChanX = false;
          document.addEventListener('4chanThreadUpdated', update, false);
          init();
        }
      },
      (1)
    );
  }
);

// Add CSS
let style = `
.ticker[price] {text-decoration: underline dotted 1px}
.ticker.red   {background-color: rgba(255,0,0,0.2)}
.ticker.green {background-color: rgba(0,255,0,0.2)}
.ticker.crab  {background-color: rgba(255,255,0,0.2)}
.ticker .tooltip {
  position: fixed;
  padding: 8px;
  box-shadow: 0 1px 2px rgba(0, 0, 0, .15);
}
.ticker .tooltip header { font-size: 1.5em }
.ticker .tooltip .price { font-family: monospace }
.ticker[symbol="CLF"] .dialog.tooltip {
  background-image: linear-gradient(to bottom, #55CDFC 20%, #F7A8B8 20% 40%, #FFFFFF 40% 60%, #F7A8B8 60% 80%, #55CDFC 80%) !important }
`;
GM.addStyle(style);