· 5 years ago · Feb 25, 2021, 10:34 AM
1// ==UserScript==
2// @name tinkoff-widget
3// @version 0.0.0
4// @description tinkoff terminal page userscript
5// @run-at document-end
6// @grant unsafeWindow
7// @match https://www.tinkoff.ru/invest-terminal/*
8// ==/UserScript==
9
10// Ищется по файлу:
11// Сам пример виджета: class HelloWorldInjectedWidget extends InjectedTabularDataWidget
12// Его техническое имя hello-world
13
14if (window.top != window.self) //-- Don't run on frames or iframes
15 return;
16
17const enableWrapper = false;
18const showDeltas = false;
19
20
21console.log('tinkoff-widget: 1 LINE. DECLARATIONS');
22console.log(`%cTO DUMP LOGS, ENTER THIS:\n copy(window.TW.Utils.logMessages.join('\\n'))`, 'font-weight: bold; font-size: 3em;');
23
24/** Главный неймспейс */
25const TW = {};
26
27/** экспортируем кишки наружу */
28unsafeWindow.TW = window.TW = TW;
29
30/** утилитки */
31TW.Utils = {
32 /** пишем ли логи в журнал */
33 writeLogs: false,
34 /** журнал логов */
35 logMessages: ['___start____'],
36 /** количество логов в массиве для ротации (чтобы память не текла) */
37 logMessageRotateCount: 1 * 1000,
38 /** пишем ли логи в консоль, или только в массив */
39 displayLogsInConsole: false,
40 /** логирование в консоль */
41 log: ((bindedLog) => {
42 return (msg) => {
43 if (TW.Utils.writeLogs) {
44 const date = new Date();
45 const time = `[${date.getHours()}:${date.getMinutes()}:${date.getSeconds()}.${date.getMilliseconds()}] `;
46 TW.Utils.logMessages.push(`${time}${msg}`);
47 if (TW.Utils.logMessages.length > TW.Utils.logMessageRotateCount) {
48 TW.Utils.logMessages = TW.Utils.logMessages.slice(-1 * TW.Utils.logMessageRotateCount);
49 }
50 }
51 if (TW.Utils.displayLogsInConsole) {
52 return bindedLog(msg);
53 }
54 return undefined;
55 }
56 })( console.log.bind(console, 'tinkoff-widget') ),
57 /** применит find к NodeList */
58 findInCollection: (collection, matcher) => [].find.call(collection, (element) => matcher(element)),
59 /** матчинг селектора и элемента */
60 matchesSelector: (el, selector) => (el.matchesSelector ?? el.webkitMatchesSelector).call(el, selector),
61 /** перемещение по DOM */
62 traverse: {
63 up: (fromElement, toSelector) => {
64 let current = fromElement;
65 while (current && current !== document.body) {
66 if (TW.Utils.matchesSelector(current, toSelector)) {
67 return current;
68 }
69 current = current.parentElement;
70 }
71 // не нашли
72 return null;
73 },
74 },
75 /** сериализация ошибки */
76 stringifyErr(error) {
77 return JSON.stringify({ name: error.name, message: error.message, stack: error.stack });
78 },
79 /** вернёт инстанс React элемента для HTML элемента */
80 getElementReactInstance (element) {
81 var propName = Object.keys(element).find(function (somePropName) {
82 return somePropName.startsWith("__reactInternalInstance$");
83 });
84 return propName ? element[propName] : null
85 },
86 /** вернёт массив подписок React для HTML элемента */
87 getElementReactEventHandlers (element) {
88 var propName = Object.keys(element).find(function (somePropName) {
89 return somePropName.startsWith("__reactEventHandlers$");
90 });
91 return propName ? element[propName] : null
92 },
93 /** вернёт connected компонент из redux для html элемента */
94 getTinkoffWidgetReduxHandle (
95 /** корневой элемент виджета тинькофф */
96 widgetElement,
97 ) {
98 let props;
99
100 if (widgetElement[TW.Symbols.reactSym]) {
101 props = widgetElement[TW.Symbols.reactSym];
102 } else {
103 const widgetInstance = TW.Utils.getElementReactInstance(widgetElement);
104 if (!widgetInstance) {
105 throw new Error('Cannot get react instance for selecting widget');
106 }
107
108 let widgetHandler = undefined;
109 const visited = new WeakSet();
110
111 const isMatchedProp = (x) => {
112 return true &&
113 Boolean(x.updateWidgetGroup) &&
114 Boolean(x.updateWidgetLayout) &&
115 Boolean(x.updateWidgetSymbol) &&
116 true;
117 };
118
119 const isMatchedElem = (x) => {
120 if (!x.memoizedProps || typeof x.memoizedProps !== 'object') {
121 return false;
122 }
123 return isMatchedProp(x.memoizedProps);
124 };
125
126 const doSearch = (current) => {
127 if (!current || typeof current !== 'object' || widgetHandler || visited.has(current)) {
128 return;
129 }
130 visited.add(current);
131
132 if (isMatchedElem(current)) {
133 widgetHandler = current;
134 return;
135 }
136
137 if (current.child) {
138 doSearch(current.child);
139 }
140 if (current.memoizedProps) {
141 for (const [_, prop] of Object.entries(current.memoizedProps)) {
142 doSearch(prop);
143 }
144 }
145 if (current._owner) {
146 doSearch(current._owner);
147 }
148 };
149
150 doSearch(widgetInstance);
151
152 if (!widgetInstance) {
153 throw new Error("Can't find widget instance");
154 }
155
156 props = widgetHandler.memoizedProps;
157
158 // do cache
159 widgetElement[TW.Symbols.reactSym] = props;
160 }
161
162 return props;
163 },
164 /** сменит тикер у виджета, вызвав API тинькофф терминала */
165 selectTickerForTinkoffWidget (
166 /** корневой элемент виджета тинькофф */
167 widgetElement,
168 /** на какой тикер меняем */
169 targetTicker,
170 ) {
171 TW.Utils.log(`[TW.Utils.selectTickerForTinkoffWidget]: select ticker ${targetTicker}`);
172
173 const handle = TW.Utils.getTinkoffWidgetReduxHandle(widgetElement);
174 handle.selectSymbol(targetTicker);
175 },
176};
177
178/** символы (для запоминания) */
179TW.Symbols = {
180 /** символ в element с классом `widget`, где будет лежать `InjectedWidget` */
181 injectedWidgetSym: Symbol('widget'),
182
183 /** кеш поиска инстанта реакта */
184 reactSym: Symbol('react'),
185};
186
187TW.Icons = {
188 'hello-world':
189 `<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
190 <path fill-rule="evenodd" clip-rule="evenodd" d="M3 1C1.89543 1 1 1.89543 1 3V13C1 14.1046 1.89543 15 3 15H13V3C13 1.89543 12.1046 1 11 1H3ZM4 3C3.44772 3 3 3.44772 3 4V6C3 6.55228 3.44772 7 4 7H6C6.55228 7 7 6.55228 7 6V4C7 3.44772 6.55228 3 6 3H4ZM3 9C3 8.44772 3.44772 8 4 8H10C10.5523 8 11 8.44772 11 9C11 9.55229 10.5523 10 10 10H4C3.44772 10 3 9.55229 3 9ZM4 11C3.44772 11 3 11.4477 3 12C3 12.5523 3.44772 13 4 13H10C10.5523 13 11 12.5523 11 12C11 11.4477 10.5523 11 10 11H4Z" fill="currentColor"></path>
191 <path d="M13 15C14.1046 15 15 14.1046 15 13L15 7H14L14 12.5C14 12.7761 13.7761 13 13.5 13C13.2239 13 13 12.7761 13 12.5L13 7V15Z" fill="currentColor"></path>
192 </svg>`,
193};
194TW.MenuButtonTitles = {
195 'hello-world': 'Hello world',
196};
197
198/** селекторы элементов на странице Тинькофф */
199TW.Selectors = {
200 subscriptions: {
201 /** верхний элемент виджета "Подписки" */
202 widget: 'div[data-widget-type="SUBSCRIPTIONS_WIDGET"]',
203 /** враппер контента виджета "Подписки" */
204 contentWrapper: 'div[class^=src-modules-Subscriptions-containers-Subscriptions-wrapper]',
205 /** внутренний элемент виджета "Подписки" (где можно рисовать своё) */
206 innerContent: 'div[class^=src-modules-Subscriptions-containers-Subscriptions-inner]',
207 /** заголовок виджета "Подписки" */
208 title: 'div[class^=src-containers-WidgetLayout-styles-text]',
209 },
210 terminal: {
211 /** Главный элемент терминала (большая область) */
212 dashboard: 'section[class^=src-containers-Dashboard-styles-spaces',
213 /** Селектор меню (открывается по клику на "Добавить виджет") */
214 menu: 'ul.pt-menu',
215 /** одна из строчек в меню (открывается по "Добавить виджет") */
216 menuItem: 'div[class*=src-components-Menu-styles-item]',
217 /** текст внутри одной из строчек в меню (откр. по "Добавить виджет") */
218 menuItemTextInner: 'div[class*=src-components-Menu-styles-textInner]',
219 /** разделитель в меню (открывается по "Добавить виджет") */
220 menuDivider: 'div[class*=src-components-Menu-styles-divider]',
221 },
222};
223
224/** матчеры элементов (проверит, является ли элемент чем-либо) */
225TW.Matchers = {
226 /** вернет `true` для враппера контента Тинькофф виджета "Подписки" */
227 isSubscriptionsTinkoffWidgetWrapper: (element) =>
228 element.tagName && TW.Utils.matchesSelector(element, TW.Selectors.subscriptions.contentWrapper),
229
230 /** вернет `true` для внутреннего элемента контента Тинькофф виджета "Подписки" */
231 isSubscriptionsTinkoffWidgetInnerContent: (element) =>
232 element.tagName && TW.Utils.matchesSelector(element, TW.Selectors.subscriptions.innerContent),
233
234 /** вернет `true` для Тинькофф виджета "Подписки" */
235 isSubscriptionsTinkoffWidget: (element) =>
236 element.tagName && TW.Utils.matchesSelector(element, TW.Selectors.subscriptions.widget),
237
238 /** вернет `true` для основной области терминала Тинькофф */
239 isDashboardSection: (element) =>
240 element.tagName && TW.Utils.matchesSelector(element, TW.Selectors.terminal.dashboard),
241
242 /** вернет `true` для попапа "Добавить Виджет" (меню) */
243 isMenu: (element) => element.tagName && TW.Utils.matchesSelector(element, TW.Selectors.terminal.menu),
244
245 /** вернет `true` для строчки меню из "Добавить виджет" */
246 isMenuItem: (element) => element.tagName && TW.Utils.matchesSelector(element, TW.Selectors.terminal.menuItem),
247
248 /** вернет `true` для заголовка виджета Тинькофф */
249 isTitleElement: (element) =>
250 element.textContent && TW.Utils.matchesSelector(element, TW.Selectors.subscriptions.title),
251};
252
253/** Поиск элементов */
254TW.Finders = {
255 /** найдёт виджет "Подписки" */
256 subscriptionsWidget: (collection) =>
257 TW.Utils.findInCollection(collection, TW.Matchers.isSubscriptionsTinkoffWidget),
258 /** найдёт враппер контента для виджета "Подписки" */
259 subscriptionsWidgetWrapper: (collection) =>
260 TW.Utils.findInCollection(collection, TW.Matchers.isSubscriptionsTinkoffWidgetWrapper),
261 /** найдёт одну из строчек меню "Добавить виджет" */
262 menuItem: (collection) =>
263 TW.Utils.findInCollection(collection, TW.Matchers.isMenuItem),
264};
265
266/** Хранилище в localStorage */
267TW.Storage = {
268 /** ключ, под каким храним данные в LS */
269 _key: '$TW$',
270
271 /** данные из LS */
272 _data: {
273 /*
274 widgets: { [widgetID]: 'hello-world' }
275 */
276 },
277
278 /** прочитает данные из LS */
279 init () {
280 const l = TW.Utils.log.bind('[TW.Storage]: ');
281
282 l('init');
283
284 TW.Storage._data = JSON.parse(
285 localStorage.getItem(TW.Storage._key)
286 );
287
288 l('launch cleanup');
289 TW.Storage.cleanup();
290 },
291
292 /** запишет данные в LS */
293 save () {
294 TW.Utils.log('[TW.Storage]: save');
295 localStorage.setItem(
296 TW.Storage._key,
297 JSON.stringify(TW.Storage._data)
298 );
299 },
300
301 /** вернет тип виджета из LS по его id ИЛИ UNDEFINED */
302 getSavedWidgetType (tinkoffWidgetId) {
303 const log = TW.Utils.log.bind('[TW.Storage] getSavedWidgetType: ');
304
305 if (TW.Storage._data?.widgets) {
306 if (TW.Storage._data.widgets[tinkoffWidgetId]) {
307 const type = TW.Storage._data.widgets[tinkoffWidgetId];
308 log(`Got type ${type} for widget id ${tinkoffWidgetId}`)
309 return type;
310 } else {
311 log(`Got no type in LS for widget id ${tinkoffWidgetId}`)
312 return undefined;
313 }
314 } else {
315 log('Got no saved widgets in LS');
316 return undefined;
317 }
318 },
319
320 /** сохранит в LS тип виджета по его ID */
321 saveWidgetType (tinkoffWidgetId, injectedWidgetType) {
322 const log = TW.Utils.log.bind('[TW.Storage] saveWidgetType: ');
323
324 if (!TW.Storage._data) {
325 TW.Storage._data = {};
326 }
327 if (!TW.Storage._data.widgets) {
328 TW.Storage._data.widgets = {};
329 }
330
331 log(`Save type ${injectedWidgetType} for widget id ${tinkoffWidgetId}`)
332 TW.Storage._data.widgets[tinkoffWidgetId] = injectedWidgetType;
333 TW.Storage.save();
334 },
335
336 /** почистит старые ID виджетов, которые были удалены с терминала */
337 cleanup () {
338 const l = TW.Utils.log.bind('[TW.Storage]:cleanup: ');
339
340 if (!TW.Storage?._data?.widgets) {
341 l('No widgets in storage. Nothing to clean');
342 return;
343 }
344
345 const widgetCount = Object.keys(TW.Storage?._data?.widgets).length;
346
347 if (widgetCount < 100) {
348 l(`Widget count [${widgetCount}] is to small to cleanup, skipping it.`);
349 return;
350 }
351
352 l('Cleanup old widget ids');
353
354 (async function () {
355 l('do request')
356 const response = await fetch('https://www.tinkoff.ru/invest-terminal/settings/state')
357 const json = await response.json();
358 // { [widgetId]: widgetType }
359 const widgetTypeMap = json.settings.widgets.idType;
360 l('got widget type. filter out unused widget id');
361
362 const currentWidgetIds = Object.keys(widgetTypeMap);
363 const storageWidgetIds = Object.keys(TW.Storage?._data?.widgets);
364
365 const stillRemainingWidgetIds = storageWidgetIds.filter((localStorageWidgetId) => currentWidgetIds.includes(localStorageWidgetId));
366 const removedWidgetIds = storageWidgetIds.filter((localStorageWidgetId) => !currentWidgetIds.includes(localStorageWidgetId));
367
368 l([
369 `tinkoff widget id count: [${currentWidgetIds.length}], `,
370 `localStorage injected widget count before [${storageWidgetIds.length}], `,
371 `remaining widget id count=[${stillRemainingWidgetIds.length}], `,
372 `removed widget count=[${removedWidgetIds.length}]`].join('')
373 );
374
375 const newStorageWidgetsCollection = stillRemainingWidgetIds.reduce((acc, widgetId) => {
376 acc[widgetId] = storageWidgetIds[widgetId];
377 return acc;
378 }, {});
379
380 if (!TW.Storage._data) {
381 TW.Storage._data = {};
382 }
383 if (!TW.Storage._data.widgets) {
384 TW.Storage._data.widgets = {};
385 }
386
387 TW.Storage._data.widgets = newStorageWidgetsCollection;
388 TW.Storage.save();
389
390 })().then(() => {
391 l('old widget ids cleanup was successful');
392 }).catch((e) => {
393 l('there was an error during cleanup, dump it next line:');
394 console.error(e);
395 });
396 },
397};
398
399/**
400 * Верхнеуровневое API для паразитирования
401 * Для класса см. @see {InjectedWidgetInterface}
402 */
403TW.Injections = {
404 /** расписание рендера виджетов */
405 refresh: {
406 /** через какой промежуток будем обновлять виджеты на экране */
407 delayTime: 400,
408
409 /** подписчики на обновление */
410 _listeners: [],
411
412 /**
413 * подписка на расписание рендера
414 */
415 add: (
416 /**
417 * хандлер
418 * будет вызван без аргументов
419 */
420 handler
421 ) => {
422 TW.Injections.refresh._listeners.push(handler);
423 return () => {
424 TW.Injections.refresh.remove(handler);
425 };
426 },
427 /**
428 * подписчика на обновление
429 * */
430 remove(handler) {
431 const index = TW.Injections.refresh._listeners.indexOf(handler);
432 if (index === -1) {
433 return;
434 }
435 TW.Injections.refresh._listeners.splice(index, 1);
436 },
437
438 /** вызвать хендлеров на обновление */
439 trigger() {
440 for (const handler of TW.Injections.refresh._listeners) {
441 handler();
442 }
443 },
444
445 /** стартует цикл обновления */
446 startCycle() {
447 TW.Utils.log('[TW.Injections.refresh]: start cycle');
448 setInterval(() => {
449 // спамит, можно включить при отладке
450 // TW.Utils.log('[TW.Injections.refresh]: handle render');
451 TW.Injections.refresh.trigger();
452 }, TW.Injections.refresh.delayTime);
453 },
454 },
455
456 /** связанное с виджетом "Подписки" */
457 subscriptions: {
458 /** вообще все инъекции (для отладки)
459 * Map<
460 * TinkoffWidgetElement,
461 * InjectedWidget
462 * >
463 */
464 _injections: new Map(),
465
466 /**
467 * исп. функцию hookNextWidgetInjection
468 *
469 * ТИП: см. TW.Storage для widgets
470 */
471 _hookedNextWidgetType: undefined,
472
473 hookNextWidgetInjection (injectedWidgetType) {
474 TW.Utils.log(`[TW.Injections.subscriptions.hookNextWidgetInjection]: hook next widget to be ${injectedWidgetType}`);
475 TW.Injections.subscriptions._hookedNextWidgetType = injectedWidgetType;
476 },
477
478 /**
479 * Вызовется при появлении
480 * нужного виджета Тинькофф,
481 * чтобы на нём паразитировать
482 * (тут выбираем, какой виджет куда вставить)
483 */
484 create(
485 /** верхний элемент виджета Тинькофф */
486 tinkoffWidgetElement,
487 /** выбранный тикер у этого виджета */
488 tinkoffWidgetTicker
489 ) {
490 /** ID виджета Тинькофф из их БД */
491 const tinkoffWidgetId = tinkoffWidgetElement.dataset.widgetId;
492
493 if (!tinkoffWidgetId) {
494 debugger;
495 throw new Error(`[TW.Injections.subscriptions]: Got invalid widget id: ${tinkoffWidgetId}`);
496 }
497
498 const existingInjectedWidgetMaybe = TW.Storage.getSavedWidgetType(tinkoffWidgetId);
499
500 const nextHookedWidgetMaybe = TW.Injections.subscriptions._hookedNextWidgetType;
501
502 if (nextHookedWidgetMaybe === 'hello-world' || existingInjectedWidgetMaybe === 'hello-world') {
503 const injectedWidgetInstance = new HelloWorldInjectedWidget(
504 tinkoffWidgetElement,
505 tinkoffWidgetTicker,
506 );
507
508 TW.Injections.subscriptions._injections.set(
509 tinkoffWidgetElement,
510 injectedWidgetInstance,
511 );
512
513 TW.Storage.saveWidgetType(tinkoffWidgetId, 'hello-world');
514
515 tinkoffWidgetElement[TW.Symbols.injectedWidgetSym] = injectedWidgetInstance;
516 }
517
518 // очищаем хук, так как хукает только следующий вызов
519 if (existingInjectedWidgetMaybe || nextHookedWidgetMaybe) {
520 TW.Injections.subscriptions._hookedNextWidgetType = undefined;
521 }
522 },
523
524 /**
525 * будет вызываться каждый раз
526 * когда внутри элемента виджета
527 * какой-то элемент что-то в себе поменял
528 */
529 update(
530 /** верхний элемент виджета Тинькофф */
531 tinkoffWidgetElement,
532 /** инъецированный виджет */
533 injectedWidget
534 ) {
535 injectedWidget.update();
536 },
537
538 /**
539 * вызывается, когда тикер был изменён извне, через вёрстку
540 * (напр., когда в группе виджетов сменился тикер)
541 */
542 changeTicker(
543 /** верхний элемент виджета Тинькофф */
544 tinkoffWidgetElement,
545 /** инъецированный виджет */
546 injectedWidget,
547 /** новый выбранный тикер у этого виджета */
548 newTicker
549 ) {
550 injectedWidget.changeTicker(newTicker);
551 },
552
553 /**
554 * вызовется, когда виджет Тинькофф будет удаляться
555 */
556 destroy(
557 /** верхний элемент виджета Тинькофф */
558 tinkoffWidgetElement,
559 /** инъецированный виджет */
560 injectedWidget
561 ) {
562 /** ID виджета Тинькофф из их БД */
563 const tinkoffWidgetId = tinkoffWidgetElement.dataset.widgetId;
564
565 if (!tinkoffWidgetId) {
566 debugger;
567 throw new Error(`[TW.Injections.subscriptions]: Got invalid widget id: ${tinkoffWidgetId}`);
568 }
569
570 // из storage удалять не надо - удалится сам, при след. перезагрузке
571
572 TW.Injections.subscriptions._injections.delete(tinkoffWidgetElement);
573 injectedWidget.destroy();
574 },
575 },
576
577 /** создание вёрстки в меню "Добавить виджет" */
578 menu: {
579 /** отрисует кнопки для создания виджетов (если их ещё нет) */
580 create: (
581 /** элемент меню Тинькофф */
582 menuElement
583 ) => {
584 if (menuElement.querySelector('*[data--t-w="yes"]')) {
585 TW.Utils.log('[TW.Injections.menu.create]: buttons are already rendered');
586 return;
587 }
588
589 const menuDivider = menuElement.querySelector(TW.Selectors.terminal.menuDivider);
590 if (!menuDivider){
591 /** это меню Тинькофф, которое не надо патчить, например при выборе тикера*/
592 return;
593 }
594
595 const clickOnNewSubscriptionsWidget = () => {
596 const menuItems = menuElement.querySelectorAll(TW.Selectors.terminal.menuItem);
597 const newSubscriptionMenuItem = [].find.call(menuItems, (item) => {
598 return Boolean(item.innerHTML.includes('Подписки'))
599 });
600 if (!newSubscriptionMenuItem) {
601 debugger;
602 throw new Error("Can't find Subscription creation button in menu!");
603 }
604 newSubscriptionMenuItem.click();
605 };
606
607 const makeDivider = () => {
608 return menuDivider.cloneNode();
609 }
610
611 const makeButton = (text, clickHandler, svgIconHTML) => {
612 const menuItems = menuElement.querySelectorAll(TW.Selectors.terminal.menuItem);
613 const SubscriptionMenuItem = [].find.call(menuItems, (item) => {
614 return Boolean(item.innerHTML.includes('Подписки'))
615 });
616 if (!SubscriptionMenuItem) {
617 debugger;
618 throw new Error("Can't find Subscription creation button in menu!");
619 }
620
621 const newMenuItem = SubscriptionMenuItem.cloneNode(true);
622 // чтобы 2 раза не нарисовать
623 newMenuItem.dataset.TW = 'yes';
624
625 newMenuItem.querySelector('svg').outerHTML = svgIconHTML;
626
627 const newMenuItemText = newMenuItem.querySelector(TW.Selectors.terminal.menuItemTextInner);
628 newMenuItemText.innerHTML = text;
629
630 newMenuItem.addEventListener('click', (e) => {
631 e.preventDefault();
632 e.stopImmediatePropagation();
633 clickHandler();
634 });
635 return newMenuItem;
636 };
637
638 const makeWidgetSelectionButton = (type) => {
639 return makeButton(TW.MenuButtonTitles[type], () => {
640 TW.Injections.subscriptions.hookNextWidgetInjection(type);
641 clickOnNewSubscriptionsWidget();
642 }, TW.Icons[type]);
643 };
644
645 const fragment = document.createDocumentFragment();
646 fragment.appendChild(makeDivider());
647 fragment.appendChild(makeWidgetSelectionButton('hello-world'));
648 menuElement.appendChild(fragment);
649 }
650 },
651};
652
653/**
654 * Слежение за мутациями на странице
655 */
656TW.MutationWatchers = {
657 /** виджет подписки на уведомления */
658 subscriptionWidget: {
659 /**
660 * слежение за созданием виджета Тинькофф
661 */
662 creation(
663 /**
664 * будет вызвана при детекте создания виджета подписок Тинькофф
665 * аргументы: (
666 * widget, // верхний элемент виджета
667 * ticker, // и его тикер
668 * )
669 */
670 handler
671 ) {
672 new MutationObserver(function (list) {
673 const log = TW.Utils.log.bind('[MutationWatchers:tickerChange]');
674
675 for (const entry of list) {
676 const { addedNodes } = entry;
677
678 const subscriptionWidgetWrapper = TW.Finders.subscriptionsWidgetWrapper(addedNodes);
679
680 // ждём враппера, но при этом рисуемся в верхний уровень виджета
681 // (враппер рисуется чуть позже, чем верхний виджет)
682 if (subscriptionWidgetWrapper) {
683 log('handle new Tinkoff widget');
684 let subscriptionWidget = TW.Utils.traverse.up(
685 subscriptionWidgetWrapper,
686 TW.Selectors.subscriptions.widget
687 );
688 const ticker = subscriptionWidget.dataset.widgetSymbolId;
689 handler(subscriptionWidget, ticker);
690 }
691 }
692 }).observe(document.body, {
693 childList: true,
694 subtree: true,
695 });
696 },
697
698 /** слежение за обновлением чего-либо внутри виджета подписок */
699 update(
700 /**
701 * будет вызвана, когда в виджете будет детектировано изменение
702 *
703 * аргументы: (
704 * widget, // верхний элемент виджета
705 * )
706 */
707 handler
708 ) {
709 new MutationObserver(function (list) {
710 const log = TW.Utils.log.bind('[MutationWatchers:update]');
711
712 for (const entry of list) {
713 const { target } = entry;
714 const parentWidget = TW.Utils.traverse.up(target, TW.Selectors.subscriptions.widget);
715
716 if (parentWidget) {
717 // сильно спамит, можно раскомментить при отладке
718 // log('Something changed inside of tinkoff widget. Call update');
719 handler(parentWidget);
720 }
721 }
722 }).observe(document.body, {
723 childList: true,
724 subtree: true,
725 });
726 },
727
728 /** слежение за удалением виджета */
729 delete(
730 /**
731 * будет вызвана, когда Тинькофф виджет будет удалён
732 *
733 * аргументы: (
734 * widget, // верхний элемент виджета
735 * )
736 */
737 handler
738 ) {
739 new MutationObserver(function (list) {
740 const log = TW.Utils.log.bind('[MutationWatchers:delete]');
741
742 for (const entry of list) {
743 const { addedNodes, removedNodes, target } = entry;
744
745 const removedWidget = TW.Finders.subscriptionsWidget(removedNodes);
746
747 if (removedWidget) {
748 if (removedWidget[TW.Symbols.injectedWidgetSym]) {
749 log('Tinkoff widget was removed and we have inject here. Teardown inject gracefully');
750 handler(removedWidget);
751 } else {
752 log('Tinkoff widget was removed but no inject found');
753 }
754 }
755 }
756 }).observe(document.body, {
757 childList: true,
758 subtree: true,
759 });
760 },
761
762 /**
763 * смена тикера у виджета "Подписки"
764 * Изменение тикера (Группы виджетов Тинькофф)
765 */
766 tickerChange(
767 /**
768 * будет вызвана при смене тикера у виджета
769 *
770 * аргументы: (
771 * widget, // верхний элемент виджета
772 * ticker, // и его тикер
773 * )
774 */
775 handler
776 ) {
777 new MutationObserver(function (list) {
778 const log = TW.Utils.log.bind('[MutationWatchers:tickerChange]');
779
780 for (const entry of list) {
781 const { target } = entry;
782
783 if (TW.Matchers.isSubscriptionsTinkoffWidget(target)) {
784 if (!target[TW.Symbols.injectedWidgetSym]) {
785 log('tinkoff widget changed symbol but got no injected widget with it');
786 continue;
787 }
788 const ticker = target.dataset.widgetSymbolId;
789
790 log(`handle, new ticker: ${ticker}`);
791
792 handler(target, ticker);
793
794 }
795 }
796 }).observe(document.body, {
797 subtree: true,
798 attributes: true,
799 attributeFilter: ['data-widget-symbol-id'],
800 });
801 },
802
803 },
804 /** меню, которое открывается по "Добавить виджет" */
805 popupMenu: {
806 /**
807 * вызовется, когда откроется меню "Добавить виджет"
808 * аргументы: (
809 * menuElement // сам элемент меню
810 * )
811 * */
812 creation (
813 handler
814 ) {
815 new MutationObserver(function (list) {
816 const log = TW.Utils.log.bind('[MutationWatchers:popupMenu]');
817
818 for (const entry of list) {
819 const { target } = entry;
820
821 if (target.querySelector(TW.Selectors.terminal.menuItem) && target.querySelector(TW.Selectors.terminal.menu)) {
822 if (!!([].find.call(Array(target.querySelector(TW.Selectors.terminal.menu)),
823 (item) => {return Boolean(item.innerHTML.includes('Подписки'))}))){
824
825 log('found menu mutation')
826 handler(target.querySelector(TW.Selectors.terminal.menu));
827 }
828 }
829 }
830 }).observe(document.body, {
831 childList: true,
832 subtree: true,
833 });
834 },
835 },
836};
837
838/** интерфейс паразитирующего виджета */
839class InjectedWidgetInterface {
840 /**
841 * Вызовется при появлении
842 * нужного виджета Тинькофф,
843 * чтобы на нём паразитировать
844 */
845 constructor(
846 /** верхний элемент виджета Тинькофф */
847 tinkoffWidgetElement,
848 /** выбранный тикер у этого виджета */
849 tinkoffWidgetTicker
850 ) {
851 TW.Utils.log('[InjectedWidgetInterface]: constructor()');
852 this.tinkoffWidgetElement = tinkoffWidgetElement;
853 this.tinkoffWidgetTicker = tinkoffWidgetTicker;
854 }
855
856 /**
857 * вызывается, когда тикер был изменён извне, через вёрстку
858 * (напр., когда в группе виджетов сменился тикер)
859 */
860 changeTicker(newTicker) {
861 throw new Error('Unimplemented');
862 }
863
864 /**
865 * будет вызываться каждый раз
866 * когда внутри элемента виджета
867 * какой-то элемент что-то в себе поменял
868 */
869 update() {
870 throw new Error('Unimplemented');
871 }
872
873 /**
874 * вызовется, когда виджет Тинькофф будет удаляться
875 */
876 destroy() {
877 throw new Error('Unimplemented');
878 }
879}
880
881/** паразитирующий виджет, который рисует таблички */
882class InjectedTabularDataWidget extends InjectedWidgetInterface {
883 /** флаг. если true - рисуем весь контент инъецированного виджета */
884 #needsFullRender = true
885 needContentBeforeTableRender = false
886 #notupdatable = false
887
888 constructor (tinkoffWidget, ticker, notupdatable) {
889 super(tinkoffWidget, ticker);
890 this.#notupdatable = notupdatable
891
892 TW.Utils.log('[InjectedTabularDataWidget]: constructor()');
893 TW.Injections.refresh.add(this.#doRender);
894
895 setTimeout(() => {
896 TW.Utils.log('[InjectedTabularDataWidget]: constructor: do render after micro-timeout');
897 this.#doRender();
898 }, 1);
899 }
900
901 // abstract methods
902 /**
903 * метод будет вызван, когда вёрстка нарисуется ВПЕРВЫЕ
904 * */
905 onMount (/* table.TW element */) {
906 throw new Error('Unimplemented');
907 }
908 /**
909 * вернет css стили для виджета
910 * метод должен вернуть строку
911 * */
912 getCSSStyles () {
913 throw new Error('Unimplemented');
914 }
915 /**
916 * вернет дополнительный css класс для таблицы (thead)
917 * метод должен вернуть строку
918 * пример:
919 * 'foobar'
920 * */
921 getTableClassName () {
922 throw new Error('Unimplemented');
923 }
924 /**
925 * вернет заголовок для таблицы (thead)
926 * метод должен вернуть строку
927 * пример:
928 * '<th>foo</th>'
929 * */
930 getTableHead () {
931 throw new Error('Unimplemented');
932 }
933 /**
934 * вернет контент таблицы (содержимое tbody)
935 * метод должен вернуть строку
936 * пример:
937 * <tr><td>foo</td></tr>
938 */
939 getTableBody () {
940 return "";
941 }
942 /**
943 * вернет контент ПЕРЕД ТАБЛИЦЕЙ
944 * метод должен вернуть строку
945 * пример:
946 * <b>hello world</b>
947 */
948 getContentBeforeTable () {
949 return "";
950 }
951 /**
952 * вернет заголовок виджета
953 * метод должен вернуть строку
954 */
955 getWidgetTitle () {
956 throw new Error('Unimplemented');
957 }
958 /** смена тикера */
959 changeTicker (newTicker) {
960 throw new Error('Unimplemented');
961 }
962 // /abstract methods
963
964 /** вернет область, где виджет будет рисовать свой контент */
965 #getRenderElement = () => {
966 return this.#notupdatable ?
967 this.tinkoffWidgetElement.querySelector(".widget")
968 : this.tinkoffWidgetElement.querySelector(TW.Selectors.subscriptions.innerContent)?.parentElement;
969 }
970
971 /** вернет область отрисовки родного видета */
972 #getOriginalRenderElement = () => {
973 return this.tinkoffWidgetElement.querySelector(TW.Selectors.subscriptions.innerContent);
974 }
975
976 /** вернет элемент заголовка виджета Тинькофф */
977 #getTinkoffTitleEl = () => {
978 return this.tinkoffWidgetElement.querySelector(TW.Selectors.subscriptions.title);
979 }
980 /** вернет текущий тикер на виджете Тинькофф */
981 #getTinkoffWidgetSymbol = () => {
982 return this.tinkoffWidgetElement.dataset.widgetSymbolId;
983 }
984 /** вернёт нарисованную инъецированным виджетом тело таблицы */
985 #getRenderedTableBody = () => {
986 return this.#getRenderElement().querySelector('.TW tbody');
987 }
988
989 #getRenderedContentBeforeTable = () => {
990 return this.#getRenderElement().closest('.widget').querySelector('.contentBeforeTable');
991 }
992
993 /** обновление виджета (из род. класса) */
994 update () {
995 TW.Utils.log('[InjectedTabularDataWidget]: update: do update()');
996
997 // элемент заголовка исчезает, когда в виджете что-то ищут
998 // (на клик по заголовку - открывается поиск)
999 // просто ничего не делаем в этот момент
1000 if (this.#notupdatable) {
1001 let widget = this.tinkoffWidgetElement.querySelector(".widget")
1002 if (widget){
1003 for (let child of widget.children) {
1004 if (!(child.className === 'Main-TW-Wrapper' || child.className === 'contentBeforeTable')) {
1005 child.style.display = "none"
1006 }
1007 }
1008 }
1009 }
1010 else{
1011 this.#hideWrapperBody();
1012 }
1013
1014 const titleElMaybe = this.#getTinkoffTitleEl();
1015 if (titleElMaybe && titleElMaybe.innerHTML !== this.getWidgetTitle()) {
1016 TW.Utils.log('[InjectedTabularDataWidget]: update: render widget title');
1017 this.#renderWidgetTitle();
1018 }
1019
1020 // контент внутри виджета потёрся
1021 // нарисуем его заново внутри интервального рендера
1022 if (!this.#getRenderElement()) {
1023 TW.Utils.log('[InjectedTabularDataWidget]: update: set flag for full-rerender to true');
1024 this.#needsFullRender = true;
1025 return;
1026 }
1027
1028 if (this.#getTinkoffWidgetSymbol() !== this.tinkoffWidgetTicker) {
1029 TW.Utils.log(`[InjectedTabularDataWidget]: update: change ticker to [${this.#getTinkoffWidgetSymbol()}]`);
1030 //ticker was also changed
1031 this.changeTicker(this.#getTinkoffWidgetSymbol());
1032 }
1033 }
1034
1035 /** деструктор */
1036 destroy () {
1037 TW.Utils.log('[InjectedTabularDataWidget]: do destroy()');
1038 TW.Injections.refresh.remove(this.#doRender);
1039 }
1040
1041 /** функция отрисовки, что будет вызвана в цикле */
1042 #doRender = () => {
1043 if (this.#needsFullRender && this.#getRenderElement()) {
1044 this.#needsFullRender = false;
1045 TW.Utils.log('[InjectedTabularDataWidget]: doRender: Do initial widget render');
1046 this.#doInitialRender();
1047 }
1048 if (!this.#getRenderElement()) {
1049 // виджет умер? - что делать?
1050 // скажет MutationObserver
1051 TW.Utils.log('[InjectedTabularDataWidget]: doRender: oops render element was not found in DOM, exiting...');
1052 return;
1053 }
1054
1055 TW.Utils.log('[InjectedTabularDataWidget]: doRender: render table body');
1056 this.#renderTableBody();
1057 if (this.needContentBeforeTableRender) {
1058 this.needContentBeforeTableRender = false;
1059 this.#renderContentBeforeTable();
1060 }
1061 };
1062
1063 /** начальная отрисовка инъецируемого виджета */
1064 #doInitialRender = () => {
1065 this.#hideWrapperBody();
1066 this.#renderWidgetTitle();
1067
1068 const initialRenderString = `
1069 <div class="TW-Wrapper">
1070 <style>
1071 ${this.getCSSStyles()}
1072
1073 div div[class^=src-modules-Subscriptions-containers-Subscriptions-inner] {
1074 display: none !important;
1075 }
1076 </style>
1077 <table class="TW pt-html-table pt-small ${this.getTableClassName()}" style="width: 100%">
1078 <thead>
1079 <tr>
1080 ${this.getTableHead()}
1081 </tr>
1082 </thead>
1083 <tbody>
1084 </tbody>
1085 </table>
1086 </div>
1087 `;
1088
1089 const initialRenderStringNotUpdatable = `
1090 <!--div class="src-modules-Subscriptions-containers-Subscriptions-wrapper-3DbLs"-->
1091 <div class="Main-TW-Wrapper" style="height: 100%;user-select: none;">
1092 <div style="display: flex; position: relative; overflow: hidden; width: 100%; height: 100%;">
1093 <style>
1094 .TW1::-webkit-scrollbar {
1095 display: none;
1096 }
1097 </style>
1098 <div class="TW1" style="position: absolute; inset: 0px; overflow: scroll;
1099 transform: translate3d(0px, 0px, 0px);">
1100 <div class="TW-Wrapper">
1101 <style>
1102 ${this.getCSSStyles()}
1103
1104 div div[class^=src-modules-Subscriptions-containers-Subscriptions-inner] {
1105 display: none !important;
1106 }
1107 </style>
1108 <table class="TW pt-html-table pt-small ${this.getTableClassName()}" style="width: 100%">
1109 <thead>
1110 <tr>
1111 ${this.getTableHead()}
1112 </tr>
1113 </thead>
1114 <tbody>
1115 </tbody>
1116 </table>
1117 </div>
1118 </div>
1119 </div>
1120 </div>
1121 `;
1122
1123 const initialContentBeforeTableString = `
1124 <div class="contentBeforeTable">${this.getContentBeforeTable()}</div>
1125 `;
1126
1127 let widget = this.#getRenderElement().closest(".widget")
1128
1129 if (this.#notupdatable){
1130 widget.insertAdjacentHTML('afterbegin', initialRenderStringNotUpdatable);
1131 }else{
1132 this.#getRenderElement().insertAdjacentHTML('beforeEnd', initialRenderString);
1133 }
1134
1135 if (!this.#getRenderedContentBeforeTable()){
1136 widget.insertAdjacentHTML('afterbegin', initialContentBeforeTableString);
1137 }
1138
1139 this.onMount(
1140 this.#getRenderElement().querySelector('.TW'),
1141 );
1142 }
1143
1144 /** спрячет контент виджета Тинькофф */
1145 #hideWrapperBody = () => {
1146 let originalEl = this.#getOriginalRenderElement();
1147 if (originalEl)
1148 originalEl.style.display = 'none';
1149 }
1150
1151 /** отрисовка тела таблицы */
1152 #renderTableBody = () => {
1153 this.#getRenderedTableBody().innerHTML = this.getTableBody();
1154 }
1155
1156 /** отрисовка заголовка виджета */
1157 #renderWidgetTitle = () => {
1158 this.#getTinkoffTitleEl().innerHTML = this.getWidgetTitle();
1159 }
1160
1161 /** отрисовка контента перед таблицей*/
1162 #renderContentBeforeTable = () => {
1163 this.#getRenderedContentBeforeTable().innerHTML = this.getContentBeforeTable();
1164 }
1165
1166}
1167
1168class HelloWorldInjectedWidget extends InjectedTabularDataWidget {
1169 /** текущий контент в таблице */
1170 #tableBodyRows = [];
1171
1172 /** макс. количество строк у таблицы */
1173 #tableBodyRowsMaxCount = 300;
1174
1175 /** ID интервала (setInterval) для отмены при смерти виджета */
1176 #intervalIdForDataChange = -1;
1177
1178 constructor (tinkoffWidget, ticker) {
1179 super(tinkoffWidget, ticker, false);
1180
1181 TW.Utils.log('[HelloWorldInjectedWidget]: constructor()');
1182
1183 // может быть пустым при первой отрисовке
1184 if (this.tinkoffWidgetTicker) {
1185 TW.Utils.log(`[HelloWorldInjectedWidget]: pre-selected ticker is -> ${this.tinkoffWidgetTicker}`);
1186 }
1187
1188 this.#intervalIdForDataChange = setInterval(() => {
1189 this.#handleIntervalDataChange();
1190 }, 1 /* ОООЧЕНЬ часто данные типа приходят */);
1191 }
1192
1193 destroy () {
1194 TW.Utils.log('[HelloWorldInjectedWidget]: destroy()');
1195
1196 if (this.#intervalIdForDataChange !== -1) {
1197 clearInterval(this.#intervalIdForDataChange);
1198 this.#intervalIdForDataChange = -1;
1199 }
1200
1201 super.destroy();
1202 }
1203
1204 /**
1205 * вернет дополнительный css класс для таблицы (thead)
1206 * метод должен вернуть строку
1207 * пример:
1208 * 'foobar'
1209 * */
1210 getTableClassName () {
1211 return 'hello-world';
1212 }
1213
1214 /**
1215 * метод будет вызван, когда вёрстка нарисуется ВПЕРВЫЕ
1216 * */
1217 onMount (/* table.TW element */) {
1218 // noop
1219 }
1220
1221 /**
1222 * вернет css стили для виджета
1223 * метод должен вернуть строку
1224 * */
1225 getCSSStyles () {
1226 return `
1227 .TW.hello-world tr td { color: inherit !important; }
1228 `;
1229 }
1230 /**
1231 * вернет заголовок для таблицы (thead)
1232 * метод должен вернуть строку
1233 * пример:
1234 * '<th>foo</th>'
1235 * */
1236 getTableHead () {
1237 return `
1238 <th>Time</th>
1239 <th>Number</th>
1240 `;
1241 }
1242 /**
1243 * вернет контент таблицы (содержимое tbody)
1244 * метод должен вернуть строку
1245 * пример:
1246 * <tr><td>foo</td></tr>
1247 */
1248 getTableBody () {
1249 // рисуем В ОБРАТНОМ порядке (чтобы не применять сортировку)
1250 return this.#tableBodyRows.map((_, index, arr) => `${arr[arr.length - index - 1]}`).join('');
1251 }
1252 /**
1253 * вернет заголовок виджета
1254 * метод должен вернуть строку
1255 */
1256 getWidgetTitle () {
1257 return `Hello ticker ${this.tinkoffWidgetTicker}`;
1258 }
1259
1260 getContentBeforeTable () {
1261 return '';
1262 }
1263
1264 /** смена тикера */
1265 changeTicker (newTicker) {
1266 TW.Utils.log(`[HelloWorldInjectedWidget]: change ticker to ${this.tinkoffWidgetTicker}`);
1267
1268 this.tinkoffWidgetTicker = newTicker;
1269 this.update();
1270 this.#tableBodyRows = [];
1271 }
1272
1273 #handleIntervalDataChange = () => {
1274 TW.Utils.log(`[HelloWorldInjectedWidget]: handle interval data change`);
1275
1276 // эти данные "типа" приходят из сервера
1277 const seconds = new Date().getSeconds();
1278 const randomNumber = Math.random();
1279
1280 const rowHTML = `
1281 <tr>
1282 <td>${seconds}</td>
1283 <td>${randomNumber.toFixed(2)}</td>
1284 </tr>
1285 `;
1286 this.#tableBodyRows.push(rowHTML);
1287
1288 this.needContentBeforeTableRender = true;
1289
1290 // срезаем строки за переделом границы макс. количества строк
1291 if (this.#tableBodyRows.length > this.#tableBodyRowsMaxCount) {
1292 this.#tableBodyRows = this.#tableBodyRows.slice(-1 * this.#tableBodyRowsMaxCount);
1293 }
1294 };
1295
1296}
1297
1298
1299
1300TW.Utils.log('INIT DONE, LAUNCH IT!');
1301
1302TW.Utils.log('Init storage');
1303TW.Storage.init();
1304
1305TW.Utils.log('Start render cycle');
1306TW.Injections.refresh.startCycle();
1307
1308TW.Utils.log('Listen on subscription widget events');
1309TW.MutationWatchers.subscriptionWidget.creation((widget, ticker) => {
1310 if (!widget[TW.Symbols.injectedWidgetSym]) {
1311 TW.Utils.log('inject new widget into tinkoff subscriptions');
1312 TW.Injections.subscriptions.create(widget, ticker);
1313 } else {
1314 TW.Utils.log('WTF: widget was created but we already have matching ');
1315 TW.Injections.subscriptions.update(widget, widget[TW.Symbols.injectedWidgetSym]);
1316 }
1317});
1318TW.MutationWatchers.subscriptionWidget.update((widget) => {
1319 if (widget[TW.Symbols.injectedWidgetSym]) {
1320 // сильно спамит, можно включить для отладки
1321 // TW.Utils.log('update widget');
1322 TW.Injections.subscriptions.update(
1323 widget,
1324 widget[TW.Symbols.injectedWidgetSym],
1325 );
1326 }
1327});
1328TW.MutationWatchers.subscriptionWidget.tickerChange((widget, ticker) => {
1329 if (widget[TW.Symbols.injectedWidgetSym]) {
1330 TW.Utils.log('change ticker');
1331 TW.Injections.subscriptions.changeTicker(
1332 widget,
1333 widget[TW.Symbols.injectedWidgetSym],
1334 ticker,
1335 );
1336 }
1337});
1338TW.MutationWatchers.subscriptionWidget.delete((widget) => {
1339 if (widget[TW.Symbols.injectedWidgetSym]) {
1340 TW.Utils.log('destroy widget');
1341 TW.Injections.subscriptions.destroy(
1342 widget,
1343 widget[TW.Symbols.injectedWidgetSym],
1344 );
1345 }
1346});
1347
1348TW.Utils.log('Listen on menu mutations');
1349TW.MutationWatchers.popupMenu.creation((menuElement) => {
1350 TW.Utils.log('render menu buttons');
1351 TW.Injections.menu.create(menuElement);
1352});
1353