this repo has no description
1
fork

Configure Feed

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

Merge pull request #14 from websages/opengraph_previews

feat: OpenGraph previews

authored by

Michael Stahnke and committed by
GitHub
4bfe3d26 91f3000e

+298 -1
+50
htdocs/css/screen.css
··· 127 127 letter-spacing: -1px; 128 128 text-align: right; 129 129 } 130 + 131 + .og-preview { 132 + margin-top: 10px; 133 + max-width: 500px; 134 + } 135 + 136 + .og-preview:empty { 137 + display: none; 138 + } 139 + 140 + .og-preview-card { 141 + border: 1px solid #e1e8ed; 142 + border-radius: 8px; 143 + overflow: hidden; 144 + background: #fff; 145 + max-width: 500px; 146 + margin-top: 8px; 147 + box-shadow: 0 1px 2px rgba(0,0,0,0.05); 148 + } 149 + 150 + .og-preview-image { 151 + width: 100%; 152 + overflow: hidden; 153 + display: block; 154 + } 155 + 156 + .og-preview-image img { 157 + width: 100%; 158 + max-height: 400px; 159 + object-fit: cover; 160 + display: block; 161 + } 162 + 163 + .og-preview-content { 164 + padding: 12px; 165 + } 166 + 167 + .og-preview-title { 168 + font-size: 14px; 169 + font-weight: bold; 170 + color: #14171a; 171 + margin-bottom: 4px; 172 + line-height: 1.4; 173 + } 174 + 175 + .og-preview-description { 176 + font-size: 13px; 177 + color: #657786; 178 + line-height: 1.4; 179 + }
+143
htdocs/ogpreview.cgi
··· 1 + #!/usr/bin/perl -w 2 + 3 + BEGIN { unshift @INC, '../lib'; } 4 + 5 + use CGI; 6 + use LWP::UserAgent; 7 + use JSON; 8 + 9 + use strict; 10 + 11 + # Try to use HTML::TreeBuilder if available, otherwise fall back to regex parsing 12 + eval { 13 + require HTML::TreeBuilder; 14 + HTML::TreeBuilder->import(); 15 + }; 16 + my $has_treebuilder = !$@; 17 + 18 + my $cgi = new CGI; 19 + my $url = $cgi->param('url'); 20 + 21 + unless ($url) { 22 + print "Content-type: application/json\n\n"; 23 + print encode_json({ error => 'No URL provided' }); 24 + exit; 25 + } 26 + 27 + # Validate URL 28 + unless ($url =~ /^https?:\/\//) { 29 + print "Content-type: application/json\n\n"; 30 + print encode_json({ error => 'Invalid URL' }); 31 + exit; 32 + } 33 + 34 + my $agentString = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.16; rv:84.0) Gecko/20100101 Firefox/84.0'; 35 + 36 + my $agent = LWP::UserAgent->new( 37 + ssl_opts => { verify_hostname => 0 }, 38 + protocols_allowed => ['https', 'http'], 39 + timeout => 10, 40 + ); 41 + $agent->agent($agentString); 42 + 43 + my $response = $agent->get($url); 44 + 45 + unless ($response->is_success) { 46 + print "Content-type: application/json\n\n"; 47 + print encode_json({ error => 'Failed to fetch URL' }); 48 + exit; 49 + } 50 + 51 + my $content = $response->decoded_content; 52 + my $result = {}; 53 + 54 + if ($has_treebuilder) { 55 + # Use HTML::TreeBuilder if available 56 + my $tree = HTML::TreeBuilder->new; 57 + $tree->parse($content); 58 + $tree->eof(); 59 + 60 + # Extract Open Graph tags 61 + my @og_tags = $tree->look_down(_tag => 'meta', sub { 62 + my $attr = $_[0]->attr('property'); 63 + return $attr && $attr =~ /^og:/; 64 + }); 65 + 66 + foreach my $tag (@og_tags) { 67 + my $property = $tag->attr('property'); 68 + my $content = $tag->attr('content'); 69 + if ($property && $content) { 70 + $property =~ s/^og://; 71 + $result->{$property} = $content; 72 + } 73 + } 74 + 75 + # Extract Twitter Card tags 76 + my @twitter_tags = $tree->look_down(_tag => 'meta', sub { 77 + my $attr = $_[0]->attr('name'); 78 + return $attr && $attr =~ /^twitter:/; 79 + }); 80 + 81 + foreach my $tag (@twitter_tags) { 82 + my $name = $tag->attr('name'); 83 + my $content = $tag->attr('content'); 84 + if ($name && $content) { 85 + $name =~ s/^twitter://; 86 + $result->{"twitter_$name"} = $content; 87 + } 88 + } 89 + 90 + # Fallback to standard meta tags if no OG/Twitter tags found 91 + unless (keys %$result) { 92 + my $title_tag = $tree->look_down(_tag => 'title'); 93 + if ($title_tag) { 94 + $result->{title} = $title_tag->as_text; 95 + } 96 + 97 + my $desc_tag = $tree->look_down(_tag => 'meta', sub { 98 + $_[0]->attr('name') && lc($_[0]->attr('name')) eq 'description'; 99 + }); 100 + if ($desc_tag) { 101 + $result->{description} = $desc_tag->attr('content'); 102 + } 103 + } 104 + 105 + $tree->delete(); 106 + } else { 107 + # Fallback to regex parsing if HTML::TreeBuilder is not available 108 + # Extract Open Graph tags - try both attribute orders 109 + while ($content =~ /<meta\s+property=["']og:([^"']+)["']\s+content=["']([^"']+)["']/gi || 110 + $content =~ /<meta\s+content=["']([^"']+)["']\s+property=["']og:([^"']+)["']/gi) { 111 + my $property = $1 || $4; 112 + my $value = $2 || $3; 113 + if ($property && $value) { 114 + $result->{$property} = $value; 115 + } 116 + } 117 + 118 + # Extract Twitter Card tags - try both attribute orders 119 + while ($content =~ /<meta\s+name=["']twitter:([^"']+)["']\s+content=["']([^"']+)["']/gi || 120 + $content =~ /<meta\s+content=["']([^"']+)["']\s+name=["']twitter:([^"']+)["']/gi) { 121 + my $name = $1 || $4; 122 + my $value = $2 || $3; 123 + if ($name && $value) { 124 + $result->{"twitter_$name"} = $value; 125 + } 126 + } 127 + 128 + # Extract title 129 + if ($content =~ /<title[^>]*>([^<]+)<\/title>/i) { 130 + $result->{title} = $1 unless $result->{title}; 131 + } 132 + 133 + # Extract description meta tag - try both attribute orders 134 + if ($content =~ /<meta\s+name=["']description["']\s+content=["']([^"']+)["']/i || 135 + $content =~ /<meta\s+content=["']([^"']+)["']\s+name=["']description["']/i) { 136 + my $desc = $1; 137 + $result->{description} = $desc unless $result->{description} || !$desc; 138 + } 139 + } 140 + 141 + print "Content-type: application/json\n\n"; 142 + print encode_json($result); 143 +
+103
htdocs/thtml/index.thtml
··· 18 18 </script> 19 19 <!-- Twitter Cards --> 20 20 <script>!function(d,s,id){var js,fjs=d.getElementsByTagName(s)[0],p=/^http:/.test(d.location)?'http':'https';if(!d.getElementById(id)){js=d.createElement(s);js.id=id;js.src=p+"://platform.twitter.com/widgets.js";fjs.parentNode.insertBefore(js,fjs);}}(document,"script","twitter-wjs");</script> 21 + 22 + <!-- Open Graph Preview Loader --> 23 + <script type="text/javascript"> 24 + (function() { 25 + function loadOGPreview(item) { 26 + var url = item.getAttribute('data-url'); 27 + var contentType = item.getAttribute('data-content-type'); 28 + var previewDiv = item.querySelector('.og-preview'); 29 + 30 + // Only load preview for non-image content (text/html) 31 + if (!url || (contentType && contentType.match(/image/))) { 32 + return; 33 + } 34 + 35 + // Ensure URL has protocol 36 + if (!url.match(/^https?:\/\//)) { 37 + url = 'http://' + url; 38 + } 39 + 40 + // Create preview endpoint URL 41 + var previewUrl = '/ogpreview.cgi?url=' + encodeURIComponent(url); 42 + 43 + // Load preview asynchronously 44 + var xhr = new XMLHttpRequest(); 45 + xhr.open('GET', previewUrl, true); 46 + xhr.onreadystatechange = function() { 47 + if (xhr.readyState === 4 && xhr.status === 200) { 48 + try { 49 + var data = JSON.parse(xhr.responseText); 50 + if (data.error) { 51 + return; 52 + } 53 + 54 + // Use Open Graph image, Twitter image, or nothing 55 + var imageUrl = data.image || data.twitter_image || null; 56 + // Use Open Graph title, Twitter title, or fallback title 57 + var title = data.title || data.twitter_title || null; 58 + // Use Open Graph description, Twitter description, or fallback description 59 + var description = data.description || data.twitter_description || null; 60 + 61 + // Only show preview if we have at least an image, title, or description 62 + if (!imageUrl && !title && !description) { 63 + return; 64 + } 65 + 66 + // Build preview HTML 67 + var previewHTML = '<div class="og-preview-card">'; 68 + 69 + if (imageUrl) { 70 + previewHTML += '<div class="og-preview-image"><img src="' + escapeHtml(imageUrl) + '" alt="" /></div>'; 71 + } 72 + 73 + if (title || description) { 74 + previewHTML += '<div class="og-preview-content">'; 75 + 76 + if (title) { 77 + previewHTML += '<div class="og-preview-title">' + escapeHtml(title) + '</div>'; 78 + } 79 + 80 + if (description) { 81 + // Truncate description if too long 82 + if (description.length > 200) { 83 + description = description.substring(0, 200) + '...'; 84 + } 85 + previewHTML += '<div class="og-preview-description">' + escapeHtml(description) + '</div>'; 86 + } 87 + 88 + previewHTML += '</div>'; 89 + } 90 + 91 + previewHTML += '</div>'; 92 + 93 + previewDiv.innerHTML = previewHTML; 94 + } catch (e) { 95 + // Silently fail if JSON parsing fails 96 + } 97 + } 98 + }; 99 + xhr.send(); 100 + } 101 + 102 + function escapeHtml(text) { 103 + var div = document.createElement('div'); 104 + div.textContent = text; 105 + return div.innerHTML; 106 + } 107 + 108 + // Load all previews when DOM is ready 109 + function initOGPreviews() { 110 + var items = document.querySelectorAll('.item[data-url]'); 111 + for (var i = 0; i < items.length; i++) { 112 + loadOGPreview(items[i]); 113 + } 114 + } 115 + 116 + // Run when DOM is ready 117 + if (document.readyState === 'loading') { 118 + document.addEventListener('DOMContentLoaded', initOGPreviews); 119 + } else { 120 + initOGPreviews(); 121 + } 122 + })(); 123 + </script> 21 124 22 125 </head> 23 126
+2 -1
htdocs/thtml/tumble_item_ircLink.thtml
··· 1 - <div class="item"> 1 + <div class="item" data-url="<!-- tmpl_var name="url" -->" data-content-type="<!-- tmpl_var name="content_type" -->" data-irc-link-id="<!-- tmpl_var name="ircLinkID" -->"> 2 2 <span class="link"><!-- tmpl_var name="content" --></span> 3 3 <span class="author"><!-- tmpl_var name="author" --></span> 4 + <div class="og-preview" id="og-preview-<!-- tmpl_var name="ircLinkID" -->"></div> 4 5 </div>