DEV Community

Matvey
Matvey

Posted on

contentClass Was Null on 110 of 110 Rows. The Classifier Lived Behind a $0.06 Switch

I run a visa agency in Bali. The other half of my week goes into Apify Actors, and one of them turns a TikTok video into a row of text: transcript with timestamps, the text burned into the picture, the hook, the call to action, speaking pace.

One field in that row exists purely to stop the rest from lying. Short-form video is full of clips where the audio is not the author talking — a trending sound, someone else's song, a meme track. Transcribe one of those and you get real words that mean nothing about the creator. contentClass is supposed to say which kind of video you are holding: narrated, borrowed_audio, silent or empty.

On 24 October 2026 I checked how often it fires on real buyer traffic. It had never fired at all.

Eleven runs, 110 rows, one empty column

The Actor runs on a schedule, so there was a clean pile of production output to read — no new runs needed, dataset reads are free:

Runs, 13–24 October 2026 11
Rows written 111
Video rows (the rest is the free run-summary row) 110
contentClass null 110 of 110
speechCoverage null 110 of 110
Builds covered 0.1.29 → 0.1.39

Not "mostly null" — null on every row those five builds ever delivered, in four languages. The cause took one grep. Here is the row-building code, trimmed:

if want_ocr and video is not None and ocr_missing is None:
    M.main(str(tx_path), str(oc), str(payload_path), min_frames=2)
    payload = json.loads(payload_path.read_text())
    cls, reason = classify(payload)          # <-- the only call site
    gfx = payload.get("on_screen_graphics") or []
Enter fullscreen mode Exit fullscreen mode

want_ocr is the Read on-screen text switch. It downloads the video and reads it frame by frame, so it is billed separately: $0.06 per video, against $0.003 for the row itself and $0.0015 per started 10 seconds of speech recognition. A switch that multiplies the price of a row by twenty is off by default and stays off for almost everybody.

And the classifier lived inside it — not because it needed the frames. Of its four inputs, two come from the transcript and only the on-screen-text counts come from OCR. It was written on the day the OCR branch was written, and it inherited that branch's if.

That is the whole bug, and it is the boring kind: no exception, no empty dataset, no alert. Every row succeeded; one column was quietly null, and everything downstream carried on as if every video were narration. A clip running on a looped sound still got a wordsPerMinute measured on somebody else's song, and a hookText taken from the sound's first line rather than the author's opening.

Then the detector itself turned out to be half-blind

The cheapest signal for a borrowed track is verbatim repetition: a human explaining something does not say the same sentence three times at even intervals, a looped sound does. The check was tuned on a Photoshop tutorial where "Last-minute gift you can make right now" came back four times — 0, 20.7, 41.6 and 45 seconds — by normalising each line and counting the most common one:

lines = [re.sub(r"[^a-zа-яё0-9 ]", "", s["text"].lower()).strip()
         for s in p.get("transcript", [])]
lines = [l for l in lines if len(l) > 12]
Enter fullscreen mode Exit fullscreen mode

Two assumptions are baked into those two lines, and both failed on the first run I inspected.

The character class deletes most of the world's writing. A Duolingo clip in Japanese came back with ten caption lines, of which それでね appeared three times — at 1.64 s, 4.12 s and 6.6 s. After that re.sub, all ten were empty strings, the length filter dropped every one, and the counter returned 0 repeats of nothing. The detector did not decide the video was narration; it never saw a line.

The 13-character floor deletes short chants. Another clip's transcript was this, with the repeats at 2.5, 4.34, 5.5 and 7.0 seconds:

This is my chin. This is my jaw. Ah, really? Ah, really? Ah, really? Ah, really?

"Ah, really?" normalises to ah really — nine characters, below the floor, never counted. Four out of five lines in the clip were the same line, and the row shipped as narrated with wordsPerMinute: 115 and hookText: "This is my chin."

The floor had a real job: counting every short filler as a repeat would flag genuine narration. So the fix replaces length with share — how much of the track the repeat occupies:

_PUNCT = re.compile(r"[^\w ]", re.UNICODE)

lines = [_norm(s.get("text")) for s in p.get("transcript", [])]
lines = [l for l in lines if len(l) >= MIN_LINE_CHARS]        # 4
text, n = collections.Counter(lines).most_common(1)[0]
share = n / len(lines)
if n >= min_repeats and (len(text) > LOOP_LINE_CHARS or share >= LOOP_SHARE):
    return True, n                                            # long line, or it owns the track
