Skill: coolmanns/postiz-ext (version 1.0.1)

--- name: postiz description: | Schedule and manage social media posts via Postiz API (self-hosted or cloud). Direct API integration — no n8n dependency. Supports X/Twitter, LinkedIn, Bluesky with platform-specific character limits. Includes deduplication, scheduling, media upload, and thread creation. WHAT IT CAN DO: - Schedule posts to 28+ channels (X, LinkedIn, Bluesky, Reddit, Instagram, Facebook, Threads, YouTube, TikTok, Pinterest, Mastodon, and more) - Multi-platform posting in a single API call with platform-adapted content - X/Twitter thread creation for longer content - Media upload (file and URL) - Find next available posting slot per channel - List, query, update, and delete scheduled posts - Deduplication workflow (check existing before posting) - Platform-specific character limits and content tone guidance - Post state management (QUEUE, PUBLISHED, ERROR, DRAFT) - Helper script for quick posting with auto-validation USE WHEN: scheduling social media posts, creating multi-platform content, managing a posting calendar, uploading media for social posts, checking post status, creating X/Twitter threads, or automating social media workflows. --- # Postiz Social Media Scheduler Direct API integration for social media posting. No n8n workflows needed. ## Quick Reference | Platform | Integration ID | Character Limit | Handle | |----------|---------------|-----------------|--------| | X/Twitter | `cml5lbs3h0001o6l6gagj9gcq` | **280** | @CoolmannSa | | LinkedIn | `cml5k1d710001s69hwekkhx1p` | **3,000** | kuhlmannsascha | | Bluesky | `cml5mre6w0009o6l6svc718ej` | **300** | coolmanns.bsky.social | ## Platform Content Guidelines ### X/Twitter (280 chars) - Short, punchy content - 1-2 hashtags max - Links count as 23 chars (t.co shortening) - Threads for longer content (multiple tweets) ### LinkedIn (3,000 chars) - Professional tone - Can be longer-form - Hashtags at end (3-5 recommended) - First 140 chars show in preview — make them count! ### Bluesky (300 chars) - Similar to X but slightly more room - No official hashtag support (use sparingly) - Growing tech/developer audience ## Authentication ```bash # Login and save cookie (required before any API call) curl -s -c /tmp/postiz-cookies.txt \ 'https://postiz.home.mykuhlmann.com/api/auth/login' \ -H 'Content-Type: application/json' \ -d '{"email":"[REDACTED_SECRET]","password":"Postiz2026!","provider":"LOCAL"}' ``` Cookie expires periodically. Re-run login if you get 401 errors. ## Find Next Available Slot ```bash curl -s -b /tmp/postiz-cookies.txt \ 'https://postiz.home.mykuhlmann.com/api/posts/find-slot/INTEGRATION_ID' ``` Returns the next open time slot for a given channel. Useful for auto-scheduling without conflicts. ## Upload Media from URL ```bash curl -s -b /tmp/postiz-cookies.txt \ 'https://postiz.home.mykuhlmann.com/api/media/upload-from-url' \ -H 'Content-Type: application/json' \ -d '{"url": "https://example.com/image.png"}' ``` ## Creating Posts ### Schedule a Post (Single Platform) ```bash curl -s -b /tmp/postiz-cookies.txt \ 'https://postiz.home.mykuhlmann.com/api/posts' \ -H 'Content-Type: application/json' \ -d '{ "type": "schedule", "date": "2026-02-05T15:00:00Z", "posts": [{ "integration": {"id": "cml5lbs3h0001o6l6gagj9gcq"}, "value": [{"content": "Your tweet here (max 280 chars)", "image": []}], "settings": {"__type": "x"} }] }' ``` ### Multi-Platform Post (Adapted Content) ```bash curl -s -b /tmp/postiz-cookies.txt \ 'https://postiz.home.mykuhlmann.com/api/posts' \ -H 'Content-Type: application/json' \ -d '{ "type": "schedule", "date": "2026-02-05T15:00:00Z", "posts": [ { "integration": {"id": "cml5lbs3h0001o6l6gagj9gcq"}, "value": [{"content": "Short X version (280 chars max)", "image": []}], "settings": {"__type": "x"} }, { "integration": {"id": "cml5k1d710001s69hwekkhx1p"}, "value": [{"content": "Longer LinkedIn version with more context and professional tone. Can be up to 3000 characters.", "image": []}], "settings": {"__type": "linkedin"} }, { "integration": {"id": "cml5mre6w0009o6l6svc718ej"}, "value": [{"content": "Bluesky version (300 chars max)", "image": []}], "settings": {"__type": "bluesky"} } ] }' ``` ### Post Types - `schedule` — Auto-publish at specified date/time - `draft` — Save for review (won't auto-publish) - `now` — Publish immediately ## Listing & Querying Posts ### Get Posts by Date Range (Required!) ```bash curl -s -b /tmp/postiz-cookies.txt \ 'https://postiz.home.mykuhlmann.com/api/posts?startDate=2026-02-01T00:00:00Z&endDate=2026-02-08T00:00:00Z' \ | jq '.posts[] | {id, state, publishDate, platform: .integration.providerIdentifier, content: .content[0:60]}' ``` ### Check for Duplicates Before Posting ```bash # Get recent posts and check content similarity curl -s -b /tmp/postiz-cookies.txt \ 'https://postiz.home.mykuhlmann.com/api/posts?startDate=2026-02-01T00:00:00Z&endDate=2026-02-08T00:00:00Z' \ | jq -r '.posts[] | "\(.integration.providerIdentifier): \(.content[0:80])"' ``` ## Post States | State | Description | |-------|-------------| | `QUEUE` | Scheduled, waiting to publish | | `PUBLISHED` | Successfully posted | | `ERROR` | Failed to publish | | `DRAFT` | Saved but not scheduled | ## Media Upload ### Upload Image ```bash # Upload returns {id, path} curl -s -b /tmp/postiz-cookies.txt \ 'https://postiz.home.mykuhlmann.com/api/media/upload-simple' \ -F 'file=@/path/to/image.png' ``` ### Use in Post ```json "value": [{ "content": "Post with image", "image": [{"id": "MEDIA_ID", "path": "/uploads/..."}] }] ``` ## Twitter/X Threads For longer content on X, create a thread: ```json "value": [ {"content": "Tweet 1/3: Introduction to the topic...", "image": []}, {"content": "Tweet 2/3: The main point explained...", "image": []}, {"content": "Tweet 3/3: Conclusion and call to action.", "image": []} ] ``` ## Managing Posts ### Delete Post ```bash curl -s -b /tmp/postiz-cookies.txt -X DELETE \ 'https://postiz.home.mykuhlmann.com/api/posts/POST_ID' ``` ### Update Schedule ```bash curl -s -b /tmp/postiz-cookies.txt -X PUT \ 'https://postiz.home.mykuhlmann.com/api/posts/POST_ID/date' \ -H 'Content-Type: application/json' \ -d '{"date": "2026-02-06T10:00:00Z"}' ``` ## Best Practices ### Avoid Duplicates 1. Always query existing posts before creating new ones 2. Use unique identifiers in content (dates, specific references) 3. Check both QUEUE and PUBLISHED states ### Scheduling - Space posts at least 2-4 hours apart per platform - Best times: 9 AM, 12 PM, 5 PM (audience timezone) - Avoid posting same content to all platforms simultaneously ### Content Adaptation Don't just truncate! Rewrite for each platform: - **X**: Hook + key insight + CTA - **LinkedIn**: Context + value + engagement question - **Bluesky**: Casual tech-friendly tone ## Helper Script Use `scripts/post.py` for easier posting with automatic character validation: ```bash # Single platform ~/.local/bin/uv run ~/clawd/skills/postiz/scripts/post.py \ --platform x \ --content "Your tweet here" \ --schedule "2026-02-05T15:00:00Z" # Multi-platform with different content ~/.local/bin/uv run ~/clawd/skills/postiz/scripts/post.py \ --x "Short X version" \ --linkedin "Longer LinkedIn version with more detail" \ --bluesky "Bluesky version" \ --schedule "2026-02-05T15:00:00Z" ``` ## Web UI Dashboard: https://postiz.home.mykuhlmann.com - Visual calendar view - Drag-and-drop scheduling - Analytics and engagement stats ## Troubleshooting ### 401 Unauthorized Re-run the login curl command to refresh cookie. ### Post Not Publishing 1. Check state is `QUEUE` not `DRAFT` 2. Verify date is in the future 3. Check integration is still connected in UI ### Duplicate Posts Always check existing posts before creating. The API doesn't deduplicate automatically.

