I run a visa agency in Bali. The other half of my week goes into scrapers on Apify, and one of them reads SEEK — the job board that owns hiring in Australia and New Zealand. The site has a public search API it uses for its own result pages, it answers plain HTTP from a datacenter IP, and it hands back one flat JSON object per advert. No browser, no login, no CAPTCHA. Of all the sites I scrape, SEEK is the polite one.

Which is why the interesting part of this Actor is not the fetching. It is the one field everybody actually wants: the salary.

Every other column on a job row is categorical — title, company, suburb, work type, classification. You group by them. Salary is the only column you do arithmetic on, and the only one SEEK does not store as a number. It stores a string that a recruiter typed into a box.

So I audited that string across a month of my own runs.

The panel: the same search, 41 times

I did not need a new run for this. The Actor has been testing itself daily with one fixed input since 10 October: registered nurse, Sydney NSW, relevance order, 50 rows. Reading back my own datasets is free, so I pulled all of them.

  • 41 runs, 10 October – 10 November 2026
  • 1,966 job rows
  • 275 distinct advert ids

That ratio is the first thing worth knowing before you schedule anything. A daily top-50 on a stable query re-buys the same adverts over and over: 1,966 rows billed, 275 jobs learned. New ids per day averaged 9.7 of 50, and one advert — a Registered Nurse post at Mater Hospital Sydney — held a top-50 slot in 37 of the 41 runs. id is stable, so deduplicating against what you already stored is the single biggest cost lever in this Actor. At $1.00 per 1,000 rows the whole month cost under two cents, but the same arithmetic at 5,000 rows a day is real money.

The second thing: relevance order is not recency. Across all 1,966 rows the median advert was 8.7 days old at read time, 55% were older than a week, and only 11% were posted in the previous 24 hours. Nothing in the panel was older than 30.0 days — SEEK expires adverts on a 30-day clock, and 37 rows sat right at the edge of it. If you are monitoring a market rather than browsing it, set sortBy: date and postedWithinDays: 1 and stop paying for the same fortnight-old hospital post.

What 164 salary strings actually look like

Of the 275 adverts, 111 publish no pay information at all. The remaining 164 publish a string. Here is a fair sample, copied byte for byte out of the dataset:

$81,748 – $114,771 per year $41.58 – $58.40 per hour $47.13 - $56.87/hr + Super + NFP benefits $49.03 - $62.93 p.h. + super & loadings $41.58 – $58.40 ph plus super RN Level 1 $82,945 - $106,630 p.a. pro rata $850-$975p/d Up to $94.50-$200 an hour + Super (RN8) $140k-$170k+Super 44.27 to 56.79 p.h. plus super and benefits NFP Salary Packaging Benefits | 12% Superannuation Annual Salary + 12% Super + 6 Weeks AL (pro rata) Above award rate Competitive $1 – $2 per year 

Eighteen of the 164 contain no usable figure at all — "Competitive", "Above award rate", "Not for profit salary packaging". One advertiser filled the required field with $1 – $2 per year, which is the job-board equivalent of writing "see below".

My Actor has always split that string into salaryMin, salaryMax, salaryCurrency and salaryPeriod, and until this week it did it with one regex and one period test. Auditing 164 real strings instead of the dozen I had in my head broke it four separate ways.

Break 1: the $146,945,000 analyst

label: "$133348.0k - $146945.0k p.a. + plus super" salaryMin: 133348000 salaryMax: 146945000 

A Senior Data Analyst at the NSW Department of Customer Service, paid a hundred and forty-seven million dollars a year. The advertiser had already expanded the thousands and then added the k anyway. My parser saw k and multiplied — a thousand times too much.

The fix is a magnitude check, not a cleverer regex: k means thousands only when the number in front of it is below 1,000. Nobody writes $80k meaning eighty million, and nobody writes $133348.0k meaning a hundred and thirty-three billion. One row in 275 looks negligible until you average a column — this single row shifted the mean annual salary in my panel by six orders of magnitude.

Break 2: sixty-four adverts that never say "per hour"

The period test was /hour/, /day/, /week/, /month/, /year|annum|p\.a\.|annual/. Australian recruiters do not type those words. They type p.h., ph, /hr, p/h, p/hr, an hour, hourly rate, p/d, /yr.

Sixty-four of the 164 priced adverts carried an abbreviation my test did not know, so they arrived with correct numbers and salaryPeriod: null. That is the worst possible failure mode for analytics: 47.13 and 81748.17 in the same column with nothing to tell them apart. Any naive median over that column is meaningless, and it looks perfectly healthy in a spreadsheet.

