Versable's agentic scraping web server, per the owner. Three repos on the
versable-git org: extractor, extractor-webserver, extractor-regression.
Read the topology section first. There are two live deployments under this
name, not one, and only the older is documented anywhere. Which of them serves
extraction.versable.ai is unresolved and marked as such.
Sources: live GCP state read 2026-08-17 (project gen-lang-client-0060614394,
display name "versable-infra"); local checkouts of ../extractor and
../extractor-webserver; and versable-git/knowledge-base, an OKF bundle whose
last update is 2026-07-10 and which covers the VM deployment only.
1. What it is#
A scrape engine plus an operator console over it. extractor is a pure CLI: it
owns scrapers, resolvers, the pipeline engine, and a jobs/<NNN_NAME>/ folder
layout on local disk. extractor-webserver is a FastAPI app that shells out to
that CLI and adds an automation state machine, a self-heal loop, and a browser
console. extractor-regression runs periodic regression tests against the
scrapers.
Callers are speedway and walmart-mvp, plus the enhancement-product UI. Whether
walmart actually calls it today is doubtful: its extractor_api_key defaults
empty and the config comment says an unset key means stage 4 skips scraping
entirely rather than failing (walmart-mvp/backend/app/config.py:89-94).
2. Shape of the runner, and the two deployments#
Deployment A, the documented one. A single Compute Engine VM,
extractor in asia-southeast1-b, custom n2 with 4 vCPU and 16 GiB, external
IP 34.177.88.50. Two systemd units on it: extractor-webserver on :8000 and
extractor-regression on :8001, managed by ctl.py. The CLI engine is invoked
as a subprocess by both. Behind Cloudflare.
caller ──▶ Cloudflare ──▶ VM extractor (asia-southeast1-b) systemd extractor-webserver :8000 │ subprocess ▼ uv run internal_scripts.py the CLI engine │ ▼ jobs/<NNN_NAME>/dispatch/<run_id>/ meta.json · stdout.log LOCAL DISKDispatch is a detached local process with a run_id, and per-run state is files
on the VM's disk. There is no queue and no object store in this path.
Deployment B, undocumented. Two Cloud Run services in
australia-southeast1, from one image
data-extraction/app:47651fadf0c5fc56dba871f7b994d0a2878b4d9d, one service
account extraction-app@.
| Service | maxScale | CPU / mem | timeout |
|---|---|---|---|
extraction-api | 100 | 2 / 1Gi | 60s |
extraction-worker | 50 | 2 / 2Gi | 300s |
Both carry a pgbouncer sidecar
(container-dependencies={"app":["pgbouncer"]}) and a VPC connector, and
both keep instances warm, but not at the same floor: extraction-api runs
minScale=1 and extraction-worker runs minScale=10. Read live 2026-08-18
in versable-infra (gen-lang-client-0060614394, australia-southeast1); an
earlier version of this doc recorded minScale=1 for the pair and understated
what the worker costs, since ten instances stay billed whether or not a job is
running. Config: DATABASE_URL, STENT_DATABASE_URL, REDIS_URL,
SCRAPER_API_KEY, OXYLABS_USER, OXYLABS_PASS, all secret-injected, plus
GCS_BUCKET_NAME=versable-data-extraction. 39 revisions, image history to
2026-02-21.
So B is Postgres and Redis and object storage, split api from worker by role, on a private network, always warm. A is a single VM writing to local disk. These are different architectures wearing one name.
Unresolved: which one is extraction.versable.ai. The hostname resolves to
Cloudflare (172.67.179.185, 104.21.75.179), so the origin cannot be read
from DNS. The knowledge base says the hostname is "the webserver's public host",
which is A. But walmart calls /api/v1/extract, and extractor-webserver
mounts only /api/automation (automation/router.py:21), which A cannot serve.
Resolving this needs Cloudflare access or someone who knows.
3. Concern by concern#
Rows describe A where a citation exists, since that is the deployment with readable source. B's cells are live-config only and marked.
Paths are extractor-webserver/ and extractor/ in
/Users/alcatraz627/Code/Versable/, read at 4a4dfad and 4fe3b0f.
| # | Concern | Mechanism | Cite | Vendor |
|---|---|---|---|---|
| 1 | Runner/payload seam | a process boundary, not an import boundary: the console shells out to the CLI and reads its output files back | extractor-webserver/server.py:5,20-22 | no |
| 2 | Caller identity | one shared API key, timing-safe compared, accepted as an x-api-key header or a ?key= query parameter | extractor-webserver/automation/router.py:33-37, esp. :35 | no |
| 3 | Tenancy | none | ||
| 4 | Roles / RBAC | none | ||
| 5 | Submit surface | POST /api/automation/scrape-request, 202 | automation/router.py:83 | no |
| 6 | Job state ownership | module owns; caller polls | automation/router.py:249 | no |
| 7 | Derived vs stored status | stored, one meta.json per run on local disk | extractor/internal/dispatch.py:3-4,51 | no |
| 8 | Dispatch / queue | detached local subprocess in a new session, no queue at all | extractor/internal/dispatch.py:183-191 (start_new_session=True) | POSIX |
| 9 | Concurrency | --parallel=N per dispatch; several dispatches may run at once, with no global ceiling | extractor/internal/dispatch.py, README | no |
| 10 | Retry / backoff | a self-heal loop over failed scrapers, not a per-item retry budget | automation/router.py:127 | no |
| 11 | Heartbeat | PID liveness via /proc, so Linux-only; degrades on macOS dev | knowledge base, ports-and-deployment | Linux |
| 12 | Cancel | scrape-stop sends SIGTERM to the whole process group; the run finalizes as killed | extractor/internal/dispatch.py:294,313,320 | POSIX |
| 13 | Resume / checkpoint | none found | ||
| 14 | Idempotency | none found. A duplicate submit starts a duplicate scrape, which costs real Oxylabs spend | ||
| 15 | Storage of inputs | jobs/<NNN_NAME>/pipeline.json and job_items.json on local disk | extractor/README.md, dispatch.py:39 | local FS |
| 16 | Storage of outcomes | outputs/ per job, JSON and Excel | extractor/README.md | local FS |
| 17 | Results reporting | GET /jobs/{job_folder}, GET /status, bulk POST /jobs/status | automation/router.py:203,249,330 | no |
| 18 | Logs per job and item | one stdout.log per run, appended to by the detached process, tailable via scrape-logs --follow; GET /runs/{id}/events | extractor/internal/dispatch.py:179-187,257-262; automation/router.py:397 | no |
| 19 | Tracing | none found | ||
| 20 | Usage metering | none found | ||
| 21 | Limits / quotas | none found | ||
| 22 | Caching | anti-bot and cache layer inside the engine | extractor/ (per knowledge base) | no |
| 23 | Rate limiting outbound | Oxylabs concurrency handled in the engine | no | |
| 24 | Config | .env beside server.py, load_dotenv at import, every value an os.environ.get with a default | extractor-webserver/server.py:12,18,20-26 | no |
| 25 | Secrets | a .env file on the host. API_KEY defaults to a fresh random token when unset, so a misconfigured start is locked rather than open | server.py:23 | no |
| 26 | Human in the loop | interactive stages: POST /runs/{id}/action and /gate to approve or reject | automation/router.py:412,446 | no |
| 27 | Completion signalling | poll only. Grepping the whole console for webhook, hmac, signature and callback finds only the Slack report path; no signed job callback exists here | automation/router.py:249; server.py:1067 (Slack) | no |
| 28 | Capability discovery | /docs Swagger | automation/router.py:4 | no |
| 29 | Versioning | none found | ||
| 30 | Health / readiness | /health per systemd unit | knowledge base | no |
| 31 | Provisioning | systemd units driven by ctl.py | extractor-webserver/ctl.py | systemd |
| 32 | Local dev | the CLI is the local mode; the console points at ~/extractor by default | server.py:20-22 | no |
| 33 | Output delivery | Excel and JSON under outputs/ | extractor/README.md | local FS |
| 34 | Data retention | none found | ||
| 35 | Multiple versions | two deployments coexist under one name, this one and the Cloud Run pair | live state | no |
| 36 | Conformance | extractor-regression runs periodic regression tests against the scrapers, the closest thing in the estate to a conformance suite | knowledge base | no |
| 37 | Outputs and exports | deployment A writes Excel and JSON under outputs/ per job on local disk and serves them by reading the files back, with no declared transform and no partial-read semantics; pulling results also mutates, because the first pull wins. Deployment B not examined, its source is not on this machine | extractor/README.md, speedway/app/lib/extractor.server.ts:11-12 | local FS |
| 38 | Data ownership split | deployment A has no database at all: per-run state is files on local disk under jobs/<NNN_NAME>/, and the caller owns everything else, which is why the caller polls. Deployment B is Postgres plus Redis plus object storage with api and worker split by role, but its source is not on this machine, so the split is known from live container config only and no evidence file exists for it | dispatch.py:39, extractor/README.md, live state | local FS / Postgres + Redis + GCS |
Deployment B, live config only#
No source. Every cell is read from the running container and nothing here rests on code. Kept separate rather than mixed into the table above, so no reader mistakes a live-config observation for a verified mechanism.
| Concern | Observed |
|---|---|
| topology | extraction-api (maxScale 100, 2cpu/1Gi, timeout 60s) and extraction-worker (maxScale 50, 2cpu/2Gi, timeout 300s), one image, one service account |
| storage | DATABASE_URL, STENT_DATABASE_URL, REDIS_URL, all secret-injected; GCS_BUCKET_NAME=versable-data-extraction |
| network | VPC connector, pgbouncer sidecar, minScale 1 on extraction-api and minScale 10 on extraction-worker (live, 2026-08-18) |
| provisioning | image tagged by git SHA; no Cloud Build triggers in-project, so CI lives elsewhere |
4. Runner vs payload#
The seam is a process boundary, which is unique in the estate.
extractor-webserver never imports the engine; it shells out
(EXTRACTOR_CMD = "uv run python internal_scripts.py", server.py:22) and
reads the engine's output files back.
That buys real isolation: the engine cannot corrupt the console's state, the console can be restarted without touching a running scrape, and the engine stays a usable standalone CLI. It costs everything a type system or a function signature would give you. The contract is argv plus a folder layout, and it is enforced nowhere.
Runner: server.py, automation/{router,models,config}.py, ctl.py.
Payload: the whole extractor repo, plus ext/extractors/ and ext/resolvers/
within it.
Leak worth naming: the console reaches into the engine's private layout.
JOBS_DIR defaults to ~/extractor/jobs (server.py:21), so the console reads
per-run meta.json and stdout.log directly rather than through any engine
API. The process boundary is clean in one direction and absent in the other.
5. Deliberate decisions#
The engine stays a CLI, and the service is a wrapper. Every capability is
reachable by a human at a terminal without the service running. This is why the
engine has an ONBOARDING.md and a scraper-builder workflow, and why a
developer can debug a scrape without touching the console.
Per-job opt-in strictness. strict_match_criteria is a pipeline-arg flag
that tightens resolver matching per job, off by default, documented with the two
symmetric failure modes that motivated it and a table of when it is a no-op
(extractor/CLAUDE.md). The reasoning is recorded next to the knob, which is
rare.
A regression service as a separate deployment. extractor-regression exists
because scrapers break when sites change, and it is the only thing in the estate
resembling canon row 36.
Human gates are first-class. POST /runs/{id}/action and /gate drive
interactive stages with approve and reject. Canon 26 is open; this is a real
implementation of it.
6. Lapses#
Frequent polling for job status (owner-reported, via gcp-fable), but the capability is not simply absent, and an earlier version of this doc said it was.
Deployment A does not deliver callbacks, and this is now grepped rather than
assumed. Searching the whole console for webhook, hmac, signature and
callback returns only the Slack report path (server.py:1067). The only
completion signals it offers are GET /status and GET /runs/{id}.
The surface speedway talks to does deliver callbacks:
speedway/app/lib/extractor.server.ts:5-9 documents a service that takes one
site plus a SKU list per job, returns 202 with a job_id, and "delivers rows
by webhook and/or polling", HMAC-SHA256 signed. Speedway receives them at
app/routes/tasks/extractor-webhook.tsx:20-21, verifying an x-signature
header. That is a third surface, distinct from A's /api/automation and from
the /api/v1/extract walmart names.
So the owner's polling complaint is real but surface-specific, and the tree should stop treating "the extractor" as one thing with one answer.
Three things about that webhook are worth canon's attention:
- The signing key is the API key.
extractor.server.ts:8-9says the webhook is "HMAC-SHA256-signed with that same key", and:252confirmscreateHmac("sha256", config.extractorApiKey).contracts/module-surface.md:150specifies HMAC "with the caller's callback secret", a separate value. Reusing the auth credential as the signing secret means a caller cannot rotate one without breaking the other, and anyone holding the API key can forge a callback. - There is no event id. The contract requires
X-Foundry-Event-Idfor receiver idempotency (module-surface.md:151). Onlyx-signatureis verified. - A read mutates.
extractor.server.ts:11-12: pulling/jobs/{id}/results"marks the job consumed remotely (first pull wins), never call it as a liveness poll; poll/jobs/{id}instead". AGETthat is not idempotent and is destructive on second call is worth an explicit contract prohibition; no doc currently carries one.
Only one environment (owner-reported). No dev or staging deployment of the
VM path. Canon 12 and 31 require three environments minimum with separate
identity per environment. This is the most severe instance of that lapse in the
estate: versable-runner at least has a -dev service, even if it shares prod's
service account.
The API key is accepted as a query parameter (automation/router.py:35,
request.query_params.get("key")). Query strings land in access logs, browser
history, and referrer headers in a way headers do not. The header path is right
there in the same expression; the query fallback is the lapse.
No tenancy (3), no roles (4), no metering (20), no limits (21), no versioning (29), no idempotency (14). A duplicate submit starts a duplicate scrape, which costs real vendor money at Oxylabs.
Two deployments under one name (35). Nobody can currently state which one serves production traffic. This is the third accidental instance of row 35 in the estate, after versable-runner's two forks and walmart's two pipelines.
The knowledge base documenting this stack is five weeks stale and describes only deployment A. It is a real artifact and it holds at least one expensive fact (see Unproven), but a reader trusting it would not learn that Cloud Run services exist.
7. Unproven#
-
Which deployment serves
extraction.versable.ai. Cloudflare hides the origin. The knowledge base says the VM; the route walmart calls (/api/v1/extract) exists on neither surface I read. Unresolved. -
Deployment B's source is not in the three named repos, and this was checked. The owner named three repos prefixed
extractoron theversable-gitorg. All three were obtained:extractorandextractor-webserverwere already local,extractor-regressionwas cloned read-only 2026-08-18. Grepping all three for deployment B's distinguishing markers (DATABASE_URL,REDIS_URL,STENT,pgbouncer,api/v1/extract) returns zero hits in any of them.So the three repos are deployment A's stack: the CLI engine, the operator console on :8000, and the regression service on :8001. Deployment B, the Cloud Run pair with Postgres, Redis and a pgbouncer sidecar, is a fourth codebase and its source has not been located. Every B cell in the table above is live container config (image, scaling, secrets, sidecar, network) and no B claim rests on source.
Anyone extending this doc should ask the owner where the
data-extractionimage is built from rather than re-searching those three. -
Whether walmart-mvp calls this service at all in practice. Its key defaults empty and the documented behaviour of an unset key is to skip scraping.
-
The Cloudflare User-Agent gotcha is inherited, not verified. Per the knowledge base log (2026-07-07), Cloudflare bot rules 403 datacenter IPs sending a default library UA (axios,
python-requests, curl), and callers must send a custom one. Not reproduced here. It is the single most useful fact in that bundle and worth keeping regardless of the bundle's staleness. -
extractor-regressionwas not examined. Row 36 rests on the knowledge base's description. -
Rows 11, 22, 30 and 36 are transcribed from the knowledge base rather than from files opened this pass.
-
The local
../extractorcheckout is at4fe3b0f(2026-06-17) while the remote moved on 2026-06-22, so engine claims may be slightly behind. -
Nothing was executed. No scrape was dispatched, no endpoint was called.