feat: add email notifications + fix websearch scan_method guard#560
feat: add email notifications + fix websearch scan_method guard#560Craesol wants to merge 6 commits intosantifer:mainfrom
Conversation
|
Welcome to career-ops, @Craesol! Thanks for your first PR. A few things to know:
We'll review your PR soon. Join our Discord if you have questions. |
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughThis pull request introduces a comprehensive automated job scanning and notification system. It adds daily ATS and public feed scanning with keyword-based title classification, maintains scan history, and delivers notifications via Gmail (scan-and-notify) and Resend API (send-agent-results, send-daily-email). Windows Task Scheduler automation runs scans twice daily with logging. ChangesJob Scanning & Notification System
🎯 4 (Complex) | ⏱️ ~60 minutes Suggested Labels
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Tip 💬 Introducing Slack Agent: The best way for teams to turn conversations into code.Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.
Built for teams:
One agent for your entire SDLC. Right inside Slack. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 9
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
setup-scheduler.ps1 (1)
63-88:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winNon-ASCII characters in a UTF-8 file without BOM — garbled output on Windows PowerShell 5.x.
The PSScriptAnalyzer BOM warning is valid. The script contains non-ASCII characters (
—on line 67,→on line 63) and the usage comment on line 7 instructspowershell -ExecutionPolicy Bypass, which invokespowershell.exe(Windows PowerShell 5.x). Without a UTF-8 BOM, PowerShell 5.x reads the file using the system ANSI codepage and misinterprets the characters. Either save the file with UTF-8 BOM or replace the non-ASCII characters with ASCII equivalents.🛠️ Proposed ASCII-safe substitution
-Write-Host "career-ops — Scheduler Setup" +Write-Host "career-ops - Scheduler Setup" Write-Host "============================="- Write-Host " Registered: $TaskName → runs daily at ${Hour}:${Minute}" + Write-Host " Registered: $TaskName -> runs daily at ${Hour}:${Minute}"🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@setup-scheduler.ps1` around lines 63 - 88, The file contains non-ASCII characters '→' and '—' in Write-Host strings (e.g., the "Registered: $TaskName → runs daily ..." and "career-ops — Scheduler Setup") while the usage comment invokes powershell -ExecutionPolicy Bypass (Windows PowerShell 5.x), which will misread UTF-8 without BOM; fix by either saving the script with a UTF-8 BOM or replacing those characters with ASCII equivalents (replace '→' with '->' or '->'/'>' and '—' with '--' or '-') in the Write-Host lines and any usage/comment text so the script displays correctly in PowerShell 5.x.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In @.env.example:
- Line 31: The GMAIL_APP_PASSWORD placeholder in .env.example should be quoted
to satisfy dotenv linting and signal to users that the 16-character App Password
(which may contain spaces) must be wrapped in quotes; update the
GMAIL_APP_PASSWORD entry so the placeholder value is enclosed in either single
or double quotes (e.g., GMAIL_APP_PASSWORD="xxxx xxxx xxxx xxxx") in
.env.example and ensure the README or comment nearby briefly notes that the real
app password must be quoted.
In `@scan-and-notify.mjs`:
- Around line 21-28: The file uses createRequire and require to load nodemailer;
replace this CJS pattern with a native ESM import: remove createRequire and the
require('nodemailer') usage and instead import the transporter API directly
(e.g., import { createTransport } from 'nodemailer'), keeping the existing
__dirname calculation via fileURLToPath; update any code that referenced the
nodemailer variable to use createTransport (or the named imports) so the module
stays pure ESM and consistent with send-agent-results.mjs.
- Around line 103-110: The HTML generation for htmlRows is inserting unescaped
values (j.company, j.title, j.url) which can break markup; use the existing
escapeHtml helper (same as in send-agent-results.mjs) when building the string:
replace interpolations in the template produced by newEntries.map (htmlRows)
with escapeHtml(j.company), escapeHtml(j.title), and escapeHtml(j.url) and also
ensure the href attribute uses the escaped URL (escapeHtml(j.url)) so ampersands
and <>& are encoded for well-formed HTML.
- Around line 53-80: The dry-run path never shows the email preview because
beforeCount is computed before the scan and remains nonzero when execSync is
skipped; update the logic so that when DRY_RUN is true you set beforeCount = 0
(or compute beforeCount after you obtain afterEntries) instead of using the
pre-scan count. Specifically, change how beforeCount/getAddedToday() is used so
that in DRY_RUN the slice uses the full afterEntries (refer to beforeCount,
DRY_RUN, getAddedToday(), newEntries) ensuring newEntries contains today's
entries and the preview block is reachable.
In `@send-agent-results.mjs`:
- Around line 5-7: Validate GMAIL_USER and GMAIL_APP_PASSWORD before creating
the nodemailer transport: check that both process.env.GMAIL_USER and
process.env.GMAIL_APP_PASSWORD are non-empty and if not throw or exit with a
clear error (referencing GMAIL_USER and GMAIL_APP_PASSWORD); construct
NOTIFY_EMAIL only after confirming GMAIL_USER exists (avoid defaulting to an
undefined GMAIL_USER), and ensure the "from" address used when calling sendMail
never interpolates an undefined GMAIL_USER (use a safe fallback or abort
earlier).
- Around line 65-67: The href uses r.url raw which can break HTML when it
contains characters like &; update the template to pass r.url through the
existing escapeHtml function (the one defined in this file) when interpolating
into the anchor's href (and optionally for the anchor text if it currently
displays the URL), so change the interpolation from ${r.url} to
${escapeHtml(r.url)} ensuring the href attribute receives an HTML-escaped URL.
- Around line 25-31: The status field is not being trimmed when parsing TSV, so
on Windows CRLF lines e.status contains a trailing '\r' and the filter newRoles
(which checks e.status === 'added') silently drops all rows; update the TSV
parsing where entries are constructed (the variable entries and the property
assignment that sets status) to trim the field (e.g., set status =
cols[5]?.trim()) so the comparison in newRoles works reliably (follow the same
approach used in scan-and-notify.mjs).
- Around line 29-34: The newRoles filter currently excludes only greenhouse-api
and ashby-api so entries with portal 'lever-api' slip through; update the filter
in the newRoles computation to be a positive filter that only accepts the
websearch portal (e.g., check e.portal?.startsWith('websearch')) instead of
relying on exclusion, or alternatively extend the exclusion list to include all
API portals such as 'lever-api'; modify the predicate used in entries.filter
(referencing newRoles, entries and e.portal) so only websearch-origin entries
are selected for the WebSearch-only email.
- Line 12: The code unconditionally calls readFileSync to load
'./data/scan-history.tsv' which throws ENOENT on fresh setups; guard the read
with fs.existsSync('./data/scan-history.tsv') and only call readFileSync if the
file exists, otherwise initialize tsvContent to an empty string (or the same
graceful default behavior used in scan-and-notify.mjs); ensure you reference the
same fs import so existsSync is available and update the declaration that
creates tsvContent to use the conditional path.
---
Outside diff comments:
In `@setup-scheduler.ps1`:
- Around line 63-88: The file contains non-ASCII characters '→' and '—' in
Write-Host strings (e.g., the "Registered: $TaskName → runs daily ..." and
"career-ops — Scheduler Setup") while the usage comment invokes powershell
-ExecutionPolicy Bypass (Windows PowerShell 5.x), which will misread UTF-8
without BOM; fix by either saving the script with a UTF-8 BOM or replacing those
characters with ASCII equivalents (replace '→' with '->' or '->'/'>' and '—'
with '--' or '-') in the Write-Host lines and any usage/comment text so the
script displays correctly in PowerShell 5.x.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: d7196278-a0b8-4858-8b92-84b6c5c042ca
📥 Commits
Reviewing files that changed from the base of the PR and between 9ae201d and d6ece83b5be19eb81d358e7cab3c0fcf72a14c59.
📒 Files selected for processing (7)
.env.exampleVERSIONpackage.jsonscan-and-notify.mjsscan.mjssend-agent-results.mjssetup-scheduler.ps1
| # Step 3: Fill in below and save as .env (not .env.example) | ||
| # | ||
| GMAIL_USER=your@gmail.com | ||
| GMAIL_APP_PASSWORD=xxxx xxxx xxxx xxxx |
There was a problem hiding this comment.
Quote the GMAIL_APP_PASSWORD placeholder value.
The dotenv linter flags ValueWithoutQuotes. Although dotenv handles unquoted space-separated values, quoting the placeholder explicitly signals to users that the actual 16-char App Password (which contains spaces) must be quoted in their .env, preventing silent truncation by shells or other tools.
🛠️ Proposed fix
-GMAIL_APP_PASSWORD=xxxx xxxx xxxx xxxx
+GMAIL_APP_PASSWORD="xxxx xxxx xxxx xxxx"📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| GMAIL_APP_PASSWORD=xxxx xxxx xxxx xxxx | |
| GMAIL_APP_PASSWORD="xxxx xxxx xxxx xxxx" |
🧰 Tools
🪛 dotenv-linter (4.0.0)
[warning] 31-31: [UnorderedKey] The GMAIL_APP_PASSWORD key should go before the GMAIL_USER key
(UnorderedKey)
[warning] 31-31: [ValueWithoutQuotes] This value needs to be surrounded in quotes
(ValueWithoutQuotes)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In @.env.example at line 31, The GMAIL_APP_PASSWORD placeholder in .env.example
should be quoted to satisfy dotenv linting and signal to users that the
16-character App Password (which may contain spaces) must be wrapped in quotes;
update the GMAIL_APP_PASSWORD entry so the placeholder value is enclosed in
either single or double quotes (e.g., GMAIL_APP_PASSWORD="xxxx xxxx xxxx xxxx")
in .env.example and ensure the README or comment nearby briefly notes that the
real app password must be quoted.
| const beforeCount = getAddedToday().length; | ||
|
|
||
| console.log(`[scan-and-notify] Starting scan at ${new Date().toLocaleTimeString()} (${today})`); | ||
| console.log(`[scan-and-notify] Roles already added today before this scan: ${beforeCount}`); | ||
|
|
||
| if (!DRY_RUN) { | ||
| try { | ||
| execSync('node scan.mjs', { | ||
| stdio: 'inherit', | ||
| cwd: __dirname, | ||
| }); | ||
| } catch (err) { | ||
| console.error('[scan-and-notify] Scan failed:', err.message); | ||
| process.exit(1); | ||
| } | ||
| } else { | ||
| console.log('[scan-and-notify] --dry-run: skipping actual scan'); | ||
| } | ||
|
|
||
| // ── Check for new entries ───────────────────────────────────────────── | ||
|
|
||
| const afterEntries = getAddedToday(); | ||
| const newEntries = afterEntries.slice(beforeCount); | ||
|
|
||
| if (newEntries.length === 0) { | ||
| console.log('[scan-and-notify] No new roles found — skipping email.'); | ||
| process.exit(0); | ||
| } |
There was a problem hiding this comment.
--dry-run never reaches the email preview — the block is dead code.
beforeCount is computed before the scan runs. In --dry-run mode the execSync is skipped, so no new rows are ever appended to scan-history.tsv. afterEntries.slice(beforeCount) therefore always returns an empty array, causing an unconditional process.exit(0) at line 79. The actual dry-run preview at lines 137–142 is unreachable.
🐛 Proposed fix
-const beforeCount = getAddedToday().length;
+const beforeCount = DRY_RUN ? 0 : getAddedToday().length;In dry-run mode this sets beforeCount to 0, so newEntries will contain all of today's already-scanned entries and the email preview is shown as intended.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const beforeCount = getAddedToday().length; | |
| console.log(`[scan-and-notify] Starting scan at ${new Date().toLocaleTimeString()} (${today})`); | |
| console.log(`[scan-and-notify] Roles already added today before this scan: ${beforeCount}`); | |
| if (!DRY_RUN) { | |
| try { | |
| execSync('node scan.mjs', { | |
| stdio: 'inherit', | |
| cwd: __dirname, | |
| }); | |
| } catch (err) { | |
| console.error('[scan-and-notify] Scan failed:', err.message); | |
| process.exit(1); | |
| } | |
| } else { | |
| console.log('[scan-and-notify] --dry-run: skipping actual scan'); | |
| } | |
| // ── Check for new entries ───────────────────────────────────────────── | |
| const afterEntries = getAddedToday(); | |
| const newEntries = afterEntries.slice(beforeCount); | |
| if (newEntries.length === 0) { | |
| console.log('[scan-and-notify] No new roles found — skipping email.'); | |
| process.exit(0); | |
| } | |
| const beforeCount = DRY_RUN ? 0 : getAddedToday().length; | |
| console.log(`[scan-and-notify] Starting scan at ${new Date().toLocaleTimeString()} (${today})`); | |
| console.log(`[scan-and-notify] Roles already added today before this scan: ${beforeCount}`); | |
| if (!DRY_RUN) { | |
| try { | |
| execSync('node scan.mjs', { | |
| stdio: 'inherit', | |
| cwd: __dirname, | |
| }); | |
| } catch (err) { | |
| console.error('[scan-and-notify] Scan failed:', err.message); | |
| process.exit(1); | |
| } | |
| } else { | |
| console.log('[scan-and-notify] --dry-run: skipping actual scan'); | |
| } | |
| // ── Check for new entries ───────────────────────────────────────────── | |
| const afterEntries = getAddedToday(); | |
| const newEntries = afterEntries.slice(beforeCount); | |
| if (newEntries.length === 0) { | |
| console.log('[scan-and-notify] No new roles found — skipping email.'); | |
| process.exit(0); | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@scan-and-notify.mjs` around lines 53 - 80, The dry-run path never shows the
email preview because beforeCount is computed before the scan and remains
nonzero when execSync is skipped; update the logic so that when DRY_RUN is true
you set beforeCount = 0 (or compute beforeCount after you obtain afterEntries)
instead of using the pre-scan count. Specifically, change how
beforeCount/getAddedToday() is used so that in DRY_RUN the slice uses the full
afterEntries (refer to beforeCount, DRY_RUN, getAddedToday(), newEntries)
ensuring newEntries contains today's entries and the preview block is reachable.
| const htmlRows = newEntries.map(j => ` | ||
| <tr> | ||
| <td style="padding:10px 12px;border-bottom:1px solid #eee"> | ||
| <strong>${j.company}</strong><br> | ||
| <span style="color:#555">${j.title}</span><br> | ||
| <a href="${j.url}" style="font-size:12px;color:#0066cc">${j.url}</a> | ||
| </td> | ||
| </tr>`).join(''); |
There was a problem hiding this comment.
Missing HTML escaping in email body — company names and titles can contain &, <, >.
j.company, j.title, and j.url are interpolated directly into the HTML table without escaping. Job titles from API responses regularly contain & (e.g. "Data & Analytics Engineer") which would break the HTML structure. send-agent-results.mjs in this same PR implements an escapeHtml() helper for exactly this purpose — apply it here for consistency and correctness. The j.url placed inside href="${j.url}" also needs at minimum &→& encoding for well-formed HTML.
🐛 Proposed fix — add escapeHtml and apply it
+function escapeHtml(str) {
+ if (!str) return '';
+ return str
+ .replace(/&/g, '&')
+ .replace(/</g, '<')
+ .replace(/>/g, '>')
+ .replace(/"/g, '"');
+}
+
const htmlRows = newEntries.map(j => `
<tr>
<td style="padding:10px 12px;border-bottom:1px solid `#eee`">
- <strong>${j.company}</strong><br>
- <span style="color:`#555`">${j.title}</span><br>
- <a href="${j.url}" style="font-size:12px;color:`#0066cc`">${j.url}</a>
+ <strong>${escapeHtml(j.company)}</strong><br>
+ <span style="color:`#555`">${escapeHtml(j.title)}</span><br>
+ <a href="${escapeHtml(j.url)}" style="font-size:12px;color:`#0066cc`">${escapeHtml(j.url)}</a>
</td>
</tr>`).join('');🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@scan-and-notify.mjs` around lines 103 - 110, The HTML generation for htmlRows
is inserting unescaped values (j.company, j.title, j.url) which can break
markup; use the existing escapeHtml helper (same as in send-agent-results.mjs)
when building the string: replace interpolations in the template produced by
newEntries.map (htmlRows) with escapeHtml(j.company), escapeHtml(j.title), and
escapeHtml(j.url) and also ensure the href attribute uses the escaped URL
(escapeHtml(j.url)) so ampersands and <>& are encoded for well-formed HTML.
| const newRoles = entries.filter(e => | ||
| e.date === today && | ||
| e.status === 'added' && | ||
| !e.portal.startsWith('greenhouse-api') && | ||
| !e.portal.startsWith('ashby-api') | ||
| ); |
There was a problem hiding this comment.
lever-api portal entries incorrectly pass the WebSearch-only filter.
scan.mjs writes source: 'lever-api' for Lever API results (see scan.mjs line 321). The current exclusion list only covers greenhouse-api and ashby-api, so Lever API entries will silently appear in what is supposed to be a WebSearch-only email. Either extend the exclusion list to all API portal types, or switch to a positive filter that only accepts the websearch portal value.
🐛 Proposed fix (positive filter approach)
-// Filter: today's date AND status=added AND portal does NOT start with greenhouse-api or ashby-api
const newRoles = entries.filter(e =>
e.date === today &&
- e.status === 'added' &&
- !e.portal.startsWith('greenhouse-api') &&
- !e.portal.startsWith('ashby-api')
+ e.status?.trim() === 'added' &&
+ !e.portal?.startsWith('greenhouse-api') &&
+ !e.portal?.startsWith('ashby-api') &&
+ !e.portal?.startsWith('lever-api')
);Or, if the websearch agent writes a known portal prefix, use a positive filter: e.portal?.startsWith('websearch').
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@send-agent-results.mjs` around lines 29 - 34, The newRoles filter currently
excludes only greenhouse-api and ashby-api so entries with portal 'lever-api'
slip through; update the filter in the newRoles computation to be a positive
filter that only accepts the websearch portal (e.g., check
e.portal?.startsWith('websearch')) instead of relying on exclusion, or
alternatively extend the exclusion list to include all API portals such as
'lever-api'; modify the predicate used in entries.filter (referencing newRoles,
entries and e.portal) so only websearch-origin entries are selected for the
WebSearch-only email.
| <td style="padding: 8px 12px; border-bottom: 1px solid #e5e7eb;"> | ||
| <a href="${r.url}" style="color: #2563eb; text-decoration: none;">View posting</a> | ||
| </td> |
There was a problem hiding this comment.
r.url not passed through escapeHtml in the href attribute.
escapeHtml is already defined in this file and applied to company/title, but not to the URL. URLs with & in query parameters (common in job board links) produce malformed HTML like href="...&ref=api" instead of href="...&ref=api". Most mail clients tolerate this, but it's inconsistent and breaks HTML validity.
🐛 Proposed fix
- <a href="${r.url}" style="color: `#2563eb`; text-decoration: none;">View posting</a>
+ <a href="${escapeHtml(r.url)}" style="color: `#2563eb`; text-decoration: none;">View posting</a>📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| <td style="padding: 8px 12px; border-bottom: 1px solid #e5e7eb;"> | |
| <a href="${r.url}" style="color: #2563eb; text-decoration: none;">View posting</a> | |
| </td> | |
| <td style="padding: 8px 12px; border-bottom: 1px solid `#e5e7eb`;"> | |
| <a href="${escapeHtml(r.url)}" style="color: `#2563eb`; text-decoration: none;">View posting</a> | |
| </td> |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@send-agent-results.mjs` around lines 65 - 67, The href uses r.url raw which
can break HTML when it contains characters like &; update the template to pass
r.url through the existing escapeHtml function (the one defined in this file)
when interpolating into the anchor's href (and optionally for the anchor text if
it currently displays the URL), so change the interpolation from ${r.url} to
${escapeHtml(r.url)} ensuring the href attribute receives an HTML-escaped URL.
## scan.mjs - Add early-return guard in `detectApi()` for `scan_method: 'websearch'` companies — prevents false Ashby API attempts and silent failures for the ~45 companies migrated to websearch fallback in portals.yml ## Email notification pipeline - Add `scan-and-notify.mjs`: wraps `scan.mjs`, reads scan-history.tsv, and sends an HTML email via nodemailer only when new roles are added today. Supports `--dry-run` flag. - Add `send-agent-results.mjs`: companion script for the CCR WebSearch agent (Level 3 scan). Splits results into two email sections — 🌍 Remote and 📍 Other — based on title/portal name. Date is dynamic (no hardcoded dates). - Add `setup-scheduler.ps1`: one-shot Windows Task Scheduler setup registering two daily tasks (11:00 and 20:00 local) to run scan-and-notify.mjs ## package.json - Add `nodemailer` ^8.0.7 dependency - Add `scan:notify` and `scan:dry` npm scripts ## .env.example - Document email notification variables (GMAIL_USER, GMAIL_APP_PASSWORD, NOTIFY_EMAIL) with step-by-step App Password setup instructions - Use placeholder values only — no real email addresses Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
d6ece83 to
a1d22a6
Compare
There was a problem hiding this comment.
Actionable comments posted: 1
♻️ Duplicate comments (9)
send-agent-results.mjs (5)
16-25:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winTrim the TSV status field before filtering.
On CRLF files,
cols[5]keeps the trailing\r, soe.status === 'added'silently drops every row on Windows.🛠️ Proposed fix
return { url: cols[0], date: cols[1], portal: cols[2], title: cols[3], company: cols[4], - status: cols[5], + status: cols[5]?.trim(), };🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@send-agent-results.mjs` around lines 16 - 25, The TSV parsing map that builds entries leaves cols[5] with possible CRLF characters so status comparisons like e.status === 'added' fail on Windows; update the mapping in the entries construction to trim whitespace on the status field (e.g., assign status: cols[5].trim()) or otherwise normalize status (trim/lowercase) so downstream filters using e.status === 'added' will match correctly.
11-13:⚠️ Potential issue | 🟠 Major | ⚡ Quick winGuard the TSV read on first run.
readFileSync('./data/scan-history.tsv')still throwsENOENTwhen the file is missing, so fresh setups can't run this script. As per coding guidelines, scripts handle missingdata/directories gracefully.🛠️ Proposed fix
-import { readFileSync } from 'fs'; +import { readFileSync, existsSync } from 'fs'; ... -const tsvContent = readFileSync('./data/scan-history.tsv', 'utf-8'); +if (!existsSync('./data/scan-history.tsv')) { + console.log('No scan history found — run scan first.'); + process.exit(0); +} +const tsvContent = readFileSync('./data/scan-history.tsv', 'utf-8');🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@send-agent-results.mjs` around lines 11 - 13, readFileSync('./data/scan-history.tsv') can throw ENOENT on first run; update the startup logic around tsvContent/lines to guard the read by checking for the file (e.g., fs.existsSync or stat) or wrapping readFileSync in a try/catch, and if the file is missing initialize tsvContent to an empty string (so lines becomes an empty array) and ensure the './data' folder is created if needed; modify the block referencing readFileSync, tsvContent and lines to use this guarded read so fresh setups don't crash.
5-7:⚠️ Potential issue | 🟠 Major | ⚡ Quick winRestore the upfront credential guard.
createTransportis still built from potentially undefined Gmail credentials, so the failure will surface later as an SMTP error and thefromaddress can become<undefined>.🐛 Proposed fix
const GMAIL_USER = process.env.GMAIL_USER; const GMAIL_APP_PASSWORD = process.env.GMAIL_APP_PASSWORD; const NOTIFY_EMAIL = process.env.NOTIFY_EMAIL || GMAIL_USER; + +if (!GMAIL_USER || !GMAIL_APP_PASSWORD) { + console.error('Missing GMAIL_USER or GMAIL_APP_PASSWORD in .env'); + process.exit(1); +}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@send-agent-results.mjs` around lines 5 - 7, The code reads GMAIL_USER, GMAIL_APP_PASSWORD, and NOTIFY_EMAIL but does not guard before building the transporter, so createTransport may be called with undefined credentials and produce an SMTP error and an undefined "from" address; add an upfront credential guard that checks GMAIL_USER and GMAIL_APP_PASSWORD (and set NOTIFY_EMAIL fallback to GMAIL_USER only after the guard) and if either is missing throw or exit with a clear error via processLogger or similar before calling createTransport/send functions (reference GMAIL_USER, GMAIL_APP_PASSWORD, NOTIFY_EMAIL and createTransport to locate where to add the guard).
28-34:⚠️ Potential issue | 🟠 Major | ⚡ Quick win
lever-apistill slips through this exclusion list.
scan.mjswrites Lever rows with alever-apisource, so this WebSearch email will pick up rows that were meant to be excluded. Extend the exclusion list or switch to a positive websearch predicate.🛠️ Proposed fix
const newRoles = entries.filter(e => e.date === today && e.status === 'added' && !e.portal.startsWith('greenhouse-api') && - !e.portal.startsWith('ashby-api') + !e.portal.startsWith('ashby-api') && + !e.portal.startsWith('lever-api') );🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@send-agent-results.mjs` around lines 28 - 34, The filter that builds newRoles (entries.filter(...)) currently excludes only greenhouse-api and ashby-api but still lets lever-api through; update the predicate to either add "!e.portal.startsWith('lever-api')" to the exclusion list or, better, change the predicate to a positive check that only includes the intended websearch source (e.g., replace the negative startsWith checks with a single positive condition like e.portal === 'websearch' or the exact portal identifier you expect). Modify the entries.filter call that assigns newRoles and update any related variable names (newRoles / e.portal) accordingly so only the intended websearch rows are included.
65-67:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winEscape the posting URL in the link markup.
r.urlis still inserted raw into thehref, so job board links with query strings can produce malformed HTML.🛠️ Proposed fix
- <a href="${r.url}" style="color: `#2563eb`; text-decoration: none;">View posting</a> + <a href="${escapeHtml(r.url)}" style="color: `#2563eb`; text-decoration: none;">View posting</a>🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@send-agent-results.mjs` around lines 65 - 67, The href currently injects r.url raw into the anchor template (the <a href="${r.url}" ...>View posting</a> snippet) which can produce malformed HTML; fix by HTML-escaping the URL before inserting it into the attribute (e.g., add or import a small utility like escapeHtml/escapeAttribute and replace href="${r.url}" with href="${escapeHtml(r.url)}" or use encodeURI(r.url) if appropriate), ensuring you call the escaping function where the template string is constructed so the link markup uses the sanitized value.scan-and-notify.mjs (3)
21-28: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick winKeep this script pure ESM.
createRequireis unnecessary here; switch to a native Nodemailer import and update the later transport creation accordingly. As per coding guidelines,.mjsscripts use.mjsextension for ES modules; maintain Node.js module format throughout.♻️ Proposed fix
-import { createRequire } from 'module'; import { fileURLToPath } from 'url'; import path from 'path'; +import { createTransport } from 'nodemailer'; import 'dotenv/config'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const require = createRequire(import.meta.url); -const nodemailer = require('nodemailer');🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@scan-and-notify.mjs` around lines 21 - 28, The file currently uses createRequire and a require call to load Nodemailer (createRequire, require, nodemailer); remove createRequire and the require invocation and instead add a native ESM import like `import nodemailer from "nodemailer"`, then update any transport creation code that referenced nodemailer via the CommonJS require to use the imported nodemailer (e.g., nodemailer.createTransport) and adjust any default-interop usages if necessary so the module stays pure ESM.
53-80:⚠️ Potential issue | 🟠 Major | ⚡ Quick win
--dry-runstill skips the preview path.
beforeCountis captured before the scan, and the scan is skipped entirely in dry-run mode, soafterEntries.slice(beforeCount)stays empty and the email preview never appears.🐛 Proposed fix
-const beforeCount = getAddedToday().length; +const beforeCount = DRY_RUN ? 0 : getAddedToday().length;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@scan-and-notify.mjs` around lines 53 - 80, The dry-run branch currently skips running the scanner so beforeCount and afterEntries are identical and the email preview never appears; change the DRY_RUN handling to still invoke the scan in a preview/simulate mode (or run the same update logic) so getAddedToday() reflects new items: e.g. in the DRY_RUN path call the scanner with a preview flag or set an env var (referencing DRY_RUN, execSync('node scan.mjs', ...), scan.mjs, beforeCount, getAddedToday, newEntries) so the scan logic executes but side-effects like sending emails are suppressed, then continue to compute afterEntries and slice newEntries as before.
103-110:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winEscape the HTML fields before interpolating them.
j.urlis still injected raw into both thehrefattribute and the visible link text, so any&or<in the posting URL will break the markup.🐛 Proposed fix
- <strong>${j.company}</strong><br> - <span style="color:`#555`">${j.title}</span><br> - <a href="${j.url}" style="font-size:12px;color:`#0066cc`">${j.url}</a> + <strong>${escapeHtml(j.company)}</strong><br> + <span style="color:`#555`">${escapeHtml(j.title)}</span><br> + <a href="${escapeHtml(j.url)}" style="font-size:12px;color:`#0066cc`">${escapeHtml(j.url)}</a>🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@scan-and-notify.mjs` around lines 103 - 110, The template in htmlRows interpolates unescaped fields (newEntries -> j.company, j.title, j.url) which can break HTML or enable injection; add an HTML-escaping helper (e.g., escapeHtml) and apply it to visible text (escapeHtml(j.company), escapeHtml(j.title), escapeHtml(j.url)) and for the href use a safe URL encoding (e.g., encodeURI(j.url) or a sanitizeUrl helper) before interpolation; update the htmlRows mapping to use these escaped/sanitized values so both the href attribute and visible link text are safe..env.example (1)
30-32:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winQuote the App Password placeholder and keep the env keys lint-friendly.
The placeholder still contains spaces unquoted, which can be truncated by shell parsers and keeps dotenv-linter unhappy. Reordering
GMAIL_APP_PASSWORDbeforeGMAIL_USERwill also clear the linter warning.🛠️ Proposed fix
-GMAIL_USER=your@gmail.com -GMAIL_APP_PASSWORD=xxxx xxxx xxxx xxxx -NOTIFY_EMAIL=your@gmail.com +GMAIL_APP_PASSWORD="xxxx xxxx xxxx xxxx" +GMAIL_USER=your@gmail.com +NOTIFY_EMAIL=your@gmail.com🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In @.env.example around lines 30 - 32, Reorder the env entries so GMAIL_APP_PASSWORD appears before GMAIL_USER and make the password placeholder lint-friendly by removing internal spaces and wrapping the value in quotes (e.g., GMAIL_APP_PASSWORD="xxxx_xxxx_xxxx_xxxx"); ensure keys remain uppercase exactly as GMAIL_APP_PASSWORD, GMAIL_USER and NOTIFY_EMAIL and that there are no stray spaces around the equals signs so dotenv-linter passes.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@setup-scheduler.ps1`:
- Around line 45-48: The scheduled task principal currently uses
New-ScheduledTaskPrincipal with -LogonType Interactive which requires an
interactive session; change the logon type to a non-interactive model (for
example -LogonType Password or -LogonType S4U) on the $principal creation so the
task runs when the user is signed out—update the New-ScheduledTaskPrincipal
invocation that sets $principal (replace -LogonType Interactive) and, if you
choose Password, ensure credentials are provided securely; if you choose S4U,
verify your environment supports S4U for unattended execution.
---
Duplicate comments:
In @.env.example:
- Around line 30-32: Reorder the env entries so GMAIL_APP_PASSWORD appears
before GMAIL_USER and make the password placeholder lint-friendly by removing
internal spaces and wrapping the value in quotes (e.g.,
GMAIL_APP_PASSWORD="xxxx_xxxx_xxxx_xxxx"); ensure keys remain uppercase exactly
as GMAIL_APP_PASSWORD, GMAIL_USER and NOTIFY_EMAIL and that there are no stray
spaces around the equals signs so dotenv-linter passes.
In `@scan-and-notify.mjs`:
- Around line 21-28: The file currently uses createRequire and a require call to
load Nodemailer (createRequire, require, nodemailer); remove createRequire and
the require invocation and instead add a native ESM import like `import
nodemailer from "nodemailer"`, then update any transport creation code that
referenced nodemailer via the CommonJS require to use the imported nodemailer
(e.g., nodemailer.createTransport) and adjust any default-interop usages if
necessary so the module stays pure ESM.
- Around line 53-80: The dry-run branch currently skips running the scanner so
beforeCount and afterEntries are identical and the email preview never appears;
change the DRY_RUN handling to still invoke the scan in a preview/simulate mode
(or run the same update logic) so getAddedToday() reflects new items: e.g. in
the DRY_RUN path call the scanner with a preview flag or set an env var
(referencing DRY_RUN, execSync('node scan.mjs', ...), scan.mjs, beforeCount,
getAddedToday, newEntries) so the scan logic executes but side-effects like
sending emails are suppressed, then continue to compute afterEntries and slice
newEntries as before.
- Around line 103-110: The template in htmlRows interpolates unescaped fields
(newEntries -> j.company, j.title, j.url) which can break HTML or enable
injection; add an HTML-escaping helper (e.g., escapeHtml) and apply it to
visible text (escapeHtml(j.company), escapeHtml(j.title), escapeHtml(j.url)) and
for the href use a safe URL encoding (e.g., encodeURI(j.url) or a sanitizeUrl
helper) before interpolation; update the htmlRows mapping to use these
escaped/sanitized values so both the href attribute and visible link text are
safe.
In `@send-agent-results.mjs`:
- Around line 16-25: The TSV parsing map that builds entries leaves cols[5] with
possible CRLF characters so status comparisons like e.status === 'added' fail on
Windows; update the mapping in the entries construction to trim whitespace on
the status field (e.g., assign status: cols[5].trim()) or otherwise normalize
status (trim/lowercase) so downstream filters using e.status === 'added' will
match correctly.
- Around line 11-13: readFileSync('./data/scan-history.tsv') can throw ENOENT on
first run; update the startup logic around tsvContent/lines to guard the read by
checking for the file (e.g., fs.existsSync or stat) or wrapping readFileSync in
a try/catch, and if the file is missing initialize tsvContent to an empty string
(so lines becomes an empty array) and ensure the './data' folder is created if
needed; modify the block referencing readFileSync, tsvContent and lines to use
this guarded read so fresh setups don't crash.
- Around line 5-7: The code reads GMAIL_USER, GMAIL_APP_PASSWORD, and
NOTIFY_EMAIL but does not guard before building the transporter, so
createTransport may be called with undefined credentials and produce an SMTP
error and an undefined "from" address; add an upfront credential guard that
checks GMAIL_USER and GMAIL_APP_PASSWORD (and set NOTIFY_EMAIL fallback to
GMAIL_USER only after the guard) and if either is missing throw or exit with a
clear error via processLogger or similar before calling createTransport/send
functions (reference GMAIL_USER, GMAIL_APP_PASSWORD, NOTIFY_EMAIL and
createTransport to locate where to add the guard).
- Around line 28-34: The filter that builds newRoles (entries.filter(...))
currently excludes only greenhouse-api and ashby-api but still lets lever-api
through; update the predicate to either add "!e.portal.startsWith('lever-api')"
to the exclusion list or, better, change the predicate to a positive check that
only includes the intended websearch source (e.g., replace the negative
startsWith checks with a single positive condition like e.portal === 'websearch'
or the exact portal identifier you expect). Modify the entries.filter call that
assigns newRoles and update any related variable names (newRoles / e.portal)
accordingly so only the intended websearch rows are included.
- Around line 65-67: The href currently injects r.url raw into the anchor
template (the <a href="${r.url}" ...>View posting</a> snippet) which can produce
malformed HTML; fix by HTML-escaping the URL before inserting it into the
attribute (e.g., add or import a small utility like escapeHtml/escapeAttribute
and replace href="${r.url}" with href="${escapeHtml(r.url)}" or use
encodeURI(r.url) if appropriate), ensuring you call the escaping function where
the template string is constructed so the link markup uses the sanitized value.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 72c365b6-a2d1-4d69-84c7-f68c5088c38f
📥 Commits
Reviewing files that changed from the base of the PR and between d6ece83b5be19eb81d358e7cab3c0fcf72a14c59 and a1d22a6.
📒 Files selected for processing (6)
.env.examplepackage.jsonscan-and-notify.mjsscan.mjssend-agent-results.mjssetup-scheduler.ps1
| $principal = New-ScheduledTaskPrincipal ` | ||
| -UserId $env:USERNAME ` | ||
| -LogonType Interactive ` | ||
| -RunLevel Limited |
There was a problem hiding this comment.
Don't require an interactive session for the scheduled task.
New-ScheduledTaskPrincipal -LogonType Interactive maps to an existing interactive session, so these daily jobs will be skipped whenever the user is signed out. For unattended notifications, this needs a non-interactive logon model. (learn.microsoft.com)
🧰 Tools
🪛 PSScriptAnalyzer (1.25.0)
[warning] Missing BOM encoding for non-ASCII encoded file 'setup-scheduler.ps1'
(PSUseBOMForUnicodeEncodedFile)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@setup-scheduler.ps1` around lines 45 - 48, The scheduled task principal
currently uses New-ScheduledTaskPrincipal with -LogonType Interactive which
requires an interactive session; change the logon type to a non-interactive
model (for example -LogonType Password or -LogonType S4U) on the $principal
creation so the task runs when the user is signed out—update the
New-ScheduledTaskPrincipal invocation that sets $principal (replace -LogonType
Interactive) and, if you choose Password, ensure credentials are provided
securely; if you choose S4U, verify your environment supports S4U for unattended
execution.
|
Hi, package.json was reset to version 1.0.0 while VERSION and .release-please-manifest.json remain at 1.6.0, which will confuse tooling and may break release automation expectations. Severity: action required | Category: correctness How to fix: Align VERSION/manifest/package.json Agent prompt to fix - you can give this to your LLM of choice:
Found by Qodo code review. FYI, Qodo is free for open-source. |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@package.json`:
- Around line 18-19: scan-and-notify.mjs currently uses CommonJS require (const
nodemailer = require('nodemailer')) which crashes in an ES module; replace that
require with an ES module import (e.g., import { createTransport } from
'nodemailer') and update any usages of nodemailer.createTransport(...) to call
createTransport(...) directly (or import default if the file expects default
export), ensuring the import is placed at top-level and any destructured names
match how send-agent-results.mjs imports nodemailer.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 53ddfcee-28a1-4560-a090-e00a786c7449
📒 Files selected for processing (1)
package.json
There was a problem hiding this comment.
♻️ Duplicate comments (2)
scan-and-notify.mjs (2)
101-108:⚠️ Potential issue | 🟠 Major | ⚡ Quick winEscape job data before embedding it in HTML.
This remains unresolved:
j.company,j.title, andj.urlare still interpolated raw into the HTML email, which can break markup and expose recipients to injected content.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@scan-and-notify.mjs` around lines 101 - 108, The HTML builder for htmlRows is injecting unescaped user data (newEntries -> j.company, j.title, j.url) directly into the template; create and use a small escaping helper (e.g., escapeHtml) to replace &,<,>,",',/ before interpolation for j.company and j.title, and sanitize/validate or percent-encode j.url (or use a safeHref helper that allows only http(s) and encodes unsafe characters) before inserting into the href and visible link text; update the htmlRows mapping to call these helpers for each field so no raw values are embedded.
51-78:⚠️ Potential issue | 🟠 Major | ⚡ Quick win
--dry-runstill skips the preview path.This remains unresolved:
beforeCountis captured before the scan, so when--dry-runskipsscan.mjs,afterEntries.slice(beforeCount)is always empty and the preview block at Lines 135-140 never runs.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@scan-and-notify.mjs` around lines 51 - 78, The dry-run path currently skips running the scanner so beforeCount is unchanged and newEntries is always empty; fix by still invoking the scan in preview mode when DRY_RUN is true (or setting an env/flag that causes scan.mjs to only simulate additions) so getAddedToday() after the run reflects the preview results; update the DRY_RUN branch to call execSync('node scan.mjs --preview') or set a PREVIEW env var before execSync, keeping the existing execSync block and error handling (referenced symbols: DRY_RUN, execSync('node scan.mjs'), scan.mjs, getAddedToday, beforeCount, newEntries, preview block).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Duplicate comments:
In `@scan-and-notify.mjs`:
- Around line 101-108: The HTML builder for htmlRows is injecting unescaped user
data (newEntries -> j.company, j.title, j.url) directly into the template;
create and use a small escaping helper (e.g., escapeHtml) to replace &,<,>,",',/
before interpolation for j.company and j.title, and sanitize/validate or
percent-encode j.url (or use a safeHref helper that allows only http(s) and
encodes unsafe characters) before inserting into the href and visible link text;
update the htmlRows mapping to call these helpers for each field so no raw
values are embedded.
- Around line 51-78: The dry-run path currently skips running the scanner so
beforeCount is unchanged and newEntries is always empty; fix by still invoking
the scan in preview mode when DRY_RUN is true (or setting an env/flag that
causes scan.mjs to only simulate additions) so getAddedToday() after the run
reflects the preview results; update the DRY_RUN branch to call execSync('node
scan.mjs --preview') or set a PREVIEW env var before execSync, keeping the
existing execSync block and error handling (referenced symbols: DRY_RUN,
execSync('node scan.mjs'), scan.mjs, getAddedToday, beforeCount, newEntries,
preview block).
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 0da81b01-9731-4b9b-bc8a-dd1fca7252b0
📒 Files selected for processing (1)
scan-and-notify.mjs
…Fs, archived 3 below-threshold) - scan.mjs: ElevenLabs Spain FDE (1 new offer added by Level 1/2) - Level 3 WebSearch: Greenhouse new grad + Workable HF/Jalasoft/Vettura + Breezy Urrly + Ashby P-1 AI + Himalayas Robots & Pencils + Remotive Littlebird (8 URLs added; 3 archived as fetch errors) - 6 new evaluations (santifer#556-santifer#561 — ElevenLabs Spain dedup'd against santifer#468): * santifer#557 Underdog 2026 New Grad (1.8/5 — no visa sponsorship; SKIP) * santifer#558/562 P-1 AI FDE (3.0/5 — engineering AGI seed; PDF generated) * santifer#559 Vettura AI/ML (3.0/5 — mid breadth NLP+CV+GenAI; PDF generated) * santifer#560 Robots & Pencils (1.8/5 — Colombia + senior; SKIP) * santifer#561 Littlebird Applied AI (3.5/5 — RAG hybrid search; PDF generated) - merge-tracker + verify-pipeline: 0 errors / 0 warnings - cleanup-low-scores: 3 archived to reports/below-threshold/ https://claude.ai/code/session_overnight_2026-05-05
Generated by the daily scan pipeline. Reads data/scan-history.tsv, groups today's roles by tier, and sends a tier-grouped HTML email via Resend API. Used as a fallback when the sandbox blocks outbound requests to api.resend.com. https://claude.ai/code/session_01DmmLET8pauRKW3jHrYgTv3
Removes the createRequire workaround and replaces it with a clean ES module import matching the pattern already used in send-agent-results.mjs. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- daily-ats-scan.mjs: standalone scanner that hits 16 ATS APIs (Greenhouse/Ashby/Lever) plus 5 public feeds (RemoteOK, Remotive, LinkedIn jobs-guest, HN Who's Hiring, Web3.career) and emails results via Resend HTTP API. Runs locally via Windows Task Scheduler — no cloud routine auth dependency. - daily-ats-scan.bat: Windows runner for Task Scheduler integration (cd's to repo, runs the scanner, appends to logs/scan.log) - send-agent-results.mjs: replace nodemailer/Gmail SMTP with Resend HTTP API. Cloud-friendly (port 443, no SMTP), simpler env vars, reads RESEND_API_KEY/RESEND_FROM/NOTIFY_EMAIL. - .gitignore: add logs/, tmp/, *.bak, and batch/batch-results-*.md for local scanner runtime artifacts. Filter improvements baked into daily-ats-scan.mjs: - Word-boundary matching for short keywords (≤4 chars) prevents false positives like "ngo" matching "Django" - Expanded NEGATIVE list with 30+ engineering-specific titles - HN parsing filters to top-level "Who is hiring" comments only (parent_id === thread.objectID), HTML entity decoding - LinkedIn regex updated to allow Tailwind class suffixes after base-card__full-link Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
923be21 to
6faf726
Compare
There was a problem hiding this comment.
Actionable comments posted: 14
♻️ Duplicate comments (6)
send-agent-results.mjs (4)
32-32:⚠️ Potential issue | 🟠 Major | ⚡ Quick winRegression:
statusfield is no longer trimmed — Windows CRLF silently drops every row.Same regression. With CRLF line endings on
scan-history.tsv(the default on Windows where the new Task Scheduler runs),cols[5]becomes'added\r'and the equality check at line 39 always fails, so no email is ever sent. Trim the field at parse time.🐛 Proposed fix
return { url: cols[0], date: cols[1], portal: cols[2], title: cols[3], company: cols[4], - status: cols[5], + status: cols[5]?.trim(), };Also applies to: 39-39
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@send-agent-results.mjs` at line 32, Trim the parsed status field so CRLF or trailing whitespace doesn't break comparisons: when constructing the object that currently sets "status: cols[5]," replace that assignment with a trimmed value (e.g., use cols[5].trim()) and ensure any equality checks that reference the same raw cols[5] (the comparison that looks for "added") use the trimmed value or the object's status property so the comparison succeeds even with CRLF/whitespace.
73-75:⚠️ Potential issue | 🟡 Minor | ⚡ Quick win
r.urlstill not escaped in thehrefattribute.
escapeHtmlis applied to company/title but not tor.url. ATS URLs often contain&(e.g.,…?gh_jid=…&utm_source=…), producing malformed HTML.🐛 Proposed fix
- <a href="${r.url}" style="color: `#2563eb`; text-decoration: none;">View posting</a> + <a href="${escapeHtml(r.url)}" style="color: `#2563eb`; text-decoration: none;">View posting</a>🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@send-agent-results.mjs` around lines 73 - 75, The anchor's href uses unescaped r.url which can break the HTML when the URL contains characters like '&'; update the template to pass r.url through the existing escapeHtml helper (the same function used for company/title) before interpolating into href so the href becomes href="${escapeHtml(r.url)}", ensuring attribute-safe escaping for ampersands, quotes, and other special chars.
19-21:⚠️ Potential issue | 🟠 Major | ⚡ Quick winRegression:
existsSyncguard fordata/scan-history.tsvis missing again.A previous review flagged this and was marked addressed, but the current state of the file calls
readFileSync('./data/scan-history.tsv', 'utf-8')unconditionally. On a fresh checkout where no scan has run yet, this throws an unhelpfulENOENT. As per coding guidelines, scripts must handle missingdata/directories gracefully.🐛 Proposed fix
-import { readFileSync } from 'fs'; +import { readFileSync, existsSync } from 'fs'; @@ -// Read scan-history.tsv -const tsvContent = readFileSync('./data/scan-history.tsv', 'utf-8'); +// Read scan-history.tsv +if (!existsSync('./data/scan-history.tsv')) { + console.log('No scan history found — run scan first.'); + process.exit(0); +} +const tsvContent = readFileSync('./data/scan-history.tsv', 'utf-8');🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@send-agent-results.mjs` around lines 19 - 21, The code unconditionally calls readFileSync('./data/scan-history.tsv') (tsvContent / lines) and will throw ENOENT on fresh checkouts; add an fs.existsSync('./data/scan-history.tsv') guard before reading and if the file is missing set tsvContent to '' (or directly set lines = []), so the rest of the script can continue gracefully; update the block around readFileSync/tsvContent/lines to check existsSync and only call readFileSync when present.
36-42:⚠️ Potential issue | 🟠 Major | ⚡ Quick win
lever-apiportal entries still slip into the WebSearch-only email.Per
scan.mjs, Lever results are written withsource: 'lever-api', but the exclusion list here only coversgreenhouse-apiandashby-api. A positive filter on the websearch portal is more robust; otherwise add Lever to the deny list.🐛 Proposed fix
const newRoles = entries.filter(e => e.date === today && - e.status === 'added' && - !e.portal.startsWith('greenhouse-api') && - !e.portal.startsWith('ashby-api') + e.status?.trim() === 'added' && + !e.portal?.startsWith('greenhouse-api') && + !e.portal?.startsWith('ashby-api') && + !e.portal?.startsWith('lever-api') );🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@send-agent-results.mjs` around lines 36 - 42, The filter that builds newRoles allows unwanted 'lever-api' entries because it only excludes 'greenhouse-api' and 'ashby-api'; update the entries.filter used to produce newRoles to either (A) explicitly deny 'lever-api' as well (add !e.portal.startsWith('lever-api')) or (B) switch to a positive match for the WebSearch-only portal (e.g., require e.portal === 'websearch' or e.source === 'websearch') so only websearch results are included; modify the filter around newRoles/entries.filter and the portal checks accordingly.scan-and-notify.mjs (2)
101-108:⚠️ Potential issue | 🟠 Major | ⚡ Quick winHTML escaping still missing in email body — company/title/url interpolated raw.
Same issue as flagged previously and not yet addressed: titles like "Data & Analytics Engineer" or any URL with
&query parameters break the HTML structure (and a malicious upstream feed could inject markup). Reuse theescapeHtmlhelper fromsend-agent-results.mjs(or inline an equivalent).🐛 Proposed fix
+function escapeHtml(str) { + if (!str) return ''; + return String(str) + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"'); +} + const htmlRows = newEntries.map(j => ` <tr> <td style="padding:10px 12px;border-bottom:1px solid `#eee`"> - <strong>${j.company}</strong><br> - <span style="color:`#555`">${j.title}</span><br> - <a href="${j.url}" style="font-size:12px;color:`#0066cc`">${j.url}</a> + <strong>${escapeHtml(j.company)}</strong><br> + <span style="color:`#555`">${escapeHtml(j.title)}</span><br> + <a href="${escapeHtml(j.url)}" style="font-size:12px;color:`#0066cc`">${escapeHtml(j.url)}</a> </td> </tr>`).join('');🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@scan-and-notify.mjs` around lines 101 - 108, The HTML rows are interpolating newEntries fields raw (in htmlRows), allowing ampersands and markup to break the email and enabling injection; update scan-and-notify.mjs to escape company, title and url when building htmlRows by reusing or importing the escapeHtml helper from send-agent-results.mjs (or inline an equivalent function) and wrap each interpolation as escapeHtml(j.company), escapeHtml(j.title), and escapeHtml(j.url) before joining into the template string so the email body is safe and well-formed.
51-78:⚠️ Potential issue | 🟠 Major | ⚡ Quick winDry-run preview is still unreachable —
beforeCountnot zeroed when--dry-run.Same logic gap as flagged previously and not yet addressed:
beforeCountis captured before the conditionalexecSync, so in dry-run mode where the scan is skipped,afterEntries.length === beforeCountandnewEntriesis always empty. Line 75 then exits before the preview block at lines 135–141 is reached.🐛 Proposed fix
-const beforeCount = getAddedToday().length; +const beforeCount = DRY_RUN ? 0 : getAddedToday().length;In dry-run mode this includes every "added" row from today, so the email preview actually renders.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@scan-and-notify.mjs` around lines 51 - 78, The dry-run path captures beforeCount before deciding to skip the actual scan, so newEntries is always empty in DRY_RUN; change the logic so beforeCount reflects the dry-run behavior (e.g., compute beforeCount only when not DRY_RUN, otherwise set beforeCount = 0) — locate the getAddedToday() call that assigns beforeCount and move/branch it around the DRY_RUN check (symbols: beforeCount, DRY_RUN, getAddedToday(), execSync, newEntries) so that in --dry-run the preview sees all today's entries instead of none.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@daily-ats-scan.bat`:
- Around line 6-12: Before redirecting output to logs, ensure the logs directory
exists by adding a check/creation step at the top of daily-ats-scan.bat: after
changing to the repo directory (the cd /d "C:\Users\Claude\career-ops" line)
create the logs folder if it doesn't exist so the subsequent echo and the node
daily-ats-scan.mjs >> logs\scan.log 2>&1 redirects succeed; make the creation
idempotent (no-op if already present) and keep the existing echo and node lines
unchanged.
- Around line 1-15: Convert this batch script to use CRLF line endings and
append an explicit exit code so Task Scheduler sees Node's exit status: re-save
daily-ats-scan.bat with CRLF line endings (e.g., git config core.autocrlf true
or unix2dos) and add a final line "exit /b %ERRORLEVEL%" after the existing
logging lines (keep the existing echo [%date% %time%] ... finished line and then
add exit /b %ERRORLEVEL%) so cmd.exe returns Node's exit code instead of an
implicit success.
- Line 6: The batch file currently uses a hardcoded, user-specific path in the
cd command ("C:\Users\Claude\career-ops"), which breaks for other contributors;
update the cd invocation in daily-ats-scan.bat to use the batch file's own
directory resolver (%~dp0) so the script changes to the repository location
relative to the .bat file (adjust to parent folder if needed) instead of the
fixed user path.
In `@daily-ats-scan.mjs`:
- Line 252: Sanitize any TSV fields before writing: the appendFileSync call that
joins [j.url,today,portal,j.title,t.company,status] must clean j.title and
company values (j.company or t.company) by replacing tabs and any newline/CR
characters with a safe separator (e.g., a single space) and trimming surrounding
whitespace so embedded '\t', '\n', '\r' cannot break columns; apply the same
sanitization helper to the other occurrence around line 270 (where company/title
are written) and use that helper in place of the raw j.title/t.company/j.company
values.
- Line 288: The HTML anchor in function row currently interpolates r.url raw
causing malformed HTML or attribute breakout; update row to use the same
escaping helper (esc) for the href value (i.e., use esc(r.url)) so the URL is
HTML-escaped before being inserted into the href attribute and ensure any
displayed link text is escaped as well if you ever render r.url there; modify
the row() implementation to replace the raw r.url with the escaped value while
keeping esc(r.company), esc(r.title), and esc(r.portal) unchanged.
- Around line 235-237: Ensure the ./data directory exists before writing files:
before the block that checks existsSync('./data/scan-history.tsv') and before
any appendFileSync('./data/pipeline.md') calls, call mkdirSync('./data', {
recursive: true }) (or equivalent) to create the directory if missing; then
proceed with the existing existsSync and writeFileSync logic so write/append
operations no longer throw ENOENT.
- Around line 280-286: Move the Resend credential validation to the very start
of the script so the scan never performs network/IO when required env is
missing: ensure the existing checks for RESEND_API_KEY and NOTIFY_EMAIL (and
fallback RESEND_FROM) run before any scraping or calls that append to
scan-history.tsv or pipeline.md (i.e., execute the current if (!RESEND_API_KEY
|| !NOTIFY_EMAIL) block at script initialization/top-level before invoking the
feed/endpoint scraping functions); log the missing-variable error with
process.exit(1) as currently done so the run aborts immediately.
- Around line 50-72: Add a 15s AbortSignal timeout to every fetch call so hung
requests don't stall the scheduled task: update the three fetches inside
fetchAts (Greenhouse, Ashby, Lever) to pass { signal: AbortSignal.timeout(15000)
} and do the same for fetchRemoteOK, fetchRemotive, fetchLinkedIn,
fetchHackerNewsWhoshiring, fetchWeb3Career, and the Resend API call (the HTTP
call around the resend/send function) so each fetch includes the same signal
option.
In `@scan-and-notify.mjs`:
- Around line 56-68: The current use of execSync('node scan.mjs') in
scan-and-notify.mjs blocks the event loop and lacks a timeout; replace it with a
non-blocking child process invocation (use child_process.spawn or spawnSync with
a timeout) and ensure a timeout is passed (e.g., 10 * 60 * 1000 ms) so a hung
scan fails fast; update the try/catch around the execSync call (refer to
execSync and the DRY_RUN branch) to use spawn with stdio:'inherit', cwd:
__dirname, and handle the child process exit/error/timeout to call
process.exit(1) on failure.
In `@send-daily-email.mjs`:
- Around line 22-25: The status field from cols[5] can include a trailing CR on
Windows, so the filter r.status === 'added' always fails; update the parsing
step that builds r (where status: cols[5]) to trim the value (e.g., use
cols[5].trim()) or trim all cols before mapping, so r.status contains the
normalized string and the subsequent filter comparing to 'added' and TODAY works
correctly.
- Line 76: The href currently injects raw r.url into the template which can
break HTML when it contains characters like &, so update the template that
renders the link (the string using ${r.url}) to pass the URL through the
existing escHtml helper (use escHtml(r.url)) just like the title is escaped with
escHtml(r.title) to ensure the href attribute is properly escaped.
- Line 7: The TODAY constant is hardcoded; replace the static assignment of
TODAY with a runtime-computed date string so the script filters correctly every
day. In send-daily-email.mjs, change the TOP-level symbol TODAY to be computed
at runtime (e.g., derive from new Date() and format as YYYY-MM-DD or using the
same formatter/util the project uses) so any DB/query that relies on TODAY (the
TODAY constant) receives the current date string; ensure the formatted string
matches the expected timezone/format used by the existing filter logic.
- Line 8: TSV_PATH in send-daily-email.mjs is a hardcoded absolute Linux path;
change it to resolve the data file relative to the running script instead.
Replace the literal '/home/user/…/scan-history.tsv' assigned to TSV_PATH with
logic that computes the path from the module location (use import.meta.url and
fileURLToPath/path.dirname or new URL with import.meta.url) so TSV_PATH points
to ./data/scan-history.tsv relative to the script regardless of OS or working
directory.
- Around line 4-11: Validate required env vars RESEND_API_KEY and NOTIFY_EMAIL
(used as TO) at startup and emit a clear processLogger.error message and
process.exit(1) if missing; do not rely on downstream Resend errors for this.
Before calling readFileSync(TSV_PATH, ...), use fs.existsSync on TSV_PATH (or
its containing data directory) to handle first-run missing files gracefully: if
the file is absent, treat the TSV as empty (e.g., set lines=[] or skip
processing) rather than letting readFileSync throw ENOENT. Update any error logs
to include which var or file is missing and mirror the validation/existsSync
pattern used in send-agent-results.mjs, referencing RESEND_API_KEY, TO,
TSV_PATH, existsSync, and readFileSync to locate the changes.
---
Duplicate comments:
In `@scan-and-notify.mjs`:
- Around line 101-108: The HTML rows are interpolating newEntries fields raw (in
htmlRows), allowing ampersands and markup to break the email and enabling
injection; update scan-and-notify.mjs to escape company, title and url when
building htmlRows by reusing or importing the escapeHtml helper from
send-agent-results.mjs (or inline an equivalent function) and wrap each
interpolation as escapeHtml(j.company), escapeHtml(j.title), and
escapeHtml(j.url) before joining into the template string so the email body is
safe and well-formed.
- Around line 51-78: The dry-run path captures beforeCount before deciding to
skip the actual scan, so newEntries is always empty in DRY_RUN; change the logic
so beforeCount reflects the dry-run behavior (e.g., compute beforeCount only
when not DRY_RUN, otherwise set beforeCount = 0) — locate the getAddedToday()
call that assigns beforeCount and move/branch it around the DRY_RUN check
(symbols: beforeCount, DRY_RUN, getAddedToday(), execSync, newEntries) so that
in --dry-run the preview sees all today's entries instead of none.
In `@send-agent-results.mjs`:
- Line 32: Trim the parsed status field so CRLF or trailing whitespace doesn't
break comparisons: when constructing the object that currently sets "status:
cols[5]," replace that assignment with a trimmed value (e.g., use
cols[5].trim()) and ensure any equality checks that reference the same raw
cols[5] (the comparison that looks for "added") use the trimmed value or the
object's status property so the comparison succeeds even with CRLF/whitespace.
- Around line 73-75: The anchor's href uses unescaped r.url which can break the
HTML when the URL contains characters like '&'; update the template to pass
r.url through the existing escapeHtml helper (the same function used for
company/title) before interpolating into href so the href becomes
href="${escapeHtml(r.url)}", ensuring attribute-safe escaping for ampersands,
quotes, and other special chars.
- Around line 19-21: The code unconditionally calls
readFileSync('./data/scan-history.tsv') (tsvContent / lines) and will throw
ENOENT on fresh checkouts; add an fs.existsSync('./data/scan-history.tsv') guard
before reading and if the file is missing set tsvContent to '' (or directly set
lines = []), so the rest of the script can continue gracefully; update the block
around readFileSync/tsvContent/lines to check existsSync and only call
readFileSync when present.
- Around line 36-42: The filter that builds newRoles allows unwanted 'lever-api'
entries because it only excludes 'greenhouse-api' and 'ashby-api'; update the
entries.filter used to produce newRoles to either (A) explicitly deny
'lever-api' as well (add !e.portal.startsWith('lever-api')) or (B) switch to a
positive match for the WebSearch-only portal (e.g., require e.portal ===
'websearch' or e.source === 'websearch') so only websearch results are included;
modify the filter around newRoles/entries.filter and the portal checks
accordingly.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: f9d33315-4310-491c-89aa-e7a224407a5c
📒 Files selected for processing (7)
.gitignoredaily-ats-scan.batdaily-ats-scan.mjspackage.jsonscan-and-notify.mjssend-agent-results.mjssend-daily-email.mjs
| @echo off | ||
| REM career-ops daily ATS scan runner | ||
| REM Hits 16 Greenhouse/Ashby/Lever endpoints, filters, dedups, emails via Resend | ||
| REM Triggered by Windows Task Scheduler at 12:00 CEST (10:00 UTC) daily | ||
|
|
||
| cd /d "C:\Users\Claude\career-ops" | ||
|
|
||
| REM Log start | ||
| echo [%date% %time%] career-ops daily scan starting >> logs\scan.log | ||
|
|
||
| REM Run scan and append output to log | ||
| node daily-ats-scan.mjs >> logs\scan.log 2>&1 | ||
|
|
||
| echo [%date% %time%] career-ops daily scan finished (exit code %ERRORLEVEL%) >> logs\scan.log | ||
| echo. >> logs\scan.log |
There was a problem hiding this comment.
Convert to CRLF line endings + add explicit exit code.
Static analysis (Blinter E018/W001) flags this file as LF-only, which can trigger Windows cmd.exe's 512-byte boundary parsing bug for GOTO/CALL and is a recurring source of "works on my machine" failures. Re-save with CRLF and add an explicit exit /b so the Task Scheduler "Last Run Result" reflects Node's exit code rather than cmd.exe's implicit success.
🛠️ Proposed addition
echo [%date% %time%] career-ops daily scan finished (exit code %ERRORLEVEL%) >> logs\scan.log
echo. >> logs\scan.log
+
+exit /b %ERRORLEVEL%Also re-save the file with CRLF line endings (e.g., git config core.autocrlf true then re-add, or unix2dos daily-ats-scan.bat).
🧰 Tools
🪛 Blinter (1.0.112)
[error] 1-1: Unix line endings detected. Explanation: Batch file uses Unix line endings (LF-only) which can cause GOTO/CALL label parsing failures and script malfunction due to Windows batch parser 512-byte boundary bugs. Recommendation: Convert file to Windows line endings (CRLF). Use tools like dos2unix, notepad++, or configure git with 'git config core.autocrlf true'. Context: File uses Unix line endings (LF-only) - 15 LF sequences found
(E018)
[warning] 15-15: Missing exit code. Explanation: Script doesn't set appropriate exit codes to indicate success or failure. Recommendation: Add EXIT /b 0 for success or EXIT /b 1 for errors at script end. Context: Script can reach end of file without explicit EXIT statement
(W001)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@daily-ats-scan.bat` around lines 1 - 15, Convert this batch script to use
CRLF line endings and append an explicit exit code so Task Scheduler sees Node's
exit status: re-save daily-ats-scan.bat with CRLF line endings (e.g., git config
core.autocrlf true or unix2dos) and add a final line "exit /b %ERRORLEVEL%"
after the existing logging lines (keep the existing echo [%date% %time%] ...
finished line and then add exit /b %ERRORLEVEL%) so cmd.exe returns Node's exit
code instead of an implicit success.
| cd /d "C:\Users\Claude\career-ops" | ||
|
|
||
| REM Log start | ||
| echo [%date% %time%] career-ops daily scan starting >> logs\scan.log | ||
|
|
||
| REM Run scan and append output to log | ||
| node daily-ats-scan.mjs >> logs\scan.log 2>&1 |
There was a problem hiding this comment.
logs\ directory is not created — first-run redirect fails silently.
.gitignore excludes logs/ and there's no .gitkeep, so on a fresh clone the directory does not exist. Append-redirects to a missing directory in cmd.exe cause The system cannot find the path specified. and the Node process never runs. Create the directory up front.
🛠️ Proposed fix
cd /d "%~dp0"
+
+if not exist "logs" mkdir "logs"
+
REM Log start
echo [%date% %time%] career-ops daily scan starting >> logs\scan.log📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| cd /d "C:\Users\Claude\career-ops" | |
| REM Log start | |
| echo [%date% %time%] career-ops daily scan starting >> logs\scan.log | |
| REM Run scan and append output to log | |
| node daily-ats-scan.mjs >> logs\scan.log 2>&1 | |
| cd /d "C:\Users\Claude\career-ops" | |
| if not exist "logs" mkdir "logs" | |
| REM Log start | |
| echo [%date% %time%] career-ops daily scan starting >> logs\scan.log | |
| REM Run scan and append output to log | |
| node daily-ats-scan.mjs >> logs\scan.log 2>&1 |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@daily-ats-scan.bat` around lines 6 - 12, Before redirecting output to logs,
ensure the logs directory exists by adding a check/creation step at the top of
daily-ats-scan.bat: after changing to the repo directory (the cd /d
"C:\Users\Claude\career-ops" line) create the logs folder if it doesn't exist so
the subsequent echo and the node daily-ats-scan.mjs >> logs\scan.log 2>&1
redirects succeed; make the creation idempotent (no-op if already present) and
keep the existing echo and node lines unchanged.
| REM Hits 16 Greenhouse/Ashby/Lever endpoints, filters, dedups, emails via Resend | ||
| REM Triggered by Windows Task Scheduler at 12:00 CEST (10:00 UTC) daily | ||
|
|
||
| cd /d "C:\Users\Claude\career-ops" |
There was a problem hiding this comment.
Hardcoded user-specific path will break for anyone but you.
C:\Users\Claude\career-ops is not portable; any contributor running this scheduled task will have to edit the file (and risk committing the edit back). Use the script's own location instead so it works wherever the repo is checked out.
🛠️ Proposed fix
-cd /d "C:\Users\Claude\career-ops"
+cd /d "%~dp0"%~dp0 resolves to the directory containing the .bat file.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| cd /d "C:\Users\Claude\career-ops" | |
| cd /d "%~dp0" |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@daily-ats-scan.bat` at line 6, The batch file currently uses a hardcoded,
user-specific path in the cd command ("C:\Users\Claude\career-ops"), which
breaks for other contributors; update the cd invocation in daily-ats-scan.bat to
use the batch file's own directory resolver (%~dp0) so the script changes to the
repository location relative to the .bat file (adjust to parent folder if
needed) instead of the fixed user path.
| async function fetchAts(t){ | ||
| try { | ||
| if(t.ats==='greenhouse'){ | ||
| const r=await fetch('https://boards-api.greenhouse.io/v1/boards/'+t.slug+'/jobs'); | ||
| if(!r.ok)return []; | ||
| const j=await r.json(); | ||
| return (j.jobs||[]).map(x=>({url:x.absolute_url,title:x.title,company:t.company})); | ||
| } | ||
| if(t.ats==='ashby'){ | ||
| const r=await fetch('https://api.ashbyhq.com/posting-api/job-board/'+encodeURIComponent(t.slug)); | ||
| if(!r.ok)return []; | ||
| const j=await r.json(); | ||
| return (j.jobs||[]).map(x=>({url:x.jobUrl||x.applyUrl,title:x.title,company:t.company})); | ||
| } | ||
| if(t.ats==='lever'){ | ||
| const r=await fetch('https://api.lever.co/v0/postings/'+t.slug+'?mode=json'); | ||
| if(!r.ok)return []; | ||
| const arr=await r.json(); | ||
| return (Array.isArray(arr)?arr:[]).map(x=>({url:x.hostedUrl,title:x.text,company:t.company})); | ||
| } | ||
| } catch(e) { console.error('['+t.company+']',e.message); } | ||
| return []; | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
Node.js fetch default timeout AbortSignal.timeout
💡 Result:
Node.js fetch has no default timeout; it will wait indefinitely unless a signal is provided or Undici's internal timeouts (like headersTimeout: 300s, bodyTimeout: 300s, connectTimeout: 10s) trigger first [1][2][3]. The AbortSignal.timeout static method creates an AbortSignal that automatically aborts after the specified milliseconds (added in Node.js v17.3.0/v16.14.0), throwing a TimeoutError DOMException (or AbortError in some cases) [4][1][5]. By default, fetch options.signal is null/undefined, so no AbortSignal.timeout is applied automatically [6][7]. To use it: fetch(url, { signal: AbortSignal.timeout(5000) }) [8][9].
Citations:
- 1: https://nodejs.org/api/globals.html
- 2: https://github.com/nodejs/undici/blob/main/docs/docs/api/Client.md
- 3:
fetch()in Node.js ignores connection timeout; no way to override 10s default nodejs/undici#4215 - 4: https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/timeout_static
- 5: AbortSignal.timeout inconsistently leads to TimeoutError or AbortError nodejs/undici#2171
- 6: https://github.com/node-fetch/node-fetch/
- 7: https://nodejs.dev/en/learn/getting-started/fetch
- 8: https://stackoverflow.com/questions/76755913/increase-timeout-in-nodejs-built-in-fetch-api
- 9: https://medium.com/deno-the-complete-reference/timeout-fetch-request-in-node-js-4231f33a9b95
🏁 Script executed:
#!/bin/bash
# Find the daily-ats-scan.mjs file
find . -name "daily-ats-scan.mjs" -type fRepository: santifer/career-ops
Length of output: 84
🏁 Script executed:
#!/bin/bash
# Read the daily-ats-scan.mjs file to check its current state
wc -l ./daily-ats-scan.mjsRepository: santifer/career-ops
Length of output: 88
🏁 Script executed:
#!/bin/bash
# Read the entire daily-ats-scan.mjs file
cat -n ./daily-ats-scan.mjsRepository: santifer/career-ops
Length of output: 20768
Add 15-second request timeouts to all fetch calls.
None of the ATS, RemoteOK, Remotive, LinkedIn, HN, or Web3.career fetch calls set a timeout. A single hung connection will block the scheduled task indefinitely until Task Scheduler kills it. Apply signal: AbortSignal.timeout(15000) to all 9 fetch calls: three in fetchAts, and one each in fetchRemoteOK, fetchRemotive, fetchLinkedIn, fetchHackerNewsWhoshiring, fetchWeb3Career, and the Resend API call at line 324.
🛡️ Proposed pattern
- const r=await fetch('https://boards-api.greenhouse.io/v1/boards/'+t.slug+'/jobs');
+ const r=await fetch('https://boards-api.greenhouse.io/v1/boards/'+t.slug+'/jobs',{signal:AbortSignal.timeout(15000)});Apply the same signal: AbortSignal.timeout(15000) to every fetch in the file.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@daily-ats-scan.mjs` around lines 50 - 72, Add a 15s AbortSignal timeout to
every fetch call so hung requests don't stall the scheduled task: update the
three fetches inside fetchAts (Greenhouse, Ashby, Lever) to pass { signal:
AbortSignal.timeout(15000) } and do the same for fetchRemoteOK, fetchRemotive,
fetchLinkedIn, fetchHackerNewsWhoshiring, fetchWeb3Career, and the Resend API
call (the HTTP call around the resend/send function) so each fetch includes the
same signal option.
| if (!existsSync('./data/scan-history.tsv')) { | ||
| writeFileSync('./data/scan-history.tsv', 'url\tfirst_seen\tportal\ttitle\tcompany\tstatus\n'); | ||
| } |
There was a problem hiding this comment.
Missing data/ directory creation — writeFileSync throws ENOENT on fresh setup.
If the data/ directory does not yet exist (fresh clone, first run on a new machine), writeFileSync('./data/scan-history.tsv', …) and the subsequent appendFileSync('./data/pipeline.md', …) calls will throw ENOENT: no such file or directory. As per coding guidelines, scripts must "handle missing data/ directories gracefully."
🛡️ Proposed fix
-import { readFileSync, appendFileSync, existsSync, writeFileSync } from 'fs';
+import { readFileSync, appendFileSync, existsSync, writeFileSync, mkdirSync } from 'fs';
@@
-if (!existsSync('./data/scan-history.tsv')) {
+mkdirSync('./data', { recursive: true });
+if (!existsSync('./data/scan-history.tsv')) {
writeFileSync('./data/scan-history.tsv', 'url\tfirst_seen\tportal\ttitle\tcompany\tstatus\n');
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| if (!existsSync('./data/scan-history.tsv')) { | |
| writeFileSync('./data/scan-history.tsv', 'url\tfirst_seen\tportal\ttitle\tcompany\tstatus\n'); | |
| } | |
| import { readFileSync, appendFileSync, existsSync, writeFileSync, mkdirSync } from 'fs'; | |
| // ... (other code) | |
| mkdirSync('./data', { recursive: true }); | |
| if (!existsSync('./data/scan-history.tsv')) { | |
| writeFileSync('./data/scan-history.tsv', 'url\tfirst_seen\tportal\ttitle\tcompany\tstatus\n'); | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@daily-ats-scan.mjs` around lines 235 - 237, Ensure the ./data directory
exists before writing files: before the block that checks
existsSync('./data/scan-history.tsv') and before any
appendFileSync('./data/pipeline.md') calls, call mkdirSync('./data', {
recursive: true }) (or equivalent) to create the directory if missing; then
proceed with the existing existsSync and writeFileSync logic so write/append
operations no longer throw ENOENT.
| const RESEND_API_KEY = process.env.RESEND_API_KEY; | ||
| const FROM = process.env.RESEND_FROM || 'career-ops <onboarding@resend.dev>'; | ||
| const TO = process.env.NOTIFY_EMAIL; | ||
| const TODAY = '2026-05-06'; | ||
| const TSV_PATH = '/home/user/career-ops/data/scan-history.tsv'; | ||
|
|
||
| // Read TSV and filter for today's added roles | ||
| const lines = readFileSync(TSV_PATH, 'utf8').trim().split('\n'); |
There was a problem hiding this comment.
Missing env-var validation and existsSync guard.
Three reachable startup failures, all silent:
RESEND_API_KEYandNOTIFY_EMAIL(used asTO) are never validated. Sending without a key produces an opaque Resend 401 response that is then logged as JSON and exits 1, rather than a clean, actionable message.readFileSync(TSV_PATH, …)throwsENOENTon first run before any scan has populated history. As per coding guidelines, scripts must handle missingdata/directories gracefully.
send-agent-results.mjs already implements both checks; mirror them here.
🛡️ Proposed fix
const RESEND_API_KEY = process.env.RESEND_API_KEY;
const FROM = process.env.RESEND_FROM || 'career-ops <onboarding@resend.dev>';
const TO = process.env.NOTIFY_EMAIL;
+
+if (!RESEND_API_KEY) { console.error('Missing RESEND_API_KEY env var.'); process.exit(1); }
+if (!TO) { console.error('Missing NOTIFY_EMAIL env var.'); process.exit(1); }
+
+if (!existsSync(TSV_PATH)) {
+ console.log('No scan history found — run scan first.');
+ process.exit(0);
+}
…
const lines = readFileSync(TSV_PATH, 'utf8').trim().split('\n');🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@send-daily-email.mjs` around lines 4 - 11, Validate required env vars
RESEND_API_KEY and NOTIFY_EMAIL (used as TO) at startup and emit a clear
processLogger.error message and process.exit(1) if missing; do not rely on
downstream Resend errors for this. Before calling readFileSync(TSV_PATH, ...),
use fs.existsSync on TSV_PATH (or its containing data directory) to handle
first-run missing files gracefully: if the file is absent, treat the TSV as
empty (e.g., set lines=[] or skip processing) rather than letting readFileSync
throw ENOENT. Update any error logs to include which var or file is missing and
mirror the validation/existsSync pattern used in send-agent-results.mjs,
referencing RESEND_API_KEY, TO, TSV_PATH, existsSync, and readFileSync to locate
the changes.
| const RESEND_API_KEY = process.env.RESEND_API_KEY; | ||
| const FROM = process.env.RESEND_FROM || 'career-ops <onboarding@resend.dev>'; | ||
| const TO = process.env.NOTIFY_EMAIL; | ||
| const TODAY = '2026-05-06'; |
There was a problem hiding this comment.
Hardcoded TODAY date — script becomes a no-op after 2026-05-06.
const TODAY = '2026-05-06'; will silently filter to zero rows on every subsequent day, sending the "no new roles" Monty Python email forever. Compute the date at runtime.
🐛 Proposed fix
-const TODAY = '2026-05-06';
+const TODAY = new Date().toISOString().slice(0, 10);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const TODAY = '2026-05-06'; | |
| const TODAY = new Date().toISOString().slice(0, 10); |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@send-daily-email.mjs` at line 7, The TODAY constant is hardcoded; replace the
static assignment of TODAY with a runtime-computed date string so the script
filters correctly every day. In send-daily-email.mjs, change the TOP-level
symbol TODAY to be computed at runtime (e.g., derive from new Date() and format
as YYYY-MM-DD or using the same formatter/util the project uses) so any DB/query
that relies on TODAY (the TODAY constant) receives the current date string;
ensure the formatted string matches the expected timezone/format used by the
existing filter logic.
| const FROM = process.env.RESEND_FROM || 'career-ops <onboarding@resend.dev>'; | ||
| const TO = process.env.NOTIFY_EMAIL; | ||
| const TODAY = '2026-05-06'; | ||
| const TSV_PATH = '/home/user/career-ops/data/scan-history.tsv'; |
There was a problem hiding this comment.
Hardcoded absolute Linux path is broken on every machine but the author's.
/home/user/career-ops/data/scan-history.tsv will ENOENT on macOS, on Windows (which is the target platform per setup-scheduler.ps1 and daily-ats-scan.bat), and on any Linux box where the repo lives elsewhere. Resolve relative to the script.
🐛 Proposed fix
-import { readFileSync } from 'fs';
-import { join } from 'path';
+import { readFileSync, existsSync } from 'fs';
+import { dirname, join } from 'path';
+import { fileURLToPath } from 'url';
@@
-const TSV_PATH = '/home/user/career-ops/data/scan-history.tsv';
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const TSV_PATH = join(__dirname, 'data', 'scan-history.tsv');🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@send-daily-email.mjs` at line 8, TSV_PATH in send-daily-email.mjs is a
hardcoded absolute Linux path; change it to resolve the data file relative to
the running script instead. Replace the literal '/home/user/…/scan-history.tsv'
assigned to TSV_PATH with logic that computes the path from the module location
(use import.meta.url and fileURLToPath/path.dirname or new URL with
import.meta.url) so TSV_PATH points to ./data/scan-history.tsv relative to the
script regardless of OS or working directory.
| status: cols[5] | ||
| }; | ||
| }) | ||
| .filter(r => r.first_seen === TODAY && r.status === 'added'); |
There was a problem hiding this comment.
status field not trimmed — Windows CRLF makes the equality check fail for every row.
When scan-history.tsv is written/read on Windows (as it will be under Task Scheduler), each line keeps its trailing \r, so cols[5] is 'added\r' and r.status === 'added' is always false. Trim at parse time.
🐛 Proposed fix
- status: cols[5]
+ status: cols[5]?.trim()🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@send-daily-email.mjs` around lines 22 - 25, The status field from cols[5] can
include a trailing CR on Windows, so the filter r.status === 'added' always
fails; update the parsing step that builds r (where status: cols[5]) to trim the
value (e.g., use cols[5].trim()) or trim all cols before mapping, so r.status
contains the normalized string and the subsequent filter comparing to 'added'
and TODAY works correctly.
| if (!roles.length) return ''; | ||
| const rows = roles.map(r => ` | ||
| <tr> | ||
| <td style="padding: 8px 12px; border-bottom: 1px solid #30363d;"><a href="${r.url}" style="color: #58a6ff; text-decoration: none;">${escHtml(r.title)}</a></td> |
There was a problem hiding this comment.
Unescaped r.url in the href attribute.
ATS URLs commonly include & query separators, producing invalid HTML. Pass it through escHtml.
🐛 Proposed fix
- <td …><a href="${r.url}" …>${escHtml(r.title)}</a></td>
+ <td …><a href="${escHtml(r.url)}" …>${escHtml(r.title)}</a></td>📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| <td style="padding: 8px 12px; border-bottom: 1px solid #30363d;"><a href="${r.url}" style="color: #58a6ff; text-decoration: none;">${escHtml(r.title)}</a></td> | |
| <td style="padding: 8px 12px; border-bottom: 1px solid `#30363d`;"><a href="${escHtml(r.url)}" style="color: `#58a6ff`; text-decoration: none;">${escHtml(r.title)}</a></td> |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@send-daily-email.mjs` at line 76, The href currently injects raw r.url into
the template which can break HTML when it contains characters like &, so update
the template that renders the link (the string using ${r.url}) to pass the URL
through the existing escHtml helper (use escHtml(r.url)) just like the title is
escaped with escHtml(r.title) to ensure the href attribute is properly escaped.
What does this PR do?
Adds a complete email notification pipeline for daily portal scans, fixes a silent failure in the websearch
scan_methodguard, and introduces Windows Task Scheduler automation.Related issue
Type of change
Changes
scan.mjs— Bug fixAdd early-return guard in
detectApi()forscan_method: 'websearch'companies. Previously, companies migrated to websearch fallback were still hitting the Ashby API detection path, causing silent 404s on every scan run.scan-and-notify.mjs— New scriptscan.mjs, readsdata/scan-history.tsv, sends HTML email via nodemailer only when new roles are added today--dry-runflag.env(never hardcoded)send-agent-results.mjs— New scriptsetup-scheduler.ps1— New scriptpackage.jsonnodemailer ^8.0.7dependencyscan:notifyandscan:drynpm scripts.env.exampleGMAIL_USER,GMAIL_APP_PASSWORD,NOTIFY_EMAILwith step-by-step App Password setup instructionsChecklist
node test-all.mjs— 68/68 pass; dashboard Go build skipped (no Go compiler in env, unrelated to these changes)Questions? Join the Discord for faster feedback.
Summary by CodeRabbit
New Features
Dependencies
Configuration
Chores