The abbreviations now live in one table of five patterns, with one trap worth repeating. A plain \bph\b does not match $70.87ph(AM), because a digit and a letter are both word characters — there is no boundary between 7 and p. It needs a lookbehind for letters instead: (?<![a-z])p\.?\/?h\.?r?(?![a-z]). And the pattern must not fire on the word every second Australian advert contains — "Salary Pack*aging", "Superannu*ation". Those two words are why I tested against all 164 strings rather than eyeballing the regex.

Result on the same 275 adverts: salaryPeriod filled on 123 instead of 100.

Break 3: superannuation is not a salary

"NFP Salary Packaging Benefits | 12% Superannuation" -> salaryMin 12, salaryMax 12 "Annual Salary + 12% Super + 6 Weeks AL (pro rata)" -> salaryMin 12, salaryMax 6 

Two rows reported a nurse earning twelve dollars, because the only numbers in the string were a pension percentage and a leave entitlement. The second one also returned a salaryMax lower than its salaryMin, which is the kind of row that silently inverts a chart.

Numbers followed by % are now dropped, numbers followed by a leave unit (6 Weeks, 38 hours) are dropped, and if a pair still comes out reversed it gets swapped. Both of those adverts now correctly report no salary at all, which is the truth: they advertise benefits, not pay.

Break 4: $41.58 is not $42

The old parser rounded to whole units. In healthcare, where award rates are negotiated to the cent and half the state's nurses sit on the same enterprise agreement, $41.58 – $58.40 per hour became 42 – 58. Eleven of the 50 rows in a single live run lost their cents. They are kept now, to two decimals.

That one change cost me a build. Apify validates every pushed row against the Actor's dataset schema, and mine declared:

"salaryMin": { "type": ["integer", "null"] } 

The run came back SUCCEEDED in five seconds with zero job rows and one error row reading Schema validation failed. No stack trace, no field name. If you keep a dataset schema on an Apify Actor, remember that loosening a parser is also a schema change — and that a successful run with an empty dataset is exactly what a buyer would have seen if I had promoted that build. This is the entire argument for building to a next tag and only moving latest after a verification run; the broken build never reached a customer.

Before and after, same live search

Both runs asked for the same 50 adverts, minutes apart:

build 0.1.21 build 0.1.24
rows with salary text 31 31
of those, salaryPeriod filled 23 26
of those, cents preserved 0 11

And across the whole 275-advert panel: numbers on 146, period on 123, both on 116. That last figure is the honest one to design against. Ask SEEK for a thousand adverts and roughly 42% of them will give you a salary you can do arithmetic on — not because the scraper is weak, but because the other 58% either say nothing, say "Competitive", or name a figure without a unit.

Running it

{ "keywords": ["registered nurse"], "location": "Sydney NSW", "country": "AU", "maxJobsPerSearch": 500, "sortBy": "date", "postedWithinDays": 7 } 
curl -X POST "https://api.apify.com/v2/acts/lergassy~seek-jobs-scraper/run-sync-get-dataset-items?token=YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{"keywords":["registered nurse"],"location":"Sydney NSW","sortBy":"date","maxJobsPerSearch":100}' 

A row from that run, trimmed:

{ "id": "95221421", "title": "Registered Nurse", "companyName": "St Vincent's Hospital Sydney", "location": "Darlinghurst, Sydney NSW", "salary": "$81748.17 - $114771.98 p.a. + super", "salaryMin": 81748.17, "salaryMax": 114771.98, "salaryCurrency": "AUD", "salaryPeriod": "year", "workType": "Full time", "workArrangement": "On-site", "classification": "Healthcare & Medical", "subclassification": "Nursing - Theatre & Recovery", "listedAt": "2026-10-13T00:17:04Z", "listedAgo": "28d ago" } 

Search rows cost $1.00 per 1,000 and arrive 100 per request — the 50-row run above finishes in about five seconds. The full advert text is a separate add-on at $0.50 per 1,000 because it is one extra request per job; skip it unless you need the description, since the search row already carries title, company, location, salary, type, industry, date and teaser. The Actor is SEEK Jobs & Salary Scraper, and the parser described here is build 0.1.24.

When this is the wrong tool

  • You want phone numbers or recruiter emails. SEEK does not publish them on the advert, so there is nothing to collect.
  • You want every nursing job in Australia in one run. A single search tops out after a few thousand results; split by city or by classification and run them as separate keywords.
  • You want a clean salary column with no nulls. It does not exist on this site. 111 of my 275 adverts named no money at all, and no scraper can invent it. Keep salary next to salaryMin so a human can see what the advertiser actually wrote.
  • You want pay benchmarks from a single query. Filter to salaryPeriod != null first, then compare within one period. Mixing an hourly casual rate and an annual award figure in one average is the fastest way to publish a wrong number.

The lesson I keep relearning on every one of these Actors: the fetch is the easy half. The expensive half is a free-text box and three hundred people typing into it.