A community based topic aggregation platform built on atproto
at main 4.6 kB view raw
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}