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.
- gigwatch_nova-0.2.0/LICENSE +21 -0
- gigwatch_nova-0.2.0/PKG-INFO +256 -0
- gigwatch_nova-0.2.0/README.md +231 -0
- gigwatch_nova-0.2.0/gigwatch/__init__.py +8 -0
- gigwatch_nova-0.2.0/gigwatch/__main__.py +6 -0
- gigwatch_nova-0.2.0/gigwatch/alerts.py +125 -0
- gigwatch_nova-0.2.0/gigwatch/cli.py +353 -0
- gigwatch_nova-0.2.0/gigwatch/config.py +131 -0
- gigwatch_nova-0.2.0/gigwatch/filtering.py +90 -0
- gigwatch_nova-0.2.0/gigwatch/ranking.py +275 -0
- gigwatch_nova-0.2.0/gigwatch/report.py +89 -0
- gigwatch_nova-0.2.0/gigwatch/sources.py +429 -0
- gigwatch_nova-0.2.0/gigwatch/state.py +65 -0
- gigwatch_nova-0.2.0/gigwatch_nova.egg-info/PKG-INFO +256 -0
- gigwatch_nova-0.2.0/gigwatch_nova.egg-info/SOURCES.txt +21 -0
- gigwatch_nova-0.2.0/gigwatch_nova.egg-info/dependency_links.txt +1 -0
- gigwatch_nova-0.2.0/gigwatch_nova.egg-info/entry_points.txt +2 -0
- gigwatch_nova-0.2.0/gigwatch_nova.egg-info/requires.txt +3 -0
- gigwatch_nova-0.2.0/gigwatch_nova.egg-info/top_level.txt +1 -0
- gigwatch_nova-0.2.0/pyproject.toml +43 -0
- gigwatch_nova-0.2.0/setup.cfg +4 -0
- gigwatch_nova-0.2.0/tests/test_gigwatch.py +504 -0
- gigwatch_nova-0.2.0/tests/test_live.py +87 -0
|
@@ -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@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@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,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
|