├── .devcontainer ├── Dockerfile └── devcontainer.json ├── .dockerignore ├── .github ├── CODEOWNERS ├── FUNDING.yml └── workflows │ ├── pr.yml │ └── tssvibelounge.yml ├── .gitignore ├── Dockerfile ├── README.md ├── app.py ├── assets ├── bin │ └── geckodriver ├── fonts │ ├── impact.ttf │ ├── verdana.ttf │ ├── verdanab.ttf │ ├── verdanai.ttf │ └── verdanaz.ttf ├── images │ ├── NicePng_evil-png_840970.png │ ├── NicePng_masha-and-the-bear_1292475.png │ ├── NicePng_meme-png_4338371.png │ ├── NicePng_roblox-girl-png_3028421.png │ ├── NicePng_spongegar-png_328488.png │ ├── NicePng_twitch-emotes-png_4412701.png │ ├── NicePng_tyler-the-creator-png_1696064.png │ ├── PngItem_1241663.png │ ├── Whatshowhasnolikablecharacters.png │ ├── centaur.png │ ├── chicken.png │ ├── dog2.png │ ├── duck.png │ ├── fairy.png │ ├── frog.png │ ├── gabe.png │ ├── ghost-dress.png │ ├── ghost.png │ ├── harambe.png │ ├── kermifire.png │ ├── kermitgun.png │ ├── kermitowel.png │ ├── kermitsuit.png │ ├── lemonman.png │ ├── mermaid-g2d706be1f_1920.png │ ├── monster.png │ ├── people │ │ └── woman001.jpg │ ├── priest.png │ ├── python-reddit-youtube-bot-tutorial.png │ ├── robot-g55cc67956_1920.png │ ├── shia.png │ ├── shibi.png │ ├── shockface.png │ ├── successkid.png │ ├── woman-g618a29d8c_1920.png │ ├── woman-g6cfce431d_1920.png │ ├── woman-g8d743d166_1920.png │ ├── woman.jpg │ ├── xm5gsv_thumbnail.png │ ├── xn8xzu.png │ ├── young-woman-g4459282f6_1920.png │ └── young-woman-gaa93b4883_1920.png ├── intro.mp4 ├── intro_welcome.mp4 ├── intro_welcome_crop.mp4 ├── music │ ├── [Non-Copyrighted Music] Chill Jazzy Lofi Hip Hop (Royalty Free) Jazz Hop Music [9Ojb8t3T2Ng].mp3 │ └── [Non-Copyrighted Music] Chill Jazzy Lofi Hip-Hop (Royalty Free) Jazz Hop Music [IygKZNWPM_0].mp3 ├── newscaster.mp4 ├── newscaster.png ├── newscaster02.mp4 ├── particles.mp4 └── scary │ └── transition │ └── lightning.mp4 ├── balcon.exe ├── comment_templates ├── dark_reddit_mockup │ └── index.html ├── example │ └── index.html ├── light_reddit_mockup │ └── index.html └── old_reddit_mockup │ └── index.html ├── comments ├── cookie-dark-mode.json ├── cookie-light-mode.json └── screenshot.py ├── config ├── auth-env.py ├── auth-example.py └── settings.py ├── csvmgr.py ├── data.csv ├── dependencies └── scripts │ └── postCreateCommand.sh ├── docker-compose.yml ├── docs ├── ERRORS.md ├── FEATURES.md ├── OrderOfEvents.md ├── edge-tts-voices.md └── notes.md ├── libsamplerate.dll ├── policy.xml ├── publish ├── login.py ├── upload.py └── youtube.py ├── pyproject.toml ├── reddit └── reddit.py ├── referral.txt ├── refresh_token.py ├── requirements-dev.txt ├── requirements.txt ├── speech ├── speech.py ├── streamlabs_polly.py └── tiktok.py ├── static.mp4 ├── test_upload.py ├── thumbnail ├── keywords.py ├── lexica.py └── thumbnail.py ├── utils ├── base64_encoding.py ├── common.py └── speed_test.py └── video_generation └── video.py /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11-bullseye 2 | 3 | ENV DEBIAN_FRONTEND=noninteractive 4 | ENV PYTHONDONTWRITEBYTECODE 1 5 | ENV PYTHONUNBUFFERED 1 6 | ENV PATH="/home/vscode/.local/bin:/scripts:$PATH" 7 | 8 | ARG USERNAME=vscode 9 | ARG USER_UID=1000 10 | ARG USER_GID=$USER_UID 11 | 12 | #RUN mkdir -p /scripts 13 | COPY ./dependencies / 14 | RUN chmod -R +x /scripts 15 | 16 | RUN groupadd --gid $USER_GID $USERNAME && \ 17 | useradd --uid $USER_UID --gid $USER_GID -m $USERNAME && \ 18 | apt-get update && \ 19 | apt-get install -y sudo vim ghostscript && \ 20 | echo $USERNAME ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/$USERNAME && \ 21 | chmod 0440 /etc/sudoers.d/$USERNAME && \ 22 | chmod -R +x /scripts 23 | 24 | RUN apt-get update -y && \ 25 | apt-get install -y --no-install-recommends build-essential 26 | 27 | RUN mkdir -p /home/vscode/.local/bin && \ 28 | curl -s https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /home/vscode/.local/bin/yt-dlp && \ 29 | chmod +x /home/vscode/.local/bin/yt-dlp && \ 30 | chown -R vscode /home/vscode/.local 31 | 32 | USER $USERNAME 33 | 34 | ENTRYPOINT ["/bin/bash"] 35 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. 2 | { 3 | "name": "redyoubot", 4 | "build": { 5 | "dockerfile": "Dockerfile", 6 | "context": "..", 7 | "args": { 8 | } 9 | }, 10 | 11 | "customizations": { 12 | "vscode": { 13 | // Set *default* container specific settings.json values on container create. 14 | "settings":{ 15 | "python.pythonPath": "/usr/local/bin/python", 16 | "python.languageServer": "Pylance", 17 | "python.globalModuleInstallation": false, 18 | "python.logging.level": "debug", 19 | "python.venvPath": "~/.pyenv", 20 | "python.terminal.activateEnvInCurrentTerminal": true, 21 | "python.analysis.autoSearchPaths": true, 22 | "python.analysis.autoImportCompletions": true, 23 | "python.analysis.typeCheckingMode": "strict", 24 | "python.formatting.provider": "black", 25 | "python.formatting.blackPath": "/usr/local/py-utils/bin/black", 26 | "python.linting.enabled": true, 27 | "python.linting.lintOnSave": true, 28 | "python.linting.banditEnabled": true, 29 | "python.linting.banditPath": "/usr/local/py-utils/bin/bandit", 30 | "python.linting.flake8Enabled": true, 31 | "python.linting.flake8Path": "/usr/local/py-utils/bin/flake8", 32 | "python.linting.mypyEnabled": true, 33 | "python.linting.mypyPath": "/usr/local/py-utils/bin/mypy", 34 | "python.linting.pycodestylePath": "/usr/local/py-utils/bin/pycodestyle", 35 | "python.linting.pydocstylePath": "/usr/local/py-utils/bin/pydocstyle", 36 | "python.testing.autoTestDiscoverOnSaveEnabled": true, 37 | "python.testing.pytestEnabled": true, 38 | "python.testing.unittestEnabled": true, 39 | "terminal.integrated.shellIntegration.enabled": true, 40 | "terminal.integrated.copyOnSelection": true, 41 | "terminal.integrated.gpuAcceleration": "auto", 42 | "terminal.integrated.useWslProfiles": true, 43 | "terminal.integrated.defaultProfile.linux":"bash" 44 | } 45 | }, 46 | 47 | // Add the IDs of extensions you want installed when the container is created. 48 | "extensions": [ 49 | "ms-python.python", 50 | "ms-python.vscode-pylance" 51 | ] 52 | }, 53 | 54 | 55 | "features": { 56 | "ghcr.io/devcontainers/features/docker-outside-of-docker:1.2.1": {}, 57 | "ghcr.io/devcontainers/features/git:1.1.5": {} 58 | }, 59 | 60 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 61 | // "forwardPorts": [], 62 | 63 | // Use 'postCreateCommand' to run commands after the container is created. 64 | "postCreateCommand": "/scripts/postCreateCommand.sh", 65 | 66 | // Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. 67 | "remoteUser": "vscode" 68 | } 69 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | final/* 2 | config/auth.py 3 | .env 4 | client_secret.json 5 | cookies.json 6 | credentials.storage -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @alexlaverty @nathonfowlie 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: alaverty 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | custom: ['https://www.buymeacoffee.com/alexlaverty', 'https://alexlaverty.github.io/donate'] 14 | -------------------------------------------------------------------------------- /.github/workflows/pr.yml: -------------------------------------------------------------------------------- 1 | name: PR Build 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | container: alexlaverty/ttsvibelounge:1.0.8 9 | steps: 10 | 11 | - name: checkout repo content 12 | uses: actions/checkout@v3 13 | 14 | - name: Run ttsvibelounge Script 15 | env: 16 | AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} 17 | AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 18 | CREDENTIALS_STORAGE: ${{ secrets.CREDENTIALS_STORAGE }} 19 | PRAW_CLIENT_ID: ${{ secrets.PRAW_CLIENT_ID }} 20 | PRAW_CLIENT_SECRET: ${{ secrets.PRAW_CLIENT_SECRET }} 21 | PRAW_USER_AGENT: ${{ secrets.PRAW_USER_AGENT }} 22 | PRAW_USERNAME: ${{ secrets.PRAW_USERNAME }} 23 | PRAW_PASSWORD: ${{ secrets.PRAW_PASSWORD }} 24 | RUMBLE_PASSWORD: ${{ secrets.RUMBLE_PASSWORD }} 25 | RUMBLE_USERNAME: ${{ secrets.RUMBLE_USERNAME }} 26 | YOUTUBE_CLIENT_SECRET: ${{ secrets.YOUTUBE_CLIENT_SECRET }} 27 | GH_TOKEN: ${{ secrets.GH_TOKEN }} 28 | run: | 29 | echo $PWD 30 | echo ${{ github.workspace }} 31 | echo $GITHUB_WORKSPACE 32 | echo $YOUTUBE_CLIENT_SECRET > client_secret.json 33 | echo $CREDENTIALS_STORAGE > credentials.storage 34 | cp config/auth-env.py config/auth.py 35 | playwright install 36 | python3 app.py --total-posts 1 \ 37 | --video-length 60 \ 38 | --enable-background \ 39 | --background-directory /app/assets/backgrounds 40 | rm -f client_secret.json 41 | rm -f credentials.storage 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /.github/workflows/tssvibelounge.yml: -------------------------------------------------------------------------------- 1 | name: TTSVibeLounge 2 | on: 3 | # schedule: 4 | # - cron: '0 */6 * * *' 5 | push: 6 | branches: 7 | - main 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | container: alexlaverty/ttsvibelounge:1.0.8 12 | steps: 13 | 14 | # # Git version in container must match git version on ubuntu server 15 | # - name: get git 16 | # run: | 17 | # apt update -y 18 | # apt install -y libz-dev libssl-dev libcurl4-gnutls-dev libexpat1-dev gettext cmake gcc 19 | # wget https://github.com/git/git/archive/refs/tags/v2.38.1.tar.gz 20 | # tar -xvf v2.38.1.tar.gz 21 | # cd git-2.38.1 22 | # make prefix=/usr/local all 23 | # make prefix=/usr/local install 24 | # git --version 25 | # gh secret set CREDENTIALS_STORAGE_REFRESH < credentials.storage 26 | 27 | - name: checkout repo content 28 | uses: actions/checkout@v3 29 | 30 | - name: Run ttsvibelounge Script 31 | env: 32 | AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} 33 | AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 34 | CREDENTIALS_STORAGE: ${{ secrets.CREDENTIALS_STORAGE }} 35 | PRAW_CLIENT_ID: ${{ secrets.PRAW_CLIENT_ID }} 36 | PRAW_CLIENT_SECRET: ${{ secrets.PRAW_CLIENT_SECRET }} 37 | PRAW_USER_AGENT: ${{ secrets.PRAW_USER_AGENT }} 38 | PRAW_USERNAME: ${{ secrets.PRAW_USERNAME }} 39 | PRAW_PASSWORD: ${{ secrets.PRAW_PASSWORD }} 40 | RUMBLE_PASSWORD: ${{ secrets.RUMBLE_PASSWORD }} 41 | RUMBLE_USERNAME: ${{ secrets.RUMBLE_USERNAME }} 42 | YOUTUBE_CLIENT_SECRET: ${{ secrets.YOUTUBE_CLIENT_SECRET }} 43 | GH_TOKEN: ${{ secrets.GH_TOKEN }} 44 | run: | 45 | echo $PWD 46 | echo ${{ github.workspace }} 47 | echo $GITHUB_WORKSPACE 48 | echo $YOUTUBE_CLIENT_SECRET > client_secret.json 49 | echo $CREDENTIALS_STORAGE > credentials.storage 50 | cp config/auth-env.py config/auth.py 51 | playwright install 52 | python3 app.py --total-posts 1 \ 53 | --enable-upload \ 54 | --enable-background \ 55 | --background-directory /app/assets/backgrounds 56 | python3 refresh_token.py 57 | rm -f client_secret.json 58 | rm -f credentials.storage 59 | 60 | 61 | - name: check for changes 62 | run: | 63 | git config --global --add safe.directory $(realpath .) 64 | if git diff --exit-code data.csv; then 65 | echo changes_exist=false 66 | echo "changes_exist=false" >> $GITHUB_ENV 67 | else 68 | echo changes_exist=true 69 | echo "changes_exist=true" >> $GITHUB_ENV 70 | fi 71 | 72 | - name: Push updates 73 | if: env.changes_exist == 'true' 74 | run: | 75 | git config --local user.email "action@github.com" 76 | git config --local user.name "GitHub Action" 77 | git add data.csv 78 | git commit -m "TTSVibeLounge Scheduled Update" -a 79 | git push -f 80 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | config.yml 2 | config.py 3 | __pycache__/ 4 | audio/ 5 | backgrounds/ 6 | final/ 7 | thumbnails/ 8 | *.log 9 | *.mp3 10 | backup/ 11 | .vscode/ 12 | client_secret.json 13 | cookies*.json 14 | credentials.storage 15 | videos/ 16 | config/auth.py 17 | .env 18 | 19 | obtain_refresh_token.py 20 | setPython.cmd 21 | data.csv 22 | 23 | **/__pycache__/ 24 | assets/work_dir/ -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu 2 | 3 | ENV DEBIAN_FRONTEND=noninteractive 4 | # ENV FIREFOX_VER 87.0 5 | # ENV GECKODRIVER_VER v0.30.0 6 | 7 | COPY assets/fonts /usr/share/fonts/truetype/ttsvibelounge 8 | 9 | #COPY assets/bin/geckodriver /usr/bin/geckodriver 10 | 11 | RUN apt update -y && \ 12 | apt install -y \ 13 | curl \ 14 | imagemagick \ 15 | libmagick++-dev \ 16 | python3-pip \ 17 | python3 \ 18 | #git \ 19 | ttf-mscorefonts-installer \ 20 | vim && \ 21 | fc-cache -f 22 | 23 | # Install specific version of git to satisfy github workflow action requirements 24 | run apt install -y libz-dev libssl-dev libcurl4-gnutls-dev libexpat1-dev gettext cmake gcc && \ 25 | wget https://github.com/git/git/archive/refs/tags/v2.38.1.tar.gz && \ 26 | tar -xvf v2.38.1.tar.gz && \ 27 | cd git-2.38.1 && \ 28 | make prefix=/usr/local all && \ 29 | make prefix=/usr/local install 30 | 31 | COPY . /app 32 | 33 | COPY policy.xml /etc/ImageMagick-6/policy.xml 34 | 35 | COPY config/auth-env.py /app/config/auth.py 36 | 37 | WORKDIR /app 38 | 39 | RUN pip install -r requirements.txt 40 | 41 | RUN playwright install-deps && playwright install 42 | 43 | CMD ["python3", "/app/app.py"] -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Automated Reddit to Youtube Bot 2 | 3 | 4 | * [Description](#description) 5 | * [Example Videos](#example-videos) 6 | * [Reddit Youtube Channels](#reddit-youtube-channels) 7 | * [Install Prerequisite Components](#install-prerequisite-components) 8 | * [Git clone repository](#git-clone-repository) 9 | * [Generate Reddit Tokens](#generate-reddit-tokens) 10 | * [Copy auth config](#copy-auth-config) 11 | * [Python Pip Install Dependencies](#python-pip-install-dependencies) 12 | * [Install Playwright](#install-playwright) 13 | * [Run Python Script](#run-python-script) 14 | * [Generate a Video for a Specific Post](#generate-a-video-for-a-specific-post) 15 | * [Generate Only Thumbnails](#generate-only-thumbnails) 16 | * [Enable a Newscaster](#enable-a-newscaster) 17 | * [Settings.py File](#settings.py-file) 18 | 19 | 23 | 24 | 25 | ## Description 26 | 27 | Scrape posts from Reddit and automatically generate Youtube Videos and Thumbnails 28 | 29 | ## Example Videos 30 | 31 | Checkout my Youtube Channel for example videos made by this repo : 32 | 33 | [What crime are you okay with people committing?](https://youtu.be/gOX1Uhxba-g) 34 | [![Watch the video](assets/images/xm5gsv_thumbnail.png)](https://youtu.be/gOX1Uhxba-g) 35 | 36 | [What show has no likable characters?](https://youtu.be/xAaPbntOVb8) 37 | [![Watch the video](assets/images/Whatshowhasnolikablecharacters.png)](https://youtu.be/xAaPbntOVb8) 38 | 39 | ## Reddit Youtube Channels 40 | 41 | Youtube Channels generated using this repo : 42 | * [TTSVibeLounge](https://www.youtube.com/@ttsvibelounge/videos) 43 | * [Aussie Banter Reddit](https://www.youtube.com/@AussieBanterReddit) 44 | 45 | If your Youtube Channel is generated using this repository and you would like it listed above please comment on this issue https://github.com/alexlaverty/python-reddit-youtube-bot/issues/91 with your youtube channel url and channel name and mention you'd like it listed in the main README file. 46 | 47 | 48 | # Quickstart Guide 49 | 50 | # Windows 51 | 52 | 53 | [Watch the Python Reddit Youtube Bot Tutorial Video :](https://youtu.be/LaFFU9EskfA) 54 | [![Watch the video](assets/images/python-reddit-youtube-bot-tutorial.png)](https://youtu.be/LaFFU9EskfA) 55 | 56 | ## Install Prerequisite Components 57 | 58 | Install these prerequisite components first : 59 | 60 | * Git - https://git-scm.com/download/win 61 | 62 | * Python 3.10 - https://www.python.org/ftp/python/3.10.0/python-3.10.0-amd64.exe 63 | 64 | * Microsoft C++ Build Tools - https://visualstudio.microsoft.com/visual-cpp-build-tools/ 65 | 66 | * ImageMagick - https://imagemagick.org/script/download.php#windows 67 | 68 | ## Git clone repository 69 | 70 | ``` 71 | git clone git@github.com:alexlaverty/python-reddit-youtube-bot.git 72 | cd python-reddit-youtube-bot 73 | ``` 74 | 75 | ## Generate Reddit Tokens 76 | 77 | Generate Reddit PRAW Tokens - https://www.reddit.com/prefs/apps/ 78 | 79 | ## Copy auth config 80 | 81 | Create a copy of the auth-example.py file and name it auth.py : 82 | 83 | ``` 84 | copy config/auth-example.py config/auth.py 85 | ``` 86 | 87 | Update the `auth.py` file to contain the Reddit Auth tokens you generated in the previous step. 88 | 89 | ## Python Pip Install Dependencies 90 | 91 | ``` 92 | pip install -r requirements.txt 93 | ``` 94 | 95 | ## Install Playwright 96 | 97 | Install and configure playwright by running : 98 | 99 | ``` 100 | playwright install 101 | ``` 102 | 103 | ## Run Python Script 104 | 105 | Run the python script : 106 | 107 | ``` 108 | python app.py 109 | ``` 110 | 111 | when it completes the video will be generated into the `videos` folder and will be named `final.mp4` 112 | 113 | # Downloading video backgrounds using yt-dlp : 114 | 115 | If you want to add a video background then install yt-dlp : 116 | 117 | [yt-dlp](https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe) 118 | 119 | then create a `backgrounds` folder and run the following command : 120 | 121 | ``` 122 | mkdir -p assets/backgrounds 123 | cd assets/backgrounds 124 | yt-dlp --playlist-items 1:10 -f 22 --output "%(uploader)s_%(id)s.%(ext)s" https://www.youtube.com/playlist?list=PLGmxyVGSCDKvmLInHxJ9VdiwEb82Lxd2E 125 | ``` 126 | 127 | # Help 128 | 129 | You can view available parameters by passing in `--help` : 130 | 131 | ``` 132 | python app.py --help 133 | 134 | ##### YOUTUBE REDDIT BOT ##### 135 | usage: app.py [-h] [-l VIDEO_LENGTH] [-o] [-s] [-t] [-u URL] 136 | 137 | options: 138 | -h, --help show this help message and exit 139 | -l VIDEO_LENGTH, --video-length VIDEO_LENGTH 140 | Set how long you want the video to be 141 | -o, --disable-overlay 142 | Disable video overlay 143 | -s, --story-mode Generate video for post title and selftext only, disables user comments 144 | -t, --thumbnail-only Generate thumbnail image only 145 | -u URL, --url URL Specify Reddit post url, seperate with a comma for multiple posts. 146 | ``` 147 | 148 | ## Generate a Video for a Specific Post 149 | 150 | or if you want to generate a video for a specific reddit post you can specify it via the `--url` param : 151 | 152 | ``` 153 | python app.py --url https://www.reddit.com/r/AskReddit/comments/hvsxty/which_legendary_reddit_post_comment_can_you_still/ 154 | ``` 155 | 156 | or you can do multiple url's by seperating with a comma, ie : 157 | 158 | ``` 159 | python app.py --url https://www.reddit.com/r/post1,https://www.reddit.com/r/post2,https://www.reddit.com/r/post3 160 | ``` 161 | 162 | ## Generate Only Thumbnails 163 | 164 | if you want to generate only thumbnails you can specify `--thumbnail-only` mode, this will skip video compilation process : 165 | 166 | ``` 167 | python app.py --thumbnail-only 168 | ``` 169 | 170 | ## Enable a Newscaster 171 | 172 | If you want to enable a Newscaster, edit settings.py and set : 173 | 174 | ``` 175 | enable_newscaster = True 176 | ``` 177 | 178 | ![](assets/newscaster.png) 179 | 180 | If the newcaster video has a green screen you can remove it with the following settings, 181 | use an eye dropper to get the RGB colour of the greenscreen and set it to have it removed : 182 | 183 | ``` 184 | newscaster_remove_greenscreen = True 185 | newscaster_greenscreen_color = [1, 255, 17] # Enter the Green Screen RGB Colour 186 | newscaster_greenscreen_remove_threshold = 100 187 | ``` 188 | 189 | ## Settings.py File 190 | 191 | Theres quite a few options you can customise in the `settings.py` file : 192 | 193 | Specify which subreddits you want to scrape : 194 | 195 | ``` 196 | subreddits = [ 197 | "AmItheAsshole", 198 | "antiwork", 199 | "AskMen", 200 | "ChoosingBeggars", 201 | "hatemyjob", 202 | "NoStupidQuestions", 203 | "pettyrevenge", 204 | "Showerthoughts", 205 | "TooAfraidToAsk", 206 | "TwoXChromosomes", 207 | "unpopularopinion", 208 | "confessions", 209 | "confession" 210 | ] 211 | ``` 212 | 213 | Subreddits to exclude : 214 | 215 | ``` 216 | subreddits_excluded = [ 217 | "r/CFB", 218 | ] 219 | ``` 220 | 221 | Filter out reddit posts via specified keywords 222 | 223 | ``` 224 | banned_keywords =["my", "nasty", "keywords"] 225 | ``` 226 | 227 | Change the Text to Speech engine you want to use, note AWS Polly requires and AWS account and auth tokens and can incur costs : 228 | 229 | Supports Speech Engines : 230 | 231 | * [AWS Polly](https://aws.amazon.com/polly/) 232 | * [Balcon](http://www.cross-plus-a.com/bconsole.htm) 233 | * Python [gtts](https://gtts.readthedocs.io/en/latest/) 234 | 235 | ``` 236 | # choices "polly","balcon","gtts" 237 | voice_engine = "polly" 238 | ``` 239 | 240 | Total number of reddit Videos to generate 241 | 242 | ``` 243 | total_posts_to_process = 5 244 | ``` 245 | 246 | The next settings are to automatically filter out posts 247 | 248 | Skip reddit posts that less than this amount of updates 249 | 250 | ``` 251 | minimum_submission_score = 5000 252 | ``` 253 | 254 | Filtering out reddit posts based on the reddit post title length 255 | 256 | ``` 257 | title_length_minimum = 20 258 | title_length_maximum = 100 259 | ``` 260 | 261 | Filter out posts that exceed the maximum self text length 262 | 263 | ``` 264 | maximum_length_self_text = 5000 265 | ``` 266 | 267 | Filter out reddit posts that don't have enough comments 268 | 269 | ``` 270 | minimum_num_comments = 200 271 | ``` 272 | 273 | Only attempt to process a maximum amount of reddit posts 274 | 275 | ``` 276 | submission_limit = 1000 277 | ``` 278 | 279 | Specify how many thumbnail images you want to generate 280 | 281 | ``` 282 | number_of_thumbnails = 3 283 | ``` 284 | 285 | Specify the maximum video length 286 | 287 | ``` 288 | max_video_length = 600 # Seconds 289 | ``` 290 | 291 | Specify maximum amount of comments to generate in the video 292 | 293 | ``` 294 | comment_limit = 600 295 | ``` 296 | 297 | Specifying various folder paths 298 | 299 | ``` 300 | assets_directory = "assets" 301 | temp_directory = "temp" 302 | audio_directory = str(Path("temp")) 303 | fonts_directory = str(Path(assets_directory,"fonts")) 304 | image_backgrounds_directory = str(Path(assets_directory,"image_backgrounds")) 305 | images_directory = str(Path(assets_directory,"images")) 306 | thumbnails_directory = str(Path(assets_directory,"images")) 307 | background_directory = str(Path(assets_directory,"backgrounds")) 308 | video_overlay_filepath = str(Path(assets_directory,"particles.mp4")) 309 | videos_directory = "videos" 310 | ``` 311 | 312 | Specify video height and width 313 | 314 | ``` 315 | video_height = 720 316 | video_width = 1280 317 | clip_size = (video_width, video_height) 318 | ``` 319 | 320 | Skip compiling the video and just exit instead 321 | 322 | ``` 323 | enable_compilation = True 324 | ``` 325 | 326 | Skip uploading to youtube 327 | 328 | ``` 329 | enable_upload = False 330 | ``` 331 | 332 | Add a video overlay to the video, for example snow falling effect 333 | 334 | ``` 335 | enable_overlay = True 336 | ``` 337 | 338 | Add in a newscaster reader to the video 339 | 340 | ``` 341 | enable_newscaster = True 342 | ``` 343 | 344 | If newcaster video is a green screen attempt to remove the green screen 345 | 346 | ``` 347 | newscaster_remove_greenscreen = True 348 | ``` 349 | 350 | Specify the color of the green screen in RGB 351 | 352 | ``` 353 | newscaster_greenscreen_color = [1, 255, 17] # Enter the Green Screen RGB Colour 354 | ``` 355 | 356 | The higher the greenscreen threshold number the more it will attempt to remove 357 | 358 | ``` 359 | newscaster_greenscreen_remove_threshold = 100 360 | ``` 361 | 362 | Path to newcaster file 363 | 364 | ``` 365 | newscaster_filepath = str(Path(assets_directory,"newscaster.mp4").resolve()) 366 | ``` 367 | 368 | Position on the screen of the newscaster 369 | 370 | ``` 371 | newscaster_position = ("left","bottom") 372 | ``` 373 | 374 | The size of the newscaster 375 | 376 | ``` 377 | newcaster_size = (video_width * 0.5, video_height * 0.5) 378 | ``` 379 | 380 | Add a pause after each text to speech audio file 381 | 382 | ``` 383 | pause = 1 # Pause after speech 384 | ``` 385 | 386 | Text style settings 387 | 388 | ``` 389 | text_bg_color = "#1A1A1B" 390 | text_bg_opacity = 1 391 | text_color = "white" 392 | text_font = "Verdana-Bold" 393 | text_fontsize = 32 394 | ``` 395 | 396 | Download images from lexica or skip trying to download 397 | 398 | ``` 399 | lexica_download_enabled = True 400 | ``` 401 | 402 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | """Main entrypoint for the bot.""" 2 | import logging 3 | import os 4 | import platform 5 | import sys 6 | from argparse import ArgumentParser, Namespace 7 | from pathlib import Path 8 | from typing import List 9 | 10 | from praw.models import Submission 11 | 12 | import config.settings as settings 13 | import reddit.reddit as reddit 14 | import thumbnail.thumbnail as thumbnail 15 | import video_generation.video as vid 16 | from csvmgr import CsvWriter 17 | from utils.common import create_directory, safe_filename 18 | 19 | logging.basicConfig( 20 | format="%(asctime)s %(levelname)-8s %(message)s", 21 | level=logging.INFO, 22 | datefmt="%Y-%m-%d %H:%M:%S", 23 | handlers=[logging.FileHandler("debug.log", "w", "utf-8"), logging.StreamHandler()], 24 | ) 25 | 26 | 27 | class Video: 28 | """Metadata used to splice content together to form a video.""" 29 | 30 | def __init__(self, submission): 31 | """Initialize a Video instance. 32 | 33 | Args: 34 | submission: The Reddit post to be converted into a video. 35 | """ 36 | self.submission = submission 37 | self.comments = [] 38 | self.clips = [] 39 | self.background = None 40 | self.music = None 41 | self.thumbnail_path = None 42 | self.folder_path = None 43 | self.video_filepath = None 44 | 45 | 46 | def process_submissions(submissions: List[Submission]) -> None: 47 | """Prepare multiple reddit posts for conversion into YouTube videos. 48 | 49 | Args: 50 | submissions: A list of zero or more Reddit posts to be converted. 51 | """ 52 | post_total: int = settings.total_posts_to_process 53 | post_count: int = 0 54 | 55 | for submission in submissions: 56 | title_path: str = safe_filename(submission.title) 57 | folder_path: Path = Path( 58 | settings.videos_directory, f"{submission.id}_{title_path}" 59 | ) 60 | video_filepath: Path = Path(folder_path, "final.mp4") 61 | if video_filepath.exists() or csvwriter.is_uploaded(submission.id): 62 | print(f"Final video already processed : {submission.id}") 63 | else: 64 | process_submission(submission) 65 | post_count += 1 66 | if post_count >= post_total: 67 | print("Reached post count total!") 68 | break 69 | 70 | 71 | def process_submission(submission: Submission) -> None: 72 | """Prepare a reddit post for conversion into a YouTube video. 73 | 74 | Args: 75 | submission: The Reddit post to be converted into to a video. 76 | """ 77 | print("===== PROCESSING SUBMISSION =====") 78 | print( 79 | f"{str(submission.id)}, {str(submission.score)}, \ 80 | {str(submission.num_comments)}, \ 81 | {len(submission.selftext)}, \ 82 | {submission.subreddit_name_prefixed}, \ 83 | {submission.title}" 84 | ) 85 | video: Video = Video(submission) 86 | title_path: str = safe_filename(submission.title) 87 | 88 | if settings.background_music_path: 89 | video.music = settings.background_music_path 90 | 91 | # Create Video Directories 92 | video.folder_path: str = str( 93 | Path(settings.videos_directory, f"{submission.id}_{title_path}") 94 | ) 95 | 96 | create_directory(video.folder_path) 97 | 98 | video.video_filepath = str(Path(video.folder_path, "final.mp4")) 99 | 100 | if os.path.exists(video.video_filepath): 101 | print(f"Final video already compiled : {video.video_filepath}") 102 | else: 103 | # Generate Thumbnail 104 | 105 | thumbnails = None 106 | if not settings.enable_thumbnails: 107 | print("Skipping thumbnails generation...") 108 | else: 109 | thumbnails: List[Path] = thumbnail.generate( 110 | video_directory=video.folder_path, 111 | subreddit=submission.subreddit_name_prefixed, 112 | title=submission.title, 113 | number_of_thumbnails=settings.number_of_thumbnails, 114 | ) 115 | 116 | if thumbnails: 117 | video.thumbnail_path = thumbnails[0] 118 | 119 | if args.thumbnail_only: 120 | print("Generating Thumbnail only skipping video compile!") 121 | else: 122 | vid.create( 123 | video_directory=video.folder_path, 124 | post=submission, 125 | thumbnails=thumbnails, 126 | ) 127 | 128 | 129 | def banner(): 130 | """Display the CLIs banner.""" 131 | print("##### YOUTUBE REDDIT BOT #####") 132 | 133 | 134 | def print_version_info(): 135 | """Display basic environment information.""" 136 | print(f"OS Version : {platform.system()} {platform.release()}") 137 | print(f"Python Version : {sys.version}") 138 | 139 | 140 | def get_args() -> Namespace: 141 | """Generate arguments supported by the CLI utility. 142 | 143 | Returns: 144 | An argparse Namepsace containing the supported CLI parameters. 145 | """ 146 | parser: ArgumentParser = ArgumentParser() 147 | 148 | parser.add_argument( 149 | "--enable-mentions", 150 | action="store_true", 151 | help="Check reddit account for u mentions", 152 | ) 153 | 154 | parser.add_argument( 155 | "--enable-saved", 156 | action="store_true", 157 | help="Check reddit account for saved posts", 158 | ) 159 | 160 | parser.add_argument( 161 | "--disable-selftext", 162 | action="store_true", 163 | help="Disable selftext video generation", 164 | ) 165 | 166 | parser.add_argument( 167 | "--voice-engine", 168 | help="Specify which text to speech engine to use", 169 | choices=["polly", "balcon", "gtts", "tiktok", "edge-tts", "streamlabspolly"], 170 | ) 171 | 172 | parser.add_argument( 173 | "-c", 174 | "--comment-style", 175 | help="Specify text based or reddit image comments", 176 | choices=["text", "reddit"], 177 | ) 178 | 179 | parser.add_argument( 180 | "-l", "--video-length", help="Set how long you want the video to be", type=int 181 | ) 182 | 183 | parser.add_argument( 184 | "-n", "--enable-nsfw", action="store_true", help="Allow NSFW Content" 185 | ) 186 | 187 | parser.add_argument( 188 | "-o", "--disable-overlay", action="store_true", help="Disable video overlay" 189 | ) 190 | 191 | parser.add_argument( 192 | "-s", 193 | "--story-mode", 194 | action="store_true", 195 | help="Generate video for post title and selftext only,\ 196 | disables user comments", 197 | ) 198 | 199 | parser.add_argument( 200 | "-t", 201 | "--thumbnail-only", 202 | action="store_true", 203 | help="Generate thumbnail image only", 204 | ) 205 | 206 | parser.add_argument( 207 | "-p", 208 | "--enable-upload", 209 | action="store_true", 210 | help="Upload video to youtube, \ 211 | requires client_secret.json and \ 212 | credentials.storage to be valid", 213 | ) 214 | 215 | parser.add_argument( 216 | "-u", 217 | "--url", 218 | help="Specify Reddit post url, \ 219 | seperate with a comma for multiple posts.", 220 | ) 221 | 222 | parser.add_argument("--subreddits", help="Specify Subreddits, seperate with +") 223 | 224 | parser.add_argument( 225 | "-b", 226 | "--enable-background", 227 | action="store_true", 228 | help="Enable video backgrounds", 229 | ) 230 | 231 | parser.add_argument( 232 | "-m", 233 | "--enable-background-music", 234 | action="store_true", 235 | help="Enable background music", 236 | ) 237 | 238 | parser.add_argument( 239 | "--background-music-path", 240 | help="Path to background music file" 241 | ) 242 | 243 | parser.add_argument( 244 | "--thumbnail-image-path", 245 | help="Path to thumbnail image file" 246 | ) 247 | 248 | 249 | parser.add_argument("--total-posts", type=int, help="Enable video backgrounds") 250 | 251 | parser.add_argument( 252 | "--submission-score", type=int, help="Minimum submission score threshold" 253 | ) 254 | 255 | parser.add_argument( 256 | "--background-directory", help="Folder path to video backgrounds" 257 | ) 258 | 259 | parser.add_argument("--sort", choices=["top", "hot"], help="Sort Reddit posts by") 260 | 261 | parser.add_argument( 262 | "--time", 263 | choices=["all", "day", "hour", "month", "week", "year"], 264 | default="day", 265 | help="Filter by time", 266 | ) 267 | 268 | parser.add_argument( 269 | "--orientation", 270 | choices=["landscape", "portrait"], 271 | default="landscape", 272 | help="Sort Reddit posts by", 273 | ) 274 | 275 | parser.add_argument( 276 | "--shorts", action="store_true", help="Generate Youtube Shorts Video" 277 | ) 278 | 279 | args = parser.parse_args() 280 | 281 | if args.orientation: 282 | settings.orientation = args.orientation 283 | if args.orientation == "portrait": 284 | settings.video_height = settings.vertical_video_height 285 | settings.video_width = settings.vertical_video_width 286 | 287 | if args.shorts: 288 | logging.info("Generating Youtube Shorts Video") 289 | settings.shorts_mode_enabled = True 290 | settings.orientation = "portrait" 291 | settings.video_height = settings.vertical_video_height 292 | settings.video_width = settings.vertical_video_width 293 | settings.add_hashtag_shorts_to_description = True 294 | settings.text_fontsize = 60 295 | settings.clip_margin = 100 296 | settings.clip_margin_top = settings.vertical_video_height / 3 297 | settings.reddit_comment_width = 0.90 298 | settings.commentstyle = "text" 299 | settings.enable_background = True 300 | settings.max_video_length = 59 301 | 302 | if args.enable_mentions: 303 | settings.enable_reddit_mentions = True 304 | logging.info("Enable Generate Videos from User Mentions") 305 | 306 | if args.enable_saved: 307 | settings.enable_reddit_saved = True 308 | logging.info("Enable Generate Videos from Saved Posts") 309 | 310 | 311 | if args.submission_score: 312 | settings.minimum_submission_score = args.submission_score 313 | logging.info( 314 | "Setting Reddit Post Minimum Submission Score : %s", 315 | settings.minimum_submission_score, 316 | ) 317 | 318 | if args.sort: 319 | settings.reddit_post_sort = args.sort 320 | logging.info("Setting Reddit Post Sort : %s", settings.reddit_post_sort) 321 | if args.time: 322 | settings.reddit_post_time_filter = args.time 323 | logging.info( 324 | "Setting Reddit Post Time Filter : %s", settings.reddit_post_time_filter 325 | ) 326 | 327 | if args.background_directory: 328 | logging.info( 329 | "Setting video background directory : %s", args.background_directory 330 | ) 331 | settings.background_directory = args.background_directory 332 | 333 | if args.enable_background_music: 334 | # Enable background music, selects random music file from assets/music 335 | logging.info( 336 | "Enable Background Music : True", 337 | ) 338 | settings.enable_background_music = True 339 | 340 | if args.background_music_path: 341 | # If you want specific music use this to specify the path to the mp3 file. 342 | logging.info( 343 | "Specify background music file : %s", args.background_music_path 344 | ) 345 | settings.enable_background_music = True 346 | settings.background_music_path = args.background_music_path 347 | 348 | if args.thumbnail_image_path: 349 | # If you want a specific thumbnail image specify the path using this arg. 350 | logging.info( 351 | "Settings thumbnail image path : %s", args.thumbnail_image_path 352 | ) 353 | settings.thumbnail_image_path = args.thumbnail_image_path 354 | 355 | if args.total_posts: 356 | logging.info("Total Posts to process : %s", args.total_posts) 357 | settings.total_posts_to_process = args.total_posts 358 | 359 | if args.comment_style: 360 | logging.info("Setting comment style to : %s", args.comment_style) 361 | settings.commentstyle = args.comment_style 362 | 363 | if args.voice_engine: 364 | logging.info("Setting speech engine to : %s", args.voice_engine) 365 | settings.voice_engine = args.voice_engine 366 | 367 | if args.video_length: 368 | logging.info("Setting video length to : %s seconds", args.video_length) 369 | settings.max_video_length = args.video_length 370 | 371 | if args.disable_overlay: 372 | logging.info("Disabling Video Overlay") 373 | settings.enable_overlay = False 374 | 375 | if args.enable_nsfw: 376 | logging.info("Enable NSFW Content") 377 | settings.enable_nsfw_content = True 378 | 379 | if args.story_mode: 380 | logging.info("Story Mode Enabled!") 381 | settings.enable_comments = False 382 | 383 | if args.disable_selftext: 384 | logging.info("Disabled SelfText!") 385 | settings.enable_selftext = False 386 | 387 | if args.enable_upload: 388 | logging.info("Upload video enabled!") 389 | settings.enable_upload = True 390 | 391 | if args.subreddits: 392 | logging.info("Subreddits :") 393 | settings.subreddits = args.subreddits.split("+") 394 | print(settings.subreddits) 395 | 396 | if args.enable_background: 397 | logging.info("Enabling Video Background!") 398 | settings.enable_background = True 399 | 400 | logging.info("Setting Orientation to : %s", settings.orientation) 401 | logging.info("Setting video_height to : %s", settings.video_height) 402 | logging.info("Setting video_width to : %s", settings.video_width) 403 | logging.info("Setting clip_margin to : %s", settings.clip_margin) 404 | logging.info("Setting reddit_comment_width to : %s", settings.reddit_comment_width) 405 | 406 | return args 407 | 408 | 409 | if __name__ == "__main__": 410 | banner() 411 | print_version_info() 412 | args: Namespace = get_args() 413 | csvwriter: CsvWriter = CsvWriter() 414 | csvwriter.initialise_csv() 415 | 416 | submissions: List[Submission] = [] 417 | 418 | if args.url: 419 | urls = args.url.split(",") 420 | for url in urls: 421 | submissions.append(reddit.get_reddit_submission(url)) 422 | else: 423 | if settings.enable_reddit_mentions: 424 | logging.info("Getting Reddit Mentions") 425 | mention_posts = reddit.get_reddit_mentions() 426 | for mention_post in mention_posts: 427 | logging.info("Reddit Mention : %s", mention_post) 428 | submissions.append(reddit.get_reddit_submission(mention_post)) 429 | 430 | if settings.enable_reddit_saved: 431 | logging.info("Getting Reddit Saved Posts") 432 | saved_posts = reddit.get_reddit_saved_posts() 433 | for saved_post in saved_posts: 434 | logging.info("Reddit Saved Post : %s", saved_post) 435 | submissions.append(reddit.get_reddit_submission(saved_post)) 436 | 437 | reddit_posts: List[Submission] = reddit.posts() 438 | for reddit_post in reddit_posts: 439 | submissions.append(reddit_post) 440 | 441 | submissions = reddit.get_valid_submissions(submissions) 442 | 443 | if submissions: 444 | process_submissions(submissions) 445 | -------------------------------------------------------------------------------- /assets/bin/geckodriver: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/bin/geckodriver -------------------------------------------------------------------------------- /assets/fonts/impact.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/fonts/impact.ttf -------------------------------------------------------------------------------- /assets/fonts/verdana.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/fonts/verdana.ttf -------------------------------------------------------------------------------- /assets/fonts/verdanab.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/fonts/verdanab.ttf -------------------------------------------------------------------------------- /assets/fonts/verdanai.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/fonts/verdanai.ttf -------------------------------------------------------------------------------- /assets/fonts/verdanaz.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/fonts/verdanaz.ttf -------------------------------------------------------------------------------- /assets/images/NicePng_evil-png_840970.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/NicePng_evil-png_840970.png -------------------------------------------------------------------------------- /assets/images/NicePng_masha-and-the-bear_1292475.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/NicePng_masha-and-the-bear_1292475.png -------------------------------------------------------------------------------- /assets/images/NicePng_meme-png_4338371.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/NicePng_meme-png_4338371.png -------------------------------------------------------------------------------- /assets/images/NicePng_roblox-girl-png_3028421.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/NicePng_roblox-girl-png_3028421.png -------------------------------------------------------------------------------- /assets/images/NicePng_spongegar-png_328488.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/NicePng_spongegar-png_328488.png -------------------------------------------------------------------------------- /assets/images/NicePng_twitch-emotes-png_4412701.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/NicePng_twitch-emotes-png_4412701.png -------------------------------------------------------------------------------- /assets/images/NicePng_tyler-the-creator-png_1696064.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/NicePng_tyler-the-creator-png_1696064.png -------------------------------------------------------------------------------- /assets/images/PngItem_1241663.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/PngItem_1241663.png -------------------------------------------------------------------------------- /assets/images/Whatshowhasnolikablecharacters.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/Whatshowhasnolikablecharacters.png -------------------------------------------------------------------------------- /assets/images/centaur.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/centaur.png -------------------------------------------------------------------------------- /assets/images/chicken.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/chicken.png -------------------------------------------------------------------------------- /assets/images/dog2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/dog2.png -------------------------------------------------------------------------------- /assets/images/duck.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/duck.png -------------------------------------------------------------------------------- /assets/images/fairy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/fairy.png -------------------------------------------------------------------------------- /assets/images/frog.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/frog.png -------------------------------------------------------------------------------- /assets/images/gabe.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/gabe.png -------------------------------------------------------------------------------- /assets/images/ghost-dress.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/ghost-dress.png -------------------------------------------------------------------------------- /assets/images/ghost.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/ghost.png -------------------------------------------------------------------------------- /assets/images/harambe.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/harambe.png -------------------------------------------------------------------------------- /assets/images/kermifire.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/kermifire.png -------------------------------------------------------------------------------- /assets/images/kermitgun.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/kermitgun.png -------------------------------------------------------------------------------- /assets/images/kermitowel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/kermitowel.png -------------------------------------------------------------------------------- /assets/images/kermitsuit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/kermitsuit.png -------------------------------------------------------------------------------- /assets/images/lemonman.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/lemonman.png -------------------------------------------------------------------------------- /assets/images/mermaid-g2d706be1f_1920.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/mermaid-g2d706be1f_1920.png -------------------------------------------------------------------------------- /assets/images/monster.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/monster.png -------------------------------------------------------------------------------- /assets/images/people/woman001.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/people/woman001.jpg -------------------------------------------------------------------------------- /assets/images/priest.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/priest.png -------------------------------------------------------------------------------- /assets/images/python-reddit-youtube-bot-tutorial.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/python-reddit-youtube-bot-tutorial.png -------------------------------------------------------------------------------- /assets/images/robot-g55cc67956_1920.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/robot-g55cc67956_1920.png -------------------------------------------------------------------------------- /assets/images/shia.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/shia.png -------------------------------------------------------------------------------- /assets/images/shibi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/shibi.png -------------------------------------------------------------------------------- /assets/images/shockface.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/shockface.png -------------------------------------------------------------------------------- /assets/images/successkid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/successkid.png -------------------------------------------------------------------------------- /assets/images/woman-g618a29d8c_1920.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/woman-g618a29d8c_1920.png -------------------------------------------------------------------------------- /assets/images/woman-g6cfce431d_1920.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/woman-g6cfce431d_1920.png -------------------------------------------------------------------------------- /assets/images/woman-g8d743d166_1920.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/woman-g8d743d166_1920.png -------------------------------------------------------------------------------- /assets/images/woman.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/woman.jpg -------------------------------------------------------------------------------- /assets/images/xm5gsv_thumbnail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/xm5gsv_thumbnail.png -------------------------------------------------------------------------------- /assets/images/xn8xzu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/xn8xzu.png -------------------------------------------------------------------------------- /assets/images/young-woman-g4459282f6_1920.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/young-woman-g4459282f6_1920.png -------------------------------------------------------------------------------- /assets/images/young-woman-gaa93b4883_1920.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/young-woman-gaa93b4883_1920.png -------------------------------------------------------------------------------- /assets/intro.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/intro.mp4 -------------------------------------------------------------------------------- /assets/intro_welcome.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/intro_welcome.mp4 -------------------------------------------------------------------------------- /assets/intro_welcome_crop.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/intro_welcome_crop.mp4 -------------------------------------------------------------------------------- /assets/music/[Non-Copyrighted Music] Chill Jazzy Lofi Hip Hop (Royalty Free) Jazz Hop Music [9Ojb8t3T2Ng].mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/music/[Non-Copyrighted Music] Chill Jazzy Lofi Hip Hop (Royalty Free) Jazz Hop Music [9Ojb8t3T2Ng].mp3 -------------------------------------------------------------------------------- /assets/music/[Non-Copyrighted Music] Chill Jazzy Lofi Hip-Hop (Royalty Free) Jazz Hop Music [IygKZNWPM_0].mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/music/[Non-Copyrighted Music] Chill Jazzy Lofi Hip-Hop (Royalty Free) Jazz Hop Music [IygKZNWPM_0].mp3 -------------------------------------------------------------------------------- /assets/newscaster.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/newscaster.mp4 -------------------------------------------------------------------------------- /assets/newscaster.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/newscaster.png -------------------------------------------------------------------------------- /assets/newscaster02.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/newscaster02.mp4 -------------------------------------------------------------------------------- /assets/particles.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/particles.mp4 -------------------------------------------------------------------------------- /assets/scary/transition/lightning.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/scary/transition/lightning.mp4 -------------------------------------------------------------------------------- /balcon.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/balcon.exe -------------------------------------------------------------------------------- /comment_templates/dark_reddit_mockup/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Dark Reddit Comment Template 4 | 5 | 6 | 145 | 146 | 147 | 148 |
149 |
150 | 151 | avatar 152 | 153 | 154 |
155 | header 156 | {{author}} 157 | 158 | 159 | {{date}} 160 | 161 |
162 | line 163 |
164 |
165 | 166 | comment 167 | {{body_html}} 168 | 169 | 170 | 171 | replies 172 | 173 | 174 | 175 | 176 | 206 |
207 |
208 | 209 | -------------------------------------------------------------------------------- /comment_templates/example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Reddit Comment Template 5 | 6 | 7 | 27 | 28 | 29 |
30 |
Avatar: 31 |
{{avatar}}
32 |
33 |
Author: 34 |
{{author}}
35 |
36 |
Comment ID: 37 |
{{id}}
38 |
39 |
Comment (html): 40 |
{{body_html}}
41 |
42 | 45 |
Upvotes: 46 |
{{score}}
47 |
48 |
Date: 49 |
{{date}}
50 |
51 |
52 | 53 | -------------------------------------------------------------------------------- /comment_templates/light_reddit_mockup/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Light Reddit Comment Template 4 | 5 | 6 | 145 | 146 | 147 | 148 |
149 |
150 | 151 | avatar 152 | 153 | 154 |
155 | header 156 | {{author}} 157 | 158 | 159 | {{date}} 160 | 161 |
162 | line 163 |
164 |
165 | 166 | comment 167 | {{body_html}} 168 | 169 | 170 | 171 | replies 172 | 173 | 174 | 175 | 176 | 206 |
207 |
208 | 209 | -------------------------------------------------------------------------------- /comment_templates/old_reddit_mockup/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 368 | 369 | 370 |
371 |

