Tumblr Scraper avatar

Tumblr Scraper

Pricing

from $2.00 / 1,000 posts scrapeds

Go to Apify Store
Tumblr Scraper

Tumblr Scraper

Scrape Tumblr posts from any public blog, tag or search query. Full-resolution media, tags, note counts, engagement metrics, reblog info and NSFW classification. No API key, no login needed.

Pricing

from $2.00 / 1,000 posts scrapeds

Rating

0.0

(0)

Developer

subimpact

subimpact

Maintained by Community

Actor stats

0

Bookmarked

50

Total users

15

Monthly active users

5 days ago

Last modified

Features

Limited permissions

Share

Scrape Tumblr posts from any public blog, tag or search query. No API key, no OAuth, no login, no browser — plain HTTP only, so runs are cheap and fast.

Every mode produces the same normalized row shape, so you can mix blog, tag and search runs into one dataset without reshaping anything.


What you get

ColumnNotes
postId, postUrlIDs are strings — Tumblr post IDs exceed 2^53
blogName, blogTitle, blogAvatarUrlLargest available avatar
postTypetext / photo / video / audio / quote / link / chat / answer — detected from the actual media, see Post types
date, timestampISO 8601 UTC and Unix seconds
tags[], summarysummary is the title or body text, HTML stripped, capped at 500 chars
noteCountAll modes
likeCount, reblogCount, replyCountTag, search and blog-fallback mode — the only source missing them is the legacy blog API
media[]{type, url, width?, height?, alt?, thumbnail?}, full resolution by default
videoPosterUrlPoster frame for video posts
isNsfw, classificationTag, search and blog-fallback mode
reblogFrom{blogName, blogTitle, postUrl, rootBlogName, rootPostUrl} when the post is a reblog
notes[]Only when includeNotes is on — see Notes
mediaFiles[]Only when downloadMedia is on
_sourceWhich mode produced the row
rawThe untouched Tumblr payload (disable with includeRaw: false)

Input

FieldTypeDefaultDescription
modeblog | tag | searchtagWhich source to scrape
blogNamesstring—Blog mode. Comma-separated. Accepts staff, staff.tumblr.com, a custom domain, or a full URL
tagsstring—Tag mode. Comma-separated. A leading # and /tagged/ URLs are accepted
searchQueriesstring—Search mode. Comma-separated
postTypesstring[][] (all)Keep only these types
maxPostsinteger50Total across all targets, deduplicated by post ID
includeNotesbooleanfalseRequest note details and emit a notes column
downloadMediabooleanfalseSave media into the key-value store
fullResMediabooleantrueResolve the largest rendition instead of a thumbnail
includeRawbooleantrueKeep the raw column
requestDelayMsinteger1100Hard floor between requests (min 500)
proxyConfigurationobjectApify Proxy onSee Proxies

Example — a blog's photo posts at full resolution

{
"mode": "blog",
"blogNames": "staff, engineering",
"postTypes": ["photo"],
"maxPosts": 200,
"fullResMedia": true
}

Example — a tag, with media downloaded

{
"mode": "tag",
"tags": "photography",
"downloadMedia": true,
"proxyConfiguration": { "useApifyProxy": true, "apifyProxyCountry": "US" }
}

How each mode works

blog — full archive access, with a built-in fallback

Blog mode first tries Tumblr's legacy read API (https://{blog}.tumblr.com/api/read/json), which paginates with the start offset against the authoritative posts-total. As of October 2025/2026 that endpoint is gated: from residential and VPS exits — and from most Apify proxy exits — it answers every blog with a "Checking your browser…" HTML interstitial (HTTP 403) instead of post data.

Blog mode therefore has an automatic fallback: when the legacy API returns HTML instead of JSONP, the actor reads the blog's server-rendered page at https://www.tumblr.com/{blog} — the exact same machinery tag and search mode use — and keeps walking the archive through Tumblr's /v2/blog/{blog}/posts page-number cursor. The output rows are identical (_source stays blog), posts are deduplicated, pushed and charged exactly the same, and the fallback rows even carry like/reblog/reply counts and NSFW classifications that the legacy API never had. If the legacy API answers normally, the fallback is never requested and behaviour is unchanged.

