Skill: ppopen/podcast-manager (version 1.0.0)

--- name: podcast-manager description: Find, subscribe to, track, and summarize podcast episodes using public RSS feeds and lightweight local tracking files. Use when a user asks to add/manage podcast subscriptions, list new episodes, mark episodes as listened, catch up on a show, or get quick episode summaries/action items. --- # Podcast Manager Manage podcast workflows with predictable steps and low token overhead. ## Working Files Use these workspace files by default: - `memory/podcasts/subscriptions.json` — list of subscribed shows - `memory/podcasts/state.json` — per-show progress (last checked, listened episodes) - `memory/podcasts/notes.md` — summaries and action notes If files or parent directories do not exist, create them. ## Data Model Keep `subscriptions.json` as: ```json { "shows": [ { "title": "Example Show", "feedUrl": "https://example.com/feed.xml", "homepage": "https://example.com", "tags": ["ai", "business"] } ] } ``` Keep `state.json` as: ```json { "shows": { "https://example.com/feed.xml": { "lastChecked": "2026-03-12T20:00:00Z", "listenedGuids": [], "bookmarkedGuids": [] } } } ``` ## Core Workflow 1. Resolve the show and RSS feed URL. 2. Fetch and parse recent episodes from the feed. 3. Compare feed entries with local state. 4. Return a concise update (new/unheard episodes first). 5. Apply requested mutations (subscribe/unsubscribe/listened/bookmark/note). 6. Persist changes in the tracking files. ## Feed Discovery When feed URL is not provided: 1. Use `web_search` with query format: `"<podcast name> official rss feed"`. 2. Open top candidates with `web_fetch`. 3. Prefer canonical feed URLs from official sites or known podcast hosts. 4. Confirm before subscribing if multiple plausible feeds remain. ## Episode Retrieval Rules - Parse RSS/Atom entries by stable identifiers in this order: `guid`, `id`, `link`, then `title+pubDate` fallback. - Normalize publication time to ISO-8601 when possible. - Keep responses compact: newest 3–10 episodes unless user asks for more. - When audio URL exists, include it only when requested. ## Commands to Support (Intent-Level) Handle these intent families: - Subscribe: add show/feed to `subscriptions.json` - Unsubscribe: remove show/feed from `subscriptions.json` - Check updates: list unheard episodes since last check - Mark listened: add episode guid to `listenedGuids` - Bookmark: add episode guid to `bookmarkedGuids` - Catch up summary: summarize N latest unheard episodes - Show notes/action items: append concise bullets to `memory/podcasts/notes.md` ## Summary Style For episode summaries: - Use 3–6 bullets: main topic, key claims, practical takeaways. - Add a `Questions to revisit` bullet if uncertainty exists. - Avoid fabricating details; say when metadata is incomplete. ## Error Handling - If feed is unreachable, report status and keep existing state unchanged. - If parsing fails, return a short diagnostic and propose one fallback source. - On ambiguous show matches, ask one disambiguation question with 2–4 options. ## Safety and UX - Do not auto-subscribe/unsubscribe without explicit user intent. - Do not expose private local paths unless the user asks. - Prefer deterministic updates to local files over ephemeral memory.

path: README.md
content: # openclaw-skill-podcast-manager

Podcast management skill for OpenClaw.

## Contents
- `SKILL.md` — skill instructions and behavior
- `scripts/feed_probe.py` — lightweight RSS/Atom feed probe utility for validation
- `SECURITY_REVIEW.md` — DD-style security/privacy checklist and verdict

## Quick check
```bash
python3 scripts/feed_probe.py https://feeds.simplecast.com/54nAGcIl
```

Expected output: compact JSON with feed title, link, and recent episode metadata.


path: SECURITY_REVIEW.md
content: # DD Security/Privacy Review — podcast-manager

## Scope
- Skill instruction content (`SKILL.md`)
- Feed probe helper (`scripts/feed_probe.py`)

## Data handling
- Uses public RSS/Atom feeds by URL.
- Tracks only podcast metadata and local progress files.
- No API keys or credentials required.
- No external write actions.

## Risks
1. Malformed XML feeds could break parsing.
2. User-provided feed URLs could point to non-podcast content.
3. Metadata may include unexpected HTML/text.

## Mitigations
- Feed URLs must be HTTP/HTTPS and their hostnames resolve to public IP addresses; localhost, loopback, link-local, private, and IANA-reserved ranges are rejected after DNS resolution.
- HTTP responses are required to return a 2xx status and an XML/RSS/Atom-like Content-Type before any parsing occurs, and server-side HTTP errors are surfaced through structured failure messages.
- Downloads are streamed with a strict 5 MB limit and any overflow aborts the probe; suspicious XML constructs (DOCTYPE/ENTITY) are detected and rejected before parsing.
- XML parser errors and failed validations exit non-zero with sanitized JSON errors, and the skill outputs only the selected podcast metadata fields.
- Skill text forbids fabricating details and auto-subscribing without explicit intent.

## Residual risk
- Low: relies on external feed quality and availability.

## Verdict
- **PASS (Low risk)** for publication.


path: scripts/feed_probe.py
content: #!/usr/bin/env python3
import ipaddress
import json
import re
import socket
import sys
import urllib.error
import urllib.parse
import urllib.request
import xml.etree.ElementTree as ET

