diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1244d52..0d833a2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -27,9 +27,9 @@ Command line and plumbing Sheet and NUworks sheet.py the Google Sheet. open_worksheet() / open_other() (kept OPEN_FOR s: OPEN, forget(), HTTPClient retries), read_rows(); limits: max_per_week(), week_window(), applied_dates(), total_count() (not_on_nuworks, - rejected_not_withdrawn), check_limits(); writers: set_status, mark_submit_started / resolve_submit, - mark_applied_by_hand, mark_site_done, set_outcome, drop, approve_proposed, unapprove, mark_on_laptop, - append_note, add_proposed; create_sheet(), google_login(). + rejected_not_withdrawn, maybe_filled), check_limits(); writers: set_status, mark_submit_started / + resolve_submit, mark_applied_by_hand, mark_site_done, set_outcome, drop, approve_proposed, unapprove, + mark_on_laptop, append_note, add_proposed; create_sheet(), google_login(). manage.py rows by hand: add(), move(), remove() (cells cleared, never deleted), lookup(), check_url(), movable(). setup_sheet.py one-time setup (refuses a sheet with data: HasData); format_sheet(); ensure_dropdown(). browser.py Playwright Firefox. launch() (lock_profile(): one process on the profile), install_domain_lock(), @@ -46,15 +46,17 @@ Job pool jobs.py the NUworks pool. PREFS / set_prefs(), cmd_list, cmd_triage_*, cmd_details (hard_rules), cmd_score_*, cmd_cat_*, build_pool(), ranked_pool() (taste model), review_queue(), job_view(), rate_viewer(), cmd_rate / cmd_approve / cmd_suggest / cmd_stats, set_rating(), check_applied(), check_applications() - (what NUworks says about sent rows -> data/applications.json). + (what NUworks says about sent rows -> data/applications.json; application_answer(): applied, and the + job's hiring stage tracked across checks). fields.py per-field starting preferences (the wizard's "What do you study?"). intern.py Summer internships (Simplify's and vanshb03's lists, one job on both kept once): the second pool, built - like jobs.py (refresh, triage, fetch, score, category, build_pool); approve() -> manage.add("other"); - update(), review_queue(), stats(). Short links -> the posting's own (real_links, real_url; used by - assist.other_target). Data in data/intern/. + like jobs.py (refresh, triage, fetch, redate, score, category, build_pool); approve() -> + manage.add("other"); update(), review_queue(), closing_dates(), stats(). Short links -> the posting's + own (real_links, real_url; used by assist.other_target). Data in data/intern/. postings.py reads one posting from a company's own job site, no login; public addresses only (allowed_host). - daily.py what the homelab timers run. main() -> scan() (via step()), reminders(), record_hand_applications(), - applications(), internships(); run_claude(), notify() / discord(), weekly(). + daily.py what the homelab timers run. main() -> scan() (via step()), applications(), reminders() (the morning + message: morning(), its lines; record_hand_applications()), internships(); run_claude(); messages: + notify() / discord(), alert() (a problem, once a day each), later() (a line for the next morning). insights.py summarize(): pay, places, kinds of work, sheet statuses; text() for `nuauto insights`. web.py Mark-done page (homelab, port 8765). link() signs URLs, Handler.do_GET (confirm) / do_POST; main() also starts gui.serve_remote() when the homelab's page is on. @@ -149,7 +151,8 @@ MAIN FLOWS STATE ON DISK (STATE_DIR = repo root; app-data folder when packaged; a temp folder in demo mode and tests) data/ the pool, owned by the homelab (the laptop pulls a copy): list.json, triage.json, scores.json, categories.json, - category_sets.json, pool.json, details/.json (jobs.py); scans.json, last_run.json (daily.py); + category_sets.json, pool.json, details/.json (jobs.py); scans.json, last_run.json, later.json, told.json + (daily.py); applications.json (jobs.check_applications); ratings.json (jobs.set_rating, merged between machines); intern/ (intern.py: listings, source, failed, unread, triage, scores, categories, pool, details/, update.lock). work/ Claude's batches: _in_NNN.json / _out_NNN.json, resume.txt (jobs.py writes the inputs, the claude @@ -198,19 +201,20 @@ REPO LAYOUT (what is not a module) TESTS (`nuauto test` runs every tests/test_*.py, 4 at a time: offline, no sheet, no real NUworks, no network; browser tests use headless Firefox on local pages or demo mode. Run it before pushing; CI runs it on Ubuntu and macOS.) test_sync sync.CODE is complete (a new module or prompt must be in it), absolute imports, rsync options, units - test_sheet sheet.py: parsing, week and total limits, max_per_week, not_on_nuworks, submit safety, drop, set_outcome, - mark_on_laptop, retries, the kept sheet (OPEN) - test_jobs hard rules, pool, model; sheet.add_proposed / approve_proposed + test_sheet sheet.py: parsing, week and total limits, max_per_week, not_on_nuworks, maybe_filled, submit safety, + drop, set_outcome, mark_on_laptop, retries, the kept sheet (OPEN) + test_jobs hard rules, pool, model; sheet.add_proposed / approve_proposed; application_answer (the hiring stage) test_apply apply.check_popup, run_rows and its queue, BETWEEN test_answers answers.py, apply.fill_extra_fields (a local form), JsonIO, json_ui_allowed test_browser domain lock, goto retry, profile lock test_demo the real apply.py on demo mode's fake pages (stops, cap, Ctrl+C, GUI gone, CLAUDECODE refusals) - test_daily daily.py's summary, to-do, Claude errors + test_daily daily.py's morning message, alerts once a day, Claude errors, the applications check test_web Mark-done links (signatures; GET never changes the sheet) test_deploy deploy.py on a throwaway git repo test_doctor_remote doctor.check_remote_assist test_health health.py's checks test_insights Insights numbers test_selftest `nuauto selftest` test_setup the wizard (onboard.py) - test_intern internship pool, postings.py (links, parsing, the network guard, fetch tries), short links, Review queue + test_intern internship pool, postings.py (links, parsing, close dates, the network guard, fetch tries, redate), + short links, Review queue test_assist the agent's guard, answer bank, slots, passwords, uploads, by-hand rows test_accounts accounts.py: sites, Bitwarden export / import of your own logins test_mail mail.py: Gmail's format, which mail counts, the `email` command diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 78a2b86..38d0abb 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -50,32 +50,47 @@ WHAT RUNS ON THE HOMELAB Unit files live in this repo (deploy/systemd/) and are installed in ~/.config/systemd/user/. All run with TZ New York. - nuauto-daily.timer -> nuauto-daily.service: `nuauto daily` at 08:00 and 18:00 New York time (+0-15 min random, Persistent=true so a missed run happens at boot). Every run, in order: - - the scan (docs/PIPELINE.md): list -> Claude triage -> details -> Claude score -> Claude category -> pool; new pool - jobs are announced; counts go to data/scans.json. - - the morning run only (before noon): the reminders. First, if the scan worked, jobs you applied to by hand on - NUworks become Applied rows (jobs.check_applied). Then messages: Approved rows closing today or tomorrow, pool jobs - closing within 3 days that are not in the sheet, the Google login warning (once 1 day is left), and "NUauto - morning" (always sent): new postings and new pool jobs in the last 24h, then the to-do list (company-site - applications owed, external Needs Human rows) with signed Mark links. + - the scan (docs/PIPELINE.md): list -> Claude triage -> details -> Claude score -> Claude category -> pool; counts + and the best new job go to data/scans.json. - the applications check: NUworks is asked about every sent row (jobs.check_applications -> data/applications.json), so the total limit counts what NUworks has; the Status dropdown is set again (Accepted / Rejected reach an older - sheet); a Rejected row NUworks still has is in the log, and in the morning message until you withdraw it there. + sheet). Rejected rows NUworks still has and Applied rows whose job looks filled (its Hiring Status turned Filled, or + it was interviewing and stopped: sheet.maybe_filled) are in the log, and in the morning message until you withdraw + them there. + - the morning run only (before noon): if the scan worked, jobs you applied to by hand on NUworks become Applied rows + (jobs.check_applied); then the morning message. - the internship update (when "internships" is on). + DISCORD (since 2026-10-26, my request: fewer, shorter messages, glanced at once a day). Two kinds only: + - "NUauto morning" (daily.morning), always sent, a line for each thing that applies: New (pool co-ops and + internships of the last 24h, each with the best one), Closing soon (Approved rows on both tabs closing within a week, + soonest first with the day, an internship's date from its posting; pool co-ops and internships closing within 3 + days not in the sheet, counted), To do (company-site applications owed, external Needs Human rows, the first three: + each name links to its job, its ✓ to the Mark-done page), Withdraw on NUworks (Rejected / may be filled), what + other runs kept for it (daily.later -> data/later.json: applications recorded from NUworks, approved internships + now closed, deploys), Week (this week's count, the total, Approved waiting), Wrong (this run stopped, the sheet + can't be read, the Google login runs out), and NUauto's page when there is one. + - a problem when it happens (daily.alert: a run that stopped, a login to renew, the sheet unreadable, a crash): the + same title at most once a day (data/told.json, TELL_AGAIN), so the 2-hourly internship update failing the same way + is told once. The morning run's own problems go in the morning message instead (daily.QUIET). + Not on Discord any more: new pool jobs per update, new internships per 2-hourly update, the separate deadline and + Google login messages, recorded applications, closed internships, "Deployed", the Sunday check-in. Unchanged: the + assistant's messages (it waits for you, wants to Submit, ended: docs/REMOTE.md), a failed deploy, device pairing. If the job list fails (NUworks slow), it is read once more 2 minutes later (daily.RETRY_WAIT), and a page load gets one - retry after 60 s; the morning reminders still go out, from the sheet and the last saved pool. Why a run stopped goes + retry after 60 s; the morning message still goes out, from the sheet and the last saved pool. Why a run stopped goes to data/last_run.json (daily.record_run: nuworks_login, nuworks, claude or failed, in a few words); the laptop pulls it with data/, and the GUI's New jobs check shows it on Today with the fix (Log in to NUworks, Run it again, Log in to - Claude there). Discord says the same: a login only when NUworks really asks for one. + Claude there). Discord says the same (once a day at most): a login only when NUworks really asks for one. Claude runs as `claude -p --model sonnet`, one call per batch file, shut in work/ (it runs there and may read and write only there: no shell, web or MCP tools; the batches hold web text, job postings, so nothing in one can reach local/, the code or your home). Headless browser; never applies to anything. -- nuauto-weekly.timer -> nuauto-weekly.service: `nuauto daily weekly`, Sunday 19:00 New York time - (Discord check-in: this week's count, Approved rows waiting, pool jobs you haven't looked at). +- nuauto-weekly.timer -> nuauto-weekly.service: `nuauto daily weekly`, Sunday 19:00 New York time. Sends nothing + since 2026-10-26 (the morning message has the week's count); kept so the installed timer runs cleanly (deploy never + removes unit files: `systemctl --user disable --now nuauto-weekly.timer` turns it off). - nuauto-intern.timer -> nuauto-intern.service: `nuauto intern update` every 2 hours (:40 on even hours New York, +0-10 min). Needs "internships" in local_config.json (docs/PIPELINE.md, SUMMER INTERNSHIPS); without it the run stops at once saying so. Simplify's and vanshb03's lists, postings from the companies' own job sites, Claude triage / score / - category, Discord for new pool internships. `nuauto daily` runs the same update after the NUworks one; a lock keeps - it to one at a time. The laptop's `nuauto intern update` starts it here. + category; the morning message counts new pool internships. `nuauto daily` runs the same update after the NUworks + one; a lock keeps it to one at a time. The laptop's `nuauto intern update` starts it here. - nuauto-deploy.timer -> nuauto-deploy.service: `nuauto deploy` 2 min after boot, then every 5 minutes (deploy_from_git only). Looks at GitHub's main; a new commit is unpacked aside (work/deploy/), imported as a check (deploy.MODULES), then its changed files are copied in (the old ones kept to undo), the package is reinstalled if pyproject.toml @@ -85,7 +100,7 @@ Unit files live in this repo (deploy/systemd/) and are installed in ~/.config/sy import, or a web that does not come back, is undone and Discord says so once. It waits while an update (`nuauto daily`, `nuauto intern update`) or a NUworks run started from NUauto's page (`nuauto apply`, `nuauto assist nuworks`: nuauto-web's children, which a restart would cut off) runs; assistant sessions run in scopes of their own and are - not cut off. Record: local/deployed.json; log: logs/deploy.log; Discord: "Deployed " when files changed. + not cut off. Record: local/deployed.json; log: logs/deploy.log; the next morning message: "Updated: ". - nuauto-assist.timer -> nuauto-assist.service: `nuauto assist auto` every 10 minutes: the assistant's queue. Does nothing unless "remote_assist" and "remote_auto" are on (docs/REMOTE.md). - nuauto-web.service: `nuauto web`, always on (Restart=on-failure). Serves the signed Mark-done links on @@ -170,8 +185,9 @@ SYNC (sync.py; runs automatically inside `nuauto` on the laptop, homelab mode on DEPLOYING A CHANGE With deploy_from_git (the homelab pulls main; no unit file or dependency steps by hand): 1. Change code on the laptop (on a branch; merge to main when approved). -2. Merge to main. Within 5 minutes the homelab has it (Discord: "Deployed "). Now: `nuauto deploy` - (starts the run on the homelab and shows the end of its log). A branch on the laptop never reaches the homelab. +2. Merge to main. Within 5 minutes the homelab has it (the next morning message: "Updated: "). Now: + `nuauto deploy` (starts the run on the homelab and shows the end of its log). A branch on the laptop never reaches + the homelab. 3. `nuauto doctor` shows the deployed commit; a failed deploy shows as FAIL with the reason (logs/deploy.log). Roll back: revert the commit on main (a new commit), merge it; the homelab deploys the revert. What it copies: src/nuauto/, prompts/, docs/, deploy/systemd/ and the top-level pyproject.toml, README.md, CLAUDE.md, diff --git a/docs/GUI.md b/docs/GUI.md index db1da59..ac92d19 100644 --- a/docs/GUI.md +++ b/docs/GUI.md @@ -107,14 +107,20 @@ A question from a run (an unknown form field, an option to pick, "did it submit? Human rows only you can finish there: a cover letter, a transcript; the assistant can work them on NUworks with you, or a chip says why not), Still owed (submitted on NUworks, the company site still wants you: Mark the company site done; the NUworks side to send again: Submit on NUworks too), Applied ("not on - NUworks" chip when NUworks has no application for it: it doesn't count toward the total; "interviewing - (NUworks)"), Accepted, Rejected, Proposed, Dropped or failed, Other rows; Recent runs here at the end. The + NUworks" chip when NUworks has no application for it: it doesn't count toward the total; the job's own + hiring stage at the last check (jobs.application_answer): "job interviewing", then amber "job stopped + interviewing" or "job filled" (sheet.maybe_filled: it may be filled; withdrawing on NUworks frees its + place in the total). Until 2026-10-26 an "interviewing (NUworks)" chip showed the job's is_interviewing + flag as if it were yours: it is about the job, set also on jobs you never applied to, and NUworks never + shows a student whom an employer picked), Accepted, Rejected, Proposed, Dropped or failed, Other rows; + Recent runs here at the end. The other tab: Ready to apply (Approved: the assistant), Applied, Accepted, Rejected, Proposed, Dropped or failed, Other rows (Sort: score, match %, pay, sheet order, and on the homelab's page the queue's order first; it changes the view only). Every row looks alike: who and what, score / match % and pay ("score 105 = 75% match + 10 Boston + 20 security" when bonuses add to it: Start goes by score), a line about it (site, notes), chips (when it - closes, or was posted: amber within a week, red once past; "may also want the company's site"; the queue's + closes, amber within a week, red once past; an internship's from its posting (gui.intern_facts), else + "no close date · posted N days ago"; "may also want the company's site"; the queue's place; "do it on the laptop" when the homelab's browser was refused), what to do next, Open, and More (⋯; on a phone in the row's top right corner). Next: Apply on an Approved NUworks row, after a confirm, just that row (`nuauto apply --row N`); while an diff --git a/docs/PIPELINE.md b/docs/PIPELINE.md index 142eb61..0c82ae1 100644 --- a/docs/PIPELINE.md +++ b/docs/PIPELINE.md @@ -117,7 +117,7 @@ year's lists are new repos: changing the term moves the addresses with it); off runs work through the backlog, best titles (fit) and newest first (intern.priority). - Lists: a listing counts when it is for the term (Simplify's "terms"; vanshb03's has only a season, "Summer", on a list whose address says the term's year). One its list closes (or drops) stays, marked inactive; an approved one - that closes sends a Discord message (intern.closed_notice). Ids: "s..." Simplify's, "sv..." vanshb03's. + that closes is named in the next morning message (intern.closed_notice). Ids: "s..." Simplify's, "sv..." vanshb03's. - Short links (2026-10-26, my request): vanshb03's list gives go.vansh.nyc/ links, not the posting's. Each update asks where each new one leads (intern.real_links: its redirect only, the posting is not opened; 0.2 s apart; public addresses only; 5 failures in a row and the rest wait for the next run) and the listing keeps the posting's own link @@ -141,7 +141,13 @@ year's lists are new repos: changing the term moves the addresses with it); off page's schema.org JobPosting data, else the page's text (needs 1500+ characters that read like a posting), else the page in a hidden browser (fresh, no profile, no images, for pages built by JavaScript). Read-only GETs, 1 s apart per site, never to a non-public address (checked after redirects too). Pay and deadline are read when the - site gives them. A posting that says it is gone is marked closed: not scored. A posting no way could read in 3 runs + site gives them (the deadline: schema.org validThrough, Workday's endDate, iCIMS's page data, Oracle's + ExternalPostedEndDate, Greenhouse's application_deadline; postings.end_day drops a placeholder years away). Greenhouse, + Lever, Ashby, SmartRecruiters, Eightfold and Workable have no date in their data; dates written only in a posting's + text are not read (12/11/2026 can be either month: no guessing). Postings read before a site's date was read + (intern.REDATE: Oracle, 2026-10-26) are read once more for the date alone (intern.redate, 40 per run). The close date + shows on Review and Apply, and the morning message's Closing soon line uses it. A posting that says it is gone is + marked closed: not scored. A posting no way could read in 3 runs (each with the browser; not twice a day) shows in Review after the scored ones, unscored: none is dropped for being unreadable. 4. Claude score (prompts/INTERN_SCORE_PROMPT.md): the same match formula as the co-ops, plus year_req, who may apply compared with your standing DURING the internship (a sophomore now = a rising junior then): ok = @@ -156,7 +162,7 @@ year's lists are new repos: changing the term moves the addresses with it); off as yes; one data/ratings.json. Approve = an Approved row in the Other jobs tab (Notes " internship (); match ..."), so no weekly or total cap; Undo = that row back to Proposed. Apply: `nuauto assist other `. - Data: data/intern/ (listings, source, links, triage, details/, failed, scores, categories, category_sets, pool, unread, - scans). Discord: new pool internships after a run; the morning message adds the last day's internship counts. + scans). No message per run: the morning message counts the last day's new pool internships, with the best one. - `nuauto intern fetch [--browser]` reads one posting now (nothing saved); `intern stats` shows each step. INSIGHTS (insights.py; `nuauto insights`, the GUI's Insights): read-only pay, places and kinds of work for the pool and diff --git a/src/nuauto/daily.py b/src/nuauto/daily.py index d1516eb..ae30a2c 100644 --- a/src/nuauto/daily.py +++ b/src/nuauto/daily.py @@ -1,8 +1,9 @@ """Pool update: list -> Claude triage -> details -> Claude score -> pool -> notifications (Discord + desktop). Runs on the homelab (systemd user timer nuauto-daily.timer, 08:00 and 18:00 New York time). - nuauto daily # the update; the morning run also sends deadline reminders - nuauto daily weekly # Sunday summary (nuauto-weekly.timer) + nuauto daily # the update; the morning run also sends the one message a day (morning()) + nuauto daily weekly # nuauto-weekly.timer (Sunday): nothing to send since 2026-10-26 +Messages: the morning message, and a problem when it happens (alert(): at most once a day each). From the laptop: `nuauto update` starts the homelab run and then syncs. Uses the hidden (headless) browser; never applies to anything and only reads the sheet. Claude runs as `claude -p` (Sonnet) with only Read/Write tools, one call per batch. @@ -28,6 +29,10 @@ AUTH_RE = re.compile(r"authenticat|logged out|/login|log in|oauth|401", re.I) SCANS = "scans.json" # per-run counts for the morning summary (data/) LAST_RUN = "last_run.json" # data/: how the last update went and why it stopped (the GUI's New jobs check, with a fix) +LATER = "later.json" # data/: what the next morning message adds (later(): {kind: [items]}), cleared once it is sent +TOLD = "told.json" # data/: when each problem was last told (alert(): at most once a day each) +TELL_AGAIN = timedelta(hours=20) # (a problem still there the next morning is told again) +QUIET = [] # set during the morning run's scan: its problems go in the morning message (wrong_line), not their own RETRY_WAIT = 120 # seconds before the job list is read once more: NUworks is sometimes slow for a while STOPPED = [] # this run's stops, in order: (reason, what stopped); main() keeps the first in LAST_RUN LOG_PATH = os.path.join(config.LOGS_DIR, f"daily-{datetime.now():%Y%m%d-%H%M}.txt") @@ -48,6 +53,27 @@ def notify(title, body=""): discord(f"**{title}**\n{body}" if body else f"**{title}**") +def alert(title, body=""): + """A problem worth a message now (a login to renew, a run that stopped). The same title at most once a day: the + 2-hourly internship update and the twice-daily update would otherwise repeat it every run.""" + told, now = jobs.load(TOLD, {}), datetime.now() + last = told.get(title) + if last and now - datetime.fromisoformat(last) < TELL_AGAIN: + return log(f"ALERT (told at {last}): {title} | {body}") + told[title] = now.isoformat(timespec="minutes") + jobs.save(TOLD, {k: v for k, v in told.items() if now - datetime.fromisoformat(v) < timedelta(days=7)}) + if QUIET: + return log(f"ALERT (in the morning message): {title} | {body}") + notify(title, body) + + +def later(kind, items): + """Things for the next morning message instead of a message now (kinds: "recorded", "closed", "deployed").""" + kept = jobs.load(LATER, {}) + kept[kind] = [*kept.get(kind, []), *(i for i in items if i not in kept.get(kind, []))][-10:] + jobs.save(LATER, kept) + + def discord(text): """Post to the Discord webhook in discord_webhook.txt (if present). Never logs the URL.""" try: @@ -68,7 +94,7 @@ def stop(reason, title, body, said=None): reason: nuworks_login (NUworks wants you to log in), nuworks (NUworks did not answer: the next run usually works), claude (Claude Code logged out or missing), failed (anything else). said: what stopped, in a few words.""" STOPPED.append((reason, said or title.removeprefix("NUauto: "))) - notify(title, body) + alert(title, body) def record_run(code): @@ -160,22 +186,31 @@ def step(fn, *args): def main(): - """The scan, then (morning run) the reminders. A failed scan still sends the reminders, from the sheet - and the last saved pool.""" + """The scan and the applications check, then (morning run) the morning message. A failed scan still sends it, from + the sheet and the last saved pool.""" before = {r["id"] for r in jobs.load("pool.json", [])} STOPPED.clear() + is_morning = datetime.now().hour < 12 # the one message a day, with the morning run: it carries this run's problems + if is_morning: + QUIET.append(True) try: - code = scan(before) - except Exception as e: # e.g. NUworks too slow, or the browser profile is busy because apply.py is running - traceback.print_exc() - what = f"{type(e).__name__}: {str(e)[:150]}" - # Playwright's TimeoutError: a NUworks page did not load (Claude's calls time out as TimeoutExpired) - stop("nuworks" if type(e).__name__ == "TimeoutError" else "failed", "NUauto: daily update failed", what, - said="NUworks did not load in time" if type(e).__name__ == "TimeoutError" else what[:120]) - code = 1 - if datetime.now().hour < 12: # reminders once a day, with the morning run + try: + code = scan(before) + except Exception as e: # e.g. NUworks too slow, or the browser profile is busy because apply.py is running + traceback.print_exc() + what = f"{type(e).__name__}: {str(e)[:150]}" + # Playwright's TimeoutError: a NUworks page did not load (Claude's calls time out as TimeoutExpired) + stop("nuworks" if type(e).__name__ == "TimeoutError" else "failed", "NUauto: daily update failed", what, + said="NUworks did not load in time" if type(e).__name__ == "TimeoutError" else what[:120]) + code = 1 + try: + applications() + except Exception as e: # never stops the morning message + log(f"applications check failed: {type(e).__name__}: {str(e)[:150]}") + finally: + QUIET.clear() + if is_morning: reminders(before, ok=code == 0) - applications() code = internships() or code record_run(code) return code @@ -183,7 +218,8 @@ def main(): def applications(): """Every run: ask NUworks about each sent row (jobs.check_applications), so the total limit counts what NUworks has. - Never changes the run's result: a sheet or NUworks that can't be read now is asked again next run.""" + Never changes the run's result: a sheet or NUworks that can't be read now is asked again next run. What to withdraw + (Rejected rows NUworks still has, jobs that look filled) goes in the log here and in the morning message.""" from nuauto import setup_sheet from nuauto import sheet try: @@ -202,10 +238,9 @@ def applications(): waiting = sheet.rejected_not_withdrawn(rows, seen) if waiting: log(f"rejected, not withdrawn on NUworks: row{'s' if len(waiting) > 1 else ''} {', '.join(str(r.number) for r in waiting)}") - if datetime.now().hour < 12: # the message once a day, with the morning run - notify(f"{len(waiting)} rejected application{'s' if len(waiting) > 1 else ''} still on NUworks", - "\n".join(f"{r.title[:40]} | {r.company[:22]}" for r in waiting) - + "\nWithdraw on NUworks: until you do they count toward the total.") + done = sheet.maybe_filled(rows, seen) + if done: + log("job looks filled: " + ", ".join(f"row {r.number} ({why})" for r, why in done)) def internships(): @@ -277,44 +312,178 @@ def ready(): # claude_ready(), asked once per run, only when there is something new = [r for r in pool if r["id"] not in before] gone = len(before - {r["id"] for r in pool}) log(f"pool {len(pool)} ({len(new)} new, {gone} closed or dropped)") - record_scan(len(set(jobs.load("list.json", {})) - listed_before), len(new)) - if new: - top = "\n".join(f"{r['effective']}% {r['title'][:45]} | {r['company'][:25]}" for r in new[:5]) - notify(f"{len(new)} new job{'s' if len(new) > 1 else ''} in your NUworks pool", - top + "\nRun: nuauto approve") + best = max(new, key=lambda r: r["effective"], default=None) # (the morning message names it) + record_scan(len(set(jobs.load("list.json", {})) - listed_before), len(new), + best and {"score": best["effective"], "text": f"{best['title'][:40]} | {best['company'][:22]}"}) return 0 def reminders(before, ok): - """Morning reminders. After a good scan, first records jobs applied to by hand (needs the fresh list).""" - rows = sheet_rows() + """The morning message. After a good scan, first records jobs applied to by hand (needs the fresh list).""" + rows = sheet_rows(tell=False) # (the message itself says when the sheet can't be read) if rows is not None and ok: try: rows = record_hand_applications(before, rows) or rows - except Exception as e: # never let this block the reminders + except Exception as e: # never let this block the message log(f"hand applications check failed: {type(e).__name__}: {str(e)[:150]}") - pool = jobs.load("pool.json", []) - summary = scan_summary(jobs.load(SCANS, []), datetime.now()) + notify("NUauto morning", morning(rows, jobs.load("pool.json", []), other=other_rows())) + jobs.save(LATER, {}) + + +def other_rows(): + """The Other jobs tab's rows when internships are on (their Approved ones can close too), else None.""" from nuauto import intern - if intern.ENABLED: - summary += "\n" + intern.summary() - for name, fn in [("approved", lambda: approved_notice(rows)), ("urgent", lambda: urgent_notice(pool, rows)), - ("todo", lambda: todo_notice(rows, summary if ok else summary + " (this morning's scan failed)")), - ("google", google_notice)]: + if not intern.ENABLED: + return None + try: + return intern.other_rows(interactive=False) + except BaseException as e: # gspread's login flow can sys.exit; the message still goes out + log(f"could not read the Other jobs tab ({type(e).__name__})") + return None + + +def morning(rows, pool, now=None, other=None): + """The one message a day (since 2026-10-26: before, each of these was a message of its own, several a day). Short: + a line for each thing that applies: new jobs, what closes, what only you can do, what to withdraw, what was recorded + or updated, this week's count, what is wrong. rows: None when the sheet can't be read; other: the Other jobs + tab's.""" + now = now or datetime.now() + lines = [] + for name, fn in [("new", lambda: new_line(now)), ("closing", lambda: closing_line(rows, pool, now.date(), other)), + ("todo", lambda: todo_line(rows)), ("withdraw", lambda: withdraw_line(rows)), + ("later", later_lines), ("week", lambda: week_line(rows)), ("wrong", lambda: wrong_line(rows))]: try: - fn() - except Exception as e: # one broken reminder must not stop the others - log(f"{name} reminder failed: {type(e).__name__}: {str(e)[:150]}") + got = fn() + except Exception as e: # one broken line must not stop the message + log(f"{name} line failed: {type(e).__name__}: {str(e)[:150]}") + continue + lines += got if isinstance(got, list) else [got] if got else [] + if config.REMOTE_APP: + from nuauto import review + lines.append(review.page_url()) + return "\n".join(lines) + + +def names(items, most=3, width=22): + """A few short names: "A, B, C +2".""" + items = [i[:width] for i in items] + return ", ".join(items[:most]) + (f" +{len(items) - most}" if len(items) > most else "") -def google_notice(): - """Heads-up the day before the 7-day Google login runs out.""" +def scan_part(scans, now, what, hours=24): + """How many jobs made a pool in the last `hours` (each run's counts: record_scan), with the best of them.""" + recent = [s for s in scans if now - datetime.fromisoformat(s["time"]) <= timedelta(hours=hours)] + n = sum(s["pool"] for s in recent) + best = max((s["best"] for s in recent if s.get("best")), key=lambda b: b["score"], default=None) + return f"{n} {what}{'s' if n != 1 else ''}" + (f" (best: {best['text']})" if best and n else "") + + +def new_line(now): + from nuauto import intern + parts = [scan_part(jobs.load(SCANS, []), now, "co-op")] + if intern.ENABLED: + parts.append(scan_part(intern.load("scans.json", []), now, "internship")) + return "New: " + " · ".join(parts) + + +def closing_line(rows, pool, today, other=None, days=3): + """Approved rows on both tabs that close within a week (by name and day, soonest first: apply to those first; an + internship's date is its posting's, when it gives one), and how many pool jobs (co-ops, internships) close within + `days` that are not in the sheet yet and not rated no.""" + from nuauto import intern + details, dates = jobs.load_details(), intern.closing_dates() if other is not None else {} + due = [(jobs.closes(d), r) for r in rows or [] if r.status == "Approved" + and (d := details.get(jobs.job_id(r.url))) and jobs.closes(d)] + due += [(dates[intern.real_url(r.url)], r) for r in other or [] if r.status == "Approved" + and intern.real_url(r.url) in dates] + due = sorted(((day, r) for day, r in due if 0 <= (day - today).days <= jobs.URGENT_DAYS), key=lambda x: x[0]) + in_sheet = {jobs.job_id(r.url) for r in rows or []} | {intern.real_url(r.url) for r in other or []} + ratings = jobs.load("ratings.json", {}) + pools = pool + (intern.load("pool.json", []) if other is not None else []) + listed = lambda r: r["id"] in in_sheet or bool(r.get("url")) and intern.real_url(r["url"]) in in_sheet # noqa: E731 + soon = [r for r in pools if r.get("closes") and not listed(r) and ratings.get(r["id"], {}).get("label") != 0 + and 0 <= (datetime.fromisoformat(r["closes"]).date() - today).days <= days] + log(f"approved closing within {jobs.URGENT_DAYS} days: {len(due)}; pool closing within {days} days: {len(soon)}") + + def when(day): + n = (day - today).days + return "today" if n == 0 else "tomorrow" if n == 1 else f"{day:%a}" + parts = ([names([f"{r.company[:22]} {when(day)}" for day, r in due], 4, 40)] if due else []) \ + + ([f"{len(soon)} pool job{'s' if len(soon) != 1 else ''} within {days} days (Review)"] if soon else []) + return "Closing soon: " + " · ".join(parts) if parts else None + + +def todo_line(rows): + """What only you can finish: company-site applications still owed and external jobs (Needs Human), the counts and + the first few: each name links to its job, its ✓ to the Mark-done page (when it is set up).""" from nuauto import sheet - age = sheet.google_login_age() - if age is not None and age >= config.GOOGLE_LOGIN_DAYS - 1: - left = config.GOOGLE_LOGIN_DAYS - age - title = "NUworks: Google login has expired" if left <= 0 else "NUworks: Google login expires today" - notify(title, "On the laptop run: `nuauto login google`") + from nuauto import web + site = [r for r in rows if r.status == "Applied" and r.notes.startswith(sheet.SITE_MARK)] if rows else [] + ext = [r for r in rows if r.status == "Needs Human" and r.notes.startswith("External application")] if rows else [] + log(f"todo: {len(site)} company-site, {len(ext)} external") + if not site and not ext: + return None + + def item(r, action): # no Mark-done page configured (web_base_url "") = no ✓ + mark = f" [✓]({web.link(action, r.number, jobs.job_id(r.url))})" if web.BASE_URL else "" + return f"[{r.company[:22]}]({r.url}){mark}" + counts = ([f"{len(site)} company site{'s' if len(site) != 1 else ''} owed"] if site else []) \ + + ([f"{len(ext)} external application{'s' if len(ext) != 1 else ''}"] if ext else []) + items = [item(r, "site") for r in site] + [item(r, "applied") for r in ext] + return (f"To do: {', '.join(counts)}: " + " · ".join(items[:3]) + (f" +{len(items) - 3}" if len(items) > 3 else "") + + (" (✓ marks one done)" if web.BASE_URL else "")) + + +def withdraw_line(rows): + """Applications NUworks still has that you may withdraw there, so they stop counting toward the total: Rejected + rows, and Applied rows whose job looks filled (the applications check just before: sheet.maybe_filled).""" + from nuauto import sheet + if rows is None: + return None + seen = sheet.nuworks_seen() + rejected = [r.company for r in sheet.rejected_not_withdrawn(rows, seen)] + filled = [r.company for r, _ in sheet.maybe_filled(rows, seen)] + parts = ([f"rejected {names(rejected)}"] if rejected else []) + ([f"may be filled {names(filled)}"] if filled else []) + return "Withdraw on NUworks to free room: " + " · ".join(parts) if parts else None + + +LATER_TEXT = {"recorded": "Recorded what you applied to on NUworks", "closed": "Approved internships now closed", + "deployed": "Updated"} + + +def later_lines(): + """What other runs kept for this message (later()).""" + return [f"{LATER_TEXT.get(k, k)}: {names(v, 2, 60) if k == 'deployed' else names(v)}" + for k, v in jobs.load(LATER, {}).items() if v] + + +def week_line(rows): + from nuauto import sheet + if rows is None: + return None + week, total = sheet.check_limits_safe(rows) + waiting = sum(1 for r in rows if r.status == "Approved") + return f"Week: {week}/{sheet.max_per_week()} applied · total {total}/{sheet.MAX_TOTAL} · {waiting} Approved waiting" + + +FIX = {"nuworks_login": "run `nuauto login` on the laptop", "claude": "run `claude auth login` on {where}", + "nuworks": "NUworks was slow; the next run usually works", "failed": "the update's log on {where} says why"} + + +def wrong_line(rows): + """What is wrong now: this run stopped (its message was held for this one: alert, QUIET), the sheet can't be read, + or the Google login runs out.""" + from nuauto import sheet + parts = [f"the update stopped: {said} ({FIX.get(reason, FIX['failed']).format(where=where())})" + for reason, said in STOPPED[:1]] + if rows is None: + parts.append("can't read the sheet (the Google login? on the laptop run `nuauto login google`)") + else: + age = sheet.google_login_age() + if age is not None and age >= config.GOOGLE_LOGIN_DAYS - 1: + parts.append(("the Google login has expired" if age >= config.GOOGLE_LOGIN_DAYS else + "the Google login expires today") + ": on the laptop run `nuauto login google`") + return "Wrong: " + " · ".join(parts) if parts else None def record_hand_applications(before, rows): @@ -339,114 +508,39 @@ def record_hand_applications(before, rows): n = len(ws.get_all_values()) + 1 ws.update(range_name=f"A{n}:F{n}", values=[[jobs.job_url(i), d.get("company", ""), d.get("title", ""), "Applied", "Applied by hand (seen on NUworks)", datetime.now().strftime(sheet.DATE_FMT)]], value_input_option="RAW") - done.append(f"{d.get('title', i)[:40]} | {d.get('company', '')[:22]}") - notify(f"Recorded {len(done)} application{'s' if len(done) > 1 else ''} you made on NUworks", "\n".join(done)) + done.append(d.get("company") or d.get("title", i)) + log(f"hand applications: recorded {', '.join(done)}") + later("recorded", done) return sheet.read_rows(ws) -def record_scan(listed, pooled): - """Remember this run's counts (new NUworks postings, new pool jobs) for the morning summary.""" +def record_scan(listed, pooled, best=None): + """Remember this run's counts (new NUworks postings, new pool jobs) and its best new job ({"score", "text"}) for the + morning message.""" scans = jobs.load(SCANS, []) - scans.append({"time": datetime.now().isoformat(timespec="minutes"), "listed": listed, "pool": pooled}) + scans.append({"time": datetime.now().isoformat(timespec="minutes"), "listed": listed, "pool": pooled, + **({"best": best} if best else {})}) jobs.save(SCANS, scans[-60:]) -def scan_summary(scans, now, hours=24): - recent = [s for s in scans if now - datetime.fromisoformat(s["time"]) <= timedelta(hours=hours)] - listed, pooled = sum(s["listed"] for s in recent), sum(s["pool"] for s in recent) - return (f"Last {hours}h ({len(recent)} scan{'s' if len(recent) != 1 else ''}): {listed} new posting{'s' if listed != 1 else ''}" - f" on NUworks, {pooled} made your pool" + (" (nuauto approve)" if pooled else "")) - - -def todo_notice(rows, summary): - """Morning message, always sent: scan counts, then things only you can do: company-site applications - still owed, and external jobs (Needs Human).""" - from nuauto import sheet - from nuauto import web - site = [r for r in rows if r.status == "Applied" and r.notes.startswith(sheet.SITE_MARK)] if rows else [] - ext = [r for r in rows if r.status == "Needs Human" and r.notes.startswith("External application")] if rows else [] - if not site and not ext: - log("todo: nothing owed") - return notify("NUauto morning", summary + ("\nNothing only you need to finish." if rows is not None else "")) - lines = [summary, "Only you can finish:"] - def mark(label, action, r): # no Mark-done page configured (web_base_url "") = no link - return f" · [{label}]({web.link(action, r.number, jobs.job_id(r.url))})" if web.BASE_URL else "" - for r in site: - lines.append(f"• {r.company[:25]}: apply on their site too ([job]({r.url})){mark('Mark done', 'site', r)}") - for r in ext: - lines.append(f"• {r.company[:25]}: external application ([job]({r.url})){mark('Mark applied', 'applied', r)}") - log(f"todo: {len(site)} company-site, {len(ext)} external") - notify("NUauto morning", "\n".join(lines)) - - -def sheet_rows(): - """Sheet rows, or None if the sheet can't be read (e.g. the Google token expired).""" +def sheet_rows(tell=True): + """Sheet rows, or None if the sheet can't be read (e.g. the Google token expired). tell: a message about it (the + morning message says it in its own line).""" try: from nuauto import sheet return sheet.read_rows(sheet.open_worksheet()) except BaseException as e: # gspread's login flow can sys.exit log(f"could not read the sheet ({type(e).__name__})") - notify("NUauto: can't read the Google sheet", - "Google login probably expired (it lasts 7 days). On the laptop run: `nuauto login google`") + if tell: + alert("NUauto: can't read the Google sheet", + "Google login probably expired (it lasts 7 days). On the laptop run: `nuauto login google`") return None -def approved_notice(rows): - """Approved rows (not applied yet) that close today or tomorrow.""" - if rows is None: - return - details = jobs.load_details() - today = datetime.now().date() - due = [] - for r in rows: - d = details.get(jobs.job_id(r.url)) if r.status == "Approved" else None - day = jobs.closes(d) if d else None - if day is not None and 0 <= (day - today).days <= 1: - due.append((day, r)) - log(f"approved closing today/tomorrow: {len(due)}") - if due: - body = "\n".join(f"{jobs.closes_text(day)} {r.title[:40]} | {r.company[:22]}" for day, r in sorted(due, key=lambda x: x[0])) - notify(f"{len(due)} Approved job{'s' if len(due) > 1 else ''} close today or tomorrow", body + "\nRun: nuauto apply") - - -def urgent_notice(pool, rows, days=3): - """Pool jobs closing within `days` that are not in the sheet yet and not rated no.""" - in_sheet = {jobs.job_id(r.url) for r in rows} if rows is not None else set() - ratings = jobs.load("ratings.json", {}) - today = datetime.now().date() - soon = sorted((r for r in pool if r.get("closes") and r["id"] not in in_sheet - and ratings.get(r["id"], {}).get("label") != 0 - and 0 <= (datetime.fromisoformat(r["closes"]).date() - today).days <= days), - key=lambda r: r["closes"]) - log(f"closing within {days} days, not in sheet: {len(soon)}") - if soon: - body = "\n".join(f"{r['closes'][5:]} {r['match']}% {r['title'][:40]} | {r['company'][:22]}" for r in soon[:5]) - notify(f"{len(soon)} pool job{'s' if len(soon) > 1 else ''} close within {days} days", body + "\nRun: nuauto approve") - - def weekly(): - """Sunday summary: this week's applications, Approved rows waiting, jobs to look at.""" - from nuauto import sheet - rows = sheet_rows() - if rows is None: - return 1 - week, total = sheet.check_limits_safe(rows) - waiting = [r for r in rows if r.status == "Approved"] - details, ratings = jobs.load_details(), jobs.load("ratings.json", {}) - today = datetime.now().date() - soon = [r for r in waiting if (d := details.get(jobs.job_id(r.url))) and jobs.closes(d) - and jobs.closes(d) <= today + timedelta(days=7)] - in_sheet = {jobs.job_id(r.url) for r in rows} - unseen = [r for r in jobs.load("pool.json", []) if r["id"] not in in_sheet and r["id"] not in ratings] - room = max(0, sheet.max_per_week() - week) - lines = [f"Applied {sheet.week_window()[1]}: {week}/{sheet.max_per_week()} (total {total}/{sheet.MAX_TOTAL}).", - f"Approved and waiting: {len(waiting)}" + (f" ({len(soon)} close within 7 days)" if soon else "") + ".", - f"Pool jobs you haven't looked at: {len(unseen)}."] - if waiting: - lines.append(f"Run `nuauto apply` (room for {room} this week).") - elif room: - lines.append(f"Nothing Approved. Run `nuauto approve` to pick up to {room}.") - notify("NUauto weekly check-in", "\n".join(lines)) + """`nuauto daily weekly` (Sunday, nuauto-weekly.timer). Since 2026-10-26 it sends nothing: the morning message has + this week's count every day. Kept so the installed timer still runs cleanly.""" + log("weekly: nothing to send (the morning message has the week's count)") return 0 @@ -454,5 +548,5 @@ def weekly(): try: sys.exit(weekly() if sys.argv[1:] == ["weekly"] else main()) except Exception as e: # main() handles scan failures itself; this catches the rest - notify("NUauto: daily update failed", f"{type(e).__name__}: {str(e)[:150]}") + alert("NUauto: daily update failed", f"{type(e).__name__}: {str(e)[:150]}") raise diff --git a/src/nuauto/demo.py b/src/nuauto/demo.py index 833337a..50a339d 100644 --- a/src/nuauto/demo.py +++ b/src/nuauto/demo.py @@ -416,7 +416,8 @@ def intern_setup(today): skills="C, Python and Linux") posts[x["url"]] = text d = intern.details_of(x, {"how": "demo", "description": text, "location": "", "pay": "$38-$46 per hour", - "deadline": None, "closed": False}, today) + "deadline": (today + timedelta(days=20)).isoformat() if n == 5 else None, # (its own date) + "closed": False}, today) intern.save(f"details/{x['id']}.json", d) scores[x["id"]] = {"match": match, "year_req": year_req, "met": ["C", "Python"], "partial": ["testing"], "missing": ["industry tools", "a previous internship"], @@ -534,6 +535,19 @@ def setup(state_names=()): _write(config.LOCAL_CONFIG_PATH, {**cfg, "remote_assist": True, "assist_slots": 3, "web_listen_host": "127.0.0.1", "remote_auto": {"nuworks": False, "other": True}}, mode=0o644) _write(_path(f"demo_sheet_{SHEET_ID}.json"), {"title": "NUauto jobs (demo)", "rows": rows}) + # what the last NUworks check said about three of those (jobs.application_answer): the job chips on the Apply screen + checked = f"{today.isoformat()}T08:10" + week_ago = f"{(today - timedelta(days=7)).isoformat()}T08:10" + seen = {} + for r, (hiring, more) in zip(sheet.parse_rows(rows), [ + ("Interviewing", {"interviewing": True, "interviewing_since": week_ago}), + ("Available", {"interviewing_since": week_ago, "interviewing_ended": checked}), + ("Filled", {"interviewing_since": week_ago, "interviewing_ended": checked, "filled_since": checked})]): + seen[jobs.job_id(r.url)] = {"applied": True, "status": "applied", "expired": False, "interviewing": False, + "hiring": hiring, "interviewing_since": None, "interviewing_ended": None, + "filled_since": None, **more, "checked": checked, "row": r.number, + "fingerprint": sheet.fingerprint(r)} + _write(os.path.join(config.DATA_DIR, sheet.APPLICATIONS), seen) _write(_path(f"demo_sheet_{SHEET_ID}.other.json"), {"rows": [list(sheet.HEADERS)] + other}) diff --git a/src/nuauto/deploy.py b/src/nuauto/deploy.py index 25eece3..240ab44 100644 --- a/src/nuauto/deploy.py +++ b/src/nuauto/deploy.py @@ -87,9 +87,10 @@ def install_deps(self): if r.returncode: raise DeployError("reinstalling the package failed: " + r.stderr.strip()[-300:]) - def announce(self, text): + def announce(self, text, later=False): + """A failed deploy: a message now. later: a deploy that worked, a line in the next morning message instead.""" from nuauto import daily - daily.discord(text) + daily.later("deployed", [text]) if later else daily.discord(text) # ---- helpers def say(self, msg): @@ -290,7 +291,7 @@ def run(self): self.say(f"deployed {sha[:7]} ({subject[:60]}): {what}" if changed else f"deployed {sha[:7]}: nothing to copy") if changed: try: - self.announce(f"**Deployed** {sha[:7]}: {subject[:80]}\n{what}") + self.announce(f"{sha[:7]} {subject[:50]}", later=True) except Exception: pass return 0 diff --git a/src/nuauto/gui.py b/src/nuauto/gui.py index b80feba..4b3cade 100644 --- a/src/nuauto/gui.py +++ b/src/nuauto/gui.py @@ -552,7 +552,11 @@ def closing(r): """When the row's NUworks posting closes (from the saved job data): the date, its text, whether it is past or within a week. All empty/false when the job is unknown or lists no deadline.""" from nuauto import apply - day = apply.row_closes(r) + return closing_of(apply.row_closes(r)) + + +def closing_of(day): + """A close date for the page (closing(); an internship's in intern_facts): None = none listed.""" left = (day - date.today()).days if day else None return {"closes": day.isoformat() if day else None, "closes_text": jobs.closes_text(day), "past": left is not None and left < 0, "soon": left is not None and 0 <= left <= jobs.URGENT_DAYS} @@ -1559,7 +1563,8 @@ def homelab_task_view(r): def intern_facts(urls): - """For the Other jobs card: the match %, score and pay (from intern.py's data) of the internships among these links.""" + """For the Other jobs card: the match %, score, pay and close date (from intern.py's data; the posting's own date, + when it gives one) of the internships among these links.""" from nuauto import insights from nuauto import intern if not intern.ENABLED or not urls: @@ -1571,13 +1576,15 @@ def intern_facts(urls): s, p = scores.get(i), pool.get(i) if s is None or x["url"] not in urls: continue - pay = (intern.load(f"details/{i}.json", None) or {}).get("pay") or None + d = intern.load(f"details/{i}.json", None) or {} + pay = d.get("pay") or None hour = insights.hourly(pay) cat = p["category"] if p else cats.get(i) cat = cat if cat in jobs.CATEGORIES else None # (the Apply tab's kind-of-work filter) out[x["url"]] = {"match": s["match"], "score": p["rank"] if p else None, "score_text": jobs.rank_text(p) if p else None, "pay": pay, "pay_hour": hour[1] if hour else None, "posted_text": intern.posted_text(x), - "category": cat, "category_label": jobs.category_label(cat) if cat else None} + "category": cat, "category_label": jobs.category_label(cat) if cat else None, + **closing_of(jobs.closes(d) if d else None)} return out @@ -1589,13 +1596,13 @@ def other_list(): rows = other_rows() if not APP.other_exists: return {"exists": False, "tab": sheet.OTHER_TAB, "ready": [], "applied": [], "rest": [], **assistants()} - facts = intern_facts({intern.clean_url(r.url) for r in rows if r.status == "Approved"}) + facts = intern_facts({intern.real_url(r.url) for r in rows if r.status == "Approved"}) ready, applied, rest = [], [], [] for r in rows: if r.status == "Approved": url, why = assist.other_target(r) ready.append(row_view(r, target=url, host=urlparse(url).hostname if url else None, why=why, - **facts.get(intern.clean_url(r.url), {}))) + **facts.get(intern.real_url(r.url), {}))) elif r.status == "Applied": applied.append(row_view(r)) else: @@ -1656,21 +1663,24 @@ def jobs_tab(tab="nuworks", order="default", recheck=False): with APP.lock: # (the rows and the version they were read at, together) rows, version = APP.rows, APP.rows_version by_number = {r.number: r for r in rows} - facts = intern_facts({intern.clean_url(r.url) for r in rows}) if other else None + facts = intern_facts({intern.real_url(r.url) for r in rows}) if other else None def locks(v): # what Move and Remove allow on this row r = by_number[v["row"]] return {**v, "locked": manage.movable(r, tab, "move"), "remove_locked": manage.movable(r, tab, "remove")} seen = {} if other else sheet.nuworks_seen() + done = {r.number: why for r, why in sheet.maybe_filled(rows, seen)} # (Applied rows whose job looks filled) def on_nuworks(r): # what NUworks said about a sent row at the last check (current: about the row as it is now) a = seen.get(jobs.job_id(r.url)) if r.status in sheet.SENT else None - return {"nuworks": {**{k: a.get(k) for k in ("applied", "status", "expired", "interviewing", "checked")}, - "current": a.get("fingerprint") == sheet.fingerprint(r)}} if a else {} + keys = ("applied", "status", "expired", "interviewing", "hiring", "interviewing_since", "interviewing_ended", + "filled_since", "checked") + return {"nuworks": {**{k: a.get(k) for k in keys}, "current": a.get("fingerprint") == sheet.fingerprint(r), + "done": done.get(r.number)}} if a else {} def view(r, **extra): - more = facts.get(intern.clean_url(r.url), {}) if other else {**closing(r), **job_facts(r, data), **on_nuworks(r)} + more = facts.get(intern.real_url(r.url), {}) if other else {**closing(r), **job_facts(r, data), **on_nuworks(r)} return locks(row_view(r, **more, **extra)) s = {k: [] for k in ("approved", "agent", "manual", "owed", "ready", "applied", "accepted", "rejected", "proposed", "closed", "rest")} diff --git a/src/nuauto/gui_static/app.js b/src/nuauto/gui_static/app.js index b743d78..2805194 100644 --- a/src/nuauto/gui_static/app.js +++ b/src/nuauto/gui_static/app.js @@ -595,7 +595,7 @@ screens.home = (view) => { return { update: draw, health: () => draw(S.state) }; }; -// When the NUworks posting closes: a chip (red once past, amber within a week), or "no deadline listed". +// When the posting closes (NUworks', or an internship's own): a chip (red once past, amber within a week), or "no deadline listed". function dueChip(t) { if (!t.closes) return el("span", { class: "chip", text: "no deadline listed", testid: "due" }); return el("span", { class: "chip" + (t.past ? " fail" : t.soon ? " warn" : ""), testid: "due", @@ -1257,8 +1257,9 @@ screens.apply = async (view) => { next = el("button", { class: "btn small primary", text: "Submit on NUworks too", testid: `retry-btn-${r.row}`, onclick: () => confirmBox("Submit on NUworks too?", `${r.company} · ${r.title} is Applied on the company site. This submits the same job on NUworks.`, "Submit on NUworks", () => action("nuworks_side", runArgs({ row: r.row }))) }); - } else if (key === "ready") { // (when it was posted where NUworks rows say when they close) - chips = [r.posted_text ? el("span", { class: "chip", testid: "posted", text: r.posted_text }) : null]; + } else if (key === "ready") { // an internship: when it closes (its posting's date), else when it was posted + chips = [r.closes ? dueChip(r) : r.posted_text ? el("span", { class: "chip", testid: "posted", text: `no close date · ${r.posted_text}`, + title: "Neither the list nor the company's posting gives a closing date." }) : null]; about = r.host || r.why; next = assistantStart(d, r, "other", `other-assist-${r.row}`, { disabled: !r.target, body: { row: r.row, tab: "other" } }); open = r.target ? linkOut(r.target, "Open") : null; @@ -1267,7 +1268,15 @@ screens.apply = async (view) => { const nu = r.nuworks; // what NUworks said at the last check (the twice-daily update) if (nu && nu.current && nu.applied === false) chips.push(el("span", { class: "chip warn", testid: `not-on-nuworks-${r.row}`, text: "not on NUworks", title: `NUworks has no application for this job (checked ${nu.checked}): it doesn't count toward your total.` })); - if (nu && nu.applied && nu.interviewing) chips.push(el("span", { class: "chip accent", text: "interviewing (NUworks)" })); + // the job's own hiring stage (sheet.maybe_filled, jobs.application_answer), never yours: NUworks doesn't show that + const day = (t) => (t || "").slice(0, 10); + if (nu && nu.done === "filled") chips.push(el("span", { class: "chip warn", testid: `job-filled-${r.row}`, text: "job filled", + title: `NUworks' Hiring Status for this job is Filled (since ${day(nu.filled_since)}). If you haven't heard back, you can withdraw on NUworks: until you do it counts toward your total.` })); + else if (nu && nu.done) chips.push(el("span", { class: "chip warn", testid: `job-stopped-${r.row}`, text: "job stopped interviewing", + title: `The employer was interviewing for this job (from ${day(nu.interviewing_since)}) and stopped (${day(nu.interviewing_ended)}): it may be filled. If you haven't heard back, you can withdraw on NUworks: until you do it counts toward your total.` })); + else if (nu && nu.applied && r.status === "Applied" && (nu.interviewing || nu.hiring === "Interviewing")) chips.push(el("span", { + class: "chip", testid: `job-interviewing-${r.row}`, text: "job interviewing", + title: `The employer is interviewing for this job (NUworks, checked ${day(nu.checked)}). That is about the job, not your application: NUworks doesn't show students whom an employer picked.` })); } const facts = r.match != null || r.score != null || r.pay ? jobFacts(r) : null; return el("li", { class: "job-row", testid: `job-${tab}-${r.row}` }, diff --git a/src/nuauto/intern.py b/src/nuauto/intern.py index 9d8c905..530f3bc 100644 --- a/src/nuauto/intern.py +++ b/src/nuauto/intern.py @@ -28,7 +28,7 @@ import time import urllib.error import urllib.request -from datetime import date, datetime, timedelta +from datetime import date, datetime from urllib.parse import parse_qsl, urlencode, urljoin, urlparse, urlunparse from nuauto import config @@ -117,12 +117,6 @@ def list_name(i): return NAMES[list_of(i)] -def lists_text(): - """The lists read, for messages: "Simplify's list", or "Simplify's and vanshb03's lists".""" - on = [NAMES[k].removesuffix(" list") for k, _, _ in LISTS if LIST_URLS.get(k)] - return f"{on[0]} list" if len(on) == 1 else ", ".join(on[:-1]) + f" and {on[-1]} lists" if on else "no list" - - def clean_url(url): """The posting link without tracking parameters (utm_*, ref=Simplify); everything else stays (gh_jid...).""" u = urlparse(str(url or "").strip()) @@ -637,6 +631,42 @@ def fetch(log=print, limit=None, browser_limit=None): return ok, bad +# Job sites whose data has a close date NUauto reads only since 2026-10-26 (Oracle's ExternalPostedEndDate): their +# postings read before that are read once more, for the date alone (redate) +REDATE = {"oracle"} + + +def redate(log=print, limit=40): + """Saved postings from a REDATE job site with no close date: read again once (plain requests only), keeping only the + date they give (the text was scored as it was). Returns how many got one.""" + from nuauto import postings + listings = load("listings.json", {}) + todo = [d for i, d in load_details().items() + if d.get("how") in REDATE and not d.get("deadline") and not d.get("redated") and i in listings][:limit] + if not todo: + return 0 + client, dated = postings.Client(), 0 + for d in todo: + try: + day = postings.read_plain(d["url"], client).get("deadline") + except Exception: # (unreadable now, or the network): no date, and it is not tried again + day = None + save(f"details/{d['id']}.json", {**d, "deadline": day, "redated": date.today().isoformat()}) + dated += bool(day) + log(f"postings read again for their close date: {len(todo)}, {dated} give one") + return dated + + +def closing_dates(): + """{posting link (clean_url): the day it closes} for the internships whose posting gives one (jobs.closes).""" + listings, out = load("listings.json", {}), {} + for i, d in load_details().items(): + day = jobs.closes(d) + if day and i in listings: + out[clean_url(listings[i]["url"])] = day + return out + + # ---------------------------------------------------------------- 4. score, 4b. category def score_export(limit=None): @@ -929,8 +959,9 @@ def run_lock(): def update(): - """The whole update: list -> triage -> postings -> score -> category -> pool, then a notification for new pool - jobs. Returns 0, or 1 if a step stopped (already notified).""" + """The whole update: list -> triage -> postings -> score -> category -> pool; the counts (and the best new one) go to + scans.json for the morning message (no message of its own since 2026-10-26). Returns 0, or 1 if a step stopped + (already told: daily.alert).""" from nuauto import daily lock = run_lock() if lock is None: @@ -956,6 +987,7 @@ def ready(): "NUauto: internship triage incomplete", fields): return 1 fetch(daily.log) + redate(daily.log) if not daily.claude_step(ready, "INTERN_SCORE_PROMPT.md", score_export(), score_import, "NUauto: internship scoring incomplete", fields): return 1 @@ -963,20 +995,18 @@ def ready(): pool = save_pool() new = [r for r in pool if r["id"] not in before] daily.log(f"internship pool {len(pool)} ({len(new)} new)") + best = max(new, key=lambda r: r["match"], default=None) scans = load("scans.json", []) scans.append({"time": datetime.now().isoformat(timespec="minutes"), "listed": len(changed[0]) if changed else 0, - "pool": len(new)}) + "pool": len(new), **({"best": {"score": best["match"], "text": f"{best['title'][:40]} | " + f"{best['company'][:22]} ({best['match']}% match)"}} if best else {})}) save("scans.json", scans[-200:]) - if new: - top = "\n".join(f"{r['match']}% {r['title'][:45]} | {r['company'][:25]}" for r in new[:5]) - daily.notify(f"{len(new)} new internship{'s' if len(new) > 1 else ''} in your pool", - top + "\nReview: NUauto > Review > Summer internships (or nuauto intern approve)") return 0 def closed_notice(closed, daily): - """Internships you approved that their list now shows as closed: a message, so no assistant run is spent on - one. The sheet is only read.""" + """Internships you approved that their list now shows as closed: a line in the morning message (daily.later), so no + assistant run is spent on one. The sheet is only read.""" listings = load("listings.json", {}) urls = {listings[i]["url"]: listings[i] for i in closed if i in listings} try: @@ -985,19 +1015,8 @@ def closed_notice(closed, daily): daily.log(f"closed internships: could not read the sheet ({type(e).__name__})") return if rows: - daily.notify(f"{len(rows)} approved internship{'s' if len(rows) > 1 else ''} closed on " - + (list_name(urls[real_url(rows[0].url)]["id"]) if len(rows) == 1 else "their lists"), - "\n".join(f"Other jobs row {r.number}: {r.title[:40]} | {r.company[:22]}" for r in rows) - + "\nCheck the posting before starting the assistant (or remove the row on the Sheet screen).") - - -def summary(now=None, hours=24): - """For the morning message: what the last day's internship updates found.""" - now = now or datetime.now() - recent = [s for s in load("scans.json", []) if now - datetime.fromisoformat(s["time"]) <= timedelta(hours=hours)] - listed, pooled = sum(s["listed"] for s in recent), sum(s["pool"] for s in recent) - return (f"Internships ({TERM}): {listed} new on {lists_text()}, {pooled} made your pool" - + (" (Review > Summer internships)" if pooled else "")) + daily.log(f"closed internships: Other jobs rows {', '.join(str(r.number) for r in rows)}") + daily.later("closed", [f"{r.company[:22]} (row {r.number})" for r in rows]) def stats(): diff --git a/src/nuauto/jobs.py b/src/nuauto/jobs.py index 9ad135c..a88b754 100644 --- a/src/nuauto/jobs.py +++ b/src/nuauto/jobs.py @@ -674,9 +674,35 @@ def check_applied(ids): return found +def application_answer(raw, prev, now): + """What NUworks' job record (/api/v3/jobs/) says about a sent row: applied, application_status, expired, and the + job's own hiring stage, tracked across checks (prev: the last answer). All of it is about the job, not you: NUworks + never shows a student whom an employer picked (checked 2026-10-26: your application record has no status, and + My Interviews lists only interviews booked through NUworks). + "hiring": its Hiring Status (internal_status: Available, Interviewing, Filled, Expired...); "interviewing": its + is_interviewing flag (hidden on NUworks' pages; set on about 1 job in 5, also jobs nobody here applied to, nearly + always on those whose Hiring Status is Interviewing). "interviewing_since" / "interviewing_ended": when the job was + first seen interviewing (either of the two) and when it stopped (it may be filled); "filled_since": when its + Hiring Status was first seen Filled.""" + prev = prev or {} + stage = raw.get("internal_status") + hiring = (stage.get("_label") if isinstance(stage, dict) else stage) or None + flag = str(raw.get("is_interviewing")) == "1" + # (answers saved before 2026-10-26 have only the flag: one that had it was interviewing at its check) + was = prev.get("interviewing_since") or (prev.get("checked") if prev.get("interviewing") else None) + if flag or hiring == "Interviewing": + since, ended = (now if not was or prev.get("interviewing_ended") else was), None + else: + since, ended = was, (prev.get("interviewing_ended") or now) if was else None + return {"applied": bool(raw.get("applied")), "status": raw.get("application_status") or None, + "expired": bool(raw.get("expired")), "interviewing": flag, "hiring": hiring, + "interviewing_since": since, "interviewing_ended": ended, + "filled_since": (prev.get("filled_since") or now) if hiring == "Filled" else None, "checked": now} + + def check_applications(rows): - """Ask NUworks about every sent row of the NUworks tab (Applied, Accepted, Rejected; its job's own data: applied, - application_status, expired, is_interviewing) and keep its answers in data/applications.json, each with the row's + """Ask NUworks about every sent row of the NUworks tab (Applied, Accepted, Rejected; its job's own data, see + application_answer) and keep its answers in data/applications.json, each with the row's fingerprint (sheet.not_on_nuworks: an answer counts only for the row as it was then). So the total limit counts what NUworks has: a company-site application whose NUworks side never went out doesn't. Returns {job id: answer}. Read-only on NUworks; rows it can't read keep their last answer.""" @@ -700,9 +726,8 @@ def check_applications(rows): except Exception: raw = None if raw and raw.get("job_id") == i: - seen[i] = {"applied": bool(raw.get("applied")), "status": raw.get("application_status") or None, - "expired": bool(raw.get("expired")), "interviewing": str(raw.get("is_interviewing")) == "1", - "checked": now, "row": r.number, "fingerprint": sheet.fingerprint(r)} + seen[i] = {**application_answer(raw, seen.get(i), now), "row": r.number, + "fingerprint": sheet.fingerprint(r)} browser.pause(page, 1.5, 3) log.write(f"asked NUworks about {len(sent)} sent rows: " f"{sum(1 for r in sent if (seen.get(job_id(r.url)) or {}).get('applied'))} it has") diff --git a/src/nuauto/postings.py b/src/nuauto/postings.py index 7c80da1..19ad00b 100644 --- a/src/nuauto/postings.py +++ b/src/nuauto/postings.py @@ -19,6 +19,7 @@ import time import urllib.error import urllib.request +from datetime import date from urllib.parse import parse_qs, urlparse UA = "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" @@ -123,11 +124,17 @@ def _ld_place(o): return "; ".join(x for x in out if x) +def end_day(v): + """The close date in a job site's data (YYYY-MM-DD: its first 10 characters), or None. A date years away is a + placeholder for "no end" (Oracle's 4712-12-31), not a deadline.""" + s = str(v or "")[:10] + return s if re.fullmatch(r"\d{4}-\d\d-\d\d", s) and int(s[:4]) <= date.today().year + 2 else None + + def from_jsonld(o): desc = text_of(o.get("description")) - end = str(o.get("validThrough") or "")[:10] return {"title": str(o.get("title") or ""), "description": desc, "location": _ld_place(o), - "pay": _ld_pay(o) or pay_from_text(desc), "deadline": end if re.fullmatch(r"\d{4}-\d\d-\d\d", end) else None} + "pay": _ld_pay(o) or pay_from_text(desc), "deadline": end_day(o.get("validThrough"))} # ---------------------------------------------------------------- the job sites' own data (pure: offline-tested) @@ -213,14 +220,13 @@ def parse_api(k, data, url): info = data.get("jobPostingInfo") or {} desc = text_of(info.get("jobDescription")) place = "; ".join([info.get("location") or ""] + list(info.get("additionalLocations") or [])) - end = str(info.get("endDate") or "")[:10] return {"title": info.get("title") or "", "description": desc, "location": place.strip("; "), - "pay": pay_from_text(desc), "deadline": end if re.fullmatch(r"\d{4}-\d\d-\d\d", end) else None, + "pay": pay_from_text(desc), "deadline": end_day(info.get("endDate")), "closed": info.get("canApply") is False} if k == "greenhouse": desc = text_of(data.get("content")) return {"title": data.get("title") or "", "description": desc, "location": (data.get("location") or {}).get("name") or "", - "pay": pay_from_text(desc), "deadline": None} + "pay": pay_from_text(desc), "deadline": end_day(data.get("application_deadline"))} if k == "lever": parts = [data.get("description") or data.get("descriptionPlain") or ""] for block in data.get("lists") or []: @@ -267,7 +273,7 @@ def parse_api(k, data, url): parts += [f"

