Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add trending subject api #830

Merged
merged 1 commit into from
Nov 22, 2024
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
1 change: 1 addition & 0 deletions lib/openapi/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export const Tag = {
Topic: 'topic',
User: 'user',
Wiki: 'wiki',
Trending: 'trending',
} as const;

export const Security = {
Expand Down
85 changes: 85 additions & 0 deletions lib/trending/subject.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { db, op } from '@app/drizzle/db.ts';
import * as schema from '@app/drizzle/schema';
import { logger } from '@app/lib/logger';
import redis from '@app/lib/redis.ts';
import { SubjectType } from '@app/lib/subject/type.ts';
import type { TrendingItem } from '@app/lib/trending/type.ts';
import { getTrendingPeriodDuration, TrendingPeriod } from '@app/lib/trending/type.ts';

function getSubjectTrendingKey(type: SubjectType, period: TrendingPeriod) {
return `trending:subjects:${type}:${period}`;
}

Check warning on line 11 in lib/trending/subject.ts

View check run for this annotation

Codecov / codecov/patch

lib/trending/subject.ts#L9-L11

Added lines #L9 - L11 were not covered by tests

export async function updateTrendingSubjects(
subjectType: SubjectType,
period = TrendingPeriod.Month,
flush = false,
) {
const trendingKey = getSubjectTrendingKey(subjectType, period);
const lockKey = `lock:${trendingKey}`;
if (flush) {
await redis.del(lockKey);
}
if (await redis.get(lockKey)) {
logger.info('Already calculating trending subjects for %s(%s)...', subjectType, period);
return;
}
await redis.set(lockKey, 1, 'EX', 3600);
logger.info('Calculating trending subjects for %s(%s)...', subjectType, period);

Check warning on line 28 in lib/trending/subject.ts

View check run for this annotation

Codecov / codecov/patch

lib/trending/subject.ts#L13-L28

Added lines #L13 - L28 were not covered by tests

const now = Date.now();
const duration = getTrendingPeriodDuration(period);
if (!duration) {
logger.error('Invalid period: %s', period);
return;
}
const minDateline = now - duration;
let doingDateline = true;
if (subjectType === SubjectType.Book || subjectType === SubjectType.Music) {
doingDateline = false;
}

Check warning on line 40 in lib/trending/subject.ts

View check run for this annotation

Codecov / codecov/patch

lib/trending/subject.ts#L30-L40

Added lines #L30 - L40 were not covered by tests

const data = await db
.select({ subjectID: schema.chiiSubjects.id, total: op.count(schema.chiiSubjects.id) })
.from(schema.chiiSubjectInterests)
.innerJoin(
schema.chiiSubjects,
op.eq(schema.chiiSubjects.id, schema.chiiSubjectInterests.subjectID),
)
.where(
op.and(
op.eq(schema.chiiSubjects.typeID, subjectType),
op.ne(schema.chiiSubjects.ban, 1),
op.eq(schema.chiiSubjects.nsfw, false),
doingDateline
? op.gt(schema.chiiSubjectInterests.doingDateline, minDateline)
: op.gt(schema.chiiSubjectInterests.updatedAt, minDateline),
),
)
.groupBy(schema.chiiSubjects.id)
.orderBy(op.desc(op.count(schema.chiiSubjects.id)))
.limit(1000)
.execute();

Check warning on line 62 in lib/trending/subject.ts

View check run for this annotation

Codecov / codecov/patch

lib/trending/subject.ts#L42-L62

Added lines #L42 - L62 were not covered by tests

const ids = [];
for (const item of data) {
ids.push({ id: item.subjectID, total: item.total } as TrendingItem);
}
await redis.set(trendingKey, JSON.stringify(ids));
await redis.del(lockKey);
}

Check warning on line 70 in lib/trending/subject.ts

View check run for this annotation

Codecov / codecov/patch

lib/trending/subject.ts#L64-L70

Added lines #L64 - L70 were not covered by tests

export async function getTrendingSubjects(
subjectType: SubjectType,
period = TrendingPeriod.Month,
limit = 20,
offset = 0,
): Promise<TrendingItem[]> {
const trendingKey = getSubjectTrendingKey(subjectType, period);
const data = await redis.get(trendingKey);
if (!data) {
return [];
}
const ids = JSON.parse(data) as TrendingItem[];
return ids.slice(offset, offset + limit);
}

Check warning on line 85 in lib/trending/subject.ts

View check run for this annotation

Codecov / codecov/patch

lib/trending/subject.ts#L72-L85

Added lines #L72 - L85 were not covered by tests
22 changes: 22 additions & 0 deletions lib/trending/type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
export interface TrendingItem {
id: number;
total: number;
}

export enum TrendingPeriod {
All = 'all',
Day = 'day',
Week = 'week',
Month = 'month',
}

export function getTrendingPeriodDuration(period: TrendingPeriod): number {
const now = Date.now();
const duration = {
all: now,
day: 86400,
week: 86400 * 7,
month: 86400 * 30,
}[period];
return duration;
}

Check warning on line 22 in lib/trending/type.ts

View check run for this annotation

Codecov / codecov/patch

lib/trending/type.ts#L14-L22

Added lines #L14 - L22 were not covered by tests
24 changes: 24 additions & 0 deletions lib/types/fetcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,30 @@
return null;
}

