Initial commit

This commit is contained in:
payacom
2026-07-09 15:25:16 +03:30
parent 0b01b262fa
commit 51eb7d0224
21 changed files with 1544 additions and 98 deletions

View File

@@ -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
View 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
View 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,
};