fetchkit-agents 0.2.0__tar.gz → 0.3.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. {fetchkit_agents-0.2.0 → fetchkit_agents-0.3.1}/PKG-INFO +143 -17
  2. {fetchkit_agents-0.2.0 → fetchkit_agents-0.3.1}/README.md +142 -16
  3. {fetchkit_agents-0.2.0 → fetchkit_agents-0.3.1}/pyproject.toml +1 -1
  4. {fetchkit_agents-0.2.0 → fetchkit_agents-0.3.1}/src/fetchkit/__init__.py +20 -4
  5. {fetchkit_agents-0.2.0 → fetchkit_agents-0.3.1}/src/fetchkit/cli.py +168 -10
  6. {fetchkit_agents-0.2.0 → fetchkit_agents-0.3.1}/src/fetchkit/collector.py +16 -10
  7. {fetchkit_agents-0.2.0 → fetchkit_agents-0.3.1}/src/fetchkit/config_loader.py +22 -11
  8. {fetchkit_agents-0.2.0 → fetchkit_agents-0.3.1}/src/fetchkit/discovery/__init__.py +2 -1
  9. {fetchkit_agents-0.2.0 → fetchkit_agents-0.3.1}/src/fetchkit/discovery/core.py +27 -1
  10. fetchkit_agents-0.3.1/src/fetchkit/fetchers/__init__.py +28 -0
  11. {fetchkit_agents-0.2.0 → fetchkit_agents-0.3.1}/src/fetchkit/fetchers/arxiv.py +44 -0
  12. fetchkit_agents-0.3.1/src/fetchkit/fetchers/bluesky.py +176 -0
  13. {fetchkit_agents-0.2.0 → fetchkit_agents-0.3.1}/src/fetchkit/fetchers/github.py +23 -1
  14. {fetchkit_agents-0.2.0 → fetchkit_agents-0.3.1}/src/fetchkit/fetchers/hackernews.py +12 -0
  15. {fetchkit_agents-0.2.0 → fetchkit_agents-0.3.1}/src/fetchkit/fetchers/lobsters.py +20 -0
  16. fetchkit_agents-0.3.1/src/fetchkit/fetchers/mastodon.py +161 -0
  17. {fetchkit_agents-0.2.0 → fetchkit_agents-0.3.1}/src/fetchkit/fetchers/rss.py +12 -0
  18. fetchkit_agents-0.3.1/src/fetchkit/fetchers/stackexchange.py +230 -0
  19. fetchkit_agents-0.3.1/src/fetchkit/fetchers/suggest_registry.py +53 -0
  20. fetchkit_agents-0.3.1/src/fetchkit/schemas/collector.py +54 -0
  21. {fetchkit_agents-0.2.0 → fetchkit_agents-0.3.1}/src/fetchkit/schemas/describe.py +50 -2
  22. {fetchkit_agents-0.2.0 → fetchkit_agents-0.3.1}/src/fetchkit/schemas/fetcher.py +95 -2
  23. {fetchkit_agents-0.2.0 → fetchkit_agents-0.3.1}/src/fetchkit/schemas/post.py +3 -0
  24. {fetchkit_agents-0.2.0 → fetchkit_agents-0.3.1}/src/fetchkit_agents.egg-info/PKG-INFO +143 -17
  25. {fetchkit_agents-0.2.0 → fetchkit_agents-0.3.1}/src/fetchkit_agents.egg-info/SOURCES.txt +4 -0
  26. fetchkit_agents-0.2.0/src/fetchkit/fetchers/__init__.py +0 -11
  27. fetchkit_agents-0.2.0/src/fetchkit/schemas/collector.py +0 -21
  28. {fetchkit_agents-0.2.0 → fetchkit_agents-0.3.1}/LICENSE +0 -0
  29. {fetchkit_agents-0.2.0 → fetchkit_agents-0.3.1}/setup.cfg +0 -0
  30. {fetchkit_agents-0.2.0 → fetchkit_agents-0.3.1}/src/fetchkit/__main__.py +0 -0
  31. {fetchkit_agents-0.2.0 → fetchkit_agents-0.3.1}/src/fetchkit/discovery/autodiscover.py +0 -0
  32. {fetchkit_agents-0.2.0 → fetchkit_agents-0.3.1}/src/fetchkit/discovery/catalog.py +0 -0
  33. {fetchkit_agents-0.2.0 → fetchkit_agents-0.3.1}/src/fetchkit/discovery/data/CATALOG.md +0 -0
  34. {fetchkit_agents-0.2.0 → fetchkit_agents-0.3.1}/src/fetchkit/discovery/data/catalog.json +0 -0
  35. {fetchkit_agents-0.2.0 → fetchkit_agents-0.3.1}/src/fetchkit/discovery/embedding.py +0 -0
  36. {fetchkit_agents-0.2.0 → fetchkit_agents-0.3.1}/src/fetchkit/discovery/errors.py +0 -0
  37. {fetchkit_agents-0.2.0 → fetchkit_agents-0.3.1}/src/fetchkit/discovery/external.py +0 -0
  38. {fetchkit_agents-0.2.0 → fetchkit_agents-0.3.1}/src/fetchkit/discovery/ranking.py +0 -0
  39. {fetchkit_agents-0.2.0 → fetchkit_agents-0.3.1}/src/fetchkit/discovery/schemas.py +0 -0
  40. {fetchkit_agents-0.2.0 → fetchkit_agents-0.3.1}/src/fetchkit/fetchers/base.py +0 -0
  41. {fetchkit_agents-0.2.0 → fetchkit_agents-0.3.1}/src/fetchkit/fetchers/registry.py +0 -0
  42. {fetchkit_agents-0.2.0 → fetchkit_agents-0.3.1}/src/fetchkit/http/__init__.py +0 -0
  43. {fetchkit_agents-0.2.0 → fetchkit_agents-0.3.1}/src/fetchkit/http/client.py +0 -0
  44. {fetchkit_agents-0.2.0 → fetchkit_agents-0.3.1}/src/fetchkit/http/rate_limit.py +0 -0
  45. {fetchkit_agents-0.2.0 → fetchkit_agents-0.3.1}/src/fetchkit/http/ssrf.py +0 -0
  46. {fetchkit_agents-0.2.0 → fetchkit_agents-0.3.1}/src/fetchkit/py.typed +0 -0
  47. {fetchkit_agents-0.2.0 → fetchkit_agents-0.3.1}/src/fetchkit/schemas/__init__.py +0 -0
  48. {fetchkit_agents-0.2.0 → fetchkit_agents-0.3.1}/src/fetchkit/schemas/base.py +0 -0
  49. {fetchkit_agents-0.2.0 → fetchkit_agents-0.3.1}/src/fetchkit/schemas/config.py +0 -0
  50. {fetchkit_agents-0.2.0 → fetchkit_agents-0.3.1}/src/fetchkit/utils/__init__.py +0 -0
  51. {fetchkit_agents-0.2.0 → fetchkit_agents-0.3.1}/src/fetchkit/utils/time.py +0 -0
  52. {fetchkit_agents-0.2.0 → fetchkit_agents-0.3.1}/src/fetchkit_agents.egg-info/dependency_links.txt +0 -0
  53. {fetchkit_agents-0.2.0 → fetchkit_agents-0.3.1}/src/fetchkit_agents.egg-info/entry_points.txt +0 -0
  54. {fetchkit_agents-0.2.0 → fetchkit_agents-0.3.1}/src/fetchkit_agents.egg-info/requires.txt +0 -0
  55. {fetchkit_agents-0.2.0 → fetchkit_agents-0.3.1}/src/fetchkit_agents.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fetchkit-agents