ALLOWED_SCHEMES = {"http", "https"}
MAX_RESPONSE_BYTES = 5 * 1024 * 1024
READ_CHUNK_SIZE = 8192
SUSPICIOUS_PATTERNS = [
    re.compile(rb"<!DOCTYPE", re.IGNORECASE),
    re.compile(rb"<!ENTITY", re.IGNORECASE),
]
CONTENT_TYPE_HINTS = ("xml", "rss", "atom")
USER_AGENT = "openclaw-skill-podcast-manager/1.0"


class FeedProbeError(Exception):
    pass


def fail(url, message, exit_code=1):
    print(json.dumps({"feedUrl": url, "error": message}, ensure_ascii=False))
    sys.exit(exit_code)


def text(node, tag):
    el = node.find(tag)
    return (el.text or "").strip() if el is not None and el.text else ""


def sanitize_url(url):
    parsed = urllib.parse.urlparse(url)
    scheme = (parsed.scheme or "").lower()
    if scheme not in ALLOWED_SCHEMES:
        raise FeedProbeError(f"scheme_not_allowed: {parsed.scheme or 'none'}")
    host = parsed.hostname
    if not host:
        raise FeedProbeError("missing_host")
    if host.lower() == "localhost":
        raise FeedProbeError("host_not_allowed: localhost")
    service = parsed.port or (443 if scheme == "https" else 80)
    ensure_safe_ip(host, service)
    return url


def ensure_safe_ip(host, service):
    try:
        infos = socket.getaddrinfo(host, service, family=socket.AF_UNSPEC, type=socket.SOCK_STREAM)
    except OSError as exc:
        raise FeedProbeError(f"dns_resolution_error: {exc}")
    seen = set()
    for info in infos:
        sockaddr = info[4]
        ip_str = sockaddr[0]
        if ip_str in seen:
            continue
        seen.add(ip_str)
        try:
            ip = ipaddress.ip_address(ip_str)
        except ValueError:
            raise FeedProbeError(f"invalid_ip_address: {ip_str}")
        if (
            ip.is_loopback
            or ip.is_link_local
            or ip.is_private
            or ip.is_reserved
            or ip.is_unspecified
            or (hasattr(ip, "is_multicast") and ip.is_multicast)
        ):
            raise FeedProbeError(f"blocked_ip_range: {ip_str}")


def validate_http_response(resp):
    status = getattr(resp, "status", None)
    if status is None or not (200 <= status < 300):
        raise FeedProbeError(f"http_status_error: {status}")
    content_type = resp.getheader("Content-Type", "")
    normalized = content_type.lower()
    if not any(hint in normalized for hint in CONTENT_TYPE_HINTS):
        raise FeedProbeError(f"unexpected_content_type: {content_type or 'none'}")


def read_response_with_limit(resp):
    buffer = bytearray()
    total = 0
    while True:
        chunk = resp.read(READ_CHUNK_SIZE)
        if not chunk:
            break
        if total + len(chunk) > MAX_RESPONSE_BYTES:
            raise FeedProbeError("max_response_size_exceeded")
        buffer.extend(chunk)
        total += len(chunk)
    return bytes(buffer)


def check_suspicious_xml(data):
    for pattern in SUSPICIOUS_PATTERNS:
        if pattern.search(data):
            raise FeedProbeError("suspicious_xml_pattern_detected")


def fetch_feed(url):
    sanitized = sanitize_url(url)
    req = urllib.request.Request(sanitized, headers={"User-Agent": USER_AGENT})
    try:
        with urllib.request.urlopen(req, timeout=15) as resp:
            validate_http_response(resp)
            return read_response_with_limit(resp)
    except urllib.error.HTTPError as exc:
        raise FeedProbeError(f"http_status_error: {exc.code}")
    except urllib.error.URLError as exc:
        raise FeedProbeError(f"network_error: {exc.reason}")
    except socket.timeout as exc:
        raise FeedProbeError(f"network_timeout: {exc}")


def main():
    if len(sys.argv) != 2:
        print("usage: feed_probe.py <feed-url>", file=sys.stderr)
        sys.exit(2)

    url = sys.argv[1]
    try:
        data = fetch_feed(url)
        check_suspicious_xml(data)
    except FeedProbeError as exc:
        fail(url, str(exc))
    except ET.ParseError as exc:
        fail(url, f"xml_parse_error: {exc}")

    try:
        root = ET.fromstring(data)
    except ET.ParseError as exc:
        fail(url, f"xml_parse_error: {exc}")

    out = {"feedUrl": url, "title": "", "link": "", "episodes": []}

    channel = root.find("channel")
    if channel is not None:
        out["title"] = text(channel, "title")
        out["link"] = text(channel, "link")
        for item in channel.findall("item")[:5]:
            out["episodes"].append(
                {
                    "title": text(item, "title"),
                    "guid": text(item, "guid") or text(item, "link"),
                    "pubDate": text(item, "pubDate"),
                    "link": text(item, "link"),
                }
            )
    else:
        ns = {"a": "http://www.w3.org/2005/Atom"}
        out["title"] = (root.findtext("a:title", default="", namespaces=ns) or "").strip()
        link_el = root.find("a:link", ns)
        out["link"] = link_el.attrib.get("href", "") if link_el is not None else ""
        for entry in root.findall("a:entry", ns)[:5]:
            link_el = entry.find("a:link", ns)
            out["episodes"].append(
                {
                    "title": (entry.findtext("a:title", default="", namespaces=ns) or "").strip(),
                    "guid": (entry.findtext("a:id", default="", namespaces=ns) or "").strip(),
                    "pubDate": (entry.findtext("a:updated", default="", namespaces=ns) or "").strip(),
                    "link": link_el.attrib.get("href", "") if link_el is not None else "",
                }
            )

    print(json.dumps(out, ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()


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