tradingview-screener 0.2.0

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.
@@ -0,0 +1,206 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module TradingviewScreener
8
+ class Error < StandardError; end
9
+
10
+ class RequestError < Error
11
+ attr_reader :status, :body
12
+
13
+ def initialize(message, status: nil, body: nil)
14
+ super(message)
15
+ @status = status
16
+ @body = body
17
+ end
18
+ end
19
+
20
+ class Client
21
+ DEFAULT_HEADERS = {
22
+ "accept" => "application/json, text/plain, */*; q=0.01",
23
+ "content-type" => "text/plain;charset=UTF-8",
24
+ "origin" => "https://www.tradingview.com",
25
+ "referer" => "https://www.tradingview.com/",
26
+ "user-agent" => "tradingview-screener-rb/#{VERSION}"
27
+ }.freeze
28
+
29
+ attr_reader :timeout, :cookies, :headers, :label_product, :proxy
30
+
31
+ def initialize(timeout: 20, cookies: nil, headers: nil, label_product: "screener-stock", proxy: nil)
32
+ @timeout = timeout
33
+ @cookies = normalize_cookies(cookies)
34
+ @headers = DEFAULT_HEADERS.merge(stringify_keys(headers || {}))
35
+ @label_product = label_product
36
+ @proxy = normalize_proxy(proxy)
37
+ end
38
+
39
+ def get(url)
40
+ uri = URI(url)
41
+ request = Net::HTTP::Get.new(uri)
42
+ headers.each { |key, value| request[key] = value }
43
+ # HTML pages prefer browser-like accept; keep caller override if provided.
44
+ cookie = cookie_header
45
+ request["cookie"] = cookie unless cookie.nil? || cookie.empty?
46
+
47
+ response = perform(uri, request)
48
+ body = response.body.to_s
49
+ unless response.is_a?(Net::HTTPSuccess)
50
+ raise RequestError.new(
51
+ "request failed: HTTP #{response.code} #{response.message}",
52
+ status: response.code.to_i,
53
+ body: body
54
+ )
55
+ end
56
+ body
57
+ end
58
+
59
+ def post(url, payload)
60
+ uri = build_uri(url)
61
+ request = Net::HTTP::Post.new(uri)
62
+ headers.each { |key, value| request[key] = value }
63
+ cookie = cookie_header
64
+ request["cookie"] = cookie unless cookie.nil? || cookie.empty?
65
+ request.body = JSON.generate(payload)
66
+
67
+ response = perform(uri, request)
68
+ body = response.body.to_s
69
+ unless response.is_a?(Net::HTTPSuccess)
70
+ raise RequestError.new(
71
+ "scanner request failed: HTTP #{response.code} #{response.message}",
72
+ status: response.code.to_i,
73
+ body: body
74
+ )
75
+ end
76
+
77
+ JSON.parse(body)
78
+ rescue JSON::ParserError => e
79
+ raise RequestError.new("invalid scanner JSON: #{e.message}", status: response&.code&.to_i, body: body)
80
+ end
81
+
82
+ def perform(uri, request)
83
+ if socks_proxy?
84
+ request_via_socks(uri, request)
85
+ else
86
+ build_http(uri).request(request)
87
+ end
88
+ end
89
+
90
+ private
91
+
92
+ def build_uri(url)
93
+ uri = URI(url)
94
+ if label_product && !label_product.empty? && !uri.query.to_s.include?("label-product=")
95
+ params = URI.decode_www_form(uri.query.to_s) + [["label-product", label_product]]
96
+ uri.query = URI.encode_www_form(params)
97
+ end
98
+ uri
99
+ end
100
+
101
+ def build_http(uri)
102
+ http =
103
+ if http_proxy?
104
+ Net::HTTP::Proxy(
105
+ proxy["host"],
106
+ proxy["port"],
107
+ proxy["username"],
108
+ proxy["password"]
109
+ ).new(uri.host, uri.port)
110
+ else
111
+ Net::HTTP.new(uri.host, uri.port)
112
+ end
113
+ http.use_ssl = uri.scheme == "https"
114
+ http.open_timeout = timeout
115
+ http.read_timeout = timeout
116
+ http
117
+ end
118
+
119
+ def request_via_socks(uri, request)
120
+ require "socksify/http"
121
+ http = Net::HTTP.SOCKSProxy(proxy["host"], proxy["port"]).new(uri.host, uri.port)
122
+ http.use_ssl = uri.scheme == "https"
123
+ http.open_timeout = timeout
124
+ http.read_timeout = timeout
125
+ http.request(request)
126
+ rescue LoadError
127
+ raise Error, "socks proxy requires the socksify gem; add gem 'socksify' and bundle install"
128
+ end
129
+
130
+ def cookie_header
131
+ return if cookies.nil? || cookies.empty?
132
+
133
+ cookies.map { |key, value| "#{key}=#{value}" }.join("; ")
134
+ end
135
+
136
+ def normalize_cookies(value)
137
+ case value
138
+ when nil then {}
139
+ when Hash then stringify_keys(value)
140
+ when String
141
+ value.split(";").each_with_object({}) do |part, memo|
142
+ key, val = part.strip.split("=", 2)
143
+ next if key.nil? || key.empty? || val.nil?
144
+
145
+ memo[key] = val
146
+ end
147
+ else
148
+ raise ArgumentError, "cookies must be Hash or Cookie header String"
149
+ end
150
+ end
151
+
152
+ def normalize_proxy(value)
153
+ case value
154
+ when nil, ""
155
+ nil
156
+ when Hash
157
+ hash = stringify_keys(value)
158
+ scheme = (hash["scheme"] || hash["type"] || hash["value"] || "http").to_s.downcase
159
+ host = first_present(hash["host"], hash["hostname"], hash.dig("extra", "host"))
160
+ port = first_present(hash["port"], hash.dig("extra", "port"))
161
+ username = first_present(hash["username"], hash["user"], hash.dig("extra", "username"), hash.dig("extra", "id"))
162
+ password = first_present(hash["password"], hash["pass"], hash.dig("extra", "password"), hash.dig("extra", "secret"))
163
+ return nil if blank?(host) || blank?(port)
164
+
165
+ {
166
+ "scheme" => scheme,
167
+ "host" => host.to_s,
168
+ "port" => port.to_i,
169
+ "username" => username,
170
+ "password" => password
171
+ }.compact
172
+ when String
173
+ uri = URI.parse(value)
174
+ {
175
+ "scheme" => (blank?(uri.scheme) ? "http" : uri.scheme).to_s.downcase,
176
+ "host" => uri.host,
177
+ "port" => uri.port,
178
+ "username" => uri.user,
179
+ "password" => uri.password
180
+ }.compact
181
+ else
182
+ raise ArgumentError, "proxy must be Hash, URL String, or nil"
183
+ end
184
+ end
185
+
186
+ def http_proxy?
187
+ proxy && %w[http https].include?(proxy["scheme"].to_s)
188
+ end
189
+
190
+ def socks_proxy?
191
+ proxy && proxy["scheme"].to_s.start_with?("socks")
192
+ end
193
+
194
+ def stringify_keys(hash)
195
+ hash.each_with_object({}) { |(k, v), memo| memo[k.to_s] = v }
196
+ end
197
+
198
+ def blank?(value)
199
+ value.nil? || (value.respond_to?(:empty?) && value.empty?) || (value.is_a?(String) && value.strip.empty?)
200
+ end
201
+
202
+ def first_present(*values)
203
+ values.find { |value| !blank?(value) }
204
+ end
205
+ end
206
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TradingviewScreener
4
+ # Arel-like column node used inside where().
5
+ #
6
+ # Screener[:close].gt(Screener[:SMA20])
7
+ # Screener[:volume].gte(1_000_000)
8
+ # Screener[:close].between(10, 300)
9
+ class Predicate
10
+ attr_reader :left, :operation, :right
11
+
12
+ def initialize(left, operation, right = nil)
13
+ @left = left.to_s
14
+ @operation = operation.to_s
15
+ @right = unwrap(right)
16
+ end
17
+
18
+ def to_h
19
+ { "left" => left, "operation" => operation, "right" => right }
20
+ end
21
+
22
+ def inspect
23
+ "#<#{self.class} #{left} #{operation} #{right.inspect}>"
24
+ end
25
+
26
+ private
27
+
28
+ def unwrap(value)
29
+ case value
30
+ when Predicate then value.left
31
+ when Field then value.name
32
+ when Symbol then value.to_s
33
+ else value
34
+ end
35
+ end
36
+ end
37
+
38
+ class Field
39
+ attr_reader :name
40
+
41
+ def initialize(name)
42
+ @name = name.to_s
43
+ end
44
+
45
+ def gt(other) = Predicate.new(name, "greater", other)
46
+ def gte(other) = Predicate.new(name, "egreater", other)
47
+ def lt(other) = Predicate.new(name, "less", other)
48
+ def lte(other) = Predicate.new(name, "eless", other)
49
+ def eq(other) = Predicate.new(name, "equal", other)
50
+ def not_eq(other) = Predicate.new(name, "nequal", other)
51
+ def between(min, max) = Predicate.new(name, "in_range", [unwrap(min), unwrap(max)])
52
+ def not_between(min, max) = Predicate.new(name, "not_in_range", [unwrap(min), unwrap(max)])
53
+ def in(values) = Predicate.new(name, "in_range", Array(values))
54
+ def not_in(values) = Predicate.new(name, "not_in_range", Array(values))
55
+ def has(values) = Predicate.new(name, "has", values)
56
+ def has_none_of(values) = Predicate.new(name, "has_none_of", values)
57
+ def matches(value) = Predicate.new(name, "match", value)
58
+ def does_not_match(value) = Predicate.new(name, "nmatch", value)
59
+ def blank = Predicate.new(name, "empty", nil)
60
+ def present = Predicate.new(name, "nempty", nil)
61
+ def crosses(other) = Predicate.new(name, "crosses", other)
62
+ def crosses_above(other) = Predicate.new(name, "crosses_above", other)
63
+ def crosses_below(other) = Predicate.new(name, "crosses_below", other)
64
+
65
+ def >(other) = gt(other)
66
+ def >=(other) = gte(other)
67
+ def <(other) = lt(other)
68
+ def <=(other) = lte(other)
69
+ def ==(other) = eq(other)
70
+ def !=(other) = not_eq(other)
71
+
72
+ def inspect
73
+ "#<#{self.class} #{name}>"
74
+ end
75
+
76
+ private
77
+
78
+ def unwrap(value)
79
+ value.is_a?(Field) ? value.name : value
80
+ end
81
+ end
82
+
83
+ # Operator wrappers for hash-style where:
84
+ # where(close: gt(:SMA20), volume: gte(1_000_000), price: 10..300)
85
+ module Predicates
86
+ module_function
87
+
88
+ def gt(value) = Op.new("greater", value)
89
+ def gte(value) = Op.new("egreater", value)
90
+ def lt(value) = Op.new("less", value)
91
+ def lte(value) = Op.new("eless", value)
92
+ def not_eq(value) = Op.new("nequal", value)
93
+ def has(value) = Op.new("has", value)
94
+ def has_none_of(value) = Op.new("has_none_of", value)
95
+
96
+ class Op
97
+ attr_reader :operation, :value
98
+
99
+ def initialize(operation, value)
100
+ @operation = operation
101
+ @value = value.is_a?(Field) || value.is_a?(Symbol) ? value.to_s.sub(/\A:/, "") : value
102
+ @value = value.name if value.is_a?(Field)
103
+ @value = value.to_s if value.is_a?(Symbol)
104
+ end
105
+ end
106
+ end
107
+ end