How I used TF-IDF to catch blackmailer
When I was young, my schoolmates always complained like: “Why do we even need math?”. But one day I was blackmailed, and Math helped me decipher the name and personality of the scamp.
TL;DR
An anonymous user from a pro-Russian extremist far-right group threatened me and my family. His account was wiped and masked, but he made one mistake — he kept posting in a public chat. I downloaded millions of messages, analyzed his writing style using TF-IDF and cosine similarity, linked him to his old accounts, and handed the findings to the police. All done in Python, with reproducible code and methodology.
The threat
The criminal was the anonymous user from far-right pro-Russian group, and they started to threaten on the basis of hate:
I got mad that day, but when I made my first attempt to figure out where this person is from and if he actually might be dangerous for my family, I miserably failed. The owner of that account definitely made some efforts to remain anonymous.
I’ve tried some OSINT tools, but in vain, I have never done any investigation of such kind. But the result was not totally empty.
One of the tools, “telegram user stat”, that collecting publicly available data, showed me that this user is very active in the “Apple chat”…
About Telegram IDs
A Telegram username is a public, unique handle we got used to see on Telegram, starting with @ symbol, like @superchromia
Username can be used as part of a link: https://t.me/superchromia
- Is optional — not all users have one
- It can be changed anytime
- One user may have only one username in the moment of time.
- Usernames are unique across the platform
A Telegram user ID (sender_id further) is a unique numeric identifier automatically assigned to every Telegram account upon its creation. It is:
- Permanent (does not change, unlike usernames)
- Numeric (usually 7–10 digits)
- Unique across the entire Telegram platform
The most important thing is Telegram user ID cannot be changed in the lifetime of account. All messages are attributed to Telegram user ID even if account is deleted.
Conversion from usernames to IDs and back are out of scope of this article.
Downloading data source
Apple chat is a discussion group in the Telegram App for the @media_apple news channel. It was completely open, so I came in.
Wow! Thousands, millions of messages, and “Healer, the second” was one of the most active there. But, wait! He came in in March’25 and became as active as he knows everyone. Maybe he had been there before, but using another account? Sounds pretty wild, but I’m already desperate enough to try.
Let’s start. I have the proper tool right in my pocket!
At first, I need to download all the messages.
I’ve used python telethon lib to do this trick. You can figure out how to install and run it according to its docs.
A small script to download them all:
import asyncio
import json
import os
from telethon import TelegramClient, types
from telethon.tl.custom.chatgetter import ChatGetter
chat_id = 1340..3989 # Apple chat ID
api_id = os.environ.get("TELEGRAM_API_ID")
api_hash = os.environ.get("TELEGRAM_API_HASH")
async def main():
client = TelegramClient('superchromia', api_id, api_hash)
async with client:
await client.get_dialogs()
chat = await client.get_entity(chat_id)
with open("apple_chat.json", "w") as f:
async for msg in client.iter_messages(chat):
data = {
"message_id": msg.id,
"sender_id": msg.sender_id,
"message_ts": msg.date.isoformat(),
"message_text": msg.message,
}
f.write(json.dumps(data) + "\n")
asyncio.run(main())Downloading takes some time, so I saved the result here in case if you want to follow my steps and check the result.
Stylometry
The hypothesis was that the previous account had been deleted to remain anonymous, and a new one was created to continue chatting. So, I had no chance to link his accounts directly. I decided to use stylometry, the study of linguistic style.
The idea: every person has a unique “fingerprint” in their writing — choice of words, syntax, favorite phrases, even consistent mistakes.
Every person has their very own style. Active vocabulary, syntax features, frequently used mistakes, filler words. I’ll try to use this knowledge to find similarities between users. However, everyone changes their style over time, so a several years’ gap between messages may blur the result.
Get Mia Grigoreva’s stories in your inbox
Join Medium for free to get updates from this writer.
One of the commonly used methods there is word usage frequency analysis, or TF-IDF. In simple words, we’ll try to find words that the sender uses significantly more or less than others. Score is calculated for every word forming a vector. Similarity between vectors implies similarity between users’ writing style.
Let’s import the data
import json
data = []
with open('apple_chat.json') as f:
for line in f:
data.append(json.loads(line))Counting basic message stats and collecting all the words from messages together:
from collections import Counter
import plotly.express as px
c = Counter(str(message['sender_id']) for message in data)
TARGET_ID = 527...8370
top = c.most_common(20)
x, y = zip(*reversed(top))
fig = px.bar(
data_frame=y,
orientation='h',
color=[('red' if user==str(TARGET_ID) else 'blue') for user in x ],
)
fig.show()Now let’s try the TF-IDF method to create user vectors
import numpy as np
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity
from collections import defaultdict
user_corpus = defaultdict(list)
for message in data:
user_corpus[message['sender_id']].append(message['message_text'])
users = list(user_corpus.keys())
corpus = ['\n'.join(value) for value in user_corpus.values()]
vectorizer = TfidfVectorizer(
analyzer='word',
max_features=1000,
ngram_range=(1 , 2),
)
vectors = vectorizer.fit_transform(corpus)Now I have the “user language image” as a 1000-dimensional vector of words and bigrams. I intentionally don’t drop any kind of auxiliary words to keep the language style as it is.
idx = users.index(TARGET_ID)
target_vector = vectors[idx]
similarity_to_target = cosine_similarity(vectors, target_vector)[:,0]
top_10_idx = np.flip(np.argsort(similarity_to_target))[1:10] # Excluding target itself
top_10_users = [users[idx] for idx in top_10_idx]
print(top_10_users)And the result leads us further:
[
838...069,
615...0333,
744...399,
686...4598,
129...9125,
894...815,
217...832,
930...915,
719...8109,
]The result was striking: several accounts showed extremely high similarity, suggesting they might be the same person — including one that had posted years earlier without any attempt at anonymity.
6151…333 and 6868…598 were also their accounts, but 744…399 is definitely false positive.
Oh… sweet summer child….
All his personal data, will be provided to the police soon, and in order not to disclose their real name I’ll call him Drochislav here and after.
Interpretation and feature importance
But what makes their style of writing so recognizable? I want to interpret the results! What makes Drochislav so Drochislav-ish?
This paert is a bit trickier. I can’t just take maximums from the vector and find what factors in his vector are maximized, just because I want to know not only words he likes to use, but also what distinguishes him from others.
Let’s calculate margin — the distance in cosine similarity from “winners” (Drochislav has multiple accounts here) from “losers” (other people in the chat).
“Losers” here means they were not classified as Drochislav, though. Just a term from computer science, in reality it should be vice versa.
Margin here is how far all accounts of Drochislav are from others. I will use the leave-one-out (LOO) technique to find out what features are most important in this margin.
I will use softmax (LSE) version of margin to put multiple users in equation:
def lse_max(values: np.ndarray, tau: float) -> float:
v = np.asarray(values, dtype=float)
return float(tau * np.logaddexp.reduce(v / tau))
def margin_soft(similarity, winners, losers, tau=0.1):
return float(lse_max(similarity[winners], tau) - lse_max(similarity[losers], tau))Then I will remove features one by one to calculate their contribution to the margin:
scores = np.zeros(1000, dtype=float)
for i in range(len(scores)):
target_vector_loo = target_vector.copy()
target_vector_loo[0, i] = 0.0
similarity_to_target_loo = cosine_similarity(vectors, target_vector_loo)
loo_margin = margin_soft(similarity_to_target_loo, winners_idx, losers_idx)
scores[i] = base_margin - loo_marginAnd let’s see what do I have in the top:
top_k = 200
terms = vectorizer.get_feature_names_out()
order_pos = np.argsort(-scores)[:top_k]
print(terms[order_pos])['не' 'меня' 'да' 'это' 'на' 'но' 'что' 'кстати' 'айфон' 'так' 'то' 'всё'
'он' 'есть' 'же' 'про' 'самсунг' 'мне' 'как' 'уже' 'ты' 'ещё' 'его' 'мой'
'эпл' 'тоже' 'даже' 'только' 'если' 'нет' 'по' 'макс' 'сяоми' 'хочу'
'там' 'ща' 'вообще' 'лучше' 'тебя' 'бля' 'про макс' 'очень' 'щас' 'надо'
'был' 'буду' 'раз' 'будет' 'какой' 'евро' 'почему' 'теперь' 'из' 'бы'
'реально' 'такой' 'их' 'куплю' 'что бы' 'от' 'один' 'телефон' 'когда'
'два' 'знаю' 'пока' 'тут' 'этот' 'айфона' 'это не' 'сам' 'могу' 'блять'
'тебе' 'него' 'понял' 'больше' 'хз' 'все' 'ничего' 'видел' 'где'
'меня есть' 'вроде' 'было' 'потому' 'чем' 'говно' 'андроид' 'до' 'но не'
'ультра' 'айфоне' 'почти' 'люблю' 'на айфоне' 'ладно' 'бери' 'нравится'
'даже не' 'пиздец' 'опять' 'правда' 'потому что' 'камера' 'меньше' 'мало'
'нас' 'именно' 'пор' 'сих пор' 'до сих' 'сих' 'мы' 'имба' 'потом' 'точно'
'никогда' 'купил' 'можно' 'лс' 'наверное' '14' 'много' 'просто' 'хоть'
'новый' 'тг' 'купить' 'хуета' 'её' 'скоро' 'одна' 'купи' 'намного' 'тот'
'без' 'самое' 'хуже' 'сейчас' 'что ли' 'который' 'телефоны' 'них' 'всего'
'ага' 'три' 'сегодня' 'во' '15' 'не буду' 'последний' 'да не' 'всегда'
'моему' 'какие' 'факт' 'чуть' 'ли' 'немного' 'самый' 'нихуя' 'сука'
'моего' 'не так' 'чехол' 'сделал' 'айфоном' 'сколько' 'выглядит' 'того'
'сказал' 'сказать' 'видео' 'он не' 'хотел' 'кроме' 'год' 'как раз'
'тогда' 'понимаю' 'ради' 'такое' 'обычно' 'всех' 'дизайн' 'оно' 'зачем'
'хуй' 'хуйня' 'но это' 'первый' 'этом' 'бан' 'чат' 'скорее' 'этого'
'так это' '10' 'давай']This produced a list of words and phrases that most contributed to the match. They weren’t exotic — many were common Russian tech slang like “айфон”, “про макс”, “самсунг” — but the unique combination and frequency was enough to identify a pattern.
Results and limitations
At the end, I linked my target’s current account to multiple older accounts, one of which had enough personal information to identify them in the real world.
All personal identifiers in this article are replaced with placeholders. In the real investigation, I passed the findings to law enforcement without publishing private data.
Stylometry is a powerful OSINT tool — but it must be used responsibly. Misinterpretation can lead to false accusations, so verification is essential.
⚠ Important: TF-IDF stylometry is probabilistic, not proof. It suggests likely matches but must be confirmed with additional evidence. It was done in this case.





