Greasy Fork 支持简体中文。

Tradingview A股助手

给 Tradingview 增加同花顺同步、拼音搜索等功能

// ==UserScript==
// @name         Tradingview A股助手
// @namespace    https://github.com/xiaopc/tradingview-ashare
// @description  给 Tradingview 增加同花顺同步、拼音搜索等功能
// @version      0.7.6
// @author       xiaopc
// @supportURL   https://github.com/xiaopc/tradingview-ashare/issues
// @match        https://*.tradingview.com/chart/*
// @icon         https://static.tradingview.com/static/images/favicon.ico
// @grant        unsafeWindow
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @connect      t.10jqka.com.cn
// @connect      www.iwencai.com
// @connect      qt.gtimg.cn
// @connect      smartbox.gtimg.cn
// @require      https://unpkg.com/[email protected]/dist/preact.min.umd.js
// @require      https://unpkg.com/[email protected]/hooks/dist/hooks.umd.js
// @require      https://lf26-cdn-tos.bytecdntp.com/cdn/expire-1-M/htm/3.1.0/htm.umd.min.js
// @require      https://lf26-cdn-tos.bytecdntp.com/cdn/expire-1-M/lodash.js/4.17.21/lodash.min.js
// @require      https://lf26-cdn-tos.bytecdntp.com/cdn/expire-1-M/lscache/1.3.0/lscache.min.js
// ==/UserScript==

// config
// * 显示智能分组
const SHOW_WENCAI_PLATE = true;

