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
19 changes: 16 additions & 3 deletions src/data/equinoxHaber.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { CollectionEntry } from 'astro:content';
import { getNewsHomeHref, getNewsStreamPageHref } from './newsSite';
import { getPublished } from '#news-source';
import { getPublished, getPublishedCount } from '#news-source';

/** Ana sayfanın öne çıkan panelini besleyen havuz. */
export const EQUINOX_HABER_PAGE_SIZE = 20;
Expand All @@ -16,6 +16,15 @@ export const NEWS_CATEGORIES = ['Siyaset', 'Ekonomi', 'Teknoloji', 'Bilim'] as c
export type NewsCategory = (typeof NEWS_CATEGORIES)[number];
export type EquinoxHaberEntry = CollectionEntry<'equinoxHaber'>;

export interface PublishedNewsOptions {
limit?: number;
offset?: number;
category?: string;
since?: Date | string;
includeTags?: boolean;
includeSources?: boolean;
}

/**
* Yayımlanmış haberler, yeniden eskiye.
*
Expand All @@ -29,8 +38,12 @@ export type EquinoxHaberEntry = CollectionEntry<'equinoxHaber'>;
* diğeri koleksiyondan okusaydı yeni yayımlanan bir haber kendi adresinde
* görünür ama listelerde görünmezdi.
*/
export async function getPublishedEquinoxHaber() {
return getPublished();
export async function getPublishedEquinoxHaber(options: PublishedNewsOptions = {}) {
return getPublished(options);
}

export async function getPublishedEquinoxHaberCount() {
return getPublishedCount();
}

export function getEquinoxHaberCategories() {
Expand Down
225 changes: 214 additions & 11 deletions src/data/equinoxHaberD1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,16 @@

export interface NewsSource { name: string; url: string }

export interface PublishedD1Options {
withBody?: boolean;
limit?: number;
offset?: number;
category?: string;
since?: Date | string;
includeTags?: boolean;
includeSources?: boolean;
}

export interface D1NewsEntry {
id: string;
body: string;
Expand Down Expand Up @@ -93,6 +103,43 @@ function assemble(
}));
}

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`;

function positiveInteger(value?: number) {
if (value === undefined) return undefined;
if (!Number.isFinite(value)) return undefined;
const normalized = Math.trunc(value);
return normalized > 0 ? normalized : undefined;
}

function nonNegativeInteger(value?: number) {
if (value === undefined || !Number.isFinite(value)) return 0;
return Math.max(0, Math.trunc(value));
}

function isoDate(value: Date | string) {
return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
}

async function readRelations<T>(
db: D1Database,
slugs: string[],
sqlForPlaceholders: (placeholders: string) => string,
): Promise<T[]> {
const rows: T[] = [];
/* D1/SQLite bağ parametresi sınırına yaklaşmamak için büyük arşiv sorgularını
* küçük parçalar halinde yürüt. Normal sayfalarda bu döngü tek turdur. */
for (let index = 0; index < slugs.length; index += 80) {
const chunk = slugs.slice(index, index + 80);
const placeholders = chunk.map(() => '?').join(', ');
const result = await db.prepare(sqlForPlaceholders(placeholders)).bind(...chunk).all<T>();
rows.push(...(result.results ?? []));
}
return rows;
}

/** Yayımlanmış haberler, yeniden eskiye.
*
* Sıralama `ORDER BY` ile veritabanında yapılıyor; koleksiyon tarafındaki
Expand All @@ -107,23 +154,179 @@ function assemble(
* `withBody` yalnız RSS için var ve orada da sınırlı sayıda haber isteniyor. */
export async function getPublishedFromD1(
db: D1Database,
options: { withBody?: boolean; limit?: number } = {},
options: PublishedD1Options = {},
): Promise<D1NewsEntry[]> {
const bodyColumns = options.withBody ? "body_md, body_html," : "'' AS body_md, '' AS body_html,";
const limit = options.limit ? ` LIMIT ${Number(options.limit)}` : "";
const where = ['is_draft = 0'];
const bindings: unknown[] = [];

if (options.category) {
where.push('category = ?');
bindings.push(options.category);
}
if (options.since) {
where.push('pub_date >= ?');
bindings.push(isoDate(options.since));
}

const limit = positiveInteger(options.limit);
const offset = nonNegativeInteger(options.offset);
const pagination = limit
? ` LIMIT ${limit}${offset ? ` OFFSET ${offset}` : ''}`
: offset
? ` LIMIT -1 OFFSET ${offset}`
: '';

const articles = await db.prepare(
`SELECT slug, title, description, category, author, ${bodyColumns}
hero_image, hero_alt, pub_date, updated_date, is_draft, breaking, editor_pick
FROM articles
WHERE ${where.join(' AND ')}
ORDER BY pub_date DESC, slug ASC${pagination}`,
).bind(...bindings).all<ArticleRow>();

const articleRows = articles.results ?? [];
const slugs = articleRows.map((row) => row.slug);
if (slugs.length === 0) return [];

const includeTags = options.includeTags ?? true;
const includeSources = options.includeSources ?? true;
const [tags, sources] = await Promise.all([
includeTags
? readRelations<{ slug: string; tag: string }>(
db,
slugs,
(placeholders) => `SELECT slug, tag FROM article_tags WHERE slug IN (${placeholders}) ORDER BY slug, position`,
)
: Promise.resolve([]),
includeSources
? readRelations<{ slug: string; name: string; url: string }>(
db,
slugs,
(placeholders) => `SELECT slug, name, url FROM article_sources WHERE slug IN (${placeholders}) ORDER BY slug, position`,
)
: Promise.resolve([]),
]);

const [articles, tags, sources] = await Promise.all([
return assemble(articleRows, tags, sources);
}

export async function getPublishedCountFromD1(db: D1Database): Promise<number> {
const row = await db.prepare(
'SELECT COUNT(*) AS total FROM articles WHERE is_draft = 0',
).first<{ total: number }>();
return Number(row?.total ?? 0);
}

export interface D1ArticlePageData {
entry: D1NewsEntry;
body: string;
bodyHtml: string | null;
nextEntry?: D1NewsEntry;
prevEntry?: D1NewsEntry;
relatedEntries: D1NewsEntry[];
}

/** Haber detay sayfası için bütün arşivi çekmeden 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. */
export async function getArticlePageFromD1(
db: D1Database,
slug: string,
): Promise<D1ArticlePageData | null> {
const current = await db.prepare(
`SELECT slug, title, description, category, author, body_md, body_html,
hero_image, hero_alt, pub_date, updated_date, is_draft, breaking, editor_pick
FROM articles
WHERE slug = ? AND is_draft = 0`,
).bind(slug).first<ArticleRow>();
if (!current) return null;

const [tagsResult, sourcesResult, newer, older] = 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 < ?))
ORDER BY pub_date ASC, slug DESC
LIMIT 1`,
).bind(current.pub_date, current.pub_date, current.slug).first<ArticleRow>(),
db.prepare(
`SELECT slug, title, description, category, author, ${bodyColumns}
hero_image, hero_alt, pub_date, updated_date, is_draft, breaking, editor_pick
FROM articles WHERE is_draft = 0 ORDER BY pub_date DESC${limit}`,
).all<ArticleRow>(),
db.prepare("SELECT slug, tag FROM article_tags ORDER BY slug, position").all<{ slug: string; tag: string }>(),
db.prepare("SELECT slug, name, url FROM article_sources ORDER BY slug, position")
.all<{ slug: string; name: string; url: string }>(),
`SELECT ${ARTICLE_META_COLUMNS}
FROM articles
WHERE is_draft = 0
AND (pub_date < ? OR (pub_date = ? AND slug > ?))
ORDER BY pub_date DESC, slug ASC
LIMIT 1`,
).bind(current.pub_date, current.pub_date, current.slug).first<ArticleRow>(),
]);

