OSINT^:OSINT

Social Media — Per-Platform Research

What each major platform still exposes to an unauthenticated researcher, and the tools that work per platform.

intermediateupdated 2026-10-04yt-dlp · gallery-dl · instaloader · telethon

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

  1. 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.
  2. Archive first, analyse second. Capture the post, the profile and the surrounding thread before you start pulling threads. Accounts get deleted mid-investigation, routinely.
  3. 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.
  4. Pivot on identifiers, not display names. Numeric IDs, DIDs and channel IDs survive renames and vanity-URL changes; handles and names do not.
  5. 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.
  6. 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.
  7. 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.

Facebook

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.

Instagram

  • Profile pictures, bios and post counts are visible without login; the feed usually is not.
  • ?__a=1 style 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.

Reddit

  • Profiles, posts and comment trees are readable without an account, and appending .json to 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.

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.

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.

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

ToolPlatformAccount neededWhat it gets you
yt-dlp1,000+ sitesNo, until the platform insistsVideo and channel metadata, subtitles, comments, timed clips
gallery-dlImages and galleries, most platformsNo, until the platform insistsBulk profile capture with per-item JSON sidecars
InstaloaderInstagramNo for public posts, yes for storiesPosts, captions, comments, geotags, profile pictures
instagram-location-searchInstagramYes, a session cookieLocation IDs near a coordinate, as CSV, JSON, GeoJSON or a map
TelethonTelegramYes, API credentials and a phone numberMessage history, forward graph, member lists, creation dates
TelepathyTelegramYes, API credentials and a phone numberThe same as a CLI, but unmaintained since July 2024
Telegram Phone Number CheckerTelegramYes, and it writes to your contactsWhether a number or username is registered
tiktok-hashtag-analysisTikTokNoPosts by hashtag, plus a co-occurrence table or plot
Arctic ShiftRedditNoDeleted posts and comments, wiki page lists, activity aggregates
goatBluesky / AT ProtocolNo for readsRepository and blob exports, PLC history, TID decoding, firehose
Meta Ad Library APIFacebook, InstagramYes, an approved developer appSpend and reach ranges, payer bylines, delivery breakdowns
X/Twitter Advanced SearchXYes, a logged-in sessionDate-, 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

