Tumblr Scraper
Pricing
from $2.00 / 1,000 posts scrapeds
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
Maintained by CommunityActor stats
0
Bookmarked
50
Total users
15
Monthly active users
5 days ago
Last modified
Categories
Features
Share
Tumblr Scraper — Blogs, Tags & Search
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
| Column | Notes |
|---|---|
postId, postUrl | IDs are strings — Tumblr post IDs exceed 2^53 |
blogName, blogTitle, blogAvatarUrl | Largest available avatar |
postType | text / photo / video / audio / quote / link / chat / answer — detected from the actual media, see Post types |
date, timestamp | ISO 8601 UTC and Unix seconds |
tags[], summary | summary is the title or body text, HTML stripped, capped at 500 chars |
noteCount | All modes |
likeCount, reblogCount, replyCount | Tag, 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 |
videoPosterUrl | Poster frame for video posts |
isNsfw, classification | Tag, 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 |
_source | Which mode produced the row |
raw | The untouched Tumblr payload (disable with includeRaw: false) |
Input
| Field | Type | Default | Description |
|---|---|---|---|
mode | blog | tag | search | tag | Which source to scrape |
blogNames | string | — | Blog mode. Comma-separated. Accepts staff, staff.tumblr.com, a custom domain, or a full URL |
tags | string | — | Tag mode. Comma-separated. A leading # and /tagged/ URLs are accepted |
searchQueries | string | — | Search mode. Comma-separated |
postTypes | string[] | [] (all) | Keep only these types |
maxPosts | integer | 50 | Total across all targets, deduplicated by post ID |
includeNotes | boolean | false | Request note details and emit a notes column |
downloadMedia | boolean | false | Save media into the key-value store |
fullResMedia | boolean | true | Resolve the largest rendition instead of a thumbnail |
includeRaw | boolean | true | Keep the raw column |
requestDelayMs | integer | 1100 | Hard floor between requests (min 500) |
proxyConfiguration | object | Apify Proxy on | See 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:
<figure data-npf='…'>— exact media URL, dimensions and poster (videos)<img srcset>— the widest candidate; Tumblr'swdescriptor caps at the original width- NPF
content[]blocks — the variant flaggedhasOriginalDimensions
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-Afterexactly 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)
| Event | Price | When |
|---|---|---|
apify-actor-start | $0.005 per run | Once 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 file | Per 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.
| Actor | Price per 1,000 posts | Platform usage |
|---|---|---|
| subimpact/tumblr-scraper | $2.00 | Included |
| hello.datawizards/tumblr-scraper | $5.00 | see listing |
| ahmed_jasarevic/tumblr-scraper | $2.50 | see listing |
| nifty.codes/tumblr-communities-scraper | $2.00 | see listing |
| igview-owner/tumblr-search-scraper | $20.00 | see 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 frommaxPostsand walks the whole archive it can reach. - Note bodies are not retrievable without authentication.
likeCount/reblogCount/replyCount/isNsfw/classificationare 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 installnpm 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).
| Path | Purpose |
|---|---|
src/main.js | Input handling, proxy setup, orchestration, media download, PPE charging |
src/lib/jsonp.js | Legacy JSONP unwrapping, rate-limit detection, read-API URLs |
src/lib/ssr.js | SSR state-blob location and parsing, timeline extraction, nextLink, consent-wall detection |
src/lib/media.js | Media sniffing (NPF, srcset, video) and post-type classification |
src/lib/normalize.js | One row shape across kebab-case, camelCase and snake_case sources |
src/lib/http.js | curl transport, pacing, backoff, media download |
src/lib/modes.js | Per-mode pagination loops and the full-res upgrade pass |
src/lib/collector.js | Dedupe, type filter, maxPosts cap |