const tvhelperCss = `
  /* @import "https://lf3-cdn-tos.bytecdntp.com/cdn/expire-1-M/bulma/0.9.3/css/bulma-rtl.min.css"; */

  .card {
    background-color: #fff;
    border-radius   : .25rem;
    box-shadow      : 0 .5em 1em -.125em rgba(10, 10, 10, .1), 0 0 0 1px rgba(10, 10, 10, .02);
    color           : #4a4a4a;
    max-width       : 100%;
    position        : relative
  }

  .card-content:first-child,
  .card-footer:first-child,
  .card-header:first-child {
    border-top-left-radius : .25rem;
    border-top-right-radius: .25rem
  }

  .card-content:last-child,
  .card-footer:last-child,
  .card-header:last-child {
    border-bottom-left-radius : .25rem;
    border-bottom-right-radius: .25rem
  }

  .card-header {
    background-color: transparent;
    align-items     : stretch;
    box-shadow      : 0 .125em .25em rgba(10, 10, 10, .1);
    display         : flex
  }

  .card-header-title {
    align-items: center;
    color      : #363636;
    display    : flex;
    flex-grow  : 1;
    font-weight: 700;
    padding    : .75rem 1rem
  }

  .card-header-title.is-centered {
    justify-content: center
  }

  .card-header-icon {
    margin         : 0;
    padding        : 0;
    align-items    : center;
    display        : flex;
    justify-content: center;
    padding        : .5rem 1rem
  }

  .card-content {
    background-color: transparent;
    padding         : 1rem
  }

  .card-footer {
    background-color: transparent;
    border-top      : 1px solid #ededed;
    align-items     : stretch;
    display         : flex
  }

  .card-footer-item {
    align-items    : center;
    display        : flex;
    flex-basis     : 0;
    flex-grow      : 1;
    flex-shrink    : 0;
    justify-content: center;
    padding        : .75rem
  }

  .card-footer-item:not(:last-child) {
    border-left: 1px solid #ededed
  }

  .card .media:not(:last-child) {
    margin-bottom: 1.5rem
  }

  .notification {
    background-color: #f5f5f5;
    border-radius   : .375em;
    position        : relative;
    padding         : 1rem 2.25rem 1rem 1.25rem;
    margin          : 1rem 0
  }

  .notification.is-warning {
    background-color: #ffe08a;
    color           : rgba(0,0,0,.7)
  }

  .menu {
    font-size: 1rem
  }

  .menu-list {
    line-height: 1.25;
    list-style : none;
    margin     : -.5rem -.75rem 0 -.75rem
  }

  .menu-list a {
    border-radius  : 2px;
    color          : #4a4a4a;
    display        : block;
    padding        : .3em .75em;
    line-height    : 1;
    align-items    : center;
    justify-content: space-between;
    display        : flex;
  }

  .menu-list a:hover {
    background-color: #f5f5f5;
    color           : #363636
  }

  .menu-list a.is-active {
    background-color: #eff5fb
  }

  .menu-list li ul {
    border-right : 1px solid #dbdbdb;
    margin       : .75em;
    padding-right: .75em
  }

  .menu-label {
    color          : #7a7a7a;
    font-size      : .75em;
    letter-spacing : .1em;
    text-transform : uppercase;
    display        : flex;
    justify-content: space-between;
  }

  .menu-label:not(:first-child) {
    margin-top: 1em
  }

  .menu-label:not(:last-child) {
    margin-bottom: 1em
  }

  .tag:not(body) {
    align-items     : center;
    background-color: #f5f5f5;
    border-radius   : 4px;
    color           : #4a4a4a;
    display         : inline-flex;
    font-size       : .75rem;
    height          : 2em;
    justify-content : center;
    line-height     : 1.5;
    padding-left    : .75em;
    padding-right   : .75em;
    white-space     : nowrap
  }

  .tag:not(body) .delete {
    margin-right: .25rem;
    margin-left : -.375rem
  }

  .tag:not(body).is-white {
    background-color: #fff;
    color           : #0a0a0a
  }

  .tag:not(body).is-black {
    background-color: #0a0a0a;
    color           : #fff
  }

  .tag:not(body).is-light {
    background-color: #f5f5f5;
    color           : rgba(0, 0, 0, .7)
  }

  .tag:not(body).is-dark {
    background-color: #363636;
    color           : #fff
  }

  .tag:not(body).is-primary {
    background-color: #00d1b2;
    color           : #fff
  }

  .tag:not(body).is-primary.is-light {
    background-color: #ebfffc;
    color           : #00947e
  }

  .tag:not(body).is-link {
    background-color: #485fc7;
    color           : #fff
  }

  .tag:not(body).is-link.is-light {
    background-color: #eff1fa;
    color           : #3850b7
  }

  .tag:not(body).is-info {
    background-color: #3e8ed0;
    color           : #fff
  }

  .tag:not(body).is-info.is-light {
    background-color: #eff5fb;
    color           : #296fa8
  }

  .tag:not(body).is-success {
    background-color: #48c78e;
    color           : #fff
  }

  .tag:not(body).is-success.is-light {
    background-color: #effaf5;
    color           : #257953
  }

  .tag:not(body).is-warning {
    background-color: #ffe08a;
    color           : rgba(0, 0, 0, .7)
  }

  .tag:not(body).is-warning.is-light {
    background-color: #fffaeb;
    color           : #946c00
  }

  .tag:not(body).is-danger {
    background-color: #f14668;
    color           : #fff
  }

  .tag:not(body).is-danger.is-light {
    background-color: #feecf0;
    color           : #cc0f35
  }

  .b-icon {
    align-items    : center;
    display        : inline-flex;
    justify-content: center;
    height         : 1rem;
    width          : 1rem;
    fill           : #7a7a7a;
    border-radius  : 4px;
  }

  .b-icon:hover {
    background-color: #eff5fb
  }

  .b-icon.is-medium {
    height : 1.2rem;
    width  : 1.2rem;
    padding: .3rem;
  }

  #tvhelper {
    position  : absolute;
    display   : block;
    width     : 13rem;
    height    : 18rem;
    min-height: 3rem;
    max-height: 95vh;
    right     : 2.2rem;
    bottom    : 0;
    margin    : 0.8rem;
    padding   : 0;
    overflow  : auto;
    resize    : vertical;
  }

  #tvhelper>.card .card-header {
    position  : sticky;
    top       : 0;
    background: inherit;
  }

  #tvhelper>.card::-webkit-scrollbar {
    width           : 6px;
    height          : 6px;
    background-color: transparent;
    z-index         : 999;
  }

  #tvhelper>.card::-webkit-scrollbar-track,
  #tvhelper>.card::-webkit-scrollbar-corner {
    background-color: transparent;
  }

  #tvhelper>.card::-webkit-scrollbar-thumb {
    border-radius   : 3px;
    background-color: #f0f3fa;
  }

  #tvhelper>.card {
    height    : 100%;
    overflow-y: auto;
    overflow-y: overlay;
    overflow-x: hidden;
    margin    : 0;
    padding   : 0;
  }

  #tvhelper>.card .card-content aside {
    display: block;
  }

  #tvhelper>.card .card-content ul.menu-list span.symbol-name,
  #tvhelper>.card .card-content p.menu-label span.plate-name{
    display      : inline-block;
    max-width    : 7rem;
    white-space  : nowrap;
    overflow     : hidden;
    text-overflow: ellipsis;
    display      : flex;
    flex-grow    : 1;
  }

  #tvhelper-tooltip {
    position: absolute;
    display : none;
    width   : 33rem;
    height  : 18rem;
    margin  : 0.8rem;
    right   : 16rem;
    bottom  : 0rem;
  }

  #tvhelper-tooltip.is-active {
    display: block;
  }

  #tvhelper-tooltip img {
    width: 100%;
  }

  .disabled {
    opacity: 0.6;
  }

  span.tv-data-mode--delayed--for-symbol-list {
    margin-left: -6px;
    transform  : scale(0.6) translate(10px, -10px)
  }`;

