From ad0c8dd498e31c24061847d8933bf73b2b0bc400 Mon Sep 17 00:00:00 2001 From: strelov1 Date: Wed, 7 Oct 2026 08:36:22 -0300 Subject: [PATCH 1/7] Design: EnergyJobline + Rigzone job portal adapters Scopes the oil & gas job-portal batch down to the two sites that are actually buildable with new code: EnergyJobline (open sitemap + ld+json, dataart.go shape) and Rigzone/Oilcareers (same shape, but needs the fingerprint-spoofed transport already used by bayt.go/gulftalent.go). Laimoon's jobs backend turned out to be dead; NaukriGulf and OilAndGasJobSearch are unreachable from both this environment and the prod host. --- ...10-07-oilgas-job-portal-adapters-design.md | 159 ++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 docs/superpowers/specs/2026-10-07-oilgas-job-portal-adapters-design.md diff --git a/docs/superpowers/specs/2026-10-07-oilgas-job-portal-adapters-design.md b/docs/superpowers/specs/2026-10-07-oilgas-job-portal-adapters-design.md new file mode 100644 index 000000000..f08dc500e --- /dev/null +++ b/docs/superpowers/specs/2026-10-07-oilgas-job-portal-adapters-design.md @@ -0,0 +1,159 @@ +# Oil & gas job portal adapters: EnergyJobline + Rigzone + +**Date:** 2026-10-07 +**Status:** design approved, pending written-spec review + +## The problem + +The user supplied ~40 oil & gas domains (agencies, job portals, majors, EPC/service +companies) to evaluate as new ingest sources. Recon across all of them, plus a pass over +the live `boards` table, narrowed things down fast: + +- 18 majors/EPC/service companies sit on a known ATS. 13 were already boarded + (Shell, BP, Baker Hughes, Eni, ExxonMobil, Fluor, Halliburton, KBR, McDermott, SLB, + TechnipFMC, Wood, Worley). 5 were added via `cmd/add-board` on 2026-10-06 (Subsea7, + Bechtel, ADNOC, QatarEnergy, TotalEnergies). Aramco is excluded — its site TCP-blocks + automated requests and the platform could not be confirmed. +- Of the 7 general/niche job portals (Rigzone, Oilcareers, EnergyJobline, + OilAndGasJobSearch, Bayt, GulfTalent, NaukriGulf, Laimoon — 8 names, 7 distinct + backends since Oilcareers shares Rigzone's infrastructure): + - **Bayt and GulfTalent already have adapters** (`internal/ingest/sources/bayt.go`, + `gulftalent.go`), already registered in `registry.go` behind the fingerprint-spoofed + transport, and already have live `boards` rows (8 Bayt country boards + 1 GulfTalent, + all `status=active`). Nothing to build. + - **Laimoon's jobs backend is dead.** `jobs.laimoon.com` serves a CloudFront-Function + stub titled "Laimoon Jobs - Coming Soon" on every path, including the bare root; + `www.laimoon.com/sitemap.xml` redirects to `courses.laimoon.com/sitemap.xml` (the + courses product's sitemap, not jobs); `courses.laimoon.com/jobs` returns a permanent + "High traffic" stub; the live `www.laimoon.com/` homepage carries no link to the jobs + subdomain at all. Google still has stale job URLs indexed, but every one of them now + 404s. Out of scope — there is no backend left to adapt to. + - **NaukriGulf and OilAndGasJobSearch are unreachable at the network level**, not just + bot-detected: `naukrigulf.com` completes a TLS handshake but never returns an HTTP + response (tar-pit behaviour), and `oilandgasjobsearch.com` refuses the TCP connection + outright. Both failures reproduce identically from this environment and from the prod + host (`89.167.94.146`), so this is not an IP-reputation problem a stealth transport + fixes — it would need a residential proxy or a headless-browser service, a separate + paid capability and decision. Out of scope for this design. + - **EnergyJobline and Rigzone (covers Oilcareers)** are the two portals this design + covers. + +## EnergyJobline + +Confirmed with a plain `curl` (Googlebot UA, no cookies, no JS) — no bot defense observed: + +- `https://www.energyjobline.com/sitemap.xml` is an open sitemap **index** of 4 + sub-sitemaps. +- A live posting, `https://www.energyjobline.com/job/controls-engineer-atlanta-31835232`, + server-renders three `` +} + +func TestEnergyjoblineFetchWalksIndexThenDetailAndMaps(t *testing.T) { + jobURL := "https://www.energyjobline.com/job/controls-engineer-atlanta-31835232" + detail := energyjoblineDetailHTML( + "Controls Engineer in Atlanta", "The AES Corporation", + "Field service controls engineering.", "2026-10-01", "Atlanta", "US") + + fake := (&routedHTTP{}). + route("sitemap.xml?page=1", energyjoblineURLSetXML( + jobURL, + "https://www.energyjobline.com/", // site root, not a job + "https://www.energyjobline.com/jobs", // listing root, not a job + "https://www.energyjobline.com/news-article/oil-and-gas/some-story", // unrelated + )). + route("sitemap.xml?page=2", energyjoblineURLSetXML()). + // Registered after the page-specific routes: routedHTTP matches by substring in + // append order, and "sitemap.xml" is itself a substring of "sitemap.xml?page=1" — + // this generic route must lose that race for the index URL to resolve correctly. + route("sitemap.xml", energyjoblineSitemapIndexXML( + "https://www.energyjobline.com/sitemap.xml?page=1", + "https://www.energyjobline.com/sitemap.xml?page=2", + )). + route("/job/controls-engineer-atlanta-31835232", detail) + + jobs, err := NewEnergyJobline(fake).Fetch(context.Background(), CompanyEntry{ + Company: "EnergyJobline", Provider: "energyjobline", + }) + if err != nil { + t.Fatalf("Fetch: %v", err) + } + if len(jobs) != 1 { + t.Fatalf("got %d jobs, want 1 (non-job sitemap entries must be excluded)", len(jobs)) + } + j := jobs[0] + if j.ExternalID != "31835232" { + t.Errorf("ExternalID = %q, want 31835232", j.ExternalID) + } + if j.Title != "Controls Engineer in Atlanta" { + t.Errorf("Title = %q", j.Title) + } + if j.Company != "The AES Corporation" { + t.Errorf("Company = %q, want the posting's own hiringOrganization", j.Company) + } + if j.URL != jobURL { + t.Errorf("URL = %q, want %q", j.URL, jobURL) + } + if j.Location != "Atlanta, US" { + t.Errorf("Location = %q", j.Location) + } + if j.PostedAt == nil || j.PostedAt.Format("2006-01-02") != "2026-10-01" { + t.Errorf("PostedAt = %v, want 2026-10-01", j.PostedAt) + } +} + +func TestEnergyjoblineBrokenSitemapIndexErrorsTheCrawl(t *testing.T) { + fake := (&routedHTTP{}).routeErr("sitemap.xml", errors.New("origin down")) + _, err := NewEnergyJobline(fake).Fetch(context.Background(), CompanyEntry{ + Company: "EnergyJobline", Provider: "energyjobline", + }) + if err == nil { + t.Fatal("Fetch: want error on a broken sitemap index, got nil") + } +} + +func TestEnergyjoblineCompanyComesFromThePosting(t *testing.T) { + jobURL := "https://www.energyjobline.com/job/welder-kent-27543774" + detail := energyjoblineDetailHTML("WELDER in Kent", "Energy Jobline ZR", + "desc", "2026-10-02", "Kent", "GB") + fake := (&routedHTTP{}).route("/job/welder-kent-27543774", detail) + + j, ok := energyjobline{http: fake}.detail(context.Background(), + CompanyEntry{Company: "EnergyJobline", Provider: "energyjobline"}, jobURL) + if !ok { + t.Fatal("detail returned ok=false") + } + // The site attributes agency-submitted postings to the agency's own brand, not a + // confidential end client — that is the real, honest value of hiringOrganization for + // this posting (verified against the live site's own human-visible company link, not + // just its ld+json), so it is stored as-is rather than filtered or guessed around. + if j.Company != "Energy Jobline ZR" { + t.Errorf("Company = %q, want the posting's own (agency) hiringOrganization", j.Company) + } +} + +func TestEnergyjoblineUnreadableDetailWhenHiringOrgEmpty(t *testing.T) { + jobURL := "https://www.energyjobline.com/job/no-employer-99999999" + detail := energyjoblineDetailHTML("Mystery Role", "", "desc", "2026-10-02", "Kent", "GB") + fake := (&routedHTTP{}).route("/job/no-employer-99999999", detail) + + j, ok := energyjobline{http: fake}.detail(context.Background(), + CompanyEntry{Company: "EnergyJobline", Provider: "energyjobline"}, jobURL) + if !ok { + t.Fatal("detail returned ok=false, want an unreadableDetail stub") + } + if !j.Unreadable { + t.Error("Unreadable = false, want true (empty hiringOrganization)") + } + if j.Company != "EnergyJobline" { + t.Errorf("Company = %q, want the configured fallback EnergyJobline", j.Company) + } +} + +func TestEnergyjoblineUnreadableDetailWhenNoJobPosting(t *testing.T) { + jobURL := "https://www.energyjobline.com/job/broken-page-11111111" + fake := (&routedHTTP{}).route("/job/broken-page-11111111", "no ld+json here") + + j, ok := energyjobline{http: fake}.detail(context.Background(), + CompanyEntry{Company: "EnergyJobline", Provider: "energyjobline"}, jobURL) + if !ok { + t.Fatal("detail returned ok=false, want an unreadableDetail stub") + } + if !j.Unreadable { + t.Error("Unreadable = false, want true (no JobPosting block)") + } +} + +func TestEnergyjoblineJobID(t *testing.T) { + cases := map[string]string{ + "https://www.energyjobline.com/job/controls-engineer-atlanta-31835232": "31835232", + "https://www.energyjobline.com/job/welder-kent-27543774/": "27543774", + "https://www.energyjobline.com/": "", + "https://www.energyjobline.com/jobs": "", + "https://www.energyjobline.com/news-article/oil-and-gas/story": "", + "https://www.energyjobline.com/company/energy-jobline-o-p": "", + } + for u, want := range cases { + if got := energyjoblineJobID(u); got != want { + t.Errorf("energyjoblineJobID(%q) = %q, want %q", u, got, want) + } + } +} + +func TestEnergyjoblineOneBadDetailDoesNotFailTheCrawl(t *testing.T) { + goodURL := "https://www.energyjobline.com/job/good-job-22222222" + badURL := "https://www.energyjobline.com/job/bad-job-33333333" + good := energyjoblineDetailHTML("Good Job", "Real Co", "d", "2026-10-02", "Kent", "GB") + + fake := (&routedHTTP{}). + route("sitemap.xml?page=1", energyjoblineURLSetXML(goodURL, badURL)). + route("sitemap.xml", energyjoblineSitemapIndexXML("https://www.energyjobline.com/sitemap.xml?page=1")). + route("/job/good-job-22222222", good). + routeErr("/job/bad-job-33333333", errors.New("fetch failed")) + + jobs, err := NewEnergyJobline(fake).Fetch(context.Background(), CompanyEntry{ + Company: "EnergyJobline", Provider: "energyjobline", + }) + if err != nil { + t.Fatalf("Fetch: %v", err) + } + if len(jobs) != 1 { + t.Fatalf("got %d jobs, want 1 (the one good posting, bad one dropped silently on fetch error)", len(jobs)) + } + if jobs[0].Title != "Good Job" { + t.Errorf("Title = %q, want Good Job", jobs[0].Title) + } +} + +func TestEnergyjoblineProviderAndBoardless(t *testing.T) { + s := NewEnergyJobline(nil) + if s.Provider() != "energyjobline" { + t.Errorf("Provider() = %q, want energyjobline", s.Provider()) + } + if _, ok := s.(boardless); !ok { + t.Error("energyjobline must implement boardless") + } + if _, ok := s.(aggregator); !ok { + t.Error("energyjobline must implement aggregator") + } +} diff --git a/internal/ingest/sources/registry.go b/internal/ingest/sources/registry.go index 019dff1ba..4e24bdb15 100644 --- a/internal/ingest/sources/registry.go +++ b/internal/ingest/sources/registry.go @@ -354,6 +354,7 @@ func All(c HTTPClient) map[string]Source { // listing and detail pages are fully static server-rendered HTML. NewStaffy(c), NewDataArt(c), + NewEnergyJobline(c), // JettyCloud: its postings exist only on its own careers site — the employer runs no // ATS a crawl can reach, so this adapter is the only way the catalogue can carry it. NewJettyCloud(c), diff --git a/openspec/changes/energyjobline-ingest-adapter/tasks.md b/openspec/changes/energyjobline-ingest-adapter/tasks.md index 35df76d36..c9d23258d 100644 --- a/openspec/changes/energyjobline-ingest-adapter/tasks.md +++ b/openspec/changes/energyjobline-ingest-adapter/tasks.md @@ -1,47 +1,47 @@ ## 1. Sitemap enumeration -- [ ] 1.1 Write a failing test: given a fixture sitemap-index XML (the top-level +- [x] 1.1 Write a failing test: given a fixture sitemap-index XML (the top-level `https://www.energyjobline.com/sitemap.xml` shape) and fixture sub-sitemaps, the adapter's enumeration step returns every job-detail URL across all sub-sitemaps. -- [ ] 1.2 Implement the job-URL extractor (matches the `.../job/-` shape) and +- [x] 1.2 Implement the job-URL extractor (matches the `.../job/-` shape) and wire it through the existing `getSitemap`/sub-sitemap walk so it handles the index shape (reuse, do not reimplement). -- [ ] 1.3 Write a failing test: a sitemap index that fails to fetch/decode makes `Fetch` +- [x] 1.3 Write a failing test: a sitemap index that fails to fetch/decode makes `Fetch` return an error (not an empty success). -- [ ] 1.4 Implement that error path. +- [x] 1.4 Implement that error path. ## 2. Per-posting detail parsing -- [ ] 2.1 Write a failing test: a fixture job-detail page with `WebSite`, `Organization`, +- [x] 2.1 Write a failing test: a fixture job-detail page with `WebSite`, `Organization`, and `JobPosting` ld+json blocks (in that order, matching the live page) decodes to a `Job` with the `JobPosting`'s title/description/location/datePosted — proving `ldJobPosting` is actually wired to skip the non-`JobPosting` blocks for this adapter's own posting struct. -- [ ] 2.2 Implement the posting struct and `detail` function. -- [ ] 2.3 Write a failing test: `hiringOrganization.name` present and non-empty becomes +- [x] 2.2 Implement the posting struct and `detail` function. +- [x] 2.3 Write a failing test: `hiringOrganization.name` present and non-empty becomes the job's `Company`. -- [ ] 2.4 Implement that resolution. -- [ ] 2.5 Write a failing test: `hiringOrganization.name` empty or the `JobPosting` block +- [x] 2.4 Implement that resolution. +- [x] 2.5 Write a failing test: `hiringOrganization.name` empty or the `JobPosting` block missing entirely both yield an `unreadableDetail` stub (not a dropped posting, not a guessed company). -- [ ] 2.6 Implement that fallback. -- [ ] 2.7 Write a failing test: `ExternalID` comes from the job-detail URL and a second +- [x] 2.6 Implement that fallback. +- [x] 2.7 Write a failing test: `ExternalID` comes from the job-detail URL and a second fetch of the same URL maps to the same `ExternalID`. -- [ ] 2.8 Implement that id extraction. +- [x] 2.8 Implement that id extraction. ## 3. Markers, transport, and registration -- [ ] 3.1 Add `boardless()` and `aggregator()` marker methods to the adapter type. -- [ ] 3.2 Wire the adapter through `fetchDetails`/`defaultDetailWorkers` for the detail +- [x] 3.1 Add `boardless()` and `aggregator()` marker methods to the adapter type. +- [x] 3.2 Wire the adapter through `fetchDetails`/`defaultDetailWorkers` for the detail fan-out (one failing/passing test proving one bad detail page does not fail the whole crawl, per spec). -- [ ] 3.3 Register `energyjobline` in `internal/ingest/sources/registry.go`'s `All()` +- [x] 3.3 Register `energyjobline` in `internal/ingest/sources/registry.go`'s `All()` alongside `dataart` (plain shared client, no fingerprint transport needed). ## 4. Quality pass and review -- [ ] 4.1 Run `simplify` over the new/changed files. -- [ ] 4.2 Re-run `go test ./internal/ingest/sources/...` and confirm green. +- [x] 4.1 Run `simplify` over the new/changed files. +- [x] 4.2 Re-run `go test ./internal/ingest/sources/...` and confirm green. - [ ] 4.3 Request code review (`requesting-code-review`); address Critical/Important feedback. From c07f1a73c0c843b8a4be445d961899c819308f21 Mon Sep 17 00:00:00 2001 From: strelov1 Date: Wed, 7 Oct 2026 10:02:00 -0300 Subject: [PATCH 6/7] Fix energyjobline detail fetch-error handling per code review GetHTML failures were dropped unconditionally, contradicting the written spec's "Resilient per-posting parsing" requirement and the adapter's own doc comment. Now mirrors bayt.go: a confirmed-gone 404/410 drops the posting, anything else becomes an unreadableDetail stub. Adds the two cheap test gaps the review flagged (a mid-walk sub-sitemap failure, a posting with zero jobLocation entries) and a short comment on the Description unescape step. --- internal/ingest/sources/energyjobline.go | 17 +++- internal/ingest/sources/energyjobline_test.go | 92 ++++++++++++++++++- .../energyjobline-ingest-adapter/tasks.md | 2 +- 3 files changed, 101 insertions(+), 10 deletions(-) diff --git a/internal/ingest/sources/energyjobline.go b/internal/ingest/sources/energyjobline.go index 982b510b9..3bd962e30 100644 --- a/internal/ingest/sources/energyjobline.go +++ b/internal/ingest/sources/energyjobline.go @@ -83,6 +83,9 @@ func (e energyjobline) detail(ctx context.Context, ce CompanyEntry, link string) } root, err := e.http.GetHTML(ctx, link) if err != nil { + if detailUnreadable(err) { + return unreadableDetail(id, link, ce.Company), true + } return Job{}, false } var p energyjoblinePosting @@ -98,11 +101,15 @@ func (e energyjobline) detail(ctx context.Context, ce CompanyEntry, link string) strings.TrimSpace(p.jobLocationAddress().Country), ) return Job{ - ExternalID: id, - URL: link, - Title: strings.TrimSpace(p.Title), - Company: company, - Location: location, + ExternalID: id, + URL: link, + Title: strings.TrimSpace(p.Title), + Company: company, + Location: location, + // EnergyJobline's description carries HTML-entity-encoded markup (e.g. literal + // "&" inside the JSON string, confirmed on a live posting), so it needs + // unescaping before sanitizeHTML — unlike bayt.go/gulftalent.go, whose descriptions + // arrive already decoded. Description: sanitizeHTML(html.UnescapeString(p.Description)), Remote: isRemote(location), PostedAt: parseDate(p.DatePosted), diff --git a/internal/ingest/sources/energyjobline_test.go b/internal/ingest/sources/energyjobline_test.go index 015a5ed24..60f9f5a03 100644 --- a/internal/ingest/sources/energyjobline_test.go +++ b/internal/ingest/sources/energyjobline_test.go @@ -101,6 +101,27 @@ func TestEnergyjoblineBrokenSitemapIndexErrorsTheCrawl(t *testing.T) { } } +// The index itself can resolve fine while one of its sub-sitemaps fails; that failure +// propagates as a hard Fetch error too (no fullBoardListing is claimed here, so any shard +// failure means the enumeration is incomplete and the caller should not trust a partial +// result as "today's whole catalogue"). +func TestEnergyjoblineSubSitemapFailureFailsTheCrawl(t *testing.T) { + fake := (&routedHTTP{}). + route("sitemap.xml?page=1", energyjoblineURLSetXML("https://www.energyjobline.com/job/ok-1")). + route("sitemap.xml", energyjoblineSitemapIndexXML( + "https://www.energyjobline.com/sitemap.xml?page=1", + "https://www.energyjobline.com/sitemap.xml?page=2", + )). + routeErr("sitemap.xml?page=2", errors.New("origin down")) + + _, err := NewEnergyJobline(fake).Fetch(context.Background(), CompanyEntry{ + Company: "EnergyJobline", Provider: "energyjobline", + }) + if err == nil { + t.Fatal("Fetch: want error when one sub-sitemap fails, got nil") + } +} + func TestEnergyjoblineCompanyComesFromThePosting(t *testing.T) { jobURL := "https://www.energyjobline.com/job/welder-kent-27543774" detail := energyjoblineDetailHTML("WELDER in Kent", "Energy Jobline ZR", @@ -153,6 +174,31 @@ func TestEnergyjoblineUnreadableDetailWhenNoJobPosting(t *testing.T) { } } +// A posting can carry a JobPosting block with no jobLocation entries at all (the field is +// an array in the markup, but nothing guarantees the site always populates it) — this must +// not panic on an empty-slice index and should simply leave Location blank. +func TestEnergyjoblineDetailWithNoJobLocation(t *testing.T) { + jobURL := "https://www.energyjobline.com/job/no-location-66666666" + html := `` + fake := (&routedHTTP{}).route("/job/no-location-66666666", html) + + j, ok := energyjobline{http: fake}.detail(context.Background(), + CompanyEntry{Company: "EnergyJobline", Provider: "energyjobline"}, jobURL) + if !ok { + t.Fatal("detail returned ok=false") + } + if j.Location != "" { + t.Errorf("Location = %q, want empty (no jobLocation entries)", j.Location) + } + if j.Company != "Real Co" { + t.Errorf("Company = %q, want Real Co", j.Company) + } +} + func TestEnergyjoblineJobID(t *testing.T) { cases := map[string]string{ "https://www.energyjobline.com/job/controls-engineer-atlanta-31835232": "31835232", @@ -186,11 +232,49 @@ func TestEnergyjoblineOneBadDetailDoesNotFailTheCrawl(t *testing.T) { if err != nil { t.Fatalf("Fetch: %v", err) } - if len(jobs) != 1 { - t.Fatalf("got %d jobs, want 1 (the one good posting, bad one dropped silently on fetch error)", len(jobs)) + // A plain transport error states nothing about the posting (it might be a timeout, a + // transient 5xx — anything short of a confirmed-gone answer), so it must not be + // mistaken for the posting having been removed: it survives as an Unreadable stub, the + // same contract fetchDetails documents and bayt.go/gulftalent.go already honour. + if len(jobs) != 2 { + t.Fatalf("got %d jobs, want 2 (the good posting plus an Unreadable stub for the one that failed to fetch)", len(jobs)) + } + markers := unreadableMarkers(jobs) + if len(markers) != 1 || markers[0].ExternalID != "33333333" { + t.Fatalf("unreadable markers = %v, want one for the posting whose detail fetch errored", markers) + } + if markers[0].Company != "EnergyJobline" { + t.Errorf("marker Company = %q, want the entry's configured company", markers[0].Company) + } + for _, j := range jobs { + if j.ExternalID == "22222222" && j.Title != "Good Job" { + t.Errorf("good posting Title = %q, want Good Job", j.Title) + } + } +} + +// The other half of the distinction (see bayt_test.go's TestBaytGoneDetailDropsThePosting): +// a 404/410 is the platform's own answer that the posting is gone, so the crawl drops it +// rather than marking it unreadable. +func TestEnergyjoblineGoneDetailDropsThePosting(t *testing.T) { + goodURL := "https://www.energyjobline.com/job/good-job-44444444" + goneURL := "https://www.energyjobline.com/job/gone-job-55555555" + good := energyjoblineDetailHTML("Good Job", "Real Co", "d", "2026-10-02", "Kent", "GB") + + fake := (&routedHTTP{}). + route("sitemap.xml?page=1", energyjoblineURLSetXML(goodURL, goneURL)). + route("sitemap.xml", energyjoblineSitemapIndexXML("https://www.energyjobline.com/sitemap.xml?page=1")). + route("/job/good-job-44444444", good). + routeErr("/job/gone-job-55555555", &StatusError{Method: "GET", Code: 404, URL: goneURL}) + + jobs, err := NewEnergyJobline(fake).Fetch(context.Background(), CompanyEntry{ + Company: "EnergyJobline", Provider: "energyjobline", + }) + if err != nil { + t.Fatalf("Fetch: %v", err) } - if jobs[0].Title != "Good Job" { - t.Errorf("Title = %q, want Good Job", jobs[0].Title) + if len(jobs) != 1 || jobs[0].ExternalID != "44444444" { + t.Fatalf("got %v, want only the posting whose detail answered — the 404'd one dropped, not marked", jobs) } } diff --git a/openspec/changes/energyjobline-ingest-adapter/tasks.md b/openspec/changes/energyjobline-ingest-adapter/tasks.md index c9d23258d..a018b10a2 100644 --- a/openspec/changes/energyjobline-ingest-adapter/tasks.md +++ b/openspec/changes/energyjobline-ingest-adapter/tasks.md @@ -42,7 +42,7 @@ - [x] 4.1 Run `simplify` over the new/changed files. - [x] 4.2 Re-run `go test ./internal/ingest/sources/...` and confirm green. -- [ ] 4.3 Request code review (`requesting-code-review`); address Critical/Important +- [x] 4.3 Request code review (`requesting-code-review`); address Critical/Important feedback. ## 5. Post-merge (manual, not part of the tests) From 0439c4a3d5b04b070cfa87ac335f841aa3d87706 Mon Sep 17 00:00:00 2001 From: strelov1 Date: Wed, 7 Oct 2026 10:16:41 -0300 Subject: [PATCH 7/7] Regenerate contracts.ts for the new energyjobline source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit make gen-contracts was missed in the previous commits — CI's "contracts" check caught the drift (SOURCE_VALUES missing energyjobline). --- web/src/lib/generated/contracts.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/lib/generated/contracts.ts b/web/src/lib/generated/contracts.ts index 738c03102..5a17e9773 100644 --- a/web/src/lib/generated/contracts.ts +++ b/web/src/lib/generated/contracts.ts @@ -1495,7 +1495,7 @@ export interface CatalogueMember { has_photo: boolean; } -export const SOURCE_VALUES = ['telegram', 'workatastartup', 'remoteok', 'arc', '4dayweek', 'adp', 'adpmyjobs', 'adzuna', 'aijobs', 'applicantpro', 'applitrack', 'arbeitnow', 'arbeitsagentur', 'ashby', 'ashbygraphql', 'avature', 'bamboohr', 'bayt', 'betterteam', 'breezy', 'briefhq', 'bullhorn', 'careerplug', 'careerspage', 'catsone', 'cleverstaff', 'clinch', 'comeet', 'compleo', 'cornerstone', 'crelate', 'cryptocurrencyjobs', 'dayforce', 'deel', 'djinni', 'dover', 'earcu', 'echojobs', 'edjoin', 'eightfold', 'enlizt', 'epam', 'erecruiter', 'eures', 'factorial', 'freshteam', 'functionalworks', 'geekhunter', 'geekjob', 'gem', 'getmanfred', 'getmatch', 'getonbrd', 'getro', 'gr8people', 'greenhouse', 'gulftalent', 'gupy', 'gusto', 'habr_career', 'hackernews', 'herp', 'hh', 'hibob', 'himalayas', 'hireology', 'hiringthing', 'hrmdirect', 'hrmos', 'humanbit', 'huntflow', 'hurma', 'icims', 'infojobs', 'inhire', 'instaffo', 'ismartrecruit', 'isolvedhire', 'itechart', 'japandev', 'jazzhr', 'jibe', 'jobappnetwork', 'jobdanmark', 'jobicy', 'jobleads', 'jobnet', 'jobscore', 'jobspresso', 'jobstash', 'jobstreet', 'jobtech', 'jobvite', 'jobylon', 'join', 'joppy', 'keka', 'landingjobs', 'lever', 'likeit', 'loxo', 'luxoft', 'manatal', 'mindsight', 'mycareersfuture', 'neogov', 'nodesk', 'nofluffjobs', 'northstone', 'odoo', 'opencats', 'oracle', 'pageup', 'paycom', 'paycor', 'paylocity', 'peopleforce', 'personio', 'phenom', 'pinpoint', 'powertofly', 'profession', 'pyjamahr', 'quickin', 'radancy', 'rapyd', 'recruitee', 'recruiterflow', 'recruitingsolutions', 'recrutei', 'reed', 'remotedotcom', 'remotive', 'remotli', 'rippling', 'scalis', 'schoolspring', 'seek', 'selfrecruit', 'senior', 'smartrecruiters', 'softgarden', 'solides', 'solidjobs', 'spark', 'speedrun', 'startupandvc', 'successfactors', 'talentadore', 'talenthr', 'talentlyft', 'taleo', 'teamex', 'teamtailor', 'techtree', 'tecla', 'thehub', 'themuse', 'topco', 'traffit', 'trakstar', 'trudvsem', 'tyomarkkinatori', 'ukg', 'ukgready', 'usajobs', 'vagas', 'vention', 'vouch', 'wantapply', 'wellfound', 'werecruit', 'weworkremotely', 'whatjobs', 'whatjobs-ae', 'whatjobs-ar', 'whatjobs-at', 'whatjobs-au', 'whatjobs-be', 'whatjobs-bh', 'whatjobs-br', 'whatjobs-ca', 'whatjobs-ch', 'whatjobs-cl', 'whatjobs-co', 'whatjobs-de', 'whatjobs-dk', 'whatjobs-eg', 'whatjobs-es', 'whatjobs-fi', 'whatjobs-fr', 'whatjobs-gr', 'whatjobs-hk', 'whatjobs-hu', 'whatjobs-id', 'whatjobs-ie', 'whatjobs-in', 'whatjobs-it', 'whatjobs-ke', 'whatjobs-kw', 'whatjobs-lu', 'whatjobs-mx', 'whatjobs-my', 'whatjobs-nl', 'whatjobs-no', 'whatjobs-nz', 'whatjobs-om', 'whatjobs-pe', 'whatjobs-ph', 'whatjobs-pk', 'whatjobs-pl', 'whatjobs-pt', 'whatjobs-py', 'whatjobs-qa', 'whatjobs-sa', 'whatjobs-se', 'whatjobs-sg', 'whatjobs-sv', 'whatjobs-th', 'whatjobs-tr', 'whatjobs-uk', 'whatjobs-ve', 'whatjobs-vn', 'whatjobs-za', 'workable', 'workablemarketplace', 'workday', 'workingnomads', 'workstream', 'wpyoast', 'zohorecruit'] as const; +export const SOURCE_VALUES = ['telegram', 'workatastartup', 'remoteok', 'arc', '4dayweek', 'adp', 'adpmyjobs', 'adzuna', 'aijobs', 'applicantpro', 'applitrack', 'arbeitnow', 'arbeitsagentur', 'ashby', 'ashbygraphql', 'avature', 'bamboohr', 'bayt', 'betterteam', 'breezy', 'briefhq', 'bullhorn', 'careerplug', 'careerspage', 'catsone', 'cleverstaff', 'clinch', 'comeet', 'compleo', 'cornerstone', 'crelate', 'cryptocurrencyjobs', 'dayforce', 'deel', 'djinni', 'dover', 'earcu', 'echojobs', 'edjoin', 'eightfold', 'energyjobline', 'enlizt', 'epam', 'erecruiter', 'eures', 'factorial', 'freshteam', 'functionalworks', 'geekhunter', 'geekjob', 'gem', 'getmanfred', 'getmatch', 'getonbrd', 'getro', 'gr8people', 'greenhouse', 'gulftalent', 'gupy', 'gusto', 'habr_career', 'hackernews', 'herp', 'hh', 'hibob', 'himalayas', 'hireology', 'hiringthing', 'hrmdirect', 'hrmos', 'humanbit', 'huntflow', 'hurma', 'icims', 'infojobs', 'inhire', 'instaffo', 'ismartrecruit', 'isolvedhire', 'itechart', 'japandev', 'jazzhr', 'jibe', 'jobappnetwork', 'jobdanmark', 'jobicy', 'jobleads', 'jobnet', 'jobscore', 'jobspresso', 'jobstash', 'jobstreet', 'jobtech', 'jobvite', 'jobylon', 'join', 'joppy', 'keka', 'landingjobs', 'lever', 'likeit', 'loxo', 'luxoft', 'manatal', 'mindsight', 'mycareersfuture', 'neogov', 'nodesk', 'nofluffjobs', 'northstone', 'odoo', 'opencats', 'oracle', 'pageup', 'paycom', 'paycor', 'paylocity', 'peopleforce', 'personio', 'phenom', 'pinpoint', 'powertofly', 'profession', 'pyjamahr', 'quickin', 'radancy', 'rapyd', 'recruitee', 'recruiterflow', 'recruitingsolutions', 'recrutei', 'reed', 'remotedotcom', 'remotive', 'remotli', 'rippling', 'scalis', 'schoolspring', 'seek', 'selfrecruit', 'senior', 'smartrecruiters', 'softgarden', 'solides', 'solidjobs', 'spark', 'speedrun', 'startupandvc', 'successfactors', 'talentadore', 'talenthr', 'talentlyft', 'taleo', 'teamex', 'teamtailor', 'techtree', 'tecla', 'thehub', 'themuse', 'topco', 'traffit', 'trakstar', 'trudvsem', 'tyomarkkinatori', 'ukg', 'ukgready', 'usajobs', 'vagas', 'vention', 'vouch', 'wantapply', 'wellfound', 'werecruit', 'weworkremotely', 'whatjobs', 'whatjobs-ae', 'whatjobs-ar', 'whatjobs-at', 'whatjobs-au', 'whatjobs-be', 'whatjobs-bh', 'whatjobs-br', 'whatjobs-ca', 'whatjobs-ch', 'whatjobs-cl', 'whatjobs-co', 'whatjobs-de', 'whatjobs-dk', 'whatjobs-eg', 'whatjobs-es', 'whatjobs-fi', 'whatjobs-fr', 'whatjobs-gr', 'whatjobs-hk', 'whatjobs-hu', 'whatjobs-id', 'whatjobs-ie', 'whatjobs-in', 'whatjobs-it', 'whatjobs-ke', 'whatjobs-kw', 'whatjobs-lu', 'whatjobs-mx', 'whatjobs-my', 'whatjobs-nl', 'whatjobs-no', 'whatjobs-nz', 'whatjobs-om', 'whatjobs-pe', 'whatjobs-ph', 'whatjobs-pk', 'whatjobs-pl', 'whatjobs-pt', 'whatjobs-py', 'whatjobs-qa', 'whatjobs-sa', 'whatjobs-se', 'whatjobs-sg', 'whatjobs-sv', 'whatjobs-th', 'whatjobs-tr', 'whatjobs-uk', 'whatjobs-ve', 'whatjobs-vn', 'whatjobs-za', 'workable', 'workablemarketplace', 'workday', 'workingnomads', 'workstream', 'wpyoast', 'zohorecruit'] as const; export type Source = (typeof SOURCE_VALUES)[number]; export const STAGE_VALUES = ['preparing', 'applied', 'screening', 'responded', 'interview', 'offer', 'accepted', 'rejected', 'withdrawn', 'expired'] as const; export type Stage = (typeof STAGE_VALUES)[number];