Threads User Posts Scraper By Keyword & Date Filter
Pricing
from $4.99 / 1,000 results
Threads User Posts Scraper By Keyword & Date Filter
๐งต Threads User Posts Scraper extracts posts, timestamps, likes, replies & media from Threads (Meta) profiles at scale. โก Fast, accurate, exportable (CSV/JSON). ๐ Ideal for social listening, competitor tracking, content research & analytics. ๐ API-friendly, pagination & rate-limit aware.
Pricing
from $4.99 / 1,000 results
Rating
0.0
(0)
Developer
Scraper Engine
Maintained by CommunityActor stats
0
Bookmarked
12
Total users
0
Monthly active users
4 days ago
Last modified
Categories
Share
Threads User Posts Scraper โ Captions, Engagement, Date Filter
Threads User Posts Scraper By Keyword & Date Filter pulls public posts from any Threads profile and keeps only the ones that match your filters โ caption, like_count, direct_reply_count, repost_count, media_type, medias and an absolute UTC taken_at_iso, 22 typed JSON keys on every row. Caption keywords, a posted-date window and a media type are applied while the Actor pages through history. Rows stream live to the dataset; export as JSON, CSV or Excel.
No Threads login, no session cookie, no API token. The Actor reads the same public profile surface a signed-out visitor sees, so there is no credential input in the schema and nothing for you to paste, rotate or risk. Paste a handle and press Start.
What is Threads User Posts Scraper By Keyword & Date Filter?
Threads User Posts Scraper By Keyword & Date Filter is an Apify Actor that reads the Threads profile-posts GraphQL query for each handle you give it and writes every matching post as one flat dataset row. It is the filter-first build of the plain Threads posts scraper: the same fields, plus caption include/exclude matching, an absolute or relative date window, a media-type selector, and a keep-limit that counts only posts that survive the filters.
No Threads account or login is required. Every request goes out logged-out โ the GraphQL call carries av: "0", __user: "0" and the x-logged-out-threads-migrated-request header โ so only publicly visible posts are ever returned.
It is built for social listening and brand-monitoring teams tracking campaign mentions, content researchers assembling date-bounded post corpora, marketers auditing a creator's output, and developers feeding Threads text into their own dashboards or AI pipelines.
What Threads post data is publicly available to scrape?
A Threads profile and its posts tab โ handle, display name, avatar, captions, attached media and engagement counts โ are readable by a signed-out visitor on the web. That public surface is exactly what this Actor reads, and it is the whole of what it can reach.
| Data Category | Visible signed out | Gated behind a login or follow approval |
|---|---|---|
Profile handle and avatar (username, profile_pic_url) | โ Public | โ |
| Post caption, post code and canonical URL | โ Public | โ |
| Engagement counts (likes, replies, quotes, reposts) | โ Public | โ |
| Attached images, video variants and carousel items | โ Public | โ |
| Posts from a private account | โ | Visible only to approved followers โ returns nothing here |
| View or impression counts | โ | Not in the profile-tab payload at all; no field is returned |
| Follower and following lists | โ | Login-walled, and not returned by this Actor |
| Other users' replies to a post | โ | Not in this query โ only the profile's own posts are read |
| Email addresses, phone numbers, direct messages | โ | Never returned |
Threads User Posts Scraper By Keyword & Date Filter only returns publicly visible data โ what any signed-out visitor sees on a profile's posts tab. Nothing behind a login wall.
โ ๏ธ Three input behaviours to know before your first run
None of the ten parameters is marked required in the schema, which makes a few defaults easy to misread. All three of these are code behaviour, not guesses.
- An unparseable date silently disables that filter.
postedAfterandpostedBeforeare parsed byparse_date_bound, which tries ISO first (2024-06-01,2024-06-01T12:00:00.000Z), then a relative expression (7 days,2 weeks,3 months,1 year ago), then%Y-%m-%d,%Y/%m/%d,%d-%m-%Yand%m/%d/%Y. Anything it cannot read returnsNoneโ andNonemeans no bound, not no results. Sendlast Fridayor06.01.2024and the run completes normally with the date filter quietly switched off. Check the run's first log line, which prints the bounds it actually resolved. maxPostsScanned: 0means 2000, not unlimited and not zero. The code readsif scan_cap <= 0: scan_cap = DEFAULT_SCAN_CEILING, and that ceiling is2000. The schema default is500, so leaving the field alone scans less deeply than setting it to0.- An empty
profileUrlsproduces an empty run, not a default profile. With no usable entry the log readsโ Add at least one Threads username or profile URL in the input list.and the run finishes with zero rows and no dataset record explaining it. The Console prefills["https://www.threads.com/@zuck"], so this only bites API callers who build the input JSON themselves โ there is no hidden fallback handle in the code, and nothing is scraped or charged.
What data can I extract with Threads User Posts Scraper By Keyword & Date Filter?
Every matching post is one row carrying the same 22 keys: who posted it, what it says, how it performed, what media it carries, and when it was published and collected.
| Field Name | Description |
|---|---|
username | The post author's handle, without the @ |
profile_pic_url | Direct CDN URL to the author's avatar image |
id | Threads' full post id, e.g. 3220231147841639674_314216 |
pk | The post's primary key from the same payload |
code | The short post code used in the post URL, e.g. Cywjyrdv9T6 |
caption | The post text, exactly as published. null when the post has no text |
caption_is_edited | true when the author edited the caption after publishing |
like_count | Likes on the post, 0 when Threads omits the value |
direct_reply_count | Direct replies to the post, 0 when omitted |
quote_count | Times the post was quoted, 0 when omitted |
repost_count | Times the post was reposted, 0 when omitted |
like_and_view_counts_disabled | true when the author has hidden like and view counts |
media_type | One of text, image, video, carousel โ derived by the Actor, not sent by Threads |
medias | Attached media, or null when there is none โ see the shape note below |
audio | Threads' raw audio object for posts with attached audio, otherwise null |
tags | Threads' raw usertags payload, null on most posts |
mentions | Accounts @mentioned in the caption, or null โ array of {username, profile_pic_url, id} |
taken_at | Publish time as YYYY-MM-DD HH:MM:SS in the run machine's local timezone, with no offset in the string |
taken_at_iso | Publish time as timezone-aware UTC ISO 8601, e.g. 2024-06-01T12:34:56+00:00 |
taken_at_timestamp | Publish time as a Unix epoch integer in seconds โ the value the date filters compare against |
url | Canonical post link, built as https://www.threads.net/@<username>/post/<code> |
scrapedAt | UTC ISO timestamp for the run, identical on every row of a single run |
All 22 keys are written on every row โ nothing is conditionally omitted, so a missing value arrives as
nullor0rather than as an absent key. The Console's default table view shows 20 of the 22 columns;caption_is_editedandlike_and_view_counts_disabledare in every exported record but not in that view. Export the JSON or read the dataset through the API to see all of them. Output keys aresnake_casewhile input parameters arecamelCaseโ deliberate, because the output mirrors Threads' own payload names.
Post identity and caption fields
username, id, pk, code and url identify the post. id and pk come from two separate keys in the upstream payload and are kept separate rather than merged. url is built on threads.net even though the Actor fetches threads.com; both hostnames resolve to the same post, so links stay clickable. caption is the full untruncated post text and is the field every keyword filter runs against. caption_is_edited tells you the text you captured is not necessarily the text originally published. profile_pic_url is a signed CDN URL that expires โ download the image if you need to keep it. scrapedAt is stamped once per run, so it dates the snapshot rather than the individual row.
Engagement counts and flags
like_count, direct_reply_count, quote_count and repost_count are read straight from the payload as integers โ no abbreviation parsing, no 12.5K-to-12500 conversion, because the GraphQL response sends exact numbers. All four fall back to 0 when Threads omits them, so a 0 means "Threads did not send one" as well as "genuinely zero". like_and_view_counts_disabled is the field that tells those two apart: when it is true, the author has hidden the counts and the zeros are not real. Note the asymmetry with direct_reply_count โ it counts replies without returning them; the reply text itself is not in this query.
Media, mentions and timestamp fields
media_type is computed by the Actor from the raw payload, in this order: a post with carousel_media is carousel, then one with video_versions is video, then one with image candidates is image, otherwise text. That is also the value the mediaType filter matches on. medias has two different shapes worth handling in code: for image and video posts it is a normalized list of {type, url, width, height} and {type, url, video_type} objects; for carousel posts it is Threads' raw carousel_media array passed through unchanged. Branch on media_type before you read it. mentions is extracted from the caption's text fragments and gives you three keys per mentioned account, which is more than a list of handle strings. tags and audio are raw pass-throughs and are null on most posts.
Three timestamps ship on every row for a reason. taken_at_timestamp is the epoch integer and the only one the filters use, taken_at_iso is explicit UTC and is what you should store or sort on, and taken_at is a human-readable string rendered in the run machine's local timezone with no offset attached โ convenient to read, unsafe to parse.
๐ค Add-on: Need additional Threads data?
Posts are one half of a Threads account picture. Threads User Followers Scraper takes any username from these rows and returns who follows that account, which is how you size the audience behind the engagement counts. Because Threads accounts are Instagram-linked, Instagram Profile Scraper turns the same handle into bio and profile detail, and Instagram Hashtag Scraper covers the topic side when you need posts by tag rather than by author.
Why not build this yourself?
You can โ the request chain is described below and none of it is secret. What turns it into a maintenance job is what sits inside each step.
Meta does publish a Threads API, but it is built around your own account: publishing, reading your own posts, and your own insights. It publishes no third-party post-history endpoint, so there is no supported route to another account's timeline. Check the current scope against Meta's published Threads API documentation before assuming otherwise โ no rate figures are quoted here, because those are Meta's to state.
That leaves the web surface, and three moving parts:
The persisted query id. The profile posts tab is a Relay persisted query addressed by a numeric doc_id that changes with every Threads front-end build. This Actor discovers it at runtime: it fetches the profile page, reads the deferred-scripts JSON block for the page's static.cdninstagram.com JavaScript bundle URLs, downloads them concurrently, and regex-matches BarcelonaProfileThreadsTabRefetchableDirectQuery inside them. Hard-code the id and your scraper breaks at Meta's next deploy.
The Relay provider variables and the response alias. The same query declares a set of __relay_internal__pv__* flags; omit one the persisted query references and Meta answers HTTP 200 with {"errors":[...], "data": null} โ a success status carrying nothing. This Actor scrapes the live flag names out of the same bundles and sends every one of them. It also extracts the Relay root_field_name and its response alias from the bundle, because the connection is not always returned under the key you expect.
The token handshake. The GraphQL endpoint validates that the x-csrftoken header matches the csrftoken cookie set during the initial HTML hit. Send the header without the shared cookie jar and the endpoint returns empty edges rather than an error โ silent, and easy to mistake for an empty profile. The APP_ID, CSRF token, LSD token and numeric user id are all read fresh from the profile HTML on every profile.
How to use Threads User Posts Scraper By Keyword & Date Filter
The Actor runs on Apify. Start it from the Apify Console or call it through the Apify API โ there is no separate signup and no credential to obtain from Threads.
- Open Threads User Posts Scraper By Keyword & Date Filter on Apify and click Try for free
- Put one or more entries into Profiles to search (
profileUrls).zuck,@zuck,https://www.threads.com/@zuckandhttps://www.threads.net/@zuckall work - Add caption terms to Caption must contain (
includeKeywords) and pickanyorallin Keyword match mode; add terms to Caption must NOT contain to drop unwanted posts - Set the date window with Posted after and Posted before โ an absolute date, or a relative value like
30 days - Optionally narrow Media type to
text,image,videoorcarousel - Set Max matching posts per profile (
maxPosts) and, for strict filters over long histories, raise Max posts scanned per profile (maxPostsScanned) - Leave the networking section alone unless you have a specific Apify Proxy group preference, then click Start
- Download the dataset as JSON, CSV or Excel, or read it through the Apify API
With no filters set the Actor simply returns the most recent maxPosts posts per profile, so it works as a plain posts scraper too.
How to scale to bulk post extraction
profileUrls is a list, so bulk is the normal mode: add as many handles as you like and the Actor works through them in order, resolving fresh tokens for each and pushing rows as it goes. maxPosts and maxPostsScanned both apply per profile, not per run โ ten profiles at maxPosts: 50 is a 500-row target. Bare handles, @handles, threads.com / threads.net URLs and {"url": "..."} objects are all accepted. One trap: anything unrecognisable is not rejected, it is lowercased and treated as a literal handle, so a mistyped entry becomes a profile lookup that fails and is skipped.
What can you do with Threads post data?
- ๐ฃ A brand monitoring analyst tracking a launch sets
includeKeywordsto the product name withpostedAfteron the announcement date, then ranks the returned rows bylike_countanddirect_reply_countto find which posts actually carried the message. - ๐ฌ A content strategist auditing a creator sets
mediaTypetovideo, pulls a quarter withpostedAfterandpostedBefore, and compareslike_countagainstrepost_countto see whether video posts earn reach or only applause. - ๐ A competitive researcher runs five rival handles in one job with
excludeKeywordsset togiveawayandad, filtering promotional noise out before the dataset is ever exported, and pivots the result onusernameandtaken_at_isoto build a posting-cadence timeline. - ๐งต A social listening team watching for topic mentions schedules a daily run with
postedAfter: "1 day", so each run returns only the last day's matching posts and the dataset grows as an append-only log keyed oncode. - ๐ค An AI engineer building a brand-voice agent indexes
captionwithtaken_at_iso,media_typeand the four engagement counts into a vector store, so the agent can answer "how has this account talked about pricing this year, and which of those posts landed" against a filtered pull rather than a full-feed dump.
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 Threads User Posts Scraper By Keyword & Date Filter handle rate limits and blocking?
No browser is launched and no CAPTCHA solving is involved โ none is claimed. Requests go out over plain HTTP with a full desktop Chrome header set and a shared cookie jar, which is what the GraphQL endpoint expects from a signed-out visitor.
Egress runs on a proxy ladder that starts with no proxy at all. The Actor talks to Threads directly first. If the site rejects a request โ any HTTP status of 400 or above, or an HTTP 200 carrying a GraphQL errors array โ it escalates to an Apify Datacenter proxy, and if that is rejected too, to Apify Residential with up to three fresh rotated sessions per profile. If the Datacenter group is not available on your account, it skips straight to Residential. Transport-level failures (timeouts, dropped connections) are retried up to five times per request with exponential backoff capped at 30 seconds, before any tier change is considered.
Two behaviours worth planning for. Tier escalation is sticky for the whole run, not just the profile that triggered it: once the Actor moves to Datacenter or Residential it stays there for every remaining profile in your list, so one dead handle early in a long list can push the rest of the run onto residential bandwidth. And paging slows down on proxies โ the page size drops from 25 to 15 posts per request when a proxy is in use, so the same target takes more requests. Between pages the Actor waits a random 1โ2 seconds.
A profile that fails on every tier is logged and skipped, and the run continues with the next handle. Rows already pushed are always kept.
โฌ๏ธ Input
Ten parameters, none of them marked required in the schema. In practice profileUrls is the one that matters: without a usable entry the run ends immediately with zero rows. Everything else has a working default.
| Parameter | Required | Type | Description | Example Value |
|---|---|---|---|---|
profileUrls | No | array | Threads profile links (threads.com / threads.net) or plain usernames, one per line. Bare handles, @handles, full URLs and {"url": "..."} objects are all accepted. Prefilled in the Console with ["https://www.threads.com/@zuck"]; empty in an API call means an empty run. | ["@zuck", "https://www.threads.com/@mosseri"] |
includeKeywords | No | array | Keep only posts whose caption contains these terms. Case-insensitive substring match. Empty skips keyword matching entirely. | ["launch", "AI"] |
keywordMatch | No | string | How includeKeywords terms combine. any = caption has at least one term, all = caption has every term. Default any. Any other value falls back to any. | "all" |
excludeKeywords | No | array | Drop any post whose caption contains one of these terms. Case-insensitive, applied after the include filter. | ["giveaway", "ad"] |
postedAfter | No | string | Keep posts published on or after this bound. Absolute (2024-01-01) or relative (7 days, 3 months). Also the early-stop signal โ scanning halts once the feed drops below it. | "2026-01-01" |
postedBefore | No | string | Keep posts published on or before this bound. Same formats as postedAfter. | "2026-06-30" |
mediaType | No | string | Keep only posts of this kind: any, text, image, video or carousel. Default any. Any unrecognised value falls back to any. | "video" |
maxPosts | No | integer | Stop after this many posts that pass the filters, per profile. Minimum 1, maximum 5000, default 25. | 100 |
maxPostsScanned | No | integer | Cap on how many posts are examined per profile while filtering. Minimum 0, maximum 20000, default 500. 0 means the built-in ceiling of 2000. | 2000 |
proxyConfiguration | No | object | Apify Proxy settings, used only as the base for the Actor's own ladder. Prefilled with {"useApifyProxy": false}; leaving it off is the recommended default. | {"useApifyProxy": false} |
Five honest notes on how these actually behave:
- The keyword filters are plain substring matches, not word matches.
kw in captionafter lowercasing both sides, soaimatchessaid,detailandemail, andadmatchesalready. Prefer distinctive multi-character terms, or lead with a space โ" ai "โ when a short token produces noise. maxPostsis a keep-limit,maxPostsScannedis the real ceiling. A run stops when either is hit. With the defaultmaxPostsScanned: 500, a narrow keyword filter will end after 500 posts examined even ifmaxPostsis set to 5000. Raise the scan cap whenever your filter is strict or your window is long.- Absolute date bounds resolve to midnight UTC.
postedBefore: "2026-06-30"becomes2026-06-30T00:00:00Z, so posts from later that same day are excluded despite the field reading "on or before this date". Pass the following day, or a full timestamp like2026-06-30T23:59:59Z, when you want the whole day. Relative values are approximations by design: a month is 30 days and a year is 365. - Two undocumented input aliases are accepted. The code reads
profileUrlsand falls back tourls, readsmaxPostsand falls back tomaxPostsPerProfile, and readsincludeKeywordsand falls back tokeywords. An input JSON shaped for the unfiltered base Actor therefore keeps working unchanged. The Console only shows the documented names. proxyConfigurationselects a group, it does not turn proxying on or off. Whatever you pass is merged, but the Actor overwritesapifyProxyGroupsitself at each rung of the ladder. Turning Apify Proxy on does not force a proxied first attempt โ the first attempt is always direct โ and leaving it off does not prevent escalation to Apify proxies when Threads rejects the direct route.
Example input
{"profileUrls": ["@zuck","https://www.threads.com/@mosseri","natgeo"],"includeKeywords": ["launch", "announcement"],"keywordMatch": "any","excludeKeywords": ["giveaway", "sponsored"],"postedAfter": "2026-01-01","postedBefore": "2026-06-30T23:59:59Z","mediaType": "any","maxPosts": 100,"maxPostsScanned": 2000,"proxyConfiguration": {"useApifyProxy": false}}
โฌ๏ธ Output
Typed, normalized JSON with a consistent shape across runs โ 22 keys on every row, always present, never conditionally omitted. Rows are pushed live as each matching post is parsed, so the dataset fills while the run is still going, profile by profile. Export as JSON, CSV or Excel, or read the dataset through the Apify API.
Charging and row types. The dataset contains post rows and nothing else. Each one is charged as a single row_result event, and there are no header rows, no diagnostic rows, no error rows and no accounting rows โ so there is no filter expression to apply and nothing to skip in your loop. Two consequences follow. First, filtered-out posts are never charged: a post is only pushed after matches() passes it, so scanning 2000 posts to keep 12 bills for 12 rows, not 2000. The scanning itself costs run time and proxy bandwidth, not row events. Second, failures leave no trace in the dataset โ an empty profileUrls, an unreachable handle, a private account or an empty date window all produce log lines and fewer rows, never an explanatory record. Read the run log when a run returns less than you expected.
Deduplication is per profile attempt, not per run. Post ids are tracked in a set that is created fresh for each scrape attempt. Within one uninterrupted profile scrape, a post is never written twice. Across profiles the set is not shared, so if two handles in your list surface the same post it is written twice. The set is also rebuilt when a profile escalates to a different proxy tier mid-scrape, so a profile that fails partway through and is retried on a proxy can write and charge for rows it already pushed. Deduplicate on id or code yourself if you need a guaranteed-unique table.
Multi-part threads become multiple rows. Each feed entry can carry several thread_items, and every one of them with a valid id and code is extracted as its own row. A five-part thread chain is five rows, not one merged record โ group them yourself on taken_at_timestamp proximity if you want them stitched. Note also that there is no seed-profile column: username is the author of the post, so in a multi-profile run a reposted item can carry a handle that is not the one you asked for.
Example output
A filtered image post from a run seeded with @zuck:
{"profile_pic_url": "https://scontent-lhr8-1.cdninstagram.com/v/t51.2885-19/452737457_1313286386312102_n.jpg","username": "zuck","id": "3220231147841639674_314216","pk": "3220231147841639674","code": "Cywjyrdv9T6","caption": "Big launch today โ the new model is rolling out to everyone this week.","caption_is_edited": false,"like_count": 3756,"direct_reply_count": 402,"quote_count": 20,"repost_count": 131,"like_and_view_counts_disabled": false,"audio": null,"medias": [{"type": "image","url": "https://scontent-lhr8-1.cdninstagram.com/v/t51.29350-15/394612360_1067307400960695_n.jpg","width": 1440,"height": 1080}],"media_type": "image","tags": null,"mentions": null,"taken_at": "2026-03-14 09:22:41","taken_at_iso": "2026-03-14T09:22:41+00:00","taken_at_timestamp": 1773480161,"url": "https://www.threads.net/@zuck/post/Cywjyrdv9T6","scrapedAt": "2026-07-25T08:15:03.412907+00:00"}
A text-only post from the same run, with a mention and hidden counts โ note that nothing is omitted, the empty values are typed fallbacks:
{"profile_pic_url": "https://scontent-lhr8-1.cdninstagram.com/v/t51.2885-19/452737457_1313286386312102_n.jpg","username": "zuck","id": "3241887332190445107_314216","pk": "3241887332190445107","code": "DAq7XmPuKr2","caption": "Good conversation with @mosseri about where the launch goes next.","caption_is_edited": true,"like_count": 0,"direct_reply_count": 88,"quote_count": 0,"repost_count": 12,"like_and_view_counts_disabled": true,"audio": null,"medias": null,"media_type": "text","tags": null,"mentions": [{"username": "mosseri","profile_pic_url": "https://scontent-lhr8-2.cdninstagram.com/v/t51.2885-19/357614920_18374_n.jpg","id": "73956966771"}],"taken_at": "2026-04-02 17:05:12","taken_at_iso": "2026-04-02T17:05:12+00:00","taken_at_timestamp": 1775149512,"url": "https://www.threads.net/@zuck/post/DAq7XmPuKr2","scrapedAt": "2026-07-25T08:15:03.412907+00:00"}
How does it work?
Threads builds a profile's posts tab from a Relay persisted GraphQL query rather than from rendered HTML. For each handle in your list the Actor loads https://www.threads.com/@<handle> signed out, reads the live values Meta embeds in that page โ the APP_ID, a CSRF token, the LSD token and the profile's numeric id โ and fetches the page's JavaScript bundles concurrently to pull the current doc_id, the Relay provider flag names and the response alias for the posts connection. It then posts that query to Threads' GraphQL endpoint through the same cookie jar, so the CSRF header and cookie match.
Pages come back newest-first. Each post is parsed into the flat row shape, then the caption, media-type and date filters run over the parsed record โ no extra network request, because the payload already carries every field they read. Matching posts are pushed to the dataset immediately; non-matching ones are counted and dropped. The Actor follows the real end_cursor from Threads' page_info on every page, so pagination is genuine depth rather than a repeated first page, and it stops when the keep-limit is reached, the scan cap is reached, four consecutive posts fall below postedAfter, or Threads reports no next page.
Because it reads structured JSON rather than markup, a Threads front-end redesign does not change your field names โ and only publicly visible data is ever returned.
Integrations
Threads User Posts Scraper By Keyword & Date Filter is an Apify Actor, so it works with anything that can call the Apify API or consume a dataset.
Calling Threads User Posts Scraper By Keyword & Date Filter from Python
from apify_client import ApifyClientclient = ApifyClient("<YOUR_APIFY_TOKEN>")run = client.actor("<YOUR_USERNAME>/threads-user-posts-scraper-by-keyword-date-filter").call(run_input={"profileUrls": ["@zuck", "https://www.threads.com/@mosseri"],"includeKeywords": ["launch"],"keywordMatch": "any","postedAfter": "90 days","maxPosts": 100,"maxPostsScanned": 2000,})for row in client.dataset(run["defaultDatasetId"]).iterate_items():print(row["username"], row["taken_at_iso"], row["like_count"], row["caption"])
Works in Go, Ruby, Node.js, cURL โ any language that can make an HTTP request. Every row has the same 22 keys, so there is nothing to branch on and no metadata rows to skip.
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; a Filter node on like_count or media_type trims the rows before they reach your Slack or Sheets step. In Make, the Apify module supports run-and-wait, so a weekly competitor-content pull can feed an Airtable or Google Sheets step directly. In LangChain, wrap the run endpoint as a tool and hand the rows straight to the model, since caption and taken_at_iso are already flat typed values. Apify schedules and webhooks cover recurrence and completion triggers, so a daily keyword watch with postedAfter: "1 day" needs no code of your own.
Is it legal to scrape Threads posts?
Scraping publicly accessible content is broadly treated as permissible where no access control is circumvented, and this Actor circumvents none โ it is signed out by design and can reach only what an anonymous visitor sees. That framing does not carry the output on its own, though.
This output is personal data. username, profile_pic_url, id, pk, code and url identify a real individual; caption is that individual's own words, which can carry opinions, locations, health or political content they never expected to see in a dataset; mentions[].username, mentions[].id and mentions[].profile_pic_url identify further individuals who did not post anything at all; and tags can identify people tagged in media. Under GDPR, UK GDPR and CCPA/CPRA, plan the whole lifecycle before you run it:
- Lawful basis. If you rely on legitimate interests, write a documented balancing test naming your purpose, why these posts are necessary for it, and why the impact on the authors is proportionate. Do it before collection, not after a complaint.
- Data minimisation. Keep only the columns your purpose needs.
profile_pic_urlandmentions[].profile_pic_urlare the highest-risk fields and their URLs expire anyway; drop them first, thenmentionsif you are not analysing the mention graph, thenpkandtags. - Retention. Set a deletion date at ingestion. Posts get edited and deleted, and
caption_is_editedtells you the text you hold may already be stale. - Transparency and subject rights. Article 14 applies because the data did not come from the individual โ your privacy notice must cover data collected from third-party sources, and you need a workable process for access, deletion and objection requests keyed on
usernameorid.
Only public posts are returned; a private account's posts are not served to a signed-out request and are inaccessible to this Actor entirely. 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 Threads post fields does Threads User Posts Scraper By Keyword & Date Filter return?
The five most used are caption, like_count, direct_reply_count, taken_at_iso and url. Every row carries 22 keys in total, including media_type, medias, mentions and both the epoch and local-string forms of the publish time โ see the data fields table above for all of them.
Does Threads User Posts Scraper By Keyword & Date Filter require a Threads account or login?
No. There is no credential parameter anywhere in the input schema, and none is needed. Every request is made signed out โ the GraphQL body sends av: "0" and __user: "0", and the request carries the x-logged-out-threads-migrated-request header. There is no session cookie to paste, nothing to rotate when it expires, and no Threads or Instagram account of yours exposed to automation. The trade-off is the scope: signed out means public profiles only, so private accounts return nothing.
How many posts can I extract per profile in one run?
That depends on two caps that both apply per profile. maxPosts limits how many matching posts are kept โ 1 to 5000, default 25. maxPostsScanned limits how many posts are examined โ 0 to 20000, default 500, where 0 means the built-in ceiling of 2000. A run for that profile ends as soon as either is hit, so a strict filter at the default settings stops after 500 posts examined regardless of maxPosts. Above those, the practical limit is how far back Threads' own cursor serves history for that profile. Pagination is genuine cursor-based paging โ the Actor reads end_cursor from each response and sends it as after on the next request โ so raising the caps really does go deeper.
Do the keyword and date filters run on Threads' side or in the Actor?
In the Actor, after each page is fetched. Threads' public profile query exposes neither a caption search nor a date range, so the Actor requests pages of posts and evaluates every one locally against the caption, media-type and date rules. Two things follow. Filtered-out posts cost run time and proxy bandwidth but are never charged โ only posts that pass are pushed as row_result events. And a narrow filter over a long history needs headroom in maxPostsScanned, because the Actor has to look at a post before it can reject it. The one server-side saving is the early stop: because the feed is newest-first, four consecutive posts older than postedAfter end the paging for that profile.
What happens if a handle is private, does not exist, or has no posts in the date window?
The run continues in all three cases โ it does not crash, and it does not write an error row.
- The handle does not exist, or tokens cannot be resolved: the Actor works through the full proxy ladder for that profile, then logs
โ @<handle> โ could not finish this profile (...). Continuing with the next one if any.and moves on. Because the ladder is sticky for the whole run, one dead handle early in a long list leaves every later profile on a proxy tier. - The account is private: its posts are not served to a signed-out request, so the query comes back with no post edges. That profile ends with
๐ @<handle> โ done! 0 matching post(s) savedand no error. - No posts fall inside the date window: identical outcome โ zero rows for that profile, and zero charge, because nothing was pushed.
In code, the signal in all three cases is the absence of rows for that handle. Since no diagnostic row is written, read the run log to tell them apart.
Can I scrape multiple Threads profiles at once?
Yes. profileUrls is a list and the Actor works through it in order, resolving fresh tokens for each handle and pushing rows as it goes. maxPosts and maxPostsScanned apply per profile, so ten handles at maxPosts: 50 is a 500-row target. Bare handles, @handles, threads.com / threads.net URLs and {"url": "..."} objects are all accepted; the legacy key urls works as an alias. There is no seed-profile column in the output, so pivot on username โ and remember that on a reposted item username is the original author.
Can it filter by hashtag, or search Threads globally by keyword?
No. The keyword filters run against the caption text of posts belonging to the profiles you list โ they are a filter on a profile's history, not a platform-wide search. A #tag in a caption will match as plain text because the comparison is a substring test, but there is no hashtag endpoint and no global search mode in this Actor. Give it handles, and it filters what those handles posted.
Does Threads User Posts Scraper By Keyword & Date Filter work with Claude, ChatGPT and other AI agent tools?
Yes. It is callable as a standard HTTP-triggered run 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. Because the dataset contains only post rows with identical keys, an agent can consume the output without any filtering or branching logic.
How does it compare to other Threads post scrapers?
Checked on the Apify Store on 25 July 2026, the observable differences are in the filtering and in the output shape. apibox/threads-user-posts-scraper documents a two-field input โ username and num โ with no keyword, date or media filter in its README, and monthly flat-price billing on its listing; its output example shows a closely related field family, but medias is a flat array of URL strings rather than typed media objects, and the mentions key is spelled metions and holds handle strings only. claude_code_reviewer/threads-scraper is a five-mode Actor covering user, hashtag, search, post and feed; its README documents dateFrom / dateTo with the same relative-date syntax, batches of up to 100 usernames, a maxPosts range of 1โ500, and states a flat $0.005 per result โ it has no caption include/exclude filter for user mode, and its own notes state that engagement counts are normalised from Threads' abbreviated display numbers (12.5K โ 12500). scraper-engine/threads-user-posts-scraper is the unfiltered base of this Actor: same fields, urls and maxPostsPerProfile only, no filters.
What this Actor documents that those listings do not: caption include and exclude lists with an any/all match mode, a keep-limit that counts only posts passing the filters, a separate scan cap, exact engagement integers straight from the GraphQL payload, and three timestamp forms per post. Where a competitor README is silent on a behaviour, that is recorded here as not documented, not as absent.
Does it return data in a format LLMs can use directly?
Yes. Typed, normalized JSON with the same 22 field names on every row across runs. No HTML parsing, no selectors, no conditional keys. Pass a row straight into an LLM context window, index it into a vector store, or hand it to an agent tool. The only field needing a branch is medias, whose shape differs between carousel posts and single-media posts.
What happens when Threads changes its layout or anti-bot system?
The Actor is maintained, and its design already assumes Threads changes constantly: the persisted-query doc_id, the Relay provider flag names and the response alias are all discovered from the live JavaScript bundles on every run rather than hard-coded, and the data itself comes from a JSON GraphQL response rather than rendered markup. A front-end redesign therefore generally does not affect your field names or types at all. The output schema stays stable across runs.
Can I use it without managing proxies or browser infrastructure?
Yes. The ladder is automatic โ direct first, then Apify Datacenter, then up to three fresh Apify Residential sessions, sticky once it escalates โ and you never create a proxy account or rotate an IP. There is no browser to run and no CAPTCHA solving. Leaving proxyConfiguration at its {"useApifyProxy": false} prefill is the recommended setting; the ladder still reaches for Apify proxies when Threads rejects the direct route.
Which Threads post fields work best for AI training data and RAG indexing?
For RAG, caption is the only substantial free-text field and is the natural chunk โ index it with taken_at_iso, username and url as metadata so retrieved text stays attributable and time-bounded. For training data or scoring, like_count, direct_reply_count, quote_count, repost_count, media_type and taken_at_timestamp are the most structurally consistent fields: four integers, an enum of four known values, and an epoch integer, present on every row. All values return as typed primitives, so no normalisation pass is needed. Remember that a 0 engagement count can mean "hidden" rather than "zero" โ check like_and_view_counts_disabled before training on it โ and that indexing this output means storing personal data, so apply the minimisation notes above before you embed it.
๐ Related scrapers
| Scraper Name | What it extracts |
|---|---|
| Threads User Followers Scraper | Who follows a Threads profile, with handles and follower counts |
| Instagram Profile Scraper | Bio, follower counts and profile metadata for a handle |
| Instagram Hashtag Scraper | Public posts and engagement for an Instagram hashtag |
| Twitter (X) Posts Scraper | Public posts and engagement metrics from X |
| Facebook Posts Scraper | Public page and profile posts with reactions and comments counts |
| Reddit Posts Scraper By Keyword & Score Filter | Reddit posts filtered by keyword and score threshold |
๐ฌ Your feedback
Found a bug, or need a field that is in the Threads posts payload but not in the output? Open an issue on the Actor's Issues tab. Reports that include the exact input JSON and the handle you ran are the fastest to reproduce and fix โ and if word-boundary keyword matching or a per-run seed-profile column is what you need, say so there, because demand is how it gets prioritised.