fetchkit-agents 0.1.0__py3-none-any.whl
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.
- fetchkit/__init__.py +91 -0
- fetchkit/__main__.py +8 -0
- fetchkit/cli.py +169 -0
- fetchkit/collector.py +116 -0
- fetchkit/config_loader.py +59 -0
- fetchkit/fetchers/__init__.py +11 -0
- fetchkit/fetchers/arxiv.py +133 -0
- fetchkit/fetchers/base.py +31 -0
- fetchkit/fetchers/github.py +124 -0
- fetchkit/fetchers/hackernews.py +295 -0
- fetchkit/fetchers/lobsters.py +85 -0
- fetchkit/fetchers/registry.py +52 -0
- fetchkit/fetchers/rss.py +252 -0
- fetchkit/http/__init__.py +17 -0
- fetchkit/http/client.py +223 -0
- fetchkit/http/rate_limit.py +46 -0
- fetchkit/py.typed +0 -0
- fetchkit/schemas/__init__.py +40 -0
- fetchkit/schemas/base.py +27 -0
- fetchkit/schemas/collector.py +21 -0
- fetchkit/schemas/config.py +103 -0
- fetchkit/schemas/contracts.py +41 -0
- fetchkit/schemas/fetcher.py +157 -0
- fetchkit/schemas/post.py +100 -0
- fetchkit/utils/__init__.py +1 -0
- fetchkit/utils/time.py +95 -0
- fetchkit_agents-0.1.0.dist-info/METADATA +356 -0
- fetchkit_agents-0.1.0.dist-info/RECORD +32 -0
- fetchkit_agents-0.1.0.dist-info/WHEEL +5 -0
- fetchkit_agents-0.1.0.dist-info/entry_points.txt +2 -0
- fetchkit_agents-0.1.0.dist-info/licenses/LICENSE +21 -0
- fetchkit_agents-0.1.0.dist-info/top_level.txt +1 -0
fetchkit/__init__.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""
|
|
2
|
+
fetchkit — a YAML-configured data fetching library for agentic applications.
|
|
3
|
+
|
|
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.
|
|
8
|
+
|
|
9
|
+
Quick start::
|
|
10
|
+
|
|
11
|
+
from fetchkit import load_config, collect_all
|
|
12
|
+
|
|
13
|
+
config = load_config("config.yaml")
|
|
14
|
+
result = collect_all(config)
|
|
15
|
+
for post in result.posts:
|
|
16
|
+
print(post.title, post.url)
|
|
17
|
+
|
|
18
|
+
See the README for the full configuration reference and the guide to adding
|
|
19
|
+
fetchers.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
__version__ = "0.1.0"
|
|
23
|
+
|
|
24
|
+
from fetchkit.collector import collect_all
|
|
25
|
+
from fetchkit.config_loader import load_config, ConfigError
|
|
26
|
+
from fetchkit.fetchers.base import Fetcher, FetcherResult
|
|
27
|
+
from fetchkit.fetchers.registry import register_fetcher, get_fetcher
|
|
28
|
+
from fetchkit.http import (
|
|
29
|
+
HttpClient,
|
|
30
|
+
RateLimiter,
|
|
31
|
+
get_default_client,
|
|
32
|
+
set_default_client,
|
|
33
|
+
use_client,
|
|
34
|
+
)
|
|
35
|
+
from fetchkit.schemas.post import Post, Comment, Source
|
|
36
|
+
from fetchkit.schemas.fetcher import (
|
|
37
|
+
SortOrder,
|
|
38
|
+
PostFetchConfig,
|
|
39
|
+
CommentFetchConfig,
|
|
40
|
+
FetcherBase,
|
|
41
|
+
HackerNewsFetchConfig,
|
|
42
|
+
RSSFeedDescriptor,
|
|
43
|
+
RSSFetchConfig,
|
|
44
|
+
ArxivFetchConfig,
|
|
45
|
+
GitHubFetchConfig,
|
|
46
|
+
LobstersFetchConfig,
|
|
47
|
+
FetcherConfig,
|
|
48
|
+
)
|
|
49
|
+
from fetchkit.schemas.collector import CollectorResult
|
|
50
|
+
from fetchkit.schemas.config import FetchKitConfig, HttpConfig
|
|
51
|
+
from fetchkit.utils.time import resolve_window, parse_duration
|
|
52
|
+
|
|
53
|
+
__all__ = [
|
|
54
|
+
"__version__",
|
|
55
|
+
# Orchestration
|
|
56
|
+
"collect_all",
|
|
57
|
+
"load_config",
|
|
58
|
+
"ConfigError",
|
|
59
|
+
# Fetcher protocol & registry
|
|
60
|
+
"Fetcher",
|
|
61
|
+
"FetcherResult",
|
|
62
|
+
"register_fetcher",
|
|
63
|
+
"get_fetcher",
|
|
64
|
+
# HTTP
|
|
65
|
+
"HttpClient",
|
|
66
|
+
"RateLimiter",
|
|
67
|
+
"get_default_client",
|
|
68
|
+
"set_default_client",
|
|
69
|
+
"use_client",
|
|
70
|
+
# Schemas
|
|
71
|
+
"Post",
|
|
72
|
+
"Comment",
|
|
73
|
+
"Source",
|
|
74
|
+
"SortOrder",
|
|
75
|
+
"PostFetchConfig",
|
|
76
|
+
"CommentFetchConfig",
|
|
77
|
+
"FetcherBase",
|
|
78
|
+
"HackerNewsFetchConfig",
|
|
79
|
+
"RSSFeedDescriptor",
|
|
80
|
+
"RSSFetchConfig",
|
|
81
|
+
"ArxivFetchConfig",
|
|
82
|
+
"GitHubFetchConfig",
|
|
83
|
+
"LobstersFetchConfig",
|
|
84
|
+
"FetcherConfig",
|
|
85
|
+
"CollectorResult",
|
|
86
|
+
"FetchKitConfig",
|
|
87
|
+
"HttpConfig",
|
|
88
|
+
# Time helpers
|
|
89
|
+
"resolve_window",
|
|
90
|
+
"parse_duration",
|
|
91
|
+
]
|
fetchkit/__main__.py
ADDED
fetchkit/cli.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Command-line interface for fetchkit.
|
|
2
|
+
|
|
3
|
+
Designed so an agent (or a shell script) can fetch news deterministically from a
|
|
4
|
+
YAML spec and parse structured JSON from stdout::
|
|
5
|
+
|
|
6
|
+
fetchkit run config.yaml # JSON: {"posts": [...], "errors": [...]}
|
|
7
|
+
fetchkit run config.yaml -o out.json # write JSON to a file instead of stdout
|
|
8
|
+
fetchkit run config.yaml --fail-on-error # exit 1 if any source failed
|
|
9
|
+
fetchkit validate config.yaml # check a config without fetching
|
|
10
|
+
|
|
11
|
+
stdout carries only JSON (for ``run``) so it is safe to pipe into ``jq`` or parse
|
|
12
|
+
programmatically. Diagnostics and ``--verbose`` logging go to stderr.
|
|
13
|
+
|
|
14
|
+
Exit codes::
|
|
15
|
+
|
|
16
|
+
0 success
|
|
17
|
+
1 fetch completed but a source failed (only with --fail-on-error)
|
|
18
|
+
2 configuration error (missing file, invalid YAML, validation failure)
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import argparse
|
|
22
|
+
import json
|
|
23
|
+
import logging
|
|
24
|
+
import sys
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any, Optional, Sequence
|
|
27
|
+
|
|
28
|
+
from fetchkit import __version__
|
|
29
|
+
from fetchkit.collector import collect_all
|
|
30
|
+
from fetchkit.config_loader import ConfigError, load_config
|
|
31
|
+
from fetchkit.utils.time import resolve_window
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _emit_json(payload: Any, indent: Optional[int], output: Optional[str]) -> None:
|
|
35
|
+
"""Write a JSON document to ``output`` (a file path) or stdout.
|
|
36
|
+
|
|
37
|
+
When writing to a file, stdout stays empty so the file is the sole result
|
|
38
|
+
artifact; a one-line confirmation is logged to stderr.
|
|
39
|
+
"""
|
|
40
|
+
text = json.dumps(payload, indent=indent, ensure_ascii=False)
|
|
41
|
+
if output is None:
|
|
42
|
+
sys.stdout.write(text + "\n")
|
|
43
|
+
return
|
|
44
|
+
Path(output).expanduser().write_text(text + "\n", encoding="utf-8")
|
|
45
|
+
print(f"Wrote {payload['count']} post(s) to {output}", file=sys.stderr)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _cmd_run(args: argparse.Namespace) -> int:
|
|
49
|
+
"""Load a config, collect posts, and emit a JSON result document on stdout."""
|
|
50
|
+
try:
|
|
51
|
+
config = load_config(args.config)
|
|
52
|
+
except ConfigError as exc:
|
|
53
|
+
print(str(exc), file=sys.stderr)
|
|
54
|
+
return 2
|
|
55
|
+
|
|
56
|
+
# The window can be set two ways: in the YAML config itself (a `window:` key,
|
|
57
|
+
# mutually exclusive with start_time/end_time), or here on the command line.
|
|
58
|
+
# --window is a runtime override: it replaces whatever bounds the config
|
|
59
|
+
# resolved to, so you can reuse one config across different time ranges.
|
|
60
|
+
if args.window:
|
|
61
|
+
try:
|
|
62
|
+
config.start_time, config.end_time = resolve_window(args.window)
|
|
63
|
+
except ValueError as exc:
|
|
64
|
+
print(f"Invalid --window: {exc}", file=sys.stderr)
|
|
65
|
+
return 2
|
|
66
|
+
|
|
67
|
+
# Keep stdout pure JSON: progress/verbosity is logged to stderr, never printed.
|
|
68
|
+
result = collect_all(config)
|
|
69
|
+
|
|
70
|
+
payload = {
|
|
71
|
+
"count": len(result.posts),
|
|
72
|
+
"posts": [post.model_dump(mode="json") for post in result.posts],
|
|
73
|
+
"errors": [
|
|
74
|
+
{"source": source, "error": str(err)} for source, err in result.errors
|
|
75
|
+
],
|
|
76
|
+
}
|
|
77
|
+
indent = None if args.compact else args.indent
|
|
78
|
+
_emit_json(payload, indent, args.output)
|
|
79
|
+
|
|
80
|
+
if args.fail_on_error and result.has_errors:
|
|
81
|
+
return 1
|
|
82
|
+
return 0
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _cmd_validate(args: argparse.Namespace) -> int:
|
|
86
|
+
"""Load and validate a config without fetching; report the result."""
|
|
87
|
+
try:
|
|
88
|
+
config = load_config(args.config)
|
|
89
|
+
except ConfigError as exc:
|
|
90
|
+
print(str(exc), file=sys.stderr)
|
|
91
|
+
return 2
|
|
92
|
+
|
|
93
|
+
enabled = sum(1 for f in config.fetchers if f.enabled)
|
|
94
|
+
print(
|
|
95
|
+
f"OK: {args.config} is valid "
|
|
96
|
+
f"({len(config.fetchers)} fetcher(s), {enabled} enabled).",
|
|
97
|
+
file=sys.stderr,
|
|
98
|
+
)
|
|
99
|
+
return 0
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
103
|
+
"""Construct the top-level argument parser with ``run`` and ``validate``."""
|
|
104
|
+
parser = argparse.ArgumentParser(
|
|
105
|
+
prog="fetchkit",
|
|
106
|
+
description="Fetch news/posts deterministically from a YAML spec.",
|
|
107
|
+
)
|
|
108
|
+
parser.add_argument(
|
|
109
|
+
"--version", action="version", version=f"fetchkit {__version__}"
|
|
110
|
+
)
|
|
111
|
+
parser.add_argument(
|
|
112
|
+
"-v", "--verbose", action="store_true",
|
|
113
|
+
help="Enable INFO-level logging to stderr.",
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
# Define the two subcommands (`fetchkit run ...` / `fetchkit validate ...`).
|
|
117
|
+
# `dest="command"` records which one was chosen; `required=True` makes picking
|
|
118
|
+
# a subcommand mandatory (running bare `fetchkit` errors with usage help).
|
|
119
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
120
|
+
|
|
121
|
+
run = sub.add_parser("run", help="Fetch posts and print JSON to stdout.")
|
|
122
|
+
run.add_argument("config", help="Path to the YAML config file.")
|
|
123
|
+
run.add_argument(
|
|
124
|
+
"-o", "--output", default=None, metavar="PATH",
|
|
125
|
+
help="Write JSON to this file instead of stdout.",
|
|
126
|
+
)
|
|
127
|
+
run.add_argument(
|
|
128
|
+
"--window", default=None, metavar="SPEC",
|
|
129
|
+
help="Override the config time window, e.g. 'last 6 hours', 'yesterday', '7d'.",
|
|
130
|
+
)
|
|
131
|
+
run.add_argument(
|
|
132
|
+
"--indent", type=int, default=2,
|
|
133
|
+
help="JSON indentation for pretty output (default: 2).",
|
|
134
|
+
)
|
|
135
|
+
run.add_argument(
|
|
136
|
+
"--compact", action="store_true",
|
|
137
|
+
help="Emit single-line JSON (overrides --indent).",
|
|
138
|
+
)
|
|
139
|
+
run.add_argument(
|
|
140
|
+
"--fail-on-error", action="store_true",
|
|
141
|
+
help="Exit 1 if any source reported an error.",
|
|
142
|
+
)
|
|
143
|
+
run.set_defaults(func=_cmd_run)
|
|
144
|
+
|
|
145
|
+
validate = sub.add_parser("validate", help="Validate a config without fetching.")
|
|
146
|
+
validate.add_argument("config", help="Path to the YAML config file.")
|
|
147
|
+
validate.set_defaults(func=_cmd_validate)
|
|
148
|
+
|
|
149
|
+
return parser
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
153
|
+
"""CLI entry point. Returns a process exit code."""
|
|
154
|
+
parser = build_parser()
|
|
155
|
+
args = parser.parse_args(argv)
|
|
156
|
+
|
|
157
|
+
if args.verbose:
|
|
158
|
+
logging.basicConfig(
|
|
159
|
+
level=logging.INFO,
|
|
160
|
+
stream=sys.stderr,
|
|
161
|
+
format="%(levelname)s %(name)s: %(message)s",
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
exit_code: int = args.func(args)
|
|
165
|
+
return exit_code
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
if __name__ == "__main__": # pragma: no cover
|
|
169
|
+
sys.exit(main())
|
fetchkit/collector.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unified fetcher orchestrator.
|
|
3
|
+
|
|
4
|
+
Orchestrates fetching from multiple data sources (Hacker News, RSS, etc.)
|
|
5
|
+
using a single top-level configuration. Preserves three core invariants:
|
|
6
|
+
|
|
7
|
+
1. Per-fetcher time windows inherit the global window when omitted.
|
|
8
|
+
2. Posts are de-duplicated by ``(source, id)`` (first occurrence wins).
|
|
9
|
+
3. Posts are sorted descending by ``(created_at or UTC_MIN, id)`` for determinism.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
from contextlib import nullcontext
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
from fetchkit.schemas.post import Post
|
|
17
|
+
from fetchkit.schemas.config import FetchKitConfig
|
|
18
|
+
from fetchkit.fetchers.registry import get_fetcher
|
|
19
|
+
from fetchkit.utils.time import UTC_MIN
|
|
20
|
+
from fetchkit.schemas.collector import CollectorResult
|
|
21
|
+
from fetchkit.http import HttpClient, use_client
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def collect_all(
|
|
27
|
+
config: FetchKitConfig,
|
|
28
|
+
verbose: bool = False,
|
|
29
|
+
http_client: Optional[HttpClient] = None,
|
|
30
|
+
) -> CollectorResult:
|
|
31
|
+
"""
|
|
32
|
+
Collect posts from all enabled data sources.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
config: Top-level FetchKitConfig with global time window and fetcher settings.
|
|
36
|
+
verbose: When True, print per-source progress to stdout (off by default for
|
|
37
|
+
library use).
|
|
38
|
+
http_client: Optional shared HTTP client to install for this run. When
|
|
39
|
+
provided, it becomes the active default client used by fetchers for the
|
|
40
|
+
duration of the call. When None and ``config.http`` is set, a client is
|
|
41
|
+
built from it; otherwise the built-in default client is used.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
CollectorResult containing posts and any errors that occurred.
|
|
45
|
+
Callers should check result.has_errors to detect partial failures.
|
|
46
|
+
"""
|
|
47
|
+
all_posts: list[Post] = []
|
|
48
|
+
errors: list[tuple[str, Exception]] = []
|
|
49
|
+
|
|
50
|
+
# Resolve the HTTP client for this run: the explicit override, else one built
|
|
51
|
+
# from config.http, else None (fall back to the ambient default). When we do
|
|
52
|
+
# have a run-specific client, install it thread-locally for the duration of
|
|
53
|
+
# the run via use_client() so concurrent collect_all() calls never clobber
|
|
54
|
+
# each other or a caller's set_default_client().
|
|
55
|
+
run_client: Optional[HttpClient] = http_client
|
|
56
|
+
if run_client is None and config.http is not None:
|
|
57
|
+
run_client = HttpClient(config.http)
|
|
58
|
+
client_ctx = use_client(run_client) if run_client is not None else nullcontext()
|
|
59
|
+
|
|
60
|
+
def _log(msg: str) -> None:
|
|
61
|
+
if verbose:
|
|
62
|
+
print(msg)
|
|
63
|
+
|
|
64
|
+
def _record_error(source: str, err: Exception) -> None:
|
|
65
|
+
"""Track a source failure and emit matching log/console diagnostics."""
|
|
66
|
+
errors.append((source, err))
|
|
67
|
+
logger.error("Failed to fetch from %s: %s", source, err)
|
|
68
|
+
_log(f"Error: Failed to fetch from {source}: {err}")
|
|
69
|
+
|
|
70
|
+
with client_ctx:
|
|
71
|
+
for fetcher_config in config.fetchers:
|
|
72
|
+
if not fetcher_config.enabled:
|
|
73
|
+
continue
|
|
74
|
+
|
|
75
|
+
source_name = fetcher_config.type
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
# Inherit global time window if not set
|
|
79
|
+
f_config = fetcher_config.model_copy()
|
|
80
|
+
if f_config.start_time is None:
|
|
81
|
+
f_config.start_time = config.start_time
|
|
82
|
+
if f_config.end_time is None:
|
|
83
|
+
f_config.end_time = config.end_time
|
|
84
|
+
|
|
85
|
+
fetcher = get_fetcher(f_config.type)
|
|
86
|
+
result = fetcher(f_config)
|
|
87
|
+
|
|
88
|
+
msg_name = f_config.name if f_config.name else f_config.type
|
|
89
|
+
_log(f"Fetched {len(result.posts)} posts from {msg_name}")
|
|
90
|
+
|
|
91
|
+
all_posts.extend(result.posts)
|
|
92
|
+
|
|
93
|
+
for err in result.errors:
|
|
94
|
+
_record_error(source_name, err)
|
|
95
|
+
|
|
96
|
+
except Exception as e:
|
|
97
|
+
_record_error(source_name, e)
|
|
98
|
+
|
|
99
|
+
# Dedup by (source, id) for maximum safety
|
|
100
|
+
seen_keys = set()
|
|
101
|
+
unique_posts = []
|
|
102
|
+
|
|
103
|
+
for post in all_posts:
|
|
104
|
+
key = (post.source, post.id)
|
|
105
|
+
if key not in seen_keys:
|
|
106
|
+
seen_keys.add(key)
|
|
107
|
+
unique_posts.append(post)
|
|
108
|
+
|
|
109
|
+
# Sort by created_at DESC, then by source-specific ID for determinism
|
|
110
|
+
sorted_posts = sorted(
|
|
111
|
+
unique_posts,
|
|
112
|
+
key=lambda x: (x.created_at or UTC_MIN, x.id),
|
|
113
|
+
reverse=True,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
return CollectorResult(posts=sorted_posts, errors=errors)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Load and validate YAML fetcher configuration into typed fetchkit models."""
|
|
2
|
+
|
|
3
|
+
import yaml
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from pydantic import ValidationError
|
|
6
|
+
from fetchkit.schemas.config import FetchKitConfig
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ConfigError(Exception):
|
|
10
|
+
"""Base exception for configuration loading errors."""
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def load_config(path: str) -> FetchKitConfig:
|
|
15
|
+
"""
|
|
16
|
+
Load a FetchKitConfig from a YAML file.
|
|
17
|
+
|
|
18
|
+
The YAML schema is fetchers-only::
|
|
19
|
+
|
|
20
|
+
start_time: "2026-01-31T00:00:00Z"
|
|
21
|
+
end_time: "2026-02-01T00:00:00Z"
|
|
22
|
+
fetchers:
|
|
23
|
+
- type: hackernews
|
|
24
|
+
posts: { max_items: 50, order: new }
|
|
25
|
+
- type: rss
|
|
26
|
+
feeds:
|
|
27
|
+
- url: "https://example.com/rss"
|
|
28
|
+
http: # optional
|
|
29
|
+
timeout: 15
|
|
30
|
+
max_retries: 5
|
|
31
|
+
|
|
32
|
+
Raises:
|
|
33
|
+
ConfigError: If the file is missing, malformed YAML, or fails validation.
|
|
34
|
+
"""
|
|
35
|
+
config_path = Path(path).expanduser()
|
|
36
|
+
if not config_path.exists():
|
|
37
|
+
raise ConfigError(f"Config file not found: {path}")
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
raw_config = yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
|
41
|
+
except yaml.YAMLError as e:
|
|
42
|
+
raise ConfigError(f"Error parsing YAML in {path}: {e}")
|
|
43
|
+
except Exception as e:
|
|
44
|
+
raise ConfigError(f"Error reading config file {path}: {e}")
|
|
45
|
+
|
|
46
|
+
if raw_config is None:
|
|
47
|
+
raw_config = {}
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
return FetchKitConfig.model_validate(raw_config)
|
|
51
|
+
except ValidationError as e:
|
|
52
|
+
# Format Pydantic errors for better CLI readability
|
|
53
|
+
error_messages = []
|
|
54
|
+
for error in e.errors():
|
|
55
|
+
loc = ".".join(str(part) for part in error["loc"])
|
|
56
|
+
msg = error["msg"]
|
|
57
|
+
error_messages.append(f" - {loc}: {msg}")
|
|
58
|
+
detail = "\n".join(error_messages)
|
|
59
|
+
raise ConfigError(f"Config validation failed for {path}:\n{detail}") from e
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Fetcher package exports and builtin fetcher registration side effects."""
|
|
2
|
+
|
|
3
|
+
from .registry import get_fetcher, register_fetcher
|
|
4
|
+
# Import built-in fetchers to ensure they register themselves on import.
|
|
5
|
+
from . import hackernews
|
|
6
|
+
from . import rss
|
|
7
|
+
from . import arxiv
|
|
8
|
+
from . import github
|
|
9
|
+
from . import lobsters
|
|
10
|
+
|
|
11
|
+
__all__ = ["get_fetcher", "register_fetcher"]
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""
|
|
2
|
+
arXiv fetcher.
|
|
3
|
+
|
|
4
|
+
Fetches papers from the arXiv export API (https://export.arxiv.org/api/query),
|
|
5
|
+
which returns Atom XML — parsed with feedparser, already a fetchkit dependency.
|
|
6
|
+
Multi-valued detail (all authors, categories, DOI, PDF link) is preserved in
|
|
7
|
+
``Post.metadata``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from typing import Any, Optional
|
|
13
|
+
|
|
14
|
+
import feedparser
|
|
15
|
+
|
|
16
|
+
from fetchkit.http import get_default_client
|
|
17
|
+
from fetchkit.schemas.post import Post, Source
|
|
18
|
+
from fetchkit.schemas.fetcher import ArxivFetchConfig, FetcherConfig
|
|
19
|
+
from fetchkit.fetchers.base import FetcherResult
|
|
20
|
+
from fetchkit.fetchers.registry import register_fetcher
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
ARXIV_API_URL = "https://export.arxiv.org/api/query"
|
|
25
|
+
SOURCE_NAME = Source.ARXIV
|
|
26
|
+
DEFAULT_TIMEOUT_S = 30 # arXiv can be slow
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _build_search_query(config: ArxivFetchConfig) -> str:
|
|
30
|
+
"""Combine categories and free-text query into an arXiv ``search_query``."""
|
|
31
|
+
clauses: list[str] = []
|
|
32
|
+
if config.categories:
|
|
33
|
+
cats = " OR ".join(f"cat:{c}" for c in config.categories)
|
|
34
|
+
clauses.append(f"({cats})")
|
|
35
|
+
if config.query:
|
|
36
|
+
clauses.append(f"all:{config.query}")
|
|
37
|
+
return " AND ".join(clauses) if clauses else "all:*"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _parse_published(entry: Any) -> Optional[datetime]:
|
|
41
|
+
"""Extract the published date (UTC) from a feedparser entry."""
|
|
42
|
+
for attr in ("published_parsed", "updated_parsed"):
|
|
43
|
+
st = getattr(entry, attr, None)
|
|
44
|
+
if st:
|
|
45
|
+
return datetime(st[0], st[1], st[2], st[3], st[4], st[5], tzinfo=timezone.utc)
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _pdf_link(entry: Any) -> Optional[str]:
|
|
50
|
+
"""Return the PDF link for an entry, if present."""
|
|
51
|
+
for link in getattr(entry, "links", []) or []:
|
|
52
|
+
if getattr(link, "type", None) == "application/pdf" or getattr(link, "title", None) == "pdf":
|
|
53
|
+
return str(getattr(link, "href", "")) or None
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _normalize_entry(entry: Any) -> Optional[Post]:
|
|
58
|
+
"""Convert a feedparser arXiv entry into a canonical Post."""
|
|
59
|
+
created_at = _parse_published(entry)
|
|
60
|
+
abs_url = str(getattr(entry, "id", "")) or None # arXiv abs page
|
|
61
|
+
if not abs_url:
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
# arXiv id is the trailing path segment of the abs URL (e.g. 2401.12345v1).
|
|
65
|
+
arxiv_id = abs_url.rstrip("/").rsplit("/", 1)[-1]
|
|
66
|
+
|
|
67
|
+
authors = [str(a.get("name")) for a in getattr(entry, "authors", []) if a.get("name")]
|
|
68
|
+
categories = [str(t.get("term")) for t in getattr(entry, "tags", []) if t.get("term")]
|
|
69
|
+
|
|
70
|
+
metadata: dict[str, Any] = {}
|
|
71
|
+
if authors:
|
|
72
|
+
metadata["authors"] = authors
|
|
73
|
+
if categories:
|
|
74
|
+
metadata["categories"] = categories
|
|
75
|
+
if getattr(entry, "arxiv_doi", None):
|
|
76
|
+
metadata["doi"] = str(entry.arxiv_doi)
|
|
77
|
+
pdf = _pdf_link(entry)
|
|
78
|
+
if pdf:
|
|
79
|
+
metadata["pdf_url"] = pdf
|
|
80
|
+
if getattr(entry, "arxiv_primary_category", None):
|
|
81
|
+
primary = entry.arxiv_primary_category.get("term") if isinstance(entry.arxiv_primary_category, dict) else None
|
|
82
|
+
if primary:
|
|
83
|
+
metadata["primary_category"] = str(primary)
|
|
84
|
+
|
|
85
|
+
return Post(
|
|
86
|
+
id=arxiv_id,
|
|
87
|
+
source=SOURCE_NAME,
|
|
88
|
+
title=getattr(entry, "title", None),
|
|
89
|
+
text=getattr(entry, "summary", None),
|
|
90
|
+
url=pdf or abs_url,
|
|
91
|
+
author=", ".join(authors) if authors else None,
|
|
92
|
+
created_at=created_at,
|
|
93
|
+
source_url=abs_url,
|
|
94
|
+
metadata=metadata,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def fetch_posts(config: ArxivFetchConfig) -> list[Post]:
|
|
99
|
+
"""Fetch papers from arXiv matching the configured categories/query."""
|
|
100
|
+
params = {
|
|
101
|
+
"search_query": _build_search_query(config),
|
|
102
|
+
"start": 0,
|
|
103
|
+
"max_results": config.max_items,
|
|
104
|
+
"sortBy": "submittedDate",
|
|
105
|
+
"sortOrder": "descending",
|
|
106
|
+
}
|
|
107
|
+
client = get_default_client()
|
|
108
|
+
response = client.get(ARXIV_API_URL, params=params, timeout=DEFAULT_TIMEOUT_S)
|
|
109
|
+
response.raise_for_status()
|
|
110
|
+
|
|
111
|
+
feed = feedparser.parse(response.content)
|
|
112
|
+
posts: list[Post] = []
|
|
113
|
+
for entry in feed.entries:
|
|
114
|
+
post = _normalize_entry(entry)
|
|
115
|
+
if post is None:
|
|
116
|
+
continue
|
|
117
|
+
# Window filtering (datetimes are UTC-normalized by the schema).
|
|
118
|
+
if config.start_time is not None and config.end_time is not None:
|
|
119
|
+
if post.created_at is None or not (config.start_time <= post.created_at <= config.end_time):
|
|
120
|
+
continue
|
|
121
|
+
posts.append(post)
|
|
122
|
+
return posts
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@register_fetcher("arxiv")
|
|
126
|
+
def fetch(config: FetcherConfig) -> FetcherResult:
|
|
127
|
+
"""Fetcher protocol implementation for arXiv."""
|
|
128
|
+
if not isinstance(config, ArxivFetchConfig):
|
|
129
|
+
raise ValueError(f"Invalid config type for arxiv fetcher: {type(config)}")
|
|
130
|
+
try:
|
|
131
|
+
return FetcherResult(posts=fetch_posts(config), errors=[])
|
|
132
|
+
except Exception as e:
|
|
133
|
+
return FetcherResult(posts=[], errors=[e])
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Base protocol for data fetchers.
|
|
3
|
+
"""
|
|
4
|
+
from typing import Protocol, runtime_checkable
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from fetchkit.schemas.post import Post
|
|
7
|
+
from fetchkit.schemas.fetcher import FetcherConfig
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class FetcherResult:
|
|
12
|
+
"""Result from a fetch operation."""
|
|
13
|
+
posts: list[Post] = field(default_factory=list)
|
|
14
|
+
errors: list[Exception] = field(default_factory=list)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@runtime_checkable
|
|
18
|
+
class Fetcher(Protocol):
|
|
19
|
+
"""Protocol that all fetchers must implement."""
|
|
20
|
+
|
|
21
|
+
def __call__(self, config: FetcherConfig) -> FetcherResult:
|
|
22
|
+
"""
|
|
23
|
+
Execute the fetch operation.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
config: The configuration for this specific fetcher instance.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
FetcherResult containing fetched posts and any errors.
|
|
30
|
+
"""
|
|
31
|
+
...
|