3
- Version: 0.2.0
3
+ Version: 0.3.1
4
4
  Summary: A YAML-configured data fetching library for agentic applications.
5
5
  Author-email: mete morris <morrismete@gmail.com>
6
6
  License: MIT
@@ -50,15 +50,16 @@ Dynamic: license-file
50
50
  A YAML-configured data-fetching library for agentic applications.
51
51
 
52
52
  `fetchkit` collects posts and comments from sources (Hacker News, RSS/Atom, arXiv,
53
- GitHub, Lobsters) into a single canonical `Post` model, with de-duplication,
53
+ GitHub, Lobsters, Stack Exchange, Bluesky, Mastodon) into a single canonical `Post`
54
+ model, with de-duplication,
54
55
  deterministic sorting, and a shared HTTP client with retries and rate limiting. It is
55
56
  designed as the data-collection layer for LLM/agent pipelines — feed it configs, get
56
57
  back clean typed data.
57
58
 
58
59
  - **YAML-first** configuration with strict validation: unknown or misspelled keys
59
60
  are rejected up front, so a bad config fails loudly instead of silently.
60
- - **Builtin fetchers**: Hacker News, RSS/Atom, arXiv, GitHub, and Lobsters all
61
- zero-auth.
61
+ - **Builtin fetchers**: Hacker News, RSS/Atom, arXiv, GitHub, Lobsters, Stack Exchange,
62
+ Bluesky, and Mastodon — all zero-auth.
62
63
  - **Relative time windows**: say `window: "last 6 hours"` instead of computing
63
64
  timestamps.
64
65
  - **Open `metadata`**: each `Post` carries a `metadata` dict for source-specific
@@ -73,6 +74,9 @@ back clean typed data.
73
74
  no Python required.
74
75
  - **RSS feed discovery**: `fetchkit discover "<use case>"` maps a natural-language
75
76
  query onto real RSS feeds an agent can fetch — closing the "which feed URL?" gap.
77
+ - **Per-source discovery**: `fetchkit suggest <source>` lists the selectable knobs
78
+ for any source (tags, sites, arXiv categories, trending hashtags, Bluesky feeds) —
79
+ all zero-auth — so an agent can fill a config without guessing.
76
80
 
77
81
  ## Install
78
82
 
@@ -107,6 +111,15 @@ fetchers:
107
111
  repos: ["python/cpython", "pydantic/pydantic"]
108
112
  - type: lobsters
109
113
  listing: hottest
114
+ - type: stackexchange
115
+ site: stackoverflow
116
+ tagged: ["python", "asyncio"]
117
+ - type: bluesky
118
+ resource: search
119
+ query: "large language models"
120
+ - type: mastodon
121
+ instance: mastodon.social
122
+ tag: ai
110
123
  - type: rss
111
124
  feeds:
112
125
  - url: "https://feeds.bbci.co.uk/news/rss.xml"
@@ -143,33 +156,49 @@ Python. `run` prints **only** JSON to stdout (diagnostics go to stderr), so it p
143
156
  cleanly into `jq`:
144
157
 
