@nytka/plugin-dataforseo
v0.1.3
Published
DataForSEO connector for nytka projects. Collects search volume, keyword ideas, rankings, SERPs and competitors into datasets/ and registers them with provenance.
Readme
@nytka/plugin-dataforseo
DataForSEO connector for nytka projects. Collects search volume, keyword ideas, a domain's
rankings, live SERPs and competitor domains into the project's datasets/ and registers them
in datasets/index.json with provenance.
Read-only — every endpoint it calls is a query, and it writes nothing back to DataForSEO.
Install
npm install @nytka/plugin-dataforseoOne dependency: @nytka/core, which has no dependencies of its own and holds the project
plumbing. The HTTP client is Node's built-in fetch; a connector that makes five kinds of POST
request does not need an HTTP library, and adding one would put it in every project that
collects anything.
Setup
About two minutes, and there is no key file. This is the first @nytka connector that
authenticates with a login and a password rather than a downloaded credential.
1. Get the API password
https://app.dataforseo.com/api-access
This is not the password you sign in to the dashboard with. It is a separate API password
on that page. Using the dashboard password is the single most common setup failure, and it
comes back as an unexplained 401.
2. Configure
One .env at the project root. Every key for the project lives there and nowhere else.
The names below ship with the package too, so recovering them later never means re-reading this guide:
cat node_modules/@nytka/plugin-dataforseo/.env.example >> .env[email protected]
DATAFORSEO_PASSWORD=<the API password from app.dataforseo.com/api-access>
# Optional defaults, overridable per call:
DATAFORSEO_LOCATION_NAME=Ukraine
DATAFORSEO_LANGUAGE_NAME=UkrainianNothing goes in private/. There is no file to point at, so this connector never reads
anything outside the project and never touches the filesystem to authenticate.
3. Verify
There is no free auth smoke test. Unlike nytka-gsc sites and nytka-ga4 properties, there
is no account-scoped resource to list — a DataForSEO account owns no properties, it buys
answers to questions. The cheapest real check is a one-keyword call:
npx nytka-dataforseo search-volume --keywords "seo"Every call spends credits. The CLI prints what each collection cost.
Use
npx nytka-dataforseo search-volume --keywords "bathroom remodel cost, cost to renovate bathroom"
npx nytka-dataforseo keyword-ideas --keywords "ліцензія на охоронну діяльність" \
--location-name Ukraine --language-name Ukrainian --limit 500
npx nytka-dataforseo ranked-keywords --target competitor.example --limit 100
npx nytka-dataforseo serp --keyword "how to get a security license" --depth 20
npx nytka-dataforseo competitors --target example.com| Command | Returns | Defaults |
|---|---|---|
| search-volume | Google Ads monthly search volume for exact keywords | whole list in one call |
| keyword-ideas | related ideas with volume, competition, difficulty, intent | --limit 100 |
| ranked-keywords | keywords a domain already ranks for, with position | --limit 100 |
| serp | top organic results for one keyword | --depth 10 |
| competitors | competitor domains for a target domain | --limit 30 |
Every command also takes --location-name / --location-code, --language-name /
--language-code, --raw and --no-register.
--limit above 1000 pages. DataForSEO caps a Labs request at 1000 items, so --limit 2500
becomes three requests, and the payload records how many results were available in total so you
can see what a limit left behind.
search-volume puts every keyword in one request, because the Google Ads endpoint is rate
limited to roughly 12 requests a minute — one call per keyword is how a list of forty becomes a
four-minute failure. DataForSEO caps how many keywords one call accepts; a very long list comes
back rejected with the API's own message. Split it.
--raw stores DataForSEO's untouched items instead of the normalised rows, for a field the
normalised shape omits. It is not quite the Python connector's --raw, which printed the whole
response envelope: paging makes "the response" plural, and the envelope's cost and timing are
recorded as provenance either way, in the payload's apiMetadata.
Programmatic:
import { run, collect } from '@nytka/plugin-dataforseo'
const { id, rowCount, rawPath, cost } = await run({ command: 'competitors', target: 'example.com' })run() returns counts, paths and cost, never rows.
What it writes
| Path | Committed? |
|---|---|
| datasets/payloads/<id>.json | no — the directory ships its own .gitignore |
| datasets/index.json | yes — one entry, added or replaced |
The dataset id
dataforseo-<command>-<subject>-<market>-<period>[-raw]| Command | Example id |
|---|---|
| search-volume | dataforseo-search-volume-bathroom-remodel-cost-e596b3fd-united-states-english-2026-07 |
| keyword-ideas | dataforseo-keyword-ideas-bathroom-remodel-cost-9323b04c-united-states-english-2026-07 |
| ranked-keywords | dataforseo-ranked-keywords-example-com-a379a6f6-2804-uk-2026-07 |
| competitors | dataforseo-competitors-example-com-a379a6f6-2804-uk-2026-07 |
| serp | dataforseo-serp-how-to-get-a-security-license-91cdd1c7-united-states-english-2026-07-28 |
A Ukrainian keyword slugifies to nothing, so the readable prefix drops out and the digest
carries the id alone: dataforseo-keyword-ideas-7b54bd60-2804-uk-2026-07. What was asked is
still recorded, in the entry's subject and in the payload.
Why there is a date in an id for data that has no date. @nytka/plugin-gsc and
@nytka/plugin-ga4 key an id to the reporting period, because both return a time series over a
range you asked for. Four of the five commands here have no range at all. The id still has to do
two jobs that pull against each other:
- Re-running the same question must update, not accumulate. The registry matches on id, so anything in the id that moves between two runs of one question appends a near-duplicate instead of replacing it. Keying on the exact collection timestamp would reproduce that bug at a rate of one entry per run.
- A different question must never land on the same id. The payload file is named after the id, so a collision destroys the earlier answer as well as its registry entry. Extra entries are a mess you can see and delete; a destroyed answer is not.
So the id is what was asked — command, subject, market — plus when it was observed. Same question in the same period: one entry, refreshed. New period: a new entry, so a series accumulates and you can compare month over month.
The period is a month for four commands and a day for serp. A ranked list is the state of
a market as observed in a month; you re-take it monthly, so two runs in one month are one
observation refined. serp is the one live crawl of the five: checking a page's position,
changing it, and checking again a fortnight later is the entire use, and a month-keyed id would
delete the "before" at the moment the "after" was collected.
--limit and --depth are not in the id. They size the answer; they do not change the
question, and @nytka/plugin-gsc leaves --limit out of its id for the same reason. A knob you
turn while exploring must not lay down an entry per turn. The cost, accepted deliberately: a
re-run at a smaller limit replaces a longer payload with a shorter one, and the entry's rows
count is where you see that happened.
--raw is in the id, as a -raw suffix, because a raw payload and a normalised payload are
different shapes under the same question. A shared id would let one overwrite the other with
something the recorded schema does not describe.
The market is in the id, and leaving it out is a real overwrite. The same domain queried at
location 2804 in Ukrainian and again in Russian is two answers to two questions. This is not
hypothetical — it is what tools/keyword-audit.js in one of the projects this was built for
does on every run.
One known limitation: --location-name "United States" and --location-code 2840 reach the
same market, and this connector cannot tell that they do — resolving a name to a code needs
another paid call. They produce two dataset ids. Pick one spelling per project and keep it.
The registry entry
Shares id, source, operation, collectedAt, validUntil, rawPath, rows, schema,
summary, status and producedBy with the gsc and ga4 entries, so one registry reads as
one file. Two fields differ, on purpose:
subject, notproperty. A property is an account-scoped thing a project owns and has been granted access to. A keyword set is not one, and neither is a competitor's domain.dateRangeis explicitlynull, not absent, plus aperiod. A consumer reading this file next to Google entries must be able to tell "this result covers no date range" from "somebody forgot the field".
The registry writer preserves the file's existing formatting: adding one dataset produces a
one-entry diff, not a reformat of every entry already there — including entries written by
other @nytka connectors into the same file.
Troubleshooting
| What you see | What it means |
|---|---|
| DATAFORSEO_LOGIN … is not set | Not in .env at the project root. Nothing was sent |
| rejected the credentials (HTTP 401) | Almost always the dashboard password instead of the API password from app.dataforseo.com/api-access |
| balance is exhausted | Out of credits. Nothing was collected — top up and re-run |
| rate-limited (HTTP 429) | Too many requests. Batch keywords into one search-volume call |
| no project.yaml found walking up from cwd | Not inside a nytka project. cd to the project root |
| status 40501: Invalid Field: location_name | A location or language name DataForSEO does not know. Try the numeric code |
| Runs fine, returns 0 rows | The market has no data for that subject. Check the location and language before assuming the subject is wrong |
| Fewer rows than --limit | That is everything available. The payload's availableCount is the total DataForSEO reported |
Rules it follows
- Payloads never enter agent context. Query them with a script; write conclusions to
research/. A dataset is evidence, a research item is knowledge. The CLI prints row counts, paths and cost for this reason. - The project is found by walking up for
project.yaml, so it works at any install depth. - No YAML parsing, no config file, no key file. Secrets from
.env, everything else from flags. - Dates are written in the local calendar, never UTC-stamped. West of Greenwich, an evening run would otherwise record tomorrow's date.
- A failure part-way through paging fails the whole collection. A truncated payload registered
as
currentis indistinguishable from a complete one afterwards.
Limits worth knowing
- Every call costs money. Set a sensible
--limit, prefer one batched call to many small ones, and read the cost the CLI prints. - 1000 items per Labs request; paging above that is automatic.
search-volume(Google Ads) is rate limited to roughly 12 requests a minute.competitionmeans two different things. Fromsearch-volumeit isLOW/MEDIUM/HIGHwith a separatecompetition_index(0–100); from the Labs endpoints it is a 0–1 float.- A
nullis unknown, not zero. DataForSEO returning no search volume for a keyword is not a keyword with no searches. rank_absolutecounts the whole SERP, features included, not the organic ordering.serpkeeps organic items only, so a row atrank_absolute: 4may be the second organic result.competitorsrows carry bothorganic_etv(the competitor's whole domain) andintersecting_etv(only the keywords it shares with your target). The list is sorted by the second. The connector this was ported from sorted by one and reported the other, so the ranking came back in an order no visible column explained.- Location and language change results completely. Set them explicitly for non-US work.
Tests
npm testnode --test against recorded API responses. No credential, no network, no client data.