ToolWhat it doesCost
4plebsSearchable archive of specific 4chan boards. Makes it possible to read threads after they are purged from 4chan.free
Bellingcat TikTok Date ExtractGet the exact upload date + time for tiktok video urlsfree
Bellingcat TikTok Hashtag AnalysisArchive content and metadata from TikTok posts that contain one or more specified hashtagsfree
BlackbirdCheck usernames and email addresses on websites and social networksfree
BskyFollowFinderA 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
DisboardSearch for public discord serversfree
Discord Chat ExporterA tool for exporting and backing up Discord chat logs in multiple formats.free
DiscordLeaksSearch hundreds of thousands of messages leaked from 290+ white-supremacist / nazi discord servers.free
F5BotSends you an email when a keyword is mentioned on Reddit.free
Facebook Video DownloaderHandy website to download public Facebook videos. Copy paste the URL of the video and download it in the available definition formats.free
FindCloneSearches images from VK profiles (within certain limits)free
GhuntA 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 CheckerCheck if an email address has been used to comment on blogs and whether there is a profile image attached.free
HaveIBeenZuckeredCheck if a telephone number is present within the Facebook data breach.free
HoaxyHoaxy is a web-based search and visualization tool. It helps visualize the spread of information on Bluesky and X (Twitter).partly free
Instagram Location SearchA command line tool that allows users to find location tags near a specified latitude and longitude.free
InstaLoaderDownload pictures or videos (with metadata) from Instagram.free
Intelligence X Telegram SearchDDDGoogle-based search engine for Telegram (includes Telegago)free
LinkdTimeBuild a clean timeline of any LinkedIn activity from a single URL or a whole list of links.free
Meta Content LibraryMeta Content Library is a controlled-access tool that lets approved academic and non-profit researchers search the full public archive of Facebook…free
MW GeofindMW 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 MeasuresOpen Measures helps open source researchers investigate harmful online activity such as extremism and disinformation.partly free
Photo-Map.RUGeotagged VK posts.free
PSNprofilesSearch PlayStation username, see daily activity, games played, country, and profile picfree
RadiTubeA 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
RedditMetisRedditMetis is an online tool that analyses any public Reddit profile and returns charts and statistics on the user’s activity, language, sentiment and…free
SherlockAllows a user to search for the presence of specific usernames across more than 400 websites and social networks.free
Snap MapSearchable map of geotagged snaps.free
Social SearcherSearch hashtags and usernames across various platforms.partly free
SteamId.ukLookup player names, view (more) previously used names, and when accounts befriended eachother (Free). View screenshots of account, (bulk) seach based on…partly free
Story SaverDownload public Instagram Stories, Highlights and Videos.free
StravaA fitness tracking platform where publicly shared GPS activity data can reveal movement patterns, routines, and precise locations of individuals…partly free
TelegagoTelegago is a Google Custom Search Engine tailored for searching public Telegram content for OSINT purposes.free
Telegram Group JoinerAutomate joining multiple Telegram groups and channels, ideal for researchers monitoring specific topics.free
Telegram Phone Number CheckerCommand line tool for checking if phone numbers are connected to Telegram accounts and retrieving related information where available.free
TelegramDBTelegramDB is a searchable database service that allows users to explore public Telegram groups and channels via a dedicated bot.partly free
TelemetrioTelemetr.io offers a range of Telegram-related services based on a catalog of Telegram channels: country and category-specific rankings, curated…partly free
TelemetryAn analytical search tool for Telegram groups and channels.partly free
TelepathyTelepathy is a versatile Telegram toolkit for OSINT analysts, enabling chat archiving, memberlist gathering, user location lookup, top poster analysis…free
TGStatTGStat 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 LibrarySearch and review TikTok ads and related metadata for transparency and research.free
TikTok-ApiTikTok-Api is an open-source Python library (unofficial TikTok API) that allows developers and researchers to retrieve various public data from TikTokfree
tlgrm.eu channelsSearch Telegram channels.free
Twitter Video DownloaderDownload videos from X (formerly Twitter) by converting tweet URLs into downloadable video links.free
Twitter/X Location SearchSearch for geocoded tweets by their distance from some coordinates.free
Vk.watchSee public comments left by an account, profile photos used, and very basic facial recognitionfree
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 SearchTwitter/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
XboxGamertagSearch gamertags, see games played and recorded game clipsfree
YouTube MetadataAn 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.createdAt are both chosen by whatever wrote the record; indexedAt is 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-filters expression 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]*$' against data-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/until on searchPosts are documented against the server’s sortAt, which need not equal record.createdAt; Arctic Shift’s created_utc aggregate 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 .json surface 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 spend and impressions as 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.

  1. 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.
  2. Date the accounts, keylessly. resolveHandle gives the Bluesky DID did: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.
  3. Characterise the output. yt-dlp --flat-playlist --dump-json on the YouTube channel lists 41 videos, all uploaded between 2026-04-02 and 2026-05-14 — six weeks — with upload_date clustered on weekdays. Volume and cadence, from the platform’s own metadata.
  4. Read what was deleted. Arctic Shift’s comments/search?author=civicwatchnow&limit=100 returns 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"}]}
  1. 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.date is 2026-03-14, two days after the Bluesky DID’s first PLC operation.
  2. Check the gaps. page.total reports 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.
  3. Check for paid distribution. The Meta Ad Library search_terms=civicwatch with ad_reached_countries=['GB'] and ad_active_status=ALL returns two advertisers running the same creative, with bylines naming a company and spend as the range £5,000–£9,999. That is a funding lead the organic surfaces never gave.
  4. 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 export the 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.