372 | 373 |

374 |
375 | 376 | 377 |
378 |
379 |

380 | [–] 381 | {{author}} 382 | 383 | 384 | 385 | 386 | 60 points 387 | {{score}} points 388 | 389 |   390 | (14 children) 391 |

392 |
393 | 394 |
395 | {{body_html}} 396 |
397 |
398 | 415 |
416 |
417 |
418 |
419 | 420 | 421 | -------------------------------------------------------------------------------- /comments/cookie-dark-mode.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "USER", 4 | "value": "eyJwcmVmcyI6eyJ0b3BDb250ZW50RGlzbWlzc2FsVGltZSI6MCwiZ2xvYmFsVGhlbWUiOiJSRURESVQiLCJuaWdodG1vZGUiOnRydWUsImNvbGxhcHNlZFRyYXlTZWN0aW9ucyI6eyJmYXZvcml0ZXMiOmZhbHNlLCJtdWx0aXMiOmZhbHNlLCJtb2RlcmF0aW5nIjpmYWxzZSwic3Vic2NyaXB0aW9ucyI6ZmFsc2UsInByb2ZpbGVzIjpmYWxzZX0sInRvcENvbnRlbnRUaW1lc0Rpc21pc3NlZCI6MH19", 5 | "domain": ".reddit.com", 6 | "path": "/" 7 | }, 8 | { 9 | "name": "eu_cookie", 10 | "value": "{%22opted%22:true%2C%22nonessential%22:false}", 11 | "domain": ".reddit.com", 12 | "path": "/" 13 | } 14 | ] 15 | -------------------------------------------------------------------------------- /comments/cookie-light-mode.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "eu_cookie", 4 | "value": "{%22opted%22:true%2C%22nonessential%22:false}", 5 | "domain": ".reddit.com", 6 | "path": "/" 7 | } 8 | ] 9 | -------------------------------------------------------------------------------- /config/auth-env.py: -------------------------------------------------------------------------------- 1 | """Load authentication credentials from environment variables.""" 2 | import os 3 | 4 | print("Getting Secrets from ENV's") 5 | # Reddit Praw 6 | praw_client_id = os.environ.get("PRAW_CLIENT_ID") 7 | praw_client_secret = os.environ.get("PRAW_CLIENT_SECRET") 8 | praw_user_agent = os.environ.get("PRAW_USER_AGENT") 9 | praw_password = os.environ.get("PRAW_PASSWORD") 10 | praw_username = os.environ.get("PRAW_USERNAME") 11 | 12 | # Amazon Polly 13 | aws_access_key_id = os.environ.get("AWS_ACCESS_KEY_ID") 14 | aws_secret_access_key = os.environ.get("AWS_SECRET_ACCESS_KEY") 15 | 16 | # Rumble 17 | rumble_username = os.environ.get("RUMBLE_USERNAME") 18 | rumble_password = os.environ.get("RUMBLE_PASSWORD") 19 | -------------------------------------------------------------------------------- /config/auth-example.py: -------------------------------------------------------------------------------- 1 | """Authentication credentials used by the bot.""" 2 | 3 | # FIXME: move credentials out of file. 4 | 5 | # Reddit Praw 6 | praw_client_id = "xxxxxx" # noqa: S105 7 | praw_client_secret = "xxxxxx" # noqa: S105 8 | praw_user_agent = "xxxxxx" 9 | praw_username = "xxxxxx" 10 | praw_password = "xxxxxx" # noqa: S105 11 | 12 | # Amazon Polly 13 | aws_access_key_id = "xxxxxx" 14 | aws_secret_access_key = "xxxxxx" # noqa: S105 15 | 16 | # Rumble 17 | rumble_username = "xxxxxx" 18 | rumble_password = "xxxxxx" # noqa: S105 19 | -------------------------------------------------------------------------------- /config/settings.py: -------------------------------------------------------------------------------- 1 | """Configuration settings for the bot.""" 2 | from sys import platform 3 | from pathlib import Path 4 | 5 | subreddits = [ 6 | "askreddit", 7 | "AmItheAsshole", 8 | "antiwork", 9 | "AskMen", 10 | "ChoosingBeggars", 11 | "hatemyjob", 12 | "NoStupidQuestions", 13 | "pettyrevenge", 14 | "Showerthoughts", 15 | "TooAfraidToAsk", 16 | "TwoXChromosomes", 17 | "unpopularopinion", 18 | "confessions", 19 | "confession", 20 | "singularity", 21 | ] 22 | 23 | subreddits_excluded = [ 24 | "r/CFB", 25 | ] 26 | 27 | # Sort reddit posts by choices ["hot","top"] 28 | reddit_post_sort = "hot" 29 | # Sort by "all", "day", "hour", "month", "week", or "year" 30 | reddit_post_time_filter = "day" 31 | 32 | # Speech Settings 33 | edge_tts_voice = "en-GB-RyanNeural" 34 | pause = 1 # Pause after speech 35 | streamlabs_polly_voice = "Brian" 36 | tiktok_voice = "en_us_006" 37 | 38 | # Set the gtts language, examples : 39 | # English (en) 40 | # Spanish (es) 41 | # French (fr) 42 | # German (de) 43 | # Italian (it) 44 | # Portuguese (pt) 45 | # Dutch (nl) 46 | # Russian (ru) 47 | # Mandarin Chinese (zh-cn) 48 | # Japanese (ja) 49 | gtts_language = "en" 50 | 51 | # Choices ["polly","balcon","gtts","tiktok","edge-tts","streamlabspolly"] 52 | voice_engine = "edge-tts" 53 | 54 | # Comment Settings 55 | banned_keywords_base64 = "cG9ybixzZXgsamVya2luZyBvZmYsc2x1dCxyYXB\ 56 | lLGZ1Y2sscmV0YXJkLG1vdGhlcmZ1Y2tlcixyYXBpc3Q=" 57 | theme = "dark" # "dark"/"light" 58 | minimum_num_comments = 200 59 | reddit_comment_opacity = 1 60 | reddit_comment_width = 0.95 61 | comment_length_max = 600 62 | comment_limit = 100 63 | comment_screenshot_timeout = 30000 64 | screenshot_debug = False # if True enables breakpoints in critical parts of screenshot.py 65 | use_old_reddit = False # if True use old.reddit.com instead of reddit.com 66 | use_old_reddit_login = True # if True use old.reddit.com to login instead of reddit.com 67 | use_comments_permalinks = False # if True don't try to scrape subreddit page, use comment permalinks directly 68 | 69 | template_url = "dark_reddit_mockup" 70 | template_abbreviated_style = "new_reddit" # can be one of ["none", "old_reddit", "new_reddit"] 71 | use_template = True # if True loads template_url as a comment template, and fills it with data using the Reddit API (using PRAW) 72 | template_debug = True # if True writes template output html files beside screenshots 73 | 74 | # Video settings 75 | background_colour = [26, 26, 27] 76 | background_opacity = 0.5 77 | background_volume = 0.5 78 | # commentstyle = ["reddit", "text"] 79 | commentstyle = "reddit" 80 | enable_background = False 81 | enable_comments = True 82 | enable_compilation = True # if True compile video 83 | enable_nsfw_content = False 84 | enable_overlay = False 85 | enable_selftext = True 86 | enable_comments_audio = True # if True generate mp3 from comment text 87 | enable_thumbnails = True # if True generate post thumbnails 88 | enable_upload = False 89 | enable_screenshot_title_image = False 90 | enable_reddit_mentions = False 91 | enable_reddit_saved = False 92 | enable_background_music = False 93 | background_music_path = "" 94 | background_music_volume = 0.3 # 30 percent (0.3) of the original volume 95 | thumbnail_image_path = "" 96 | lexica_download_enabled = True # Download files from Lexica 97 | max_video_length = 600 # Seconds 98 | maximum_length_self_text = 3000 99 | minimum_submission_score = 2000 100 | number_of_thumbnails = 1 101 | submission_limit = 500 102 | text_bg_color = "#1A1A1B" 103 | text_bg_opacity = 1 104 | text_color = "white" 105 | text_font = "Verdana-Bold" 106 | text_fontsize = 32 107 | clip_margin = 50 108 | clip_margin_top = 30 109 | title_length_maximum = 100 110 | title_length_minimum = 20 111 | total_posts_to_process = 1 112 | video_height = 720 113 | video_width = 1280 114 | vertical_video_width = 1080 115 | vertical_video_height = 1920 116 | # Video Orientation choices ["landscape","portrait"] 117 | orientation = "landscape" 118 | 119 | 120 | clip_size = (video_width, video_height) 121 | 122 | # Thumbnail settings choices ['random','lexica'] 123 | thumbnail_image_source = "lexica" 124 | thumbnail_text_width = video_width * 0.65 125 | enable_thumbnail_image_gradient = True 126 | 127 | # Directories and paths 128 | assets_directory = "assets" 129 | temp_directory = str(Path(assets_directory, "work_dir")) 130 | audio_directory = temp_directory 131 | speech_directory = temp_directory 132 | screenshot_directory = temp_directory 133 | fonts_directory = str(Path(assets_directory, "fonts")) 134 | image_backgrounds_directory = str(Path(assets_directory, "image_backgrounds")) 135 | images_directory = str(Path(assets_directory, "images")) 136 | thumbnails_directory = str(Path(assets_directory, "images")) 137 | background_directory = str(Path(assets_directory, "backgrounds")) 138 | music_directory = str(Path(assets_directory, "music")) 139 | soundeffects_directory = str(Path(assets_directory, "soundeffects")) 140 | video_overlay_filepath = str(Path(assets_directory, "particles.mp4")) 141 | videos_directory = "videos" 142 | add_hashtag_shorts_to_description = False 143 | shorts_mode_enabled = False 144 | 145 | # Youtube 146 | # Choices ['private', 'unlisted', 'public'] 147 | youtube_privacy_status = "public" 148 | 149 | # Newcaster Settings 150 | enable_newscaster = False 151 | newscaster_remove_greenscreen = True 152 | newscaster_greenscreen_color = [1, 255, 17] # Enter Green Screen RGB Colour 153 | newscaster_greenscreen_remove_threshold = 100 154 | newscaster_filepath = str(Path(assets_directory, "newscaster.mp4").resolve()) 155 | newscaster_position = ("left", "bottom") 156 | newcaster_size = (video_width * 0.5, video_height * 0.5) 157 | 158 | # Tweak for performance, set number of cores 159 | threads = 4 160 | 161 | # Whether to launch the Browser in Headless mode 162 | headless_browser = True # defaults to True, but can set it to False to see what happens 163 | 164 | if platform == "linux" or platform == "linux2": 165 | firefox_binary = "/opt/firefox/firefox" 166 | elif platform == "win32": 167 | firefox_binary = "C:\\Program Files\\Mozilla Firefox\\firefox.exe" 168 | -------------------------------------------------------------------------------- /csvmgr.py: -------------------------------------------------------------------------------- 1 | """Manage the CSV file that contains metadata about Reddit posts.""" 2 | import os 3 | from typing import Any, Dict 4 | 5 | import pandas as pd 6 | from pandas import DataFrame 7 | from typing import List 8 | 9 | 10 | class CsvWriter: 11 | """CSV Writer used to manage the bots database.""" 12 | 13 | def __init__(self, csv_file="data.csv") -> None: 14 | """Initialize a new CSV Writer for managing the bots database. 15 | 16 | Args: 17 | csv_file: Path to the CSV file used to track video uploads. 18 | """ 19 | self.csv_file = csv_file 20 | self.headers = ["id", "title", "thumbnail", "duration", "compiled", "uploaded"] 21 | 22 | def initialise_csv(self) -> None: 23 | """Initialise a fresh CSV file.""" 24 | csv_exists = os.path.isfile(self.csv_file) 25 | if not csv_exists: 26 | new_csv: DataFrame = DataFrame(columns=self.headers) 27 | new_csv.to_csv(self.csv_file, index=False) 28 | 29 | def write_entry(self, row: Dict[str, Any]) -> None: 30 | """Save a new record to the CSV file. 31 | 32 | Args: 33 | row: New record containing metadata about a video. 34 | """ 35 | csv: DataFrame = pd.read_csv(self.csv_file) 36 | entry: List[Dict[str, Any]] = [row] 37 | entry_df: DataFrame = DataFrame(entry) 38 | csv_entry: DataFrame = pd.concat([csv, entry_df]) 39 | csv_entry.to_csv(self.csv_file, index=False) 40 | 41 | def is_uploaded(self, id: str) -> bool: 42 | """Validate whether a video has been uploaded. 43 | 44 | Args: 45 | id: Unique id of the video tbe validated. 46 | 47 | Returns: 48 | `True` if the video has already been uploaded, else `False`. 49 | """ 50 | csv: DataFrame = pd.read_csv(self.csv_file) 51 | 52 | # TODO: ??? 53 | results: int = len(csv.loc[(csv["id"] == id) & (csv["uploaded"] == True)]) 54 | if results > 0: 55 | return True 56 | else: 57 | return False 58 | 59 | def set_uploaded(self, id: str) -> None: 60 | """Update the record to indicate the video has been uploaded. 61 | 62 | Args: 63 | id: Unique id of the record to be updated. 64 | """ 65 | c: DataFrame = pd.read_csv(self.csv_file) 66 | for index, row in c.iterrows(): 67 | if row["id"] == id: 68 | c.at[index, "uploaded"] = "true" 69 | c.to_csv(self.csv_file, index=False) 70 | 71 | 72 | if __name__ == "__main__": 73 | csvwriter: CsvWriter = CsvWriter() 74 | csvwriter.initialise_csv() 75 | print(csvwriter.is_uploaded("snppah")) 76 | 77 | csvwriter.set_uploaded("snppah") 78 | print(csvwriter.is_uploaded("snppah")) 79 | -------------------------------------------------------------------------------- /dependencies/scripts/postCreateCommand.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Needed to keep poetry happy 4 | pip install --user --upgrade pip setuptools wheel 5 | 6 | # Needed to keep imagemagick happy 7 | sudo sed -i '/ 9 | main() 10 | File "C:\Users\laverty\Documents\ttsvibelounge\app.py", line 61, in main 11 | post_video = video.create(post) 12 | File "C:\Users\laverty\Documents\ttsvibelounge\video.py", line 188, in create 13 | post_video.write_videofile(video_filename) 14 | File "", line 2, in write_videofile 15 | File "C:\Users\laverty\AppData\Local\Programs\Python\Python39\lib\site-packages\moviepy\decorators.py", line 54, in requires_duration 16 | return f(clip, *a, **k) 17 | File "", line 2, in write_videofile 18 | File "C:\Users\laverty\AppData\Local\Programs\Python\Python39\lib\site-packages\moviepy\decorators.py", line 135, in use_clip_fps_by_default 19 | return f(clip, *new_a, **new_kw) 20 | File "", line 2, in write_videofile 21 | File "C:\Users\laverty\AppData\Local\Programs\Python\Python39\lib\site-packages\moviepy\decorators.py", line 22, in convert_masks_to_RGB 22 | return f(clip, *a, **k) 23 | File "C:\Users\laverty\AppData\Local\Programs\Python\Python39\lib\site-packages\moviepy\video\VideoClip.py", line 293, in write_videofile 24 | self.audio.write_audiofile(audiofile, audio_fps, 25 | File "", line 2, in write_audiofile 26 | File "C:\Users\laverty\AppData\Local\Programs\Python\Python39\lib\site-packages\moviepy\decorators.py", line 54, in requires_duration 27 | return f(clip, *a, **k) 28 | File "C:\Users\laverty\AppData\Local\Programs\Python\Python39\lib\site-packages\moviepy\audio\AudioClip.py", line 206, in write_audiofile 29 | return ffmpeg_audiowrite(self, filename, fps, nbytes, buffersize, 30 | File "", line 2, in ffmpeg_audiowrite 31 | File "C:\Users\laverty\AppData\Local\Programs\Python\Python39\lib\site-packages\moviepy\decorators.py", line 54, in requires_duration 32 | return f(clip, *a, **k) 33 | File "C:\Users\laverty\AppData\Local\Programs\Python\Python39\lib\site-packages\moviepy\audio\io\ffmpeg_audiowriter.py", line 166, in ffmpeg_audiowrite 34 | for chunk in clip.iter_chunks(chunksize=buffersize, 35 | File "C:\Users\laverty\AppData\Local\Programs\Python\Python39\lib\site-packages\moviepy\audio\AudioClip.py", line 85, in iter_chunks 36 | yield self.to_soundarray(tt, nbytes=nbytes, quantize=quantize, 37 | File "", line 2, in to_soundarray 38 | File "C:\Users\laverty\AppData\Local\Programs\Python\Python39\lib\site-packages\moviepy\decorators.py", line 54, in requires_duration 39 | return f(clip, *a, **k) 40 | File "C:\Users\laverty\AppData\Local\Programs\Python\Python39\lib\site-packages\moviepy\audio\AudioClip.py", line 127, in to_soundarray 41 | snd_array = self.get_frame(tt) 42 | File "", line 2, in get_frame 43 | File "C:\Users\laverty\AppData\Local\Programs\Python\Python39\lib\site-packages\moviepy\decorators.py", line 89, in wrapper 44 | return f(*new_a, **new_kw) 45 | File "C:\Users\laverty\AppData\Local\Programs\Python\Python39\lib\site-packages\moviepy\Clip.py", line 93, in get_frame 46 | return self.make_frame(t) 47 | File "C:\Users\laverty\AppData\Local\Programs\Python\Python39\lib\site-packages\moviepy\audio\AudioClip.py", line 296, in make_frame 48 | sounds = [c.get_frame(t - c.start)*np.array([part]).T 49 | File "C:\Users\laverty\AppData\Local\Programs\Python\Python39\lib\site-packages\moviepy\audio\AudioClip.py", line 296, in 50 | sounds = [c.get_frame(t - c.start)*np.array([part]).T 51 | File "", line 2, in get_frame 52 | File "C:\Users\laverty\AppData\Local\Programs\Python\Python39\lib\site-packages\moviepy\decorators.py", line 89, in wrapper 53 | return f(*new_a, **new_kw) 54 | File "C:\Users\laverty\AppData\Local\Programs\Python\Python39\lib\site-packages\moviepy\Clip.py", line 93, in get_frame 55 | return self.make_frame(t) 56 | File "C:\Users\laverty\AppData\Local\Programs\Python\Python39\lib\site-packages\moviepy\Clip.py", line 136, in 57 | newclip = self.set_make_frame(lambda t: fun(self.get_frame, t)) 58 | File "C:\Users\laverty\AppData\Local\Programs\Python\Python39\lib\site-packages\moviepy\audio\fx\volumex.py", line 19, in 59 | return clip.fl(lambda gf, t: factor * gf(t), 60 | File "", line 2, in get_frame 61 | File "C:\Users\laverty\AppData\Local\Programs\Python\Python39\lib\site-packages\moviepy\decorators.py", line 89, in wrapper 62 | return f(*new_a, **new_kw) 63 | File "C:\Users\laverty\AppData\Local\Programs\Python\Python39\lib\site-packages\moviepy\Clip.py", line 93, in get_frame 64 | return self.make_frame(t) 65 | File "C:\Users\laverty\AppData\Local\Programs\Python\Python39\lib\site-packages\moviepy\audio\io\AudioFileClip.py", line 77, in 66 | self.make_frame = lambda t: self.reader.get_frame(t) 67 | File "C:\Users\laverty\AppData\Local\Programs\Python\Python39\lib\site-packages\moviepy\audio\io\readers.py", line 170, in get_frame 68 | raise IOError("Error in file %s, "%(self.filename)+ 69 | OSError: Error in file backgrounds\ULTRAWAVE_i7jU0xDABGM.mp4, Accessing time t=262.42-262.46 seconds, with clip duration=262 seconds, 70 | ``` -------------------------------------------------------------------------------- /docs/FEATURES.md: -------------------------------------------------------------------------------- 1 | ## Feature Requests 2 | 3 | * Loop background to fit length of video 4 | * Need an intro 5 | * Output video data to json file 6 | * [Deleted] comments are showing up in the video, filter them out 7 | * Support for emojis in text 8 | * Youtube Tags, get 9 | * Speech should handle Acronyms 10 | * YTA you're the asshole 11 | * NTA Not the asshole 12 | * AITA Am I The Asshole? 13 | * WTF What the Fuck -------------------------------------------------------------------------------- /docs/OrderOfEvents.md: -------------------------------------------------------------------------------- 1 | # Order of events 2 | 3 | Documenting the tasks that take place when making a video out of a Reddit post. 4 | 5 | 6 | * Get arguments passed into script and update settings values. 7 | 8 | * Get reddit posts or use specific reddit post if someone has passed in a url 9 | 10 | * Create required directory and directory for the video to be created based on the reddit post title and id 11 | 12 | * Check if Video already exists 13 | 14 | * Generate thumbnails 15 | 16 | ** Size the text properly to fit the thumbnail 17 | ** select random colours for borders and text 18 | 19 | * get images from Lexica search engine to use for thumbnails 20 | 21 | * Generate Video 22 | 23 | ** Create Video Title 24 | 25 | ** Generate selftext clips 26 | 27 | * Add static video clip after selftext completes 28 | 29 | * Generate Clips for Comments 30 | 31 | ** Filter out bad comments based on criteria 32 | 33 | ** Screenshot downloader downloads comment images 34 | 35 | * Add background clip 36 | 37 | * Add overlay clip 38 | 39 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /docs/notes.md: -------------------------------------------------------------------------------- 1 | ``` 2 | python app.py --enable-background --subreddits BreakUps+DeadBedrooms+LongDistance+RelationshipsOver35+dating_advice+polyamory+relationship_advice+relationshipproblems --story-mode 3 | ``` 4 | 5 | ## Using Dev Containers 6 | 7 | A basic VSCode dev container has been made available to simplify local development. 8 | 9 | ### Pre-requisites 10 | 11 | - Visual Studio Code 12 | - Docker Desktop 13 | 14 | ### Configuring your local environment. 15 | 16 | Open the repo folder locally when performing the below steps, don't do this 17 | through a WSL remote connection, or the devcontainer build will fail. The 18 | assumption is that Docker Desktop is configured to use the WSL2 based engine. 19 | 20 | 1. Install the **Dev Containers** Visual Studio Code extension. 21 | 2. Press **Ctrl+Shift+P** to open the command palette and run 22 | "Dev Containers: Open workspace in container". 23 | 3. Wait about 15mins for the devcontainer to be built. Don't worry, this is 24 | only slow the first time the image is built. It will: 25 | - Install ImageMagick and update the security policy. 26 | - Download the latest version of yt-dlp. 27 | - Initialize playwright, including downloading the Firefox browser. 28 | - Install python dependencies needed to run the application, as defined in 29 | `requirements.txt`. 30 | - Install python dependencies needed for local development, as defined in 31 | `requirements-dev.txt`. 32 | 33 | ### Future Improvements 34 | 35 | - Pre-build the base image to reduce startup time. 36 | - Add firstrun script to configure settings.py. 37 | - Configure pre-commit to ensure static analysis/tests are executed during 38 | commit process. -------------------------------------------------------------------------------- /libsamplerate.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/libsamplerate.dll -------------------------------------------------------------------------------- /policy.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 9 | ]> 10 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | -------------------------------------------------------------------------------- /publish/login.py: -------------------------------------------------------------------------------- 1 | """Login module.""" 2 | import json 3 | from typing import Dict, List 4 | 5 | from selenium.webdriver.common.by import By 6 | from selenium.webdriver.remote.webdriver import WebDriver 7 | from selenium.webdriver.support import expected_conditions as ec 8 | from selenium.webdriver.support.ui import WebDriverWait 9 | 10 | 11 | def domain_to_url(domain: str) -> str: 12 | """Convert a partial domain to valid URL. 13 | 14 | Args: 15 | domain: Domain to be converted. 16 | 17 | Returns: 18 | A fully qualified URL. 19 | """ 20 | if domain == ".chrome.google.com/robots.txt": 21 | domain = "chrome.google.com/robots.txt" 22 | else: 23 | if domain.startswith("."): 24 | domain = f"www{domain}" 25 | return f"http://{domain}" 26 | 27 | 28 | def login_using_cookie_file(driver: WebDriver, cookie_file: str) -> None: 29 | """Restore auth cookies from a file. 30 | 31 | Does not guarantee that the user is logged in afterwards. 32 | 33 | Visits the domains specified in the cookies to set them, the previous page 34 | is not restored. 35 | 36 | Args: 37 | driver: Selenium webdriver. 38 | cookie_file: Existing, valid authentication cookie. 39 | """ 40 | domain_cookies: Dict[str, List[object]] = {} 41 | with open(cookie_file) as file: 42 | cookies: List = json.load(file) 43 | # Sort cookies by domain, because we need to visit to domain to add cookies 44 | for cookie in cookies: 45 | try: 46 | domain_cookies[cookie["domain"]].append(cookie) 47 | except KeyError: 48 | domain_cookies[cookie["domain"]] = [cookie] 49 | 50 | for domain, cookies in domain_cookies.items(): 51 | driver.get(domain_to_url(domain + "/robots.txt")) 52 | for cookie in cookies: 53 | cookie.pop("sameSite", None) # Attribute should be available in Selenium >4 54 | cookie.pop("storeId", None) # Firefox container attribute 55 | try: 56 | driver.add_cookie(cookie) 57 | except Exception: 58 | print(f"Couldn't set cookie {cookie['name']} for {domain}") 59 | 60 | 61 | def confirm_logged_in(driver: WebDriver) -> bool: 62 | """Confirm that the user is logged in. 63 | 64 | The browser needs to be navigated to a YouTube page. 65 | 66 | Args: 67 | driver: Selenium webdrive. 68 | 69 | Returns: 70 | `True` if the user is logged in, otherwise `False`. 71 | """ 72 | try: 73 | WebDriverWait(driver, 5).until( 74 | ec.element_to_be_clickable((By.ID, "avatar-btn")) 75 | ) 76 | return True 77 | except TimeoutError: 78 | return False 79 | -------------------------------------------------------------------------------- /publish/upload.py: -------------------------------------------------------------------------------- 1 | """Upload a YouTube video.""" 2 | import logging 3 | import re 4 | from datetime import datetime, timedelta 5 | from time import sleep 6 | 7 | from selenium.common.exceptions import ( 8 | ElementNotInteractableException, 9 | NoSuchElementException, 10 | ) 11 | from selenium.webdriver.common.by import By 12 | from selenium.webdriver.common.keys import Keys 13 | from selenium.webdriver.remote.webdriver import WebDriver 14 | from selenium.webdriver.remote.webelement import WebElement 15 | from selenium.webdriver.support import expected_conditions as ec 16 | from selenium.webdriver.support.ui import WebDriverWait 17 | 18 | today = datetime.now() + timedelta(minutes=15) 19 | 20 | 21 | def upload_file( 22 | driver: WebDriver, 23 | video_path: str, 24 | title: str, 25 | description: str, 26 | game: str = False, 27 | kids: bool = False, 28 | upload_time: datetime = None, 29 | thumbnail_path: str = None, 30 | ) -> None: 31 | """Upload a single video to YouTube. 32 | 33 | Args: 34 | driver: Selenium webdriver. 35 | video_path: Path to the video to be uploaded. 36 | title: Video title as it will appear on YouTube. 37 | description: Video description as it will appear on YouTube. 38 | game: Game that the video is associated with. 39 | kids: `True` to indicate that the video is suitable for viewing by 40 | kids, otherwise `false`. 41 | upload_time: Date and time that the video was uploaded. 42 | """ 43 | WebDriverWait(driver, 20).until( 44 | ec.element_to_be_clickable((By.CSS_SELecTOR, "ytcp-button#create-icon")) 45 | ).click() 46 | WebDriverWait(driver, 20).until( 47 | ec.element_to_be_clickable( 48 | (By.XPATH, '//tp-yt-paper-item[@test-id="upload-beta"]') 49 | ) 50 | ).click() 51 | video_input = driver.find_element(by=By.XPATH, value='//input[@type="file"]') 52 | logging.info("Setting Video File Path") 53 | video_input.send_keys(video_path) 54 | 55 | _set_basic_settings(driver, title, description, thumbnail_path) 56 | _set_advanced_settings(driver, game, kids) 57 | # Go to visibility settings 58 | for _i in range(3): 59 | WebDriverWait(driver, 20).until( 60 | ec.element_to_be_clickable((By.ID, "next-button")) 61 | ).click() 62 | 63 | # _set_time(driver, upload_time) 64 | try: 65 | _set_visibility(driver) 66 | except Exception: 67 | print("error uploading, continuing") 68 | _wait_for_processing(driver) 69 | # Go back to endcard settings 70 | # find_element(By.CSS_SELecTOR,"#step-badge-1").click() 71 | # _set_endcard(driver) 72 | 73 | # for _ in range(2): 74 | # # Sometimes, the button is clickable but clicking it raises an 75 | # # error, so we add a "safety-sleep" here 76 | # sleep(5) 77 | # WebDriverWait(driver, 20) 78 | # .until(ec.element_to_be_clickable((By.ID, "next-button"))) 79 | # .click() 80 | 81 | # sleep(5) 82 | # WebDriverWait(driver, 20) 83 | # .until(ec.element_to_be_clickable((By.ID, "done-button"))) 84 | # .click() 85 | 86 | # # Wait for the dialog to disappear 87 | # sleep(5) 88 | driver.close() 89 | logging.info("Upload is complete") 90 | 91 | 92 | def _wait_for_processing(driver: WebDriver) -> None: 93 | """Wait for YouTube to process the video. 94 | 95 | Calling this method will cause progress updates to be sent to stdout 96 | every 5 seconds until the processing is complete. 97 | 98 | Args: 99 | driver: Selenium webdriver. 100 | """ 101 | logging.info("Waiting for processing to complete") 102 | # Wait for processing to complete 103 | progress_label: WebElement = driver.find_element( 104 | By.CSS_SELecTOR, "span.progress-label" 105 | ) 106 | pattern = re.compile(r"(finished processing)|(processing hd.*)|(check.*)") 107 | current_progress = progress_label.get_attribute("textContent") 108 | last_progress = None 109 | while not pattern.match(current_progress.lower()): 110 | if last_progress != current_progress: 111 | logging.info(f"Current progress: {current_progress}") 112 | last_progress = current_progress 113 | sleep(5) 114 | current_progress = progress_label.get_attribute("textContent") 115 | if "Processing 99" in current_progress: 116 | print("Finished Processing!") 117 | sleep(10) 118 | break 119 | 120 | 121 | def _set_basic_settings( 122 | driver: WebDriver, title: str, description: str, thumbnail_path: str = None 123 | ) -> None: 124 | """Configure basic video settings. 125 | 126 | This function can be used to configure the videos title, description, and set 127 | the image that should be used as a thumbnail. 128 | 129 | Args: 130 | driver: Selenium webdriver. 131 | title: Video title. 132 | description: Video description. 133 | thumbnail_path: Path to be used to set the videos thumbnail, as it 134 | appears in YouTube search results. 135 | """ 136 | logging.info("Setting Basic Settings") 137 | title_input: WebElement = WebDriverWait(driver, 20).until( 138 | ec.element_to_be_clickable( 139 | ( 140 | By.XPATH, 141 | # '//ytcp-mention-textbox[@label="Title"]//div[@id="textbox"]', 142 | '//ytcp-social-suggestions-textbox[@id="title-textarea"]//\ 143 | div[@id="textbox"]', 144 | ) 145 | ) 146 | ) 147 | 148 | # Input meta data (title, description, etc ... ) 149 | description_input: WebElement = driver.find_element( 150 | by=By.XPATH, 151 | # '//ytcp-mention-textbox[@label="Description"]//div[@id="textbox"]' 152 | value="//ytcp-social-suggestions-textbox[@label='Description']//\ 153 | div[@id='textbox']", 154 | ) 155 | thumbnail_input: WebElement = driver.find_element( 156 | By.CSS_SELecTOR, "input#file-loader" 157 | ) 158 | 159 | title_input.clear() 160 | logging.info("Setting Video Title") 161 | title_input.send_keys(title) 162 | logging.info("Setting Video Description") 163 | description_input.send_keys(description) 164 | if thumbnail_path: 165 | logging.info("Setting Video Thumbnail") 166 | thumbnail_input.send_keys(thumbnail_path) 167 | 168 | 169 | def _set_advanced_settings( 170 | driver: WebDriver, game_title: str, made_for_kids: bool 171 | ) -> None: 172 | """Associate the video with a game, and/or flag it as suitable for kids. 173 | 174 | Args: 175 | driver: Selenium webdriver. 176 | game_title: Name of the game that the video is relevant to. 177 | made_for_kids: `True` to indicate the video is suitable for viewing by 178 | kids, otherwise `False`. 179 | """ 180 | logging.info("Setting Advanced Settings") 181 | # Open advanced options 182 | driver.find_element(By.CSS_SELecTOR, "#toggle-button").click() 183 | if game_title: 184 | game_title_input: WebElement = driver.find_element( 185 | By.CSS_SELecTOR, 186 | ".ytcp-form-gaming > " 187 | "ytcp-dropdown-trigger:nth-child(1) > " 188 | ":nth-child(2) > div:nth-child(3) > input:nth-child(3)", 189 | ) 190 | game_title_input.send_keys(game_title) 191 | 192 | # Select first item in game drop down 193 | WebDriverWait(driver, 20).until( 194 | ec.element_to_be_clickable( 195 | ( 196 | By.CSS_SELecTOR, 197 | "#text-item-2", # The first item is an empty item 198 | ) 199 | ) 200 | ).click() 201 | 202 | WebDriverWait(driver, 20).until( 203 | ec.element_to_be_clickable( 204 | ( 205 | By.NAME, 206 | "VIDEO_MADE_FOR_KIDS_MFK" 207 | if made_for_kids 208 | else "VIDEO_MADE_FOR_KIDS_NOT_MFK", 209 | ) 210 | ) 211 | ).click() 212 | 213 | 214 | def _set_endcard(driver: WebDriver) -> None: 215 | """Set the end card. 216 | 217 | Args: 218 | driver: Selenium webdriver. 219 | """ 220 | logging.info("Endscreen") 221 | 222 | # Add endscreen 223 | driver.find_element(By.CSS_SELecTOR, "#endscreens-button").click() 224 | sleep(5) 225 | 226 | for i in range(1, 11): 227 | try: 228 | # Select endcard type from last video or first suggestion if no prev. video 229 | driver.find_element(By.CSS_SELecTOR, "div.card:nth-child(1)").click() 230 | break 231 | except (NoSuchElementException, ElementNotInteractableException): 232 | logging.warning(f"Couldn't find endcard button. Retry in 5s! ({i}/10)") 233 | sleep(5) 234 | 235 | WebDriverWait(driver, 20).until( 236 | ec.element_to_be_clickable((By.ID, "save-button")) 237 | ).click() 238 | 239 | 240 | def _set_time(driver: WebDriver, upload_time: datetime) -> None: 241 | """Schedule a time for the video to become available. 242 | 243 | Args: 244 | driver: Selenium webdriver. 245 | upload_time: Date and time that the video should be published. 246 | """ 247 | # Start time scheduling 248 | WebDriverWait(driver, 20).until( 249 | ec.element_to_be_clickable((By.NAME, "SCHEDULE")) 250 | ).click() 251 | 252 | # Open date_picker 253 | driver.find_element( 254 | By.CSS_SELecTOR, "#datepicker-trigger > ytcp-dropdown-trigger:nth-child(1)" 255 | ).click() 256 | 257 | date_input: WebElement = driver.find_element( 258 | By.CSS_SELecTOR, "input.tp-yt-paper-input" 259 | ) 260 | date_input.clear() 261 | # Transform date into required format: Mar 19, 2021 262 | date_input.send_keys(upload_time.strftime("%b %d, %Y")) 263 | date_input.send_keys(Keys.RETURN) 264 | 265 | # Open time_picker 266 | driver.find_element( 267 | By.CSS_SELecTOR, 268 | "#time-of-day-trigger > ytcp-dropdown-trigger:nth-child(1) > div:nth-child(2)", 269 | ).click() 270 | 271 | time_list = driver.find_elements( 272 | By.CSS_SELecTOR, "tp-yt-paper-item.tp-yt-paper-item" 273 | ) 274 | # Transform time into required format: 8:15 PM 275 | time_str: str = upload_time.strftime("%I:%M %p").strip("0") 276 | time = [time for time in time_list[2:] if time.text == time_str][0] 277 | time.click() 278 | 279 | 280 | def _set_visibility(driver: WebDriver) -> None: 281 | """Make a published video public. 282 | 283 | Args: 284 | driver: Selenium webdriver. 285 | """ 286 | # Start time scheduling 287 | logging.info("Setting Visibility to public") 288 | WebDriverWait(driver, 30).until( 289 | ec.element_to_be_clickable((By.NAME, "FIRST_CONTAINER")) 290 | ).click() 291 | WebDriverWait(driver, 30).until( 292 | ec.element_to_be_clickable((By.NAME, "PUBLIC")) 293 | ).click() 294 | sleep(10) 295 | WebDriverWait(driver, 30).until( 296 | ec.element_to_be_clickable((By.ID, "done-button")) 297 | ).click() 298 | # sleep(10) 299 | # WebDriverWait(driver, 30) 300 | # .until(ec.element_to_be_clickable((By.ID, "close-button"))).click() 301 | -------------------------------------------------------------------------------- /publish/youtube.py: -------------------------------------------------------------------------------- 1 | """Upload a single video to YouTube.""" 2 | 3 | from argparse import ArgumentParser, Namespace 4 | from dataclasses import dataclass, field 5 | from googleapiclient.discovery import build 6 | from googleapiclient.errors import HttpError 7 | from googleapiclient.http import MediaFileUpload 8 | from oauth2client.client import flow_from_clientsecrets 9 | from oauth2client.file import Storage 10 | from oauth2client.tools import argparser, run_flow 11 | from pathlib import Path 12 | import httplib2 13 | import logging 14 | import os 15 | import random 16 | import sys 17 | import time 18 | 19 | #import config.settings as settings 20 | 21 | logging.basicConfig( 22 | format="%(asctime)s %(levelname)-8s %(message)s", 23 | level=logging.INFO, 24 | datefmt="%Y-%m-%d %H:%M:%S", 25 | handlers=[logging.FileHandler("debug.log"), logging.StreamHandler()], 26 | ) 27 | 28 | CLIENT_SECRETS_FILE = "client_secret.json" 29 | MAX_RETRIES = 10 30 | MISSING_CLIENT_SECRETS_MESSAGE = "MISSING_CLIENT_SECRETS_MESSAGE" 31 | RETRIABLE_EXCEPTIONS = (httplib2.HttpLib2Error, IOError) 32 | RETRIABLE_STATUS_CODES = [500, 502, 503, 504] 33 | YOUTUBE_API_SERVICE_NAME = "youtube" 34 | YOUTUBE_API_VERSION = "v3" 35 | YOUTUBE_UPLOAD_SCOPE = "https://www.googleapis.com/auth/youtube.upload" 36 | 37 | @dataclass 38 | class Video: 39 | """A video to be uploaded to YouTube.""" 40 | 41 | filepath: Path = field(default_factory=Path) 42 | title: str = field(default_factory=str) 43 | thumbnail: Path = field(default_factory=Path) 44 | description: str = "This is my videos description" 45 | 46 | # Call the API's thumbnails.set method to upload the thumbnail image and 47 | # associate it with the appropriate video. 48 | def upload_thumbnail(youtube, video_id, file): 49 | print("Uploading and setting Youtube Thumbnail..") 50 | print(f"video_id: {video_id}") 51 | print(f"file: {file}") 52 | youtube.thumbnails().set( 53 | videoId=video_id, 54 | media_body=MediaFileUpload(file) 55 | ).execute() 56 | 57 | def publish(video: Video) -> None: 58 | flow = flow_from_clientsecrets(CLIENT_SECRETS_FILE, 59 | scope=YOUTUBE_UPLOAD_SCOPE, 60 | message=MISSING_CLIENT_SECRETS_MESSAGE) 61 | 62 | storage = Storage("credentials.storage") 63 | credentials = storage.get() 64 | 65 | if credentials is None or credentials.invalid: 66 | print("Credentials invalid...") 67 | 68 | youtube = build(YOUTUBE_API_SERVICE_NAME, YOUTUBE_API_VERSION, http=credentials.authorize(httplib2.Http())) 69 | 70 | body = dict( 71 | snippet=dict( 72 | title=video.title, 73 | description=video.description, 74 | tags=["Reddit","Australia","Aussie","Banter"], 75 | categoryId="24" 76 | ), 77 | status=dict( 78 | privacyStatus="public", 79 | selfDeclaredMadeForKids=False 80 | ) 81 | ) 82 | 83 | insert_request = youtube.videos().insert( 84 | part=",".join(body.keys()), 85 | body=body, 86 | media_body=MediaFileUpload(video.filepath, chunksize=-1, resumable=True) 87 | ) 88 | 89 | resumable_upload(video, youtube, insert_request) 90 | 91 | 92 | 93 | # This method implements an exponential backoff strategy to resume a 94 | # failed upload. 95 | 96 | 97 | def resumable_upload(video, youtube, insert_request): 98 | response = None 99 | error = None 100 | retry = 0 101 | while response is None: 102 | try: 103 | print("Uploading file...") 104 | status, response = insert_request.next_chunk() 105 | if response is not None: 106 | if 'id' in response: 107 | print("Video id '%s' was successfully uploaded." % 108 | response['id']) 109 | upload_thumbnail(youtube, response['id'], video.thumbnail) 110 | else: 111 | exit("The upload failed with an unexpected response: %s" % response) 112 | except HttpError as e: 113 | if e.resp.status in RETRIABLE_STATUS_CODES: 114 | error = "A retriable HTTP error %d occurred:\n%s" % (e.resp.status, 115 | e.content) 116 | else: 117 | raise 118 | except RETRIABLE_EXCEPTIONS as e: 119 | error = "A retriable error occurred: %s" % e 120 | 121 | if error is not None: 122 | print(error) 123 | retry += 1 124 | if retry > MAX_RETRIES: 125 | exit("No longer attempting to retry.") 126 | 127 | max_sleep = 2 ** retry 128 | sleep_seconds = random.random() * max_sleep 129 | print("Sleeping %f seconds and then retrying..." % sleep_seconds) 130 | time.sleep(sleep_seconds) 131 | 132 | 133 | if __name__ == "__main__": 134 | parser: ArgumentParser = ArgumentParser() 135 | parser.add_argument( 136 | "--filepath", 137 | default="D:\\src\\aussie-banter\\videos\\13r3zri_Is_a_sausage_roll_with_tomato_sauce_for_breakfast_\\final.mp4", 138 | help="Specify path to video file", 139 | ) 140 | parser.add_argument( 141 | "--title", 142 | default="r/AusFinance Is a sausage roll with tomato sauce for breakfast common?", 143 | help="Video Title", 144 | ) 145 | parser.add_argument( 146 | "--thumbnail", 147 | default="D:\\src\\aussie-banter\\videos\\13r3zri_Is_a_sausage_roll_with_tomato_sauce_for_breakfast_\\thumbnail_0.png", 148 | help="Video Thumbnail Image", 149 | ) 150 | args: Namespace = parser.parse_args() 151 | 152 | video: Video = Video( 153 | filepath=args.filepath, 154 | title=args.title, 155 | thumbnail=args.thumbnail 156 | ) 157 | 158 | publish(video) -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.flake8] 2 | max-line-length = 88 3 | extend-ignore = ['E203'] 4 | 5 | [tool.isort] 6 | profile = "black" 7 | 8 | [tool.pylint.format] 9 | max-line-length = 88 10 | 11 | [tool.pycodestyle] 12 | max-line-length = 88 13 | ignore = ['E203'] 14 | -------------------------------------------------------------------------------- /reddit/reddit.py: -------------------------------------------------------------------------------- 1 | """Helpers used to retreive, filter and process reddit posts.""" 2 | import praw 3 | import config.settings as settings 4 | import config.auth as auth 5 | import base64 6 | import re 7 | from praw.models import Submission 8 | from typing import List 9 | 10 | 11 | def is_valid_submission(submission) -> bool: 12 | """Determine whether a Reddit post is worth turning in to a video. 13 | 14 | A post is deemed "worthy", if: 15 | - It isn't stickied. 16 | - It was submitted by ttvibe. 17 | - The posts title is within the min/max ranges defined in settings. 18 | - The post hasn't been flagged as NSFW. 19 | - The post doesn't contain banned keywords. 20 | - The post wasn't made in a subreddit that has been added to the 21 | ignore list. 22 | - The submission score is within range. 23 | - The length of the post content is less than the configured maximum 24 | length. 25 | - The post has more than a minimum number of comments. 26 | - The post is not an update on a previous post. 27 | - The post doesn't contain 'covid' or 'vaccine' in the title, as these 28 | tend to trigger Youtube strikes. Censorship is double-plus good... 29 | 30 | Args: 31 | submission: 32 | 33 | Returns: 34 | `True` if the Reddit post is deemed worthy of turning in to a video, 35 | otherwise returns `False`. 36 | """ 37 | if submission.stickied: 38 | return False 39 | 40 | if not settings.enable_screenshot_title_image and not submission.is_self: 41 | return False 42 | 43 | if ( 44 | len(submission.title) < settings.title_length_minimum 45 | or len(submission.title) > settings.title_length_maximum 46 | ): 47 | return False 48 | 49 | if not settings.enable_nsfw_content: 50 | if submission.over_18: 51 | print("Skipping NSFW...") 52 | return False 53 | for banned_keyword in ( 54 | base64.b64decode(settings.banned_keywords_base64.encode("ascii")) 55 | .decode("ascii") 56 | .split(",") 57 | ): 58 | if banned_keyword in submission.title.lower(): 59 | print( 60 | f"{submission.title} \ 61 | <-- Skipping post, title contains banned keyword!" 62 | ) 63 | return False 64 | 65 | if submission.subreddit_name_prefixed in settings.subreddits_excluded: 66 | return False 67 | 68 | if submission.score < settings.minimum_submission_score: 69 | print(f"{submission.title} <-- Submission score too low!") 70 | return False 71 | 72 | if len(submission.selftext) > settings.maximum_length_self_text: 73 | return False 74 | 75 | if ( 76 | settings.enable_comments 77 | and submission.num_comments < settings.minimum_num_comments 78 | ): 79 | print(f"{submission.title} <-- Number of comments too low!") 80 | return False 81 | 82 | if "update" in submission.title.lower(): 83 | return False 84 | 85 | if "covid" in submission.title.lower() or "vaccine" in submission.title.lower(): 86 | print(f"{submission.title} <-- Youtube Channel Strikes if Covid content...!") 87 | return False 88 | 89 | return True 90 | 91 | 92 | def get_reddit_submission(url: str) -> Submission: 93 | """Get a single Reddit post. 94 | 95 | Args: 96 | url: URL to the post to be retrieved. 97 | 98 | Returns: 99 | The post contents. 100 | """ 101 | r: praw.Reddit = praw.Reddit( 102 | client_id=auth.praw_client_id, 103 | client_secret=auth.praw_client_secret, 104 | user_agent=auth.praw_user_agent, 105 | check_for_async=not settings.use_template # disable check (to hide warnings) if settings.use_template is True 106 | ) 107 | 108 | submission: Submission = r.submission(url=url) 109 | return submission 110 | 111 | 112 | def get_reddit_mentions() -> List[str]: 113 | """Get a list of comments where ttvibe has been mentioned. 114 | 115 | Returns: 116 | A list containing zero or more URLs where ttvibe has been mentioned. 117 | """ 118 | r: praw.Reddit = praw.Reddit( 119 | client_id=auth.praw_client_id, 120 | client_secret=auth.praw_client_secret, 121 | user_agent=auth.praw_user_agent, 122 | username=auth.praw_username, 123 | password=auth.praw_password, 124 | ) 125 | 126 | mention_urls: List[str] = [] 127 | for mention in r.inbox.mentions(limit=None): 128 | post_url: str = re.sub( 129 | rf"/{mention.id}/\?context=\d", "", mention.context, flags=re.IGNORECASE 130 | ) 131 | mention_urls.append(f"https://www.reddit.com{post_url}") 132 | 133 | return mention_urls 134 | 135 | def get_reddit_saved_posts() -> List[str]: 136 | """Get a list of comments where ttvibe has been mentioned. 137 | 138 | Returns: 139 | A list containing zero or more URLs where ttvibe has been mentioned. 140 | """ 141 | r: praw.Reddit = praw.Reddit( 142 | client_id=auth.praw_client_id, 143 | client_secret=auth.praw_client_secret, 144 | user_agent=auth.praw_user_agent, 145 | username=auth.praw_username, 146 | password=auth.praw_password, 147 | ) 148 | 149 | saved_posts_urls: List[str] = [] 150 | # Get the user's saved posts 151 | saved_posts = r.user.me().saved(limit=None) # 'limit=None' fetches all saved posts 152 | 153 | # Print the URLs of saved posts 154 | for post in saved_posts: 155 | print(post.url) 156 | saved_posts_urls.append(post.url) 157 | 158 | return saved_posts_urls 159 | 160 | def get_reddit_submissions() -> List[Submission]: 161 | """Get the latest Reddit posts. 162 | 163 | Posts will be retrieved according to the sort order defined in settings. 164 | 165 | Returns: 166 | A list containing zero or more Reddit posts. 167 | """ 168 | r: praw.Reddit = praw.Reddit( 169 | client_id=auth.praw_client_id, 170 | client_secret=auth.praw_client_secret, 171 | user_agent=auth.praw_user_agent, 172 | check_for_async=not settings.use_template # disable check (to hide warnings) if settings.use_template is True 173 | ) 174 | 175 | if settings.subreddits: 176 | subreddits: str = "+".join(settings.subreddits) 177 | else: 178 | subreddits: str = "all" 179 | print("Retrieving posts from subreddit :") 180 | print(subreddits) 181 | 182 | # Get Reddit Hot Posts 183 | if settings.reddit_post_sort == "hot": 184 | submissions: List[Submission] = r.subreddit(subreddits).hot( 185 | limit=settings.submission_limit 186 | ) 187 | 188 | if settings.reddit_post_sort == "top": 189 | # Get Reddit Top Posts 190 | # "all", "day", "hour", "month", "week", or "year" (default: "all" 191 | 192 | submissions: List[Submission] = r.subreddit(subreddits).top( 193 | limit=settings.submission_limit, 194 | time_filter=settings.reddit_post_time_filter, 195 | ) 196 | 197 | return submissions 198 | 199 | 200 | def get_valid_submissions(submissions: List[Submission]) -> List[Submission]: 201 | """Get Reddit posts deemed suitable for use as source material. 202 | 203 | Args: 204 | submissions: List of Reddit posts to validate. 205 | 206 | Returns: 207 | A list of zero or more Reddit posts that are suitable for use as source 208 | material, to generate a video. 209 | """ 210 | valid_submissions: List[Submission] = [] 211 | print("===== Retrieving valid Reddit submissions =====") 212 | print("ID, SCORE, NUM_COMMENTS, LEN_SELFTEXT, SUBREDDIT, TITLE") 213 | for submission in submissions: 214 | if is_valid_submission(submission): 215 | msg: str = ", ".join( 216 | [str(submission.id), 217 | str(submission.score), 218 | str(submission.num_comments), 219 | str(len(submission.selftext)), 220 | submission.subreddit_name_prefixed, 221 | submission.title] 222 | ) 223 | print(msg) 224 | valid_submissions.append(submission) 225 | 226 | return valid_submissions 227 | 228 | 229 | def posts() -> List[Submission]: 230 | """Get a list of available Reddit posts. 231 | 232 | Returns: 233 | A list of zero or more Reddit submissions. 234 | """ 235 | submissions: List[Submission] = get_reddit_submissions() 236 | return submissions 237 | -------------------------------------------------------------------------------- /referral.txt: -------------------------------------------------------------------------------- 1 | https://www.buymeacoffee.com/ttsvibelounge 2 | -------------------------------------------------------------------------------- /refresh_token.py: -------------------------------------------------------------------------------- 1 | """Helpers to fresh stale GitHub secrets.""" 2 | import os 3 | from base64 import b64encode 4 | from pathlib import Path 5 | from typing import Any, Dict 6 | 7 | import requests 8 | from nacl import encoding 9 | from nacl.public import PublicKey, SealedBox 10 | from requests import Response 11 | 12 | github_user = "alexlaverty" 13 | github_repo = "ttsvibelounge" 14 | github_token = os.getenv("GH_TOKEN") 15 | secret_name = "CREDENTIALS_STORAGE" # noqa: S105 16 | api_endpoint = f"https://api.github.com/repos/{github_user}/{github_repo}" 17 | 18 | 19 | def get_repo_key() -> Dict: 20 | """Get the key to a GitHub repository. 21 | 22 | Returns: 23 | The GitHub repository key. 24 | """ 25 | headers = { 26 | "Accept": "application/vnd.github+json", 27 | "X-GitHub-Api-Version": "2022-11-28", 28 | "Authorization": f"Bearer {github_token}", 29 | } 30 | return requests.get( 31 | f"{api_endpoint}/actions/secrets/public-key", headers=headers, timeout=120 32 | ).json() 33 | 34 | 35 | def encrypt(public_key: str, secret_value: str) -> str: 36 | """Encrypt a Unicode string using the public key. 37 | 38 | Args: 39 | public_key: Public encryption key. 40 | secret_value: Secret to be encrypted. 41 | 42 | Returns: 43 | Returns the base64 encoded, encrypted secret. 44 | """ 45 | public_key: PublicKey = PublicKey( 46 | public_key.encode("utf-8"), encoding.Base64Encoder() 47 | ) 48 | sealed_box: SealedBox = SealedBox(public_key) 49 | encrypted: Any = sealed_box.encrypt(secret_value.encode("utf-8")) 50 | return b64encode(encrypted).decode("utf-8") 51 | 52 | 53 | def write_secret(repo_key: Any, secret_encrypted_string: str) -> None: 54 | """Add a secret to GitHub. 55 | 56 | Args: 57 | repo_key: Name of the repository that should contain the secret. 58 | secret_encrypted_string: Secret value. 59 | """ 60 | url: str = f"https://api.github.com/repos/{github_user}/{github_repo}/actions/secrets/{secret_name}" 61 | headers: Dict[str, str] = { 62 | "Accept": "application/vnd.github+json", 63 | "X-GitHub-Api-Version": "2022-11-28", 64 | "Authorization": f"Bearer {github_token}", 65 | } 66 | data: Dict[str, str] = { 67 | "encrypted_value": secret_encrypted_string, 68 | "key_id": repo_key["key_id"], 69 | } 70 | print("---------------------------------") 71 | print(url) 72 | print(headers) 73 | print(data) 74 | print("---------------------------------") 75 | x: Response = requests.put(url, json=data, headers=headers, timeout=120) 76 | print(x.text) 77 | 78 | 79 | def get_file_contents(filename: Path) -> str: 80 | """Read the contents of a text file. 81 | 82 | Args: 83 | filename: Path to the file to read. 84 | 85 | Returns: 86 | The file contents. 87 | """ 88 | with open(filename, "r") as f: 89 | return f.read() 90 | 91 | 92 | file_contents: str = get_file_contents("credentials.storage") 93 | 94 | repo_key: Any = get_repo_key() 95 | print(repo_key) 96 | 97 | encrypted_secret: str = encrypt(repo_key["key"], file_contents) 98 | write_secret(repo_key, encrypted_secret) 99 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | dlint==0.14.1 2 | flake8==6.0.0 3 | flake8-bandit==4.1.1 4 | flake8-black==0.3.6 5 | flake8-bugbear==23.3.23 6 | flake8-docstrings==1.7.0 7 | flake8-isort==4.0.0 8 | flake8-pyproject==1.2.3 9 | flake8-secure-coding-standard==1.4.0 10 | mypy==1.2.0 11 | pep8-naming==0.13.3 12 | types-flake8-docstrings==1.7.0.1 13 | pandas-stubs==2.0.0.230412 14 | types-flake8-docstrings==1.7.0.1 15 | types-openpyxl==3.1.0.4 16 | types-pillow==9.5.0.2 17 | types-requests==2.29.0.0 -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiohttp==3.9.3 2 | aiosignal==1.3.1 3 | async-timeout==4.0.3 4 | attrs==23.2.0 5 | beautifulsoup4==4.12.3 6 | boto3==1.34.53 7 | botocore==1.34.53 8 | bs4==0.0.2 9 | build==1.1.1 10 | CacheControl==0.14.0 11 | cachetools==5.3.3 12 | certifi==2024.2.2 13 | cffi==1.16.0 14 | chardet==4.0.0 15 | charset-normalizer==3.3.2 16 | cleo==2.1.0 17 | click==8.1.7 18 | crashtest==0.4.1 19 | cryptography==42.0.5 20 | dbus-python==1.2.18 21 | decorator==4.4.2 22 | distlib==0.3.8 23 | distro-info==1.1+ubuntu0.2 24 | dulwich==0.21.7 25 | edge-tts==6.1.10 26 | emoji==2.10.1 27 | exceptiongroup==1.2.0 28 | executing==2.0.1 29 | fastjsonschema==2.19.1 30 | filelock==3.13.1 31 | frozenlist==1.4.1 32 | google-api-core==2.17.1 33 | google-api-python-client==2.120.0 34 | google-auth==2.28.1 35 | google-auth-httplib2==0.2.0 36 | googleapis-common-protos==1.62.0 37 | greenlet==3.0.3 38 | gTTS==2.5.1 39 | h11==0.14.0 40 | httplib2==0.22.0 41 | idna==3.6 42 | imageio==2.9.0 43 | imageio-ffmpeg==0.4.9 44 | importlib-metadata==7.0.1 45 | installer==0.7.0 46 | jaraco.classes==3.3.1 47 | jeepney==0.8.0 48 | Jinja2==3.1.3 49 | jmespath==1.0.1 50 | joblib==1.3.2 51 | keyring==24.3.1 52 | markdown-it-py==3.0.0 53 | MarkupSafe==2.1.5 54 | mdurl==0.1.2 55 | more-itertools==10.2.0 56 | moviepy==1.0.3 57 | msgpack==1.0.7 58 | multidict==6.0.5 59 | nltk==3.8.1 60 | numpy==1.26.4 61 | oauth2client==4.1.3 62 | outcome==1.3.0.post0 63 | packaging==23.2 64 | pandas==2.2.1 65 | pexpect==4.9.0 66 | Pillow==7.2.0 67 | pkginfo==1.9.6 68 | platformdirs==4.2.0 69 | playwright==1.41.2 70 | poetry==1.8.1 71 | poetry-core==1.9.0 72 | poetry-plugin-export==1.6.0 73 | praw==7.7.1 74 | prawcore==2.4.0 75 | proglog==0.1.10 76 | progressbar2==4.4.1 77 | protobuf==4.25.3 78 | ptyprocess==0.7.0 79 | pyasn1==0.5.1 80 | pyasn1-modules==0.3.0 81 | pycparser==2.21 82 | pyee==11.0.1 83 | Pygments==2.17.2 84 | PyGObject==3.42.1 85 | PyNaCl==1.5.0 86 | pyparsing==3.1.1 87 | pyproject_hooks==1.0.0 88 | pyser==0.1.5 89 | PySocks==1.7.1 90 | python-apt==2.4.0+ubuntu3 91 | python-dateutil==2.9.0 92 | python-debian==0.1.43+ubuntu1.1 93 | python-utils==3.8.2 94 | pytime==0.2.3 95 | pytz==2024.1 96 | PyYAML==5.4.1 97 | rapidfuzz==3.6.1 98 | regex==2023.12.25 99 | requests==2.31.0 100 | requests-toolbelt==1.0.0 101 | rich==13.7.1 102 | rsa==4.9 103 | s3transfer==0.10.0 104 | SecretStorage==3.3.3 105 | selenium==4.18.1 106 | shellingham==1.5.4 107 | simple-youtube-api==0.2.8 108 | six==1.16.0 109 | sniffio==1.3.1 110 | sortedcontainers==2.4.0 111 | soupsieve==2.5 112 | tomli==2.0.1 113 | tomlkit==0.12.4 114 | tqdm==4.66.2 115 | trio==0.24.0 116 | trio-websocket==0.11.1 117 | trove-classifiers==2024.2.23 118 | typing_extensions==4.10.0 119 | tzdata==2024.1 120 | ubuntu-advantage-tools==8001 121 | update-checker==0.18.0 122 | uritemplate==4.1.1 123 | urllib3==2.0.7 124 | varname==0.13.0 125 | virtualenv==20.25.1 126 | websocket-client==1.7.0 127 | wsproto==1.2.0 128 | yarl==1.9.4 129 | zipp==3.17.0 -------------------------------------------------------------------------------- /speech/speech.py: -------------------------------------------------------------------------------- 1 | """Transform text to speech.""" 2 | import logging 3 | import os 4 | import re 5 | import subprocess # noqa: S404 6 | import textwrap 7 | from argparse import ArgumentParser, Namespace 8 | from pathlib import Path 9 | from typing import Any, List 10 | 11 | import boto3 12 | from gtts import gTTS 13 | from moviepy.editor import AudioFileClip, concatenate_audioclips 14 | 15 | import config 16 | import config.settings as settings 17 | from speech.streamlabs_polly import StreamlabsPolly 18 | from speech.tiktok import TikTok 19 | from utils.common import sanitize_text 20 | 21 | logging.basicConfig( 22 | format="%(asctime)s %(levelname)-8s %(message)s", 23 | level=logging.INFO, 24 | datefmt="%Y-%m-%d %H:%M:%S", 25 | handlers=[logging.FileHandler("debug.log", "w", "utf-8"), logging.StreamHandler()], 26 | ) 27 | 28 | 29 | def process_speech_text(text: str) -> str: 30 | """Sanitize raw text. 31 | 32 | This will process raw text prior to converting it to speech, replacing 33 | common abbreviations with their full english version to ensure that the 34 | generated speech is intelligable. 35 | 36 | Args: 37 | text: Text to be sanitized. 38 | 39 | Returns: 40 | Updated text that has had common abbreviations converted to their 41 | full english equivalent. 42 | """ 43 | text = text.replace(" AFAIK ", " as far as I know ") 44 | text = text.replace("AITA", " Am I The Asshole? ") 45 | text = text.replace(" AMA ", " Ask me anything ") 46 | text = text.replace(" ELI5 ", " Explain Like I'm Five ") 47 | text = text.replace(" IAMA ", " I am a ") 48 | text = text.replace("IANAD", " i am not a doctor ") 49 | text = text.replace("IANAL", " i am not a lawyer ") 50 | text = text.replace(" IMO ", " in my opinion ") 51 | text = text.replace(" NSFL ", " Not safe for life ") 52 | text = text.replace(" NSFW ", " Not safe for Work ") 53 | text = text.replace("NTA", " Not The Asshole ") 54 | text = text.replace(" SMH ", " Shaking my head ") 55 | text = text.replace("TD;LR", " too long didn't read ") 56 | text = text.replace("TDLR", " too long didn't read ") 57 | text = text.replace(" TIL ", " Today I Learned ") 58 | text = text.replace("YTA", " You're the asshole ") 59 | text = text.replace("SAHM", " stay at home mother ") 60 | text = text.replace("WIBTA", " would I be the asshole ") 61 | text = text.replace(" stfu ", " shut the fuck up ") 62 | text = text.replace(" OP ", " o p ") 63 | text = text.replace(" CB ", " choosing beggar ") 64 | text = text.replace("pettyrevenge", "petty revenge") 65 | text = text.replace("askreddit", "ask reddit") 66 | text = text.replace("twoxchromosomes", "two x chromosomes") 67 | text = text.replace("showerthoughts", "shower thoughts") 68 | text = text.replace("amitheasshole", "am i the asshole") 69 | text = text.replace("“", '"') 70 | text = text.replace("“", '"') 71 | text = text.replace("’", "'") 72 | text = text.replace("...", ".") 73 | text = text.replace("*", "") 74 | text = re.sub(r"(\[|\()[0-9]{1,2}\s*(m|f)?(\)|\])", "", text, flags=re.IGNORECASE) 75 | text = sanitize_text(text) 76 | return text 77 | 78 | 79 | def create_audio(path: Path, text: str) -> Path: 80 | """Generate an audio file using text to speech. 81 | 82 | Args: 83 | path: Path to save the generated audio file to. 84 | text: Text to be converted to speech. 85 | 86 | Returns: 87 | Path to the generated audio file. 88 | """ 89 | # logging.info(f"Generating Audio File : {text}") 90 | output_path = os.path.normpath(path) 91 | text: str = process_speech_text(text) 92 | if not os.path.exists(output_path) or not os.path.getsize(output_path) > 0: 93 | if settings.voice_engine == "polly": 94 | polly_client: Any = boto3.Session( 95 | aws_access_key_id=config.aws_access_key_id, 96 | aws_secret_access_key=config.aws_secret_access_key, 97 | region_name="us-west-2", 98 | ).client("polly") 99 | 100 | response: Any = polly_client.synthesize_speech( 101 | Engine="neural", OutputFormat="mp3", Text=text, VoiceId="Matthew" 102 | ) 103 | 104 | with open(output_path, "wb") as file: # noqa: SCS109 105 | file.write(response["AudioStream"].read()) 106 | 107 | if settings.voice_engine == "gtts": 108 | ttmp3: Any = gTTS(text, lang=settings.gtts_language, slow=False) 109 | ttmp3.save(output_path) 110 | 111 | if settings.voice_engine == "streamlabspolly": 112 | slp: StreamlabsPolly = StreamlabsPolly() 113 | speech_text_character_limit: int = 550 114 | 115 | if len(text) > speech_text_character_limit: 116 | logging.info( 117 | "Text exceeds StreamlabsPolly limit, breaking up into chunks" 118 | ) 119 | speech_chunks: List[Path] = [] 120 | chunk_list: List[str] = textwrap.wrap( 121 | text, 122 | width=speech_text_character_limit, 123 | break_long_words=True, 124 | break_on_hyphens=False, 125 | ) 126 | print(chunk_list) 127 | 128 | for count, chunk in enumerate(chunk_list): 129 | print(count) 130 | if chunk == "​": 131 | logging.info("Skip zero space character comment : %s", chunk) 132 | continue 133 | 134 | if chunk == "": 135 | logging.info("Skipping blank comment") 136 | continue 137 | 138 | tmp_path: Path = f"{output_path}{count}" 139 | slp.run(chunk, tmp_path) 140 | speech_chunks.append(tmp_path) 141 | 142 | clips: List[AudioFileClip] = [AudioFileClip(c) for c in speech_chunks] 143 | final_clip: AudioFileClip = concatenate_audioclips(clips) 144 | final_clip.write_audiofile(output_path) 145 | else: 146 | print(text) 147 | slp.run(text, output_path) 148 | 149 | if settings.voice_engine == "edge-tts": 150 | print("+++ edge-tts +++") 151 | pattern = r'[^a-zA-Z0-9\s.,!?\'"-]' # You can customize this pattern as needed. 152 | # Use the re.sub() function to replace any characters that don't match the pattern with an empty string. 153 | text = re.sub(pattern, '', text) 154 | text = re.sub(r'\n+', ' ', text) 155 | text = re.sub(r'\s+', ' ', text) 156 | 157 | print(text) 158 | subprocess.run( # noqa: S603, S607 159 | [ 160 | "edge-tts", 161 | "--voice", 162 | settings.edge_tts_voice, 163 | "--text", 164 | f"'{text.strip()}'", 165 | "--write-media", 166 | output_path, 167 | ] 168 | ) 169 | 170 | if settings.voice_engine == "balcon": 171 | balcon_cmd: List[Any] = ["balcon.exe", "-w", output_path, "-t", f"{text}"] 172 | subprocess.call(balcon_cmd) # noqa: S603 173 | 174 | if settings.voice_engine == "tiktok": 175 | speech_text_character_limit: int = 200 176 | tt: TikTok = TikTok() 177 | 178 | if len(text) > speech_text_character_limit: 179 | logging.info( 180 | "Text exceeds tiktok limit, \ 181 | breaking up into chunks" 182 | ) 183 | speech_chunks: List[Path] = [] 184 | chunk_list: List[str] = textwrap.wrap( 185 | text, 186 | width=speech_text_character_limit, 187 | break_long_words=True, 188 | break_on_hyphens=False, 189 | ) 190 | print(chunk_list) 191 | 192 | for count, chunk in enumerate(chunk_list): 193 | print(count) 194 | if chunk == "​": 195 | logging.info("Skip zero space character comment : %s", chunk) 196 | continue 197 | 198 | if chunk == "": 199 | logging.info("Skipping blank comment") 200 | continue 201 | 202 | tmp_path: Path = f"{path}{count}" 203 | tt.run(chunk, tmp_path) 204 | speech_chunks.append(tmp_path) 205 | 206 | clips: List[AudioFileClip] = [AudioFileClip(c) for c in speech_chunks] 207 | final_clip: AudioFileClip = concatenate_audioclips(clips) 208 | final_clip.write_audiofile(output_path) 209 | else: 210 | print(text) 211 | tt.run(text, output_path) 212 | else: 213 | logging.info("Audio file already exists : %s", output_path) 214 | 215 | logging.info("Created Audio File : %s", output_path) 216 | 217 | return output_path 218 | 219 | 220 | if __name__ == "__main__": 221 | parser: ArgumentParser = ArgumentParser() 222 | parser.add_argument( 223 | "--speech", default="Hello world this is a test.", help="Enter text for speech" 224 | ) 225 | parser.add_argument( 226 | "--path", default="test_audio.mp3", help="Path to save audio file to" 227 | ) 228 | args: Namespace = parser.parse_args() 229 | 230 | print(args.path) 231 | print(args.speech) 232 | create_audio(args.path, args.speech) 233 | -------------------------------------------------------------------------------- /speech/streamlabs_polly.py: -------------------------------------------------------------------------------- 1 | """Polly text to speech convertor.""" 2 | import sys 3 | import time as pytime 4 | from datetime import datetime 5 | from pathlib import Path 6 | from random import SystemRandom 7 | from time import sleep 8 | from typing import Dict, List, Union 9 | 10 | import requests 11 | 12 | # from utils import settings 13 | # from utils.voice import check_ratelimit 14 | from requests import Response 15 | from requests.exceptions import JSONDecodeError 16 | 17 | import config.settings as settings 18 | from utils.common import sanitize_text 19 | 20 | if sys.version_info[0] >= 3: 21 | from datetime import timezone 22 | 23 | voices = [ 24 | "Brian", 25 | "Emma", 26 | "Russell", 27 | "Joey", 28 | "Matthew", 29 | "Joanna", 30 | "Kimberly", 31 | "Amy", 32 | "Geraint", 33 | "Nicole", 34 | "Justin", 35 | "Ivy", 36 | "Kendra", 37 | "Salli", 38 | "Raveena", 39 | ] 40 | # valid voices https://lazypy.ro/tts/ 41 | 42 | 43 | def sleep_until(time: Union[int, datetime]) -> None: 44 | """Pause until a specific end time. 45 | 46 | Args: 47 | time: Either a valid datetime object or unix timestamp in seconds 48 | (i.e. seconds since Unix epoch). 49 | """ 50 | end: int = time 51 | 52 | # Convert datetime to unix timestamp and adjust for locality 53 | if isinstance(time, datetime): 54 | # If we're on Python 3 and the user specified a timezone, convert to 55 | # UTC and get the timestamp. 56 | if sys.version_info[0] >= 3 and time.tzinfo: 57 | end: datetime = time.astimezone(timezone.utc).timestamp() 58 | else: 59 | zone_diff: float = ( 60 | pytime.time() - (datetime.now() - datetime(1970, 1, 1)).total_seconds() 61 | ) 62 | end: float = (time - datetime(1970, 1, 1)).total_seconds() + zone_diff 63 | 64 | # Type check 65 | if not isinstance(end, (int, float)): 66 | raise Exception("The time parameter is not a number or datetime object") 67 | 68 | # Now we wait 69 | while True: 70 | now: float = pytime.time() 71 | diff: float = end - now 72 | 73 | # Time is up! 74 | if diff <= 0: 75 | break 76 | else: 77 | # 'logarithmic' sleeping to minimize loop iterations 78 | sleep(diff / 2) 79 | 80 | 81 | def check_ratelimit(response: Response) -> bool: 82 | """Check if the rate limit has been hit. 83 | 84 | If it has, sleep for the time specified in the response. 85 | 86 | Args: 87 | response: The HTTP response to be examined. 88 | 89 | Returns: 90 | `True` if the rate limit has been reached, otherwise `False`. 91 | """ 92 | if response.status_code == 429: 93 | try: 94 | time: int = int(response.headers["X-RateLimit-Reset"]) 95 | print("Ratelimit hit, sleeping...") 96 | sleep_until(time) 97 | return False 98 | except KeyError: # if the header is not present, we don't know how long to wait 99 | return False 100 | 101 | return True 102 | 103 | 104 | class StreamlabsPolly: 105 | """Polly text to speech convertor.""" 106 | 107 | def __init__(self): 108 | """Initialise a new Polly text to speech convertor.""" 109 | self.url: str = "https://streamlabs.com/polly/speak" 110 | self.max_chars: int = 550 111 | self.voices: List[str] = voices 112 | 113 | def run(self, text: str, filepath: Path, random_voice: bool = False) -> None: 114 | """Convert text to an audio clip using a random Polly voice. 115 | 116 | Args: 117 | text: The text to be converted to speech. 118 | filepath: Path to save the generated audio clip to. 119 | random_voice: If `true`, selects a random voice, otherwise selects 120 | the default polly voice. 121 | """ 122 | if random_voice: 123 | voice: str = self.randomvoice() 124 | else: 125 | if not settings.streamlabs_polly_voice: 126 | raise ValueError( 127 | f"Please set the config variable streamlabs_polly_voice \ 128 | to a valid voice. options are: {voices}" 129 | ) 130 | voice: str = str(settings.streamlabs_polly_voice).capitalize() 131 | text: str = sanitize_text(text) 132 | body: Dict[str, List[str]] = {"voice": voice, "text": text, "service": "polly"} 133 | response: Response = requests.post(self.url, data=body, timeout=120) 134 | if not check_ratelimit(response): 135 | self.run(text, filepath, random_voice) 136 | else: 137 | try: 138 | voice_data: Response = requests.get( 139 | response.json()["speak_url"], timeout=120 140 | ) 141 | with open(filepath, "wb") as f: # noqa: SCS109 142 | f.write(voice_data.content) 143 | except (KeyError, JSONDecodeError): 144 | try: 145 | if response.json()["error"] == "No text specified!": 146 | raise ValueError("Please specify a text to convert to speech.") 147 | except (KeyError, JSONDecodeError): 148 | print("Error occurred calling Streamlabs Polly") 149 | 150 | def randomvoice(self) -> str: 151 | """Select a random Polly voice. 152 | 153 | Returns: 154 | The name of a randomly selected Polly voice. 155 | """ 156 | rnd: SystemRandom() = SystemRandom() 157 | return rnd.choice(self.voices) 158 | -------------------------------------------------------------------------------- /speech/tiktok.py: -------------------------------------------------------------------------------- 1 | """TikTok text to speech convertor.""" 2 | import base64 3 | from random import SystemRandom 4 | import urllib.parse 5 | from pathlib import Path 6 | from typing import Dict, List 7 | 8 | import requests 9 | from requests import Response, Session 10 | from requests.adapters import HTTPAdapter, Retry 11 | from requests.exceptions import SSLError 12 | 13 | import config.settings as settings 14 | 15 | # from profanity_filter import ProfanityFilter 16 | # pf = ProfanityFilter() 17 | # Code by @JasonLovesDoggo 18 | # https://twitter.com/scanlime/status/1512598559769702406 19 | 20 | nonhuman = [ # DISNEY VOICES 21 | "en_us_ghostface", # Ghost Face 22 | "en_us_chewbacca", # Chewbacca 23 | "en_us_c3po", # C3PO 24 | "en_us_stitch", # Stitch 25 | "en_us_stormtrooper", # Stormtrooper 26 | "en_us_rocket", # Rocket 27 | # ENGLISH VOICES 28 | ] 29 | 30 | human = [ 31 | "en_au_001", # English AU - Female 32 | "en_au_002", # English AU - Male 33 | "en_uk_001", # English UK - Male 1 34 | "en_uk_003", # English UK - Male 2 35 | "en_us_001", # English US - Female (Int. 1) 36 | "en_us_002", # English US - Female (Int. 2) 37 | "en_us_006", # English US - Male 1 38 | "en_us_007", # English US - Male 2 39 | "en_us_009", # English US - Male 3 40 | "en_us_010", 41 | ] 42 | voices = nonhuman + human 43 | 44 | noneng = [ 45 | "fr_001", # French - Male 1 46 | "fr_002", # French - Male 2 47 | "de_001", # German - Female 48 | "de_002", # German - Male 49 | "es_002", # Spanish - Male 50 | # AMERICA VOICES 51 | "es_mx_002", # Spanish MX - Male 52 | "br_001", # Portuguese BR - Female 1 53 | "br_003", # Portuguese BR - Female 2 54 | "br_004", # Portuguese BR - Female 3 55 | "br_005", # Portuguese BR - Male 56 | # ASIA VOICES 57 | "id_001", # Indonesian - Female 58 | "jp_001", # Japanese - Female 1 59 | "jp_003", # Japanese - Female 2 60 | "jp_005", # Japanese - Female 3 61 | "jp_006", # Japanese - Male 62 | "kr_002", # Korean - Male 1 63 | "kr_003", # Korean - Female 64 | "kr_004", # Korean - Male 2 65 | ] 66 | 67 | 68 | # good_voices = { 69 | # 'good': ['en_us_002', 'en_us_006'], 70 | # 'ok': ['en_au_002', 'en_uk_001']} 71 | # less en_us_stormtrooper more less en_us_rocket en_us_ghostface 72 | 73 | 74 | class TikTok: # TikTok Text-to-Speech Wrapper 75 | """Text to Speech wrapper for TikTok.""" 76 | 77 | def __init__(self): 78 | """Initialise a new TikTok text to speech generator.""" 79 | self.URI_BASE: str = ( 80 | "https://api16-normal-useast5.us.tiktokv.com" 81 | "/media/api/text/speech/invoke/?text_speaker=" 82 | ) 83 | self.max_chars: int = 300 84 | self.voices: Dict[str, List[str]] = { 85 | "human": human, 86 | "nonhuman": nonhuman, 87 | "noneng": noneng, 88 | } 89 | 90 | def run(self, text: str, filepath: Path, random_voice: bool = False) -> None: 91 | """Convert text to an audio clip using a random tik-tok styled voice. 92 | 93 | Args: 94 | text: The text to be converted to speech. 95 | filepath: Path to save the generated audio clip to. 96 | random_voice: If `true`, selects a random voice, otherwise selects 97 | the default tiktok voice. 98 | """ 99 | # if censor: 100 | # req_text = pf.censor(req_text) 101 | # pass 102 | rnd: SystemRandom = SystemRandom() 103 | voice: str = ( 104 | self.randomvoice() 105 | if random_voice 106 | else (settings.tiktok_voice or rnd.choice(self.voices["human"])) 107 | ) 108 | try: 109 | text: str = urllib.parse.quote(text) 110 | print(len(text)) 111 | tt_uri: str = f"{self.URI_BASE}{voice}&req_text={text}&speaker_map_type=0" 112 | print(tt_uri) 113 | r: Response = requests.post(tt_uri, timeout=120) 114 | except SSLError: 115 | # https://stackoverflow.com/a/47475019/18516611 116 | session: Session = Session() 117 | retry: Retry = Retry(connect=3, backoff_factor=0.5) 118 | adapter: HTTPAdapter = HTTPAdapter(max_retries=retry) 119 | session.mount("http://", adapter) 120 | session.mount("https://", adapter) 121 | r: Response = session.post( 122 | f"{self.URI_BASE}{voice}&req_text={text}&speaker_map_type=0" 123 | ) 124 | # print(r.text) 125 | vstr: str = [r.json()["data"]["v_str"]][0] 126 | b64d: bytes = base64.b64decode(vstr) 127 | 128 | with open(filepath, "wb") as out: # noqa: SCS109 129 | out.write(b64d) 130 | 131 | def randomvoice(self) -> str: 132 | """Select a random voice. 133 | 134 | Returns: 135 | A randomly chosen human voice. 136 | """ 137 | rnd: SystemRandom = SystemRandom() 138 | return rnd.choice(self.voices["human"]) 139 | 140 | 141 | if __name__ == "__main__": 142 | tt: TikTok = TikTok() 143 | text_to_say: str = "Hello world this is some spoken text" 144 | print(str(len(text_to_say))) 145 | tt.run(text_to_say, "tiktok_test.mp3") 146 | -------------------------------------------------------------------------------- /static.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/static.mp4 -------------------------------------------------------------------------------- /test_upload.py: -------------------------------------------------------------------------------- 1 | """Test upload to YouTube.""" 2 | import os 3 | 4 | from simple_youtube_api.Channel import Channel 5 | from simple_youtube_api.LocalVideo import LocalVideo 6 | 7 | directory = os.getcwd() 8 | print(directory) 9 | 10 | 11 | class Video: 12 | """Video metdata.""" 13 | 14 | video = f"{directory}\\videos\\\ 15 | ylg9ls_Does_anyone_know_how_a_loss_curve_like_this_can_ha\\\ 16 | final.mp4" 17 | title = "What city will you NEVER visit based on it's reputation? \ 18 | (r\\AskReddit)" 19 | thumbnail = f"{directory}\\videos\\\ 20 | ylg9ls_Does_anyone_know_how_a_loss_curve_like_this_can_ha\ 21 | \\thumbnail_0.png" 22 | description = "TTSVibeLounge" 23 | 24 | 25 | v = Video() 26 | 27 | 28 | # loggin into the channel 29 | channel = Channel() 30 | channel.login("client_secret.json", "credentials.storage") 31 | 32 | # setting up the video that is going to be uploaded 33 | video = LocalVideo(file_path=v.video) 34 | 35 | # setting snippet 36 | video.set_title(v.title) 37 | video.set_description(v.description) 38 | video.set_tags(["reddit", "tts"]) 39 | video.set_category("gaming") 40 | video.set_default_language("en-US") 41 | 42 | # setting status 43 | video.set_embeddable(True) 44 | video.set_license("creativeCommon") 45 | video.set_privacy_status("private") 46 | video.set_public_stats_viewable(True) 47 | 48 | # setting thumbnail 49 | video.set_thumbnail_path(v.thumbnail) 50 | 51 | # uploading video and printing the results 52 | video = channel.upload_video(video) 53 | print(video.id) 54 | print(video) 55 | -------------------------------------------------------------------------------- /thumbnail/keywords.py: -------------------------------------------------------------------------------- 1 | """Functions to extract key phrases out of text.""" 2 | from multi_rake import Rake 3 | 4 | 5 | def get_keywords(text): 6 | """Extract key phrases out of a block of text. 7 | 8 | Args: 9 | text: The text to be processed. 10 | 11 | Returns: 12 | A selection of key phrases. 13 | """ 14 | rake = Rake() 15 | keywords = rake.apply(text) 16 | return keywords 17 | 18 | 19 | if __name__ == "__main__": 20 | text = ( 21 | "Compatibility of systems of linear constraints over the set of " 22 | "natural numbers. Criteria of compatibility of a system of linear " 23 | "Diophantine equations, strict inequations, and nonstrict inequations " 24 | "are considered. Upper bounds for components of a minimal set of " 25 | "solutions and algorithms of construction of minimal generating sets " 26 | "of solutions for all types of systems are given. These criteria and " 27 | "the corresponding algorithms for constructing a minimal supporting " 28 | "set of solutions can be used in solving all the considered types of " 29 | "systems and systems of mixed types." 30 | ) 31 | 32 | keywords = get_keywords(text) 33 | print(keywords[:10]) 34 | -------------------------------------------------------------------------------- /thumbnail/lexica.py: -------------------------------------------------------------------------------- 1 | """Functions used to process and download images.""" 2 | import json 3 | import logging 4 | import os 5 | import time 6 | import urllib 7 | from pathlib import Path 8 | from typing import List 9 | from urllib.request import Request, urlopen 10 | from requests import Response 11 | import requests 12 | 13 | import config.settings as settings 14 | 15 | 16 | def download_image(url: str, file_path: Path) -> None: 17 | """Download a single image. 18 | 19 | Args: 20 | url: URL to download the image from. 21 | file_path: Path to save the image to. 22 | """ 23 | req: Request = Request(url, headers={"User-Agent": "Mozilla/5.0"}) 24 | # FIXME: This is a potential security issue 25 | raw_img = urlopen(req).read() # noqa: S310 26 | 27 | with open(file_path, "wb") as f: # noqa: SCS109 28 | f.write(raw_img) 29 | f.close() 30 | 31 | # Sleeping to prevent being Rate LimitedF 32 | time.sleep(5) 33 | 34 | 35 | def get_images( 36 | folder_path: Path, sentence: str, number_of_images: int = 1 37 | ) -> List[Path]: 38 | """Download random images that are relevant to the provided text. 39 | 40 | Args: 41 | folder_path: Path to save the images. 42 | sentence: Text used to find relevant images. 43 | number_of_images: Number of images to be downloaded. 44 | 45 | Returns: 46 | A list of files path to the downloaded images. 47 | """ 48 | images: List[Path] = [] 49 | 50 | if settings.lexica_download_enabled: 51 | if number_of_images > 0: 52 | safe_query: str = urllib.parse.quote(sentence.strip()) 53 | lexica_url: str = f"https://lexica.art/api/v1/search?q={safe_query}" 54 | logging.info("Downloading Image From Lexica : %s", sentence) 55 | try: 56 | r: Response = requests.get(lexica_url, timeout=120) 57 | j: object = json.loads(r.text) 58 | except Exception: 59 | print("Error Retrieving Lexica Images") 60 | pass 61 | return 62 | 63 | for num in range(0, number_of_images): 64 | image_path: Path = Path(folder_path, f"lexica_{num}.png") 65 | if not os.path.exists(image_path): 66 | image_url: str = j["images"][num]["src"] 67 | download_image(image_url, image_path) 68 | else: 69 | logging.info("Image already exists : %s - %s", id, sentence) 70 | images.append(image_path) 71 | else: 72 | logging.info("Downloading Images Set to False.......") 73 | 74 | return images 75 | -------------------------------------------------------------------------------- /thumbnail/thumbnail.py: -------------------------------------------------------------------------------- 1 | """Functions used to download random images associated with a reddit post. 2 | 3 | This module also contains functions that can take those images and generate 4 | a series of video clips that can be spliced together to form the final video. 5 | """ 6 | import logging 7 | import os 8 | import sys 9 | from pathlib import Path 10 | 11 | # from nltk.corpus import stopwords 12 | from random import SystemRandom 13 | from typing import Any, List 14 | 15 | from moviepy.editor import CompositeVideoClip, ImageClip, TextClip 16 | from PIL import Image 17 | 18 | import config.settings as settings 19 | import thumbnail.lexica as lexica 20 | from utils.common import random_rgb_colour, sanitize_text 21 | 22 | logging.basicConfig( 23 | format="%(asctime)s %(levelname)-8s %(message)s", 24 | level=logging.INFO, 25 | datefmt="%Y-%m-%d %H:%M:%S", 26 | handlers=[logging.FileHandler("debug.log", "w", "utf-8"), logging.StreamHandler()], 27 | ) 28 | 29 | 30 | def apply_black_gradient( 31 | path_in: Path, 32 | path_out: Path = "out.png", 33 | gradient: float = 1.0, 34 | initial_opacity: float = 1.0, 35 | ) -> None: 36 | """Apply a black gradient to the image, going from left to right. 37 | 38 | Args: 39 | path_in: Path to image to apply gradient to. 40 | path_out: Path to save result to (default 'out.png'). 41 | gradient: Gradient of the gradient, should be non-negative (default 1.0). 42 | if gradient = 0., the image is black; 43 | if gradient = 1., the gradient smoothly varies over the full width; 44 | if gradient > 1., the gradient terminates before the end of the width; 45 | initial_opacity: Scales the initial opacity of the gradient, eg: on the 46 | far left of the image (default 1.0). 47 | 48 | Should be between 0.0 and 1.0. Values between 0.9-1.0 generally 49 | give good results. 50 | """ 51 | # get image to operate on 52 | input_im: Image = Image.open(path_in) 53 | if input_im.mode != "RGBA": 54 | input_im = input_im.convert("RGBA") 55 | width, height = input_im.size 56 | 57 | # create a gradient that 58 | # starts at full opacity * initial_value 59 | # decrements opacity by gradient * x / width 60 | alpha_gradient: Image = Image.new("L", (width, 1), color=0xFF) 61 | for x in range(width): 62 | a: int = int((initial_opacity * 255.0) * (1.0 - gradient * float(x) / width)) 63 | if a > 0: 64 | alpha_gradient.putpixel((x, 0), a) 65 | else: 66 | alpha_gradient.putpixel((x, 0), 0) 67 | # print '{}, {:.2f}, {}'.format(x, float(x) / width, a) 68 | alpha: Any = alpha_gradient.resize(input_im.size) 69 | 70 | # create black image, apply gradient 71 | black_im: Image = Image.new("RGBA", (width, height), color=0) # i.e. black 72 | black_im.putalpha(alpha) 73 | 74 | # make composite with original image 75 | output_im: Image = Image.alpha_composite(input_im, black_im) 76 | output_im.save(path_out, "PNG") 77 | 78 | return 79 | 80 | 81 | def get_font_size(length: int) -> int: 82 | """Select a preset font size based on the amount of available space. 83 | 84 | Args: 85 | length: The amount of horizontal space available to insert text. 86 | 87 | Returns: 88 | A suggested font size. 89 | """ 90 | fontsize = 50 91 | lineheight = 60 92 | 93 | if length < 10: 94 | fontsize = 190 95 | 96 | if length >= 10 and length < 20: 97 | fontsize = 150 98 | 99 | if length >= 20 and length < 30: 100 | fontsize = 150 101 | 102 | if length >= 30 and length < 40: 103 | fontsize = 130 104 | 105 | if length >= 40 and length < 50: 106 | fontsize = 110 107 | 108 | if length >= 50 and length < 60: 109 | fontsize = 100 110 | 111 | if length >= 60 and length < 70: 112 | fontsize = 90 113 | 114 | if length >= 70 and length < 80: 115 | fontsize = 85 116 | 117 | if length >= 80 and length < 90: 118 | fontsize = 90 119 | 120 | if length >= 90 and length < 100: 121 | fontsize = 80 122 | 123 | logging.debug("Title Length : %s", length) 124 | logging.debug("Setting Fontsize : %s", fontsize) 125 | logging.debug("Setting Lineheight : %s", lineheight) 126 | 127 | return fontsize, lineheight 128 | 129 | 130 | def generate( 131 | video_directory: Path, 132 | subreddit: str, 133 | title: str, 134 | number_of_thumbnails: int = 1, 135 | ) -> List[Path]: 136 | """Generate a series of thumbnails to be included in the generated video. 137 | 138 | The images will be randomly selected by lexica using the name of the 139 | subreddit, and post title as keywords to find relevant images. 140 | 141 | Args: 142 | video_directory: Path to save the images to. 143 | subreddit: Name of the subreddit that contains the post. 144 | title: Name of the reddit post. 145 | number_of_thumbnails: Number of relevant images to download 146 | (default=1). 147 | 148 | Returns: 149 | A list of paths where the downloaded images can be found. 150 | """ 151 | logging.info("========== Generating Thumbnail ==========") 152 | 153 | # image_path = str(Path(video_directory, "lexica.png").absolute()) 154 | 155 | title = sanitize_text(title).strip() 156 | 157 | # Get rid of double spaces 158 | title = title.replace(" ", " ") 159 | title = title.replace("’", "") 160 | 161 | logging.info(title) 162 | 163 | if settings.thumbnail_image_source == "random": 164 | rnd: SystemRandom = SystemRandom() 165 | random_image = rnd.choice(os.listdir(settings.images_directory)) 166 | random_image_filepath = str( 167 | Path(settings.images_directory, random_image).absolute() 168 | ) 169 | images = [random_image_filepath] 170 | 171 | if settings.thumbnail_image_source == "lexica": 172 | images = lexica.get_images( 173 | video_directory, title, number_of_images=number_of_thumbnails 174 | ) 175 | 176 | if settings.thumbnail_image_path: 177 | images = [settings.thumbnail_image_path] 178 | 179 | thumbnails = [] 180 | 181 | if images: 182 | for index, image in enumerate(images): 183 | thumbnail = create_thumbnail( 184 | video_directory, subreddit, title, image, index 185 | ) 186 | thumbnails.append(thumbnail) 187 | 188 | return thumbnails 189 | 190 | 191 | def create_thumbnail( 192 | video_directory: Path, subreddit: str, title: str, image: Path, index: int = 0 193 | ) -> Path: 194 | """Generate a thumbnail image for the video. 195 | 196 | Args: 197 | video_directory: Path to save the thumbnail in. 198 | subreddit: Name of the subreddit that the post was made in. 199 | title: Name of the Reddit post. 200 | image: Path to the image used to create the thumbnail. 201 | index: Image offset. 202 | 203 | Returns: 204 | The full path to the updated video containing the thumbnail. 205 | """ 206 | clips: List[Any] = [] 207 | 208 | thumbnail_path: Path = Path( 209 | video_directory, f"thumbnail_{str(index)}.png" 210 | ).absolute() 211 | if thumbnail_path.exists(): 212 | logging.info("Thumbnail already exists : %s", thumbnail_path) 213 | return thumbnail_path 214 | 215 | border_width: int = 20 216 | height: int = 720 - (border_width * 2) 217 | width: int = 1280 - (border_width * 2) 218 | 219 | background_clip: Any = TextClip( 220 | "", size=(width, height), bg_color="#000000", method="caption" 221 | ) 222 | 223 | # .margin(border_width, color=random_rgb_colour()) 224 | 225 | clips.append(background_clip) 226 | 227 | gradient_out: Path = Path(video_directory, "gradient.png").absolute() 228 | 229 | if settings.enable_thumbnail_image_gradient: 230 | apply_black_gradient( 231 | image, path_out=gradient_out, gradient=1.3, initial_opacity=0.9 232 | ) 233 | image = gradient_out 234 | 235 | # img_width = width / 2 236 | 237 | img_clip = ( 238 | ImageClip(str(image)) 239 | .resize(height=height) 240 | .set_position(("right", "center")) 241 | .set_opacity(1) 242 | ) 243 | 244 | img_clip = img_clip.set_position(("right", "center")) 245 | 246 | # img_clip = img_clip.set_position((width - img_clip.w , 0 + border_width)) 247 | 248 | clips.append(img_clip) 249 | 250 | def get_text_clip(text: str, fs: int, txt_color: str = "#FFFFFF") -> TextClip: 251 | """Generate a video containing text. 252 | 253 | Args: 254 | text: The text to include in the video. 255 | fs: The font-size, expressed as an integer. 256 | txt_color: Colour to be used for the text, expressed in hex format. 257 | 258 | Returns: 259 | The generated video clip containing the specified text. 260 | """ 261 | txt_clip = TextClip( 262 | text, 263 | fontsize=fs, 264 | color=txt_color, 265 | align="west", 266 | font="Impact", 267 | stroke_color="#000000", 268 | stroke_width=3, 269 | method="caption", 270 | size=(settings.thumbnail_text_width, 0), 271 | ) 272 | return txt_clip 273 | 274 | # fontsize = 40 275 | title = title.replace("’", "") 276 | fontsize, lineheight = get_font_size(len(title)) 277 | logging.info("Title Length : %s", len(title)) 278 | logging.info("Optimising Font Size : ") 279 | sys.stdout.write(str(fontsize)) 280 | 281 | while True: 282 | previous_fontsize: int = fontsize 283 | fontsize += 1 284 | txt_clip: TextClip = get_text_clip(title.upper(), fontsize) 285 | sys.stdout.write(".") 286 | if txt_clip.h > height: 287 | optimal_font_size: int = previous_fontsize 288 | print(optimal_font_size) 289 | break 290 | 291 | word_height: TextClip = get_text_clip( 292 | "Hello", fs=optimal_font_size, txt_color="#FFFFFF" 293 | ) 294 | # word_height = TextClip( 295 | # "Hello", 296 | # fontsize=optimal_font_size, 297 | # align="center", 298 | # font="Impact", 299 | # stroke_color="#000000", 300 | # stroke_width=3, 301 | # ) 302 | 303 | txt_y: int = 0 304 | txt_x: int = 15 305 | 306 | words: List[str] = title.upper().split(" ") 307 | word_color: str = "#FFFFFF" 308 | line_number: int = 1 309 | space_width: int = 9 310 | line_colours: List[str] = [ 311 | "#46F710", 312 | "#F9CD10", 313 | "#04FF74", 314 | "#FF5252", 315 | "#FF7545", 316 | "#09C1F9", 317 | "#EFFF00", 318 | "#E7AD61", 319 | "#00677B", 320 | ] 321 | 322 | rnd = SystemRandom() 323 | rnd.shuffle(line_colours) 324 | 325 | for word in words: 326 | if (line_number % 2) == 0: 327 | word_color: str = line_colours[line_number] 328 | else: 329 | word_color: str = "#FFFFFF" 330 | 331 | # txt_clip = get_text_clip(word, 332 | # optimal_font_size, 333 | # txt_color=word_color)\ 334 | # .set_position((txt_x, txt_y)) 335 | 336 | txt_clip: TextClip = TextClip( 337 | word, 338 | fontsize=optimal_font_size, 339 | color=word_color, 340 | align="west", 341 | font="Impact", 342 | stroke_color="#000000", 343 | stroke_width=3, 344 | method="caption", 345 | ).set_position((txt_x, txt_y)) 346 | 347 | current_text_width: int = txt_x + space_width + txt_clip.w 348 | 349 | if current_text_width > settings.thumbnail_text_width: 350 | txt_x = 15 351 | txt_y += word_height.h * 0.85 352 | line_number += 1 353 | 354 | if (line_number % 2) == 0: 355 | word_color: str = line_colours[line_number] 356 | else: 357 | word_color: str = "#FFFFFF" 358 | 359 | # txt_clip = get_text_clip(word, 360 | # fs=optimal_font_size, 361 | # txt_color=word_color)\ 362 | # .set_position((txt_x, txt_y)) 363 | txt_clip: TextClip = TextClip( 364 | word, 365 | fontsize=optimal_font_size, 366 | color=word_color, 367 | align="west", 368 | font="Impact", 369 | stroke_color="#000000", 370 | stroke_width=3, 371 | method="caption", 372 | ).set_position((txt_x, txt_y)) 373 | 374 | clips.append(txt_clip) 375 | txt_x += txt_clip.w + space_width 376 | 377 | txt_clip = txt_clip.set_duration(10) 378 | txt_clip = txt_clip.set_position(("center", "center")) 379 | 380 | final_video: CompositeVideoClip = CompositeVideoClip(clips) 381 | final_video = final_video.margin(border_width, color=random_rgb_colour()) 382 | 383 | logging.info("Saving Thumbnail to : %s", thumbnail_path) 384 | final_video.save_frame(thumbnail_path, 1) 385 | return thumbnail_path 386 | 387 | 388 | if __name__ == "__main__": 389 | from argparse import ArgumentParser, Namespace 390 | 391 | parser: ArgumentParser = ArgumentParser() 392 | parser.add_argument( 393 | "-d", 394 | "--directory", 395 | help="Specify directory to save thumbnail image to", 396 | default=".", 397 | ) 398 | parser.add_argument( 399 | "-s", "--subreddit", help="Specify subreddit", default="r/AskReddit" 400 | ) 401 | parser.add_argument( 402 | "-t", 403 | "--title", 404 | help="Specify Post Title", 405 | default="What's your most gatekeeping culinary opinion?", 406 | ) 407 | parser.add_argument( 408 | "-i", 409 | "--image", 410 | help="Specify Thumbnail Image", 411 | default=Path(settings.images_directory, "woman.jpg").absolute(), 412 | ) 413 | 414 | args: Namespace = parser.parse_args() 415 | 416 | create_thumbnail(".", "Something", args.title, args.image, index=0) 417 | 418 | # generate(args.directory, 419 | # args.subreddit, 420 | # args.title, 421 | # number_of_thumbnails=1) 422 | -------------------------------------------------------------------------------- /utils/base64_encoding.py: -------------------------------------------------------------------------------- 1 | """Base64 encoding utilities.""" 2 | import base64 3 | 4 | 5 | def base64_encode_string(message: str) -> None: 6 | """Encode a string to base64. 7 | 8 | Args: 9 | message: The string to be encoded. 10 | """ 11 | # TODO: What is my purpose? 12 | message_bytes = message.encode("ascii") 13 | base64_bytes = base64.b64encode(message_bytes) 14 | base64_message = base64_bytes.decode("ascii") 15 | print(base64_message) 16 | 17 | 18 | def base64_decode_string(message: str) -> str: 19 | """Decode a base64 encoded string to ASCII format. 20 | 21 | Args: 22 | message: The base64 string to be decoded. 23 | 24 | Returns: 25 | The decoded ASCII string. 26 | """ 27 | return base64.b64decode(message.encode("ascii")).decode("ascii") 28 | 29 | 30 | # Ignored keywords Testing the encoding function? 31 | message = "porn,sex,jerking off,slut,rape" 32 | base64_encode_string(message) 33 | 34 | # Encoded keywords. Testing the decoding function? 35 | message = "cG9ybixzZXgsamVya2luZyBvZmYsc2x1dCxyYXBl" 36 | decoded_string = base64_decode_string(message) 37 | print(decoded_string) 38 | print(decoded_string.split(",")) 39 | -------------------------------------------------------------------------------- /utils/common.py: -------------------------------------------------------------------------------- 1 | """Common helpers to keep the codebase DRY.""" 2 | from random import SystemRandom 3 | import re 4 | import os 5 | from pathlib import Path 6 | 7 | 8 | def random_hex_colour() -> str: 9 | """Generate a random hex value that represents a colour. 10 | 11 | Returns: 12 | A HTML colour expressed in hex format, for example - 0F37A1. 13 | """ 14 | rnd: SystemRandom = SystemRandom() 15 | r = rnd.randint(0, 255) 16 | rcolor = "#%02X%02X%02X" % (r(), r(), r()) 17 | return rcolor 18 | 19 | 20 | def random_rgb_colour() -> int: 21 | """Generate a random integer between 0 and 255. 22 | 23 | Returns: 24 | A random integer. 25 | """ 26 | rnd: SystemRandom = SystemRandom() 27 | rbg_colour: int = rnd.choices(range(256), k=3) 28 | print("Generated random rgb colour") 29 | print(rbg_colour) 30 | return rbg_colour 31 | 32 | 33 | def give_emoji_free_text(data: str) -> str: 34 | """Strip special characters from a string. 35 | 36 | This function will santize strings by removing emoticons, symbols and 37 | chinese characters. 38 | 39 | Args: 40 | data: The raw string to be processed. 41 | 42 | Returns: 43 | A santized string with special characters removed. 44 | """ 45 | emoj = re.compile( 46 | "[" 47 | "\U0001F600-\U0001F64F" # emoticons 48 | "\U0001F300-\U0001F5FF" # symbols & pictographs 49 | "\U0001F680-\U0001F6FF" # transport & map symbols 50 | "\U0001F1E0-\U0001F1FF" # flags (iOS) 51 | "\U00002500-\U00002BEF" # chinese char 52 | "\U00002702-\U000027B0" 53 | "\U00002702-\U000027B0" 54 | "\U000024C2-\U0001F251" 55 | "\U0001f926-\U0001f937" 56 | "\U00010000-\U0010ffff" 57 | "\u2640-\u2642" 58 | "\u2600-\u2B55" 59 | "\u200d" 60 | "\u23cf" 61 | "\u23e9" 62 | "\u231a" 63 | "\ufe0f" # dingbats 64 | "\u3030" 65 | "]+", 66 | re.UNICODE, 67 | ) 68 | return re.sub(emoj, "", data) 69 | 70 | 71 | def safe_filename(text: str) -> str: 72 | """Replace all spaces in a string with an underscore. 73 | 74 | Args: 75 | text: string to be processed. 76 | 77 | Returns: 78 | The processed string, with all spaces converted to underscores. 79 | """ 80 | text = text.replace(" ", "_") 81 | return "".join([c for c in text if re.match(r"\w", c)])[:50] 82 | 83 | 84 | def create_directory(folder_path: Path) -> None: 85 | """Create a directory path on the filesystem. 86 | 87 | Args: 88 | folder_path: Path to be created. 89 | """ 90 | if not os.path.exists(folder_path): 91 | os.makedirs(folder_path) 92 | 93 | 94 | def contains_url(text: str) -> bool: 95 | """Validate if a string is a valid URL. 96 | 97 | This just does a simple protocol check. If the string starts with http:// 98 | or https://, it's assumed to be a valid URL. 99 | 100 | Args: 101 | text: string to be evaluated. 102 | 103 | Returns: 104 | `True` if the string is a URL, otherwise `False`. 105 | """ 106 | matches = ["http://", "https://"] 107 | if any(x in text for x in matches): 108 | return True 109 | 110 | 111 | def sanitize_text(text: str) -> str: 112 | r"""Sanitize the text for tts. 113 | 114 | What gets removed: 115 | - following characters `^_~@!&;#:-%“”‘"%*/{}[]()\|<>?=+` 116 | - any http or https links 117 | 118 | Args: 119 | text: Text to be sanitized 120 | 121 | Returns: 122 | str: Sanitized text 123 | """ 124 | # Remove age related info 125 | # fmt: off 126 | text: str = re.sub( 127 | r"(\[|\()[0-9]{1,2}\s*(m|f)?(\)|\])", 128 | "", 129 | text, 130 | flags=re.IGNORECASE 131 | ) 132 | 133 | # remove any urls from the text 134 | regex_urls: str = ( 135 | r"((http|https)\:\/\/)" 136 | r"?[a-zA-Z0-9\.\/\?\:@\-_=#]" 137 | r"+\." 138 | r"([a-zA-Z]){2,6}" 139 | r"([a-zA-Z0-9\.\&\/\?\:@\-_=#])" 140 | r"*" 141 | ) 142 | 143 | result: str = re.sub(regex_urls, " ", text) 144 | 145 | # note: not removing apostrophes 146 | regex_expr: str = r"\s['|’]|['|’]\s|[\^_~@!&;#:\-–—%“”‘\"%\*/{}\[\]\(\)\\|<>=+]" 147 | result: str = re.sub(regex_expr, " ", result) 148 | result = result.replace("+", "plus").replace("&", "and") 149 | 150 | # remove extra whitespace 151 | # return " ".join(result.split()) 152 | return result 153 | -------------------------------------------------------------------------------- /utils/speed_test.py: -------------------------------------------------------------------------------- 1 | """Does things and tests the speed.""" 2 | --------------------------------------------------------------------------------