Top 5 LinkedIn and Social Syndication Workflows for Senior Engineers without Relying on Paid Advertising Budgets
Automated Content Mirroring: LinkedIn Articles to Blog Posts
Many senior engineers and technical leaders leverage LinkedIn’s article publishing platform to share their insights. However, this content often remains siloed. A powerful workflow involves automatically mirroring these LinkedIn articles to your own blog, enhancing SEO and providing a central hub for your technical thought leadership. This requires a programmatic approach to extract content from LinkedIn and publish it to your CMS.
We can achieve this using a combination of web scraping (ethically, respecting LinkedIn’s ToS and robots.txt) and your CMS’s API. For this example, let’s assume you’re using WordPress and have an API key. We’ll use Python with the `requests` and `BeautifulSoup` libraries for scraping, and `python-wordpress-xmlrpc` for publishing.
Prerequisites
- Python 3.x installed.
- `requests`, `BeautifulSoup4`, and `python-wordpress-xmlrpc` installed (`pip install requests beautifulsoup4 python-wordpress-xmlrpc`).
- Your LinkedIn profile URL.
- Your WordPress site’s XML-RPC endpoint (e.g.,
https://yourdomain.com/xmlrpc.php), username, and application password.
Python Script for Mirroring
This script will fetch the latest article from your LinkedIn profile, extract its title and content, and then publish it as a new post on your WordPress blog. It includes basic de-duplication by checking if a post with the same title already exists.
import requests
from bs4 import BeautifulSoup
from wordpress_xmlrpc import Client, WordPressPost
from wordpress_xmlrpc.methods.posts import GetPosts, NewPost
from datetime import datetime
# --- Configuration ---
LINKEDIN_PROFILE_URL = "https://www.linkedin.com/in/your-linkedin-username/" # Replace with your LinkedIn profile URL
WORDPRESS_URL = "https://yourdomain.com/xmlrpc.php" # Replace with your WordPress XML-RPC endpoint
WORDPRESS_USERNAME = "your_wp_username" # Replace with your WordPress username
WORDPRESS_PASSWORD = "your_wp_application_password" # Replace with your WordPress application password
POST_CATEGORY = "Syndicated" # Optional: Category to assign to syndicated posts
POST_TAGS = ["LinkedIn", "Syndication"] # Optional: Tags to assign to syndicated posts
# --- End Configuration ---
def get_latest_linkedin_article(profile_url):
try:
response = requests.get(profile_url, timeout=10)
response.raise_for_status() # Raise an exception for bad status codes
soup = BeautifulSoup(response.content, 'html.parser')
# LinkedIn's structure can change. This is a common pattern for article links.
# Inspect your profile page's HTML to find the correct selector.
article_link_element = soup.select_one('a[data-control-name="background_details_webസിൽ]') # Example selector, may need adjustment
if not article_link_element:
article_link_element = soup.select_one('a[href*="/activity/articles/"]') # Another common pattern
if article_link_element and article_link_element.get('href'):
article_url = article_link_element['href']
# Fetch the article content
article_response = requests.get(article_url, timeout=10)
article_response.raise_for_status()
article_soup = BeautifulSoup(article_response.content, 'html.parser')
# Extract title and content. Again, selectors are crucial and may change.
title_element = article_soup.select_one('h1') # Common for article titles
if not title_element:
title_element = article_soup.select_one('div[class*="article-title"]') # Alternative
content_element = article_soup.select_one('div[class*="article-body"]') # Common for article content
if not content_element:
content_element = article_soup.select_one('div[class*="content-inner"]') # Alternative
if title_element and content_element:
title = title_element.get_text(strip=True)
# Basic HTML cleaning for content
content = str(content_element)
return title, content, article_url
else:
print("Could not find title or content elements in the article.")
return None, None, None
else:
print("Could not find the latest article link on the profile page.")
return None, None, None
except requests.exceptions.RequestException as e:
print(f"Error fetching LinkedIn profile or article: {e}")
return None, None, None
except Exception as e:
print(f"An unexpected error occurred during LinkedIn scraping: {e}")
return None, None, None
def get_existing_post_titles(wp_client):
try:
# Fetch recent posts to check for duplicates. Adjust 'number' as needed.
posts = wp_client.call(GetPosts({'number': 50}))
return {post.title for post in posts}
except Exception as e:
print(f"Error fetching existing post titles from WordPress: {e}")
return set()
def publish_to_wordpress(title, content, original_url, wp_client, category=None, tags=None):
post = WordPressPost()
post.title = title
post.content = content
post.post_status = 'publish' # Or 'draft' if you want to review first
post.link = original_url # Link to the original LinkedIn article
if category:
post.terms_names = {
'category': [category]
}
if tags:
if 'terms_names' not in post.__dict__:
post.terms_names = {}
post.terms_names['post_tag'] = tags
try:
post_id = wp_client.call(NewPost(post))
print(f"Successfully published post '{title}' with ID: {post_id}")
return True
except Exception as e:
print(f"Error publishing post '{title}' to WordPress: {e}")
return False
def main():
print("Starting LinkedIn to WordPress syndication process...")
# 1. Get latest article from LinkedIn
title, content, original_url = get_latest_linkedin_article(LINKEDIN_PROFILE_URL)
if not title or not content:
print("Failed to retrieve article content. Exiting.")
return
print(f"Found article: '{title}'")
# 2. Connect to WordPress
try:
wp = Client(WORDPRESS_URL, WORDPRESS_USERNAME, WORDPRESS_PASSWORD)
print("Successfully connected to WordPress.")
except Exception as e:
print(f"Failed to connect to WordPress: {e}")
return
# 3. Check for existing posts
existing_titles = get_existing_post_titles(wp)
if title in existing_titles:
print(f"Post '{title}' already exists on the blog. Skipping.")
return
# 4. Publish to WordPress
print(f"Publishing '{title}' to WordPress...")
publish_to_wordpress(title, content, original_url, wp, POST_CATEGORY, POST_TAGS)
print("Syndication process finished.")
if __name__ == "__main__":
main()
Deployment and Automation
This script can be run periodically using a cron job on a server or a cloud function (e.g., AWS Lambda, Google Cloud Functions). Schedule it to run daily or weekly, depending on your content publishing frequency on LinkedIn.
Cron Job Example (Linux/macOS)
Edit your crontab with crontab -e and add a line like this to run the script every day at 3 AM:
0 3 * * * /usr/bin/python3 /path/to/your/linkedin_to_wp.py >> /var/log/linkedin_syndication.log 2>&1
Important Notes:
- LinkedIn HTML Structure: LinkedIn frequently updates its website structure. The CSS selectors used in
get_latest_linkedin_article(e.g.,soup.select_one('a[data-control-name="background_details_webസിൽ]')) are examples and will likely need to be updated by inspecting the HTML of your LinkedIn profile page in a browser’s developer tools. Look for unique attributes or class names associated with article links and content containers. - Rate Limiting & ToS: Be mindful of LinkedIn’s Terms of Service and potential rate limiting. Excessive or aggressive scraping can lead to IP bans or account suspension. Implement delays if necessary and ensure your scraping is respectful.
- Application Passwords: For WordPress, use an “Application Password” instead of your main user password for enhanced security. You can generate these in your WordPress user profile settings.
- Error Handling: The provided script has basic error handling. For production, consider more robust logging, retry mechanisms, and notifications (e.g., email alerts on failure).
- Content Formatting: LinkedIn’s rich text editor might use specific HTML. The script performs basic HTML conversion. You might need more sophisticated HTML sanitization or transformation to ensure consistent rendering on your blog.
Cross-Platform Content Distribution: Twitter Threads from Blog Posts
Long-form content on your blog is excellent for depth, but bite-sized, engaging content is key for social media platforms like Twitter. This workflow automates the creation of Twitter threads from your blog posts, driving traffic back to your site.
Workflow Overview
- Trigger: A new post is published on your blog (e.g., via RSS feed, webhook, or direct CMS API event).
- Processing: The content of the blog post is fetched.
- Summarization/Chunking: The post is intelligently summarized and broken down into tweet-sized chunks (max 280 characters).
- API Interaction: The generated tweets are posted to Twitter via its API.
Technical Implementation (Python with Tweepy and Feedparser)
This example uses Python. We’ll simulate the trigger by checking your blog’s RSS feed. For a real-time trigger, you’d integrate with your CMS’s webhook system.
Prerequisites
- Python 3.x installed.
- `feedparser`, `tweepy`, `nltk` (for potential advanced summarization) installed (`pip install feedparser tweepy nltk`).
- Your blog’s RSS feed URL.
- Twitter API v2 credentials (Bearer Token for read-only, API Key/Secret, Access Token/Secret for posting).
- A way to track which posts have already been tweeted (e.g., a simple file or database).
Python Script for Twitter Threading
import feedparser
import tweepy
import re
import os
import json
from datetime import datetime
# --- Configuration ---
RSS_FEED_URL = "https://yourdomain.com/feed/" # Replace with your blog's RSS feed URL
TWITTER_API_KEY = "YOUR_API_KEY"
TWITTER_API_SECRET = "YOUR_API_SECRET"
TWITTER_ACCESS_TOKEN = "YOUR_ACCESS_TOKEN"
TWITTER_ACCESS_TOKEN_SECRET = "YOUR_ACCESS_TOKEN_SECRET"
TWITTER_BEARER_TOKEN = "YOUR_BEARER_TOKEN" # For v2 API client
# File to store processed post GUIDs to avoid re-tweeting
PROCESSED_POSTS_FILE = "processed_posts.json"
# --- Helper Functions ---
def load_processed_posts():
if os.path.exists(PROCESSED_POSTS_FILE):
with open(PROCESSED_POST_FILE, 'r') as f:
try:
return json.load(f)
except json.JSONDecodeError:
return {}
return {}
def save_processed_posts(processed_posts):
with open(PROCESSED_POST_FILE, 'w') as f:
json.dump(processed_posts, f, indent=4)
def get_latest_blog_post(rss_url):
feed = feedparser.parse(rss_url)
if feed.entries:
# Assuming the first entry is the latest
latest_entry = feed.entries[0]
return {
"title": latest_entry.title,
"link": latest_entry.link,
"published": latest_entry.published,
"guid": latest_entry.id if 'id' in latest_entry else latest_entry.link # Use GUID or link as unique identifier
}
return None
def clean_html(raw_html):
"""Removes HTML tags and extracts text."""
cleanr = re.compile('<.*?>')
cleantext = re.sub(cleanr, '', raw_html)
return cleantext
def chunk_text(text, max_length=270):
"""
Splits text into chunks suitable for Twitter threads.
This is a basic implementation. More advanced NLP could be used.
"""
chunks = []
current_chunk = ""
words = text.split()
for word in words:
# Check if adding the next word exceeds the limit, considering space and potential URL
# A rough estimate for URL length is 23 chars (t.co shortener)
potential_chunk = current_chunk + " " + word if current_chunk else word
if len(potential_chunk) <= max_length:
current_chunk = potential_chunk
else:
if current_chunk: # Only add if current_chunk is not empty
chunks.append(current_chunk)
current_chunk = word # Start new chunk with the current word
if current_chunk: # Add the last chunk if it exists
chunks.append(current_chunk)
# Further refinement: Ensure chunks aren't too short if possible, and handle very long words/sentences
# This basic version might create very short chunks if sentences are long.
# For production, consider sentence boundary detection (e.g., using NLTK).
return chunks
def create_twitter_thread(api_v1, post_title, post_link, post_content_raw):
"""Creates and posts a Twitter thread."""
print(f"Creating Twitter thread for: {post_title}")
# Basic content extraction and cleaning
post_content_text = clean_html(post_content_raw)
# Combine title and content for thread generation
full_text = f"{post_title}\n\n{post_content_text}\n\nRead more: {post_link}"
# Split into tweet-sized chunks
tweet_chunks = chunk_text(full_text)
if not tweet_chunks:
print("No content to tweet.")
return
thread = []
try:
# Post the first tweet
first_tweet_text = tweet_chunks[0]
# Add tweet number indicator if more than one tweet
if len(tweet_chunks) > 1:
first_tweet_text = f"1/{len(tweet_chunks)}: {first_tweet_text}"
# Ensure first tweet is within limits (especially with potential added text)
if len(first_tweet_text) > 280:
first_tweet_text = first_tweet_text[:277] + "..." # Truncate if still too long
status = api_v1.update_status(status=first_tweet_text)
thread.append(status.id_str)
print(f"Posted tweet: {status.id_str}")
# Post subsequent tweets in the thread
for i, chunk in enumerate(tweet_chunks[1:]):
tweet_text = chunk
# Add tweet number indicator
tweet_text = f"{i+2}/{len(tweet_chunks)}: {tweet_text}"
# Ensure tweet is within limits
if len(tweet_text) > 280:
tweet_text = tweet_text[:277] + "..." # Truncate if still too long
reply_status = api_v1.update_status(
status=tweet_text,
in_reply_to_status_id=thread[-1],
auto_populate_reply_metadata=True
)
thread.append(reply_status.id_str)
print(f"Posted tweet: {reply_status.id_str}")
print(f"Successfully created Twitter thread. First tweet ID: {thread[0]}")
return True
except tweepy.errors.TweepyException as e:
print(f"Error posting to Twitter: {e}")
return False
except Exception as e:
print(f"An unexpected error occurred during Twitter posting: {e}")
return False
def main():
print("Starting blog post to Twitter thread syndication...")
processed_posts = load_processed_posts()
latest_post = get_latest_blog_post(RSS_FEED_URL)
if not latest_post:
print("Could not retrieve latest blog post from RSS feed. Exiting.")
return
post_guid = latest_post['guid']
if post_guid in processed_posts:
print(f"Post '{latest_post['title']}' (GUID: {post_guid}) has already been processed. Exiting.")
return
print(f"Found new post: '{latest_post['title']}'")
# Authenticate with Twitter API v1.1 for posting
try:
auth = tweepy.OAuth1UserHandler(
TWITTER_API_KEY, TWITTER_API_SECRET,
TWITTER_ACCESS_TOKEN, TWITTER_ACCESS_TOKEN_SECRET
)
api_v1 = tweepy.API(auth)
# Verify credentials (optional but good practice)
api_v1.verify_credentials()
print("Twitter API v1.1 authentication successful.")
except tweepy.errors.TweepyException as e:
print(f"Error during Twitter API v1.1 authentication: {e}")
return
except Exception as e:
print(f"An unexpected error occurred during Twitter authentication: {e}")
return
# Fetch full post content (RSS often only provides a summary)
# This requires fetching the actual post URL and parsing its content.
# For simplicity, we'll assume the RSS feed entry contains 'content' or 'summary' field with HTML.
# If not, you'll need to scrape the post_link URL.
post_content_raw = ""
if 'content' in latest_post and latest_post['content']:
post_content_raw = latest_post['content'][0]['value'] # feedparser structure
elif 'summary' in latest_post and latest_post['summary']:
post_content_raw = latest_post['summary']
else:
print("RSS feed entry does not contain full content or summary. Cannot create thread.")
# Add logic here to fetch and parse the actual post_link if needed
return
# Create and post the thread
success = create_twitter_thread(api_v1, latest_post['title'], latest_post['link'], post_content_raw)
# Update processed posts list if successful
if success:
processed_posts[post_guid] = datetime.utcnow().isoformat()
save_processed_posts(processed_posts)
print("Successfully processed and tweeted the post.")
else:
print("Failed to create Twitter thread.")
print("Syndication process finished.")
if __name__ == "__main__":
main()
Automation and Scheduling
Similar to the LinkedIn workflow, this script can be automated. Instead of relying solely on RSS, consider integrating with your CMS’s webhook system. When a new post is published, your CMS can send an HTTP POST request to a webhook endpoint (e.g., a small Flask/Django app or a serverless function) that triggers this Python script.
Webhook Integration (Conceptual)
Your CMS (e.g., WordPress with a plugin like WP Webhooks) can be configured to send a payload to a URL like https://your-api-gateway.com/tweet-post whenever a new post is published. The endpoint would then execute the Python script.
Twitter API Considerations
- API v1.1 vs v2: While Twitter API v2 is recommended for most tasks, posting tweets and managing threads is still most straightforward with v1.1’s `update_status` and `in_reply_to_status_id`. Ensure you have the necessary permissions for both.
- Rate Limits: Be aware of Twitter’s API rate limits. Posting a thread consumes multiple requests. Monitor your usage.
- Content Summarization: The
chunk_textfunction is basic. For better results, integrate NLP libraries like NLTK or spaCy to identify sentence boundaries and create more coherent summaries. You might also consider using summarization models (e.g., Hugging Face Transformers) for more advanced summarization, though this adds complexity and computational cost. - Error Handling & Retries: Implement robust error handling and retry logic, especially for network issues or API rate limit errors.
- Tracking Processed Posts: The
processed_posts.jsonfile is a simple way to track. For larger scale, use a database (like Redis or PostgreSQL) for more efficient lookups and persistence. - Tweet Content: Ensure the extracted content is suitable for Twitter. You might want to strip certain HTML tags or reformat content specifically for the platform.
Leveraging GitHub for Technical Documentation Syndication
For engineers and technical companies, GitHub is a natural habitat. Using GitHub repositories to host and syndicate technical documentation offers several advantages: version control, collaboration, and a familiar interface for developers. This workflow focuses on pushing documentation updates from a central source (like a knowledge base or blog) to a GitHub repository, making it accessible and versioned.
Workflow: Central Knowledge Base to GitHub Docs Repo
- Source of Truth: Your primary documentation platform (e.g., a dedicated knowledge base tool, a CMS, or even a set of Markdown files).
- Transformation: Content is converted into a format suitable for GitHub (typically Markdown).
- Git Operations: A script automates `git add`, `git commit`, and `git push` operations to a designated GitHub repository.
- Trigger: Manual execution, scheduled job, or webhook from the source system.
Technical Implementation (Python with GitPython)
This example assumes your source documentation can be exported or accessed programmatically as Markdown files. We’ll use Python with the GitPython library.
Prerequisites
- Python 3.x installed.
- `GitPython` installed (`pip install GitPython`).
- Git installed and configured on the machine running the script (including user name and email).
- A local clone of your target GitHub repository.
- A GitHub Personal Access Token (PAT) with `repo` scope for pushing.
- Your source documentation files (e.g., Markdown files).
Python Script for GitHub Sync
import git
import os
import shutil
from datetime import datetime
# --- Configuration ---
SOURCE_DOCS_DIR = "/path/to/your/source/docs" # Directory containing your source Markdown files
GITHUB_REPO_PATH = "/path/to/your/local/github/repo" # Path to your cloned GitHub repository
GITHUB_BRANCH = "main" # Or your default branch (e.g., 'master')
COMMIT_MESSAGE_PREFIX = "[Auto-Sync]"
# --- End Configuration ---
def sync_docs_to_github(source_dir, repo_path, branch):
"""
Copies documentation from source_dir to repo_path and commits/pushes changes.
"""
print(f"Starting documentation sync from '{source_dir}' to GitHub repo '{repo_path}'...")
# Ensure source directory exists
if not os.path.isdir(source_dir):
print(f"Error: Source directory '{source_dir}' not found.")
return False
# Ensure GitHub repo path exists and is a Git repository
if not os.path.isdir(repo_path) or not os.path.isdir(os.path.join(repo_path, '.git')):
print(f"Error: GitHub repository path '{repo_path}' is not a valid Git repository.")
# Optionally, add logic here to clone the repo if it doesn't exist
return False
try:
# Initialize GitPython repository object
repo = git.Repo(repo_path)
# Ensure we are on the correct branch and it's clean
if repo.active_branch.name != branch:
print(f"Switching to branch '{branch}'...")
repo.git.checkout(branch)
if repo.is_dirty(untracked_files=True):
print("Warning: Repository has uncommitted changes or untracked files. Proceeding with caution.")
# In a production scenario, you might want to abort or stash changes.
# --- Copy new/updated files ---
print("Copying files from source to repository...")
for item_name in os.listdir(source_dir):
source_item_path = os.path.join(source_dir, item_name)
dest_item_path = os.path.join(repo_path, item_name)
if os.path.isdir(source_item_path):
# If it's a directory, copy recursively, overwriting existing
if os.path.exists(dest_item_path):
shutil.rmtree(dest_item_path) # Remove existing directory first
shutil.copytree(source_item_path, dest_item_path)
else:
# If it's a file, copy it, overwriting existing
shutil.copy2(source_item_path, dest_item_path)
print("File copy complete.")
# --- Git Operations ---
# Add all changes (new files, modified files)
repo.git.add(A=True) # Equivalent to 'git add .'
# Check if there are any staged changes
if not repo.index.diff("HEAD"):
print("No changes detected. Nothing to commit.")
return True # Indicate success as no action was needed
# Create commit message
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
commit_message = f"{COMMIT_MESSAGE_PREFIX} Automated sync on {timestamp}"
# Commit changes
print(f"Committing changes with message: '{commit_message}'")
repo.index.commit(commit_message)
# Push changes to the remote repository
print(f"Pushing changes to origin/{branch}...")
origin = repo.remote(name='origin')
origin.push(refspec=f"{branch}:{branch}") # Push current branch to its remote counterpart
print("Successfully pushed changes to GitHub.")
return True
except git.exc.GitCommandError as e:
print(f"Git command error: {e}")
return False
except Exception as e:
print(f"An unexpected error occurred: {e}")
return False
def main():
if sync_docs_to_github(SOURCE_DOCS_DIR, GITHUB_REPO_PATH, GITHUB_BRANCH):
print("Documentation sync completed successfully.")
else:
print("Documentation sync failed.")
if __name__ == "__main__":
main()
Automation and Security
This script is typically run manually or via a cron job. For automated pushes, ensure your GitHub Personal Access Token (PAT) is securely managed. Avoid hardcoding it directly into the script. Use environment variables or a secrets management system.
Environment Variable Example
# In your shell environment or a .env file loaded by your script: export GITHUB_PAT="your_personal_access_token_here"
Then, modify the script to use this environment variable for authentication when pushing (GitPython often uses the credential helper configured in your Git setup, which might already be using the PAT if configured correctly via HTTPS URL or SSH keys).
Key Considerations
- Source Content Format: This script assumes Markdown. If your source is HTML or another format, you’ll need a conversion step (e.g., using Pandoc or custom parsers) before copying files.
- Conflict Resolution: The current script overwrites destination files. If multiple people or processes might edit the GitHub repo simultaneously, you’ll need a more sophisticated conflict resolution strategy (e.g., fetching before pushing, handling merge conflicts).
- File Structure: Ensure the file structure in your
SOURCE_DOCS_DIRmirrors the desired structure in your GitHub repository. - Git Configuration: The machine running the script must have Git installed and configured with a user name and email that are recognized by GitHub. The PAT needs to be associated with an account that has write access to the target repository.
- Large Repositories: For very large documentation sets or frequent updates, consider optimizing the copy process and Git operations to avoid excessive disk I/O or long commit times.
Advanced: RSS Feed Aggregation and Cross-Posting
For a more robust content syndication strategy, aggregating RSS feeds from multiple sources (your own blogs, guest posts, partner sites) and then selectively re-syndicating them to your platforms can amplify reach. This involves building a mini-aggregator.
Workflow: RSS Aggregation and Smart Re-posting
- Input: A list of RSS feed URLs.
- Aggregation: Periodically fetch and parse all feeds, storing new entries.
- Filtering/Selection: Apply rules (keywords, source domains, manual curation) to decide which aggregated content to re-syndicate.
- Output: Post selected content to other platforms (e.g., LinkedIn, Twitter, Slack channels) using their respective APIs.
- De-duplication: Crucial to avoid reposting the same content multiple times.
Technical Implementation (Python with Feedparser and API Clients)
This requires a more complex script or application, potentially running as a background service.
Prerequisites
- Python 3.x installed.
- `feedparser`, `requests`, and relevant API client libraries (e.g., `tweepy`, `python-wordpress-xmlrpc`, LinkedIn API wrappers if available).
- A database (e.g., SQLite, PostgreSQL) to store aggregated feed entries and track posted items.
- Configuration for multiple RSS feeds and target platforms.
Conceptual Python Structure (Simplified)
import feedparser
import sqlite3
import json
from datetime import datetime
import requests # For LinkedIn API, etc.
import tweepy # For Twitter API
# --- Configuration ---
RSS_FEEDS = {
"my_blog": "https://myblog.com/feed/",
"partner_site": "https://partnersite.com/rss/",
# Add more feeds
}
DATABASE_FILE = "aggregator_db.sqlite"
POSTED_ITEMS_TRACKER = "posted_items.json" # Simple tracker for this example
# Target platform configurations (API keys, endpoints, etc.)
LINKEDIN_CONFIG = { ... }
TWITTER_CONFIG = { ... }
WORDPRESS_CONFIG = { ... }
# Filtering rules (example)
FILTER_KEYWORDS = ["AI", "Machine Learning", "DevOps"]
EXCLUDE_DOMAINS = ["spamdomain.com"]
# --- End Configuration ---
def init_db():
conn = sqlite3.connect(DATABASE_FILE)
cursor = conn.cursor()
cursor.execute('''
CREATE TABLE IF NOT EXISTS feed_items (
id INTEGER PRIMARY KEY AUTO