crowdsonar 0.1.2__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.
@@ -0,0 +1,14 @@
1
+ # Secrets & credentials — never commit
2
+ .env
3
+ *.key
4
+
5
+ # Python
6
+ __pycache__/
7
+ *.pyc
8
+ .venv/
9
+
10
+ # Data — platform-derived content stays local
11
+ data/
12
+
13
+ # OS noise
14
+ .DS_Store
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 shrwnsan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,134 @@
1
+ Metadata-Version: 2.5
2
+ Name: crowdsonar
3
+ Version: 0.1.2
4
+ Summary: Self-hosted social signal synthesis engine — ingest, classify, and synthesize community chatter into actionable briefings.
5
+ License: MIT
6
+ License-File: LICENSE
7
+ Keywords: briefings,reddit,self-hosted,signal-intelligence,social-listening
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: Communications :: Chat
13
+ Classifier: Topic :: Office/Business :: News/Diary
14
+ Requires-Python: >=3.10
15
+ Requires-Dist: duckdb>=1.5
16
+ Requires-Dist: httpx>=0.28
17
+ Requires-Dist: praw>=8.0
18
+ Requires-Dist: pyarrow>=25.0
19
+ Requires-Dist: pyyaml>=6.0
20
+ Description-Content-Type: text/markdown
21
+
22
+ # CrowdSonar
23
+
24
+ Self-hosted social signal synthesis engine. Ingest posts and comments from social platforms (Reddit first), classify them by signal type, sentiment, and strength, cluster themes, and synthesize actionable intelligence into briefings.
25
+
26
+ Point it at any community conversation with a YAML topic config — subreddits, keywords, taxonomy — and get a decision-ready briefing instead of a raw firehose.
27
+
28
+ ## How It Works
29
+
30
+ ```
31
+ TOPIC CONFIG ──▶ INGEST ──▶ CLASSIFY ──▶ SYNTHESIZE ──▶ DELIVER
32
+ (YAML) Arctic via any rank + terminal
33
+ Shift API OpenAI- cluster briefing
34
+ compatible
35
+ endpoint
36
+ ```
37
+
38
+ ## CLI Usage
39
+
40
+ ```
41
+ crowdsonar --topic <name> [flags]
42
+ ```
43
+
44
+ | Flag | What it does |
45
+ |------|--------------|
46
+ | `--topic`, `-t` | Topic config to run (from `src/crowdsonar/configs/` or `--config-dir`) |
47
+ | `--list`, `-l` | List available topics |
48
+ | *(default run)* | Full pipeline: ingest → classify → synthesize |
49
+ | `--dry-run` | Ingest only — skip classification and synthesis |
50
+ | `--source` | Ingestion source: `arctic` (default, credential-free), `praw`, or `auto` |
51
+ | `--backfill` | One-off deep backfill — widens the ingest window to `backfill_days` from the topic config |
52
+ | `--classify-only` | Only classify already-stored data |
53
+ | `--synthesize-only` | Only generate a briefing from classified data |
54
+ | `--model` | Classification model (default: `gpt-4o-mini`) |
55
+ | `--synth-model` | Synthesis model (default: same as `--model`) |
56
+ | `--config-dir` | Override the configs directory |
57
+
58
+ ## Quick Start
59
+
60
+ Requires Python 3.10+ (developed on 3.13).
61
+
62
+ ```bash
63
+ # 1. Create a venv and install
64
+ python3 -m venv .venv
65
+ .venv/bin/pip install -e .
66
+
67
+ # 2. Configure credentials (env vars or .env)
68
+ export OPENAI_API_KEY="..." # required — LLM access for classify + synthesize
69
+ export OPENAI_BASE_URL="..." # optional — any OpenAI-compatible endpoint
70
+ export REDDIT_CLIENT_ID="..." # optional — enables real-time PRAW ingest
71
+ export REDDIT_CLIENT_SECRET="..." # optional
72
+
73
+ # 3. Run
74
+ crowdsonar --list
75
+ crowdsonar --topic example
76
+ ```
77
+
78
+ Default ingestion is the **Arctic Shift API** — unauthenticated, no Reddit credentials needed. PRAW real-time ingest activates automatically if `REDDIT_CLIENT_ID`/`REDDIT_CLIENT_SECRET` are set (or force it with `--source praw`).
79
+
80
+ Any OpenAI-compatible chat-completions endpoint works for classification and synthesis — point `OPENAI_BASE_URL` at Mistral, DeepSeek, OpenRouter, a local llama.cpp server, anything speaking the OpenAI protocol.
81
+
82
+ ## Environment Variables
83
+
84
+ | Variable | Required | Default | Purpose |
85
+ |----------|----------|---------|---------|
86
+ | `OPENAI_API_KEY` | yes | — | LLM API key (classification + synthesis) |
87
+ | `OPENAI_BASE_URL` | no | `https://api.openai.com/v1` | Any OpenAI-compatible chat-completions endpoint |
88
+ | `REDDIT_CLIENT_ID` | no | — | Reddit OAuth app ID — enables real-time PRAW ingest |
89
+ | `REDDIT_CLIENT_SECRET` | no | — | Reddit OAuth app secret |
90
+
91
+ Data is written to `./data/` relative to where you run the command; override with `CROWDSONAR_DATA_DIR`.
92
+
93
+ ## Dependencies
94
+
95
+ Managed via `pyproject.toml` (`pip install -e .`):
96
+
97
+ | Package | Role |
98
+ |---------|------|
99
+ | `praw` | Reddit API ingest (optional overlay) |
100
+ | `duckdb` | Signal store queries |
101
+ | `pyarrow` | Parquet storage |
102
+ | `httpx` | Arctic Shift / LLM HTTP client |
103
+ | `pyyaml` | Topic config loading |
104
+
105
+ ## Storage
106
+
107
+ DuckDB + Parquet. One `signals.parquet` per topic/run; DuckDB queries across them. Analytical, not transactional.
108
+
109
+ ## Adding a Topic
110
+
111
+ Point `--config-dir` at a directory of YAML files (or drop one into `src/crowdsonar/configs/`), then change the name, subreddits, keywords, and taxonomy. No code changes — the pipeline is config-driven.
112
+
113
+ ## Project Layout
114
+
115
+ ```
116
+ src/crowdsonar/
117
+ cli.py # console entry point (ingest → classify → synthesize)
118
+ config.py # topic YAML loader (importlib.resources; --config-dir override)
119
+ ingest.py # PRAW ingestion (optional overlay)
120
+ arctic_shift.py # Arctic Shift API adapter (default source)
121
+ classify.py # LLM classification (signal type, sentiment, strength)
122
+ storage.py # DuckDB + Parquet signal store
123
+ synthesize.py # briefing generation
124
+ configs/ # bundled topic configs (example.yaml)
125
+ prompts/ # classifier + synthesizer prompt templates (packaged data)
126
+ ```
127
+
128
+ ## Status
129
+
130
+ Alpha — the full ingest → classify → synthesize pipeline runs end-to-end via Arctic Shift (credential-free). Roadmap: cron briefing delivery, threshold alerts, additional sources (HN, Bluesky, RSS).
131
+
132
+ ## License
133
+
134
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,113 @@
1
+ # CrowdSonar
2
+
3
+ Self-hosted social signal synthesis engine. Ingest posts and comments from social platforms (Reddit first), classify them by signal type, sentiment, and strength, cluster themes, and synthesize actionable intelligence into briefings.
4
+
5
+ Point it at any community conversation with a YAML topic config — subreddits, keywords, taxonomy — and get a decision-ready briefing instead of a raw firehose.
6
+
7
+ ## How It Works
8
+
9
+ ```
10
+ TOPIC CONFIG ──▶ INGEST ──▶ CLASSIFY ──▶ SYNTHESIZE ──▶ DELIVER
11
+ (YAML) Arctic via any rank + terminal
12
+ Shift API OpenAI- cluster briefing
13
+ compatible
14
+ endpoint
15
+ ```
16
+
17
+ ## CLI Usage
18
+
19
+ ```
20
+ crowdsonar --topic <name> [flags]
21
+ ```
22
+
23
+ | Flag | What it does |
24
+ |------|--------------|
25
+ | `--topic`, `-t` | Topic config to run (from `src/crowdsonar/configs/` or `--config-dir`) |
26
+ | `--list`, `-l` | List available topics |
27
+ | *(default run)* | Full pipeline: ingest → classify → synthesize |
28
+ | `--dry-run` | Ingest only — skip classification and synthesis |
29
+ | `--source` | Ingestion source: `arctic` (default, credential-free), `praw`, or `auto` |
30
+ | `--backfill` | One-off deep backfill — widens the ingest window to `backfill_days` from the topic config |
31
+ | `--classify-only` | Only classify already-stored data |
32
+ | `--synthesize-only` | Only generate a briefing from classified data |
33
+ | `--model` | Classification model (default: `gpt-4o-mini`) |
34
+ | `--synth-model` | Synthesis model (default: same as `--model`) |
35
+ | `--config-dir` | Override the configs directory |
36
+
37
+ ## Quick Start
38
+
39
+ Requires Python 3.10+ (developed on 3.13).
40
+
41
+ ```bash
42
+ # 1. Create a venv and install
43
+ python3 -m venv .venv
44
+ .venv/bin/pip install -e .
45
+
46
+ # 2. Configure credentials (env vars or .env)
47
+ export OPENAI_API_KEY="..." # required — LLM access for classify + synthesize
48
+ export OPENAI_BASE_URL="..." # optional — any OpenAI-compatible endpoint
49
+ export REDDIT_CLIENT_ID="..." # optional — enables real-time PRAW ingest
50
+ export REDDIT_CLIENT_SECRET="..." # optional
51
+
52
+ # 3. Run
53
+ crowdsonar --list
54
+ crowdsonar --topic example
55
+ ```
56
+
57
+ Default ingestion is the **Arctic Shift API** — unauthenticated, no Reddit credentials needed. PRAW real-time ingest activates automatically if `REDDIT_CLIENT_ID`/`REDDIT_CLIENT_SECRET` are set (or force it with `--source praw`).
58
+
59
+ Any OpenAI-compatible chat-completions endpoint works for classification and synthesis — point `OPENAI_BASE_URL` at Mistral, DeepSeek, OpenRouter, a local llama.cpp server, anything speaking the OpenAI protocol.
60
+
61
+ ## Environment Variables
62
+
63
+ | Variable | Required | Default | Purpose |
64
+ |----------|----------|---------|---------|
65
+ | `OPENAI_API_KEY` | yes | — | LLM API key (classification + synthesis) |
66
+ | `OPENAI_BASE_URL` | no | `https://api.openai.com/v1` | Any OpenAI-compatible chat-completions endpoint |
67
+ | `REDDIT_CLIENT_ID` | no | — | Reddit OAuth app ID — enables real-time PRAW ingest |
68
+ | `REDDIT_CLIENT_SECRET` | no | — | Reddit OAuth app secret |
69
+
70
+ Data is written to `./data/` relative to where you run the command; override with `CROWDSONAR_DATA_DIR`.
71
+
72
+ ## Dependencies
73
+
74
+ Managed via `pyproject.toml` (`pip install -e .`):
75
+
76
+ | Package | Role |
77
+ |---------|------|
78
+ | `praw` | Reddit API ingest (optional overlay) |
79
+ | `duckdb` | Signal store queries |
80
+ | `pyarrow` | Parquet storage |
81
+ | `httpx` | Arctic Shift / LLM HTTP client |
82
+ | `pyyaml` | Topic config loading |
83
+
84
+ ## Storage
85
+
86
+ DuckDB + Parquet. One `signals.parquet` per topic/run; DuckDB queries across them. Analytical, not transactional.
87
+
88
+ ## Adding a Topic
89
+
90
+ Point `--config-dir` at a directory of YAML files (or drop one into `src/crowdsonar/configs/`), then change the name, subreddits, keywords, and taxonomy. No code changes — the pipeline is config-driven.
91
+
92
+ ## Project Layout
93
+
94
+ ```
95
+ src/crowdsonar/
96
+ cli.py # console entry point (ingest → classify → synthesize)
97
+ config.py # topic YAML loader (importlib.resources; --config-dir override)
98
+ ingest.py # PRAW ingestion (optional overlay)
99
+ arctic_shift.py # Arctic Shift API adapter (default source)
100
+ classify.py # LLM classification (signal type, sentiment, strength)
101
+ storage.py # DuckDB + Parquet signal store
102
+ synthesize.py # briefing generation
103
+ configs/ # bundled topic configs (example.yaml)
104
+ prompts/ # classifier + synthesizer prompt templates (packaged data)
105
+ ```
106
+
107
+ ## Status
108
+
109
+ Alpha — the full ingest → classify → synthesize pipeline runs end-to-end via Arctic Shift (credential-free). Roadmap: cron briefing delivery, threshold alerts, additional sources (HN, Bluesky, RSS).
110
+
111
+ ## License
112
+
113
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,33 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "crowdsonar"
7
+ version = "0.1.2"
8
+ description = "Self-hosted social signal synthesis engine — ingest, classify, and synthesize community chatter into actionable briefings."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ keywords = ["social-listening", "reddit", "signal-intelligence", "briefings", "self-hosted"]
13
+ classifiers = [
14
+ "Development Status :: 3 - Alpha",
15
+ "Intended Audience :: Developers",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Programming Language :: Python :: 3",
18
+ "Topic :: Communications :: Chat",
19
+ "Topic :: Office/Business :: News/Diary",
20
+ ]
21
+ dependencies = [
22
+ "praw>=8.0",
23
+ "duckdb>=1.5",
24
+ "pyarrow>=25.0",
25
+ "httpx>=0.28",
26
+ "pyyaml>=6.0",
27
+ ]
28
+
29
+ [project.scripts]
30
+ crowdsonar = "crowdsonar.cli:main"
31
+
32
+ [tool.hatch.build.targets.wheel]
33
+ packages = ["src/crowdsonar"]
@@ -0,0 +1,3 @@
1
+ """CrowdSonar — social signal synthesis engine (Reddit listening → LLM classification → briefings)."""
2
+
3
+ __version__ = "0.1.2"
@@ -0,0 +1,248 @@
1
+ """Arctic Shift API adapter — credential-free Reddit ingestion (primary source).
2
+
3
+ Replaces PullPush (dead 2026-09) as the default ingestion path. Source-selection
4
+ rationale, in brief: PullPush's API went dark in September 2026; the official
5
+ Reddit API was a poor fit for research-scale reads (paid tier, per-request
6
+ costs, and 2026-09 policy shifts that killed several projects in this niche);
7
+ Arctic Shift is a volunteer-run public archive with a free, key-less JSON API
8
+ covering full Reddit history. Cost of the trade-off: single-operator
9
+ availability risk — mitigated by per-run local snapshots and a pluggable
10
+ source layer (PRAW overlay retained, HN/RSS adapters planned).
11
+
12
+ Strategy: fetch the full post window per subreddit (server-side time filter,
13
+ paginated), then filter by keywords locally using the same `_matches_keywords`
14
+ semantics as the PRAW path. Server-side keyword search (`title=`/`selftext=`/
15
+ `body=` params) is deliberately avoided — it times out on active subreddits
16
+ and the API docs mark its behavior as "not guaranteed".
17
+
18
+ Comment recovery mirrors PRAW: for each keyword-matched post, fetch comments
19
+ via `link_id`, sort by score locally, keep the top `comments_per_post`.
20
+
21
+ Be polite: this is a free volunteer-run service. Requests are throttled below
22
+ "a couple per second" (their stated safe zone); 429 and query-timeout get
23
+ retried with backoff and a shrunk limit.
24
+ """
25
+
26
+ import logging
27
+ import time
28
+ from datetime import datetime, timedelta, timezone
29
+
30
+ import httpx
31
+
32
+ from .ingest import IngestResult, RawPost, _matches_keywords
33
+
34
+ log = logging.getLogger(__name__)
35
+
36
+ BASE_URL = "https://arctic-shift.photon-reddit.com"
37
+ POST_FIELDS = "id,title,selftext,subreddit,author,score,created_utc"
38
+ COMMENT_FIELDS = "id,body,author,score,created_utc,link_id"
39
+ PAGE_SIZE = 100 # explicit (not "auto") so cursor pagination is predictable
40
+ MAX_PAGES_PER_SUB = 20 # safety cap: 20 × 100 = 2000 posts per sub per window
41
+ REQUEST_INTERVAL = 0.6 # seconds between requests — inside their "couple/sec" tolerance
42
+
43
+ TIME_FILTER_DAYS = {
44
+ "hour": 1 / 24,
45
+ "day": 1,
46
+ "week": 7,
47
+ "month": 30,
48
+ "year": 365,
49
+ "all": None,
50
+ }
51
+
52
+
53
+ def _iso(ts: float) -> str:
54
+ """Epoch seconds → ISO string in the format the API accepts (validated)."""
55
+ return datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
56
+
57
+
58
+ class ArcticShiftClient:
59
+ """Throttled wrapper over the Arctic Shift search endpoints."""
60
+
61
+ def __init__(self, base_url: str = BASE_URL, timeout: float = 30.0,
62
+ request_interval: float = REQUEST_INTERVAL):
63
+ self._http = httpx.Client(base_url=base_url, timeout=timeout)
64
+ self._interval = request_interval
65
+ self._last = 0.0
66
+
67
+ def close(self) -> None:
68
+ self._http.close()
69
+
70
+ def search(self, kind: str, params: dict) -> list[dict]:
71
+ """GET /api/{kind}/search with 429 / query-timeout retry. Returns data list."""
72
+ p = dict(params)
73
+ path = f"/api/{kind}/search"
74
+ for attempt in range(3):
75
+ wait = self._interval - (time.monotonic() - self._last)
76
+ if wait > 0:
77
+ time.sleep(wait)
78
+ self._last = time.monotonic()
79
+ try:
80
+ resp = self._http.get(path, params=p)
81
+ except httpx.HTTPError as e:
82
+ log.warning("Arctic Shift %s: %s (attempt %d/3)", path, e, attempt + 1)
83
+ time.sleep(2 ** attempt)
84
+ continue
85
+ if resp.status_code == 429:
86
+ reset = float(resp.headers.get("X-RateLimit-Reset") or 30)
87
+ log.warning("Arctic Shift: rate limited, sleeping %.0fs", min(reset, 120))
88
+ time.sleep(min(reset, 120))
89
+ continue
90
+ resp.raise_for_status()
91
+ body = resp.json()
92
+ err = body.get("error")
93
+ if err:
94
+ msg = str(err)
95
+ if "imeout" in msg: # "Query timed out" / "Timeout. Maybe slow down a bit"
96
+ p = dict(p)
97
+ lim = p.get("limit", PAGE_SIZE)
98
+ # "auto" (comment search) has no numeric half — drop to 100.
99
+ p["limit"] = max(10, lim // 2) if isinstance(lim, int) else 100
100
+ log.warning("Arctic Shift: query timed out — retrying with limit=%s", p["limit"])
101
+ time.sleep(2 ** attempt)
102
+ continue
103
+ raise RuntimeError(f"Arctic Shift error: {msg}")
104
+ return body.get("data") or []
105
+ return [] # retries exhausted; caller sees a short/empty page
106
+
107
+
108
+ def fetch_window_posts(client: ArcticShiftClient, subreddit: str,
109
+ window_days: float | None) -> list[dict]:
110
+ """All posts in r/<subreddit> within `window_days` (None = no lower bound)."""
111
+ items: list[dict] = []
112
+ cursor: float | None = None
113
+ for _ in range(MAX_PAGES_PER_SUB):
114
+ params = {
115
+ "subreddit": subreddit,
116
+ "limit": PAGE_SIZE,
117
+ "sort": "asc",
118
+ "fields": POST_FIELDS,
119
+ }
120
+ if cursor is not None:
121
+ params["after"] = _iso(cursor)
122
+ elif window_days is not None:
123
+ params["after"] = _iso(
124
+ (datetime.now(timezone.utc) - timedelta(days=window_days)).timestamp()
125
+ )
126
+ batch = client.search("posts", params)
127
+ if not batch:
128
+ break
129
+ items.extend(batch)
130
+ if len(batch) < PAGE_SIZE:
131
+ break
132
+ new_cursor = float(batch[-1].get("created_utc") or 0)
133
+ if cursor is not None and new_cursor <= cursor:
134
+ break # cursor stalled (same-second pileup); dedup handles overlap
135
+ cursor = new_cursor
136
+ return items
137
+
138
+
139
+ def fetch_post_comments(client: ArcticShiftClient, post_id: str) -> list[dict]:
140
+ """Comments under a post (up to ~1000 via limit=auto); caller sorts/filters."""
141
+ return client.search("comments", {
142
+ "link_id": f"t3_{post_id}",
143
+ "limit": "auto",
144
+ "fields": COMMENT_FIELDS,
145
+ })
146
+
147
+
148
+ def _post_raw(item: dict, kw: str) -> RawPost:
149
+ pid = item.get("id", "")
150
+ sub = item.get("subreddit", "")
151
+ return RawPost(
152
+ source="arctic",
153
+ post_id=pid,
154
+ post_title=item.get("title", ""),
155
+ post_url=f"https://reddit.com/r/{sub}/comments/{pid}",
156
+ post_subreddit=sub,
157
+ post_author=item.get("author") or "[deleted]",
158
+ post_score=int(item.get("score") or 0),
159
+ post_created_utc=float(item.get("created_utc") or 0),
160
+ post_body=item.get("selftext") or "",
161
+ keyword_match=kw,
162
+ )
163
+
164
+
165
+ def _comment_raw(item: dict, parent: RawPost, kw: str) -> RawPost:
166
+ return RawPost(
167
+ source="arctic",
168
+ post_id=parent.post_id,
169
+ post_title=parent.post_title,
170
+ post_url=parent.post_url,
171
+ post_subreddit=parent.post_subreddit,
172
+ post_author=parent.post_author,
173
+ post_score=parent.post_score,
174
+ post_created_utc=parent.post_created_utc,
175
+ post_body=parent.post_body,
176
+ comment_id=item.get("id", ""),
177
+ comment_body=item.get("body") or "",
178
+ comment_author=item.get("author") or "[deleted]",
179
+ comment_score=int(item.get("score") or 0),
180
+ comment_created_utc=float(item.get("created_utc") or 0),
181
+ keyword_match=kw,
182
+ )
183
+
184
+
185
+ def ingest_arctic(
186
+ subreddits: list[str],
187
+ keywords: list[str],
188
+ time_filter: str = "week",
189
+ post_limit: int = 50,
190
+ comments_per_post: int = 20,
191
+ backfill_days: int = 0,
192
+ base_url: str = BASE_URL,
193
+ ) -> IngestResult:
194
+ """Ingest via Arctic Shift — semantics mirror ingest_praw.
195
+
196
+ Posts matching any keyword (title or body, case-insensitive substring)
197
+ within the time window, plus the top `comments_per_post` comments (by
198
+ score) of each matched post. `backfill_days > 0` widens the window
199
+ (replaces the dead PullPush backfill). `post_limit` caps matched posts
200
+ per subreddit (newest kept).
201
+ """
202
+ result = IngestResult()
203
+ window = backfill_days if backfill_days > 0 else TIME_FILTER_DAYS.get(time_filter, 7)
204
+ client = ArcticShiftClient(base_url=base_url)
205
+ try:
206
+ for sub in subreddits:
207
+ try:
208
+ items = fetch_window_posts(client, sub, window)
209
+ except Exception as e:
210
+ result.errors.append(f"posts r/{sub}: {e}")
211
+ continue
212
+
213
+ # Pagination can straddle a boundary second — dedupe by post id.
214
+ seen: set[str] = set()
215
+ unique = [x for x in items
216
+ if not ((x.get("id") or "") in seen or seen.add(x.get("id") or ""))]
217
+ log.info("Arctic Shift: r/%s → %d posts in %.1f-day window",
218
+ sub, len(unique), window if window is not None else -1)
219
+
220
+ matched: list[tuple[dict, str]] = []
221
+ for it in unique:
222
+ kw = _matches_keywords(
223
+ (it.get("title") or "") + " " + (it.get("selftext") or ""), keywords
224
+ )
225
+ if kw:
226
+ matched.append((it, kw))
227
+
228
+ if post_limit and len(matched) > post_limit:
229
+ matched.sort(key=lambda x: x[0].get("created_utc") or 0, reverse=True)
230
+ matched = matched[:post_limit]
231
+
232
+ for it, kw in matched:
233
+ parent = _post_raw(it, kw)
234
+ result.posts.append(parent)
235
+ if comments_per_post > 0:
236
+ try:
237
+ cs = fetch_post_comments(client, it.get("id", ""))
238
+ except Exception as e:
239
+ result.errors.append(f"comments t3_{it.get('id')}: {e}")
240
+ cs = []
241
+ cs.sort(key=lambda c: c.get("score") or 0, reverse=True)
242
+ for c in cs[:comments_per_post]:
243
+ result.posts.append(_comment_raw(c, parent, kw))
244
+ finally:
245
+ client.close()
246
+
247
+ log.info("Arctic Shift: collected %d items, %d errors", len(result.posts), len(result.errors))
248
+ return result