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.
- posthorn-0.1.0/LICENSE +21 -0
- posthorn-0.1.0/PKG-INFO +191 -0
- posthorn-0.1.0/README.md +154 -0
- posthorn-0.1.0/pyproject.toml +168 -0
- posthorn-0.1.0/setup.cfg +4 -0
- posthorn-0.1.0/src/posthorn/__init__.py +31 -0
- posthorn-0.1.0/src/posthorn/adapters/__init__.py +18 -0
- posthorn-0.1.0/src/posthorn/adapters/alert_carriers/__init__.py +9 -0
- posthorn-0.1.0/src/posthorn/adapters/alert_carriers/discord.py +80 -0
- posthorn-0.1.0/src/posthorn/adapters/alert_carriers/telegram.py +71 -0
- posthorn-0.1.0/src/posthorn/adapters/job_boards/__init__.py +11 -0
- posthorn-0.1.0/src/posthorn/adapters/job_boards/indeed.py +108 -0
- posthorn-0.1.0/src/posthorn/adapters/job_boards/linkedin.py +100 -0
- posthorn-0.1.0/src/posthorn/adapters/job_boards/ziprecruiter.py +108 -0
- posthorn-0.1.0/src/posthorn/adapters/state_dbs/__init__.py +9 -0
- posthorn-0.1.0/src/posthorn/adapters/state_dbs/duckdb.py +223 -0
- posthorn-0.1.0/src/posthorn/adapters/state_dbs/postgres.py +210 -0
- posthorn-0.1.0/src/posthorn/core/__init__.py +29 -0
- posthorn-0.1.0/src/posthorn/core/interfaces/__init__.py +11 -0
- posthorn-0.1.0/src/posthorn/core/interfaces/alert_carrier.py +18 -0
- posthorn-0.1.0/src/posthorn/core/interfaces/job_board.py +20 -0
- posthorn-0.1.0/src/posthorn/core/interfaces/state_db.py +81 -0
- posthorn-0.1.0/src/posthorn/core/managers/__init__.py +9 -0
- posthorn-0.1.0/src/posthorn/core/managers/campaign_manager.py +109 -0
- posthorn-0.1.0/src/posthorn/core/managers/job_board_manager.py +107 -0
- posthorn-0.1.0/src/posthorn/core/models/__init__.py +11 -0
- posthorn-0.1.0/src/posthorn/core/models/campaign_model.py +11 -0
- posthorn-0.1.0/src/posthorn/core/models/job_post_model.py +13 -0
- posthorn-0.1.0/src/posthorn/core/models/job_state_enum.py +14 -0
- posthorn-0.1.0/src/posthorn/core/posthorn.py +64 -0
- posthorn-0.1.0/src/posthorn/sugar/__init__.py +9 -0
- posthorn-0.1.0/src/posthorn/sugar/current_local_datetime.py +8 -0
- posthorn-0.1.0/src/posthorn/sugar/to_datetime.py +26 -0
- posthorn-0.1.0/src/posthorn.egg-info/PKG-INFO +191 -0
- posthorn-0.1.0/src/posthorn.egg-info/SOURCES.txt +38 -0
- posthorn-0.1.0/src/posthorn.egg-info/dependency_links.txt +1 -0
- posthorn-0.1.0/src/posthorn.egg-info/requires.txt +26 -0
- posthorn-0.1.0/src/posthorn.egg-info/top_level.txt +1 -0
- posthorn-0.1.0/tests/test_coordinator.py +70 -0
- 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.
|
posthorn-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
+
```
|
posthorn-0.1.0/README.md
ADDED
|
@@ -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
|
posthorn-0.1.0/setup.cfg
ADDED
|
@@ -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,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
|