Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 102 additions & 38 deletions src/data/equinoxHaberD1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,15 @@ const ARTICLE_META_COLUMNS = `slug, title, description, category, author,
'' AS body_md, '' AS body_html,
hero_image, hero_alt, pub_date, updated_date, is_draft, breaking, editor_pick`;

/* Bunlar yayın hattı/metaveri etiketleri; haberler arası anlamlı konu bağı
* değildir. İlgili haber skoruna katılırlarsa yüzlerce kaydı yapay biçimde
* birbirine bağlayıp hem öneri kalitesini hem D1 rows_read maliyetini bozar. */
const RELATED_IGNORED_TAGS = new Set(['pipeline', 'haber']);

function isRelatedTag(tag: string) {
return !RELATED_IGNORED_TAGS.has(tag.trim().toLowerCase());
}

function positiveInteger(value?: number) {
if (value === undefined) return undefined;
if (!Number.isFinite(value)) return undefined;
Expand Down Expand Up @@ -227,13 +236,15 @@ export interface D1ArticlePageData {
relatedEntries: D1NewsEntry[];
}

/** Haber detay sayfası için bütün arşivi çekmeden gereken küçük çalışma seti.
/** Haber detay sayfası için gereken küçük çalışma seti.
*
* Eski yol her tekil haber isteğinde ~4-7 bin D1 satırı okuyordu çünkü ilgili
* haberleri JS'te hesaplamak için tüm haberler + tüm etiketler + tüm kaynaklar
* belleğe alınıyordu. Burada yalnız mevcut haber, komşuları ve üç ilgili aday
* okunur. Botların eski haber arşivini taraması bu yüzden günlük kotayı artık
* doğrusal biçimde eritmez. */
* D1 kotası döndürülen değil TARANAN satırı sayar. Bu yüzden `LIMIT 3` tek
* başına ucuzluk garantisi değildir: korelasyonlu etiket sorgusu birkaç sonuç
* için binlerce satır tarayabiliyordu. Buradaki yol, mevcut indeksleri açıkça
* kullanır: komşular iki basit tarih/slug sorgusuyla; ilgili haber adaylarıysa
* etiket indeksinden küçük bir slug kümesi çıkarılıp uygulamada puanlanarak
* bulunur. Böylece crawler trafiği arşiv büyüdükçe doğrusal D1 taramasına
* dönüşmez. */
export async function getArticlePageFromD1(
db: D1Database,
slug: string,
Expand All @@ -246,58 +257,111 @@ export async function getArticlePageFromD1(
).bind(slug).first<ArticleRow>();
if (!current) return null;

const [tagsResult, sourcesResult, newer, older] = await Promise.all([
/* `(pub_date > ? OR (pub_date = ? ...))` biçimi SQLite planner'ında arşivin
* büyük bölümünü tarayabiliyor. Aynı tarih ve başka tarih durumlarını ayırıp
* en yakın iki adayı uygulamada seçmek indeksin doğrudan kullanılmasını
* sağlıyor. */
const [tagsResult, sourcesResult, newerSameDate, newerDate, olderSameDate, olderDate] = await Promise.all([
db.prepare('SELECT slug, tag FROM article_tags WHERE slug = ? ORDER BY position')
.bind(slug).all<{ slug: string; tag: string }>(),
db.prepare('SELECT slug, name, url FROM article_sources WHERE slug = ? ORDER BY position')
.bind(slug).all<{ slug: string; name: string; url: string }>(),
db.prepare(
`SELECT ${ARTICLE_META_COLUMNS}
FROM articles
WHERE is_draft = 0
AND (pub_date > ? OR (pub_date = ? AND slug < ?))
WHERE is_draft = 0 AND pub_date = ? AND slug < ?
ORDER BY slug DESC
LIMIT 1`,
).bind(current.pub_date, current.slug).first<ArticleRow>(),
db.prepare(
`SELECT ${ARTICLE_META_COLUMNS}
FROM articles
WHERE is_draft = 0 AND pub_date > ?
ORDER BY pub_date ASC, slug DESC
LIMIT 1`,
).bind(current.pub_date, current.pub_date, current.slug).first<ArticleRow>(),
).bind(current.pub_date).first<ArticleRow>(),
db.prepare(
`SELECT ${ARTICLE_META_COLUMNS}
FROM articles
WHERE is_draft = 0
AND (pub_date < ? OR (pub_date = ? AND slug > ?))
WHERE is_draft = 0 AND pub_date = ? AND slug > ?
ORDER BY slug ASC
LIMIT 1`,
).bind(current.pub_date, current.slug).first<ArticleRow>(),
db.prepare(
`SELECT ${ARTICLE_META_COLUMNS}
FROM articles
WHERE is_draft = 0 AND pub_date < ?
ORDER BY pub_date DESC, slug ASC
LIMIT 1`,
).bind(current.pub_date, current.pub_date, current.slug).first<ArticleRow>(),
).bind(current.pub_date).first<ArticleRow>(),
]);

const newer = newerSameDate ?? newerDate;
const older = olderSameDate ?? olderDate;
const currentTags = (tagsResult.results ?? []).map((row) => row.tag);
let relatedWhere = 'a.category = ?';
let scoreExpression = '6';
let relatedBindings: unknown[] = [slug, current.category];

if (currentTags.length > 0) {
const placeholders = currentTags.map(() => '?').join(', ');
relatedWhere = `(a.category = ? OR a.slug IN (
SELECT slug FROM article_tags WHERE tag IN (${placeholders})
))`;
scoreExpression = `(CASE WHEN a.category = ? THEN 6 ELSE 0 END) + 3 * (
SELECT COUNT(*) FROM article_tags matched
WHERE matched.slug = a.slug AND matched.tag IN (${placeholders})
)`;
/* SELECT içindeki yer tutucular WHERE'dekilerden önce bağlanır. */
relatedBindings = [current.category, ...currentTags, slug, current.category, ...currentTags];
}
const relatedTags = currentTags.filter(isRelatedTag);

const related = await db.prepare(
`SELECT ${ARTICLE_META_COLUMNS}, ${scoreExpression} AS relation_score
FROM articles a
WHERE a.is_draft = 0
AND a.slug <> ?
AND ${relatedWhere}
ORDER BY relation_score DESC, a.pub_date DESC, a.slug ASC
/* Kategori bonusu olan ama ortak etiketi olmayan adaylardan yalnız en yeni
* üçü gerekebilir: hepsinin skoru 6 ve eşitlikte tarih kazanır. Ortak etiketli
* tüm adaylar ayrıca aşağıdaki indeksli etiket sorgusundan gelir. */
const categoryPromise = db.prepare(
`SELECT ${ARTICLE_META_COLUMNS}
FROM articles
WHERE is_draft = 0 AND category = ? AND slug <> ?
ORDER BY pub_date DESC, slug ASC
LIMIT 3`,
).bind(...relatedBindings).all<ArticleRow>();
).bind(current.category, slug).all<ArticleRow>();

const tagMatchesPromise = relatedTags.length > 0
? (() => {
const placeholders = relatedTags.map(() => '?').join(', ');
return db.prepare(
`SELECT slug, COUNT(*) AS shared_tags
FROM article_tags
WHERE tag IN (${placeholders}) AND slug <> ?
GROUP BY slug`,
).bind(...relatedTags, slug).all<{ slug: string; shared_tags: number }>();
})()
: Promise.resolve({ results: [] } as Pick<D1Result<{ slug: string; shared_tags: number }>, 'results'>);

const [categoryResult, tagMatchesResult] = await Promise.all([categoryPromise, tagMatchesPromise]);
const sharedBySlug = new Map(
(tagMatchesResult.results ?? []).map((row) => [row.slug, Number(row.shared_tags)] as const),
);
const tagSlugs = [...sharedBySlug.keys()];
const taggedRows = tagSlugs.length > 0
? await readRelations<ArticleRow>(
db,
tagSlugs,
/* `is_draft = 0 AND slug IN (...)` planner'ı tarih indeksine çekip
* bütün yayımlanmış tabloyu taratabiliyor. PK lookup yap, taslağı
* aşağıda bellekte ele. */
(placeholders) => `SELECT ${ARTICLE_META_COLUMNS}
FROM articles
WHERE slug IN (${placeholders})`,
)
: [];

const candidates = new Map<string, ArticleRow>();
for (const row of categoryResult.results ?? []) candidates.set(row.slug, row);
for (const row of taggedRows) {
if (row.is_draft === 0) candidates.set(row.slug, row);
}

let relatedRows = [...candidates.values()]
.map((row) => ({
row,
score: (row.category === current.category ? 6 : 0) + 3 * (sharedBySlug.get(row.slug) ?? 0),
}))
.sort((a, b) => {
const scoreOrder = b.score - a.score;
if (scoreOrder) return scoreOrder;
const dateOrder = b.row.pub_date.localeCompare(a.row.pub_date);
return dateOrder || a.row.slug.localeCompare(b.row.slug);
})
.slice(0, 3)
.map(({ row }) => row);

let relatedRows = related.results ?? [];
if (relatedRows.length < 3) {
const excluded = [slug, ...relatedRows.map((row) => row.slug)];
const placeholders = excluded.map(() => '?').join(', ');
Expand Down
5 changes: 4 additions & 1 deletion src/data/newsSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ import { getCollection, render, type CollectionEntry } from 'astro:content';

export type NewsEntry = CollectionEntry<'equinoxHaber'>;

const RELATED_IGNORED_TAGS = new Set(['pipeline', 'haber']);
const isRelatedTag = (tag: string) => !RELATED_IGNORED_TAGS.has(tag.trim().toLowerCase());

export interface PublishedOptions {
limit?: number;
offset?: number;
Expand Down Expand Up @@ -55,7 +58,7 @@ export async function getArticlePage(slug: string) {
if (currentIndex < 0) return null;

const entry = entries[currentIndex]!;
const currentTags = new Set(entry.data.tags ?? []);
const currentTags = new Set((entry.data.tags ?? []).filter((tag: string) => isRelatedTag(tag)));
const relatedEntries = entries
.filter((item) => item.id !== entry.id)
.map((item, index) => {
Expand Down
8 changes: 5 additions & 3 deletions src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,9 +120,11 @@ export const onRequest: MiddlewareHandler = async (context, next) => {

const response = await next();

/* Yalnız 200 önbelleklenir. 404 ve 500'ü tutmak, bir hatayı bir dakika
boyunca herkese servis etmek demek. */
if (response.status !== 200) return response;
/* 404 de sürümlü anahtarla güvenle önbelleklenebilir: daha sonra aynı slug
* yayımlanırsa content_version değişir ve eski 404 anahtarı erişilemez olur.
* Scanner'ların aynı olmayan yolu tekrar tekrar D1'e sordurmasını önler.
* Sunucu hataları ise asla önbelleğe girmez. */
if (response.status !== 200 && response.status !== 404) return response;

const cacheable = new Response(response.body, response);
cacheable.headers.set(
Expand Down
8 changes: 6 additions & 2 deletions src/pages/[...slug].astro
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,18 @@ export async function getStaticPaths() {
}

const routeSlug = Astro.params.slug;
const articleSlug = routeSlug && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(routeSlug) ? routeSlug : null;

/* Sunucu modunda tekil haber yolu bütün arşivi okumaz. D1 kaynağı mevcut
haber + komşular + ilgili üçlü için hedefli sorgular kullanır; statik kaynak
aynı şekli içerik koleksiyonundan kurar. */
const articlePage = routeSlug ? await getArticlePage(routeSlug) : null;
const articlePage = articleSlug ? await getArticlePage(articleSlug) : null;
const entry = articlePage?.entry;

if (!entry) {
/* Dosya/klasör ve exploit-probe yollarını haber slug'ı sanıp D1'e sorma. */
if (!articleSlug) return new Response(null, { status: 404 });

/* Yayından kaldırılmış bir haber mi?
*
* Adres bir süre yayında kaldı; paylaşımlarda ve arama motorlarında
Expand All @@ -36,7 +40,7 @@ if (!entry) {
* Gerekçe SAYFADA GÖSTERİLMİYOR: `article_withdrawals.reason` operatöre
* yazılmış bir iç nottur ("kaynak yanlış, teyit bekliyoruz") ve
* kendiliğinden yayımlanması sürpriz olurdu. */
const kaldirilan = await getWithdrawal(routeSlug!);
const kaldirilan = await getWithdrawal(articleSlug);
if (kaldirilan) {
return new Response(kaldirilanSayfasi(kaldirilan.tarih), {
status: 410,
Expand Down
Loading