finlight-client 0.1.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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +168 -0
- data/lib/finlight/api_client.rb +82 -0
- data/lib/finlight/article_service.rb +50 -0
- data/lib/finlight/client/version.rb +10 -0
- data/lib/finlight/client.rb +55 -0
- data/lib/finlight/config.rb +29 -0
- data/lib/finlight/errors.rb +28 -0
- data/lib/finlight/flex_time.rb +26 -0
- data/lib/finlight/logging.rb +15 -0
- data/lib/finlight/models.rb +189 -0
- data/lib/finlight/params.rb +25 -0
- data/lib/finlight/source_service.rb +16 -0
- data/lib/finlight/webhook_service.rb +83 -0
- data/lib/finlight/websocket/article_client.rb +21 -0
- data/lib/finlight/websocket/base_client.rb +409 -0
- data/lib/finlight/websocket/raw_article_client.rb +17 -0
- data/lib/finlight-client.rb +3 -0
- metadata +93 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 708938713e1f97553c96baf010eb5d314a8cc04a9450d9555f20e73e79f66f47
|
|
4
|
+
data.tar.gz: e9528ef5a54995767df07e1d6a863a3a30fb5f26713520a716d6b0e0617249a5
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 2d34def24369e14979d80c40bd6f29a841cfa42df0cf0e07be6949884030205787a9179149f7df098989b4c8e27bfc31d84c9e146299bda8fda315ce0e5a443e
|
|
7
|
+
data.tar.gz: 28ea56c433b06ee1b9ee1c3e592349e570c9d3bf8f03d6387cb7f45444f2752453178b3c9537a3bbe61f16eb635464de4882fcebfb31c74cdc0aac18a73140ce
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 finlight.me
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# finlight-client (Ruby)
|
|
2
|
+
|
|
3
|
+
The official Ruby client for the [finlight.me](https://finlight.me) API — financial news with sentiment analysis, entity recognition, and real-time streaming.
|
|
4
|
+
|
|
5
|
+
📚 **Full API documentation: [docs.finlight.me](https://docs.finlight.me)**
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- **REST API**: search articles, fetch single articles by link, list sources
|
|
10
|
+
- **Real-time streaming**: enhanced and raw article streams over WebSocket
|
|
11
|
+
- **Resilient by default**: request retries with exponential backoff; WebSocket auto-reconnect, keepalive with pong watchdog, proactive connection rotation, and rate-limit handling
|
|
12
|
+
- **Webhook support**: HMAC-SHA256 signature verification with replay protection
|
|
13
|
+
- **Minimal dependencies**: stdlib `Net::HTTP` and `OpenSSL`, plus the pure-Ruby `websocket-driver`
|
|
14
|
+
|
|
15
|
+
Requires Ruby 3.2+.
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
# Gemfile
|
|
21
|
+
gem "finlight-client"
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Or directly:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
gem install finlight-client
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Quick Start
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
require "finlight/client"
|
|
34
|
+
|
|
35
|
+
client = Finlight::Client.new(api_key: ENV["FINLIGHT_API_KEY"])
|
|
36
|
+
|
|
37
|
+
response = client.articles.fetch_articles(query: "nvidia", page_size: 10)
|
|
38
|
+
response.articles.each { |article| puts "[#{article.source}] #{article.title}" }
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## REST API
|
|
42
|
+
|
|
43
|
+
### Search articles
|
|
44
|
+
|
|
45
|
+
See the [query language reference](https://docs.finlight.me) for the full `query` syntax. Parameters are snake_case keywords; they map 1:1 to the camelCase API fields.
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
response = client.articles.fetch_articles(
|
|
49
|
+
query: '(ticker:AAPL OR ticker:NVDA) AND "Elon Musk"',
|
|
50
|
+
tickers: %w[AAPL NVDA],
|
|
51
|
+
from: "2025-01-01",
|
|
52
|
+
include_content: true,
|
|
53
|
+
include_entities: true,
|
|
54
|
+
order_by: "publishDate",
|
|
55
|
+
order: "DESC",
|
|
56
|
+
page_size: 20
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
response.articles.each do |article|
|
|
60
|
+
puts "#{article.publish_date} #{article.title} (#{article.sentiment})"
|
|
61
|
+
end
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Fetch a single article by link
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
article = client.articles.fetch_article_by_link(
|
|
68
|
+
link: "https://www.reuters.com/technology/example",
|
|
69
|
+
include_content: true
|
|
70
|
+
)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### List sources
|
|
74
|
+
|
|
75
|
+
```ruby
|
|
76
|
+
sources = client.sources.get_sources
|
|
77
|
+
defaults = sources.select(&:is_default_source)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Real-time streaming
|
|
81
|
+
|
|
82
|
+
`connect` blocks and reconnects automatically until you call `stop`; use `connect_async` to run it on a background thread. The enhanced stream delivers articles with sentiment and entities; the raw stream (`client.raw_websocket`) delivers unenriched articles with minimal latency.
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
85
|
+
websocket = client.websocket
|
|
86
|
+
websocket.connect(tickers: %w[AAPL NVDA], extended: true) do |article|
|
|
87
|
+
puts article.title
|
|
88
|
+
end
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Custom stream options:
|
|
92
|
+
|
|
93
|
+
```ruby
|
|
94
|
+
websocket = client.websocket(
|
|
95
|
+
takeover: true, # take over an existing connection for the same key
|
|
96
|
+
on_close: ->(code, reason) { puts "closed: #{code} #{reason}" }
|
|
97
|
+
)
|
|
98
|
+
thread = websocket.connect_async(query: "bitcoin") { |article| puts article.title }
|
|
99
|
+
# ...
|
|
100
|
+
websocket.stop
|
|
101
|
+
thread.join
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
If the server permanently rejects the connection (close code 1008), `connect` raises `Finlight::BlockedError` — reconnecting will not help; contact support.
|
|
105
|
+
|
|
106
|
+
## Webhooks
|
|
107
|
+
|
|
108
|
+
Verify incoming webhooks with the raw (unparsed) request body:
|
|
109
|
+
|
|
110
|
+
```ruby
|
|
111
|
+
# e.g. in a Rails controller
|
|
112
|
+
def webhook
|
|
113
|
+
article = Finlight::WebhookService.construct_event(
|
|
114
|
+
request.raw_post,
|
|
115
|
+
request.headers["X-Webhook-Signature"],
|
|
116
|
+
ENV["WEBHOOK_SECRET"],
|
|
117
|
+
request.headers["X-Webhook-Timestamp"]
|
|
118
|
+
)
|
|
119
|
+
Rails.logger.info("New article: #{article.title}")
|
|
120
|
+
head :ok
|
|
121
|
+
rescue Finlight::WebhookVerificationError
|
|
122
|
+
head :bad_request
|
|
123
|
+
end
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Configuration
|
|
127
|
+
|
|
128
|
+
| Option | Default | Description |
|
|
129
|
+
| -------------- | ------------------------- | ----------------------------------------------- |
|
|
130
|
+
| `base_url:` | `https://api.finlight.me` | REST endpoint |
|
|
131
|
+
| `wss_url:` | `wss://wss.finlight.me` | WebSocket endpoint |
|
|
132
|
+
| `timeout:` | 5 | Per-request and connect timeout in seconds |
|
|
133
|
+
| `retry_count:` | 3 | Total attempts for retryable failures (429/5xx) |
|
|
134
|
+
| `logger:` | stderr, WARN | Any stdlib-compatible `Logger` |
|
|
135
|
+
|
|
136
|
+
```ruby
|
|
137
|
+
client = Finlight::Client.new(
|
|
138
|
+
api_key: api_key,
|
|
139
|
+
timeout: 10,
|
|
140
|
+
retry_count: 5,
|
|
141
|
+
logger: Logger.new($stdout, level: Logger::INFO)
|
|
142
|
+
)
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Development
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
bundle install
|
|
149
|
+
bundle exec rake # run the test suite
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### Verification against the real API
|
|
153
|
+
|
|
154
|
+
The `local/` directory is gitignored and holds smoke runners, mirroring the sibling clients — keep credentials in the environment, never in code.
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
# One-shot smoke: REST endpoints + 30s article stream
|
|
158
|
+
FINLIGHT_API_KEY=sk_... bundle exec ruby local/smoke.rb
|
|
159
|
+
|
|
160
|
+
# REST integration tests (auto-skipped when the key is not set)
|
|
161
|
+
FINLIGHT_API_KEY=sk_... bundle exec rspec spec/integration
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Optional: `FINLIGHT_BASE_URL` / `FINLIGHT_WSS_URL` to target dev.
|
|
165
|
+
|
|
166
|
+
## License
|
|
167
|
+
|
|
168
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Finlight
|
|
8
|
+
# Performs authenticated REST requests with retry and exponential backoff
|
|
9
|
+
# (500ms * 2^(attempt-1)), mirroring the sibling clients: retries on 429 and
|
|
10
|
+
# transient 5xx.
|
|
11
|
+
class ApiClient
|
|
12
|
+
RETRYABLE_STATUSES = [429, 500, 502, 503, 504].freeze
|
|
13
|
+
BASE_RETRY_DELAY = 0.5
|
|
14
|
+
|
|
15
|
+
def initialize(config, logger: Logging.default)
|
|
16
|
+
@config = config
|
|
17
|
+
@logger = logger
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# @return [Object] the decoded JSON response
|
|
21
|
+
def get(path, query = {})
|
|
22
|
+
uri = URI.parse(@config.base_url + path)
|
|
23
|
+
uri.query = URI.encode_www_form(query) unless query.empty?
|
|
24
|
+
perform(uri, Net::HTTP::Get.new(uri))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @return [Object] the decoded JSON response
|
|
28
|
+
def post(path, body)
|
|
29
|
+
uri = URI.parse(@config.base_url + path)
|
|
30
|
+
request = Net::HTTP::Post.new(uri)
|
|
31
|
+
request["Content-Type"] = "application/json"
|
|
32
|
+
request.body = JSON.generate(body)
|
|
33
|
+
perform(uri, request)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def perform(uri, request)
|
|
39
|
+
request["X-API-KEY"] = @config.api_key
|
|
40
|
+
request["User-Agent"] = Client::CLIENT_VERSION
|
|
41
|
+
|
|
42
|
+
attempt = 0
|
|
43
|
+
loop do
|
|
44
|
+
attempt += 1
|
|
45
|
+
response = execute(uri, request)
|
|
46
|
+
status = response.code.to_i
|
|
47
|
+
return decode(response.body.to_s) if (200..299).cover?(status)
|
|
48
|
+
|
|
49
|
+
if RETRYABLE_STATUSES.include?(status) && attempt < @config.retry_count
|
|
50
|
+
delay = BASE_RETRY_DELAY * (2**(attempt - 1))
|
|
51
|
+
@logger.warn do
|
|
52
|
+
"finlight: retrying request (status=#{status}, " \
|
|
53
|
+
"attempt=#{attempt}/#{@config.retry_count}, delay=#{(delay * 1000).round}ms)"
|
|
54
|
+
end
|
|
55
|
+
sleep(delay)
|
|
56
|
+
next
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
raise ApiError.new(status, response.body.to_s)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def execute(uri, request)
|
|
64
|
+
Net::HTTP.start(
|
|
65
|
+
uri.hostname, uri.port,
|
|
66
|
+
use_ssl: uri.scheme == "https",
|
|
67
|
+
open_timeout: @config.timeout,
|
|
68
|
+
read_timeout: @config.timeout,
|
|
69
|
+
write_timeout: @config.timeout
|
|
70
|
+
) { |http| http.request(request) }
|
|
71
|
+
rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout,
|
|
72
|
+
OpenSSL::SSL::SSLError, SystemCallError, IOError => e
|
|
73
|
+
raise Error, "finlight: request failed: #{e.message}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def decode(body)
|
|
77
|
+
JSON.parse(body)
|
|
78
|
+
rescue JSON::ParserError => e
|
|
79
|
+
raise Error, "finlight: cannot decode response: #{e.message}"
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Finlight
|
|
4
|
+
# Fetches financial news articles.
|
|
5
|
+
class ArticleService
|
|
6
|
+
def initialize(api_client)
|
|
7
|
+
@api_client = api_client
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# Searches articles with advanced filtering by query, tickers, sources,
|
|
11
|
+
# dates, countries and categories.
|
|
12
|
+
#
|
|
13
|
+
# @example
|
|
14
|
+
# response = client.articles.fetch_articles(
|
|
15
|
+
# query: "Nvidia", tickers: ["NVDA"], include_content: true, page_size: 20
|
|
16
|
+
# )
|
|
17
|
+
# response.articles.each { |a| puts a.title }
|
|
18
|
+
#
|
|
19
|
+
# @param params [Hash] snake_case keywords, e.g. +query:+, +sources:+,
|
|
20
|
+
# +exclude_sources:+, +from:+, +to:+, +language:+, +tickers:+,
|
|
21
|
+
# +include_entities:+, +exclude_empty_content:+, +include_content:+,
|
|
22
|
+
# +order_by:+ ("publishDate"|"createdAt"|"revisedDate"),
|
|
23
|
+
# +order:+ ("ASC"|"DESC"), +page_size:+ (1-1000), +page:+, +countries:+,
|
|
24
|
+
# +categories:+
|
|
25
|
+
# @return [ArticleResponse]
|
|
26
|
+
# @raise [ApiError] if the API request fails
|
|
27
|
+
def fetch_articles(**params)
|
|
28
|
+
response = @api_client.post("/v2/articles", Params.normalize(params))
|
|
29
|
+
ArticleResponse.from_h(response)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Fetches a single article by its URL.
|
|
33
|
+
#
|
|
34
|
+
# @example
|
|
35
|
+
# article = client.articles.fetch_article_by_link(
|
|
36
|
+
# link: "https://www.reuters.com/technology/example", include_content: true
|
|
37
|
+
# )
|
|
38
|
+
#
|
|
39
|
+
# @param link [String] the URL of the article to fetch
|
|
40
|
+
# @param params [Hash] optional +include_content:+ and +include_entities:+
|
|
41
|
+
# @return [Article]
|
|
42
|
+
# @raise [ApiError] if the API request fails or the article is not found
|
|
43
|
+
def fetch_article_by_link(link:, **params)
|
|
44
|
+
query = Params.normalize(params.merge(link: link))
|
|
45
|
+
query = query.transform_values { |v| [true, false].include?(v) ? v.to_s : v }
|
|
46
|
+
response = @api_client.get("/v2/articles/by-link", query)
|
|
47
|
+
Article.from_h(response.fetch("article"))
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "client/version"
|
|
4
|
+
require_relative "errors"
|
|
5
|
+
require_relative "logging"
|
|
6
|
+
require_relative "config"
|
|
7
|
+
require_relative "flex_time"
|
|
8
|
+
require_relative "params"
|
|
9
|
+
require_relative "models"
|
|
10
|
+
require_relative "api_client"
|
|
11
|
+
require_relative "article_service"
|
|
12
|
+
require_relative "source_service"
|
|
13
|
+
require_relative "webhook_service"
|
|
14
|
+
require_relative "websocket/base_client"
|
|
15
|
+
require_relative "websocket/article_client"
|
|
16
|
+
require_relative "websocket/raw_article_client"
|
|
17
|
+
|
|
18
|
+
module Finlight
|
|
19
|
+
# Entry point to the finlight API.
|
|
20
|
+
#
|
|
21
|
+
# @example
|
|
22
|
+
# client = Finlight::Client.new(api_key: ENV["FINLIGHT_API_KEY"])
|
|
23
|
+
# response = client.articles.fetch_articles(query: "Nvidia", page_size: 20)
|
|
24
|
+
# response.articles.each { |article| puts article.title }
|
|
25
|
+
class Client
|
|
26
|
+
attr_reader :config, :articles, :sources
|
|
27
|
+
|
|
28
|
+
# Remaining keywords (+base_url:+, +wss_url:+, +timeout:+ in seconds,
|
|
29
|
+
# +retry_count:+) are forwarded to {Config}.
|
|
30
|
+
#
|
|
31
|
+
# @param api_key [String] your finlight API key
|
|
32
|
+
def initialize(api_key:, logger: Logging.default, **)
|
|
33
|
+
@config = Config.new(api_key: api_key, **)
|
|
34
|
+
@logger = logger
|
|
35
|
+
api_client = ApiClient.new(@config, logger: logger)
|
|
36
|
+
@articles = ArticleService.new(api_client)
|
|
37
|
+
@sources = SourceService.new(api_client)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# A streaming client for enriched articles — see
|
|
41
|
+
# {BaseWebSocketClient#connect} for options and usage.
|
|
42
|
+
#
|
|
43
|
+
# @return [ArticleWebSocketClient]
|
|
44
|
+
def websocket(**)
|
|
45
|
+
ArticleWebSocketClient.new(@config, logger: @logger, **)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# A streaming client for raw articles (minimal latency, no enrichment).
|
|
49
|
+
#
|
|
50
|
+
# @return [RawArticleWebSocketClient]
|
|
51
|
+
def raw_websocket(**)
|
|
52
|
+
RawArticleWebSocketClient.new(@config, logger: @logger, **)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Finlight
|
|
4
|
+
# Connection settings shared by the REST and WebSocket clients.
|
|
5
|
+
class Config
|
|
6
|
+
DEFAULT_BASE_URL = "https://api.finlight.me"
|
|
7
|
+
DEFAULT_WSS_URL = "wss://wss.finlight.me"
|
|
8
|
+
DEFAULT_TIMEOUT = 5
|
|
9
|
+
DEFAULT_RETRY_COUNT = 3
|
|
10
|
+
|
|
11
|
+
attr_reader :api_key, :base_url, :wss_url, :timeout, :retry_count
|
|
12
|
+
|
|
13
|
+
# @param api_key [String] your finlight API key
|
|
14
|
+
# @param base_url [String] REST endpoint
|
|
15
|
+
# @param wss_url [String] WebSocket endpoint
|
|
16
|
+
# @param timeout [Numeric] per-request and connect timeout in seconds
|
|
17
|
+
# @param retry_count [Integer] total attempts for retryable failures (429/5xx)
|
|
18
|
+
def initialize(api_key:, base_url: DEFAULT_BASE_URL, wss_url: DEFAULT_WSS_URL,
|
|
19
|
+
timeout: DEFAULT_TIMEOUT, retry_count: DEFAULT_RETRY_COUNT)
|
|
20
|
+
raise ArgumentError, "api_key is required" if api_key.nil? || api_key.to_s.empty?
|
|
21
|
+
|
|
22
|
+
@api_key = api_key
|
|
23
|
+
@base_url = base_url.chomp("/")
|
|
24
|
+
@wss_url = wss_url.chomp("/")
|
|
25
|
+
@timeout = timeout
|
|
26
|
+
@retry_count = retry_count
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Finlight
|
|
4
|
+
# Base class for all errors raised by this library.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Raised when the API responds with a non-success status code.
|
|
8
|
+
class ApiError < Error
|
|
9
|
+
attr_reader :status_code, :body
|
|
10
|
+
|
|
11
|
+
def initialize(status_code, body)
|
|
12
|
+
@status_code = status_code
|
|
13
|
+
@body = body
|
|
14
|
+
super("finlight: API request failed with status #{status_code}: #{body}")
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Raised when the streaming server permanently rejects the connection
|
|
19
|
+
# (close code 1008). Reconnecting will not help; contact support.
|
|
20
|
+
class BlockedError < Error
|
|
21
|
+
def initialize(msg = "finlight: connection rejected by server (blocked)")
|
|
22
|
+
super
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Raised when webhook signature or timestamp verification fails.
|
|
27
|
+
class WebhookVerificationError < Error; end
|
|
28
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
|
|
5
|
+
module Finlight
|
|
6
|
+
# Parses the timestamp formats used by the finlight API: RFC 3339 with or
|
|
7
|
+
# without zone, space-separated, and date-only. Zone-less timestamps are
|
|
8
|
+
# interpreted as UTC, matching the sibling clients.
|
|
9
|
+
module FlexTime
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# @param value [String, Time, nil]
|
|
13
|
+
# @return [Time, nil] the parsed time in UTC, or nil for nil input
|
|
14
|
+
# @raise [Finlight::Error] if the value matches none of the known formats
|
|
15
|
+
def parse(value)
|
|
16
|
+
return nil if value.nil?
|
|
17
|
+
return value.utc if value.is_a?(Time)
|
|
18
|
+
|
|
19
|
+
s = value.to_s.strip
|
|
20
|
+
iso_like = s.include?(" ") ? s.sub(" ", "T") : s
|
|
21
|
+
DateTime.iso8601(iso_like).to_time.utc
|
|
22
|
+
rescue ArgumentError, TypeError
|
|
23
|
+
raise Error, "finlight: cannot parse timestamp: #{value.inspect}"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "logger"
|
|
4
|
+
|
|
5
|
+
module Finlight
|
|
6
|
+
# Default logger shared by the clients: warnings and errors to stderr.
|
|
7
|
+
# Pass your own +logger:+ to the client or WebSocket clients to override.
|
|
8
|
+
module Logging
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def default
|
|
12
|
+
@default ||= Logger.new($stderr, level: Logger::WARN, progname: "finlight")
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Finlight
|
|
4
|
+
# Shared parsing helpers for wire-format (camelCase) JSON hashes.
|
|
5
|
+
module ModelParsing
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
# The API sometimes delivers numbers as strings (e.g. "confidence":"0.9").
|
|
9
|
+
def to_float(value)
|
|
10
|
+
return nil if value.nil?
|
|
11
|
+
|
|
12
|
+
Float(value)
|
|
13
|
+
rescue ArgumentError, TypeError
|
|
14
|
+
nil
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def to_string_list(value)
|
|
18
|
+
value&.map(&:to_s)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# A ticker listing on an exchange.
|
|
23
|
+
class Listing
|
|
24
|
+
attr_reader :ticker, :exchange_code, :exchange_country
|
|
25
|
+
|
|
26
|
+
def initialize(ticker:, exchange_code:, exchange_country:)
|
|
27
|
+
@ticker = ticker
|
|
28
|
+
@exchange_code = exchange_code
|
|
29
|
+
@exchange_country = exchange_country
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.from_h(hash)
|
|
33
|
+
new(
|
|
34
|
+
ticker: hash["ticker"],
|
|
35
|
+
exchange_code: hash["exchangeCode"],
|
|
36
|
+
exchange_country: hash["exchangeCountry"]
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# A company tagged in an article.
|
|
42
|
+
class Company
|
|
43
|
+
attr_reader :company_id, :confidence, :country, :exchange, :industry, :sector,
|
|
44
|
+
:name, :ticker, :isin, :openfigi, :primary_listing, :isins, :other_listings
|
|
45
|
+
|
|
46
|
+
def initialize(company_id:, name:, ticker:, confidence: nil, country: nil, exchange: nil,
|
|
47
|
+
industry: nil, sector: nil, isin: nil, openfigi: nil, primary_listing: nil,
|
|
48
|
+
isins: nil, other_listings: nil)
|
|
49
|
+
@company_id = company_id
|
|
50
|
+
@confidence = confidence
|
|
51
|
+
@country = country
|
|
52
|
+
@exchange = exchange
|
|
53
|
+
@industry = industry
|
|
54
|
+
@sector = sector
|
|
55
|
+
@name = name
|
|
56
|
+
@ticker = ticker
|
|
57
|
+
@isin = isin
|
|
58
|
+
@openfigi = openfigi
|
|
59
|
+
@primary_listing = primary_listing
|
|
60
|
+
@isins = isins
|
|
61
|
+
@other_listings = other_listings
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.from_h(hash)
|
|
65
|
+
new(
|
|
66
|
+
company_id: hash["companyId"],
|
|
67
|
+
confidence: ModelParsing.to_float(hash["confidence"]),
|
|
68
|
+
country: hash["country"],
|
|
69
|
+
exchange: hash["exchange"],
|
|
70
|
+
industry: hash["industry"],
|
|
71
|
+
sector: hash["sector"],
|
|
72
|
+
name: hash["name"],
|
|
73
|
+
ticker: hash["ticker"],
|
|
74
|
+
isin: hash["isin"],
|
|
75
|
+
openfigi: hash["openfigi"],
|
|
76
|
+
primary_listing: hash["primaryListing"] && Listing.from_h(hash["primaryListing"]),
|
|
77
|
+
isins: ModelParsing.to_string_list(hash["isins"]),
|
|
78
|
+
other_listings: hash["otherListings"]&.map { |l| Listing.from_h(l) }
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# An unenriched article as delivered by the raw stream.
|
|
84
|
+
class RawArticle
|
|
85
|
+
attr_reader :link, :title, :publish_date, :source, :language, :summary, :images,
|
|
86
|
+
:created_at, :revised_date, :is_update, :categories
|
|
87
|
+
|
|
88
|
+
def initialize(link:, title:, publish_date:, source:, language:, summary: nil, images: nil,
|
|
89
|
+
created_at: nil, revised_date: nil, is_update: nil, categories: nil, **)
|
|
90
|
+
@link = link
|
|
91
|
+
@title = title
|
|
92
|
+
@publish_date = publish_date
|
|
93
|
+
@source = source
|
|
94
|
+
@language = language
|
|
95
|
+
@summary = summary
|
|
96
|
+
@images = images
|
|
97
|
+
@created_at = created_at
|
|
98
|
+
@revised_date = revised_date
|
|
99
|
+
@is_update = is_update
|
|
100
|
+
@categories = categories
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def self.base_attributes(hash)
|
|
104
|
+
{
|
|
105
|
+
link: hash["link"],
|
|
106
|
+
title: hash["title"],
|
|
107
|
+
publish_date: FlexTime.parse(hash["publishDate"]),
|
|
108
|
+
source: hash["source"],
|
|
109
|
+
language: hash["language"],
|
|
110
|
+
summary: hash["summary"],
|
|
111
|
+
images: ModelParsing.to_string_list(hash["images"]),
|
|
112
|
+
created_at: FlexTime.parse(hash["createdAt"]),
|
|
113
|
+
revised_date: FlexTime.parse(hash["revisedDate"]),
|
|
114
|
+
is_update: hash["isUpdate"],
|
|
115
|
+
categories: ModelParsing.to_string_list(hash["categories"])
|
|
116
|
+
}
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def self.from_h(hash)
|
|
120
|
+
new(**base_attributes(hash))
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# An enriched article with sentiment, entities and content.
|
|
125
|
+
class Article < RawArticle
|
|
126
|
+
attr_reader :sentiment, :confidence, :content, :companies, :countries
|
|
127
|
+
|
|
128
|
+
def initialize(sentiment: nil, confidence: nil, content: nil, companies: nil,
|
|
129
|
+
countries: nil, **base)
|
|
130
|
+
super(**base)
|
|
131
|
+
@sentiment = sentiment
|
|
132
|
+
@confidence = confidence
|
|
133
|
+
@content = content
|
|
134
|
+
@companies = companies
|
|
135
|
+
@countries = countries
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def self.from_h(hash)
|
|
139
|
+
new(
|
|
140
|
+
**base_attributes(hash),
|
|
141
|
+
sentiment: hash["sentiment"],
|
|
142
|
+
confidence: ModelParsing.to_float(hash["confidence"]),
|
|
143
|
+
content: hash["content"],
|
|
144
|
+
companies: hash["companies"]&.map { |c| Company.from_h(c) },
|
|
145
|
+
countries: ModelParsing.to_string_list(hash["countries"])
|
|
146
|
+
)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# A page of search results.
|
|
151
|
+
class ArticleResponse
|
|
152
|
+
attr_reader :status, :page, :page_size, :articles
|
|
153
|
+
|
|
154
|
+
def initialize(status:, page:, page_size:, articles:)
|
|
155
|
+
@status = status
|
|
156
|
+
@page = page
|
|
157
|
+
@page_size = page_size
|
|
158
|
+
@articles = articles
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def self.from_h(hash)
|
|
162
|
+
new(
|
|
163
|
+
status: hash["status"],
|
|
164
|
+
page: hash["page"],
|
|
165
|
+
page_size: hash["pageSize"],
|
|
166
|
+
articles: (hash["articles"] || []).map { |a| Article.from_h(a) }
|
|
167
|
+
)
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# A news source available through the API.
|
|
172
|
+
class Source
|
|
173
|
+
attr_reader :domain, :is_content_available, :is_default_source
|
|
174
|
+
|
|
175
|
+
def initialize(domain:, is_content_available:, is_default_source:)
|
|
176
|
+
@domain = domain
|
|
177
|
+
@is_content_available = is_content_available
|
|
178
|
+
@is_default_source = is_default_source
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def self.from_h(hash)
|
|
182
|
+
new(
|
|
183
|
+
domain: hash["domain"],
|
|
184
|
+
is_content_available: hash["isContentAvailable"] == true,
|
|
185
|
+
is_default_source: hash["isDefaultSource"] == true
|
|
186
|
+
)
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Finlight
|
|
4
|
+
# Converts idiomatic snake_case Ruby keyword arguments into the camelCase
|
|
5
|
+
# JSON wire format, omitting unset (nil) fields. Keys that are already
|
|
6
|
+
# camelCase pass through unchanged.
|
|
7
|
+
module Params
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# @param params [Hash]
|
|
11
|
+
# @return [Hash{String => Object}]
|
|
12
|
+
def normalize(params)
|
|
13
|
+
params.each_with_object({}) do |(key, value), out|
|
|
14
|
+
next if value.nil?
|
|
15
|
+
|
|
16
|
+
out[camelize(key)] = value
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# page_size -> pageSize, exclude_sources -> excludeSources, from -> from
|
|
21
|
+
def camelize(key)
|
|
22
|
+
key.to_s.gsub(/_([a-z0-9])/) { Regexp.last_match(1).upcase }
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Finlight
|
|
4
|
+
# Lists the news sources available through the API.
|
|
5
|
+
class SourceService
|
|
6
|
+
def initialize(api_client)
|
|
7
|
+
@api_client = api_client
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# @return [Array<Source>] all available news sources
|
|
11
|
+
# @raise [ApiError] if the API request fails
|
|
12
|
+
def get_sources # rubocop:disable Naming/AccessorMethodName -- mirrors the sibling clients' API
|
|
13
|
+
@api_client.get("/v2/sources").map { |s| Source.from_h(s) }
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "openssl"
|
|
5
|
+
|
|
6
|
+
module Finlight
|
|
7
|
+
# Securely receives and verifies webhook events from finlight: HMAC-SHA256
|
|
8
|
+
# signature verification with replay attack protection.
|
|
9
|
+
module WebhookService
|
|
10
|
+
SIGNATURE_PREFIX = "sha256="
|
|
11
|
+
REPLAY_TOLERANCE_SECONDS = 5 * 60
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# Constructs and verifies a webhook event from raw request data.
|
|
16
|
+
#
|
|
17
|
+
# @example Rack/Rails endpoint
|
|
18
|
+
# article = Finlight::WebhookService.construct_event(
|
|
19
|
+
# request.raw_post,
|
|
20
|
+
# request.headers["X-Webhook-Signature"],
|
|
21
|
+
# ENV["WEBHOOK_SECRET"],
|
|
22
|
+
# request.headers["X-Webhook-Timestamp"]
|
|
23
|
+
# )
|
|
24
|
+
#
|
|
25
|
+
# @param raw_body [String] the raw, unparsed request body
|
|
26
|
+
# @param signature [String] the X-Webhook-Signature header (with or
|
|
27
|
+
# without the "sha256=" prefix)
|
|
28
|
+
# @param endpoint_secret [String] your webhook secret from the dashboard
|
|
29
|
+
# @param timestamp [String, nil] the X-Webhook-Timestamp header; when
|
|
30
|
+
# given it is included in the signed message and checked against a
|
|
31
|
+
# 5-minute replay tolerance
|
|
32
|
+
# @return [Article] the verified and parsed article
|
|
33
|
+
# @raise [WebhookVerificationError] if verification fails
|
|
34
|
+
def construct_event(raw_body, signature, endpoint_secret, timestamp = nil)
|
|
35
|
+
timestamp = nil if timestamp.to_s.empty?
|
|
36
|
+
normalized = signature.to_s.delete_prefix(SIGNATURE_PREFIX)
|
|
37
|
+
|
|
38
|
+
message = timestamp ? "#{timestamp}.#{raw_body}" : raw_body
|
|
39
|
+
expected = compute_signature(message, endpoint_secret)
|
|
40
|
+
raise WebhookVerificationError, "Invalid webhook signature" unless secure_compare(normalized, expected)
|
|
41
|
+
|
|
42
|
+
verify_timestamp(timestamp) if timestamp
|
|
43
|
+
|
|
44
|
+
parse_payload(raw_body)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def compute_signature(payload, secret)
|
|
48
|
+
OpenSSL::HMAC.hexdigest("SHA256", secret, payload)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def verify_timestamp(timestamp)
|
|
52
|
+
begin
|
|
53
|
+
webhook_time = FlexTime.parse(timestamp)
|
|
54
|
+
rescue Error
|
|
55
|
+
raise WebhookVerificationError, "Invalid timestamp format"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
return unless (Time.now.utc - webhook_time).abs > REPLAY_TOLERANCE_SECONDS
|
|
59
|
+
|
|
60
|
+
raise WebhookVerificationError, "Webhook timestamp outside allowed tolerance"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def parse_payload(raw_body)
|
|
64
|
+
data = begin
|
|
65
|
+
JSON.parse(raw_body)
|
|
66
|
+
rescue JSON::ParserError
|
|
67
|
+
raise WebhookVerificationError, "Invalid JSON payload"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
begin
|
|
71
|
+
Article.from_h(data)
|
|
72
|
+
rescue StandardError => e
|
|
73
|
+
raise WebhookVerificationError, "Invalid article data: #{e.message}"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def secure_compare(left, right)
|
|
78
|
+
return false unless left.bytesize == right.bytesize
|
|
79
|
+
|
|
80
|
+
OpenSSL.fixed_length_secure_compare(left, right)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Finlight
|
|
4
|
+
# Streams enriched articles (sentiment, entities, content). Duplicate
|
|
5
|
+
# deliveries are suppressed via a small cache of recent article links.
|
|
6
|
+
class ArticleWebSocketClient < BaseWebSocketClient
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def websocket_url
|
|
10
|
+
@config.wss_url
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def parse_article(data)
|
|
14
|
+
Article.from_h(data)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def article_id(article)
|
|
18
|
+
article.link
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
require "socket"
|
|
7
|
+
require "uri"
|
|
8
|
+
require "websocket/driver"
|
|
9
|
+
|
|
10
|
+
module Finlight
|
|
11
|
+
# Shared implementation of the finlight streaming protocol: reconnect loop
|
|
12
|
+
# with exponential backoff, application-level ping/pong with watchdog,
|
|
13
|
+
# proactive connection rotation before the server-side lifetime cap, and
|
|
14
|
+
# optional duplicate suppression.
|
|
15
|
+
#
|
|
16
|
+
# Use the concrete {ArticleWebSocketClient} and {RawArticleWebSocketClient}
|
|
17
|
+
# obtained from {Client#websocket} and {Client#raw_websocket}.
|
|
18
|
+
class BaseWebSocketClient
|
|
19
|
+
RECENT_ARTICLE_CACHE_SIZE = 10
|
|
20
|
+
CLOSE_GRACE_SECONDS = 5
|
|
21
|
+
DIAL_RATE_LIMIT_BACKOFF_SECONDS = 60
|
|
22
|
+
ERROR_RATE_LIMIT_BACKOFF_SECONDS = 60
|
|
23
|
+
ERROR_BLOCKED_BACKOFF_SECONDS = 3600
|
|
24
|
+
DEFAULT_ADMIN_KICK_RETRY_MS = 900_000
|
|
25
|
+
|
|
26
|
+
# Close codes of the finlight WebSocket protocol.
|
|
27
|
+
CLOSE_BLOCKED = 1008
|
|
28
|
+
CLOSE_PROACTIVE_ROTATION = 4000
|
|
29
|
+
CLOSE_RATE_LIMITED = 4001
|
|
30
|
+
CLOSE_USER_BLOCKED = 4002
|
|
31
|
+
CLOSE_ADMIN_KICK = 4003
|
|
32
|
+
|
|
33
|
+
# Adapter between websocket-driver and the raw socket.
|
|
34
|
+
class DriverSocket
|
|
35
|
+
attr_reader :url
|
|
36
|
+
|
|
37
|
+
def initialize(url, io)
|
|
38
|
+
@url = url
|
|
39
|
+
@io = io
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def write(data)
|
|
43
|
+
@io.write(data)
|
|
44
|
+
rescue StandardError
|
|
45
|
+
# The connection is going down; the read loop notices independently.
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @param config [Config]
|
|
50
|
+
# @param ping_interval [Numeric] seconds between application-level pings
|
|
51
|
+
# @param pong_timeout [Numeric] seconds without a pong before forcing a reconnect
|
|
52
|
+
# @param base_reconnect_delay [Numeric] initial reconnect backoff in seconds
|
|
53
|
+
# @param max_reconnect_delay [Numeric] backoff cap in seconds
|
|
54
|
+
# @param connection_lifetime [Numeric] seconds before proactively rotating
|
|
55
|
+
# the connection (server caps connections at 2h)
|
|
56
|
+
# @param takeover [Boolean] take over an existing connection for the same key
|
|
57
|
+
# @param on_close [#call, nil] callback invoked with (code, reason) whenever
|
|
58
|
+
# a connection closes
|
|
59
|
+
def initialize(config, ping_interval: 25, pong_timeout: 60,
|
|
60
|
+
base_reconnect_delay: 0.5, max_reconnect_delay: 10.0,
|
|
61
|
+
connection_lifetime: 115 * 60, takeover: false, on_close: nil,
|
|
62
|
+
logger: Logging.default)
|
|
63
|
+
@config = config
|
|
64
|
+
@ping_interval = ping_interval
|
|
65
|
+
@pong_timeout = pong_timeout
|
|
66
|
+
@base_reconnect_delay = base_reconnect_delay
|
|
67
|
+
@max_reconnect_delay = max_reconnect_delay
|
|
68
|
+
@connection_lifetime = connection_lifetime
|
|
69
|
+
@takeover = takeover
|
|
70
|
+
@on_close = on_close
|
|
71
|
+
@logger = logger
|
|
72
|
+
@stop = false
|
|
73
|
+
@running = false
|
|
74
|
+
@recent_articles = []
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Connects to the stream and blocks, yielding every received article.
|
|
78
|
+
# Reconnects automatically (exponential backoff, honors server-mandated
|
|
79
|
+
# wait times) until {#stop} is called or the server preempts the
|
|
80
|
+
# connection in favor of a newer one.
|
|
81
|
+
#
|
|
82
|
+
# @param params [Hash] stream filters as snake_case keywords, e.g.
|
|
83
|
+
# +query:+, +sources:+, +exclude_sources:+, +language:+, +tickers:+,
|
|
84
|
+
# +extended:+, +include_entities:+, +exclude_empty_content:+,
|
|
85
|
+
# +countries:+, +categories:+, +include_updates:+
|
|
86
|
+
# @yield [article] every article received on the stream
|
|
87
|
+
# @raise [BlockedError] if the server permanently rejected the connection
|
|
88
|
+
# (close code 1008)
|
|
89
|
+
def connect(**params, &on_article)
|
|
90
|
+
raise ArgumentError, "finlight: connect requires a block" unless on_article
|
|
91
|
+
raise Error, "finlight: connect is already running on this client" if @running
|
|
92
|
+
|
|
93
|
+
@running = true
|
|
94
|
+
@stop = false
|
|
95
|
+
@reconnect_at = nil
|
|
96
|
+
payload = Params.normalize(params)
|
|
97
|
+
delay = @base_reconnect_delay
|
|
98
|
+
|
|
99
|
+
begin
|
|
100
|
+
until @stop
|
|
101
|
+
@logger.info { "finlight: connecting to #{websocket_url}" }
|
|
102
|
+
result = run_connection(payload, on_article)
|
|
103
|
+
raise BlockedError if result == :blocked
|
|
104
|
+
break if @stop
|
|
105
|
+
|
|
106
|
+
delay = @base_reconnect_delay if result == :connected
|
|
107
|
+
|
|
108
|
+
now = monotonic
|
|
109
|
+
if @reconnect_at && @reconnect_at > now
|
|
110
|
+
wait = @reconnect_at - now
|
|
111
|
+
@logger.info { "finlight: waiting #{wait.round(1)}s until server-mandated reconnect time" }
|
|
112
|
+
else
|
|
113
|
+
wait = delay
|
|
114
|
+
@logger.info { "finlight: reconnecting in #{wait.round(1)}s" }
|
|
115
|
+
delay = [delay * 2, @max_reconnect_delay].min
|
|
116
|
+
end
|
|
117
|
+
interruptible_sleep(wait)
|
|
118
|
+
end
|
|
119
|
+
ensure
|
|
120
|
+
@running = false
|
|
121
|
+
end
|
|
122
|
+
nil
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Runs {#connect} on a background thread and returns it. Joining the
|
|
126
|
+
# thread re-raises {BlockedError} if the stream ended that way.
|
|
127
|
+
#
|
|
128
|
+
# @return [Thread]
|
|
129
|
+
def connect_async(**params, &on_article)
|
|
130
|
+
Thread.new do
|
|
131
|
+
Thread.current.report_on_exception = false
|
|
132
|
+
connect(**params, &on_article)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Stops the stream: the current connection closes and the reconnect loop
|
|
137
|
+
# ends. Safe to call from any thread; {#connect} returns shortly after.
|
|
138
|
+
def stop
|
|
139
|
+
@stop = true
|
|
140
|
+
nil
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
private
|
|
144
|
+
|
|
145
|
+
# @return [String] the full WebSocket URL to connect to
|
|
146
|
+
def websocket_url
|
|
147
|
+
raise NotImplementedError
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# @return [Object] the parsed article for a sendArticle payload
|
|
151
|
+
def parse_article(data)
|
|
152
|
+
raise NotImplementedError
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Identifier used for duplicate suppression, or nil to disable it.
|
|
156
|
+
def article_id(_article)
|
|
157
|
+
nil
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def run_connection(payload, on_article)
|
|
161
|
+
uri = URI.parse(websocket_url)
|
|
162
|
+
begin
|
|
163
|
+
io = dial(uri)
|
|
164
|
+
rescue StandardError => e
|
|
165
|
+
@logger.error { "finlight: connection failed: #{e.message}" }
|
|
166
|
+
return :failed
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
begin
|
|
170
|
+
run_stream(io, payload, on_article)
|
|
171
|
+
ensure
|
|
172
|
+
begin
|
|
173
|
+
io.close
|
|
174
|
+
rescue StandardError
|
|
175
|
+
nil
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def dial(uri)
|
|
181
|
+
host = uri.host
|
|
182
|
+
secure = %w[wss https].include?(uri.scheme)
|
|
183
|
+
port = uri.port || (secure ? 443 : 80)
|
|
184
|
+
tcp = Socket.tcp(host, port, connect_timeout: @config.timeout)
|
|
185
|
+
return tcp unless secure
|
|
186
|
+
|
|
187
|
+
context = OpenSSL::SSL::SSLContext.new
|
|
188
|
+
context.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
189
|
+
context.cert_store = OpenSSL::X509::Store.new.tap(&:set_default_paths)
|
|
190
|
+
ssl = OpenSSL::SSL::SSLSocket.new(tcp, context)
|
|
191
|
+
ssl.hostname = host
|
|
192
|
+
ssl.sync_close = true
|
|
193
|
+
ssl.connect
|
|
194
|
+
ssl.post_connection_check(host)
|
|
195
|
+
ssl
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def run_stream(io, payload, on_article)
|
|
199
|
+
state = {
|
|
200
|
+
opened: false, closed: nil, error: nil, stop_sent: false,
|
|
201
|
+
last_pong: monotonic, next_ping: nil, abort_at: nil,
|
|
202
|
+
rotate_at: monotonic + @connection_lifetime
|
|
203
|
+
}
|
|
204
|
+
nonce = SecureRandom.uuid
|
|
205
|
+
|
|
206
|
+
# The server reads these headers case-sensitively in exact lowercase.
|
|
207
|
+
driver = WebSocket::Driver.client(DriverSocket.new(websocket_url, io))
|
|
208
|
+
driver.set_header("x-api-key", @config.api_key)
|
|
209
|
+
driver.set_header("x-client-version", Client::CLIENT_VERSION)
|
|
210
|
+
driver.set_header("x-takeover", "true") if @takeover
|
|
211
|
+
|
|
212
|
+
driver.on(:open) do
|
|
213
|
+
state[:opened] = true
|
|
214
|
+
state[:last_pong] = monotonic
|
|
215
|
+
state[:next_ping] = monotonic + @ping_interval
|
|
216
|
+
@reconnect_at = nil
|
|
217
|
+
@logger.info { "finlight: connected" }
|
|
218
|
+
driver.text(JSON.generate(payload.merge("clientNonce" => nonce)))
|
|
219
|
+
end
|
|
220
|
+
driver.on(:message) { |event| handle_message(event.data, driver, state, nonce, on_article) }
|
|
221
|
+
driver.on(:close) { |event| state[:closed] ||= [event.code || 1006, event.reason.to_s] }
|
|
222
|
+
driver.on(:error) { |event| handle_driver_error(event.message.to_s, state) }
|
|
223
|
+
|
|
224
|
+
queue = Thread::Queue.new
|
|
225
|
+
reader = Thread.new do
|
|
226
|
+
Thread.current.report_on_exception = false
|
|
227
|
+
begin
|
|
228
|
+
loop { queue << io.readpartial(16_384) }
|
|
229
|
+
rescue StandardError
|
|
230
|
+
queue << :eof
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
driver.start
|
|
235
|
+
pump(driver, queue, state)
|
|
236
|
+
|
|
237
|
+
close_code, close_reason = state[:closed] || [1006, state[:error] || "connection lost"]
|
|
238
|
+
@logger.info { "finlight: connection closed: #{close_code} - #{close_reason}" }
|
|
239
|
+
notify_close(close_code, close_reason)
|
|
240
|
+
|
|
241
|
+
return :blocked if close_code == CLOSE_BLOCKED
|
|
242
|
+
|
|
243
|
+
state[:opened] ? :connected : :failed
|
|
244
|
+
ensure
|
|
245
|
+
reader&.kill
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def pump(driver, queue, state)
|
|
249
|
+
until state[:closed]
|
|
250
|
+
break if state[:error] && !state[:opened]
|
|
251
|
+
|
|
252
|
+
if @stop
|
|
253
|
+
break unless state[:opened]
|
|
254
|
+
|
|
255
|
+
request_close(driver, state, "client stopped", 1000)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
chunk = queue.pop(timeout: 0.2)
|
|
259
|
+
if chunk == :eof
|
|
260
|
+
state[:closed] ||= [1006, "connection lost"]
|
|
261
|
+
break
|
|
262
|
+
end
|
|
263
|
+
driver.parse(chunk) if chunk
|
|
264
|
+
|
|
265
|
+
now = monotonic
|
|
266
|
+
if state[:opened]
|
|
267
|
+
if state[:next_ping] && now >= state[:next_ping]
|
|
268
|
+
@logger.debug { "finlight: sending ping" }
|
|
269
|
+
driver.text(JSON.generate({ "action" => "ping", "t" => wallclock_millis }))
|
|
270
|
+
state[:next_ping] = now + @ping_interval
|
|
271
|
+
end
|
|
272
|
+
if now - state[:last_pong] > @pong_timeout
|
|
273
|
+
@logger.warn { "finlight: no pong received in time, forcing reconnect" }
|
|
274
|
+
break
|
|
275
|
+
end
|
|
276
|
+
if now >= state[:rotate_at]
|
|
277
|
+
@logger.info { "finlight: proactive rotation before server connection cap" }
|
|
278
|
+
state[:rotate_at] = Float::INFINITY
|
|
279
|
+
request_close(driver, state, "Proactive rotation", CLOSE_PROACTIVE_ROTATION)
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
break if state[:abort_at] && now >= state[:abort_at]
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def handle_message(data, driver, state, nonce, on_article)
|
|
287
|
+
msg = begin
|
|
288
|
+
JSON.parse(data)
|
|
289
|
+
rescue JSON::ParserError => e
|
|
290
|
+
@logger.error { "finlight: cannot parse message: #{e.message}" }
|
|
291
|
+
nil
|
|
292
|
+
end
|
|
293
|
+
return unless msg.is_a?(Hash)
|
|
294
|
+
|
|
295
|
+
case msg["action"]
|
|
296
|
+
when "pong"
|
|
297
|
+
ping_time = msg["t"]
|
|
298
|
+
if ping_time
|
|
299
|
+
@logger.debug { "finlight: pong received (rtt=#{wallclock_millis - ping_time.to_i}ms)" }
|
|
300
|
+
else
|
|
301
|
+
@logger.debug { "finlight: pong received" }
|
|
302
|
+
end
|
|
303
|
+
state[:last_pong] = monotonic
|
|
304
|
+
when "admit"
|
|
305
|
+
@logger.info { "finlight: admitted (leaseId=#{msg["leaseId"]})" }
|
|
306
|
+
server_nonce = msg["clientNonce"].to_s
|
|
307
|
+
if !server_nonce.empty? && server_nonce != nonce
|
|
308
|
+
@logger.warn { "finlight: nonce mismatch: expected #{nonce}, got #{server_nonce}" }
|
|
309
|
+
end
|
|
310
|
+
when "preempted"
|
|
311
|
+
@logger.warn do
|
|
312
|
+
"finlight: connection preempted: #{msg.fetch("reason", "unknown")} " \
|
|
313
|
+
"(new lease: #{msg["newLeaseId"]})"
|
|
314
|
+
end
|
|
315
|
+
@stop = true
|
|
316
|
+
request_close(driver, state, "Preempted by server", 1000)
|
|
317
|
+
when "sendArticle"
|
|
318
|
+
handle_article(msg["data"] || {}, on_article)
|
|
319
|
+
when "admin_kick"
|
|
320
|
+
retry_after_ms = msg.fetch("retryAfter", DEFAULT_ADMIN_KICK_RETRY_MS).to_i
|
|
321
|
+
@reconnect_at = monotonic + (retry_after_ms / 1000.0)
|
|
322
|
+
@logger.warn { "finlight: admin kick - retry after #{retry_after_ms}ms" }
|
|
323
|
+
request_close(driver, state, "Admin kick", CLOSE_ADMIN_KICK)
|
|
324
|
+
when "error"
|
|
325
|
+
raw = msg["data"] || msg["error"] || ""
|
|
326
|
+
text = raw.is_a?(String) ? raw : JSON.generate(raw)
|
|
327
|
+
@logger.error { "finlight: server error: #{text}" }
|
|
328
|
+
if text.downcase.include?("limit")
|
|
329
|
+
@reconnect_at = monotonic + ERROR_RATE_LIMIT_BACKOFF_SECONDS
|
|
330
|
+
request_close(driver, state, "Rate limited", CLOSE_RATE_LIMITED)
|
|
331
|
+
elsif text.downcase.include?("blocked")
|
|
332
|
+
@reconnect_at = monotonic + ERROR_BLOCKED_BACKOFF_SECONDS
|
|
333
|
+
request_close(driver, state, "User blocked", CLOSE_USER_BLOCKED)
|
|
334
|
+
end
|
|
335
|
+
else
|
|
336
|
+
@logger.warn { "finlight: unknown message action: #{msg["action"]}" }
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def handle_article(data, on_article)
|
|
341
|
+
article = begin
|
|
342
|
+
parse_article(data)
|
|
343
|
+
rescue StandardError => e
|
|
344
|
+
@logger.error { "finlight: cannot parse article: #{e.message}" }
|
|
345
|
+
nil
|
|
346
|
+
end
|
|
347
|
+
return if article.nil?
|
|
348
|
+
|
|
349
|
+
id = article_id(article)
|
|
350
|
+
if id
|
|
351
|
+
if @recent_articles.include?(id)
|
|
352
|
+
@logger.debug { "finlight: skipping duplicate article: #{id}" }
|
|
353
|
+
return
|
|
354
|
+
end
|
|
355
|
+
@recent_articles << id
|
|
356
|
+
@recent_articles.shift if @recent_articles.size > RECENT_ARTICLE_CACHE_SIZE
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
begin
|
|
360
|
+
on_article.call(article)
|
|
361
|
+
rescue StandardError => e
|
|
362
|
+
@logger.error { "finlight: article callback failed: #{e.message}" }
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def handle_driver_error(message, state)
|
|
367
|
+
if message[/Unexpected response code: (\d+)/, 1].to_i == 429
|
|
368
|
+
@reconnect_at = monotonic + DIAL_RATE_LIMIT_BACKOFF_SECONDS
|
|
369
|
+
@logger.warn { "finlight: server rejected connection (429), backing off" }
|
|
370
|
+
else
|
|
371
|
+
@logger.error { "finlight: connection error: #{message}" }
|
|
372
|
+
end
|
|
373
|
+
state[:error] = message
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
# Sends a close frame once and arms a grace deadline in case the server
|
|
377
|
+
# never completes the close handshake.
|
|
378
|
+
def request_close(driver, state, reason, code)
|
|
379
|
+
return if state[:stop_sent]
|
|
380
|
+
|
|
381
|
+
state[:stop_sent] = true
|
|
382
|
+
state[:abort_at] = monotonic + CLOSE_GRACE_SECONDS
|
|
383
|
+
driver.close(reason, code)
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
def notify_close(code, reason)
|
|
387
|
+
return unless @on_close
|
|
388
|
+
|
|
389
|
+
begin
|
|
390
|
+
@on_close.call(code, reason)
|
|
391
|
+
rescue StandardError => e
|
|
392
|
+
@logger.error { "finlight: on_close callback failed: #{e.message}" }
|
|
393
|
+
end
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def interruptible_sleep(seconds)
|
|
397
|
+
deadline = monotonic + seconds
|
|
398
|
+
sleep([0.05, deadline - monotonic].min) while !@stop && monotonic < deadline
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def monotonic
|
|
402
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def wallclock_millis
|
|
406
|
+
(Time.now.to_f * 1000).to_i
|
|
407
|
+
end
|
|
408
|
+
end
|
|
409
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Finlight
|
|
4
|
+
# Streams unenriched articles with minimal latency. The raw stream delivers
|
|
5
|
+
# every message, including updates — no duplicate suppression.
|
|
6
|
+
class RawArticleWebSocketClient < BaseWebSocketClient
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def websocket_url
|
|
10
|
+
"#{@config.wss_url}/raw"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def parse_article(data)
|
|
14
|
+
RawArticle.from_h(data)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: finlight-client
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- finlight.me
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: logger
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '1.6'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '1.6'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: websocket-driver
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '0.8'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '0.8'
|
|
40
|
+
description: 'Financial news with sentiment analysis, entity recognition and real-time
|
|
41
|
+
streaming: REST, WebSocket and webhook verification client for finlight.me.'
|
|
42
|
+
email:
|
|
43
|
+
- info@finlight.me
|
|
44
|
+
executables: []
|
|
45
|
+
extensions: []
|
|
46
|
+
extra_rdoc_files: []
|
|
47
|
+
files:
|
|
48
|
+
- LICENSE
|
|
49
|
+
- README.md
|
|
50
|
+
- lib/finlight-client.rb
|
|
51
|
+
- lib/finlight/api_client.rb
|
|
52
|
+
- lib/finlight/article_service.rb
|
|
53
|
+
- lib/finlight/client.rb
|
|
54
|
+
- lib/finlight/client/version.rb
|
|
55
|
+
- lib/finlight/config.rb
|
|
56
|
+
- lib/finlight/errors.rb
|
|
57
|
+
- lib/finlight/flex_time.rb
|
|
58
|
+
- lib/finlight/logging.rb
|
|
59
|
+
- lib/finlight/models.rb
|
|
60
|
+
- lib/finlight/params.rb
|
|
61
|
+
- lib/finlight/source_service.rb
|
|
62
|
+
- lib/finlight/webhook_service.rb
|
|
63
|
+
- lib/finlight/websocket/article_client.rb
|
|
64
|
+
- lib/finlight/websocket/base_client.rb
|
|
65
|
+
- lib/finlight/websocket/raw_article_client.rb
|
|
66
|
+
homepage: https://finlight.me
|
|
67
|
+
licenses:
|
|
68
|
+
- MIT
|
|
69
|
+
metadata:
|
|
70
|
+
homepage_uri: https://finlight.me
|
|
71
|
+
documentation_uri: https://docs.finlight.me
|
|
72
|
+
source_code_uri: https://github.com/callbk/finlight-client-ruby
|
|
73
|
+
changelog_uri: https://github.com/callbk/finlight-client-ruby/blob/main/CHANGELOG.md
|
|
74
|
+
bug_tracker_uri: https://github.com/callbk/finlight-client-ruby/issues
|
|
75
|
+
rubygems_mfa_required: 'true'
|
|
76
|
+
rdoc_options: []
|
|
77
|
+
require_paths:
|
|
78
|
+
- lib
|
|
79
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
80
|
+
requirements:
|
|
81
|
+
- - ">="
|
|
82
|
+
- !ruby/object:Gem::Version
|
|
83
|
+
version: '3.2'
|
|
84
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
85
|
+
requirements:
|
|
86
|
+
- - ">="
|
|
87
|
+
- !ruby/object:Gem::Version
|
|
88
|
+
version: '0'
|
|
89
|
+
requirements: []
|
|
90
|
+
rubygems_version: 3.6.9
|
|
91
|
+
specification_version: 4
|
|
92
|
+
summary: Official Ruby client for the finlight.me API
|
|
93
|
+
test_files: []
|