diff --git a/BACKLOG.md b/BACKLOG.md index 532de5b..348cd89 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -160,8 +160,9 @@ review items and unsupported forms still need human attention. - [x] [#274 — Ready: a packet whose last run failed is never retried, and a Ready row with no packet is never built — both sit forever](https://github.com/CryptoJones/OSApplyTrack/issues/274) (fixed in 1.48.4: a `ReadyReconciler` on every agent pass queues a prepare for a Ready row with no packet and another dry run for one whose last *dry* run died on something transient — a crash, a timeout, an Apply button that never answered — three failures per seven days, six hours apart. Never for a captcha, a sign-in wall, an unmapped required field, a provider the browser may not drive, a real run, or a submit whose confirmation was not recognised: the board may have taken that one, and a second application is worse than a parked one. New crash evidence carries `transient`, so the rule stops depending on wording) - [x] [#393 — Errors view: a "Retry All" button at the top right](https://github.com/CryptoJones/OSApplyTrack/issues/393) (fixed in 1.66.0: a **Retry all** button on the Errors heading row queues a dry-run retry for every stuck row in one request through the Ready lane's bulk route, which now takes `dry_run: true` to force a dry run whatever *Dry run only* says — so the batch counts once against the submit rate limit and never makes a real click; what was queued and what was not, and why, is announced; focus returns to the heading) - [x] [#394 — Due view: follow up agentically — the agent drafts (and optionally sends) the follow-up](https://github.com/CryptoJones/OSApplyTrack/issues/394) (fixed in 1.66.0: **Follow up** on each Due row and **Follow up on all** walk the follow-ups in one dialog — the tenant's model (any OpenAI-compatible endpoint, metered like any draft, no call at all with drafting off) writes a short email from the application and its dates, the contact, the cover letter, the notes log and the last messages the mailbox has exchanged with that contact; the person edits it, then **Send** mails it from their **own** account (new SMTP host/port on the mailbox settings, same username and sealed app password, host pinned to a public address like the IMAP side) — never the instance's sender — or **Mark followed up** logs one made elsewhere; either way it lands in the notes log and the date moves or clears. No contact on file: the row says so and the draft is still written, to copy. Unreviewed sending split out as #398) -- [ ] [#401 — Mobile: Errors and Due tables overflow or crush on a phone — Retry cut off](https://github.com/CryptoJones/OSApplyTrack/issues/401) +- [x] [#401 — Mobile: Errors and Due tables overflow or crush on a phone — Retry cut off](https://github.com/CryptoJones/OSApplyTrack/issues/401) (fixed in 1.66.1) - [ ] [#398 — Follow-ups: opt-in to let the agent send a drafted follow-up without review](https://github.com/CryptoJones/OSApplyTrack/issues/398) +- [x] [#403 — Agent: LinkedIn Easy Apply times out on the new SDUI dialog (no .jobs-easy-apply-modal)](https://github.com/CryptoJones/OSApplyTrack/issues/403) (fixed in 1.66.2: the driver now also recognizes LinkedIn's SDUI ``. It finds Next / Review / Submit application and Dismiss by their text or label, reads fields from `label[for]` / `aria-label` and radio options from the text beside each one, catches errors that appear only after a refused Next, unticks Follow by its aria-label, and answers the blank mobile-number box from the packet's standard phone. Checked against the live dialog on 2026-10-03.) ## Production audit — 2026-09-12, closed out 2026-09-13 diff --git a/api/ApplyTrack.Api.Tests/BrowserSubmitterTests.cs b/api/ApplyTrack.Api.Tests/BrowserSubmitterTests.cs index c72b754..de00a15 100644 --- a/api/ApplyTrack.Api.Tests/BrowserSubmitterTests.cs +++ b/api/ApplyTrack.Api.Tests/BrowserSubmitterTests.cs @@ -74,6 +74,8 @@ public async Task InitializeAsync() _fixture.MapGet("/li/jobs/view/7/", () => Results.Content(EasyApplyHtml.Replace( """Easy Apply""", """Continue

You last modified this application now

"""), "text/html")); + // The same flow in LinkedIn's SDUI dialog, as it shipped around 2026-09-30 (#403). + _fixture.MapGet("/li/jobs/view/sdui", () => Results.Content(EasyApplySduiHtml, "text/html")); // A "Follow" box that will not stay unticked. _fixture.MapGet("/li/jobs/view/sticky-follow", () => Results.Content(EasyApplyHtml.Replace( """id="follow-company-checkbox" checked>""", """id="follow-company-checkbox" checked onchange="this.checked=true">"""), "text/html")); @@ -111,6 +113,12 @@ public async Task InitializeAsync() lock (_posts) _posts.Add(new() { ["json"] = body }); return Results.Json(new { ok = true }); }); + // The SDUI dialog's submit call: the fixture reports "sent" only once this has answered 2xx. + _fixture.MapPost("/li/flagship-web/rsc-action/actions/submit", () => + { + lock (_posts) _posts.Add(new() { ["sdui_submit"] = "1" }); + return Results.Json(new { ok = true }); + }); _fixture.MapGet("/jobs/1", () => Results.Content(FormHtml, "text/html")); // Ashby draws its own checkbox and hides the real input: no id, no size, tabindex -1 (#280). _fixture.MapGet("/jobs/styled-checkbox", () => Results.Content(FormHtml.Replace( @@ -1904,11 +1912,96 @@ function render() { """; + // The SDUI dialog, copied from the live one on 2026-10-03 (#403): a native around the + // EasyApply screen, generated class names, no data-* hooks; label[for] on contact fields, the + // question as each control's aria-label, radio options in a

beside an empty label, a + // required "*", errors only after a refused Next, Follow ticked on Review, and a second + //

that asks Save or Discard. + private const string EasyApplySduiHtml = """ + +

Senior .NET Engineer

+ + + + + +

Apply to Acme

+
+

+
+

+
+
+

Save this application?

+ +
+ + + """; + private static AgentPacket EasyApplyPacket(bool answered) { var packet = new AgentPacket { ApplicationName = "acme-senior-.net-engineer.md", Provider = ApplyTrack.Api.Agent.AtsProvider.LinkedInEasy }; packet.Questions.Add(new("first_name", "First Name", true, PacketQuestion.Text, [], PacketQuestion.Standard)); packet.Answers["first_name"] = "Ada"; + // The SDUI dialog leaves the mobile number blank (#403); the old one arrives filled. + packet.Questions.Add(new("std:phone", "Phone", false, PacketQuestion.Text, [], PacketQuestion.Standard)); + packet.Answers["std:phone"] = "4025550100"; if (!answered) return packet; // Keyed by label, as the driver hands a discovered question back. foreach (var (label, answer) in new[] @@ -2967,6 +3060,59 @@ public async Task Easy_apply_submits_with_the_packets_answers_and_without_follow Assert.False(sent.GetProperty("follow").GetBoolean()); } + [SkippableFact] + public async Task Easy_apply_sdui_dialog_hands_back_new_questions_and_saves_the_draft() + { + Skip.IfNot(Available, "Node Playwright is not installed (npm ci)"); + // LinkedIn's SDUI dialog (#403): same flow, none of the old hooks. Every run timed out + // waiting for .jobs-easy-apply-modal while this dialog sat open on screen. + var outcome = await Submitter().RunAsync($"{_fixtureUrl}/li/jobs/view/sdui", EasyApplyPacket(answered: false), (Pdf, "resume.pdf"), dryRun: true); + + Assert.False(outcome.Submitted); + Assert.Equal( + ["Are you legally authorized to work in the United States?", "How many years of experience do you have with C#?", "Will you require visa sponsorship?"], + outcome.Unmapped); + var found = Assert.IsType>(outcome.Discovered); + Assert.Equal(["Yes", "No"], found[0].Options); + Assert.Equal(PacketQuestion.Text, found[1].Type); + Assert.Equal(["Yes", "No"], found[2].Options); + Assert.All(found, q => Assert.True(q.Required)); + var left = Assert.Single(EasyApplyEvents()); + Assert.Equal("save", left.GetProperty("left").GetString()); + Assert.Equal(3, left.GetProperty("step").GetInt32()); + } + + [SkippableFact] + public async Task Easy_apply_sdui_dialog_dry_run_reaches_submit_and_discards() + { + Skip.IfNot(Available, "Node Playwright is not installed (npm ci)"); + var outcome = await Submitter().RunAsync($"{_fixtureUrl}/li/jobs/view/sdui", EasyApplyPacket(answered: true), (Pdf, "resume.pdf"), dryRun: true); + + Assert.True(outcome.Filled, outcome.Error); + Assert.True(outcome.ReachedSubmit, outcome.Error); + Assert.False(outcome.Submitted); + Assert.Empty(outcome.Unmapped); + Assert.Equal(4, outcome.Mapped.Count); // the phone, and the three questions + Assert.Equal("discard", Assert.Single(EasyApplyEvents()).GetProperty("left").GetString()); + } + + [SkippableFact] + public async Task Easy_apply_sdui_dialog_submits_without_following_the_company() + { + Skip.IfNot(Available, "Node Playwright is not installed (npm ci)"); + var outcome = await Submitter().RunAsync($"{_fixtureUrl}/li/jobs/view/sdui", EasyApplyPacket(answered: true), (Pdf, "resume.pdf"), dryRun: false); + + Assert.True(outcome.Submitted, outcome.Error); + Assert.Contains("Application sent", outcome.Confirmation); + var sent = Assert.Single(EasyApplyEvents()).GetProperty("answers"); + Assert.Equal("Yes", sent.GetProperty("q-auth").GetString()); + Assert.Equal("12", sent.GetProperty("q-years").GetString()); + Assert.Equal("No", sent.GetProperty("visa").GetString()); + Assert.Equal("4025550100", sent.GetProperty("_r_t_").GetString()); + Assert.False(sent.GetProperty("follow").GetBoolean()); + lock (_posts) Assert.Single(_posts, p => p.ContainsKey("sdui_submit")); + } + [SkippableFact] public async Task Easy_apply_says_so_when_linkedin_serves_the_signed_out_page() { diff --git a/api/ApplyTrack.Api/Agent/Browser/LinkedInEasyApply.cs b/api/ApplyTrack.Api/Agent/Browser/LinkedInEasyApply.cs index 739b6e1..20049df 100644 --- a/api/ApplyTrack.Api/Agent/Browser/LinkedInEasyApply.cs +++ b/api/ApplyTrack.Api/Agent/Browser/LinkedInEasyApply.cs @@ -50,11 +50,17 @@ internal static partial class LinkedInEasyApply + "a[aria-label^='Easy Apply to' i], button[aria-label^='Easy Apply to' i], " + "a[aria-label^='Continue applying' i], button[aria-label^='Continue applying' i], " + "button#jobs-apply-button-id, button.jobs-apply-button[data-live-test-job-apply-button]"; - private const string Modal = ".jobs-easy-apply-modal"; - private const string Next = Modal + " button[data-easy-apply-next-button], " + Modal + " button[data-live-test-easy-apply-review-button]"; - private const string Submit = Modal + " button[data-live-test-easy-apply-submit-button]"; - private const string Close = Modal + " [data-test-modal-close-btn]"; - private const string Follow = Modal + " #follow-company-checkbox"; + // Two dialogs, one flow. The first is the artdeco modal mapped on 2026-09-20. The second + // shipped around 2026-09-30 with LinkedIn's SDUI front end (#403): a native around + // the EasyApply screen, every class name generated and none of the data-* hooks left, so its + // buttons are known only by what they say. Every run from then on timed out waiting for a + // .jobs-easy-apply-modal that was no longer there, with the dialog already open on screen. + private const string Modal = ":is(.jobs-easy-apply-modal, dialog[open]:has([data-sdui-screen*='easyapply']))"; + private const string Next = Modal + " button[data-easy-apply-next-button], " + Modal + " button[data-live-test-easy-apply-review-button], " + + Modal + " footer button:has-text('Next'), " + Modal + " footer button:has-text('Review')"; + private const string Submit = Modal + " button[data-live-test-easy-apply-submit-button], " + Modal + " button:has-text('Submit application')"; + private const string Close = Modal + " [data-test-modal-close-btn], " + Modal + " button[aria-label='Dismiss']"; + private const string Follow = Modal + " #follow-company-checkbox, " + Modal + " input[type=checkbox][aria-label^='Follow ']"; /// More steps than any real dialog has; a loop that will not end is a bug, not a form. private const int MaxSteps = 12; @@ -309,7 +315,7 @@ void OnResponse(object? _, IResponse r) while (DateTime.UtcNow < deadline) { await page.WaitForTimeoutAsync(1_000); - var said = await page.Locator("[role=dialog], .artdeco-modal").AllInnerTextsAsync(); + var said = await page.Locator("[role=dialog], .artdeco-modal, dialog[open]").AllInnerTextsAsync(); var sent = said.Select(t => Sent().Match(t)).FirstOrDefault(m => m.Success); if (sent is not null || accepted.Task.IsCompletedSuccessfully) { @@ -349,7 +355,8 @@ private static async Task LeaveAsync(IPage page, bool save) try { await page.Locator(Close).First.ClickAsync(new() { Timeout = 5_000 }); - var choice = page.Locator("[data-test-dialog-primary-btn], [data-test-dialog-secondary-btn], [role=alertdialog] button, .artdeco-modal button") + // The SDUI dialog asks "Save this application?" in a second of its own. + var choice = page.Locator("[data-test-dialog-primary-btn], [data-test-dialog-secondary-btn], [role=alertdialog] button, .artdeco-modal button, dialog[open] button") .Filter(new() { HasTextRegex = save ? new Regex(@"^\s*Save\s*$", RegexOptions.IgnoreCase) : new Regex(@"^\s*Discard\s*$", RegexOptions.IgnoreCase) }).First; await choice.ClickAsync(new() { Timeout = 5_000 }); // Let it land. The run ends here and its context closes with it, which would cut off @@ -372,6 +379,10 @@ public static string AnswerFor(AgentPacket packet, string label) // for want of what the résumé already says. The typeahead takes the first match for it. // Only a location that names a city ("Minden, Nebraska"): a bare "United States" would // let the typeahead's first suggestion stand in for a city the résumé never gave. + // The SDUI dialog (#403) leaves the mobile number blank where the old one filled it from + // the profile; the packet's standard phone is the same number. + if (PhoneLabel().IsMatch(label) && packet.Answers.TryGetValue("std:phone", out var phone) && phone.Trim().Length > 0) + return phone.Trim(); if (CityLabel().IsMatch(label) && packet.Resume is { Location.Length: > 0 } resume && AnswerDrafter.StripLocationSuffix(resume.Location) is { } place && place.Contains(',')) return place; @@ -381,6 +392,9 @@ public static string AnswerFor(AgentPacket packet, string label) [GeneratedRegex(@"^\s*(?:city|location|current location|location \(city\))\s*\*?\s*$", RegexOptions.IgnoreCase)] private static partial Regex CityLabel(); + [GeneratedRegex(@"^\s*(?:mobile\s+)?phone(?:\s+number)?\s*\*?\s*$", RegexOptions.IgnoreCase)] + private static partial Regex PhoneLabel(); + private static bool SameLabel(string a, string b) => string.Equals(Squash(a), Squash(b), StringComparison.OrdinalIgnoreCase); @@ -415,9 +429,15 @@ private static async Task FillAsync(IPage page, Field f, string answer) """ (first, want) => { const group = first.closest('fieldset') || first.closest('[data-test-form-element]') || first.parentElement; - for (const r of group.querySelectorAll('input[type=radio]')) { + // The same scope ReadStepAsync read the options from: every same-name radio. + const scope = first.closest('fieldset') || first.closest('[data-test-form-element]') || first.getRootNode(); + const radios = first.name ? [...scope.querySelectorAll(`input[type=radio][name="${CSS.escape(first.name)}"]`)] : [...group.querySelectorAll('input[type=radio]')]; + const line = t => (t || '').split('\n').map(x => x.trim()).filter(Boolean)[0] || ''; + for (const r of radios) { const label = first.getRootNode().querySelector(`label[for="${CSS.escape(r.id)}"]`); - if (((label?.innerText || r.value || '').trim().toLowerCase()) === want.toLowerCase()) { (label || r).click(); return r.checked; } + // The SDUI dialog's label is empty; the option's words sit beside it (#403). + const text = line(label?.innerText) || line(r.parentElement?.parentElement?.innerText) || r.value || ''; + if (text.toLowerCase() === want.toLowerCase()) { (label || r).click(); return r.checked; } } return false; } @@ -532,6 +552,43 @@ public static async Task> ReadStepAsync(IPage page) || !!g.querySelector('[data-test-form-builder-radio-button-form-component__required], .fb-dash-form-element__label--is-required, [class*=is-required]'); out.push({ Label: text, Kind: kind, Required: required, Filled: filled, Options: options, ControlId: c.id || '', Invalid: !!g.querySelector('.artdeco-inline-feedback--error') }); } + if (groups.length) return JSON.stringify(out); + + // The SDUI dialog (#403): no group wrappers, just controls. A label[for] names a + // contact field; a question's control carries the question as its aria-label, and a + // radio group's options say themselves in a

beside each empty label. "*" marks + // required. Errors arrive only after a refused Next: the helper text's own first

+ // ("Invalid input", "Please enter a valid phone number"), or a

after the fieldset. + const column = modal.querySelector('[data-component-type=LazyColumn]') || modal; + const optionText = r => clean(said(root.querySelector(`label[for="${CSS.escape(r.id)}"]`)) || r.parentElement?.parentElement?.innerText) || r.value; + const errorish = /invalid|required|please (enter|select|make|provide|choose)|must be|enter a (valid|whole|decimal|number)|larger than|less than|between/i; + for (const c of column.querySelectorAll('input:not([type=hidden]):not([type=file]), select, textarea')) { + if (seen.has(c) || !shown(c)) continue; + const kind = c.tagName === 'SELECT' ? 'select' : c.tagName === 'TEXTAREA' ? 'textarea' : c.type === 'radio' ? 'radio' : c.type === 'checkbox' ? 'checkbox' : 'text'; + const aria = clean(c.getAttribute('aria-label')); + if (kind === 'checkbox' && (/top choice/i.test(aria) || /^follow /i.test(aria))) continue; + const fieldset = c.closest('fieldset'); + const radios = kind === 'radio' ? [...(fieldset || column).querySelectorAll(`input[type=radio][name="${CSS.escape(c.name)}"]`)] : []; + radios.forEach(r => seen.add(r)); seen.add(c); + // The résumé cards are a radio group too, each named for its file. + if (kind === 'radio' && (fieldset?.querySelector('[componentkey=easyApplyUploadedResumeRef]') || /\.(pdf|docx?|rtf|txt)$/i.test(aria))) continue; + const box = c.closest('[componentkey^=easyApplyFieldFocus]') || fieldset?.parentElement || c.parentElement?.parentElement || c; + // The question's own

: inside a radio group's box, or just before a text box's. + const lead = box.querySelector(':scope > p') || box.parentElement?.querySelector(':scope > p'); + let text = (kind === 'radio' ? '' : said(root.querySelector(`label[for="${CSS.escape(c.id)}"]`))) || aria || clean(lead?.innerText); + const starred = /\*\s*$/.test(text) || /\*\s*$/.test(clean(lead?.innerText)); + text = text.replace(/\s*\*\s*$/, '').trim(); + if (!text) continue; + const options = kind === 'select' ? [...c.options].map(o => o.text.trim()).filter(t => t && !/^select an option$/i.test(t)) + : radios.map(optionText); + const filled = kind === 'select' ? !!c.value && !/^select an option$/i.test(c.options[c.selectedIndex]?.text.trim() || '') + : kind === 'radio' ? radios.some(r => r.checked) : kind === 'checkbox' ? c.checked : (c.value || '').trim().length > 0; + const required = starred || c.required || c.getAttribute('aria-required') === 'true' || radios.some(r => r.required || r.getAttribute('aria-required') === 'true'); + const helper = box.querySelector('[data-testid=text-input-helper-text] > p'); + const after = fieldset?.nextElementSibling?.tagName === 'P' ? fieldset.nextElementSibling : null; + const invalid = c.getAttribute('aria-invalid') === 'true' || [helper, after].some(p => p && errorish.test(p.innerText || '')); + out.push({ Label: text, Kind: kind, Required: required, Filled: filled, Options: options, ControlId: c.id || '', Invalid: invalid }); + } return JSON.stringify(out); } """); diff --git a/api/ApplyTrack.Api/ApplyTrack.Api.csproj b/api/ApplyTrack.Api/ApplyTrack.Api.csproj index ce9c1b9..5e9db0f 100644 --- a/api/ApplyTrack.Api/ApplyTrack.Api.csproj +++ b/api/ApplyTrack.Api/ApplyTrack.Api.csproj @@ -5,7 +5,7 @@ enable enable ApplyTrack.Api - 1.66.1 + 1.66.2 Aaron K. Clark Copyright 2026 Aaron K. Clark Apache-2.0 diff --git a/pyproject.toml b/pyproject.toml index 979a93c..8a6cd04 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "applytrack-poller" -version = "1.66.1" +version = "1.66.2" description = "Discovery poller for OSApplyTrack — fetches and scores remote job leads into shared Postgres." requires-python = ">=3.10" license = { text = "Apache-2.0" } diff --git a/src/applytrack/__init__.py b/src/applytrack/__init__.py index 129b6f3..fd3a52e 100644 --- a/src/applytrack/__init__.py +++ b/src/applytrack/__init__.py @@ -2,4 +2,4 @@ # Copyright 2026 Aaron K. Clark """applytrack — a local, markdown-backed job-application tracker.""" -__version__ = "1.66.1" +__version__ = "1.66.2" diff --git a/uv.lock b/uv.lock index dd01f69..3d8b57d 100644 --- a/uv.lock +++ b/uv.lock @@ -43,7 +43,7 @@ wheels = [ [[package]] name = "applytrack-poller" -version = "1.66.1" +version = "1.66.2" source = { editable = "." } dependencies = [ { name = "cryptography" },