126 lines
6.1 KiB
C#
126 lines
6.1 KiB
C#
using Microsoft.Extensions.Caching.Memory;
|
|||
|
|
|
||
|
|
using MongoDB.Driver;
|
||
|
|
using MongoDB.Entities;
|
||
|
|
|
||
|
|
using PrivaPub.Models.Post;
|
||
|
|
using PrivaPub.Models.User;
|
||
|
|
using PrivaPub.StaticServices;
|
||
|
|
|
||
|
|
namespace PrivaPub.Domain.Social
|
||
|
|
{
|
||
|
|
// What is being talked about here (owner decision 2026-10-04: trends count only public posts PrivaPub already holds,
|
||
|
|
// and fetch nothing). Hashtags and links rise with the number of different authors using them today against the
|
||
|
|
// week before; posts with their favourites, boosts and replies, halving every twelve hours. A post trends only if its
|
||
|
|
// author is discoverable, and nothing warned about (content warning) trends. Computed at most every ten minutes.
|
||
|
|
public sealed class Trends
|
||
|
|
{
|
||
|
|
public static readonly TimeSpan Fresh = TimeSpan.FromMinutes(10);
|
||
|
|
const int MinAuthors = 2;//one account alone never makes a trend
|
||
|
|
const int Kept = 40;
|
||
|
|
|
||
|
|
readonly DbEntities _dbEntities;
|
||
|
|
readonly IMemoryCache _cache;
|
||
|
|
|
||
|
|
public Trends(DbEntities dbEntities, IMemoryCache cache)
|
||
|
|
{
|
||
|
|
_dbEntities = dbEntities;
|
||
|
|
_cache = cache;
|
||
|
|
}
|
||
|
|
|
||
|
|
// what is computed is forgotten: the next read computes it again
|
||
|
|
public void Forget()
|
||
|
|
{
|
||
|
|
foreach (var key in new[] { "trends:tags", "trends:links", "trends:statuses" })
|
||
|
|
_cache.Remove(key);
|
||
|
|
}
|
||
|
|
|
||
|
|
public sealed record DayUse(DateTime Day, int Uses, int Accounts);
|
||
|
|
|
||
|
|
public sealed record Trend(string Key, List<DayUse> History, string SamplePostId);
|
||
|
|
|
||
|
|
public Task<List<Trend>> Tags(CancellationToken token) =>
|
||
|
|
_cache.GetOrCreateAsync("trends:tags", async entry =>
|
||
|
|
{
|
||
|
|
entry.AbsoluteExpirationRelativeToNow = Fresh;
|
||
|
|
var posts = await Week(p => p.Tags.Count > 0, token);
|
||
|
|
return Rank(posts.SelectMany(p => p.Tags.Distinct().Select(t => (Key: t, Post: p))));
|
||
|
|
});
|
||
|
|
|
||
|
|
public Task<List<Trend>> Links(CancellationToken token) =>
|
||
|
|
_cache.GetOrCreateAsync("trends:links", async entry =>
|
||
|
|
{
|
||
|
|
entry.AbsoluteExpirationRelativeToNow = Fresh;
|
||
|
|
var posts = await Week(p => p.Link != null, token);
|
||
|
|
return Rank(posts.Where(p => Uri.TryCreate(p.Link.Href, UriKind.Absolute, out var uri) && uri.Scheme is "https" or "http")
|
||
|
|
.Select(p => (Key: Normal(p.Link.Href), Post: p)));
|
||
|
|
});
|
||
|
|
|
||
|
|
// the ids of the trending posts, best first
|
||
|
|
public Task<List<string>> Statuses(CancellationToken token) =>
|
||
|
|
_cache.GetOrCreateAsync("trends:statuses", async entry =>
|
||
|
|
{
|
||
|
|
entry.AbsoluteExpirationRelativeToNow = Fresh;
|
||
|
|
var now = DateTime.UtcNow;
|
||
|
|
var since = now.AddHours(-48);
|
||
|
|
var posts = await _dbEntities.Posts
|
||
|
|
.Match(p => p.Visibility == PostVisibility.Public && !p.DeletedAt.HasValue && !p.AuthorGone && !p.HasContentWarning && !p.IsLocalOnly
|
||
|
|
&& p.ReblogOfPostId == null && p.InReplyToURI == null && p.AnsweringToPostId == null && p.CreationDate >= since
|
||
|
|
&& p.FavouritesCount + p.ReblogsCount + p.RepliesCount > 0)
|
||
|
|
.ExecuteAsync(token);
|
||
|
|
var discoverable = await Discoverable(posts.Select(p => p.AuthorAccountId ?? p.GroupUserId), token);
|
||
|
|
return posts.Where(p => discoverable.Contains(p.AuthorAccountId ?? p.GroupUserId))
|
||
|
|
.Select(p => (p.ID, Score: (p.FavouritesCount + 2.0 * p.ReblogsCount + p.RepliesCount) * Math.Pow(0.5, (now - p.CreationDate).TotalHours / 12)))
|
||
|
|
.OrderByDescending(p => p.Score).Take(Kept).Select(p => p.ID).ToList();
|
||
|
|
});
|
||
|
|
|
||
|
|
Task<List<Post>> Week(System.Linq.Expressions.Expression<Func<Post, bool>> with, CancellationToken token)
|
||
|
|
{
|
||
|
|
var since = DateTime.UtcNow.Date.AddDays(-6);
|
||
|
|
return _dbEntities.Posts
|
||
|
|
.Match(p => p.Visibility == PostVisibility.Public && !p.DeletedAt.HasValue && !p.AuthorGone && !p.HasContentWarning && !p.IsLocalOnly
|
||
|
|
&& p.ReblogOfPostId == null && p.CreationDate >= since)
|
||
|
|
.Match(with)
|
||
|
|
.Project(p => p.Include(x => x.ID).Include(x => x.Tags).Include(x => x.Link).Include(x => x.CreationDate)
|
||
|
|
.Include(x => x.AuthorAccountId).Include(x => x.GroupUserId))
|
||
|
|
.ExecuteAsync(token);
|
||
|
|
}
|
||
|
|
|
||
|
|
// rising: today's authors squared over the week's daily average before today, so a new subject beats a steady one
|
||
|
|
static List<Trend> Rank(IEnumerable<(string Key, Post Post)> uses)
|
||
|
|
{
|
||
|
|
var today = DateTime.UtcNow.Date;
|
||
|
|
return uses.GroupBy(u => u.Key)
|
||
|
|
.Select(g =>
|
||
|
|
{
|
||
|
|
var history = Enumerable.Range(0, 7).Select(back => today.AddDays(-back)).Select(day =>
|
||
|
|
{
|
||
|
|
var that = g.Where(u => u.Post.CreationDate >= day && u.Post.CreationDate < day.AddDays(1)).ToList();
|
||
|
|
return new DayUse(day, that.Count, that.Select(u => u.Post.AuthorAccountId ?? u.Post.GroupUserId).Distinct().Count());
|
||
|
|
}).ToList();
|
||
|
|
var recentAuthors = g.Where(u => u.Post.CreationDate >= today.AddDays(-1)).Select(u => u.Post.AuthorAccountId ?? u.Post.GroupUserId).Distinct().Count();
|
||
|
|
var before = history.Skip(1).Average(h => h.Accounts);
|
||
|
|
return (Trend: new Trend(g.Key, history, g.OrderByDescending(u => u.Post.CreationDate).First().Post.ID),
|
||
|
|
Recent: recentAuthors, Score: recentAuthors * recentAuthors / (before + 1));
|
||
|
|
})
|
||
|
|
.Where(t => t.Recent >= MinAuthors)
|
||
|
|
.OrderByDescending(t => t.Score).ThenByDescending(t => t.Recent).ThenBy(t => t.Trend.Key, StringComparer.Ordinal)
|
||
|
|
.Take(Kept).Select(t => t.Trend).ToList();
|
||
|
|
}
|
||
|
|
|
||
|
|
static string Normal(string href) =>
|
||
|
|
Uri.TryCreate(href, UriKind.Absolute, out var uri) ? uri.GetLeftPart(UriPartial.Query).TrimEnd('/') : href;
|
||
|
|
|
||
|
|
// the accounts among these whose owners let them be discovered: personas by their setting, remote accounts by theirs
|
||
|
|
static async Task<HashSet<string>> Discoverable(IEnumerable<string> accountIds, CancellationToken token)
|
||
|
|
{
|
||
|
|
var ids = accountIds.Where(id => !string.IsNullOrEmpty(id)).Distinct().ToList();
|
||
|
|
var barred = await Privacy.Counted.BarredPersonas(token);
|
||
|
|
var local = await DB.Default.Find<Avatar>().Match(a => ids.Contains(a.ID) && a.Settings.IsDiscoverable && !a.DeletionAt.HasValue && !barred.Contains(a.ID))
|
||
|
|
.Project(a => a.Include(x => x.ID)).ExecuteAsync(token);
|
||
|
|
var remote = await DB.Default.Find<ForeignAvatar>().Match(f => ids.Contains(f.ID) && f.IsDiscoverable && !f.DeletionAt.HasValue && !f.SuspendedAt.HasValue)
|
||
|
|
.Project(f => f.Include(x => x.ID)).ExecuteAsync(token);
|
||
|
|
return local.Select(a => a.ID).Concat(remote.Select(f => f.ID)).ToHashSet();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|