path: scripts/check_duplicates.py
content: #!/usr/bin/env python3
"""
Check for duplicate content in Postiz posts.

Usage:
    uv run check_duplicates.py                    # Check last 30 days
    uv run check_duplicates.py --days 7           # Check last 7 days  
    uv run check_duplicates.py --content "text"   # Check if text would be duplicate
"""

import argparse
from datetime import datetime, timedelta, timezone
from collections import defaultdict
from difflib import SequenceMatcher

# /// script
# requires-python = ">=3.10"
# dependencies = ["requests"]
# ///

import requests

POSTIZ_URL = "https://postiz.home.mykuhlmann.com"
COOKIE_FILE = "/tmp/postiz-cookies.txt"
CREDENTIALS = {
    "email": "[REDACTED_SECRET]",
    "password": "Postiz2026!",
    "provider": "LOCAL"
}


def get_session():
    """Get authenticated session."""
    session = requests.Session()
    
    # Try to load existing cookies
    try:
        with open(COOKIE_FILE) as f:
            for line in f:
                if line.startswith('#') or not line.strip():
                    continue
                parts = line.strip().split('\t')
                if len(parts) >= 7:
                    session.cookies.set(parts[5], parts[6], domain=parts[0])
    except FileNotFoundError:
        pass
    
    # Check if auth works
    resp = session.get(f"{POSTIZ_URL}/api/integrations/list")
    if resp.status_code == 401:
        # Login
        resp = session.post(f"{POSTIZ_URL}/api/auth/login", json=CREDENTIALS)
        if resp.status_code != 200:
            raise Exception("Failed to authenticate")
        # Save cookies
        with open(COOKIE_FILE, 'w') as f:
            f.write("# Netscape HTTP Cookie File\n")
            for cookie in session.cookies:
                f.write(f"{cookie.domain}\tTRUE\t{cookie.path}\t"
                       f"{'TRUE' if cookie.secure else 'FALSE'}\t"
                       f"{cookie.expires or 0}\t{cookie.name}\t{cookie.value}\n")
    
    return session