{title}

" if title else "", it[key]] desc = text_of("\n".join(parts)) return {"title": it.get("Title") or "", "description": desc, "location": it.get("PrimaryLocation") or "", - "pay": pay_from_text(desc), "deadline": None} + "pay": pay_from_text(desc), "deadline": end_day(it.get("ExternalPostedEndDate"))} if k == "eightfold": desc = text_of(data.get("job_description")) return {"title": data.get("name") or "", "description": desc, "location": data.get("location") or "", diff --git a/src/nuauto/sheet.py b/src/nuauto/sheet.py index 9c6581c..a771496 100644 --- a/src/nuauto/sheet.py +++ b/src/nuauto/sheet.py @@ -113,8 +113,9 @@ def fingerprint(row): def nuworks_seen(): - """{job id: {"applied", "status", "expired", "interviewing", "checked", "row", "fingerprint"}}: NUworks' answers from - the last check (the twice-daily update); {} when there is none (or it can't be read).""" + """{job id: {"applied", "status", "expired", "interviewing", "hiring", "interviewing_since", "interviewing_ended", + "filled_since", "checked", "row", "fingerprint"}} (jobs.application_answer): NUworks' answers from the last check + (the twice-daily update); {} when there is none (or it can't be read).""" try: with open(os.path.join(config.DATA_DIR, APPLICATIONS)) as f: data = json.load(f) @@ -150,6 +151,24 @@ def rejected_not_withdrawn(rows, seen=None): return out +def maybe_filled(rows, seen=None): + """[(row, "filled" | "stopped interviewing")]: the Applied rows of the NUworks tab whose job looks done hiring + at the last check (jobs.application_answer: its Hiring Status is Filled, or it was interviewing and isn't any + more) while NUworks still has your application (about the row as it is now). If you haven't heard back, + withdrawing there frees its place in the total. Never asked = not listed.""" + from nuauto import jobs + seen = nuworks_seen() if seen is None else seen + out = [] + for r in rows: + s = seen.get(jobs.job_id(r.url)) if r.status == "Applied" and r.url else None + if s and s.get("applied") and s.get("fingerprint") == fingerprint(r): + if s.get("filled_since"): + out.append((r, "filled")) + elif s.get("interviewing_ended"): + out.append((r, "stopped interviewing")) + return out + + def total_count(rows, seen=None): """What counts toward the total limit: the sent rows NUworks has (or hasn't been asked about yet). The NUworks tab's rows only (the Other jobs tab has no limits). Until 2026-10-16 every Applied row counted, also those never sent diff --git a/tests/test_daily.py b/tests/test_daily.py index b274182..3e9df42 100644 --- a/tests/test_daily.py +++ b/tests/test_daily.py @@ -2,9 +2,11 @@ Uses a temp data dir, temp log file and a stubbed notify: no sheet, no Discord, no network. """ +import json +import re import os import tempfile -from datetime import datetime +from datetime import date, datetime from nuauto import config @@ -39,6 +41,23 @@ log_text = open(daily.LOG_PATH).read() assert "applications: 1 count toward the total; NUworks has none for row 2" in log_text, log_text[-300:] assert "rejected, not withdrawn on NUworks: row 3" in log_text, log_text[-300:] # NUworks still has the Rejected row's application +# an Applied row whose job stopped interviewing / got filled: in the log every run, no message (the morning one has it) +R_APPS.append(sheet.Row(4, "https://n/jobs/detail/c", "Filled Co", "Firmware", "Applied", "", "2026-10-03")) +saved = os.path.join(config.DATA_DIR, sheet.APPLICATIONS) +answers = {"b": {"applied": True, "fingerprint": sheet.fingerprint(R_APPS[1])}, + "c": {"applied": True, "fingerprint": sheet.fingerprint(R_APPS[2]), "interviewing_ended": "t2"}} +daily.jobs.check_applications = lambda rows: answers +sent.clear() +daily.applications() +assert "job looks filled: row 4 (stopped interviewing)" in open(daily.LOG_PATH).read() and sent == [], sent +os.makedirs(config.DATA_DIR, exist_ok=True) +with open(saved, "w") as f: # (what that check saved: the morning message reads it) + json.dump(answers, f) +line = daily.withdraw_line(R_APPS) +assert line == "Withdraw on NUworks to free room: rejected Co · may be filled Filled Co", line +os.remove(saved) +assert daily.withdraw_line(R_APPS) is None and daily.withdraw_line(None) is None +R_APPS.pop() def no_nuworks(rows): raise SystemExit(daily.jobs.NOT_LOGGED_IN) daily.jobs.check_applications = no_nuworks @@ -52,34 +71,78 @@ def no_sheet(interactive=True): sheet.open_worksheet, sheet.read_rows, daily.jobs.check_applications, setup_sheet.ensure_dropdown = real_sheet_fns daily.applications = lambda: None # (main() below: never the real sheet or NUworks) -# record_scan keeps per-run counts; scan_summary adds up the last 24 hours only -daily.record_scan(12, 2) +# record_scan keeps per-run counts and the best new job; scan_part adds up the last 24 hours only +daily.record_scan(12, 2, {"score": 109, "text": "Firmware Co-op | Harbor (score 109)"}) scans = daily.jobs.load(daily.SCANS, []) -assert len(scans) == 1 and scans[0]["listed"] == 12 and scans[0]["pool"] == 2, scans -now = datetime(2026, 10, 5, 8, 5) -scans = [{"time": "2026-10-04T07:00", "listed": 99, "pool": 9}, # older than 24h: ignored - {"time": "2026-10-04T18:04", "listed": 5, "pool": 1}, - {"time": "2026-10-05T08:03", "listed": 3, "pool": 0}] -s = daily.scan_summary(scans, now) -assert s == "Last 24h (2 scans): 8 new postings on NUworks, 1 made your pool (nuauto approve)", s -s = daily.scan_summary([], now) -assert s == "Last 24h (0 scans): 0 new postings on NUworks, 0 made your pool", s +assert len(scans) == 1 and scans[0]["listed"] == 12 and scans[0]["pool"] == 2 and scans[0]["best"]["score"] == 109, scans for _ in range(70): daily.record_scan(1, 0) -assert len(daily.jobs.load(daily.SCANS, [])) == 60 # capped - -# morning message is always sent, with the summary first -row = lambda n, status, notes: sheet.Row(n, f"https://x/jobs/{n}", f"Co{n}", "Title", status, notes, "") -daily.todo_notice([row(2, "Applied", "")], "SUMMARY") -assert sent[-1] == ("NUauto morning", "SUMMARY\nNothing only you need to finish."), sent[-1] -daily.todo_notice(None, "SUMMARY") # sheet unreadable: still the summary, no claim about the to-do list -assert sent[-1] == ("NUauto morning", "SUMMARY"), sent[-1] -web.BASE_URL = "" # no Mark-done page: no links -daily.todo_notice([row(3, "Applied", sheet.SITE_MARK + " ..."), row(4, "Needs Human", "External application ...")], "SUMMARY") -title, body = sent[-1] -lines = body.split("\n") -assert title == "NUauto morning" and lines[:2] == ["SUMMARY", "Only you can finish:"], sent[-1] -assert "Co3" in lines[2] and "Co4" in lines[3] and "Mark" not in body, body +assert len(daily.jobs.load(daily.SCANS, [])) == 60 and "best" not in daily.jobs.load(daily.SCANS, [])[-1] # capped +now = datetime(2026, 10, 5, 8, 5) +scans = [{"time": "2026-10-04T07:00", "listed": 99, "pool": 9, "best": {"score": 200, "text": "old"}}, # older than 24h + {"time": "2026-10-04T18:04", "listed": 5, "pool": 1, "best": {"score": 90, "text": "A | X (score 90)"}}, + {"time": "2026-10-05T08:03", "listed": 3, "pool": 2, "best": {"score": 95, "text": "B | Y (score 95)"}}] +assert daily.scan_part(scans, now, "co-op") == "3 co-ops (best: B | Y (score 95))" +assert daily.scan_part([], now, "internship") == "0 internships" +daily.jobs.save(daily.SCANS, scans) + +# a problem: a message at once, then the same one at most once a day however often the runs fail +sent.clear() +daily.alert("NUauto: X broke", "why") +daily.alert("NUauto: X broke", "why") +assert sent == [("NUauto: X broke", "why")], sent +told = daily.jobs.load(daily.TOLD, {}) +daily.jobs.save(daily.TOLD, {**told, "NUauto: X broke": (datetime.now() - daily.TELL_AGAIN).isoformat(timespec="minutes")}) +daily.alert("NUauto: X broke", "why") +assert len(sent) == 2, sent # the next day: told again + +# the morning message: always sent, one short line for each thing that applies +row = lambda n, status, notes, day="2026-10-05": sheet.Row(n, f"https://x/jobs/{n}", f"Co{n}", "Title", status, notes, day) +web.BASE_URL = "" # no Mark-done page: no Mark links +config.REMOTE_APP = False # (your own settings may have the homelab's page: its link would end the message) +sheet.google_login_age = lambda: 1 +lines = daily.morning([row(2, "Applied", "")], [], now).split("\n") +assert lines[0] == "New: 3 co-ops (best: B | Y (score 95))" and len(lines) == 2, lines +assert re.fullmatch(r"Week: \d+/\d+ applied · total 1/99 · 0 Approved waiting", lines[1]), lines # (this week: by today) +lines = daily.morning([row(3, "Applied", sheet.SITE_MARK + " ..."), row(4, "Needs Human", "External application ...", "")], + [], now).split("\n") +assert lines[1] == "To do: 1 company site owed, 1 external application: [Co3](https://x/jobs/3) · [Co4](https://x/jobs/4)", lines +daily.later("recorded", ["Acme"]) +daily.later("recorded", ["Acme", "Beta"]) # (kept once each) +daily.later("deployed", ["abc1234 Shorter messages"]) +sheet.google_login_age = lambda: 7 +lines = daily.morning(None, [], now).split("\n") # the sheet can't be read: no to-do or week, said why +assert lines[1:] == ["Recorded what you applied to on NUworks: Acme, Beta", "Updated: abc1234 Shorter messages", + "Wrong: can't read the sheet (the Google login? on the laptop run `nuauto login google`)"], lines +lines = daily.morning([], [], now).split("\n") +assert lines[-1] == "Wrong: the Google login has expired: on the laptop run `nuauto login google`", lines +sheet.google_login_age = lambda: 1 +config.REMOTE_APP = True +from nuauto import review # noqa: E402 +real_page, review.page_url = review.page_url, lambda slot=None: "http://page/" +assert daily.morning([], [], now).split("\n")[-1] == "http://page/" # the homelab's page ends it +review.page_url, config.REMOTE_APP = real_page, False + +# Closing soon: Approved rows on both tabs that close within a week (soonest first, with the day; an internship's date +# is its posting's), and pool jobs not in the sheet that close within 3 days +os.makedirs(os.path.join(config.DATA_DIR, "details"), exist_ok=True) +for i, end in (("n1", "2026-10-05"), ("n2", "2026-10-09"), ("n3", "2026-10-20")): + daily.jobs.save(f"details/{i}.json", {"id": i, "deadline": end, "posting_end": None}) +nrow = lambda n, i, status: sheet.Row(n, f"https://x/jobs/{i}", f"Co {i}", "T", status, "", "2026-10-01") # noqa: E731 +real_dates = intern.closing_dates +intern.closing_dates = lambda: {"https://acme.example/j/1": date(2026, 10, 6)} +intern.save("links.json", {"https://go.vansh.nyc/AB": "https://acme.example/j/1"}) # (a row added with vanshb03's short link) +other = [sheet.Row(2, "https://go.vansh.nyc/AB", "Acme", "Intern", "Approved", "", ""), + sheet.Row(3, "https://beta.example/j/2", "Beta", "Intern", "Approved", "", "")] +pool = [{"id": "p1", "closes": "2026-10-07"}, {"id": "n1", "closes": "2026-10-05"}, {"id": "p2", "closes": "2026-10-20"}] +line = daily.closing_line([nrow(5, "n1", "Approved"), nrow(6, "n2", "Approved"), nrow(7, "n3", "Approved"), + nrow(8, "n2", "Applied")], pool, date(2026, 10, 5), other) +assert line == f"Closing soon: Co n1 today, Acme tomorrow, Co n2 {date(2026, 10, 9):%a} · 1 pool job within 3 days (Review)", line +assert daily.closing_line([], [], date(2026, 10, 5)) is None +intern.closing_dates = real_dates +os.remove(intern.path("links.json")) +for i in ("n1", "n2", "n3"): + os.remove(os.path.join(config.DATA_DIR, "details", f"{i}.json")) # a batch's Claude is shut in work/ (the batches hold web text: job postings, Simplify's list): it runs there, may read # and edit only there (Edit rules cover Write; never a bare Write, which would allow every path), no shell, web or MCP @@ -124,7 +187,8 @@ def missing(): # no batches: nothing to do, no login check assert daily.claude_step(lambda: 1 / 0, "TRIAGE_PROMPT.md", [], no_import, "T") is True -# a failed scan (e.g. NUworks too slow) still sends the morning reminders, from the sheet + last saved pool +# a failed scan (e.g. NUworks too slow) still sends the morning message, from the sheet + last saved pool; the +# problem is in it (no message of its own), and one broken line never stops the others class Morning(datetime): @classmethod def now(cls, tz=None): @@ -136,24 +200,23 @@ def never(*a): raise AssertionError("hand-application check needs a fresh list; must not run after a failed scan") real_scan = daily.scan daily.scan, daily.record_hand_applications = slow, never -daily.sheet_rows = lambda: [row(4, "Needs Human", "External application ...")] -daily.google_notice = lambda: 1 / 0 # one broken reminder must not stop the others +daily.sheet_rows = lambda tell=True: [row(4, "Needs Human", "External application ...", "")] +daily.closing_line = lambda *a: 1 / 0 sent.clear() -assert daily.main() == 1 -titles = [t for t, _ in sent] -assert titles[0] == "NUauto: daily update failed" and "TimeoutError" in sent[0][1], sent -assert "NUauto morning" in titles, sent -body = dict(sent)["NUauto morning"] -assert "(this morning's scan failed)" in body and "Co4" in body, body +assert daily.main() == 1 and not daily.QUIET +assert [t for t, _ in sent] == ["NUauto morning"], sent +body = sent[0][1] +assert "1 external application: [Co4]" in body and "Wrong: the update stopped: NUworks did not load in time (NUworks was slow" in body, body +assert daily.jobs.load(daily.LATER, {}) == {} # what it kept went out with it -# afternoon run: no reminders +# the evening run: no morning message, and the same problem was told today already: nothing class Evening(Morning): @classmethod def now(cls, tz=None): return datetime(2026, 10, 5, 18, 6) daily.datetime = Evening sent.clear() -assert daily.main() == 1 and [t for t, _ in sent] == ["NUauto: daily update failed"], sent +assert daily.main() == 1 and sent == [], sent # why a run stopped is kept for the GUI (last_run.json), and a slow NUworks gets a second try daily.scan = real_scan diff --git a/tests/test_deploy.py b/tests/test_deploy.py index a102bfa..3add29f 100644 --- a/tests/test_deploy.py +++ b/tests/test_deploy.py @@ -67,8 +67,8 @@ def update_running(self): def install_deps(self): self.installs += 1 - def announce(self, text): - self.said.append(text) + def announce(self, text, later=False): + self.said.append(("later: " if later else "") + text) deploy.time.sleep = lambda s: None # the restart check waits a few seconds @@ -95,12 +95,12 @@ def announce(self, text): n = len(d.calls) assert d.run() == 0 and len(d.calls) == n and not d.said, "the same commit again must do nothing" -# a docs-only commit: copied, no web restart, no reinstall, one Discord message, tests/ never copied +# a docs-only commit: copied, no web restart, no reinstall, a line for the morning message, tests/ never copied commit({"docs/DEPLOY.md": "v2\n", "tests/test_x.py": "changed\n"}, "docs only") assert d.run() == 0 assert read_live("docs/DEPLOY.md") == "v2\n" and not os.path.exists(os.path.join(LIVE, "tests")) assert ("restart", "nuauto-web.service") not in d.calls and d.installs == 0 -assert len(d.said) == 1 and "docs only" in d.said[0], d.said +assert len(d.said) == 1 and d.said[0].startswith("later: ") and "docs only" in d.said[0], d.said assert d.load_state()["sha"] != first_sha # web code changed + a new module: both copied, web restarted diff --git a/tests/test_gui.py b/tests/test_gui.py index 4451314..a25a331 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -562,6 +562,10 @@ def eventually(check, what, seconds=20): approved_one = get("/api/jobs?tab=nuworks")["sections"]["approved"][0] post("/api/mark", {"action": "outcome", "outcome": "Accepted", "row": approved_one["row"], "url": approved_one["url"]}, expect=409) post("/api/mark", {"action": "outcome", "outcome": "Failed", "row": sent["row"], "url": sent["url"]}, expect=409) + # the job's hiring stage at the last check (demo.setup's answers; sheet.maybe_filled): the chips on Applied rows + stage = {r["row"]: (r["nuworks"]["hiring"], r["nuworks"]["done"]) + for r in get("/api/jobs?tab=nuworks")["sections"]["applied"] if r.get("nuworks")} + assert stage == {2: ("Interviewing", None), 3: ("Available", "stopped interviewing"), 4: ("Filled", "filled")}, stage # NUworks' answer about a sent row (the twice-daily update: data/applications.json): "not on NUworks" when it has none with open(os.path.join(STATE, "data", "applications.json"), "w") as f: json.dump({sent["url"].rstrip("/").split("/")[-1]: {"applied": False, "status": None, "expired": False, @@ -575,6 +579,9 @@ def eventually(check, what, seconds=20): done = post("/api/decide", {"id": beacon["id"], "decision": "approve", "mode": "approve"}) assert done["tab"] == "other" and done["row"], done assert beacon["id"] not in [j["id"] for j in get("/api/review?mode=approve&source=intern")["jobs"]] # the list moved on + # its row on Apply says when it closes (its posting's own date: demo.intern_setup), the others when they were posted + ready = {x["row"]: x for x in get("/api/jobs?tab=other&recheck=1")["sections"]["ready"]} + assert ready[done["row"]]["closes"] and not ready[done["row"]]["soon"] and "(in 20 days)" in ready[done["row"]]["closes_text"], ready post("/api/undo", {"id": beacon["id"], "row": done["row"], "previous": done["previous"], "tab": "other"}) assert json.load(open(other_file))["rows"][done["row"] - 1][3] == "Proposed" post("/api/undo", {"id": "s" + "0" * 32, "row": done["row"], "previous": None, "tab": "other"}, expect=409) # not a listing diff --git a/tests/test_intern.py b/tests/test_intern.py index c50c6e4..e959470 100644 --- a/tests/test_intern.py +++ b/tests/test_intern.py @@ -93,6 +93,11 @@ assert sr["location"] == "Austin, TX" and sr["description"].startswith("Role") assert P("oracle", {"items": []}, "u") is None assert "Qualifications" in P("oracle", {"items": [{"Title": "T", "ExternalDescriptionStr": LONG, "ExternalQualificationsStr": "

C

"}]}, "u")["description"] +# close dates in a job site's own data (Oracle's posting end, Greenhouse's deadline); a far-off placeholder is none +assert P("oracle", {"items": [{"ExternalDescriptionStr": LONG, "ExternalPostedEndDate": "2026-12-05T05:00:00+00:00"}]}, "u")["deadline"] == "2026-12-05" +assert P("oracle", {"items": [{"ExternalDescriptionStr": LONG, "ExternalPostedEndDate": "4712-12-31T00:00:00+00:00"}]}, "u")["deadline"] is None +assert P("greenhouse", {"content": LONG, "application_deadline": "2026-11-20T23:59:00-05:00"}, "u")["deadline"] == "2026-11-20" +assert P("greenhouse", {"content": LONG, "application_deadline": None}, "u")["deadline"] is None assert P("eightfold", {"name": "T", "job_description": LONG}, "u")["description"] assert P("workable", {"title": "T", "description": LONG, "requirements": "

C

", "location": {"city": "Boston", "region": "MA"}}, "u")["location"] == "Boston, MA" @@ -232,7 +237,6 @@ def sim(n, title="Firmware Intern", term="Summer 2027", active=True, degrees=("B # ---------------------------------------------------------------- vanshb03's list: a season, not terms; the same job on two lists assert set(intern.LIST_URLS) == {"simplify", "vansh"} and all(intern.LIST_URLS.values()) -assert intern.lists_text() == "Simplify's and vanshb03's lists" def van(n, title="Firmware Intern", season="Summer", active=True, url=None, places=("Boston, MA",), company=None): @@ -627,11 +631,28 @@ def __enter__(self): assert trains == [1, 1, 1] jobs.load_details, jobs.train = real_load, real_train -# summary for the morning message; stats run -intern.save("scans.json", [{"time": "2026-10-10T06:00", "listed": 4, "pool": 1}, {"time": "2026-10-08T06:00", "listed": 9, "pool": 9}]) -from datetime import datetime # noqa: E402 -assert intern.summary(datetime(2026, 10, 10, 8, 0)) == \ - "Internships (Summer 2027): 4 new on Simplify's and vanshb03's lists, 1 made your pool (Review > Summer internships)" +# a posting from a job site whose close date NUauto reads only now (REDATE): read once more, for the date alone +listings_now = intern.load("listings.json", {}) +lid = next(iter(listings_now)) +kept_details = intern.load(f"details/{lid}.json", None) +intern.save(f"details/{lid}.json", {"id": lid, "url": listings_now[lid]["url"], "how": "oracle", "deadline": None, + "posting_end": None, "description": "scored text"}) +reads = [] +real_read = postings.read_plain +postings.read_plain = lambda url, client: reads.append(url) or {"deadline": "2026-12-05", "description": "new text"} +try: + assert intern.redate(lambda m: None) == 1 and len(reads) == 1 + again = intern.load(f"details/{lid}.json", None) + assert again["deadline"] == "2026-12-05" and again["redated"] and again["description"] == "scored text", again + assert intern.redate(lambda m: None) == 0 and len(reads) == 1 # once only + assert intern.closing_dates()[intern.clean_url(listings_now[lid]["url"])] == date(2026, 12, 5) +finally: + postings.read_plain = real_read + if kept_details is None: + os.remove(intern.path(f"details/{lid}.json")) + else: + intern.save(f"details/{lid}.json", kept_details) +# stats run intern.stats() # the whole update, Claude's calls stood in for, the list read from the demo file @@ -660,18 +681,18 @@ def fake_claude(prompt_file, batch, extra=None): intern.save("demo_postings.json", {"https://jobs.example.com/11": TEXT}) assert intern.update() == 0 pool = intern.load("pool.json", []) -assert ID(11) in {r["id"] for r in pool} and sent and sent[-1][0] == "1 new internship in your pool", sent -assert "Robotics Intern" in sent[-1][1] -# an approved internship that Simplify closes: a message (the sheet is only read) +assert ID(11) in {r["id"] for r in pool} and sent == [], sent # no message: the morning one counts it +best = intern.load("scans.json", [])[-1]["best"] +assert best["score"] == 91 and best["text"].startswith("Robotics Intern") and "(91% match)" in best["text"], best +# an approved internship that Simplify closes: a line in the next morning message (the sheet is only read) real_rows = intern.other_rows intern.other_rows = lambda interactive=True: [R(4, "https://jobs.example.com/11", "Approved"), R(5, "https://jobs.example.com/6", "Applied")] -sent.clear() intern.closed_notice([ID(11), ID(6)], daily) -assert len(sent) == 1 and sent[0][0] == "1 approved internship closed on Simplify's list" and "Other jobs row 4" in sent[0][1], sent +kept = daily.jobs.load(daily.LATER, {})["closed"] +assert sent == [] and len(kept) == 1 and kept[0].endswith("(row 4)"), (sent, kept) intern.other_rows = lambda interactive=True: (_ for _ in ()).throw(sheet.NotLoggedIn("expired")) -sent.clear() -intern.closed_notice([ID(11)], daily) # the sheet can't be read: no message, no crash -assert sent == [] +intern.closed_notice([ID(11)], daily) # the sheet can't be read: nothing kept, no crash +assert sent == [] and daily.jobs.load(daily.LATER, {})["closed"] == kept intern.other_rows = real_rows # a crash in the 2-hourly run: Discord says so, the unit fails real_update = intern.update diff --git a/tests/test_jobs.py b/tests/test_jobs.py index 4117dc4..a5a49e8 100644 --- a/tests/test_jobs.py +++ b/tests/test_jobs.py @@ -216,4 +216,27 @@ def update(self, range_name, values, value_input_option=None): # a single Statu assert [r["id"] for r in rate] == ["902"] # the rating viewer is unchanged jobs.load, jobs.ranked_pool = real_load, real_ranked +# what the applications check keeps about a sent row's job: its hiring stage, and when it started / stopped interviewing +def job(flag="0", stage="Available", applied=True): + return {"job_id": "j", "applied": applied, "application_status": "applied" if applied else None, "expired": False, + "is_interviewing": flag, "internal_status": {"_id": "1", "_label": stage}} +a = jobs.application_answer(job(), None, "t1") +assert (a["hiring"], a["interviewing"], a["interviewing_since"], a["interviewing_ended"], a["filled_since"]) == \ + ("Available", False, None, None, None) and a["applied"] and a["checked"] == "t1" +a = jobs.application_answer(job("1"), a, "t2") # the flag alone is interviewing +assert a["interviewing"] and a["interviewing_since"] == "t2" and not a["interviewing_ended"] +a = jobs.application_answer(job(None, "Interviewing"), a, "t3") # so is the Hiring Status alone; since stays +assert a["interviewing_since"] == "t2" and not a["interviewing_ended"] +a = jobs.application_answer(job(), a, "t4") # neither: it stopped (it may be filled) +assert a["interviewing_since"] == "t2" and a["interviewing_ended"] == "t4" and not a["filled_since"] +a = jobs.application_answer(job(stage="Filled"), a, "t5") +assert a["interviewing_ended"] == "t4" and a["filled_since"] == "t5" +assert jobs.application_answer(job(stage="Filled"), a, "t6")["filled_since"] == "t5" # the first time seen stays +a = jobs.application_answer(job("1"), a, "t7") # interviewing again: a new period +assert a["interviewing_since"] == "t7" and not a["interviewing_ended"] and not a["filled_since"] +old = {"applied": True, "interviewing": True, "checked": "t0"} # an answer saved before the tracking: only the flag +assert jobs.application_answer(job(), old, "t1")["interviewing_ended"] == "t1" +assert jobs.application_answer(job(), {**old, "interviewing": False}, "t1")["interviewing_ended"] is None +assert jobs.application_answer({**job(), "internal_status": None}, None, "t1")["hiring"] is None + print("All job-pool checks passed.") diff --git a/tests/test_sheet.py b/tests/test_sheet.py index 7fa6d31..3c99566 100644 --- a/tests/test_sheet.py +++ b/tests/test_sheet.py @@ -94,6 +94,15 @@ def expect(exc, fn): assert [r.number for r in sheet.rejected_not_withdrawn(R3, on)] == [4] on["c"]["applied"] = False assert sheet.rejected_not_withdrawn(R3, on) == [] and sheet.rejected_not_withdrawn(R3, {}) == [] # withdrawn / never asked +# an Applied row whose job looks filled (its Hiring Status, or it stopped interviewing) while NUworks has the application +jobs_now = {"b": {"applied": True, "fingerprint": fp["b"], "interviewing_ended": "t2"}, + "c": {"applied": True, "fingerprint": fp["c"], "filled_since": "t2"}} # (c is Rejected: not listed) +assert [(r.number, why) for r, why in sheet.maybe_filled(R3, jobs_now)] == [(3, "stopped interviewing")] +jobs_now["b"]["filled_since"] = "t3" +assert [(r.number, why) for r, why in sheet.maybe_filled(R3, jobs_now)] == [(3, "filled")] +jobs_now["b"]["applied"] = False # withdrawn: nothing left to do +assert sheet.maybe_filled(R3, jobs_now) == [] and sheet.maybe_filled(R3, {}) == [] +assert sheet.maybe_filled(R3, {"b": {"applied": True, "fingerprint": "older", "filled_since": "t3"}}) == [] R3[0].notes += " NUworks side submitted." # it changed since NUworks was asked: it counts assert sheet.total_count(R3, seen) == 3 and sheet.total_count(R3, {}) == 3 # (never asked: counts) many = rows_with([("Applied", "2026-09-01")] * sheet.MAX_TOTAL)