Initial commit
This commit is contained in:
@@ -20,6 +20,13 @@ const resolveRatingForComment = async ({ creatorId, targetUserId, rate }) => {
|
||||
const existingRating = await findExistingUserRating(creatorId, targetUserId)
|
||||
|
||||
if (existingRating) {
|
||||
if (rate && rate >= 1 && rate <= 5) {
|
||||
return {
|
||||
ratingValue: null,
|
||||
isNewRating: false,
|
||||
alreadyRated: true,
|
||||
}
|
||||
}
|
||||
return { ratingValue: null, isNewRating: false }
|
||||
}
|
||||
|
||||
|
||||
543
utils/exploreAlgorithm.js
Normal file
543
utils/exploreAlgorithm.js
Normal file
@@ -0,0 +1,543 @@
|
||||
const LikeModel = require('../models/LikeModel')
|
||||
const CommentModel = require('../models/CommentModel')
|
||||
const OfferModel = require('../models/OfferModel')
|
||||
const UserModel = require('../models/UserModel')
|
||||
const PostModel = require('../models/PostModel')
|
||||
const ExploreInteractionModel = require('../models/ExploreInteractionModel')
|
||||
|
||||
const EXPERTISE_ALIASES = {
|
||||
مدل: ['مدل', 'مدلینگ'],
|
||||
مدلینگ: ['مدل', 'مدلینگ'],
|
||||
عکاس: ['عکاس', 'عکاسی'],
|
||||
عکاسی: ['عکاس', 'عکاسی'],
|
||||
آرایشگر: ['آرایشگر', 'زیبایی'],
|
||||
زیبایی: ['آرایشگر', 'زیبایی']
|
||||
}
|
||||
|
||||
const ACTION_WEIGHTS = {
|
||||
offer: 10,
|
||||
profile_visit: 6,
|
||||
share: 5,
|
||||
comment: 4,
|
||||
like: 3,
|
||||
watch_complete: 3.5,
|
||||
view: 2,
|
||||
skip: -1.5,
|
||||
dwell: 1.5
|
||||
}
|
||||
|
||||
function expertiseMatches(userExpertise, authorExpertise) {
|
||||
if (!userExpertise || !authorExpertise) return false
|
||||
const aliases = EXPERTISE_ALIASES[userExpertise] || [userExpertise]
|
||||
return aliases.includes(authorExpertise)
|
||||
}
|
||||
|
||||
function interactionDecay(createdAt) {
|
||||
const ageDays =
|
||||
(Date.now() - new Date(createdAt).getTime()) / (1000 * 60 * 60 * 24)
|
||||
return Math.exp(-ageDays / 21)
|
||||
}
|
||||
|
||||
function engagementVelocity(likesCount, commentsCount, ageHours) {
|
||||
const raw = likesCount * 0.55 + commentsCount * 1.1
|
||||
const ageFactor = Math.max(1, ageHours / 24)
|
||||
return raw / Math.sqrt(ageFactor)
|
||||
}
|
||||
|
||||
function shuffleArray(items, seed = Date.now()) {
|
||||
const copy = [...items]
|
||||
let state = seed % 2147483647 || 1
|
||||
const random = () => {
|
||||
state = (state * 16807) % 2147483647
|
||||
return (state - 1) / 2147483646
|
||||
}
|
||||
for (let i = copy.length - 1; i > 0; i -= 1) {
|
||||
const j = Math.floor(random() * (i + 1))
|
||||
;[copy[i], copy[j]] = [copy[j], copy[i]]
|
||||
}
|
||||
return copy
|
||||
}
|
||||
|
||||
function interleaveWeighted(primary, secondary, primaryRatio = 0.7) {
|
||||
if (!primary.length) return shuffleArray(secondary)
|
||||
if (!secondary.length) return shuffleArray(primary)
|
||||
|
||||
const result = []
|
||||
let primaryIdx = 0
|
||||
let secondaryIdx = 0
|
||||
const primaryBurst = Math.max(2, Math.round(primaryRatio * 10))
|
||||
const secondaryBurst = Math.max(1, 10 - primaryBurst)
|
||||
let primaryCount = 0
|
||||
let secondaryCount = 0
|
||||
|
||||
while (primaryIdx < primary.length || secondaryIdx < secondary.length) {
|
||||
const shouldTakeSecondary =
|
||||
secondaryIdx < secondary.length &&
|
||||
(primaryIdx >= primary.length ||
|
||||
(secondaryCount < secondaryBurst &&
|
||||
primaryCount >= primaryBurst))
|
||||
|
||||
if (shouldTakeSecondary) {
|
||||
result.push(secondary[secondaryIdx])
|
||||
secondaryIdx += 1
|
||||
secondaryCount += 1
|
||||
primaryCount = 0
|
||||
continue
|
||||
}
|
||||
|
||||
if (primaryIdx < primary.length) {
|
||||
result.push(primary[primaryIdx])
|
||||
primaryIdx += 1
|
||||
primaryCount += 1
|
||||
secondaryCount = 0
|
||||
continue
|
||||
}
|
||||
|
||||
if (secondaryIdx < secondary.length) {
|
||||
result.push(secondary[secondaryIdx])
|
||||
secondaryIdx += 1
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function matchesSeedCategory(author, seedAuthor) {
|
||||
if (!seedAuthor || !author) return false
|
||||
|
||||
if (
|
||||
seedAuthor.expertise &&
|
||||
expertiseMatches(seedAuthor.expertise, author.expertise)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (
|
||||
seedAuthor.sub_expertise?.length &&
|
||||
author.sub_expertise?.some((s) => seedAuthor.sub_expertise.includes(s))
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function hasEnoughUserSignals(signals) {
|
||||
const meaningful =
|
||||
(signals.likedPostIds?.length || 0) +
|
||||
(signals.commentedPostIds?.length || 0) +
|
||||
Object.keys(signals.offerAuthors || {}).length
|
||||
|
||||
const affinityStrength = Object.values(signals.authorAffinity || {}).reduce(
|
||||
(sum, value) => sum + value,
|
||||
0
|
||||
)
|
||||
|
||||
return meaningful >= 8 || affinityStrength >= 22
|
||||
}
|
||||
|
||||
function scoreColdStartPost(post, author, seedPost, seedAuthor) {
|
||||
let score = Math.random() * 80
|
||||
|
||||
if (matchesSeedCategory(author, seedAuthor)) {
|
||||
score += 50
|
||||
}
|
||||
|
||||
if (
|
||||
seedAuthor?.sub_expertise?.length &&
|
||||
author?.sub_expertise?.some((s) => seedAuthor.sub_expertise.includes(s))
|
||||
) {
|
||||
score += 22
|
||||
}
|
||||
|
||||
if (seedPost?.type && post.type === seedPost.type) {
|
||||
score += 14
|
||||
}
|
||||
|
||||
const likesCount = post.likes ? post.likes.length : post.likesCount || 0
|
||||
const commentsCount = post.commentsCount || 0
|
||||
const ageHours =
|
||||
(Date.now() - new Date(post.createdAt).getTime()) / (1000 * 60 * 60)
|
||||
score += engagementVelocity(likesCount, commentsCount, ageHours) * 0.25
|
||||
|
||||
return score
|
||||
}
|
||||
|
||||
function rankColdStartReels(pool, usersById, seedAuthor, seedPost) {
|
||||
const sameCategory = []
|
||||
const others = []
|
||||
|
||||
pool.forEach((post) => {
|
||||
const author = usersById[String(post.user_id)] || {}
|
||||
if (matchesSeedCategory(author, seedAuthor)) {
|
||||
sameCategory.push(post)
|
||||
} else {
|
||||
others.push(post)
|
||||
}
|
||||
})
|
||||
|
||||
const seedKey = seedPost?._id ? String(seedPost._id) : '0'
|
||||
const shuffledSame = shuffleArray(sameCategory, seedKey.length * 997)
|
||||
const shuffledOthers = shuffleArray(others, seedKey.length * 499)
|
||||
|
||||
return interleaveWeighted(shuffledSame, shuffledOthers, 0.72)
|
||||
}
|
||||
|
||||
function diversifyByAuthor(posts, maxPerPage = 2) {
|
||||
const authorCounts = {}
|
||||
const picked = []
|
||||
const deferred = []
|
||||
|
||||
for (const post of posts) {
|
||||
const authorId = String(post.user_id)
|
||||
const count = authorCounts[authorId] || 0
|
||||
if (count < maxPerPage) {
|
||||
picked.push(post)
|
||||
authorCounts[authorId] = count + 1
|
||||
} else {
|
||||
deferred.push(post)
|
||||
}
|
||||
}
|
||||
|
||||
return [...picked, ...deferred]
|
||||
}
|
||||
|
||||
function blendFreshIntoRanked(rankedPosts, allPosts, ratio = 0.12) {
|
||||
if (!rankedPosts.length) return rankedPosts
|
||||
|
||||
const rankedIds = new Set(rankedPosts.map((p) => String(p._id)))
|
||||
const fresh = [...allPosts]
|
||||
.filter((p) => !rankedIds.has(String(p._id)))
|
||||
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
|
||||
|
||||
if (!fresh.length) return rankedPosts
|
||||
|
||||
const result = [...rankedPosts]
|
||||
const step = Math.max(4, Math.round(1 / ratio))
|
||||
|
||||
let freshIdx = 0
|
||||
for (let i = step - 1; i < result.length && freshIdx < fresh.length; i += step) {
|
||||
result.splice(i, 0, fresh[freshIdx])
|
||||
freshIdx += 1
|
||||
}
|
||||
|
||||
while (freshIdx < fresh.length && result.length < rankedPosts.length + fresh.length) {
|
||||
result.push(fresh[freshIdx])
|
||||
freshIdx += 1
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
async function buildUserExploreSignals(userId) {
|
||||
if (!userId) {
|
||||
return {
|
||||
userExpertise: null,
|
||||
userSubExpertise: [],
|
||||
contentTypeWeight: { image: 0, video: 0, academy: 0 },
|
||||
authorAffinity: {},
|
||||
offerAuthors: {},
|
||||
viewedPosts: {},
|
||||
viewedAuthors: {},
|
||||
likedPostIds: [],
|
||||
commentedPostIds: [],
|
||||
isColdStart: true
|
||||
}
|
||||
}
|
||||
|
||||
const user = await UserModel.findById(userId)
|
||||
.select('expertise sub_expertise')
|
||||
.lean()
|
||||
|
||||
const [likes, comments, offers, interactions] = await Promise.all([
|
||||
LikeModel.find({ userId }).select('postId createdAt').lean(),
|
||||
CommentModel.find({
|
||||
creator: userId,
|
||||
comment_for: 'post',
|
||||
post: { $ne: null }
|
||||
})
|
||||
.select('post user createdAt')
|
||||
.lean(),
|
||||
OfferModel.find({ sender: userId }).select('receiver createdAt').lean(),
|
||||
ExploreInteractionModel.find({ viewerId: userId })
|
||||
.sort({ createdAt: -1 })
|
||||
.limit(1200)
|
||||
.lean()
|
||||
])
|
||||
|
||||
const likedPostIds = likes.map((l) => String(l.postId))
|
||||
const likedPosts = likedPostIds.length
|
||||
? await PostModel.find({ _id: { $in: likedPostIds } })
|
||||
.select('user_id type')
|
||||
.lean()
|
||||
: []
|
||||
|
||||
const contentTypeWeight = { image: 0, video: 0, academy: 0 }
|
||||
const authorAffinity = {}
|
||||
const offerAuthors = {}
|
||||
const viewedPosts = {}
|
||||
const viewedAuthors = {}
|
||||
|
||||
const bumpAuthor = (authorId, weight) => {
|
||||
if (!authorId) return
|
||||
const key = String(authorId)
|
||||
authorAffinity[key] = (authorAffinity[key] || 0) + weight
|
||||
}
|
||||
|
||||
const bumpContent = (type, weight) => {
|
||||
if (!type) return
|
||||
contentTypeWeight[type] = (contentTypeWeight[type] || 0) + weight
|
||||
}
|
||||
|
||||
const bumpViewedPost = (postId, weight) => {
|
||||
if (!postId) return
|
||||
const key = String(postId)
|
||||
viewedPosts[key] = Math.max(viewedPosts[key] || 0, weight)
|
||||
}
|
||||
|
||||
const bumpViewedAuthor = (authorId, weight) => {
|
||||
if (!authorId) return
|
||||
const key = String(authorId)
|
||||
viewedAuthors[key] = Math.max(viewedAuthors[key] || 0, weight)
|
||||
}
|
||||
|
||||
offers.forEach((offer) => {
|
||||
const decay = interactionDecay(offer.createdAt)
|
||||
const key = String(offer.receiver)
|
||||
offerAuthors[key] = (offerAuthors[key] || 0) + 12 * decay
|
||||
bumpAuthor(offer.receiver, 8 * decay)
|
||||
})
|
||||
|
||||
likedPosts.forEach((post) => {
|
||||
bumpAuthor(post.user_id, 4)
|
||||
bumpContent(post.type, 2)
|
||||
})
|
||||
|
||||
comments.forEach((comment) => {
|
||||
const decay = interactionDecay(comment.createdAt)
|
||||
bumpAuthor(comment.user, 5 * decay)
|
||||
})
|
||||
|
||||
interactions.forEach((item) => {
|
||||
const decay = interactionDecay(item.createdAt)
|
||||
const base =
|
||||
ACTION_WEIGHTS[item.action] ?? ACTION_WEIGHTS.view
|
||||
const weight = base * decay
|
||||
|
||||
if (item.contentType) bumpContent(item.contentType, weight * 0.5)
|
||||
if (item.authorId) bumpAuthor(item.authorId, weight)
|
||||
|
||||
if (
|
||||
item.targetType === 'post' &&
|
||||
['view', 'watch_complete', 'skip', 'dwell'].includes(item.action)
|
||||
) {
|
||||
const viewWeight =
|
||||
item.action === 'watch_complete'
|
||||
? 1
|
||||
: item.action === 'skip'
|
||||
? 0.85
|
||||
: item.action === 'dwell'
|
||||
? 0.55
|
||||
: 0.35
|
||||
bumpViewedPost(item.targetId, viewWeight * decay)
|
||||
if (item.authorId) bumpViewedAuthor(item.authorId, viewWeight * decay * 0.6)
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
userExpertise: user?.expertise || null,
|
||||
userSubExpertise: user?.sub_expertise || [],
|
||||
contentTypeWeight,
|
||||
authorAffinity,
|
||||
offerAuthors,
|
||||
viewedPosts,
|
||||
viewedAuthors,
|
||||
likedPostIds,
|
||||
commentedPostIds: comments.map((c) => String(c.post)),
|
||||
isColdStart: !hasEnoughUserSignals({
|
||||
likedPostIds,
|
||||
commentedPostIds: comments.map((c) => String(c.post)),
|
||||
offerAuthors,
|
||||
authorAffinity
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function scorePost(post, author, signals, options = {}) {
|
||||
const { reelsMode = false, seedPost = null, seedAuthor = null } = options
|
||||
let score = Math.random() * 4
|
||||
|
||||
const postId = String(post._id)
|
||||
const authorId = String(post.user_id)
|
||||
const likesCount = post.likes ? post.likes.length : post.likesCount || 0
|
||||
const commentsCount = post.commentsCount || 0
|
||||
|
||||
const ageHours =
|
||||
(Date.now() - new Date(post.createdAt).getTime()) / (1000 * 60 * 60)
|
||||
|
||||
score += engagementVelocity(likesCount, commentsCount, ageHours)
|
||||
|
||||
if (signals.userExpertise && expertiseMatches(signals.userExpertise, author?.expertise)) {
|
||||
score += 24
|
||||
}
|
||||
|
||||
if (
|
||||
signals.userSubExpertise?.length &&
|
||||
author?.sub_expertise?.some((s) => signals.userSubExpertise.includes(s))
|
||||
) {
|
||||
score += 16
|
||||
}
|
||||
|
||||
if (post.type && signals.contentTypeWeight[post.type]) {
|
||||
score += signals.contentTypeWeight[post.type] * 2
|
||||
}
|
||||
|
||||
score += signals.authorAffinity[authorId] || 0
|
||||
score += signals.offerAuthors[authorId] || 0
|
||||
|
||||
if (signals.likedPostIds?.includes(postId)) score -= 35
|
||||
if (signals.commentedPostIds?.includes(postId)) score -= 28
|
||||
|
||||
const viewedWeight = signals.viewedPosts?.[postId] || 0
|
||||
if (viewedWeight > 0) score -= 8 + viewedWeight * 22
|
||||
|
||||
const authorFatigue = signals.viewedAuthors?.[authorId] || 0
|
||||
if (authorFatigue > 0) score -= authorFatigue * 6
|
||||
|
||||
if (ageHours < 24) score += 6
|
||||
else if (ageHours < 48) score += 4
|
||||
else if (ageHours < 168) score += 2
|
||||
|
||||
if (reelsMode && post.type === 'video') score += 14
|
||||
|
||||
if (seedPost) {
|
||||
if (String(seedPost.user_id) === authorId) score += 20
|
||||
if (
|
||||
seedAuthor?.expertise &&
|
||||
expertiseMatches(seedAuthor.expertise, author?.expertise)
|
||||
) {
|
||||
score += 18
|
||||
}
|
||||
if (
|
||||
seedAuthor?.sub_expertise?.length &&
|
||||
author?.sub_expertise?.some((s) => seedAuthor.sub_expertise.includes(s))
|
||||
) {
|
||||
score += 8
|
||||
}
|
||||
if (seedPost.type && post.type === seedPost.type) score += 6
|
||||
}
|
||||
|
||||
return score
|
||||
}
|
||||
|
||||
async function rankPostsForFeed({
|
||||
posts,
|
||||
usersById,
|
||||
userId,
|
||||
seedPostId,
|
||||
reelsMode = false,
|
||||
blendFresh = false
|
||||
}) {
|
||||
const signals = await buildUserExploreSignals(userId)
|
||||
const coldStart = signals.isColdStart
|
||||
|
||||
let seedPost = null
|
||||
let seedAuthor = null
|
||||
if (seedPostId) {
|
||||
seedPost = posts.find((p) => String(p._id) === String(seedPostId)) || null
|
||||
if (!seedPost) {
|
||||
seedPost = await PostModel.findById(seedPostId).lean()
|
||||
}
|
||||
if (seedPost) {
|
||||
seedAuthor =
|
||||
usersById[String(seedPost.user_id)] ||
|
||||
(await UserModel.findById(seedPost.user_id)
|
||||
.select(
|
||||
'_id expertise sub_expertise user_name first_name last_name profile_image'
|
||||
)
|
||||
.lean())
|
||||
if (seedAuthor && !usersById[String(seedPost.user_id)]) {
|
||||
usersById[String(seedPost.user_id)] = seedAuthor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const pool = seedPost
|
||||
? posts.filter((p) => String(p._id) !== String(seedPostId))
|
||||
: posts
|
||||
|
||||
let ranked = []
|
||||
|
||||
if (coldStart && reelsMode && seedAuthor) {
|
||||
ranked = rankColdStartReels(pool, usersById, seedAuthor, seedPost)
|
||||
} else if (coldStart && reelsMode && seedPost) {
|
||||
ranked = shuffleArray(pool, String(seedPostId).length * 131)
|
||||
} else if (coldStart) {
|
||||
ranked = shuffleArray(pool)
|
||||
if (blendFresh) {
|
||||
ranked = blendFreshIntoRanked(ranked, pool, 0.2)
|
||||
}
|
||||
} else {
|
||||
const scored = pool
|
||||
.map((post) => ({
|
||||
post,
|
||||
score: scorePost(post, usersById[String(post.user_id)] || {}, signals, {
|
||||
reelsMode,
|
||||
seedPost,
|
||||
seedAuthor
|
||||
})
|
||||
}))
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.map((item) => item.post)
|
||||
|
||||
ranked = diversifyByAuthor(scored, reelsMode ? 1 : 2)
|
||||
|
||||
if (blendFresh) {
|
||||
ranked = blendFreshIntoRanked(ranked, pool)
|
||||
}
|
||||
}
|
||||
|
||||
if (seedPost) {
|
||||
ranked = [seedPost, ...ranked]
|
||||
}
|
||||
|
||||
return ranked
|
||||
}
|
||||
|
||||
async function paginatePersonalizedExplore({
|
||||
posts,
|
||||
usersById,
|
||||
page,
|
||||
limit,
|
||||
userId,
|
||||
seedPostId,
|
||||
reelsMode = false,
|
||||
blendFresh = false
|
||||
}) {
|
||||
const pageNum = Math.max(1, parseInt(page, 10) || 1)
|
||||
const limitNum = Math.max(1, parseInt(limit, 10) || 10)
|
||||
|
||||
const ranked = await rankPostsForFeed({
|
||||
posts,
|
||||
usersById,
|
||||
userId,
|
||||
seedPostId,
|
||||
reelsMode,
|
||||
blendFresh: blendFresh && !seedPostId && !reelsMode
|
||||
})
|
||||
|
||||
const totalItems = ranked.length
|
||||
const startIndex = (pageNum - 1) * limitNum
|
||||
|
||||
return {
|
||||
posts: ranked.slice(startIndex, startIndex + limitNum),
|
||||
totalItems
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildUserExploreSignals,
|
||||
paginatePersonalizedExplore,
|
||||
rankPostsForFeed,
|
||||
scorePost
|
||||
}
|
||||
20
utils/projectProfile.js
Normal file
20
utils/projectProfile.js
Normal file
@@ -0,0 +1,20 @@
|
||||
function canCreateProject(user) {
|
||||
return Boolean(
|
||||
user?.user_name?.trim() &&
|
||||
user?.first_name?.trim() &&
|
||||
user?.last_name?.trim()
|
||||
);
|
||||
}
|
||||
|
||||
function respondProjectProfileIncomplete(res) {
|
||||
return res.status(422).json({
|
||||
error: true,
|
||||
message:
|
||||
"برای ثبت پروژه، تکمیل نام کاربری، نام و نام خانوادگی در تنظیمات الزامی است",
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
canCreateProject,
|
||||
respondProjectProfileIncomplete,
|
||||
};
|
||||
Reference in New Issue
Block a user