def similarity(a: str, b: str) -> float:
    """Calculate string similarity ratio (0-1)."""
    return SequenceMatcher(None, a.lower(), b.lower()).ratio()


def main():
    parser = argparse.ArgumentParser(description="Check for duplicate posts in Postiz")
    parser.add_argument("--days", "-d", type=int, default=30, help="Days to look back (default: 30)")
    parser.add_argument("--content", "-c", help="Check if this content would be a duplicate")
    parser.add_argument("--threshold", "-t", type=float, default=0.8, 
                       help="Similarity threshold for duplicates (default: 0.8)")
    args = parser.parse_args()
    
    session = get_session()
    
    # Get posts
    end = datetime.now(timezone.utc)
    start = end - timedelta(days=args.days)
    
    resp = session.get(f"{POSTIZ_URL}/api/posts", params={
        "startDate": start.isoformat(),
        "endDate": end.isoformat()
    })
    
    if resp.status_code != 200:
        print(f"Error fetching posts: {resp.text}")
        return
    
    posts = resp.json().get("posts", [])
    print(f"Found {len(posts)} posts in last {args.days} days\n")
    
    if args.content:
        # Check if new content would be duplicate
        print(f"Checking if new content is a duplicate:")
        print(f"  \"{args.content[:60]}{'...' if len(args.content) > 60 else ''}\"\n")
        
        duplicates = []
        for post in posts:
            content = post.get("content", "")
            sim = similarity(args.content, content)
            if sim >= args.threshold:
                duplicates.append({
                    "similarity": sim,
                    "platform": post.get("integration", {}).get("providerIdentifier", "?"),
                    "state": post.get("state", "?"),
                    "date": post.get("publishDate", "?")[:10],
                    "content": content[:80]
                })
        
        if duplicates:
            print(f"⚠️  Found {len(duplicates)} potential duplicate(s):\n")
            for d in sorted(duplicates, key=lambda x: -x["similarity"]):
                print(f"  [{d['similarity']:.0%} similar] {d['date']} {d['platform']} ({d['state']})")
                print(f"    \"{d['content']}...\"\n")
        else:
            print("✓ No duplicates found - safe to post!")
        return
    
    # Find duplicates among existing posts
    by_platform = defaultdict(list)
    for post in posts:
        platform = post.get("integration", {}).get("providerIdentifier", "unknown")
        by_platform[platform].append(post)
    
    print("Checking for duplicates by platform:\n")
    
    total_dupes = 0
    for platform, platform_posts in by_platform.items():
        dupes = []
        for i, p1 in enumerate(platform_posts):
            for p2 in platform_posts[i+1:]:
                c1, c2 = p1.get("content", ""), p2.get("content", "")
                sim = similarity(c1, c2)
                if sim >= args.threshold:
                    dupes.append({
                        "similarity": sim,
                        "post1": {"id": p1["id"], "date": p1.get("publishDate", "?")[:10], 
                                 "state": p1.get("state"), "content": c1[:50]},
                        "post2": {"id": p2["id"], "date": p2.get("publishDate", "?")[:10],
                                 "state": p2.get("state"), "content": c2[:50]}
                    })
        
        if dupes:
            print(f"⚠️  {platform}: {len(dupes)} duplicate pair(s)")
            for d in sorted(dupes, key=lambda x: -x["similarity"])[:5]:
                print(f"    [{d['similarity']:.0%}] {d['post1']['date']} vs {d['post2']['date']}")
                print(f"      1: \"{d['post1']['content']}...\" ({d['post1']['state']})")
                print(f"      2: \"{d['post2']['content']}...\" ({d['post2']['state']})")
            total_dupes += len(dupes)
        else:
            print(f"✓ {platform}: No duplicates")
        print()
    
    if total_dupes == 0:
        print("✓ No duplicates found across all platforms!")
    else:
        print(f"⚠️  Total: {total_dupes} duplicate pair(s) found")
        print("\nTo delete duplicates, use the Postiz web UI or:")
        print("  curl -s -b /tmp/postiz-cookies.txt -X DELETE 'https://postiz.home.mykuhlmann.com/api/posts/POST_ID'")


