posthorn 0.1.0__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 (40) hide show
  1. posthorn-0.1.0/LICENSE +21 -0
  2. posthorn-0.1.0/PKG-INFO +191 -0
  3. posthorn-0.1.0/README.md +154 -0
  4. posthorn-0.1.0/pyproject.toml +168 -0
  5. posthorn-0.1.0/setup.cfg +4 -0
  6. posthorn-0.1.0/src/posthorn/__init__.py +31 -0
  7. posthorn-0.1.0/src/posthorn/adapters/__init__.py +18 -0
  8. posthorn-0.1.0/src/posthorn/adapters/alert_carriers/__init__.py +9 -0
  9. posthorn-0.1.0/src/posthorn/adapters/alert_carriers/discord.py +80 -0
  10. posthorn-0.1.0/src/posthorn/adapters/alert_carriers/telegram.py +71 -0
  11. posthorn-0.1.0/src/posthorn/adapters/job_boards/__init__.py +11 -0
  12. posthorn-0.1.0/src/posthorn/adapters/job_boards/indeed.py +108 -0
  13. posthorn-0.1.0/src/posthorn/adapters/job_boards/linkedin.py +100 -0
  14. posthorn-0.1.0/src/posthorn/adapters/job_boards/ziprecruiter.py +108 -0
  15. posthorn-0.1.0/src/posthorn/adapters/state_dbs/__init__.py +9 -0
  16. posthorn-0.1.0/src/posthorn/adapters/state_dbs/duckdb.py +223 -0
  17. posthorn-0.1.0/src/posthorn/adapters/state_dbs/postgres.py +210 -0
  18. posthorn-0.1.0/src/posthorn/core/__init__.py +29 -0
  19. posthorn-0.1.0/src/posthorn/core/interfaces/__init__.py +11 -0
  20. posthorn-0.1.0/src/posthorn/core/interfaces/alert_carrier.py +18 -0
  21. posthorn-0.1.0/src/posthorn/core/interfaces/job_board.py +20 -0
  22. posthorn-0.1.0/src/posthorn/core/interfaces/state_db.py +81 -0
  23. posthorn-0.1.0/src/posthorn/core/managers/__init__.py +9 -0
  24. posthorn-0.1.0/src/posthorn/core/managers/campaign_manager.py +109 -0
  25. posthorn-0.1.0/src/posthorn/core/managers/job_board_manager.py +107 -0
  26. posthorn-0.1.0/src/posthorn/core/models/__init__.py +11 -0
  27. posthorn-0.1.0/src/posthorn/core/models/campaign_model.py +11 -0
  28. posthorn-0.1.0/src/posthorn/core/models/job_post_model.py +13 -0
  29. posthorn-0.1.0/src/posthorn/core/models/job_state_enum.py +14 -0
  30. posthorn-0.1.0/src/posthorn/core/posthorn.py +64 -0
  31. posthorn-0.1.0/src/posthorn/sugar/__init__.py +9 -0
  32. posthorn-0.1.0/src/posthorn/sugar/current_local_datetime.py +8 -0
  33. posthorn-0.1.0/src/posthorn/sugar/to_datetime.py +26 -0
  34. posthorn-0.1.0/src/posthorn.egg-info/PKG-INFO +191 -0
  35. posthorn-0.1.0/src/posthorn.egg-info/SOURCES.txt +38 -0
  36. posthorn-0.1.0/src/posthorn.egg-info/dependency_links.txt +1 -0
  37. posthorn-0.1.0/src/posthorn.egg-info/requires.txt +26 -0
  38. posthorn-0.1.0/src/posthorn.egg-info/top_level.txt +1 -0
  39. posthorn-0.1.0/tests/test_coordinator.py +70 -0
  40. posthorn-0.1.0/tests/test_duckdb_state_machine.py +93 -0
