129 lines
3.3 KiB
Go
129 lines
3.3 KiB
Go
package stocktwits
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/tradarr/backend/internal/scraper"
|
|
)
|
|
|
|
const apiBase = "https://api.stocktwits.com/api/2"
|
|
|
|
type StockTwits struct {
|
|
client *http.Client
|
|
}
|
|
|
|
func New() *StockTwits {
|
|
return &StockTwits{
|
|
client: &http.Client{Timeout: 15 * time.Second},
|
|
}
|
|
}
|
|
|
|
func (s *StockTwits) Name() string { return "stocktwits" }
|
|
|
|
type apiResponse struct {
|
|
Response struct {
|
|
Status int `json:"status"`
|
|
Error string `json:"error,omitempty"`
|
|
} `json:"response"`
|
|
Messages []struct {
|
|
ID int `json:"id"`
|
|
Body string `json:"body"`
|
|
CreatedAt string `json:"created_at"`
|
|
User struct {
|
|
Username string `json:"username"`
|
|
} `json:"user"`
|
|
Entities struct {
|
|
Sentiment *struct {
|
|
Basic string `json:"basic"`
|
|
} `json:"sentiment"`
|
|
} `json:"entities"`
|
|
} `json:"messages"`
|
|
}
|
|
|
|
func (s *StockTwits) Scrape(ctx context.Context, symbols []string) ([]scraper.Article, error) {
|
|
var articles []scraper.Article
|
|
for i, symbol := range symbols {
|
|
// Délai entre les requêtes pour éviter le rate limiting
|
|
if i > 0 {
|
|
select {
|
|
case <-ctx.Done():
|
|
return articles, ctx.Err()
|
|
case <-time.After(500 * time.Millisecond):
|
|
}
|
|
}
|
|
msgs, err := s.fetchSymbol(ctx, symbol)
|
|
if err != nil {
|
|
fmt.Printf("stocktwits %s: %v\n", symbol, err)
|
|
continue
|
|
}
|
|
articles = append(articles, msgs...)
|
|
}
|
|
return articles, nil
|
|
}
|
|
|
|
func (s *StockTwits) fetchSymbol(ctx context.Context, symbol string) ([]scraper.Article, error) {
|
|
url := fmt.Sprintf("%s/streams/symbol/%s.json", apiBase, symbol)
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36")
|
|
|
|
resp, err := s.client.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if resp.StatusCode == 429 {
|
|
return nil, fmt.Errorf("rate limited by StockTwits for %s", symbol)
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("StockTwits returned HTTP %d for %s: %s", resp.StatusCode, symbol, string(body))
|
|
}
|
|
|
|
var data apiResponse
|
|
if err := json.Unmarshal(body, &data); err != nil {
|
|
return nil, fmt.Errorf("parse response for %s: %w", symbol, err)
|
|
}
|
|
|
|
// L'API StockTwits retourne un status dans le body même en HTTP 200
|
|
if data.Response.Status != 0 && data.Response.Status != 200 {
|
|
return nil, fmt.Errorf("StockTwits API error %d for %s: %s", data.Response.Status, symbol, data.Response.Error)
|
|
}
|
|
|
|
var articles []scraper.Article
|
|
for _, msg := range data.Messages {
|
|
if msg.Body == "" {
|
|
continue
|
|
}
|
|
sentiment := ""
|
|
if msg.Entities.Sentiment != nil {
|
|
sentiment = " [" + msg.Entities.Sentiment.Basic + "]"
|
|
}
|
|
title := fmt.Sprintf("$%s — @%s%s", symbol, msg.User.Username, sentiment)
|
|
publishedAt, _ := time.Parse(time.RFC3339, msg.CreatedAt)
|
|
msgURL := fmt.Sprintf("https://stocktwits.com/%s/message/%d", msg.User.Username, msg.ID)
|
|
|
|
articles = append(articles, scraper.Article{
|
|
Title: title,
|
|
Content: msg.Body,
|
|
URL: msgURL,
|
|
PublishedAt: &publishedAt,
|
|
Symbols: []string{symbol},
|
|
})
|
|
}
|
|
fmt.Printf("stocktwits %s: %d messages fetched\n", symbol, len(articles))
|
|
return articles, nil
|
|
}
|