Skip to content
This repository was archived by the owner on Feb 20, 2026. It is now read-only.

Commit ab4ebdd

Browse files
committed
start working on supporting rtcv requests
1 parent 2457ea9 commit ab4ebdd

7 files changed

Lines changed: 308 additions & 85 deletions

File tree

README.md

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# RT-CV scraper client
2+
A helper program that aims to ease the communication between a scraper and [RT-CV](https://bitbucket.org/teamscript/rt-cv)
23

3-
A helper program that aims to ease the communication between a scraper and [RT-CV](https://github.com/script-development/RT-CV)
44

55
## How does this work?
66

@@ -144,6 +144,42 @@ Check if a reference number is in the cache
144144
- Body: The reference number
145145
- Resp: **true** / **false**
146146
147+
### `$SCRAPER_ADDRESS/server_request`
148+
149+
This route only response when once rt-cv has a request for the scraper.
150+
151+
This url should be called continously by the scraper and should have no request timeout as this request might take hours to before RT-CV sends a request.
152+
153+
- Resp: a request for something by RT-CV
154+
155+
The request and respones are defined in [bitbucket.org/teamscript/rt-cv > /controller/scraperWebsocket/README.md](https://bitbucket.org/teamscript/rt-cv/src/main/controller/scraperWebsocket/README.md)
156+
157+
```jsonc
158+
{
159+
"type": "message type",
160+
"id": "message id",
161+
"data": {} // Change this
162+
}
163+
```
164+
165+
### `$SCRAPER_ADDRESS/server_response`
166+
167+
You should send a response to `/server_request` to this url
168+
169+
- Body: Almost equal to the response of `/server_request` but with the data changed to what RT-CV expected
170+
171+
The request and respones are defined in [bitbucket.org/teamscript/rt-cv > /controller/scraperWebsocket/README.md](https://bitbucket.org/teamscript/rt-cv/src/main/controller/scraperWebsocket/README.md)
172+
173+
```jsonc
174+
{
175+
"type": "message type",
176+
"id": "message id",
177+
"data": {} // Change this
178+
}
179+
```
180+
181+
- Resp: **true**
182+
147183
## `env.json` is in another dir or has another name?
148184
149185
You can change the credentials file location using this shell variable

api.go

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,11 @@ import (
1010
"io"
1111
"io/ioutil"
1212
"net/http"
13+
"strings"
14+
"sync/atomic"
1315
"time"
16+
17+
"github.com/gorilla/websocket"
1418
)
1519

1620
type serverConn struct {
@@ -26,12 +30,20 @@ type API struct {
2630
MockMode bool
2731
mockCache map[string]time.Time
2832

33+
CancelPreviouseCommunicationChan chan struct{}
34+
WebsocketReq chan []byte
35+
WebsocketResp chan []byte
36+
2937
Cache map[string]time.Time
3038
}
3139

3240
// NewAPI creates a new instance of the API
3341
func NewAPI() *API {
3442
return &API{
43+
CancelPreviouseCommunicationChan: make(chan struct{}),
44+
WebsocketReq: make(chan []byte),
45+
WebsocketResp: make(chan []byte),
46+
3547
Cache: map[string]time.Time{},
3648
}
3749
}
@@ -64,6 +76,9 @@ func (a *API) SetCredentials(credentialsList []SetCredentialsArg) error {
6476
if credentials.ServerLocation == "" {
6577
return errors.New("server_location cannot be empty")
6678
}
79+
if !strings.HasPrefix(credentials.ServerLocation, "http://") && !strings.HasPrefix(credentials.ServerLocation, "https://") {
80+
return errors.New("server_location must start with a supported protocol like: http:// or https://")
81+
}
6782
conn.serverLocation = credentials.ServerLocation
6883

6984
if credentials.APIKeyID == "" {
@@ -166,6 +181,41 @@ func (c *serverConn) DoRequest(method, path string, body, unmarshalResInto inter
166181
}
167182
}
168183

184+
func (c *serverConn) tryConnectToWS() *websocket.Conn {
185+
url := c.serverLocation
186+
url = strings.Replace(url, "http://", "ws://", 1)
187+
url = strings.Replace(url, "https://", "wss://", 1)
188+
url += "/api/v1/scraper/ws"
189+
190+
attempt := 0
191+
for {
192+
conn, _, err := websocket.DefaultDialer.Dial(url, http.Header{"Authorization": []string{c.authHeaderValue}})
193+
if err == nil {
194+
if attempt > 0 {
195+
fmt.Println("connected to web socket")
196+
}
197+
return conn
198+
}
199+
200+
attempt++
201+
retryInSeconds := time.Second
202+
if attempt == 1 {
203+
// retry in 1 second
204+
} else if attempt <= 2 {
205+
retryInSeconds *= 2
206+
} else if attempt <= 4 {
207+
retryInSeconds *= 4
208+
} else if attempt <= 6 {
209+
retryInSeconds *= 10
210+
} else {
211+
retryInSeconds *= 15
212+
}
213+
214+
fmt.Printf("unable to connect to web socket, error: %s, retrying in %s\n", err, retryInSeconds)
215+
time.Sleep(retryInSeconds)
216+
}
217+
}
218+
169219
// NoCredentials returns true if the SetCredentials method was not yet called and we aren't in mock mode
170220
func (a *API) NoCredentials() bool {
171221
return len(a.connections) == 0 && !a.MockMode
@@ -193,3 +243,115 @@ func (a *API) CacheEntryExists(referenceNr string) bool {
193243
}
194244
return !expired
195245
}
246+
247+
// WSMsg is a message recived and send to the websocket
248+
type WSMsg[T any] struct {
249+
Type string `json:"type"`
250+
ID string `json:"id,omitempty"`
251+
Data T `json:"data"`
252+
}
253+
254+
// ConnectToWS connects to the rtcv websocket
255+
func (a *API) ConnectToWS() {
256+
if a.MockMode {
257+
go func() {
258+
for {
259+
resp := <-a.WebsocketResp
260+
fmt.Println("got websocket response but we are in mock mode", string(resp))
261+
}
262+
}()
263+
return
264+
}
265+
266+
server := a.connections[0]
267+
268+
url := server.serverLocation
269+
url = strings.Replace(url, "http://", "ws://", 1)
270+
url = strings.Replace(url, "https://", "wss://", 1)
271+
url += "/api/v1/scraper/ws"
272+
273+
var c *websocket.Conn
274+
defer func() {
275+
if c != nil {
276+
c.Close()
277+
}
278+
}()
279+
280+
go func() {
281+
for {
282+
// TODO: if the response fails to send data might get lost.
283+
// It would be nice if the response is retried when WriteMessage fails
284+
resp := <-a.WebsocketResp
285+
err := c.WriteMessage(1, resp)
286+
if err != nil {
287+
fmt.Println("unable to write ws response:", err)
288+
}
289+
}
290+
}()
291+
292+
firstMessage := true
293+
var aMessageWasHandled atomic.Bool
294+
for {
295+
c = a.connections[0].tryConnectToWS()
296+
297+
for {
298+
msgType, msgBytes, err := c.ReadMessage()
299+
if err != nil {
300+
fmt.Println("error reading from web socket:", err)
301+
break
302+
}
303+
304+
switch msgType {
305+
case 1, 2:
306+
// 1 - text message
307+
// 2 - binary message
308+
// Ok continue
309+
default:
310+
// Ignore other message types
311+
continue
312+
}
313+
314+
msg := WSMsg[json.RawMessage]{}
315+
err = json.Unmarshal(msgBytes, &msg)
316+
if err != nil {
317+
fmt.Println("error unmarshaling web socket message:", err)
318+
continue
319+
}
320+
321+
timeout := time.Second
322+
if aMessageWasHandled.Load() || firstMessage {
323+
// It might be this scraper does not listen to the /server_request url thus we will try to send something over a channel that will never read
324+
// That's a lot of waisted time
325+
timeout = time.Second * 30
326+
}
327+
firstMessage = false
328+
329+
go func(msgBytes []byte, timeout time.Duration) {
330+
select {
331+
case a.WebsocketReq <- msgBytes:
332+
// Ok message was send
333+
aMessageWasHandled.Store(true)
334+
case <-time.After(timeout):
335+
errMsg := "Unable to handle request by RT-CV server"
336+
if !aMessageWasHandled.Load() {
337+
errMsg += ", probably becuase there is no one waiting for a response"
338+
}
339+
fmt.Println(errMsg)
340+
}
341+
}(msgBytes, timeout)
342+
}
343+
344+
c.Close()
345+
}
346+
347+
}
348+
349+
// CancelPreviouseCommunication cancels the previous communication if it's still running
350+
func (a *API) CancelPreviouseCommunication() {
351+
select {
352+
case a.CancelPreviouseCommunicationChan <- struct{}{}:
353+
// A previous communication was canceled
354+
default:
355+
// There are no more previous communications to cancel
356+
}
357+
}

env.go

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package main
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
)
7+
8+
// Env contains the structure of an env.json file
9+
type Env struct {
10+
PrivateKey string `json:"private_key"`
11+
PublicKey string `json:"public_key"`
12+
PrimaryServer EnvServer `json:"primary_server"`
13+
AlternativeServers []EnvServer `json:"alternative_servers"`
14+
MockMode bool `json:"mock_mode"`
15+
MockUsers []EnvUser `json:"mock_users"`
16+
}
17+
18+
func (e *Env) validate() error {
19+
if e.MockMode {
20+
if len(e.MockUsers) == 0 {
21+
fmt.Println(`"mock_users" is empty in env.json, most scrapers require at least one user to login.`)
22+
fmt.Println(`For documentation about mocking see https://github.com/script-development/rtcv_scraper_client`)
23+
if e.MockUsers == nil {
24+
e.MockUsers = []EnvUser{}
25+
}
26+
}
27+
return nil
28+
}
29+
30+
err := e.PrimaryServer.validate()
31+
if err != nil {
32+
return fmt.Errorf("primary_server.%s", err.Error())
33+
}
34+
35+
for idx, server := range e.AlternativeServers {
36+
err := server.validate()
37+
if err != nil {
38+
return fmt.Errorf("%s[%d].%s", "alternative_servers", idx, err.Error())
39+
}
40+
}
41+
42+
keyPairHelpMsg := `, use the go program inside the "gen_key" folder to generate a key pair`
43+
if e.PrivateKey == "" && e.PublicKey == "" {
44+
return errors.New(`"public_key" and "private_key" are required` + keyPairHelpMsg)
45+
} else if e.PrivateKey == "" {
46+
return errors.New(`"private_key" required` + keyPairHelpMsg)
47+
} else if e.PublicKey == "" {
48+
return errors.New(`"public_key" required` + keyPairHelpMsg)
49+
}
50+
51+
return nil
52+
}
53+
54+
// EnvServer contains the structure of the primary_server and alternative_servers inside the .env file
55+
type EnvServer struct {
56+
ServerLocation string `json:"server_location"`
57+
APIKeyID string `json:"api_key_id"`
58+
APIKey string `json:"api_key"`
59+
}
60+
61+
func (e *EnvServer) validate() error {
62+
if e.ServerLocation == "" {
63+
return errors.New("server_location is required")
64+
}
65+
if e.APIKeyID == "" {
66+
return errors.New("api_key_id is required")
67+
}
68+
if e.APIKey == "" {
69+
return errors.New("api_key is required")
70+
}
71+
72+
return nil
73+
}
74+
75+
func (e *EnvServer) toCredArg(isPrimary bool) SetCredentialsArg {
76+
return SetCredentialsArg{
77+
ServerLocation: e.ServerLocation,
78+
APIKeyID: e.APIKeyID,
79+
APIKey: e.APIKey,
80+
Primary: isPrimary,
81+
}
82+
}
83+
84+
// EnvUser contains the structure of the login_users inside the .env file
85+
type EnvUser struct {
86+
Username string `json:"username"`
87+
Password string `json:"password"`
88+
EncryptedPassword string `json:"encryptedPassword,omitempty"`
89+
}

go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ go 1.18
44

55
require github.com/valyala/fasthttp v1.40.0
66

7+
require github.com/gorilla/websocket v1.5.0 // indirect
8+
79
require (
810
github.com/andybalholm/brotli v1.0.4 // indirect
911
github.com/klauspost/compress v1.15.9 // indirect

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY3JY=
22
github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
3+
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
4+
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
35
github.com/klauspost/compress v1.15.0/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
46
github.com/klauspost/compress v1.15.9 h1:wKRjX6JRtDdrE9qwa4b/Cip7ACOshUI4smpCQanqjSY=
57
github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU=

0 commit comments

Comments
 (0)