@mathieuc/tradingview 2.0.91 → 3.0.0-pre

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/client.js ADDED
@@ -0,0 +1,280 @@
1
+ const WebSocket = require('ws');
2
+
3
+ const misc = require('./miscRequests');
4
+ const protocol = require('./protocol');
5
+
6
+ const quoteSessionGenerator = require('./quote/session');
7
+ const chartSessionGenerator = require('./chart/session');
8
+
9
+ /**
10
+ * @typedef {Object} Session
11
+ * @prop {'quote' | 'chart' | 'replay'} type Session type
12
+ * @prop {(data: {}) => null} onData When there is a data
13
+ */
14
+
15
+ /** @typedef {Object<string, Session>} SessionList Session list */
16
+
17
+ /**
18
+ * @typedef {(t: string, p: []) => null} SendPacket Send a custom packet
19
+ * @param {string} t Packet type
20
+ * @param {string[]} p Packet data
21
+ */
22
+
23
+ /**
24
+ * @typedef {Object} ClientBridge
25
+ * @prop {SessionList} sessions
26
+ * @prop {SendPacket} send
27
+ */
28
+
29
+ /**
30
+ * @typedef { 'connected' | 'disconnected'
31
+ * | 'logged' | 'ping' | 'data'
32
+ * | 'error' | 'event'
33
+ * } ClientEvent
34
+ */
35
+
36
+ module.exports = class Client {
37
+ #ws;
38
+
39
+ #logged = false;
40
+
41
+ /** If the client is logged in */
42
+ get isLogged() {
43
+ return this.#logged;
44
+ }
45
+
46
+ /** If the cient was closed */
47
+ get isOpen() {
48
+ return this.#ws.readyState === this.#ws.OPEN;
49
+ }
50
+
51
+ /** @type {SessionList} */
52
+ #sessions = {};
53
+
54
+ #callbacks = {
55
+ connected: [],
56
+ disconnected: [],
57
+ logged: [],
58
+ ping: [],
59
+ data: [],
60
+
61
+ error: [],
62
+ event: [],
63
+ };
64
+
65
+ /**
66
+ * @param {ClientEvent} ev Client event
67
+ * @param {...{}} data Packet data
68
+ */
69
+ #handleEvent(ev, ...data) {
70
+ this.#callbacks[ev].forEach((e) => e(...data));
71
+ this.#callbacks.event.forEach((e) => e(ev, ...data));
72
+ }
73
+
74
+ #handleError(...msgs) {
75
+ if (this.#callbacks.error.length === 0) console.error(...msgs);
76
+ else this.#handleEvent('error', ...msgs);
77
+ }
78
+
79
+ /**
80
+ * When client is connected
81
+ * @param {() => void} cb Callback
82
+ * @event
83
+ */
84
+ onConnected(cb) {
85
+ this.#callbacks.connected.push(cb);
86
+ }
87
+
88
+ /**
89
+ * When client is disconnected
90
+ * @param {() => void} cb Callback
91
+ * @event
92
+ */
93
+ onDisconnected(cb) {
94
+ this.#callbacks.disconnected.push(cb);
95
+ }
96
+
97
+ /**
98
+ * @typedef {Object} SocketSession
99
+ * @prop {string} session_id Socket session ID
100
+ * @prop {number} timestamp Session start timestamp
101
+ * @prop {number} timestampMs Session start milliseconds timestamp
102
+ * @prop {string} release Release
103
+ * @prop {string} studies_metadata_hash Studies metadata hash
104
+ * @prop {'json' | string} protocol Used protocol
105
+ * @prop {string} javastudies Javastudies
106
+ * @prop {number} auth_scheme_vsn Auth scheme type
107
+ * @prop {string} via Socket IP
108
+
109
+ * When client is logged
110
+ * @param {(SocketSession: SocketSession) => void} cb Callback
111
+ * @event
112
+ */
113
+ onLogged(cb) {
114
+ this.#callbacks.logged.push(cb);
115
+ }
116
+
117
+ /**
118
+ * When server is pinging the client
119
+ * @param {(i: number) => void} cb Callback
120
+ * @event
121
+ */
122
+ onPing(cb) {
123
+ this.#callbacks.ping.push(cb);
124
+ }
125
+
126
+ /**
127
+ * When unparsed data is received
128
+ * @param {(...{}) => void} cb Callback
129
+ * @event
130
+ */
131
+ onData(cb) {
132
+ this.#callbacks.data.push(cb);
133
+ }
134
+
135
+ /**
136
+ * When a client error happens
137
+ * @param {(...{}) => void} cb Callback
138
+ * @event
139
+ */
140
+ onError(cb) {
141
+ this.#callbacks.error.push(cb);
142
+ }
143
+
144
+ /**
145
+ * When a client event happens
146
+ * @param {(...{}) => void} cb Callback
147
+ * @event
148
+ */
149
+ onEvent(cb) {
150
+ this.#callbacks.event.push(cb);
151
+ }
152
+
153
+ #parsePacket(str) {
154
+ if (!this.isOpen) return;
155
+
156
+ protocol.parseWSPacket(str).forEach((packet) => {
157
+ if (global.TW_DEBUG) console.log('§90§30§107 CLIENT §0 PACKET', packet);
158
+ if (typeof packet === 'number') { // Ping
159
+ this.#ws.send(protocol.formatWSPacket(`~h~${packet}`));
160
+ this.#handleEvent('ping', packet);
161
+ return;
162
+ }
163
+
164
+ if (packet.m === 'protocol_error') { // Error
165
+ this.#handleError('Client critical error:', packet.p);
166
+ this.#ws.close();
167
+ return;
168
+ }
169
+
170
+ if (packet.m && packet.p) { // Normal packet
171
+ const parsed = {
172
+ type: packet.m,
173
+ data: packet.p,
174
+ };
175
+
176
+ const session = packet.p[0];
177
+
178
+ if (session && this.#sessions[session]) {
179
+ this.#sessions[session].onData(parsed);
180
+ return;
181
+ }
182
+ }
183
+
184
+ if (!this.#logged) {
185
+ this.#handleEvent('logged', packet);
186
+ return;
187
+ }
188
+
189
+ this.#handleEvent('data', packet);
190
+ });
191
+ }
192
+
193
+ #sendQueue = [];
194
+
195
+ /** @type {SendPacket} Send a custom packet */
196
+ send(t, p = []) {
197
+ this.#sendQueue.push(protocol.formatWSPacket({ m: t, p }));
198
+ this.sendQueue();
199
+ }
200
+
201
+ /** Send all waiting packets */
202
+ sendQueue() {
203
+ while (this.isOpen && this.#logged && this.#sendQueue.length > 0) {
204
+ const packet = this.#sendQueue.shift();
205
+ this.#ws.send(packet);
206
+ }
207
+ }
208
+
209
+ /**
210
+ * @typedef {Object} ClientOptions
211
+ * @prop {string} [token] User auth token (in 'sessionid' cookie)
212
+ * @prop {boolean} [DEBUG] Enable debug mode
213
+ */
214
+
215
+ /** Client object
216
+ * @param {ClientOptions} clientOptions TradingView client options
217
+ */
218
+ constructor(clientOptions = {}) {
219
+ if (clientOptions.DEBUG) global.TW_DEBUG = clientOptions.DEBUG;
220
+
221
+ this.#ws = new WebSocket('wss://widgetdata.tradingview.com/socket.io/websocket', {
222
+ origin: 'https://s.tradingview.com',
223
+ });
224
+
225
+ if (clientOptions.token) {
226
+ misc.getUser(clientOptions.token).then((user) => {
227
+ this.#sendQueue.unshift(protocol.formatWSPacket({
228
+ m: 'set_auth_token',
229
+ p: [user.authToken],
230
+ }));
231
+ this.#logged = true;
232
+ this.sendQueue();
233
+ }).catch((err) => {
234
+ this.#handleError('Credentials error:', err.message);
235
+ });
236
+ } else {
237
+ this.#sendQueue.unshift(protocol.formatWSPacket({
238
+ m: 'set_auth_token',
239
+ p: ['unauthorized_user_token'],
240
+ }));
241
+ this.#logged = true;
242
+ this.sendQueue();
243
+ }
244
+
245
+ this.#ws.on('open', () => {
246
+ this.#handleEvent('connected');
247
+ this.sendQueue();
248
+ });
249
+
250
+ this.#ws.on('close', () => {
251
+ this.#logged = false;
252
+ this.#handleEvent('disconnected');
253
+ });
254
+
255
+ this.#ws.on('message', (data) => this.#parsePacket(data));
256
+ }
257
+
258
+ /** @type {ClientBridge} */
259
+ #clientBridge = {
260
+ sessions: this.#sessions,
261
+ send: (t, p) => this.send(t, p),
262
+ };
263
+
264
+ /** @namespace Session */
265
+ Session = {
266
+ Quote: quoteSessionGenerator(this.#clientBridge),
267
+ Chart: chartSessionGenerator(this.#clientBridge),
268
+ };
269
+
270
+ /**
271
+ * Close the websocket connection
272
+ * @return {Promise<void>} When websocket is closed
273
+ */
274
+ end() {
275
+ return new Promise((cb) => {
276
+ this.#ws.close();
277
+ cb();
278
+ });
279
+ }
280
+ };
@@ -1,7 +1,9 @@
1
1
  const https = require('https');
2
2
 
3
+ const PineIndicator = require('./classes/PineIndicator');
4
+
3
5
  const indicators = ['Recommend.Other', 'Recommend.All', 'Recommend.MA'];
4
- const indicList = [];
6
+ const builtInIndicList = [];
5
7
 
6
8
  /**
7
9
  * @param {https.RequestOptions} options HTTPS Request options
@@ -132,7 +134,7 @@ module.exports = {
132
134
  },
133
135
 
134
136
  /**
135
- * @typedef {Object} SearchResult
137
+ * @typedef {Object} SearchMarketResult
136
138
  * @property {string} id
137
139
  * @property {string} exchange
138
140
  * @property {string} fullExchange
@@ -147,9 +149,9 @@ module.exports = {
147
149
  * Find a symbol
148
150
  * @param {string} search Keywords
149
151
  * @param {'stock' | 'futures' | 'forex' | 'cfd' | 'crypto' | 'index' | 'economic'} [filter]
150
- * @returns {Promise<SearchResult[]>} Search results
152
+ * @returns {Promise<SearchMarketResult[]>} Search results
151
153
  */
152
- async search(search, filter = '') {
154
+ async searchMarket(search, filter = '') {
153
155
  const data = await request({
154
156
  host: 'symbol-search.tradingview.com',
155
157
  path: `/symbol_search/?text=${search.replace(/ /g, '%20')}&type=${filter}`,
@@ -179,7 +181,7 @@ module.exports = {
179
181
  },
180
182
 
181
183
  /**
182
- * @typedef {Object} IndicatorResult
184
+ * @typedef {Object} SearchIndicatorResult
183
185
  * @property {string} id Script ID
184
186
  * @property {string} version Script version
185
187
  * @property {string} name Script complete name
@@ -187,18 +189,20 @@ module.exports = {
187
189
  * @property {string} image Image ID https://tradingview.com/i/${image}
188
190
  * @property {string | ''} source Script source (if available)
189
191
  * @property {'study' | 'strategy'} type Script type (study / strategy)
190
- * @property {'open_source' | 'closed_source' | 'invite_only' | 'other'} access Script access type
192
+ * @property {'open_source' | 'closed_source' | 'invite_only'
193
+ * | 'private' | 'other'} access Script access type
194
+ * @property {() => Promise<PineIndicator>} get Get the full indicator informations
191
195
  */
192
196
 
193
197
  /**
194
198
  * Find an indicator
195
199
  * @param {string} search Keywords
196
- * @returns {Promise<IndicatorResult[]>} Search results
200
+ * @returns {Promise<SearchIndicatorResult[]>} Search results
197
201
  */
198
202
  async searchIndicator(search = '') {
199
- if (!indicList.length) {
203
+ if (!builtInIndicList.length) {
200
204
  await Promise.all(['standard', 'candlestick', 'fundamental'].map(async (type) => {
201
- indicList.push(...await request({
205
+ builtInIndicList.push(...await request({
202
206
  host: 'pine-facade.tradingview.com',
203
207
  path: `/pine-facade/list/?filter=${type}`,
204
208
  }));
@@ -215,7 +219,7 @@ module.exports = {
215
219
  }
216
220
 
217
221
  return [
218
- ...indicList.filter((i) => (
222
+ ...builtInIndicList.filter((i) => (
219
223
  norm(i.scriptName).includes(norm(search))
220
224
  || norm(i.extra.shortDescription).includes(norm(search))
221
225
  )).map((ind) => ({
@@ -230,6 +234,9 @@ module.exports = {
230
234
  access: 'closed_source',
231
235
  source: '',
232
236
  type: (ind.extra && ind.extra.kind) ? ind.extra.kind : 'study',
237
+ get() {
238
+ return module.exports.getIndicator(ind.scriptIdPart, ind.version);
239
+ },
233
240
  })),
234
241
 
235
242
  ...data.results.map((ind) => ({
@@ -244,42 +251,20 @@ module.exports = {
244
251
  access: ['open_source', 'closed_source', 'invite_only'][ind.access - 1] || 'other',
245
252
  source: ind.scriptSource,
246
253
  type: (ind.extra && ind.extra.kind) ? ind.extra.kind : 'study',
254
+ get() {
255
+ return module.exports.getIndicator(ind.scriptIdPart, ind.version);
256
+ },
247
257
  })),
248
258
  ];
249
259
  },
250
260
 
251
- /**
252
- * @typedef {Object} indicatorInput
253
- * @property {string} name
254
- * @property {string} inline Inline name
255
- * @property { 'text' | 'source' | 'integer' | 'float' | 'resolution' | 'bool' } type
256
- * @property {string | number | boolean} value
257
- * @property {string | number | boolean} defVal
258
- * @property {boolean} hidden
259
- * @property {boolean} isFake
260
- * @property {string[]} [options]
261
- */
262
-
263
- /**
264
- * @typedef {Object} Indicator
265
- * @property {string} pineId Indicator ID
266
- * @property {string} pineVersion Indicator version
267
- * @property {string} description Indicator description
268
- * @property {string} shortDescription Indicator short description
269
- * @property {string} typeID Indicator script type ID
270
- * @property {Object<string, indicatorInput>} inputs Indicator inputs
271
- * @property {Object<string, string>} plots Indicator plots
272
- * @property {string} script Indicator script
273
- */
274
-
275
261
  /**
276
262
  * Get an indicator
277
263
  * @param {string} id Indicator ID (Like: PUB;XXXXXXXXXXXXXXXXXXXXX)
278
- * @param {string} version Wanted version of the indicator
279
- * @param {'study' | 'strategy'} [type] Script type
280
- * @returns {Promise<Indicator>} Indicator
264
+ * @param {'last' | string} [version] Wanted version of the indicator
265
+ * @returns {Promise<PineIndicator>} Indicator
281
266
  */
282
- async getIndicator(id, version, settings = [], type = 'study') {
267
+ async getIndicator(id, version = 'last') {
283
268
  const indicID = id.replace(/ |%/g, '%25');
284
269
 
285
270
  let data = await request({
@@ -294,26 +279,24 @@ module.exports = {
294
279
  }
295
280
 
296
281
  if (!data.success || !data.result.metaInfo || !data.result.metaInfo.inputs) {
297
- console.error(data);
298
- throw new Error('Inexistent or unsupported indicator');
282
+ throw new Error(`Inexistent or unsupported indicator: "${data.reason}"`);
299
283
  }
300
284
 
301
285
  const inputs = {};
302
286
 
303
287
  data.result.metaInfo.inputs.forEach((input) => {
304
288
  if (['text', 'pineId', 'pineVersion'].includes(input.id)) return;
305
- const inline = input.inline || input.name.replace(/ /g, '_').replace(/[^a-zA-Z0-9_]/g, '');
306
-
307
- const i = parseInt(input.id.replace(/[^0-9]/g, ''), 10);
308
289
 
309
290
  inputs[input.id] = {
310
291
  name: input.name,
292
+ inline: input.inline || input.name.replace(/ /g, '_').replace(/[^a-zA-Z0-9_]/g, ''),
293
+ internalID: input.internalID,
294
+ tooltip: input.tooltip,
295
+
311
296
  type: input.type,
312
- value: settings[input.internalID] ?? settings[i] ?? input.defval,
313
- defVal: input.defval,
314
- hidden: !!input.isHidden,
297
+ value: input.defval,
298
+ isHidden: !!input.isHidden,
315
299
  isFake: !!input.isFake,
316
- inline,
317
300
  };
318
301
 
319
302
  if (input.options) inputs[input.id].options = input.options;
@@ -322,22 +305,37 @@ module.exports = {
322
305
  const plots = {};
323
306
 
324
307
  Object.keys(data.result.metaInfo.styles).forEach((plotId) => {
325
- plots[plotId] = data.result.metaInfo.styles[plotId].title.replace(/ /g, '_').replace(/[^a-zA-Z0-9_]/g, '');
308
+ const plotTitle = data
309
+ .result
310
+ .metaInfo
311
+ .styles[plotId]
312
+ .title
313
+ .replace(/ /g, '_')
314
+ .replace(/[^a-zA-Z0-9_]/g, '');
315
+
316
+ const titles = Object.values(plots);
317
+
318
+ if (titles.includes(plotTitle)) {
319
+ let i = 2;
320
+ while (titles.includes(`${plotTitle}_${i}`)) i += 1;
321
+ plots[plotId] = `${plotTitle}_${i}`;
322
+ } else plots[plotId] = plotTitle;
323
+ });
324
+
325
+ data.result.metaInfo.plots.forEach((plot) => {
326
+ if (!plot.target) return;
327
+ plots[plot.id] = `${plots[plot.target] ?? plot.target}_${plot.type}`;
326
328
  });
327
329
 
328
- return {
329
- pineId: indicID,
330
- pineVersion: version,
330
+ return new PineIndicator({
331
+ pineId: data.result.metaInfo.scriptIdPart || indicID,
332
+ pineVersion: data.result.metaInfo.pine.version || version,
331
333
  description: data.result.metaInfo.description,
332
334
  shortDescription: data.result.metaInfo.shortDescription,
333
- typeID: {
334
- study: 'Script@tv-scripting-101!',
335
- strategy: 'StrategyScript@tv-scripting-101!',
336
- }[type] || type,
337
335
  inputs,
338
336
  plots,
339
337
  script: data.result.ilTemplate,
340
- };
338
+ });
341
339
  },
342
340
 
343
341
  /**
@@ -404,6 +402,49 @@ module.exports = {
404
402
  });
405
403
  },
406
404
 
405
+ /**
406
+ * Get user's private indicators from a 'sessionid' cookie
407
+ * @param {string} session User 'sessionid' cookie
408
+ * @returns {Promise<SearchIndicatorResult[]>} Search results
409
+ */
410
+ async getPrivateIndicators(session) {
411
+ return new Promise((cb, err) => {
412
+ https.get('https://pine-facade.tradingview.com/pine-facade/list?filter=saved', {
413
+ headers: { cookie: `sessionid=${session}` },
414
+ }, (res) => {
415
+ let rs = '';
416
+ res.on('data', (d) => { rs += d; });
417
+ res.on('end', async () => {
418
+ try {
419
+ rs = JSON.parse(rs);
420
+ } catch (error) {
421
+ err(new Error('Can\'t parse private indicator list'));
422
+ return;
423
+ }
424
+
425
+ cb(rs.map((ind) => ({
426
+ id: ind.scriptIdPart,
427
+ version: ind.version,
428
+ name: ind.scriptName,
429
+ author: {
430
+ id: -1,
431
+ username: '@ME@',
432
+ },
433
+ image: ind.imageUrl,
434
+ access: 'private',
435
+ source: ind.scriptSource,
436
+ type: (ind.extra && ind.extra.kind) ? ind.extra.kind : 'study',
437
+ get() {
438
+ return module.exports.getIndicator(ind.scriptIdPart, ind.version);
439
+ },
440
+ })));
441
+ });
442
+
443
+ res.on('error', err);
444
+ }).end();
445
+ });
446
+ },
447
+
407
448
  /**
408
449
  * User credentials
409
450
  * @typedef {Object} UserCredentials
@@ -0,0 +1,54 @@
1
+ const JSZip = require('jszip');
2
+
3
+ /**
4
+ * @typedef {Object} TWPacket
5
+ * @prop {string} [m] Packet type
6
+ * @prop {[session: string, {}]} [p] Packet data
7
+ */
8
+
9
+ module.exports = {
10
+ /**
11
+ * Parse websocket packet
12
+ * @param {string} str Websocket raw data
13
+ * @returns {TWPacket[]} TradingView packets
14
+ */
15
+ parseWSPacket(str) {
16
+ return str.replace(/~h~/g, '').split(/~m~[0-9]{1,}~m~/g)
17
+ .map((p) => {
18
+ if (!p) return false;
19
+ try {
20
+ return JSON.parse(p);
21
+ } catch (error) {
22
+ console.warn('Cant parse', p);
23
+ return false;
24
+ }
25
+ })
26
+ .filter((p) => p);
27
+ },
28
+
29
+ /**
30
+ * Format websocket packet
31
+ * @param {TWPacket} packet TradingView packet
32
+ * @returns {string} Websocket raw data
33
+ */
34
+ formatWSPacket(packet) {
35
+ const msg = typeof packet === 'object'
36
+ ? JSON.stringify(packet)
37
+ : packet;
38
+ return `~m~${msg.length}~m~${msg}`;
39
+ },
40
+
41
+ /**
42
+ * Parse compressed data
43
+ * @param {string} data Compressed data
44
+ * @returns {Promise<{}>} Parsed data
45
+ */
46
+ async parseCompressed(data) {
47
+ const zip = new JSZip();
48
+ return JSON.parse(
49
+ await (
50
+ await zip.loadAsync(data, { base64: true })
51
+ ).file('').async('text'),
52
+ );
53
+ },
54
+ };