feat: send matching jobs to telegram twice daily
telegram.ts
- Bot API wrapper: HTML escaping, 4096-char chunking, rate-limit
spacing, and --setup to discover the chat ID from the bot's inbox.
Reads TELEGRAM_BOT (or TELEGRAM_TOKEN) and TELEGRAM_CHAT_ID.
profile.ts
- matchesProfile() scores a title against cv/polo.md. Hard rejects for
Microsoft stack, Java/C++/SAP, aggregators (Jobgether, ALLPS),
non-software functions and life-science domains; then weighted
signals for the core stack, platform/DevOps, data, AI/ML, cloud and
seniority. Threshold is 4 so short bullseye titles ("Data Engineer",
"DevOps Engineer") pass, and forward-deployed is weighted to clear it
on its own.
daily.ts
- Runs the fetch, diffs jobs.csv, keeps only profile matches, groups by
area and pushes to Telegram. results/sent.txt records every link
actually sent, written only after a successful send, so a failure
retries instead of losing jobs and an ad is never sent twice.
- --dry-run previews, --resend --date= re-sends a past day.
- A failed fetch reports itself rather than going quiet.
Scheduled at 08:00 and 18:00 by a launchd agent
(com.rafapolo.linkme-daily). launchd runs missed jobs on wake, and the
digest is diff-based, so downtime delays delivery without losing jobs.
This commit is contained in:
+69
@@ -0,0 +1,69 @@
|
||||
// Does a job match Rafael's profile? Title/company-level scoring, used to keep
|
||||
// the Telegram digest to real leads instead of everything the scraper finds.
|
||||
//
|
||||
// Hard rejects encode standing decisions:
|
||||
// - Microsoft stack (.NET/C#/Azure-first/Windows/SharePoint/Power Platform)
|
||||
// - Jobgether (aggregator, not an employer)
|
||||
// - ALLPS (postings require German despite passing the English filter)
|
||||
// - life-science domains (also filtered at fetch time by BIO_DOMAIN_RE)
|
||||
// - non-software functions (sales, HR, finance, marketing, support)
|
||||
|
||||
export const MS_STACK_RE =
|
||||
/\.net\b|\bc#|\basp\.net\b|sharepoint|dynamics\s*365|power\s*(?:platform|bi|apps|automate)|\bsccm\b|\bmecm\b|intune|autopilot|windows[\s\/]|\bm365\b|microsoft\s*365|\bazure\b/i;
|
||||
|
||||
export const WRONG_STACK_RE =
|
||||
/\bjava\b(?!script)|\bspring\s*boot\b|\bc\+\+\b|\bembedded\b|\bcobol\b|\babap\b|\bsap\b|\bdelphi\b/i;
|
||||
|
||||
export const BLOCKED_COMPANY_RE = /\bjobgether\b|\ballps\b/i;
|
||||
|
||||
export const BIO_RE =
|
||||
/\bbio(?:informatic|statistic|logic)\w*|\bpatholog\w*|\bgenomic\w*|\bproteomic\w*|\bGMP\b|\bMSAT\b|\bdrug\s+(?:discovery|design)\b|\bchemist(?:ry)?\b|\bclinical\s+(?:trial|research)\b|\bbiomarker\b|\bassay\b/i;
|
||||
|
||||
export const NON_SOFTWARE_RE =
|
||||
/\b(?:sales\s+(?:director|manager|representative)|account\s+(?:executive|manager)|business\s+development|recruit(?:er|ment)|talent\s+acquisition|marketing|copywriter|\bHR\b|human\s+resources|accountant|controller|auditor|receptionist|customer\s+(?:support|service)\s+agent|help\s?desk|service\s+desk|1st\s+level)\b/i;
|
||||
|
||||
/** Core stack and domains from cv/polo.md — each hit adds to the score. */
|
||||
const SIGNALS: [RegExp, number, string][] = [
|
||||
[/\bpython\b/i, 3, "Python"],
|
||||
[/\btypescript\b|\bnode\.?js\b|\bjavascript\b/i, 3, "TS/Node"],
|
||||
[/\bruby\b|\brails\b/i, 4, "Ruby/Rails"],
|
||||
[/\breact\b/i, 2, "React"],
|
||||
[/\bplatform\s+engineer|\binfrastructure\s+engineer|\bsre\b|site\s+reliability/i, 4, "platform/SRE"],
|
||||
[/\bdevops\b|\bdevsecops\b|kubernetes|\bk8s\b|terraform|\bci\/cd\b/i, 4, "DevOps"],
|
||||
[/\bdata\s+(?:engineer|platform|pipeline)/i, 4, "data eng"],
|
||||
[/\bai\b|\bml\b|machine\s+learning|\bllm\b|\bgenai\b|\brag\b|mlops/i, 3, "AI/ML"],
|
||||
[/\bcloud\b|\baws\b|solutions?\s+architect/i, 3, "cloud/AWS"],
|
||||
[/\bsecurity\b/i, 2, "security"],
|
||||
[/\bbackend\b|\bfull.?stack\b|\bapi\b|\bintegration\b/i, 2, "backend/API"],
|
||||
[/forward\s+deployed|\bfield\s+engineer|solutions?\s+engineer|customer\s+engineer/i, 4, "forward-deployed"],
|
||||
[/\bsoftware\s+engineer\b|\bdeveloper\b/i, 2, "software"],
|
||||
// seniority — he's Staff-level; junior titles are already dropped upstream
|
||||
[/\b(?:senior|staff|principal|lead|head\s+of|architect)\b/i, 2, "senior"],
|
||||
];
|
||||
|
||||
export interface Match {
|
||||
ok: boolean;
|
||||
score: number;
|
||||
reasons: string[];
|
||||
rejectedBy?: string;
|
||||
}
|
||||
|
||||
export function matchesProfile(title: string, company = "", minScore = 4): Match {
|
||||
const t = `${title}`;
|
||||
if (BLOCKED_COMPANY_RE.test(company) || BLOCKED_COMPANY_RE.test(t))
|
||||
return { ok: false, score: 0, reasons: [], rejectedBy: "blocked company" };
|
||||
if (MS_STACK_RE.test(t)) return { ok: false, score: 0, reasons: [], rejectedBy: "Microsoft stack" };
|
||||
if (WRONG_STACK_RE.test(t)) return { ok: false, score: 0, reasons: [], rejectedBy: "wrong stack (Java/C++/SAP)" };
|
||||
if (BIO_RE.test(t)) return { ok: false, score: 0, reasons: [], rejectedBy: "life-science domain" };
|
||||
if (NON_SOFTWARE_RE.test(t)) return { ok: false, score: 0, reasons: [], rejectedBy: "non-software role" };
|
||||
|
||||
let score = 0;
|
||||
const reasons: string[] = [];
|
||||
for (const [re, pts, label] of SIGNALS) {
|
||||
if (re.test(t)) {
|
||||
score += pts;
|
||||
reasons.push(label);
|
||||
}
|
||||
}
|
||||
return { ok: score >= minScore, score, reasons, rejectedBy: score >= minScore ? undefined : "weak match" };
|
||||
}
|
||||
Reference in New Issue
Block a user