Repository navigation
Releases: 2scraper/justjoin-scraper
Release list
v0.1.4
Behaviour changes for existing users.
--route ssrnow refuses, with
exit 2, any filter other than category, city, remote and keyword (it used
to accept them and ignore them).diff_runs.pynow refuses a run with no
sidecar unless--forceis passed.--retries 0is refused.CSV output now prefixes a cell that starts with
=,+,-,@, a tab, CR or LF with an apostrophe (JSON is
unchanged), and the sidecar gainscsv_cells_escaped..env.example
no longer setsJUSTJOIN_URL; aJUSTJOIN_URLfrom the environment that
names a different mode than--modeis now ignored with a log line
instead of exiting 2.
Fixed
-
An offer queue no longer ends at the first delisted offer (all three
engines). A 404 on one offer was read as the end of the run. -
--mode offerworks in Selenium and pyppeteer, and pyppeteer no
longer crashes without a proxy pool. -
--route ssrno longer accepts filters it ignores, and no longer
records a sort, order or total the page does not carry. -
--mode facetsis partial when a group is missing, and the parity
engines now request the categories. -
diff_runs.pyrefuses runs without a sidecar, and tells an absent
filterskey from an empty one. Engines always recordfilters. -
The Scraper API client writes a sidecar and picks the row class by
mode (its facets CSV had the wrong header). -
Output writes are atomic. A failed write no longer replaces the
previous good JSON, CSV or sidecar with a truncated one. -
CSV cells are formula-escaped. Every string in a row is written by an
employer, and a spreadsheet runs a cell starting with=,+,-or
@as a formula. Escaped in the CSV only, after list cells are joined;
numbers stay numbers and JSON keeps the site's bytes. The count goes in
the sidecar ascsv_cells_escaped, under any caller-provided field of
the same name. -
JUSTJOIN_URLno longer breaks--mode offerand--mode facets.
The listings URL that.env.exampleshipped was read as if--urlhad
been typed, so a copied example made both modes exit 2. The engines now
record what was typed before reading the environment; only a typed URL is
checked against--mode. The example now shows the variable commented
out. -
diff_runs.pysees changes in a facets run. It compared the job
columns on facet rows, which have none of them, so anoffer_count
going from 100 to 250 reported "0 changed". Facets runs are now compared
ontitle,offer_count,is_cumulativeandoverlaps_other_keys. -
The credential scan works from any parent directory. Its skip list
was matched against the absolute path, so a clone under a directory
namedbuild,envorvenvscanned 0 files and reported clean. It now
matches the path inside the repo, and the suite runs the scan from such a
copy with a planted key. -
--dump-htmlhelp pointed at aTROUBLESHOOTING.mdthis repo does not
have; it now points at the README section on traps that look like bugs. -
Engine comments said the paid captcha path runs only for a
challenge
state that does not exist here; they now describe what
page_flow.STATE_POLICYdoes. -
.gitignorecovers--dump-htmlpage files of any extension
(*.page[0-9]*), alive/scratch directory and HTML dumps at the root. -
CI tests Python 3.13, which the README badge already claimed.
-
Comments, docstrings and changelog entries no longer cite internal notes
that are not public; each rule is stated in place. A suite check keeps it
that way. -
Scraper API:
waitForis now sent as a JSON object. Measured
2026-09-23 againstscraper.2captcha.com/tasks/sync: the JSON-encoded
string this client sent (the form older docs described) is refused with
HTTP 422 "params.waitFor must be an object" -- and the task is still
billed ($0.0005) -- so every run with--wait-textor its sibling wait flags
failed with exit 5. The object form answers HTTP 200. -
Scraper API: the target page's status is read from
http_code. The
response'sstatusfield is the API's own verdict string ("success"),
not the target site's HTTP code, so a target 403/503 was never seen by
this client.http_code(an int) is read now, withstatuskept as a
fallback only when it is an int.
Leftovers from the repos this one was bootstrapped from, each describing
another site as if it were this one:
-
CONTRIBUTING.mddescribed Mercor's sources, markers, captcha, live-run
notes and scope (and Wellfound's pagination and pay strings). Rewritten
for justjoin.it from this repo's own README measurements. -
SECURITY.mdasked for "mercor-scraper security" in the subject line and
named Mercor in the out-of-scope list; it also said the project has no
releases, and pointed at a README section that does not exist. -
The issue templates were Etsy's: DataDome advice,
etsy.comexample
URLs, a JSON-LD evidence recipe for a site whose parser reads JSON, and
links to aTROUBLESHOOTING.mdthis repo does not have. -
requirements.txtwas headed# mercor-scraper. -
.gitignoreand.dockerignoreignoredwellfound_jobs.*instead of
this repo's own default output prefixjustjoin_jobs. -
smoke_test.py: the sidecar check used Mercor run metadata
(mercor.com,/explore, 390 records); it now uses a real
--route ssrsidecar from this repo. The suite's--helpdescription
said "mercor-scraper offline suite", and three comments described
Mercor's routes as this site's. -
captcha_solver.py's docstring pointed at a "No DataDome solver" section
that does not exist in this repo (it came with the copied core). Removed.
v0.1.3 — audit fixes
An audit against this family's own checklist, run after publishing
rather than before it. Everything here was found by a check or a live run,
not by reading.
Fixed
scraper_api_client.py reported an upstream failure as an empty board.
It did not consult page_flow.should_parse() the way the three browser
engines do, so a body the site never served came back as Parsed 0 job(s)
and exit 4 — "the query matched nothing". It now names the state, saves
the body and exits 5. Blocked, empty and failed are three different
answers.
A latent NameError in the same client — EXIT_NO_PRODUCTS used on
the new not_found branch and never imported. Caught by the suite's
undefined-name walk, and the control was verified: planting the fault makes
the check name that exact symbol.
--url was validated against HOST while HOSTS sat unread. The
tuple is the single source of truth now rather than decoration.
Corrected
The README had the Scraper API backwards. It said the service suits the
rendered listing page better than the endpoint — reasoned, not measured.
Measured 2026-09-18:
--url |
result |
|---|---|
/api/candidate-api/offers?… (47 KB) |
HTTP 200 in 12s, 20 rows, $0.0005 |
/job-offers/all-locations (1.8 MB) |
HTTP 408 at the default 60s |
the same page at --timeout 120 |
API 200, upstream HTTP 500, 0 bytes |
Point it at the endpoint.
An inherited figure replaced with a measured one. A comment repeated a
"17 of 18 repos" count for the fingerprint flags. Re-counted across the 26
siblings: 25 of 26 — with the command beside it, so the next reader
re-measures instead of inheriting.
Verified live, with a real 2Captcha key
--fingerprintapplies what it fetches. Four defects have shipped on
this path across this family; none is present here. A PL fingerprint
reached the browser intact — user agentWindows NT 10.0 … Chrome/151,
localepl-PL(noten-PL), timezoneEurope/Warsaw— and
--fp-tags Windowsis accepted by the API.- Credentials never reach a log, including an exception message. A dead
proxy carryingsecretuser:secretpass123logged
http://***:***@127.0.0.1:9— host and port kept, both credentials
masked, zero occurrences of either anywhere in the run — and was named a
proxy failure rather than a timeout. Identical on all three engines.
597 offline checks, up from 591.
v0.1.2 — walk the sitemap newest-first
Fixes
--mode offercoming back empty on a site that is serving
perfectly. If a small offer run gave you zero rows, this is that.
The canary found it — the second thing it has found in two runs, which is
what a canary that is not gated on a secret is for.
justjoin.it serves sitemaps/active-jobs/part0.xml oldest first (its
first lastmod was 2026-09-14 and its last 2026-09-18) and generates it
ahead of any fetch, so its head is where offers that have since been taken
down collect. Measured by asking the endpoint for a sample of each end:
| sample | alive | gone |
|---|---|---|
| first 12 entries | 10 | 2 |
| last 12 entries | 12 | 0 |
| 12 at random | 12 | 0 |
So --mode offer --pages 3 was being pointed at the three likeliest-dead
addresses in the file.
The enumeration is now sorted by lastmod descending — which is also what
--pages N should have meant all along: the N most recently updated
offers, not the N most stale. Entries with no lastmod are kept and sort
last rather than being dropped, and the document order is still available
so a check can assert the sort loses nothing.
The offer canary asks for five offers rather than three, so one delisted
offer is an ordinary event rather than a red check — while zero rows out of
five still means something real broke.
591 offline checks, up from 586.
v0.1.1 — a delisted offer is a 404, not a parse error
Fixes a wasted fetch and a spurious debug dump in
--mode offer. If
you sawPage N came back as parse_erroron an offer run, this is that.
The canary found it on its first run — which is the point of a canary that
is not gated on a secret.
justjoin.it's sitemap is generated ahead of the fetch, so an offer can be
taken down in between. The first slug in active-jobs/part0.xml had
been. All three engines were discarding what the navigation returned, so
the site's 404 reached the classifier as status=None, fell past the
not_found branch and landed on parse_error — a retry, a debug dump and
a wasted fetch for an address that will never exist again.
Two fixes, because one of them cannot cover every engine:
- Playwright and pyppeteer now bind
response.statusand pass it
through. - Selenium cannot.
driver.get()returns None and WebDriver exposes no
HTTP status at all. Soproduct_parser.problem_statusreads the status
out of the site's own RFC 7231 problem document, which justjoin.it states
in the body of every 404. That reader is what makes the three engines
agree rather than two of them being better informed than the third.
Verified live against the real delisted offer: all three now report
not_found, exit 4, no retry and no dump. Pinned by a check that asserts
both the body reader and that each engine actually threads a status, so
the fix cannot go missing from a twin.
586 offline checks, up from 573.
v0.1.0 — justjoin.it scraper
First release. Reads justjoin.it, the Polish IT job
board, through Playwright, Selenium, Puppeteer or the 2Captcha Scraping
Browser API over CDP.
pip install -r requirements.txt -r requirements-playwright.txt
python3 -m playwright install chromium
python3 playwright_scraper.py --category python --pages 5 --format csvThat command needs no account, no key and no proxy.
Three modes
--mode listings(default) — the board through justjoin.it's own
offers endpoint, with offset pagination and nine filters measured to
change what the site sends.--mode offer— one offer in full: the description body, per-skill
levels, the company website and size, the country. Enumerated from the
site's own sitemap (10,646 offer URLs), from--url, or from a previous
run's output via--slugs-file.--mode facets— the board's own taxonomy with its own counts, in a
second row schema. These counts are not capped, which is what makes
them the tool for planning runs that reach past the per-query ceiling.
What running it found
All measured 2026-09-18 from a netcup VPS in Nuremberg (AS197540).
Nothing on the read path is gated. HTTP 200 to curl with no headers at
all, to python-requests, to wget and to a headless Chromium. Sixteen
candidate bot-challenge markers counted at zero across seven captures. One
exception, and it is a denylist entry rather than a gate: a
Python-urllib/* User-Agent gets HTTP 403.
The captcha is reCAPTCHA v2 — on the application form, and on no route
this scraper reads. The site publishes its own key
(googleRecaptchaV2Key), and the only string referencing it lives in the
job-application form's i18n namespace. In a live browser window.grecaptcha
was undefined on a rendered listing, an offer page and the endpoint alike:
the script is never even loaded on the read path.
The endpoint the front end calls is not the host the page config names.
api.justjoin.it — the site's own baseApiUrl — answers HTTP 503 from
nginx to everything. The route that works is the same-origin proxy at
justjoin.it/api/candidate-api/.
The salary is not employmentTypes[0]. The employer's own figure
carries currencySource: "original" and sits elsewhere on 321 of 1,000
listing rows and 29 of 40 detail records. On the detail route 16 of those 40
would get a wrong number, not just a wrong currency — 39.15 CHF printed
for a job offering 180 PLN.
from is the rate normalised to a month while unit names the
employer's unit. Reading the two adjacent keys together — the obvious
thing to do — says "18,480 PLN per hour" for a job paying 110 an hour, on
307 of 582 quoted entries. The scalar columns carry the quoted rate; the
monthly figure lives in columns that say salary_monthly_*.
?page=2 on a rendered listing returns page 1 again under HTTP 200, so
page_url() refuses to invent one and --route ssr plans a single page.
Every query is capped at 10,000 while the board held 19,381, and
from=10000 is an HTTP 500 rather than an empty page. The sidecar records
the whole arithmetic, because "complete" and "exhaustive" are different
words.
The default ordering is not a paid placement — checked rather than
assumed. isSuperOffer was spread through the result (9–34 per hundred at
five offsets) rather than banked at the top, so the scraper keeps the site's
own default sort. The sort is recorded, because on a capped result set it
decides which offers are in the file: two orderings of one query overlapped
on 5 of their first 100 ids.
Two defects fixed in the family core
Both inherited, both found by running the code rather than reading it.
- The two parity engines called
mask(pool.current)unguarded in the
block-retry path while the Playwright engine guarded it, so any retryable
state on a run without--proxydied withAttributeErrorinstead of
retrying. scraper_api_client.pycould not import at all — it called a parser entry
point that had been renamed, so--helpitself was anImportError.
Caught by the suite's signature-binding check.
Verified
All three engines produce byte-identical rows and identical sidecars for
the same command. 573 offline checks, and a daily canary that runs a real
three-page scrape from a bare GitHub runner with no secrets — expected to be
green, because that is what keeps the README's central claim honest.