ditto/src/controllers/api/trends.ts
2024-07-23 23:07:34 -05:00

217 lines
5.7 KiB
TypeScript

import { NostrEvent, NostrFilter, NStore } from '@nostrify/nostrify';
import { z } from 'zod';
import { AppController } from '@/app.ts';
import { Conf } from '@/config.ts';
import { hydrateEvents } from '@/storages/hydrate.ts';
import { Storages } from '@/storages.ts';
import { generateDateRange, Time } from '@/utils/time.ts';
import { unfurlCardCached } from '@/utils/unfurl.ts';
import { renderStatus } from '@/views/mastodon/statuses.ts';
let trendingHashtagsCache = getTrendingHashtags().catch((e) => {
console.error(`Failed to get trending hashtags: ${e}`);
return Promise.resolve([]);
});
Deno.cron('update trending hashtags cache', '35 * * * *', async () => {
try {
const trends = await getTrendingHashtags();
trendingHashtagsCache = Promise.resolve(trends);
} catch (e) {
console.error(e);
}
});
const trendingTagsQuerySchema = z.object({
limit: z.coerce.number().catch(10).transform((value) => Math.min(Math.max(value, 0), 20)),
offset: z.number().nonnegative().catch(0),
});
const trendingTagsController: AppController = async (c) => {
const { limit, offset } = trendingTagsQuerySchema.parse(c.req.query());
const trends = await trendingHashtagsCache;
return c.json(trends.slice(offset, offset + limit));
};
async function getTrendingHashtags() {
const store = await Storages.db();
const trends = await getTrendingTags(store, 't');
return trends.map((trend) => {
const hashtag = trend.value;
const history = trend.history.map(({ day, authors, uses }) => ({
day: String(day),
accounts: String(authors),
uses: String(uses),
}));
return {
name: hashtag,
url: Conf.local(`/tags/${hashtag}`),
history,
};
});
}
let trendingLinksCache = getTrendingLinks().catch((e) => {
console.error(`Failed to get trending links: ${e}`);
return Promise.resolve([]);
});
Deno.cron('update trending links cache', '50 * * * *', async () => {
try {
const trends = await getTrendingLinks();
trendingLinksCache = Promise.resolve(trends);
} catch (e) {
console.error(e);
}
});
const trendingLinksController: AppController = async (c) => {
const { limit, offset } = trendingTagsQuerySchema.parse(c.req.query());
const trends = await trendingLinksCache;
return c.json(trends.slice(offset, offset + limit));
};
async function getTrendingLinks() {
const store = await Storages.db();
const trends = await getTrendingTags(store, 'r');
return Promise.all(trends.map(async (trend) => {
const link = trend.value;
const card = await unfurlCardCached(link);
const history = trend.history.map(({ day, authors, uses }) => ({
day: String(day),
accounts: String(authors),
uses: String(uses),
}));
return {
url: link,
title: '',
description: '',
type: 'link',
author_name: '',
author_url: '',
provider_name: '',
provider_url: '',
html: '',
width: 0,
height: 0,
image: null,
embed_url: '',
blurhash: null,
...card,
history,
};
}));
}
const trendingStatusesQuerySchema = z.object({
limit: z.coerce.number().catch(20).transform((value) => Math.min(Math.max(value, 0), 40)),
offset: z.number().nonnegative().catch(0),
});
const trendingStatusesController: AppController = async (c) => {
const store = await Storages.db();
const { limit, offset } = trendingStatusesQuerySchema.parse(c.req.query());
const [label] = await store.query([{
kinds: [1985],
'#L': ['pub.ditto.trends'],
'#l': ['#e'],
authors: [Conf.pubkey],
limit: 1,
}]);
const ids = (label?.tags ?? [])
.filter(([name]) => name === 'e')
.map(([, id]) => id)
.slice(offset, offset + limit);
if (!ids.length) {
return c.json([]);
}
const results = await store.query([{ kinds: [1], ids }])
.then((events) => hydrateEvents({ events, store }));
// Sort events in the order they appear in the label.
const events = ids
.map((id) => results.find((event) => event.id === id))
.filter((event): event is NostrEvent => !!event);
const statuses = await Promise.all(
events.map((event) => renderStatus(event, {})),
);
return c.json(statuses.filter(Boolean));
};
interface TrendingTag {
name: string;
value: string;
history: {
day: number;
authors: number;
uses: number;
}[];
}
export async function getTrendingTags(store: NStore, tagName: string): Promise<TrendingTag[]> {
const [label] = await store.query([{
kinds: [1985],
'#L': ['pub.ditto.trends'],
'#l': [`#${tagName}`],
authors: [Conf.pubkey],
limit: 1,
}]);
if (!label) return [];
const date = new Date(label.created_at * 1000);
const lastWeek = new Date(date.getTime() - Time.days(7));
const dates = generateDateRange(lastWeek, date).reverse();
const results: TrendingTag[] = [];
for (const [name, value] of label.tags) {
if (name !== tagName) continue;
const history: TrendingTag['history'] = [];
for (const date of dates) {
const [label] = await store.query([{
kinds: [1985],
'#L': ['pub.ditto.trends'],
'#l': [`#${tagName}`],
[`#${tagName}`]: [value],
authors: [Conf.pubkey],
since: Math.floor(date.getTime() / 1000),
until: Math.floor((date.getTime() + Time.days(1)) / 1000),
limit: 1,
} as NostrFilter]);
const [, , , accounts, uses] = label?.tags.find(([n, v]) => n === tagName && v === value) ?? [];
history.push({
day: Math.floor(date.getTime() / 1000),
authors: Number(accounts || 0),
uses: Number(uses || 0),
});
}
results.push({
name: tagName,
value,
history,
});
}
return results;
}
export { trendingLinksController, trendingStatusesController, trendingTagsController };