├── .gitignore ├── README.md ├── config.py ├── discordWorkaround.py ├── package-lock.json ├── package.json ├── requirements.txt ├── serverless.yml ├── templates ├── base.html ├── image.html ├── message.html ├── text.html └── video.html ├── videoCombiner └── __init__.py ├── vxreddit.ini ├── vxreddit.py ├── vxreddit.service └── wsgi.py /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | venv/ 3 | node_modules 4 | tmp/ 5 | *.conf 6 | .serverless/ -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vxreddit 2 | Basic website that serves Reddit posts with actual working embeds to various platforms (Discord, Telegram, etc.) by using Reddit's API. 3 | 4 | ## How to use the hosted version 5 | 6 | Just replace reddit.com with vxreddit.com on the link to the Reddit post: `https://www.reddit.com/r/subreddit/comments/postid/title` -> `https://vxreddit.com/r/subreddit/comments/postid/title` 7 | 8 | ## Limitations 9 | - The application relies on Reddit's JSON API, which may change or be removed. 10 | - Video conversion may require additional setup if not using the local option. -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | import os 3 | 4 | currentConfig = configparser.ConfigParser() 5 | 6 | ## default values 7 | 8 | currentConfig["MAIN"]={ 9 | "appName": "vxReddit", 10 | "embedColor": "#EE1D52", 11 | "repoURL":"https://github.com/dylanpdx/vxReddit", 12 | "domainName":"vxreddit.com", 13 | "videoConversion":"local", 14 | } 15 | 16 | if 'RUNNING_SERVERLESS' in os.environ and os.environ['RUNNING_SERVERLESS'] == '1': 17 | currentConfig["MAIN"]={ 18 | "appName": os.environ['APP_NAME'], 19 | "embedColor": "#EE1D52", 20 | "repoURL":os.environ['REPO_URL'], 21 | "domainName":os.environ['DOMAINNAME'], 22 | "videoConversion":os.environ['VIDEOCONVERSION'], 23 | } 24 | else: 25 | if os.path.exists("vxReddit.conf"): 26 | # as per python docs, "the most recently added configuration has the highest priority" 27 | # "conflicting keys are taken from the more recent configuration while the previously existing keys are retained" 28 | currentConfig.read("vxReddit.conf") 29 | 30 | with open("vxReddit.conf", "w") as configfile: 31 | currentConfig.write(configfile) # write current config to file -------------------------------------------------------------------------------- /discordWorkaround.py: -------------------------------------------------------------------------------- 1 | import base64 2 | from urllib.parse import quote 3 | 4 | def fixUrlForDiscord(url): 5 | # convert url to base64 6 | url = base64.b64encode(url.encode()).decode() 7 | # url encode 8 | url = quote(url,safe='') 9 | return f'https://redirect.dylanpdx.workers.dev/{url}' # TODO: don't hardcode this -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "devDependencies": { 3 | "serverless-plugin-common-excludes": "^4.0.0", 4 | "serverless-plugin-include-dependencies": "^5.1.0", 5 | "serverless-python-requirements": "^6.0.1", 6 | "serverless-wsgi": "^3.0.3" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Flask==2.3.3 2 | Flask-Cors==4.0.0 3 | yt-dlp==2023.7.6 4 | requests==2.31.0 5 | urllib3==1.26.6 6 | Werkzeug==2.3.7 -------------------------------------------------------------------------------- /serverless.yml: -------------------------------------------------------------------------------- 1 | service: vxReddit 2 | 3 | provider: 4 | name: aws 5 | runtime: python3.8 6 | stage: dev 7 | environment: 8 | APP_NAME: vxReddit 9 | EMBED_COLOR: \#EE1D52 10 | REPO_URL: https://github.com/dylanpdx/vxReddit 11 | DOMAINNAME: vxreddit.com 12 | VIDEOCONVERSION: local 13 | RUNNING_SERVERLESS: 1 14 | 15 | package: 16 | patterns: 17 | - '!node_modules/**' 18 | - '!venv/**' 19 | 20 | plugins: 21 | - serverless-wsgi 22 | - serverless-python-requirements 23 | - serverless-plugin-common-excludes 24 | - serverless-plugin-include-dependencies 25 | 26 | functions: 27 | vxRedditApp: 28 | handler: wsgi_handler.handler 29 | url: true 30 | timeout: 15 31 | memorySize: 250 32 | layers: 33 | - Ref: PythonRequirementsLambdaLayer 34 | - arn:aws:lambda:us-east-1:001057775987:layer:ffmpeg:2 35 | 36 | 37 | custom: 38 | wsgi: 39 | app: vxreddit.app 40 | pythonRequirements: 41 | layer: true 42 | dockerizePip: true -------------------------------------------------------------------------------- /templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {% block head %}{% endblock %} 6 | 7 | 8 | 9 | {% block body %}{% endblock %} 10 | 11 | 12 | -------------------------------------------------------------------------------- /templates/image.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} {% block head %} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | {% endblock %} {% block body %} Please wait... 16 | Or click here. {% endblock %} 17 | -------------------------------------------------------------------------------- /templates/message.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block head %} 4 | 5 | 6 | 7 | {% endblock %} 8 | 9 | {% block body %} 10 |
11 |
12 |
13 |

{{ message }}

14 |
15 |
16 |
17 | {% endblock %} -------------------------------------------------------------------------------- /templates/text.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} {% block head %} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | {% endblock %} {% block body %} Please wait... 15 | Or click here. {% endblock %} 16 | -------------------------------------------------------------------------------- /templates/video.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} {% block head %} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | {% endblock %} {% block body %} Please wait... 27 | Or click here. {% endblock %} 28 | -------------------------------------------------------------------------------- /videoCombiner/__init__.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import subprocess 3 | import tempfile 4 | 5 | def generateVideo(videoUrl,audioUrl): 6 | with tempfile.TemporaryDirectory() as tmpdirname: 7 | # combine video and audio into one file using ffmpeg 8 | subprocess.run(["ffmpeg", "-i", videoUrl, "-i", audioUrl, "-c:v", "copy", "-c:a", "aac", "-map", "0:v:0", "-map", "1:a:0", f"{tmpdirname}/combined.mp4"], capture_output=True) 9 | # encode video to base64 10 | with open(f"{tmpdirname}/combined.mp4", "rb") as videoFile: 11 | encoded_string = base64.b64encode(videoFile.read()) 12 | return encoded_string -------------------------------------------------------------------------------- /vxreddit.ini: -------------------------------------------------------------------------------- 1 | [uwsgi] 2 | module = wsgi:app 3 | 4 | master = true 5 | processes = 5 6 | 7 | socket = /tmp/vxreddit.sock 8 | chmod-socket = 660 9 | vacuum = true 10 | 11 | die-on-term = true 12 | -------------------------------------------------------------------------------- /vxreddit.py: -------------------------------------------------------------------------------- 1 | from flask import Flask, render_template, request, redirect, send_file, abort 2 | from flask_cors import CORS 3 | import config 4 | import requests 5 | import videoCombiner 6 | import base64 7 | import io 8 | import urllib.parse 9 | app = Flask(__name__) 10 | CORS(app) 11 | import os 12 | from discordWorkaround import fixUrlForDiscord 13 | 14 | embed_user_agents = [ 15 | "facebookexternalhit/1.1", 16 | "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/31.0.1650.57 Safari/537.36", 17 | "Mozilla/5.0 (Windows; U; Windows NT 10.0; en-US; Valve Steam Client/default/1596241936; ) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.117 Safari/537.36", 18 | "Mozilla/5.0 (Windows; U; Windows NT 10.0; en-US; Valve Steam Client/default/0; ) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.117 Safari/537.36", 19 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_1) AppleWebKit/601.2.4 (KHTML, like Gecko) Version/9.0.1 Safari/601.2.4 facebookexternalhit/1.1 Facebot Twitterbot/1.0", 20 | "facebookexternalhit/1.1", 21 | "Mozilla/5.0 (Windows; U; Windows NT 6.1; en-US; Valve Steam FriendsUI Tenfoot/0; ) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/84.0.4147.105 Safari/537.36", 22 | "Slackbot-LinkExpanding 1.0 (+https://api.slack.com/robots)", 23 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.10; rv:38.0) Gecko/20100101 Firefox/38.0", 24 | "Mozilla/5.0 (compatible; Discordbot/2.0; +https://discordapp.com)", 25 | "TelegramBot (like TwitterBot)", 26 | "Mozilla/5.0 (compatible; January/1.0; +https://gitlab.insrt.uk/revolt/january)", 27 | "test"] 28 | 29 | 30 | r_headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0"} 31 | 32 | if os.environ.get('REDDIT_COOKIE') is not None: 33 | r_headers['cookie'] = os.environ.get('REDDIT_COOKIE') 34 | 35 | def message(text): 36 | return render_template( 37 | 'message.html', 38 | message=text, 39 | appname=config.currentConfig["MAIN"]["appName"] 40 | ) 41 | 42 | def getVideoFromPostURL(url): 43 | url = url+".json" 44 | 45 | response = requests.get(url,headers=r_headers) 46 | if response.status_code != 200: 47 | return None 48 | resp=response.json() 49 | post_info = resp[0]["data"]["children"][0]["data"] 50 | 51 | # determine post type (video, image, text, gif, link, image gallery) 52 | post_type = "unknown" 53 | if "media_metadata" in post_info: 54 | post_type = "gallery" 55 | elif "url" in post_info and post_info["url"].endswith((".jpg",".png",".gif",".jpeg")): 56 | post_type = "image" 57 | elif ("is_video" in post_info and post_info["is_video"]) or ("post_hint" in post_info and post_info["post_hint"] == "hosted:video"): 58 | post_type = "video" 59 | elif "url_overridden_by_dest" in post_info: 60 | post_type = "link" 61 | elif "selftext" in post_info and post_info["selftext"] != "": 62 | post_type = "text" 63 | 64 | if not post_info: 65 | return None 66 | 67 | vxData = { 68 | "post_type": post_type, 69 | "title": post_info["title"], 70 | "author": post_info["author"], 71 | "subreddit": post_info["subreddit_name_prefixed"], 72 | "permalink": post_info["permalink"], 73 | "url": post_info["url"], 74 | "upvotes": post_info["ups"], 75 | "comments": post_info["num_comments"], 76 | "awards": post_info["total_awards_received"], 77 | "created": post_info["created_utc"], 78 | "permalink": "https://www.reddit.com"+post_info["permalink"] 79 | } 80 | 81 | if (post_type == "video"): 82 | vxData["video_url"] = post_info["media"]["reddit_video"]["fallback_url"] 83 | vxData["video_width"] = post_info["media"]["reddit_video"]["width"] 84 | vxData["video_height"] = post_info["media"]["reddit_video"]["height"] 85 | # get audio url 86 | audio_url = None 87 | if "has_audio" in post_info["media"]["reddit_video"] and not post_info["media"]["reddit_video"]["has_audio"]: 88 | audio_url = None 89 | else: 90 | for url in ['DASH_AUDIO_128.mp4','DASH_audio.mp4']: 91 | testUrl = post_info["media"]["reddit_video"]["fallback_url"].split("DASH_")[0]+url 92 | if requests.head(testUrl).status_code == 200: 93 | audio_url = testUrl 94 | break 95 | vxData["audio_url"] = audio_url 96 | # get thumbnail 97 | if 'preview' in post_info: 98 | vxData["thumbnail_url"] = post_info["preview"]["images"][0]["source"]["url"].replace("&","&") 99 | else: 100 | vxData["thumbnail_url"] = post_info["thumbnail"] 101 | elif (post_type == "image"): 102 | vxData["images"] = [post_info["url"]] 103 | # get thumbnail 104 | vxData["thumbnail_url"] = post_info["thumbnail"] 105 | elif (post_type == "gallery"): 106 | vxData["images"] = [] 107 | for image in post_info["media_metadata"]: 108 | vxData["images"].append(post_info["media_metadata"][image]["s"]["u"]) 109 | # get thumbnail 110 | vxData["thumbnail_url"] = post_info["thumbnail"] 111 | #elif (post_type == "link"): 112 | #vxData["link_url"] = post_info["url_overridden_by_dest"] 113 | # get thumbnail 114 | #vxData["thumbnail_url"] = post_info["thumbnail"] 115 | else: 116 | vxData["text"] = post_info["selftext"] 117 | # get thumbnail 118 | vxData["thumbnail_url"] = post_info["thumbnail"] 119 | if vxData["text"] == "" and vxData["title"] != "": 120 | vxData["text"] = post_info["title"] 121 | if vxData["post_type"] == "link" and vxData["url"] != "": 122 | url=vxData["url"] 123 | vxData["text"] = f"【🌐 {url} 】\n\n"+vxData['text'] 124 | 125 | 126 | return vxData 127 | 128 | def build_stats_line(post_info): 129 | upvotes = post_info["upvotes"] 130 | comments = post_info["comments"] 131 | awards = post_info["awards"] 132 | stats_line = f"⬆️ {upvotes} | 💬 {comments} | 🏆 {awards}" 133 | return stats_line 134 | 135 | @app.route('/redditvideo.mp4') 136 | def get_video(): 137 | # get video_url and audio_url from query string 138 | video_url = request.args.get('video_url') 139 | audio_url = request.args.get('audio_url') 140 | if video_url is None: 141 | abort (400) 142 | # check if video_url and audio_url are valid 143 | if not video_url.startswith("https://v.redd.it/") or (audio_url is not None and not audio_url.startswith("https://v.redd.it/")): 144 | abort (400) 145 | if audio_url is None: 146 | return redirect(video_url) 147 | if config.currentConfig["MAIN"]["videoConversion"] == "local": 148 | # combine video and audio into one file using ffmpeg 149 | b64 = videoCombiner.generateVideo(video_url,audio_url) 150 | # return video file 151 | return send_file(io.BytesIO(base64.b64decode(b64)), mimetype='video/mp4') 152 | else: 153 | renderer=config.currentConfig["MAIN"]["videoConversion"] 154 | # url encode video_url and audio_url 155 | video_url = urllib.parse.quote(video_url, safe='') 156 | audio_url = urllib.parse.quote(audio_url, safe='') 157 | return redirect(f"{renderer}?video_url={video_url}&audio_url={audio_url}",code=307) 158 | 159 | def embed_reddit(post_link,isDiscordBot=False): 160 | videoInfo = getVideoFromPostURL(post_link) 161 | if videoInfo is None: 162 | return message("Failed to get data from Reddit") 163 | statsLine = build_stats_line(videoInfo) 164 | if videoInfo["post_type"] == "unknown": 165 | return message("Unknown post type") 166 | elif videoInfo["post_type"] == "text" or videoInfo["post_type"] == "link": 167 | return render_template("text.html", vxData=videoInfo,appname=config.currentConfig["MAIN"]["appName"], statsLine=statsLine, domainName=config.currentConfig["MAIN"]["domainName"]) 168 | elif videoInfo["post_type"] == "image": 169 | return render_template("image.html", vxData=videoInfo,appname=config.currentConfig["MAIN"]["appName"], statsLine=statsLine, domainName=config.currentConfig["MAIN"]["domainName"]) 170 | elif videoInfo["post_type"] == "gallery": 171 | imageCount = str(len(videoInfo["images"])) 172 | return render_template("image.html", vxData=videoInfo,appname=config.currentConfig["MAIN"]["appName"]+" - Image 1 of "+imageCount, statsLine=statsLine, domainName=config.currentConfig["MAIN"]["domainName"]) 173 | #elif videoInfo["post_type"] == "link": 174 | # return redirect(videoInfo["link_url"]) # this might need to be improved later 175 | elif videoInfo["post_type"] == "video": 176 | if videoInfo["audio_url"] is None: 177 | convertedUrl = videoInfo["video_url"] 178 | else: 179 | encodedVideoURL = urllib.parse.quote(videoInfo["video_url"], safe='') 180 | encodedAudioURL = urllib.parse.quote(videoInfo["audio_url"], safe='') 181 | convertedUrl = "https://"+config.currentConfig["MAIN"]["domainName"]+"/redditvideo.mp4?video_url="+encodedVideoURL+"&audio_url="+encodedAudioURL 182 | if isDiscordBot: 183 | convertedUrl = fixUrlForDiscord(convertedUrl) 184 | return render_template("video.html", vxData=videoInfo,appname=config.currentConfig["MAIN"]["appName"], statsLine=statsLine, domainName=config.currentConfig["MAIN"]["domainName"],mp4URL=convertedUrl) 185 | else: 186 | return videoInfo 187 | 188 | @app.route('/') 189 | def main(): 190 | return redirect(config.currentConfig["MAIN"]["repoURL"]) 191 | 192 | @app.route('/owoembed') 193 | def alternateJSON(): 194 | return { 195 | "author_name": request.args.get('text'), 196 | "author_url": request.args.get('url'), 197 | "provider_name": request.args.get('provider_name'), 198 | "provider_url": config.currentConfig["MAIN"]["repoURL"], 199 | "title": "Reddit", 200 | "type": "link", 201 | "version": "1.0" 202 | } 203 | 204 | 205 | @app.route('/') 206 | def embedReddit(sub_path): 207 | user_agent = request.headers.get('user-agent') 208 | post_link = "https://www.reddit.com/" + sub_path 209 | 210 | r = requests.get(post_link, allow_redirects=False, headers=r_headers) 211 | if 'location' in r.headers and r.headers['location'].startswith("https"): 212 | post_link = r.headers['location'] 213 | if "?" in post_link: 214 | post_link = post_link.split("?")[0] 215 | 216 | return embed_reddit(post_link,'Discordbot' in user_agent) 217 | 218 | if __name__ == "__main__": 219 | app.run(host='0.0.0.0') 220 | -------------------------------------------------------------------------------- /vxreddit.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=vxreddit WSGI service 3 | After=network.target 4 | 5 | [Service] 6 | User=dylan 7 | Group=dylan 8 | WorkingDirectory=/home/dylan/vxreddit 9 | Environment="PATH=/home/dylan/vxreddit/venv/bin" 10 | ExecStart=/home/dylan/vxreddit/venv/bin/uwsgi --ini vxreddit.ini 11 | Restart=always 12 | RestartSec=3 13 | 14 | [Install] 15 | WantedBy=multi-user.target 16 | -------------------------------------------------------------------------------- /wsgi.py: -------------------------------------------------------------------------------- 1 | from vxreddit import app 2 | 3 | if __name__ == "__main__": 4 | app.run(host='0.0.0.0') --------------------------------------------------------------------------------