if __name__ == "__main__":
    main()


path: scripts/post.py
content: #!/usr/bin/env python3
"""
Postiz Social Media Poster

Post to X/Twitter, LinkedIn, and Bluesky with automatic character validation.

Usage:
    # Single platform
    uv run post.py --platform x --content "Your tweet" --schedule "2026-02-05T15:00:00Z"
    
    # Multi-platform
    uv run post.py --x "Short tweet" --linkedin "Longer post" --schedule "2026-02-05T15:00:00Z"
    
    # Post now
    uv run post.py --platform x --content "Posting now!" --now
    
    # With image
    uv run post.py --platform x --content "Check this out!" --image /path/to/image.png --schedule "2026-02-05T15:00:00Z"
"""

import argparse
import json
import sys
from datetime import datetime, timezone
from pathlib import Path

# /// script
# requires-python = ">=3.10"
# dependencies = ["requests"]
# ///

import requests

# Configuration
POSTIZ_URL = "https://postiz.home.mykuhlmann.com"
COOKIE_FILE = "/tmp/postiz-cookies.txt"
CREDENTIALS = {
    "email": "[REDACTED_SECRET]",
    "password": "Postiz2026!",
    "provider": "LOCAL"
}

# Platform configurations
PLATFORMS = {
    "x": {
        "id": "cml5lbs3h0001o6l6gagj9gcq",
        "char_limit": 280,
        "type": "x",
        "name": "X/Twitter"
    },
    "linkedin": {
        "id": "cml5k1d710001s69hwekkhx1p",
        "char_limit": 3000,
        "type": "linkedin",
        "name": "LinkedIn"
    },
    "bluesky": {
        "id": "cml5mre6w0009o6l6svc718ej",
        "char_limit": 300,
        "type": "bluesky",
        "name": "Bluesky"
    }
}