145
158
  ```bash
146
- fetchkit run config.yaml # pretty JSON: {"count", "posts", "errors"}
159
+ fetchkit run config.yaml # pretty JSON: {"count", "posts", "sources", "errors"}
147
160
  fetchkit run config.yaml -o out.json # write JSON to a file (stdout stays empty)
148
161
  fetchkit run config.yaml --window "6h" # override the time window at runtime
149
162
  fetchkit run config.yaml --compact # single-line JSON
150
163
  fetchkit run config.yaml --fail-on-error # exit 1 if any source failed
151
- fetchkit validate config.yaml # validate a config without fetching
164
+ echo "$CONFIG_YAML" | fetchkit run - # read the config from stdin
165
+ fetchkit validate config.yaml # validate a config without fetching (also accepts -)
152
166
  fetchkit schema # JSON Schema for every config/output model
153
167
  python -m fetchkit run config.yaml # module form, identical behavior
154
168
  ```
155
169
 
170
+ `fetchkit run --help` and `fetchkit validate --help` print the YAML config format
171
+ inline (top-level keys, a minimal example, and the fetcher type list), so simple
172
+ configs need no schema round-trip.
173
+
156
174
  `fetchkit schema` lets an agent discover what it can fetch — it prints the JSON
157
175
  Schema (with field descriptions) for the top-level config, the shared HTTP
158
176
  settings, every builtin fetcher's typed config, and the canonical `Post` output —
159
- so a config can be written without knowing the YAML format out of band:
177
+ so a config can be written without knowing the YAML format out of band. Pass a
178
+ section name to print just that part instead of the full ~50 KB document:
160
179
 
161
180
  ```bash
162
- fetchkit schema | jq '.fetchers | keys' # ["arxiv","github","hackernews","lobsters","rss"]
181
+ fetchkit schema | jq '.fetchers | keys' # ["arxiv","bluesky","github","hackernews","lobsters","mastodon","rss","stackexchange"]
182
+ fetchkit schema config # just the top-level config schema
183
+ fetchkit schema rss # just one fetcher's config schema
184
+ fetchkit schema post # just the output Post model
163
185
  fetchkit schema -o schema.json # write to a file; supports --compact too
164
186
  ```
165
187
 
166
- Output shape:
188
+ Output shape — `sources` reports per-fetcher success/failure (one entry per
189
+ enabled fetcher, in config order), so partial failures are visible without
190
+ diffing domains against `posts`. A source can partially fail, e.g. one dead
191
+ feed among many: it keeps its posts, records the error, and reports `ok: false`.
167
192
 
168
193
  ```json
169
194
  {
170
195
  "count": 2,
171
196
  "posts": [ { "id": "...", "source": "rss", "title": "...", "url": "...", "created_at": "..." } ],
172
- "errors": [ { "source": "hackernews", "error": "..." } ]
197
+ "sources": [
198
+ { "source": "rss", "name": null, "ok": false, "posts": 2,
199
+ "errors": ["Failed to fetch feed https://dead.example/feed.xml: ..."] }
200
+ ],
201
+ "errors": [ { "source": "rss", "error": "Failed to fetch feed https://dead.example/feed.xml: ..." } ]
173
202
  }
174
203
  ```
175
204
 
@@ -194,11 +223,12 @@ result = collect_all(config)
194
223
 
195
224
  ## Discovering RSS feeds
196
225
 
197
- Every other source has a finite, nameable set of options (`fetchkit schema`
198
- enumerates them). RSS is the exception: a feed is an *arbitrary URL*, so an agent
199
- that wants "central-bank policy" or "rust programming" has no way to know which
200
- feeds exist. The optional `discovery` module closes that gap — give it a
226
+ RSS is the hardest source to discover for: a feed is an *arbitrary URL*, so an
227
+ agent that wants "central-bank policy" or "rust programming" has no way to know
228
+ which feeds exist. The optional `discovery` module closes that gap give it a
201
229
  use case, get back ranked feeds you can drop straight into an `rss` fetcher.
