- // ==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/preact@10.11.0/dist/preact.min.umd.js
- // @require https://unpkg.com/preact@10.11.0/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);