@clawhub-bearly-hodling-f41d2e9529
Automatically review X/Twitter bookmarks for useful tools, projects, repos, products, and ideas. Fetches via xurl, analyses for value, and outputs an actiona...
---
name: x-bookmarks-digest
description: Automatically review X/Twitter bookmarks for useful tools, projects, repos, products, and ideas. Fetches via xurl, analyses for value, and outputs an actionable digest with proposed next steps — including clawhub installs or new skill scaffolding.
version: 1.0.0
author: openclaw
triggers:
- "digest x bookmarks"
- "check my bookmarks"
- "x bookmarks"
- "twitter bookmarks"
- "bookmark digest"
- "review bookmarks"
- "bookmarks digest"
metadata:
openclaw:
emoji: "🔖"
version: "1.0.0"
author: "openclaw"
requires:
bins: ["python3", "xurl"]
---
# X Bookmarks Digest
Fetch, analyse, and digest your X/Twitter bookmarks into actionable insights.
## When to Use
Activate this skill when the user says anything like:
- "digest x bookmarks"
- "check my bookmarks"
- "review my x bookmarks"
- "what's interesting in my bookmarks?"
- "bookmark digest"
- "any good stuff in my twitter bookmarks?"
## Prerequisites Check
Before running the workflow, verify xurl authentication:
```bash
xurl whoami
```
**If 401/Unauthorized:**
Tell the user to set up xurl authentication:
```
xurl auth apps add <app-name> --client-id <id> --client-secret <secret>
```
Then run `xurl auth default <app-name>` to set it as default.
Do NOT proceed until auth works. Stop and report the issue.
## Workflow — Step by Step
### Step 1: Check Rate Limit
Read the state file to check when the last run was:
```bash
cat {baseDir}/state.json 2>/dev/null || echo '{"last_bookmark_id": null, "last_run_ts": null, "processed_count": 0}'
```
If `last_run_ts` is less than 1 hour ago, warn the user:
> "Last digest was run at {time}. Free tier allows max 1 run/hour. Use --force to override."
Only proceed if:
- No previous run exists, OR
- More than 1 hour has elapsed, OR
- User explicitly says to force/override
### Step 2: Fetch Bookmarks
Run the fetch script to get new bookmarks:
```bash
python3 {baseDir}/scripts/fetch_bookmarks.py --count 50
```
Options:
- `--count N` — number of bookmarks to fetch (default 50, max 100)
- `--force` — skip rate limit check
- `--all` — fetch all (ignore last-checked ID, reprocess everything)
**Output:** JSON array of bookmark objects to stdout.
**Side effect:** Updates `{baseDir}/state.json` with new watermark.
If the output is empty or `[]`, report: "No new bookmarks since last check."
### Step 3: Analyse Bookmarks
Pipe the fetched bookmarks through the analyser:
```bash
python3 {baseDir}/scripts/fetch_bookmarks.py --count 50 | python3 {baseDir}/scripts/analyse_bookmarks.py
```
Or if you saved fetch output to a variable, pass it via file:
```bash
python3 {baseDir}/scripts/analyse_bookmarks.py --file /tmp/bookmarks.json
```
**Output:** Structured JSON with categories and relevance scores:
```json
{
"summary": {"total": 50, "new": 12, "high": 4, "medium": 5, "low": 3},
"bookmarks": [
{
"id": "123",
"text": "...",
"author": "@user",
"category": "tool",
"relevance": 5,
"urls": ["https://github.com/..."],
"github_repos": ["user/repo"],
"keywords": ["python", "cli"]
}
]
}
```
### Step 4: Generate Digest
Using the structured analysis output, write a digest following this format:
```markdown
# X Bookmarks Digest — {date}
## Summary
- {total} bookmarks checked, {new} new since last run
- {high} high-value, {medium} medium, {low} low
## High Value (relevance 4-5)
### [{category}] {title or key topic}
@{author}: "{first 100 chars of text}..."
- URL: {extracted url}
- Why: {1-line explanation of value}
- Action: {specific next step}
## Medium Value (relevance 3)
{same format, briefer}
## Proposed Actions
1. [ ] {action 1}
2. [ ] {action 2}
...
```
### Step 5: Decide on Actions
For each high-value bookmark, decide:
| Bookmark Type | Action |
|--------------|--------|
| **GitHub repo / tool** | Propose `git clone` or `brew install` |
| **Clawhub-compatible skill** | Propose `clawhub install <slug>` |
| **Interesting project to build** | Propose scaffolding a new skill in `skills/` |
| **Useful article/thread** | Propose saving to Obsidian vault |
| **Tip/technique** | Propose saving to OpenClaw memory |
Ask the user which actions to execute. Do not auto-execute without confirmation.
### Step 6: Update State
After successful digest, verify state was updated:
```bash
cat {baseDir}/state.json
```
Should show updated `last_bookmark_id` and `last_run_ts`.
## Error Handling
| Problem | Action |
|---------|--------|
| **xurl not found** | Tell user: `brew install xurl` |
| **xurl 401** | Guide user through `xurl auth apps add` setup |
| **xurl 429 (rate limit)** | Report rate limit hit. Suggest waiting 15 mins. |
| **Empty bookmarks** | Report "No bookmarks found" — user may need to bookmark posts first |
| **No new bookmarks** | Report "No new bookmarks since {last_run_ts}" |
| **state.json missing** | First run — create fresh state after fetch |
| **Python error** | Print stderr, check Python 3.10+ installed |
## Test Commands
Quick test (dry run, no state update):
```bash
# Test xurl auth
xurl whoami
# Test fetch (small batch)
python3 {baseDir}/scripts/fetch_bookmarks.py --count 5 --force
# Test analyse (with sample data)
echo '[{"id":"1","text":"Check out this amazing CLI tool https://github.com/user/repo","author_username":"devuser","created_at":"2026-03-19T10:00:00Z"}]' | python3 {baseDir}/scripts/analyse_bookmarks.py
# Full pipeline test
python3 {baseDir}/scripts/fetch_bookmarks.py --count 10 --force | python3 {baseDir}/scripts/analyse_bookmarks.py
```
Or just say: **"digest x bookmarks"** to run the full workflow.
## Configuration
All config is in `{baseDir}/state.json`:
- `last_bookmark_id` — watermark for incremental fetches
- `last_run_ts` — rate limit enforcement
- `processed_count` — running total of processed bookmarks
No additional configuration files needed. xurl manages its own auth.
FILE:ARCHITECTURE.md
# X Bookmarks Digest — Architecture & Implementation Plan
## 1. Purpose
Automatically review X/Twitter bookmarks for useful insights, tools, projects,
repos, products, and ideas. Produce a clean, actionable digest with proposed
next actions — including auto-installing promising skills via clawhub or
scaffolding new ones.
## 2. System Context
```
User trigger ("digest x bookmarks" / "check my bookmarks" / cron)
│
▼
┌─────────────────────────────────────────────────────┐
│ Claude Agent (OpenClaw) │
│ │
│ 1. Trigger match → load SKILL.md │
│ 2. Run fetch_bookmarks.py → raw bookmark JSON │
│ 3. Agent analyses bookmarks (LLM-native analysis) │
│ 4. Output digest + proposed actions │
│ 5. Update state (last-checked bookmark ID + ts) │
└──────────────┬──────────────────────────────────────┘
│ xurl CLI (authenticated X API)
▼
┌─────────────────────────────────────────────────────┐
│ X/Twitter API v2 (via xurl) │
│ │
│ GET /2/users/:id/bookmarks │
│ Fields: text, author, urls, created_at, metrics │
└─────────────────────────────────────────────────────┘
```
## 3. Components
### 3.1 SKILL.md (Agent instruction manual)
The primary artifact. Contains:
- Frontmatter (name, description, triggers, metadata)
- Step-by-step workflow the agent follows
- Analysis criteria for categorising bookmarks
- Output format specification
- Error handling table
### 3.2 scripts/fetch_bookmarks.py
Fetches bookmarks via the `xurl` CLI and manages state.
**Responsibilities:**
- Call `xurl bookmarks -n <count>` to get latest bookmarks
- Load last-checked state from `state.json`
- Filter out already-processed bookmarks (by ID comparison)
- Output new bookmarks as JSON to stdout
- Update `state.json` with newest bookmark ID + timestamp
- Enforce rate limit (1 run per hour minimum)
**State file** (`state.json`):
```json
{
"last_bookmark_id": "1234567890",
"last_run_ts": "2026-03-19T12:00:00Z",
"processed_count": 150
}
```
### 3.3 scripts/analyse_bookmarks.py
Lightweight categorisation and scoring of bookmark content.
**Responsibilities:**
- Read bookmark JSON from stdin or file
- Categorise each bookmark:
- `tool` — CLI tools, libraries, frameworks, SDKs
- `project` — GitHub repos, open-source projects
- `product` — SaaS, apps, services, startups
- `insight` — Tips, techniques, architectural patterns
- `resource` — Articles, papers, tutorials, threads
- `other` — Doesn't fit above categories
- Score relevance (1-5) based on keyword matching + heuristics
- Extract URLs, GitHub links, product names
- Output structured JSON with categories and scores
**Note:** The heavy analysis (deciding what's actionable, writing summaries,
proposing next steps) is done by the agent itself after reading the structured
output. This script just pre-processes and categorises.
### 3.4 scripts/digest_runner.sh
Orchestrator script that chains fetch → analyse → output.
```bash
#!/bin/bash
# Usage: digest_runner.sh [--count N] [--force]
# --count: number of bookmarks to fetch (default 50)
# --force: skip rate limit check
```
### 3.5 references/analysis_criteria.md
Reference document for the agent describing:
- What makes a bookmark "actionable"
- Category definitions with examples
- How to decide: install via clawhub vs build new skill vs just note it
- Output format template
## 4. Authentication
```
xurl handles all X API authentication internally.
No tokens are stored or handled by this skill.
Setup check:
1. Run: xurl whoami
2. If 401 → guide user through: xurl auth apps add
3. If success → proceed with bookmarks fetch
```
## 5. Rate Limiting Strategy
X API Free Tier constraints:
- Bookmarks endpoint: limited reads per month
- Safe cadence: max 1 run per hour, recommended 1-2x daily
Implementation:
- `state.json` stores `last_run_ts`
- `fetch_bookmarks.py` checks elapsed time before API call
- `--force` flag bypasses for manual runs
- SKILL.md instructs agent to check before running
## 6. Analysis Workflow
```
Raw bookmarks (JSON from xurl)
│
▼
analyse_bookmarks.py (categorise + score)
│
▼
Structured JSON with categories
│
▼
Agent (Claude) reads structured data and:
1. Writes human-readable summaries for high-value items
2. Identifies GitHub repos → proposes cloning or starring
3. Identifies tools/skills → proposes clawhub install or new skill creation
4. Identifies insights → proposes saving to memory or Obsidian
5. Formats final digest output
```
## 7. Output Format
```markdown
# X Bookmarks Digest — 2026-03-19
## Summary
- 47 bookmarks checked, 12 new since last run
- 4 high-value items, 5 medium, 3 low
## High Value
### [Tool] uv — Fast Python package manager
@astaborist: "uv is replacing pip for me..."
- URL: https://github.com/astral-sh/uv
- Action: Install via `pip install uv` or `brew install uv`
### [Project] localllm — Run LLMs locally with 4-bit quantisation
@dev_username: "Just shipped v2.0..."
- URL: https://github.com/user/localllm
- Action: Clone and evaluate for OpenClaw integration
## Medium Value
...
## Proposed Actions
1. [ ] Install uv (`brew install uv`)
2. [ ] Clone localllm for evaluation
3. [ ] Save "React Server Components" thread to Obsidian
4. [ ] Check clawhub for "pdf-ocr" skill mentioned in bookmark #5
```
## 8. File Layout
```
skills/x-bookmarks-digest/
├── SKILL.md # Agent instruction manual
├── ARCHITECTURE.md # This document
├── _meta.json # ClawHub package metadata
├── scripts/
│ ├── fetch_bookmarks.py # Fetch + dedupe + state management
│ ├── analyse_bookmarks.py # Categorise + score bookmarks
│ └── digest_runner.sh # Orchestrator script
├── references/
│ └── analysis_criteria.md # Category definitions + scoring guide
├── state.json # Runtime state (last ID, timestamp)
└── .clawhub/
└── package.json # ClawHub registry metadata
```
## 9. Dependencies
| Dependency | Purpose | Install |
|-----------|---------|---------|
| xurl | Authenticated X API access | `brew install xurl` (already installed) |
| Python 3.10+ | Script runtime | Pre-installed |
| jq (optional) | JSON processing in shell | `brew install jq` |
## 10. Security
- No X API tokens stored or handled by this skill — xurl manages auth
- No bookmark content persisted beyond state.json (just IDs)
- Rate limiting prevents accidental API quota exhaustion
- Digest output stays local (not posted anywhere)
FILE:_meta.json
{
"slug": "x-bookmarks-digest",
"version": "1.0.0",
"publishedAt": null
}
FILE:references/analysis_criteria.md
# Bookmark Analysis Criteria
## Categories
| Category | What It Means | Examples |
|----------|--------------|---------|
| **tool** | CLI tools, libraries, frameworks, SDKs, packages | "Just released a new Python CLI for...", npm/pip/brew packages |
| **project** | GitHub repos, open-source projects, shipped code | Links to github.com repos, "just shipped v2.0" |
| **product** | SaaS apps, services, startups, commercial tools | "Launched our new platform...", pricing pages, beta invites |
| **insight** | Tips, techniques, architectural patterns, lessons | "Here's what I learned...", design patterns, threads with advice |
| **resource** | Articles, papers, tutorials, guides, videos | Blog posts, arxiv papers, YouTube talks, documentation |
| **other** | Doesn't fit above — memes, opinions, personal updates | Hot takes, jokes, personal announcements |
## Relevance Scoring (1-5)
| Score | Meaning | Typical Signals |
|-------|---------|----------------|
| **5** | Must-act — directly useful for current projects | Stack-relevant tool/project + GitHub link + matches active work |
| **4** | High value — worth investigating or installing | GitHub repo + relevant domain + actionable content |
| **3** | Medium — interesting but not urgent | Good insight or resource, loosely related to stack |
| **2** | Low — mildly interesting | General tech content, tangentially related |
| **1** | Noise — skip in digest | Memes, off-topic, duplicate of known tool |
## Action Decision Matrix
| Category + Score | Recommended Action |
|-----------------|-------------------|
| tool (4-5) | Install it (`brew install`, `pip install`, `cargo install`) |
| project (4-5) | Clone repo, evaluate, consider integrating |
| product (4-5) | Sign up / bookmark for trial |
| insight (4-5) | Save to Obsidian vault or OpenClaw memory |
| resource (4-5) | Save link to Obsidian, read later queue |
| Any (3) | Mention in digest, let user decide |
| Any (1-2) | Skip or brief mention only |
## Stack-Relevance Keywords
These keywords boost relevance because they match the user's tech stack:
Python, TypeScript, Swift, FastAPI, React, PostgreSQL, Docker,
Cloudflare, LLM, AI, Agent, Automation, MCP, Claude, Anthropic,
OpenAI, GPT, Embedding, RAG, Vector, DuckDB, SQLite, Hetzner, Caddy
## Clawhub Install Criteria
A bookmark qualifies for "install via clawhub" if:
1. It references an agent skill, Claude tool, or MCP server
2. It has a clawhub slug or is listed on clawhub.ai
3. It's a standalone automation that fits the OpenClaw skill model
Otherwise, if it's a promising automation idea but not packaged as a skill,
propose scaffolding it as a new skill under `skills/`.
FILE:scripts/analyse_bookmarks.py
#!/usr/bin/env python3
"""Categorise and score X bookmarks for digest generation."""
import argparse
import json
import re
import sys
from urllib.parse import urlparse
# Category keywords — matched against bookmark text (case-insensitive)
CATEGORY_SIGNALS = {
"tool": [
"cli", "tool", "utility", "command-line", "terminal", "sdk",
"library", "framework", "package", "npm", "pip", "brew",
"cargo", "gem", "crate", "binary", "executable",
],
"project": [
"github.com", "gitlab.com", "repo", "repository", "open source",
"open-source", "oss", "just shipped", "just released", "v1", "v2",
"launched", "release", "source code",
],
"product": [
"saas", "app", "platform", "startup", "launched", "product",
"pricing", "beta", "waitlist", "sign up", "try it",
"subscription", "freemium",
],
"insight": [
"tip", "trick", "pattern", "architecture", "design", "approach",
"technique", "best practice", "lesson", "learned", "mistake",
"how i", "how we", "thread", "here's what",
],
"resource": [
"article", "blog", "tutorial", "guide", "paper", "course",
"video", "talk", "presentation", "documentation", "docs",
"cheatsheet", "cheat sheet", "book",
],
}
# High-value domain signals (boost relevance)
HIGH_VALUE_DOMAINS = [
"github.com", "gitlab.com", "arxiv.org", "huggingface.co",
"news.ycombinator.com",
]
# Tech keywords that boost relevance for this user's stack
STACK_KEYWORDS = [
"python", "typescript", "swift", "fastapi", "react", "postgresql",
"docker", "cloudflare", "llm", "ai", "agent", "automation",
"mcp", "claude", "anthropic", "openai", "gpt", "embedding",
"rag", "vector", "duckdb", "sqlite", "hetzner", "caddy",
]
def extract_urls(text: str) -> list[str]:
"""Extract all URLs from text."""
return re.findall(r'https?://[^\s<>"\')\]]+', text)
def extract_github_repos(urls: list[str]) -> list[str]:
"""Extract GitHub user/repo paths from URLs."""
repos = []
for url in urls:
parsed = urlparse(url)
if parsed.hostname in ("github.com", "www.github.com"):
parts = parsed.path.strip("/").split("/")
if len(parts) >= 2:
repo = f"{parts[0]}/{parts[1]}"
if repo not in repos:
repos.append(repo)
return repos
def categorise(text: str, urls: list[str]) -> str:
"""Determine the primary category of a bookmark."""
text_lower = text.lower()
full_text = text_lower + " " + " ".join(urls).lower()
scores = {}
for category, keywords in CATEGORY_SIGNALS.items():
score = sum(1 for kw in keywords if kw in full_text)
if score > 0:
scores[category] = score
if not scores:
return "other"
return max(scores, key=scores.get)
def score_relevance(text: str, urls: list[str], category: str) -> int:
"""Score relevance 1-5 based on signals."""
text_lower = text.lower()
score = 2 # base score
# GitHub repos boost
github_repos = extract_github_repos(urls)
if github_repos:
score += 1
# High-value domains
for url in urls:
parsed = urlparse(url)
if parsed.hostname and any(d in parsed.hostname for d in HIGH_VALUE_DOMAINS):
score += 1
break
# Stack-relevant keywords
stack_hits = sum(1 for kw in STACK_KEYWORDS if kw in text_lower)
if stack_hits >= 2:
score += 1
elif stack_hits >= 1:
score += 0 # mild interest, no boost
# Tool/project categories inherently more actionable
if category in ("tool", "project"):
score += 1
return min(5, max(1, score))
def analyse_bookmark(bookmark: dict) -> dict:
"""Analyse a single bookmark and return enriched data."""
text = bookmark.get("text", "")
author = bookmark.get("author_username", bookmark.get("username", "unknown"))
bm_id = bookmark.get("id", "")
created = bookmark.get("created_at", "")
# Handle nested author object (X API v2 format with expansions)
if isinstance(bookmark.get("author"), dict):
author = bookmark["author"].get("username", author)
urls = extract_urls(text)
# Also check entities.urls if present (X API v2)
entities = bookmark.get("entities", {})
if isinstance(entities, dict):
for url_entity in entities.get("urls", []):
expanded = url_entity.get("expanded_url", "")
if expanded and expanded not in urls:
urls.append(expanded)
github_repos = extract_github_repos(urls)
category = categorise(text, urls)
relevance = score_relevance(text, urls, category)
return {
"id": bm_id,
"text": text[:500], # Truncate very long tweets
"author": f"@{author}" if not author.startswith("@") else author,
"created_at": created,
"category": category,
"relevance": relevance,
"urls": urls,
"github_repos": github_repos,
"keywords": extract_keywords(text),
}
def extract_keywords(text: str) -> list[str]:
"""Extract relevant tech keywords found in text."""
text_lower = text.lower()
return [kw for kw in STACK_KEYWORDS if kw in text_lower]
def main():
parser = argparse.ArgumentParser(description="Analyse X bookmarks")
parser.add_argument("--file", help="Read bookmarks from file instead of stdin")
parser.add_argument("--min-relevance", type=int, default=1, help="Minimum relevance score to include (1-5)")
args = parser.parse_args()
if args.file:
with open(args.file) as f:
bookmarks = json.load(f)
else:
raw = sys.stdin.read()
if not raw.strip():
print(json.dumps({"summary": {"total": 0, "new": 0, "high": 0, "medium": 0, "low": 0}, "bookmarks": []}))
return
bookmarks = json.loads(raw)
if not isinstance(bookmarks, list):
bookmarks = [bookmarks]
analysed = []
for bm in bookmarks:
result = analyse_bookmark(bm)
if result["relevance"] >= args.min_relevance:
analysed.append(result)
# Sort by relevance descending
analysed.sort(key=lambda x: x["relevance"], reverse=True)
high = sum(1 for b in analysed if b["relevance"] >= 4)
medium = sum(1 for b in analysed if b["relevance"] == 3)
low = sum(1 for b in analysed if b["relevance"] <= 2)
output = {
"summary": {
"total": len(bookmarks),
"new": len(analysed),
"high": high,
"medium": medium,
"low": low,
},
"bookmarks": analysed,
}
print(json.dumps(output, indent=2))
if __name__ == "__main__":
main()
FILE:scripts/digest_runner.sh
#!/usr/bin/env bash
# X Bookmarks Digest — Orchestrator
# Usage: digest_runner.sh [--count N] [--force] [--all] [--dry-run]
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
BASE_DIR="$(dirname "$SCRIPT_DIR")"
PYTHON="python3"
COUNT=50
FORCE=""
ALL=""
DRY_RUN=""
while [[ $# -gt 0 ]]; do
case "$1" in
--count) COUNT="$2"; shift 2 ;;
--force) FORCE="--force"; shift ;;
--all) ALL="--all"; shift ;;
--dry-run) DRY_RUN="--dry-run"; shift ;;
*) echo "Unknown option: $1" >&2; exit 1 ;;
esac
done
# Step 1: Check xurl auth
echo "--- Checking xurl authentication..." >&2
if ! xurl whoami >/dev/null 2>&1; then
echo "ERROR: xurl auth failed. Run: xurl auth apps add <app> --client-id <id> --client-secret <secret>" >&2
exit 1
fi
echo "--- Auth OK" >&2
# Step 2: Fetch bookmarks
echo "--- Fetching bookmarks (count=$COUNT)..." >&2
BOOKMARKS=$($PYTHON "$SCRIPT_DIR/fetch_bookmarks.py" --count "$COUNT" $FORCE $ALL $DRY_RUN 2>&1)
FETCH_EXIT=$?
if [[ $FETCH_EXIT -ne 0 ]]; then
echo "ERROR: Fetch failed:" >&2
echo "$BOOKMARKS" >&2
exit $FETCH_EXIT
fi
# Check if empty
if [[ "$BOOKMARKS" == "[]" ]] || [[ -z "$BOOKMARKS" ]]; then
echo "No new bookmarks since last check." >&2
exit 0
fi
# Step 3: Analyse
echo "--- Analysing bookmarks..." >&2
ANALYSIS=$(echo "$BOOKMARKS" | $PYTHON "$SCRIPT_DIR/analyse_bookmarks.py")
# Step 4: Output
echo "$ANALYSIS"
# Summary to stderr
TOTAL=$(echo "$ANALYSIS" | $PYTHON -c "import sys,json; d=json.load(sys.stdin); print(d['summary']['total'])")
HIGH=$(echo "$ANALYSIS" | $PYTHON -c "import sys,json; d=json.load(sys.stdin); print(d['summary']['high'])")
MEDIUM=$(echo "$ANALYSIS" | $PYTHON -c "import sys,json; d=json.load(sys.stdin); print(d['summary']['medium'])")
echo "--- Done: $TOTAL bookmarks analysed ($HIGH high-value, $MEDIUM medium)" >&2
FILE:scripts/fetch_bookmarks.py
#!/usr/bin/env python3
"""Fetch X/Twitter bookmarks via xurl CLI with state management."""
import argparse
import json
import os
import subprocess
import sys
from datetime import datetime, timezone
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
BASE_DIR = os.path.dirname(SCRIPT_DIR)
STATE_FILE = os.path.join(BASE_DIR, "state.json")
MIN_INTERVAL_SECS = 3600 # 1 hour
def load_state() -> dict:
"""Load state from state.json, return defaults if missing."""
if os.path.exists(STATE_FILE):
with open(STATE_FILE) as f:
return json.load(f)
return {"last_bookmark_id": None, "last_run_ts": None, "processed_count": 0}
def save_state(state: dict):
"""Persist state to state.json."""
with open(STATE_FILE, "w") as f:
json.dump(state, f, indent=2)
def check_rate_limit(state: dict, force: bool) -> bool:
"""Return True if allowed to run, False if rate-limited."""
if force or state["last_run_ts"] is None:
return True
last_run = datetime.fromisoformat(state["last_run_ts"])
elapsed = (datetime.now(timezone.utc) - last_run).total_seconds()
if elapsed < MIN_INTERVAL_SECS:
remaining = int((MIN_INTERVAL_SECS - elapsed) / 60)
print(
json.dumps({
"error": "rate_limited",
"message": f"Last run was {int(elapsed / 60)} mins ago. Wait {remaining} more mins or use --force.",
"last_run": state["last_run_ts"],
}),
file=sys.stderr,
)
return False
return True
def fetch_bookmarks(count: int) -> list:
"""Call xurl bookmarks and return parsed JSON list."""
try:
result = subprocess.run(
["xurl", "bookmarks", "-n", str(count)],
capture_output=True,
text=True,
timeout=30,
)
except FileNotFoundError:
print(json.dumps({"error": "xurl_not_found", "message": "xurl not installed. Run: brew install xurl"}), file=sys.stderr)
sys.exit(1)
except subprocess.TimeoutExpired:
print(json.dumps({"error": "timeout", "message": "xurl timed out after 30s"}), file=sys.stderr)
sys.exit(1)
if result.returncode != 0:
stderr = result.stderr.strip()
# Try to parse as JSON for structured error
try:
err = json.loads(stderr)
status = err.get("status", result.returncode)
except (json.JSONDecodeError, TypeError):
status = result.returncode
err = {"detail": stderr}
if status == 401:
print(json.dumps({
"error": "unauthorized",
"message": "xurl auth failed. Run: xurl auth apps add <app> --client-id <id> --client-secret <secret>",
}), file=sys.stderr)
elif status == 429:
print(json.dumps({
"error": "rate_limited_api",
"message": "X API rate limit hit. Wait 15 minutes.",
}), file=sys.stderr)
else:
print(json.dumps({
"error": "xurl_error",
"message": f"xurl failed (exit {result.returncode}): {err.get('detail', stderr)}",
}), file=sys.stderr)
sys.exit(1)
# Parse xurl output
raw = result.stdout.strip()
if not raw:
return []
try:
data = json.loads(raw)
except json.JSONDecodeError:
print(json.dumps({"error": "parse_error", "message": "Could not parse xurl output as JSON"}), file=sys.stderr)
sys.exit(1)
# xurl bookmarks returns {"data": [...], "meta": {...}} or just a list
if isinstance(data, dict):
bookmarks = data.get("data", [])
elif isinstance(data, list):
bookmarks = data
else:
bookmarks = []
return bookmarks
def filter_new_bookmarks(bookmarks: list, last_id: str | None, fetch_all: bool) -> list:
"""Filter out already-processed bookmarks."""
if fetch_all or last_id is None:
return bookmarks
new = []
for bm in bookmarks:
bm_id = bm.get("id", "")
if bm_id == last_id:
break
new.append(bm)
return new
def main():
parser = argparse.ArgumentParser(description="Fetch X bookmarks via xurl")
parser.add_argument("--count", type=int, default=50, help="Number of bookmarks to fetch (1-100)")
parser.add_argument("--force", action="store_true", help="Skip rate limit check")
parser.add_argument("--all", action="store_true", help="Reprocess all bookmarks (ignore last-checked ID)")
parser.add_argument("--dry-run", action="store_true", help="Fetch but don't update state")
args = parser.parse_args()
args.count = max(1, min(100, args.count))
state = load_state()
if not check_rate_limit(state, args.force):
sys.exit(2)
bookmarks = fetch_bookmarks(args.count)
if not bookmarks:
print(json.dumps([]))
if not args.dry_run:
state["last_run_ts"] = datetime.now(timezone.utc).isoformat()
save_state(state)
return
new_bookmarks = filter_new_bookmarks(bookmarks, state["last_bookmark_id"], args.all)
# Output new bookmarks as JSON
print(json.dumps(new_bookmarks, indent=2))
# Update state (unless dry run)
if not args.dry_run and new_bookmarks:
newest_id = bookmarks[0].get("id") # First bookmark is newest
if newest_id:
state["last_bookmark_id"] = newest_id
state["last_run_ts"] = datetime.now(timezone.utc).isoformat()
state["processed_count"] = state.get("processed_count", 0) + len(new_bookmarks)
save_state(state)
if __name__ == "__main__":
main()
FILE:state.json
{
"last_bookmark_id": null,
"last_run_ts": null,
"processed_count": 0
}
Stremio automation via browser + Torrentio on Mac Mini. Searches for shows/movies, selects highest-seeded streams, and plays them. Use when user wants to wat...
--- name: stremio-cli description: Stremio automation via browser + Torrentio on Mac Mini. Searches for shows/movies, selects highest-seeded streams, and plays them. Use when user wants to watch something on Stremio (especially Stargate SG-1 S4E15+ or similar). Always confirm specific season/episode if not provided. Credentials for Stremio account are in Keychain. --- # Stremio CLI Automates Stremio web with Torrentio addon using browser control on the Mac Mini. **Workflow**: Open Stremio web → search title → find episode/season → select highest-seeded Torrentio stream → play. Perfect for quick streaming sessions. Stremio account credentials are saved in macOS Keychain ([email protected]). ## When to use Use this skill for any request like: - "watch stargate" - "play stremio" - "stremio sg1" - "put on stargate on stremio" Always ask for specific season + episode if not given (default pattern is S4E15+ for Stargate SG-1). ## Prerequisites - Browser tool (Playwright) available in OpenClaw - Stremio account logged in via Keychain on Mac Mini ## How to use Trigger with natural requests like "stremio stargate" or "watch latest episode". The skill uses the `browser` tool to: 1. Navigate to Stremio web (with Torrentio addon active) 2. Search for the show 3. Select the correct season/episode 4. Pick the highest-seeded Torrentio stream 5. Play it Current default: Stargate SG-1 (ask for exact S##E## if needed). The script in `scripts/stremio_cast.py` is Portuguese/legacy and not used — we rely on the built-in browser tool instead. FILE:_meta.json { "ownerId": "kn71r6v8mwwzwm493nwhg4eb2d7zyb2x", "slug": "stremio-cli", "version": "1.0.0", "publishedAt": 1769722293034 } FILE:scripts/stremio_cast.py import asyncio import sys import subprocess import time from playwright.async_api import async_playwright async def cast_stremio(query, device="Living Room"): print(f"[Moltbot] Iniciando Stremio para buscar: {query}") stremio_url = "https://app.strem.io/shell-v4.4/?streamingServer=https%3A%2F%2F192-168-15-162.519b6502d940.stremio.rocks%3A12470#/" stream_url = None async with async_playwright() as p: # Lançar navegador browser = await p.chromium.launch(headless=True, args=['--no-sandbox', '--disable-setuid-sandbox', '--ignore-certificate-errors']) context = await browser.new_context() page = await context.new_page() # Interceptar requisições para encontrar a URL do stream async def handle_request(request): nonlocal stream_url url = request.url if 'stremio.rocks' in url and ('/stream/' in url or url.endswith('.mp4')): stream_url = url print(f"[Moltbot] URL de stream encontrada: {stream_url}") page.on("request", handle_request) try: # 1. Navegar para o Stremio await page.goto(stremio_url, wait_until="load", timeout=60000) # 2. Buscar conteúdo # Esperar pelo input de busca (seletor genérico baseado no rascunho) await page.wait_for_selector('input[type="text"]', timeout=10000) await page.fill('input[type="text"]', query) await page.keyboard.press("Enter") # 3. Selecionar o primeiro resultado # Seletor baseado no rascunho (.poster-container) await page.wait_for_selector('.poster-container', timeout=10000) await page.click('.poster-container') # 4. Selecionar o stream # Seletor baseado no rascunho (.stream-item) await page.wait_for_selector('.stream-item', timeout=15000) await page.click('.stream-item') # 5. Aguardar a URL do stream ser capturada start_time = time.time() while not stream_url and (time.time() - start_time < 45): await asyncio.sleep(1) if not stream_url: print("Erro: Não foi possível extrair a URL do stream dentro do tempo limite.") return False # 6. Executar o casting via CATT print(f"[Moltbot] Transmitindo para {device}...") try: # Nota: Assume que 'catt' está instalado no ambiente subprocess.Popen(['catt', '-d', device, 'cast', stream_url]) print(f"Sucesso: Reproduzindo '{query}' em {device}.") # Mantemos o script rodando por um tempo para garantir que o stream inicie # No ambiente real, o processo pai deve gerenciar o ciclo de vida do navegador await asyncio.sleep(10) return True except Exception as e: print(f"Erro ao executar CATT: {e}") return False except Exception as e: print(f"Erro durante a automação: {e}") return False finally: # Nota: Fechar o navegador pode interromper o servidor local de streaming do Stremio # se ele depender da aba aberta. Em uso real, pode ser necessário manter aberto. await browser.close() if __name__ == "__main__": if len(sys.argv) < 2: print("Uso: python stremio_cast.py <query> [device]") sys.exit(1) query_arg = sys.argv[1] device_arg = sys.argv[2] if len(sys.argv) > 2 else "Living Room" asyncio.run(cast_stremio(query_arg, device_arg))