Track where your TikTok Shop products rank in search for the keywords that matter, every day. Exact rank positions, movement vs the previous run, share of search, and the competitor listings that outrank you. Filters out TikTok's search volatility so you only see real movement.
Fixed: three dataset columns promised a measurement the Actor cannot make
isPaidPlacement became null in 0.2.1 because neither search source publishes an ad marker, but
the published dataset views were not changed with it. The competitor table and the movers table both
still carried a column headed "Paid Ad?", the market table carried "Paid Ads Above You", and the
output schema described the movers view as showing "whether they paid for it". Every one of those
was permanently empty. The columns are gone and the description says what the view actually shows.
The fields remain in the item schema, nullable, so a source that starts publishing an ad marker
needs no migration.
Fixed: a failing run could cost more than it earned
The upstream time allowance grew at 20 seconds per keyword with no ceiling, so a 200-keyword run
authorised 4,090 seconds of data-source calls. A run that measures nothing still earns only the
per-run rank check, and a container held for 68 minutes costs more than that fee returns. The
allowance is now capped at 1,200 seconds however many keywords are asked for, which leaves a healthy
margin on the worst run the Actor can produce and does not constrain real work: the largest measured
sweep returned in under two minutes.
Changed: the example tasks are configured and published from the repository
create-tasks.mjs now writes publicConfig and isPublic as well as the input, so the file is the
whole definition of each public example page rather than half of it - previously a re-run would have
reverted a page configured by hand in Console. Each task also carries an explicit
maxTotalChargeUsd sized from its own keyword count.
Four examples were repointed after being run against live TikTok Shop search. The beauty and agency
examples tracked shops that ranked for none of their keywords; the deep-scan example shared 72% of
its listings with the electronics example, and the seller template shared 71% with the home-lighting
example. Every example now ranks for at least one of its keywords, and the largest overlap between
any two is 38%.
0.2.1
Fixed: real keywords were being rejected
The placeholder-detection guard tested /^(replace|enter|add|type)\b/, so it refused any search
term starting with one of those verbs. "type c cable", "type c charger", "add ons for hair" and
"enter the chat gaming chair" were all treated as unedited template text, and a run whose every
keyword tripped the guard was rejected outright. A USB-C accessory seller could not use the Actor at
all. Detection now needs an instruction verb AND a template noun at the end of the phrase, and both
functions have tests.
Fixed: isPaidPlacement was asserting something nobody knew
Neither data source publishes an ad marker for a search slot, and the mapping filled the gap with a
hardcoded false - so every published listing claimed to be a confirmed organic result, and the
report told sellers a competitor "took the slot organically" on no evidence. The field is now
null, the report says nothing about placement when nothing is known, and the claim has been
removed from the Actor description and the README.
Fixed: drop-outs were being silenced
Scan depth was benchmarked against the deepest rank ANY source had ever reached for a keyword, while
the measurement came from one source. One deep read by the fallback provider permanently marked
every later run by the primary "too shallow", which downgraded genuine drop-outs to
scan_too_shallow and emitted no alert. A seller could vanish from the results and be told
nothing.
Fixed: "blocked" was the catch-all verdict
A run that merely ran out of wall clock, and a run whose upstream mapping was out of date, both
published outcome: "blocked" - the second alongside a message reading "this is a defect in this
Actor's upstream mapping, not a block by TikTok". Status rows now carry upstream_failed,
stopped_early and degraded, and blocked is reserved for TikTok actually refusing the
search.
Changed: how the per-run fee is charged
The rank-check fee is now taken once on every run that reaches the search source, rather than
only on runs that returned data. A run the source blocks now costs $0.05 instead of nothing.
Every keyword on such a run is still free. The old behaviour made blocked runs, all-empty runs and
timeouts entirely free while each still occupied a container for up to the full run timeout. Use
run-level "Max total charge" for a hard ceiling.
Added
RANK_TREND.html in the key-value store: a self-contained rank-over-time chart per keyword,
axis inverted so a better position sits higher, with breaks rather than invented positions where
the shop was measured and not found.
Alert webhook. Set alertWebhookUrl and the run posts its urgent findings as JSON with a
ready-made text field that Slack and Discord render directly. alertLevel chooses the
threshold and defaults to critical only. Nothing is sent on a quiet run.
Two example tasks: an agency one-client-per-tracking-ID pattern, and a depth-50 deep scan.
Also
Actor.init() moved inside the error guard. A failure there was an unhandled top-level
rejection: a FAILED run with no status record for the health check to read.
Keyword de-duplication now uses the same normaliser as storage, so "LED Strip" and "led strip"
no longer survive as two keywords that then collide and report each other as no_data.
The CSV, markdown report and OUTPUT.json are now scoped to the keywords actually delivered, so
a run stopped by a charge cap no longer exports the data the cap withheld.
Competitor rows are built and flushed per keyword instead of being materialised whole, so a
200-keyword deep scan no longer holds 20,000 items at once. Maximum memory is now 512 MB.
README pricing corrected: the source cost table quoted trakk's Gold-tier rate, understating the
bill for a Free or Bronze account by about 64%. It now quotes $2.00 per 1,000 rows with the tier
discount noted.
README gains input-parameter, FAQ and API/integration sections.
Docker image builds with npm ci, excludes tests, and prunes dev dependencies.
0.2.0
Breaking: the output shape changed. Rows now carry schemaVersion: 2 and an itemType
discriminator (keyword_rank, listing, run_summary, run_status). Your stored rank history
carries over automatically.
What a run now tells you
Fields are named the way a seller would say them: yourRank, usualRank, whatHappened,
nextStep, whoTookYourSpot. Internal QA state (source, sourceChanged, baselineAgeDays,
movementReliable) moved to RUN_DIAGNOSTICS.json or folded into whatHappened.
Competitor listings are their own rows instead of a nested array no dataset view could reach.
Six Console views, led by Needs Action.
New, from data that was already being collected and discarded: whether the listing that took
your slot is a paid placement, your price gap against the listings above you, whether your title
even contains the keyword you rank for, and a cross-keyword "who keeps beating you" rollup.
comparison says WHY a check could not be compared: first_check, source_changed,
page_reshuffled, scan_too_shallow, last_check_too_old - instead of one opaque flag.
A keyword checked repeatedly and never found now says so rather than reporting "not enough
data" forever and raising no alert.
A keyword you rank for but only half the time now raises an alert, which no absence rule could
ever do because they all required you to be missing.
Correctness
A keyword the source returned nothing for is no longer counted as a lost ranking.
The run summary can no longer contradict its own alerts: a confirmed absence counts as a loss, and
a run holding a critical finding never states a bare "trending up" verdict.
Scan depth no longer masquerades as movement. Absences are scored against one floor across the
window, and an absence found on a scan shallower than the keyword has previously reached is
discarded rather than counted against you.
Your price and sold count are read from your listing, not from whichever product shared its rank.
Rank history stores real rank positions, so a page with a gap no longer misaligns the displacer,
churn and new-entrant windows.
Duplicate products from pagination overlap no longer inflate shelf space.
One malformed source row no longer discards a whole keyword.
Cost and reliability
One spend ceiling for the whole run, shared across retries and the fallback source, published as
meta.upstreamChargeAuthorizedUsd with the child run IDs.
The per-run base fee is charged last, so a tight "Max total charge" costs this Actor its fee
rather than costing you your results.
Data a source produced before being cut short is used rather than discarded - you were billed for
it either way.
The requested upstreamProvider is now honoured for every keyword instead of being overridden by
stored history, and meta records what was asked for against what answered.
Rank history is keyed by what you track, so pointing a tracking ID at a different shop starts a
clean history instead of publishing the difference between two businesses as movement.
Concurrent runs on one tracking ID no longer overwrite each other's measurements.
Known limits
isSponsored is not yet supplied by either search source, so paidPlacementsAbove reports null
rather than asserting "no ads above you" on absent evidence. The ad-vs-organic advice activates as
soon as a source populates the flag.
United States only. The sources advertise other regions but were measured returning the US
catalogue under a foreign label, or nothing at all.
0.1.0
Initial release: per-keyword rank positions, movement against the previous run, share of search,
competitor listings, rolling rank history and churn-gated alerts.