class PostizClient:
    def __init__(self):
        self.session = requests.Session()
        self._load_cookies()
    
    def _load_cookies(self):
        """Load cookies from file if they exist."""
        cookie_path = Path(COOKIE_FILE)
        if cookie_path.exists():
            # Parse Netscape cookie file format
            with open(cookie_path) as f:
                for line in f:
                    if line.startswith('#') or not line.strip():
                        continue
                    parts = line.strip().split('\t')
                    if len(parts) >= 7:
                        self.session.cookies.set(parts[5], parts[6], domain=parts[0])
    
    def _save_cookies(self):
        """Save cookies to file in Netscape format."""
        with open(COOKIE_FILE, 'w') as f:
            f.write("# Netscape HTTP Cookie File\n")
            for cookie in self.session.cookies:
                f.write(f"{cookie.domain}\tTRUE\t{cookie.path}\t"
                       f"{'TRUE' if cookie.secure else 'FALSE'}\t"
                       f"{cookie.expires or 0}\t{cookie.name}\t{cookie.value}\n")
    
    def login(self):
        """Authenticate with Postiz."""
        resp = self.session.post(
            f"{POSTIZ_URL}/api/auth/login",
            json=CREDENTIALS
        )
        if resp.status_code == 200:
            self._save_cookies()
            return True
        return False
    
    def _ensure_auth(self):
        """Ensure we're authenticated, login if needed."""
        # Try a simple request to check auth
        resp = self.session.get(f"{POSTIZ_URL}/api/integrations/list")
        if resp.status_code == 401:
            if not self.login():
                raise Exception("Failed to authenticate with Postiz")
    
    def upload_media(self, file_path: str) -> dict:
        """Upload an image and return media info."""
        self._ensure_auth()
        with open(file_path, 'rb') as f:
            resp = self.session.post(
                f"{POSTIZ_URL}/api/media/upload-simple",
                files={"file": f}
            )
        if resp.status_code != 200:
            raise Exception(f"Failed to upload media: {resp.text}")
        return resp.json()
    
    def get_posts(self, start_date: str, end_date: str) -> list:
        """Get posts in date range for deduplication check."""
        self._ensure_auth()
        resp = self.session.get(
            f"{POSTIZ_URL}/api/posts",
            params={"startDate": start_date, "endDate": end_date}
        )
        if resp.status_code != 200:
            return []
        return resp.json().get("posts", [])
    
    def create_post(self, posts: list, schedule_date: str = None, post_type: str = "schedule") -> dict:
        """
        Create a post to one or more platforms.
        
        Args:
            posts: List of {platform, content, image} dicts
            schedule_date: ISO date string for scheduling (required for 'schedule' type)
            post_type: 'schedule', 'draft', or 'now'
        """
        self._ensure_auth()
        
        post_items = []
        for post in posts:
            platform = post["platform"]
            config = PLATFORMS[platform]
            
            # Validate character limit
            content = post["content"]
            if len(content) > config["char_limit"]:
                raise ValueError(
                    f"{config['name']} content exceeds {config['char_limit']} chars "
                    f"(got {len(content)}). Please shorten."
                )
            
            # Build post item
            value = [{"content": content, "image": post.get("image", [])}]
            post_items.append({
                "integration": {"id": config["id"]},
                "value": value,
                "settings": {"__type": config["type"]}
            })
        
        payload = {
            "type": post_type,
            "shortLink": False,
            "posts": post_items
        }
        
        if schedule_date and post_type == "schedule":
            payload["date"] = schedule_date
        elif post_type == "schedule" and not schedule_date:
            raise ValueError("Schedule date required for scheduled posts")
        
        resp = self.session.post(
            f"{POSTIZ_URL}/api/posts",
            json=payload
        )
        
        if resp.status_code not in (200, 201):
            raise Exception(f"Failed to create post: {resp.text}")
        
        return resp.json()


def validate_content(platform: str, content: str) -> tuple[bool, str]:
    """Validate content length for platform. Returns (is_valid, message)."""
    config = PLATFORMS.get(platform)
    if not config:
        return False, f"Unknown platform: {platform}"
    
    char_count = len(content)
    limit = config["char_limit"]
    
    if char_count > limit:
        return False, f"❌ {config['name']}: {char_count}/{limit} chars (over by {char_count - limit})"
    elif char_count > limit * 0.9:
        return True, f"⚠️  {config['name']}: {char_count}/{limit} chars (close to limit)"
    else:
        return True, f"✓ {config['name']}: {char_count}/{limit} chars"


