Tradingview A股助手

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

  1. // ==UserScript==
  2. // @name Tradingview A股助手
  3. // @namespace https://github.com/xiaopc/tradingview-ashare
  4. // @description 给 Tradingview 增加同花顺同步、拼音搜索等功能
  5. // @version 0.7.6
  6. // @author xiaopc
  7. // @supportURL https://github.com/xiaopc/tradingview-ashare/issues
  8. // @match https://*.tradingview.com/chart/*
  9. // @icon https://static.tradingview.com/static/images/favicon.ico
  10. // @grant unsafeWindow
  11. // @grant GM_xmlhttpRequest
  12. // @grant GM_addStyle
  13. // @connect t.10jqka.com.cn
  14. // @connect www.iwencai.com
  15. // @connect qt.gtimg.cn
  16. // @connect smartbox.gtimg.cn
  17. // @require https://unpkg.com/preact@10.11.0/dist/preact.min.umd.js
  18. // @require https://unpkg.com/preact@10.11.0/hooks/dist/hooks.umd.js
  19. // @require https://lf26-cdn-tos.bytecdntp.com/cdn/expire-1-M/htm/3.1.0/htm.umd.min.js
  20. // @require https://lf26-cdn-tos.bytecdntp.com/cdn/expire-1-M/lodash.js/4.17.21/lodash.min.js
  21. // @require https://lf26-cdn-tos.bytecdntp.com/cdn/expire-1-M/lscache/1.3.0/lscache.min.js
  22. // ==/UserScript==
  23.  
  24. // config
  25. // * 显示智能分组
  26. const SHOW_WENCAI_PLATE = true;
  27.  
  28. const tvhelperCss = `
  29. /* @import "https://lf3-cdn-tos.bytecdntp.com/cdn/expire-1-M/bulma/0.9.3/css/bulma-rtl.min.css"; */
  30.  
  31. .card {
  32. background-color: #fff;
  33. border-radius : .25rem;
  34. box-shadow : 0 .5em 1em -.125em rgba(10, 10, 10, .1), 0 0 0 1px rgba(10, 10, 10, .02);
  35. color : #4a4a4a;
  36. max-width : 100%;
  37. position : relative
  38. }
  39.  
  40. .card-content:first-child,
  41. .card-footer:first-child,
  42. .card-header:first-child {
  43. border-top-left-radius : .25rem;
  44. border-top-right-radius: .25rem
  45. }
  46.  
  47. .card-content:last-child,
  48. .card-footer:last-child,
  49. .card-header:last-child {
  50. border-bottom-left-radius : .25rem;
  51. border-bottom-right-radius: .25rem
  52. }
  53.  
  54. .card-header {
  55. background-color: transparent;
  56. align-items : stretch;
  57. box-shadow : 0 .125em .25em rgba(10, 10, 10, .1);
  58. display : flex
  59. }
  60.  
  61. .card-header-title {
  62. align-items: center;
  63. color : #363636;
  64. display : flex;
  65. flex-grow : 1;
  66. font-weight: 700;
  67. padding : .75rem 1rem
  68. }
  69.  
  70. .card-header-title.is-centered {
  71. justify-content: center
  72. }
  73.  
  74. .card-header-icon {
  75. margin : 0;
  76. padding : 0;
  77. align-items : center;
  78. display : flex;
  79. justify-content: center;
  80. padding : .5rem 1rem
  81. }
  82.  
  83. .card-content {
  84. background-color: transparent;
  85. padding : 1rem
  86. }
  87.  
  88. .card-footer {
  89. background-color: transparent;
  90. border-top : 1px solid #ededed;
  91. align-items : stretch;
  92. display : flex
  93. }
  94.  
  95. .card-footer-item {
  96. align-items : center;
  97. display : flex;
  98. flex-basis : 0;
  99. flex-grow : 1;
  100. flex-shrink : 0;
  101. justify-content: center;
  102. padding : .75rem
  103. }
  104.  
  105. .card-footer-item:not(:last-child) {
  106. border-left: 1px solid #ededed
  107. }
  108.  
  109. .card .media:not(:last-child) {
  110. margin-bottom: 1.5rem
  111. }
  112.  
  113. .notification {
  114. background-color: #f5f5f5;
  115. border-radius : .375em;
  116. position : relative;
  117. padding : 1rem 2.25rem 1rem 1.25rem;
  118. margin : 1rem 0
  119. }
  120.  
  121. .notification.is-warning {
  122. background-color: #ffe08a;
  123. color : rgba(0,0,0,.7)
  124. }
  125.  
  126. .menu {
  127. font-size: 1rem
  128. }
  129.  
  130. .menu-list {
  131. line-height: 1.25;
  132. list-style : none;
  133. margin : -.5rem -.75rem 0 -.75rem
  134. }
  135.  
  136. .menu-list a {
  137. border-radius : 2px;
  138. color : #4a4a4a;
  139. display : block;
  140. padding : .3em .75em;
  141. line-height : 1;
  142. align-items : center;
  143. justify-content: space-between;
  144. display : flex;
  145. }
  146.  
  147. .menu-list a:hover {
  148. background-color: #f5f5f5;
  149. color : #363636
  150. }
  151.  
  152. .menu-list a.is-active {
  153. background-color: #eff5fb
  154. }
  155.  
  156. .menu-list li ul {
  157. border-right : 1px solid #dbdbdb;
  158. margin : .75em;
  159. padding-right: .75em
  160. }
  161.  
  162. .menu-label {
  163. color : #7a7a7a;
  164. font-size : .75em;
  165. letter-spacing : .1em;
  166. text-transform : uppercase;
  167. display : flex;
  168. justify-content: space-between;
  169. }
  170.  
  171. .menu-label:not(:first-child) {
  172. margin-top: 1em
  173. }
  174.  
  175. .menu-label:not(:last-child) {
  176. margin-bottom: 1em
  177. }
  178.  
  179. .tag:not(body) {
  180. align-items : center;
  181. background-color: #f5f5f5;
  182. border-radius : 4px;
  183. color : #4a4a4a;
  184. display : inline-flex;
  185. font-size : .75rem;
  186. height : 2em;
  187. justify-content : center;
  188. line-height : 1.5;
  189. padding-left : .75em;
  190. padding-right : .75em;
  191. white-space : nowrap
  192. }
  193.  
  194. .tag:not(body) .delete {
  195. margin-right: .25rem;
  196. margin-left : -.375rem
  197. }
  198.  
  199. .tag:not(body).is-white {
  200. background-color: #fff;
  201. color : #0a0a0a
  202. }
  203.  
  204. .tag:not(body).is-black {
  205. background-color: #0a0a0a;
  206. color : #fff
  207. }
  208.  
  209. .tag:not(body).is-light {
  210. background-color: #f5f5f5;
  211. color : rgba(0, 0, 0, .7)
  212. }
  213.  
  214. .tag:not(body).is-dark {
  215. background-color: #363636;
  216. color : #fff
  217. }
  218.  
  219. .tag:not(body).is-primary {
  220. background-color: #00d1b2;
  221. color : #fff
  222. }
  223.  
  224. .tag:not(body).is-primary.is-light {
  225. background-color: #ebfffc;
  226. color : #00947e
  227. }
  228.  
  229. .tag:not(body).is-link {
  230. background-color: #485fc7;
  231. color : #fff
  232. }
  233.  
  234. .tag:not(body).is-link.is-light {
  235. background-color: #eff1fa;
  236. color : #3850b7
  237. }
  238.  
  239. .tag:not(body).is-info {
  240. background-color: #3e8ed0;
  241. color : #fff
  242. }
  243.  
  244. .tag:not(body).is-info.is-light {
  245. background-color: #eff5fb;
  246. color : #296fa8
  247. }
  248.  
  249. .tag:not(body).is-success {
  250. background-color: #48c78e;
  251. color : #fff
  252. }
  253.  
  254. .tag:not(body).is-success.is-light {
  255. background-color: #effaf5;
  256. color : #257953
  257. }
  258.  
  259. .tag:not(body).is-warning {
  260. background-color: #ffe08a;
  261. color : rgba(0, 0, 0, .7)
  262. }
  263.  
  264. .tag:not(body).is-warning.is-light {
  265. background-color: #fffaeb;
  266. color : #946c00
  267. }
  268.  
  269. .tag:not(body).is-danger {
  270. background-color: #f14668;
  271. color : #fff
  272. }
  273.  
  274. .tag:not(body).is-danger.is-light {
  275. background-color: #feecf0;
  276. color : #cc0f35
  277. }
  278.  
  279. .b-icon {
  280. align-items : center;
  281. display : inline-flex;
  282. justify-content: center;
  283. height : 1rem;
  284. width : 1rem;
  285. fill : #7a7a7a;
  286. border-radius : 4px;
  287. }
  288.  
  289. .b-icon:hover {
  290. background-color: #eff5fb
  291. }
  292.  
  293. .b-icon.is-medium {
  294. height : 1.2rem;
  295. width : 1.2rem;
  296. padding: .3rem;
  297. }
  298.  
  299. #tvhelper {
  300. position : absolute;
  301. display : block;
  302. width : 13rem;
  303. height : 18rem;
  304. min-height: 3rem;
  305. max-height: 95vh;
  306. right : 2.2rem;
  307. bottom : 0;
  308. margin : 0.8rem;
  309. padding : 0;
  310. overflow : auto;
  311. resize : vertical;
  312. }
  313.  
  314. #tvhelper>.card .card-header {
  315. position : sticky;
  316. top : 0;
  317. background: inherit;
  318. }
  319.  
  320. #tvhelper>.card::-webkit-scrollbar {
  321. width : 6px;
  322. height : 6px;
  323. background-color: transparent;
  324. z-index : 999;
  325. }
  326.  
  327. #tvhelper>.card::-webkit-scrollbar-track,
  328. #tvhelper>.card::-webkit-scrollbar-corner {
  329. background-color: transparent;
  330. }
  331.  
  332. #tvhelper>.card::-webkit-scrollbar-thumb {
  333. border-radius : 3px;
  334. background-color: #f0f3fa;
  335. }
  336.  
  337. #tvhelper>.card {
  338. height : 100%;
  339. overflow-y: auto;
  340. overflow-y: overlay;
  341. overflow-x: hidden;
  342. margin : 0;
  343. padding : 0;
  344. }
  345.  
  346. #tvhelper>.card .card-content aside {
  347. display: block;
  348. }
  349.  
  350. #tvhelper>.card .card-content ul.menu-list span.symbol-name,
  351. #tvhelper>.card .card-content p.menu-label span.plate-name{
  352. display : inline-block;
  353. max-width : 7rem;
  354. white-space : nowrap;
  355. overflow : hidden;
  356. text-overflow: ellipsis;
  357. display : flex;
  358. flex-grow : 1;
  359. }
  360.  
  361. #tvhelper-tooltip {
  362. position: absolute;
  363. display : none;
  364. width : 33rem;
  365. height : 18rem;
  366. margin : 0.8rem;
  367. right : 16rem;
  368. bottom : 0rem;
  369. }
  370.  
  371. #tvhelper-tooltip.is-active {
  372. display: block;
  373. }
  374.  
  375. #tvhelper-tooltip img {
  376. width: 100%;
  377. }
  378.  
  379. .disabled {
  380. opacity: 0.6;
  381. }
  382.  
  383. span.tv-data-mode--delayed--for-symbol-list {
  384. margin-left: -6px;
  385. transform : scale(0.6) translate(10px, -10px)
  386. }`;
  387.  
  388. 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>`;
  389.  
  390. (function(window) {
  391. 'use strict';
  392. const marketMap = {sz: 'SZSE', sh: 'SSE', hk: 'HKEX', hsi: 'HSI', ny: 'NYSE', oq: 'NASDAQ', am: 'AMEX'}; // nq: 三板
  393. const currencyMap = {sz: 'CNY', sh: 'CNY', hk: 'HKD', hsi: 'HKD', ny: 'USD', oq: 'USD', am: 'USD'};
  394.  
  395. // utils
  396. const cEl = function (tag) { return document.createElement(tag) };
  397. const gID = function (id) { return document.getElementById(id) };
  398. const deU = function (str) { return JSON.parse(`["${str}"]`)[0] };
  399.  
  400. // gtimg
  401. const gtRealtimeFetcher = async (ids) => {
  402. return new Promise((resolve, reject) => {
  403. GM_xmlhttpRequest({
  404. method: 'GET',
  405. url: 'https://qt.gtimg.cn/q=' + ids.join(','),
  406. responseType: 'arraybuffer',
  407. onload: function (response) {
  408. const responseText = new TextDecoder('gbk').decode(response.response);
  409. resolve(_.fromPairs(responseText.split('\n').filter(l => l.length > 2).map(l => {
  410. let [key, val] = l.split('=');
  411. return [key.slice(2), val.slice(1, -2)];
  412. })));
  413. },
  414. onerror: function (err) {
  415. reject(err);
  416. }
  417. });
  418. });
  419. };
  420. const gtSuggestRaw = async (text) => {
  421. return new Promise((resolve, reject) => {
  422. GM_xmlhttpRequest({
  423. method: "GET",
  424. url: `https://smartbox.gtimg.cn/s3/?v=2&q=${encodeURIComponent(text)}&t=all&c=1`,
  425. onload: function (response) {
  426. const line = deU(response.responseText.split('\n').filter(l => l.startsWith('v_hint'))[0].slice(8, -1));
  427. if (line.startsWith('N')) {
  428. resolve([]);
  429. } else {
  430. resolve(_.flatten([line.split('^')]).map(l => l.split('~')));
  431. }
  432. },
  433. onerror: function (err) {
  434. reject(err);
  435. }
  436. });
  437. });
  438. };
  439. const fetchDataToDict = function (data, keys) {
  440. return _.zipObject(Object.keys(data), Object.values(data).map(i => _.zipObject(keys, i.split('~'))));
  441. };
  442. const getRealtimeBasic = async (...args) => {
  443. const keys = ['_', 'name', 'code', 'last', 'prev_close', 'open', 'volume', 's', 'b',
  444. 'buy1', 'buy1_vol', 'buy2', 'buy2_vol', 'buy3', 'buy3_vol', 'buy4', 'buy4_vol', 'buy5', 'buy5_vol',
  445. 'sell1', 'sell1_vol', 'sell2', 'sell2_vol', 'sell3', 'sell3_vol', 'sell4', 'sell4_vol', 'sell5', 'sell5_vol',
  446. 'latest_deal', 'time', 'change', 'change_rate', 'high', 'low', 'p_v_m', '_volume', 'turnover', 'turn_rate',
  447. 'pe', 'status'];
  448. let ids = [...args];
  449. ids = ids.map(i => (i.startsWith('ny') || i.startsWith('oq') || i.startsWith('am')) ? 'us' + i.slice(2) : i);
  450. const data = await gtRealtimeFetcher(ids);
  451. return fetchDataToDict(data, keys);
  452. };
  453. const gtSuggest = async (text) => {
  454. const arr = await gtSuggestRaw(text);
  455. const typeMap = {GP: 'stock', 'GP-A': 'stock', 'GP-A-KCB': 'stock', ZS: 'index', ETF: 'fund', LOF: 'fund', 'QDII-LOF': 'fund'}; // KJ: 'fund'
  456. return arr.map(i => {
  457. const [type, description] = [typeMap[i[4]], i[2]];
  458. if (type == undefined) return null;
  459. let [exchange, symbol] = [i[0], i[1]];
  460. if (symbol.includes('.')) {
  461. [symbol, exchange] = symbol.split('.');
  462. if (exchange == 'n') exchange = 'ny';
  463. } else if (exchange == 'hk' && type == typeMap.GP) {
  464. symbol = Number(symbol).toString();
  465. } else if (exchange == 'hk' && type == typeMap.ZS) {
  466. exchange = 'hsi';
  467. }
  468. if (marketMap[exchange] == undefined) return null;
  469. return {
  470. "symbol": symbol,
  471. "description": description,
  472. "type": type,
  473. "exchange": marketMap[exchange],
  474. "currency_code": currencyMap[exchange],
  475. "provider_id": "ice",
  476. "country": currencyMap[exchange].slice(0, 2)
  477. };
  478. }).filter(i => !!i);
  479. };
  480.  
  481. // tonghuashun
  482. const getThsSelfRaw = async () => {
  483. return new Promise((resolve, reject) => {
  484. GM_xmlhttpRequest({
  485. method: "GET",
  486. url: "https://t.10jqka.com.cn/newcircle/group/getSelfStockWithMarket",
  487. responseType: 'json',
  488. onload: function (response) {
  489. resolve(response.response);
  490. },
  491. onerror: function (err) {
  492. reject(err);
  493. }
  494. });
  495. });
  496. };
  497. const getWencaiPlateRaw = async () => {
  498. return new Promise((resolve, reject) => {
  499. GM_xmlhttpRequest({
  500. method: "POST",
  501. url: "https://www.iwencai.com/unifiedwap/self-stock/plate/list",
  502. data: 'stocks=0&ths=0',
  503. responseType: 'json',
  504. onload: function (response) {
  505. resolve(response.response);
  506. },
  507. onerror: function (err) {
  508. reject(err);
  509. }
  510. });
  511. });
  512. };
  513. const parseMarketCode = (obj, mark = 'mark', stock = 'stock') => {
  514. if (obj[mark] == '17' || obj[mark] == '20') return 'sh' + obj[stock];
  515. if (obj[mark] == '33' || obj[mark] == '36' || obj[mark] == '32') return 'sz' + obj[stock];
  516. if (obj[mark] == '16') return 'sh' + obj[stock].replace(/^1B/, '00');
  517. if (obj[mark] == '120' && obj[stock].startsWith('00')) return 'sh' + obj[stock];
  518. if (obj[mark] == '177') return 'hk0' + obj[stock].slice(2);
  519. if (obj[mark] == '169') return 'ny' + obj[stock];
  520. if (obj[mark] == '185') return 'oq' + obj[stock];
  521. return null;
  522. };
  523. const getThsSelf = async () => {
  524. const obj = await getThsSelfRaw();
  525. if (obj.errorCode != 0) return obj.errorMsg;
  526. return obj.result.map(obj => parseMarketCode(obj, 'marketid', 'code')).filter(c => !!c);
  527. };
  528. const getWencaiPlate = async () => {
  529. const obj = await getWencaiPlateRaw();
  530. if (!obj.success) return []; // TODO: show error
  531. return obj.data.map(g => {
  532. const stocks = g.list.map(obj => parseMarketCode(obj)).filter(c => !!c);
  533. return {
  534. id: g.sn,
  535. name: g.ln,
  536. items: stocks
  537. };
  538. });
  539. };
  540.  
  541. // tradingview
  542. const { fetch: originalFetch, _exposed_chartWidgetCollection: tvChart } = window;
  543. const originalSetSymbol = tvChart?.setSymbol;
  544. const toTvSymbol = (id) => {
  545. const [market, code] = [marketMap[id.slice(0, 2)], id.slice(2)];
  546. return market + ':' + (market == 'HKEX' ? Number(code).toString() : code);
  547. };
  548. const fromTvSymbol = (symbol) => {
  549. if (!symbol) return null;
  550. let [market, code] = symbol.split(':');
  551. if (market == 'HKEX') code = _.padStart(code, 5, '0');
  552. market = _.findKey(marketMap, (m) => m == market);
  553. if (market == undefined) return null;
  554. return market + code;
  555. };
  556. let latestSearchKw = null, latestSearchRes = null;
  557. const updateTvSymbol = (id) => {
  558. if (typeof tvChart?.setSymbol != 'function') return;
  559. tvChart.setSymbol(toTvSymbol(id), null, tvChart._subscribedChartWidget);
  560. };
  561. const hookedTvSearch = async (...args) => {
  562. const [resource, config] = args;
  563. if (!resource.startsWith('https://symbol-search'))
  564. return await originalFetch(resource, config);
  565. const kw = new URL(resource).searchParams.get('text');
  566. latestSearchKw = kw;
  567. const symbols = await gtSuggest(kw);
  568. latestSearchRes = symbols;
  569. return {
  570. ok: true,
  571. status: 200,
  572. json: () => ({symbols: symbols, symbols_remaining: 0})
  573. };
  574. };
  575.  
  576. // render app
  577. const {h, render} = preact;
  578. const {useState, useEffect, useMemo} = preactHooks;
  579. const html = htm.bind(h);
  580.  
  581. function App (props) {
  582. // data
  583. const [plateData, setPlateData] = useState([]);
  584. const [marketData, setMarketData] = useState({});
  585. const [marketCache, setMarketCache] = useState({});
  586. // ui
  587. const [onRefresh, setOnRefresh] = useState(false);
  588. const [isLogin, setIsLogin] = useState(true);
  589. // hook
  590. const [enableSearchHook, setEnableSearchHook] = useState(false);
  591. const [curSymbolTv, setCurSymbolTv] = useState(null);
  592.  
  593. useEffect(() =>{
  594. window.fetch = enableSearchHook ? hookedTvSearch : originalFetch;
  595. }, [enableSearchHook]);
  596. useEffect(() => {
  597. if (typeof originalSetSymbol != 'function') return;
  598. // const tvSymbols = tvChart.chartsSymbols();
  599. // if (Object.values(tvSymbols).length > 0) {
  600. // setCurSymbolTv(fromTvSymbol(Object.values(tvSymbols)[0].symbol));
  601. // }
  602. tvChart.setSymbol = (...args) => {
  603. setCurSymbolTv(fromTvSymbol(args[0]));
  604. if (latestSearchKw == args[0] && latestSearchRes.length > 0) {
  605. return originalSetSymbol.bind(tvChart)(latestSearchRes[0].symbol, null, tvChart._subscribedChartWidget);
  606. } else {
  607. return originalSetSymbol.bind(tvChart)(...args);
  608. }
  609. };
  610. }, []);
  611.  
  612. const cachePlateData = (data) => { lscache.set('plateData', data, 1e15); };
  613. const updatePlateData = async () => {
  614. if (onRefresh) return;
  615. setOnRefresh(true);
  616. // start update
  617. const selfData = await getThsSelf();
  618. if (typeof selfData == 'string') {
  619. setIsLogin(false);
  620. setOnRefresh(false);
  621. return;
  622. }
  623. setIsLogin(true);
  624. const newPlateData = await getWencaiPlate();
  625. const filteredPlateData = SHOW_WENCAI_PLATE ? newPlateData : newPlateData.filter(g => Number(g.id) > 0);
  626. const newData = [{id: 0, name: '自选股', items: selfData, open: true}, ...filteredPlateData];
  627. let saveData = [], insertedIds = [];
  628. plateData.forEach(g => {
  629. const ol = newData.filter(o => o.id == g.id);
  630. if (ol.length == 0) return;
  631. const d = g;
  632. [d.name, d.items] = [ol[0].name, ol[0].items];
  633. saveData.push(d);
  634. insertedIds.push(d.id);
  635. });
  636. saveData = _.concat(saveData, newData.filter(o => !insertedIds.includes(o.id)));
  637. setPlateData(saveData);
  638. cachePlateData(saveData);
  639. // end update
  640. setOnRefresh(false);
  641. };
  642. useEffect(() =>{
  643. const cache = lscache.get('plateData');
  644. if (cache) {
  645. setPlateData(cache);
  646. return;
  647. }
  648. updatePlateData();
  649. }, []);
  650.  
  651. let interval;
  652. const getNow = (div = 0) => Math.floor(new Date().getTime() / (div == 0 ? 1 : div));
  653. const updateMarketData = async () => {
  654. let now = getNow();
  655. const stocks = _.uniq(_.flatten(plateData.filter((_, i) => getPlateOpen(i)).map(g => g.items)));
  656. const noDataStocks = _.difference(stocks, Object.keys(marketCache));
  657. const needUpdateStocks = Object.keys(marketCache).filter(i => stocks.includes(i)).sort((a, b) => marketCache[a] - marketCache[b]);
  658. const pass = _.slice([...noDataStocks, ...needUpdateStocks], 0, 20);
  659. if (pass.length == 0) {
  660. // clearInterval(interval);
  661. return;
  662. }
  663. const passData = await getRealtimeBasic(...pass);
  664. const passStocks = Object.keys(passData);
  665. const passCache = _.zipObject(passStocks, _.fill(Array(passStocks.length), getNow()));
  666. setMarketData({...marketData, ...passData});
  667. setMarketCache({...marketCache, ...passCache});
  668. };
  669. useEffect(() => {
  670. if (plateData.length == 0) return;
  671. interval = setInterval(updateMarketData, 5000);
  672. return () => { clearInterval(interval) };
  673. }, [plateData, marketData]);
  674.  
  675. const showIntraday = _.debounce((e) => {
  676. if (e.type != "mouseover") {
  677. tooltipElement.classList.remove('is-active');
  678. return;
  679. }
  680. const id = e.srcElement.dataset.id;
  681. if (!id.startsWith('sz') && !id.match(/^sh[^0]/)) return;
  682. tooltipElement.innerHTML = `<img src="https://image.sinajs.cn/newchart/min/n/${id}.gif?_=${getNow(100000)}" referrerpolicy="no-referrer">`;
  683. tooltipElement.classList.add('is-active');
  684. }, 1000);
  685. function Item (props) {
  686. const id = (props.id.startsWith('ny') || props.id.startsWith('oq')) ? 'us' + props.id.slice(2) : props.id;
  687. const marketItem = marketData ? marketData[id] : null;
  688. const name = marketItem ? marketItem.name : id;
  689. const suspend = marketItem?.status == 'S';
  690. const percent = marketItem ? (suspend ? '停牌' : marketItem.change_rate) : '-';
  691. let spanClass = '';
  692. if (percent > 0) spanClass = 'is-success';
  693. else if (percent < 0) spanClass = 'is-danger';
  694. return html`
  695. <li>
  696. <a onclick=${updateTvSymbol.bind(null, props.id)} class="${props.id == curSymbolTv ? 'is-active' : ''}">
  697. <span class="symbol-name">${name}</span>
  698. <span class="tag is-info is-light ${spanClass}" data-id=${id} onmouseover=${showIntraday} onmouseout=${showIntraday}
  699. >${percent}%</span>
  700. </a>
  701. </li>`
  702. }
  703. function raisePlate (index) {
  704. if (index < 1) return;
  705. let newPlate = [...plateData];
  706. [newPlate[index - 1], newPlate[index]] = [newPlate[index], newPlate[index - 1]];
  707. setPlateData(newPlate);
  708. cachePlateData(newPlate);
  709. }
  710. function getPlateOpen (index) {
  711. const plate = plateData[index];
  712. return Object.keys(plate).includes('open') && plate.open;
  713. }
  714. function flipPlate (index) {
  715. let newPlate = [...plateData];
  716. newPlate[index].open = !getPlateOpen(index);
  717. setPlateData(newPlate);
  718. cachePlateData(newPlate);
  719. }
  720. function Plate (props) {
  721. const {group, groupid} = props;
  722. const visible = getPlateOpen(groupid);
  723. return html`
  724. <p class="menu-label">
  725. <span class="plate-name">${group.name}</span>
  726. <span>
  727. <svg class="b-icon" onclick=${flipPlate.bind(null, groupid)}>
  728. <use xlink:href="#eye${visible ? '' : '-off'}-outline"/>
  729. </svg>
  730. <svg class="b-icon" onclick=${raisePlate.bind(null, groupid)}><use xlink:href="#caret-up-outline"/></svg>
  731. </span>
  732. </p>
  733. <ul class="menu-list" style="display: ${visible ? 'block' : 'none'};">
  734. ${group.items.map(i => html`<${Item} id="${i}" />`)}
  735. </ul>`
  736. }
  737.  
  738. return html`
  739. <div class="card">
  740. <header class="card-header">
  741. <p class="card-header-title">同花顺小窗</p>
  742. <span class="card-header-icon">
  743. <svg class="b-icon is-medium"
  744. onclick=${() => setEnableSearchHook(!enableSearchHook)}>
  745. <use xlink:href="#search-circle${enableSearchHook ? '' : '-outline'}"/>
  746. </svg>
  747. <svg class="b-icon is-medium ${onRefresh ? 'disabled' : ''}"
  748. onclick=${updatePlateData}>
  749. <use xlink:href="#refresh-outline"/>
  750. </svg>
  751. </span>
  752. </header>
  753. <div class="card-content">
  754. <div class="notification is-warning" style="display: ${!isLogin ? 'block' : 'none'};">
  755. 未登录(不可用),
  756. <a
  757. href="https://www.10jqka.com.cn/"
  758. title="若无法加载自选板块,请登录(不可用)后点击同花顺主页的“问财”"
  759. rel="noopener noreferrer"
  760. target="_blank">到同花顺官网登录(不可用)</a>
  761. </div>
  762. <aside class="menu">
  763. ${plateData.map((g, gi) => html`<${Plate} group=${g} groupid=${gi} /`)}
  764. </aside>
  765. </div>
  766. </div>`
  767. }
  768.  
  769. const container = cEl('div'), svgElement = cEl('div'), tooltipElement = cEl('div');
  770. container.id = 'tvhelper';
  771. container.className = tooltipElement.className = 'card';
  772. tooltipElement.id = 'tvhelper-tooltip';
  773. svgElement.innerHTML = svgSprite;
  774. document.body.appendChild(svgElement);
  775. document.body.appendChild(container);
  776. document.body.appendChild(tooltipElement);
  777. render(html`<${App} />`, container);
  778.  
  779. GM_addStyle(tvhelperCss);
  780.  
  781. })(unsafeWindow ?? window);

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址