Compare commits

29 Commits

Author SHA1 Message Date
Amit Serper
e4e5a22a0f Fix repository link in README.md
Updated the repository link in README.md from piko-system to aserper.
2025-12-13 23:07:31 -05:00
unroll3677
fc5a4bec00 Update docker-compose.yml 2025-07-16 14:36:55 +02:00
unroll3677
6ff72f7f97 Update README.md 2025-07-16 14:35:20 +02:00
unroll3677
c42d230053 Update Dockerfile 2025-07-16 14:24:29 +02:00
unroll3677
22677de465 Update docker-publish.yml 2025-07-16 14:18:13 +02:00
unroll3677
760d84d104 Delete .github/workflows/masto-rss.yml 2025-07-16 14:14:48 +02:00
unroll3677
faf443b5b7 Create docker-publish.yml 2025-07-16 14:14:04 +02:00
unroll3677
f795520ad6 Create .dockerignore 2025-07-15 15:22:56 +02:00
unroll3677
6ae6978d68 Update Dockerfile 2025-07-15 15:22:17 +02:00
unroll3677
4c043a596b Update README.md 2025-07-15 14:07:44 +02:00
unroll3677
d3c02fddac Create docker-compose.yml 2025-07-15 10:12:06 +02:00
unroll3677
0860fa555d Update README.md 2025-07-15 10:08:40 +02:00
unroll3677
7a16534593 Update README.md 2025-07-15 09:58:43 +02:00
unroll3677
b4810530ab Update main.py
added toot template and translated to english
2025-07-15 09:55:28 +02:00
unroll3677
83fe193d67 Update main.py
added hashtag fix
2025-07-14 14:55:16 +02:00
unroll3677
6e947528cb Update README.md 2025-07-14 14:45:21 +02:00
unroll3677
2e11ea076a Update README.md 2025-07-14 14:39:16 +02:00
unroll3677
43c9ab7bdb Update README.md 2025-07-14 14:38:51 +02:00
unroll3677
ec657cb5af Update main.py
added state file max lines
2025-07-14 14:37:41 +02:00
unroll3677
a96fcf9ca6 Update README.md 2025-07-14 14:25:23 +02:00
unroll3677
8ef903a720 Update main.py
added hashtag support
2025-07-14 14:23:13 +02:00
Amit Serper
89b298253a Merge pull request #5 from aserper/dependabot/pip/urllib3-2.2.2
Bump urllib3 from 2.1.0 to 2.2.2
2024-08-06 16:47:00 -04:00
Amit Serper
cff2a721af Merge pull request #4 from aserper/dependabot/pip/requests-2.32.2
Bump requests from 2.31.0 to 2.32.2
2024-08-06 16:46:46 -04:00
Amit Serper
40ede411a5 Merge pull request #3 from aserper/dependabot/pip/idna-3.7
Bump idna from 3.6 to 3.7
2024-08-06 16:46:33 -04:00
Amit Serper
4d2dea7ab2 Merge pull request #2 from aserper/dependabot/pip/certifi-2024.7.4
Bump certifi from 2023.11.17 to 2024.7.4
2024-08-06 16:46:13 -04:00
dependabot[bot]
02b289f5d1 Bump urllib3 from 2.1.0 to 2.2.2
Bumps [urllib3](https://github.com/urllib3/urllib3) from 2.1.0 to 2.2.2.
- [Release notes](https://github.com/urllib3/urllib3/releases)
- [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst)
- [Commits](https://github.com/urllib3/urllib3/compare/2.1.0...2.2.2)

---
updated-dependencies:
- dependency-name: urllib3
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-08-06 20:45:43 +00:00
dependabot[bot]
37ed31782f Bump requests from 2.31.0 to 2.32.2
Bumps [requests](https://github.com/psf/requests) from 2.31.0 to 2.32.2.
- [Release notes](https://github.com/psf/requests/releases)
- [Changelog](https://github.com/psf/requests/blob/main/HISTORY.md)
- [Commits](https://github.com/psf/requests/compare/v2.31.0...v2.32.2)

---
updated-dependencies:
- dependency-name: requests
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-08-06 20:45:24 +00:00
dependabot[bot]
d7c710b51c Bump idna from 3.6 to 3.7
Bumps [idna](https://github.com/kjd/idna) from 3.6 to 3.7.
- [Release notes](https://github.com/kjd/idna/releases)
- [Changelog](https://github.com/kjd/idna/blob/master/HISTORY.rst)
- [Commits](https://github.com/kjd/idna/compare/v3.6...v3.7)

---
updated-dependencies:
- dependency-name: idna
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-08-06 20:45:00 +00:00
dependabot[bot]
78469217b3 Bump certifi from 2023.11.17 to 2024.7.4
Bumps [certifi](https://github.com/certifi/python-certifi) from 2023.11.17 to 2024.7.4.
- [Commits](https://github.com/certifi/python-certifi/compare/2023.11.17...2024.07.04)

---
updated-dependencies:
- dependency-name: certifi
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-08-06 20:44:42 +00:00
8 changed files with 350 additions and 92 deletions

23
.dockerignore Normal file
View File

@@ -0,0 +1,23 @@
# Git-related files
.git
.gitignore
# Docker-related files
Dockerfile
.dockerignore
# Python-specific files and directories
__pycache__/
*.pyc
*.pyo
*.pyd
.venv/
venv/
env/
# IDE / Editor-specific directories
.vscode/
.idea/
# Documentation
README.md

25
.github/workflows/docker-publish.yml vendored Normal file
View File

@@ -0,0 +1,25 @@
name: Push to Docker Hub
on:
push:
branches: [ "main" ]
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v4
with:
context: .
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/masto-rss-advanced:latest

View File

@@ -1,20 +0,0 @@
name: Masto-rss
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- name: Build & Push Image
run: |
echo "${{ secrets.DH_PASSWORD }}" | docker login -u "amitserper" --password-stdin
docker build -t amitserper/masto-rss .
docker push amitserper/masto-rss

View File

@@ -1,17 +1,22 @@
# Use an appropriate base image with Python pre-installed
FROM alpine:3.18
# Step 1: Use an official, slim Python image as a base
# 'slim' is a good balance between size and functionality.
FROM python:3.11-slim
# Set the working directory inside the container
WORKDIR /app
# Copy the entire current directory into the container at /app
COPY . /app
# Step 2: Copy only the requirements file
# This makes optimal use of Docker's layer caching.
COPY requirements.txt .
# Install any Python dependencies
# Step 3: Install the dependencies
# The --no-cache-dir option keeps the image smaller.
RUN pip install --no-cache-dir -r requirements.txt
RUN apk add python3
RUN apk add py3-pip
RUN pip install -r requirements.txt
# Step 4: Now, copy the rest of the application code
# Because the code changes more often than the dependencies, this step is placed later.
COPY . .
# Run Python script
CMD ["python", "main.py"]
# Step 5: Execute the correct Python script
# Note the correct filename.
CMD ["python3", "main.py"]

162
README.md
View File

@@ -1,20 +1,158 @@
# Masto-rss
# Masto-RSS Advanced Bot
A simple Mastodon bot written in python that posts updates from an RSS feed to a Mastodon account.
This project is meant to be built to a docker container, so all of the options need to be set as environment variables:
A flexible and robust Python script to automatically post the latest entry from an RSS feed to Mastodon, designed to run continuously in a Docker container.
MASTODON_CLIENT_ID = Mastodon client ID
This project is based on the original concept of [masto-rss](https://github.com/aserper/masto-rss) but has been significantly rewritten and extended with modern features for stability and customization.
MASTODON_CLIENT_SECRET = Mastodon client secret
## Features
MASTODON_ACCESS_TOKEN = Mastodon access token
* **Posts Only the Latest Item**: Checks an RSS feed periodically and posts only the single most recent entry, preventing feed spam.
* **Prevents Duplicates**: Keeps a history of posted items to ensure the same link is never posted twice.
* **History Limit**: The history file is automatically pruned to a configurable size (`MAX_HISTORY_SIZE`) to prevent it from growing indefinitely.
* **Custom Toot Format**: Fully customize the look of your posts using a template string with placeholders (`{title}`, `{link}`, `{hashtags}`).
* **Hashtag Support**: Automatically append a configurable list of hashtags to every post.
* **Dockerized**: Runs as a pre-built container image for easy, reliable, and isolated deployment. You don't need to manage Python or dependencies.
* **Robust Error Handling**: Designed to run forever, it handles connection errors to the RSS feed or Mastodon gracefully without crashing.
MASTODON_INSTANCE_URL = Mastodon instance URL
## Getting Started
RSS_FEED_URL = URL of RSS/xml feed
The recommended and simplest way to run this bot is by using the pre-built Docker image from Docker Hub with Docker Compose.
TOOT_VISIBILITY = 'public', 'unlisted', 'private', or 'direct'
### 1. Prerequisites
The best way to use this project is by using [its docker container](https://hub.docker.com/r/amitserper/masto-rss)
When using docker, make a bind mount between /state on the container to whatever directory you want on your machine in order to keep the state of the feeds that were already posted
![image](https://github.com/aserper/masto-rss/actions/workflows/masto-rss.yml/badge.svg)
* Docker and Docker Compose installed on your system.
* A Mastodon account and an application set up via **Preferences > Development**. You will need the **Client Key**, **Client Secret**, and **Access Token**.
### 2. Configuration
You only need to create one file: `docker-compose.yml`.
First, create a directory for your project and navigate into it:
```bash
mkdir my-masto-bot
cd my-masto-bot
```
Next, create a file named `docker-compose.yml` and paste the following content into it. **You must edit the `environment` section with your own credentials and settings.**
```yaml
version: '3.8'
services:
masto-rss:
# Use the pre-built image from Docker Hub
image: doorknob2947/masto-rss-advanced:latest
container_name: masto-rss-bot
restart: unless-stopped
volumes:
# This volume persists the history of posted links
- ./state:/state
environment:
# --- Mastodon API Credentials (Required) ---
- MASTODON_CLIENT_ID=YOUR_CLIENT_KEY_HERE
- MASTODON_CLIENT_SECRET=YOUR_CLIENT_SECRET_HERE
- MASTODON_ACCESS_TOKEN=YOUR_ACCESS_TOKEN_HERE
- MASTODON_INSTANCE_URL=[https://mastodon.social](https://mastodon.social)
# --- RSS Feed Configuration (Required) ---
- RSS_FEED_URL=[https://www.theverge.com/rss/index.xml](https://www.theverge.com/rss/index.xml)
# --- Bot Behavior (Optional) ---
- CHECK_INTERVAL=3600 # Time in seconds between checks (e.g., 1 hour)
- MAX_HISTORY_SIZE=500 # Max number of posted links to remember
- TOOT_VISIBILITY=public # public, unlisted, private, or direct
# --- Toot Content (Optional) ---
- MASTO_RSS_HASHTAGS="news tech python" # Space-separated list of hashtags
- TOOT_TEMPLATE='{title}\n\n{link}\n\n{hashtags}'
# --- System (Do not change) ---
- PYTHONUNBUFFERED=1
```
### 3. Environment Variables
All configuration is handled through the `environment` section in your `docker-compose.yml` file.
| Variable | Description | Example |
| ------------------------ | ------------------------------------------------------------------------ | --------------------------------------------------- |
| `MASTODON_CLIENT_ID` | Your Mastodon application's Client Key. | `abc...` |
| `MASTODON_CLIENT_SECRET` | Your Mastodon application's Client Secret. | `def...` |
| `MASTODON_ACCESS_TOKEN` | Your Mastodon application's Access Token. | `ghi...` |
| `MASTODON_INSTANCE_URL` | The base URL of your Mastodon instance. | `https://mastodon.social` |
| `RSS_FEED_URL` | The full URL of the RSS feed you want to monitor. | `https://www.theverge.com/rss/index.xml` |
| `CHECK_INTERVAL` | (Optional) The time in seconds between checks. | `3600` (for 1 hour) |
| `MASTO_RSS_HASHTAGS` | (Optional) A space-separated list of hashtags to add to each post. | `news tech python` |
| `MAX_HISTORY_SIZE` | (Optional) The maximum number of post URLs to remember. | `500` |
| `TOOT_VISIBILITY` | (Optional) The visibility of the toot (`public`, `unlisted`, `private`, `direct`). | `public` |
| `TOOT_TEMPLATE` | (Optional) A string to format the toot. See "Customizing the Toot Format" below. | `'{title}\n\n{link}\n\n{hashtags}'` |
| `PYTHONUNBUFFERED` | Should be kept at `1` to ensure logs appear in real-time in Docker. | `1` |
### 4. Customizing the Toot Format
You can change the layout of your posts using the `TOOT_TEMPLATE` variable. Use the following placeholders:
* `{title}`: The title of the RSS entry.
* `{link}`: The URL of the RSS entry.
* `{hashtags}`: The configured hashtags.
**Examples in `docker-compose.yml`:**
* **Compact Format:**
```yaml
- TOOT_TEMPLATE='{title} - {link} {hashtags}'
```
* **Personalized Format:**
```yaml
- TOOT_TEMPLATE='New on the blog: {title}\nRead it here: {link}\n\n{hashtags}'
```
## Running the Bot
1. **Create the `state` directory.** This is required for the bot to remember which links it has already posted.
```bash
mkdir ./state
```
2. **Start the container** in detached (background) mode:
```bash
docker-compose up -d
```
The bot is now running!
## Managing the Bot
* **View logs in real-time:**
```bash
docker-compose logs -f
```
* **Stop the container:**
```bash
docker-compose down
```
* **Restart the container:**
```bash
docker-compose restart
```
## Updating the Bot
To update to the latest version of the image from Docker Hub:
1. **Pull the latest image:**
```bash
docker-compose pull
```
2. **Restart the container** to apply the update:
```bash
docker-compose up -d
```
## Acknowledgements
* This project is heavily inspired by and based on the original [masto-rss](https://github.com/aserper/masto-rss). This version aims to add more robustness, flexibility, and ease of deployment using modern practices.
* The Python script, Docker configuration, and this README were written and modified with the assistance of AI.

30
docker-compose.yml Normal file
View File

@@ -0,0 +1,30 @@
services:
masto-rss:
# Use the pre-built image from Docker Hub
image: doorknob2947/masto-rss-advanced:latest
container_name: masto-rss-bot
restart: unless-stopped
volumes:
# This volume persists the history of posted links
- ./state:/state
environment:
# --- Mastodon API Credentials (Required) ---
- MASTODON_CLIENT_ID=YOUR_CLIENT_KEY_HERE
- MASTODON_CLIENT_SECRET=YOUR_CLIENT_SECRET_HERE
- MASTODON_ACCESS_TOKEN=YOUR_ACCESS_TOKEN_HERE
- MASTODON_INSTANCE_URL=[https://mastodon.social](https://mastodon.social)
# --- RSS Feed Configuration (Required) ---
- RSS_FEED_URL=[https://www.theverge.com/rss/index.xml](https://www.theverge.com/rss/index.xml)
# --- Bot Behavior (Optional) ---
- CHECK_INTERVAL=3600 # Time in seconds between checks (e.g., 1 hour)
- MAX_HISTORY_SIZE=500 # Max number of posted links to remember
- TOOT_VISIBILITY=public # public, unlisted, private, or direct
# --- Toot Content (Optional) ---
- MASTO_RSS_HASHTAGS="news tech python" # Space-separated list of hashtags
- TOOT_TEMPLATE='{title}\n\n{link}\n\n{hashtags}'
# --- System (Do not change) ---
- PYTHONUNBUFFERED=1

135
main.py
View File

@@ -2,73 +2,130 @@ import feedparser
from mastodon import Mastodon
import os
import time
# Replace these with your Mastodon application details and access token
MASTODON_CLIENT_ID = os.environ['MASTODON_CLIENT_ID']
MASTODON_CLIENT_SECRET = os.environ['MASTODON_CLIENT_SECRET']
MASTODON_ACCESS_TOKEN = os.environ['MASTODON_ACCESS_TOKEN']
MASTODON_INSTANCE_URL = os.environ['MASTODON_INSTANCE_URL']
TOOT_VISIBILITY = os.environ['TOOT_VISIBILITY'] # Toot visibility ('public', 'unlisted', 'private', or 'direct')
# RSS feed URL
RSS_FEED_URL = os.environ['RSS_FEED_URL']
from collections import deque
# File to store the processed entry URLs. Note that /state directory is for the docker setup
# --- Configuration ---
# Get configuration from environment variables
MASTODON_CLIENT_ID = os.environ.get('MASTODON_CLIENT_ID')
MASTODON_CLIENT_SECRET = os.environ.get('MASTODON_CLIENT_SECRET')
MASTODON_ACCESS_TOKEN = os.environ.get('MASTODON_ACCESS_TOKEN')
MASTODON_INSTANCE_URL = os.environ.get('MASTODON_INSTANCE_URL')
RSS_FEED_URL = os.environ.get('RSS_FEED_URL')
# Optional configuration with default values
TOOT_VISIBILITY = os.environ.get('TOOT_VISIBILITY', 'public')
CHECK_INTERVAL = int(os.environ.get('CHECK_INTERVAL', 3600))
HASHTAGS = os.environ.get('MASTO_RSS_HASHTAGS', '')
MAX_HISTORY_SIZE = int(os.environ.get('MAX_HISTORY_SIZE', 500))
# NEW: Template for the toot format.
# Use {title}, {link}, and {hashtags} as placeholders.
DEFAULT_TEMPLATE = '{title}\n\n{link}\n\n{hashtags}'
TOOT_TEMPLATE = os.environ.get('TOOT_TEMPLATE', DEFAULT_TEMPLATE)
# File to store the processed entry URLs.
PROCESSED_ENTRIES_FILE = '/state/processed_entries.txt'
# Time delay between RSS checks (in seconds)
CHECK_INTERVAL = os.environ['CHECK_INTERVAL'] # Check interval in seconds
# Check if all required configuration is present
if not all([MASTODON_CLIENT_ID, MASTODON_CLIENT_SECRET, MASTODON_ACCESS_TOKEN, MASTODON_INSTANCE_URL, RSS_FEED_URL]):
print("Error: Not all required environment variables are set.")
exit(1)
# Initialize Mastodon client
mastodon = Mastodon(
# --- Mastodon Initialization ---
try:
mastodon = Mastodon(
client_id=MASTODON_CLIENT_ID,
client_secret=MASTODON_CLIENT_SECRET,
access_token=MASTODON_ACCESS_TOKEN,
api_base_url=MASTODON_INSTANCE_URL
)
)
mastodon.account_verify_credentials()
print("Successfully logged in to Mastodon.")
except Exception as e:
print(f"Error initializing Mastodon client: {e}")
exit(1)
# --- Functions ---
# Function to load processed entry URLs from a file
def load_processed_entries():
"""Loads processed entry URLs from a file into a deque."""
os.makedirs(os.path.dirname(PROCESSED_ENTRIES_FILE), exist_ok=True)
try:
with open(PROCESSED_ENTRIES_FILE, 'r') as file:
return set(file.read().splitlines())
lines = file.read().splitlines()
return deque(lines, maxlen=MAX_HISTORY_SIZE)
except FileNotFoundError:
return set()
return deque(maxlen=MAX_HISTORY_SIZE)
# Function to save processed entry URLs to a file
def save_processed_entries(processed_entries):
def save_processed_entries(processed_entries_deque):
"""Saves the processed entry URLs from the deque to a file."""
with open(PROCESSED_ENTRIES_FILE, 'w') as file:
file.write('\n'.join(processed_entries))
file.write('\n'.join(processed_entries_deque))
def format_hashtags(hashtag_string):
"""Formats a string of hashtags into a correct list."""
if not hashtag_string:
return ""
clean_string = hashtag_string.strip(' "\'')
tags = filter(None, clean_string.split(' '))
return " ".join([f"#{tag.lstrip('#')}" for tag in tags])
# Function to check and post new RSS items
def check_and_post_new_items():
while True:
print("Checking for new RSS items...")
# Load processed entry URLs from the file
"""Checks the RSS feed and only posts the latest item if it's new."""
formatted_hashtags = format_hashtags(HASHTAGS)
if formatted_hashtags:
print(f"Hashtags configured: {formatted_hashtags}")
else:
print("INFO: No hashtags configured.")
print(f"INFO: Using toot template: {TOOT_TEMPLATE.replace(chr(10), ' ')}")
processed_entries = load_processed_entries()
# Parse the RSS feed
while True:
print(f"Checking for new RSS items from: {RSS_FEED_URL}")
feed = feedparser.parse(RSS_FEED_URL)
for entry in feed.entries:
entry_url = entry.link
if feed.bozo:
print(f"Warning: RSS feed may be malformed. Error: {feed.bozo_exception}")
# Check if the entry is new (not in the processed_entries set)
if entry_url not in processed_entries:
print(f"Found a new RSS item: {entry.title}")
# Create a Mastodon status
status = f"\n{entry.title}\n\n{entry.link}"
if not feed.entries:
print("No items found in the RSS feed.")
else:
latest_entry = feed.entries[0]
entry_url = latest_entry.get('link')
entry_title = latest_entry.get('title', 'No title')
# Post the status to Mastodon
if not entry_url:
print(f"Skipping latest item '{entry_title}' (no link).")
elif entry_url not in processed_entries:
print(f"Found new latest item: {entry_title}")
# Compose the Mastodon status based on the template
status = TOOT_TEMPLATE.format(
title=entry_title,
link=entry_url,
hashtags=formatted_hashtags
).strip()
try:
print(f"Posting: {status.replace(chr(10), ' ')}")
mastodon.status_post(status, visibility=TOOT_VISIBILITY)
print("Post successful.")
# Add the entry URL to the processed_entries set
processed_entries.add(entry_url)
# Save the updated processed_entries set to the file
processed_entries.append(entry_url)
save_processed_entries(processed_entries)
print("Sleeping for", CHECK_INTERVAL, "seconds...")
# Wait for the specified interval before checking again
except Exception as e:
print(f"Error posting to Mastodon: {e}")
else:
print("The latest item has already been posted.")
print(f"Waiting for {CHECK_INTERVAL} seconds...")
time.sleep(int(CHECK_INTERVAL))
# --- Main Program ---
if __name__ == "__main__":
check_and_post_new_items()

View File

@@ -6,7 +6,7 @@
#
blurhash==1.1.4
# via mastodon-py
certifi==2023.11.17
certifi==2024.7.4
# via requests
charset-normalizer==3.3.2
# via requests
@@ -14,7 +14,7 @@ decorator==5.1.1
# via mastodon-py
feedparser==6.0.11
# via -r requirements.in
idna==3.6
idna==3.7
# via requests
mastodon-py==1.8.1
# via -r requirements.in
@@ -22,7 +22,7 @@ python-dateutil==2.8.2
# via mastodon-py
python-magic==0.4.27
# via mastodon-py
requests==2.31.0
requests==2.32.2
# via mastodon-py
sgmllib3k==1.0.0
# via feedparser
@@ -30,5 +30,5 @@ six==1.16.0
# via
# mastodon-py
# python-dateutil
urllib3==2.1.0
urllib3==2.2.2
# via requests