ToolWhat it does
Analyst Research ToolsA browser-based investigative research platform pulling together a collection of free lookup tools to help you gather, organise…
Archivarix Tube SearchA free OSINT tool for discovering historical versions of YouTube videos using archived snapshots from archive data.
Arctic ShiftA Reddit search and archival platform that enables investigators to search historical Reddit posts, comments, deleted content…
Aware Online: Reddit SearchA free OSINT tool that helps investigators search Reddit more effectively by querying posts, comments, usernames, and discussions…
AzSkyA Reddit-style interface powered by Bluesky, designed to make exploring decentralised social conversations easier.
BirdHuntFind tweets based on location, helping you uncover what people were saying in a specific place at a specific time.
Bluesky Follow FinderSurfaces accounts followed by people a profile follows, but that the profile doesn’t.
BoardReaderA specialised search engine for online forums, message boards and discussion communities.
BuzzglobeSocial-media search aggregator for discovering publicly indexed profiles, pages, posts and images across multiple platforms.
de-diggerA specialist search and discovery engine for finding publicly accessible files on Google Drive.
DeaddropTelegram search engine.
Discord LeaksA searchable archive of leaked Discord server, RocketChat and Skype messages, mainly from extremist and politically active…
Dolphin RadarAll-in-one Instagram Tracker & Analyser
DumporInstagram viewer and profile analysis tool that enables anonymous viewing of public Instagram content without logging in.
Export CommentsExtracts, downloads, and analyses comments from social media platforms and video-sharing sites.
ExportGramA web tool that lets you extract and download comments from Instagram posts and reels into spreadsheet formats.
FilmotA search engine that lets you search through YouTube video subtitles and metadata to find videos that contain specific words or…
GovskyAllows users to explore and identify official government and public sector accounts on the Bluesky social media platform.
Graph TipsGenerates Facebook search queries that recreate much of the functionality of the former Facebook Graph Search.
Hippie OSINT ToolkitAn OSINT web toolkit that allows you to reverse search a domain, a TikTok post, an image, or username (and more).
Inflact Instagram Profile ViewerA no-login Instagram viewer that lets you peek at public profiles, posts, and basic engagement data anonymously.
Instagram MapAllows users within Instagram to view geotagged posts, Stories, and shared live locations (where enabled) on a map inside the…
Internect.infoA search interface for the AT Protocol allowing users to locate and query Bluesky handles and associated account information…
NitterA privacy-focused alternative front-end for X (Twitter) that lets you view tweets without tracking, ads, or needing an account.
SotweTracks Twitter trends and finds the most popular Twitter users, hashtags and places.
SowsearchA 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 StatsA free Reddit analytics tool used to track subreddit growth, activity levels, top posts/users, keyword trends, and community…
Subreddits.orgAn 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 DecoderInstantly recovers hidden spoiler text on MacOS within a Telegram chat.
ToolsCordA free, browser-based toolkit focused on Discord utilities.
TreeverseVisualise and explore conversations on Bluesky as interactive threads and networks.
TweeterIDA simple X/Twitter username and ID conversion tool.
Twitter ViewerAn online tool that lets you browse Twitter profiles, tweets, and media, and download videos without logging in.
WaybienA search engine for Telegram, Facebook, Discord, and Whatsapp groups.
Who Posted What? Facebook ToolA non-public Facebook keyword search tool that allows you to search keywords on specific dates.
YouTube Video FinderInvestigates 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.

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.

On this page

☧ CPTS · ordered posts

Locked

Unlock with your Main_Key through local GPG.

Start the local GPG helper

In your cheatsheet checkout, run:

npm run unlock:helper

Open the link it prints, or paste its pairing code below. Keep the terminal open until GPG finishes.

Import an index you already decrypted

Download the encrypted index, then decrypt with GPG:

gpg --output report-index.json --decrypt report-index.gpg

Your private key stays in GPG. The decrypted index is saved in this browser so you can reopen it after navigation, refreshes and restarts. Lock this browser or clear this site's browser data to remove the saved index. Nothing is uploaded.