# 🔍 Facebook Hashtag Search Scraper (`scraper-engine/facebook-hashtag-search-scraper`) Actor

- **URL**: https://apify.com/scraper-engine/facebook-hashtag-search-scraper.md
- **Developed by:** [Scraper Engine](https://apify.com/scraper-engine) (community)
- **Categories:** Social media, Developer tools, Automation
- **Stats:** 1 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $4.99 / 1,000 results

This Actor is paid per event and usage. You are charged both the fixed price for specific events and for Apify platform usage.

Learn more: https://docs.apify.com/platform/actors/running/actors-in-store#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

### Facebook Hashtag Search Scraper — Posts, Reactions and Reels Data

Facebook Hashtag Search Scraper turns any Facebook hashtag into a structured post dataset: post text, author name and profile, the full reaction breakdown by type, comment and share counts, video view counts, UTC timestamps and every attachment — including enriched Reels metadata for video posts. One typed JSON row per post, pushed live as it is collected, exportable to CSV, Excel, XML or JSON. Paste a hashtag, add your Facebook session cookies, press Start.

⚠️ **Read the cookie requirement below before your first run.** Facebook serves hashtag search results only to a logged-in session, so this Actor needs your own `c_user` and `xs` cookies. Without both, the run finishes cleanly and writes a single unbilled record telling you what to fix — it does not return posts.

### What is Facebook Hashtag Search Scraper?

Facebook Hashtag Search Scraper is an Apify Actor that reads Facebook's own hashtag-search GraphQL responses and shapes each result into a flat post record. It accepts plain hashtags, `#hashtags` and full hashtag URLs, processes as many as you supply in one run, and writes each post to the dataset the moment it is extracted.

A Facebook login **is** required — not an Apify-side account, but your own browser session, supplied as the `c_user` and `xs` cookies. Facebook's hashtag feed returns nothing to an anonymous request, which is a platform behaviour, not an Actor limitation.

It is built for social listening and trend teams tracking what gets posted under a tag, growth marketers benchmarking engagement in a niche, and developers piping hashtag posts into a dashboard or an LLM pipeline.

### What Facebook hashtag post data is publicly available to scrape?

Facebook's hashtag feed shows each matching post exactly as a logged-in visitor sees it in search: the caption, the author, the aggregate engagement counts and the attached media. What it never exposes is the individual people behind those counts, or anything from a non-public audience.

| Data Category | Returned by this Actor | Not exposed by the hashtag feed |
| ----- | ----- | ----- |
| Post caption text, post ID, canonical post URL | ✅ Yes | — |
| Author display name, numeric ID, profile URL, avatar image | ✅ Yes | — |
| Reaction breakdown by reaction name, comment count, share count | ✅ Yes | — |
| Video view count and full Reels / FB Shorts media metadata | ✅ Yes | — |
| Post creation time — UTC string plus Unix seconds | ✅ Yes | — |
| Attachments — photos, albums, videos, share cards, life events | ✅ Yes | — |
| Comment text and comment threads | ❌ | Aggregate count only in this feed |
| Who reacted, who shared — individual reactor and sharer lists | ❌ | Not in the feed payload |
| Friends-only posts, closed-group posts, private profiles | ❌ | Audience-restricted |
| Author email, phone or other contact details | ❌ | Never in a post payload |

Facebook Hashtag Search Scraper only returns publicly visible posts — what any logged-in visitor sees on the hashtag page. Nothing from a private profile, a friends-only audience or a closed group.

### 🍪 You must supply your own Facebook session cookies

This is the one prerequisite, and it is worth stating plainly rather than burying it in the input table.

Facebook's hashtag search endpoint only answers an authenticated session. The Actor bootstraps every run by loading `https://www.facebook.com/hashtag/<tag>` with your cookies attached, then reads the dynamic GraphQL parameters out of that page. On a logged-out response Facebook does not even ship the search query bundle, so there is nothing to paginate.

Supply two cookie values from a browser where you are already logged in:

- `c_user` — your numeric Facebook user ID
- `xs` — your session token, stored as a secret input so it is masked in the Console and run history

Get both from DevTools → **Application** → **Cookies** → `https://www.facebook.com`, or from a cookie-export extension such as Cookie-Editor.

Neither field is marked `required` in the schema, deliberately: a run without them finishes **SUCCEEDED** and writes one unbilled record with `status` set to `credentials_required`, instead of failing. Two related states are detected and reported the same way — `cookies_expired` when Facebook serves the logged-out page, and `account_checkpoint` when Facebook has locked the account behind a security check. A checkpoint cannot be worked around by changing IP, so the Actor stops on it immediately rather than retrying. Use cookies from a healthy account, and expect that any account whose session is replayed by automation carries a real risk of being challenged or locked by Facebook.

### What data can I extract with Facebook Hashtag Search Scraper?

Every row carries four groups of data: which hashtag produced it, who posted it, how the post performed, and what media is attached.

| Field Name | Description |
| ----- | ----- |
| `searchQuery` | The normalised hashtag that produced this post, without the `#` |
| `postId` | Facebook post ID |
| `url` | Canonical post URL, built as `https://www.facebook.com/<postId>` |
| `text` | Post caption / message text, `""` when the post has no text |
| `user` | Author object: `name`, `id`, `url`, `avatar` |
| `topReactions` | Reaction counts keyed by Facebook's own reaction name, `{}` when none |
| `commentsCount` | Total comments on the post, `0` when unavailable |
| `sharesCount` | Total shares, `0` when unavailable |
| `videoViewCount` | Video views for video and Reels posts, `0` otherwise |
| `time` | Creation time as `YYYY-MM-DD HH:MM:SS` in UTC, `""` when no timestamp |
| `timestamp` | Creation time as Unix seconds, `0` when unavailable |
| `attachments` | Array of attachment objects — photos, albums, videos, shares |
| `status` | Diagnostic rows only: `credentials_required`, `cookies_expired`, `account_checkpoint` or `no_results` |
| `message` | Diagnostic rows only: the human-readable explanation of that status |

#### Post, author and engagement fields

`searchQuery`, `postId`, `url` and `text` identify the post. `searchQuery` is the cleaned tag, so `#Travel`, `travel` and `https://www.facebook.com/hashtag/travel` all land in the dataset as `travel`.

`user` is a nested object with four keys: `name` and `id` from the post's owning profile, `url` built as `https://www.facebook.com/profile.php?id=<id>`, and `avatar` taken from the author's profile picture in the story payload. All four keys are always present; any of them can be an empty string when Facebook omits the owning profile, and in that case `user.url` still comes back as the bare `profile.php?id=` form.

`topReactions` is a map, not a fixed list — the keys are Facebook's **localized** reaction names, taken from `localized_name` in the response. An English session returns `Like`, `Love`, `Care`, `Wow`, `Haha`, `Sad`, `Angry`; a session set to another language returns those names translated. If you aggregate across accounts, key on the session language or normalise the labels yourself. `commentsCount`, `sharesCount` and `videoViewCount` are plain integers, and `videoViewCount` is read from either the story or the UFI feedback block, whichever carries it.

`time` and `timestamp` are the same instant twice. `time` is rendered in UTC on purpose, so the string does not shift with the container's time zone; `timestamp` is Unix seconds.

#### Attachment and Reels fields

`attachments` is an array. Each entry has the same eight-key shape:

| Attachment key | Description |
| ----- | ----- |
| `deduplication_key` | Facebook's own dedupe key for the attachment, `""` when absent |
| `target` | `{ "__typename": <media type>, "id": <media id> }` — the fastest way to tell a `Video` from a `Photo` |
| `__typename` | Always the literal `StoryAttachment` |
| `style_list` | Facebook's renderer style tokens, e.g. `["photo", "fallback"]` or `["fb_shorts_creation", "video", "fallback"]`, derived from the renderer when Facebook does not send them |
| `styles` | The full renderer object as Facebook returns it |
| `media` | The media object as Facebook returns it |
| `all_subattachments` | `{ "nodes": [...] }` — album children, `{ "nodes": [] }` when there are none |

Video attachments get a second pass. For every attachment whose `target.__typename` is `Video`, the Actor issues a separate Reels detail query and rewrites that attachment's `style_list`, `styles` and `media` with the full FB Shorts payload. After enrichment, `styles.attachment.style_infos[0].fb_shorts_story` carries `post_id`, `creation_time`, `message`, `feedback`, `privacy_scope`, `tracking`, `encrypted_tracking`, `sponsored_data`, `branded_content_post_info`, `save_info`, `to` and `legal_reporting_uri`, plus a `short_form_video_context` object holding `playback_video`, `video`, `video_owner`, `shareable_url`, `video_owner_type`, `soundtrack_info`, `track_title`, `music_album_art_uri`, `is_original_audio_on_facebook`, `first_frame_thumbnail`, `remix_info`, `is_passive_content` and `fb_shorts_reshare_context`. `playback_video` itself carries the player-level fields — `width`, `height`, `length_in_second`, `permalink_url`, `captions_url`, `is_looping`, `is_spherical`, `muted_segments`, `drm_info`, `audio_settings`, `preferred_thumbnail`, `videoDeliveryLegacyFields` and the rest of the delivery block. These are passed through with Facebook's own key names rather than renamed, so a value you find in a browser response is at the same path here.

Two behaviours to plan for. A post is only written when it has either `text` or at least one attachment — a story with neither is skipped rather than emitted as an empty row. And `status` / `message` are **omitted entirely** from post rows rather than set to `null`, so their presence is a reliable marker of a diagnostic record.

#### 🤖 Add-on: Need additional Facebook data?

Hashtag search is one entry point into Facebook. **Facebook Posts Scraper** pulls posts from a specific page or profile timeline rather than a tag, and **Facebook Group Post Scraper** does the same inside groups you can access. **Facebook Video Search Scraper** is the better fit when video is the unit you care about, and **Instagram Hashtag Scraper** covers the same hashtag-research job on the other Meta surface.

### Why not build this yourself?

Meta's Graph API publishes no endpoint that returns a Facebook hashtag feed to a third-party app — check Meta's current Graph API reference before assuming otherwise, and note that the hashtag-search endpoints it does document belong to Instagram, not Facebook. So the alternative to this Actor is your own scraper, and the maintenance load is the real cost.

Facebook's hashtag results come from persisted GraphQL queries, and every request needs values that only exist inside the live page: an `fb_dtsg` token, an `lsd` token, `jazoest`, the haste session, the spin parameters, and — the awkward part — the numeric `doc_id` of three separate operations (`SearchCometResultsInitialResultsQuery`, `SearchCometResultsPaginatedResultsQuery`, `FBReelsRootWithEntrypointQuery`). Those IDs change with Facebook's build. This Actor extracts them from the hashtag page HTML, and when they are not there it fans out across the page's `static.xx.fbcdn.net` JavaScript bundles to find them, with a dedicated fallback path through `/reel/` for the Reels query. It also has to unwrap Facebook's `for (;;);` anti-CSRF prefix and split the multi-object streamed response before any JSON parsing happens.

The second cost is the session. Facebook ties account trust to a stable IP, so a naive residential proxy URL — which rotates its exit IP per request — scatters one authenticated session across dozens of addresses and reliably triggers a checkpoint lock. Getting this right means pinning a sticky session, keeping it for the whole run, rotating only on a genuine hard block, and telling a transient block apart from an expired cookie and from a locked account. That is the logic that keeps a run producing rows instead of producing an account you can no longer log into.

### How to use Facebook Hashtag Search Scraper

Facebook Hashtag Search Scraper runs on Apify. Start it from the Apify Console, or call it through the Apify API.

1. Open Facebook Hashtag Search Scraper on Apify and click **Try for free**
2. Enter one or more hashtags or hashtag URLs in **Hashtags or Hashtag URLs** (`searchQueries`) — this is the only schema-required input
3. Paste your `c_user` and `xs` Facebook cookies — required in practice, see the cookie section above
4. Set **Max Posts Per Hashtag** (`maxItems`) — this is per hashtag, not a run total
5. Leave **Proxy Configuration** at its default unless you have a specific reason to override it
6. Click **Start**, watch posts stream into the Output tab live, then export as JSON, CSV, Excel or XML

The log prints one line per saved post with the author, reaction total, comment, share and view counts, so you can see whether a tag is worth a bigger run before committing to one.

#### How to scale to bulk hashtag extraction

`searchQueries` is a list — add as many hashtags as you like and each is processed in turn into the same dataset. Plain tags, `#tags` and full `facebook.com/hashtag/...` or `facebook.com/explore/tags/...` URLs can be mixed freely; every entry is normalised to a bare slug and duplicates are dropped case-insensitively, so `Travel`, `#travel` and the travel hashtag URL together produce one hashtag, not three.

`maxItems` applies **per hashtag**, so ten hashtags at `maxItems: 50` is a target of 500 rows, not 50. A hashtag that yields nothing does not stop the run — the loop moves to the next one. Expired cookies and a locked account do stop it, because neither can be fixed by continuing.

### What can you do with Facebook hashtag data?

- 📈 A **social listening analyst** tracking a campaign tag runs it daily and diffs `postId` sets between runs to surface only new posts, ranking them by `topReactions` totals and `sharesCount`.
- 🎯 A **growth marketer** benchmarking a niche pulls several competitor tags in one run and compares median `commentsCount` and `videoViewCount` per tag to decide where the engaged audience actually is.
- 🎬 A **short-form video researcher** filters rows where `attachments[].target.__typename` is `Video`, then reads `playback_video.length_in_second` and `soundtrack_info` from the enriched Reels block to study what length and audio pattern travels.
- 🧭 A **brand-safety reviewer** monitoring a branded tag scans `text` and `user.name` for impersonation and off-brand usage, using `url` to jump straight to the post that needs a report.
- 🤖 An **AI engineer** building a trend agent indexes `text`, `searchQuery` and `time` into a vector store and keeps `topReactions`, `commentsCount` and `sharesCount` as metadata filters, so the agent answers "what is resonating under this tag this week" against live posts rather than a stale export.

Every one of these is callable from an agent framework over the Apify API, since the Actor is a standard HTTP-triggered run.

### How does Facebook Hashtag Search Scraper handle rate limits and blocking?

No CAPTCHA solving is involved, and no browser is launched. The Actor speaks to `https://www.facebook.com/api/graphql/` over plain HTTP with your session cookies and a full desktop Chrome header set, which is what the endpoint expects.

The networking model is a **sticky residential exit IP**, held for the whole run. Facebook ties account trust to a stable IP, so hopping addresses mid-session is what triggers its "suspicious activity" locks. Requests therefore go out through one Apify Residential session by default, and only rotate to a fresh single IP on a genuine hard block — never back to a datacenter address. Rotation is bounded at three new sessions; after that the request gives up rather than burning the account. If you configure your own proxy, it is honoured and a sticky session is still pinned so the exit IP stays constant.

A response counts as blocked when it is not valid JSON, carries no `data` object, or returns Facebook error code `1357004` or `1675004` or a `CRITICAL` severity. Each page request retries up to six times across that escalation, and the initial session bootstrap gets its own six attempts. Requests time out at 30 seconds; a timeout is treated as a block. Pages are fetched sequentially with a delay of roughly 1.5 seconds plus jitter between them, and Reels enrichment runs at a concurrency of 8.

Failures never fail the run. A hashtag that cannot be collected is logged and skipped, an expired cookie or a checkpoint stops early with a named reason, and if the run ends with zero posts a single unbilled diagnostic row explains why.

### ⬇️ Input

| Parameter | Required | Type | Description | Example Value |
| ----- | ----- | ----- | ----- | ----- |
| `searchQueries` | ✅ Yes | array | One or more hashtags (`football`, `#football`) or full Facebook hashtag URLs (`https://www.facebook.com/hashtag/football`). Bulk input supported; entries are normalised and deduplicated. Default `["football"]`. | `["football", "travel"]` |
| `maxItems` | No | integer | Maximum posts to collect **per hashtag**. Minimum 1, maximum 10000. Default `10`. | `50` |
| `c_user` | No | string | Your Facebook `c_user` cookie — the numeric user ID. Not schema-required, but required in practice to collect any posts. | `"100012345678901"` |
| `xs` | No | string | Your Facebook `xs` cookie — the URL-encoded session token. Stored as a secret. Required in practice alongside `c_user`. | `"46%3AAbCdEf..."` |
| `proxyConfiguration` | No | object | Apify Proxy settings. Default `{"useApifyProxy": false}`. See the note below — the default does not mean "no proxy". | `{"useApifyProxy": true, "apifyProxyGroups": ["RESIDENTIAL"]}` |

Three honest notes on how these behave at runtime:

- **`proxyConfiguration` default is misleading.** The field's default is `{"useApifyProxy": false}` and its schema description still describes an old `none → datacenter → residential` fallback chain. The current implementation does not do that. When you leave the field alone, the Actor goes **straight to a sticky Apify Residential session** and stays on it, because the platform's direct egress is a datacenter IP that Facebook blocks for authenticated sessions. Escalation never returns to datacenter either: a configured non-residential proxy jumps to residential on the first hard block, and residential rotates to a fresh sticky IP up to three times. Treat the default as "let the Actor pick residential", not as "run without a proxy" — and budget residential proxy usage accordingly.
- **`maxItems` is a target, not a promise, and it is per hashtag.** The schema accepts up to 10000, but see the ceiling note in the Output section. The value is not clamped in code, so an out-of-range value sent via the API is bounded only by Facebook's own end-of-feed and the Actor's internal pagination cap.
- **`searchQueries` has an undocumented alias and a different runtime fallback.** The Actor also reads a `urls` key if `searchQueries` is absent, which is handy when piping output from another Actor. And if nothing resolves to a usable hashtag, it falls back to `travel` — not to the schema default `football`.

#### Example input

```json
{
  "searchQueries": [
    "football",
    "#travelphotography",
    "https://www.facebook.com/hashtag/streetfood"
  ],
  "maxItems": 50,
  "c_user": "100012345678901",
  "xs": "46%3AAbCdEfGhIjKlMn%3A2%3A1753400000%3A-1%3A9999",
  "proxyConfiguration": { "useApifyProxy": false }
}
```

### ⬆️ Output

Every post is one dataset row with the same twelve keys in the same order, run after run. Missing values arrive as typed empty defaults — `""` for strings, `{}` for the reaction map, `[]` for attachments, `0` for counts — never as a missing key, so downstream code needs no presence checks. Rows are pushed live as each post is extracted, so a run you stop early still leaves you everything collected up to that point. Export as JSON, CSV, Excel or XML, or read the dataset through the Apify API.

Billing is per row: each post row is pushed with the `row_result` charged event, one event per post. **Diagnostic rows are pushed with no charged event at all** — the `credentials_required`, `cookies_expired`, `account_checkpoint` and `no_results` records are free. They are also easy to exclude, because `status` is absent from real post rows:

```python
posts = [row for row in items if "status" not in row]
```

⚠️ **Per-hashtag ceiling.** Facebook's hashtag feed stops paginating long before `maxItems`' 10000 maximum, and the Actor also caps itself at 60 result pages per hashtag. This Actor's own changelog records an observed ceiling of roughly 200 posts per hashtag. Plan around collecting a few hundred posts per tag and running more tags, rather than expecting thousands from one.

#### Example output

A post row with an enriched Reels attachment (the Facebook-passthrough blocks are abbreviated with `…`, but every key name shown is the real one):

```json
{
  "searchQuery": "streetfood",
  "postId": "1017535753998725",
  "user": {
    "name": "Bangkok Bites",
    "id": "100072269304011",
    "url": "https://www.facebook.com/profile.php?id=100072269304011",
    "avatar": "https://scontent-lhr8-1.xx.fbcdn.net/v/t39.30808-1/429972849_122130234884145259_n.jpg"
  },
  "url": "https://www.facebook.com/1017535753998725",
  "text": "Boat noodles at 6am, three stalls deep into Talat Noi. #streetfood #bangkok",
  "topReactions": {
    "Like": 2694,
    "Love": 891,
    "Care": 142,
    "Wow": 63,
    "Haha": 31,
    "Sad": 4,
    "Angry": 1
  },
  "commentsCount": 158,
  "sharesCount": 74,
  "videoViewCount": 143521,
  "time": "2026-07-19 04:11:22",
  "timestamp": 1784434282,
  "attachments": [
    {
      "deduplication_key": "0119ac5697c575b62f508b681459152f",
      "target": {
        "__typename": "Video",
        "id": "2193122174769647"
      },
      "__typename": "StoryAttachment",
      "style_list": ["fb_shorts_creation", "video", "fallback"],
      "styles": {
        "__typename": "StoryAttachmentFBReelsStyleRenderer",
        "__isStoryAttachmentStyleRendererUnion": "StoryAttachmentFBReelsStyleRenderer",
        "is_prod_eligible": true,
        "attachment": {
          "style_list": ["fb_shorts_creation", "video", "fallback"],
          "style_infos": [
            {
              "__typename": "FBShortsShareAttachmentStyleInfo",
              "fb_shorts_story": {
                "post_id": "1017535753998725",
                "creation_time": 1784434282,
                "message": { "text": "Boat noodles at 6am, three stalls deep into Talat Noi." },
                "short_form_video_context": {
                  "playback_video": {
                    "id": "2193122174769647",
                    "width": 1080,
                    "height": 1920,
                    "length_in_second": 47,
                    "permalink_url": "https://www.facebook.com/reel/2193122174769647/",
                    "is_looping": true,
                    "is_spherical": false,
                    "captions_url": null,
                    "muted_segments": [],
                    "videoDeliveryLegacyFields": {
                      "dash_manifest_url": null,
                      "browser_native_hd_url": null,
                      "browser_native_sd_url": null,
                      "dash_manifest_xml_string": null,
                      "id": "2193122174769647"
                    }
                  },
                  "video": {
                    "__typename": "Video",
                    "id": "2193122174769647",
                    "playable_duration_in_ms": 47120,
                    "owner": { "id": "100072269304011", "name": "Bangkok Bites" }
                  },
                  "video_owner": { "id": "100072269304011", "name": "Bangkok Bites" },
                  "shareable_url": "https://www.facebook.com/reel/2193122174769647/",
                  "track_title": "Original audio",
                  "is_original_audio_on_facebook": true,
                  "is_passive_content": false,
                  "fb_shorts_reshare_context": { "is_reshare": false, "reshare_creator": null },
                  "first_frame_thumbnail": "https://scontent-lhr8-1.xx.fbcdn.net/v/t15.5256-10/frame0.jpg"
                },
                "feedback": { "…": "Facebook feedback block, passed through" },
                "privacy_scope": { "…": "as returned" }
              },
              "enable_horizontal_ufi": false,
              "enable_comet_reels_unification_for_ifr": false
            },
            { "__typename": "FeedStandardAttachmentStyleInfo" },
            { "__typename": "FutureOfFeedAttachmentStyleInfo" }
          ]
        }
      },
      "media": {
        "__typename": "Video",
        "allow_unencrypted_playback": true,
        "__isNode": "Video",
        "id": "2193122174769647"
      },
      "all_subattachments": { "nodes": [] }
    }
  ]
}
```

An unbilled diagnostic row, written when a run collects nothing. It carries the same twelve keys with empty defaults, plus `status` and `message`:

```json
{
  "searchQuery": "streetfood",
  "postId": "",
  "user": {},
  "url": "",
  "text": "Facebook served the logged-out page — your `c_user` / `xs` cookies are expired or invalid. Log in at facebook.com and copy fresh `c_user` and `xs` cookie values into the input.",
  "topReactions": {},
  "commentsCount": 0,
  "sharesCount": 0,
  "videoViewCount": 0,
  "time": "",
  "timestamp": 0,
  "attachments": [],
  "status": "cookies_expired",
  "message": "Facebook served the logged-out page — your `c_user` / `xs` cookies are expired or invalid. Log in at facebook.com and copy fresh `c_user` and `xs` cookie values into the input."
}
```

### How does it work?

Each hashtag starts with one page load. The Actor requests `https://www.facebook.com/hashtag/<tag>` through a sticky residential IP with your session cookies attached, and reads the dynamic parameters Facebook embeds there — the `fb_dtsg` and `lsd` tokens, `jazoest`, the haste session and spin values, the initial query variables, and the numeric `doc_id` for the initial, paginated and Reels queries. When a `doc_id` is not in the HTML, it scans the page's JavaScript bundles until it finds one.

With those in hand it calls Facebook's GraphQL endpoint directly, page by page, following the feed's own `end_cursor` and stopping when `has_next_page` goes false, your `maxItems` target is met, or the internal page cap is reached. Each response is stripped of Facebook's `for (;;);` prefix, parsed, and walked for the story behind every result. Video attachments then get a second Reels query to fill in full short-form metadata.

Because it reads Facebook's structured GraphQL payload rather than rendered HTML, a Facebook front-end redesign does not change your field names — the twelve output keys stay the same across runs. Only publicly visible hashtag-feed posts are collected.

### Integrations

Facebook Hashtag Search Scraper is an Apify Actor, so it works with anything that can call the Apify API or consume a dataset.

#### Calling Facebook Hashtag Search Scraper from Python

```python
from apify_client import ApifyClient

client = ApifyClient("<YOUR_APIFY_TOKEN>")

run = client.actor("<YOUR_USERNAME>/facebook-hashtag-search-scraper-pay-per-events").call(run_input={
    "searchQueries": ["streetfood", "#travelphotography"],
    "maxItems": 50,
    "c_user": "<YOUR_C_USER_COOKIE>",
    "xs": "<YOUR_XS_COOKIE>",
})

for row in client.dataset(run["defaultDatasetId"]).iterate_items():
    if "status" in row:
        print("diagnostic:", row["status"], row["message"])
        continue
    print(row["searchQuery"], row["user"]["name"], row["commentsCount"], row["url"])
```

Works in Go, Ruby, Node.js, cURL — any language that can make an HTTP request. Branch on the presence of `status` to keep diagnostic rows out of your post pipeline.

#### Scheduling and webhooks

Because cookies expire, hashtag monitoring works best on a schedule you can see failing. Use an Apify schedule to run the same `searchQueries` daily, and attach an Actor webhook on run success to POST the dataset to your endpoint. A run whose dataset contains a single row with `status` set to `cookies_expired` is your signal to refresh the `xs` value — worth alerting on explicitly.

#### No-code tools (n8n, Make, LangChain)

In n8n, use the Apify node — or an HTTP Request node pointed at the Apify run endpoint with your token — and pass the same JSON input shown above; an IF node on `status` splits real posts from diagnostics. In Make, the Apify module supports run-and-wait, so a daily tag sweep can feed a Google Sheets or Airtable step directly. In LangChain, wrap the run endpoint as a tool and hand the typed rows to the agent as-is; no parsing step is needed.

### Is it legal to scrape Facebook hashtag posts?

Scraping publicly visible posts is broadly treated as permissible where the content is accessible without circumventing an access restriction, and this Actor returns only public posts from Facebook's hashtag feed — nothing from a private profile, a friends-only audience or a closed group. But this output **is personal data**, and that changes your obligations rather than removing them.

The fields that carry personal data are specific and worth naming: `user.name`, `user.id`, `user.url` and `user.avatar` identify a real person, `text` is content they authored and can itself contain names and other personal details, `postId` and `url` link straight back to them, and the enriched Reels blocks carry further identity objects under `short_form_video_context.video_owner`, `short_form_video_context.video.owner` and `fb_shorts_reshare_context.reshare_creator`. Under GDPR you need a lawful basis before you store or process any of it — legitimate interests is the usual route for research and brand monitoring, and it requires a documented balancing test, a retention limit, and a way to honour deletion and objection requests. CCPA/CPRA treats the same fields as personal information about California residents.

Data minimisation is the practical control here, and this output makes it easy: if you only need engagement patterns, drop `user`, `text` and the enriched `styles` blob at ingest and keep the counts, the `timestamp` and the hashtag. Aggregate rather than storing rows where you can.

Separately, you are supplying your own Facebook session cookies, which is governed by Facebook's Terms of Service — read them and decide for yourself. Consult legal counsel if your use case involves bulk storage of personal data, profiling of identified individuals, or transferring these rows outside your jurisdiction.

### ❓ Frequently asked questions

#### What Facebook hashtag fields does Facebook Hashtag Search Scraper return?

The five most used are `text`, `user`, `topReactions`, `commentsCount` and `url`. Twelve keys are returned on every post row — plus `status` and `message` on unbilled diagnostic rows. See the data fields table above for all of them, including the attachment and Reels shapes.

#### Does Facebook Hashtag Search Scraper require a Facebook account or login?

Yes — your own session, supplied as the `c_user` and `xs` cookies. Facebook's hashtag search endpoint returns nothing to an anonymous request, so this is unavoidable rather than a design choice. Neither field is marked required in the schema, but without both the run writes one unbilled `credentials_required` record and collects no posts. The `xs` field is stored as a secret, so it is masked in the Console and run history.

#### How many posts can I extract per hashtag?

`maxItems` accepts 1 to 10000 and applies per hashtag, but Facebook's feed ends the pagination well before that, and the Actor caps itself at 60 result pages per hashtag. This Actor's changelog records an observed ceiling of roughly 200 posts per hashtag. For volume, add more hashtags to `searchQueries` rather than raising `maxItems`.

#### What happens if a hashtag returns zero results?

It depends on whether the whole run came back empty. If some hashtags produced posts and one did not, that hashtag is simply absent from the dataset — the log notes it and the run continues to the next tag, with no row and no charge for it. If the **entire** run collected nothing, the Actor writes exactly one unbilled diagnostic row and still finishes SUCCEEDED, with `status` set to the reason: `no_results` for a transient block or an unusual tag, `credentials_required` when cookies were missing, `cookies_expired` when Facebook served the logged-out page, or `account_checkpoint` when the account is locked. In code, filter with `[row for row in items if "status" not in row]` and branch on `row["status"]` for the rest — `status` never appears on a real post row.

#### Can I scrape multiple Facebook hashtags at once?

Yes. `searchQueries` is a list and each entry is processed in turn into the same dataset. Plain tags, `#tags` and hashtag URLs can be mixed; everything is normalised to a bare slug and deduplicated case-insensitively. Remember that `maxItems` is per hashtag, so the run total is roughly `maxItems × number of hashtags`.

#### Does Facebook Hashtag Search Scraper work with Claude, ChatGPT and other AI agent tools?

Yes. It is callable as a standard HTTP endpoint through the Apify API, so LangChain, CrewAI, n8n or a hand-written tool definition can invoke it and receive typed JSON with no parsing step. Give the agent the same run input shown above and have it skip rows where `status` is present.

#### How does Facebook Hashtag Search Scraper compare to other Facebook hashtag scrapers?

Checked on the Apify Store on 25 July 2026: `easyapi/facebook-hashtag-search-scraper` is the most used of the three and returns a very similar post shape — `postId`, `user`, `text`, `topReactions`, `commentsCount`, `sharesCount`, `videoViewCount`, `time`, `timestamp`, `attachments` — from a single `searchQuery` string plus `maxItems`; bulk hashtag input and cookie authentication are not documented in its README. `datapilot/facebook-hashtag-search-scraper` documents the same field set from a single `hashtag` input, and its own README describes the current implementation as generating sample posts to demonstrate the data structure, with real scraping left as an integration step. `scrapium/facebook-hashtag-search-scraper` documents the closest feature set — bulk `searchQueries`, `c_user` / `xs` cookies, Reels enrichment — and describes a `none → datacenter → residential` proxy chain. What this Actor documents that the others do not: bulk input with hashtag-URL normalisation and deduplication, the sticky-residential session model with named checkpoint and expired-cookie detection, unbilled diagnostic rows with a documented `status` vocabulary, and the per-hashtag ceiling stated up front.

#### Does Facebook Hashtag Search Scraper return data in a format LLMs can use directly?

Yes. Every row is typed, normalized JSON with the same field names on every run. No HTML parsing, no selectors. Pass a row straight into an LLM context window, index it into a vector store, or hand it to an agent tool. If context size matters, drop the `attachments` blob — the enriched Reels payload is by far the largest part of a video row.

#### What happens when Facebook changes its layout or anti-bot system?

The scraper is maintained, and because it reads Facebook's GraphQL payload rather than rendered markup, a visual redesign generally does not affect it. The exposure is Facebook's build-specific `doc_id` values and token names; those are extracted from the live page and its JavaScript bundles on every run rather than hardcoded, which is what lets a new Facebook build work without a code change. Your field names and types stay the same on your end.

#### Can I use Facebook Hashtag Search Scraper without managing proxies or browser infrastructure?

Yes. There is no browser to run and no proxy account to create — the Actor uses Apify Residential with a pinned sticky session, holds one exit IP for the whole run, and rotates to a fresh one only on a hard block. It does not solve CAPTCHAs, because the flow it uses does not present one; what it does handle is block detection, proxy escalation, retry limits and telling a transient block apart from an authentication problem. The one thing you manage yourself is your Facebook cookies, which expire and need refreshing.

#### Which Facebook hashtag fields work best for AI training data and RAG indexing?

For RAG indexing: `text` is the high-information field and chunks cleanly on its own, with `searchQuery` and `user.name` as useful metadata for filtering and `time` for recency ranking. For training data and analytics: `topReactions`, `commentsCount`, `sharesCount`, `videoViewCount` and `timestamp` are the most structurally consistent fields across records, since they always arrive with typed defaults rather than going missing. All values are returned as typed primitives, arrays or plain nested objects, so no normalization pass is needed before indexing.

### 🔗 Related scrapers

| Scraper Name | What it extracts |
| ----- | ----- |
| Facebook Posts Scraper | Posts from a specific Facebook page or profile timeline |
| Facebook Group Post Scraper | Posts and engagement from inside Facebook groups |
| Facebook Video Search Scraper | Facebook video search results with video metadata |
| Facebook Pages Recent Posts Scraper | The latest posts across a list of Facebook pages |
| Instagram Hashtag Scraper | The same hashtag-research job on Instagram |
| Twitter Trends Scraper | Trending topics for timely content and tag planning |

### 💬 Your feedback

Found a bug, or need a field that is in Facebook's hashtag payload but not in the output? Open an issue on the Actor's Issues tab and it will be looked at. Reports that include the exact `searchQueries` value and the run ID are the fastest to reproduce and fix.

# Actor input Schema

## `searchQueries` (type: `array`):

🔎 Enter one or more **hashtags** (e.g. `football`, `travel`) or full Facebook hashtag URLs (e.g. `https://www.facebook.com/hashtag/football`). Both formats are accepted and bulk input is fully supported.

## `maxItems` (type: `integer`):

🎯 Maximum number of posts to scrape **per hashtag**. Higher = more data, longer runtime.

## `c_user` (type: `string`):

Your Facebook session cookie `c_user` (the numeric user ID). Copy it from your browser DevTools → Application → Cookies → www.facebook.com. Required to authenticate the scraper against Facebook's GraphQL API. 🔑

## `xs` (type: `string`):

Your Facebook session cookie `xs` (URL-encoded session token). Copy it from your browser DevTools → Application → Cookies → www.facebook.com. Keep it secret — it grants access to your account. 🔐

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

🛡️ By default the actor runs **without a proxy**. If Facebook rejects or rate-limits the request, the actor automatically falls back to a **datacenter proxy**, then to a **residential proxy** (with up to 3 retries). Once a fallback is engaged, the residential proxy stays in use for the rest of the run.

## Actor input object example

```json
{
  "searchQueries": [
    "football"
  ],
  "maxItems": 10,
  "proxyConfiguration": {
    "useApifyProxy": false
  }
}
```

# 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 = {
    "searchQueries": [
        "football"
    ],
    "c_user": "",
    "proxyConfiguration": {
        "useApifyProxy": false
    }
};

// Run the Actor and wait for it to finish
const run = await client.actor("scraper-engine/facebook-hashtag-search-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 = {
    "searchQueries": ["football"],
    "c_user": "",
    "proxyConfiguration": { "useApifyProxy": False },
}

# Run the Actor and wait for it to finish
run = client.actor("scraper-engine/facebook-hashtag-search-scraper").call(run_input=run_input)

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

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

```

## CLI example

```bash
echo '{
  "searchQueries": [
    "football"
  ],
  "c_user": "",
  "proxyConfiguration": {
    "useApifyProxy": false
  }
}' |
apify call scraper-engine/facebook-hashtag-search-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "command": "npx",
            "args": [
                "mcp-remote",
                "https://mcp.apify.com/?tools=scraper-engine/facebook-hashtag-search-scraper",
                "--header",
                "Authorization: Bearer <YOUR_API_TOKEN>"
            ]
        }
    }
}

```

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/acts/O5dta58KFZAvcvwaN/builds/z51PiPgHFWTOFyYyT/openapi.json
