A community based topic aggregation platform built on atproto
1package unfurl
2
3import (
4 "context"
5 "fmt"
6 "log"
7 "time"
8)
9
10// Service handles URL unfurling with caching
11type Service interface {
12 UnfurlURL(ctx context.Context, url string) (*UnfurlResult, error)
13 IsSupported(url string) bool
14}
15
16type service struct {
17 repo Repository
18 circuitBreaker *circuitBreaker
19 userAgent string
20 timeout time.Duration
21 cacheTTL time.Duration
22}
23
24// NewService creates a new unfurl service
25func NewService(repo Repository, opts ...ServiceOption) Service {
26 s := &service{
27 repo: repo,
28 timeout: 10 * time.Second,
29 userAgent: "CovesBot/1.0 (+https://coves.social)",
30 cacheTTL: 24 * time.Hour,
31 circuitBreaker: newCircuitBreaker(),
32 }
33
34 for _, opt := range opts {
35 opt(s)
36 }
37
38 return s
39}
40
41// ServiceOption configures the service
42type ServiceOption func(*service)
43
44// WithTimeout sets the HTTP timeout for oEmbed requests
45func WithTimeout(timeout time.Duration) ServiceOption {
46 return func(s *service) {
47 s.timeout = timeout
48 }
49}
50
51// WithUserAgent sets the User-Agent header for oEmbed requests
52func WithUserAgent(userAgent string) ServiceOption {
53 return func(s *service) {
54 s.userAgent = userAgent
55 }
56}
57
58// WithCacheTTL sets the cache TTL
59func WithCacheTTL(ttl time.Duration) ServiceOption {
60 return func(s *service) {
61 s.cacheTTL = ttl
62 }
63}
64
65// IsSupported returns true if we can unfurl this URL
66func (s *service) IsSupported(url string) bool {
67 return isSupported(url)
68}
69
70// UnfurlURL fetches metadata for a URL (with caching)
71func (s *service) UnfurlURL(ctx context.Context, urlStr string) (*UnfurlResult, error) {
72 // 1. Check cache first
73 cached, err := s.repo.Get(ctx, urlStr)
74 if err == nil && cached != nil {
75 log.Printf("[UNFURL] Cache hit for %s (provider: %s)", urlStr, cached.Provider)
76 return cached, nil
77 }
78
79 // 2. Check if we support this URL
80 if !isSupported(urlStr) {
81 return nil, fmt.Errorf("unsupported URL: %s", urlStr)
82 }
83
84 var result *UnfurlResult
85 domain := extractDomain(urlStr)
86
87 // 3. Smart routing: Special handling for Kagi Kite (client-side rendered, no og:image tags)
88 if domain == "kite.kagi.com" {
89 provider := "kagi"
90
91 // Check circuit breaker
92 canAttempt, err := s.circuitBreaker.canAttempt(provider)
93 if !canAttempt {
94 log.Printf("[UNFURL] Skipping %s due to circuit breaker: %v", urlStr, err)
95 return nil, err
96 }
97
98 log.Printf("[UNFURL] Cache miss for %s, fetching via Kagi parser...", urlStr)
99 result, err = fetchKagiKite(ctx, urlStr, s.timeout, s.userAgent)
100 if err != nil {
101 s.circuitBreaker.recordFailure(provider, err)
102 return nil, err
103 }
104
105 s.circuitBreaker.recordSuccess(provider)
106
107 // Cache result
108 if cacheErr := s.repo.Set(ctx, urlStr, result, s.cacheTTL); cacheErr != nil {
109 log.Printf("[UNFURL] Warning: failed to cache result: %v", cacheErr)
110 }
111 return result, nil
112 }
113
114 // 4. Check if this is a known oEmbed provider
115 if isOEmbedProvider(urlStr) {
116 provider := domain // Use domain as provider name (e.g., "streamable.com", "youtube.com")
117
118 // Check circuit breaker
119 canAttempt, err := s.circuitBreaker.canAttempt(provider)
120 if !canAttempt {
121 log.Printf("[UNFURL] Skipping %s due to circuit breaker: %v", urlStr, err)
122 return nil, err
123 }
124
125 log.Printf("[UNFURL] Cache miss for %s, fetching from oEmbed...", urlStr)
126
127 // Fetch from oEmbed provider
128 oembed, err := fetchOEmbed(ctx, urlStr, s.timeout, s.userAgent)
129 if err != nil {
130 s.circuitBreaker.recordFailure(provider, err)
131 return nil, fmt.Errorf("failed to fetch oEmbed data: %w", err)
132 }
133
134 s.circuitBreaker.recordSuccess(provider)
135
136 // Convert to UnfurlResult
137 result = mapOEmbedToResult(oembed, urlStr)
138 } else {
139 provider := "opengraph"
140
141 // Check circuit breaker
142 canAttempt, err := s.circuitBreaker.canAttempt(provider)
143 if !canAttempt {
144 log.Printf("[UNFURL] Skipping %s due to circuit breaker: %v", urlStr, err)
145 return nil, err
146 }
147
148 log.Printf("[UNFURL] Cache miss for %s, fetching via OpenGraph...", urlStr)
149
150 // Fetch via OpenGraph
151 result, err = fetchOpenGraph(ctx, urlStr, s.timeout, s.userAgent)
152 if err != nil {
153 s.circuitBreaker.recordFailure(provider, err)
154 return nil, fmt.Errorf("failed to fetch OpenGraph data: %w", err)
155 }
156
157 s.circuitBreaker.recordSuccess(provider)
158 }
159
160 // 5. Store in cache
161 if cacheErr := s.repo.Set(ctx, urlStr, result, s.cacheTTL); cacheErr != nil {
162 // Log but don't fail - cache is best-effort
163 log.Printf("[UNFURL] Warning: Failed to cache result for %s: %v", urlStr, cacheErr)
164 }
165
166 log.Printf("[UNFURL] Successfully unfurled %s (provider: %s, type: %s)",
167 urlStr, result.Provider, result.Type)
168
169 return result, nil
170}