Files
SocialPub/PrivaPub/Domain/Social/Trends.cs
T

126 lines
6.1 KiB
C#
Raw Normal View History

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();
}
}
}