One caveat inherited from the SSR machinery: from EU exit IPs, Tumblr's /api/v2/ JSON endpoints can redirect to the EU consent wall. If that happens the actor stops the walk cleanly, keeps page 1's posts and reports stoppedBecause: "consent-wall" — running with a non-EU country (US) avoids it.

This is the only mode that can walk a full archive; the crawl depth follows your maxPosts setting in every path.

tag / search / blog — deep pagination through Tumblr's web API

https://www.tumblr.com/tagged/{tag}, https://www.tumblr.com/search/{query} and https://www.tumblr.com/{blog} are server-rendered: the complete first timeline is embedded in the page, so no browser is needed. One page yields roughly 17 posts per blog and 7 posts per tag (the raw timeline carries ads, section titles and carousels — all filtered out).

Going deeper follows the timeline's nextLink cursor, exactly like Tumblr's own web client does: the actor reads the page state's apiUrl (https://www.tumblr.com/api) and its public web token, resolves nextLink against apiUrl (https://www.tumblr.com/api/v2/hubs/…, …/api/v2/blog/{blog}/posts?…), and sends Authorization: Bearer <token> on every pagination request — verified live 2026-10-22 from a US datacenter proxy answering HTTP 200 JSON pages (blog page 2: 20 posts; tag page 2: 10 posts). Resolving without the /api prefix or without the bearer header answers HTTP 404, which is what every run did before this.

If pagination is nevertheless refused (EU consent-wall redirect, HTTP 401/403), the actor stops the walk cleanly: the posts already collected are kept and delivered, and RUN_SUMMARY reports stoppedBecause: "consent-wall" or "api-blocked" rather than failing.


Behaviors worth knowing

Post types are sniffed, not trusted

The legacy API's type= filter is unreliable, and its type field is not much better. Verified live on staff.tumblr.com: requesting type=photo or type=video returns posts that all declare "type": "regular", with the media inline in the post body HTML and no photo-url-* fields at all.

So this actor never sends a type= filter. Instead it detects media by parsing the post body (NPF data-npf payloads, <img srcset>, <video>/<source>) and NPF content blocks, then classifies from what it actually found.

A post can match more than one type. A regular post carrying inline photos matches both text and photo, so postTypes: ["photo"] finds it — while postType reports the most specific medium present (photo). Filtering is deliberately inclusive; the column is deliberately specific.

Full-resolution media costs no extra requests (usually)

fullResMedia: true reads the largest rendition straight out of the markup already in hand:

  1. <figure data-npf='…'> — exact media URL, dimensions and poster (videos)
  2. <img srcset> — the widest candidate; Tumblr's w descriptor caps at the original width
  3. NPF content[] blocks — the variant flagged hasOriginalDimensions

Only if a post yields nothing better than a thumbnail (e.g. an old photo-url-* post) does the actor fetch that post's page, capped at 25 posts per run. If that parse fails, the thumbnail URLs are kept — you never lose a row over it.

Notes are counts only

noteCount is always populated. Individual note bodies are not available: verified live, notes_info=true on the keyless legacy endpoint returns only note-count, no notes array. With includeNotes: true the notes column is emitted as an explicit null so you can tell "asked, unavailable" from "never asked". The parsing is in place, so if Tumblr ever returns notes they will populate automatically.

Rate limiting

Tumblr's per-IP limit is aggressive — bursts of 3–6 rapid requests start returning 429, and without a browser User-Agent the legacy API returns 429 immediately. The client therefore:

  • always sends a browser User-Agent (mandatory, not cosmetic);
  • enforces a hard floor of requestDelayMs (default 1100 ms ≈ 1 req/s) between all requests, serialising concurrent callers so no burst can slip through;
  • retries 429s with exponential backoff from 2 s (+jitter, 60 s ceiling), honouring Retry-After exactly when present;
  • detects the rate-limit page even when it arrives with a 200 status.

Throttling degrades gracefully: posts already collected are kept and pushed.

Deduplication

Rows are deduplicated by postId across pages, targets and modes — overlapping tags and queries genuinely return the same posts, and you are never charged twice for one post.

Media download