230
+ (The other sources have their own, simpler discovery surface — see
231
+ [Discoverability for every source](#discoverability-for-every-source) below.)
202
232
 
203
233
  ```python
204
234
  from fetchkit.discovery import discover, to_rss_config
@@ -255,6 +285,48 @@ not the live article stream — for the latter, fetch the feed with the `rss`
255
285
  fetcher and match individual posts. Catalog quality is the main lever on result
256
286
  quality; see `src/fetchkit/discovery/data/CATALOG.md` to extend it.
257
287
 
288
+ Because discovery only *ranks* candidates it already holds, a niche query (a
289
+ specific region, product, or community) can come back filled with generic feeds
290
+ that matched only the query's broad terms. The CLI flags this instead of letting
291
+ it pass: the JSON payload carries `unmatched_terms` (query terms no returned
292
+ feed mentions) plus a `hint`, and the same hint is printed to stderr. When you
293
+ see it, feed discovery better candidates — `--from-urls` with sites from your
294
+ own web search, `fetchkit find-feeds <site-url>`, or `--external`.
295
+
296
+ ## Discoverability for every source
297
+
298
+ `discover` solves RSS's "which feed URL?" problem. The other sources have a
299
+ *different* discoverability gap — *which tag / site / instance / feed do I put in
300
+ the config?* — answered by `fetchkit suggest <source>` (and `run_suggester()` in
301
+ Python). Each suggester is **no-auth** and returns JSON-ready rows:
302
+
303
+ ```bash
304
+ fetchkit suggest lobsters # all Lobsters tags
305
+ fetchkit suggest stackexchange --site stackoverflow # popular SO tags
306
+ fetchkit suggest stackexchange --what sites # available SE sites
307
+ fetchkit suggest arxiv --query vision # matching arXiv categories
308
+ fetchkit suggest github --query "language:rust stars:>5000" # popular repos to watch
309
+ fetchkit suggest mastodon --instance fosstodon.org # trending hashtags
310
+ fetchkit suggest bluesky # popular custom feeds
311
+ fetchkit suggest bluesky --what actors --query "ai" # accounts for an author_feed
312
+ fetchkit suggest rss --query "AI safety news" # delegates to discover()
313
+ ```
314
+
315
+ | Source | `suggest` returns | Fills config field |
316
+ |--------|-------------------|--------------------|
317
+ | `hackernews` | sort orders (static — HN has no tags) | `posts.order` |
318
+ | `rss` | ranked feeds (delegates to `discover`) | `feeds` |
319
+ | `arxiv` | category codes + names (static taxonomy) | `categories` |
320
+ | `github` | popular `owner/name` repos for a query | `repos` |
321
+ | `lobsters` | all tags (from `/tags.json`) | `tag` |
322
+ | `stackexchange` | popular tags, or sites (`--what sites`) | `tagged` / `site` |
323
+ | `bluesky` | popular feeds, or actors (`--what actors`) | `actor` |
324
+ | `mastodon` | trending hashtags on an instance | `tag` / `instance` |
325
+
326
+ > Most suggesters call the source's live API (all no-auth); `hackernews`/`arxiv`
327
+ > are static, and `rss` reuses the offline catalog ranker. Network suggesters fail
328
+ > gracefully (the CLI reports the error on stderr and exits non-zero).
329
+
258
330
  ## Configuration reference
259
331
 
260
332
  ### Top-level (`FetchKitConfig`)
@@ -271,7 +343,9 @@ Set the time window with **either** a relative `window` **or** both
271
343
  `start_time`/`end_time` — the two are mutually exclusive. **If you specify none of
272
344
  them, the window defaults to the last 24 hours.** `start_time <= end_time` is
273
345
  enforced, and unknown top-level keys are rejected. Per-fetcher
274
- `start_time`/`end_time` default to `None` and **inherit** the global window at runtime.
346
+ `start_time`/`end_time` default to `None` and **inherit** the run window from these
347
+ top-level keys at runtime (there is no `global:` key — the top level *is* the
348
+ global scope).
275
349
 
276
350
  #### Relative windows
277
351
 
@@ -352,6 +426,47 @@ The lobste.rs public JSON endpoints (no auth). Tags are preserved in `post.metad
352
426
  | `tag` | null | Restrict to a single tag (uses `/t/<tag>.json`) |
353
427
  | `max_items`| 50 | 1–200 |
354
428
 
429
+ ### Stack Exchange (`type: stackexchange`)
430
+
431
+ The Stack Exchange API (no auth; anonymous access is capped at 300 requests/day/IP).
432
+ Questions become `Post`s; with `comments.fetch: true`, top answers are attached as
433
+ `Comment`s. Tags, answer count, and the accepted-answer id are kept in `post.metadata`.
434
+
435
+ | Field | Default | Notes |
436
+ |------------|----------------|-------------------------------------------------------------|
437
+ | `site` | stackoverflow | API site, e.g. `serverfault`, `superuser`, `askubuntu` |
438
+ | `tagged` | `[]` | Questions carrying ALL of these tags (joined with `;`) |
439
+ | `query` | null | Free-text search (uses `/search/advanced`) |
440
+ | `posts` | — | `max_items` (1–500) and `order` (`top`→votes, `new`→creation) |
441
+ | `comments` | — | `fetch`/`max_items` — answers attached as comments |
442
+
443
+ ### Bluesky (`type: bluesky`)
444
+
445
+ The public Bluesky AppView (`public.api.bsky.app`), unauthenticated by design.
446
+ `uri`, `cid`, and `langs` are kept in `post.metadata`; likes map to `score` and
447
+ replies to `comment_count`.
448
+
449
+ | Field | Default | Notes |
450
+ |------------|---------|--------------------------------------------------------------|
451
+ | `resource` | search | `search` (full-text) or `author_feed` |
452
+ | `query` | null | Search query — required for `resource: search` |
453
+ | `actor` | null | Handle/DID — required for `resource: author_feed` |
454
+ | `max_items`| 50 | 1–500 (paginated, 100/page) |
455
+
456
+ ### Mastodon (`type: mastodon`)
457
+
458
+ Public and hashtag timelines on any instance (no auth, when the instance keeps
459
+ public preview enabled — otherwise requests return 401). HTML content is reduced to
460
+ plain text; tags, instance, and visibility are kept in `post.metadata`.
461
+
462
+ | Field | Default | Notes |
463
+ |------------|------------------|--------------------------------------------------------|
464
+ | `instance` | mastodon.social | Instance host without scheme, e.g. `fosstodon.org` |
465
+ | `resource` | tag | `tag` (`/timelines/tag/<tag>`) or `public` |
466
+ | `tag` | null | Hashtag without `#` — required for `resource: tag` |
467
+ | `local` | false | Restrict to statuses originating on this instance |
468
+ | `max_items`| 50 | 1–200 (paginated, 40/page) |
469
+
355
470
  ### HTTP (`http:`)
356
471
 
357
472
  | Field | Default | Notes |
@@ -384,6 +499,15 @@ validation, and tests. Add a new source via a PR or a local fork in four steps:
384
499
 
385
500
  3. Import the module in `src/fetchkit/fetchers/__init__.py` so it registers on import.
386
501
  4. Add tests (mock HTTP with the `responses` library — see `tests/fetchers/`).
502
+ 5. *(Optional)* register a discovery helper so agents can find your source's knobs:
503
+
504
+ ```python
505
+ from fetchkit.fetchers.suggest_registry import register_suggester
506
+
507
+ @register_suggester("mysource")
508
+ def suggest(*, query=None, limit=20, **kwargs) -> list[dict]:
509
+ return [{"tag": "…"}] # JSON-ready rows; surfaced via `fetchkit suggest mysource`
510
+ ```
387
511
 
388
512
  Use `Post.metadata` for any source-specific fields that don't map to the canonical
389
513
  columns.
@@ -394,6 +518,7 @@ columns.
394
518
  class Post(BaseModel):
395
519
  id: str # unique within source
396
520
  source: str # "hackernews" | "rss" | "arxiv" | "github" | "lobsters"
521
+ # | "stackexchange" | "bluesky" | "mastodon"
397
522
  title: str | None
398
523
  text: str | None # body / content
399
524
  url: str | None # external link
@@ -410,8 +535,9 @@ All datetimes are normalized to UTC. Posts are deduplicated by `(source, id)` an
410
535
  sorted descending by `(created_at, id)` for deterministic output.
411
536
 
412
537
  > **`score` is source-relative.** Each source defines it differently — Hacker News
413
- > points, Lobsters score, GitHub stars (for `search_repos`), and `None` for arXiv
414
- > and RSS. The values are **not comparable across sources**, so don't rank a mixed
538
+ > points, Lobsters score, GitHub stars (for `search_repos`), Stack Exchange question
539
+ > score, Bluesky likes, Mastodon favourites, and `None` for arXiv and RSS. The values
540
+ > are **not comparable across sources**, so don't rank a mixed
415
541
  > feed by `score` directly. Compare within a single `source`, or use a source-aware
416
542
  > ranking of your own. Output is ordered by recency (`created_at`), not by `score`.
417
543
 
@@ -8,15 +8,16 @@
8
8
  A YAML-configured data-fetching library for agentic applications.
9
9
 
10
10
  `fetchkit` collects posts and comments from sources (Hacker News, RSS/Atom, arXiv,
11
- GitHub, Lobsters) into a single canonical `Post` model, with de-duplication,
11
+ GitHub, Lobsters, Stack Exchange, Bluesky, Mastodon) into a single canonical `Post`
12
+ model, with de-duplication,
12
13
  deterministic sorting, and a shared HTTP client with retries and rate limiting. It is
13
14
  designed as the data-collection layer for LLM/agent pipelines — feed it configs, get
14
15
  back clean typed data.
15
16
 
16
17
  - **YAML-first** configuration with strict validation: unknown or misspelled keys
17
18
  are rejected up front, so a bad config fails loudly instead of silently.
18
- - **Builtin fetchers**: Hacker News, RSS/Atom, arXiv, GitHub, and Lobsters all
19
- zero-auth.
19
+ - **Builtin fetchers**: Hacker News, RSS/Atom, arXiv, GitHub, Lobsters, Stack Exchange,
20
+ Bluesky, and Mastodon — all zero-auth.
20
21
  - **Relative time windows**: say `window: "last 6 hours"` instead of computing
21
22
  timestamps.
22
23
  - **Open `metadata`**: each `Post` carries a `metadata` dict for source-specific
@@ -31,6 +32,9 @@ back clean typed data.
31
32
  no Python required.
32
33
  - **RSS feed discovery**: `fetchkit discover "<use case>"` maps a natural-language
33
34
  query onto real RSS feeds an agent can fetch — closing the "which feed URL?" gap.
35
+ - **Per-source discovery**: `fetchkit suggest <source>` lists the selectable knobs
36
+ for any source (tags, sites, arXiv categories, trending hashtags, Bluesky feeds) —
37
+ all zero-auth — so an agent can fill a config without guessing.
34
38
 
35
39
  ## Install
36
40
 
@@ -65,6 +69,15 @@ fetchers:
65
69
  repos: ["python/cpython", "pydantic/pydantic"]
66
70
  - type: lobsters
67
71
  listing: hottest
72
+ - type: stackexchange
73
+ site: stackoverflow
74
+ tagged: ["python", "asyncio"]
75
+ - type: bluesky
76
+ resource: search
77
+ query: "large language models"
78
+ - type: mastodon
79
+ instance: mastodon.social
80
+ tag: ai
68
81
  - type: rss
69
82
  feeds:
70
83
  - url: "https://feeds.bbci.co.uk/news/rss.xml"
@@ -101,33 +114,49 @@ Python. `run` prints **only** JSON to stdout (diagnostics go to stderr), so it p
101
114
  cleanly into `jq`:
102
115
 
103
116
  ```bash
104
- fetchkit run config.yaml # pretty JSON: {"count", "posts", "errors"}
117
+ fetchkit run config.yaml # pretty JSON: {"count", "posts", "sources", "errors"}
105
118
  fetchkit run config.yaml -o out.json # write JSON to a file (stdout stays empty)
106
119
  fetchkit run config.yaml --window "6h" # override the time window at runtime
107
120
  fetchkit run config.yaml --compact # single-line JSON
108
121
  fetchkit run config.yaml --fail-on-error # exit 1 if any source failed
109
- fetchkit validate config.yaml # validate a config without fetching
122
+ echo "$CONFIG_YAML" | fetchkit run - # read the config from stdin
123
+ fetchkit validate config.yaml # validate a config without fetching (also accepts -)
110
124
  fetchkit schema # JSON Schema for every config/output model
111
125
  python -m fetchkit run config.yaml # module form, identical behavior
112
126
  ```
113
127
 
128
+ `fetchkit run --help` and `fetchkit validate --help` print the YAML config format
129
+ inline (top-level keys, a minimal example, and the fetcher type list), so simple
130
+ configs need no schema round-trip.
131
+
114
132
  `fetchkit schema` lets an agent discover what it can fetch — it prints the JSON
115
133
  Schema (with field descriptions) for the top-level config, the shared HTTP
116
134
  settings, every builtin fetcher's typed config, and the canonical `Post` output —
117
- so a config can be written without knowing the YAML format out of band:
135
+ so a config can be written without knowing the YAML format out of band. Pass a
136
+ section name to print just that part instead of the full ~50 KB document:
118
137
 
119
138
  ```bash
120
- fetchkit schema | jq '.fetchers | keys' # ["arxiv","github","hackernews","lobsters","rss"]
139
+ fetchkit schema | jq '.fetchers | keys' # ["arxiv","bluesky","github","hackernews","lobsters","mastodon","rss","stackexchange"]
140
+ fetchkit schema config # just the top-level config schema
141
+ fetchkit schema rss # just one fetcher's config schema
142
+ fetchkit schema post # just the output Post model
121
143
  fetchkit schema -o schema.json # write to a file; supports --compact too
122
144
  ```
123
145
 
124
- Output shape:
146
+ Output shape — `sources` reports per-fetcher success/failure (one entry per
147
+ enabled fetcher, in config order), so partial failures are visible without
148
+ diffing domains against `posts`. A source can partially fail, e.g. one dead
149
+ feed among many: it keeps its posts, records the error, and reports `ok: false`.
125
150
 
126
151
  ```json
127
152
  {
128
153
  "count": 2,
129
154
  "posts": [ { "id": "...", "source": "rss", "title": "...", "url": "...", "created_at": "..." } ],
130
- "errors": [ { "source": "hackernews", "error": "..." } ]
155
+ "sources": [
156
+ { "source": "rss", "name": null, "ok": false, "posts": 2,
157
+ "errors": ["Failed to fetch feed https://dead.example/feed.xml: ..."] }
158
+ ],
159
+ "errors": [ { "source": "rss", "error": "Failed to fetch feed https://dead.example/feed.xml: ..." } ]
131
160
  }
132
161
  ```
133
162
 
@@ -152,11 +181,12 @@ result = collect_all(config)
152
181
 
153
182
  ## Discovering RSS feeds
154
183
 
155
- Every other source has a finite, nameable set of options (`fetchkit schema`
156
- enumerates them). RSS is the exception: a feed is an *arbitrary URL*, so an agent
157
- that wants "central-bank policy" or "rust programming" has no way to know which
158
- feeds exist. The optional `discovery` module closes that gap — give it a
184
+ RSS is the hardest source to discover for: a feed is an *arbitrary URL*, so an
185
+ agent that wants "central-bank policy" or "rust programming" has no way to know
186
+ which feeds exist. The optional `discovery` module closes that gap give it a
159
187
  use case, get back ranked feeds you can drop straight into an `rss` fetcher.
188
+ (The other sources have their own, simpler discovery surface — see
189
+ [Discoverability for every source](#discoverability-for-every-source) below.)
160
190
 
161
191
  ```python
162
192
  from fetchkit.discovery import discover, to_rss_config
@@ -213,6 +243,48 @@ not the live article stream — for the latter, fetch the feed with the `rss`
213
243
  fetcher and match individual posts. Catalog quality is the main lever on result
214
244
  quality; see `src/fetchkit/discovery/data/CATALOG.md` to extend it.
215
245
 
246
+ Because discovery only *ranks* candidates it already holds, a niche query (a
247
+ specific region, product, or community) can come back filled with generic feeds
248
+ that matched only the query's broad terms. The CLI flags this instead of letting
249
+ it pass: the JSON payload carries `unmatched_terms` (query terms no returned
250
+ feed mentions) plus a `hint`, and the same hint is printed to stderr. When you
251
+ see it, feed discovery better candidates — `--from-urls` with sites from your
252
+ own web search, `fetchkit find-feeds <site-url>`, or `--external`.
253
+
254
+ ## Discoverability for every source
255
+
256
+ `discover` solves RSS's "which feed URL?" problem. The other sources have a
257
+ *different* discoverability gap — *which tag / site / instance / feed do I put in
258
+ the config?* — answered by `fetchkit suggest <source>` (and `run_suggester()` in
259
+ Python). Each suggester is **no-auth** and returns JSON-ready rows:
260
+
261
+ ```bash
262
+ fetchkit suggest lobsters # all Lobsters tags
263
+ fetchkit suggest stackexchange --site stackoverflow # popular SO tags
264
+ fetchkit suggest stackexchange --what sites # available SE sites
265
+ fetchkit suggest arxiv --query vision # matching arXiv categories
266
+ fetchkit suggest github --query "language:rust stars:>5000" # popular repos to watch
267
+ fetchkit suggest mastodon --instance fosstodon.org # trending hashtags
268
+ fetchkit suggest bluesky # popular custom feeds
269
+ fetchkit suggest bluesky --what actors --query "ai" # accounts for an author_feed
270
+ fetchkit suggest rss --query "AI safety news" # delegates to discover()
271
+ ```
272
+
273
+ | Source | `suggest` returns | Fills config field |
274
+ |--------|-------------------|--------------------|
275
+ | `hackernews` | sort orders (static — HN has no tags) | `posts.order` |
276
+ | `rss` | ranked feeds (delegates to `discover`) | `feeds` |
277
+ | `arxiv` | category codes + names (static taxonomy) | `categories` |
278
+ | `github` | popular `owner/name` repos for a query | `repos` |
279
+ | `lobsters` | all tags (from `/tags.json`) | `tag` |
280
+ | `stackexchange` | popular tags, or sites (`--what sites`) | `tagged` / `site` |
281
+ | `bluesky` | popular feeds, or actors (`--what actors`) | `actor` |
282
+ | `mastodon` | trending hashtags on an instance | `tag` / `instance` |
283
+
284
+ > Most suggesters call the source's live API (all no-auth); `hackernews`/`arxiv`
285
+ > are static, and `rss` reuses the offline catalog ranker. Network suggesters fail
286
+ > gracefully (the CLI reports the error on stderr and exits non-zero).
287
+
216
288
  ## Configuration reference
217
289
 
218
290
  ### Top-level (`FetchKitConfig`)
@@ -229,7 +301,9 @@ Set the time window with **either** a relative `window` **or** both
229
301
  `start_time`/`end_time` — the two are mutually exclusive. **If you specify none of
230
302
  them, the window defaults to the last 24 hours.** `start_time <= end_time` is
231
303
  enforced, and unknown top-level keys are rejected. Per-fetcher
232
- `start_time`/`end_time` default to `None` and **inherit** the global window at runtime.
304
+ `start_time`/`end_time` default to `None` and **inherit** the run window from these
305
+ top-level keys at runtime (there is no `global:` key — the top level *is* the
306
+ global scope).
233
307
 
234
308
  #### Relative windows
235
309
 
@@ -310,6 +384,47 @@ The lobste.rs public JSON endpoints (no auth). Tags are preserved in `post.metad
310
384
  | `tag` | null | Restrict to a single tag (uses `/t/<tag>.json`) |
311
385
  | `max_items`| 50 | 1–200 |
312
386
 
387
+ ### Stack Exchange (`type: stackexchange`)
388
+
389
+ The Stack Exchange API (no auth; anonymous access is capped at 300 requests/day/IP).
390
+ Questions become `Post`s; with `comments.fetch: true`, top answers are attached as
391
+ `Comment`s. Tags, answer count, and the accepted-answer id are kept in `post.metadata`.
392
+
393
+ | Field | Default | Notes |
394
+ |------------|----------------|-------------------------------------------------------------|
395
+ | `site` | stackoverflow | API site, e.g. `serverfault`, `superuser`, `askubuntu` |
396
+ | `tagged` | `[]` | Questions carrying ALL of these tags (joined with `;`) |
397
+ | `query` | null | Free-text search (uses `/search/advanced`) |
398
+ | `posts` | — | `max_items` (1–500) and `order` (`top`→votes, `new`→creation) |
399
+ | `comments` | — | `fetch`/`max_items` — answers attached as comments |
400
+
401
+ ### Bluesky (`type: bluesky`)
402
+
403
+ The public Bluesky AppView (`public.api.bsky.app`), unauthenticated by design.
404
+ `uri`, `cid`, and `langs` are kept in `post.metadata`; likes map to `score` and
405
+ replies to `comment_count`.
406
+
407
+ | Field | Default | Notes |
408
+ |------------|---------|--------------------------------------------------------------|
409
+ | `resource` | search | `search` (full-text) or `author_feed` |
410
+ | `query` | null | Search query — required for `resource: search` |
411
+ | `actor` | null | Handle/DID — required for `resource: author_feed` |
412
+ | `max_items`| 50 | 1–500 (paginated, 100/page) |
413
+
414
+ ### Mastodon (`type: mastodon`)
415
+
416
+ Public and hashtag timelines on any instance (no auth, when the instance keeps
417
+ public preview enabled — otherwise requests return 401). HTML content is reduced to
418
+ plain text; tags, instance, and visibility are kept in `post.metadata`.
419
+
420
+ | Field | Default | Notes |
421
+ |------------|------------------|--------------------------------------------------------|
422
+ | `instance` | mastodon.social | Instance host without scheme, e.g. `fosstodon.org` |
423
+ | `resource` | tag | `tag` (`/timelines/tag/<tag>`) or `public` |
424
+ | `tag` | null | Hashtag without `#` — required for `resource: tag` |
425
+ | `local` | false | Restrict to statuses originating on this instance |
426
+ | `max_items`| 50 | 1–200 (paginated, 40/page) |
427
+
313
428
  ### HTTP (`http:`)
314
429
 
315
430
  | Field | Default | Notes |
@@ -342,6 +457,15 @@ validation, and tests. Add a new source via a PR or a local fork in four steps:
342
457
 
343
458
  3. Import the module in `src/fetchkit/fetchers/__init__.py` so it registers on import.
344
459
  4. Add tests (mock HTTP with the `responses` library — see `tests/fetchers/`).
460
+ 5. *(Optional)* register a discovery helper so agents can find your source's knobs:
461
+
462
+ ```python
463
+ from fetchkit.fetchers.suggest_registry import register_suggester
464
+
465
+ @register_suggester("mysource")
466
+ def suggest(*, query=None, limit=20, **kwargs) -> list[dict]:
467
+ return [{"tag": "…"}] # JSON-ready rows; surfaced via `fetchkit suggest mysource`
468
+ ```
345
469
 
346
470
  Use `Post.metadata` for any source-specific fields that don't map to the canonical
347
471
  columns.
@@ -352,6 +476,7 @@ columns.
352
476
  class Post(BaseModel):
353
477
  id: str # unique within source
354
478
  source: str # "hackernews" | "rss" | "arxiv" | "github" | "lobsters"
479
+ # | "stackexchange" | "bluesky" | "mastodon"
355
480
  title: str | None
356
481
  text: str | None # body / content
357
482
  url: str | None # external link
@@ -368,8 +493,9 @@ All datetimes are normalized to UTC. Posts are deduplicated by `(source, id)` an
368
493
  sorted descending by `(created_at, id)` for deterministic output.
369
494
 
370
495
  > **`score` is source-relative.** Each source defines it differently — Hacker News
371
- > points, Lobsters score, GitHub stars (for `search_repos`), and `None` for arXiv
372
- > and RSS. The values are **not comparable across sources**, so don't rank a mixed
496
+ > points, Lobsters score, GitHub stars (for `search_repos`), Stack Exchange question
497
+ > score, Bluesky likes, Mastodon favourites, and `None` for arXiv and RSS. The values
498
+ > are **not comparable across sources**, so don't rank a mixed
373
499
  > feed by `score` directly. Compare within a single `source`, or use a source-aware
374
500
  > ranking of your own. Output is ordered by recency (`created_at`), not by `score`.
375
501
 
@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
6
6
  # Distribution name on PyPI. The importable package and CLI are still `fetchkit`;
7
7
  # the `fetchkit` PyPI name was already taken by an unrelated project.
8
8
  name = "fetchkit-agents"
9
- version = "0.2.0"
9
+ version = "0.3.1"
10
10
  description = "A YAML-configured data fetching library for agentic applications."
11
11
  readme = "README.md"
12
12
  requires-python = ">=3.10"
@@ -2,9 +2,9 @@
2
2
  fetchkit — a YAML-configured data fetching library for agentic applications.
3
3
 
4
4
  Fetch posts and comments from sources (Hacker News, RSS/Atom, arXiv, GitHub,
5
- Lobsters) into a single canonical ``Post`` model, with deduplication, sorting,
6
- and a typed config. Designed as a data-collection layer for agentic / LLM
7
- applications.
5
+ Lobsters, Stack Exchange, Bluesky, Mastodon) into a single canonical ``Post``
6
+ model, with deduplication, sorting, and a typed config. Designed as a
7
+ data-collection layer for agentic / LLM applications.
8
8
 
9
9
  Quick start::
10
10
 
@@ -19,7 +19,7 @@ See the README for the full configuration reference and the guide to adding
19
19
  fetchers.
20
20
  """
21
21
 
22
- __version__ = "0.2.0"
22
+ __version__ = "0.3.1"
23
23
 
24
24
  from typing import TYPE_CHECKING, Any
25
25
 
@@ -27,6 +27,12 @@ from fetchkit.collector import collect_all
27
27
  from fetchkit.config_loader import load_config, ConfigError
28
28
  from fetchkit.fetchers.base import Fetcher, FetcherResult
29
29
  from fetchkit.fetchers.registry import register_fetcher, get_fetcher
30
+ from fetchkit.fetchers.suggest_registry import (
31
+ register_suggester,
32
+ get_suggester,
33
+ list_suggesters,
34
+ run_suggester,
35
+ )
30
36
  from fetchkit.http import (
31
37
  HttpClient,
32
38
  RateLimiter,
@@ -46,6 +52,9 @@ from fetchkit.schemas.fetcher import (
46
52
  ArxivFetchConfig,
47
53
  GitHubFetchConfig,
48
54
  LobstersFetchConfig,
55
+ StackExchangeFetchConfig,
56
+ BlueskyFetchConfig,
57
+ MastodonFetchConfig,
49
58
  FetcherConfig,
50
59
  )
51
60
  from fetchkit.schemas.collector import CollectorResult
@@ -85,6 +94,10 @@ __all__ = [
85
94
  "FetcherResult",
86
95
  "register_fetcher",
87
96
  "get_fetcher",
97
+ "register_suggester",
98
+ "get_suggester",
99
+ "list_suggesters",
100
+ "run_suggester",
88
101
  # HTTP
89
102
  "HttpClient",
90
103
  "RateLimiter",
@@ -105,6 +118,9 @@ __all__ = [
105
118
  "ArxivFetchConfig",
106
119
  "GitHubFetchConfig",
107
120
  "LobstersFetchConfig",
121
+ "StackExchangeFetchConfig",
122
+ "BlueskyFetchConfig",
123
+ "MastodonFetchConfig",
108
124
  "FetcherConfig",
109
125
  "CollectorResult",
110
126
  "FetchKitConfig",