export async function fetchSubjectsByIDs(
ids: number[],
allowNsfw = false,
): Promise<Map<number, res.ISubject>> {
const data = await db
.select()
.from(schema.chiiSubjects)
.innerJoin(schema.chiiSubjectFields, op.eq(schema.chiiSubjects.id, schema.chiiSubjectFields.id))
.where(
op.and(
op.inArray(schema.chiiSubjects.id, ids),
op.ne(schema.chiiSubjects.ban, 1),
allowNsfw ? undefined : op.eq(schema.chiiSubjects.nsfw, false),
),
)
.execute();
const map = new Map<number, res.ISubject>();
for (const d of data) {
const subject = convert.toSubject(d.chii_subjects, d.chii_subject_fields);
map.set(subject.id, subject);
}
return map;
}

Check warning on line 84 in lib/types/fetcher.ts

View check run for this annotation

Codecov / codecov/patch

lib/types/fetcher.ts#L62-L84

Added lines #L62 - L84 were not covered by tests

export async function fetchSlimCharacterByID(
id: number,
allowNsfw = false,
Expand Down
79 changes: 79 additions & 0 deletions routes/__snapshots__/index.test.ts.snap

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions routes/private/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import * as person from './routes/person.ts';
import * as post from './routes/post.ts';
import * as subject from './routes/subject.ts';
import * as group from './routes/topic.ts';
import * as trending from './routes/trending.ts';
import * as user from './routes/user.ts';
import * as wiki from './routes/wiki/index.ts';

Expand Down Expand Up @@ -50,6 +51,7 @@ async function API(app: App) {
await app.register(person.setup);
await app.register(post.setup);
await app.register(subject.setup);
await app.register(trending.setup);
await app.register(user.setup);
await app.register(wiki.setup, { prefix: '/wiki' });
}
64 changes: 64 additions & 0 deletions routes/private/routes/trending.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import type { Static } from '@sinclair/typebox';
import { Type as t } from '@sinclair/typebox';

import { Tag } from '@app/lib/openapi/index.ts';
import { SubjectType } from '@app/lib/subject/type.ts';
import { getTrendingSubjects } from '@app/lib/trending/subject.ts';
import { TrendingPeriod } from '@app/lib/trending/type';
import * as fetcher from '@app/lib/types/fetcher.ts';
import * as res from '@app/lib/types/res.ts';
import type { App } from '@app/routes/type.ts';

export type ITrendingSubject = Static<typeof TrendingSubject>;
const TrendingSubject = t.Object(
{
subject: t.Ref(res.Subject),
count: t.Integer(),
},
{ $id: 'TrendingSubject' },
);

// eslint-disable-next-line @typescript-eslint/require-await
export async function setup(app: App) {
app.addSchema(TrendingSubject);

app.get(
'/trending/subjects',
{
schema: {
summary: '获取热门条目',
operationId: 'getTrendingSubjects',
tags: [Tag.Trending],
querystring: t.Object({
type: t.Enum(SubjectType, { description: '条目类型' }),
limit: t.Optional(
t.Integer({ default: 20, minimum: 1, maximum: 100, description: 'max 100' }),
),
offset: t.Optional(t.Integer({ default: 0, minimum: 0, description: 'min 0' })),
}),
response: {
200: res.Paged(t.Ref(TrendingSubject)),
},
},
},
async ({ query: { type, limit = 20, offset = 0 } }) => {
const items = await getTrendingSubjects(type, TrendingPeriod.Month, limit, offset);
const subjectIDs = items.map((item) => item.id);
const subjects = await fetcher.fetchSubjectsByIDs(subjectIDs);
const data = [];
for (const item of items) {
const subject = subjects.get(item.id);
if (subject) {
data.push({
subject,
count: item.total,
});
}
}
return {
total: 1000,
data,
};
},

Check warning on line 62 in routes/private/routes/trending.ts

View check run for this annotation

Codecov / codecov/patch

routes/private/routes/trending.ts#L45-L62

Added lines #L45 - L62 were not covered by tests
);
}
Loading