Araştırma: Reddit, HackerNews, Twitter (X) açık kaynak algoritmalarından derlendi.
Hazırlayan: Prototürk (prototurk.com) Tayfun Erbilen için Hazırlayan: Recep Şen
İki temel saldırı vektörü:
| Saldırı | Örnek | Tehlike |
|---|---|---|
| Duplicate Post Spam | Aynı içerik 50 kez paylaşılıyor | Trend manipülasyonu |
| Comment Ring Bombing | 20 sahte hesap aynı posta yorum/beğeni yağdırıyor | Yapay trend |
Açık kaynak Arc kodu: Score = (P - 1)^0.8 / (T + 2)^G
| Değişken | Değer | Açıklama |
|---|---|---|
P |
int | Post aldığı toplam upvote sayısı |
P - 1 |
int | Kendi oyunu iptal eder |
T |
float | Postun yaşı (saat cinsinden) |
T + 2 |
float | Sıfıra bölmeyi önler, yeni postları korur |
G |
1.8 | Gravity sabiti — çürüme hızı |
Gravity Kılavuzu:
G = 1.2→ Post uzun süre görünür kalır (slow forum)G = 1.8→ HN'in seçimi — 24 saatte skor %10'a düşerG = 2.5→ Hızlı akan feed (Twitter tarzı)
function hnScore(votes: number, ageHours: number, gravity = 1.8): number {
const adjustedVotes = Math.max(votes - 1, 0);
return Math.pow(adjustedVotes, 0.8) / Math.pow(ageHours + 2, gravity);
}Hot = sign(s) × log10(max(|s|, 1)) + (unix_time - 1134028003) / 45000
s = upvotes - downvotes45000 saniye = 12.5 saat→ Her 12.5 saatlik periyot, 1 oy büyüklüğüne denk- Logaritmik sıkıştırma: 1→10 oy, 10→100 oy ile aynı ağırlığa sahip
function redditHot(ups: number, downs: number, date: Date): number {
const REDDIT_EPOCH = 1134028003; // Dec 8, 2005
const s = ups - downs;
const order = Math.log10(Math.max(Math.abs(s), 1));
const sign = s > 0 ? 1 : s < 0 ? -1 : 0;
const seconds = date.getTime() / 1000 - REDDIT_EPOCH;
return Math.round((sign * order + seconds / 45000) * 1e7) / 1e7;
}Kritik özellik: Log compression doğal olarak spam'e karşı direnç sağlar. 10 sahte oy almak 1→10 arası fark yaratır. 10→100 aynı farkı yaratmak için 90 ek oy lazım.
score = (p̂ + z²/2n - z√(p̂(1-p̂)/n + z²/4n²)) / (1 + z²/n)
p̂ = upvotes / (upvotes + downvotes)n = total_votes,z = 1.96(95% güven aralığı)
function wilsonScore(ups: number, downs: number, z = 1.96): number {
const n = ups + downs;
if (n === 0) return 0;
const pHat = ups / n;
const z2 = z * z;
const left = pHat + z2 / (2 * n);
const right = z * Math.sqrt((pHat * (1 - pHat)) / n + z2 / (4 * n * n));
const bottom = 1 + z2 / n;
return (left - right) / bottom;
}
// Kullanım: yorumları en kaliteliden sırala
function sortComments<T extends { upvotes: number; downvotes: number }>(
comments: T[]
): T[] {
return [...comments].sort(
(a, b) =>
wilsonScore(b.upvotes, b.downvotes) - wilsonScore(a.upvotes, a.downvotes)
);
}Twitter'ın approximate production ağırlıkları (community reverse-engineering):
| Engagement | Ağırlık |
|---|---|
| Like | 0.5 |
| Retweet / Share | 1.0 |
| Reply | 27.0 |
| Yazar reply'e cevap verdi | 75.0 |
| Profil tıklandı + engage edildi | 11.0 |
| Negatif feedback | negatif |
| Rapor edildi | negatif |
interface PostEngagement {
likes: number;
shares: number;
comments: number;
authorReplies: number; // yazarın kendi postundaki yorumlara verdiği cevaplar
bookmarks: number;
reports: number;
hides: number;
}
function twitterStyleWeightedScore(engagement: PostEngagement): number {
return (
engagement.likes * 0.5 +
engagement.shares * 1.0 +
engagement.comments * 5.0 + // forum için comment daha değerli
engagement.authorReplies * 15.0 + // yazar engage olunca engagement yüksek
engagement.bookmarks * 2.0 -
engagement.reports * 20.0 - // rapor büyük ceza
engagement.hides * 5.0
);
}POST GELİYOR
│
▼
[KATMAN 1: RATE LIMITER] ── aynı IP/kullanıcıdan çok hızlı mı?
│ geçtiyse
▼
[KATMAN 2: DUPLICATE DETECTOR] ── bu içerik daha önce paylaşıldı mı?
│ geçtiyse
▼
[KATMAN 3: BEHAVIORAL CHECK] ── hesap yeni/şüpheli mi?
│ geçtiyse
▼
[KATMAN 4: SPAM CLASSIFIER] ── içerik spam pattern'ı var mı?
│ geçtiyse
▼
POST KABUL EDİLDİ (spam_score ile birlikte)
import { createClient } from "redis";
const redis = createClient();
interface RateLimitResult {
allowed: boolean;
reason?: string;
}
async function canPost(
userId: string,
limit = 3,
windowSeconds = 300
): Promise<RateLimitResult> {
const now = Date.now() / 1000;
const key = `post_rate:${userId}`;
await redis.zRemRangeByScore(key, 0, now - windowSeconds);
const count = await redis.zCard(key);
if (count >= limit) {
return {
allowed: false,
reason: `Rate limit: ${windowSeconds / 60} dakikada max ${limit} post`,
};
}
await redis.zAdd(key, { score: now, value: String(now) });
await redis.expire(key, windowSeconds);
return { allowed: true };
}
async function canComment(
userId: string,
postId: string,
limit = 10,
windowSeconds = 60
): Promise<boolean> {
const now = Date.now() / 1000;
const key = `comment_rate:${userId}:${postId}`;
await redis.zRemRangeByScore(key, 0, now - windowSeconds);
const count = await redis.zCard(key);
if (count >= limit) return false;
await redis.zAdd(key, { score: now, value: String(now) });
await redis.expire(key, windowSeconds);
return true;
}Tam kopya tespiti (SHA-256 hash):
import crypto from "crypto";
async function isExactDuplicate(
userId: string,
content: string,
windowSeconds = 86400 // 24 saat
): Promise<boolean> {
const normalized = content.trim().toLowerCase();
const hash = crypto
.createHash("sha256")
.update(normalized)
.digest("hex");
const key = `content_hash:${userId}:${hash}`;
const exists = await redis.exists(key);
if (exists) return true;
await redis.setEx(key, windowSeconds, "1");
return false;
}Yakın kopya tespiti (Jaccard Similarity):
function getShingles(text: string, n = 3): Set<string> {
const normalized = text.toLowerCase().trim();
const shingles = new Set<string>();
for (let i = 0; i <= normalized.length - n; i++) {
shingles.add(normalized.slice(i, i + n));
}
return shingles;
}
function jaccardSimilarity(textA: string, textB: string, n = 3): number {
const a = getShingles(textA, n);
const b = getShingles(textB, n);
const intersection = new Set([...a].filter((x) => b.has(x)));
const union = new Set([...a, ...b]);
return union.size === 0 ? 0 : intersection.size / union.size;
}
function isNearDuplicate(
newContent: string,
recentPosts: Array<{ id: string; content: string }>,
threshold = 0.75
): { isDuplicate: boolean; matchedPostId?: string } {
for (const post of recentPosts) {
if (jaccardSimilarity(newContent, post.content) >= threshold) {
return { isDuplicate: true, matchedPostId: post.id };
}
}
return { isDuplicate: false };
}interface User {
createdAt: Date;
spamFlagCount: number;
approvedPosts: number;
isEmailVerified: boolean;
}
function accountTrustScore(user: User): number {
const now = new Date();
const ageDays =
(now.getTime() - user.createdAt.getTime()) / (1000 * 60 * 60 * 24);
// Yaş faktörü: 30 günde ~0.63, 90 günde ~0.95
const ageFactor = 1 - Math.exp(-ageDays / 30);
// Spam cezası: Her flag %40 azaltır
const spamFactor = Math.exp(-user.spamFlagCount * 0.5);
// İyi içerik bonusu: Logaritmik
const goodPostsFactor = Math.min(Math.log1p(user.approvedPosts) / 10, 1.0);
// Email doğrulama bonusu
const emailBonus = user.isEmailVerified ? 1.0 : 0.7;
const trust =
ageFactor * spamFactor * (0.5 + 0.5 * goodPostsFactor) * emailBonus;
return Math.max(0.01, Math.min(1.0, trust));
}
interface Vote {
userId: string;
value: 1 | -1;
user: User;
}
function weightedVoteScore(votes: Vote[]): number {
return votes.reduce((total, vote) => {
const trust = accountTrustScore(vote.user);
return total + vote.value * trust;
}, 0);
}interface Action {
userId: string;
postId: string;
timestamp: number; // unix ms
}
interface CoActivityEdge {
users: [string, string];
weight: number;
}
function detectVoteRing(
actions: Action[],
timeWindowMs = 60_000, // 1 dakika
minEdgeWeight = 5 // 5 ortak aksiyon = ring
): { suspiciousUsers: Set<string>; edges: CoActivityEdge[] } {
// Post bazında aksiyonları grupla
const byPost = new Map<string, Array<{ userId: string; timestamp: number }>>();
for (const action of actions) {
if (!byPost.has(action.postId)) byPost.set(action.postId, []);
byPost.get(action.postId)!.push({
userId: action.userId,
timestamp: action.timestamp,
});
}
// Co-activity graph oluştur
const coActivity = new Map<string, number>();
for (const [, userTimes] of byPost) {
userTimes.sort((a, b) => a.timestamp - b.timestamp);
for (let i = 0; i < userTimes.length; i++) {
for (let j = i + 1; j < userTimes.length; j++) {
const { userId: u1, timestamp: t1 } = userTimes[i];
const { userId: u2, timestamp: t2 } = userTimes[j];
if (Math.abs(t2 - t1) <= timeWindowMs) {
const edgeKey = [u1, u2].sort().join("|");
coActivity.set(edgeKey, (coActivity.get(edgeKey) ?? 0) + 1);
}
}
}
}
// Eşik üzerindeki kenarları bul
const suspiciousEdges: CoActivityEdge[] = [];
const suspiciousUsers = new Set<string>();
for (const [edgeKey, weight] of coActivity) {
if (weight >= minEdgeWeight) {
const [u1, u2] = edgeKey.split("|") as [string, string];
suspiciousEdges.push({ users: [u1, u2], weight });
suspiciousUsers.add(u1);
suspiciousUsers.add(u2);
}
}
return { suspiciousUsers, edges: suspiciousEdges };
}interface PostSpamSignals {
voteIpConcentration: number; // 0-1: oyların ne kadarı aynı subnet'ten
voteVelocityZscore: number; // istatistiksel anormallik skoru
newAccountRatio: number; // oy verenlerin ne kadarı <7 gün hesap
isDuplicate: boolean;
reportCount: number;
}
function calculateSpamPenalty(signals: PostSpamSignals): number {
let penalty = 1.0;
// IP konsantrasyonu: Oyların %70'i aynı /24 subnet'ten
if (signals.voteIpConcentration > 0.7) {
penalty *= 0.2;
}
// Hız anomalisi: Z-score > 3 = istatistiksel outlier
if (signals.voteVelocityZscore > 3.0) {
const reduction = Math.max(0.1, 1 - (signals.voteVelocityZscore - 3) * 0.1);
penalty *= reduction;
}
// Yeni hesap oranı: %60'tan fazla yeni hesap
if (signals.newAccountRatio > 0.6) {
penalty *= 0.3;
}
// Duplicate içerik
if (signals.isDuplicate) {
penalty *= 0.1;
}
// Rapor sayısı
if (signals.reportCount > 5) {
penalty *= Math.max(0.05, 1 - signals.reportCount * 0.1);
}
return Math.max(0.0, Math.min(1.0, penalty));
}interface PostMetrics {
weightedVoteScore: number; // weightedVoteScore() sonucu
commentCount: number;
uniqueViewers: number;
bookmarkCount: number;
reportCount: number;
hideCount: number;
createdAt: Date;
spamSignals: PostSpamSignals;
}
function prototurkTrendScore(
metrics: PostMetrics,
gravity = 1.8
): number {
const now = new Date();
const ageHours =
(now.getTime() - metrics.createdAt.getTime()) / (1000 * 60 * 60);
// 1. Ham engagement skoru
const rawScore =
metrics.weightedVoteScore * 1.0 +
metrics.commentCount * 3.0 +
metrics.uniqueViewers * 0.1 +
metrics.bookmarkCount * 2.0 -
metrics.reportCount * 15.0 -
metrics.hideCount * 3.0;
// 2. Spam cezası uygula
const spamPenalty = calculateSpamPenalty(metrics.spamSignals);
const adjustedScore = Math.max(rawScore * spamPenalty, 0);
// 3. HN Gravity ile zaman çürümesi
return Math.pow(adjustedScore, 0.8) / Math.pow(ageHours + 2, gravity);
}function calculateVelocityZscore(
voteCounts: number[] // son N saatteki oylama sayıları (saatlik dilimlerde)
): number {
if (voteCounts.length < 2) return 0;
const mean = voteCounts.reduce((a, b) => a + b, 0) / voteCounts.length;
const variance =
voteCounts.reduce((sum, v) => sum + Math.pow(v - mean, 2), 0) /
voteCounts.length;
const stddev = Math.sqrt(variance);
if (stddev === 0) return 0;
const current = voteCounts[voteCounts.length - 1];
return (current - mean) / stddev;
// z > 3.0 = şüpheli burst
}Reddit'in yaklaşımı — ban yerine sessizce etkisiz kıl:
interface ShadowbanOptions {
userId: string;
reason: string;
db: DatabaseClient; // kendi ORM/client'ın
}
async function applyShadowban({
userId,
reason,
db,
}: ShadowbanOptions): Promise<void> {
/*
* Kullanıcı normal görünür ama:
* - Trend skoruna katkı yapmaz
* - Diğer kullanıcılara gösterilmez
* - Kullanıcı kendisi görür (direni azaltır)
*/
await db.users.update({
where: { id: userId },
data: {
isShadowbanned: true,
shadowbanReason: reason,
shadowbannedAt: new Date(),
},
});
// Geçmiş vote ağırlıklarını sıfırla
await db.votes.updateMany({
where: { userId },
data: { weight: 0.0 },
});
}
// İçerik sorgularında shadowban filtresi
function buildPostQuery(viewerUserId: string) {
return {
where: {
OR: [
{ author: { isShadowbanned: false } },
{ authorId: viewerUserId }, // kendi postlarını görebilir
],
},
};
}model Post {
id String @id @default(cuid())
content String
authorId String
author User @relation(fields: [authorId], references: [id])
createdAt DateTime @default(now())
// Trend metrikleri
trendScore Float @default(0)
spamPenalty Float @default(1.0) // 1.0 = temiz
weightedVoteScore Float @default(0)
// Spam sinyalleri
voteIpConcentration Float @default(0)
voteVelocityZscore Float @default(0)
newAccountVoteRatio Float @default(0)
isDuplicate Boolean @default(false)
votes Vote[]
comments Comment[]
@@index([trendScore(sort: Desc)])
}
model User {
id String @id @default(cuid())
createdAt DateTime @default(now())
// Güven sistemi
trustScore Float @default(0.3)
spamFlagCount Int @default(0)
approvedPosts Int @default(0)
isShadowbanned Boolean @default(false)
shadowbanReason String?
isEmailVerified Boolean @default(false)
posts Post[]
votes Vote[]
}
model Vote {
id String @id @default(cuid())
postId String
post Post @relation(fields: [postId], references: [id])
userId String
user User @relation(fields: [userId], references: [id])
value Int // +1 veya -1
weight Float @default(1.0) // accountTrustScore sonucu
voterTrustAtTime Float @default(1.0)
voterIp String?
createdAt DateTime @default(now())
@@unique([postId, userId])
}// Her 5 dakikada bir çalıştır (cron: "*/5 * * * *")
async function refreshTrendingScores(db: DatabaseClient): Promise<void> {
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
const activePosts = await db.posts.findMany({
where: { createdAt: { gte: sevenDaysAgo } },
include: {
votes: { include: { user: true } },
_count: { select: { comments: true } },
},
});
const updates = activePosts.map((post) => {
const ageHours =
(Date.now() - post.createdAt.getTime()) / (1000 * 60 * 60);
const voteScore = weightedVoteScore(
post.votes.map((v) => ({ ...v, value: v.value as 1 | -1 }))
);
const spamSignals: PostSpamSignals = {
voteIpConcentration: post.voteIpConcentration,
voteVelocityZscore: post.voteVelocityZscore,
newAccountRatio: post.newAccountVoteRatio,
isDuplicate: post.isDuplicate,
reportCount: 0, // ayrıca say
};
const trendScore = prototurkTrendScore(
{
weightedVoteScore: voteScore,
commentCount: post._count.comments,
uniqueViewers: 0,
bookmarkCount: 0,
reportCount: 0,
hideCount: 0,
createdAt: post.createdAt,
spamSignals,
},
1.8 // gravity
);
return db.posts.update({
where: { id: post.id },
data: { trendScore, weightedVoteScore: voteScore },
});
});
await Promise.all(updates);
console.log(`[TREND] ${activePosts.length} post güncellendi`);
}1. [ACİL] Rate limiter → Redis sliding window
2. [ACİL] Exact duplicate → SHA-256 hash kontrolü
3. [ORTA] Account trust score → Hesap yaşı + flag sayısına göre
4. [ORTA] HN Gravity formula → Mevcut sıralamanın yerini alır
5. [UZUN VADELİ] Ring voting tespiti → Co-activity graph analizi
6. [UZUN VADELİ] Naive Bayes → Forum büyüdükçe eğitilir
| Araç | Amaç | URL |
|---|---|---|
| Akismet API | Yorum spam tespiti (Wordpress'in kullandığı) | akismet.com/development |
| Redis | Rate limiter + sliding window | redis.io |
| Prisma | ORM + schema yönetimi | prisma.io |
| BullMQ | Trend skoru güncelleme için queue | bullmq.io |
- twitter/the-algorithm — Twitter açık kaynak algoritma (Nisan 2023)
- twitter/the-algorithm-ml — Heavy ranker MaskNet modeli
- How HN Ranking Really Works — Ken Shirriff, 2013
- How Not to Sort by Average Rating — Wilson Score (Evan Miller)
- Reddit Hot Algorithm
- arXiv:2201.09420 — Coordinated Inauthentic Behavior Detection
- Akismet Developer API
Güncelleme: Çift Saldırı Vektörü Tespiti
Durum: Hem aynı post tekrarı hem yorum bombardımanı mevcut → büyük ihtimalle koordineli bir ring (organize grup, tek kullanıcı değil).
Saldırı 1: Aynı Post Tekrarı → Content Hash Katmanı
Mevcut ayarlar bu saldırıyı kaçırıyor çünkü:
cluster_size = 5→ 5 farklı kullanıcı gerekiyor, küçük ring'ler (2-4 kişi) geçiyorshort_post_chars = 50→ 50 karakter altı postlar muaf, spam genellikle kısaAcil değişiklikler:
Saldırı 2: Yorum Bombardımanı → Behavioral + Rate Limiter Katmanı
Mevcut ayarlar yavaş botları kaçırıyor:
like_burst_count = 30→ 60 saniyede 30 like şartı çok yükseklike_target_focus_count = 5→ tek yazara 5 like yetersizwindow_seconds = 60→ pencere çok dar, botlar aralıklı çalışıyorAcil değişiklikler:
Suspicion Score — Ring Tespiti İçin Kritik
İki saldırı birden aktifse puan birikimi hızlanmalı, hafıza uzamalı:
Koordineli Ring için Ek Önlem
Mevcut sistemde cross-user koordinasyon tespiti eksik görünüyor.
Şu sorguyu periyodik çalıştırmak ring'leri ortaya çıkarır:
Bu sorgular ring üyelerini direkt listeler — manuel inceleme için başlangıç noktası.