const svgSprite = `<svg width="0" height="0" class="hidden"><symbol xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" id="refresh-outline"><title>Refresh</title><path d="M320 146s24.36-12-64-12a160 160 0 10160 160" fill="none" stroke="currentColor" stroke-linecap="round" stroke-miterlimit="10" stroke-width="32"></path><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="32" d="M256 58l80 80-80 80"></path></symbol><symbol xmlns="http://www.w3.org/2000/svg" viewBox="0 0 460 460" id="search-circle"><title>Search Circle</title><path d="m225,33c-105.87,0 -192,86.13 -192,192s86.13,192 192,192s192,-86.13 192,-192s-86.13,-192 -192,-192zm91.31,283.31a16,16 0 0 1 -22.62,0l-42.84,-42.83a88.08,88.08 0 1 1 22.63,-22.63l42.83,42.84a16,16 0 0 1 0,22.62z" id="svg_1"/><circle cx="201" cy="201" id="svg_2" r="56"/></symbol><symbol xmlns="http://www.w3.org/2000/svg" viewBox="0 0 460 460" id="search-circle-outline"><title>Search Circle</title><path d="m230,54a176,176 0 1 0 176,176a176,176 0 0 0 -176,-176z" fill="none" id="svg_1" stroke="currentColor" stroke-miterlimit="10" stroke-width="32"/><path d="m206,134a72,72 0 1 0 72,72a72,72 0 0 0 -72,-72z" fill="none" id="svg_2" stroke="currentColor" stroke-miterlimit="10" stroke-width="32"/><path d="m257.64,257.64l52.36,52.36" fill="none" id="svg_3" stroke="currentColor" stroke-linecap="round" stroke-miterlimit="10" stroke-width="32"/></symbol><symbol xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" id="eye-outline"><title>Eye</title><path d="M255.66 112c-77.94 0-157.89 45.11-220.83 135.33a16 16 0 00-.27 17.77C82.92 340.8 161.8 400 255.66 400c92.84 0 173.34-59.38 221.79-135.25a16.14 16.14 0 000-17.47C428.89 172.28 347.8 112 255.66 112z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="32"></path><circle cx="256" cy="256" r="80" fill="none" stroke="currentColor" stroke-miterlimit="10" stroke-width="32"></circle></symbol><symbol xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" id="eye-off-outline"><title>Eye Off</title><path d="M432 448a15.92 15.92 0 01-11.31-4.69l-352-352a16 16 0 0122.62-22.62l352 352A16 16 0 01432 448zM255.66 384c-41.49 0-81.5-12.28-118.92-36.5-34.07-22-64.74-53.51-88.7-91v-.08c19.94-28.57 41.78-52.73 65.24-72.21a2 2 0 00.14-2.94L93.5 161.38a2 2 0 00-2.71-.12c-24.92 21-48.05 46.76-69.08 76.92a31.92 31.92 0 00-.64 35.54c26.41 41.33 60.4 76.14 98.28 100.65C162 402 207.9 416 255.66 416a239.13 239.13 0 0075.8-12.58 2 2 0 00.77-3.31l-21.58-21.58a4 4 0 00-3.83-1 204.8 204.8 0 01-51.16 6.47zM490.84 238.6c-26.46-40.92-60.79-75.68-99.27-100.53C349 110.55 302 96 255.66 96a227.34 227.34 0 00-74.89 12.83 2 2 0 00-.75 3.31l21.55 21.55a4 4 0 003.88 1 192.82 192.82 0 0150.21-6.69c40.69 0 80.58 12.43 118.55 37 34.71 22.4 65.74 53.88 89.76 91a.13.13 0 010 .16 310.72 310.72 0 01-64.12 72.73 2 2 0 00-.15 2.95l19.9 19.89a2 2 0 002.7.13 343.49 343.49 0 0068.64-78.48 32.2 32.2 0 00-.1-34.78z"></path><path d="M256 160a95.88 95.88 0 00-21.37 2.4 2 2 0 00-1 3.38l112.59 112.56a2 2 0 003.38-1A96 96 0 00256 160zM165.78 233.66a2 2 0 00-3.38 1 96 96 0 00115 115 2 2 0 001-3.38z"></path></symbol><symbol xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" id="caret-up-outline"><title>Caret Up</title><path d="M414 321.94L274.22 158.82a24 24 0 00-36.44 0L98 321.94c-13.34 15.57-2.28 39.62 18.22 39.62h279.6c20.5 0 31.56-24.05 18.18-39.62z"></path></symbol></svg>`;