repeat_share = 1 - len(set(lines)) / len(lines)
if n >= min_repeats and len(lines) >= CHANT_LINES and repeat_share >= CHANT_SHARE:
    return True, n                                            # a chant of short syllables
Enter fullscreen mode Exit fullscreen mode

\w with re.UNICODE keeps letters of any script; the underscore is stripped by hand. Thresholds: 3 repeats minimum, 0.4 of lines for a short line, 0.3 verbatim-repetition share over at least 6 lines for a chant.

The control case is what those numbers are chosen for. A 45.6-second interview clip has 16 lines and shouts "Boom! Boom!" twice:

Clip Lines Top line Repeats Top-line share Repetition share Verdict
Japanese caption track 9 それでね 3 0.33 0.33 borrowed
"Ah, really?" 5 ah really 4 0.80 0.60 borrowed
LE SSERAFIM interview 16 boom boom 2 0.13 0.06 narrated

Replaying all 21 distinct videos from those 11 production runs through the new rules gives 19 narrated and 2 borrowed_audio, with no flips among the other 19.

Same 11 URLs, two builds, 40 minutes apart

Local replays prove the arithmetic, not the pipeline. So: the same 11 URLs through the previous build and the new one, from the same account.

0.1.39 (before) 0.1.40 (after)
Rows 10 videos + 1 free error row 10 videos + 1 free error row
contentClass filled 0 10
borrowed_audio 0 2
speechCoverage filled 0 10
Cost $0.0435 $0.0435

Identical cost: the class is computed from data the row already holds, so there is no extra request and no new billable event. (Durations were 55 s and 82 s, but the runs overlapped on one account — that gap is scheduling, not the fix.)

The two reclassified rows changed in the way that matters:

{
  "videoId": "7699514104419061022",
  "contentClass": "borrowed_audio",
  "contentClassReason": "the same line repeats word for word 4 times: a looped sound",
  "speechCoverage": 0.996,
  "transcriptText": "This is my chin. This is my jaw. Ah, really? Ah, really? Ah, really? Ah, really?",
  "hookText": "i’m ready for the “I BLOCKEDJA” comments",
  "hookSource": "caption",
  "wordsPerMinute": null
}
Enter fullscreen mode Exit fullscreen mode

wordsPerMinute went from 115 to null and the hook moved from speech to caption. The transcript is still there — you asked for it, you get it — but nothing downstream presents it as the author's script any more. The Japanese clip did the same: 62 → null, hook source speech → caption.

curl -X POST "https://api.apify.com/v2/acts/lergassy~tiktok-transcript-scraper/run-sync-get-dataset-items?token=<TOKEN>" \
  -H "Content-Type: application/json" \
  -d '{"videoUrls":["https://www.tiktok.com/@duolingo/video/7699514104419061022"],
       "transcriptionMode":"captions-first","includeTranscriptSegments":true}'
Enter fullscreen mode Exit fullscreen mode

The listing is TikTok Transcript Scraper — links or a whole handle in, one row per video out, Instagram Reels and YouTube Shorts links accepted in the same run.

What it still gets wrong, on purpose

The Actor's own prefilled example is a Duolingo fashion clip whose audio is a gospel song: "God, thank you for sunshine / Thank you for rain / Thank you for joy / Thank you for pain." Borrowed, obviously, to you and me. Five lines, none repeated, speech covering 97.8% of the clip — so after the fix it is still narrated, and still wrong.

Catching that one needs the second signal, which genuinely needs the pixels: sparse speech with no burned-in subtitles, because creators who voice over their own clips subtitle them and creators who slap a trending sound on do not. That branch stays behind the on-screen-text switch, and in audio-only mode I disabled it rather than let it guess. For the same reason audio-only mode can never return empty — "no speech and no on-screen text" is not a claim you may make about frames you never looked at. Three values there, not four.

When this is the wrong tool. If you need certainty about whose voice it is, pay for the frames; audio alone misses unrepeated song lyrics. If you only want the words, ignore the class. And if your clips are long-form talking heads, none of this applies — the repetition rules are tuned on vertical video under a minute, where a looped sound is the normal case rather than an oddity.

The lesson I would bill myself for: a validator that lives inside an if is only as good as that if, and a field that is always null looks exactly like a field that is always fine. If a column exists to catch bad data, put a count of how often it fires into your own weekly numbers. "Never" is a result, and mine took eleven runs and 110 rows to notice.

Top comments (0)