A one-off export tells you who used a sound today. The more useful question for a label or a campaign manager is what changed since yesterday: which videos are new, and which ones are still picking up views. That takes a daily snapshot and a diff, and Node 24 has everything needed for both.
The tracker in this guide uses only what ships with Node: fetch for the API and node:sqlite for storage. It has two modes. count records the number of videos using each sound for 1 credit. videos walks every page of videos, stores views, likes and comments for each one under today's date, and prints new videos and the biggest view gains since the last run. If you only need a single export, the Python sound export guide is shorter.
Before you start
- A ScrapingBot API key. Create a free account for 100 credits, no card needed.
- Node.js 24 or newer (
node --version), which includesfetchandnode:sqlitewith no flags. This guide was run on 24.9. - One or more sound IDs: the number at the end of a sound page URL,
tiktok.com/music/<name>-<id>.
The cheap headline number
Before tracking individual videos, decide whether you need them. /music/info returns a sound's title, author, duration and video_count, the number of videos that use it. One call per sound per day gives you a growth curve for 1 credit a day, which is enough to see whether a sound is spreading or flat.
curl -X POST https://scrapingbot.io/api/v1/tiktok \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"endpoint": "/music/info", "params": {"music_id": "7683546066251827217"}}'
{
"success": true,
"data": {
"id": "7683546066251827217",
"title": "Hoe Craft",
"author": "MC",
"album": "Hoe Craft",
"duration": 60,
"original": false,
"video_count": 218,
"play": "https://sf16.tiktokcdn-us.com/…",
"cover": "https://p16-common.tiktokcdn-us.com/…"
},
"processed_time": 0.1327,
"duration": "0.13",
"statusCode": 200,
"creditsUsed": 1
}
video_count also tells you what the full snapshot will cost. TikTok usually returns about 10 videos per page of /music/posts even when you ask for more, so 218 videos is roughly 22 calls.
What the tracker stores
Two tables, both keyed by date. Storing one row per video per day, instead of overwriting the latest numbers, is what makes the diff possible: any two days can be compared later, including days you did not think to compare at the time.
| Table | One row per | Columns |
|---|---|---|
sound_counts | sound, day | title, author, video_count |
video_snapshots | sound, video, day | creator, posted (Unix time), views, likes, comments |
The day is the UTC date of the run, and it is part of the primary key. Writes use INSERT OR REPLACE, so if cron fires twice or you rerun after a failure, the second run replaces that day's rows rather than doubling them.
The script
Save this as tracker.mjs. The .mjs extension lets it use top-level await without a package.json.
// tracker.mjs: a daily TikTok sound tracker. Node 24+, no dependencies.
// node tracker.mjs count <sound_id> [...] 1 credit per sound: how many videos use it
// node tracker.mjs videos <sound_id> [...] 1 credit per page: snapshot every video and report changes
import { DatabaseSync } from "node:sqlite";
const API = "https://scrapingbot.io/api/v1/tiktok";
const KEY = process.env.SCRAPINGBOT_API_KEY;
const MAX_PAGES = Number(process.env.MAX_PAGES ?? 50); // per sound, per run
const DAY = new Date().toISOString().slice(0, 10); // UTC date of this run
async function call(endpoint, params, tries = 3) {
let body;
for (let attempt = 0; attempt < tries; attempt++) {
const res = await fetch(API, {
method: "POST",
headers: { "x-api-key": KEY, "Content-Type": "application/json" },
body: JSON.stringify({ endpoint, params }),
signal: AbortSignal.timeout(60_000),
});
body = await res.json();
if (body.success) return body.data;
if ([400, 401, 402].includes(res.status)) throw new Error(body.error); // will not get better
await new Promise((r) => setTimeout(r, 1000 * 2 ** attempt)); // 408, 429, 5xx: wait, retry
}
throw new Error(`${endpoint} kept failing: ${body?.error}`);
}
const db = new DatabaseSync("sound-tracker.db");
db.exec(`
CREATE TABLE IF NOT EXISTS sound_counts (
sound_id TEXT, day TEXT, title TEXT, author TEXT, video_count INTEGER,
PRIMARY KEY (sound_id, day));
CREATE TABLE IF NOT EXISTS video_snapshots (
sound_id TEXT, video_id TEXT, day TEXT, creator TEXT, posted INTEGER,
views INTEGER, likes INTEGER, comments INTEGER,
PRIMARY KEY (sound_id, video_id, day));
`);
const num = (n) => n.toLocaleString("en-US");
const link = (r) => `https://www.tiktok.com/@${r.creator}/video/${r.video_id}`;
async function count(soundId) {
const info = await call("/music/info", { music_id: soundId });
const prev = db.prepare(`SELECT day, video_count FROM sound_counts
WHERE sound_id = ? AND day < ? ORDER BY day DESC LIMIT 1`).get(soundId, DAY);
db.prepare(`INSERT OR REPLACE INTO sound_counts VALUES (?, ?, ?, ?, ?)`)
.run(soundId, DAY, info.title, info.author, info.video_count);
const change = prev
? `${info.video_count - prev.video_count >= 0 ? "+" : ""}${num(info.video_count - prev.video_count)} since ${prev.day}`
: "first reading";
console.log(`${info.title} by ${info.author}: ${num(info.video_count)} videos (${change})`);
}
async function walk(soundId) {
const videos = new Map();
let cursor;
for (let page = 1; page <= MAX_PAGES; page++) {
const params = { music_id: soundId, count: 30 };
if (cursor !== undefined) params.cursor = cursor;
const data = await call("/music/posts", params);
for (const v of data.videos ?? []) if (!videos.has(v.video_id)) videos.set(v.video_id, v);
if (!data.hasMore) return { videos: [...videos.values()], pages: page, complete: true };
cursor = data.cursor;
}
return { videos: [...videos.values()], pages: MAX_PAGES, complete: false };
}
async function snapshot(soundId) {
const { videos, pages, complete } = await walk(soundId);
const save = db.prepare(`INSERT OR REPLACE INTO video_snapshots VALUES (?, ?, ?, ?, ?, ?, ?, ?)`);
db.exec("BEGIN");
for (const v of videos) {
save.run(soundId, v.video_id, DAY, v.author?.unique_id ?? "", v.create_time ?? null,
v.play_count ?? 0, v.digg_count ?? 0, v.comment_count ?? 0);
}
db.exec("COMMIT");
console.log(`sound ${soundId} on ${DAY}: ${pages} page(s), ${videos.length} video(s)` +
(complete ? "" : `, stopped at MAX_PAGES=${MAX_PAGES}`));
const prev = db.prepare(`SELECT MAX(day) AS day FROM video_snapshots
WHERE sound_id = ? AND day < ?`).get(soundId, DAY).day;
if (!prev) {
for (const v of videos.sort((a, b) => b.play_count - a.play_count).slice(0, 5)) {
console.log(` ${num(v.play_count).padStart(12)} views @${v.author.unique_id} ${v.title.slice(0, 50)}`);
}
console.log(" first snapshot: run again tomorrow to see new videos and view gains");
return;
}
// New = never seen on any earlier day, not just missing from the last run.
const fresh = db.prepare(`SELECT video_id, creator, views FROM video_snapshots t
WHERE sound_id = ? AND day = ? AND NOT EXISTS (
SELECT 1 FROM video_snapshots p
WHERE p.sound_id = t.sound_id AND p.video_id = t.video_id AND p.day < t.day)
ORDER BY views DESC`).all(soundId, DAY);
console.log(` ${fresh.length} new since ${prev}`);
for (const r of fresh.slice(0, 10)) console.log(` + ${num(r.views).padStart(10)} views ${link(r)}`);
const gains = db.prepare(`SELECT t.video_id, t.creator, t.views, t.views - p.views AS gain
FROM video_snapshots t JOIN video_snapshots p
ON p.sound_id = t.sound_id AND p.video_id = t.video_id AND p.day = ?
WHERE t.sound_id = ? AND t.day = ? AND t.views > p.views
ORDER BY gain DESC LIMIT 10`).all(prev, soundId, DAY);
console.log(` biggest view gains since ${prev}:`);
for (const r of gains) console.log(` ^ ${num(r.gain).padStart(10)} views ${link(r)}`);
}
const [mode, ...sounds] = process.argv.slice(2);
if (!KEY || !["count", "videos"].includes(mode) || !sounds.length) {
console.error("usage: SCRAPINGBOT_API_KEY=... node tracker.mjs count|videos <sound_id> [...]");
process.exit(1);
}
for (const id of sounds) {
try {
await (mode === "count" ? count(id) : snapshot(id));
} catch (err) {
console.error(`sound ${id}: ${err.message}`); // one bad sound should not stop the rest
process.exitCode = 1;
}
}
The parts worth understanding before you schedule it:
- Paging stops on
hasMore, never on page size. A page with fewer videos than you asked for is normal in the middle of a list. Thecursorfrom each response goes back unchanged in the next request. - Videos are keyed on
video_id. Pages can overlap, so theMapkeeps the first copy of each video and the snapshot never counts one twice. MAX_PAGEScaps the daily spend. At 50 pages a sound costs at most 50 credits per run. A sound with thousands of videos will hit the cap, and the output says so; for those, thecountmode is usually the better daily signal.- "New" means never seen before, on any earlier day. Comparing only with the previous snapshot would report a video as new again if it fell outside the page cap one day and came back the next.
- Gains compare with the most recent earlier snapshot, not strictly with yesterday. If the machine was off for two days, the report covers three days of growth and says which date it compared against.
- One sound failing does not stop the others. Errors are printed and the exit code is set to 1, which is what cron and most schedulers look at. A
400,401or402is not retried because it will fail the same way; timeouts,429and server errors are retried with a growing pause, and failed calls are refunded.
First run
Start with the headline count, then a one-page snapshot so the first test costs a single credit:
export SCRAPINGBOT_API_KEY=your_key_here
node tracker.mjs count 7683546066251827217
MAX_PAGES=1 node tracker.mjs videos 7682907287056337667
The output from those two commands:
Hoe Craft by MC: 218 videos (first reading)
sound 7682907287056337667 on 2026-10-03: 1 page(s), 1 video(s), stopped at MAX_PAGES=1
353,558 views @aetherrr.flp luna eterna #lofihouse #electronicmusic #club #fyp
first snapshot: run again tomorrow to see new videos and view gains
Node 24 also prints an ExperimentalWarning on every run, because the script loads node:sqlite. It goes to stderr and does not affect the result; the cron line below turns it off with --disable-warning=ExperimentalWarning.
Everything is now in sound-tracker.db next to the script. You can query it with the sqlite3 command that comes with macOS and most Linux distributions:
sqlite3 sound-tracker.db \
"SELECT day, views FROM video_snapshots WHERE video_id = '7682907275505224982' ORDER BY day"
2026-10-03|353558
After a week of runs the same query returns seven rows, one per day, which is a view curve for that video you can paste into a spreadsheet or chart.
What later runs print
From the second day on, the videos mode stops listing the top videos and prints the diff instead. For each sound you get three things, in this order:
- A count of new videos, with the date it compared against, in the form
<n> new since <date>. - Up to ten new videos, each on a line starting with
+, showing their current views and a link built from the creator handle andvideo_id. They are sorted by views, so a new video that is already doing well is at the top. - The ten biggest view gains among videos present in both snapshots, each on a line starting with
^. The number is today'splay_countminus the earlier one. Videos that did not gain views are left out.
The count mode prints the change in video_count with a sign, in the form (+<n> since <date>). A rising count with few new videos in your snapshot usually means the sound is bigger than MAX_PAGES covers.
View counts are what TikTok reports at the moment of the call, and large counts are sometimes rounded. Treat day-over-day gains as a ranking signal (which videos are moving) rather than an exact audit.
Run it every day with cron
Put the key in a .env file next to the script so it stays out of your crontab, then add two lines with crontab -e. Use the full path to node (from which node), because cron runs with a minimal PATH:
# .env
SCRAPINGBOT_API_KEY=your_key_here
# crontab: counts at 06:00, full snapshots at 06:05
0 6 * * * cd /home/me/tracker && /usr/local/bin/node --env-file=.env --disable-warning=ExperimentalWarning tracker.mjs count 7683546066251827217 7682907287056337667 >> tracker.log 2>&1
5 6 * * * cd /home/me/tracker && /usr/local/bin/node --env-file=.env --disable-warning=ExperimentalWarning tracker.mjs videos 7682907287056337667 >> tracker.log 2>&1
--env-file is built into Node, so this is still dependency-free. Schedule both at a fixed time of day: comparing a 06:00 snapshot with a 23:00 one would make some days look much better than others. Because the date is UTC, a run just after midnight UTC belongs to the new day.
What it costs
| Tracking | Calls per day | Credits per month |
|---|---|---|
| Count only, 10 sounds | 10 | about 300 |
| Full snapshot, one sound with 218 videos | about 22 | about 660 |
| Full snapshot at the default cap | up to 50 per sound | up to 1,500 per sound |
Every TikTok call is 1 credit. The free account's 100 credits cover a few days of testing; the Starter plan is $49.99 for 275,000 credits a month, so even dozens of fully tracked sounds fit with room to spare. Calls that fail are refunded.
Where to go from here
- Look up the creators behind the fastest-growing videos with follower and like counts.
- Read what people are saying under a breakout video: export its comments and replies to CSV.
- Need a one-off CSV instead of a tracker? Use the Python sound export.
- The /music/posts reference and pagination notes list every parameter; the TikTok API overview covers the other endpoints, and the TikTok scraping guide covers the wider picture.
Common questions
Is there a TikTok sound API I can call from Node.js?
Yes. Send a POST to https://scrapingbot.io/api/v1/tiktok with {"endpoint": "/music/info"} or {"endpoint": "/music/posts"} and the sound ID in params. Node 18 and newer have fetch built in, so no SDK or package is needed.
How do I track a TikTok sound's growth over time?
Save a snapshot every day and compare it with the previous one. The cheapest number is video_count from /music/info (1 credit). For per-video growth, store each video's play_count from /music/posts per day and subtract the earlier snapshot.
Why use node:sqlite instead of a JSON file?
Each run adds one row per video, and the questions you ask later (what is new, what grew, how did one video move over a month) are joins and filters. SQLite answers them in one query, and Node 24 ships it, so there is still nothing to install.
How much does tracking a sound every day cost?
The count is 1 credit per sound per day. A full snapshot is 1 credit per page of about 10 videos, so a sound with 218 videos is about 22 credits a day, roughly 660 a month. The Starter plan has 275,000 credits for $49.99.
What happens if a run fails or I skip a day?
Failed calls are refunded, and the script compares with the most recent earlier snapshot, whatever day that was. Running twice on the same day overwrites that day's rows instead of adding duplicates.