save just read feed in redis sorted set
This commit is contained in:
@ -209,5 +209,8 @@ export class LibraryItem {
|
||||
seenAt?: Date
|
||||
|
||||
@Column('text')
|
||||
topic!: string
|
||||
topic?: string
|
||||
|
||||
@Column('timestamptz')
|
||||
digestedAt?: Date
|
||||
}
|
||||
|
||||
@ -12,10 +12,10 @@ export class PublicItem {
|
||||
id!: string
|
||||
|
||||
@Column('text')
|
||||
source_name!: string
|
||||
sourceName!: string
|
||||
|
||||
@Column('text')
|
||||
source_icon!: string
|
||||
sourceIcon?: string
|
||||
|
||||
@Column('text')
|
||||
type!: string
|
||||
@ -54,5 +54,11 @@ export class PublicItem {
|
||||
updatedAt!: Date
|
||||
|
||||
@Column('text')
|
||||
topic!: string
|
||||
topic?: string
|
||||
|
||||
@Column('integer')
|
||||
wordCount?: number
|
||||
|
||||
@Column('text')
|
||||
siteName?: string
|
||||
}
|
||||
|
||||
@ -41,4 +41,7 @@ export class PublicItemInteraction {
|
||||
|
||||
@Column('timestamptz')
|
||||
updated!: Date
|
||||
|
||||
@Column('timestamptz')
|
||||
digested?: Date
|
||||
}
|
||||
|
||||
@ -1391,9 +1391,10 @@ export type JustReadFeedItem = {
|
||||
source?: Maybe<JustReadFeedSource>;
|
||||
thumbnail?: Maybe<Scalars['String']>;
|
||||
title: Scalars['String'];
|
||||
topic: Scalars['String'];
|
||||
topic?: Maybe<Scalars['String']>;
|
||||
updatedAt?: Maybe<Scalars['Date']>;
|
||||
url: Scalars['String'];
|
||||
wordCount?: Maybe<Scalars['Int']>;
|
||||
};
|
||||
|
||||
export type JustReadFeedResult = JustReadFeedError | JustReadFeedSuccess;
|
||||
@ -1416,7 +1417,7 @@ export type JustReadFeedSuccess = {
|
||||
export type JustReadFeedTopic = {
|
||||
__typename?: 'JustReadFeedTopic';
|
||||
items: Array<JustReadFeedItem>;
|
||||
name: Scalars['String'];
|
||||
name?: Maybe<Scalars['String']>;
|
||||
thumbnail?: Maybe<Scalars['String']>;
|
||||
};
|
||||
|
||||
@ -2072,6 +2073,11 @@ export type MutationUploadImportFileArgs = {
|
||||
type: UploadImportFileType;
|
||||
};
|
||||
|
||||
export type MySubscriptionRootType = {
|
||||
__typename?: 'MySubscriptionRootType';
|
||||
justReadFeed: JustReadFeedResult;
|
||||
};
|
||||
|
||||
export type NewsletterEmail = {
|
||||
__typename?: 'NewsletterEmail';
|
||||
address: Scalars['String'];
|
||||
@ -2218,7 +2224,6 @@ export type Query = {
|
||||
hello?: Maybe<Scalars['String']>;
|
||||
integration: IntegrationResult;
|
||||
integrations: IntegrationsResult;
|
||||
justReadFeed: JustReadFeedResult;
|
||||
labels: LabelsResult;
|
||||
me?: Maybe<User>;
|
||||
newsletterEmails: NewsletterEmailsResult;
|
||||
@ -2270,14 +2275,6 @@ export type QueryIntegrationArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type QueryJustReadFeedArgs = {
|
||||
after?: InputMaybe<Scalars['String']>;
|
||||
first?: InputMaybe<Scalars['Int']>;
|
||||
language?: InputMaybe<Scalars['String']>;
|
||||
location?: InputMaybe<Scalars['String']>;
|
||||
};
|
||||
|
||||
|
||||
export type QueryRulesArgs = {
|
||||
enabled?: InputMaybe<Scalars['Boolean']>;
|
||||
};
|
||||
@ -4317,6 +4314,7 @@ export type ResolversTypes = {
|
||||
MoveToFolderResult: ResolversTypes['MoveToFolderError'] | ResolversTypes['MoveToFolderSuccess'];
|
||||
MoveToFolderSuccess: ResolverTypeWrapper<MoveToFolderSuccess>;
|
||||
Mutation: ResolverTypeWrapper<{}>;
|
||||
MySubscriptionRootType: ResolverTypeWrapper<{}>;
|
||||
NewsletterEmail: ResolverTypeWrapper<NewsletterEmail>;
|
||||
NewsletterEmailsError: ResolverTypeWrapper<NewsletterEmailsError>;
|
||||
NewsletterEmailsErrorCode: NewsletterEmailsErrorCode;
|
||||
@ -4494,7 +4492,7 @@ export type ResolversTypes = {
|
||||
SubscribeInput: SubscribeInput;
|
||||
SubscribeResult: ResolversTypes['SubscribeError'] | ResolversTypes['SubscribeSuccess'];
|
||||
SubscribeSuccess: ResolverTypeWrapper<SubscribeSuccess>;
|
||||
Subscription: ResolverTypeWrapper<{}>;
|
||||
Subscription: ResolverTypeWrapper<Subscription>;
|
||||
SubscriptionStatus: SubscriptionStatus;
|
||||
SubscriptionType: SubscriptionType;
|
||||
SubscriptionsError: ResolverTypeWrapper<SubscriptionsError>;
|
||||
@ -4856,6 +4854,7 @@ export type ResolversParentTypes = {
|
||||
MoveToFolderResult: ResolversParentTypes['MoveToFolderError'] | ResolversParentTypes['MoveToFolderSuccess'];
|
||||
MoveToFolderSuccess: MoveToFolderSuccess;
|
||||
Mutation: {};
|
||||
MySubscriptionRootType: {};
|
||||
NewsletterEmail: NewsletterEmail;
|
||||
NewsletterEmailsError: NewsletterEmailsError;
|
||||
NewsletterEmailsResult: ResolversParentTypes['NewsletterEmailsError'] | ResolversParentTypes['NewsletterEmailsSuccess'];
|
||||
@ -4994,7 +4993,7 @@ export type ResolversParentTypes = {
|
||||
SubscribeInput: SubscribeInput;
|
||||
SubscribeResult: ResolversParentTypes['SubscribeError'] | ResolversParentTypes['SubscribeSuccess'];
|
||||
SubscribeSuccess: SubscribeSuccess;
|
||||
Subscription: {};
|
||||
Subscription: Subscription;
|
||||
SubscriptionsError: SubscriptionsError;
|
||||
SubscriptionsResult: ResolversParentTypes['SubscriptionsError'] | ResolversParentTypes['SubscriptionsSuccess'];
|
||||
SubscriptionsSuccess: SubscriptionsSuccess;
|
||||
@ -6089,9 +6088,10 @@ export type JustReadFeedItemResolvers<ContextType = ResolverContext, ParentType
|
||||
source?: Resolver<Maybe<ResolversTypes['JustReadFeedSource']>, ParentType, ContextType>;
|
||||
thumbnail?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
title?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
topic?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
topic?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
updatedAt?: Resolver<Maybe<ResolversTypes['Date']>, ParentType, ContextType>;
|
||||
url?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
wordCount?: Resolver<Maybe<ResolversTypes['Int']>, ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
|
||||
@ -6116,7 +6116,7 @@ export type JustReadFeedSuccessResolvers<ContextType = ResolverContext, ParentTy
|
||||
|
||||
export type JustReadFeedTopicResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['JustReadFeedTopic'] = ResolversParentTypes['JustReadFeedTopic']> = {
|
||||
items?: Resolver<Array<ResolversTypes['JustReadFeedItem']>, ParentType, ContextType>;
|
||||
name?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
name?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
thumbnail?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
@ -6357,6 +6357,10 @@ export type MutationResolvers<ContextType = ResolverContext, ParentType extends
|
||||
uploadImportFile?: Resolver<ResolversTypes['UploadImportFileResult'], ParentType, ContextType, RequireFields<MutationUploadImportFileArgs, 'contentType' | 'type'>>;
|
||||
};
|
||||
|
||||
export type MySubscriptionRootTypeResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['MySubscriptionRootType'] = ResolversParentTypes['MySubscriptionRootType']> = {
|
||||
justReadFeed?: SubscriptionResolver<ResolversTypes['JustReadFeedResult'], "justReadFeed", ParentType, ContextType>;
|
||||
};
|
||||
|
||||
export type NewsletterEmailResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['NewsletterEmail'] = ResolversParentTypes['NewsletterEmail']> = {
|
||||
address?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
confirmationCode?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
@ -6448,7 +6452,6 @@ export type QueryResolvers<ContextType = ResolverContext, ParentType extends Res
|
||||
hello?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
integration?: Resolver<ResolversTypes['IntegrationResult'], ParentType, ContextType, RequireFields<QueryIntegrationArgs, 'name'>>;
|
||||
integrations?: Resolver<ResolversTypes['IntegrationsResult'], ParentType, ContextType>;
|
||||
justReadFeed?: Resolver<ResolversTypes['JustReadFeedResult'], ParentType, ContextType, Partial<QueryJustReadFeedArgs>>;
|
||||
labels?: Resolver<ResolversTypes['LabelsResult'], ParentType, ContextType>;
|
||||
me?: Resolver<Maybe<ResolversTypes['User']>, ParentType, ContextType>;
|
||||
newsletterEmails?: Resolver<ResolversTypes['NewsletterEmailsResult'], ParentType, ContextType>;
|
||||
@ -7032,28 +7035,29 @@ export type SubscribeSuccessResolvers<ContextType = ResolverContext, ParentType
|
||||
};
|
||||
|
||||
export type SubscriptionResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['Subscription'] = ResolversParentTypes['Subscription']> = {
|
||||
autoAddToLibrary?: SubscriptionResolver<Maybe<ResolversTypes['Boolean']>, "autoAddToLibrary", ParentType, ContextType>;
|
||||
count?: SubscriptionResolver<ResolversTypes['Int'], "count", ParentType, ContextType>;
|
||||
createdAt?: SubscriptionResolver<ResolversTypes['Date'], "createdAt", ParentType, ContextType>;
|
||||
description?: SubscriptionResolver<Maybe<ResolversTypes['String']>, "description", ParentType, ContextType>;
|
||||
failedAt?: SubscriptionResolver<Maybe<ResolversTypes['Date']>, "failedAt", ParentType, ContextType>;
|
||||
fetchContent?: SubscriptionResolver<ResolversTypes['Boolean'], "fetchContent", ParentType, ContextType>;
|
||||
fetchContentType?: SubscriptionResolver<ResolversTypes['FetchContentType'], "fetchContentType", ParentType, ContextType>;
|
||||
folder?: SubscriptionResolver<ResolversTypes['String'], "folder", ParentType, ContextType>;
|
||||
icon?: SubscriptionResolver<Maybe<ResolversTypes['String']>, "icon", ParentType, ContextType>;
|
||||
id?: SubscriptionResolver<ResolversTypes['ID'], "id", ParentType, ContextType>;
|
||||
isPrivate?: SubscriptionResolver<Maybe<ResolversTypes['Boolean']>, "isPrivate", ParentType, ContextType>;
|
||||
lastFetchedAt?: SubscriptionResolver<Maybe<ResolversTypes['Date']>, "lastFetchedAt", ParentType, ContextType>;
|
||||
mostRecentItemDate?: SubscriptionResolver<Maybe<ResolversTypes['Date']>, "mostRecentItemDate", ParentType, ContextType>;
|
||||
name?: SubscriptionResolver<ResolversTypes['String'], "name", ParentType, ContextType>;
|
||||
newsletterEmail?: SubscriptionResolver<Maybe<ResolversTypes['String']>, "newsletterEmail", ParentType, ContextType>;
|
||||
refreshedAt?: SubscriptionResolver<Maybe<ResolversTypes['Date']>, "refreshedAt", ParentType, ContextType>;
|
||||
status?: SubscriptionResolver<ResolversTypes['SubscriptionStatus'], "status", ParentType, ContextType>;
|
||||
type?: SubscriptionResolver<ResolversTypes['SubscriptionType'], "type", ParentType, ContextType>;
|
||||
unsubscribeHttpUrl?: SubscriptionResolver<Maybe<ResolversTypes['String']>, "unsubscribeHttpUrl", ParentType, ContextType>;
|
||||
unsubscribeMailTo?: SubscriptionResolver<Maybe<ResolversTypes['String']>, "unsubscribeMailTo", ParentType, ContextType>;
|
||||
updatedAt?: SubscriptionResolver<Maybe<ResolversTypes['Date']>, "updatedAt", ParentType, ContextType>;
|
||||
url?: SubscriptionResolver<Maybe<ResolversTypes['String']>, "url", ParentType, ContextType>;
|
||||
autoAddToLibrary?: Resolver<Maybe<ResolversTypes['Boolean']>, ParentType, ContextType>;
|
||||
count?: Resolver<ResolversTypes['Int'], ParentType, ContextType>;
|
||||
createdAt?: Resolver<ResolversTypes['Date'], ParentType, ContextType>;
|
||||
description?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
failedAt?: Resolver<Maybe<ResolversTypes['Date']>, ParentType, ContextType>;
|
||||
fetchContent?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType>;
|
||||
fetchContentType?: Resolver<ResolversTypes['FetchContentType'], ParentType, ContextType>;
|
||||
folder?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
icon?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
|
||||
isPrivate?: Resolver<Maybe<ResolversTypes['Boolean']>, ParentType, ContextType>;
|
||||
lastFetchedAt?: Resolver<Maybe<ResolversTypes['Date']>, ParentType, ContextType>;
|
||||
mostRecentItemDate?: Resolver<Maybe<ResolversTypes['Date']>, ParentType, ContextType>;
|
||||
name?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
newsletterEmail?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
refreshedAt?: Resolver<Maybe<ResolversTypes['Date']>, ParentType, ContextType>;
|
||||
status?: Resolver<ResolversTypes['SubscriptionStatus'], ParentType, ContextType>;
|
||||
type?: Resolver<ResolversTypes['SubscriptionType'], ParentType, ContextType>;
|
||||
unsubscribeHttpUrl?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
unsubscribeMailTo?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
updatedAt?: Resolver<Maybe<ResolversTypes['Date']>, ParentType, ContextType>;
|
||||
url?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
|
||||
export type SubscriptionsErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['SubscriptionsError'] = ResolversParentTypes['SubscriptionsError']> = {
|
||||
@ -7676,6 +7680,7 @@ export type Resolvers<ContextType = ResolverContext> = {
|
||||
MoveToFolderResult?: MoveToFolderResultResolvers<ContextType>;
|
||||
MoveToFolderSuccess?: MoveToFolderSuccessResolvers<ContextType>;
|
||||
Mutation?: MutationResolvers<ContextType>;
|
||||
MySubscriptionRootType?: MySubscriptionRootTypeResolvers<ContextType>;
|
||||
NewsletterEmail?: NewsletterEmailResolvers<ContextType>;
|
||||
NewsletterEmailsError?: NewsletterEmailsErrorResolvers<ContextType>;
|
||||
NewsletterEmailsResult?: NewsletterEmailsResultResolvers<ContextType>;
|
||||
|
||||
@ -1,3 +1,9 @@
|
||||
schema {
|
||||
query: Query
|
||||
mutation: Mutation
|
||||
subscription: MySubscriptionRootType
|
||||
}
|
||||
|
||||
directive @sanitize(allowedTags: [String], maxLength: Int, minLength: Int, pattern: String) on INPUT_FIELD_DEFINITION
|
||||
|
||||
type AddDiscoverFeedError {
|
||||
@ -1248,9 +1254,10 @@ type JustReadFeedItem {
|
||||
source: JustReadFeedSource
|
||||
thumbnail: String
|
||||
title: String!
|
||||
topic: String!
|
||||
topic: String
|
||||
updatedAt: Date
|
||||
url: String!
|
||||
wordCount: Int
|
||||
}
|
||||
|
||||
union JustReadFeedResult = JustReadFeedError | JustReadFeedSuccess
|
||||
@ -1270,7 +1277,7 @@ type JustReadFeedSuccess {
|
||||
|
||||
type JustReadFeedTopic {
|
||||
items: [JustReadFeedItem!]!
|
||||
name: String!
|
||||
name: String
|
||||
thumbnail: String
|
||||
}
|
||||
|
||||
@ -1555,6 +1562,10 @@ type Mutation {
|
||||
uploadImportFile(contentType: String!, type: UploadImportFileType!): UploadImportFileResult!
|
||||
}
|
||||
|
||||
type MySubscriptionRootType {
|
||||
justReadFeed: JustReadFeedResult!
|
||||
}
|
||||
|
||||
type NewsletterEmail {
|
||||
address: String!
|
||||
confirmationCode: String
|
||||
@ -1692,7 +1703,6 @@ type Query {
|
||||
hello: String
|
||||
integration(name: String!): IntegrationResult!
|
||||
integrations: IntegrationsResult!
|
||||
justReadFeed(after: String, first: Int, language: String, location: String): JustReadFeedResult!
|
||||
labels: LabelsResult!
|
||||
me: User
|
||||
newsletterEmails: NewsletterEmailsResult!
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
import { LibraryItem } from '../entity/library_item'
|
||||
import { PublicItem } from '../entity/public_item'
|
||||
import { User } from '../entity/user'
|
||||
import { redisDataSource } from '../redis_data_source'
|
||||
import { findUnseenPublicItems } from '../services/just_read_feed'
|
||||
import { searchLibraryItems } from '../services/library_item'
|
||||
import { findActiveUser } from '../services/user'
|
||||
import { logger } from '../utils/logger'
|
||||
|
||||
export const UPDATE_JUST_READ_FEED_JOB = 'UPDATE_JUST_READ_FEED_JOB'
|
||||
@ -15,18 +17,22 @@ interface JustReadFeedItem {
|
||||
id: string
|
||||
title: string
|
||||
url: string
|
||||
topic: string
|
||||
createdAt: Date
|
||||
sourceName: string
|
||||
|
||||
siteName?: string
|
||||
topic?: string
|
||||
thumbnail?: string
|
||||
previewContent?: string
|
||||
languageCode?: string
|
||||
author?: string
|
||||
dir?: string
|
||||
publishedAt?: Date
|
||||
subscription?: string
|
||||
wordCount?: number
|
||||
sourceIcon?: string
|
||||
}
|
||||
|
||||
interface JustReadFeedTopic {
|
||||
name: string
|
||||
name?: string
|
||||
items: Array<JustReadFeedItem>
|
||||
thumbnail: string
|
||||
}
|
||||
@ -35,7 +41,10 @@ interface JustReadFeed {
|
||||
topics: Array<JustReadFeedTopic>
|
||||
}
|
||||
|
||||
const libraryItemToFeedItem = (item: LibraryItem): JustReadFeedItem => ({
|
||||
const libraryItemToFeedItem = (
|
||||
user: User,
|
||||
item: LibraryItem
|
||||
): JustReadFeedItem => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
url: item.originalUrl,
|
||||
@ -44,9 +53,12 @@ const libraryItemToFeedItem = (item: LibraryItem): JustReadFeedItem => ({
|
||||
languageCode: item.itemLanguage || undefined, // TODO: map to language code
|
||||
author: item.author || undefined,
|
||||
dir: item.directionality || undefined,
|
||||
publishedAt: item.publishedAt || undefined,
|
||||
subscription: item.subscription || undefined,
|
||||
createdAt: item.createdAt,
|
||||
topic: item.topic,
|
||||
wordCount: item.wordCount || undefined,
|
||||
sourceIcon: user.profile.pictureUrl || undefined,
|
||||
sourceName: user.name,
|
||||
siteName: item.siteName || undefined,
|
||||
})
|
||||
|
||||
const publicItemToFeedItem = (item: PublicItem): JustReadFeedItem => ({
|
||||
@ -58,14 +70,23 @@ const publicItemToFeedItem = (item: PublicItem): JustReadFeedItem => ({
|
||||
languageCode: item.languageCode,
|
||||
author: item.author,
|
||||
dir: item.dir,
|
||||
publishedAt: item.publishedAt,
|
||||
subscription: item.source_name,
|
||||
createdAt: item.createdAt,
|
||||
topic: item.topic,
|
||||
wordCount: item.wordCount || undefined,
|
||||
sourceIcon: item.sourceIcon,
|
||||
sourceName: item.sourceName,
|
||||
siteName: item.siteName,
|
||||
})
|
||||
|
||||
interface FeedItemScore {
|
||||
id: string
|
||||
score: number
|
||||
}
|
||||
|
||||
const selectCandidates = async (
|
||||
userId: string
|
||||
user: User
|
||||
): Promise<Array<JustReadFeedItem>> => {
|
||||
const userId = user.id
|
||||
// get last 100 library items saved and not seen by user
|
||||
const libraryItems = await searchLibraryItems(
|
||||
{
|
||||
@ -78,7 +99,7 @@ const selectCandidates = async (
|
||||
|
||||
// map library items to candidates
|
||||
const privateCandidates: Array<JustReadFeedItem> = libraryItems.map(
|
||||
libraryItemToFeedItem
|
||||
(libraryItem) => libraryItemToFeedItem(user, libraryItem)
|
||||
)
|
||||
|
||||
// get candidates from public inventory
|
||||
@ -98,11 +119,12 @@ const rankFeedItems = async (
|
||||
feedItems: Array<JustReadFeedItem>
|
||||
): Promise<Array<JustReadFeedItem>> => {
|
||||
if (feedItems.length <= 10) {
|
||||
// no need to rank if there are less than 10 candidates
|
||||
return feedItems
|
||||
}
|
||||
|
||||
// TODO: rank candidates
|
||||
const API_URL = 'https://rank.omnivore.app'
|
||||
// TODO: get score of candidates
|
||||
const API_URL = 'https://score.omnivore.app' // fake URL
|
||||
|
||||
const response = await fetch(API_URL, {
|
||||
method: 'POST',
|
||||
@ -113,18 +135,25 @@ const rankFeedItems = async (
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to rank candidates: ${response.statusText}`)
|
||||
throw new Error(`Failed to score candidates: ${response.statusText}`)
|
||||
}
|
||||
|
||||
return response.json() as Promise<Array<JustReadFeedItem>>
|
||||
const scores = (await response.json()) as Array<FeedItemScore>
|
||||
|
||||
// rank candidates by score in ascending order
|
||||
return feedItems.sort((a, b) => {
|
||||
const scoreA = scores.find((score) => score.id === a.id)?.score || 0
|
||||
const scoreB = scores.find((score) => score.id === b.id)?.score || 0
|
||||
|
||||
return scoreA - scoreB
|
||||
})
|
||||
}
|
||||
|
||||
const redisKey = (userId: string) => `just-read-feed:${userId}`
|
||||
const MAX_FEED_ITEMS = 500
|
||||
|
||||
export const getJustReadFeed = async (
|
||||
userId: string,
|
||||
limit: number,
|
||||
offset: number
|
||||
userId: string
|
||||
): Promise<JustReadFeed> => {
|
||||
const redisClient = redisDataSource.redisClient
|
||||
if (!redisClient) {
|
||||
@ -133,7 +162,7 @@ export const getJustReadFeed = async (
|
||||
|
||||
const key = redisKey(userId)
|
||||
|
||||
const results = await redisClient.lrange(key, offset, offset + limit - 1)
|
||||
const results = await redisClient.zrevrange(key, 0, MAX_FEED_ITEMS)
|
||||
|
||||
const feedItems = results.map((item) => JSON.parse(item) as JustReadFeedItem)
|
||||
|
||||
@ -155,8 +184,8 @@ export const getJustReadFeed = async (
|
||||
return { topics }
|
||||
}
|
||||
|
||||
const prependItemsToFeed = async (
|
||||
candidates: Array<JustReadFeedItem>,
|
||||
const appendItemsToFeed = async (
|
||||
feedItems: Array<JustReadFeedItem>,
|
||||
userId: string
|
||||
) => {
|
||||
const redisClient = redisDataSource.redisClient
|
||||
@ -166,26 +195,43 @@ const prependItemsToFeed = async (
|
||||
|
||||
const key = redisKey(userId)
|
||||
|
||||
// store candidates in redis sorted set
|
||||
const pipeline = redisClient.pipeline()
|
||||
candidates.forEach((candidate) =>
|
||||
pipeline.lpush(key, JSON.stringify(candidate))
|
||||
)
|
||||
// keep only the first 100 items
|
||||
pipeline.ltrim(key, 0, 99)
|
||||
|
||||
const scoreMembers = feedItems.flatMap((item) => [
|
||||
Date.now() + 86_400_000, // items expire in 24 hours
|
||||
JSON.stringify(item),
|
||||
])
|
||||
// add candidates to the sorted set
|
||||
pipeline.zadd(key, ...scoreMembers)
|
||||
|
||||
// remove expired items and keep only the top 500
|
||||
pipeline.zremrangebyrank(key, 0, -(MAX_FEED_ITEMS + 1))
|
||||
pipeline.zremrangebyscore(key, '-inf', Date.now())
|
||||
|
||||
await pipeline.exec()
|
||||
}
|
||||
|
||||
const updateJustReadFeed = async (data: UpdateJustReadFeedJobData) => {
|
||||
export const updateJustReadFeed = async (data: UpdateJustReadFeedJobData) => {
|
||||
const { userId } = data
|
||||
const user = await findActiveUser(userId)
|
||||
if (!user) {
|
||||
logger.error(`User ${userId} not found`)
|
||||
return
|
||||
}
|
||||
|
||||
logger.info(`Updating just read feed for user ${userId}`)
|
||||
|
||||
const feedItems = await selectCandidates(userId)
|
||||
const feedItems = await selectCandidates(user)
|
||||
logger.info(`Found ${feedItems.length} candidates`)
|
||||
|
||||
// TODO: integrity check on candidates?
|
||||
|
||||
const rankedFeedItems = await rankFeedItems(feedItems)
|
||||
|
||||
await prependItemsToFeed(rankedFeedItems, userId)
|
||||
// TODO: filtering
|
||||
// get top 100 ranked feed items
|
||||
const filteredFeedItems = rankedFeedItems.slice(0, 100)
|
||||
|
||||
await appendItemsToFeed(filteredFeedItems, userId)
|
||||
}
|
||||
|
||||
@ -59,6 +59,10 @@ import {
|
||||
UPDATE_HIGHLIGHT_JOB,
|
||||
UPDATE_LABELS_JOB,
|
||||
} from './jobs/update_db'
|
||||
import {
|
||||
updateJustReadFeed,
|
||||
UPDATE_JUST_READ_FEED_JOB,
|
||||
} from './jobs/update_just_read_feed'
|
||||
import { updatePDFContentJob } from './jobs/update_pdf_content'
|
||||
import { uploadContentJob, UPLOAD_CONTENT_JOB } from './jobs/upload_content'
|
||||
import { redisDataSource } from './redis_data_source'
|
||||
@ -185,6 +189,8 @@ export const createWorker = (connection: ConnectionOptions) =>
|
||||
return createDigest(job.data)
|
||||
case UPLOAD_CONTENT_JOB:
|
||||
return uploadContentJob(job.data)
|
||||
case UPDATE_JUST_READ_FEED_JOB:
|
||||
return updateJustReadFeed(job.data)
|
||||
default:
|
||||
logger.warning(`[queue-processor] unhandled job: ${job.name}`)
|
||||
}
|
||||
|
||||
@ -1,31 +1,13 @@
|
||||
import {
|
||||
JustReadFeedError,
|
||||
JustReadFeedSuccess,
|
||||
QueryJustReadFeedArgs,
|
||||
} from '../../generated/graphql'
|
||||
import { JustReadFeedError, JustReadFeedSuccess } from '../../generated/graphql'
|
||||
import { getJustReadFeed } from '../../jobs/update_just_read_feed'
|
||||
import { enqueueUpdateJustReadFeed } from '../../utils/createTask'
|
||||
import { authorized } from '../../utils/gql-utils'
|
||||
|
||||
export const justReadFeedResolver = authorized<
|
||||
JustReadFeedSuccess,
|
||||
JustReadFeedError,
|
||||
QueryJustReadFeedArgs
|
||||
>(async (_, { first, after }, { uid, log }) => {
|
||||
first = first || 10
|
||||
after = after || '0'
|
||||
const offset = parseInt(after, 10)
|
||||
|
||||
const feed = await getJustReadFeed(uid, first, offset)
|
||||
if (feed.topics.length === 0) {
|
||||
log.info('No feed items found, updating feed')
|
||||
|
||||
await enqueueUpdateJustReadFeed({ userId: uid })
|
||||
|
||||
return {
|
||||
topics: [],
|
||||
}
|
||||
}
|
||||
JustReadFeedError
|
||||
>(async (_, __, { uid, log }) => {
|
||||
const feed = await getJustReadFeed(uid)
|
||||
log.info('Just read feed fetched')
|
||||
|
||||
return feed
|
||||
})
|
||||
|
||||
@ -3114,7 +3114,7 @@ const schema = gql`
|
||||
type JustReadFeedItem {
|
||||
id: ID!
|
||||
title: String!
|
||||
topic: String!
|
||||
topic: String
|
||||
url: String!
|
||||
thumbnail: String
|
||||
publishedAt: Date
|
||||
@ -3131,10 +3131,11 @@ const schema = gql`
|
||||
languageCode: String
|
||||
dir: String
|
||||
seen_at: Date
|
||||
wordCount: Int
|
||||
}
|
||||
|
||||
type JustReadFeedTopic {
|
||||
name: String!
|
||||
name: String
|
||||
items: [JustReadFeedItem!]!
|
||||
thumbnail: String
|
||||
}
|
||||
@ -3154,6 +3155,10 @@ const schema = gql`
|
||||
|
||||
union JustReadFeedResult = JustReadFeedSuccess | JustReadFeedError
|
||||
|
||||
type MySubscriptionRootType {
|
||||
justReadFeed: JustReadFeedResult!
|
||||
}
|
||||
|
||||
# Mutations
|
||||
type Mutation {
|
||||
googleLogin(input: GoogleLoginInput!): LoginResult!
|
||||
@ -3349,12 +3354,12 @@ const schema = gql`
|
||||
feeds(input: FeedsInput!): FeedsResult!
|
||||
discoverFeeds: DiscoverFeedResult!
|
||||
scanFeeds(input: ScanFeedsInput!): ScanFeedsResult!
|
||||
justReadFeed(
|
||||
location: String
|
||||
language: String
|
||||
first: Int
|
||||
after: String
|
||||
): JustReadFeedResult!
|
||||
}
|
||||
|
||||
schema {
|
||||
query: Query
|
||||
mutation: Mutation
|
||||
subscription: MySubscriptionRootType
|
||||
}
|
||||
`
|
||||
|
||||
|
||||
@ -12,8 +12,8 @@ CREATE TABLE omnivore.public_item_source (
|
||||
url TEXT NOT NULL,
|
||||
language_codes TEXT[] NOT NULL,
|
||||
approved BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TRIGGER update_public_item_source_modtime BEFORE UPDATE ON omnivore.public_item_source FOR EACH ROW EXECUTE PROCEDURE update_updated_at_column();
|
||||
@ -26,7 +26,7 @@ CREATE TABLE omnivore.public_item (
|
||||
type TEXT NOT NULL, -- public feeds, newsletters, or user recommended
|
||||
title TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
topic TEXT NOT NULL,
|
||||
topic TEXT,
|
||||
approved BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
thumbnail TEXT,
|
||||
preview_content TEXT,
|
||||
@ -34,8 +34,10 @@ CREATE TABLE omnivore.public_item (
|
||||
author TEXT,
|
||||
dir TEXT,
|
||||
published_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
word_count INT,
|
||||
site_name TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TRIGGER update_public_item_modtime BEFORE UPDATE ON omnivore.public_item FOR EACH ROW EXECUTE PROCEDURE update_updated_at_column();
|
||||
@ -47,8 +49,8 @@ CREATE TABLE omnivore.public_item_stats (
|
||||
save_count INT NOT NULL DEFAULT 0,
|
||||
like_count INT NOT NULL DEFAULT 0,
|
||||
broadcast_count INT NOT NULL DEFAULT 0,
|
||||
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX public_item_stats_public_item_id_idx ON omnivore.public_item_stats(public_item_id);
|
||||
@ -63,6 +65,7 @@ CREATE TABLE omnivore.public_item_interactions (
|
||||
liked_at TIMESTAMPTZ,
|
||||
broadcasted_at TIMESTAMPTZ,
|
||||
seen_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
digested_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@ -74,6 +77,7 @@ CREATE TRIGGER update_public_item_interactions_modtime BEFORE UPDATE ON omnivore
|
||||
|
||||
ALTER TABLE omnivore.library_item
|
||||
ADD COLUMN seen_at timestamptz,
|
||||
ADD COLUMN topic TEXT NOT NULL;
|
||||
ADD COLUMN digested_at timestamptz,
|
||||
ADD COLUMN topic TEXT;
|
||||
|
||||
COMMIT;
|
||||
|
||||
@ -11,6 +11,7 @@ DROP TABLE omnivore.public_item_source;
|
||||
|
||||
ALTER TABLE omnivore.library_item
|
||||
DROP COLUMN seen_at,
|
||||
DROP COLUMN digested_at,
|
||||
DROP COLUMN topic;
|
||||
|
||||
COMMIT;
|
||||
|
||||
Reference in New Issue
Block a user