this repo has no description
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: oembed for major websites

+322 -182
+181 -124
internal/handler/preview.go
··· 4 4 "encoding/json" 5 5 "fmt" 6 6 "net/http" 7 + "net/url" 8 + "regexp" 7 9 "strings" 8 10 9 11 "golang.org/x/net/html" 10 12 ) 11 13 14 + // OEmbed Providers Configuration 15 + var oembedProviders = []struct { 16 + Pattern string 17 + Endpoint string 18 + Format string // "json" (default) or "xml" (not used here yet as we assume json) 19 + }{ 20 + {`^https?://(www\.)?(youtube\.com|youtu\.be)/.+`, "https://www.youtube.com/oembed", ""}, 21 + {`^https?://(open\.)?spotify\.com/.+`, "https://open.spotify.com/oembed", ""}, 22 + {`^https?://(www\.)?tiktok\.com/.+`, "https://www.tiktok.com/oembed", ""}, 23 + {`^https?://(www\.)?reddit\.com/.+`, "https://www.reddit.com/oembed", ""}, 24 + {`^https?://(www\.)?flickr\.com/.+`, "https://www.flickr.com/services/oembed", ""}, 25 + {`^https?://(www\.|mobile\.)?(twitter|x)\.com/.+`, "https://publish.twitter.com/oembed", ""}, 26 + {`^https?://(www\.)?instagram\.com/.+`, "https://api.instagram.com/oembed", ""}, // Note: Often requires token 27 + {`^https?://(www\.)?dailymotion\.com/.+`, "https://www.dailymotion.com/services/oembed", ""}, 28 + {`^https?://(www\.)?kickstarter\.com/projects/.+`, "https://www.kickstarter.com/services/oembed", ""}, 29 + {`^https?://(www\.)?slideshare\.net/.+`, "https://www.slideshare.net/api/oembed/2", ""}, 30 + {`^https?://speakerdeck\.com/.+`, "https://speakerdeck.com/oembed.json", ""}, 31 + {`^https?://giphy\.com/gifs/.+`, "https://giphy.com/services/oembed", ""}, 32 + } 33 + 34 + // OEmbedResponse represents standard OEmbed keys 35 + type OEmbedResponse struct { 36 + Type string `json:"type"` 37 + Version string `json:"version"` 38 + Title string `json:"title"` 39 + AuthorName string `json:"author_name"` 40 + AuthorURL string `json:"author_url"` 41 + ProviderName string `json:"provider_name"` 42 + ProviderURL string `json:"provider_url"` 43 + CacheAge int64 `json:"cache_age"` 44 + ThumbnailURL string `json:"thumbnail_url"` 45 + ThumbnailW int `json:"thumbnail_width"` 46 + ThumbnailH int `json:"thumbnail_height"` 47 + HTML string `json:"html"` 48 + Width int `json:"width"` 49 + Height int `json:"height"` 50 + Description string `json:"description"` // Non-standard but common 51 + URL string `json:"url"` // Required for type=photo 52 + } 53 + 12 54 // OGPreviewHandler handles /ogpreview.cgi 13 55 func (h *Handler) OGPreviewHandler(w http.ResponseWriter, r *http.Request) { 14 56 urlParam := r.URL.Query().Get("url") ··· 19 61 return 20 62 } 21 63 22 - // Reddit JSON API (better than oEmbed) 23 - if strings.Contains(urlParam, "reddit.com") { 24 - if meta, err := h.fetchRedditJSON(urlParam); err == nil { 25 - json.NewEncoder(w).Encode(meta) 64 + // 1. Try OEmbed 65 + if meta, err := h.tryOEmbed(urlParam); err == nil { 66 + // For Twitter/X, if successful, we might still want to return empty object to avoid duplicating 67 + // the widget embedded by server-side logic? 68 + // User request implies utilizing OEmbed. If server-side generates a widget, and we generate a card, 69 + // we have duplication. 70 + // However, returning OEmbed data allows the frontend to optionally replace or augment. 71 + // Current existing logic for Twitter was: 72 + // "If valid (200), return empty success so frontend keeps the existing embed" 73 + // If we stick to that for Twitter/X ONLY: 74 + if strings.Contains(urlParam, "twitter.com") || strings.Contains(urlParam, "x.com") { 75 + json.NewEncoder(w).Encode(map[string]string{}) 26 76 return 27 77 } 28 - // Fallback to normal scraping 78 + 79 + json.NewEncoder(w).Encode(meta) 80 + return 29 81 } 30 82 31 - // Twitter / X OEmbed (to detect deletions) 32 - if strings.Contains(urlParam, "twitter.com") || strings.Contains(urlParam, "x.com") { 33 - status, err := h.fetchTwitterStatus(urlParam) 34 - if err != nil || status == 404 || status == 403 { 35 - w.Header().Set("Content-Type", "application/json") 36 - json.NewEncoder(w).Encode(map[string]interface{}{ 37 - "error": "Tweet Unavailable", 38 - "status": status, 39 - }) 40 - return 83 + // 2. Fallback to generic scraping 84 + h.fetchOGScrape(w, urlParam) 85 + } 86 + 87 + func (h *Handler) tryOEmbed(targetURL string) (map[string]string, error) { 88 + var endpoint string 89 + for _, p := range oembedProviders { 90 + if matched, _ := regexp.MatchString(p.Pattern, targetURL); matched { 91 + endpoint = p.Endpoint 92 + break 41 93 } 42 - // If valid (200), return empty success so frontend keeps the existing embed 43 - // and doesn't render a duplicate card. 44 - json.NewEncoder(w).Encode(map[string]string{}) 45 - return 94 + } 95 + 96 + if endpoint == "" { 97 + return nil, fmt.Errorf("no provider") 98 + } 99 + 100 + // Build OEmbed URL 101 + reqURL := fmt.Sprintf("%s?url=%s&format=json", endpoint, url.QueryEscape(targetURL)) 102 + 103 + req, err := http.NewRequest("GET", reqURL, nil) 104 + if err != nil { 105 + return nil, err 106 + } 107 + // Use strict browser UA to avoid bot detection (Kickstarter, TikTok, etc.) 108 + req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") 109 + 110 + client := &http.Client{} 111 + resp, err := client.Do(req) 112 + if err != nil { 113 + return nil, err 114 + } 115 + defer resp.Body.Close() 116 + 117 + if resp.StatusCode >= 400 { 118 + return nil, fmt.Errorf("oembed status %d", resp.StatusCode) 119 + } 120 + 121 + var data OEmbedResponse 122 + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { 123 + return nil, err 124 + } 125 + 126 + // Validate "richness" of data 127 + // If we get just a title (TikTok sometimes), it's not enough for a preview card if we want rich media. 128 + // We need at least an Image OR HTML. 129 + // For type=photo, URL is the image. 130 + hasImage := data.ThumbnailURL != "" || (data.Type == "photo" && data.URL != "") 131 + if !hasImage && data.HTML == "" { 132 + // If we only have title, maybe fallback to OG is better? 133 + return nil, fmt.Errorf("incomplete oembed data") 134 + } 135 + 136 + // Map to our expected metadata format 137 + meta := make(map[string]string) 138 + meta["title"] = data.Title 139 + meta["provider_name"] = data.ProviderName 140 + meta["type"] = data.Type 141 + 142 + // Icon Logic for OEmbed 143 + if data.ProviderURL != "" { 144 + meta["icon"] = fmt.Sprintf("https://www.google.com/s2/favicons?domain=%s&sz=32", data.ProviderURL) 145 + } 146 + 147 + // Image preference 148 + if data.ThumbnailURL != "" { 149 + meta["image"] = data.ThumbnailURL 150 + } else if data.Type == "photo" && data.URL != "" { 151 + meta["image"] = data.URL 152 + } 153 + 154 + // Description 155 + // If standard OEmbed doesn't have it, we might want to leave it empty or use Author? 156 + if data.Description != "" { 157 + meta["description"] = data.Description 158 + } else if data.AuthorName != "" { 159 + meta["description"] = fmt.Sprintf("By %s", data.AuthorName) 160 + } 161 + 162 + // Pass the embed HTML for video/rich types 163 + if (data.Type == "video" || data.Type == "rich") && data.HTML != "" { 164 + meta["embed_html"] = data.HTML 46 165 } 47 166 167 + return meta, nil 168 + } 169 + 170 + func (h *Handler) fetchOGScrape(w http.ResponseWriter, urlParam string) { 48 171 // Fetch data 49 172 req, err := http.NewRequest("GET", urlParam, nil) 50 173 if err != nil { 51 174 json.NewEncoder(w).Encode(map[string]string{"error": "Invalid URL"}) 52 175 return 53 176 } 54 - // Use a standard browser UA to avoid 403s (e.g. Wikipedia) 177 + // Use a standard browser UA to avoid 403s 55 178 req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") 56 179 57 180 client := &http.Client{} ··· 127 250 } 128 251 } 129 252 } 253 + // Look for link rel=icon 254 + if n.Type == html.ElementNode && n.Data == "link" { 255 + var rel, href string 256 + for _, a := range n.Attr { 257 + if a.Key == "rel" { 258 + rel = a.Val 259 + } 260 + if a.Key == "href" { 261 + href = a.Val 262 + } 263 + } 264 + if (rel == "icon" || rel == "shortcut icon") && href != "" { 265 + // Resolve absolute URL 266 + if strings.HasPrefix(href, "http") { 267 + metadata["icon"] = href 268 + } else if strings.HasPrefix(href, "//") { 269 + metadata["icon"] = "https:" + href 270 + } else if strings.HasPrefix(href, "/") { 271 + // Need base URL. 272 + // Simple hack: use the scheme/host from urlParam 273 + u, _ := url.Parse(urlParam) 274 + if u != nil { 275 + metadata["icon"] = fmt.Sprintf("%s://%s%s", u.Scheme, u.Host, href) 276 + } 277 + } 278 + } 279 + } 280 + 130 281 // Also look for title tag 131 282 if n.Type == html.ElementNode && n.Data == "title" { 132 283 if n.FirstChild != nil { ··· 140 291 if n.Type == html.ElementNode && n.Data == "p" { 141 292 if _, hasDesc := metadata["description"]; !hasDesc { 142 293 text := strings.TrimSpace(extractText(n)) 143 - // Wikipedia paragraphs often have citations [1] or are empty/short 144 294 // Simple heuristic: length > 50 145 295 if len(text) > 50 { 146 - // Check for "Coordinates:" which matches length but isn't intro 147 296 if !strings.HasPrefix(text, "Coordinates:") { 148 297 metadata["description"] = text 149 298 } ··· 157 306 } 158 307 f(doc) 159 308 160 - // Check for YouTube "soft 404" (Video unavailable) 161 - // YouTube returns 200 but minimal metadata for unavailable videos. 309 + // Fallback icon if not found in Scrape 310 + if metadata["icon"] == "" { 311 + // Try using google favicon service on the scraped URL 312 + u, _ := url.Parse(urlParam) 313 + if u != nil { 314 + metadata["icon"] = fmt.Sprintf("https://www.google.com/s2/favicons?domain=%s://%s&sz=32", u.Scheme, u.Host) 315 + } 316 + } 317 + 318 + // Check for YouTube "soft 404" 162 319 if strings.Contains(urlParam, "youtube.com") || strings.Contains(urlParam, "youtu.be") { 163 320 title, hasTitle := metadata["title"] 164 - // Valid videos usually have a specific title in og:title or title tag. 165 - // Unavailable videos often have just "- YouTube" or no og:title. 166 321 if !hasTitle || title == " - YouTube" || title == "YouTube" { 167 322 w.Header().Set("Content-Type", "application/json") 168 323 json.NewEncoder(w).Encode(map[string]interface{}{ ··· 171 326 }) 172 327 return 173 328 } 174 - // Force type to video for YouTube 175 329 metadata["type"] = "video" 176 330 } 177 331 178 332 json.NewEncoder(w).Encode(metadata) 179 333 } 180 - 181 - func (h *Handler) fetchRedditJSON(url string) (map[string]string, error) { 182 - jsonURL := url + ".json" 183 - req, err := http.NewRequest("GET", jsonURL, nil) 184 - if err != nil { 185 - return nil, err 186 - } 187 - // Unique UA to ensure access 188 - req.Header.Set("User-Agent", "Tumble/1.0 (internal tool; +http://tumble.example.com)") 189 - 190 - client := &http.Client{} 191 - resp, err := client.Do(req) 192 - if err != nil { 193 - return nil, err 194 - } 195 - defer resp.Body.Close() 196 - 197 - if resp.StatusCode != 200 { 198 - return nil, fmt.Errorf("bad status: %d", resp.StatusCode) 199 - } 200 - 201 - var data []interface{} 202 - if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { 203 - return nil, err 204 - } 205 - 206 - if len(data) == 0 { 207 - return nil, fmt.Errorf("no data") 208 - } 209 - 210 - meta := make(map[string]string) 211 - meta["provider_name"] = "Reddit" 212 - 213 - // Traverse: [0] -> data -> children -> [0] -> data 214 - if listing, ok := data[0].(map[string]interface{}); ok { 215 - if dataObj, ok := listing["data"].(map[string]interface{}); ok { 216 - if children, ok := dataObj["children"].([]interface{}); ok && len(children) > 0 { 217 - if child, ok := children[0].(map[string]interface{}); ok { 218 - if post, ok := child["data"].(map[string]interface{}); ok { 219 - if title, ok := post["title"].(string); ok { 220 - meta["title"] = title 221 - meta["og:title"] = title 222 - } 223 - 224 - // Construct description 225 - author, _ := post["author"].(string) 226 - sub, _ := post["subreddit_name_prefixed"].(string) 227 - if author != "" && sub != "" { 228 - meta["description"] = fmt.Sprintf("Posted by u/%s in %s", author, sub) 229 - } 230 - 231 - if hint, ok := post["post_hint"].(string); ok { 232 - if hint == "hosted:video" || hint == "rich:video" { 233 - meta["type"] = "video" 234 - } 235 - } 236 - 237 - // Image extraction 238 - // 1. Try 'preview' images (highest quality usually) 239 - foundImage := false 240 - if preview, ok := post["preview"].(map[string]interface{}); ok { 241 - if images, ok := preview["images"].([]interface{}); ok && len(images) > 0 { 242 - if img, ok := images[0].(map[string]interface{}); ok { 243 - if source, ok := img["source"].(map[string]interface{}); ok { 244 - if u, ok := source["url"].(string); ok { 245 - meta["image"] = strings.ReplaceAll(u, "&", "&") 246 - foundImage = true 247 - } 248 - } 249 - } 250 - } 251 - } 252 - 253 - // 2. Fallback to 'thumbnail' if valid URL 254 - if !foundImage { 255 - if thumb, ok := post["thumbnail"].(string); ok && strings.HasPrefix(thumb, "http") { 256 - meta["image"] = thumb 257 - } 258 - } 259 - } 260 - } 261 - } 262 - } 263 - } 264 - 265 - return meta, nil 266 - } 267 - 268 - func (h *Handler) fetchTwitterStatus(url string) (int, error) { 269 - oembedURL := "https://publish.twitter.com/oembed?url=" + url 270 - resp, err := http.Get(oembedURL) 271 - if err != nil { 272 - return 0, err 273 - } 274 - defer resp.Body.Close() 275 - return resp.StatusCode, nil 276 - }
+114 -58
internal/templates/views/index.html
··· 50 50 return; 51 51 } 52 52 53 - 54 - 55 53 // Ensure URL has protocol 56 54 if (!url.match(/^https?:\/\//)) { 57 55 url = "http://" + url; ··· 64 62 var xhr = new XMLHttpRequest(); 65 63 xhr.open("GET", previewUrl, true); 66 64 xhr.onreadystatechange = function () { 67 - if (xhr.readyState === 4 && xhr.status === 200) { 68 - try { 69 - var data = JSON.parse(xhr.responseText); 65 + if (xhr.readyState === 4) { 66 + if (xhr.status === 200) { 67 + try { 68 + var data = JSON.parse(xhr.responseText); 69 + handlePreviewData(data); 70 + } catch (e) { 71 + // Silently fail or remove 72 + previewDiv.remove(); 73 + } 74 + } else { 75 + previewDiv.remove(); 76 + } 77 + } 78 + }; 79 + xhr.send(); 70 80 71 - // Check for explicit error or HTTP error status from backend 72 - if (data.error || (data.status && data.status >= 400)) { 73 - var status = data.status || 404; 74 - // Find the link container in the parent item 75 - var linkSpan = item.querySelector(".link"); 76 - if (linkSpan) { 77 - // Completely replace content to remove potential broken embeds (Twitter/YouTube) 78 - // and show a clean error link. 79 - var statusPrefix = '<span class="http-error-badge">' + status + '</span> '; 80 - linkSpan.innerHTML = '<a href="' + escapeHtml(url) + '" class="missing-link" target="_blank">' + statusPrefix + escapeHtml(url) + '</a>'; 81 - } 82 - return; 81 + function handlePreviewData(data) { 82 + // Handle Errors 83 + if (data.error || (data.status && data.status >= 400)) { 84 + var status = data.status || 404; 85 + var linkSpan = item.querySelector(".link"); 86 + if (linkSpan) { 87 + var statusPrefix = '<span class="http-error-badge">' + status + '</span> '; 88 + linkSpan.innerHTML = '<a href="' + escapeHtml(url) + '" class="missing-link" target="_blank">' + statusPrefix + escapeHtml(url) + '</a>'; 83 89 } 90 + previewDiv.remove(); 91 + return; 92 + } 84 93 94 + // Client-side OEmbed Fallback (TikTok) 95 + // If missing embed_html but is TikTok, fetch from TikTok's OEmbed endpoint directly. 96 + if (!data.embed_html && url.match(/tiktok\.com/)) { 97 + var oembedUrl = 'https://www.tiktok.com/oembed?url=' + encodeURIComponent(url) + '&format=json'; 98 + fetch(oembedUrl) 99 + .then(function(response) { 100 + if (response.ok) return response.json(); 101 + throw new Error('Client-side OEmbed failed'); 102 + }) 103 + .then(function(oembedData) { 104 + if (oembedData.html) data.embed_html = oembedData.html; 105 + if (oembedData.thumbnail_url) data.image = oembedData.thumbnail_url; 106 + if (oembedData.title) data.title = oembedData.title; 107 + if (oembedData.provider_name) data.provider_name = oembedData.provider_name; 108 + data.type = "video"; 109 + renderPreview(data); 110 + }) 111 + .catch(function(e) { 112 + console.log("Fallback failed", e); 113 + renderPreview(data); 114 + }); 115 + return; 116 + } 117 + 118 + renderPreview(data); 119 + } 120 + 121 + function renderPreview(data) { 85 122 // Use Open Graph image, Twitter image, or nothing 86 123 var imageUrl = data.image || data.twitter_image || null; 87 - // Use Open Graph title, Twitter title, or fallback title 88 124 var title = data.title || data.twitter_title || null; 89 - // Use Open Graph description, Twitter description, or fallback description 90 - var description = 91 - data.description || data.twitter_description || null; 125 + var description = data.description || data.twitter_description || null; 92 126 93 127 // Only show preview if we have at least an image, title, or description 94 - if (!imageUrl && !title && !description) { 128 + if (!imageUrl && !title && !description && !data.embed_html) { 95 129 return; 96 130 } 97 131 ··· 100 134 101 135 // Site Info (Icon + Name) 102 136 var provider = data.provider_name || "Link"; 103 - var iconUrl = "/favicon.ico"; 104 - if (provider === "Reddit") 105 - iconUrl = 106 - "https://www.redditstatic.com/desktop2x/img/favicon/favicon-32x32.png"; 137 + var iconUrl = data.icon || "/favicon.ico"; 138 + if (provider === "Reddit" && !data.icon) 139 + iconUrl = "https://www.redditstatic.com/desktop2x/img/favicon/favicon-32x32.png"; 107 140 108 141 previewHTML += 109 142 '<div class="og-site-info"><img src="' + 110 - iconUrl + 143 + escapeHtml(iconUrl) + 111 144 '" class="og-site-icon" /> ' + 112 145 escapeHtml(provider) + 113 146 "</div>"; 114 147 115 - // Title 148 + // Image 149 + if (imageUrl) { 150 + var imageContent = '<img src="' + escapeHtml(imageUrl) + '" alt="" />'; 151 + var onClickAttr = ''; 152 + 153 + // If it's a video, add the play button overlay 154 + if (data.type === "video" || data.type === "rich") { 155 + imageContent += '<div class="og-play-button"></div>'; 156 + onClickAttr = 'onclick="replaceWithVideo(this)"'; 157 + } 158 + 159 + var embedHtmlAttr = ''; 160 + if (data.embed_html) { 161 + embedHtmlAttr = ' data-embed-html="' + encodeURIComponent(data.embed_html) + '"'; 162 + } 163 + 164 + previewHTML += 165 + '<div class="og-image ' + 166 + (data.type === "video" || data.type === "rich" ? "is-video" : "") + 167 + '" ' + onClickAttr + embedHtmlAttr + '>' + 168 + imageContent + 169 + "</div>"; 170 + } 171 + 172 + // Text Conten 173 + previewHTML += '<div class="og-content">'; 116 174 if (title) { 117 175 previewHTML += 118 176 '<div class="og-title"><a href="' + ··· 121 179 escapeHtml(title) + 122 180 "</a></div>"; 123 181 } 124 - 125 - // Description 126 182 if (description) { 127 183 if (description.length > 300) { 128 184 description = description.substring(0, 300) + "..."; ··· 132 188 escapeHtml(description) + 133 189 "</div>"; 134 190 } 135 - 136 - // Image 137 - if (imageUrl) { 138 - var imageContent = 139 - '<img src="' + escapeHtml(imageUrl) + '" alt="" />'; 140 - var onClickAttr = ''; 141 - 142 - // If it's a video, add the play button overlay 143 - if (data.type === "video") { 144 - imageContent += '<div class="og-play-button"></div>'; 145 - onClickAttr = 'onclick="replaceWithVideo(this)"'; 146 - } 147 - 148 - previewHTML += 149 - '<div class="og-image ' + 150 - (data.type === "video" ? "is-video" : "") + 151 - '" ' + onClickAttr + '>' + 152 - imageContent + 153 - "</div>"; 154 - } 155 - 156 - previewHTML += "</div>"; 191 + previewHTML += "</div>"; // end og-conten 192 + previewHTML += "</div>"; // end og-card 157 193 158 194 previewDiv.innerHTML = previewHTML; 159 - } catch (e) { 160 - // Silently fail if JSON parsing fails 161 - } 162 - } 163 - }; 164 - xhr.send(); 195 + } 165 196 } 166 197 167 198 function escapeHtml(text) { ··· 172 203 173 204 // Global handler for video replacemen 174 205 window.replaceWithVideo = function(container) { 206 + // Check for pre-fetched embed HTML 207 + var embedHtmlEncoded = container.getAttribute('data-embed-html'); 208 + if (embedHtmlEncoded) { 209 + var embedHtml = decodeURIComponent(embedHtmlEncoded); 210 + container.innerHTML = embedHtml; 211 + container.onclick = null; 212 + container.classList.remove('is-video'); 213 + container.style.cursor = 'default'; 214 + 215 + // Manually execute scripts (innerHTML does not execute them) 216 + var scripts = container.getElementsByTagName('script'); 217 + for (var i = 0; i < scripts.length; i++) { 218 + var oldScript = scripts[i]; 219 + var newScript = document.createElement('script'); 220 + if (oldScript.src) { 221 + newScript.src = oldScript.src; 222 + newScript.async = true; // Most embeds need async 223 + } else { 224 + newScript.textContent = oldScript.textContent; 225 + } 226 + oldScript.parentNode.replaceChild(newScript, oldScript); 227 + } 228 + return; 229 + } 230 + 175 231 // Find URL from parent item 176 232 var item = container.closest('.item'); 177 233 if (!item) return;
+27
tests/load_fixtures.sh
··· 51 51 $ADD_QUOTE_SCRIPT "Brian Kernighan" "Debugging is twice as hard as writing the code in the first place." 52 52 $ADD_QUOTE_SCRIPT "Simba" "Everything the light touches is our kingdom." 53 53 54 + # Spotify 55 + $ADD_LINK_SCRIPT "music_lover" "https://open.spotify.com/episode/7makk4oTQel546B0PZlDM5" 56 + 57 + # TikTok 58 + $ADD_LINK_SCRIPT "tiktok_star" "https://www.tiktok.com/@tiagogreis/video/6830059644233223429" 59 + 60 + # Flickr 61 + $ADD_LINK_SCRIPT "photog" "http://flickr.com/photos/bees/2362225867/" 62 + 63 + # Instagram 64 + $ADD_LINK_SCRIPT "insta_fan" "https://www.instagram.com/p/fA9uwTtkSN/" 65 + 66 + # Dailymotion 67 + $ADD_LINK_SCRIPT "video_daily" "https://www.dailymotion.com/video/x7tgad0" 68 + 69 + # Kickstarter 70 + $ADD_LINK_SCRIPT "backer" "https://www.kickstarter.com/projects/ouya/ouya-a-new-kind-of-video-game-console" 71 + 72 + # SlideShare 73 + $ADD_LINK_SCRIPT "presenter" "http://www.slideshare.net/lyndadotcom/code-drivesworld12" 74 + 75 + # Speaker Deck 76 + $ADD_LINK_SCRIPT "speaker" "https://speakerdeck.com/mislav/git" 77 + 78 + # Giphy 79 + $ADD_LINK_SCRIPT "gif_master" "https://giphy.com/gifs/cant-hardly-wait-kW8mnYSNkUYKc" 80 + 54 81 echo "Loading backdated 'Hot Links' directly into DB..." 55 82 sqlite3 tumble.sqlite < tests/fixtures_hot.sql 56 83