(function(window) {
    'use strict';
    const marketMap = {sz: 'SZSE', sh: 'SSE', hk: 'HKEX', hsi: 'HSI', ny: 'NYSE', oq: 'NASDAQ', am: 'AMEX'}; // nq: 三板
    const currencyMap = {sz: 'CNY', sh: 'CNY', hk: 'HKD', hsi: 'HKD', ny: 'USD', oq: 'USD', am: 'USD'};

    // utils
    const cEl = function (tag) { return document.createElement(tag) };
    const gID = function (id) { return document.getElementById(id) };
    const deU = function (str) { return JSON.parse(`["${str}"]`)[0] };

    // gtimg
    const gtRealtimeFetcher = async (ids) => {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'GET',
                url: 'https://qt.gtimg.cn/q=' + ids.join(','),
                responseType: 'arraybuffer',
                onload: function (response) {
                    const responseText = new TextDecoder('gbk').decode(response.response);
                    resolve(_.fromPairs(responseText.split('\n').filter(l => l.length > 2).map(l => {
                        let [key, val] = l.split('=');
                        return [key.slice(2), val.slice(1, -2)];
                    })));
                },
                onerror: function (err) {
                    reject(err);
                }
            });
        });
    };
    const gtSuggestRaw = async (text) => {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: "GET",
                url: `https://smartbox.gtimg.cn/s3/?v=2&q=${encodeURIComponent(text)}&t=all&c=1`,
                onload: function (response) {
                    const line = deU(response.responseText.split('\n').filter(l => l.startsWith('v_hint'))[0].slice(8, -1));
                    if (line.startsWith('N')) {
                        resolve([]);
                    } else {
                        resolve(_.flatten([line.split('^')]).map(l => l.split('~')));
                    }
                },
                onerror: function (err) {
                    reject(err);
                }
            });
        });
    };
    const fetchDataToDict = function (data, keys) {
        return _.zipObject(Object.keys(data), Object.values(data).map(i => _.zipObject(keys, i.split('~'))));
    };
    const getRealtimeBasic = async (...args) => {
        const keys = ['_', 'name', 'code', 'last', 'prev_close', 'open', 'volume', 's', 'b',
                      'buy1', 'buy1_vol', 'buy2', 'buy2_vol', 'buy3', 'buy3_vol', 'buy4', 'buy4_vol', 'buy5', 'buy5_vol',
                      'sell1', 'sell1_vol', 'sell2', 'sell2_vol', 'sell3', 'sell3_vol', 'sell4', 'sell4_vol', 'sell5', 'sell5_vol',
                      'latest_deal', 'time', 'change', 'change_rate', 'high', 'low', 'p_v_m', '_volume', 'turnover', 'turn_rate',
                      'pe', 'status'];
        let ids = [...args];
        ids = ids.map(i => (i.startsWith('ny') || i.startsWith('oq') || i.startsWith('am')) ? 'us' + i.slice(2) : i);
        const data = await gtRealtimeFetcher(ids);
        return fetchDataToDict(data, keys);
    };
    const gtSuggest = async (text) => {
        const arr = await gtSuggestRaw(text);
        const typeMap = {GP: 'stock', 'GP-A': 'stock', 'GP-A-KCB': 'stock', ZS: 'index', ETF: 'fund', LOF: 'fund', 'QDII-LOF': 'fund'}; // KJ: 'fund'
        return arr.map(i => {
            const [type, description] = [typeMap[i[4]], i[2]];
            if (type == undefined) return null;
            let [exchange, symbol] = [i[0], i[1]];
            if (symbol.includes('.')) {
                [symbol, exchange] = symbol.split('.');
                if (exchange == 'n') exchange = 'ny';
            } else if (exchange == 'hk' && type == typeMap.GP) {
                symbol = Number(symbol).toString();
            } else if (exchange == 'hk' && type == typeMap.ZS) {
                exchange = 'hsi';
            }
            if (marketMap[exchange] == undefined) return null;
            return {
                "symbol": symbol,
                "description": description,
                "type": type,
                "exchange": marketMap[exchange],
                "currency_code": currencyMap[exchange],
                "provider_id": "ice",
                "country": currencyMap[exchange].slice(0, 2)
            };
        }).filter(i => !!i);
    };

    // tonghuashun
    const getThsSelfRaw = async () => {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: "GET",
                url: "https://t.10jqka.com.cn/newcircle/group/getSelfStockWithMarket",
                responseType: 'json',
                onload: function (response) {
                    resolve(response.response);
                },
                onerror: function (err) {
                    reject(err);
                }
            });
        });
    };
    const getWencaiPlateRaw = async () => {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: "POST",
                url: "https://www.iwencai.com/unifiedwap/self-stock/plate/list",
                data: 'stocks=0&ths=0',
                responseType: 'json',
                onload: function (response) {
                    resolve(response.response);
                },
                onerror: function (err) {
                    reject(err);
                }
            });
        });
    };
    const parseMarketCode = (obj, mark = 'mark', stock = 'stock') => {
        if (obj[mark] == '17' || obj[mark] == '20') return 'sh' + obj[stock];
        if (obj[mark] == '33' || obj[mark] == '36' || obj[mark] == '32') return 'sz' + obj[stock];
        if (obj[mark] == '16') return 'sh' + obj[stock].replace(/^1B/, '00');
        if (obj[mark] == '120' && obj[stock].startsWith('00')) return 'sh' + obj[stock];
        if (obj[mark] == '177') return 'hk0' + obj[stock].slice(2);
        if (obj[mark] == '169') return 'ny' + obj[stock];
        if (obj[mark] == '185') return 'oq' + obj[stock];
        return null;
    };
    const getThsSelf = async () => {
        const obj = await getThsSelfRaw();
        if (obj.errorCode != 0) return obj.errorMsg;
        return obj.result.map(obj => parseMarketCode(obj, 'marketid', 'code')).filter(c => !!c);
    };
    const getWencaiPlate = async () => {
        const obj = await getWencaiPlateRaw();
        if (!obj.success) return []; // TODO: show error
        return obj.data.map(g => {
            const stocks = g.list.map(obj => parseMarketCode(obj)).filter(c => !!c);
            return {
                id: g.sn,
                name: g.ln,
                items: stocks
            };
        });
    };

    // tradingview
    const { fetch: originalFetch, _exposed_chartWidgetCollection: tvChart } = window;
    const originalSetSymbol = tvChart?.setSymbol;
    const toTvSymbol = (id) => {
        const [market, code] = [marketMap[id.slice(0, 2)], id.slice(2)];
        return market + ':' + (market == 'HKEX' ? Number(code).toString() : code);
    };
    const fromTvSymbol = (symbol) => {
        if (!symbol) return null;
        let [market, code] = symbol.split(':');
        if (market == 'HKEX') code = _.padStart(code, 5, '0');
        market = _.findKey(marketMap, (m) => m == market);
        if (market == undefined) return null;
        return market + code;
    };
    let latestSearchKw = null, latestSearchRes = null;
    const updateTvSymbol = (id) => {
        if (typeof tvChart?.setSymbol != 'function') return;
        tvChart.setSymbol(toTvSymbol(id), null, tvChart._subscribedChartWidget);
    };
    const hookedTvSearch = async (...args) => {
        const [resource, config] = args;
        if (!resource.startsWith('https://symbol-search'))
            return await originalFetch(resource, config);
        const kw = new URL(resource).searchParams.get('text');
        latestSearchKw = kw;
        const symbols = await gtSuggest(kw);
        latestSearchRes = symbols;
        return {
            ok: true,
            status: 200,
            json: () => ({symbols: symbols, symbols_remaining: 0})
        };
    };

    // render app
    const {h, render} = preact;
    const {useState, useEffect, useMemo} = preactHooks;
    const html = htm.bind(h);

    function App (props) {
        // data
        const [plateData, setPlateData] = useState([]);
        const [marketData, setMarketData] = useState({});
        const [marketCache, setMarketCache] = useState({});
        // ui
        const [onRefresh, setOnRefresh] = useState(false);
        const [isLogin, setIsLogin] = useState(true);
        // hook
        const [enableSearchHook, setEnableSearchHook] = useState(false);
        const [curSymbolTv, setCurSymbolTv] = useState(null);

        useEffect(() =>{
            window.fetch = enableSearchHook ? hookedTvSearch : originalFetch;
        }, [enableSearchHook]);
        useEffect(() => {
            if (typeof originalSetSymbol != 'function') return;
            // const tvSymbols = tvChart.chartsSymbols();
            // if (Object.values(tvSymbols).length > 0) {
            //     setCurSymbolTv(fromTvSymbol(Object.values(tvSymbols)[0].symbol));
            // }
            tvChart.setSymbol = (...args) => {
                setCurSymbolTv(fromTvSymbol(args[0]));
                if (latestSearchKw == args[0] && latestSearchRes.length > 0) {
                    return originalSetSymbol.bind(tvChart)(latestSearchRes[0].symbol, null, tvChart._subscribedChartWidget);
                } else {
                    return originalSetSymbol.bind(tvChart)(...args);
                }
            };
        }, []);

        const cachePlateData = (data) => { lscache.set('plateData', data, 1e15); };
        const updatePlateData = async () => {
            if (onRefresh) return;
            setOnRefresh(true);
            // start update
            const selfData = await getThsSelf();
            if (typeof selfData == 'string') {
                setIsLogin(false);
                setOnRefresh(false);
                return;
            }
            setIsLogin(true);
            const newPlateData = await getWencaiPlate();
            const filteredPlateData = SHOW_WENCAI_PLATE ? newPlateData : newPlateData.filter(g => Number(g.id) > 0);
            const newData = [{id: 0, name: '自选股', items: selfData, open: true}, ...filteredPlateData];
            let saveData = [], insertedIds = [];
            plateData.forEach(g => {
                const ol = newData.filter(o => o.id == g.id);
                if (ol.length == 0) return;
                const d = g;
                [d.name, d.items] = [ol[0].name, ol[0].items];
                saveData.push(d);
                insertedIds.push(d.id);
            });
            saveData = _.concat(saveData, newData.filter(o => !insertedIds.includes(o.id)));
            setPlateData(saveData);
            cachePlateData(saveData);
            // end update
            setOnRefresh(false);
        };
        useEffect(() =>{
            const cache = lscache.get('plateData');
            if (cache) {
                setPlateData(cache);
                return;
            }
            updatePlateData();
        }, []);

        let interval;
        const getNow = (div = 0) => Math.floor(new Date().getTime() / (div == 0 ? 1 : div));
        const updateMarketData = async () => {
            let now = getNow();
            const stocks = _.uniq(_.flatten(plateData.filter((_, i) => getPlateOpen(i)).map(g => g.items)));
            const noDataStocks = _.difference(stocks, Object.keys(marketCache));
            const needUpdateStocks = Object.keys(marketCache).filter(i => stocks.includes(i)).sort((a, b) => marketCache[a] - marketCache[b]);
            const pass = _.slice([...noDataStocks, ...needUpdateStocks], 0, 20);
            if (pass.length == 0) {
                // clearInterval(interval);
                return;
            }
            const passData = await getRealtimeBasic(...pass);
            const passStocks = Object.keys(passData);
            const passCache = _.zipObject(passStocks, _.fill(Array(passStocks.length), getNow()));
            setMarketData({...marketData, ...passData});
            setMarketCache({...marketCache, ...passCache});
        };
        useEffect(() => {
            if (plateData.length == 0) return;
            interval = setInterval(updateMarketData, 5000);
            return () => { clearInterval(interval) };
        }, [plateData, marketData]);

        const showIntraday = _.debounce((e) => {
            if (e.type != "mouseover") {
                tooltipElement.classList.remove('is-active');
                return;
            }
            const id = e.srcElement.dataset.id;
            if (!id.startsWith('sz') && !id.match(/^sh[^0]/)) return;
            tooltipElement.innerHTML = `<img src="https://image.sinajs.cn/newchart/min/n/${id}.gif?_=${getNow(100000)}" referrerpolicy="no-referrer">`;
            tooltipElement.classList.add('is-active');
        }, 1000);
        function Item (props) {
            const id = (props.id.startsWith('ny') || props.id.startsWith('oq')) ? 'us' + props.id.slice(2) : props.id;
            const marketItem = marketData ? marketData[id] : null;
            const name = marketItem ? marketItem.name : id;
            const suspend = marketItem?.status == 'S';
            const percent = marketItem ? (suspend ? '停牌' : marketItem.change_rate) : '-';
            let spanClass = '';
            if (percent > 0) spanClass = 'is-success';
            else if (percent < 0) spanClass = 'is-danger';
            return html`
            <li>
              <a onclick=${updateTvSymbol.bind(null, props.id)} class="${props.id == curSymbolTv ? 'is-active' : ''}">
                <span class="symbol-name">${name}</span>
                <span class="tag is-info is-light ${spanClass}" data-id=${id} onmouseover=${showIntraday} onmouseout=${showIntraday}
                >${percent}%</span>
              </a>
            </li>`
        }
        function raisePlate (index) {
            if (index < 1) return;
            let newPlate = [...plateData];
            [newPlate[index - 1], newPlate[index]] = [newPlate[index], newPlate[index - 1]];
            setPlateData(newPlate);
            cachePlateData(newPlate);
        }
        function getPlateOpen (index) {
            const plate = plateData[index];
            return Object.keys(plate).includes('open') && plate.open;
        }
        function flipPlate (index) {
            let newPlate = [...plateData];
            newPlate[index].open = !getPlateOpen(index);
            setPlateData(newPlate);
            cachePlateData(newPlate);
        }
        function Plate (props) {
            const {group, groupid} = props;
            const visible = getPlateOpen(groupid);
            return html`
            <p class="menu-label">
              <span class="plate-name">${group.name}</span>
              <span>
                <svg class="b-icon" onclick=${flipPlate.bind(null, groupid)}>
                  <use xlink:href="#eye${visible ? '' : '-off'}-outline"/>
                </svg>
                <svg class="b-icon" onclick=${raisePlate.bind(null, groupid)}><use xlink:href="#caret-up-outline"/></svg>
              </span>
            </p>
            <ul class="menu-list" style="display: ${visible ? 'block' : 'none'};">
              ${group.items.map(i => html`<${Item} id="${i}" />`)}
            </ul>`
        }

        return html`
        <div class="card">
          <header class="card-header">
            <p class="card-header-title">同花顺小窗</p>
            <span class="card-header-icon">
              <svg class="b-icon is-medium"
                   onclick=${() => setEnableSearchHook(!enableSearchHook)}>
                <use xlink:href="#search-circle${enableSearchHook ? '' : '-outline'}"/>
              </svg>
              <svg class="b-icon is-medium ${onRefresh ? 'disabled' : ''}"
                   onclick=${updatePlateData}>
                <use xlink:href="#refresh-outline"/>
              </svg>
            </span>
          </header>
          <div class="card-content">
            <div class="notification is-warning" style="display: ${!isLogin ? 'block' : 'none'};">
              未登录,
              <a
                href="https://www.10jqka.com.cn/"
                title="若无法加载自选板块,请登录后点击同花顺主页的“问财”"
                rel="noopener noreferrer"
                target="_blank">到同花顺官网登录</a>
            </div>
            <aside class="menu">
              ${plateData.map((g, gi) => html`<${Plate} group=${g} groupid=${gi} /`)}
            </aside>
          </div>
        </div>`
    }

    const container = cEl('div'), svgElement = cEl('div'), tooltipElement = cEl('div');
    container.id = 'tvhelper';
    container.className = tooltipElement.className = 'card';
    tooltipElement.id = 'tvhelper-tooltip';
    svgElement.innerHTML = svgSprite;
    document.body.appendChild(svgElement);
    document.body.appendChild(container);
    document.body.appendChild(tooltipElement);
    render(html`<${App} />`, container);

    GM_addStyle(tvhelperCss);

})(unsafeWindow ?? window);