# Tumblr Scraper (`subimpact/tumblr-scraper`) Actor

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.

- **URL**: https://apify.com/subimpact/tumblr-scraper.md
- **Developed by:** [subimpact](https://apify.com/subimpact) (community)
- **Categories:** Social media, Automation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $2.50 / 1,000 post scrapeds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.
Actors are written with capital "A".

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## 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](#post-types-are-sniffed-not-trusted) |
| `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 and search modes only** — the legacy blog API does not expose these |
| `media[]` | `{type, url, width?, height?, alt?, thumbnail?}`, full resolution by default |
| `videoPosterUrl` | Poster frame for video posts |
| `isNsfw`, `classification` | **Tag and search modes only** |
| `reblogFrom` | `{blogName, blogTitle, postUrl, rootBlogName, rootPostUrl}` when the post is a reblog |
| `notes[]` | Only when `includeNotes` is on — see [Notes](#notes-are-counts-only) |
| `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` | `blog` | 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](#proxies) |

#### Example — a blog's photo posts at full resolution

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

#### Example — a tag, with media downloaded

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

***

### How each mode works

#### `blog` — full archive access

Uses Tumblr's **legacy read API** (`https://{blog}.tumblr.com/api/read/json`), which still works without any credentials. Paginates with the `start` offset against the authoritative `posts-total`, so it can walk an entire archive.

This is the only mode with **unlimited pagination depth**.

#### `tag` / `search` — first page guaranteed, deeper needs a non-EU IP

`https://www.tumblr.com/tagged/{tag}` and `https://www.tumblr.com/search/{query}` are server-rendered: the complete timeline JSON is embedded in the page, so no browser is needed. One page yields roughly **7 posts per tag** and **14 per query** (the raw timeline carries ~11 and ~22 entries, the rest being ads, section titles and carousels — all filtered out).

Going deeper follows the timeline's `nextLink` (`/v2/hubs/…` or `/v2/timeline/search`). **Those `/v2/` paths redirect to Tumblr's EU consent wall from an EU exit IP** — verified live. The SSR page itself serves full data even when Tumblr flags the request as EU (`gdprIsEu: true`); only the JSON API is gated. The classic `/tagged/{tag}/page/2` fallback is also gone (HTTP 404), so there is no SSR-side workaround.

**What this means for you:** run tag and search modes through a **non-EU proxy** (`apifyProxyCountry: "US"`). If the actor hits the wall it logs a clear warning, keeps the posts it already has, and reports `stoppedBecause: "consent-wall"` in `RUN_SUMMARY` 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:** set a **non-EU country**, e.g. `{"useApifyProxy": true, "apifyProxyCountry": "US"}`, or pagination stops after the first page.
- **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 | When |
| --- | --- |
| `apify-actor-start` | Once per run |
| `page-scraped` | Per post pushed to the dataset (deduplicated) |
| `media-downloaded` | 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.

***

### Limits

- **Tag and search depth** is capped at one page from an EU exit IP (see above). Use a US proxy.
- **Note bodies** are not retrievable without authentication.
- `likeCount` / `reblogCount` / `replyCount` / `isNsfw` / `classification` are **null in blog mode** — the legacy API does not carry them.
- Private, password-protected and deleted blogs are not accessible; blog mode reports HTTP 404 and moves on.
- 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

```bash
npm install
npm test          # 128 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 |

# Actor input Schema

## `mode` (type: `string`):

blog = posts from one or more blogs (legacy read API, paginates through the whole archive). tag = posts from a tag hub page. search = posts matching a search query.

## `blogNames` (type: `string`):

One or more blogs, comma-separated. Accepts a bare name (staff), a host (staff.tumblr.com), a custom domain, or a full URL (https://staff.tumblr.com).

## `tags` (type: `string`):

One or more tags, comma-separated. A leading # and a full /tagged/ URL are both accepted. Note: only the first page (~8 posts per tag) is reachable from an EU exit IP — see the README on the consent wall.

## `searchQueries` (type: `string`):

One or more search queries, comma-separated. Note: only the first page (~16 posts per query) is reachable from an EU exit IP — see the README on the consent wall.

## `postTypes` (type: `array`):

Keep only these post types. Leave empty for all types. Types are detected by sniffing the actual post media, not by trusting Tumblr's declared type — a 'regular' post with inline photos matches both text and photo.

## `maxPosts` (type: `integer`):

Total posts to collect across all targets. Deduplicated by post ID, so overlapping tags/queries never double-count.

## `includeNotes` (type: `boolean`):

Request note details (notes\_info + reblog\_info) and emit a notes\[] column. Note counts always come back; individual note bodies are NOT returned by Tumblr's keyless endpoints, so notes\[] is usually null — see the README.

## `downloadMedia` (type: `boolean`):

Download images and videos into the run's key-value store and reference them from each row's mediaFiles column. Files over 50 MB and third-party embeds are skipped. Billed per file downloaded.

## `fullResMedia` (type: `boolean`):

Pick the largest available rendition of every image/video instead of Tumblr's small thumbnail. Uses inline srcset / NPF data where possible, and only falls back to fetching the post page when a post offers nothing better.

## `includeRaw` (type: `boolean`):

Keep the untouched Tumblr object in a raw column for full fidelity. Turn off for smaller datasets.

## `requestDelayMs` (type: `integer`):

Hard floor between HTTP requests. Tumblr rate-limits per IP and starts returning 429 after bursts of 3-6 rapid requests, so values under 1000 ms are not recommended.

## `proxyConfiguration` (type: `object`):

Apify Proxy settings. Datacenter is enough for blog mode. For tag and search mode set a non-EU country (US) — Tumblr redirects its /v2/ pagination endpoints to an EU consent wall, which caps those modes at one page. Switch to RESIDENTIAL if you see repeated 429s.

## Actor input object example

```json
{
  "mode": "blog",
  "blogNames": "staff",
  "tags": "photography",
  "searchQueries": "photography",
  "postTypes": [],
  "maxPosts": 50,
  "includeNotes": false,
  "downloadMedia": false,
  "fullResMedia": true,
  "includeRaw": true,
  "requestDelayMs": 1100,
  "proxyConfiguration": {
    "useApifyProxy": true,
    "apifyProxyGroups": []
  }
}
```

# Actor output Schema

## `postId` (type: `string`):

Tumblr post ID

## `postUrl` (type: `string`):

Canonical permalink

## `blogName` (type: `string`):

Blog that published the post

## `postType` (type: `string`):

Detected post type

## `date` (type: `string`):

ISO 8601 publication time

## `tags` (type: `string`):

Tags on the post

## `summary` (type: `string`):

Title or body text, HTML stripped

## `noteCount` (type: `string`):

Total note count

## `mediaCount` (type: `string`):

Media entries found

## `media` (type: `string`):

Media entries with URLs and dimensions

## `mediaFiles` (type: `string`):

Stored media file references

## `_source` (type: `string`):

Mode that produced the row

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "mode": "blog",
    "blogNames": "staff",
    "tags": "photography",
    "searchQueries": "photography",
    "maxPosts": 50,
    "requestDelayMs": 1100,
    "proxyConfiguration": {
        "useApifyProxy": true,
        "apifyProxyGroups": []
    }
};

// Run the Actor and wait for it to finish
const run = await client.actor("subimpact/tumblr-scraper").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "mode": "blog",
    "blogNames": "staff",
    "tags": "photography",
    "searchQueries": "photography",
    "maxPosts": 50,
    "requestDelayMs": 1100,
    "proxyConfiguration": {
        "useApifyProxy": True,
        "apifyProxyGroups": [],
    },
}

# Run the Actor and wait for it to finish
run = client.actor("subimpact/tumblr-scraper").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "mode": "blog",
  "blogNames": "staff",
  "tags": "photography",
  "searchQueries": "photography",
  "maxPosts": 50,
  "requestDelayMs": 1100,
  "proxyConfiguration": {
    "useApifyProxy": true,
    "apifyProxyGroups": []
  }
}' |
apify call subimpact/tumblr-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,subimpact/tumblr-scraper"
        }
    }
}

```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/PADpV1EHhBamwSHGa/builds/hPZimRPd1wgKlJOMw/openapi.json