posthorn-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Drew Vandagriff (DREW-V)
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,191 @@
1
+ Metadata-Version: 2.4
2
+ Name: posthorn
3
+ Version: 0.1.0
4
+ Summary: Lightweight configurable job posting scanner and alerting. Work smarter, not harder
5
+ Author-email: Drew Vandagriff <vandagriff.drew@gmail.com>
6
+ Classifier: Intended Audience :: Developers
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Programming Language :: Python :: 3.11
9
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
10
+ Classifier: Framework :: Pytest
11
+ Requires-Python: >=3.11
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: beautifulsoup4>=4.15.0
15
+ Requires-Dist: httpx>=0.28.1
16
+ Requires-Dist: pytz>=2026.4
17
+ Provides-Extra: test
18
+ Requires-Dist: pytest>=9.1.0; extra == "test"
19
+ Requires-Dist: pytest-cov>=7.1.0; extra == "test"
20
+ Requires-Dist: pytest-mock>=3.15.0; extra == "test"
21
+ Requires-Dist: responses>=0.26.0; extra == "test"
22
+ Requires-Dist: respx>=0.23.0; extra == "test"
23
+ Requires-Dist: freezegun>=1.5.0; extra == "test"
24
+ Provides-Extra: dev
25
+ Requires-Dist: ruff>=0.16.0; extra == "dev"
26
+ Requires-Dist: mypy>=2.3.0; extra == "dev"
27
+ Requires-Dist: pre-commit>=4.6.0; extra == "dev"
28
+ Requires-Dist: build>=1.6.0; extra == "dev"
29
+ Requires-Dist: twine>=7.0.0; extra == "dev"
30
+ Requires-Dist: ipython>=9.17.0; extra == "dev"
31
+ Provides-Extra: duckdb
32
+ Requires-Dist: duckdb>=0.10.0; extra == "duckdb"
33
+ Provides-Extra: postgresql
34
+ Requires-Dist: psycopg2-binary>=2.9.0; extra == "postgresql"
35
+ Requires-Dist: asyncpg>=0.31.0; extra == "postgresql"
36
+ Dynamic: license-file
37
+
38
+ <div align="center">
39
+ <img src=".github/images/icon.png" alt="Posthorn Logo" width="220"/>
40
+
41
+ <h1>Posthorn</h1>
42
+ <p><b>An extensible, async-first job monitoring and alerting daemon.</b></p>
43
+ </div>
44
+
45
+ <br/>
46
+
47
+ ## ⚡ Core Philosophy
48
+
49
+ Posthorn is a lightweight, strictly-typed Python background service designed to poll job boards, filter for specific campaigns, and push early-warning alerts to webhooks or chat platforms. Built on modern Python concurrency, it relies on an embedded DuckDB state machine to enforce absolute idempotency—guaranteeing that a matched job is alerted on exactly once, even across overlapping sweeps or network failures.
50
+
51
+ ## ⚡ Core Philosophy
52
+
53
+ * **Performance over Gimmicks:** Zero heavy headless browsers. Polling relies purely on `httpx` and lightweight `BeautifulSoup4` HTML parsing.
54
+ * **Structural Subtyping:** Core architecture relies entirely on `typing.Protocol`. Adapters for new job boards or carriers duck-type perfectly into the orchestrator without rigid inheritance hierarchies.
55
+ * **Strict Idempotency:** The DuckDB backend maintains a transactional state machine (`DISCOVERED` -> `ALERT_QUEUED` -> `ALERT_SENT`), acting as an airlock against duplicate alerts and race conditions.
56
+ * **Type-Safe & Tested:** 100% Pyright/Mypy compliant, backed by a comprehensive `pytest-asyncio` test suite utilizing memory-resident database fixtures.
57
+
58
+ ---
59
+
60
+ ## 🏗 Architecture
61
+
62
+ Posthorn is built around three interchangeable layers orchestrated by the main `Posthorn` daemon loop:
63
+
64
+ 1. **Job Boards (`JobBoard` Protocol):** Inbound data adapters.
65
+ * *Included:* `LinkedIn`, `Indeed`, `ZipRecruiter`.
66
+
67
+
68
+ 2. **Alert Carriers (`AlertCarrier` Protocol):** Outbound notification dispatchers.
69
+ * *Included:* `Discord` (Rich Webhooks), `Telegram`.
70
+
71
+
72
+ 3. **State Database:** The idempotency lock and metrics tracker.
73
+ * *Included:* `DuckDB`.
74
+
75
+
76
+
77
+ ---
78
+
79
+ ## 🚀 Quick Start
80
+
81
+ ### Installation
82
+
83
+ Posthorn is designed to be deployed into modern, isolated Python environments. We recommend using Astral's `uv`:
84
+
85
+ ```bash
86
+ uv pip install posthorn
87
+
88
+ ```
89
+
90
+ ### Example Usage: The "Telelink" Daemon
91
+
92
+ Wiring up an alerting daemon requires simply declaring your carriers, boards, and campaigns, then awaiting the `run()` loop.
93
+
94
+ ```python
95
+ import asyncio
96
+
97
+ import posthorn as horn
98
+ from posthorn.adapters import LinkedIn, Telegram
99
+
100
+ async def main():
101
+ app = horn.Posthorn(
102
+ # 1. Define where alerts should go
103
+ alert_carrier=Telegram(
104
+ bot_token="YOUR_BOT_TOKEN",
105
+ chat_id="YOUR_CHAT_ID",
106
+ ),
107
+
108
+ # 2. Define which platforms to scrape
109
+ job_boards=horn.JobBoardManager([
110
+ LinkedIn(),
111
+ ]),
112
+
113
+ # 3. Define your target campaigns
114
+ campaigns=horn.CampaignManager([
115
+ horn.Campaign(
116
+ name="EdgeAI-Engineer",
117
+ keywords=["Edge AI", "Engineer", "Rust", "Python"],
118
+ locations=["Remote", "Richardson, TX"]
119
+ ),
120
+ horn.Campaign(
121
+ name="Data-Architect",
122
+ keywords=["Data Architect", "Data Engineering"],
123
+ locations=["Remote", "Dallas, TX"]
124
+ ),
125
+ ]),
126
+ )
127
+
128
+ # Fire up the daemon loop (defaults to a 15-minute sweep interval)
129
+ await app.run(interval_seconds=900)
130
+
131
+ if __name__ == "__main__":
132
+ asyncio.run(main())
133
+
134
+ ```
135
+
136
+ ---
137
+
138
+ ## 🛠 Extending Posthorn
139
+
140
+ Adding a custom integration is as simple as fulfilling a Protocol. No subclassing required.
141
+
142
+ ### Custom Alert Carrier
143
+
144
+ Just implement a `.name` property and an `async def dispatch()` method.
145
+
146
+ ```python
147
+ class SlackCarrier:
148
+ @property
149
+ def name(self) -> str:
150
+ return "slack"
151
+
152
+ async def dispatch(self, job: JobPost, campaign: Campaign) -> None:
153
+ payload = {"text": f"New match for {campaign.name}: {job.title} at {job.company}\n{job.url}"}
154
+ async with httpx.AsyncClient() as client:
155
+ await client.post(SLACK_WEBHOOK_URL, json=payload)
156
+
157
+ ```
158
+
159
+ ---
160
+
161
+ ## 🧪 Development & Testing
162
+
163
+ Posthorn utilizes `pytest` with `pytest-asyncio` for its testing framework. The suite is designed to run locally with zero infrastructure footprint by injecting a transient, in-memory DuckDB database into the test context.
164
+
165
+ ```bash
166
+ # Install development dependencies
167
+ uv sync --dev
168
+
169
+ # Run the test suite
170
+ uv run pytest -v
171
+
172
+ ```
173
+
174
+ ---
175
+
176
+ ## 📝 License
177
+
178
+ MIT License. Build, extend, and deploy.
179
+
180
+ ----
181
+
182
+ Made with ♥️ by
183
+ ```py
184
+ DREW-V := {
185
+ "Simplicity in the Architecture",
186
+ "Efficiency in the Engineering",
187
+ "Purity in the Science",
188
+ "Audacity in the Art",
189
+ "Life in the Logic"
190
+ }
191
+ ```
@@ -0,0 +1,154 @@
1
+ <div align="center">
2
+ <img src=".github/images/icon.png" alt="Posthorn Logo" width="220"/>
3
+
4
+ <h1>Posthorn</h1>
5
+ <p><b>An extensible, async-first job monitoring and alerting daemon.</b></p>
6
+ </div>
7
+
8
+ <br/>
9
+
10
+ ## ⚡ Core Philosophy
11
+
12
+ Posthorn is a lightweight, strictly-typed Python background service designed to poll job boards, filter for specific campaigns, and push early-warning alerts to webhooks or chat platforms. Built on modern Python concurrency, it relies on an embedded DuckDB state machine to enforce absolute idempotency—guaranteeing that a matched job is alerted on exactly once, even across overlapping sweeps or network failures.
13
+
14
+ ## ⚡ Core Philosophy
15
+
16
+ * **Performance over Gimmicks:** Zero heavy headless browsers. Polling relies purely on `httpx` and lightweight `BeautifulSoup4` HTML parsing.
17
+ * **Structural Subtyping:** Core architecture relies entirely on `typing.Protocol`. Adapters for new job boards or carriers duck-type perfectly into the orchestrator without rigid inheritance hierarchies.
18
+ * **Strict Idempotency:** The DuckDB backend maintains a transactional state machine (`DISCOVERED` -> `ALERT_QUEUED` -> `ALERT_SENT`), acting as an airlock against duplicate alerts and race conditions.
19
+ * **Type-Safe & Tested:** 100% Pyright/Mypy compliant, backed by a comprehensive `pytest-asyncio` test suite utilizing memory-resident database fixtures.
20
+
21
+ ---
22
+
23
+ ## 🏗 Architecture
24
+
25
+ Posthorn is built around three interchangeable layers orchestrated by the main `Posthorn` daemon loop:
26
+
27
+ 1. **Job Boards (`JobBoard` Protocol):** Inbound data adapters.
28
+ * *Included:* `LinkedIn`, `Indeed`, `ZipRecruiter`.
29
+
30
+
31
+ 2. **Alert Carriers (`AlertCarrier` Protocol):** Outbound notification dispatchers.
32
+ * *Included:* `Discord` (Rich Webhooks), `Telegram`.
33
+
34
+
35
+ 3. **State Database:** The idempotency lock and metrics tracker.
36
+ * *Included:* `DuckDB`.
37
+
38
+
39
+
40
+ ---
41
+
42
+ ## 🚀 Quick Start
43
+
44
+ ### Installation
45
+
46
+ Posthorn is designed to be deployed into modern, isolated Python environments. We recommend using Astral's `uv`:
47
+
48
+ ```bash
49
+ uv pip install posthorn
50
+
51
+ ```
52
+
53
+ ### Example Usage: The "Telelink" Daemon
54
+
55
+ Wiring up an alerting daemon requires simply declaring your carriers, boards, and campaigns, then awaiting the `run()` loop.
56
+
57
+ ```python
58
+ import asyncio
59
+
60
+ import posthorn as horn
61
+ from posthorn.adapters import LinkedIn, Telegram
62
+
63
+ async def main():
64
+ app = horn.Posthorn(
65
+ # 1. Define where alerts should go
66
+ alert_carrier=Telegram(
67
+ bot_token="YOUR_BOT_TOKEN",
68
+ chat_id="YOUR_CHAT_ID",
69
+ ),
70
+
71
+ # 2. Define which platforms to scrape
72
+ job_boards=horn.JobBoardManager([
73
+ LinkedIn(),
74
+ ]),
75
+
76
+ # 3. Define your target campaigns
77
+ campaigns=horn.CampaignManager([
78
+ horn.Campaign(
79
+ name="EdgeAI-Engineer",
80
+ keywords=["Edge AI", "Engineer", "Rust", "Python"],
81
+ locations=["Remote", "Richardson, TX"]
82
+ ),
83
+ horn.Campaign(
84
+ name="Data-Architect",
85
+ keywords=["Data Architect", "Data Engineering"],
86
+ locations=["Remote", "Dallas, TX"]
87
+ ),
88
+ ]),
89
+ )
90
+
91
+ # Fire up the daemon loop (defaults to a 15-minute sweep interval)
92
+ await app.run(interval_seconds=900)
93
+
94
+ if __name__ == "__main__":
95
+ asyncio.run(main())
96
+
97
+ ```
98
+
99
+ ---
100
+
101
+ ## 🛠 Extending Posthorn
102
+
103
+ Adding a custom integration is as simple as fulfilling a Protocol. No subclassing required.
104
+
105
+ ### Custom Alert Carrier
106
+
107
+ Just implement a `.name` property and an `async def dispatch()` method.
108
+
109
+ ```python
110
+ class SlackCarrier:
111
+ @property
112
+ def name(self) -> str:
113
+ return "slack"
114
+
115
+ async def dispatch(self, job: JobPost, campaign: Campaign) -> None:
116
+ payload = {"text": f"New match for {campaign.name}: {job.title} at {job.company}\n{job.url}"}
117
+ async with httpx.AsyncClient() as client:
118
+ await client.post(SLACK_WEBHOOK_URL, json=payload)
119
+
120
+ ```
121
+
122
+ ---
123
+
124
+ ## 🧪 Development & Testing
125
+
126
+ Posthorn utilizes `pytest` with `pytest-asyncio` for its testing framework. The suite is designed to run locally with zero infrastructure footprint by injecting a transient, in-memory DuckDB database into the test context.
127
+
128
+ ```bash
129
+ # Install development dependencies
130
+ uv sync --dev
131
+
132
+ # Run the test suite
133
+ uv run pytest -v
134
+
135
+ ```
136
+
137
+ ---
138
+
139
+ ## 📝 License
140
+
141
+ MIT License. Build, extend, and deploy.
142
+
143
+ ----
144
+
145
+ Made with ♥️ by
146
+ ```py
147
+ DREW-V := {
148
+ "Simplicity in the Architecture",
149
+ "Efficiency in the Engineering",
150
+ "Purity in the Science",
151
+ "Audacity in the Art",
152
+ "Life in the Logic"
153
+ }
154
+ ```
@@ -0,0 +1,168 @@
1
+ # ──────────────────────────────────────────────────────────────────────────────
2
+ # Build System
3
+ # ──────────────────────────────────────────────────────────────────────────────
4
+ [build-system]
5
+ requires = ["setuptools>=68.0", "wheel"]
6
+ build-backend = "setuptools.build_meta"
7
+
8
+ # ──────────────────────────────────────────────────────────────────────────────
9
+ # Project Metadata
10
+ # ──────────────────────────────────────────────────────────────────────────────
11
+ [project]
12
+ name = "posthorn"
13
+ version = "0.1.0"
14
+ description = "Lightweight configurable job posting scanner and alerting. Work smarter, not harder"
15
+ readme = "README.md"
16
+ authors = [
17
+ {name = "Drew Vandagriff", email = "vandagriff.drew@gmail.com"},
18
+ ]
19
+ classifiers = [
20
+ "Intended Audience :: Developers",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Topic :: Software Development :: Libraries :: Python Modules",
24
+ "Framework :: Pytest",
25
+ ]
26
+ requires-python = ">=3.11"
27
+
28
+ # ── Runtime Dependencies ──────────────────────────────────────────────────────
29
+ dependencies = [
30
+ "beautifulsoup4>=4.15.0",
31
+ "httpx>=0.28.1",
32
+ "pytz>=2026.4",
33
+ ]
34
+
35
+ # ── Optional / Extra Dependencies ─────────────────────────────────────────────
36
+ [project.optional-dependencies]
37
+ test = [
38
+ "pytest>=9.1.0",
39
+ "pytest-cov>=7.1.0",
40
+ "pytest-mock>=3.15.0",
41
+ "responses>=0.26.0",
42
+ "respx>=0.23.0",
43
+ "freezegun>=1.5.0",
44
+ ]
45
+ dev = [
46
+ "ruff>=0.16.0",
47
+ "mypy>=2.3.0",
48
+ "pre-commit>=4.6.0",
49
+ "build>=1.6.0",
50
+ "twine>=7.0.0",
51
+ "ipython>=9.17.0",
52
+ ]
53
+ duckdb = [
54
+ "duckdb>=0.10.0",
55
+ ]
56
+ postgresql = [
57
+ "psycopg2-binary>=2.9.0",
58
+ "asyncpg>=0.31.0",
59
+ ]
60
+
61
+ # ──────────────────────────────────────────────────────────────────────────────
62
+ # Package Configuration
63
+ # ──────────────────────────────────────────────────────────────────────────────
64
+ [tool.setuptools.packages.find]
65
+ where = ["src"]
66
+ include = ["posthorn*"]
67
+ exclude = ["tests*"]
68
+
69
+ [tool.setuptools.package-data]
70
+ "posthorn" = ["**/*.json", "**/*.yaml", "**/*.yml", "**/*.toml"]
71
+
72
+ # ──────────────────────────────────────────────────────────────────────────────
73
+ # Pytest Configuration
74
+ # ──────────────────────────────────────────────────────────────────────────────
75
+ [tool.pytest.ini_options]
76
+ asyncio_mode = "auto"
77
+ asyncio_default_fixture_loop_scope = "function"
78
+ testpaths = ["tests"]
79
+ python_files = ["test_*.py", "*_test.py"]
80
+ python_classes = ["Test*"]
81
+ python_functions = ["test_*"]
82
+ addopts = [
83
+ "--strict-markers",
84
+ "--tb=short",
85
+ "-v",
86
+ ]
87
+ markers = [
88
+ "slow: marks tests as slow (deselect with '-m \"not slow\"')",
89
+ "integration: marks tests as integration tests",
90
+ "duckdb: marks tests that require DuckDB connectivity",
91
+ "postgres: marks tests that require PostgreSQL connectivity",
92
+ "leap_year: marks tests for leap year boundary conditions",
93
+ "dst_transition: marks tests for DST transition edge cases",
94
+ ]
95
+ filterwarnings = [
96
+ "error::DeprecationWarning",
97
+ "error::PendingDeprecationWarning",
98
+ "ignore::UserWarning",
99
+ ]
100
+
101
+ # ──────────────────────────────────────────────────────────────────────────────
102
+ # Ruff — Linter & Formatter
103
+ # ──────────────────────────────────────────────────────────────────────────────
104
+ [tool.ruff]
105
+ target-version = "py311"
106
+ line-length = 100
107
+
108
+ [tool.ruff.lint]
109
+ select = [
110
+ "E", # pycodestyle errors
111
+ "F", # pyflakes
112
+ "I", # isort
113
+ "W", # pycodestyle warnings
114
+ "UP", # pyupgrade
115
+ "B", # flake8-bugbear
116
+ "SIM", # flake8-simplify
117
+ "C4", # flake8-comprehensions
118
+ ]
119
+ ignore = [
120
+ "E501", # line length is handled by formatter
121
+ ]
122
+
123
+ [tool.ruff.lint.isort]
124
+ known-first-party = ["posthorn"]
125
+
126
+ # ──────────────────────────────────────────────────────────────────────────────
127
+ # MyPy — Static Type Checking
128
+ # ──────────────────────────────────────────────────────────────────────────────
129
+ [tool.mypy]
130
+ python_version = "3.11"
131
+ warn_return_any = true
132
+ warn_unused_configs = true
133
+ disallow_untyped_defs = false
134
+ ignore_missing_imports = true
135
+
136
+ [[tool.mypy.overrides]]
137
+ module = ["tests.*"]
138
+ disallow_untyped_defs = false
139
+ ignore_missing_imports = true
140
+
141
+ # ──────────────────────────────────────────────────────────────────────────────
142
+ # Coverage
143
+ # ──────────────────────────────────────────────────────────────────────────────
144
+ [tool.coverage.run]
145
+ source = ["src/posthorn"]
146
+ omit = [
147
+ "tests/*",
148
+ "*/tests/*",
149
+ ]
150
+
151
+ [tool.coverage.report]
152
+ show_missing = true
153
+ skip_empty = true
154
+ fail_under = 0 # bump as tests mature (e.g. 80)
155
+
156
+ [tool.coverage.html]
157
+ directory = "htmlcov"
158
+
159
+ [dependency-groups]
160
+ dev = [
161
+ "pytest-asyncio>=1.4.0",
162
+ ]
163
+
164
+ # ──────────────────────────────────────────────────────────────────────────────
165
+ # Virtual Environment — use uv's lockfile
166
+ # ──────────────────────────────────────────────────────────────────────────────
167
+ # This project uses `uv` for dependency management.
168
+ # Get started: uv sync --all-extras
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ from .core import (
4
+ AlertCarrier,
5
+ Campaign,
6
+ CampaignManager,
7
+ JobBoard,
8
+ JobBoardManager,
9
+ JobPost,
10
+ JobState,
11
+ Posthorn,
12
+ StateDB,
13
+ )
14
+ from .sugar import (
15
+ current_local_datetime,
16
+ to_datetime,
17
+ )
18
+
19
+ __all__ = [
20
+ "AlertCarrier",
21
+ "JobBoard",
22
+ "StateDB",
23
+ "CampaignManager",
24
+ "JobBoardManager",
25
+ "Campaign",
26
+ "JobPost",
27
+ "JobState",
28
+ "Posthorn",
29
+ "current_local_datetime",
30
+ "to_datetime",
31
+ ]
@@ -0,0 +1,18 @@
1
+ from __future__ import annotations
2
+
3
+ from .alert_carriers import Discord, Telegram
4
+ from .job_boards import Indeed, LinkedIn, ZipRecruiter
5
+ from .state_dbs import DuckDB, PostgresDB
6
+
7
+ __all__ = [
8
+ # Alert Carriers
9
+ "Discord",
10
+ "Telegram",
11
+ # Job Boards
12
+ "Indeed",
13
+ "LinkedIn",
14
+ "ZipRecruiter",
15
+ # State DBs
16
+ "DuckDB",
17
+ "PostgresDB",
18
+ ]
@@ -0,0 +1,9 @@
1
+ from __future__ import annotations
2
+
3
+ from .discord import Discord
4
+ from .telegram import Telegram
5
+
6
+ __all__ = [
7
+ "Discord",
8
+ "Telegram",
9
+ ]
@@ -0,0 +1,80 @@
1
+ import logging
2
+
3
+ import httpx
4
+
5
+ from ...core.models.campaign_model import Campaign
6
+ from ...core.models.job_post_model import JobPost
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ class Discord:
12
+ """
13
+ Discord webhook alert dispatcher.
14
+ Structurally fulfills the AlertCarrier protocol.
15
+ """
16
+
17
+ def __init__(self, webhook_url: str) -> None:
18
+ """
19
+ Args:
20
+ webhook_url: The full webhook URL generated from Discord
21
+ (Server Settings -> Integrations -> Webhooks).
22
+ """
23
+ self.webhook_url = webhook_url
24
+
25
+ @property
26
+ def name(self) -> str:
27
+ return "discord"
28
+
29
+ async def dispatch(self, job: JobPost, campaign: Campaign) -> None:
30
+ """
31
+ Transmits the job payload to the configured Discord channel via webhook.
32
+
33
+ Raises:
34
+ RuntimeError: If the HTTP request fails, allowing the orchestrator
35
+ to catch the error and transition the DB state to FAILED.
36
+ """
37
+ # Using Discord's rich embed format provides a significantly
38
+ # cleaner UI experience than raw markdown text.
39
+ payload = {
40
+ "embeds": [
41
+ {
42
+ "title": job.title,
43
+ "url": job.url,
44
+ "color": 3447003, # Posthorn Blue (Hex: #3498DB)
45
+ "author": {
46
+ "name": f"🚨 New Match: {campaign.name}"
47
+ },
48
+ "fields": [
49
+ {
50
+ "name": "Company",
51
+ "value": job.company,
52
+ "inline": True
53
+ },
54
+ {
55
+ "name": "Board",
56
+ "value": job.board,
57
+ "inline": True
58
+ }
59
+ ],
60
+ "footer": {
61
+ "text": "Posthorn Alerting Daemon"
62
+ }
63
+ }
64
+ ]
65
+ }
66
+
67
+ # Instantiating the client per-dispatch is optimal for low-frequency sweeps.
68
+ async with httpx.AsyncClient() as client:
69
+ try:
70
+ # Discord requires a standard JSON POST to the webhook endpoint
71
+ response = await client.post(self.webhook_url, json=payload, timeout=10.0)
72
+ response.raise_for_status()
73
+ except httpx.HTTPStatusError as e:
74
+ error_msg = f"Discord webhook rejected payload (Status {e.response.status_code}): {e.response.text}"
75
+ logger.error(error_msg)
76
+ raise RuntimeError(error_msg) from e
77
+ except httpx.RequestError as e:
78
+ error_msg = f"Network error connecting to Discord webhook: {e}"
79
+ logger.error(error_msg)
80
+ raise RuntimeError(error_msg) from e