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.
- crowdsonar-0.1.2/.gitignore +14 -0
- crowdsonar-0.1.2/LICENSE +21 -0
- crowdsonar-0.1.2/PKG-INFO +134 -0
- crowdsonar-0.1.2/README.md +113 -0
- crowdsonar-0.1.2/pyproject.toml +33 -0
- crowdsonar-0.1.2/src/crowdsonar/__init__.py +3 -0
- crowdsonar-0.1.2/src/crowdsonar/arctic_shift.py +248 -0
- crowdsonar-0.1.2/src/crowdsonar/classify.py +147 -0
- crowdsonar-0.1.2/src/crowdsonar/cli.py +262 -0
- crowdsonar-0.1.2/src/crowdsonar/config.py +49 -0
- crowdsonar-0.1.2/src/crowdsonar/configs/example.yaml +55 -0
- crowdsonar-0.1.2/src/crowdsonar/ingest.py +151 -0
- crowdsonar-0.1.2/src/crowdsonar/prompts/classify.md +65 -0
- crowdsonar-0.1.2/src/crowdsonar/prompts/synthesize.md +59 -0
- crowdsonar-0.1.2/src/crowdsonar/storage.py +325 -0
- crowdsonar-0.1.2/src/crowdsonar/synthesize.py +111 -0
crowdsonar-0.1.2/LICENSE
ADDED
|
@@ -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,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
|