Batched RSS/Atom/RDF feed crawl from N feed-URLs via Python feedparser; per-feed markdown + manifest.json to caller dir; rolled-up + per-source summary.
Scanned 9/6/2026
Install to Claude Code
npx -y skills add thangnguyenworkspace/industry-pulse --skill research-crawl-rss --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Research Crawl Rss?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/thangnguyenworkspace-research-crawl-rss)More formats (shields.io, HTML) on the badges page.
---
name: research-crawl-rss
description: Batched RSS/Atom/RDF feed crawl from N feed-URLs via Python feedparser; per-feed markdown + manifest.json to caller dir; rolled-up + per-source summary.
---
# Research Crawl RSS
**Argument:** $ARGUMENTS (required, see Runtime Inputs)
If `$ARGUMENTS` is empty or missing required fields, STOP and report which fields are absent.
---
## Preamble
### Runtime Inputs
Parse from `$ARGUMENTS`:
```
--feed-urls=[u1,u2,...] [JSON-style array of absolute feed URLs (RSS / Atom / RDF), non-empty; each must be an http(s) URL. Singular invocation expressed as a single-element array.]
--raw-output-dir={PATH} [absolute path to a directory where per-feed rendered markdown files + manifest.json will be written]
--max-items-per-feed={N} [positive integer per-feed item cap; MUST be ≥ 1, see §1.0 Step 3 + §6.0 row max-items-zero]
# Optional power params (omit when not needed):
--days={N} [positive integer recency window in days; default 1]
--max-body-chars={N} [positive integer per-item body truncation cap; default 8000]
```
Validation rules (before §1.0):
- `--feed-urls`, `--raw-output-dir`, and `--max-items-per-feed` mandatory. If any missing, STOP and report which.
- `--feed-urls` must parse as a non-empty JSON array of strings. Else reject (`feed-urls-malformed`).
- Each entry must be an absolute `http://` or `https://` URL. First failing entry → STOP and report (`feed-urls-malformed`) with the offending value named.
- `--raw-output-dir` must be absolute. Reject relative paths (caller owns path resolution).
- `--max-items-per-feed` must parse as integer ≥ 1. Zero rejected per §6.0 `max-items-zero`.
- `--days` and `--max-body-chars` must parse as positive integers when supplied; defaults 1 and 8000.
If any validation fails, STOP and report which field failed.
### Global References
```
RSS_INGEST_SCRIPT = scripts/rss-ingest.py # feedparser-via-Bash leaf engine; one batched run over all feeds
```
Leaf does not read tool guides at runtime, the documented pitfalls are already encoded inside `RSS_INGEST_SCRIPT` (browser-UA default load-bearing, `Accept-Encoding: identity` + gzip fallback, HTML-strip before boundary-aware truncation, per-feed cap + newest-first sort-then-slice as the token governor, recency-window filtering on `published`/`updated` dates, dateless-item fallback + `items_dropped_dateless` surfacing, 403 gating classification `cloudflare-waf` vs `cloudflare-js-challenge`, `bozo` + content-type sniff + empty-body soft-failure surfacing, title-only `body_sources:{none}` provenance, `est_tokens` chars/4 budgeting heuristic). RSS fetch is free, $0 per run; the per-feed cap is a context/token governor, not a cost cap.
### Caller Isolation
Pure primitive, no agent spawn, no pipeline agents, no telemetry. The skill runs the feedparser fetch + parse + render directly in whatever context invokes it; the caller absorbs the raw-feed context cost (raw feed items land in the caller's window). Callers crawling many feeds, or composing this leaf alongside other work, wrap the invocation in their own isolation:
```
Agent({subagent_type: "general-purpose", model: "sonnet", prompt: "Invoke /research-crawl-rss with <args>"})
```
Consumer skills document their own isolation choice. The run-pulse composer wraps each source-type leaf in one classifier sub-agent per source-type.
---
## 1.0 Context Capture
### Step 1: Verify raw output directory parent exists
Check the parent directory of `--raw-output-dir` exists. If not, route to §6.0 (`raw-output-parent-missing`). Do not create parents, caller owns path resolution. The `--raw-output-dir` itself MAY or MAY NOT exist; §3.0 Step 1 creates it via `mkdir -p` before the run.
### Step 2: Validate feed URLs and derive per-feed slugs
For each entry in `--feed-urls`: confirm it is an absolute `http(s)` URL. Build `feeds[]` preserving caller order. First entry that fails → route to §6.0 (`feed-urls-malformed`) with the offending value named.
Derive a deterministic slug per feed: lowercase the URL, drop the `http://` / `https://` scheme, replace each run of non-alphanumeric characters with a single `-`, strip leading/trailing `-`, truncate to 50 characters. If two feeds produce the same slug, append `-{ordinal}` to the later one so each feed maps to a distinct file. The slug becomes the feed's TSV name (and the script's output filename).
### Step 3: Validate `--max-items-per-feed` lower bound
Confirm `--max-items-per-feed ≥ 1`. If `--max-items-per-feed == 0`, route to §6.0 (`max-items-zero`): pass a positive integer. The cap is the effective token governor, the script sorts in-window items newest-first then slices to this count, so a large feed backlog clamps to a bounded payload. RSS fetch is free; this is a context cap, not a cost cap.
### Step 4: Build the feeds TSV
Write a tab-separated feed list for the script, one line per feed, `{slug}<TAB>{url}<TAB>` (empty domain column; the leaf does not classify or tag). Write it to a temp path via `mktemp` (e.g., `RSS_FEEDS_TSV=$(mktemp /tmp/rss-feeds-XXXXXX)`, no suffix after the X's; BSD/macOS `mktemp` rejects a template-then-suffix form like `-XXXXXX.tsv`, and the script reads `--feeds-file` by path, not by extension). The window (`--days`), per-feed cap (`--max-items-per-feed`), and body cap (`--max-body-chars`) pass directly to the script; no per-feed query construction is needed (feedparser fetches the whole feed and the script windows + caps internally).
---
## 2.0 Plan & Confirm
Skill runs autonomously, no user pause. Pure primitive; executes directly in the caller's context. Composed leaves never gate parallelization; direct invocations log the execution config and proceed.
State the planned execution config in one structured log statement, then proceed directly to §3.0:
```
## /research-crawl-rss: executing crawl
feeds: {feeds[]} # N feed URLs in caller-supplied order
raw_output_dir: {--raw-output-dir}
max_items_per_feed: {--max-items-per-feed} # per-feed cap (token governor)
days: {--days} # default 1
max_body_chars: {--max-body-chars} # default 8000
engine: {RSS_INGEST_SCRIPT} # feedparser via Bash; $0/run
execution: direct (pure primitive, no sub-agent spawn); one batched script run over all feeds
```
---
## 3.0 Crawl
Execute the crawl directly, no sub-agent spawn. Run `RSS_INGEST_SCRIPT` once over all feeds (it fetches, windows, caps, and renders per-feed markdown), then parse its manifest to build the per-source return. This step writes raw bytes only, it does NOT classify (which domain, which author), summarize, template-fill, deduplicate across feeds, or follow item links to recover missing bodies; the composing router or domain orchestrator handles all downstream transformation. Cross-feed dedup belongs to the dispatcher / classifier layer, not this leaf; title-only feeds whose bodies are genuinely absent surface `body_sources:{none}` and a downstream layer decides whether to fetch the full body from the item link.
### Inputs (from §1.0)
```
Feeds: {feeds[]} # N feed URLs in caller-supplied order
Feed TSV: {RSS_FEEDS_TSV} # {slug}<TAB>{url}<TAB> per feed
Raw output dir: {--raw-output-dir}
Max items per feed: {--max-items-per-feed}
Window: last {--days} day(s)
Engine: {RSS_INGEST_SCRIPT}
```
### Procedure
Execute these steps in order:
```
1. Ensure {--raw-output-dir} exists. Use Bash `mkdir -p {--raw-output-dir}`.
2. Write the feeds TSV from §1.0 Step 4 to {RSS_FEEDS_TSV}.
3. Run the engine once, capturing the manifest to disk:
python3 {RSS_INGEST_SCRIPT} \
--feeds-file={RSS_FEEDS_TSV} \
--out-dir={--raw-output-dir} \
--days={--days} \
--max-items={--max-items-per-feed} \
--max-body-chars={--max-body-chars} \
> {--raw-output-dir}/manifest.json
The script writes one markdown file per NON-EMPTY feed into {--raw-output-dir}
(filename {slug}.md) and prints the JSON manifest to stdout, redirected to
{--raw-output-dir}/manifest.json. The manifest lists ALL N feeds, including
empty / gated / errored ones, and is the canonical per-source rollup (the
dispatcher's volume-driven auto-scaler keys on manifest counts + est_tokens,
never on file existence). Empty feeds deliberately produce no md file.
Exit codes: 0 = ran (per-feed failures are captured in the manifest, not the
exit code); 2 = bad invocation (neither --feeds-file nor --url) → §6.0
`script-exit-nonzero`; non-zero otherwise (e.g. ModuleNotFoundError for
feedparser) → §6.0 `script-exit-nonzero` / `feedparser-missing`.
4. Read {--raw-output-dir}/manifest.json. For each record in manifest.feeds[],
derive the per-feed crawl_status:
- failed, record.gated is non-null (use the gated label as diagnostic),
OR record.error is a hard failure: an HTTPError (404 etc.),
"not a feed (...)", "unparseable: ...", or "empty body (...)".
- empty, record.error is "no items in window" or "feed has zero
entries" (feed fetched + parsed cleanly; just no recent items).
An empty window is a clean operational outcome, not a failure.
- clean, record.error is null and record.out_file is present
(items_emitted ≥ 1).
5. Build per_source_outputs[], one entry per feed in caller order:
per_source_outputs[i] = {
feed_url: {feeds[i]},
raw_output_path: {record.out_file}, # null for empty / failed feeds
item_count: {record.items_emitted},
char_count: {wc -c on record.out_file, or 0 when no file},
est_tokens: {record.est_tokens},
crawl_status: {"clean" | "empty" | "failed"}, # per-feed
diagnostic: {record.gated or record.error when failed; otherwise omit},
}
6. Compute aggregate fields:
total_char_count = sum(entry.char_count for entry in per_source_outputs)
total_item_count = sum(entry.item_count for entry in per_source_outputs)
overall_crawl_status = (
"clean" if all entries have crawl_status in {clean, empty}
else "failed" if all entries have crawl_status == "failed"
else "partial"
)
Note: `empty` entries do NOT degrade overall status, an empty window is a
clean outcome. Only mixed clean+failed or all-failed produces
`partial`/`failed` aggregate.
7. Read-back: confirm {--raw-output-dir}/manifest.json exists and lists N feed
records. Confirm one md file exists for each entry with crawl_status: clean.
Mismatch → set overall_crawl_status = "failed" with diagnostic =
"post-write reconciliation failed: expected N manifest records / M clean files".
```
### Capture
After the read-back (step 7), capture the structured summary as `CRAWL_RETURN` and proceed to §4.0:
```
raw_output_dir: {--raw-output-dir}
manifest_path: {--raw-output-dir}/manifest.json
char_count: <total across all per-feed files> # aggregate
item_count: <total emitted across all feeds> # aggregate
crawl_status: <clean | partial | failed> # aggregate
window_days: {--days}
per_source_outputs: [<one entry per feed, see step 5>]
diagnostic: <one-line note when crawl_status != clean; otherwise omit>
```
Do NOT surface raw feed content to the caller, the rendered per-feed markdown is on disk at `{--raw-output-dir}`. §5.0 returns the reference plus summary only.
---
## 4.0 Verify
### Step 1: File-state verification
Verify the raw output directory, manifest, and per-feed files:
- [ ] Directory exists at `--raw-output-dir` (use Bash: `test -d {DIR}`).
- [ ] `manifest.json` exists at `--raw-output-dir` and parses as JSON with `feeds[]` length N.
- [ ] One md file exists at each `CRAWL_RETURN.per_source_outputs[i].raw_output_path` whose `crawl_status == clean`, and that file is non-empty (`char_count > 200`, a single rendered item plus the metadata comment exceeds 200 bytes).
- [ ] Entries with `crawl_status` of `empty` or `failed` carry `raw_output_path: null` (no file), confirm no orphan expectation.
If any check fails (entry status `clean` but file empty/missing, manifest absent or wrong feed count), route to §6.0 (`agent-write-mismatch`).
---
## 5.0 Summary
Return this structured summary to the caller (typically the `/run-pulse` composer):
```
raw_output_dir: {CRAWL_RETURN.raw_output_dir}
manifest_path: {CRAWL_RETURN.manifest_path}
char_count: {CRAWL_RETURN.char_count} # aggregate
item_count: {CRAWL_RETURN.item_count} # aggregate
crawl_status: {CRAWL_RETURN.crawl_status} # clean | partial | failed
window_days: {CRAWL_RETURN.window_days}
per_source_outputs: {CRAWL_RETURN.per_source_outputs[]}
# Each entry: { feed_url, raw_output_path, item_count, char_count,
# est_tokens, crawl_status (clean|empty|failed per-feed), diagnostic? }
diagnostic: {CRAWL_RETURN.diagnostic if present}
```
Caller decides next action based on overall `crawl_status` (short-circuit on `failed`; proceed with extraction iteration on `clean` or `partial`) and per-feed `crawl_status` (skip extraction on entries with `crawl_status == failed`; treat `empty` as no-content-this-cycle). The manifest carries per-feed `est_tokens` + counts for the dispatcher's volume-driven auto-scaler. The leaf does not retry, the caller governs retry policy.
---
## 6.0 Error Handling
| Error | Response |
|---|---|
| `--feed-urls`, `--raw-output-dir`, or `--max-items-per-feed` missing from `$ARGUMENTS` | STOP. Report which field is absent. |
| `feed-urls-malformed`, `--feed-urls` does not parse as a JSON array, OR array is empty, OR any entry is not an absolute http(s) URL | STOP. Report the malformed value (the offending entry when per-entry). Caller passes absolute feed URLs. |
| `--raw-output-dir` not an absolute path | STOP. Report the relative path; caller must resolve. |
| `max-items-zero`, `--max-items-per-feed == 0` or not a positive integer | STOP. Report the malformed value; pass a positive integer. The cap is the token governor, an uncapped run is unbounded. |
| `raw-output-parent-missing`, parent directory of `--raw-output-dir` does not exist | STOP. Report the missing parent path. Do not create, caller scaffolds the directory tree before invocation. The `--raw-output-dir` itself MAY or MAY NOT pre-exist; §3.0 Step 1 creates it. |
| A single feed produces `crawl_status: failed` (HTTP error / 404, non-feed HTML, unparseable XML, empty body, or a `gated:cloudflare-waf` / `gated:cloudflare-js-challenge` classification) | The script captures the failure in that feed's manifest record and continues the batch, no md file is written for it. The failure is isolated; aggregate becomes `partial` (or `failed` only if all feeds failed). Per-feed diagnostic (the gated label or error string) surfaces in `per_source_outputs[]`. A `js-challenge` feed is unreachable via any static fetcher, route it to another lane, do not retry here. |
| Crawl produces overall `crawl_status: partial` (some feeds succeeded, others failed) | Proceed to §5.0 normally. Return surfaces overall `partial` + per-feed detail in `per_source_outputs[]`. Caller iterates the array, drops failed entries from downstream extraction, continues with clean entries. |
| Crawl produces `crawl_status: empty` for one or more feeds | Proceed normally. Per-feed entry surfaces `item_count: 0` with `raw_output_path: null` (valid no-recent-items result; the feed was reachable). Empty does NOT degrade aggregate status. |
| `script-exit-nonzero`, the script exits 2 (neither `--feeds-file` nor `--url` passed, indicates a leaf bug in TSV construction) or any non-zero code | Surface the script's stderr verbatim. Exit 2 means the TSV was empty or the `--feeds-file` path was wrong, verify §3.0 Step 2 wrote the TSV. Do not retry without correcting the invocation. |
| `feedparser-missing`, the script aborts with `ModuleNotFoundError: feedparser` | STOP. The runtime dependency is absent. Report the install command `pip install feedparser`; the leaf cannot run without it. |
| `agent-write-mismatch`, a record reports `crawl_status: clean` but the md file is empty/missing, OR `manifest.json` is absent / lists a feed count ≠ N | Surface the mismatch in the return diagnostic. Set the affected entry's `crawl_status: failed`; recompute overall status. File-state on disk (manifest + md files) is authoritative. |
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!