downloadMedia: true streams each file into the run's key-value store and adds mediaFiles[] to the row with the store key and a public record URL. Files over 50 MB are skipped, as are third-party iframe embeds (they are player pages, not media). Failures are recorded per entry in mediaFiles[].skipped instead of failing the row.


Proxies

  • Blog mode: datacenter proxy (the default) is fine.
  • Tag / search mode: datacenter proxy works for pagination (verified live); add apifyProxyCountry: "US" if your exit IP is EU-based (Tumblr's consent wall can gate the JSON API from EU IPs) or if you see repeated 429s.
  • Repeated 429s: switch to RESIDENTIAL. The limit is per IP, so rotating residential IPs is the effective fix.
  • Running with no proxy at all works for small runs but rate-limits quickly; the actor warns when it detects this.

Pricing (pay per event)

EventPriceWhen
apify-actor-start$0.005 per runOnce per run
page-scraped$0.002 per post ($2.00 per 1,000 posts)Per post pushed to the dataset (deduplicated)
media-downloaded$0.005 per filePer media file actually stored — only with downloadMedia

Charges never interrupt a run: a failing charge is logged as a warning and the run continues. Posts are pushed and charged incrementally, in batches of at most 10 as the crawl progresses — so a timed-out run still delivers (and is charged for) exactly the posts it collected, and an upstream block that yields nothing bills nothing.

How this compares

Plain HTTP only: no browser, no API key, no OAuth and no login, and every mode (blog, tag, search) produces the same normalized row shape, so results from different modes can share one dataset. Rows carry fields the basic alternatives may not, such as notes[] counts, the reblogFrom graph, media variants and NSFW classification. At $2.00 per 1,000 posts it is below hello.datawizards/tumblr-scraper ($5.00) and ahmed_jasarevic/tumblr-scraper ($2.50), and level with nifty.codes/tumblr-communities-scraper ($2.00). Media downloads are billed separately at $0.005 per file, and the run fee is $0.005.

ActorPrice per 1,000 postsPlatform usage
subimpact/tumblr-scraper$2.00Included
hello.datawizards/tumblr-scraper$5.00see listing
ahmed_jasarevic/tumblr-scraper$2.50see listing
nifty.codes/tumblr-communities-scraper$2.00see listing
igview-owner/tumblr-search-scraper$20.00see listing

Prices are Apify Store list prices on the Free plan, checked 24 October 2026. Competitor prices can change; check their listings.


Limits

  • Tag and search depth has a per-target safety cap (maxPagesPerTarget = 40 pages ≈ 400+ posts per tag/query target in the fixed-cap modes); blog mode derives its page budget from maxPosts and walks the whole archive it can reach.
  • Note bodies are not retrievable without authentication.
  • likeCount / reblogCount / replyCount / isNsfw / classification are null in blog mode when the legacy API answers — it does not carry them; the SSR fallback fills them in.
  • Private, password-protected and deleted blogs are not accessible; blog mode reports HTTP 404 and moves on. If the SSR fallback itself is blocked or unparseable, the run ends FAILED (or, with everything blocked, with a clear zero-row failure) instead of returning an empty success.
  • Full-resolution post-page upgrades are capped at 25 posts per run to bound request count.

Every run writes a RUN_SUMMARY record to the key-value store with per-target page counts, stop reasons, dedupe/filter tallies, request counts and rate-limit hits — start there when a run returns less than you expected.


Development

npm install
npm test # 190 tests, node --test, fixtures only — no network

Tests run entirely against captured fixtures in fixtures/, including a real 429 rate-limit page, and assert byte-exact parses (item counts, first post IDs, resolved media URLs).

PathPurpose
src/main.jsInput handling, proxy setup, orchestration, media download, PPE charging
src/lib/jsonp.jsLegacy JSONP unwrapping, rate-limit detection, read-API URLs
src/lib/ssr.jsSSR state-blob location and parsing, timeline extraction, nextLink, consent-wall detection
src/lib/media.jsMedia sniffing (NPF, srcset, video) and post-type classification
src/lib/normalize.jsOne row shape across kebab-case, camelCase and snake_case sources
src/lib/http.jscurl transport, pacing, backoff, media download
src/lib/modes.jsPer-mode pagination loops and the full-res upgrade pass
src/lib/collector.jsDedupe, type filter, maxPosts cap