def main():
    parser = argparse.ArgumentParser(description="Post to social media via Postiz")
    
    # Single platform mode
    parser.add_argument("--platform", "-p", choices=["x", "linkedin", "bluesky"],
                       help="Platform to post to (single platform mode)")
    parser.add_argument("--content", "-c", help="Post content (single platform mode)")
    
    # Multi-platform mode
    parser.add_argument("--x", help="Content for X/Twitter (280 chars max)")
    parser.add_argument("--linkedin", help="Content for LinkedIn (3000 chars max)")
    parser.add_argument("--bluesky", help="Content for Bluesky (300 chars max)")
    
    # Scheduling
    parser.add_argument("--schedule", "-s", help="Schedule date (ISO 8601, e.g. 2026-02-05T15:00:00Z)")
    parser.add_argument("--now", action="store_true", help="Post immediately")
    parser.add_argument("--draft", action="store_true", help="Save as draft (don't publish)")
    
    # Media
    parser.add_argument("--image", "-i", help="Path to image file to attach")
    
    # Utility
    parser.add_argument("--validate", "-v", action="store_true", 
                       help="Only validate content length, don't post")
    parser.add_argument("--list", "-l", help="List posts for date range (e.g. '7d' for last 7 days)")
    
    args = parser.parse_args()
    
    # List mode
    if args.list:
        client = PostizClient()
        days = int(args.list.rstrip('d'))
        from datetime import timedelta
        end = datetime.now(timezone.utc)
        start = end - timedelta(days=days)
        posts = client.get_posts(start.isoformat(), end.isoformat())
        for post in posts:
            platform = post.get("integration", {}).get("providerIdentifier", "?")
            state = post.get("state", "?")
            date = post.get("publishDate", "?")[:10]
            content = post.get("content", "")[:60]
            print(f"[{state:10}] {date} {platform:10} {content}...")
        return
    
    # Build posts list
    posts = []
    
    if args.platform and args.content:
        # Single platform mode
        posts.append({"platform": args.platform, "content": args.content})
    else:
        # Multi-platform mode
        if args.x:
            posts.append({"platform": "x", "content": args.x})
        if args.linkedin:
            posts.append({"platform": "linkedin", "content": args.linkedin})
        if args.bluesky:
            posts.append({"platform": "bluesky", "content": args.bluesky})
    
    if not posts:
        parser.print_help()
        print("\nError: No content provided. Use --platform/--content or --x/--linkedin/--bluesky")
        sys.exit(1)
    
    # Validate all content
    print("Content validation:")
    all_valid = True
    for post in posts:
        valid, msg = validate_content(post["platform"], post["content"])
        print(f"  {msg}")
        if not valid:
            all_valid = False
    
    if not all_valid:
        print("\n❌ Content validation failed. Please fix and retry.")
        sys.exit(1)
    
    if args.validate:
        print("\n✓ Validation passed (--validate mode, not posting)")
        return
    
    # Determine post type
    if args.now:
        post_type = "now"
    elif args.draft:
        post_type = "draft"
    else:
        post_type = "schedule"
        if not args.schedule:
            print("\nError: --schedule DATE required (or use --now / --draft)")
            sys.exit(1)
    
    # Handle image upload
    client = PostizClient()
    if args.image:
        print(f"\nUploading image: {args.image}")
        media = client.upload_media(args.image)
        image_data = [{"id": media.get("id"), "path": media.get("path")}]
        for post in posts:
            post["image"] = image_data
    
    # Create the post
    print(f"\nCreating {post_type} post...")
    try:
        result = client.create_post(posts, args.schedule, post_type)
        print(f"✓ Post created successfully!")
        if post_type == "schedule":
            print(f"  Scheduled for: {args.schedule}")
        elif post_type == "now":
            print(f"  Posted immediately!")
        else:
            print(f"  Saved as draft")
        
        # Show platforms
        platforms = [PLATFORMS[p["platform"]]["name"] for p in posts]
        print(f"  Platforms: {', '.join(platforms)}")
        
    except Exception as e:
        print(f"❌ Error: {e}")
        sys.exit(1)


if __name__ == "__main__":
    main()


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: suspicious | SkillSpector: suspicious (CRITICAL)