add incremental refresh + cooldown to -likes mode
After backfill completes for a blog, -likes can now pick up only newer likes instead of being a one-shot pull. Tracks a per-blog liked_timestamp high-water mark and stops the refresh walk once it crosses the stored mark. A configurable cooldown (LikesRefreshCooldownDays, default 7) gates which blogs are re-checked on each run. -force bypasses the cooldown. The migration adds three columns to Blogs (LikesNewestTimestamp, LikesLastRefreshed, LikesLastNewCount) and does a one-time reset of all likes tracking state so the new high-water mark starts from a clean baseline. Existing ByLikes posts remain; the UNIQUE constraint absorbs re-inserts during the first re-backfill. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
@@ -29,9 +29,17 @@ namespace URLNotesGrabberCORE
|
||||
string apiSectionName = "TumblrApi";
|
||||
bool apiExplicitlySet = false;
|
||||
string startFromBlogName = string.Empty;
|
||||
bool forceIgnoreCooldown = false;
|
||||
List<string> filteredArgs = new List<string>();
|
||||
for (int i = 0; i < args.Length; i++)
|
||||
{
|
||||
if (string.Equals(args[i], "-force", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(args[i], "--force", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
forceIgnoreCooldown = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.Equals(args[i], "-api3", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(args[i], "--api3", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
@@ -159,7 +167,9 @@ namespace URLNotesGrabberCORE
|
||||
|
||||
Console.WriteLine("-replies\t Fetch and update missing reply text for all replies in database");
|
||||
|
||||
Console.WriteLine("-likes\t Fetch likes for all blogs needing it (LikesPulled=0), or a specific blog via param");
|
||||
Console.WriteLine("-likes\t Fetch likes: initial backfill for new blogs, incremental refresh for blogs past cooldown. Optional blog name forces single-blog run.");
|
||||
|
||||
Console.WriteLine("-force\t (with -likes) Ignore cooldown and refresh every fully-backfilled blog");
|
||||
|
||||
Console.WriteLine("-urldump\t Scan all posts' text columns and extract suspected URLs to configured file");
|
||||
|
||||
@@ -291,7 +301,8 @@ namespace URLNotesGrabberCORE
|
||||
|
||||
case "-likes":
|
||||
string likeBlog = args.Length > 1 ? args[1] : null;
|
||||
CollectLikes(likeBlog, contains).GetAwaiter().GetResult();
|
||||
int cooldownDays = settings.GetValue("LikesRefreshCooldownDays", 7);
|
||||
CollectLikes(likeBlog, contains, cooldownDays, forceIgnoreCooldown).GetAwaiter().GetResult();
|
||||
break;
|
||||
|
||||
case "-urldump":
|
||||
@@ -681,14 +692,14 @@ namespace URLNotesGrabberCORE
|
||||
}
|
||||
}
|
||||
|
||||
static async Task CollectLikes(string specificBlog, List<string> contains)
|
||||
static async Task CollectLikes(string specificBlog, List<string> contains, int cooldownDays = 7, bool ignoreCooldown = false)
|
||||
{
|
||||
try
|
||||
{
|
||||
DataAccess.EnsureBlogsLikesColumnsExist();
|
||||
|
||||
Console.WriteLine("Starting collection of likes...");
|
||||
var blogsToProcess = DataAccess.GetBlogsForLikes(specificBlog);
|
||||
Console.WriteLine($"Starting collection of likes... (cooldown {cooldownDays}d, ignoreCooldown={ignoreCooldown})");
|
||||
var blogsToProcess = DataAccess.GetBlogsForLikes(specificBlog, cooldownDays, ignoreCooldown);
|
||||
|
||||
if (blogsToProcess.Count == 0)
|
||||
{
|
||||
@@ -696,7 +707,9 @@ namespace URLNotesGrabberCORE
|
||||
return;
|
||||
}
|
||||
|
||||
Console.WriteLine($"Found {blogsToProcess.Count} blogs to process likes.");
|
||||
int backfillCount = blogsToProcess.Count(b => b.Item2 == 0);
|
||||
int refreshCount = blogsToProcess.Count(b => b.Item2 == 1);
|
||||
Console.WriteLine($"Found {blogsToProcess.Count} blogs to process likes ({backfillCount} backfill, {refreshCount} refresh).");
|
||||
|
||||
RateLimiter limiter = new SlidingWindowRateLimiter(new SlidingWindowRateLimiterOptions
|
||||
{
|
||||
@@ -713,13 +726,24 @@ namespace URLNotesGrabberCORE
|
||||
string blogName = blogInfo.Item1;
|
||||
int likesPulled = blogInfo.Item2;
|
||||
long cursor = blogInfo.Item3;
|
||||
long storedNewestTs = blogInfo.Item4;
|
||||
|
||||
bool isRefresh = likesPulled == 1;
|
||||
long parsedForBlog = 0;
|
||||
long matchedForBlog = 0;
|
||||
int likedCountForBlog = 0;
|
||||
long observedMaxLikedTs = storedNewestTs;
|
||||
int newInsertedInRefresh = 0;
|
||||
|
||||
Console.WriteLine($"Processing likes for blog: {blogName} | Cursor: {cursor}");
|
||||
// Refresh always starts from the top (newest) and walks backward until it crosses
|
||||
// the stored high-water mark. Backfill resumes from its last persisted cursor.
|
||||
if (isRefresh) cursor = 0;
|
||||
|
||||
string mode = isRefresh ? "REFRESH" : "BACKFILL";
|
||||
Console.WriteLine($"Processing likes for blog: {blogName} | Mode: {mode} | Cursor: {cursor} | HighWaterMark: {storedNewestTs}");
|
||||
|
||||
bool hasMoreLikes = true;
|
||||
bool isFirstPage = true;
|
||||
|
||||
while (hasMoreLikes)
|
||||
{
|
||||
@@ -748,14 +772,23 @@ namespace URLNotesGrabberCORE
|
||||
if (response?.statusCode == "NotFound" || (response?.meta != null && response.meta.status == 404))
|
||||
{
|
||||
Console.WriteLine($"API returned 404 Not Found for {blogName} Likes");
|
||||
DataAccess.UpdateBlogLikesStatus(blogName, 1, cursor);
|
||||
if (isRefresh)
|
||||
DataAccess.UpdateBlogLikesRefreshStatus(blogName, observedMaxLikedTs, newInsertedInRefresh);
|
||||
else
|
||||
DataAccess.UpdateBlogLikesStatus(blogName, 1, cursor);
|
||||
break;
|
||||
}
|
||||
|
||||
if (response?.response?.liked_posts == null || response.response.liked_posts.Count == 0)
|
||||
{
|
||||
Console.WriteLine($"[Likes] No more likes found for {blogName}. Marking complete.");
|
||||
DataAccess.UpdateBlogLikesStatus(blogName, 1, cursor); // Done parsing
|
||||
if (isRefresh)
|
||||
DataAccess.UpdateBlogLikesRefreshStatus(blogName, observedMaxLikedTs, newInsertedInRefresh);
|
||||
else
|
||||
{
|
||||
DataAccess.UpdateBlogLikesStatus(blogName, 1, cursor);
|
||||
DataAccess.UpdateBlogLikesNewestTimestamp(blogName, observedMaxLikedTs);
|
||||
}
|
||||
hasMoreLikes = false;
|
||||
break;
|
||||
}
|
||||
@@ -765,6 +798,8 @@ namespace URLNotesGrabberCORE
|
||||
|
||||
Console.WriteLine($"[Likes] Fetched {response.response.liked_posts.Count} likes for {blogName}");
|
||||
|
||||
bool crossedHighWaterMark = false;
|
||||
|
||||
foreach (var post in response.response.liked_posts)
|
||||
{
|
||||
parsedForBlog++;
|
||||
@@ -772,10 +807,24 @@ namespace URLNotesGrabberCORE
|
||||
long postID = 0;
|
||||
try { postID = Convert.ToInt64(post.id); } catch { continue; }
|
||||
|
||||
// liked_timestamp is when the user liked the post (matches the `before` cursor semantics).
|
||||
// It's the only reliable field for the refresh stop condition.
|
||||
long likedTs = 0;
|
||||
try { likedTs = Convert.ToInt64(post.liked_timestamp); } catch { }
|
||||
|
||||
if (isRefresh && likedTs > 0 && storedNewestTs > 0 && likedTs <= storedNewestTs)
|
||||
{
|
||||
Console.WriteLine($"[Likes] Reached high-water mark for {blogName} at liked_timestamp={likedTs} (<= stored {storedNewestTs}). Stopping refresh.");
|
||||
crossedHighWaterMark = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (likedTs > observedMaxLikedTs) observedMaxLikedTs = likedTs;
|
||||
|
||||
string authorBlog = post.blog_name?.ToString() ?? ".";
|
||||
string postURL = post.post_url?.ToString() ?? ".";
|
||||
string date = post.date?.ToString() ?? ".";
|
||||
|
||||
|
||||
// Legacy format fields
|
||||
string body = post.body?.ToString() ?? ".";
|
||||
string summary = post.summary?.ToString() ?? ".";
|
||||
@@ -829,7 +878,7 @@ namespace URLNotesGrabberCORE
|
||||
// Only insert if any of the data contains strings from ContainsList
|
||||
bool shouldInsert = false;
|
||||
string matchedFieldName = string.Empty;
|
||||
|
||||
|
||||
// Let's check a few fields that usually have URLs or relevant info that might match ContainsList
|
||||
var fieldsToCheck = new (string Name, string Value)[] {
|
||||
("postURL", postURL),
|
||||
@@ -863,6 +912,7 @@ namespace URLNotesGrabberCORE
|
||||
if (shouldInsert)
|
||||
{
|
||||
matchedForBlog++;
|
||||
if (isRefresh) newInsertedInRefresh++;
|
||||
Console.WriteLine($"[Likes] Match | Author: {authorBlog} | PostID: {postID} | Field: {matchedFieldName}");
|
||||
await Task.Delay(3000);
|
||||
DataAccess.AddPost(authorBlog, postID, reblogURL, date, postURL, slug, reblogKey,
|
||||
@@ -874,6 +924,22 @@ namespace URLNotesGrabberCORE
|
||||
|
||||
WriteLikesTotalsLine(blogName, "Running Totals", parsedForBlog, matchedForBlog, likedCountForBlog);
|
||||
|
||||
// Branch B: refresh terminates as soon as we crossed the high-water mark.
|
||||
if (isRefresh && crossedHighWaterMark)
|
||||
{
|
||||
DataAccess.UpdateBlogLikesRefreshStatus(blogName, observedMaxLikedTs, newInsertedInRefresh);
|
||||
hasMoreLikes = false;
|
||||
break;
|
||||
}
|
||||
|
||||
// Branch A: on the very first page, capture & persist the newest liked_timestamp
|
||||
// so subsequent refresh runs (after backfill completes) have a stopping point.
|
||||
if (!isRefresh && isFirstPage && observedMaxLikedTs > 0)
|
||||
{
|
||||
DataAccess.UpdateBlogLikesNewestTimestamp(blogName, observedMaxLikedTs);
|
||||
}
|
||||
isFirstPage = false;
|
||||
|
||||
// Determine the next BeforeCursor.
|
||||
long nextCursor = 0;
|
||||
if (response.response._links?.next?.query_params != null)
|
||||
@@ -881,9 +947,12 @@ namespace URLNotesGrabberCORE
|
||||
long.TryParse(response.response._links.next.query_params.before ?? "0", out nextCursor);
|
||||
}
|
||||
|
||||
// Persist cursor progress after every page so resume is always up-to-date
|
||||
long cursorToPersist = nextCursor > 0 ? nextCursor : cursor;
|
||||
DataAccess.UpdateBlogLikesStatus(blogName, 0, cursorToPersist);
|
||||
if (!isRefresh)
|
||||
{
|
||||
// Persist cursor progress after every page so resume is always up-to-date
|
||||
long cursorToPersist = nextCursor > 0 ? nextCursor : cursor;
|
||||
DataAccess.UpdateBlogLikesStatus(blogName, 0, cursorToPersist);
|
||||
}
|
||||
|
||||
if (nextCursor > 0)
|
||||
{
|
||||
@@ -893,13 +962,24 @@ namespace URLNotesGrabberCORE
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"[Likes] No further pagination items. Done with {blogName}.");
|
||||
DataAccess.UpdateBlogLikesStatus(blogName, 1, cursorToPersist); // Mark as completely pulled
|
||||
if (isRefresh)
|
||||
{
|
||||
DataAccess.UpdateBlogLikesRefreshStatus(blogName, observedMaxLikedTs, newInsertedInRefresh);
|
||||
}
|
||||
else
|
||||
{
|
||||
long cursorToPersist = nextCursor > 0 ? nextCursor : cursor;
|
||||
DataAccess.UpdateBlogLikesStatus(blogName, 1, cursorToPersist);
|
||||
DataAccess.UpdateBlogLikesNewestTimestamp(blogName, observedMaxLikedTs);
|
||||
}
|
||||
hasMoreLikes = false;
|
||||
}
|
||||
|
||||
await Task.Delay(1000); // 1-second delay between pages
|
||||
}
|
||||
|
||||
if (isRefresh)
|
||||
Console.WriteLine($"[Likes] {blogName} refresh complete | New inserted: {newInsertedInRefresh} | New HighWaterMark: {observedMaxLikedTs}");
|
||||
WriteLikesTotalsLine(blogName, "Final Totals", parsedForBlog, matchedForBlog, likedCountForBlog);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user