Improve Twitter/X link handling and text formatting
- Use FxTwitter API for full note tweet text (with syndication API fallback) - Save Twitter posts based on media content: - Videos → embed type (proxied video) - Images → image type (gallery) - Text-only → quote type - Add granular preview badges: 'X VIDEO', 'X GALLERY', 'X POST' - Preserve formatting/spacing with white-space: pre-wrap for quotes and descriptions - Rename VideoInfo to EmbedInfo for better semantic clarity
This commit is contained in:
parent
8a046728ef
commit
4a2cb341fa
7 changed files with 249 additions and 137 deletions
|
|
@ -20,8 +20,8 @@ const (
|
|||
ProviderTwitter Provider = "twitter"
|
||||
)
|
||||
|
||||
// VideoInfo contains information about an embedded video.
|
||||
type VideoInfo struct {
|
||||
// EmbedInfo contains information about an embed (video, image gallery, or text).
|
||||
type EmbedInfo struct {
|
||||
Provider Provider
|
||||
VideoID string
|
||||
Title string
|
||||
|
|
@ -30,6 +30,7 @@ type VideoInfo struct {
|
|||
ThumbnailURLs []string // All thumbnail URLs (for multi-image tweets)
|
||||
EmbedHTML string
|
||||
VideoURL string // Direct video URL (for Twitter videos)
|
||||
MediaType string // "video", "images", "text" - for Twitter only
|
||||
}
|
||||
|
||||
var (
|
||||
|
|
@ -40,7 +41,7 @@ var (
|
|||
)
|
||||
|
||||
// Detect checks if a URL is a YouTube, Vimeo, or Twitter/X post and returns its info.
|
||||
func Detect(ctx context.Context, targetURL string) (*VideoInfo, error) {
|
||||
func Detect(ctx context.Context, targetURL string) (*EmbedInfo, error) {
|
||||
// Try YouTube
|
||||
if matches := youtubeRegex.FindStringSubmatch(targetURL); len(matches) > 1 {
|
||||
return fetchYouTube(ctx, matches[1])
|
||||
|
|
@ -59,7 +60,7 @@ func Detect(ctx context.Context, targetURL string) (*VideoInfo, error) {
|
|||
return nil, nil // Not a recognized embed
|
||||
}
|
||||
|
||||
func fetchYouTube(ctx context.Context, videoID string) (*VideoInfo, error) {
|
||||
func fetchYouTube(ctx context.Context, videoID string) (*EmbedInfo, error) {
|
||||
// YouTube thumbnails are available without API
|
||||
thumbnailURL := fmt.Sprintf("https://img.youtube.com/vi/%s/maxresdefault.jpg", videoID)
|
||||
|
||||
|
|
@ -77,7 +78,7 @@ func fetchYouTube(ctx context.Context, videoID string) (*VideoInfo, error) {
|
|||
videoID,
|
||||
)
|
||||
|
||||
return &VideoInfo{
|
||||
return &EmbedInfo{
|
||||
Provider: ProviderYouTube,
|
||||
VideoID: videoID,
|
||||
Title: title,
|
||||
|
|
@ -86,7 +87,7 @@ func fetchYouTube(ctx context.Context, videoID string) (*VideoInfo, error) {
|
|||
}, nil
|
||||
}
|
||||
|
||||
func fetchVimeo(ctx context.Context, videoID string) (*VideoInfo, error) {
|
||||
func fetchVimeo(ctx context.Context, videoID string) (*EmbedInfo, error) {
|
||||
oembedURL := fmt.Sprintf("https://vimeo.com/api/oembed.json?url=%s",
|
||||
url.QueryEscape("https://vimeo.com/"+videoID))
|
||||
|
||||
|
|
@ -100,7 +101,7 @@ func fetchVimeo(ctx context.Context, videoID string) (*VideoInfo, error) {
|
|||
videoID,
|
||||
)
|
||||
|
||||
return &VideoInfo{
|
||||
return &EmbedInfo{
|
||||
Provider: ProviderVimeo,
|
||||
VideoID: videoID,
|
||||
Title: meta.Title,
|
||||
|
|
@ -110,7 +111,31 @@ func fetchVimeo(ctx context.Context, videoID string) (*VideoInfo, error) {
|
|||
}, nil
|
||||
}
|
||||
|
||||
// twitterSyndicationResponse represents the Twitter syndication API response
|
||||
// fxTwitterResponse represents the FxTwitter API response
|
||||
type fxTwitterResponse struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Tweet struct {
|
||||
Text string `json:"text"`
|
||||
Author struct {
|
||||
Name string `json:"name"`
|
||||
ScreenName string `json:"screen_name"`
|
||||
} `json:"author"`
|
||||
Media struct {
|
||||
Photos []struct {
|
||||
URL string `json:"url"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
} `json:"photos"`
|
||||
Videos []struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"videos"`
|
||||
} `json:"media"`
|
||||
IsNoteTweet bool `json:"is_note_tweet"`
|
||||
} `json:"tweet"`
|
||||
}
|
||||
|
||||
// twitterSyndicationResponse represents the Twitter syndication API response (fallback)
|
||||
type twitterSyndicationResponse struct {
|
||||
Text string `json:"text"`
|
||||
User struct {
|
||||
|
|
@ -138,7 +163,91 @@ type twitterSyndicationResponse struct {
|
|||
} `json:"video"`
|
||||
}
|
||||
|
||||
func fetchTwitter(ctx context.Context, tweetID string, originalURL string) (*VideoInfo, error) {
|
||||
func fetchTwitter(ctx context.Context, tweetID string, originalURL string) (*EmbedInfo, error) {
|
||||
// Extract username from URL for FxTwitter API
|
||||
matches := twitterRegex.FindStringSubmatch(originalURL)
|
||||
if len(matches) < 3 {
|
||||
return nil, fmt.Errorf("invalid twitter URL format")
|
||||
}
|
||||
username := matches[1]
|
||||
|
||||
// Try FxTwitter API first (supports full note tweets)
|
||||
fxURL := fmt.Sprintf("https://api.fxtwitter.com/%s/status/%s", username, tweetID)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", fxURL, nil)
|
||||
if err == nil {
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; Lookbook/1.0)")
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err == nil {
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
var fxResp fxTwitterResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&fxResp); err == nil && fxResp.Code == 200 {
|
||||
// Successfully got data from FxTwitter
|
||||
return parseFxTwitterResponse(&fxResp, tweetID, originalURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to syndication API (may have truncated text for note tweets)
|
||||
return fetchTwitterSyndication(ctx, tweetID, originalURL)
|
||||
}
|
||||
|
||||
func parseFxTwitterResponse(fxResp *fxTwitterResponse, tweetID string, originalURL string) (*EmbedInfo, error) {
|
||||
tweet := &fxResp.Tweet
|
||||
|
||||
// Collect media
|
||||
var thumbnailURL, videoURL string
|
||||
var thumbnailURLs []string
|
||||
var mediaType string
|
||||
|
||||
if len(tweet.Media.Videos) > 0 {
|
||||
videoURL = tweet.Media.Videos[0].URL
|
||||
mediaType = "video"
|
||||
// Videos usually have a poster/thumbnail in photos
|
||||
if len(tweet.Media.Photos) > 0 {
|
||||
thumbnailURL = tweet.Media.Photos[0].URL
|
||||
}
|
||||
} else if len(tweet.Media.Photos) > 0 {
|
||||
thumbnailURL = tweet.Media.Photos[0].URL
|
||||
for _, photo := range tweet.Media.Photos {
|
||||
thumbnailURLs = append(thumbnailURLs, photo.URL)
|
||||
}
|
||||
mediaType = "images"
|
||||
} else {
|
||||
mediaType = "text"
|
||||
}
|
||||
|
||||
// Build embed HTML using Twitter's embed widget
|
||||
embedHTML := fmt.Sprintf(
|
||||
`<blockquote class="twitter-tweet"><a href="%s"></a></blockquote><script async src="https://platform.twitter.com/widgets.js"></script>`,
|
||||
originalURL,
|
||||
)
|
||||
|
||||
title := fmt.Sprintf("@%s", tweet.Author.ScreenName)
|
||||
if tweet.Author.Name != "" {
|
||||
title = fmt.Sprintf("%s (@%s)", tweet.Author.Name, tweet.Author.ScreenName)
|
||||
}
|
||||
|
||||
// Clean up tweet text - remove trailing t.co URLs
|
||||
description := tcoRegex.ReplaceAllString(tweet.Text, "")
|
||||
description = strings.TrimSpace(description)
|
||||
|
||||
return &EmbedInfo{
|
||||
Provider: ProviderTwitter,
|
||||
VideoID: tweetID,
|
||||
Title: title,
|
||||
Description: description,
|
||||
ThumbnailURL: thumbnailURL,
|
||||
ThumbnailURLs: thumbnailURLs,
|
||||
VideoURL: videoURL,
|
||||
EmbedHTML: embedHTML,
|
||||
MediaType: mediaType,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func fetchTwitterSyndication(ctx context.Context, tweetID string, originalURL string) (*EmbedInfo, error) {
|
||||
apiURL := fmt.Sprintf("https://cdn.syndication.twimg.com/tweet-result?id=%s&token=0", tweetID)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
|
||||
|
|
@ -166,12 +275,15 @@ func fetchTwitter(ctx context.Context, tweetID string, originalURL string) (*Vid
|
|||
// Find thumbnail and video URL from media
|
||||
var thumbnailURL, videoURL string
|
||||
var thumbnailURLs []string
|
||||
var mediaType string
|
||||
|
||||
if len(tweet.Photos) > 0 {
|
||||
thumbnailURL = tweet.Photos[0].URL
|
||||
// Collect all photo URLs for multi-image tweets
|
||||
for _, photo := range tweet.Photos {
|
||||
thumbnailURLs = append(thumbnailURLs, photo.URL)
|
||||
}
|
||||
mediaType = "images"
|
||||
} else if len(tweet.MediaDetails) > 0 {
|
||||
media := tweet.MediaDetails[0]
|
||||
thumbnailURL = media.MediaURLHTTPS
|
||||
|
|
@ -185,8 +297,12 @@ func fetchTwitter(ctx context.Context, tweetID string, originalURL string) (*Vid
|
|||
videoURL = v.URL
|
||||
}
|
||||
}
|
||||
mediaType = "video"
|
||||
}
|
||||
} else {
|
||||
mediaType = "text"
|
||||
}
|
||||
|
||||
if thumbnailURL == "" && tweet.Video.Poster != "" {
|
||||
thumbnailURL = tweet.Video.Poster
|
||||
}
|
||||
|
|
@ -206,7 +322,7 @@ func fetchTwitter(ctx context.Context, tweetID string, originalURL string) (*Vid
|
|||
description := tcoRegex.ReplaceAllString(tweet.Text, "")
|
||||
description = strings.TrimSpace(description)
|
||||
|
||||
return &VideoInfo{
|
||||
return &EmbedInfo{
|
||||
Provider: ProviderTwitter,
|
||||
VideoID: tweetID,
|
||||
Title: title,
|
||||
|
|
@ -215,6 +331,7 @@ func fetchTwitter(ctx context.Context, tweetID string, originalURL string) (*Vid
|
|||
ThumbnailURLs: thumbnailURLs,
|
||||
VideoURL: videoURL,
|
||||
EmbedHTML: embedHTML,
|
||||
MediaType: mediaType,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue