├── .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')
--------------------------------------------------------------------------------