This service uses puppeteer(to be deprecated), playwright and axios to crawl pages.
It allows to scroll, take screenshots and get images encoded as Base64.
Out of scope: it allows to get apps from playstore, in the future it will be migrated to a new service.
Dev environment
npm start
or
make run
A Dockerfile is provided which install chrome inside the container.
A docker-compose.yml is provided for local use. It builds the image and keeps the crawled
cookies in a named volume. Locally it runs with JWT_ALG=HS256 and a shared secret, so there is
no keypair to generate:
make compose-up # or: docker compose up --build
make token # a token the running container accepts
make compose-down
make token defaults to the same JWT_ALG=HS256 / JWT_SECRET=dev-secret-change-me the compose
file uses, and both read .env, so overriding the secret in one place keeps them in sync. For the
ES512/production path:
./scripts/generate_keys.sh # creates .secrets/{private,public}.key
JWT_ALG=ES512 JWT_SECRET=.secrets/public.key make token # signs with JWT_PRIVATE_KEY
The service listens on http://localhost:3000 (loopback only) with the Swagger UI at
/docs. Any of WEB_PORT, WEB_TIMEOUT, JWT_ALG, JWT_SECRET and
CRAWLER_DOCS can be overridden from a .env file next to the compose file, or inline — use this
if something else already holds port 3000:
WEB_PORT=3010 docker compose up --build
The container runs with init: true and shm_size: 1gb; Chromium leaves zombie processes behind
and crashes on Docker's default 64MB of shared memory.
WEB_PORT = 3000
WEB_TIMEOUT = 150 # segs
JWT_SECRET = my-secret-hash
JWT_ALG = HS256
PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium-browser
HEADLESS = true
CRAWLER_DOCS = false # set to "true" to serve the Swagger UI at /docs
LOG_HEALTHCHECK = false # set to "true" to also log the container healthcheck and /metrics scrapes
V7_EXECUTOR = false # set to "true" to mount /v7/executor - it runs caller-supplied code, read that section first
V7_EXECUTOR_CONCURRENCY = 2 # scripts at once; each one owns a browser
V7_EXECUTOR_MEM_MB = 512 # heap cap for the executor child process
V7_EXECUTOR_MAX_SCRIPT_BYTES = 65536
V7_EXECUTOR_MAX_RESULT_BYTES = 1048576
REDIS = redis://127.0.0.1:6379 # with localhost it tries ipv6, redis is disabled
COOKIES_STORE = fs # fs | redis - where the cookie jars live
COOKIES_PATH = data/cookies # fs store only
COOKIES_REDIS_URL = redis://127.0.0.1:6379 # redis store only, falls back to REDIS
COOKIES_PASSWORD = # optional, read from REDIS_PASSWORD
COOKIES_TTL = 7200 # redis store only, seconds a jar survives
useCookies keeps the browser context cookies between calls, keyed by the hostname of the
url and, in v7, by sessionId. Where those jars are kept is an operator decision:
COOKIES_STORE |
Where | Notes |
|---|---|---|
fs (default) |
COOKIES_PATH/<domain>, COOKIES_PATH/sessions/<sessionId>/<domain> |
Per instance. Needs a volume to survive a restart |
redis |
cookies:<sessionId or "shared">:<domain> |
Shared by every instance, expires by COOKIES_TTL |
redis is what to pick when the service runs several replicas or on an ephemeral
filesystem: a jar written by one instance is readable by the next, and jars expire on
their own instead of piling up as dead files.
⚠️ Sharing jars across instances is what version 5 did and what version 6 deliberately moved away from (see the 2023-08-10 changelog entry): if the replicas sit behind different egress IPs, replaying one session's cookies from several addresses is exactly what anti-bot systems look for. Userediswhen the replicas share an egress IP, or when you namespace persessionIdand pin a session to a proxy. Otherwise stay onfs.
A jar the store cannot read or write is logged and the crawl continues without cookies; a
cookie backend being down does not fail a request. A misconfigured COOKIES_STORE, on the
other hand, fails at startup rather than silently falling back to the disk.
Adding a third backend is three methods — get(namespace, domain), set(...),
del(...) — in src/cookies.js; postgres would be an upsert on (session_id, domain).
If JWT_ALG is "ES512", then JWT_SECRET must contain the absolute or relative path to the public key:
JWT_ALG = "ES512"
JWT_SECRET = ".secrets/public.key"
PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH is set by default and is related to where Alpine install the chromiun browser.
This variable doesn't belong to playwright, and it is used becose playwright uses their own browser binaries, which are not compatible for alpine.
⚠️ In the future a Dockerfile.debian version could be provided. It requires a change in how the dockerfile is structured.
Setting CRAWLER_DOCS=true mounts a Swagger UI at /docs, with the
OpenAPI 3 document at /docs/openapi.json. Request bodies in the spec are generated from the same
Joi schemas the handlers validate with, so they cannot drift from the implementation.
CRAWLER_DOCS=true npm start
The docs and the spec are served without authentication (Swagger UI cannot send a bearer token
for its own page load), which is why they are opt-in and off by default: don't enable them on an
internet-exposed instance. The endpoints themselves are still protected — use the Authorize
button with a token from make token to try them from the UI.
Swagger UI assets are served from the bundled swagger-ui-dist package, so /docs works in the
container without outbound internet access.
The project follows a calendar version release as YY.MM.DD_PATCH[-MODIFIER], where:
- YY short year: 23, 24..
- MM short month: 01, 02...
- DD Short day: 01, 02, 03
- PATCH: bug fix or changes 1, 2, 3
- MODIFIER: lts, rc0, rc2...
As example, the version 23.08.10_2 means from the year 2023 of month August from day 10th, patch 0.
MODIFIER field is optional.
const browserConfType =
{
headless: Joi.boolean().default(headless),
emulation: Joi.object().keys(emulationType).default(emulationDefault),
proxy: Joi.object().keys(proxyType).allow(null).default(null),
}
EmulationType
const emulationType =
{
locale: Joi.string().default("en-US"),
timezoneId: Joi.string().default("America/New_York"),
isMobile: Joi.boolean().default(false),
viewport: Joi.object().keys(viewPortType),
geoEnabled: Joi.boolean().default(false),
geolocation: Joi.object().keys(geoType).optional().allow(null),
}
emulationDefault:
const emulationDefault = {
locale: "en-US",
timezoneId: "America/New_York",
isMobile: false,
viewport: { width: 1280, height: 720},
geoEnabled: false,
geolocation: {
longitude: 40.6976312,
latitude: -74.1444858
}
}
ProxyType
const proxyType =
{
server: Joi.string().required(),
username: Joi.string(),
password: Joi.string()
}
const crawlPageType = Joi.object(
{
// Valid formed url to open
url: Joi.string().required(),
// Timeout in secs
ts: Joi.number().default(defaultTs),
// Visible text of an element to wait
waitElement: Joi.string().optional().allow(null),
// Take a screenshot of the fullpage
screenshot: Joi.bool().default(false),
// Save cookies for the domain of the url
useCookies: Joi.bool().default(false),
// If ture, the browser will have a fresh start
cleanCookies: Joi.bool().default(false),
// Derecated
cookieId: Joi.string().allow(null).default(null),
// Headers used only for axios
headers: Joi.any().allow(null),
// Browser configuration see BrowserConfType
browser: Joi.object().keys(browserConfType).optional().allow(null).default(defaultBrowserConf),
}
)
-
GET /
- 200 Root status
-
GET /metrics
- 200 prometheus stats
-
GET /v6/image
- Uses axios to get an image encoded as base64
- Query Params: url
-
POST /v6/axios
- 200 if everything ok, 500 if something went wrong
- body:
CrawlPageType - notes: ProxyType not used in axios
- POST /v6/chrome
- 200 if everything ok, 500 if something went wrong
- body:
CrawlPageType - response 200:
fullurl[string]: Raw html of the responsecontent[string]: Raw html of the responseheaders[object]: not usedstatus[number]: status code, 200 or 500fullLoaded[bool]: if the page was loaded completlyscreenshot[string]: Base64 encoded imageerror[string]: any message errorcookieId[string]: generated
- response 500:
error[string]: message error
Opens a url in a real Chromium browser, waits for the page to load and returns what it rendered. Javascript runs, so this works on pages that come back empty from a plain HTTP fetch. It is written for a caller that never looks at the page — an AI agent, a pipeline — and has to decide what to do next from the response fields alone.
Shaping the request
formatdecides whatcontentis.htmlis the raw DOM: complete, and mostly navigation, scripts and inline styles.markdownkeeps headings, lists and links;textis prose only. Both are a small fraction of the html — ask for html only to parse it.maxCharsboundscontent.truncatedsays it was cut,contentLengthsays what the full length was, so you know whether to come back with a larger bound.includeLinksreturns{url, text}pairs, absolute and deduplicated, so you can pick the next page without parsing html.maxLinkscaps the list.waitElementis visible text that must appear before the page is read — for pages that fill themselves in after loading.waitElementFoundreports whether it showed up.tsis the timeout in seconds (30 by default). Keep it under whatever timeout your own caller is holding.profilesets locale, timezone, viewport and geolocation together as one coherent identity.geoEnabled: truegrants the geolocation permission using its coordinates.useCookiespersists cookies between calls, which is what lets a login or a consent banner survive. Always pair it with asessionId: without one the jar is shared with every other caller on the instance.cleanCookiesstarts that jar fresh. The jars live on disk by default, or in redis withCOOKIES_STORE=redis(see Cookie storage).screenshotreturns a full-page base64 PNG. It is large, and it turns image blocking off for that request so the picture is not blank where the images should be.proxyroutes the crawl through a proxy;includeHeadersreturns every response header instead of the handful worth acting on.
The request surface is flat by design: no nested browser object, and passing one is a 400.
Nested optionals are where a programmatic caller hallucinates fields and burns attention
tuning things that do not matter. headless stays with the operator (the HEADLESS env
var), which is where debugging belongs.
Reading the response
- Check
okfirst. true means the fetch succeeded and the content is worth using. Whenever it is false,erroris set — the two never disagree, so either is safe to branch on. outcomesays what the network did, and nothing else.statusis the upstream HTTP status andfinalUrlis the url after redirects — cite that one, not the url you sent.warningscarries judgements about the bytes:ANTI_BOT_CHALLENGE,EMPTY_CONTENT. They are heuristics and can be wrong, which is why they are kept out ofoutcome. Do not quote or summarise content that arrived with ablockingwarning: a challenge page reads like a real page.title,description,lang,contentTypeandfetchedAtdescribe the page.fetchedAtis when the content was true, which matters if you cite or cache it.
When it fails
error is {code, message, retryable, retryAfterMs}:
retryable: false— the same request fails the same way. Change something or give up.retryable: truewithretryAfterMs— the server asked for that wait. Take it.retryable: truewithretryAfterMs: null— back off on your own.TIMEOUToften clears with a largerts,EMPTY_CONTENTwith awaitElement.BLOCKED, or anyANTI_BOT_CHALLENGEwarning — an identical request is blocked identically. Come back with a differentsessionIdorproxy, not sooner.
CrawlPageV7Type
const crawlPageV7Type = Joi.object({
// absolute http(s) url to open
url: Joi.string().uri({ scheme: ["http", "https"] }).required(),
// shape of `content`: raw DOM html, flattened text, or markdown
format: Joi.string().valid("html", "text", "markdown").default("html"),
// truncate `content` to this many characters, 0 means no limit
maxChars: Joi.number().integer().min(0).default(0),
// return the page links, resolved against the final url
includeLinks: Joi.boolean().default(false),
maxLinks: Joi.number().integer().min(1).max(1000).default(100),
// timeout in secs, V7_TIMEOUT (30) by default
ts: Joi.number().min(1).max(300).default(defaultTs),
// visible text to wait for, reported back in `waitElementFound`
waitElement: Joi.string().allow(null).default(null),
screenshot: Joi.boolean().default(false),
// abort image requests. null = true, or false when screenshot is set
blockImages: Joi.boolean().allow(null).default(null),
useCookies: Joi.boolean().default(false),
cleanCookies: Joi.boolean().default(false),
// cookie jar namespace, see "Cookie storage" for where the jar lives
sessionId: Joi.string().pattern(/^[A-Za-z0-9_-]{1,64}$/).allow(null).default(null),
// desktop-us | desktop-es | desktop-ar | mobile-us | mobile-es | mobile-ar
// sets locale, timezone, viewport and geolocation coherently
profile: Joi.string().valid(...).default("desktop-us"),
// proxy to route the crawl through
proxy: Joi.object().keys(proxyType).allow(null).default(null),
// grant the geolocation permission, using the profile's coordinates
geoEnabled: Joi.boolean().default(false),
// return every response header instead of only the ones worth acting on
includeHeaders: Joi.boolean().default(false),
})
- POST /v7/chrome
- The HTTP status says whether the API worked, not whether the page did:
- 200: a crawl ran, check
outcome - 400: the body did not validate
- 502: the crawl could not run at all (browser launch, bad proxy, ...)
- 200: a crawl ran, check
- body:
CrawlPageV7Type - every response, success or failure, carries the same keys:
url[string]: the url that was requestedfinalUrl[string]: the url after redirectsoutcome[string]: what the fetch did.ok|http_error|timeout|nav_error|invalid_requestok[bool]:outcomeis ok and no blocking warning was raised. False if and only iferroris setwarnings[array]:{code, detail, blocking}heuristics about the content.ANTI_BOT_CHALLENGE,EMPTY_CONTENT.blockingmarks the ones that makeokfalsestatus[number|null]: upstream HTTP status of the navigationnavigated[bool]: the navigation itself completedwaitElementFound[bool|null]: null when nowaitElementwas asked fortitle,description,lang,contentType[string|null]: page metadataformat[string]: the formatcontentis incontent[string|null]: the page in the requested formattruncated[bool] /contentLength[number]: whethercontentwas cut, and its length before cuttingtextLength[number]: length of the extracted text regardless of formatlinks[array|null]:{url, text}, absolute and deduplicated. Only withincludeLinksscreenshot[string|null]: base64 PNGheaders[object]: the response headers worth acting on (content-type, retry-after, location, last-modified, etag, content-length, content-language). The full set only withincludeHeaders: truefetchedAt[string]: when the crawl started, ISO 8601. What a caller citing or caching the content needselapsedMs[number]error[object|null]:{code, message, retryable, retryAfterMs}, null exactly whenok.retryAfterMscomes from the upstreamRetry-Afterheader and is null when the server did not say — no backoff is invented. Codes:INVALID_REQUEST,HTTP_ERROR,BLOCKED,EMPTY_CONTENT,TIMEOUT,DNS,CONNECTION_REFUSED,CONNECTION_RESET,TLS,PROXY_ERROR,TOO_MANY_REDIRECTS,NAV_FAILED,CONTENT_READ,BROWSER_LAUNCH,UNKNOWN
- The HTTP status says whether the API worked, not whether the page did:
ANTI_BOT_CHALLENGE is a heuristic: known challenge markers in the page, plus weaker ones
(a captcha widget, "access denied") when the page is tiny or the status is 401/403/429/503.
It can be wrong in both directions, which is exactly why it is a warning and not the
outcome — a false positive costs the caller a warning it can ignore, never the real
status of the response. But an honest guess beats silently handing a challenge page back as
if it were the article that was asked for.
curl -X POST localhost:3000/v7/chrome \
-H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d '{"url": "https://example.com", "format": "markdown", "includeLinks": true, "maxChars": 4000}'
- POST /v7/executor
The escape hatch for flows /v7/chrome and /v7/search cannot express: multi-step forms,
infinite scroll, a login followed by an internal API call, scraping shaped by what the
previous page said. You send the body of an async function; it runs against a real
Chromium page and whatever it returns comes back as result.
In scope inside the script, and nothing else:
| Name | What it is |
|---|---|
page, context |
The real Playwright Page and BrowserContext. Already open on url if you gave one |
args |
The object you sent, so the script can stay constant across calls |
http (alias axios) |
axios with a timeout, a size cap and validateStatus off, so a 404 is data. Private and link-local addresses are refused unless allowPrivateNetwork |
cheerio, extract |
Parsing, and the same helpers /v7/chrome uses (htmlToText, htmlToMarkdown, extractLinks, detectBlocked, ...) |
require(name) |
Allowlisted only: axios, cheerio, playwright, google-play-scraper, crypto, url, querystring, util, zlib |
console.* |
Captured into logs and returned. You cannot see stdout, so this is how you debug |
step(name) |
A named checkpoint with a timestamp. On a timeout, steps says how far the script got |
sleep, Buffer, URL, URLSearchParams, setTimeout |
eval and new Function throw. There is no fs, no process, no child_process.
curl -X POST localhost:3000/v7/executor \
-H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d '{
"url": "https://example.com",
"args": {"min": 15},
"ts": 60,
"script": "step(\"parse\"); const $ = cheerio.load(await page.content()); const items = []; $(\".item\").each((_, el) => items.push({name: $(el).text(), price: Number($(el).attr(\"data-price\"))})); console.log(\"found\", items.length); return items.filter(i => i.price >= args.min);"
}'
Reading the answer works like the rest of v7 — check ok first, error is set exactly when
ok is false — with two differences. outcome describes the script (ok,
script_error, timeout, nav_error), not the fetch, so an upstream 404 is a non-blocking
HTTP_STATUS warning your script is free to be expecting. And logs and steps come back
on every response, because they are your only window into a run you cannot watch. A
SCRIPT_ERROR carries a stack trimmed to your own frames, with line numbers matching the
script you sent.
url is a convenience: it is opened before the script starts, and if it fails to load the
script never runs (nav_error). Leave it null and call page.goto yourself when you want
to handle navigation failures in your own code. ts is one deadline for the whole request —
whatever the pre-navigation spends comes out of the script's budget.
This endpoint is remote code execution, and it is off unless V7_EXECUTOR says otherwise.
vm is not a security boundary: it isolates the global object, not the realm, and every
object handed to a script leaks a path back through .constructor.constructor. So nothing
here pretends otherwise — the script gets the real page, not a facade that would cost you
half of Playwright and buy nothing. What contains a run is the process: every request is
a forked child with an allowlisted environment, a heap cap, its own Chromium, and a parent
holding SIGKILL — the only thing that can stop while (true) {}.
The known gap, written down rather than hidden: that child is a fork, so it shares the
server's uid, and an escaped script can read /proc/<ppid>/environ to recover the parent's
real environment. Closing it means running the executor as a separate container with no
secrets mounted, which is the next phase. Until then:
- do not enable it where
JWT_ALG=HS256puts a forgeable signing secret in the environment; with the productionES512setup the environment holds a public key, which is worth nothing - treat an executor token as a shell on the crawler, and issue it accordingly
- keep the cookie jars in mind: a script can read every jar the process can
An in-page browser mode — sandboxed by Chromium, no node access, enough for most "the DOM
here is weird" cases — is the other half of that phase. mode already rejects anything but
node.
CrawlDuckGoType
const crawlDuckGoType = Joi.object(
{
// a query to search in duckduckgo.com
text: Joi.string().required(),
// timeout in secs
ts: Joi.number().default(defaultTs),
// How many clicks on "More Results"
moreResults: Joi.number().default(1),
// "ar-es" by default.
region: Joi.string().default("ar-es"),
// "Any Time", "Past day", "Past week", "Past month", "Past year". Null by default
timeFilter: Joi.string().default(null).allow(null),
// Take a screenshot of full rendered page
screenshot: Joi.bool().default(false),
// It will store and load cookies
useCookies: Joi.bool().default(true),
// Deprecated
cookieId: Joi.string().allow(null).default(null),
browser: Joi.object().keys(browserConfType).optional().allow(null).default(defaultBrowserConf),
}
)
For regios codes, check see regions codes
- POST /v6/duckduckgo
- 200 if everything ok, 500 if something went wrong
- body
crawlDuckGoType - response 200:
query[string]: Parsed queryfullurl[string]: Fullurlcontent[string]: Raw html of the responseheaders[object]: Emptystatus[number]: status code, 200 or 500links[List[{href:text}]]: uri of the next pagefullLoaded[bool]: if the page was loaded completlyscreenshot[string]: Base64 encoded imageerror[string]: any message errorcookieId[string]: generated
- response 500:
error[string]: message error
crawlGoogleType
const crawlGoogleType = Joi.object(
{
// a query to search in google.com
text: Joi.string().required(),
// timeout in secs
ts: Joi.number().default(defaultTs),
// It will performs a "PgDown" actions for `moreResults` times.
moreResults: Joi.number().default(1),
// region: Joi.string().default("countryAR"),
region: Joi.string().default("Argentina"),
// country
cr: Joi.string().default("US"),
// interfaz lang
hl: Joi.string().default("en"),
// "Any Time", "Past hour", "Past 24 hours", "Past week", "Past month", "Past year". Null by default
timeFilter: Joi.string().default(null).allow(null),
// Take and screenshot
screenshot: Joi.bool().default(false),
// use cookies
useCookies: Joi.bool().default(true),
// deprecated
cookieId: Joi.string().allow(null).default(null),
browser: Joi.object().keys(browserConfType).optional().allow(null).default(defaultBrowserConf),
}
)
- POST /v6/google
- 200 if everything ok, 500 if something went wrong
- body
crawlGoogleType - response 200:
query[string]: Parsed queryfullurl[string]: Fullurlcontent[string]: Raw html of the responseheaders[object]: Emptystatus[number]: status code, 200 or 500links[List[{href:text}]]: uri of the next pagefullLoaded[bool]: if the page was loaded completlyscreenshot[string]: Base64 encoded imageerror[string]: any message errorcookieId[string]: generated
- response 500:
error[string]: message error
⚠️ Playstore API could be very inestable, for more information refer to https://github.com/facundoolano/google-play-scraper
-
GET /v1/playstore/:appid
- Get app detail based on the appid
-
POST /v1/playstore/list
-
POST /v1/playstore/search
- Perform a search in google playstore
- body
term: the term to search by.num(optional, defaults to 20, max is 250): the amount of apps to retrieve.lang(optional, defaults to'en'): the two letter language code used to retrieve the applications.country(optional, defaults to'us'): the two letter country code used to retrieve the applications.fullDetail(optional, defaults tofalse): iftrue, an extra request will be made for every resulting app to fetch its full detail.price(optional, defaults toall): allows to control if the results apps are free, paid or both.all: Free and paidfree: Free apps onlypaid: Paid apps only
-
POST /v1/playstore/similar
- Returns a list of similar apps to the one specified
- body:
appId: the Google Play id of the application to get similar apps for.lang(optional, defaults to'en'): the two letter language code used to retrieve the applications.country(optional, defaults to'us'): the two letter country code used to retrieve the applications.fullDetail(optional, defaults tofalse): iftrue, an extra request will be made for every resulting app to fetch its full detail.
Example:
curl http://localhost:3000/v1/chrome?url=https://www.google.com/doodles/
url must have the protocol screen is a optional param, any value is taked as true
duckduckgo
Check https://duckduckgo.com/settings
Copy the value of the option:
- For "All regions" the value is
wt-wt - For "Argentina" the value is
ar-es
google: Check https://www.google.com/preferences Copy as the text shown in Region settings part:
- For "Brazil", the value is
Brazil - For "Agentina", the value is
Argentina
2026-08-27
-
New
/v7/executorendpoint: caller-supplied JavaScript run against a real browser, withpage,context, a guardedhttp,cheerio,extractand an allowlistedrequirein scope.outcomedescribes the script rather than the fetch,logsandstepscome back with every response, and a script error carries a stack trimmed to the caller's own frames. Each run happens in a forked child with an allowlisted environment, a heap cap and a parent holding SIGKILL, so a runaway script dies with a process. Off unlessV7_EXECUTORis set; read "Before you enable it" above, including the deferred same-uid gap. -
New
/v7/chromeendpoint, shaped for programmatic/agent callers. v6 is unchanged: v7 reports the real upstream status, distinguishesok/http_error/blocked/timeout/nav_error/emptyin anoutcomefield, resolvesfinalUrl, can return text or markdown bounded bymaxChars, returns typed{code, message, retryable}errors, namespaces cookies persessionIdand replaces the nested emulation object with aprofilepreset. Heuristics about the content are kept inwarningsso they cannot overwrite the reported status,okis false exactly whenerroris set,retryAfterMscomes from the upstream header, response headers are trimmed to the ones worth acting on andfetchedAtsays when the content was true. Its default timeout is 30s instead of 180s.
2023-08-10
-
Restarting changelog from now.
-
Main endpoints (chrome, axios, image, google and duckduck) will follow simple version schema: Breaking changes in the response or the payload for req/resp will imply a new version in the enpoint.
-
calendar version release adopted for the project. More detail in versioning, but the new format is:
YY.MM.DD_PATCH[-MODIFIER]. As example, this version will be:23.08.10_0 -
Cookies are now per instance, stored locally. In version 5, cookies were shared cross instances using redis. Because instances could be hosted in different servers with different IPs and having requests with the same cookies from different IPS is not recommended. Instead, each instance store locally their cookies, which is aligned with how actually a browser works.
-
Duckduckgo parsing of links disabled by now.y
-
Google try first to get the search button using "Search" if it fails, it will try "Buscar"
-
A
HEADLESSenv added. Iffalseit will open the browser. Useful for debugging.
Before
⚠️ From V4 endpoint, it uses Playwright instead of puppeteer