return assemble(articles.results ?? [], tags.results ?? [], sources.results ?? []);
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 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
LIMIT 3`,
).bind(...relatedBindings).all<ArticleRow>();

let relatedRows = related.results ?? [];
if (relatedRows.length < 3) {
const excluded = [slug, ...relatedRows.map((row) => row.slug)];
const placeholders = excluded.map(() => '?').join(', ');
const fallback = await db.prepare(
`SELECT ${ARTICLE_META_COLUMNS}
FROM articles
WHERE is_draft = 0 AND slug NOT IN (${placeholders})
ORDER BY pub_date DESC, slug ASC
LIMIT ${3 - relatedRows.length}`,
).bind(...excluded).all<ArticleRow>();
relatedRows = [...relatedRows, ...(fallback.results ?? [])];
}

const [entry] = assemble(
[current],
tagsResult.results ?? [],
sourcesResult.results ?? [],
);
const [nextEntry] = newer ? assemble([newer], [], []) : [];
const [prevEntry] = older ? assemble([older], [], []) : [];

return {
entry,
body: current.body_md,
bodyHtml: current.body_html,
nextEntry,
prevEntry,
relatedEntries: assemble(relatedRows, [], []),
};
}

/** Tek haber, gövdesiyle.
Expand Down
69 changes: 65 additions & 4 deletions src/data/newsSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,71 @@ import { getCollection, render, type CollectionEntry } from 'astro:content';

export type NewsEntry = CollectionEntry<'equinoxHaber'>;

export async function getPublished(): Promise<any[]> {
return (await getCollection('equinoxHaber'))
export interface PublishedOptions {
limit?: number;
offset?: number;
category?: string;
since?: Date | string;
includeTags?: boolean;
includeSources?: boolean;
}

export async function getPublished(options: PublishedOptions = {}): Promise<any[]> {
let entries = (await getCollection('equinoxHaber'))
.filter((entry) => !entry.data.isDraft)
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
.sort((a, b) => {
const dateOrder = b.data.pubDate.valueOf() - a.data.pubDate.valueOf();
return dateOrder || a.id.localeCompare(b.id);
});

if (options.category) {
entries = entries.filter((entry) => entry.data.category === options.category);
}
if (options.since) {
const since = options.since instanceof Date ? options.since : new Date(options.since);
entries = entries.filter((entry) => entry.data.pubDate.valueOf() >= since.valueOf());
}

const offset = Math.max(0, Math.trunc(options.offset ?? 0));
const limit = options.limit && options.limit > 0 ? Math.trunc(options.limit) : undefined;
return limit === undefined ? entries.slice(offset) : entries.slice(offset, offset + limit);
}

export async function getPublishedCount(): Promise<number> {
return (await getPublished()).length;
}

/** Statik derlemede D1 maliyeti yok; aynı sayfa verisini koleksiyon üzerinden
* kurup sunucu sürümüyle aynı şekli döndürür. */
export async function getArticlePage(slug: string) {
const entries = await getPublished();
const currentIndex = entries.findIndex((entry) => entry.id === slug);
if (currentIndex < 0) return null;

const entry = entries[currentIndex]!;
const currentTags = new Set(entry.data.tags ?? []);
const relatedEntries = entries
.filter((item) => item.id !== entry.id)
.map((item, index) => {
const sharedTags = (item.data.tags ?? []).filter((tag: string) => currentTags.has(tag)).length;
const sameCategory = item.data.category === entry.data.category;
return {
entry: item,
score: (sameCategory ? 6 : 0) + sharedTags * 3 - index * 0.001,
};
})
.sort((a, b) => b.score - a.score)
.slice(0, 3)
.map((item) => item.entry);

return {
entry,
body: entry.body ?? '',
bodyHtml: null,
nextEntry: entries[currentIndex - 1],
prevEntry: entries[currentIndex + 1],
relatedEntries,
};
}

/** Gövdeyi render eden Astro bileşeni. D1 tarafında karşılığı yok: orada
Expand All @@ -27,7 +88,7 @@ export async function renderEntry(entry: any) {

/** RSS gövdeleri. Koleksiyon girdileri gövdeyi zaten taşıyor. */
export async function getPublishedWithBody(limit: number): Promise<any[]> {
return (await getPublished()).slice(0, limit);
return getPublished({ limit });
}

/** Haber sayfasının gövdesi. Statik modda gövde girdinin içinde geliyor ve
Expand Down
Loading