gigwatch-nova 0.2.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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nova (earnnova7)
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,256 @@
1
+ Metadata-Version: 2.4
2
+ Name: gigwatch-nova
3
+ Version: 0.2.0
4
+ Summary: Self-hosted freelance-gig watcher: monitor job feeds, filter by your skills, alert on new matches.
5
+ Author-email: Nova <novagw@uberip.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/earnnova-dev/gigwatch
8
+ Project-URL: Issues, https://github.com/earnnova-dev/gigwatch/issues
9
+ Keywords: freelance,jobs,gigs,alerts,monitor,remote,upwork
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Intended Audience :: End Users/Desktop
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Topic :: Office/Business
18
+ Classifier: Topic :: Utilities
19
+ Requires-Python: >=3.9
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest>=7; extra == "dev"
24
+ Dynamic: license-file
25
+
26
+ # GigWatch
27
+
28
+ **A self-hosted freelance-gig watcher.** GigWatch monitors live job/gig feeds,
29
+ filters them against *your* skills, remembers what it has already shown you,
30
+ and alerts you (console, email, or Slack) **only when a new matching gig
31
+ appears**.
32
+
33
+ Stop refreshing Upwork/Remotive/LinkedIn every 20 minutes. Point GigWatch at
34
+ the feeds you care about, tell it what you do, and let it ping you when
35
+ something relevant lands.
36
+
37
+ ```
38
+ $ gigwatch scan
39
+ scanned 17 job(s) from 1 source(s); 4 match filter; 2 new
40
+ GigWatch: 2 new matching gig(s)
41
+
42
+ 1. Senior Python Backend Engineer
43
+ company: Acme Digital
44
+ salary: $120k-$150k
45
+ where: Remote (Worldwide)
46
+ matched: python, backend
47
+ score: 9.0
48
+ https://remotive.com/remote-jobs/...
49
+ ```
50
+
51
+ ## Why this exists
52
+
53
+ Freelancers lose real money to *latency* — the best gigs get filled in the
54
+ first hours. Existing monitors (Distill, PageCrawl, Apify's Upwork monitor)
55
+ are hosted SaaS that scrape your sessions and cost monthly. GigWatch is:
56
+
57
+ - **Self-hosted & private** — your skills, your feeds, your machine. No
58
+ account, no session scraping, no data leaving your box.
59
+ - **Free & open (MIT)** — the core is a small, readable Python CLI.
60
+ - **Portable** — run it on a laptop, a $5 VPS, or in a cron job.
61
+
62
+ ## Features
63
+
64
+ - **Multiple feed sources** — Remotive, We Work Remotely, RemoteOK, and
65
+ Hacker News "Who is Hiring?" (built-in, no auth), any RSS/Atom feed, or any
66
+ JSON endpoint returning a list of job objects.
67
+ - **AI job ranking** — `gigwatch rank` scores every match 0-100 against a
68
+ profile you write (role, skills, location, notes) and explains *why*. Uses
69
+ an LLM when `OPENAI_API_KEY` is set (any OpenAI-compatible endpoint);
70
+ otherwise a deterministic, dependency-free heuristic. Either way you get a
71
+ ranked shortlist, not a raw dump.
72
+ - **Output formats** — `scan`, `list`, and `rank` take `--format
73
+ text|markdown|json` (a Markdown table or a JSON array of job objects for
74
+ piping elsewhere).
75
+ - **Skill-based filtering** — keyword matching (any/all), category and
76
+ location filters, exclude-list, and a relevance score (title hits weigh
77
+ more than body hits).
78
+ - **Dedupe by state** — a local JSON state file tracks what you've already
79
+ seen, so you're only ever alerted on *new* matches. State auto-prunes.
80
+ - **Alerts** — console (default), SMTP email, or Slack (webhook or chat API).
81
+ Secrets come from environment variables, never the config file.
82
+ - **Zero dependencies** — pure Python standard library. If you can run
83
+ `python3`, you can run GigWatch.
84
+
85
+ ## Quick start
86
+
87
+ No install required — it's stdlib-only:
88
+
89
+ ```bash
90
+ # 1. Get the code
91
+ git clone https://github.com/earnnova-dev/gigwatch
92
+ cd gigwatch
93
+
94
+ # 2. Create a starter config (or copy config.example.json to config.json)
95
+ python3 -m gigwatch init
96
+
97
+ # 3. Edit config.json: put YOUR skills in filters.keywords
98
+ # e.g. ["python", "backend", "api", "django"]
99
+
100
+ # 4. See what would match right now (dry run, no state touched)
101
+ python3 -m gigwatch list
102
+
103
+ # 5. Do a real scan: alerts on new matches, remembers them
104
+ python3 -m gigwatch scan
105
+ ```
106
+
107
+ Or install it as a proper command:
108
+
109
+ ```bash
110
+ pip install . # or: pipx install .
111
+ gigwatch init && gigwatch scan
112
+ ```
113
+
114
+ ### Run it continuously
115
+
116
+ ```bash
117
+ # Loop forever, re-scanning every poll_interval seconds (default 15 min):
118
+ gigwatch watch
119
+
120
+ # Or use cron on a VPS (once an hour):
121
+ 0 * * * * cd /opt/gigwatch && /usr/bin/python3 -m gigwatch scan
122
+ ```
123
+
124
+ ### Rank matches by fit
125
+
126
+ `rank` fetches and filters like `scan`, then scores every match 0-100 against
127
+ a profile and explains the score. It uses an LLM when `OPENAI_API_KEY` is set
128
+ (any OpenAI-compatible endpoint; the model is auto-discovered), otherwise a
129
+ deterministic heuristic — so it works with no key at all.
130
+
131
+ ```bash
132
+ # Use the profile from config.json:
133
+ gigwatch rank
134
+
135
+ # Or pass a profile on the command line:
136
+ gigwatch rank --title "Senior Python Engineer" \
137
+ --skills "python,backend,api" \
138
+ --location remote --notes "senior, \$150k+"
139
+
140
+ # Force the offline heuristic (no LLM call):
141
+ gigwatch rank --no-ai --format markdown
142
+ ```
143
+
144
+ ### Alerts
145
+
146
+ Console is on by default. To also get email/Slack, fill in the `alerts`
147
+ section of `config.json` and set the env vars it references:
148
+
149
+ ```bash
150
+ export GIGWATCH_EMAIL_TO=you@yourdomain.com
151
+ export GIGWATCH_SMTP_USER=...
152
+ export GIGWATCH_SMTP_PASS=...
153
+ export GIGWATCH_SLACK_WEBHOOK=https://hooks.slack.com/services/...
154
+ ```
155
+
156
+ The `${VAR}` placeholders in the config are expanded from the environment at
157
+ load time, so secrets never live in the file (and the file is safe to commit
158
+ if you want).
159
+
160
+ ## Configuration
161
+
162
+ See [`config.example.json`](config.example.json) for a fully annotated
163
+ example. The top-level keys:
164
+
165
+ | Key | Meaning |
166
+ |-----|---------|
167
+ | `sources` | List of feeds to watch. `{"type":"remotive"}`, `{"type":"wwr"}`, `{"type":"remoteok"}`, `{"type":"hn"}`, `{"type":"rss","url":...}`, or `{"type":"json","url":...}`. |
168
+ | `profile` | Optional candidate profile for `rank`: `{"title","skills":[...],"location","notes"}`. |
169
+ | `filters.keywords` | Your skills. A job matches if it contains any of these (or all, with `require_all_keywords`). |
170
+ | `filters.categories` / `filters.locations` | Optional extra filters (empty = match anything). |
171
+ | `filters.exclude_keywords` | Words that disqualify a job (e.g. `"intern"`, `"junior"`). |
172
+ | `filters.min_score` | Relevance floor. Title hits = 3 pts, body hits = 1 pt per keyword. |
173
+ | `alerts` | `console`, `email`, `slack` (see above) and `max_per_alert`. |
174
+ | `state_file` | Where seen-jobs are remembered (default `gigwatch-state.json`). |
175
+ | `poll_interval` | Seconds between scans in `watch` mode (default 900). |
176
+
177
+ ### Adding your own JSON source
178
+
179
+ Any endpoint that returns a JSON array of objects works. Each object should
180
+ have at least `title` and `url`; `company`/`company_name`, `category`,
181
+ `location`/`country`, `salary`, `tags`, and `description` are picked up if
182
+ present. Wrap in `{"jobs":[...]}`, `{"data":[...]}`, `{"results":[...]}`, or
183
+ `{"items":[...]}` and it still works.
184
+
185
+ ## Commands
186
+
187
+ | Command | What it does |
188
+ |---------|-------------|
189
+ | `gigwatch init` | Write a starter `config.json`. |
190
+ | `gigwatch list` | **Dry run** — fetch + filter + print matches. Does not touch state. |
191
+ | `gigwatch scan` | Fetch, filter, alert on new matches, and record them as seen. |
192
+ | `gigwatch rank` | Fetch + filter, then rank matches 0-100 for your `profile` (AI or heuristic). |
193
+ | `gigwatch watch` | Loop `scan` every `poll_interval` seconds. |
194
+ | `gigwatch reset` | Clear the seen-state (next scan alerts on everything that matches). |
195
+
196
+ Useful flags: `--config PATH` (default `config.json`), `-v/--verbose`,
197
+ `--max-age-days N` (state pruning; `0` keeps everything), and
198
+ `--format text|markdown|json` on `scan`/`list`/`rank`. For `rank`, also
199
+ `--profile FILE` (a JSON profile), `--skills a,b,c`, `--title`, `--location`,
200
+ `--notes`, and `--no-ai` (force the deterministic heuristic engine).
201
+
202
+ ## How it works
203
+
204
+ ```
205
+ sources ──fetch──> [Job, Job, ...]
206
+
207
+ filter_jobs() (keywords / category / location / score)
208
+
209
+ [ScoredJob, ...]
210
+
211
+ compare against state file
212
+
213
+ ┌──────┴──────┐
214
+ new jobs already seen
215
+ │ │
216
+ alert (console/ drop silently
217
+ email/slack)
218
+
219
+ mark seen in state file ──> gigwatch-state.json
220
+ ```
221
+
222
+ The state file is plain JSON (`{job_id: first_seen_utc}`) so you can inspect
223
+ it, back it up, or move it between machines.
224
+
225
+ ## Roadmap / ideas
226
+
227
+ - ~~More built-in sources (Hacker News "Who is hiring")~~ — **done in 0.2.0**.
228
+ - ~~AI ranking: summarize each match and rank by fit to a profile~~ — **done in
229
+ 0.2.0** (`gigwatch rank`).
230
+ - LinkedIn via RSS, Upwork via a user-supplied export.
231
+ - Draft proposals / cover letters per match.
232
+ - A tiny hosted tier (the natural monetization path — see below).
233
+
234
+ ## Monetization
235
+
236
+ The core is free and MIT-licensed. The obvious paid tier is a **hosted
237
+ GigWatch**: we run the scanner on our infra, you get email/Slack alerts
238
+ without running anything, plus AI-ranked matches and proposal drafts. That's
239
+ the plan — the open-source CLI is both the product's engine and the
240
+ marketing.
241
+
242
+ ## License
243
+
244
+ MIT — see [LICENSE](LICENSE).
245
+
246
+ ## Contact
247
+
248
+ For questions, hosting-tier interest, or support, open an
249
+ [issue](https://github.com/earnnova-dev/gigwatch/issues/new) — that's the
250
+ fastest route. Email works too: <a href="mailto:novagw&#64;uberip.com">novagw[at]uberip[dot]com</a>.
251
+
252
+ ## Contributing
253
+
254
+ Issues and PRs welcome. The code is deliberately small and stdlib-only;
255
+ keep it that way. Run `pytest` (unit) and `pytest -m live` (hits the real
256
+ Remotive API) before sending a PR.
@@ -0,0 +1,231 @@
1
+ # GigWatch
2
+
3
+ **A self-hosted freelance-gig watcher.** GigWatch monitors live job/gig feeds,
4
+ filters them against *your* skills, remembers what it has already shown you,
5
+ and alerts you (console, email, or Slack) **only when a new matching gig
6
+ appears**.
7
+
8
+ Stop refreshing Upwork/Remotive/LinkedIn every 20 minutes. Point GigWatch at
9
+ the feeds you care about, tell it what you do, and let it ping you when
10
+ something relevant lands.
11
+
12
+ ```
13
+ $ gigwatch scan
14
+ scanned 17 job(s) from 1 source(s); 4 match filter; 2 new
15
+ GigWatch: 2 new matching gig(s)
16
+
17
+ 1. Senior Python Backend Engineer
18
+ company: Acme Digital
19
+ salary: $120k-$150k
20
+ where: Remote (Worldwide)
21
+ matched: python, backend
22
+ score: 9.0
23
+ https://remotive.com/remote-jobs/...
24
+ ```
25
+
26
+ ## Why this exists
27
+
28
+ Freelancers lose real money to *latency* — the best gigs get filled in the
29
+ first hours. Existing monitors (Distill, PageCrawl, Apify's Upwork monitor)
30
+ are hosted SaaS that scrape your sessions and cost monthly. GigWatch is:
31
+
32
+ - **Self-hosted & private** — your skills, your feeds, your machine. No
33
+ account, no session scraping, no data leaving your box.
34
+ - **Free & open (MIT)** — the core is a small, readable Python CLI.
35
+ - **Portable** — run it on a laptop, a $5 VPS, or in a cron job.
36
+
37
+ ## Features
38
+
39
+ - **Multiple feed sources** — Remotive, We Work Remotely, RemoteOK, and
40
+ Hacker News "Who is Hiring?" (built-in, no auth), any RSS/Atom feed, or any
41
+ JSON endpoint returning a list of job objects.
42
+ - **AI job ranking** — `gigwatch rank` scores every match 0-100 against a
43
+ profile you write (role, skills, location, notes) and explains *why*. Uses
44
+ an LLM when `OPENAI_API_KEY` is set (any OpenAI-compatible endpoint);
45
+ otherwise a deterministic, dependency-free heuristic. Either way you get a
46
+ ranked shortlist, not a raw dump.
47
+ - **Output formats** — `scan`, `list`, and `rank` take `--format
48
+ text|markdown|json` (a Markdown table or a JSON array of job objects for
49
+ piping elsewhere).
50
+ - **Skill-based filtering** — keyword matching (any/all), category and
51
+ location filters, exclude-list, and a relevance score (title hits weigh
52
+ more than body hits).
53
+ - **Dedupe by state** — a local JSON state file tracks what you've already
54
+ seen, so you're only ever alerted on *new* matches. State auto-prunes.
55
+ - **Alerts** — console (default), SMTP email, or Slack (webhook or chat API).
56
+ Secrets come from environment variables, never the config file.
57
+ - **Zero dependencies** — pure Python standard library. If you can run
58
+ `python3`, you can run GigWatch.
59
+
60
+ ## Quick start
61
+
62
+ No install required — it's stdlib-only:
63
+
64
+ ```bash
65
+ # 1. Get the code
66
+ git clone https://github.com/earnnova-dev/gigwatch
67
+ cd gigwatch
68
+
69
+ # 2. Create a starter config (or copy config.example.json to config.json)
70
+ python3 -m gigwatch init
71
+
72
+ # 3. Edit config.json: put YOUR skills in filters.keywords
73
+ # e.g. ["python", "backend", "api", "django"]
74
+
75
+ # 4. See what would match right now (dry run, no state touched)
76
+ python3 -m gigwatch list
77
+
78
+ # 5. Do a real scan: alerts on new matches, remembers them
79
+ python3 -m gigwatch scan
80
+ ```
81
+
82
+ Or install it as a proper command:
83
+
84
+ ```bash
85
+ pip install . # or: pipx install .
86
+ gigwatch init && gigwatch scan
87
+ ```
88
+
89
+ ### Run it continuously
90
+
91
+ ```bash
92
+ # Loop forever, re-scanning every poll_interval seconds (default 15 min):
93
+ gigwatch watch
94
+
95
+ # Or use cron on a VPS (once an hour):
96
+ 0 * * * * cd /opt/gigwatch && /usr/bin/python3 -m gigwatch scan
97
+ ```
98
+
99
+ ### Rank matches by fit
100
+
101
+ `rank` fetches and filters like `scan`, then scores every match 0-100 against
102
+ a profile and explains the score. It uses an LLM when `OPENAI_API_KEY` is set
103
+ (any OpenAI-compatible endpoint; the model is auto-discovered), otherwise a
104
+ deterministic heuristic — so it works with no key at all.
105
+
106
+ ```bash
107
+ # Use the profile from config.json:
108
+ gigwatch rank
109
+
110
+ # Or pass a profile on the command line:
111
+ gigwatch rank --title "Senior Python Engineer" \
112
+ --skills "python,backend,api" \
113
+ --location remote --notes "senior, \$150k+"
114
+
115
+ # Force the offline heuristic (no LLM call):
116
+ gigwatch rank --no-ai --format markdown
117
+ ```
118
+
119
+ ### Alerts
120
+
121
+ Console is on by default. To also get email/Slack, fill in the `alerts`
122
+ section of `config.json` and set the env vars it references:
123
+
124
+ ```bash
125
+ export GIGWATCH_EMAIL_TO=you@yourdomain.com
126
+ export GIGWATCH_SMTP_USER=...
127
+ export GIGWATCH_SMTP_PASS=...
128
+ export GIGWATCH_SLACK_WEBHOOK=https://hooks.slack.com/services/...
129
+ ```
130
+
131
+ The `${VAR}` placeholders in the config are expanded from the environment at
132
+ load time, so secrets never live in the file (and the file is safe to commit
133
+ if you want).
134
+
135
+ ## Configuration
136
+
137
+ See [`config.example.json`](config.example.json) for a fully annotated
138
+ example. The top-level keys:
139
+
140
+ | Key | Meaning |
141
+ |-----|---------|
142
+ | `sources` | List of feeds to watch. `{"type":"remotive"}`, `{"type":"wwr"}`, `{"type":"remoteok"}`, `{"type":"hn"}`, `{"type":"rss","url":...}`, or `{"type":"json","url":...}`. |
143
+ | `profile` | Optional candidate profile for `rank`: `{"title","skills":[...],"location","notes"}`. |
144
+ | `filters.keywords` | Your skills. A job matches if it contains any of these (or all, with `require_all_keywords`). |
145
+ | `filters.categories` / `filters.locations` | Optional extra filters (empty = match anything). |
146
+ | `filters.exclude_keywords` | Words that disqualify a job (e.g. `"intern"`, `"junior"`). |
147
+ | `filters.min_score` | Relevance floor. Title hits = 3 pts, body hits = 1 pt per keyword. |
148
+ | `alerts` | `console`, `email`, `slack` (see above) and `max_per_alert`. |
149
+ | `state_file` | Where seen-jobs are remembered (default `gigwatch-state.json`). |
150
+ | `poll_interval` | Seconds between scans in `watch` mode (default 900). |
151
+
152
+ ### Adding your own JSON source
153
+
154
+ Any endpoint that returns a JSON array of objects works. Each object should
155
+ have at least `title` and `url`; `company`/`company_name`, `category`,
156
+ `location`/`country`, `salary`, `tags`, and `description` are picked up if
157
+ present. Wrap in `{"jobs":[...]}`, `{"data":[...]}`, `{"results":[...]}`, or
158
+ `{"items":[...]}` and it still works.
159
+
160
+ ## Commands
161
+
162
+ | Command | What it does |
163
+ |---------|-------------|
164
+ | `gigwatch init` | Write a starter `config.json`. |
165
+ | `gigwatch list` | **Dry run** — fetch + filter + print matches. Does not touch state. |
166
+ | `gigwatch scan` | Fetch, filter, alert on new matches, and record them as seen. |
167
+ | `gigwatch rank` | Fetch + filter, then rank matches 0-100 for your `profile` (AI or heuristic). |
168
+ | `gigwatch watch` | Loop `scan` every `poll_interval` seconds. |
169
+ | `gigwatch reset` | Clear the seen-state (next scan alerts on everything that matches). |
170
+
171
+ Useful flags: `--config PATH` (default `config.json`), `-v/--verbose`,
172
+ `--max-age-days N` (state pruning; `0` keeps everything), and
173
+ `--format text|markdown|json` on `scan`/`list`/`rank`. For `rank`, also
174
+ `--profile FILE` (a JSON profile), `--skills a,b,c`, `--title`, `--location`,
175
+ `--notes`, and `--no-ai` (force the deterministic heuristic engine).
176
+
177
+ ## How it works
178
+
179
+ ```
180
+ sources ──fetch──> [Job, Job, ...]
181
+
182
+ filter_jobs() (keywords / category / location / score)
183
+
184
+ [ScoredJob, ...]
185
+
186
+ compare against state file
187
+
188
+ ┌──────┴──────┐
189
+ new jobs already seen
190
+ │ │
191
+ alert (console/ drop silently
192
+ email/slack)
193
+
194
+ mark seen in state file ──> gigwatch-state.json
195
+ ```
196
+
197
+ The state file is plain JSON (`{job_id: first_seen_utc}`) so you can inspect
198
+ it, back it up, or move it between machines.
199
+
200
+ ## Roadmap / ideas
201
+
202
+ - ~~More built-in sources (Hacker News "Who is hiring")~~ — **done in 0.2.0**.
203
+ - ~~AI ranking: summarize each match and rank by fit to a profile~~ — **done in
204
+ 0.2.0** (`gigwatch rank`).
205
+ - LinkedIn via RSS, Upwork via a user-supplied export.
206
+ - Draft proposals / cover letters per match.
207
+ - A tiny hosted tier (the natural monetization path — see below).
208
+
209
+ ## Monetization
210
+
211
+ The core is free and MIT-licensed. The obvious paid tier is a **hosted
212
+ GigWatch**: we run the scanner on our infra, you get email/Slack alerts
213
+ without running anything, plus AI-ranked matches and proposal drafts. That's
214
+ the plan — the open-source CLI is both the product's engine and the
215
+ marketing.
216
+
217
+ ## License
218
+
219
+ MIT — see [LICENSE](LICENSE).
220
+
221
+ ## Contact
222
+
223
+ For questions, hosting-tier interest, or support, open an
224
+ [issue](https://github.com/earnnova-dev/gigwatch/issues/new) — that's the
225
+ fastest route. Email works too: <a href="mailto:novagw&#64;uberip.com">novagw[at]uberip[dot]com</a>.
226
+
227
+ ## Contributing
228
+
229
+ Issues and PRs welcome. The code is deliberately small and stdlib-only;
230
+ keep it that way. Run `pytest` (unit) and `pytest -m live` (hits the real
231
+ Remotive API) before sending a PR.
@@ -0,0 +1,8 @@
1
+ """GigWatch: a self-hosted freelance-gig watcher.
2
+
3
+ Pulls live job/gig feeds, filters them against your skills, dedupes against a
4
+ local state file, and alerts you (console / email / Slack) only when a new
5
+ matching gig appears.
6
+ """
7
+
8
+ __version__ = "0.2.0"
@@ -0,0 +1,6 @@
1
+ """Allow `python -m gigwatch` to run the CLI."""
2
+
3
+ from gigwatch.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
@@ -0,0 +1,125 @@
1
+ """Alert delivery: console, email (SMTP), Slack (webhook or chat API).
2
+
3
+ All senders are best-effort: a failure in one channel never blocks the others
4
+ and never crashes the scan. Errors are returned so the CLI can report them.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import smtplib
11
+ import ssl
12
+ import urllib.request
13
+ from email.message import EmailMessage
14
+ from typing import List, Optional
15
+
16
+ from gigwatch.config import AlertConfig
17
+ from gigwatch.filtering import ScoredJob
18
+
19
+
20
+ def format_jobs(jobs: List[ScoredJob], max_per_alert: int = 25) -> str:
21
+ """Human-readable multi-line summary of new matches."""
22
+ lines = ["GigWatch: %d new matching gig(s)" % len(jobs), ""]
23
+ for i, s in enumerate(jobs[:max_per_alert], 1):
24
+ j = s.job
25
+ lines.append("%d. %s" % (i, j.title))
26
+ if j.company:
27
+ lines.append(" company: %s" % j.company)
28
+ if j.salary:
29
+ lines.append(" salary: %s" % j.salary)
30
+ if j.location:
31
+ lines.append(" where: %s" % j.location)
32
+ if s.matched_keywords:
33
+ lines.append(" matched: %s" % ", ".join(s.matched_keywords))
34
+ lines.append(" score: %s" % s.score)
35
+ lines.append(" %s" % j.url)
36
+ lines.append("")
37
+ if len(jobs) > max_per_alert:
38
+ lines.append("... and %d more (raise alerts.max_per_alert to see them)"
39
+ % (len(jobs) - max_per_alert))
40
+ return "\n".join(lines).strip()
41
+
42
+
43
+ def send_console(jobs: List[ScoredJob], cfg: AlertConfig) -> Optional[str]:
44
+ if not cfg.console:
45
+ return None
46
+ text = format_jobs(jobs, cfg.max_per_alert)
47
+ print(text)
48
+ return None
49
+
50
+
51
+ def send_email(jobs: List[ScoredJob], cfg: AlertConfig) -> Optional[str]:
52
+ e = cfg.email
53
+ if not e or not e.get("to"):
54
+ return None
55
+ try:
56
+ msg = EmailMessage()
57
+ msg["Subject"] = "GigWatch: %d new matching gig(s)" % len(jobs)
58
+ msg["From"] = e.get("from", "gigwatch@localhost")
59
+ msg["To"] = e["to"] if isinstance(e["to"], str) else ", ".join(e["to"])
60
+ msg.set_content(format_jobs(jobs, cfg.max_per_alert))
61
+
62
+ host = e.get("smtp_host", "localhost")
63
+ port = int(e.get("smtp_port", 587))
64
+ use_tls = bool(e.get("use_tls", True))
65
+ username = e.get("username")
66
+ password = e.get("password", "")
67
+
68
+ if use_tls:
69
+ server = smtplib.SMTP(host, port, timeout=30)
70
+ server.starttls(context=ssl.create_default_context())
71
+ else:
72
+ server = smtplib.SMTP(host, port, timeout=30)
73
+ if username:
74
+ server.login(username, password)
75
+ server.send_message(msg)
76
+ server.quit()
77
+ return None
78
+ except Exception as exc: # noqa: BLE001 - best-effort alert
79
+ return "email: %s" % exc
80
+
81
+
82
+ def send_slack(jobs: List[ScoredJob], cfg: AlertConfig) -> Optional[str]:
83
+ s = cfg.slack
84
+ if not s:
85
+ return None
86
+ text = format_jobs(jobs, cfg.max_per_alert)
87
+ try:
88
+ if s.get("webhook_url"):
89
+ payload = {"text": text[:3900]}
90
+ req = urllib.request.Request(
91
+ s["webhook_url"],
92
+ data=json.dumps(payload).encode("utf-8"),
93
+ headers={"Content-Type": "application/json"},
94
+ )
95
+ urllib.request.urlopen(req, timeout=30).read()
96
+ return None
97
+ if s.get("token") and s.get("channel"):
98
+ payload = {"channel": s["channel"], "text": text[:3900]}
99
+ req = urllib.request.Request(
100
+ "https://slack.com/api/chat.postMessage",
101
+ data=json.dumps(payload).encode("utf-8"),
102
+ headers={
103
+ "Content-Type": "application/json; charset=utf-8",
104
+ "Authorization": "Bearer %s" % s["token"],
105
+ },
106
+ )
107
+ resp = json.loads(urllib.request.urlopen(req, timeout=30).read())
108
+ if not resp.get("ok"):
109
+ return "slack: %s" % resp.get("error")
110
+ return None
111
+ return "slack: no webhook_url or (token+channel) configured"
112
+ except Exception as exc: # noqa: BLE001
113
+ return "slack: %s" % exc
114
+
115
+
116
+ def send_all(jobs: List[ScoredJob], cfg: AlertConfig) -> List[str]:
117
+ """Send through every enabled channel. Returns a list of error strings."""
118
+ errors: List[str] = []
119
+ if not jobs:
120
+ return errors
121
+ for fn in (send_console, send_email, send_slack):
122
+ err = fn(jobs, cfg)
123
+ if err:
124
+ errors.append(err)
125
+ return errors