What this covers
Each platform exposes a different surface and closes it on a different schedule. This is the per-platform picture: what is still reachable without an account, what needs one, and which tools survive contact with the current APIs. Assume anything here can break without notice — platforms change access rules faster than tooling keeps up.
Method
- Decide whether you can be seen before you touch anything. Some platforms attribute a view, a follow or a group join to your account. Work out which, on this platform, today — then pick the account you are willing to burn.
- Archive first, analyse second. Capture the post, the profile and the surrounding thread before you start pulling threads. Accounts get deleted mid-investigation, routinely.
- Take the public surface without an account where it exists: profile metadata, follower counts, pinned posts, video metadata. Much of this is still reachable unauthenticated and costs you nothing.
- Pivot on identifiers, not display names. Numeric IDs, DIDs and channel IDs survive renames and vanity-URL changes; handles and names do not.
- Trace to origin. The account with the most engagement is almost never the source. Follow forwards, quote-posts and reuploads backwards until the chain stops.
- Date everything from the platform, not the page. Upload timestamps, message IDs and creation dates are platform-side facts; the date shown on a reposted screenshot is not.
- Expect the tooling to be broken. Anything built on an undocumented endpoint dies without notice. Check the project’s last commit before you trust its output, and cross-check one result by hand.
The judgement calls: decide early whether you need the content or the network, because bulk media capture and graph enumeration use different tools and different amounts of patience. Decide which account you are prepared to attribute the work to, before you authenticate anything. And decide whether a date is given or derived — if you read a date off the page and then “confirm” it with a tool that reads the same page, you have proved nothing.
The identifier arithmetic
Most platform IDs are not counters. They are clock readings, so the ID you already have dates the object with no request to the platform at all. That matters twice over: the object may be deleted by the time you look, and the date rendered on a page is frequently the repost’s date rather than the original’s.
TikTok. A video ID is a 64-bit value whose top 32 bits hold a Unix timestamp in seconds. Shift right by 32 and read it:
video id : 7169068344567909638
width : 63 bits, so the timestamp occupies bits 62..32
id >> 32 : 1669178797
as UTC : 2022-11-23 04:46:37Z
X / Twitter. Status IDs are Snowflakes: 41 bits of milliseconds since a platform-specific epoch, sitting 22 bits up. The epoch is the number you have to get right.
status id : 1949394974262350098
id >> 22 : 464771979871 milliseconds since the Twitter epoch
+ 1288834974657 : 1753606954528 milliseconds since the Unix epoch
as UTC : 2025-07-27 09:02:34.528Z
Bluesky. A record key is a TID: 13 characters of the sortable base32 alphabet
234567abcdefghijklmnopqrstuvwxyz, decoding to a top bit of 0, then 53 bits of microseconds
since the Unix epoch, then a 10-bit random clock identifier. Because the alphabet sorts, record
keys sort chronologically as plain strings.
# all three, from the shell
python3 -c 'import datetime as d; i=7169068344567909638; print(d.datetime.fromtimestamp(i>>32, d.timezone.utc))'
python3 -c 'import datetime as d; i=1949394974262350098; print(d.datetime.fromtimestamp(((i>>22)+1288834974657)/1000, d.timezone.utc))'
goat syntax tid inspect 3kzifvcppte22
2022-11-23 04:46:37+00:00
2025-07-27 09:02:34.528000+00:00
Timestamp (UTC): 2024-08-12T02:08:03.29Z
Timestamp (Local): 2024-08-11T19:08:03-07:00
ClockID: 0
uint64: 0x187dcbda2b5ca800
Three ways this misleads. The first is the obvious one: an ID dates the record, not the
footage. A clip shot in 2019 and uploaded in 2026 has a 2026 ID, and the arithmetic is still
correct. The second is who generated the number. TikTok and X mint IDs server-side at creation, so
they are harder to forge than a rendered date; an atproto TID is minted by whichever component
writes the record, off that generator’s own local clock, and the specification states plainly that
uniqueness cannot be guaranteed because an adversarial party can create records reusing known TIDs.
A Bluesky record key is therefore the same class of evidence as record.createdAt — an assertion
by the writer — and the relay’s indexedAt is the independent number. The third is bit-width drift: quote the shift, not a
string slice. Taking “the first 31 characters of the binary” works for every current 19-digit
TikTok ID only because such IDs happen to be 63 bits wide; id >> 32 keeps working when they are
not.
Telegram
The most productive platform for open-source research, because public channels are genuinely public, history is retained, and forwarding metadata exposes the network between channels.
- Forward chains are the key primitive. A forwarded message keeps its origin, so you can trace content back through the channels that amplified it.
- Member lists are visible in many groups. Your own account appears in them too — use a research account.
- Channel creation dates and message IDs are sequential, which lets you estimate when a channel started and how much has been deleted.
Before any tooling, the web preview gives you a channel’s recent history with no account, no app and no API credentials. It is the cheapest first look there is, and it is the only one that costs you nothing in attribution:
https://t.me/s/channelname the public web view: recent messages, dates, view counts
https://t.me/channelname/4312 one message, by its sequential id -- the permalink form
https://t.me/s/channelname?before=4312 page backwards from that id, ~20 messages at a time
https://t.me/+AbCdEf0123456789 a private invite link; opening it does not join, but
it does reveal the chat title and member count
# how far back the web view goes, and whether ids are contiguous
curl -s 'https://t.me/s/channelname' \
| grep -o 'data-post="channelname/[0-9]*"' | sed 's/.*\///; s/"//' | sort -n | head -1
# walk backwards in pages of ~20 and collect every id the preview will serve
for b in 4312 4292 4272 4252; do
curl -s "https://t.me/s/channelname?before=$b" \
| grep -o 'data-post="channelname/[0-9]*"' | sed 's/.*\///; s/"//'
done | sort -nu > ids.txt
# the gaps are deletions: compare the id range against the count you actually got
awk 'NR==1{min=$1} {max=$1; n++} END{print "ids", min"-"max, "present", n, "missing", max-min+1-n}' ids.txt
ids 401-440 present 39 missing 1
Strip the id with sed, not with a second grep -o '[0-9]*$'. The attribute ends in a quote, so
anchoring digits to end-of-line matches the empty string and emits one blank line per post: twenty
matches in, twenty blanks out, exit status 0. The run looks like a channel with no posts rather
than like a pipeline that threw the answer away.
The web view is a preview, not the archive: it serves a bounded window of recent history and will not page back indefinitely, and a channel can disable it entirely. Treat a missing ID as evidence of deletion only after you have confirmed the preview is serving that range at all. Archiving a channel properly, mapping its forwards and checking a number against the platform all have tooling below under Key tools.
Graph search is gone and most enumeration is dead. What still works:
- Numeric profile IDs persist even when vanity URLs change, and map back to a profile.
- Public pages, groups and events remain readable and are frequently forgotten by their owners.
- Ad Library (facebook.com/ads/library) is genuinely open, keyword-searchable, and covers political advertising with spend and reach data.
- Photo comments and reactions on public posts still expose participant lists.
The two URL forms worth memorising. The first resolves a numeric ID that survived a rename; the second is the Ad Library filter set, which the web interface writes into its own query string, so you can build a link rather than click through four dropdowns:
# a numeric id back to a profile, which survives every vanity-URL change
https://www.facebook.com/profile.php?id=100012345678901
# the Ad Library UI's own filter parameters, as it constructs them
https://www.facebook.com/ads/library/?active_status=all&ad_type=political_and_issue_ads
&country=GB&q=climate&search_type=keyword_unordered&media_type=all
# every ad from one page, which is the attribution-relevant view
https://www.facebook.com/ads/library/?active_status=all&ad_type=all&country=GB
&view_all_page_id=123456789
Those are interface parameters observed from the Ad Library itself, not a documented API; the documented parameter set is the Graph endpoint under Meta Ad Library API below, and that is what to script against. Everything else on Facebook that once had a query form now does not: Who posted what? and Graph Tips build search URLs for you, and whether they work on any given day is a question you answer by trying.
- Profile pictures, bios and post counts are visible without login; the feed usually is not.
?__a=1style JSON endpoints have been closed off repeatedly — assume they do not work.- Instaloader still handles public profile and hashtag downloads, including comments and geotags where present; it has its own section below.
- Aggressive use gets the account and the IP rate-limited quickly, then blocked outright.
Instaloader’s target syntax is the part worth learning, because it is how you address something other than a profile. All four forms are documented targets, not flags:
profile_name a public profile, or a private one with --login
"#hashtag" posts carrying a hashtag; the quotes are usually necessary
"%212998617" posts tagged with a numeric location id
"@profile_name" the profiles that profile_name follows, i.e. its followees
Location IDs are the non-obvious pivot: they are numeric, they are stable, and instagram-location-search below turns a coordinate into a list of them, which turns “somewhere near this junction” into a set of addressable targets.
X / Twitter
Heavily restricted since the API changes. Advanced search still works while logged in, and is still the best tool on the platform — the operator set is below under Key tools.
Archived snapshots are now often more reliable than the live site for deleted content — go to the Wayback Machine first.
One thing still works with no session at all: a status ID dates itself. If you have a screenshot with a URL in it, you have the post’s creation time whether or not the post survives:
# status id -> UTC creation time, from the Snowflake layout above
python3 - <<'PY'
import datetime
for sid in (1949394974262350098, 1234567890123456789):
ms = (sid >> 22) + 1288834974657
print(sid, datetime.datetime.fromtimestamp(ms / 1000, datetime.timezone.utc).isoformat())
PY
1949394974262350098 2025-07-27T09:02:34.528000+00:00
1234567890123456789 2020-03-02T19:54:56.824000+00:00
That is a creation time for the ID, which for an original post is the post’s time. For a quote-post
or a reply it is that reply’s time, not the thing it replies to, and the conversation_id operator
below is how you get back to the root.
TikTok
- Profiles and individual videos are viewable without an account.
- Bellingcat’s TikTok Hashtag Analysis collects posts by hashtag and charts co-occurrence, which is the useful bulk primitive.
- Video download and metadata extraction both work through
yt-dlp.
Dating a TikTok needs neither an account nor a live video, because the ID carries the time. This is the whole of what Bellingcat’s extractor does, and doing it locally means it also works for a URL you only have as text:
# pull the id out of the URL and decode it -- no network request at all
url='https://www.tiktok.com/@user/video/7169068344567909638'
python3 -c "
import datetime, re, sys
vid = int(re.search(r'/video/(\d+)', sys.argv[1]).group(1))
print(vid, datetime.datetime.fromtimestamp(vid >> 32, datetime.timezone.utc).isoformat())
" "$url"
# cross-check against what the platform says, which needs the video to still exist
yt-dlp --dump-json "$url" | jq '{id,timestamp,upload_date,uploader_id,view_count}'
7169068344567909638 2022-11-23T04:46:37+00:00
When the decoded time and yt-dlp’s timestamp disagree by more than a second or two, the
interesting case is a reupload: the ID in the URL belongs to the copy you are looking at, and an
earlier copy with an earlier ID is what you are actually hunting for.
YouTube
Still the most researcher-friendly large platform: metadata, channel listings, comments and
auto-generated subtitles all come out of yt-dlp, covered below.
Upload timestamps are in the metadata and are a reliable earliest-possible date for footage.
The two invocations that do most of the work on this platform are a channel listing and a subtitle dump — one tells you what exists, the other turns hours of video into text you can grep:
# the whole channel as a dated listing, without touching a single video file
yt-dlp --flat-playlist --dump-json 'https://youtube.com/@channel/videos' \
| jq -r '[.upload_date, .id, .duration, .title] | @tsv' | sort
# only uploads inside a window, which is how you tie output to an event
yt-dlp --flat-playlist --dateafter 20260101 --datebefore 20260331 \
-O '%(upload_date)s %(id)s %(title)s' 'https://youtube.com/@channel/videos'
# subtitles for every video in a playlist, as text, no media
yt-dlp --write-auto-subs --sub-langs 'en.*' --convert-subs srt --skip-download \
-o '%(upload_date)s-%(id)s.%(ext)s' 'https://youtube.com/playlist?list=PLID'
# then the actual search, across the lot
grep -rin 'phrase you care about' *.srt
--flat-playlist is what keeps a channel listing cheap: it reads the playlist entries without
resolving each video, so a 2,000-video channel is one request chain rather than 2,000. The price is
that some fields are absent from flat entries, so confirm a field exists in --dump-json output
before you build a pipeline on it.
- Profiles, posts and comment trees are readable without an account, and appending
.jsonto any Reddit URL returns the same thing as structured data. - Deleted and removed content is the point of interest, and Reddit does not serve it. The historical archives do — Arctic Shift below is the live successor to Pushshift.
- Subreddit moderator lists, wiki pages and automoderator configs are public and frequently forgotten by the people who wrote them.
The .json trick, with the parameters that matter:
# any Reddit URL plus .json, with escaping turned off and a full page
curl -s -A 'research-tooling/1.0 (contact: you@example.test)' \
'https://www.reddit.com/user/some_user/comments/.json?limit=100&raw_json=1' \
| jq -r '.data.children[] | "\(.data.created_utc) \(.data.subreddit) \(.data.body[0:100])"'
# page with the fullname the previous response handed back
curl -s -A 'research-tooling/1.0' \
'https://www.reddit.com/r/subreddit/new/.json?limit=100&raw_json=1&after=t3_abc123'
raw_json=1 is not cosmetic: without it Reddit replaces <, > and & with HTML entities in
every string field, so a URL or a quoted snippet comes back mangled and your grep misses it.
Expect this route to fail anyway. Reddit now returns 403 to unauthenticated JSON requests from
datacentre address ranges regardless of User-Agent — tested from a hosted address, a custom agent
string makes no difference — so a VPS or CI runner gets nothing while a residential browser
session gets everything. That asymmetry is exactly why the archive below is not optional.
Bluesky
Structurally the most open platform here, because the AT Protocol is a public API by design: every post, follow and profile is a record in a repository you can read without an account.
- DIDs are permanent. A handle is a pointer; the
did:plc:…identifier survives every rename, and the PLC directory keeps an audit log of every change to it — including the account’s first operation, which dates its creation. - Follow graphs are fully enumerable, which makes network analysis possible in a way it is not anywhere else on this page.
- Search is the one thing that is not open. Post search now needs an authenticated session; profile and feed reads do not.
There are two layers and it is worth knowing which one you are on. The AppView
(public.api.bsky.app) serves the rendered, moderated view that the app shows. The account’s own
PDS serves the raw repository, which includes record types the app never displays:
# which record collections this account actually holds -- not just posts
curl -s 'https://bsky.social/xrpc/com.atproto.repo.describeRepo?repo=atproto.com' \
| jq '{handle, did, handleIsCorrect, collections}'
# raw records from one collection, straight out of the repository
curl -s 'https://bsky.social/xrpc/com.atproto.repo.listRecords?repo=atproto.com&collection=app.bsky.feed.post&limit=100&reverse=true' \
| jq -r '.records[] | "\(.value.createdAt) \(.uri | split("/") | last) \(.value.text[0:80])"'
reverse=true on listRecords gives you the oldest records first, which is how you read an
account’s first ever posts without paging through everything since. The full tool coverage, the
AppView endpoints and the goat CLI are below under Key tools.
Key tools
yt-dlp
Does far more than download: it reads the platform’s own metadata for a video, a channel or a playlist, and it covers well over a thousand sites, so the same invocation works on YouTube, TikTok, Twitch, Instagram and most of the long tail. For dating footage and enumerating a channel’s output it is the first tool to reach for.
pipx install yt-dlp
# everything the platform will tell you about one video, without downloading it
yt-dlp --dump-json 'https://youtube.com/watch?v=VIDEOID' | jq '{upload_date,uploader_id,duration,view_count}'
# just the fields you need, printed — faster to read than the full JSON
yt-dlp -O '%(upload_date)s %(uploader_id)s %(title)s' 'https://youtube.com/watch?v=VIDEOID'
# the whole channel as a listing, without touching the videos themselves
yt-dlp --flat-playlist --dump-json 'https://youtube.com/@channel/videos' \
| jq -r '[.upload_date,.id,.title] | @tsv'
# the entire playlist as ONE json object, for feeding a script rather than a reader
yt-dlp --flat-playlist --dump-single-json 'https://youtube.com/@channel/videos' \
| jq '{channel: .uploader, count: (.entries | length)}'
# auto-generated subtitles, which turn hours of video into greppable text
yt-dlp --write-auto-subs --sub-langs en --skip-download 'https://youtube.com/watch?v=VIDEOID'
# which subtitle tracks exist at all, before you guess a language code
yt-dlp --list-subs 'https://youtube.com/watch?v=VIDEOID'
# comments, saved alongside the metadata, for identifying participants
yt-dlp --write-info-json --write-comments --skip-download 'https://youtube.com/watch?v=VIDEOID'
# the thumbnail set, which is a reverse-image-search input and sometimes an earlier frame
yt-dlp --write-thumbnail --skip-download 'https://youtube.com/watch?v=VIDEOID'
# one clip out of a long stream, by timestamp, instead of the whole six hours
yt-dlp --download-sections '*01:12:30-01:14:00' 'https://youtube.com/watch?v=VIDEOID'
# a date window across a channel, for tying output to an event
yt-dlp --flat-playlist --dateafter 20260101 --datebefore 20260331 \
-O '%(upload_date)s %(id)s' 'https://youtube.com/@channel/videos'
# filter on any metadata field rather than on dates; "&" is how conditions AND together
yt-dlp --match-filters 'duration > 600 & view_count > 10000' \
-O '%(id)s %(duration)s %(view_count)s' 'https://youtube.com/@channel/videos'
# "?" after the operator keeps items whose field is absent, instead of dropping them silently
yt-dlp --match-filters 'view_count >? 10000' \
-O '%(id)s %(view_count)s' 'https://youtube.com/@channel/videos'
# a TikTok video with its metadata — same tool, same flags
yt-dlp --write-info-json 'https://www.tiktok.com/@user/video/1234567890123456789'
# items 1 to 50 of a playlist, with an archive file so a resumed run does not re-fetch
yt-dlp -I 1:50 --download-archive seen.txt 'https://youtube.com/playlist?list=PLID'
# a livestream from its start, for an event already in progress
yt-dlp --live-from-start 'https://youtube.com/watch?v=LIVEID'
# the logged-in surface, when a public one does not exist — uses your browser's session
yt-dlp --cookies-from-browser firefox --dump-json URL
upload_date is the platform’s own value and is a reliable earliest-possible date for footage;
it is not the date the footage was shot. Fields vary by extractor, so check with --dump-json
before scripting against a key — --match-filters silently matches nothing when the field you
named does not exist on that extractor, which looks identical to a channel with no matching
videos. The >? form above is the documented escape: it treats an absent field as a pass, so a
sweep that returns nothing is telling you about the videos rather than about the extractor. Passing --cookies-from-browser attaches your real session to every request, which
attributes the activity to that account — use a research profile. Extractors break when platforms
change, so pipx upgrade yt-dlp before blaming the URL.
gallery-dl
The image-and-gallery counterpart to yt-dlp: it walks a profile, a tag or a thread and takes the media plus the per-item JSON. Where it earns its place is bulk profile capture with metadata intact — the captions, timestamps and IDs that make a media set evidence rather than a folder of pictures.
pipx install gallery-dl
# what it would fetch, and the metadata for each item, without downloading
gallery-dl -j 'https://www.instagram.com/username/'
# the available metadata keys for a URL, so your filters reference real fields
gallery-dl -K 'https://www.reddit.com/user/username/submitted/'
# media plus a JSON sidecar per file, into a named directory
gallery-dl --write-metadata -D ./capture/username 'https://twitter.com/username/media'
# direct URLs only, to hand to an archiving tool instead of downloading yourself
gallery-dl -g 'https://www.reddit.com/r/subreddit/comments/abc123/'
# a chosen field per item, printed as the run goes, instead of parsing json afterwards
gallery-dl --no-download -N '{date} {num} {filename}' 'https://www.reddit.com/user/username/submitted/'
# the first 50 items of a long profile, which is usually all you need to characterise it
gallery-dl --range 1-50 'https://www.flickr.com/photos/username/'
# only the large images, skipping avatars and thumbnails
gallery-dl --filter 'image_width >= 1000' 'https://example-gallery.test/user/x'
# an archive file, so re-running a monitored profile fetches only what is new
gallery-dl --download-archive seen.sqlite3 'https://www.instagram.com/username/'
# many targets from a file, which is how a watchlist gets run
gallery-dl -i targets.txt --download-archive seen.sqlite3
# slow and polite: the alternative is a 429 and then a block
gallery-dl --sleep 3 --sleep-request 2 --sleep-429 120 'https://www.instagram.com/username/'
# hash each file as it lands, so the capture is checkable later
gallery-dl --exec 'sha256sum {} >> hashes.txt' -D ./capture 'https://twitter.com/username/media'
# keep the raw HTML the extractor parsed, which is the only way to debug an empty run
gallery-dl --write-pages -j 'https://www.instagram.com/username/'
# a logged-in session where the public surface is gone
gallery-dl --cookies-from-browser firefox 'https://www.instagram.com/username/'
Per-site options live in a config file (gallery-dl --config-create writes a starter), and most
rate-limit problems are solved there rather than on the command line — -o KEY=VALUE sets one
inline when you do not want to edit the file. Timestamps in the sidecar JSON are the platform’s,
which is the point — but the field name differs per extractor, so read -K output before you build
a timeline. A profile capture is a snapshot: items deleted before you ran it are simply absent, and
nothing in the output tells you that. An extractor that returns zero items is far more often broken
than correct, and --write-pages is how you tell the difference.
Instaloader
Instagram specifically, and still working where most Instagram tooling has died. Public profiles, hashtags, locations and stories, with geotags and comments where they exist.
pipx install instaloader
# a public profile: posts, captions and metadata JSON
instaloader profile_name
# metadata only — no media, much faster, much less conspicuous
instaloader --no-pictures --no-videos profile_name
# comments and geotags, which are the parts worth having
instaloader --comments --geotags profile_name
# just the profile picture at full resolution, for reverse image search
instaloader --no-posts profile_name
# cap a location run; --count does not apply to ordinary profile targets
instaloader --count 50 --no-videos '%212998617'
# posts within a date window, using a Python filter expression
instaloader --post-filter 'date_utc >= datetime(2026,1,1)' profile_name
# only posts that carry a location, which is the geolocation-relevant subset
instaloader --post-filter 'location is not None' --geotags profile_name
# a hashtag rather than an account
instaloader '#hashtag'
# a numeric location id, which is what instagram-location-search below produces
instaloader '%212998617'
# everywhere this account has been tagged by other people
instaloader --tagged --no-posts profile_name
# one post by shortcode; the leading dash needs the -- separator first
instaloader -- -B_Nmp6MlQGV
# readable JSON instead of xz-compressed, for grepping a capture afterwards
instaloader --no-compress-json --no-pictures --no-videos profile_name
# stories and highlights need a session; both attribute the view to that account
instaloader --login YOUR_RESEARCH_ACCOUNT --stories --highlights profile_name
# reuse a browser session instead of handing credentials to the tool
instaloader --load-cookies firefox --sessionfile ./research.session profile_name
# stop on the first rate-limit response rather than grinding into a block
instaloader --abort-on 429,401 --max-connection-attempts 1 profile_name
# resume by stopping at the first already-downloaded post
instaloader --fast-update profile_name
# resume by recorded timestamp instead, which is the only resume a metadata-only run can use
instaloader --no-pictures --no-videos --latest-stamps stamps.ini profile_name
Viewing a story is attributed. The account whose session you used appears in the poster’s
viewer list, so --stories is never a passive operation. Unauthenticated use is rate-limited
aggressively and then blocked by IP; authenticated use gets the account flagged and sometimes
disabled — --abort-on 429 is the difference between one bad response and a dead account.
--no-pictures cannot be combined with --fast-update, because the resume logic keys off
downloaded media, so a metadata-only monitoring run needs --latest-stamps instead. Geotags are
only present where the poster added them, and the location name is user-chosen, not GPS.
instagram-location-search
Bellingcat’s answer to “what is Instagram’s own name for this place”. It takes a coordinate and returns the location tags Instagram knows about nearby, with their numeric IDs — which is the pivot, because an ID is an addressable target for Instaloader while a place name is not.
pip install instagram-location-search
# a coordinate to a list of location tags, as CSV
instagram-location-search --cookie "$IG_COOKIE" --lat 51.5074 --lng -0.1278 --csv locs.csv
# every output format at once: the raw API response, a geospatial layer, and a map to eyeball
instagram-location-search --cookie "$IG_COOKIE" --lat 51.5074 --lng -0.1278 \
--json locs.json --geojson locs.geojson --map locs.html
# just the IDs, which is the form the next tool wants
instagram-location-search --cookie "$IG_COOKIE" --lat 51.5074 --lng -0.1278 --ids locs.txt
# then feed each one to Instaloader as a location target
while read -r id; do instaloader --no-videos --geotags "%$id"; done < locs.txt
It needs a session cookie, so every search is attributed to that account. The locations it returns are Instagram’s places, which are user-created: there are duplicates, misspellings, joke entries and venues that have closed, and a radius search returns them by proximity to the coordinate rather than by relevance. A location tag on a post means the poster selected that place from a list, not that a GPS fix put them there — see Geolocation & Chronolocation for what actually constitutes placing an image.
Telegram: Telepathy and Telethon
Telepathy is the toolkit the Telegram sections of older OSINT guides point at, but it has been unmaintained since July 2024 and the current source branch does not install cleanly. The commands below document the published 2.3.4 interface; for anything you need to keep working, the durable route is Telethon, which tracks the Telegram API itself.
pip3 install telepathy
telepathy -t channelname # basic channel scan
telepathy -t channelname -c # comprehensive: adds the message history archive
telepathy -t channelname -c -f # plus a forward edgelist, the useful part
telepathy -t channelname -c -m # media archiving alongside the comprehensive scan
telepathy -t channelname -c -r # archive channel replies as well as posts
telepathy -t username -u # look up a user your account has encountered
telepathy -e # export your account's chat list to CSV
telepathy -t channelname -a 2 # run from an alternative number or API key set
Both need API credentials from my.telegram.org, which are tied to a real phone number — use one you are willing to lose. Telethon in forty lines does the part that matters, and does not rot:
# pip install telethon
from collections import Counter
from telethon.sync import TelegramClient
from telethon.errors import FloodWaitError
from telethon.tl.functions.channels import GetFullChannelRequest
from telethon.tl.types import InputMessagesFilterPhotos
with TelegramClient('research-session', API_ID, API_HASH) as client:
entity = client.get_entity('channelname')
print(entity.id, entity.title, entity.date) # channel id and creation date
# what Telegram itself reports about the channel, including the linked discussion group
full = client(GetFullChannelRequest(entity)).full_chat
print(full.participants_count, full.linked_chat_id, (full.about or '')[:120])
# message history, newest first; message IDs are sequential, so gaps are deletions
for msg in client.iter_messages(entity, limit=500):
fwd = msg.forward.chat.username if msg.forward and msg.forward.chat else None
print(msg.id, msg.date.isoformat(), fwd or '-', (msg.message or '')[:80])
# the count Telegram reports, against the newest id: the difference is deleted content
page = client.get_messages(entity, limit=100)
newest = page[0]
print('reported total', page.total, 'newest id', newest.id, 'gap', newest.id - page.total)
# oldest first, which is how you read a channel's first posts without paging the lot
for msg in client.iter_messages(entity, limit=20, reverse=True):
print(msg.id, msg.date.isoformat(), (msg.message or '')[:80])
# server-side keyword search inside one channel -- far cheaper than fetching everything
for msg in client.iter_messages(entity, search='keyword', limit=100):
print(msg.id, msg.date.isoformat(), (msg.message or '')[:120])
# only messages carrying photos, for building a verification set
for msg in client.iter_messages(entity, filter=InputMessagesFilterPhotos, limit=50):
print(msg.id, msg.date.isoformat(), msg.file.name if msg.file else '-')
# everything one account posted in a group, which is the behavioural view
for msg in client.iter_messages('groupname', from_user='username', limit=200):
print(msg.id, msg.date.isoformat(), (msg.message or '')[:80])
# the forward graph: which channels this one amplifies, with counts
sources = Counter(
m.forward.chat.username
for m in client.iter_messages(entity, limit=2000)
if m.forward and m.forward.chat
)
print(sources.most_common(20))
# membership, where the group exposes it — you appear in their list too
for user in client.iter_participants(entity, limit=200):
print(user.id, user.username, user.first_name)
Sequential message IDs are the quiet win here: a gap between IDs is deleted content, and the first
ID dates the channel. The search= parameter runs server-side, which matters for a channel with
100,000 messages — it is the difference between one request and a thousand. Joining a group to read
it adds your research account to a list other members can see, and scraping at speed from one
account is exactly the behaviour Telegram bans for. Rate limits surface as FloodWaitError with a
seconds attribute — sleep for exactly that, rather than retrying, because retrying extends it.
telegram-phone-number-checker
Bellingcat’s tool for the one Telegram question with a clean answer: is this phone number registered, and if so what does the account show. Also takes usernames.
pip install telegram-phone-number-checker
# credentials go in a .env: API_ID, API_HASH, PHONE_NUMBER
telegram-phone-number-checker --phone-numbers +14155550123
# several at once, comma-separated
telegram-phone-number-checker --phone-numbers +14155550123,+14155550124
# pull the profile photos too, for reverse image search
telegram-phone-number-checker --phone-numbers +14155550123 --download-profile-photos
# by username instead of number
telegram-phone-number-checker --usernames johndoe
# both in one run, to a named output file
telegram-phone-number-checker --phone-numbers +14155550123 --usernames johndoe --output results.json
# credentials on the command line, overriding the .env, for a one-off run on a second account
telegram-phone-number-checker --api-id 123456 --api-hash abcdef0123456789 \
--api-phone-number +14155550000 --phone-numbers +14155550123
# through a proxy, since the lookups come from your account
pip install 'telegram-phone-number-checker[proxy]'
telegram-phone-number-checker --phone-numbers +14155550123 --proxy socks5://127.0.0.1:1080
Checking a number works by adding it to your account’s contacts, which is a write operation on Telegram’s side: the README’s own advice is not to use a personal account, and that is not boilerplate. A negative result means the number is not registered or the account’s privacy settings hide it from non-contacts — the tool cannot tell you which. See email and phone OSINT for the rest of the number work.
tiktok-hashtag-analysis
Bellingcat’s bulk primitive for TikTok: collect the posts carrying one or more hashtags, store the metadata, and chart which hashtags co-occur. That co-occurrence graph is how a coordinated campaign becomes visible, which individual video analysis never shows.
pip install tiktok-hashtag-analysis
# collect posts for one or more hashtags
tiktok-hashtag-analysis london paris
# hashtags from a file, when the list is long
tiktok-hashtag-analysis --file hashtags.txt --output-dir ./tiktok-run
# the top 20 co-occurring hashtags, as a table
tiktok-hashtag-analysis london --number 20 --table
# the same, plotted
tiktok-hashtag-analysis london --number 20 --plot
# download the videos too, capped per hashtag
tiktok-hashtag-analysis london --download --limit 200
# short forms, for the same thing: -d download, -t table, -p plot
tiktok-hashtag-analysis london -d -t --number 20 --limit 50
# keep a log file, which is the only record of what a long run actually did
tiktok-hashtag-analysis london --log ./run.log --output-dir ./tiktok-run
# watch the browser work, which is how you debug a run that returns nothing
tiktok-hashtag-analysis london --headed -v
It drives a real browser, so it is slow and it breaks when TikTok changes its front end — a run
that returns zero posts for a busy hashtag means the scraper is broken, not that the hashtag is
empty. Check with --headed before concluding anything. Hashtag collection is a sample, never the
complete set, so counts are comparative at best, and two runs an hour apart will not agree. For a
single video’s exact upload time, decode the ID as shown under TikTok above, and
yt-dlp --dump-json gets the rest.
Arctic Shift
The live successor to Pushshift for Reddit: a public, keyless archive of posts and comments, including content since deleted from Reddit itself. This is the only practical way to read a removed comment or reconstruct a user’s history after they wiped it.
AS='https://arctic-shift.photon-reddit.com/api'
# everything an author posted, oldest first
curl -s -G "$AS/posts/search" --data-urlencode 'author=some_user' \
--data-urlencode 'sort=asc' --data-urlencode 'limit=100' \
| jq -r '.data[] | "\(.created_utc) \(.subreddit) \(.title)"'
# their comments, which are usually more revealing than their posts
curl -s -G "$AS/comments/search" --data-urlencode 'author=some_user' \
--data-urlencode 'limit=100' | jq -r '.data[] | "\(.subreddit) \(.body[0:120])"'
# keyword search inside a subreddit and a date window
curl -s -G "$AS/posts/search" --data-urlencode 'subreddit=worldnews' \
--data-urlencode 'title=wuhan' --data-urlencode 'after=2019-12-30' --data-urlencode 'limit=10'
# title and selftext together, which is what 'query' is for
curl -s -G "$AS/posts/search" --data-urlencode 'subreddit=osint' \
--data-urlencode 'query=telegram' --data-urlencode 'limit=25'
# only the fields you want, which keeps a wide sweep manageable
curl -s -G "$AS/posts/search" --data-urlencode 'subreddit=osint' \
--data-urlencode 'fields=title,created_utc,author' --data-urlencode 'limit=100'
# posts linking a specific domain — how a site spreads across subreddits
curl -s -G "$AS/posts/search" --data-urlencode 'url=example-news-daily.com' \
--data-urlencode 'limit=100' | jq -r '.data[].subreddit' | sort | uniq -c | sort -rn
# posting cadence rather than content: a month-by-month count
curl -s -G "$AS/posts/search/aggregate" --data-urlencode 'subreddit=osint' \
--data-urlencode 'aggregate=created_utc' --data-urlencode 'frequency=month' \
--data-urlencode 'after=2026-01-01' | jq -r '.data[] | "\(.created_utc[0:7]) \(.count)"'
# where an account actually operates, weighted and ranked
curl -s -G "$AS/users/interactions/subreddits" --data-urlencode 'author=some_user' \
--data-urlencode 'limit=10' | jq -r '.data[] | "\(.count)\t\(.subreddit)"'
# who an account talks to, which is the social graph Reddit does not expose
curl -s -G "$AS/users/interactions/users" --data-urlencode 'author=some_user' \
--data-urlencode 'min_count=3' --data-urlencode 'limit=20'
# every wiki page a subreddit has, including the ones nobody links to
curl -s -G "$AS/subreddits/wikis/list" --data-urlencode 'subreddit=osint' | jq -r '.data[]'
# the full comment tree under one post, up to 25,000 comments
curl -s -G "$AS/comments/tree" --data-urlencode 'link_id=abc123' \
--data-urlencode 'limit=9999'
# known IDs straight to records, up to 500 per call
curl -s -G "$AS/comments/ids" --data-urlencode 'ids=t1_aaa,t1_bbb'
# accounts by username prefix, for a sockpuppet family
curl -s -G "$AS/users/search" --data-urlencode 'author_prefix=civicwatch' \
--data-urlencode 'sort_type=author' --data-urlencode 'limit=100' | jq -r '.data[].author'
# an RSS feed of a standing query, for monitoring rather than a one-off sweep
curl -s -G "$AS/comments/search" --data-urlencode 'subreddit=osint' \
--data-urlencode 'format=rss' --data-urlencode 'limit=25'
A real response, from the aggregate query above:
{"data":[{"created_utc":"2025-12-31T23:00:00.000Z","count":"390"},
{"created_utc":"2026-01-31T23:00:00.000Z","count":"376"},
{"created_utc":"2026-02-28T23:00:00.000Z","count":"503"},
{"created_utc":"2026-03-31T22:00:00.000Z","count":"450"}]}
Note the bucket boundaries: 23:00:00Z then 22:00:00Z after March, because the buckets are cut
in a local timezone that observes DST, so a month’s created_utc label is the start of the bucket
and not the month. Keyword search is constrained by design — title works only alongside author
or subreddit, body only alongside author, subreddit, link_id or parent_id — so a bare
keyword sweep is refused, and it is refused for very active authors and subreddits too. The archive
holds what it ingested at the time, so an edit after ingestion is invisible and a post removed
within seconds of being made may never have been captured. A record here is evidence that text was
published, not that it is still live, and the live check is a separate step.
Bluesky and the AT Protocol
No key, no account, and a genuinely public API — the most researcher-friendly surface of any current platform. Everything below runs against the public AppView except post search, which now requires an authenticated session.
API='https://public.api.bsky.app/xrpc'
# handle to DID: the identifier that survives renames
curl -s "$API/com.atproto.identity.resolveHandle?handle=bellingcat.com" | jq -r .did
# the PLC audit log: every handle and server change, and the creation date in entry one
curl -s 'https://plc.directory/did:plc:z72i7hdynmk6r22z27h6tvur/log/audit' \
| jq -r '.[] | "\(.createdAt) \(.operation.alsoKnownAs // [] | join(","))"'
# profile: display name, counts, and the account's own description
curl -s "$API/app.bsky.actor.getProfile?actor=bellingcat.com" \
| jq '{handle,displayName,followersCount,postsCount,createdAt}'
# up to 25 accounts in one request, which is how you compare a suspected cluster
curl -s -G "$API/app.bsky.actor.getProfiles" \
--data-urlencode 'actors=bellingcat.com' --data-urlencode 'actors=atproto.com' \
| jq -r '.profiles[] | "\(.createdAt) \(.followersCount)\t\(.handle)"'
# the author's posts, paged with the cursor the response returns
curl -s "$API/app.bsky.feed.getAuthorFeed?actor=bellingcat.com&limit=100" \
| jq -r '.feed[] | "\(.post.indexedAt) \(.post.record.text[0:100])"'
# only posts carrying media, which is the usual filter for verification work
curl -s "$API/app.bsky.feed.getAuthorFeed?actor=bellingcat.com&limit=50&filter=posts_with_media"
# the account's own threads without the replies noise
curl -s "$API/app.bsky.feed.getAuthorFeed?actor=bellingcat.com&limit=50&filter=posts_no_replies"
# the follow graph, fully enumerable in both directions
curl -s "$API/app.bsky.graph.getFollows?actor=bellingcat.com&limit=100" | jq -r '.follows[].handle'
curl -s "$API/app.bsky.graph.getFollowers?actor=bellingcat.com&limit=100" | jq -r '.followers[].handle'
# the lists an account curates, which say who it considers relevant
curl -s "$API/app.bsky.graph.getLists?actor=bellingcat.com&limit=50" \
| jq -r '.lists[] | "\(.purpose)\t\(.listItemCount)\t\(.name)"'
# who amplified one post, and who liked it -- both need the post's AT-URI
curl -s "$API/app.bsky.feed.getRepostedBy?uri=at://did:plc:xxxx/app.bsky.feed.post/yyyy&limit=100" \
| jq -r '.repostedBy[].handle'
curl -s "$API/app.bsky.feed.getLikes?uri=at://did:plc:xxxx/app.bsky.feed.post/yyyy&limit=100" \
| jq -r '.likes[] | "\(.createdAt) \(.actor.handle)"'
# a whole thread, replies included, from one post's AT URI
curl -s "$API/app.bsky.feed.getPostThread?uri=at://did:plc:xxxx/app.bsky.feed.post/yyyy"
# post search needs a session — this returns an auth error on the public host
curl -s -G "$API/app.bsky.feed.searchPosts" --data-urlencode 'q=osint' \
--data-urlencode 'sort=latest' --data-urlencode 'since=2026-01-01' --data-urlencode 'limit=25'
Once you have a session, searchPosts is the richest query surface on the platform: q plus
author, mentions, domain, url, lang, repeated tag parameters that AND together, and
since/until which take a bare YYYY-MM-DD. Authenticate with
com.atproto.server.createSession against the account’s own PDS and send the returned
accessJwt as a bearer token:
# a session on the account's own PDS; use an app password, never the real one
PDS='https://bsky.social'
TOKEN=$(curl -s -X POST "$PDS/xrpc/com.atproto.server.createSession" \
-H 'Content-Type: application/json' \
-d '{"identifier":"you.bsky.social","password":"xxxx-xxxx-xxxx-xxxx"}' | jq -r .accessJwt)
# every post from one account mentioning a domain, inside a date window
curl -s -G "$PDS/xrpc/app.bsky.feed.searchPosts" -H "Authorization: Bearer $TOKEN" \
--data-urlencode 'q=*' --data-urlencode 'author=bellingcat.com' \
--data-urlencode 'domain=example-news-daily.com' --data-urlencode 'since=2026-01-01' \
| jq -r '.posts[] | "\(.record.createdAt) \(.author.handle) \(.record.text[0:100])"'
# posts carrying two tags at once, which is the coordination signal
curl -s -G "$PDS/xrpc/app.bsky.feed.searchPosts" -H "Authorization: Bearer $TOKEN" \
--data-urlencode 'q=*' --data-urlencode 'tag=osint' --data-urlencode 'tag=verification' \
--data-urlencode 'sort=latest' | jq -r '.posts[].author.handle' | sort | uniq -c | sort -rn
The PLC audit log is the find here: it is an append-only record of every handle the account has
used and every server it has moved between, dated, with the first entry establishing when the
account was created. indexedAt is when the relay saw the post, not when the author wrote it —
record.createdAt is the author’s claim and is trivially spoofable, as is the TID in the record
key, so quote both and say which is which. searchPosts is explicitly documented as possibly
non-public, so treat the public host returning results for it as luck rather than a contract, and
note that since/until filter on the server’s sortAt, which may not match createdAt at all.
Authenticating turns a keyless read into an attributable one.
goat
The reference AT Protocol CLI, from Bluesky themselves. It does the things curl makes awkward:
exporting a whole repository as a single file, decoding record keys, walking the PLC operation log,
and tailing the firehose. For Bluesky work it replaces about ten of the commands above.
brew install goat # or: go install github.com/bluesky-social/goat@latest
# resolve an identity to its DID document, including which PDS holds the data
goat resolve wyden.senate.gov
# every record collection the account holds -- including ones the app never renders
goat ls -c dril.bsky.social
# one record, as the author wrote it
goat get at://dril.bsky.social/app.bsky.feed.post/3kkreaz3amd27
# the entire repository as a CAR file: every record, in one evidential artefact
goat repo export jay.bsky.team
# and the blobs, which is every image the account has ever uploaded
goat blob export jay.bsky.team
# the full identity history: handle changes, PDS moves, key rotations, dated
goat plc history atproto.com
# a record key back to the time the client claims it was written
goat syntax tid inspect 3kzifvcppte22
# live posts matching nothing in particular, for watching an event unfold
goat firehose --ops -c app.bsky.feed.post
# handle changes across the whole network, which is a renaming-detection feed
goat firehose --account-events | jq .payload.handle
# any XRPC query against any host, which is the escape hatch
goat xrpc query https://public.api.bsky.app app.bsky.actor.getProfile actor==atproto.com
goat repo export is the one to reach for when the account might be deleted: a CAR file is the
whole repository at one moment, signed, and it keeps working after the account does not. Note the
== in the xrpc query form — that is how goat distinguishes a query parameter from a header,
and a single = there means something else. Reads need no authentication; goat account login
exists for writes and for the handful of authenticated reads, and logging in attributes everything
after it. A TID decoded by syntax tid inspect reads the clock of whatever generated the record
key, not the network’s, and the TID specification notes that known TIDs can be reused — so it
dates a claim rather than an event.
Meta Ad Library API
The one Meta surface that is genuinely open, and the only reliable way to get spend and reach data for political and issue advertising. Searchable by keyword or page, with the creative, the dates, the platforms and the demographic breakdown.
# access needs a Meta developer app, ID verification and Ad Library API approval first
TOKEN="$META_TOKEN"
G='https://graph.facebook.com/v23.0/ads_archive'
# keyword search; ad_reached_countries is mandatory, and so is search_terms or search_page_ids
curl -s -G "$G" -d "access_token=$TOKEN" \
-d 'search_terms=climate' -d "ad_reached_countries=['GB']" \
-d 'fields=page_name,ad_delivery_start_time,ad_snapshot_url' | jq -r '.data[].page_name' | sort -u
# an exact phrase rather than unordered keywords
curl -s -G "$G" -d "access_token=$TOKEN" \
-d 'search_terms=net zero' -d 'search_type=KEYWORD_EXACT_PHRASE' \
-d "ad_reached_countries=['GB']" -d 'fields=page_name,ad_creative_bodies'
# one page's entire ad history, which is the attribution-relevant query
curl -s -G "$G" -d "access_token=$TOKEN" \
-d 'search_page_ids=123456789' -d "ad_reached_countries=['GB']" \
-d 'ad_active_status=ALL' \
-d 'fields=ad_creative_bodies,ad_delivery_start_time,ad_delivery_stop_time,spend,impressions'
# who paid, which is the field the transparency rules exist for
curl -s -G "$G" -d "access_token=$TOKEN" \
-d 'search_terms=election' -d "ad_reached_countries=['GB']" \
-d 'fields=bylines,beneficiary_payers,page_name,spend,currency,publisher_platforms'
# where it ran and to whom
curl -s -G "$G" -d "access_token=$TOKEN" \
-d 'search_terms=election' -d "ad_reached_countries=['GB']" \
-d 'fields=delivery_by_region,demographic_distribution,languages'
# only the ads that ran as video, on Instagram rather than Facebook
curl -s -G "$G" -d "access_token=$TOKEN" \
-d 'search_terms=election' -d "ad_reached_countries=['GB']" \
-d 'media_type=VIDEO' -d "publisher_platforms=['INSTAGRAM']" \
-d 'fields=page_name,ad_snapshot_url,publisher_platforms'
# the non-political categories, which most people never query
curl -s -G "$G" -d "access_token=$TOKEN" \
-d 'search_terms=apartment' -d "ad_reached_countries=['GB']" \
-d 'ad_type=HOUSING_ADS' -d 'fields=page_name,ad_creative_bodies,ad_delivery_start_time'
# a date window, for tying a campaign to an event
curl -s -G "$G" -d "access_token=$TOKEN" \
-d 'search_terms=referendum' -d "ad_reached_countries=['IE']" \
-d 'ad_delivery_date_min=2026-01-01' -d 'ad_delivery_date_max=2026-03-31' \
-d 'fields=page_name,spend,ad_snapshot_url'
# reach, not impressions: the EU and Brazil figures are per-ad totals
curl -s -G "$G" -d "access_token=$TOKEN" \
-d 'search_page_ids=123456789' -d "ad_reached_countries=['IE']" \
-d 'fields=eu_total_reach,age_country_gender_reach_breakdown,estimated_audience_size'
# page the result set with the cursor Graph hands back
curl -s -G "$G" -d "access_token=$TOKEN" -d 'search_terms=climate' \
-d "ad_reached_countries=['GB']" -d 'fields=page_name' -d 'limit=100' -d 'after=CURSOR'
Leaving out both search_terms and search_page_ids is a parameter error, not an unfiltered
dump. search_terms matches the ad’s own language and is capped at 100 characters, spaces are
treated as AND unless you set search_type=KEYWORD_EXACT_PHRASE, and search_page_ids takes at
most ten IDs. spend and impressions come back as ranges, not numbers — report them as ranges,
and note that eu_total_reach is a single figure while estimated_audience_size is another range.
Several fields and filters — bylines, delivery_by_region, demographic_distribution,
estimated_audience_size_min/_max — exist only for political and issue ads, so an empty result
for a commercial advertiser means the field does not apply rather than that the ad does not exist.
Coverage is political and issue ads in the countries where Meta is required to disclose them, plus
the EMPLOYMENT_ADS, FINANCIAL_PRODUCTS_AND_SERVICES_ADS and HOUSING_ADS categories;
ordinary commercial ads appear in the web Ad Library with far less metadata and are not in the API
the same way.
X / Twitter advanced search
Web only in practice. The API is priced out of reach for research, snscrape has been
non-functional against X since the 2023 access changes, and the public Nitter instances are
largely dead — so the live tool is X’s own advanced search, which still works while logged in.
from:username since:2026-01-01 until:2026-06-30 one account, bounded by date
to:username replies directed at an account
"exact phrase" -filter:retweets the original post rather than its echoes
filter:images filter:links posts carrying media or outbound links
filter:replies only replies, which is where arguments live
url:example-news-daily.com who linked a domain
geocode:51.5074,-0.1278,5km posts placed near a coordinate
min_faves:500 min_retweets:100 min_replies:50 the versions that actually travelled
lang:ru "phrase" one language, for a multilingual campaign
(term1 OR term2) -term3 grouping and negation, as you would expect
list:12345678 "phrase" search inside a list you have built
conversation_id:1234567890123456789 one thread, including replies
from:username filter:media since:2026-03-01 one account's images in one month
Every one of these requires a logged-in session, which attributes the searching to that account —
use a research account, and assume the queries are logged. Date-bounded from: queries are the
most reliable form; free-text search silently drops older results, so an empty result for an old
date range is not evidence of absence. X publishes no machine-readable operator list and its help
pages block automated fetches, so verify any operator against the advanced-search form itself
before relying on it — the form is the only authority, and it has quietly lost operators before.
For deleted posts, go to the Wayback Machine and archive.today first:
for X specifically, archived snapshots are now a more dependable source than the live site. A
status ID still dates itself with no session at all, as shown under X / Twitter
above.
Platform tooling at a glance
| Tool | Platform | Account needed | What it gets you |
|---|---|---|---|
| yt-dlp | 1,000+ sites | No, until the platform insists | Video and channel metadata, subtitles, comments, timed clips |
| gallery-dl | Images and galleries, most platforms | No, until the platform insists | Bulk profile capture with per-item JSON sidecars |
| Instaloader | No for public posts, yes for stories | Posts, captions, comments, geotags, profile pictures | |
| instagram-location-search | Yes, a session cookie | Location IDs near a coordinate, as CSV, JSON, GeoJSON or a map | |
| Telethon | Telegram | Yes, API credentials and a phone number | Message history, forward graph, member lists, creation dates |
| Telepathy | Telegram | Yes, API credentials and a phone number | The same as a CLI, but unmaintained since July 2024 |
| Telegram Phone Number Checker | Telegram | Yes, and it writes to your contacts | Whether a number or username is registered |
| tiktok-hashtag-analysis | TikTok | No | Posts by hashtag, plus a co-occurrence table or plot |
| Arctic Shift | No | Deleted posts and comments, wiki page lists, activity aggregates | |
| goat | Bluesky / AT Protocol | No for reads | Repository and blob exports, PLC history, TID decoding, firehose |
| Meta Ad Library API | Facebook, Instagram | Yes, an approved developer app | Spend and reach ranges, payer bylines, delivery breakdowns |
| X/Twitter Advanced Search | X | Yes, a logged-in session | Date-, geo- and engagement-bounded post search |
Reach for yt-dlp and gallery-dl first on any platform either covers, because they are the only
two on this list maintained at the pace platforms break things. Reach for Arctic Shift and goat
when the question is historical, because both hold data the live platform will not serve. Everything
else here is worth a check against its last commit date before you trust its output.
Tool reference
| Tool | What it does | Cost |
|---|---|---|
| 4plebs | Searchable archive of specific 4chan boards. Makes it possible to read threads after they are purged from 4chan. | free |
| Bellingcat TikTok Date Extract | Get the exact upload date + time for tiktok video urls | free |
| Bellingcat TikTok Hashtag Analysis | Archive content and metadata from TikTok posts that contain one or more specified hashtags | free |
| Blackbird | Check usernames and email addresses on websites and social networks | free |
| BskyFollowFinder | A tool that identifies which Bluesky accounts are followed by a profile’s contacts but not by that profile. Can be used for expanding networks and social… | free |
| Disboard | Search for public discord servers | free |
| Discord Chat Exporter | A tool for exporting and backing up Discord chat logs in multiple formats. | free |
| DiscordLeaks | Search hundreds of thousands of messages leaked from 290+ white-supremacist / nazi discord servers. | free |
| F5Bot | Sends you an email when a keyword is mentioned on Reddit. | free |
| Facebook Video Downloader | Handy website to download public Facebook videos. Copy paste the URL of the video and download it in the available definition formats. | free |
| FindClone | Searches images from VK profiles (within certain limits) | free |
| Ghunt | A command line tool for obtaining information about Google accounts. | free |
| Google Account Finder (EPIEOS) | Find the profile picture and public Google Map Reviews + Photos associated with a G-mail adress. Also checks for phone numbers, and checks for email… | free |
| Gravatar Email Checker | Check if an email address has been used to comment on blogs and whether there is a profile image attached. | free |
| HaveIBeenZuckered | Check if a telephone number is present within the Facebook data breach. | free |
| Hoaxy | Hoaxy is a web-based search and visualization tool. It helps visualize the spread of information on Bluesky and X (Twitter). | partly free |
| Instagram Location Search | A command line tool that allows users to find location tags near a specified latitude and longitude. | free |
| InstaLoader | Download pictures or videos (with metadata) from Instagram. | free |
| Intelligence X Telegram SearchDDD | Google-based search engine for Telegram (includes Telegago) | free |
| LinkdTime | Build a clean timeline of any LinkedIn activity from a single URL or a whole list of links. | free |
| Meta Content Library | Meta Content Library is a controlled-access tool that lets approved academic and non-profit researchers search the full public archive of Facebook… | free |
| MW Geofind | MW Geofind is a tool designed to help users identify the filming location of YouTube videos, facilitating the exploration of global content from a… | free |
| Open Measures | Open Measures helps open source researchers investigate harmful online activity such as extremism and disinformation. | partly free |
| Photo-Map.RU | Geotagged VK posts. | free |
| PSNprofiles | Search PlayStation username, see daily activity, games played, country, and profile pic | free |
| RadiTube | A search engine that searches the subtitles of about 380 (right/left) radical YouTube channels. You query for example for “q says” of “voter fraud” in… | free |
| RedditMetis | RedditMetis is an online tool that analyses any public Reddit profile and returns charts and statistics on the user’s activity, language, sentiment and… | free |
| Sherlock | Allows a user to search for the presence of specific usernames across more than 400 websites and social networks. | free |
| Snap Map | Searchable map of geotagged snaps. | free |
| Social Searcher | Search hashtags and usernames across various platforms. | partly free |
| SteamId.uk | Lookup player names, view (more) previously used names, and when accounts befriended eachother (Free). View screenshots of account, (bulk) seach based on… | partly free |
| Story Saver | Download public Instagram Stories, Highlights and Videos. | free |
| Strava | A fitness tracking platform where publicly shared GPS activity data can reveal movement patterns, routines, and precise locations of individuals… | partly free |
| Telegago | Telegago is a Google Custom Search Engine tailored for searching public Telegram content for OSINT purposes. | free |
| Telegram Group Joiner | Automate joining multiple Telegram groups and channels, ideal for researchers monitoring specific topics. | free |
| Telegram Phone Number Checker | Command line tool for checking if phone numbers are connected to Telegram accounts and retrieving related information where available. | free |
| TelegramDB | TelegramDB is a searchable database service that allows users to explore public Telegram groups and channels via a dedicated bot. | partly free |
| Telemetrio | Telemetr.io offers a range of Telegram-related services based on a catalog of Telegram channels: country and category-specific rankings, curated… | partly free |
| Telemetry | An analytical search tool for Telegram groups and channels. | partly free |
| Telepathy | Telepathy is a versatile Telegram toolkit for OSINT analysts, enabling chat archiving, memberlist gathering, user location lookup, top poster analysis… | free |
| TGStat | TGStat is a web-based analytics tool for Telegram that monitors active channels and provides profile analytics and statistics. It tracks channel… | partly free |
| TikTok Ad Library | Search and review TikTok ads and related metadata for transparency and research. | free |
| TikTok-Api | TikTok-Api is an open-source Python library (unofficial TikTok API) that allows developers and researchers to retrieve various public data from TikTok | free |
| tlgrm.eu channels | Search Telegram channels. | free |
| Twitter Video Downloader | Download videos from X (formerly Twitter) by converting tweet URLs into downloadable video links. | free |
| Twitter/X Location Search | Search for geocoded tweets by their distance from some coordinates. | free |
| Vk.watch | See public comments left by an account, profile photos used, and very basic facial recognition | free |
| Who posted what? | A tool that allows a keyword search on Facebook on a specific date or within a specific time frame. | free |
| X/Twitter Advanced Search | Twitter/X Advanced Search is X’s own tool to help users find more precise information on the platform by filtering posts according to criteria such as… | free |
| XboxGamertag | Search gamertags, see games played and recorded game clips | free |
| YouTube Metadata | An alternative to Amnesty’s YT viewer, with slightly more information. | free |
Pitfalls
- Viewing can notify. LinkedIn tells people who looked. Story views are attributed. Joining a Telegram group adds you to a visible list.
- Tooling rots fast. Anything depending on an undocumented endpoint is one deploy from broken. Check a tool’s commit history before trusting its output.
- Deleted is not gone, and present is not original. Archive first, then analyse.
- Reposts dominate. The account with the most engagement is rarely the origin. Trace back.
- An ID dates the record, not the footage. Decoding a TikTok or Snowflake ID tells you when that upload was created. A 2019 clip uploaded in 2026 has a 2026 ID and the arithmetic is still right.
- A client-written timestamp is a claim. An atproto TID and
record.createdAtare both chosen by whatever wrote the record;indexedAtis the relay’s. Quote the one the account did not control, and say which you are quoting. - Zero results and broken scraper look identical. A busy hashtag returning no posts, or a
--match-filtersexpression naming a field that extractor does not emit, both print nothing. Run one query you already know the answer to before you believe a negative. - A pipeline can match and then discard the match.
grep -o '[0-9]*$'againstdata-post="channel/441"anchors digits to a line that ends in a quote, so it emits a blank line per post and exits 0. The extractor worked, the shell threw the answer away, and the symptom is an empty file. Count the lines at each stage of a new pipeline before trusting the last one. - A date filter may not filter on the date you mean. Bluesky’s
since/untilonsearchPostsare documented against the server’ssortAt, which need not equalrecord.createdAt; Arctic Shift’screated_utcaggregate buckets are cut in a DST-observing local zone. Both return a defensible answer to a question you did not ask. - Unauthenticated does not mean unattributed. Your IP is an identifier, and a sweep from one address is a signature. Conversely, authenticating to get past a block converts an anonymous read into a logged one tied to an account you will lose.
- Datacentre addresses are blocked where browsers are not. Reddit’s
.jsonsurface returns 403 from hosted ranges regardless of User-Agent. Code that works on a laptop and fails in CI has not found a bug; it has found a policy. - Archives ingest, they do not watch. Arctic Shift holds what it captured when it captured it: an edit made afterwards is invisible, and something deleted within seconds may never have been recorded at all.
- Spend and reach are ranges. Meta reports
spendandimpressionsas buckets. Writing a midpoint and calling it a figure invents precision the source does not have. - Counts from a sample are comparative at best. Hashtag collection, search results and “top twenty” lists are samples whose size you do not control. Two runs an hour apart will disagree, which is a property of the method, not of the world.
- The same handle on four platforms is one lead, not one person. It takes content, timing or a payment record to tie accounts together. Say which of the three you have.
Worked example
One datum: the handle @civicwatchnow, read off a screenshot of a post, with no platform attached and no other context.
- Find the platforms. A username sweep — see username and account discovery — returns hits on Bluesky, YouTube, Reddit and a Telegram channel of the same name. Four surfaces, four different amounts of openness.
- Date the accounts, keylessly.
resolveHandlegives the Bluesky DIDdid:plc:z72i7hdynmk6r22z27h6tvur; the PLC audit log’s first entry dates the account to 12 March 2026 and shows one earlier handle. An account presenting itself as an established watchdog, three months old, is the first real finding. - Characterise the output.
yt-dlp --flat-playlist --dump-jsonon the YouTube channel lists 41 videos, all uploaded between 2026-04-02 and 2026-05-14 — six weeks — withupload_dateclustered on weekdays. Volume and cadence, from the platform’s own metadata. - Read what was deleted. Arctic Shift’s
comments/search?author=civicwatchnow&limit=100returns 300 comments across eleven subreddits, of which fourteen no longer exist on Reddit. The removed ones carry the same link, which the live profile does not show. The aggregate endpoint gives the shape of it:
{"data":[{"created_utc":"2026-03-31T22:00:00.000Z","count":"18"},
{"created_utc":"2026-04-30T22:00:00.000Z","count":"197"},
{"created_utc":"2026-05-31T22:00:00.000Z","count":"85"}]}
- Map the amplification. The Telethon forward counter over the Telegram channel’s last 2,000
messages returns a top-twenty list in which three channels account for 1,340 of 1,612
forwards, and the channel’s own
entity.dateis 2026-03-14, two days after the Bluesky DID’s first PLC operation. - Check the gaps.
page.totalreports 1,900 messages against a newest ID of 2,146, so roughly 246 messages — 11 per cent — have been deleted. Which ones is a separate question; that they were is not. - Check for paid distribution. The Meta Ad Library
search_terms=civicwatchwithad_reached_countries=['GB']andad_active_status=ALLreturns two advertisers running the same creative, withbylinesnaming a company andspendas the range £5,000–£9,999. That is a funding lead the organic surfaces never gave. - Trace to origin, then archive. The earliest instance of the clip is the TikTok upload, dated
from the video ID —
7169068344567909638 >> 32= 2022-11-23 04:46:37Z — rather than the display text, which read “2 days ago”. The YouTube and Telegram copies are later. Capture all four profiles,goat repo exportthe Bluesky repository and save the ad snapshots before writing anything — see archiving and evidence.
What you can assert: four accounts under one handle, created within three days of each other in March 2026, pushing a single clip whose earliest known copy is dated on TikTok to November 2022, amplified by three Telegram channels carrying 83 per cent of the forwards, and promoted by two named advertisers with a disclosed spend range. Every date in that sentence came from a platform-side identifier or an archive, not from a rendered page.
What would falsify it: an earlier copy of the clip on a platform nobody swept, which would make the TikTok ID a reupload’s ID rather than the original’s; a Telegram channel that was renamed into the handle rather than created under it, which the creation date alone cannot distinguish; or an advertiser byline that turns out to be a media-buying agency acting for an unrelated client. The measurement carrying the most risk is the Bluesky creation date, because a PLC log’s first operation dates the DID, not the project — an operator who registered the DID months before using it would push that date earlier with no deception at all, so quote it as “first PLC operation on 12 March 2026” rather than as a founding date. What you cannot assert at all is that one person runs all four: a shared handle is a lead until content, timing or a payment record ties them, and here it is the ad byline, not the handle, doing that work.
Broader catalogues
More tools
Further tools for this area from the OSINT Newsletter Tools Library (Facebook, Instagram, Twitter/X, Telegram, YouTube, Reddit, Bluesky, Snapchat, Other/Multiple Platforms), excluding those already listed above.
| Tool | What it does |
|---|---|
| Analyst Research Tools | A browser-based investigative research platform pulling together a collection of free lookup tools to help you gather, organise… |
| Archivarix Tube Search | A free OSINT tool for discovering historical versions of YouTube videos using archived snapshots from archive data. |
| Arctic Shift | A Reddit search and archival platform that enables investigators to search historical Reddit posts, comments, deleted content… |
| Aware Online: Reddit Search | A free OSINT tool that helps investigators search Reddit more effectively by querying posts, comments, usernames, and discussions… |
| AzSky | A Reddit-style interface powered by Bluesky, designed to make exploring decentralised social conversations easier. |
| BirdHunt | Find tweets based on location, helping you uncover what people were saying in a specific place at a specific time. |
| Bluesky Follow Finder | Surfaces accounts followed by people a profile follows, but that the profile doesn’t. |
| BoardReader | A specialised search engine for online forums, message boards and discussion communities. |
| Buzzglobe | Social-media search aggregator for discovering publicly indexed profiles, pages, posts and images across multiple platforms. |
| de-digger | A specialist search and discovery engine for finding publicly accessible files on Google Drive. |
| Deaddrop | Telegram search engine. |
| Discord Leaks | A searchable archive of leaked Discord server, RocketChat and Skype messages, mainly from extremist and politically active… |
| Dolphin Radar | All-in-one Instagram Tracker & Analyser |
| Dumpor | Instagram viewer and profile analysis tool that enables anonymous viewing of public Instagram content without logging in. |
| Export Comments | Extracts, downloads, and analyses comments from social media platforms and video-sharing sites. |
| ExportGram | A web tool that lets you extract and download comments from Instagram posts and reels into spreadsheet formats. |
| Filmot | A search engine that lets you search through YouTube video subtitles and metadata to find videos that contain specific words or… |
| Govsky | Allows users to explore and identify official government and public sector accounts on the Bluesky social media platform. |
| Graph Tips | Generates Facebook search queries that recreate much of the functionality of the former Facebook Graph Search. |
| Hippie OSINT Toolkit | An OSINT web toolkit that allows you to reverse search a domain, a TikTok post, an image, or username (and more). |
| Inflact Instagram Profile Viewer | A no-login Instagram viewer that lets you peek at public profiles, posts, and basic engagement data anonymously. |
| Instagram Map | Allows users within Instagram to view geotagged posts, Stories, and shared live locations (where enabled) on a map inside the… |
| Internect.info | A search interface for the AT Protocol allowing users to locate and query Bluesky handles and associated account information… |
| Nitter | A privacy-focused alternative front-end for X (Twitter) that lets you view tweets without tracking, ads, or needing an account. |
| Sotwe | Tracks Twitter trends and finds the most popular Twitter users, hashtags and places. |
| Sowsearch | A specialist Facebook tool to search posts, profiles, comments, places, events, groups, and public content more efficiently using… |
| Story Saver (Instagram) | Download public Instagram Stories, highlights and videos. |
| Subreddit Stats | A free Reddit analytics tool used to track subreddit growth, activity levels, top posts/users, keyword trends, and community… |
| Subreddits.org | An online directory and search tool that helps users find and explore Reddit communities (subreddits) by keyword, topic, or… |
| Telegago (Telegram) | A custom Google search engine to help users search publicly available content on Telegram channels & groups. |
| Telegram Spoiler Decoder | Instantly recovers hidden spoiler text on MacOS within a Telegram chat. |
| ToolsCord | A free, browser-based toolkit focused on Discord utilities. |
| Treeverse | Visualise and explore conversations on Bluesky as interactive threads and networks. |
| TweeterID | A simple X/Twitter username and ID conversion tool. |
| Twitter Viewer | An online tool that lets you browse Twitter profiles, tweets, and media, and download videos without logging in. |
| Waybien | A search engine for Telegram, Facebook, Discord, and Whatsapp groups. |
| Who Posted What? Facebook Tool | A non-public Facebook keyword search tool that allows you to search keywords on specific dates. |
| YouTube Video Finder | Investigates a YouTube video using its URL or ID, with quick links to archives and analysis tools. |
Sources
Both catalogues below are maintained by other people and are considerably larger than this page. Use them as the canonical index; this sheet is a working route through them.
- Bellingcat’s Online Investigation Toolkit — ~340 tools, each with its own review page covering cost, difficulty, requirements and limitations.
- OSINT Newsletter Tools Library — ~280 tools, organised by investigative goal.
Neither publishes a licence, so nothing here is copied from them: tool names, one-line descriptions, cost flags and links are catalogue facts, and the method and commentary are this site’s own. See credits.