├── .github └── dependabot.yml ├── .gitignore ├── Dockerfile ├── README.md ├── cache.py ├── config.py ├── package-lock.json ├── package.json ├── requirements.txt ├── serverless.yml ├── slideshowBuilder └── __init__.py ├── templates ├── base.html ├── message.html └── video.html ├── vxtiktok-cf-worker-proxy ├── .editorconfig ├── .gitignore ├── .prettierrc ├── package-lock.json ├── package.json ├── src │ └── index.js ├── vitest.config.js └── wrangler.toml ├── vxtiktok.ini ├── vxtiktok.py ├── vxtiktok.service └── wsgi.py /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "pip" 9 | directory: "/" 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | venv/ 3 | node_modules 4 | tmp/ 5 | *.conf 6 | .serverless/ 7 | .vscode/ -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.10-alpine AS build 2 | RUN apk add build-base python3-dev linux-headers pcre-dev jpeg-dev zlib-dev 3 | RUN pip install --upgrade pip 4 | RUN pip install uwsgi 5 | 6 | FROM python:3.10-alpine AS deps 7 | WORKDIR /vxtiktok 8 | COPY requirements.txt requirements.txt 9 | COPY --from=build /usr/local/lib/python3.10/site-packages /usr/local/lib/python3.10/site-packages 10 | RUN pip install -r requirements.txt 11 | 12 | FROM python:3.10-alpine AS runner 13 | EXPOSE 9000 14 | RUN apk add pcre-dev jpeg-dev zlib-dev 15 | WORKDIR /vxtiktok 16 | CMD ["uwsgi", "vxtiktok.ini"] 17 | COPY --from=build /usr/local/bin/uwsgi /usr/local/bin/uwsgi 18 | COPY --from=deps /usr/local/lib/python3.10/site-packages /usr/local/lib/python3.10/site-packages 19 | COPY . . 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vxtiktok 2 | Basic Website that serves fixed TikTok video embeds to various platforms (i.e Discord) by using yt-dlp to grab TikTok video information. 3 | ## How to use the hosted version 4 | 5 | Just replace tiktok.com with vxtiktok.com on the link to the TikTok post! `https://www.tiktok.com/@username/video/1234567890123456789` -> `https://vxtiktok.com/@username/video/1234567890123456789` 6 | 7 | ## Other stuff 8 | 9 | We handle various TikTok URL formats, including short mobile URLs and subdomain variants. The application will attempt to resolve these to the full TikTok URL before processing. Be sure to only replace "tiktok" in the url (i.e `vm.tiktok.com` -> `vm.vxtiktok.com`) 10 | 11 | For TikTok posts containing multiple images, a slideshow video will be generated* (This can be done locally or via an external API, depending on the configuration.) 12 | 13 | \* As of the time of writing, due to TikTok API changes this may not be working 14 | 15 | **Note**: If you enjoy this service, please consider donating to help cover server costs. https://ko-fi.com/dylanpdx 16 | 17 | ## Limitations 18 | - The application relies on TikTok's internal api; Changes made by them might break things 19 | - Some features may not work for all types of TikTok content, especially non-video formats. 20 | -------------------------------------------------------------------------------- /cache.py: -------------------------------------------------------------------------------- 1 | from codecs import getdecoder 2 | import config 3 | import pymongo 4 | import boto3 5 | from datetime import date,datetime, timedelta 6 | 7 | client=None 8 | db=None 9 | DYNAMO_CACHE_TBL=None 10 | if config.currentConfig["CACHE"]["cacheMethod"] == "mongodb": 11 | client = pymongo.MongoClient(config.currentConfig["CACHE"]["databaseurl"], connect=False) 12 | table = config.currentConfig["CACHE"]["databasetable"] 13 | db = client[table] 14 | elif config.currentConfig["CACHE"]["cacheMethod"]=="dynamodb": 15 | DYNAMO_CACHE_TBL=config.currentConfig["CACHE"]["databasetable"] 16 | client = boto3.resource('dynamodb') 17 | 18 | def getDefaultTTL(): 19 | return datetime.today().replace(microsecond=0) + timedelta(days=1) 20 | 21 | def addToCache(url,vidInfo): 22 | try: 23 | if config.currentConfig["CACHE"]["cacheMethod"] == "none": 24 | pass 25 | elif config.currentConfig["CACHE"]["cacheMethod"] == "mongodb": 26 | ttl=getDefaultTTL() 27 | db.linkCache.insert_one({"url":url,"info":vidInfo,"ttl":ttl}) 28 | elif config.currentConfig["CACHE"]["cacheMethod"] == "dynamodb": 29 | ttl=getDefaultTTL() 30 | ttl = int(ttl.strftime('%s')) 31 | table = client.Table(DYNAMO_CACHE_TBL) 32 | table.put_item(Item={"url":url,"info":vidInfo,"ttl":ttl}) 33 | except Exception as e: 34 | print("addToCache for URL "+url+" failed: "+str(e)) 35 | 36 | def getFromCache(url): 37 | try: 38 | if config.currentConfig["CACHE"]["cacheMethod"] == "none": 39 | return None 40 | elif config.currentConfig["CACHE"]["cacheMethod"] == "mongodb": 41 | obj = db.linkCache.find_one({'url': url}) 42 | if obj == None: 43 | return None 44 | return obj["info"] 45 | elif config.currentConfig["CACHE"]["cacheMethod"] == "dynamodb": 46 | obj = client.Table(DYNAMO_CACHE_TBL).get_item(Key={'url': url}) 47 | if 'Item' not in obj: 48 | return None 49 | return obj["Item"]["info"] 50 | except Exception as e: 51 | print("getFromCache for URL "+url+" failed: "+str(e)) 52 | return None -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | import os 3 | 4 | currentConfig = configparser.ConfigParser() 5 | 6 | ## default values 7 | 8 | currentConfig["MAIN"]={ 9 | "appName": "vxTiktok", 10 | "embedColor": "#EE1D52", 11 | "repoURL":"https://github.com/dylanpdx/vxtiktok", 12 | "domainName":"vxtiktok.com", 13 | "slideshowRenderer":"local", 14 | } 15 | 16 | currentConfig["CACHE"]={ 17 | "cacheMethod":"none", 18 | "databaseURL":"{mongodb URL}", 19 | "databaseTable":"vxTiktok", 20 | "cacheTTL":86400, 21 | } 22 | 23 | if 'RUNNING_SERVERLESS' in os.environ and os.environ['RUNNING_SERVERLESS'] == '1': 24 | currentConfig["MAIN"]={ 25 | "appName": os.environ['APP_NAME'], 26 | "embedColor": "#EE1D52", 27 | "repoURL":os.environ['REPO_URL'], 28 | "domainName":os.environ['DOMAINNAME'], 29 | "slideshowRenderer":os.environ['SLIDESHOW_RENDERER'], 30 | } 31 | currentConfig["CACHE"]={ 32 | "cacheMethod":os.environ['CACHE_METHOD'], 33 | "databaseURL":os.environ['DATABASE_URL'], 34 | "databaseTable":os.environ['CACHE_TABLE'], 35 | "cacheTTL":int(os.environ['CACHE_TTL']), 36 | } 37 | else: 38 | if os.path.exists("vxTiktok.conf"): 39 | # as per python docs, "the most recently added configuration has the highest priority" 40 | # "conflicting keys are taken from the more recent configuration while the previously existing keys are retained" 41 | currentConfig.read("vxTiktok.conf") 42 | 43 | with open("vxTiktok.conf", "w") as configfile: 44 | currentConfig.write(configfile) # write current config to file -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vxtiktok", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/dylanpdx/vxtiktok.git" 12 | }, 13 | "keywords": [], 14 | "author": "", 15 | "license": "ISC", 16 | "bugs": { 17 | "url": "https://github.com/dylanpdx/vxtiktok/issues" 18 | }, 19 | "homepage": "https://github.com/dylanpdx/vxtiktok#readme", 20 | "dependencies": { 21 | "serverless-plugin-common-excludes": "^4.0.0", 22 | "serverless-plugin-include-dependencies": "^5.0.0", 23 | "serverless-python-requirements": "^5.4.0", 24 | "serverless-wsgi": "^3.0.1" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Flask==2.3.3 2 | Flask-Cors==4.0.0 3 | yt-dlp==2024.08.06 4 | pymongo==4.5.0 5 | boto3==1.28.37 6 | requests==2.32.3 7 | idna<4,>=2.5 8 | urllib3>=1.21.1,<2.1 9 | certifi>=2017.4.17 10 | charset-normalizer>=2,<4 11 | Werkzeug==2.3.7 -------------------------------------------------------------------------------- /serverless.yml: -------------------------------------------------------------------------------- 1 | service: vxTiktok 2 | 3 | provider: 4 | name: aws 5 | runtime: python3.8 6 | stage: dev 7 | iamRoleStatements: 8 | - Effect: Allow 9 | Action: 10 | - dynamodb:Query 11 | - dynamodb:Scan 12 | - dynamodb:GetItem 13 | - dynamodb:PutItem 14 | - dynamodb:UpdateItem 15 | - dynamodb:DeleteItem 16 | Resource: 17 | - { "Fn::GetAtt": ["vxTiktokDynamoTable", "Arn" ] } 18 | environment: 19 | APP_NAME: vxTiktok 20 | EMBED_COLOR: \#EE1D52 21 | REPO_URL: https://github.com/dylanpdx/vxtiktok 22 | DOMAINNAME: vxtiktok.com 23 | SLIDESHOW_RENDERER: local 24 | CACHE_METHOD: dynamodb 25 | DATABASE_URL: none 26 | CACHE_TABLE: ${self:custom.tableName} 27 | CACHE_TTL: 86400 28 | 29 | RUNNING_SERVERLESS: 1 30 | 31 | package: 32 | patterns: 33 | - '!node_modules/**' 34 | - '!venv/**' 35 | 36 | plugins: 37 | - serverless-wsgi 38 | - serverless-python-requirements 39 | - serverless-plugin-common-excludes 40 | - serverless-plugin-include-dependencies 41 | 42 | functions: 43 | vxTiktokApp: 44 | handler: wsgi_handler.handler 45 | url: true 46 | timeout: 15 47 | memorySize: 1000 48 | layers: 49 | - Ref: PythonRequirementsLambdaLayer 50 | 51 | 52 | custom: 53 | tableName: 'tiktok-table-${self:provider.stage}' 54 | wsgi: 55 | app: vxtiktok.app 56 | pythonRequirements: 57 | layer: true 58 | dockerizePip: true 59 | 60 | 61 | resources: 62 | Resources: 63 | vxTiktokDynamoTable: 64 | Type: 'AWS::DynamoDB::Table' 65 | Properties: 66 | AttributeDefinitions: 67 | - 68 | AttributeName: url 69 | AttributeType: S 70 | KeySchema: 71 | - 72 | AttributeName: url 73 | KeyType: HASH 74 | TableName: ${self:custom.tableName} 75 | BillingMode: PAY_PER_REQUEST 76 | TimeToLiveSpecification: 77 | AttributeName: ttl 78 | Enabled: true -------------------------------------------------------------------------------- /slideshowBuilder/__init__.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import os 3 | import subprocess 4 | import json 5 | import sys 6 | import tempfile 7 | 8 | def generateVideo(slideshowVideoData): 9 | # slideshowData = {..., "slideshowData":{"musicUrl":"","imageUrls":[]}} 10 | slideshowData = slideshowVideoData["slideshowData"] 11 | txtData = "" 12 | 13 | if len(slideshowData["imageURLs"]) == 1: 14 | slideshowData["imageURLs"].append(slideshowData["imageURLs"][0]) 15 | 16 | for url in slideshowData["imageURLs"]: 17 | txtData += f"file '{url}'\nduration 2\n" 18 | with tempfile.TemporaryDirectory() as tmpdirname: 19 | txtPath = os.path.join(tmpdirname, "test.txt") 20 | with open(txtPath, "w") as f: 21 | f.write(txtData) 22 | musicPath = slideshowData["musicURL"] 23 | outputFilePath = os.path.join(tmpdirname, "out.mp4") 24 | subprocess.run(["ffmpeg", "-f", "concat", "-safe", "0", "-protocol_whitelist", "file,http,tcp,https,tls", "-i", txtPath, "-i", musicPath, "-map", "0:v", "-map", "1:a","-vf","scale=1280:720:force_original_aspect_ratio=decrease:eval=frame,pad=1280:720:-1:-1:eval=frame,format=yuv420p", "-filter_complex", "[1:0] apad","-shortest", outputFilePath]) 25 | with open(outputFilePath, "rb") as vid_file: 26 | encoded_string = base64.b64encode(vid_file.read()).decode('ascii') 27 | return encoded_string -------------------------------------------------------------------------------- /templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {% block head %}{% endblock %} 6 | 7 | 8 | 9 | {% block body %}{% endblock %} 10 | 11 | 12 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /vxtiktok-cf-worker-proxy/.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = tab 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.yml] 12 | indent_style = space 13 | -------------------------------------------------------------------------------- /vxtiktok-cf-worker-proxy/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | 3 | logs 4 | _.log 5 | npm-debug.log_ 6 | yarn-debug.log* 7 | yarn-error.log* 8 | lerna-debug.log* 9 | .pnpm-debug.log* 10 | 11 | # Diagnostic reports (https://nodejs.org/api/report.html) 12 | 13 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 14 | 15 | # Runtime data 16 | 17 | pids 18 | _.pid 19 | _.seed 20 | \*.pid.lock 21 | 22 | # Directory for instrumented libs generated by jscoverage/JSCover 23 | 24 | lib-cov 25 | 26 | # Coverage directory used by tools like istanbul 27 | 28 | coverage 29 | \*.lcov 30 | 31 | # nyc test coverage 32 | 33 | .nyc_output 34 | 35 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 36 | 37 | .grunt 38 | 39 | # Bower dependency directory (https://bower.io/) 40 | 41 | bower_components 42 | 43 | # node-waf configuration 44 | 45 | .lock-wscript 46 | 47 | # Compiled binary addons (https://nodejs.org/api/addons.html) 48 | 49 | build/Release 50 | 51 | # Dependency directories 52 | 53 | node_modules/ 54 | jspm_packages/ 55 | 56 | # Snowpack dependency directory (https://snowpack.dev/) 57 | 58 | web_modules/ 59 | 60 | # TypeScript cache 61 | 62 | \*.tsbuildinfo 63 | 64 | # Optional npm cache directory 65 | 66 | .npm 67 | 68 | # Optional eslint cache 69 | 70 | .eslintcache 71 | 72 | # Optional stylelint cache 73 | 74 | .stylelintcache 75 | 76 | # Microbundle cache 77 | 78 | .rpt2_cache/ 79 | .rts2_cache_cjs/ 80 | .rts2_cache_es/ 81 | .rts2_cache_umd/ 82 | 83 | # Optional REPL history 84 | 85 | .node_repl_history 86 | 87 | # Output of 'npm pack' 88 | 89 | \*.tgz 90 | 91 | # Yarn Integrity file 92 | 93 | .yarn-integrity 94 | 95 | # dotenv environment variable files 96 | 97 | .env 98 | .env.development.local 99 | .env.test.local 100 | .env.production.local 101 | .env.local 102 | 103 | # parcel-bundler cache (https://parceljs.org/) 104 | 105 | .cache 106 | .parcel-cache 107 | 108 | # Next.js build output 109 | 110 | .next 111 | out 112 | 113 | # Nuxt.js build / generate output 114 | 115 | .nuxt 116 | dist 117 | 118 | # Gatsby files 119 | 120 | .cache/ 121 | 122 | # Comment in the public line in if your project uses Gatsby and not Next.js 123 | 124 | # https://nextjs.org/blog/next-9-1#public-directory-support 125 | 126 | # public 127 | 128 | # vuepress build output 129 | 130 | .vuepress/dist 131 | 132 | # vuepress v2.x temp and cache directory 133 | 134 | .temp 135 | .cache 136 | 137 | # Docusaurus cache and generated files 138 | 139 | .docusaurus 140 | 141 | # Serverless directories 142 | 143 | .serverless/ 144 | 145 | # FuseBox cache 146 | 147 | .fusebox/ 148 | 149 | # DynamoDB Local files 150 | 151 | .dynamodb/ 152 | 153 | # TernJS port file 154 | 155 | .tern-port 156 | 157 | # Stores VSCode versions used for testing VSCode extensions 158 | 159 | .vscode-test 160 | 161 | # yarn v2 162 | 163 | .yarn/cache 164 | .yarn/unplugged 165 | .yarn/build-state.yml 166 | .yarn/install-state.gz 167 | .pnp.\* 168 | 169 | # wrangler project 170 | 171 | .dev.vars 172 | .wrangler/ 173 | -------------------------------------------------------------------------------- /vxtiktok-cf-worker-proxy/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 140, 3 | "singleQuote": true, 4 | "semi": true, 5 | "useTabs": true 6 | } 7 | -------------------------------------------------------------------------------- /vxtiktok-cf-worker-proxy/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vxtiktok-cf-worker-proxy", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "deploy": "wrangler deploy", 7 | "dev": "wrangler dev", 8 | "start": "wrangler dev", 9 | "test": "vitest" 10 | }, 11 | "devDependencies": { 12 | "wrangler": "^3.0.0", 13 | "vitest": "1.3.0", 14 | "@cloudflare/vitest-pool-workers": "^0.1.0" 15 | } 16 | } -------------------------------------------------------------------------------- /vxtiktok-cf-worker-proxy/src/index.js: -------------------------------------------------------------------------------- 1 | addEventListener('fetch', event => { 2 | event.respondWith(handleRequest(event.request)) 3 | }) 4 | 5 | async function handleRequest(request) { 6 | const url = new URL(request.url) 7 | const path = url.pathname 8 | 9 | let match = path.match(/^\/vid\/([^\/]+)\/([^\/]+)$/) 10 | if (!match) { 11 | return new Response('Invalid URL format', { status: 400 }) 12 | } 13 | 14 | const author = match[1] 15 | let vid = match[2] 16 | 17 | if (vid.endsWith('.mp4')) { 18 | vid = vid.slice(0, -4) 19 | } 20 | 21 | const postLink = `https://www.tiktok.com/@${author}/video/${vid}` 22 | const videoData = await downloadVideoFromPostURL(postLink) 23 | if (!videoData) { 24 | return new Response('Failed to retrieve video', { status: 500 }) 25 | } 26 | 27 | return new Response(videoData, { 28 | status: 200, 29 | headers: { 30 | 'Content-Type': 'video/mp4' 31 | } 32 | }) 33 | } 34 | 35 | async function getWebDataFromResponse(response) { 36 | if (response.status !== 200) { 37 | return null 38 | } 39 | const text = await response.text() 40 | const rx = /